Workspaces

This commit is contained in:
2026-02-22 00:05:29 +00:00
parent 9e9acd9631
commit 23b369c7e1
101 changed files with 701 additions and 512 deletions
@@ -6,7 +6,7 @@ type AppPickerProps = {
};
export const AppPicker = ({ registry, onSelect }: AppPickerProps) => {
const entries = Object.entries(registry).filter(([, entry]) => !entry.widget && entry.availableOnPanel !== false);
const entries = Object.entries(registry).filter(([, entry]) => entry.availableOnPanel !== false);
return (
<div className="flex flex-wrap gap-1.5 max-w-md">
@@ -3,11 +3,12 @@ import type { LayoutNode, AppRegistry, PanelComponents } from './types';
import { updateSizes } from './layout-utils';
import { WorkspaceProvider } from './WorkspaceContext';
import { WorkspaceRenderer } from './WorkspaceRenderer';
import { useAppRegistry } from 'officerdev';
type WorkspaceLayoutProps = {
layout: LayoutNode;
onLayoutChange: (layout: LayoutNode) => void;
registry: AppRegistry;
registry?: AppRegistry;
components?: PanelComponents;
workspaceId?: string;
cwd?: string;
@@ -15,7 +16,10 @@ type WorkspaceLayoutProps = {
const noop = () => {};
export const WorkspaceLayout = ({ layout, onLayoutChange, registry, components, workspaceId, cwd }: WorkspaceLayoutProps) => {
export const WorkspaceLayout = ({ layout, onLayoutChange, registry: registryProp, components, workspaceId, cwd }: WorkspaceLayoutProps) => {
const { registry: globalRegistry } = useAppRegistry();
const registry = registryProp ?? globalRegistry;
const handleResized = useCallback(
(groupId: string, sizes: number[]) => {
onLayoutChange(updateSizes(layout, groupId, sizes));
@@ -1,25 +1,26 @@
import { useState, useCallback, useEffect } from 'react';
import { flushSync } from 'react-dom';
import { ResizablePanel, ResizablePanelGroup, ResizableHandle } from '../ui/resizable';
import type { LayoutNode, AppRegistry, WorkspaceState, EphemeralPanels } from './types';
import type { LayoutNode, WorkspaceState, EphemeralPanels } from './types';
import type { DropPosition } from './layout-utils';
import { splitPanel, removePanel, setApp, updateSizes, swapPanels, movePanel, countPanels } from './layout-utils';
import { WorkspaceProvider } from './WorkspaceContext';
import { WorkspaceRenderer } from './WorkspaceRenderer';
import { useAppRegistry } from 'officerdev';
type WorkspaceViewProps = {
workspace: WorkspaceState;
registry: AppRegistry;
cwd?: string;
ephemeral?: EphemeralPanels | null;
};
const noop = () => {};
export const WorkspaceView = ({ workspace, registry, cwd = '~', ephemeral }: WorkspaceViewProps) => {
if (!workspace.isLoaded) return null;
export const WorkspaceView = ({ workspace, cwd = '~', ephemeral }: WorkspaceViewProps) => {
const { registry } = useAppRegistry();
const { value: layout, setValue: onLayoutChange } = workspace;
const layout = workspace.value;
const onLayoutChange = workspace.setValue;
const [swapSourceId, setSwapSourceId] = useState<string | null>(null);
const [dragSourceId, setDragSourceId] = useState<string | null>(null);
const [maximizedPanelId, setMaximizedPanelId] = useState<string | null>(null);
@@ -105,6 +106,8 @@ export const WorkspaceView = ({ workspace, registry, cwd = '~', ephemeral }: Wor
return () => window.removeEventListener('keydown', onKeyDown);
}, [swapSourceId, dragSourceId, maximizedPanelId, setMaximizedAnimated]);
if (!workspace.isLoaded) return null;
const baseRenderer = (
<WorkspaceRenderer
layout={layout}
@@ -53,7 +53,6 @@ export type AppRegistryEntry = {
provider?: ComponentType<{ panelId: string; children: ReactNode }>;
transparent?: boolean;
fixedHeight?: number;
widget?: boolean;
availableOnPanel?: boolean;
};
+1
View File
@@ -9,6 +9,7 @@
},
"dependencies": {
"hooks": "workspace:*",
"state": "workspace:*",
"widgets": "workspace:*"
}
}
@@ -0,0 +1,14 @@
import { appRegistryMetas as fileBrowserMetas } from '../apps/FileBrowser';
import { appRegistryMetas as terminalMetas } from '../apps/Terminal';
import { appRegistryMetas as codeEditorMetas } from '../apps/CodeEditor';
import { appRegistryMetas as chatMetas } from '../apps/Chat';
import { appRegistryMetas as fileViewerMetas } from '../apps/FileViewer';
import { appRegistryMetas as workspaceMetas } from '../apps/Workspaces';
import { useAppRegistry } from './useAppRegistry';
const apps = [...fileBrowserMetas, ...terminalMetas, ...codeEditorMetas, ...chatMetas, ...fileViewerMetas, ...workspaceMetas];
export const AppRegistry = () => {
useAppRegistry(apps);
return null;
};
@@ -0,0 +1,2 @@
export * from './AppRegistry';
export * from './useAppRegistry';
@@ -0,0 +1 @@
export { useAppRegistry, type UseAppRegistryType, type AppRegistryMeta } from './useAppRegistry';
@@ -0,0 +1,19 @@
import type { AppRegistry, AppRegistryEntry } from '@/components/Workspace';
import { useGlobal } from 'hooks/useGlobal';
export type AppRegistryMeta = { key: string } & AppRegistryEntry;
export function useAppRegistry(initialApps: AppRegistryMeta[] = []) {
const [registry, setRegistry] = useGlobal<AppRegistry>('APP_REGISTRY', () => metasToRegistry(initialApps));
const registerApp = (key: string, entry: AppRegistryEntry) => {
setRegistry((prev) => ({ ...prev, [key]: entry }));
};
return { registry, registerApp };
}
export type UseAppRegistryType = ReturnType<typeof useAppRegistry>;
const metasToRegistry = (metas: AppRegistryMeta[]): AppRegistry =>
Object.fromEntries(metas.map(({ key, ...entry }) => [key, entry]));
@@ -1,5 +1,5 @@
import { MessageSquare } from 'lucide-react';
import { useChatSessions } from '@/state/useChatSessions';
import { useChatSessions } from 'state/useChatSessions';
export const ChatList = () => {
const { sessions } = useChatSessions();
@@ -0,0 +1,3 @@
import { EmbeddableChat } from './EmbeddableChat';
export const ChatPanelWrapper = () => <EmbeddableChat className="h-full" />;
@@ -1,6 +1,6 @@
import type { KeyboardEvent } from 'react';
import { useState, useRef, useEffect } from 'react';
import { useVisiblePiModels } from '@/state/useModels';
import { useVisiblePiModels } from 'state/useModels';
import { usePiChat, type UsePiChatType } from '../../../hooks/usePiChat';
import { useAttachments } from '../useAttachments';
import { useSlashCommands } from '../useSlashCommands';
@@ -1,5 +1,5 @@
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import type { ModelOption } from '@/state/useModels';
import type { ModelOption } from 'state/useModels';
import type { ChatMessage } from '../types';
const PROVIDER_DISPLAY: Record<string, string> = {
@@ -1,3 +1,7 @@
import type { AppRegistryMeta } from '../../AppRegistry';
import { MessageSquare } from 'lucide-react';
import { ChatPanelWrapper } from './ChatPanelWrapper';
export { MessageList } from './components/MessageList';
export { MessageBubble, StreamingBubble } from './components/MessageBubble';
export { ToolActivity } from './components/ToolActivity';
@@ -18,3 +22,12 @@ export { useAttachments, type UseAttachmentsType } from './useAttachments';
export { useAudioRecording, type UseAudioRecordingType } from './useAudioRecording';
export * from './types';
export const appRegistryMetas: AppRegistryMeta[] = [
{
key: 'officerdev/chat',
name: 'Chat',
icon: MessageSquare,
component: ChatPanelWrapper,
},
];
@@ -0,0 +1,13 @@
import { Code } from 'lucide-react';
import { useWorkspace } from '@/components/Workspace';
export const CodeEditorHeader = () => {
const { cwd } = useWorkspace();
return (
<>
<Code className="h-3.5 w-3.5 shrink-0" />
<span className="text-xs font-medium shrink-0">Code Editor</span>
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
</>
);
};
@@ -0,0 +1,3 @@
import { CodeEditorView } from './CodeEditor';
export const CodeEditorPanelWrapper = () => <CodeEditorView className="h-full w-full" />;
@@ -1 +1,16 @@
import type { AppRegistryMeta } from '../../AppRegistry';
import { Code } from 'lucide-react';
import { CodeEditorPanelWrapper } from './CodeEditorPanelWrapper';
import { CodeEditorHeader } from './CodeEditorHeader';
export { CodeEditorView } from './CodeEditor';
export const appRegistryMetas: AppRegistryMeta[] = [
{
key: 'officerdev/code-editor',
name: 'Code Editor',
icon: Code,
component: CodeEditorPanelWrapper,
header: CodeEditorHeader,
},
];
@@ -13,24 +13,20 @@ type FileBrowserAppProps = {
export const FileBrowserApp = ({ basePath = '/' }: FileBrowserAppProps) => {
const fileBrowserManager = useFileBrowserApp(basePath);
const { handleNavigate } = fileBrowserManager;
return (
<div className="flex flex-col h-full overflow-hidden">
<Toolbar fileBrowserManager={fileBrowserManager} />
<HomeDirSelector fileBrowserManager={fileBrowserManager} basePath={basePath} />
{/* Breadcrumb */}
<div className="shrink-0 border-b border-duck-dark/10 px-4 py-2">
<Breadcrumb path={fileBrowserManager.currentPath} onNavigate={fileBrowserManager.handleNavigate} basePath={basePath} />
</div>
<UploadProgress fileBrowserManager={fileBrowserManager} />
<FileViewContainer fileBrowserManager={fileBrowserManager} />
<TaskRunnerDialog fileBrowserManager={fileBrowserManager} />
<VideoDownloadDialog fileBrowserManager={fileBrowserManager} />
</div>
<div className="flex flex-col h-full overflow-hidden">
<Toolbar fileBrowserManager={fileBrowserManager} />
<HomeDirSelector fileBrowserManager={fileBrowserManager} basePath={basePath} />
<Breadcrumb
path={fileBrowserManager.currentPath}
onNavigate={handleNavigate} basePath={basePath} />
<UploadProgress fileBrowserManager={fileBrowserManager} />
<FileViewContainer fileBrowserManager={fileBrowserManager} />
<TaskRunnerDialog fileBrowserManager={fileBrowserManager} />
<VideoDownloadDialog fileBrowserManager={fileBrowserManager} />
</div>
);
};
@@ -0,0 +1,9 @@
import { useWorkspace } from '@/components/Workspace';
import { FileBrowserApp } from './FileBrowserApp';
const cwdToPath = (cwd: string) => (cwd === '~' ? '/' : cwd.slice(1));
export const FileBrowserPanelWrapper = () => {
const { cwd } = useWorkspace();
return <FileBrowserApp basePath={cwdToPath(cwd)} />;
};
@@ -11,36 +11,38 @@ export const Breadcrumb = ({ path, onNavigate, basePath = '/' }: BreadcrumbProps
const segments = relativePath.split('/').filter(Boolean);
return (
<nav className="flex items-center gap-1 text-sm flex-wrap">
<button
onClick={() => onNavigate(basePath)}
className="flex items-center gap-1 text-duck-forest hover:text-duck-teal transition-colors cursor-pointer font-medium"
>
<Home className="h-4 w-4" />
<span>{basePath === '/' ? 'home' : basePath.split('/').pop()}</span>
</button>
<div className="shrink-0 border-b border-duck-dark/10 px-4 py-2">
<nav className="flex items-center gap-1 text-sm flex-wrap">
<button
onClick={() => onNavigate(basePath)}
className="flex items-center gap-1 text-duck-forest hover:text-duck-teal transition-colors cursor-pointer font-medium"
>
<Home className="h-4 w-4" />
<span>{basePath === '/' ? 'home' : basePath.split('/').pop()}</span>
</button>
{segments.map((segment, i) => {
const relative = '/' + segments.slice(0, i + 1).join('/');
const segmentPath = basePath === '/' ? relative : basePath + relative;
const isLast = i === segments.length - 1;
{segments.map((segment, i) => {
const relative = '/' + segments.slice(0, i + 1).join('/');
const segmentPath = basePath === '/' ? relative : basePath + relative;
const isLast = i === segments.length - 1;
return (
<span key={segmentPath} className="flex items-center gap-1">
<ChevronRight className="h-4 w-4 text-duck-dark/40" />
{isLast ? (
<span className="text-duck-dark font-semibold">{segment}</span>
) : (
<button
onClick={() => onNavigate(segmentPath)}
className="text-duck-forest hover:text-duck-teal transition-colors cursor-pointer font-medium"
>
{segment}
</button>
)}
</span>
);
})}
</nav>
return (
<span key={segmentPath} className="flex items-center gap-1">
<ChevronRight className="h-4 w-4 text-duck-dark/40" />
{isLast ? (
<span className="text-duck-dark font-semibold">{segment}</span>
) : (
<button
onClick={() => onNavigate(segmentPath)}
className="text-duck-forest hover:text-duck-teal transition-colors cursor-pointer font-medium"
>
{segment}
</button>
)}
</span>
);
})}
</nav>
</div>
);
};
@@ -5,7 +5,7 @@ import * as DialogPrimitive from '@radix-ui/react-dialog';
import { cardStyle } from '@/components/Card';
import type { TaskInfo } from '../../../Chat';
import { usePiChat, EmbeddableChat } from '../../../Chat';
import { useSettings } from '@/state/useSettings';
import { useSettings } from 'state/useSettings';
import type { TaskSummary } from '../../useTasks';
const playDing = () => {
@@ -1 +1,2 @@
export * from './FileBrowserApp';
export * from './FileBrowserPanelWrapper';
@@ -3,7 +3,7 @@ import { useSearchParams, useNavigate } from 'react-router';
import { toast } from 'sonner';
import { useFilesAPI, type DirEntry } from '../../../hooks/useFilesAPI';
import { useTasks, type TaskSummary } from '../useTasks';
import { useUserState } from '@/state/useUserState';
import { useUserState } from 'state/useUserState';
import { useAuth } from 'hooks/useAuth';
export const useFileBrowserApp = (basePath: string) => {
@@ -1,7 +1,28 @@
import type { AppRegistryMeta } from '../../AppRegistry';
import { FolderOpen } from 'lucide-react';
import { FileBrowserPanelWrapper } from './FileBrowserApp';
import { FileBrowserWidget } from './FileBrowserWidget';
export { useFilesAPI, type DirEntry } from '../../hooks/useFilesAPI';
export { useTasks, type TaskSummary } from './useTasks';
export { useRecentFiles } from './useRecentFiles';
export { usePinnedFiles } from './usePinnedFiles';
export { FileBrowserApp } from './FileBrowserApp';
export { FileBrowserApp, FileBrowserPanelWrapper } from './FileBrowserApp';
export { FileBrowserWidget } from './FileBrowserWidget';
export { TaskRunnerModal } from './FileBrowserApp/components/TaskRunnerModal';
export const appRegistryMetas: AppRegistryMeta[] = [
{
key: 'officerdev/file-browser',
name: 'File Browser',
icon: FolderOpen,
component: FileBrowserPanelWrapper,
},
{
key: 'officerdev/file-browser-widget',
name: 'File Browser',
icon: FolderOpen,
component: FileBrowserWidget,
availableOnPanel: false,
},
];
@@ -1,4 +1,4 @@
import { useUserState } from '@/state/useUserState';
import { useUserState } from 'state/useUserState';
type PinnedFile = { path: string; name: string; pinnedAt: number };
@@ -1,4 +1,4 @@
import { useUserState } from '@/state/useUserState';
import { useUserState } from 'state/useUserState';
type RecentFile = { path: string; name: string; openedAt: number };
@@ -0,0 +1,29 @@
import type { ReactNode } from 'react';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { FileViewerProvider } from './FileViewerProvider';
type FileViewerChannelState = {
filePath: string;
fileName: string;
root?: string;
} | null;
const FILE_VIEWER_CHANNEL = 'file-viewer';
export const FileViewerPanelProvider = ({ panelId, children }: { panelId: string; children: ReactNode }) => {
const [state] = usePanelChannel<FileViewerChannelState>(`${FILE_VIEWER_CHANNEL}:${panelId}`, null);
if (!state) {
return (
<div className="flex h-full w-full items-center justify-center text-duck-dark/30 text-sm">
No file selected
</div>
);
}
return (
<FileViewerProvider filePath={state.filePath} fileName={state.fileName} root={state.root}>
{children}
</FileViewerProvider>
);
};
@@ -1,6 +1,25 @@
import type { AppRegistryMeta } from '../../AppRegistry';
import { Eye } from 'lucide-react';
import { FileViewerBody } from './FileViewerBody';
import { FileViewerHeader } from './FileViewerHeader';
import { FileViewerPanelProvider } from './FileViewerPanelWrapper';
export { FileViewerView } from './FileViewerView';
export { FileViewerProvider } from './FileViewerProvider';
export { FileViewerHeader } from './FileViewerHeader';
export { FileViewerBody } from './FileViewerBody';
export { FileViewerPanelProvider } from './FileViewerPanelWrapper';
export { useFileViewer } from './FileViewerContext';
export { getFileType, getLang, getExt, getArchiveBaseName, ARCHIVE_EXTS, type FileType } from './file-types';
export const appRegistryMetas: AppRegistryMeta[] = [
{
key: 'officerdev/file-viewer',
name: 'File Viewer',
icon: Eye,
component: FileViewerBody,
header: FileViewerHeader,
provider: FileViewerPanelProvider,
availableOnPanel: false,
},
];
@@ -0,0 +1,43 @@
import { useEffect, useRef } from 'react';
import { useWorkspace } from '@/components/Workspace';
import { useWorkspacesState } from 'state/useWorkspacesState';
import { TerminalView } from './Terminal';
const EMPTY_TERMINALS: Record<string, string> = {};
type CommandTerminalWrapperProps = {
panelId: string;
command: string;
statePrefix: string;
};
export const CommandTerminalWrapper = ({ panelId, command, statePrefix }: CommandTerminalWrapperProps) => {
const { workspaceId, cwd } = useWorkspace();
const stateKey = workspaceId ? `ws-${statePrefix}-${workspaceId}` : `ws-${statePrefix}-default`;
const { value: terminals, setValue: setTerminals } = useWorkspacesState<Record<string, string>>(stateKey, EMPTY_TERMINALS);
const setTerminalsRef = useRef(setTerminals);
setTerminalsRef.current = setTerminals;
const sessionId = terminals[panelId];
useEffect(() => {
if (!sessionId) {
setTerminals((prev) => ({ ...prev, [panelId]: crypto.randomUUID() }));
}
}, [panelId, sessionId, setTerminals]);
useEffect(() => {
return () => {
setTerminalsRef.current((prev) => {
const { [panelId]: _, ...rest } = prev;
return rest;
});
};
}, [panelId]);
if (!sessionId) return null;
const fullCommand = cwd && cwd !== '~' ? `cd ${cwd} && ${command}` : command;
return <TerminalView className="h-full w-full p-2" sessionId={sessionId} cwd={cwd} initialInput={fullCommand} />;
};
@@ -0,0 +1,62 @@
import { TerminalSquare, Monitor, Columns2, PenLine } from 'lucide-react';
import { useWorkspace } from '@/components/Workspace';
import { useAuth } from 'hooks/useAuth';
import { useTerminalMode } from './useTerminalMode';
export const TerminalHeader = ({ panelId }: { panelId: string }) => {
const { cwd } = useWorkspace();
const { user } = useAuth();
const { mode, toggle } = useTerminalMode(panelId);
const isHost = mode === 'host';
const Icon = isHost ? Monitor : TerminalSquare;
return (
<>
<Icon className="h-3.5 w-3.5 shrink-0" />
<span className="text-xs font-medium shrink-0">Terminal</span>
{user?.role === 'Super Admin' && (
<button
type="button"
onClick={toggle}
className="text-[10px] font-medium px-1.5 py-0.5 rounded bg-white/10 hover:bg-white/20 transition-colors cursor-pointer shrink-0"
>
{isHost ? 'Host' : 'Home'}
</button>
)}
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
</>
);
};
export const HostTerminalHeader = () => {
const { cwd } = useWorkspace();
return (
<>
<Monitor className="h-3.5 w-3.5 shrink-0" />
<span className="text-xs font-medium shrink-0">Terminal</span>
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
</>
);
};
export const TmuxHeader = () => {
const { cwd } = useWorkspace();
return (
<>
<Columns2 className="h-3.5 w-3.5 shrink-0" />
<span className="text-xs font-medium shrink-0">Tmux</span>
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
</>
);
};
export const NvimHeader = () => {
const { cwd } = useWorkspace();
return (
<>
<PenLine className="h-3.5 w-3.5 shrink-0" />
<span className="text-xs font-medium shrink-0">Neovim</span>
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
</>
);
};
@@ -0,0 +1,45 @@
import { useEffect, useRef } from 'react';
import { useWorkspace } from '@/components/Workspace';
import { useAuth } from 'hooks/useAuth';
import { useWorkspacesState } from 'state/useWorkspacesState';
import { TerminalView } from './Terminal';
const EMPTY_TERMINALS: Record<string, string> = {};
export const HostTerminalWrapper = ({ panelId }: { panelId: string }) => {
const { user } = useAuth();
const { workspaceId, cwd } = useWorkspace();
const stateKey = workspaceId ? `ws-host-terminals-${workspaceId}` : 'ws-host-terminals-default';
const { value: terminals, setValue: setTerminals } = useWorkspacesState<Record<string, string>>(stateKey, EMPTY_TERMINALS);
const setTerminalsRef = useRef(setTerminals);
setTerminalsRef.current = setTerminals;
const sessionId = terminals[panelId];
useEffect(() => {
if (!sessionId) {
setTerminals((prev) => ({ ...prev, [panelId]: crypto.randomUUID() }));
}
}, [panelId, sessionId, setTerminals]);
useEffect(() => {
return () => {
setTerminalsRef.current((prev) => {
const { [panelId]: _, ...rest } = prev;
return rest;
});
};
}, [panelId]);
if (user?.role !== 'Super Admin') {
return (
<div className="flex h-full w-full items-center justify-center text-sm text-muted-foreground">
Host Terminal requires Super Admin permissions.
</div>
);
}
if (!sessionId) return null;
return <TerminalView className="h-full w-full p-2" sessionId={sessionId} sandboxed={false} cwd={cwd} />;
};
@@ -0,0 +1,38 @@
import { useEffect, useRef } from 'react';
import { useWorkspace } from '@/components/Workspace';
import { useWorkspacesState } from 'state/useWorkspacesState';
import { TerminalView } from './Terminal';
import { useTerminalMode } from './useTerminalMode';
const EMPTY_TERMINALS: Record<string, string> = {};
export const TerminalWrapper = ({ panelId }: { panelId: string }) => {
const { workspaceId, cwd } = useWorkspace();
const { mode } = useTerminalMode(panelId);
const sandboxed = mode === 'sandboxed';
const stateKey = workspaceId ? `ws-terminals-${mode}-${workspaceId}` : `ws-terminals-${mode}-default`;
const { value: terminals, setValue: setTerminals } = useWorkspacesState<Record<string, string>>(stateKey, EMPTY_TERMINALS);
const setTerminalsRef = useRef(setTerminals);
setTerminalsRef.current = setTerminals;
const sessionId = terminals[panelId];
useEffect(() => {
if (!sessionId) {
setTerminals((prev) => ({ ...prev, [panelId]: crypto.randomUUID() }));
}
}, [panelId, sessionId, setTerminals]);
useEffect(() => {
return () => {
setTerminalsRef.current((prev) => {
const { [panelId]: _, ...rest } = prev;
return rest;
});
};
}, [panelId]);
if (!sessionId) return null;
return <TerminalView className="h-full w-full p-2" sessionId={sessionId} sandboxed={sandboxed} cwd={cwd} />;
};
@@ -1 +0,0 @@
export { TerminalView, type TerminalViewProps } from './Terminal';
@@ -0,0 +1,48 @@
import type { AppRegistryMeta } from '../../AppRegistry';
import { TerminalSquare, Monitor, Columns2, PenLine } from 'lucide-react';
import { TerminalWrapper } from './TerminalWrapper';
import { HostTerminalWrapper } from './HostTerminalWrapper';
import { CommandTerminalWrapper } from './CommandTerminalWrapper';
import { TerminalHeader, HostTerminalHeader, TmuxHeader, NvimHeader } from './Headers';
export { TerminalView, type TerminalViewProps } from './Terminal';
const TmuxWrapper = ({ panelId }: { panelId: string }) => (
<CommandTerminalWrapper panelId={panelId} command="tmux" statePrefix="tmux" />
);
const NvimWrapper = ({ panelId }: { panelId: string }) => (
<CommandTerminalWrapper panelId={panelId} command="nvim" statePrefix="nvim" />
);
export const appRegistryMetas: AppRegistryMeta[] = [
{
key: 'officerdev/terminal',
name: 'Terminal',
icon: TerminalSquare,
component: TerminalWrapper,
header: TerminalHeader,
},
{
key: 'officerdev/terminal-host',
name: 'Host Terminal',
icon: Monitor,
component: HostTerminalWrapper,
header: HostTerminalHeader,
availableOnPanel: false,
},
{
key: 'officerdev/tmux',
name: 'Tmux',
icon: Columns2,
component: TmuxWrapper,
header: TmuxHeader,
},
{
key: 'officerdev/nvim',
name: 'Neovim',
icon: PenLine,
component: NvimWrapper,
header: NvimHeader,
},
];
@@ -0,0 +1,11 @@
import { useGlobal } from 'hooks/useGlobal';
type TerminalMode = 'sandboxed' | 'host';
export const useTerminalMode = (panelId: string) => {
const [mode, setMode] = useGlobal<TerminalMode>(`terminal-mode-${panelId}`, 'sandboxed');
const toggle = () => setMode((prev) => (prev === 'sandboxed' ? 'host' : 'sandboxed'));
return { mode, setMode, toggle };
};
@@ -0,0 +1,190 @@
import { useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router';
import { LayoutGrid, Plus, Pencil, Trash2, Search } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import { useGlobal } from 'hooks/useGlobal';
import { useClient } from 'hooks/useClient';
import { generateSlug } from 'helpers/slug';
import { useUserState } from 'state/useUserState';
import { useWorkspacesState } from 'state/useWorkspacesState';
import type { WorkspaceDefinition } from '@/components/Workspace';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {
SELECTED_WORKSPACE_KEY,
CREATING_WORKSPACE_KEY,
EDITING_WORKSPACE_KEY,
NEW_WS_NAME_KEY,
NEW_WS_DESC_KEY,
NEW_WS_TEMPLATE_KEY,
} from './constants';
export const WorkspaceListApp = () => {
const location = useLocation();
const navigate = useNavigate();
const client = useClient();
const queryClient = useQueryClient();
const { value: workspaces, setValue: setWorkspaces } = useWorkspacesState<WorkspaceDefinition[]>('workspaces', []);
const [selected, setSelected] = useGlobal<string | null>(SELECTED_WORKSPACE_KEY, null);
const [, setCreating] = useGlobal<boolean>(CREATING_WORKSPACE_KEY, false);
const [, setEditing] = useGlobal<string | null>(EDITING_WORKSPACE_KEY, null);
const [, setName] = useGlobal<string>(NEW_WS_NAME_KEY, '');
const [, setDescription] = useGlobal<string>(NEW_WS_DESC_KEY, '');
const [, setTemplateIdx] = useGlobal<number>(NEW_WS_TEMPLATE_KEY, 0);
const [, setFilePath] = useUserState<string>('files/currentPath', '/');
const [search, setSearch] = useState('');
const [deleting, setDeleting] = useState<WorkspaceDefinition | null>(null);
const isWorkspacesPage = location.pathname === '/workspaces';
const filtered = search
? workspaces.filter((ws) => {
const q = search.toLowerCase();
return [ws.name, ws.id, ws.description ?? '', ws.cwd ?? ''].some((field) => field.toLowerCase().includes(q));
})
: workspaces;
const handleEdit = (ev: React.MouseEvent, ws: WorkspaceDefinition) => {
ev.stopPropagation();
setSelected(null);
setCreating(false);
setEditing(ws.id);
setName(ws.name);
setDescription(ws.description ?? '');
setTemplateIdx(ws.templateIdx ?? 0);
const cwdPath = !ws.cwd || ws.cwd === '~' ? '/' : ws.cwd.replace(/^~\//, '/');
setFilePath(cwdPath);
};
const handleDelete = (ev: React.MouseEvent, ws: WorkspaceDefinition) => {
ev.stopPropagation();
setDeleting(ws);
};
const confirmDelete = () => {
if (!deleting) return;
setWorkspaces((prev) => prev.filter((w) => w.id !== deleting.id));
if (selected === deleting.id) setSelected(null);
const layoutKey = `ws-layout-${deleting.id}`;
const currentState = queryClient.getQueryData<Record<string, unknown>>(['WORKSPACES_STATE']) ?? {};
const { [layoutKey]: _, ...rest } = currentState;
queryClient.setQueryData(['WORKSPACES_STATE'], rest);
client.patch('/workspaces', { [layoutKey]: null }).catch(() => {});
setDeleting(null);
};
const handleClick = (ws: WorkspaceDefinition) => {
if (isWorkspacesPage) {
setSelected(ws.id);
setCreating(false);
setEditing(null);
} else {
navigate(`/workspaces/${ws.id}`);
}
};
return (
<div className="flex flex-col h-full overflow-y-auto">
<div className="p-3 pb-0 flex flex-col gap-2">
<Link
to="/workspaces"
className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium bg-duck-teal/15 text-duck-teal"
>
<LayoutGrid className="h-4 w-4" />
Workspaces
</Link>
<button
type="button"
onClick={() => {
setSelected(null);
setEditing(null);
setName(generateSlug());
setDescription('');
setTemplateIdx(0);
setFilePath('/');
setCreating(true);
if (!isWorkspacesPage) navigate('/workspaces');
}}
className="flex items-center justify-center gap-1 py-2 px-3 rounded-lg text-sm font-medium bg-duck-teal hover:bg-duck-teal/90 text-white transition-all cursor-pointer"
>
<Plus className="h-4 w-4 shrink-0" />
New Workspace
</button>
</div>
<div className="relative px-3 pt-2">
<Search className="absolute left-6 top-1/2 h-3.5 w-3.5 text-gray-500 pointer-events-none" />
<input
type="text"
value={search}
onChange={(ev) => setSearch(ev.target.value)}
placeholder="Search workspaces"
className="w-full rounded-lg border border-duck-dark/15 bg-transparent py-1.5 pl-8 pr-3 text-sm text-white placeholder:text-gray-500 focus:outline-none focus:ring-1 focus:ring-duck-teal/30 focus:border-duck-teal/40"
/>
</div>
<div className="flex flex-col gap-0.5 px-3 pt-2">
{filtered.map((ws) => (
<div
key={ws.id}
className={`flex items-center gap-2.5 py-2 px-3 rounded-lg text-sm font-medium cursor-pointer group ${
isWorkspacesPage && selected === ws.id
? 'bg-duck-teal/10 text-duck-teal'
: 'text-white/80 hover:bg-duck-dark/5'
}`}
onClick={() => handleClick(ws)}
>
<LayoutGrid className="h-4 w-4 shrink-0" />
<span className="flex-1 text-left truncate">{ws.name}</span>
{isWorkspacesPage && (
<>
<button
type="button"
onClick={(ev) => handleEdit(ev, ws)}
className="shrink-0 p-1 rounded text-duck-dark/20 md:opacity-0 md:group-hover:opacity-100 hover:text-duck-teal transition-opacity cursor-pointer"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={(ev) => handleDelete(ev, ws)}
className="shrink-0 p-1 rounded text-duck-dark/20 md:opacity-0 md:group-hover:opacity-100 hover:text-red-500 transition-opacity cursor-pointer"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</>
)}
</div>
))}
{filtered.length === 0 && (
<p className="text-xs text-gray-500 px-3 py-4 text-center">
{search ? 'No matches' : 'No workspaces yet'}
</p>
)}
</div>
<AlertDialog open={deleting !== null} onOpenChange={(open) => { if (!open) setDeleting(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete workspace</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete <strong>{deleting?.name}</strong>? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={confirmDelete} className="bg-red-600 hover:bg-red-700">
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};
@@ -0,0 +1,506 @@
import { useState } from 'react';
import { Link, useNavigate } from 'react-router';
import { FolderOpen, LayoutGrid, Plus, ArrowRight, Type, Rocket, FileText, Layout } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import { useGlobal } from 'hooks/useGlobal';
import { useClient } from 'hooks/useClient';
import { useUserState } from 'state/useUserState';
import { useWorkspacesState } from 'state/useWorkspacesState';
import { WorkspaceLayout, WorkspaceView, createDefaultLayout } from '@/components/Workspace';
import type { LayoutNode, WorkspaceDefinition } from '@/components/Workspace';
import { Button } from '@/components/ui/button';
import { generateSlug, slugify } from 'helpers/slug';
import { FileBrowserApp } from '../FileBrowser';
import { useAppRegistry } from '../../AppRegistry';
import {
SELECTED_WORKSPACE_KEY,
CREATING_WORKSPACE_KEY,
EDITING_WORKSPACE_KEY,
NEW_WS_NAME_KEY,
NEW_WS_DESC_KEY,
NEW_WS_TEMPLATE_KEY,
} from './constants';
// --- Layout Templates ---
let tplCounter = 0;
const tplUid = () => `tpl-${++tplCounter}`;
type LayoutTemplate = {
name: string;
layout: () => LayoutNode;
};
const templates: LayoutTemplate[] = [
{
name: 'Single',
layout: () => ({ type: 'panel', id: tplUid(), appType: null }),
},
{
name: '2 Columns',
layout: () => ({
type: 'group',
id: tplUid(),
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
],
}),
},
{
name: 'Main + Side',
layout: () => ({
type: 'group',
id: tplUid(),
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
{
node: {
type: 'group',
id: tplUid(),
direction: 'vertical',
children: [
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
],
},
size: 50,
},
],
}),
},
{
name: 'Sidebar',
layout: () => ({
type: 'group',
id: tplUid(),
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 25 },
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 75 },
],
}),
},
{
name: '2x2 Grid',
layout: () => ({
type: 'group',
id: tplUid(),
direction: 'vertical',
children: [
{
node: {
type: 'group',
id: tplUid(),
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
],
},
size: 50,
},
{
node: {
type: 'group',
id: tplUid(),
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
],
},
size: 50,
},
],
}),
},
{
name: 'Cols + Bottom',
layout: () => ({
type: 'group',
id: tplUid(),
direction: 'vertical',
children: [
{
node: {
type: 'group',
id: tplUid(),
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
],
},
size: 70,
},
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 30 },
],
}),
},
];
// --- Template Thumbnails ---
const ThumbnailNode = ({ node }: { node: LayoutNode }) => {
if (node.type === 'panel') {
return <div className="h-full w-full bg-duck-teal/15 border border-duck-teal/20 rounded-sm" />;
}
const isH = node.direction === 'horizontal';
return (
<div className={`flex h-full w-full gap-1.5 ${isH ? 'flex-row' : 'flex-col'}`}>
{node.children.map((child) => (
<div key={child.node.id} style={{ flex: child.size }} className="min-h-0 min-w-0">
<ThumbnailNode node={child.node} />
</div>
))}
</div>
);
};
const TemplateCell = ({ index }: { index: number }) => {
const tpl = templates[index]!;
const [selected, setSelected] = useGlobal<number>(NEW_WS_TEMPLATE_KEY, 0);
const node = tpl.layout();
const isSelected = selected === index;
return (
<div className="h-full w-full">
<button
type="button"
onClick={() => setSelected(index)}
className={`flex h-full w-full flex-col items-center justify-center gap-2 p-3 cursor-pointer transition-colors ${
isSelected ? 'bg-duck-teal/10' : 'hover:bg-duck-dark/5'
}`}
>
<div className="w-full flex-1 min-h-0 rounded border border-duck-dark/20 overflow-hidden">
<ThumbnailNode node={node} />
</div>
<span className={`text-xs font-medium ${isSelected ? 'text-duck-teal' : 'text-duck-dark/60'}`}>{tpl.name}</span>
</button>
</div>
);
};
// --- Panel: Template Picker (3x2 workspace) ---
const tplPanelLayout: LayoutNode = {
type: 'group',
id: 'tpl-root',
direction: 'vertical',
children: [
{
node: {
type: 'group',
id: 'tpl-row-0',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'tpl-0', appType: 'tpl-0' }, size: 33.33 },
{ node: { type: 'panel', id: 'tpl-1', appType: 'tpl-1' }, size: 33.33 },
{ node: { type: 'panel', id: 'tpl-2', appType: 'tpl-2' }, size: 33.34 },
],
},
size: 50,
},
{
node: {
type: 'group',
id: 'tpl-row-1',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'tpl-3', appType: 'tpl-3' }, size: 33.33 },
{ node: { type: 'panel', id: 'tpl-4', appType: 'tpl-4' }, size: 33.33 },
{ node: { type: 'panel', id: 'tpl-5', appType: 'tpl-5' }, size: 33.34 },
],
},
size: 50,
},
],
};
const tplRegistry = Object.fromEntries(
templates.map((tpl, i) => [`tpl-${i}`, { name: tpl.name, icon: Layout, component: () => <TemplateCell index={i} /> }]),
);
const TemplatePanel = () => (
<WorkspaceLayout layout={tplPanelLayout} onLayoutChange={() => {}} registry={tplRegistry} />
);
// --- Panel: Name ---
const NamePanel = () => {
const [name, setName] = useGlobal<string>(NEW_WS_NAME_KEY, '');
const [description, setDescription] = useGlobal<string>(NEW_WS_DESC_KEY, '');
return (
<div className="h-full w-full">
<div className="flex h-full flex-col gap-4 p-4">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-duck-dark/50 flex items-center gap-1.5">
<Type className="h-3 w-3" />
Name
</label>
<input
type="text"
value={name}
onChange={(ev) => setName(ev.target.value)}
placeholder="My Workspace"
autoFocus
className="rounded-lg border border-duck-dark/20 bg-background px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-2 focus:ring-duck-teal/30 focus:border-duck-teal/50"
/>
</div>
<div className="flex flex-1 min-h-0 flex-col gap-1.5">
<label className="text-xs font-medium text-duck-dark/50 flex items-center gap-1.5">
<FileText className="h-3 w-3" />
Description
</label>
<textarea
value={description}
onChange={(ev) => setDescription(ev.target.value)}
placeholder="What is this workspace for?"
className="flex-1 min-h-0 resize-none rounded-lg border border-duck-dark/20 bg-background px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-2 focus:ring-duck-teal/30 focus:border-duck-teal/50"
/>
</div>
</div>
</div>
);
};
// --- Panel: Create ---
const CreatePanel = () => {
const navigate = useNavigate();
const queryClient = useQueryClient();
const client = useClient();
const [name, setName] = useGlobal<string>(NEW_WS_NAME_KEY, '');
const [filePath] = useUserState<string>('files/currentPath', '/');
const { value: workspaces, setValue: setWorkspaces } = useWorkspacesState<WorkspaceDefinition[]>('workspaces', []);
const cwd = filePath === '/' ? '~' : `~/${filePath.replace(/^\//, '')}`;
const [description, setDescription] = useGlobal<string>(NEW_WS_DESC_KEY, '');
const [templateIdx, setTemplateIdx] = useGlobal<number>(NEW_WS_TEMPLATE_KEY, 0);
const [editingId, setEditingId] = useGlobal<string | null>(EDITING_WORKSPACE_KEY, null);
const [, setCreating] = useGlobal<boolean>(CREATING_WORKSPACE_KEY, false);
const [, setSelected] = useGlobal<string | null>(SELECTED_WORKSPACE_KEY, null);
const isEditing = !!editingId;
const handleSubmit = () => {
const trimmed = name.trim();
if (!trimmed) return;
const desc = description.trim();
if (isEditing) {
const existingIds = new Set(workspaces.filter((w) => w.id !== editingId).map((w) => w.id));
let newId = slugify(trimmed) || generateSlug();
while (existingIds.has(newId)) newId = `${newId}-${generateSlug(1)}`;
const idChanged = newId !== editingId;
setWorkspaces((prev) =>
prev.map((ws) =>
ws.id === editingId
? { ...ws, id: newId, name: trimmed, description: desc || undefined, templateIdx }
: ws,
),
);
const wsLayout = templates[templateIdx]?.layout() ?? createDefaultLayout();
const currentState = queryClient.getQueryData<Record<string, unknown>>(['WORKSPACES_STATE']) ?? {};
const newLayoutKey = `ws-layout-${newId}`;
if (idChanged) {
const oldKeys = [`ws-layout-${editingId}`, `ws-terminals-${editingId}`, `ws-host-terminals-${editingId}`];
const cleaned = { ...currentState };
for (const k of oldKeys) delete cleaned[k];
queryClient.setQueryData(['WORKSPACES_STATE'], { ...cleaned, [newLayoutKey]: wsLayout });
const patch: Record<string, unknown> = { [newLayoutKey]: wsLayout };
for (const k of oldKeys) patch[k] = null;
client.patch('/workspaces', patch).catch(() => {});
} else {
queryClient.setQueryData(['WORKSPACES_STATE'], { ...currentState, [newLayoutKey]: wsLayout });
client.patch('/workspaces', { [newLayoutKey]: wsLayout }).catch(() => {});
}
setEditingId(null);
setSelected(newId);
} else {
const existingIds = new Set(workspaces.map((w) => w.id));
let id = slugify(trimmed) || generateSlug();
while (existingIds.has(id)) id = `${id}-${generateSlug(1)}`;
const ws: WorkspaceDefinition = {
id,
name: trimmed,
cwd,
description: desc || undefined,
templateIdx,
};
const wsLayout = templates[templateIdx]?.layout() ?? createDefaultLayout();
const layoutKey = `ws-layout-${ws.id}`;
setWorkspaces((prev) => [...prev, ws]);
const currentState = queryClient.getQueryData<Record<string, unknown>>(['WORKSPACES_STATE']) ?? {};
queryClient.setQueryData(['WORKSPACES_STATE'], { ...currentState, [layoutKey]: wsLayout });
client.patch('/workspaces', { [layoutKey]: wsLayout }).catch(() => {});
setName('');
setDescription('');
setTemplateIdx(0);
navigate(`/workspaces/${ws.id}`);
}
};
return (
<div className="h-full w-full">
<div className="flex h-full flex-col items-center justify-center gap-4 p-4">
<Rocket className="h-8 w-8 text-duck-teal/40" />
<div className="text-center">
<p className="text-sm font-medium text-duck-dark/70">{name.trim() || 'Untitled'}</p>
<p className="text-xs font-mono text-duck-dark/40 mt-1">{cwd}</p>
</div>
<Button
onClick={handleSubmit}
disabled={!name.trim()}
className="bg-duck-teal hover:bg-duck-teal/90 cursor-pointer disabled:opacity-40"
>
<Plus className="h-4 w-4 mr-1" />
{isEditing ? 'Update Workspace' : 'Create Workspace'}
</Button>
{isEditing && (
<Button
onClick={() => navigate(`/workspaces/${editingId}`)}
variant="outline"
className="cursor-pointer"
>
<ArrowRight className="h-4 w-4 mr-1" />
Open Workspace
</Button>
)}
</div>
</div>
);
};
// --- CwdFileBrowser ---
const CwdFileBrowser = () => {
const [currentPath] = useUserState<string>('files/currentPath', '/');
const [basePath] = useState(currentPath);
return <FileBrowserApp basePath={basePath} />;
};
// --- New Workspace Layout ---
const newWsLayout: LayoutNode = {
type: 'group',
id: 'new-ws-root',
direction: 'vertical',
children: [
{
node: {
type: 'group',
id: 'new-ws-top',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'new-ws-name', appType: 'new-ws-name' }, size: 30 },
{ node: { type: 'panel', id: 'new-ws-dir', appType: 'file-browser-cwd' }, size: 70 },
],
},
size: 50,
},
{
node: {
type: 'group',
id: 'new-ws-bottom',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'new-ws-template', appType: 'new-ws-template' }, size: 70 },
{ node: { type: 'panel', id: 'new-ws-create', appType: 'new-ws-create' }, size: 30 },
],
},
size: 50,
},
],
};
const NewWorkspaceForm = () => {
const { registry } = useAppRegistry();
const newWsRegistry = {
...registry,
'file-browser-cwd': { name: 'File Browser', icon: FolderOpen, component: CwdFileBrowser },
'new-ws-name': { name: 'Name', icon: Type, component: NamePanel },
'new-ws-template': { name: 'Template', icon: Layout, component: TemplatePanel },
'new-ws-create': { name: 'Create', icon: Rocket, component: CreatePanel },
};
return <WorkspaceLayout layout={newWsLayout} onLayoutChange={() => {}} registry={newWsRegistry} />;
};
// --- Empty State ---
const WorkspacePreviewEmpty = () => {
const [creating, setCreating] = useGlobal<boolean>(CREATING_WORKSPACE_KEY, false);
const [editingId] = useGlobal<string | null>(EDITING_WORKSPACE_KEY, null);
const [, setName] = useGlobal<string>(NEW_WS_NAME_KEY, '');
const [, setDescription] = useGlobal<string>(NEW_WS_DESC_KEY, '');
const [, setTemplateIdx] = useGlobal<number>(NEW_WS_TEMPLATE_KEY, 0);
const [, setFilePath] = useUserState<string>('files/currentPath', '/');
if (creating || editingId) return <NewWorkspaceForm />;
const handleCreate = () => {
setName(generateSlug());
setDescription('');
setTemplateIdx(0);
setFilePath('/');
setCreating(true);
};
return (
<div className="h-full w-full">
<div className="flex h-full w-full flex-col items-center justify-center gap-4">
<LayoutGrid className="h-10 w-10 text-white/80" />
<p className="text-sm text-white/80">Select a workspace or create a new one</p>
<Button onClick={handleCreate} className="bg-duck-teal hover:bg-duck-teal/90 cursor-pointer">
<Plus className="h-4 w-4 mr-1" />
New Workspace
</Button>
</div>
</div>
);
};
// --- Preview Inner ---
const WorkspacePreviewInner = ({ workspace }: { workspace: WorkspaceDefinition }) => {
const ws = useWorkspacesState<LayoutNode>(`ws-layout-${workspace.id}`, createDefaultLayout());
return <WorkspaceView workspace={ws} cwd={workspace.cwd} />;
};
// --- Main Export ---
export const WorkspacePreview = () => {
const [selectedId] = useGlobal<string | null>(SELECTED_WORKSPACE_KEY, null);
const { value: workspaces } = useWorkspacesState<WorkspaceDefinition[]>('workspaces', []);
const workspace = selectedId ? workspaces.find((ws) => ws.id === selectedId) : null;
if (!workspace) return <WorkspacePreviewEmpty />;
return (
<div className="relative h-full w-full">
<WorkspacePreviewInner key={workspace.id} workspace={workspace} />
<Link
to={`/workspaces/${workspace.id}`}
className="absolute inset-0 z-10 flex items-center justify-center bg-transparent hover:bg-duck-dark/10 transition-colors group"
>
<ArrowRight className="h-16 w-16 text-duck-teal/0 group-hover:text-duck-teal/60 transition-colors" />
</Link>
</div>
);
};
@@ -0,0 +1,6 @@
export const SELECTED_WORKSPACE_KEY = 'SELECTED_WORKSPACE';
export const CREATING_WORKSPACE_KEY = 'CREATING_WORKSPACE';
export const EDITING_WORKSPACE_KEY = 'EDITING_WORKSPACE';
export const NEW_WS_NAME_KEY = 'NEW_WS_NAME';
export const NEW_WS_DESC_KEY = 'NEW_WS_DESC';
export const NEW_WS_TEMPLATE_KEY = 'NEW_WS_TEMPLATE';
@@ -0,0 +1,25 @@
import type { AppRegistryMeta } from '../../AppRegistry';
import { LayoutGrid } from 'lucide-react';
import { WorkspaceListApp } from './WorkspaceListApp';
import { WorkspacePreview } from './WorkspacePreview';
export { WorkspaceListApp };
export { WorkspacePreview };
export { SELECTED_WORKSPACE_KEY, CREATING_WORKSPACE_KEY, EDITING_WORKSPACE_KEY, NEW_WS_NAME_KEY, NEW_WS_DESC_KEY, NEW_WS_TEMPLATE_KEY } from './constants';
export const appRegistryMetas: AppRegistryMeta[] = [
{
key: 'workspace-list',
name: 'Workspaces',
icon: LayoutGrid,
component: WorkspaceListApp,
availableOnPanel: false
},
{
key: 'workspace-preview',
name: 'Workspace Preview',
icon: LayoutGrid,
component: WorkspacePreview,
availableOnPanel: false
},
];
@@ -1,5 +0,0 @@
import type { AppRegistry } from '@/components/Workspace';
export function useAppRegistry(): AppRegistry {
return {};
}
@@ -1 +0,0 @@
export { useAppRegistry } from './appRegistry';
@@ -1,4 +1,3 @@
export * from './appRegistry';
export * from './useFilesAPI';
export * from './useFileViewerPanels';
export * from './usePiChat';
@@ -1,6 +1,6 @@
import type { ReactNode } from 'react';
import { useSearchParams } from 'react-router';
import { useUserState } from '@/state/useUserState';
import { useUserState } from 'state/useUserState';
import { FileViewerProvider } from '../../apps/FileViewer';
export function ViewerProvider({ children }: { children: ReactNode }) {
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react';
import { useChatWebSocket } from 'hooks/useChatWebSocket';
import { useChatSessions } from '@/state/useChatSessions';
import { useChatSessions } from 'state/useChatSessions';
import type { ChatMessage, ServerMessage, TaskInfo, Message } from '../apps/Chat/types';
const SAVE_DEBOUNCE_MS = 1000;
+15 -6
View File
@@ -1,7 +1,16 @@
export * from './hooks';
export * from './apps/Chat';
export * from './apps/ChatHistory';
export * from './apps/CodeEditor';
export * from './apps/FileBrowser';
export * from './apps/FileViewer';
export * from './apps/Terminal';
export * from './AppRegistry';
// Re-export app modules (excluding appRegistryMetas to avoid name collisions)
export { MessageList, MessageBubble, StreamingBubble, ToolActivity, QuestionActivity, ModelSelector, InputArea, ChatLauncher, AttachmentList, AttachButton, WebpageDialog, EmbeddableChat, usePiChat, ChatList, useSlashCommands, useChatSessions, useChatSession, useAttachments, useAudioRecording } from './apps/Chat';
export type { UseEmbeddableChatType, UsePiChatType, UseChatSessionsType, UseChatSessionType, UseAttachmentsType, UseAudioRecordingType } from './apps/Chat';
export * from './apps/Chat/types';
export { SessionBar } from './apps/ChatHistory';
export { CodeEditorView } from './apps/CodeEditor';
export { useFilesAPI, useTasks, useRecentFiles, usePinnedFiles, FileBrowserApp, FileBrowserPanelWrapper, FileBrowserWidget, TaskRunnerModal } from './apps/FileBrowser';
export type { DirEntry, TaskSummary } from './apps/FileBrowser';
export { FileViewerView, FileViewerProvider, FileViewerHeader, FileViewerBody, FileViewerPanelProvider, useFileViewer, getFileType, getLang, getExt, getArchiveBaseName, ARCHIVE_EXTS } from './apps/FileViewer';
export type { FileType } from './apps/FileViewer';
export { TerminalView } from './apps/Terminal';
export type { TerminalViewProps } from './apps/Terminal';
export { WorkspaceListApp, WorkspacePreview, SELECTED_WORKSPACE_KEY, CREATING_WORKSPACE_KEY, EDITING_WORKSPACE_KEY, NEW_WS_NAME_KEY, NEW_WS_DESC_KEY, NEW_WS_TEMPLATE_KEY } from './apps/Workspaces';
+14
View File
@@ -0,0 +1,14 @@
{
"name": "state",
"version": "0.0.1",
"license": "MIT",
"type": "module",
"exports": {
".": "./src/index.ts",
"./*": "./src/*.ts"
},
"dependencies": {
"hooks": "workspace:*",
"officerdev": "workspace:*"
}
}
+16
View File
@@ -0,0 +1,16 @@
export { useSettings, DEFAULT_SETTINGS } from './useSettings';
export type { UseSettingsType, UserSettings, UserState } from './useSettings';
export { useUserState } from './useUserState';
export { useWorkspacesState } from './useWorkspacesState';
export { useProjectsState } from './useProjectsState';
export { usePiModels, useVisiblePiModels, modelKey } from './useModels';
export type { ModelOption } from './useModels';
export { useRecentModels } from './useRecentModels';
export { usePlans } from './usePlans';
export { useLandingPage } from './useLandingPage';
export { useServerSettings } from './useServerSettings';
export { useResources, getResourceCategory } from './useResources';
export type { Resource, ResourceCredentials, ResourceConnectionConfig, PingResult, ResourceCategory } from './useResources';
export { useChatSessions } from './useChatSessions';
export type { UseChatSessionsType } from './useChatSessions';
export { useChatGroups } from './useChatGroups';
+53
View File
@@ -0,0 +1,53 @@
import type { GroupEntry } from 'officerdev';
import { useAuth } from 'hooks/useAuth';
import { useClient } from 'hooks/useClient';
import { useQuery, useQueryClient } from '@tanstack/react-query';
export function useChatGroups() {
const client = useClient();
const queryClient = useQueryClient();
const { isAuthenticated } = useAuth();
const { data: groups = [] } = useQuery<GroupEntry[]>({
queryKey: ['PI_GROUPS'],
enabled: isAuthenticated,
queryFn: () => client.get<{ groups: GroupEntry[] }>('/pi/groups').then((r) => r.groups),
});
async function createGroup(name: string, slug: string, description?: string, sessionIds?: string[]) {
const result = await client.post<{ group: GroupEntry }>('/pi/groups', {
name,
slug,
description,
sessionIds,
});
queryClient.invalidateQueries({ queryKey: ['PI_GROUPS'] });
queryClient.invalidateQueries({ queryKey: ['PI_SESSIONS'] });
return result.group;
}
async function updateGroup(slug: string, updates: { name?: string; description?: string }) {
await client.patch(`/pi/groups/${slug}`, updates);
queryClient.invalidateQueries({ queryKey: ['PI_GROUPS'] });
}
async function deleteGroup(slug: string) {
await client.delete(`/pi/groups/${slug}`);
queryClient.invalidateQueries({ queryKey: ['PI_GROUPS'] });
queryClient.invalidateQueries({ queryKey: ['PI_SESSIONS'] });
}
async function moveSession(sessionId: string, groupSlug: string | null) {
await client.post(`/pi/sessions/${sessionId}/move`, { groupSlug });
queryClient.invalidateQueries({ queryKey: ['PI_GROUPS'] });
queryClient.invalidateQueries({ queryKey: ['PI_SESSIONS'] });
}
return {
groups,
createGroup,
updateGroup,
deleteGroup,
moveSession,
};
}
@@ -0,0 +1,69 @@
import type { SessionEntry, ChatMessage, Message } from 'officerdev';
import { useAuth } from 'hooks/useAuth';
import { useClient } from 'hooks/useClient';
import { useQuery, useQueryClient } from '@tanstack/react-query';
export function useChatSessions() {
const client = useClient();
const queryClient = useQueryClient();
const { isAuthenticated } = useAuth();
const { data: sessions = [] } = useQuery<SessionEntry[]>({
queryKey: ['PI_SESSIONS'],
enabled: isAuthenticated,
queryFn: () => client.post<{ sessions: SessionEntry[] }>('/pi/sessions').then((r) => r.sessions),
});
function getSession(sessionId: string) {
return client.get<{ session: SessionWithMessages }>(`/pi/sessions/${sessionId}`);
}
function saveMessages(sessionId: string, messages: ChatMessage[]) {
return client.put(`/pi/sessions/${sessionId}/messages`, messages);
}
async function renameSession(sessionId: string, title: string) {
await client.patch(`/pi/sessions/${sessionId}`, { title });
queryClient.invalidateQueries({ queryKey: ['PI_SESSIONS'] });
}
async function deleteSession(sessionId: string) {
await client.delete(`/pi/sessions/${sessionId}`);
queryClient.setQueryData<SessionEntry[]>(
['PI_SESSIONS'],
(prev) => prev?.filter((s) => s.id !== sessionId) ?? [],
);
}
function searchSessions(query: string) {
return client.get<{ results: SessionEntry[] }>(`/pi/sessions/search?q=${encodeURIComponent(query)}`);
}
return {
sessions,
getSession,
saveMessages,
renameSession,
deleteSession,
searchSessions,
};
}
export type UseChatSessionsType = ReturnType<typeof useChatSessions>;
type SessionWithMessages = {
id: string;
title: string;
model: string;
cwd: string;
groupSlug?: string | null;
createdAt: number;
updatedAt: number;
messageCount: number;
cost: {
inputTokens: number;
outputTokens: number;
totalUSD: number;
};
messages: Message[];
};
@@ -0,0 +1,15 @@
import { useQuery } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
export const useLandingPage = () => {
const apiClient = useClient();
const { data: setupData, isLoading } = useQuery({
queryKey: ['LANDING_PAGE_DATA'],
queryFn: () => apiClient.get<{ registrationOpen: boolean }>('/landing-page-data'),
});
return {
isLoading,
registrationOpen: setupData?.registrationOpen,
};
};
+39
View File
@@ -0,0 +1,39 @@
import { useQuery } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
import { useAuth } from 'hooks/useAuth';
import { useSettings } from './useSettings';
import type { ModelOption } from 'officerdev';
export type { ModelOption };
export function modelKey(m: ModelOption): string {
return `${m.provider}:${m.id}`;
}
export function usePiModels() {
const client = useClient();
const { isAuthenticated } = useAuth();
const { data: models = [] } = useQuery<ModelOption[]>({
queryKey: ['PI_MODELS'],
enabled: isAuthenticated,
queryFn: async () => {
const data = await client.get<{ models: ModelOption[] }>('/pi/models');
return data.models;
},
staleTime: 5 * 60 * 1000,
});
return models;
}
export function useVisiblePiModels() {
const models = usePiModels();
const { settings } = useSettings();
const enabled = settings.ai?.enabledModels ?? [];
const filtered = models.filter((m) => enabled.includes(modelKey(m)));
// If no models match the visibility filter, show all
// The provider list changes dynamically based on API keys so the filter may be stale
return filtered.length > 0 ? filtered : models;
}
+18
View File
@@ -0,0 +1,18 @@
import { useAuth } from 'hooks/useAuth';
import { useClient } from 'hooks/useClient';
import { useQuery } from '@tanstack/react-query';
export const usePlans = () => {
const client = useClient();
const { isAuthenticated } = useAuth();
const { data: plans = [] } = useQuery<string[]>({
queryKey: ['PLANS'],
enabled: isAuthenticated,
queryFn: () => client.get<string[]>('/plans'),
});
const getPlan = (name: string) => client.getText(`/plans/${name}`);
return { plans, getPlan };
};
@@ -0,0 +1,44 @@
import { useCallback, useRef } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
import { useAuth } from 'hooks/useAuth';
import type { UserState } from './useSettings';
const QUERY_KEY = ['PROJECTS_STATE'];
export function useProjectsState<T>(key: string, defaultValue: T): [T, (value: T | ((prev: T) => T)) => void, boolean] {
const client = useClient();
const { isAuthenticated } = useAuth();
const queryClient = useQueryClient();
const clientRef = useRef(client);
clientRef.current = client;
const { data: state = {}, isSuccess } = useQuery<UserState>({
queryKey: QUERY_KEY,
enabled: isAuthenticated,
queryFn: () => client.get<UserState>('/user/projects-state'),
staleTime: Infinity,
});
const value = key in state ? (state[key] as T) : defaultValue;
const setValue = useCallback(
(update: T | ((prev: T) => T)) => {
const currentState = queryClient.getQueryData<UserState>(QUERY_KEY) ?? {};
const currentValue = key in currentState ? (currentState[key] as T) : defaultValue;
const newValue = typeof update === 'function' ? (update as (prev: T) => T)(currentValue) : update;
queryClient.setQueryData(QUERY_KEY, { ...currentState, [key]: newValue });
clientRef.current
.patch<UserState>('/user/projects-state', { [key]: newValue })
.then((serverState) => {
if (serverState) queryClient.setQueryData(QUERY_KEY, serverState);
})
.catch(() => {});
},
[key, defaultValue, queryClient],
);
return [value, setValue, isSuccess];
}
@@ -0,0 +1,41 @@
import { useCallback, useEffect, useRef } from 'react';
import { useUserState } from './useUserState';
import type { ModelOption } from './useModels';
const MAX_RECENTS = 5;
export const useRecentModels = () => {
const [recents, setRecents] = useUserState<ModelOption[]>('recentModels', []);
const migrated = useRef(false);
// One-time migration from localStorage
useEffect(() => {
if (migrated.current) return;
migrated.current = true;
const raw = localStorage.getItem('OC_RECENT_MODELS');
if (!raw) return;
try {
const parsed = JSON.parse(raw) as ModelOption[];
if (Array.isArray(parsed) && parsed.length > 0) {
setRecents(parsed.slice(0, MAX_RECENTS));
localStorage.removeItem('OC_RECENT_MODELS');
}
} catch {
localStorage.removeItem('OC_RECENT_MODELS');
}
}, []);
const addRecent = useCallback(
(model: ModelOption) => {
setRecents((prev) => {
const filtered = prev.filter((m) => m.id !== model.id);
return [model, ...filtered].slice(0, MAX_RECENTS);
});
},
[setRecents],
);
return { recents, addRecent };
};
+68
View File
@@ -0,0 +1,68 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
export type ResourceCredentials = {
apiKey?: string;
username?: string;
password?: string;
};
export type ResourceConnectionConfig = {
url: string;
credentials?: ResourceCredentials;
};
export type Resource = {
id: string;
name: string;
subtitle: string;
type: string;
port: string | null;
description: string;
installCommand: string | null;
uninstallCommand: string | null;
manageCommand: string | null;
verifyCommand: string | null;
updateCommand: string | null;
installed: boolean;
version: string | null;
connectionConfig: ResourceConnectionConfig | null;
};
export type PingResult = {
reachable: boolean;
latencyMs: number | null;
};
export type ResourceCategory = 'api-based' | 'local-cli';
export const getResourceCategory = (r: Resource): ResourceCategory => (r.port ? 'api-based' : 'local-cli');
const RESOURCES_KEY = ['RESOURCES'];
export const useResources = () => {
const client = useClient();
const queryClient = useQueryClient();
const { data: resources, isLoading } = useQuery({
queryKey: RESOURCES_KEY,
queryFn: () => client.get<Resource[]>('/server-settings/resources'),
});
const saveConnectionConfig = async (id: string, config: Partial<ResourceConnectionConfig>) => {
await client.patch(`/server-settings/resources/config/${id}`, config);
queryClient.invalidateQueries({ queryKey: RESOURCES_KEY });
};
const pingResource = async (id: string, url?: string) => {
return client.post<PingResult>(`/server-settings/resources/${id}/ping`, { url });
};
const runCommand = async (id: string, action: string) => {
const result = await client.post<{ exitCode: number; output: string }>(`/server-settings/resources/${id}/run`, { action });
queryClient.invalidateQueries({ queryKey: RESOURCES_KEY });
return result;
};
return { resources, isLoading, saveConnectionConfig, pingResource, runCommand };
};
@@ -0,0 +1,42 @@
import { useCallback } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
type AIHarnesses = {
claudeCode: boolean;
opencode: boolean;
piMono: boolean;
};
type ServerSettings = {
onboardingComplete?: boolean;
accountMode?: 'organization' | 'single';
aiHarnesses?: AIHarnesses;
plugins?: Record<string, boolean>;
};
const SETTINGS_KEY = ['SERVER_SETTINGS'];
export const useServerSettings = () => {
const client = useClient();
const queryClient = useQueryClient();
const { data: settings, isLoading } = useQuery({
queryKey: SETTINGS_KEY,
queryFn: () => client.get<ServerSettings>('/server-settings/settings'),
});
const onboardingComplete = settings?.onboardingComplete ?? true;
const accountMode = settings?.accountMode;
const aiHarnesses = settings?.aiHarnesses;
const plugins = settings?.plugins;
const saveSettings = useCallback(
async (update: Partial<ServerSettings>) => {
const result = await client.put<ServerSettings>('/server-settings', update);
queryClient.setQueryData(SETTINGS_KEY, result);
},
[client, queryClient],
);
return { onboardingComplete, accountMode, aiHarnesses, plugins, isLoading, saveSettings };
};
+101
View File
@@ -0,0 +1,101 @@
import { useCallback } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
import { useAuth } from 'hooks/useAuth';
const QUERY_KEY = ['USER_SETTINGS'];
const mergeWithDefaults = (saved: Partial<UserSettings>): UserSettings => ({
chat: { ...DEFAULT_SETTINGS.chat, ...saved.chat },
ai: {
enabledModels: saved.ai?.enabledModels?.length ? saved.ai.enabledModels : DEFAULT_SETTINGS.ai.enabledModels,
enabledProviders: saved.ai?.enabledProviders ?? DEFAULT_SETTINGS.ai.enabledProviders,
},
tasks: { ...DEFAULT_SETTINGS.tasks, ...saved.tasks },
appearance: { ...DEFAULT_SETTINGS.appearance, ...saved.appearance },
languages: { ...DEFAULT_SETTINGS.languages, ...saved.languages },
});
export const useSettings = () => {
const client = useClient();
const { isAuthenticated } = useAuth();
const queryClient = useQueryClient();
const { data: settings = DEFAULT_SETTINGS } = useQuery<UserSettings>({
queryKey: QUERY_KEY,
enabled: isAuthenticated,
queryFn: async () => {
const saved = await client.get<Partial<UserSettings>>('/user/settings');
return mergeWithDefaults(saved);
},
staleTime: Infinity,
});
const saveSettings = useCallback(
async (newSettings: UserSettings) => {
queryClient.setQueryData(QUERY_KEY, newSettings);
await client.put<UserSettings>('/user/settings', newSettings);
},
[client, queryClient],
);
return { settings, saveSettings };
};
export type UseSettingsType = ReturnType<typeof useSettings>;
export type UserSettings = {
chat: {
defaultProvider: 'pi';
defaultModel: string | null;
systemPrompt: string;
temperature: number;
defaultPwd: string;
};
ai: {
enabledModels: string[];
enabledProviders: string[];
};
tasks: {
defaultProvider: 'pi';
defaultModel: string | null;
};
appearance: {
colorMode: 'light' | 'dark';
colorTheme: string;
};
languages: {
spoken: string[];
default: string;
translateTo: string;
};
};
export type UserState = Record<string, unknown>;
export const DEFAULT_SETTINGS: UserSettings = {
chat: {
defaultProvider: 'pi',
defaultModel: null,
systemPrompt: '',
temperature: 1,
defaultPwd: '~',
},
ai: {
enabledModels: [],
enabledProviders: [],
},
tasks: {
defaultProvider: 'pi',
defaultModel: null,
},
appearance: {
colorMode: 'light',
colorTheme: 'DuckPond',
},
languages: {
spoken: ['en'],
default: 'en',
translateTo: 'en',
},
};
+40
View File
@@ -0,0 +1,40 @@
import { useCallback, useRef } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
import { useAuth } from 'hooks/useAuth';
import type { UserState } from './useSettings';
const QUERY_KEY = ['USER_STATE'];
export function useUserState<T>(key: string, defaultValue: T): [T, (value: T | ((prev: T) => T)) => void, boolean] {
const client = useClient();
const { isAuthenticated } = useAuth();
const queryClient = useQueryClient();
const clientRef = useRef(client);
clientRef.current = client;
const { data: state = {}, isSuccess } = useQuery<UserState>({
queryKey: QUERY_KEY,
enabled: isAuthenticated,
queryFn: () => client.get<UserState>('/user/state'),
staleTime: Infinity,
});
const value = key in state ? (state[key] as T) : defaultValue;
const setValue = useCallback(
(update: T | ((prev: T) => T)) => {
const currentState = queryClient.getQueryData<UserState>(QUERY_KEY) ?? {};
const currentValue = key in currentState ? (currentState[key] as T) : defaultValue;
const newValue = typeof update === 'function' ? (update as (prev: T) => T)(currentValue) : update;
queryClient.setQueryData(QUERY_KEY, { ...currentState, [key]: newValue });
// Immediate fire-and-forget PATCH
clientRef.current.patch('/user/state', { [key]: newValue }).catch(() => {});
},
[key, defaultValue, queryClient],
);
return [value, setValue, isSuccess];
}
@@ -0,0 +1,52 @@
import { useCallback, useEffect, useRef } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
import { useAuth } from 'hooks/useAuth';
import type { UserState } from './useSettings';
const QUERY_KEY = ['WORKSPACES_STATE'];
export function useWorkspacesState<T>(key: string, defaultValue: T) {
const client = useClient();
const { isAuthenticated } = useAuth();
const queryClient = useQueryClient();
const clientRef = useRef(client);
clientRef.current = client;
const { data: state = {}, isSuccess } = useQuery<UserState>({
queryKey: QUERY_KEY,
enabled: isAuthenticated,
queryFn: () => client.get<UserState>('/workspaces'),
staleTime: Infinity,
});
// Seed default to backend when key is missing after initial fetch
const seededKeyRef = useRef<string | null>(null);
useEffect(() => {
if (!isSuccess || seededKeyRef.current === key) return;
seededKeyRef.current = key;
const currentState = queryClient.getQueryData<UserState>(QUERY_KEY) ?? {};
if (!(key in currentState)) {
queryClient.setQueryData(QUERY_KEY, { ...currentState, [key]: defaultValue });
clientRef.current.patch('/workspaces', { [key]: defaultValue }).catch(() => { });
}
}, [isSuccess, key, defaultValue, queryClient]);
const value = key in state ? (state[key] as T) : defaultValue;
const setValue = useCallback(
(update: T | ((prev: T) => T)) => {
const currentState = queryClient.getQueryData<UserState>(QUERY_KEY) ?? {};
const currentValue = key in currentState ? (currentState[key] as T) : defaultValue;
const newValue = typeof update === 'function' ? (update as (prev: T) => T)(currentValue) : update;
queryClient.setQueryData(QUERY_KEY, { ...currentState, [key]: newValue });
clientRef.current.patch('/workspaces', { [key]: newValue }).catch(() => { });
},
[key, defaultValue, queryClient],
);
return { key, value, setValue, isLoaded: isSuccess };
}
+5 -5
View File
@@ -7,9 +7,9 @@ import { DailyGoals } from './DailyGoals/index';
import { QuickNotes } from './QuickNotes/index';
export const widgetRegistry: Record<string, AppRegistryEntry> = {
'clock': { name: 'Clock', icon: ClockIcon, component: () => <Clock />, widget: true },
'weather': { name: 'Weather', icon: CloudSun, component: () => <Weather />, widget: true },
'pomodoro': { name: 'Pomodoro', icon: Timer, component: () => <Pomodoro />, widget: true },
'daily-goals': { name: 'Daily Goals', icon: Target, component: () => <DailyGoals />, widget: true },
'quick-notes': { name: 'Quick Notes', icon: StickyNote, component: () => <QuickNotes />, widget: true },
'clock': { name: 'Clock', icon: ClockIcon, component: () => <Clock />, availableOnPanel: false },
'weather': { name: 'Weather', icon: CloudSun, component: () => <Weather />, availableOnPanel: false },
'pomodoro': { name: 'Pomodoro', icon: Timer, component: () => <Pomodoro />, availableOnPanel: false },
'daily-goals': { name: 'Daily Goals', icon: Target, component: () => <DailyGoals />, availableOnPanel: false },
'quick-notes': { name: 'Quick Notes', icon: StickyNote, component: () => <QuickNotes />, availableOnPanel: false },
};