/workspaces refactor
This commit is contained in:
@@ -48,13 +48,14 @@ export function App() {
|
||||
<Route path="/chat/opencode/:sessionId" element={<Dashboard.SessionListPage provider="opencode" />} />
|
||||
<Route path="/plans" element={<Dashboard.Plans />} />
|
||||
<Route path="/files" element={<Dashboard.FilesPage />} />
|
||||
<Route path="/terminal" element={<Dashboard.Terminal />} />
|
||||
|
||||
<Route path="/code-editor" element={<Dashboard.CodeEditor />} />
|
||||
<Route path="/skills" element={<Dashboard.Skills />} />
|
||||
<Route path="/tasks" element={<Dashboard.Tasks />} />
|
||||
<Route path="/processes" element={<Dashboard.Processes />} />
|
||||
<Route path="/task-logs" element={<Dashboard.TaskLogs />} />
|
||||
<Route path="/workspaces" element={<Dashboard.WorkspaceListScreen />} />
|
||||
<Route path="/workspaces/new" element={<Dashboard.NewWorkspaceRedirect />} />
|
||||
<Route path="/workspaces/:id" element={<Dashboard.WorkspaceScreen />} />
|
||||
<Route path="/auth/signout" element={<Authentication.SignoutScreen />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
||||
@@ -109,12 +109,12 @@ export const Dock = ({ items, className }: DockProps) => {
|
||||
};
|
||||
|
||||
|
||||
import { MessageCircle, TerminalSquare, FileText, FolderOpen, Code, LayoutGrid, Bot, ScrollText } from 'lucide-react';
|
||||
import { MessageCircle, FileText, FolderOpen, Code, LayoutGrid, Bot, ScrollText } from 'lucide-react';
|
||||
|
||||
export const dockItems: DockItem[] = [
|
||||
{ label: 'Files', to: '/files', icon: FolderOpen, color: '#fbbf24' },
|
||||
{ label: 'Chat', to: '/chat', icon: MessageCircle, color: '#60a5fa' },
|
||||
{ label: 'Terminal', to: '/terminal', icon: TerminalSquare, color: '#34d399' },
|
||||
|
||||
{ label: 'Editor', to: '/code-editor', icon: Code, color: '#a78bfa' },
|
||||
{ label: 'Plans', to: '/plans', icon: FileText, color: '#f472b6' },
|
||||
{ label: 'Automation', to: '/automation', icon: Bot, color: '#2dd4bf' },
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useServerSettings } from '@/state/useServerSettings';
|
||||
|
||||
export const TerminalSection = () => {
|
||||
const { terminalSandboxed, saveSettings } = useServerSettings();
|
||||
|
||||
const toggleSandbox = (checked: boolean) => {
|
||||
saveSettings({ terminalSandboxed: checked });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-duck-dark">Sandbox terminal (Docker)</div>
|
||||
<div className="text-xs text-duck-dark/50">Restrict terminal access to the user's home directory.</div>
|
||||
</div>
|
||||
<Switch checked={terminalSandboxed === true} onCheckedChange={toggleSandbox} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,17 +1,15 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Terminal, Shield, Server } from 'lucide-react';
|
||||
import { Terminal, Server } from 'lucide-react';
|
||||
import type { LayoutNode, PanelComponents } from '@/components/Workspace';
|
||||
import { WorkspaceLayout } from '@/components/Workspace';
|
||||
import { appRegistry } from '../../Workspaces/app-registry';
|
||||
import { createSettingsPanelComponents, type SettingsSection } from '../SettingsPanel';
|
||||
import { AIHarnessesSection } from './AIHarnessesSection';
|
||||
import { TerminalSection } from './TerminalSection';
|
||||
|
||||
const GLOBAL_KEY = 'SERVER_SETTINGS_SELECTED';
|
||||
|
||||
const sections: SettingsSection[] = [
|
||||
{ key: 'ai-harnesses', icon: Terminal, title: 'AI Harnesses', description: 'AI coding tools setup', content: <AIHarnessesSection /> },
|
||||
{ key: 'terminal', icon: Shield, title: 'Terminal', description: 'Sandbox and access controls', content: <TerminalSection /> },
|
||||
];
|
||||
|
||||
const { Sidebar, Content } = createSettingsPanelComponents({
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { TerminalView } from 'apps/Terminal';
|
||||
|
||||
export const Terminal = () => {
|
||||
return <TerminalView className="h-full w-full p-2" />;
|
||||
};
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router';
|
||||
import { LayoutGrid, Plus, Pencil, Trash2 } from 'lucide-react';
|
||||
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 { useUserState } from '@/state/useUserState';
|
||||
import { generateSlug } from 'helpers/slug';
|
||||
import { useWorkspacesState } from '@/state/useWorkspacesState';
|
||||
import type { WorkspaceDefinition } from '@/components/Workspace';
|
||||
import {
|
||||
SELECTED_WORKSPACE_KEY,
|
||||
@@ -19,7 +21,7 @@ export const WorkspaceListApp = () => {
|
||||
const navigate = useNavigate();
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
const [workspaces, setWorkspaces] = useUserState<WorkspaceDefinition[]>('workspaces', []);
|
||||
const [workspaces, 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);
|
||||
@@ -27,7 +29,14 @@ export const WorkspaceListApp = () => {
|
||||
const [, setDescription] = useGlobal<string>(NEW_WS_DESC_KEY, '');
|
||||
const [, setTemplateIdx] = useGlobal<number>(NEW_WS_TEMPLATE_KEY, 0);
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
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();
|
||||
@@ -45,10 +54,10 @@ export const WorkspaceListApp = () => {
|
||||
if (selected === ws.id) setSelected(null);
|
||||
|
||||
const layoutKey = `ws-layout-${ws.id}`;
|
||||
const currentState = queryClient.getQueryData<Record<string, unknown>>(['USER_STATE']) ?? {};
|
||||
const currentState = queryClient.getQueryData<Record<string, unknown>>(['WORKSPACES_STATE']) ?? {};
|
||||
const { [layoutKey]: _, ...rest } = currentState;
|
||||
queryClient.setQueryData(['USER_STATE'], rest);
|
||||
client.patch('/user/state', { [layoutKey]: null }).catch(() => {});
|
||||
queryClient.setQueryData(['WORKSPACES_STATE'], rest);
|
||||
client.patch('/user/workspaces-state', { [layoutKey]: null }).catch(() => {});
|
||||
};
|
||||
|
||||
const handleClick = (ws: WorkspaceDefinition) => {
|
||||
@@ -63,7 +72,7 @@ export const WorkspaceListApp = () => {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-y-auto">
|
||||
<div className="p-3 pb-0">
|
||||
<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"
|
||||
@@ -71,15 +80,41 @@ export const WorkspaceListApp = () => {
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
Workspaces
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelected(null);
|
||||
setEditing(null);
|
||||
setName(generateSlug());
|
||||
setDescription('');
|
||||
setTemplateIdx(0);
|
||||
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="flex flex-col gap-0.5 px-3 pt-3">
|
||||
{workspaces.map((ws) => (
|
||||
<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-duck-dark/60 hover:bg-duck-dark/5'
|
||||
: 'text-white/80 hover:bg-duck-dark/5'
|
||||
}`}
|
||||
onClick={() => handleClick(ws)}
|
||||
>
|
||||
@@ -105,22 +140,10 @@ export const WorkspaceListApp = () => {
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{workspaces.length === 0 && (
|
||||
<p className="text-xs text-duck-dark/40 px-3 py-4 text-center">No workspaces yet</p>
|
||||
)}
|
||||
{isWorkspacesPage && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelected(null);
|
||||
setEditing(null);
|
||||
setCreating(true);
|
||||
}}
|
||||
className="flex items-center gap-2.5 py-2 px-3 rounded-lg text-sm font-medium text-duck-dark/40 hover:bg-duck-dark/5 hover:text-duck-dark transition-all cursor-pointer"
|
||||
>
|
||||
<Plus className="h-4 w-4 shrink-0" />
|
||||
<span className="flex-1 text-left">New workspace</span>
|
||||
</button>
|
||||
{filtered.length === 0 && (
|
||||
<p className="text-xs text-gray-500 px-3 py-4 text-center">
|
||||
{search ? 'No matches' : 'No workspaces yet'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
import { Link, Navigate, useNavigate, useSearchParams } from 'react-router';
|
||||
import { 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 { appRegistry } from './app-registry';
|
||||
import { SELECTED_WORKSPACE_KEY, CREATING_WORKSPACE_KEY, EDITING_WORKSPACE_KEY, NEW_WS_NAME_KEY, NEW_WS_DESC_KEY, NEW_WS_TEMPLATE_KEY } from './constants';
|
||||
|
||||
@@ -275,7 +277,7 @@ const CreatePanel = () => {
|
||||
const client = useClient();
|
||||
const [name, setName] = useGlobal<string>(NEW_WS_NAME_KEY, '');
|
||||
const [filePath] = useUserState<string>('files/currentPath', '/');
|
||||
const [, setWorkspaces] = useUserState<WorkspaceDefinition[]>('workspaces', []);
|
||||
const [workspaces, setWorkspaces] = useWorkspacesState<WorkspaceDefinition[]>('workspaces', []);
|
||||
|
||||
const cwd = filePath === '/' ? '~' : `~/${filePath.replace(/^\//, '')}`;
|
||||
|
||||
@@ -293,22 +295,49 @@ const CreatePanel = () => {
|
||||
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, name: trimmed, description: desc || undefined, templateIdx }
|
||||
? { ...ws, id: newId, name: trimmed, description: desc || undefined, templateIdx }
|
||||
: ws,
|
||||
),
|
||||
);
|
||||
|
||||
const wsLayout = templates[templateIdx]?.layout() ?? createDefaultLayout();
|
||||
const layoutKey = `ws-layout-${editingId}`;
|
||||
const currentState = queryClient.getQueryData<Record<string, unknown>>(['USER_STATE']) ?? {};
|
||||
queryClient.setQueryData(['USER_STATE'], { ...currentState, [layoutKey]: wsLayout });
|
||||
client.patch('/user/state', { [layoutKey]: wsLayout }).catch(() => {});
|
||||
const currentState = queryClient.getQueryData<Record<string, unknown>>(['WORKSPACES_STATE']) ?? {};
|
||||
const newLayoutKey = `ws-layout-${newId}`;
|
||||
|
||||
if (idChanged) {
|
||||
// Remove old keys from cache
|
||||
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 });
|
||||
|
||||
// Delete old files, write new layout
|
||||
const patch: Record<string, unknown> = { [newLayoutKey]: wsLayout };
|
||||
for (const k of oldKeys) patch[k] = null;
|
||||
client.patch('/user/workspaces-state', patch).catch(() => {});
|
||||
} else {
|
||||
queryClient.setQueryData(['WORKSPACES_STATE'], { ...currentState, [newLayoutKey]: wsLayout });
|
||||
client.patch('/user/workspaces-state', { [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: `ws-${Date.now()}`,
|
||||
id,
|
||||
name: trimmed,
|
||||
cwd,
|
||||
description: desc || undefined,
|
||||
@@ -318,9 +347,9 @@ const CreatePanel = () => {
|
||||
const layoutKey = `ws-layout-${ws.id}`;
|
||||
|
||||
setWorkspaces((prev) => [...prev, ws]);
|
||||
const currentState = queryClient.getQueryData<Record<string, unknown>>(['USER_STATE']) ?? {};
|
||||
queryClient.setQueryData(['USER_STATE'], { ...currentState, [layoutKey]: wsLayout });
|
||||
client.patch('/user/state', { [layoutKey]: wsLayout }).catch(() => {});
|
||||
const currentState = queryClient.getQueryData<Record<string, unknown>>(['WORKSPACES_STATE']) ?? {};
|
||||
queryClient.setQueryData(['WORKSPACES_STATE'], { ...currentState, [layoutKey]: wsLayout });
|
||||
client.patch('/user/workspaces-state', { [layoutKey]: wsLayout }).catch(() => {});
|
||||
|
||||
setName('');
|
||||
setDescription('');
|
||||
@@ -408,15 +437,25 @@ const NewWorkspaceForm = () => (
|
||||
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);
|
||||
|
||||
if (creating || editingId) return <NewWorkspaceForm />;
|
||||
|
||||
const handleCreate = () => {
|
||||
setName(generateSlug());
|
||||
setDescription('');
|
||||
setTemplateIdx(0);
|
||||
setCreating(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-4 text-duck-dark/30">
|
||||
<LayoutGrid className="h-10 w-10" />
|
||||
<p className="text-sm">Select a workspace or create a new one</p>
|
||||
<Button onClick={() => setCreating(true)} className="bg-duck-teal hover:bg-duck-teal/90 cursor-pointer">
|
||||
<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>
|
||||
@@ -426,14 +465,14 @@ const WorkspacePreviewEmpty = () => {
|
||||
};
|
||||
|
||||
const WorkspacePreviewInner = ({ workspace }: { workspace: WorkspaceDefinition }) => {
|
||||
const [layout, setLayout] = useUserState<LayoutNode>(`ws-layout-${workspace.id}`, createDefaultLayout());
|
||||
const [layout, setLayout] = useWorkspacesState<LayoutNode>(`ws-layout-${workspace.id}`, createDefaultLayout());
|
||||
|
||||
return <WorkspaceView workspace={workspace} layout={layout} onLayoutChange={setLayout} registry={appRegistry} />;
|
||||
};
|
||||
|
||||
const WorkspacePreview = () => {
|
||||
const [selectedId] = useGlobal<string | null>(SELECTED_WORKSPACE_KEY, null);
|
||||
const [workspaces] = useUserState<WorkspaceDefinition[]>('workspaces', []);
|
||||
const [workspaces] = useWorkspacesState<WorkspaceDefinition[]>('workspaces', []);
|
||||
const workspace = selectedId ? workspaces.find((ws) => ws.id === selectedId) : null;
|
||||
|
||||
if (!workspace) return <WorkspacePreviewEmpty />;
|
||||
@@ -454,7 +493,7 @@ const WorkspacePreview = () => {
|
||||
const registry = { ...appRegistry, 'workspace-preview': { name: 'Workspace Preview', icon: LayoutGrid, component: WorkspacePreview } };
|
||||
|
||||
export const WorkspaceListScreen = () => {
|
||||
const [layout, setLayout, isLoaded] = useUserState<LayoutNode>('ws-layout-workspaces', defaultLayout);
|
||||
const [layout, setLayout, isLoaded] = useWorkspacesState<LayoutNode>('ws-layout-ws-homepage', defaultLayout);
|
||||
|
||||
if (!isLoaded) return null;
|
||||
|
||||
@@ -464,3 +503,27 @@ export const WorkspaceListScreen = () => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const NewWorkspaceRedirect = () => {
|
||||
const [params] = useSearchParams();
|
||||
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 [, setCreating] = useGlobal<boolean>(CREATING_WORKSPACE_KEY, false);
|
||||
const [, setEditing] = useGlobal<string | null>(EDITING_WORKSPACE_KEY, null);
|
||||
const [, setSelected] = useGlobal<string | null>(SELECTED_WORKSPACE_KEY, null);
|
||||
const [, setFilePath] = useUserState<string>('files/currentPath', '/');
|
||||
|
||||
const name = params.get('name');
|
||||
const cwd = params.get('cwd');
|
||||
|
||||
setSelected(null);
|
||||
setEditing(null);
|
||||
setName(name || generateSlug());
|
||||
setDescription('');
|
||||
setTemplateIdx(0);
|
||||
setCreating(true);
|
||||
if (cwd) setFilePath(cwd);
|
||||
|
||||
return <Navigate to="/workspaces" replace />;
|
||||
};
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useParams, Navigate } from 'react-router';
|
||||
import { useUserState } from '@/state/useUserState';
|
||||
import { useWorkspacesState } from '@/state/useWorkspacesState';
|
||||
import { WorkspaceView, createDefaultLayout } from '@/components/Workspace';
|
||||
import type { LayoutNode, WorkspaceDefinition } from '@/components/Workspace';
|
||||
import { appRegistry } from './app-registry';
|
||||
|
||||
export const WorkspaceScreen = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [workspaces, , isLoaded] = useUserState<WorkspaceDefinition[]>('workspaces', []);
|
||||
const [workspaces, , isLoaded] = useWorkspacesState<WorkspaceDefinition[]>('workspaces', []);
|
||||
const workspace = workspaces.find((ws) => ws.id === id);
|
||||
|
||||
if (!isLoaded) return null;
|
||||
@@ -16,7 +16,7 @@ export const WorkspaceScreen = () => {
|
||||
};
|
||||
|
||||
const WorkspaceScreenInner = ({ workspace }: { workspace: WorkspaceDefinition }) => {
|
||||
const [layout, setLayout] = useUserState<LayoutNode>(`ws-layout-${workspace.id}`, createDefaultLayout());
|
||||
const [layout, setLayout] = useWorkspacesState<LayoutNode>(`ws-layout-${workspace.id}`, createDefaultLayout());
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useState } from 'react';
|
||||
import { MessageSquare, FolderOpen, History, Music, Code, TerminalSquare, LayoutGrid, LayoutDashboard, Sparkles } from 'lucide-react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { MessageSquare, FolderOpen, History, Music, Code, TerminalSquare, Monitor, LayoutGrid, LayoutDashboard, Sparkles } from 'lucide-react';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import type { AppRegistry } from '@/components/Workspace';
|
||||
import { useWorkspace } from '@/components/Workspace';
|
||||
import { CodeEditorView } from 'apps/CodeEditor';
|
||||
import { TerminalView } from 'apps/Terminal';
|
||||
import { useWorkspacesState } from '@/state/useWorkspacesState';
|
||||
import { useClaude } from '../Chat/useClaude';
|
||||
import { useOpenCode } from '../Chat/useOpenCode';
|
||||
import { ChatPanel } from '../Chat/ChatPanel';
|
||||
@@ -38,7 +41,50 @@ const OpenCodeChatWidget = ({ onProviderChange }: { onProviderChange: (p: 'claud
|
||||
|
||||
const CodeEditorWrapper = () => <CodeEditorView className="h-full w-full" />;
|
||||
|
||||
const TerminalWrapper = () => <TerminalView className="h-full w-full p-2" />;
|
||||
const TerminalWrapper = ({ panelId }: { panelId: string }) => {
|
||||
const { workspaceId } = useWorkspace();
|
||||
const stateKey = workspaceId ? `ws-terminals-${workspaceId}` : 'ws-terminals-default';
|
||||
const [terminals, setTerminals] = useWorkspacesState<Record<string, string>>(stateKey, {});
|
||||
|
||||
const sessionId = terminals[panelId];
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) {
|
||||
setTerminals((prev) => ({ ...prev, [panelId]: crypto.randomUUID() }));
|
||||
}
|
||||
}, [panelId, sessionId, setTerminals]);
|
||||
|
||||
if (!sessionId) return null;
|
||||
|
||||
return <TerminalView className="h-full w-full p-2" sessionId={sessionId} />;
|
||||
};
|
||||
|
||||
const HostTerminalWrapper = ({ panelId }: { panelId: string }) => {
|
||||
const { user } = useAuth();
|
||||
const { workspaceId } = useWorkspace();
|
||||
const stateKey = workspaceId ? `ws-host-terminals-${workspaceId}` : 'ws-host-terminals-default';
|
||||
const [terminals, setTerminals] = useWorkspacesState<Record<string, string>>(stateKey, {});
|
||||
|
||||
const sessionId = terminals[panelId];
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) {
|
||||
setTerminals((prev) => ({ ...prev, [panelId]: crypto.randomUUID() }));
|
||||
}
|
||||
}, [panelId, sessionId, setTerminals]);
|
||||
|
||||
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} />;
|
||||
};
|
||||
|
||||
export const appRegistry: AppRegistry = {
|
||||
'chat': { name: 'Chat', icon: MessageSquare, component: ChatWidget },
|
||||
@@ -47,6 +93,7 @@ export const appRegistry: AppRegistry = {
|
||||
'sound-library': { name: 'Sound Library', icon: Music, component: () => <Catalog /> },
|
||||
'code-editor': { name: 'Code Editor', icon: Code, component: CodeEditorWrapper },
|
||||
'terminal': { name: 'Terminal', icon: TerminalSquare, component: TerminalWrapper },
|
||||
'terminal-host': { name: 'Host Terminal', icon: Monitor, component: HostTerminalWrapper },
|
||||
'workspace-list': { name: 'Workspaces', icon: LayoutGrid, component: () => <WorkspaceListApp /> },
|
||||
'chat-launcher': { name: 'Chat Launcher', icon: Sparkles, component: () => <ChatLauncher />, fixedHeight: 180 },
|
||||
'widget-panel': { name: 'Widget Panel', icon: LayoutDashboard, component: WidgetPanel, transparent: true },
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { WorkspaceListScreen } from './WorkspaceListScreen';
|
||||
export { WorkspaceListScreen, NewWorkspaceRedirect } from './WorkspaceListScreen';
|
||||
export { WorkspaceScreen } from './WorkspaceScreen';
|
||||
|
||||
@@ -11,7 +11,7 @@ export * from './Automation';
|
||||
export * from './Skills';
|
||||
export * from './TaskLogs';
|
||||
export * from './Tasks';
|
||||
export * from './Terminal';
|
||||
|
||||
export * from './Files';
|
||||
export * from './ChatHistory';
|
||||
export * from './CodeEditor';
|
||||
|
||||
@@ -12,7 +12,6 @@ type ServerSettings = {
|
||||
accountMode?: 'organization' | 'single';
|
||||
aiHarnesses?: AIHarnesses;
|
||||
plugins?: Record<string, boolean>;
|
||||
terminalSandboxed?: boolean;
|
||||
};
|
||||
|
||||
const SETTINGS_KEY = ['SERVER_SETTINGS'];
|
||||
@@ -30,8 +29,6 @@ export const useServerSettings = () => {
|
||||
const accountMode = settings?.accountMode;
|
||||
const aiHarnesses = settings?.aiHarnesses;
|
||||
const plugins = settings?.plugins;
|
||||
const terminalSandboxed = settings?.terminalSandboxed;
|
||||
|
||||
const saveSettings = useCallback(
|
||||
async (update: Partial<ServerSettings>) => {
|
||||
const result = await client.put<ServerSettings>('/server-settings', update);
|
||||
@@ -40,5 +37,5 @@ export const useServerSettings = () => {
|
||||
[client, queryClient],
|
||||
);
|
||||
|
||||
return { onboardingComplete, accountMode, aiHarnesses, plugins, terminalSandboxed, isLoading, saveSettings };
|
||||
return { onboardingComplete, accountMode, aiHarnesses, plugins, isLoading, saveSettings };
|
||||
};
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
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 './types/user-settings';
|
||||
|
||||
const QUERY_KEY = ['WORKSPACES_STATE'];
|
||||
|
||||
export function useWorkspacesState<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/workspaces-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('/user/workspaces-state', { [key]: newValue }).catch(() => {});
|
||||
},
|
||||
[key, defaultValue, queryClient],
|
||||
);
|
||||
|
||||
return [value, setValue, isSuccess];
|
||||
}
|
||||
Reference in New Issue
Block a user