workspaces to dashboards, imap email sync, ffmpeg tool, tts fix, file browser refresh, automation sidebar reorder

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 20:00:45 +00:00
co-authored by Claude Opus 4.6
parent bd362dc586
commit 927267e041
98 changed files with 1176 additions and 802 deletions
@@ -4,7 +4,7 @@ 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 { appRegistryMetas as dashboardMetas } from '../apps/Dashboards';
import { appRegistryMetas as projectMetas } from '../apps/Projects';
import { appRegistryMetas as chatHistoryMetas } from '../apps/ChatHistory';
import { appRegistryMetas as previewMetas } from '../apps/Preview';
@@ -15,7 +15,7 @@ import { createUserAppPanel } from '../apps/UserApp/UserAppPanel';
import { createUserAppHeader } from '../apps/UserApp/UserAppHeader';
import { resolveIcon } from '../utils/resolve-icon';
const apps = [...fileBrowserMetas, ...terminalMetas, ...codeEditorMetas, ...chatMetas, ...fileViewerMetas, ...workspaceMetas, ...projectMetas, ...chatHistoryMetas, ...previewMetas, ...widgetMetas];
const apps = [...fileBrowserMetas, ...terminalMetas, ...codeEditorMetas, ...chatMetas, ...fileViewerMetas, ...dashboardMetas, ...projectMetas, ...chatHistoryMetas, ...previewMetas, ...widgetMetas];
export const AppRegistry = () => {
const { registerApp } = useAppRegistry(apps);
@@ -9,7 +9,7 @@ import { getProviderDisplayName } from 'state/useModels';
type ChatSessionSelection = {
sessionId: string | null;
model?: string | null;
workspaceId?: string;
dashboardId?: string;
};
function formatModel(model: string): string {
@@ -22,13 +22,13 @@ function formatModel(model: string): string {
}
export const ChatHeader = () => {
const { workspaceId } = useWorkspace();
const contextFilter = workspaceId === 'email' || workspaceId === 'screens/email'
const { dashboardId } = useWorkspace();
const contextFilter = dashboardId === 'email' || dashboardId === 'screens/email'
? { context: 'email' as const }
: workspaceId?.startsWith('proj-layout-')
? { context: 'project' as const, contextId: workspaceId.replace('proj-layout-', '') }
: workspaceId && !workspaceId.startsWith('screens/')
? { context: 'workspace' as const, contextId: workspaceId }
: dashboardId?.startsWith('proj-layout-')
? { context: 'project' as const, contextId: dashboardId.replace('proj-layout-', '') }
: dashboardId && !dashboardId.startsWith('screens/')
? { context: 'dashboard' as const, contextId: dashboardId }
: undefined;
const { sessions, clearSessions } = useChatSessions(contextFilter);
const [selection, setSelection] = usePanelChannel<ChatSessionSelection | null>('chat:panel-session', null);
@@ -38,25 +38,25 @@ export const ChatHeader = () => {
// Clear stale selection from a different workspace
useEffect(() => {
if (selection && selection.workspaceId !== workspaceId) {
if (selection && selection.dashboardId !== dashboardId) {
setSelection(null);
autoResumedRef.current = false;
}
}, [workspaceId, selection]);
}, [dashboardId, selection]);
// Auto-resume the latest session for this workspace
useEffect(() => {
if (autoResumedRef.current) return;
if (selection && selection.workspaceId === workspaceId) return;
if (selection && selection.dashboardId === dashboardId) return;
if (sessions.length > 0) {
const latest = sessions[0]!;
setSelection({ sessionId: latest.id, model: latest.model ?? null, workspaceId });
setSelection({ sessionId: latest.id, model: latest.model ?? null, dashboardId });
autoResumedRef.current = true;
}
}, [sessions, selection, workspaceId]);
}, [sessions, selection, dashboardId]);
const selectSession = (sessionId: string | null, model?: string | null) => {
setSelection({ sessionId, model, workspaceId });
setSelection({ sessionId, model, dashboardId });
setOpen(false);
};
@@ -7,7 +7,7 @@ import { EmbeddableChat } from './EmbeddableChat';
type ChatSessionSelection = {
sessionId: string | null;
model?: string | null;
workspaceId?: string;
dashboardId?: string;
};
type ChatPanelInnerProps = {
@@ -45,30 +45,34 @@ const ChatPanelInner = ({ sessionId, model, scoped, sandboxed, cwdParam, promptP
};
export const ChatPanelWrapper = () => {
const { workspaceId, cwd, root, promptPrefix } = useWorkspace();
const { dashboardId, cwd, root, promptPrefix } = useWorkspace();
const scoped = cwd !== '~';
const hostRoot = root === '~' || root === 'officer.dev';
const sandboxed = !hostRoot;
const chatContext = workspaceId === 'email' || workspaceId === 'screens/email'
const chatContext = dashboardId === 'email' || dashboardId === 'screens/email'
? { context: 'email' as const }
: workspaceId?.startsWith('proj-layout-')
? { context: 'project' as const, contextId: workspaceId.replace('proj-layout-', '') }
: workspaceId && !workspaceId.startsWith('screens/')
? { context: 'workspace' as const, contextId: workspaceId }
: dashboardId?.startsWith('proj-layout-')
? { context: 'project' as const, contextId: dashboardId.replace('proj-layout-', '') }
: dashboardId && !dashboardId.startsWith('screens/')
? { context: 'dashboard' as const, contextId: dashboardId }
: {};
const [selection] = usePanelChannel<ChatSessionSelection | null>('chat:panel-session', null);
const [, setActiveSession] = usePanelChannel<string | null>('chat:active-session', null);
const [, setPreviewRefresh] = usePanelChannel<number>('preview:refresh', 0);
const [, setFilesRefresh] = usePanelChannel<number>('files:refresh-signal', 0);
const onTurnComplete = useCallback((hadToolCalls: boolean) => {
if (hadToolCalls) setPreviewRefresh(Date.now());
}, [setPreviewRefresh]);
if (hadToolCalls) {
setPreviewRefresh(Date.now());
setFilesRefresh(Date.now());
}
}, [setPreviewRefresh, setFilesRefresh]);
const cwdParam = scoped ? { root, path: cwd } : undefined;
const isCurrentWorkspace = !selection?.workspaceId || selection.workspaceId === workspaceId;
const isCurrentWorkspace = !selection?.dashboardId || selection.dashboardId === dashboardId;
const sessionId = isCurrentWorkspace ? selection?.sessionId ?? undefined : undefined;
const model = isCurrentWorkspace ? selection?.model ?? undefined : undefined;
@@ -22,10 +22,11 @@ type EmbeddableChatProps = {
chat?: UsePiChatType;
context?: string;
contextId?: string;
onMessageComplete?: () => void;
};
export const EmbeddableChat = ({ className, ...params }: EmbeddableChatProps) => {
const manager = useEmbeddableChat(params);
export const EmbeddableChat = ({ className, onMessageComplete, ...params }: EmbeddableChatProps) => {
const manager = useEmbeddableChat(params, onMessageComplete);
return (
<div className={`flex flex-col ${className ?? ''}`}>
@@ -25,7 +25,7 @@ type UseEmbeddableChatParams = {
contextId?: string;
};
export function useEmbeddableChat(params: UseEmbeddableChatParams) {
export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComplete?: () => void) {
const { initialMessage, defaultInput = '', promptPrefix, cwd, sandboxed, autoSend = false, chat: externalChat } = params;
const internalChat = usePiChat(params.sessionId, params.initialModel, { replaceUrl: params.replaceUrl ?? false, context: params.context, contextId: params.contextId });
@@ -51,6 +51,17 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams) {
const attachmentManager = useAttachments({ sessionId });
const slashCommands = useSlashCommands({ sessionId });
// Detect when agent finishes a message (isGenerating: true → false)
const wasGeneratingRef = useRef(false);
useEffect(() => {
if (isGenerating) {
wasGeneratingRef.current = true;
} else if (wasGeneratingRef.current) {
wasGeneratingRef.current = false;
onMessageComplete?.();
}
}, [isGenerating]);
const [input, setInput] = useState(defaultInput);
const [showJumpToBottom, setShowJumpToBottom] = useState(false);
const [commandFeedback, setCommandFeedback] = useState<string | null>(null);
@@ -13,12 +13,12 @@ import { SessionContextMenu } from './SessionContextMenu';
export const SessionList = () => {
const navigate = useNavigate();
const { workspaceId } = useWorkspace();
const { dashboardId } = useWorkspace();
// Only scope sessions for actual project/workspace contexts, not screen layout IDs like 'screens/chat'
const contextFilter = workspaceId?.startsWith('proj-layout-')
? { context: 'project' as const, contextId: workspaceId.replace('proj-layout-', '') }
: workspaceId && !workspaceId.startsWith('screens/')
? { context: 'workspace' as const, contextId: workspaceId }
const contextFilter = dashboardId?.startsWith('proj-layout-')
? { context: 'project' as const, contextId: dashboardId.replace('proj-layout-', '') }
: dashboardId && !dashboardId.startsWith('screens/')
? { context: 'dashboard' as const, contextId: dashboardId }
: undefined;
const { sessions, deleteSession } = useChatSessions(contextFilter);
const { groups } = useChatGroups();
@@ -6,8 +6,8 @@ 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 { useDashboardState } from 'state/useDashboardState';
import type { DashboardDefinition } from '../../components/Workspace';
import {
AlertDialog,
AlertDialogAction,
@@ -19,39 +19,39 @@ import {
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,
SELECTED_DASHBOARD_KEY,
CREATING_DASHBOARD_KEY,
EDITING_DASHBOARD_KEY,
NEW_DASH_NAME_KEY,
NEW_DASH_DESC_KEY,
NEW_DASH_TEMPLATE_KEY,
} from './constants';
export const WorkspaceListApp = () => {
export const DashboardListApp = () => {
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 { value: dashboards, setValue: setDashboards } = useDashboardState<DashboardDefinition[]>('workspaces', []);
const [selected, setSelected] = useGlobal<string | null>(SELECTED_DASHBOARD_KEY, null);
const [, setCreating] = useGlobal<boolean>(CREATING_DASHBOARD_KEY, false);
const [, setEditing] = useGlobal<string | null>(EDITING_DASHBOARD_KEY, null);
const [, setName] = useGlobal<string>(NEW_DASH_NAME_KEY, '');
const [, setDescription] = useGlobal<string>(NEW_DASH_DESC_KEY, '');
const [, setTemplateIdx] = useGlobal<number>(NEW_DASH_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 [deleting, setDeleting] = useState<DashboardDefinition | null>(null);
const isDashboardsPage = location.pathname === '/dashboards';
const filtered = search
? workspaces.filter((ws) => {
? dashboards.filter((ws) => {
const q = search.toLowerCase();
return [ws.name, ws.id, ws.description ?? '', ws.cwd ?? ''].some((field) => field.toLowerCase().includes(q));
})
: workspaces;
: dashboards;
const handleEdit = (ev: React.MouseEvent, ws: WorkspaceDefinition) => {
const handleEdit = (ev: React.MouseEvent, ws: DashboardDefinition) => {
ev.stopPropagation();
setSelected(null);
setCreating(false);
@@ -63,31 +63,31 @@ export const WorkspaceListApp = () => {
setFilePath(cwdPath);
};
const handleDelete = (ev: React.MouseEvent, ws: WorkspaceDefinition) => {
const handleDelete = (ev: React.MouseEvent, ws: DashboardDefinition) => {
ev.stopPropagation();
setDeleting(ws);
};
const confirmDelete = () => {
if (!deleting) return;
setWorkspaces((prev) => prev.filter((w) => w.id !== deleting.id));
setDashboards((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 currentState = queryClient.getQueryData<Record<string, unknown>>(['DASHBOARD_STATE']) ?? {};
const { [layoutKey]: _, ...rest } = currentState;
queryClient.setQueryData(['WORKSPACES_STATE'], rest);
client.patch('/workspaces', { [layoutKey]: null }).catch(() => {});
queryClient.setQueryData(['DASHBOARD_STATE'], rest);
client.patch('/dashboards', { [layoutKey]: null }).catch(() => {});
setDeleting(null);
};
const handleClick = (ws: WorkspaceDefinition) => {
if (isWorkspacesPage) {
const handleClick = (ws: DashboardDefinition) => {
if (isDashboardsPage) {
setSelected(ws.id);
setCreating(false);
setEditing(null);
} else {
navigate(`/workspaces/${ws.id}`);
navigate(`/dashboards/${ws.id}`);
}
};
@@ -95,11 +95,11 @@ export const WorkspaceListApp = () => {
<div className="flex flex-col h-full overflow-y-auto">
<div className="p-3 pb-0 flex flex-col gap-2">
<Link
to="/workspaces"
to="/dashboards"
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
Dashboards
</Link>
<button
type="button"
@@ -111,12 +111,12 @@ export const WorkspaceListApp = () => {
setTemplateIdx(0);
setFilePath('/');
setCreating(true);
if (!isWorkspacesPage) navigate('/workspaces');
if (!isDashboardsPage) navigate('/dashboards');
}}
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
New Dashboard
</button>
</div>
<div className="relative px-3 pt-2">
@@ -125,7 +125,7 @@ export const WorkspaceListApp = () => {
type="text"
value={search}
onChange={(ev) => setSearch(ev.target.value)}
placeholder="Search workspaces"
placeholder="Search dashboards"
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>
@@ -134,7 +134,7 @@ export const WorkspaceListApp = () => {
<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
isDashboardsPage && selected === ws.id
? 'bg-duck-teal/10 text-duck-teal'
: 'text-white/80 hover:bg-duck-dark/5'
}`}
@@ -142,7 +142,7 @@ export const WorkspaceListApp = () => {
>
<LayoutGrid className="h-4 w-4 shrink-0" />
<span className="flex-1 text-left truncate">{ws.name}</span>
{isWorkspacesPage && (
{isDashboardsPage && (
<>
<button
type="button"
@@ -164,7 +164,7 @@ export const WorkspaceListApp = () => {
))}
{filtered.length === 0 && (
<p className="text-xs text-gray-500 px-3 py-4 text-center">
{search ? 'No matches' : 'No workspaces yet'}
{search ? 'No matches' : 'No dashboards yet'}
</p>
)}
</div>
@@ -172,7 +172,7 @@ export const WorkspaceListApp = () => {
<AlertDialog open={deleting !== null} onOpenChange={(open) => { if (!open) setDeleting(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete workspace</AlertDialogTitle>
<AlertDialogTitle>Delete dashboard</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete <strong>{deleting?.name}</strong>? This action cannot be undone.
</AlertDialogDescription>
@@ -5,20 +5,20 @@ 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 { useDashboardState } from 'state/useDashboardState';
import { WorkspaceLayout, WorkspaceView, createDefaultLayout } from '../../components/Workspace';
import type { LayoutNode, WorkspaceDefinition, HomeRoot } from '../../components/Workspace';
import type { LayoutNode, DashboardDefinition, HomeRoot } 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,
SELECTED_DASHBOARD_KEY,
CREATING_DASHBOARD_KEY,
EDITING_DASHBOARD_KEY,
NEW_DASH_NAME_KEY,
NEW_DASH_DESC_KEY,
NEW_DASH_TEMPLATE_KEY,
} from './constants';
// --- Layout Templates ---
@@ -162,7 +162,7 @@ const ThumbnailNode = ({ node }: { node: LayoutNode }) => {
const TemplateCell = ({ index }: { index: number }) => {
const tpl = templates[index]!;
const [selected, setSelected] = useGlobal<number>(NEW_WS_TEMPLATE_KEY, 0);
const [selected, setSelected] = useGlobal<number>(NEW_DASH_TEMPLATE_KEY, 0);
const node = tpl.layout();
const isSelected = selected === index;
@@ -184,7 +184,7 @@ const TemplateCell = ({ index }: { index: number }) => {
);
};
// --- Panel: Template Picker (3x2 workspace) ---
// --- Panel: Template Picker (3x2 layout) ---
const tplPanelLayout: LayoutNode = {
type: 'group',
@@ -231,8 +231,8 @@ const TemplatePanel = () => (
// --- Panel: Name ---
const NamePanel = () => {
const [name, setName] = useGlobal<string>(NEW_WS_NAME_KEY, '');
const [description, setDescription] = useGlobal<string>(NEW_WS_DESC_KEY, '');
const [name, setName] = useGlobal<string>(NEW_DASH_NAME_KEY, '');
const [description, setDescription] = useGlobal<string>(NEW_DASH_DESC_KEY, '');
return (
<div className="h-full w-full">
@@ -246,7 +246,7 @@ const NamePanel = () => {
type="text"
value={name}
onChange={(ev) => setName(ev.target.value)}
placeholder="My Workspace"
placeholder="My Dashboard"
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"
/>
@@ -259,7 +259,7 @@ const NamePanel = () => {
<textarea
value={description}
onChange={(ev) => setDescription(ev.target.value)}
placeholder="What is this workspace for?"
placeholder="What is this dashboard 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>
@@ -274,18 +274,18 @@ const CreatePanel = () => {
const navigate = useNavigate();
const queryClient = useQueryClient();
const client = useClient();
const [name, setName] = useGlobal<string>(NEW_WS_NAME_KEY, '');
const [name, setName] = useGlobal<string>(NEW_DASH_NAME_KEY, '');
const [filePath] = useUserState<string>('files/currentPath', '/');
const [homeRoot] = useUserState<HomeRoot>('files/homeRoot', 'home');
const { value: workspaces, setValue: setWorkspaces } = useWorkspacesState<WorkspaceDefinition[]>('workspaces', []);
const { value: dashboards, setValue: setDashboards } = useDashboardState<DashboardDefinition[]>('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 [description, setDescription] = useGlobal<string>(NEW_DASH_DESC_KEY, '');
const [templateIdx, setTemplateIdx] = useGlobal<number>(NEW_DASH_TEMPLATE_KEY, 0);
const [editingId, setEditingId] = useGlobal<string | null>(EDITING_DASHBOARD_KEY, null);
const [, setCreating] = useGlobal<boolean>(CREATING_DASHBOARD_KEY, false);
const [, setSelected] = useGlobal<string | null>(SELECTED_DASHBOARD_KEY, null);
const isEditing = !!editingId;
@@ -295,13 +295,13 @@ const CreatePanel = () => {
const desc = description.trim();
if (isEditing) {
const existingIds = new Set(workspaces.filter((w) => w.id !== editingId).map((w) => w.id));
const existingIds = new Set(dashboards.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) =>
setDashboards((prev) =>
prev.map((ws) =>
ws.id === editingId
? { ...ws, id: newId, name: trimmed, description: desc || undefined, templateIdx }
@@ -310,31 +310,31 @@ const CreatePanel = () => {
);
const wsLayout = templates[templateIdx]?.layout() ?? createDefaultLayout();
const currentState = queryClient.getQueryData<Record<string, unknown>>(['WORKSPACES_STATE']) ?? {};
const currentState = queryClient.getQueryData<Record<string, unknown>>(['DASHBOARD_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 });
queryClient.setQueryData(['DASHBOARD_STATE'], { ...cleaned, [newLayoutKey]: wsLayout });
const patch: Record<string, unknown> = { [newLayoutKey]: wsLayout };
for (const k of oldKeys) patch[k] = null;
client.patch('/workspaces', patch).catch(() => {});
client.patch('/dashboards', patch).catch(() => {});
} else {
queryClient.setQueryData(['WORKSPACES_STATE'], { ...currentState, [newLayoutKey]: wsLayout });
client.patch('/workspaces', { [newLayoutKey]: wsLayout }).catch(() => {});
queryClient.setQueryData(['DASHBOARD_STATE'], { ...currentState, [newLayoutKey]: wsLayout });
client.patch('/dashboards', { [newLayoutKey]: wsLayout }).catch(() => {});
}
setEditingId(null);
setSelected(newId);
} else {
const existingIds = new Set(workspaces.map((w) => w.id));
const existingIds = new Set(dashboards.map((w) => w.id));
let id = slugify(trimmed) || generateSlug();
while (existingIds.has(id)) id = `${id}-${generateSlug(1)}`;
const ws: WorkspaceDefinition = {
const ws: DashboardDefinition = {
id,
name: trimmed,
cwd,
@@ -345,15 +345,15 @@ const CreatePanel = () => {
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(() => {});
setDashboards((prev) => [...prev, ws]);
const currentState = queryClient.getQueryData<Record<string, unknown>>(['DASHBOARD_STATE']) ?? {};
queryClient.setQueryData(['DASHBOARD_STATE'], { ...currentState, [layoutKey]: wsLayout });
client.patch('/dashboards', { [layoutKey]: wsLayout }).catch(() => {});
setName('');
setDescription('');
setTemplateIdx(0);
navigate(`/workspaces/${ws.id}`);
navigate(`/dashboards/${ws.id}`);
}
};
@@ -371,16 +371,16 @@ const CreatePanel = () => {
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'}
{isEditing ? 'Update Dashboard' : 'Create Dashboard'}
</Button>
{isEditing && (
<Button
onClick={() => navigate(`/workspaces/${editingId}`)}
onClick={() => navigate(`/dashboards/${editingId}`)}
variant="outline"
className="cursor-pointer"
>
<ArrowRight className="h-4 w-4 mr-1" />
Open Workspace
Open Dashboard
</Button>
)}
</div>
@@ -396,9 +396,9 @@ const CwdFileBrowser = () => {
return <FileBrowserApp basePath={basePath} />;
};
// --- New Workspace Layout ---
// --- New Dashboard Layout ---
const newWsLayout: LayoutNode = {
const newDashLayout: LayoutNode = {
type: 'group',
id: 'new-ws-root',
direction: 'vertical',
@@ -430,9 +430,9 @@ const newWsLayout: LayoutNode = {
],
};
const NewWorkspaceForm = () => {
const NewDashboardForm = () => {
const { registry } = useAppRegistry();
const newWsRegistry = {
const newDashRegistry = {
...registry,
'file-browser-cwd': { name: 'File Browser', icon: FolderOpen, component: CwdFileBrowser },
'new-ws-name': { name: 'Name', icon: Type, component: NamePanel },
@@ -440,20 +440,20 @@ const NewWorkspaceForm = () => {
'new-ws-create': { name: 'Create', icon: Rocket, component: CreatePanel },
};
return <WorkspaceLayout layout={newWsLayout} onLayoutChange={() => {}} registry={newWsRegistry} noHeader />;
return <WorkspaceLayout layout={newDashLayout} onLayoutChange={() => {}} registry={newDashRegistry} noHeader />;
};
// --- 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 DashboardPreviewEmpty = () => {
const [creating, setCreating] = useGlobal<boolean>(CREATING_DASHBOARD_KEY, false);
const [editingId] = useGlobal<string | null>(EDITING_DASHBOARD_KEY, null);
const [, setName] = useGlobal<string>(NEW_DASH_NAME_KEY, '');
const [, setDescription] = useGlobal<string>(NEW_DASH_DESC_KEY, '');
const [, setTemplateIdx] = useGlobal<number>(NEW_DASH_TEMPLATE_KEY, 0);
const [, setFilePath] = useUserState<string>('files/currentPath', '/');
if (creating || editingId) return <NewWorkspaceForm />;
if (creating || editingId) return <NewDashboardForm />;
const handleCreate = () => {
setName(generateSlug());
@@ -467,10 +467,10 @@ const WorkspacePreviewEmpty = () => {
<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>
<p className="text-sm text-white/80">Select a dashboard 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
New Dashboard
</Button>
</div>
</div>
@@ -479,26 +479,26 @@ const WorkspacePreviewEmpty = () => {
// --- Preview Inner ---
const WorkspacePreviewInner = ({ workspace }: { workspace: WorkspaceDefinition }) => {
const ws = useWorkspacesState<LayoutNode>(`ws-layout-${workspace.id}`, createDefaultLayout());
const DashboardPreviewInner = ({ dashboard }: { dashboard: DashboardDefinition }) => {
const ws = useDashboardState<LayoutNode>(`ws-layout-${dashboard.id}`, createDefaultLayout());
return <WorkspaceView workspace={ws} cwd={workspace.cwd} root={workspace.root} />;
return <WorkspaceView workspace={ws} cwd={dashboard.cwd} root={dashboard.root} />;
};
// --- 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;
export const DashboardPreview = () => {
const [selectedId] = useGlobal<string | null>(SELECTED_DASHBOARD_KEY, null);
const { value: dashboards } = useDashboardState<DashboardDefinition[]>('workspaces', []);
const dashboard = selectedId ? dashboards.find((ws) => ws.id === selectedId) : null;
if (!workspace) return <WorkspacePreviewEmpty />;
if (!dashboard) return <DashboardPreviewEmpty />;
return (
<div className="relative h-full w-full">
<WorkspacePreviewInner key={workspace.id} workspace={workspace} />
<DashboardPreviewInner key={dashboard.id} dashboard={dashboard} />
<Link
to={`/workspaces/${workspace.id}`}
to={`/dashboards/${dashboard.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" />
@@ -0,0 +1,6 @@
export const SELECTED_DASHBOARD_KEY = 'SELECTED_DASHBOARD';
export const CREATING_DASHBOARD_KEY = 'CREATING_DASHBOARD';
export const EDITING_DASHBOARD_KEY = 'EDITING_DASHBOARD';
export const NEW_DASH_NAME_KEY = 'NEW_DASH_NAME';
export const NEW_DASH_DESC_KEY = 'NEW_DASH_DESC';
export const NEW_DASH_TEMPLATE_KEY = 'NEW_DASH_TEMPLATE';
@@ -0,0 +1,24 @@
import type { AppRegistryMeta } from '../../AppRegistry';
import { LayoutGrid } from 'lucide-react';
import { DashboardListApp } from './DashboardListApp';
import { DashboardPreview } from './DashboardPreview';
export { DashboardListApp };
export { DashboardPreview };
export { SELECTED_DASHBOARD_KEY, CREATING_DASHBOARD_KEY, EDITING_DASHBOARD_KEY, NEW_DASH_NAME_KEY, NEW_DASH_DESC_KEY, NEW_DASH_TEMPLATE_KEY } from './constants';
export const appRegistryMetas: AppRegistryMeta[] = [
{
key: 'dashboard-list',
name: 'Dashboards',
icon: LayoutGrid,
component: DashboardListApp
},
{
key: 'dashboard-preview',
name: 'Dashboard Preview',
icon: LayoutGrid,
component: DashboardPreview,
availableOnPanel: false
},
];
@@ -68,7 +68,7 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => {
handlePlay,
getMatchingTasks,
handleRunTask,
handleCreateWorkspace,
handleCreateDashboard,
fileScrollRef: scrollRef,
} = fileBrowserManager;
const { defaultSort } = fileBrowserManager;
@@ -187,7 +187,7 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => {
onPlay={handlePlay}
matchingTasks={getMatchingTasks(entry.name, entry.type)}
onRunTask={handleRunTask}
onCreateWorkspace={handleCreateWorkspace}
onCreateDashboard={handleCreateDashboard}
/>
);
@@ -52,7 +52,7 @@ export type FileItemProps = {
onPlay: (entry: DirEntry) => void;
matchingTasks: TaskSummary[];
onRunTask: (task: TaskSummary, entry: DirEntry) => void;
onCreateWorkspace: (entry: DirEntry) => void;
onCreateDashboard: (entry: DirEntry) => void;
};
function formatSize(bytes: number): string {
@@ -87,7 +87,7 @@ type MenuItemsProps = {
onCopy: () => void;
matchingTasks: TaskSummary[];
onRunTask: (task: TaskSummary, entry: DirEntry) => void;
onCreateWorkspace: (e: DirEntry) => void;
onCreateDashboard: (e: DirEntry) => void;
};
const DropdownMenuItems = ({
@@ -108,7 +108,7 @@ const DropdownMenuItems = ({
onCopy,
matchingTasks,
onRunTask,
onCreateWorkspace,
onCreateDashboard,
}: MenuItemsProps) => {
const fileType = entry.type === 'file' ? getFileType(entry.name) : null;
const showReadAloud = fileType === 'markdown' || fileType === 'code' || fileType === 'text';
@@ -186,9 +186,9 @@ const DropdownMenuItems = ({
{entry.type === 'directory' && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onCreateWorkspace(entry)} className="cursor-pointer">
<DropdownMenuItem onClick={() => onCreateDashboard(entry)} className="cursor-pointer">
<LayoutGrid className="mr-2 h-4 w-4" />
Create Workspace here
Create Dashboard here
</DropdownMenuItem>
</>
)}
@@ -234,7 +234,7 @@ const ContextMenuItems = ({
onCopy,
matchingTasks,
onRunTask,
onCreateWorkspace,
onCreateDashboard,
}: MenuItemsProps) => {
const fileType = entry.type === 'file' ? getFileType(entry.name) : null;
const showReadAloud = fileType === 'markdown' || fileType === 'code' || fileType === 'text';
@@ -312,9 +312,9 @@ const ContextMenuItems = ({
{entry.type === 'directory' && (
<>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => onCreateWorkspace(entry)} className="cursor-pointer">
<ContextMenuItem onClick={() => onCreateDashboard(entry)} className="cursor-pointer">
<LayoutGrid className="mr-2 h-4 w-4" />
Create Workspace here
Create Dashboard here
</ContextMenuItem>
</>
)}
@@ -367,9 +367,13 @@ const InlineRenameInput = ({
onCancel: () => void;
}) => {
const [value, setValue] = useState(initialName);
const inputRef = useRef<HTMLInputElement | null>(null);
const focused = useRef(false);
const mountRef = (node: HTMLInputElement | null) => {
if (!node) return;
useEffect(() => {
const node = inputRef.current;
if (!node || focused.current) return;
focused.current = true;
requestAnimationFrame(() => {
node.focus();
const dotIndex = initialName.lastIndexOf('.');
@@ -379,7 +383,7 @@ const InlineRenameInput = ({
node.select();
}
});
};
}, []);
const commit = () => {
const trimmed = value.trim();
@@ -392,7 +396,7 @@ const InlineRenameInput = ({
return (
<input
ref={mountRef}
ref={inputRef}
value={value}
onChange={(ev) => setValue(ev.target.value)}
onBlur={commit}
@@ -459,7 +463,7 @@ export const FileItem = ({
onPlay,
matchingTasks,
onRunTask,
onCreateWorkspace,
onCreateDashboard,
}: FileItemProps) => {
const [renaming, setRenaming] = useState(false);
const clickTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -543,7 +547,7 @@ export const FileItem = ({
onCopy,
matchingTasks,
onRunTask,
onCreateWorkspace,
onCreateDashboard,
};
const cutOpacity = isCut ? 'opacity-50' : '';
@@ -1,4 +1,4 @@
import { Loader2, Folder, ClipboardPaste, FolderPlus, LayoutGrid, Download, Upload, ClipboardCopy, MessageSquare } from 'lucide-react';
import { Loader2, Folder, ClipboardPaste, FolderPlus, LayoutGrid, Upload, ClipboardCopy, MessageSquare } from 'lucide-react';
import { getIcon } from 'material-file-icons';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
import type { UseFileBrowserAppType } from '../useFileBrowserApp';
@@ -26,7 +26,7 @@ export const FileViewContainer = ({ fileBrowserManager }: FileViewContainerProps
handleCopyCurrentPath,
handleChatHere,
handleCreateDir,
handleCreateWorkspaceHere,
handleCreateDashboardHere,
setShowVideoDownload,
} = fileBrowserManager;
@@ -120,14 +120,14 @@ export const FileViewContainer = ({ fileBrowserManager }: FileViewContainerProps
<FolderPlus className="mr-2 h-4 w-4" />
New folder
</ContextMenuItem>
<ContextMenuItem onClick={handleCreateWorkspaceHere} className="cursor-pointer">
<ContextMenuItem onClick={handleCreateDashboardHere} className="cursor-pointer">
<LayoutGrid className="mr-2 h-4 w-4" />
Create Workspace here
Create Dashboard here
</ContextMenuItem>
<ContextMenuItem onClick={() => setShowVideoDownload(true)} className="cursor-pointer">
{/* <ContextMenuItem onClick={() => setShowVideoDownload(true)} className="cursor-pointer">
<Download className="mr-2 h-4 w-4" />
Download video
</ContextMenuItem>
</ContextMenuItem> */}
</ContextMenuContent>
</ContextMenu>
)}
@@ -378,9 +378,9 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, initi
setRunningTask({ task, entry });
};
const handleCreateWorkspace = (entry: DirEntry) => {
const handleCreateDashboard = (entry: DirEntry) => {
const folderPath = currentPath === '/' ? `/${entry.name}` : `${currentPath}/${entry.name}`;
navigate(`/workspaces/new?name=${encodeURIComponent(entry.name)}&cwd=${encodeURIComponent(folderPath)}`);
navigate(`/dashboards/new?name=${encodeURIComponent(entry.name)}&cwd=${encodeURIComponent(folderPath)}`);
};
const handleCopyPath = (entry: DirEntry) => {
@@ -402,11 +402,11 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, initi
});
};
const handleCreateWorkspaceHere = () => {
const handleCreateDashboardHere = () => {
const dirName = currentPath === '/' ? '' : currentPath.split('/').pop()!;
const params = new URLSearchParams({ cwd: currentPath });
if (dirName) params.set('name', dirName);
navigate(`/workspaces/new?${params}`);
navigate(`/dashboards/new?${params}`);
};
const handleReadAloud = async (entry: DirEntry) => {
@@ -703,8 +703,8 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, initi
handleDownload,
handleDownloadSelected,
handleRunTask,
handleCreateWorkspace,
handleCreateWorkspaceHere,
handleCreateDashboard,
handleCreateDashboardHere,
handleReadAloud,
handleOcr,
handleTranscribe,
@@ -5,7 +5,7 @@ import { useAuth } from 'hooks/useAuth';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { useWorkspace } from '../../components/Workspace';
import type { ProjectDefinition } from '../../components/Workspace';
import { useWorkspacesState } from 'state/useWorkspacesState';
import { useDashboardState } from 'state/useDashboardState';
import { PreviewContext } from './PreviewContext';
type DevServerResponse = { url: string; port: number };
@@ -17,7 +17,7 @@ export const PreviewProvider = ({ panelId, children }: PreviewProviderProps) =>
const { cwd } = useWorkspace();
const client = useClient();
const { user } = useAuth();
const { value: projects } = useWorkspacesState<ProjectDefinition[]>('projects', []);
const { value: projects } = useDashboardState<ProjectDefinition[]>('projects', []);
const [selectedSlug, setSelectedSlug] = useState<string | null>(null);
const [url, setUrl] = useState<string | null>(null);
const [port, setPort] = useState<number | null>(null);
@@ -5,7 +5,7 @@ import { useQueryClient } from '@tanstack/react-query';
import { useGlobal } from 'hooks/useGlobal';
import { useClient } from 'hooks/useClient';
import { generateSlug } from 'helpers/slug';
import { useWorkspacesState } from 'state/useWorkspacesState';
import { useDashboardState } from 'state/useDashboardState';
import type { ProjectDefinition, ProjectType } from '../../components/Workspace';
import { PublishDialog } from './PublishDialog';
import {
@@ -41,7 +41,7 @@ export const ProjectListApp = () => {
const navigate = useNavigate();
const client = useClient();
const queryClient = useQueryClient();
const { value: projects } = useWorkspacesState<ProjectDefinition[]>('projects', []);
const { value: projects } = useDashboardState<ProjectDefinition[]>('projects', []);
const [selected, setSelected] = useGlobal<string | null>(SELECTED_PROJECT, null);
const [, setCreating] = useGlobal<boolean>(CREATING_PROJECT, false);
const [, setEditing] = useGlobal<string | null>(EDITING_PROJECT, null);
@@ -91,19 +91,19 @@ export const ProjectListApp = () => {
const confirmDelete = () => {
if (!deleting) return;
// Optimistic: remove from cache immediately
const current = queryClient.getQueryData<Record<string, unknown>>(['WORKSPACES_STATE']) ?? {};
const current = queryClient.getQueryData<Record<string, unknown>>(['DASHBOARD_STATE']) ?? {};
const optimistic = { ...current };
optimistic['projects'] = (optimistic['projects'] as ProjectDefinition[]).filter((p) => p.id !== deleting.id);
delete optimistic[`proj-layout-${deleting.id}`];
delete optimistic[`proj-terminals-${deleting.id}`];
delete optimistic[`proj-host-terminals-${deleting.id}`];
queryClient.setQueryData(['WORKSPACES_STATE'], optimistic);
queryClient.setQueryData(['DASHBOARD_STATE'], optimistic);
if (selected === deleting.id) setSelected(null);
setDeleting(null);
client
.patch('/workspaces', { [`proj-meta-${deleting.id}`]: null })
.then((res) => queryClient.setQueryData(['WORKSPACES_STATE'], res))
.patch('/dashboards', { [`proj-meta-${deleting.id}`]: null })
.then((res) => queryClient.setQueryData(['DASHBOARD_STATE'], res))
.catch(() => {});
};
@@ -4,7 +4,7 @@ import { FolderKanban, Plus, ArrowRight, Type, Rocket, FileText, Layout, Loader2
import { useQueryClient } from '@tanstack/react-query';
import { useGlobal } from 'hooks/useGlobal';
import { useClient } from 'hooks/useClient';
import { useWorkspacesState } from 'state/useWorkspacesState';
import { useDashboardState } from 'state/useDashboardState';
import { WorkspaceLayout, WorkspaceView, createDefaultLayout } from '../../components/Workspace';
import type { LayoutNode, ProjectDefinition, ProjectType } from '../../components/Workspace';
import { Button } from '@/components/ui/button';
@@ -373,7 +373,7 @@ const CreatePanel = () => {
const queryClient = useQueryClient();
const client = useClient();
const [name, setName] = useGlobal<string>(NEW_PROJ_NAME, '');
const { value: projects } = useWorkspacesState<ProjectDefinition[]>('projects', []);
const { value: projects } = useDashboardState<ProjectDefinition[]>('projects', []);
const [description, setDescription] = useGlobal<string>(NEW_PROJ_DESC, '');
const [templateIdx, setTemplateIdx] = useGlobal<number>(NEW_PROJ_TEMPLATE, 0);
@@ -405,8 +405,8 @@ const CreatePanel = () => {
if (isEditing) {
client
.patch('/workspaces', { [`proj-meta-${editingId}`]: meta, [`proj-layout-${editingId}`]: previewLayout })
.then((res) => queryClient.setQueryData(['WORKSPACES_STATE'], res))
.patch('/dashboards', { [`proj-meta-${editingId}`]: meta, [`proj-layout-${editingId}`]: previewLayout })
.then((res) => queryClient.setQueryData(['DASHBOARD_STATE'], res))
.catch(() => {});
setEditingId(null);
@@ -422,8 +422,8 @@ const CreatePanel = () => {
: previewLayout;
try {
const res = await client.patch('/workspaces', { [`proj-meta-${id}`]: meta, [`proj-layout-${id}`]: finalLayout });
queryClient.setQueryData(['WORKSPACES_STATE'], res);
const res = await client.patch('/dashboards', { [`proj-meta-${id}`]: meta, [`proj-layout-${id}`]: finalLayout });
queryClient.setQueryData(['DASHBOARD_STATE'], res);
setName('');
setDescription('');
setTemplateIdx(0);
@@ -576,13 +576,13 @@ const ProjectPreviewEmpty = () => {
};
const ProjectPreviewInner = ({ project }: { project: ProjectDefinition }) => {
const ws = useWorkspacesState<LayoutNode>(`proj-layout-${project.id}`, createDefaultLayout());
const ws = useDashboardState<LayoutNode>(`proj-layout-${project.id}`, createDefaultLayout());
return <WorkspaceView workspace={ws} cwd={project.cwd} />;
};
export const ProjectPreview = () => {
const [selectedId] = useGlobal<string | null>(SELECTED_PROJECT, null);
const { value: projects } = useWorkspacesState<ProjectDefinition[]>('projects', []);
const { value: projects } = useDashboardState<ProjectDefinition[]>('projects', []);
const project = selectedId ? projects.find((p) => p.id === selectedId) : null;
if (!project) return <ProjectPreviewEmpty />;
@@ -24,7 +24,6 @@ export const appRegistryMetas: AppRegistryMeta[] = [
name: 'Projects',
icon: FolderKanban,
component: ProjectListApp,
availableOnPanel: false,
},
{
key: 'project-preview',
@@ -1,6 +1,6 @@
import { useEffect, useRef } from 'react';
import { useWorkspace } from '../../components/Workspace';
import { useWorkspacesState } from 'state/useWorkspacesState';
import { useDashboardState } from 'state/useDashboardState';
import { TerminalView } from './Terminal';
const EMPTY_TERMINALS: Record<string, string> = {};
@@ -12,9 +12,9 @@ type CommandTerminalWrapperProps = {
};
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 { dashboardId, cwd } = useWorkspace();
const stateKey = dashboardId ? `ws-${statePrefix}-${dashboardId}` : `ws-${statePrefix}-default`;
const { value: terminals, setValue: setTerminals } = useDashboardState<Record<string, string>>(stateKey, EMPTY_TERMINALS);
const setTerminalsRef = useRef(setTerminals);
setTerminalsRef.current = setTerminals;
@@ -1,16 +1,16 @@
import { useEffect, useRef } from 'react';
import { useWorkspace } from '../../components/Workspace';
import { useAuth } from 'hooks/useAuth';
import { useWorkspacesState } from 'state/useWorkspacesState';
import { useDashboardState } from 'state/useDashboardState';
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 { dashboardId, cwd } = useWorkspace();
const stateKey = dashboardId ? `ws-host-terminals-${dashboardId}` : 'ws-host-terminals-default';
const { value: terminals, setValue: setTerminals } = useDashboardState<Record<string, string>>(stateKey, EMPTY_TERMINALS);
const setTerminalsRef = useRef(setTerminals);
setTerminalsRef.current = setTerminals;
@@ -1,18 +1,18 @@
import { useEffect, useRef } from 'react';
import { useWorkspace } from '../../components/Workspace';
import { useWorkspacesState } from 'state/useWorkspacesState';
import { useDashboardState } from 'state/useDashboardState';
import { TerminalView } from './Terminal';
import { useTerminalMode } from './useTerminalMode';
const EMPTY_TERMINALS: Record<string, string> = {};
export const TerminalWrapper = ({ panelId }: { panelId: string }) => {
const { workspaceId, cwd, root } = useWorkspace();
const { dashboardId, cwd, root } = useWorkspace();
const { mode } = useTerminalMode(panelId);
const hostRoot = root === '~' || root === 'officer.dev';
const sandboxed = !hostRoot && (cwd !== '~' || 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 stateKey = dashboardId ? `ws-terminals-${mode}-${dashboardId}` : `ws-terminals-${mode}-default`;
const { value: terminals, setValue: setTerminals } = useDashboardState<Record<string, string>>(stateKey, EMPTY_TERMINALS);
const setTerminalsRef = useRef(setTerminals);
setTerminalsRef.current = setTerminals;
@@ -1,6 +0,0 @@
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';
@@ -1,25 +0,0 @@
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
},
];
@@ -8,7 +8,7 @@ export type DefaultFileSort = {
};
type WorkspaceContextValue = {
workspaceId: string | null;
dashboardId: string | null;
cwd: string;
root?: string;
initialFilePath?: string;
@@ -30,7 +30,7 @@ type WorkspaceContextValue = {
const noop = () => {};
const WorkspaceContext = createContext<WorkspaceContextValue>({
workspaceId: null,
dashboardId: null,
cwd: '~',
swapSourceId: null,
setSwapSourceId: noop,
@@ -10,7 +10,7 @@ type WorkspaceLayoutProps = {
onLayoutChange: (layout: LayoutNode) => void;
registry?: AppRegistry;
components?: PanelComponents;
workspaceId?: string;
dashboardId?: string;
cwd?: string;
promptPrefix?: string;
noHeader?: boolean;
@@ -21,7 +21,7 @@ type WorkspaceLayoutProps = {
const noop = () => {};
export const WorkspaceLayout = ({ layout, onLayoutChange, registry: registryProp, components, workspaceId, cwd, promptPrefix, noHeader, isMobile, mobilePanelId, onMobileBack }: WorkspaceLayoutProps) => {
export const WorkspaceLayout = ({ layout, onLayoutChange, registry: registryProp, components, dashboardId, cwd, promptPrefix, noHeader, isMobile, mobilePanelId, onMobileBack }: WorkspaceLayoutProps) => {
const { registry: globalRegistry } = useAppRegistry();
const registry = registryProp ?? globalRegistry;
@@ -33,7 +33,7 @@ export const WorkspaceLayout = ({ layout, onLayoutChange, registry: registryProp
);
return (
<WorkspaceProvider value={{ workspaceId: workspaceId ?? null, cwd: cwd ?? '~', promptPrefix, swapSourceId: null, setSwapSourceId: noop, onSwap: noop, dragSourceId: null, setDragSourceId: noop, onMove: noop, maximizedPanelId: null, setMaximizedPanelId: noop, transitioningPanelId: null, isMobile: isMobile ?? false, onMobileBack: onMobileBack ?? null }}>
<WorkspaceProvider value={{ dashboardId: dashboardId ?? null, cwd: cwd ?? '~', promptPrefix, swapSourceId: null, setSwapSourceId: noop, onSwap: noop, dragSourceId: null, setDragSourceId: noop, onMove: noop, maximizedPanelId: null, setMaximizedPanelId: noop, transitioningPanelId: null, isMobile: isMobile ?? false, onMobileBack: onMobileBack ?? null }}>
<WorkspaceRenderer
layout={layout}
registry={registry}
@@ -2,7 +2,7 @@ import { useState, useCallback, useEffect, useRef, useMemo, type ComponentRef }
import { flushSync } from 'react-dom';
import { ResizablePanel, ResizablePanelGroup, ResizableHandle } from '@/components/ui/resizable';
import { useIsMobile } from 'hooks/useIsMobile';
import type { LayoutNode, WorkspaceState, EphemeralPanels, PanelComponents } from './types';
import type { LayoutNode, DashboardState, EphemeralPanels, PanelComponents } from './types';
import type { DefaultFileSort } from './WorkspaceContext';
import type { DropPosition } from './layout-utils';
import { splitPanel, removePanel, setApp, updateSizes, swapPanels, movePanel, countPanels } from './layout-utils';
@@ -11,7 +11,7 @@ import { WorkspaceRenderer } from './WorkspaceRenderer';
import { useAppRegistry } from '../../AppRegistry/useAppRegistry';
type WorkspaceViewProps = {
workspace: WorkspaceState;
workspace: DashboardState;
locked?: boolean;
cwd?: string;
root?: string;
@@ -143,7 +143,7 @@ export const WorkspaceView = ({ workspace, locked, cwd = '~', root, initialFileP
return (
<WorkspaceProvider
value={{
workspaceId: workspace.key,
dashboardId: workspace.key,
cwd,
root,
initialFilePath,
@@ -1,4 +1,4 @@
export type { LayoutNode, LayoutGroup, LayoutPanel, WorkspaceDefinition, WorkspaceState, ProjectType, ProjectDefinition, AppRegistry, AppRegistryEntry, PanelComponents, PanelComponentEntry, EphemeralPanels, HomeRoot } from './types';
export type { LayoutNode, LayoutGroup, LayoutPanel, DashboardDefinition, DashboardState, ProjectType, ProjectDefinition, AppRegistry, AppRegistryEntry, PanelComponents, PanelComponentEntry, EphemeralPanels, HomeRoot } from './types';
export type { DropPosition } from './layout-utils';
export { createDefaultLayout, splitPanel, removePanel, setApp, updateSizes, swapPanels, movePanel, pruneEmptyPanels, countPanels, hasAnyApp } from './layout-utils';
export type { DefaultFileSort } from './WorkspaceContext';
@@ -19,7 +19,7 @@ export type LayoutNode = LayoutGroup | LayoutPanel;
export type HomeRoot = 'home' | '~' | 'officer.dev';
export type WorkspaceDefinition = {
export type DashboardDefinition = {
id: string;
name: string;
cwd: string;
@@ -28,7 +28,7 @@ export type WorkspaceDefinition = {
templateIdx?: number;
};
export type WorkspaceState = {
export type DashboardState = {
key: string;
value: LayoutNode;
setValue: (value: LayoutNode | ((prev: LayoutNode) => LayoutNode)) => void;
@@ -1,6 +1,7 @@
import type { ReactNode } from 'react';
import { type ReactNode, useCallback } from 'react';
import { useSearchParams } from 'react-router';
import { useUserState } from 'state/useUserState';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { FileViewerProvider } from '../../apps/FileViewer';
import { EmbeddableChat } from '../../apps/Chat/EmbeddableChat';
@@ -66,6 +67,7 @@ export function Ephemeral2Provider({ children }: { children: ReactNode }) {
export const ChatEphemeralBody = () => {
const [searchParams] = useSearchParams();
const [homeRoot] = useUserState<string>('files/homeRoot', 'home');
const [, setRefreshSignal] = usePanelChannel<number>('files:refresh-signal', 0);
const chatContext = searchParams.get('chatContext') ?? '';
const chatType = searchParams.get('chatType') as 'file' | 'folder' | null;
const hostRoot = homeRoot === '~' || homeRoot === 'officer.dev';
@@ -81,12 +83,17 @@ export const ChatEphemeralBody = () => {
? `[${tag}: ${path}] Let's talk about this file`
: `[${tag}: ${path || '/'}] consider, for this session, this directory as your current working directory`;
const handleMessageComplete = useCallback(() => {
setRefreshSignal((n) => n + 1);
}, [setRefreshSignal]);
return (
<EmbeddableChat
className="h-full"
cwd={{ root: homeRoot, path: cwdPath.replace(/^\//, '') || '/' }}
sandboxed={sandboxed}
defaultInput={message}
onMessageComplete={handleMessageComplete}
/>
);
};
+2 -2
View File
@@ -15,7 +15,7 @@ export { FileViewerView, FileViewerProvider, FileViewerHeader, FileViewerBody, F
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';
export { DashboardListApp, DashboardPreview, SELECTED_DASHBOARD_KEY, CREATING_DASHBOARD_KEY, EDITING_DASHBOARD_KEY, NEW_DASH_NAME_KEY, NEW_DASH_DESC_KEY, NEW_DASH_TEMPLATE_KEY } from './apps/Dashboards';
export { ProjectListApp, ProjectPreview, SELECTED_PROJECT, CREATING_PROJECT, EDITING_PROJECT, NEW_PROJ_NAME, NEW_PROJ_DESC, NEW_PROJ_TEMPLATE, NEW_PROJ_TYPE, NEW_PROJ_HAS_BACKEND, NEW_PROJ_HAS_AUTH, NEW_PROJ_PREVIEW_LAYOUT } from './apps/Projects';
export { createUserAppPanel, createUserAppHeader } from './apps/UserApp';
export { resolveIcon, availableIconNames } from './utils/resolve-icon';
@@ -23,4 +23,4 @@ export { resolveIcon, availableIconNames } from './utils/resolve-icon';
// Workspace
export { WorkspaceView, WorkspaceLayout, WorkspaceProvider, useWorkspace } from './components/Workspace';
export { createDefaultLayout, splitPanel, removePanel, setApp, updateSizes, swapPanels, movePanel, pruneEmptyPanels, countPanels, hasAnyApp } from './components/Workspace';
export type { LayoutNode, LayoutGroup, LayoutPanel, WorkspaceDefinition, WorkspaceState, ProjectType, ProjectDefinition, AppRegistry, AppRegistryEntry, PanelComponents, PanelComponentEntry, EphemeralPanels, DropPosition, HomeRoot, DefaultFileSort } from './components/Workspace';
export type { LayoutNode, LayoutGroup, LayoutPanel, DashboardDefinition, DashboardState, ProjectType, ProjectDefinition, AppRegistry, AppRegistryEntry, PanelComponents, PanelComponentEntry, EphemeralPanels, DropPosition, HomeRoot, DefaultFileSort } from './components/Workspace';
+1 -1
View File
@@ -1,7 +1,7 @@
export { useSettings, DEFAULT_SETTINGS } from './useSettings';
export type { UseSettingsType, UserSettings, UserState } from './useSettings';
export { useUserState } from './useUserState';
export { useWorkspacesState } from './useWorkspacesState';
export { useDashboardState } from './useDashboardState';
export { usePiModels, useVisiblePiModels, useEnabledPiModels, modelKey } from './useModels';
export type { ModelOption } from './useModels';
export { useAccessPolicy } from './useAccessPolicy';
@@ -4,9 +4,9 @@ import { useClient } from 'hooks/useClient';
import { useAuth } from 'hooks/useAuth';
import type { UserState } from './useSettings';
const QUERY_KEY = ['WORKSPACES_STATE'];
const QUERY_KEY = ['DASHBOARD_STATE'];
export function useWorkspacesState<T>(key: string, defaultValue: T) {
export function useDashboardState<T>(key: string, defaultValue: T) {
const client = useClient();
const { isAuthenticated } = useAuth();
const queryClient = useQueryClient();
@@ -16,7 +16,7 @@ export function useWorkspacesState<T>(key: string, defaultValue: T) {
const { data: state = {}, isSuccess } = useQuery<UserState>({
queryKey: QUERY_KEY,
enabled: isAuthenticated,
queryFn: () => client.get<UserState>('/workspaces'),
queryFn: () => client.get<UserState>('/dashboards'),
staleTime: Infinity,
});
@@ -29,7 +29,7 @@ export function useWorkspacesState<T>(key: string, defaultValue: T) {
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(() => { });
clientRef.current.patch('/dashboards', { [key]: defaultValue }).catch(() => { });
}
}, [isSuccess, key, defaultValue, queryClient]);
@@ -43,7 +43,7 @@ export function useWorkspacesState<T>(key: string, defaultValue: T) {
queryClient.setQueryData(QUERY_KEY, { ...currentState, [key]: newValue });
clientRef.current.patch('/workspaces', { [key]: newValue }).catch(() => { });
clientRef.current.patch('/dashboards', { [key]: newValue }).catch(() => { });
},
[key, defaultValue, queryClient],
);