Workspaces layout, automation page

This commit is contained in:
2026-02-18 17:04:32 +00:00
parent 89f23f9426
commit 485ae4c3d7
89 changed files with 2168 additions and 514 deletions
@@ -3,7 +3,7 @@ import Editor, { type OnMount } from '@monaco-editor/react';
import type { editor as MonacoEditor } from 'monaco-editor';
import { toast } from 'sonner';
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable';
import { useFiles } from 'widgets/FileBrowser';
import { useFiles } from 'apps/FileBrowser';
import { FileTree } from './FileTree';
import { EditorTabs } from './EditorTabs';
import { useEditorState } from './useEditorState';
@@ -1,7 +1,7 @@
import { useState, useMemo, useCallback } from 'react';
import { ChevronRight, ChevronDown, Folder } from 'lucide-react';
import { getIcon } from 'material-file-icons';
import { useFiles, type DirEntry } from 'widgets/FileBrowser';
import { useFiles, type DirEntry } from 'apps/FileBrowser';
type FileTreeProps = {
root: string;
+11
View File
@@ -0,0 +1,11 @@
{
"name": "apps",
"private": true,
"exports": {
"./Terminal": "./Terminal/index.ts",
"./FileBrowser": "./FileBrowser/index.ts",
"./ChatHistory": "./ChatHistory/index.ts",
"./Chat": "./Chat/index.ts",
"./CodeEditor": "./CodeEditor/index.ts"
}
}
@@ -1,11 +1,11 @@
import type { WidgetRegistry } from './types';
import type { AppRegistry } from './types';
type WidgetPickerProps = {
registry: WidgetRegistry;
onSelect: (widgetType: string) => void;
type AppPickerProps = {
registry: AppRegistry;
onSelect: (appType: string) => void;
};
export const WidgetPicker = ({ registry, onSelect }: WidgetPickerProps) => {
export const AppPicker = ({ registry, onSelect }: AppPickerProps) => {
const entries = Object.entries(registry);
return (
@@ -6,10 +6,10 @@ type LayoutEditorProps = {
isLastPanel: boolean;
onSplit: (panelId: string, direction: 'horizontal' | 'vertical') => void;
onRemove: (panelId: string) => void;
onClearWidget: () => void;
onClearApp: () => void;
};
export const LayoutEditor = ({ panelId, hasWidget, isLastPanel, onSplit, onRemove, onClearWidget }: LayoutEditorProps) => (
export const LayoutEditor = ({ panelId, hasWidget, isLastPanel, onSplit, onRemove, onClearApp }: LayoutEditorProps) => (
<div className="absolute top-1 right-1 flex gap-1 z-10">
<button
type="button"
@@ -31,7 +31,7 @@ export const LayoutEditor = ({ panelId, hasWidget, isLastPanel, onSplit, onRemov
<button
type="button"
className="p-1 rounded bg-duck-teal/10 border border-duck-teal/20 text-duck-teal/70 hover:text-duck-teal hover:bg-duck-teal/20 cursor-pointer"
onClick={onClearWidget}
onClick={onClearApp}
title="Clear Widget"
>
<X size={14} />
@@ -1,75 +1,107 @@
import type { LayoutPanel, WidgetRegistry } from './types';
import type { LayoutPanel, AppRegistry, PanelComponents } from './types';
import { Card } from '../Card';
import { WidgetPicker } from './WidgetPicker';
import { AppPicker } from './AppPicker';
import { LayoutEditor } from './LayoutEditor';
type PanelSlotProps = {
panel: LayoutPanel;
registry: WidgetRegistry;
registry: AppRegistry;
components?: PanelComponents;
editing: boolean;
isLastPanel: boolean;
onSetWidget: (panelId: string, widgetType: string | null) => void;
onSetApp: (panelId: string, appType: string | null) => void;
onSplit: (panelId: string, direction: 'horizontal' | 'vertical') => void;
onRemove: (panelId: string) => void;
};
export const PanelSlot = ({ panel, registry, editing, isLastPanel, onSetWidget, onSplit, onRemove }: PanelSlotProps) => {
const entry = panel.widgetType ? registry[panel.widgetType] : null;
const WidgetComponent = entry?.component;
export const PanelSlot = ({ panel, registry, components, editing, isLastPanel, onSetApp, onSplit, onRemove }: PanelSlotProps) => {
const PanelComponent = components?.[panel.id];
const entry = panel.appType ? registry[panel.appType] : null;
const AppComponent = PanelComponent ?? entry?.component;
if (!editing && !WidgetComponent) return null;
if (!WidgetComponent) {
if (!editing && !AppComponent) {
return (
<Card className="relative h-full w-full flex flex-col items-center justify-center gap-3 p-4">
<WidgetPicker registry={registry} onSelect={(type) => onSetWidget(panel.id, type)} />
<div className="flex gap-1">
<button
type="button"
className="px-2 py-1 text-xs rounded border border-duck-teal/20 text-duck-teal/70 hover:bg-duck-teal/10 hover:text-duck-teal cursor-pointer"
onClick={() => onSplit(panel.id, 'horizontal')}
>
Split H
</button>
<button
type="button"
className="px-2 py-1 text-xs rounded border border-duck-teal/20 text-duck-teal/70 hover:bg-duck-teal/10 hover:text-duck-teal cursor-pointer"
onClick={() => onSplit(panel.id, 'vertical')}
>
Split V
</button>
{!isLastPanel && (
<div className="h-full w-full p-1">
<div className="h-full w-full rounded-lg border-3 border-duck-teal/50" />
</div>
);
}
if (!AppComponent) {
return (
<div className="h-full w-full p-1">
<Card className="relative h-full w-full flex flex-col items-center justify-center gap-3 p-4">
<AppPicker registry={registry} onSelect={(type) => onSetApp(panel.id, type)} />
<div className="flex gap-1">
<button
type="button"
className="px-2 py-1 text-xs rounded border border-red-400/40 text-red-400/70 hover:bg-red-400/10 hover:text-red-400 cursor-pointer"
onClick={() => onRemove(panel.id)}
className="px-2 py-1 text-xs rounded border border-duck-teal/20 text-duck-teal/70 hover:bg-duck-teal/10 hover:text-duck-teal cursor-pointer"
onClick={() => onSplit(panel.id, 'horizontal')}
>
Remove
Split H
</button>
<button
type="button"
className="px-2 py-1 text-xs rounded border border-duck-teal/20 text-duck-teal/70 hover:bg-duck-teal/10 hover:text-duck-teal cursor-pointer"
onClick={() => onSplit(panel.id, 'vertical')}
>
Split V
</button>
{!isLastPanel && (
<button
type="button"
className="px-2 py-1 text-xs rounded border border-red-400/40 text-red-400/70 hover:bg-red-400/10 hover:text-red-400 cursor-pointer"
onClick={() => onRemove(panel.id)}
>
Remove
</button>
)}
</div>
</Card>
</div>
);
}
if (entry?.transparent) {
return (
<div className="h-full w-full p-1">
<div className="relative h-full w-full overflow-hidden">
<AppComponent panelId={panel.id} />
{editing && (
<LayoutEditor
panelId={panel.id}
hasWidget
isLastPanel={isLastPanel}
onSplit={onSplit}
onRemove={onRemove}
onClearApp={() => onSetApp(panel.id, null)}
/>
)}
</div>
</Card>
</div>
);
}
return (
<div
className="relative h-full w-full overflow-hidden rounded-lg border backdrop-blur-xl p-2"
style={{ backgroundColor: 'rgba(255, 255, 255, 0.12)', borderColor: 'rgba(255, 255, 255, 0.2)' }}
>
<Card className="h-full w-full overflow-hidden p-0 [&>*]:!h-full [&>*]:!flex [&>*]:!flex-col [&>*]:!rounded-none [&>*]:!border-0 [&>*]:!shadow-none [&>*>*:last-child]:!flex-1 [&>*>*:last-child]:!min-h-0 [&>*>*:last-child]:!max-h-none [&>*>*:last-child]:!overflow-hidden">
<WidgetComponent panelId={panel.id} />
</Card>
{editing && (
<LayoutEditor
panelId={panel.id}
hasWidget
isLastPanel={isLastPanel}
onSplit={onSplit}
onRemove={onRemove}
onClearWidget={() => onSetWidget(panel.id, null)}
/>
)}
<div className="h-full w-full p-1">
<div
className="relative h-full w-full overflow-hidden rounded-lg border backdrop-blur-xl p-2"
style={{ backgroundColor: 'rgba(255, 255, 255, 0.12)', borderColor: 'rgba(255, 255, 255, 0.2)' }}
>
<Card className="h-full w-full overflow-hidden p-0 [&>*]:!h-full [&>*]:!flex [&>*]:!flex-col [&>*]:!rounded-none [&>*]:!border-0 [&>*]:!shadow-none [&>*>*:last-child]:!flex-1 [&>*>*:last-child]:!min-h-0 [&>*>*:last-child]:!max-h-none [&>*>*:last-child]:!overflow-auto">
<AppComponent panelId={panel.id} />
</Card>
{editing && (
<LayoutEditor
panelId={panel.id}
hasWidget
isLastPanel={isLastPanel}
onSplit={onSplit}
onRemove={onRemove}
onClearApp={() => onSetApp(panel.id, null)}
/>
)}
</div>
</div>
);
};
@@ -0,0 +1,40 @@
import { useCallback } from 'react';
import type { LayoutNode, AppRegistry, PanelComponents } from './types';
import { updateSizes } from './layout-utils';
import { WorkspaceProvider } from './WorkspaceContext';
import { WorkspaceRenderer } from './WorkspaceRenderer';
type WorkspaceLayoutProps = {
layout: LayoutNode;
onLayoutChange: (layout: LayoutNode) => void;
registry: AppRegistry;
components?: PanelComponents;
workspaceId?: string;
cwd?: string;
};
const noop = () => {};
export const WorkspaceLayout = ({ layout, onLayoutChange, registry, components, workspaceId, cwd }: WorkspaceLayoutProps) => {
const handleResized = useCallback(
(groupId: string, sizes: number[]) => {
onLayoutChange(updateSizes(layout, groupId, sizes));
},
[layout, onLayoutChange],
);
return (
<WorkspaceProvider value={{ workspaceId: workspaceId ?? null, cwd: cwd ?? '~', editing: false }}>
<WorkspaceRenderer
layout={layout}
registry={registry}
components={components}
editing={false}
onSetApp={noop}
onSplit={noop}
onRemove={noop}
onResized={handleResized}
/>
</WorkspaceProvider>
);
};
@@ -1,14 +1,15 @@
import { useCallback, useRef } from 'react';
import { ResizablePanel, ResizablePanelGroup, ResizableHandle } from '../ui/resizable';
import type { LayoutNode, WidgetRegistry } from './types';
import { pruneEmptyPanels, countPanels } from './layout-utils';
import type { LayoutNode, AppRegistry, PanelComponents } from './types';
import { countPanels } from './layout-utils';
import { PanelSlot } from './PanelSlot';
type WorkspaceRendererProps = {
layout: LayoutNode;
registry: WidgetRegistry;
registry: AppRegistry;
components?: PanelComponents;
editing: boolean;
onSetWidget: (panelId: string, widgetType: string | null) => void;
onSetApp: (panelId: string, appType: string | null) => void;
onSplit: (panelId: string, direction: 'horizontal' | 'vertical') => void;
onRemove: (panelId: string) => void;
onResized: (groupId: string, sizes: number[]) => void;
@@ -17,27 +18,24 @@ type WorkspaceRendererProps = {
export const WorkspaceRenderer = ({
layout,
registry,
components,
editing,
onSetWidget,
onSetApp,
onSplit,
onRemove,
onResized,
}: WorkspaceRendererProps) => {
const displayLayout = editing ? layout : pruneEmptyPanels(layout);
if (!displayLayout) {
return <div className="flex h-full items-center justify-center text-sm text-muted-foreground">No widgets</div>;
}
const totalPanels = countPanels(layout);
return (
<div className="h-full w-full">
<LayoutNodeRenderer
node={displayLayout}
node={layout}
registry={registry}
components={components}
editing={editing}
totalPanels={totalPanels}
onSetWidget={onSetWidget}
onSetApp={onSetApp}
onSplit={onSplit}
onRemove={onRemove}
onResized={onResized}
@@ -48,30 +46,42 @@ export const WorkspaceRenderer = ({
type LayoutNodeRendererProps = {
node: LayoutNode;
registry: WidgetRegistry;
registry: AppRegistry;
components?: PanelComponents;
editing: boolean;
totalPanels: number;
onSetWidget: (panelId: string, widgetType: string | null) => void;
onSetApp: (panelId: string, appType: string | null) => void;
onSplit: (panelId: string, direction: 'horizontal' | 'vertical') => void;
onRemove: (panelId: string) => void;
onResized: (groupId: string, sizes: number[]) => void;
};
const getFixedHeight = (node: LayoutNode, registry: AppRegistry): number | undefined => {
if (node.type === 'panel' && node.appType) return registry[node.appType]?.fixedHeight;
return undefined;
};
const LayoutNodeRenderer = ({
node,
registry,
components,
editing,
totalPanels,
onSetWidget,
onSetApp,
onSplit,
onRemove,
onResized,
}: LayoutNodeRendererProps) => {
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
const mountedRef = useRef(false);
const handleLayout = useCallback(
(sizes: number[]) => {
if (node.type !== 'group') return;
if (!mountedRef.current) {
mountedRef.current = true;
return;
}
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
onResized(node.id, sizes);
@@ -85,27 +95,56 @@ const LayoutNodeRenderer = ({
<PanelSlot
panel={node}
registry={registry}
components={components}
editing={editing}
isLastPanel={totalPanels <= 1}
onSetWidget={onSetWidget}
onSetApp={onSetApp}
onSplit={onSplit}
onRemove={onRemove}
/>
);
}
const hasFixedChild = node.direction === 'vertical' && node.children.some((c) => getFixedHeight(c.node, registry) !== undefined);
if (hasFixedChild) {
return (
<div className="flex h-full w-full flex-col">
{node.children.map((child) => {
const fixed = getFixedHeight(child.node, registry);
return (
<div key={child.node.id} className={fixed !== undefined ? 'shrink-0' : 'min-h-0 flex-1'} style={fixed !== undefined ? { height: fixed } : undefined}>
<LayoutNodeRenderer
node={child.node}
registry={registry}
components={components}
editing={editing}
totalPanels={totalPanels}
onSetApp={onSetApp}
onSplit={onSplit}
onRemove={onRemove}
onResized={onResized}
/>
</div>
);
})}
</div>
);
}
return (
<ResizablePanelGroup direction={node.direction} onLayout={handleLayout} className="h-full w-full">
{node.children.map((child, i) => (
<ChildEntry key={child.node.id} index={i} total={node.children.length}>
<ResizablePanel defaultSize={child.size} minSize={5}>
<div className="h-full w-full p-1">
<div className="h-full w-full">
<LayoutNodeRenderer
node={child.node}
registry={registry}
components={components}
editing={editing}
totalPanels={totalPanels}
onSetWidget={onSetWidget}
onSetApp={onSetApp}
onSplit={onSplit}
onRemove={onRemove}
onResized={onResized}
@@ -1,6 +1,6 @@
import { useState, useCallback } from 'react';
import type { LayoutNode, WorkspaceDefinition, WidgetRegistry } from './types';
import { splitPanel, removePanel, setWidget, updateSizes, countPanels, hasAnyWidget } from './layout-utils';
import type { LayoutNode, WorkspaceDefinition, AppRegistry } from './types';
import { splitPanel, removePanel, setApp, updateSizes, countPanels, hasAnyApp } from './layout-utils';
import { WorkspaceProvider } from './WorkspaceContext';
import { WorkspaceHeader } from './WorkspaceHeader';
import { WorkspaceRenderer } from './WorkspaceRenderer';
@@ -10,15 +10,15 @@ type WorkspaceViewProps = {
name?: string;
layout: LayoutNode;
onLayoutChange: (layout: LayoutNode) => void;
registry: WidgetRegistry;
registry: AppRegistry;
};
export const WorkspaceView = ({ workspace, name, layout, onLayoutChange, registry }: WorkspaceViewProps) => {
const [editing, setEditing] = useState(() => !hasAnyWidget(layout));
const [editing, setEditing] = useState(() => !hasAnyApp(layout));
const handleSetWidget = useCallback(
(panelId: string, widgetType: string | null) => {
onLayoutChange(setWidget(layout, panelId, widgetType));
const handleSetApp = useCallback(
(panelId: string, appType: string | null) => {
onLayoutChange(setApp(layout, panelId, appType));
},
[layout, onLayoutChange],
);
@@ -56,7 +56,7 @@ export const WorkspaceView = ({ workspace, name, layout, onLayoutChange, registr
layout={layout}
registry={registry}
editing={editing}
onSetWidget={handleSetWidget}
onSetApp={handleSetApp}
onSplit={handleSplit}
onRemove={handleRemove}
onResized={handleResized}
+3 -2
View File
@@ -1,4 +1,5 @@
export type { LayoutNode, LayoutGroup, LayoutPanel, WorkspaceDefinition, WidgetRegistry, WidgetRegistryEntry } from './types';
export { createDefaultLayout, splitPanel, removePanel, setWidget, updateSizes, pruneEmptyPanels, countPanels, hasAnyWidget } from './layout-utils';
export type { LayoutNode, LayoutGroup, LayoutPanel, WorkspaceDefinition, AppRegistry, AppRegistryEntry, PanelComponents } from './types';
export { createDefaultLayout, splitPanel, removePanel, setApp, updateSizes, pruneEmptyPanels, countPanels, hasAnyApp } from './layout-utils';
export { WorkspaceProvider, useWorkspace } from './WorkspaceContext';
export { WorkspaceView } from './WorkspaceView';
export { WorkspaceLayout } from './WorkspaceLayout';
@@ -6,14 +6,14 @@ const uid = () => `p-${Date.now()}-${++counter}`;
export const createDefaultLayout = (): LayoutPanel => ({
type: 'panel',
id: uid(),
widgetType: null,
appType: null,
});
export function splitPanel(root: LayoutNode, panelId: string, direction: 'horizontal' | 'vertical'): LayoutNode {
return mapNode(root, (node, parent) => {
if (node.type !== 'panel' || node.id !== panelId) return node;
const newPanel: LayoutPanel = { type: 'panel', id: uid(), widgetType: null };
const newPanel: LayoutPanel = { type: 'panel', id: uid(), appType: null };
if (parent && parent.direction === direction) {
return null;
@@ -40,7 +40,7 @@ function mapNode(
const result = fn(node, parent);
if (result === null && parent !== null && node.type === 'panel') {
const newPanel: LayoutPanel = { type: 'panel', id: uid(), widgetType: null };
const newPanel: LayoutPanel = { type: 'panel', id: uid(), appType: null };
const idx = parent.children.findIndex((c) => c.node.id === node.id);
const newChildren = [
...parent.children.slice(0, idx + 1),
@@ -104,13 +104,13 @@ export function removePanel(root: LayoutNode, panelId: string): LayoutNode {
}
}
export function setWidget(root: LayoutNode, panelId: string, widgetType: string | null): LayoutNode {
export function setApp(root: LayoutNode, panelId: string, appType: string | null): LayoutNode {
if (root.type === 'panel') {
return root.id === panelId ? { ...root, widgetType } : root;
return root.id === panelId ? { ...root, appType } : root;
}
const newChildren = root.children.map((child) => ({
...child,
node: setWidget(child.node, panelId, widgetType),
node: setApp(child.node, panelId, appType),
}));
return { ...root, children: newChildren };
}
@@ -132,7 +132,7 @@ export function updateSizes(root: LayoutNode, groupId: string, sizes: number[]):
export function pruneEmptyPanels(root: LayoutNode): LayoutNode | null {
if (root.type === 'panel') {
return root.widgetType ? root : null;
return root.appType ? root : null;
}
const pruned = root.children
@@ -157,7 +157,7 @@ export function countPanels(node: LayoutNode): number {
return node.children.reduce((sum, child) => sum + countPanels(child.node), 0);
}
export function hasAnyWidget(node: LayoutNode): boolean {
if (node.type === 'panel') return node.widgetType !== null;
return node.children.some((child) => hasAnyWidget(child.node));
export function hasAnyApp(node: LayoutNode): boolean {
if (node.type === 'panel') return node.appType !== null;
return node.children.some((child) => hasAnyApp(child.node));
}
+7 -3
View File
@@ -11,7 +11,7 @@ export type LayoutGroup = {
export type LayoutPanel = {
type: 'panel';
id: string;
widgetType: string | null;
appType: string | null;
};
export type LayoutNode = LayoutGroup | LayoutPanel;
@@ -22,10 +22,14 @@ export type WorkspaceDefinition = {
cwd: string;
};
export type WidgetRegistryEntry = {
export type AppRegistryEntry = {
name: string;
icon: LucideIcon;
component: ComponentType<{ panelId: string }>;
transparent?: boolean;
fixedHeight?: number;
};
export type WidgetRegistry = Record<string, WidgetRegistryEntry>;
export type AppRegistry = Record<string, AppRegistryEntry>;
export type PanelComponents = Record<string, ComponentType>;
+1
View File
@@ -1,4 +1,5 @@
export { useGlobal } from './useGlobal';
export { usePanelChannel } from './usePanelChannel';
export { useClient, createClient } from './useClient';
export { useDebounce } from './useDebounce';
export { useDragAndDrop } from './useDragAndDrop';
@@ -0,0 +1,5 @@
import { useGlobal } from './useGlobal';
export const usePanelChannel = <T>(channel: string, initialData: T) => {
return useGlobal<T>(['PANEL_CHANNEL', channel], initialData);
};
+1 -1
View File
@@ -1,6 +1,6 @@
import { useState, useRef, useMemo } from 'react';
import { Search, Play, Square, AudioLines } from 'lucide-react';
import { Widget } from '@/components/Widget';
import { Widget } from 'widgets/Widget';
import { Input } from '@/components/ui/input';
import { categories, allSounds } from './catalog-data';
import type { SoundAsset } from './types';
+23
View File
@@ -0,0 +1,23 @@
import { useState, useEffect } from 'react';
import { Widget } from '../Widget';
export const Clock = () => {
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const id = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(id);
}, []);
const time = now.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' });
const date = now.toLocaleDateString(undefined, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
return (
<Widget title="Clock">
<div className="flex flex-col items-center gap-1 px-4 pb-4">
<span className="text-3xl font-bold tabular-nums tracking-tight">{time}</span>
<span className="text-sm text-muted-foreground">{date}</span>
</div>
</Widget>
);
};
@@ -3,20 +3,30 @@ import { useCallback, useLayoutEffect, useRef, useState } from 'react';
import type { LucideIcon } from 'lucide-react';
import { ChevronDown, ChevronUp, Minus, Plus, X } from 'lucide-react';
import { cn } from 'helpers/cn';
import { Card } from './Card';
import { Card } from '@/components/Card';
type Position = { x: number; y: number };
type WidgetProps = ComponentPropsWithoutRef<'div'> & {
title?: string;
resizable?: boolean;
collapsible?: boolean | { title: string; icon?: LucideIcon };
moveable?: boolean;
position?: Position;
onPositionChange?: (pos: Position) => void;
onClose?: () => void;
};
export const Widget = ({ title, className, style, resizable, collapsible, moveable, onClose, children, ...props }: WidgetProps) => {
export const Widget = ({ title, className, style, resizable, collapsible, moveable, position: controlledPosition, onPositionChange, onClose, children, ...props }: WidgetProps) => {
const [expanded, setExpanded] = useState(true);
const [minimized, setMinimized] = useState(false);
const [position, setPosition] = useState({ x: 0, y: 0 });
const [internalPosition, setInternalPosition] = useState<Position>({ x: 0, y: 0 });
const isControlled = controlledPosition !== undefined;
const position = isControlled ? controlledPosition : internalPosition;
const setPosition = isControlled ? (pos: Position | ((prev: Position) => Position)) => {
const next = typeof pos === 'function' ? pos(controlledPosition) : pos;
onPositionChange?.(next);
} : setInternalPosition;
const cardRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<{
startX: number;
@@ -0,0 +1,25 @@
import { widgetRegistry } from '../widget-registry';
type WidgetPickerProps = {
onSelect: (widgetType: string) => void;
};
export const WidgetPicker = ({ onSelect }: WidgetPickerProps) => {
const entries = Object.entries(widgetRegistry);
return (
<div className="grid grid-cols-3 gap-2 max-w-xs">
{entries.map(([key, entry]) => (
<button
key={key}
type="button"
className="flex flex-col items-center gap-1.5 rounded-lg border border-border/50 bg-card/80 backdrop-blur-sm px-3 py-3 text-foreground hover:border-border hover:bg-card transition-colors cursor-pointer"
onClick={() => onSelect(key)}
>
<entry.icon className="h-5 w-5" />
<span className="text-xs font-medium leading-tight text-center">{entry.name}</span>
</button>
))}
</div>
);
};
@@ -0,0 +1,238 @@
import type { PointerEvent as ReactPointerEvent } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
import { useAuth } from 'hooks/useAuth';
import { Plus, X } from 'lucide-react';
import { widgetRegistry } from '../widget-registry';
import { WidgetPicker } from './WidgetPicker';
type Position = { x: number; y: number };
type WidgetInstance = {
id: string;
widgetType: string;
position: Position;
};
type WidgetPanelConfig = {
instances: WidgetInstance[];
nextId: number;
};
const USER_STATE_KEY = ['USER_STATE'];
function useWidgetPanelState(panelId: string) {
const client = useClient();
const { isAuthenticated } = useAuth();
const queryClient = useQueryClient();
const clientRef = useRef(client);
clientRef.current = client;
const stateKey = `widget-panel:${panelId}`;
const { data: state = {} } = useQuery<Record<string, unknown>>({
queryKey: USER_STATE_KEY,
enabled: isAuthenticated,
queryFn: () => client.get('/user/state'),
staleTime: Infinity,
});
const config = (state[stateKey] as WidgetPanelConfig | undefined) ?? { instances: [], nextId: 0 };
const setConfig = useCallback(
(update: WidgetPanelConfig | ((prev: WidgetPanelConfig) => WidgetPanelConfig)) => {
const currentState = queryClient.getQueryData<Record<string, unknown>>(USER_STATE_KEY) ?? {};
const current = (currentState[stateKey] as WidgetPanelConfig | undefined) ?? { instances: [], nextId: 0 };
const next = typeof update === 'function' ? update(current) : update;
queryClient.setQueryData(USER_STATE_KEY, { ...currentState, [stateKey]: next });
clientRef.current.patch('/user/state', { [stateKey]: next }).catch(() => {});
},
[stateKey, queryClient],
);
return [config, setConfig] as const;
}
// --- DraggableWidget ---
type DraggableWidgetProps = {
instance: WidgetInstance;
panelRef: React.RefObject<HTMLDivElement | null>;
onMove: (pos: Position) => void;
onRemove: () => void;
};
type DragState = {
startX: number;
startY: number;
originX: number;
originY: number;
widgetW: number;
widgetH: number;
panelW: number;
panelH: number;
};
const DraggableWidget = ({ instance, panelRef, onMove, onRemove }: DraggableWidgetProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<DragState | null>(null);
const onPointerDown = useCallback(
(ev: ReactPointerEvent) => {
const target = ev.target as HTMLElement;
if (!target.closest('[data-widget-header]') || target.closest('button')) return;
const widgetRect = containerRef.current?.getBoundingClientRect();
const panelRect = panelRef.current?.getBoundingClientRect();
if (!widgetRect || !panelRect) return;
dragRef.current = {
startX: ev.clientX,
startY: ev.clientY,
originX: instance.position.x,
originY: instance.position.y,
widgetW: widgetRect.width,
widgetH: widgetRect.height,
panelW: panelRect.width,
panelH: panelRect.height,
};
containerRef.current?.setPointerCapture(ev.pointerId);
},
[instance.position, panelRef],
);
const endDrag = useCallback(
(ev: ReactPointerEvent) => {
if (!dragRef.current) return;
dragRef.current = null;
containerRef.current?.releasePointerCapture(ev.pointerId);
},
[],
);
const onPointerMove = useCallback(
(ev: ReactPointerEvent) => {
if (!dragRef.current) return;
const panelRect = panelRef.current?.getBoundingClientRect();
if (panelRect && (ev.clientX < panelRect.left || ev.clientX > panelRect.right || ev.clientY < panelRect.top || ev.clientY > panelRect.bottom)) {
endDrag(ev);
return;
}
const d = dragRef.current;
const rawX = d.originX + (ev.clientX - d.startX);
const rawY = d.originY + (ev.clientY - d.startY);
onMove({
x: Math.max(0, Math.min(rawX, d.panelW - d.widgetW)),
y: Math.max(0, Math.min(rawY, d.panelH - d.widgetH)),
});
},
[onMove, panelRef, endDrag],
);
const onPointerUp = useCallback(
(ev: ReactPointerEvent) => {
endDrag(ev);
},
[endDrag],
);
const entry = widgetRegistry[instance.widgetType];
if (!entry) return null;
const WidgetComponent = entry.component;
return (
<div
ref={containerRef}
className="absolute group"
style={{ left: instance.position.x, top: instance.position.y }}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
>
<button
type="button"
className="absolute -top-2 -right-2 z-10 flex h-5 w-5 items-center justify-center rounded-full bg-destructive text-destructive-foreground opacity-0 shadow transition-opacity cursor-pointer group-hover:opacity-100"
onClick={onRemove}
>
<X size={12} />
</button>
<WidgetComponent panelId={instance.id} />
</div>
);
};
// --- WidgetPanel ---
export const WidgetPanel = ({ panelId }: { panelId: string }) => {
const [config, setConfig] = useWidgetPanelState(panelId);
const [instances, setInstances] = useState<WidgetInstance[]>(config.instances);
const [showPicker, setShowPicker] = useState(false);
const panelRef = useRef<HTMLDivElement>(null);
const nextId = useRef(config.nextId);
const initialized = useRef(false);
// Sync from persisted state on first load
useEffect(() => {
if (initialized.current) return;
if (config.instances.length > 0 || config.nextId > 0) {
setInstances(config.instances);
nextId.current = config.nextId;
initialized.current = true;
}
}, [config]);
// Persist whenever instances change (skip the initial mount)
const mounted = useRef(false);
useEffect(() => {
if (!mounted.current) {
mounted.current = true;
return;
}
setConfig({ instances, nextId: nextId.current });
}, [instances, setConfig]);
const addWidget = useCallback((widgetType: string) => {
nextId.current++;
const id = `widget-${nextId.current}`;
const offset = (nextId.current % 5) * 30;
setInstances((prev) => [...prev, { id, widgetType, position: { x: 20 + offset, y: 20 + offset } }]);
setShowPicker(false);
}, []);
const removeWidget = useCallback((id: string) => {
setInstances((prev) => prev.filter((w) => w.id !== id));
}, []);
const updatePosition = useCallback((id: string, pos: Position) => {
setInstances((prev) => prev.map((w) => (w.id === id ? { ...w, position: pos } : w)));
}, []);
return (
<div ref={panelRef} className="relative h-full w-full overflow-hidden">
{instances.map((instance) => (
<DraggableWidget
key={instance.id}
instance={instance}
panelRef={panelRef}
onMove={(pos) => updatePosition(instance.id, pos)}
onRemove={() => removeWidget(instance.id)}
/>
))}
<div className="absolute bottom-4 right-4 flex flex-col items-end">
{showPicker && (
<div className="mb-2 rounded-xl border border-border bg-card p-3 shadow-lg">
<WidgetPicker onSelect={addWidget} />
</div>
)}
<button
type="button"
className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg hover:bg-primary/90 transition-colors cursor-pointer"
onClick={() => setShowPicker((v) => !v)}
>
<Plus size={20} />
</button>
</div>
</div>
);
};
@@ -0,0 +1,54 @@
import { Link } from 'react-router';
import { LayoutGrid, ArrowRight } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
import { useAuth } from 'hooks/useAuth';
import type { WorkspaceDefinition } from '@/components/Workspace';
import { Widget } from '../Widget';
export const Workspaces = () => {
const client = useClient();
const { isAuthenticated } = useAuth();
const { data: state = {} } = useQuery<Record<string, unknown>>({
queryKey: ['USER_STATE'],
enabled: isAuthenticated,
queryFn: () => client.get('/user/state'),
staleTime: Infinity,
});
const workspaces = (state.workspaces ?? []) as WorkspaceDefinition[];
return (
<Widget title="Workspaces">
<div className="px-2 pb-3 max-h-52 overflow-y-auto">
{workspaces.length === 0 ? (
<div className="flex flex-col items-center gap-2 py-4 text-muted-foreground">
<LayoutGrid className="h-5 w-5" />
<p className="text-xs">No workspaces</p>
<Link to="/workspaces" className="text-xs text-primary hover:underline">
Create one
</Link>
</div>
) : (
<ul className="space-y-0.5">
{workspaces.map((ws) => (
<li key={ws.id} className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-muted/50 group">
<Link to={`/workspaces/${ws.id}`} className="flex items-center gap-2 flex-1 min-w-0">
<LayoutGrid className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="text-sm truncate">{ws.name}</span>
</Link>
<Link
to={`/workspaces/${ws.id}`}
className="shrink-0 p-1 rounded text-muted-foreground/40 md:opacity-0 md:group-hover:opacity-100 hover:text-primary transition-opacity"
>
<ArrowRight className="h-3.5 w-3.5" />
</Link>
</li>
))}
</ul>
)}
</div>
</Widget>
);
};
+5 -5
View File
@@ -2,10 +2,10 @@
"name": "widgets",
"private": true,
"exports": {
"./Terminal": "./Terminal/index.ts",
"./FileBrowser": "./FileBrowser/index.ts",
"./ChatHistory": "./ChatHistory/index.ts",
"./Chat": "./Chat/index.ts",
"./CodeEditor": "./CodeEditor/index.ts"
"./Widget": "./Widget.tsx",
"./Clock": "./Clock/index.tsx",
"./widget-registry": "./widget-registry.tsx",
"./WidgetPanel": "./WidgetPanel/index.tsx",
"./Workspaces": "./Workspaces/index.tsx"
}
}
@@ -0,0 +1,9 @@
import { Clock as ClockIcon, LayoutGrid } from 'lucide-react';
import type { AppRegistryEntry } from '@/components/Workspace';
import { Clock } from './Clock/index';
import { Workspaces } from './Workspaces/index';
export const widgetRegistry: Record<string, AppRegistryEntry> = {
'clock': { name: 'Clock', icon: ClockIcon, component: () => <Clock /> },
'workspaces': { name: 'Workspaces', icon: LayoutGrid, component: () => <Workspaces /> },
};