diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx
index 5f998f52..fa7aeb68 100644
--- a/src/apps/officer-web/App.tsx
+++ b/src/apps/officer-web/App.tsx
@@ -54,6 +54,10 @@ export function App() {
} />
} />
} />
+ } />
+ } />
+ } />
+ } />
} />
} />
diff --git a/src/apps/officer-web/Screens/Dashboard/Home/index.tsx b/src/apps/officer-web/Screens/Dashboard/Home/index.tsx
index 9aa7741c..359a82a7 100644
--- a/src/apps/officer-web/Screens/Dashboard/Home/index.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/Home/index.tsx
@@ -12,15 +12,16 @@ const initialLayout: LayoutNode = {
{
node: {
type: 'group',
- id: 'home-right',
+ id: 'home-center',
direction: 'vertical',
children: [
{ node: { type: 'panel', id: 'homepage-widget-panel', appType: 'widget-panel' }, size: 60 },
{ node: { type: 'panel', id: 'home-chat', appType: 'chat-launcher' }, size: 40 },
],
},
- size: 80,
+ size: 60,
},
+ { node: { type: 'panel', id: 'home-right', appType: 'project-list' }, size: 20 },
],
};
diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx
index dd5d063c..8139eba3 100644
--- a/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx
@@ -1,3 +1,5 @@
+import { useMemo } from 'react';
+import { useAuth } from 'hooks/useAuth';
import { Background } from './Background';
import { Header } from './Header';
import { Dock, dockItems } from './Dock';
@@ -6,12 +8,18 @@ type DashboardLayoutProps = {
children?: React.ReactNode;
};
export function DashboardLayout({ children }: DashboardLayoutProps) {
+ const { user } = useAuth();
+ const visibleItems = useMemo(
+ () => dockItems.filter((item) => !item.role || item.role === user?.role),
+ [user?.role],
+ );
+
return (
-
+
{children}
diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx
index 3ea9b48c..cf94d577 100644
--- a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx
@@ -7,6 +7,7 @@ export type DockItem = {
to: string;
icon: LucideIcon;
color: string;
+ role?: string;
};
type DockProps = {
@@ -109,7 +110,7 @@ export const Dock = ({ items, className }: DockProps) => {
};
-import { MessageCircle, FileText, FolderOpen, Code, LayoutGrid, Bot, ScrollText } from 'lucide-react';
+import { MessageCircle, FileText, FolderOpen, Code, LayoutGrid, Bot, ScrollText, FolderKanban, Monitor } from 'lucide-react';
export const dockItems: DockItem[] = [
{ label: 'Files', to: '/files', icon: FolderOpen, color: '#fbbf24' },
@@ -119,5 +120,7 @@ export const dockItems: DockItem[] = [
{ label: 'Plans', to: '/plans', icon: FileText, color: '#f472b6' },
{ label: 'Automation', to: '/automation', icon: Bot, color: '#2dd4bf' },
{ label: 'Logs', to: '/task-logs', icon: ScrollText, color: '#94a3b8' },
+ { label: 'Terminal', to: '/terminal', icon: Monitor, color: '#f97316', role: 'Super Admin' },
+ { label: 'Projects', to: '/projects', icon: FolderKanban, color: '#10b981' },
{ label: 'Workspaces', to: '/workspaces', icon: LayoutGrid, color: '#8b5cf6' },
];
diff --git a/src/apps/officer-web/Screens/Dashboard/Projects/ProjectListApp.tsx b/src/apps/officer-web/Screens/Dashboard/Projects/ProjectListApp.tsx
new file mode 100644
index 00000000..955b4938
--- /dev/null
+++ b/src/apps/officer-web/Screens/Dashboard/Projects/ProjectListApp.tsx
@@ -0,0 +1,206 @@
+import { useState } from 'react';
+import { Link, useLocation, useNavigate } from 'react-router';
+import { FolderKanban, 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 { useProjectsState } from '@/state/useProjectsState';
+import type { ProjectDefinition, ProjectType } from '@/components/Workspace';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from '@/components/ui/alert-dialog';
+import {
+ 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,
+} from './constants';
+
+const PROJECT_TYPE_LABELS: Record = {
+ 'landing-page': 'Landing Page',
+ 'website': 'Website',
+ 'app': 'App',
+};
+
+export const ProjectListApp = () => {
+ const location = useLocation();
+ const navigate = useNavigate();
+ const client = useClient();
+ const queryClient = useQueryClient();
+ const [projects, setProjects] = useProjectsState('projects', []);
+ const [selected, setSelected] = useGlobal(SELECTED_PROJECT, null);
+ const [, setCreating] = useGlobal(CREATING_PROJECT, false);
+ const [, setEditing] = useGlobal(EDITING_PROJECT, null);
+ const [, setName] = useGlobal(NEW_PROJ_NAME, '');
+ const [, setDescription] = useGlobal(NEW_PROJ_DESC, '');
+ const [, setTemplateIdx] = useGlobal(NEW_PROJ_TEMPLATE, 0);
+ const [, setProjectType] = useGlobal(NEW_PROJ_TYPE, 'app');
+ const [, setHasBackend] = useGlobal(NEW_PROJ_HAS_BACKEND, false);
+ const [, setHasAuth] = useGlobal(NEW_PROJ_HAS_AUTH, false);
+
+ const [search, setSearch] = useState('');
+ const [deleting, setDeleting] = useState(null);
+ const isProjectsPage = location.pathname === '/projects';
+ const filtered = search
+ ? projects.filter((p) => {
+ const q = search.toLowerCase();
+ return [p.name, p.id, p.description ?? '', p.cwd ?? '', p.projectType].some((field) =>
+ field.toLowerCase().includes(q),
+ );
+ })
+ : projects;
+
+ const handleEdit = (ev: React.MouseEvent, p: ProjectDefinition) => {
+ ev.stopPropagation();
+ setSelected(null);
+ setCreating(false);
+ setEditing(p.id);
+ setName(p.name);
+ setDescription(p.description ?? '');
+ setTemplateIdx(p.templateIdx ?? 0);
+ setProjectType(p.projectType);
+ setHasBackend(p.hasBackend ?? false);
+ setHasAuth(p.hasAuth ?? false);
+ };
+
+ const handleDelete = (ev: React.MouseEvent, p: ProjectDefinition) => {
+ ev.stopPropagation();
+ setDeleting(p);
+ };
+
+ const confirmDelete = () => {
+ if (!deleting) return;
+ setProjects((prev) => prev.filter((p) => p.id !== deleting.id));
+ if (selected === deleting.id) setSelected(null);
+
+ const layoutKey = `proj-layout-${deleting.id}`;
+ const currentState = queryClient.getQueryData>(['PROJECTS_STATE']) ?? {};
+ const { [layoutKey]: _, ...rest } = currentState;
+ queryClient.setQueryData(['PROJECTS_STATE'], rest);
+ client.patch('/user/projects-state', { [layoutKey]: null }).catch(() => {});
+ setDeleting(null);
+ };
+
+ const handleClick = (p: ProjectDefinition) => {
+ if (isProjectsPage) {
+ setSelected(p.id);
+ setCreating(false);
+ setEditing(null);
+ } else {
+ navigate(`/projects/${p.id}`);
+ }
+ };
+
+ return (
+
+
+
+
+ Projects
+
+
+
+
+
+ setSearch(ev.target.value)}
+ placeholder="Search projects"
+ 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-emerald-500/30 focus:border-emerald-500/40"
+ />
+
+
+ {filtered.map((p) => (
+
handleClick(p)}
+ >
+
+
{p.name}
+
{PROJECT_TYPE_LABELS[p.projectType]}
+ {isProjectsPage && (
+ <>
+
+
+ >
+ )}
+
+ ))}
+ {filtered.length === 0 && (
+
+ {search ? 'No matches' : 'No projects yet'}
+
+ )}
+
+
+
{ if (!open) setDeleting(null); }}>
+
+
+ Delete project
+
+ Are you sure you want to delete {deleting?.name}? This action cannot be undone.
+
+
+
+ Cancel
+
+ Delete
+
+
+
+
+
+ );
+};
diff --git a/src/apps/officer-web/Screens/Dashboard/Projects/ProjectListScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Projects/ProjectListScreen.tsx
new file mode 100644
index 00000000..b8c07988
--- /dev/null
+++ b/src/apps/officer-web/Screens/Dashboard/Projects/ProjectListScreen.tsx
@@ -0,0 +1,647 @@
+import { useEffect, useState } from 'react';
+import { Link, useNavigate, useSearchParams } from 'react-router';
+import { FolderKanban, 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 { useProjectsState } from '@/state/useProjectsState';
+import { WorkspaceLayout, WorkspaceView, createDefaultLayout } from '@/components/Workspace';
+import type { LayoutNode, ProjectDefinition, ProjectType } from '@/components/Workspace';
+import { Button } from '@/components/ui/button';
+import { generateSlug, slugify } from 'helpers/slug';
+import { appRegistry } from '../Workspaces/app-registry';
+import {
+ 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 './constants';
+import { ProjectListApp } from './ProjectListApp';
+
+const defaultLayout: LayoutNode = {
+ type: 'group',
+ id: 'proj-home-root',
+ direction: 'horizontal',
+ children: [
+ { node: { type: 'panel', id: 'proj-home-list', appType: 'project-list' }, size: 25 },
+ { node: { type: 'panel', id: 'proj-home-right', appType: 'project-preview' }, size: 75 },
+ ],
+};
+
+// --- Layout Templates ---
+
+let tplCounter = 0;
+const tplUid = () => `ptpl-${++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 ;
+ }
+ const isH = node.direction === 'horizontal';
+ return (
+
+ {node.children.map((child) => (
+
+
+
+ ))}
+
+ );
+};
+
+const TemplateCell = ({ index }: { index: number }) => {
+ const tpl = templates[index]!;
+ const [selected, setSelected] = useGlobal(NEW_PROJ_TEMPLATE, 0);
+ const node = tpl.layout();
+ const isSelected = selected === index;
+
+ return (
+
+
+
+ );
+};
+
+// --- Panel: Template Picker (3x2 workspace) ---
+
+const tplPanelLayout: LayoutNode = {
+ type: 'group',
+ id: 'ptpl-root',
+ direction: 'vertical',
+ children: [
+ {
+ node: {
+ type: 'group',
+ id: 'ptpl-row-0',
+ direction: 'horizontal',
+ children: [
+ { node: { type: 'panel', id: 'ptpl-0', appType: 'ptpl-0' }, size: 33.33 },
+ { node: { type: 'panel', id: 'ptpl-1', appType: 'ptpl-1' }, size: 33.33 },
+ { node: { type: 'panel', id: 'ptpl-2', appType: 'ptpl-2' }, size: 33.34 },
+ ],
+ },
+ size: 50,
+ },
+ {
+ node: {
+ type: 'group',
+ id: 'ptpl-row-1',
+ direction: 'horizontal',
+ children: [
+ { node: { type: 'panel', id: 'ptpl-3', appType: 'ptpl-3' }, size: 33.33 },
+ { node: { type: 'panel', id: 'ptpl-4', appType: 'ptpl-4' }, size: 33.33 },
+ { node: { type: 'panel', id: 'ptpl-5', appType: 'ptpl-5' }, size: 33.34 },
+ ],
+ },
+ size: 50,
+ },
+ ],
+};
+
+const tplRegistry = Object.fromEntries(
+ templates.map((tpl, i) => [`ptpl-${i}`, { name: tpl.name, icon: Layout, component: () => }]),
+);
+
+const TemplatePanel = () => (
+ {}} registry={tplRegistry} />
+);
+
+// --- Panel: Details (Name + Description + Project Type + Backend/Auth toggles) ---
+
+const PROJECT_TYPE_OPTIONS: { value: ProjectType; label: string }[] = [
+ { value: 'landing-page', label: 'Landing Page' },
+ { value: 'website', label: 'Website' },
+ { value: 'app', label: 'App' },
+];
+
+const DetailsPanel = () => {
+ const [name, setName] = useGlobal(NEW_PROJ_NAME, '');
+ const [description, setDescription] = useGlobal(NEW_PROJ_DESC, '');
+ const [projectType, setProjectType] = useGlobal(NEW_PROJ_TYPE, 'app');
+ const [hasBackend, setHasBackend] = useGlobal(NEW_PROJ_HAS_BACKEND, false);
+ const [hasAuth, setHasAuth] = useGlobal(NEW_PROJ_HAS_AUTH, false);
+
+ return (
+
+
+
+
+ setName(ev.target.value)}
+ placeholder="My Project"
+ 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-emerald-500/30 focus:border-emerald-500/50"
+ />
+
+
+
+
+
+
+
+ {PROJECT_TYPE_OPTIONS.map((opt) => (
+
+ ))}
+
+
+ {projectType === 'app' && (
+
+
+
+
+ )}
+
+
+ );
+};
+
+// --- Panel: Create ---
+
+const CreatePanel = () => {
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+ const client = useClient();
+ const [name, setName] = useGlobal(NEW_PROJ_NAME, '');
+ const [projects, setProjects] = useProjectsState('projects', []);
+
+ const [description, setDescription] = useGlobal(NEW_PROJ_DESC, '');
+ const [templateIdx, setTemplateIdx] = useGlobal(NEW_PROJ_TEMPLATE, 0);
+ const [previewLayout] = useGlobal(NEW_PROJ_PREVIEW_LAYOUT, createDefaultLayout());
+ const [projectType] = useGlobal(NEW_PROJ_TYPE, 'app');
+ const [hasBackend] = useGlobal(NEW_PROJ_HAS_BACKEND, false);
+ const [hasAuth] = useGlobal(NEW_PROJ_HAS_AUTH, false);
+ const [editingId, setEditingId] = useGlobal(EDITING_PROJECT, null);
+ const [, setCreating] = useGlobal(CREATING_PROJECT, false);
+ const [, setSelected] = useGlobal(SELECTED_PROJECT, null);
+
+ const isEditing = !!editingId;
+ const slug = slugify(name.trim()) || generateSlug();
+
+ const handleSubmit = () => {
+ const trimmed = name.trim();
+ if (!trimmed) return;
+ const desc = description.trim();
+
+ if (isEditing) {
+ const existingIds = new Set(projects.filter((p) => p.id !== editingId).map((p) => p.id));
+ let newId = slugify(trimmed) || generateSlug();
+ while (existingIds.has(newId)) newId = `${newId}-${generateSlug(1)}`;
+
+ const idChanged = newId !== editingId;
+
+ setProjects((prev) =>
+ prev.map((p) =>
+ p.id === editingId
+ ? {
+ ...p,
+ id: newId,
+ name: trimmed,
+ cwd: '',
+ description: desc || undefined,
+ projectType,
+ hasBackend: projectType === 'app' ? hasBackend : undefined,
+ hasAuth: projectType === 'app' ? hasAuth : undefined,
+ templateIdx,
+ }
+ : p,
+ ),
+ );
+
+ const currentState = queryClient.getQueryData>(['PROJECTS_STATE']) ?? {};
+ const newLayoutKey = `proj-layout-${newId}`;
+
+ if (idChanged) {
+ const oldKeys = [`proj-layout-${editingId}`, `proj-terminals-${editingId}`, `proj-host-terminals-${editingId}`];
+ const cleaned = { ...currentState };
+ for (const k of oldKeys) delete cleaned[k];
+ queryClient.setQueryData(['PROJECTS_STATE'], { ...cleaned, [newLayoutKey]: previewLayout });
+
+ const patch: Record = { [newLayoutKey]: previewLayout };
+ for (const k of oldKeys) patch[k] = null;
+ client.patch('/user/projects-state', patch).catch(() => {});
+ } else {
+ queryClient.setQueryData(['PROJECTS_STATE'], { ...currentState, [newLayoutKey]: previewLayout });
+ client.patch('/user/projects-state', { [newLayoutKey]: previewLayout }).catch(() => {});
+ }
+
+ setEditingId(null);
+ setSelected(newId);
+ } else {
+ const existingIds = new Set(projects.map((p) => p.id));
+ let id = slugify(trimmed) || generateSlug();
+ while (existingIds.has(id)) id = `${id}-${generateSlug(1)}`;
+
+ const proj: ProjectDefinition = {
+ id,
+ name: trimmed,
+ cwd: '',
+ description: desc || undefined,
+ projectType,
+ hasBackend: projectType === 'app' ? hasBackend : undefined,
+ hasAuth: projectType === 'app' ? hasAuth : undefined,
+ templateIdx,
+ };
+ const layoutKey = `proj-layout-${proj.id}`;
+
+ setProjects((prev) => [...prev, proj]);
+ const currentState = queryClient.getQueryData>(['PROJECTS_STATE']) ?? {};
+ queryClient.setQueryData(['PROJECTS_STATE'], { ...currentState, [layoutKey]: previewLayout });
+ client.patch('/user/projects-state', { [layoutKey]: previewLayout }).catch(() => {});
+
+ setName('');
+ setDescription('');
+ setTemplateIdx(0);
+ navigate(`/projects/${proj.id}`);
+ }
+ };
+
+ return (
+
+
+
+
+
{name.trim() || 'Untitled'}
+
projects/{slug}
+
+
+ {isEditing && (
+
+ )}
+
+
+ );
+};
+
+// --- Panel: Live Template Preview (full interactive sub-workspace) ---
+// Uses local state for WorkspaceView to avoid cascading re-renders,
+// and syncs to global query cache (write-only) so CreatePanel can read it.
+
+const PREVIEW_LAYOUT_KEY = ['USE_GLOBAL', NEW_PROJ_PREVIEW_LAYOUT];
+
+const TemplatePreviewInner = ({ templateIdx }: { templateIdx: number }) => {
+ const queryClient = useQueryClient();
+ const [layout, setLocalLayout] = useState(() => {
+ const initial = templates[templateIdx]!.layout();
+ queryClient.setQueryData(PREVIEW_LAYOUT_KEY, initial);
+ return initial;
+ });
+
+ const handleLayoutChange = (next: LayoutNode) => {
+ setLocalLayout(next);
+ queryClient.setQueryData(PREVIEW_LAYOUT_KEY, next);
+ };
+
+ return ;
+};
+
+const TemplatePreviewPanel = () => {
+ const [selectedIdx] = useGlobal(NEW_PROJ_TEMPLATE, 0);
+ return ;
+};
+
+// --- New Project Layout ---
+
+const newProjLayout: LayoutNode = {
+ type: 'group',
+ id: 'new-proj-root',
+ direction: 'vertical',
+ children: [
+ {
+ node: {
+ type: 'group',
+ id: 'new-proj-top',
+ direction: 'horizontal',
+ children: [
+ { node: { type: 'panel', id: 'new-proj-details', appType: 'new-proj-details' }, size: 30 },
+ { node: { type: 'panel', id: 'new-proj-template', appType: 'new-proj-template' }, size: 70 },
+ ],
+ },
+ size: 50,
+ },
+ {
+ node: {
+ type: 'group',
+ id: 'new-proj-bottom',
+ direction: 'horizontal',
+ children: [
+ { node: { type: 'panel', id: 'new-proj-placeholder', appType: 'new-proj-placeholder' }, size: 70 },
+ { node: { type: 'panel', id: 'new-proj-create', appType: 'new-proj-create' }, size: 30 },
+ ],
+ },
+ size: 50,
+ },
+ ],
+};
+
+const newProjRegistry = {
+ ...appRegistry,
+ 'new-proj-details': { name: 'Details', icon: Type, component: DetailsPanel },
+ 'new-proj-placeholder': { name: 'Preview', icon: Layout, component: TemplatePreviewPanel },
+ 'new-proj-template': { name: 'Template', icon: Layout, component: TemplatePanel },
+ 'new-proj-create': { name: 'Create', icon: Rocket, component: CreatePanel },
+};
+
+const NewProjectForm = () => (
+ {}} registry={newProjRegistry} />
+);
+
+const ProjectPreviewEmpty = () => {
+ const [creating, setCreating] = useGlobal(CREATING_PROJECT, false);
+ const [editingId] = useGlobal(EDITING_PROJECT, null);
+ const [, setName] = useGlobal(NEW_PROJ_NAME, '');
+ const [, setDescription] = useGlobal(NEW_PROJ_DESC, '');
+ const [, setTemplateIdx] = useGlobal(NEW_PROJ_TEMPLATE, 0);
+ const [, setProjectType] = useGlobal(NEW_PROJ_TYPE, 'app');
+ const [, setHasBackend] = useGlobal(NEW_PROJ_HAS_BACKEND, false);
+ const [, setHasAuth] = useGlobal(NEW_PROJ_HAS_AUTH, false);
+
+ if (creating || editingId) return ;
+
+ const handleCreate = () => {
+ setName(generateSlug());
+ setDescription('');
+ setTemplateIdx(0);
+ setProjectType('app');
+ setHasBackend(false);
+ setHasAuth(false);
+ setCreating(true);
+ };
+
+ return (
+
+
+
+
Select a project or create a new one
+
+
+
+ );
+};
+
+const ProjectPreviewInner = ({ project }: { project: ProjectDefinition }) => {
+ const [layout, setLayout] = useProjectsState(`proj-layout-${project.id}`, createDefaultLayout());
+ const workspace = { id: project.id, name: project.name, cwd: project.cwd };
+
+ return ;
+};
+
+const ProjectPreview = () => {
+ const [selectedId] = useGlobal(SELECTED_PROJECT, null);
+ const [projects] = useProjectsState('projects', []);
+ const project = selectedId ? projects.find((p) => p.id === selectedId) : null;
+
+ if (!project) return ;
+
+ return (
+
+ );
+};
+
+const projHomeRegistry = {
+ ...appRegistry,
+ 'project-list': { name: 'Projects', icon: FolderKanban, component: () => },
+ 'project-preview': { name: 'Project Preview', icon: FolderKanban, component: ProjectPreview },
+};
+
+export const ProjectListScreen = () => {
+ const [layout, setLayout, isLoaded] = useProjectsState('proj-layout-proj-homepage', defaultLayout);
+
+ if (!isLoaded) return null;
+
+ return (
+
+
+
+ );
+};
+
+export const NewProjectRedirect = () => {
+ const navigate = useNavigate();
+ const [params] = useSearchParams();
+ const [, setName] = useGlobal(NEW_PROJ_NAME, '');
+ const [, setDescription] = useGlobal(NEW_PROJ_DESC, '');
+ const [, setTemplateIdx] = useGlobal(NEW_PROJ_TEMPLATE, 0);
+ const [, setCreating] = useGlobal(CREATING_PROJECT, false);
+ const [, setEditing] = useGlobal(EDITING_PROJECT, null);
+ const [, setSelected] = useGlobal(SELECTED_PROJECT, null);
+ const [, setProjectType] = useGlobal(NEW_PROJ_TYPE, 'app');
+ const [, setHasBackend] = useGlobal(NEW_PROJ_HAS_BACKEND, false);
+ const [, setHasAuth] = useGlobal(NEW_PROJ_HAS_AUTH, false);
+
+ useEffect(() => {
+ setSelected(null);
+ setEditing(null);
+ setName(params.get('name') || generateSlug());
+ setDescription('');
+ setTemplateIdx(0);
+ setProjectType('app');
+ setHasBackend(false);
+ setHasAuth(false);
+ setCreating(true);
+ navigate('/projects', { replace: true });
+ }, []);
+
+ return null;
+};
diff --git a/src/apps/officer-web/Screens/Dashboard/Projects/ProjectScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Projects/ProjectScreen.tsx
new file mode 100644
index 00000000..821d74c9
--- /dev/null
+++ b/src/apps/officer-web/Screens/Dashboard/Projects/ProjectScreen.tsx
@@ -0,0 +1,32 @@
+import { useParams, Navigate } from 'react-router';
+import { useProjectsState } from '@/state/useProjectsState';
+import { WorkspaceView, createDefaultLayout } from '@/components/Workspace';
+import type { LayoutNode, ProjectDefinition } from '@/components/Workspace';
+import { appRegistry } from '../Workspaces/app-registry';
+
+export const ProjectScreen = () => {
+ const { id } = useParams<{ id: string }>();
+ const [projects, , isLoaded] = useProjectsState('projects', []);
+ const project = projects.find((p) => p.id === id);
+
+ if (!isLoaded) return null;
+ if (!project) return ;
+
+ return ;
+};
+
+const ProjectScreenInner = ({ project }: { project: ProjectDefinition }) => {
+ const [layout, setLayout] = useProjectsState(`proj-layout-${project.id}`, createDefaultLayout());
+ const workspace = { id: project.id, name: project.name, cwd: project.cwd };
+
+ return (
+
+
+
+ );
+};
diff --git a/src/apps/officer-web/Screens/Dashboard/Projects/constants.ts b/src/apps/officer-web/Screens/Dashboard/Projects/constants.ts
new file mode 100644
index 00000000..9ba8eeaa
--- /dev/null
+++ b/src/apps/officer-web/Screens/Dashboard/Projects/constants.ts
@@ -0,0 +1,10 @@
+export const SELECTED_PROJECT = 'SELECTED_PROJECT';
+export const CREATING_PROJECT = 'CREATING_PROJECT';
+export const EDITING_PROJECT = 'EDITING_PROJECT';
+export const NEW_PROJ_NAME = 'NEW_PROJ_NAME';
+export const NEW_PROJ_DESC = 'NEW_PROJ_DESC';
+export const NEW_PROJ_TEMPLATE = 'NEW_PROJ_TEMPLATE';
+export const NEW_PROJ_TYPE = 'NEW_PROJ_TYPE';
+export const NEW_PROJ_HAS_BACKEND = 'NEW_PROJ_HAS_BACKEND';
+export const NEW_PROJ_HAS_AUTH = 'NEW_PROJ_HAS_AUTH';
+export const NEW_PROJ_PREVIEW_LAYOUT = 'NEW_PROJ_PREVIEW_LAYOUT';
diff --git a/src/apps/officer-web/Screens/Dashboard/Projects/index.tsx b/src/apps/officer-web/Screens/Dashboard/Projects/index.tsx
new file mode 100644
index 00000000..df91e256
--- /dev/null
+++ b/src/apps/officer-web/Screens/Dashboard/Projects/index.tsx
@@ -0,0 +1,2 @@
+export { ProjectListScreen, NewProjectRedirect } from './ProjectListScreen';
+export { ProjectScreen } from './ProjectScreen';
diff --git a/src/apps/officer-web/Screens/Dashboard/Terminal/index.tsx b/src/apps/officer-web/Screens/Dashboard/Terminal/index.tsx
new file mode 100644
index 00000000..cea9c6e5
--- /dev/null
+++ b/src/apps/officer-web/Screens/Dashboard/Terminal/index.tsx
@@ -0,0 +1,21 @@
+import { useState } from 'react';
+import { Navigate } from 'react-router';
+import { useAuth } from 'hooks/useAuth';
+import { WorkspaceView, createDefaultLayout } from '@/components/Workspace';
+import type { LayoutNode } from '@/components/Workspace';
+import { appRegistry } from '../Workspaces/app-registry';
+
+const defaultLayout: LayoutNode = { type: 'panel', id: 'terminal-root', appType: 'terminal-host' };
+
+export const TerminalScreen = () => {
+ const { user } = useAuth();
+ const [layout, setLayout] = useState(defaultLayout);
+
+ if (user?.role !== 'Super Admin') return ;
+
+ return (
+
+
+
+ );
+};
diff --git a/src/apps/officer-web/Screens/Dashboard/Workspaces/app-registry.tsx b/src/apps/officer-web/Screens/Dashboard/Workspaces/app-registry.tsx
index 33391c4b..8832461c 100644
--- a/src/apps/officer-web/Screens/Dashboard/Workspaces/app-registry.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/Workspaces/app-registry.tsx
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react';
import type { ReactNode } from 'react';
-import { MessageSquare, FolderOpen, History, Music, Code, TerminalSquare, Monitor, LayoutGrid, LayoutDashboard, Sparkles, Eye } from 'lucide-react';
+import { MessageSquare, FolderOpen, History, Music, Code, TerminalSquare, Monitor, LayoutGrid, LayoutDashboard, Sparkles, Eye, FolderKanban, Columns2, PenLine } from 'lucide-react';
import { useAuth } from 'hooks/useAuth';
import type { AppRegistry } from '@/components/Workspace';
import { useWorkspace } from '@/components/Workspace';
@@ -16,6 +16,7 @@ import { ChatHistoryApp as ChatHistory } from '../ChatHistory';
import { Files } from '../Files';
import { Catalog } from 'sounds';
import { WorkspaceListApp } from './WorkspaceListApp';
+import { ProjectListApp } from '../Projects/ProjectListApp';
import { ChatLauncher } from '../Home/ChatLauncher';
import { widgetRegistry } from 'widgets/widget-registry';
import { WidgetPanel } from 'widgets/WidgetPanel';
@@ -36,6 +37,39 @@ const FileBrowserWrapper = () => {
const CodeEditorWrapper = () => ;
+const TerminalHeader = () => {
+ const { cwd } = useWorkspace();
+ return (
+ <>
+
+ Terminal
+ {cwd}
+ >
+ );
+};
+
+const HostTerminalHeader = () => {
+ const { cwd } = useWorkspace();
+ return (
+ <>
+
+ Terminal
+ {cwd}
+ >
+ );
+};
+
+const CodeEditorHeader = () => {
+ const { cwd } = useWorkspace();
+ return (
+ <>
+
+ Code Editor
+ {cwd}
+ >
+ );
+};
+
const EMPTY_TERMINALS: Record = {};
const TerminalWrapper = ({ panelId }: { panelId: string }) => {
@@ -105,6 +139,67 @@ const HostTerminalWrapper = ({ panelId }: { panelId: string }) => {
return ;
};
+const CommandTerminalWrapper = ({ panelId, command, statePrefix }: { panelId: string; command: string; statePrefix: string }) => {
+ const { workspaceId, cwd } = useWorkspace();
+ const stateKey = workspaceId ? `ws-${statePrefix}-${workspaceId}` : `ws-${statePrefix}-default`;
+ const [terminals, setTerminals] = useWorkspacesState>(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 ;
+};
+
+const TmuxWrapper = ({ panelId }: { panelId: string }) => (
+
+);
+
+const NvimWrapper = ({ panelId }: { panelId: string }) => (
+
+);
+
+const TmuxHeader = () => {
+ const { cwd } = useWorkspace();
+ return (
+ <>
+
+ Tmux
+ {cwd}
+ >
+ );
+};
+
+const NvimHeader = () => {
+ const { cwd } = useWorkspace();
+ return (
+ <>
+
+ Neovim
+ {cwd}
+ >
+ );
+};
+
type FileViewerChannelState = {
filePath: string;
fileName: string;
@@ -134,14 +229,17 @@ const FileViewerWorkspaceProvider = ({ panelId, children }: { panelId: string; c
export const appRegistry: AppRegistry = {
'chat': { name: 'Chat', icon: MessageSquare, component: ChatWidget },
'file-browser': { name: 'File Browser', icon: FolderOpen, component: FileBrowserWrapper },
- 'chat-history': { name: 'Chat History', icon: History, component: () => },
- 'sound-library': { name: 'Sound Library', icon: Music, component: () => },
- '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: () => },
- 'file-viewer': { name: 'File Viewer', icon: Eye, component: FileViewerBody, header: FileViewerHeader, provider: FileViewerWorkspaceProvider },
- 'chat-launcher': { name: 'Chat Launcher', icon: Sparkles, component: () => , fixedHeight: 180 },
+ 'chat-history': { name: 'Chat History', icon: History, component: () => , availableOnPanel: false },
+ 'sound-library': { name: 'Sound Library', icon: Music, component: () => , availableOnPanel: false },
+ 'code-editor': { name: 'Code Editor', icon: Code, component: CodeEditorWrapper, header: CodeEditorHeader },
+ 'terminal': { name: 'Terminal', icon: TerminalSquare, component: TerminalWrapper, header: TerminalHeader },
+ 'tmux': { name: 'Tmux', icon: Columns2, component: TmuxWrapper, header: TmuxHeader },
+ 'nvim': { name: 'Neovim', icon: PenLine, component: NvimWrapper, header: NvimHeader },
+ 'terminal-host': { name: 'Host Terminal', icon: Monitor, component: HostTerminalWrapper, header: HostTerminalHeader, availableOnPanel: false },
+ 'workspace-list': { name: 'Workspaces', icon: LayoutGrid, component: () => , availableOnPanel: false },
+ 'project-list': { name: 'Projects', icon: FolderKanban, component: () => , availableOnPanel: false },
+ 'file-viewer': { name: 'File Viewer', icon: Eye, component: FileViewerBody, header: FileViewerHeader, provider: FileViewerWorkspaceProvider, availableOnPanel: false },
+ 'chat-launcher': { name: 'Chat Launcher', icon: Sparkles, component: () => , fixedHeight: 180, availableOnPanel: false },
'widget-panel': { name: 'Widget Panel', icon: LayoutDashboard, component: WidgetPanel, transparent: true },
...widgetRegistry,
};
diff --git a/src/apps/officer-web/Screens/Dashboard/index.tsx b/src/apps/officer-web/Screens/Dashboard/index.tsx
index afccef7a..71a73bea 100644
--- a/src/apps/officer-web/Screens/Dashboard/index.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/index.tsx
@@ -16,3 +16,5 @@ export * from './Files';
export * from './ChatHistory';
export * from './CodeEditor';
export * from './Workspaces';
+export * from './Projects';
+export * from './Terminal';
diff --git a/src/apps/officer-web/state/useProjectsState.ts b/src/apps/officer-web/state/useProjectsState.ts
new file mode 100644
index 00000000..fbec3ffe
--- /dev/null
+++ b/src/apps/officer-web/state/useProjectsState.ts
@@ -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 './types/user-settings';
+
+const QUERY_KEY = ['PROJECTS_STATE'];
+
+export function useProjectsState(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({
+ queryKey: QUERY_KEY,
+ enabled: isAuthenticated,
+ queryFn: () => client.get('/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(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/projects-state', { [key]: newValue })
+ .then((serverState) => {
+ if (serverState) queryClient.setQueryData(QUERY_KEY, serverState);
+ })
+ .catch(() => {});
+ },
+ [key, defaultValue, queryClient],
+ );
+
+ return [value, setValue, isSuccess];
+}
diff --git a/src/server.tsx b/src/server.tsx
index e2f7b760..8cdbe016 100644
--- a/src/server.tsx
+++ b/src/server.tsx
@@ -22,6 +22,8 @@ type WSData = {
sessionId?: string;
cwd?: string;
command?: string;
+ cols?: number;
+ rows?: number;
};
const handlers: Record = {
@@ -51,8 +53,10 @@ async function upgradeWs(req: Request, server: any, provider: /* 'claude' | 'ope
const sandboxed = url.searchParams.get('sandboxed') !== 'false';
const cwd = url.searchParams.get('cwd') ?? undefined;
const command = url.searchParams.get('command') ?? undefined;
+ const cols = url.searchParams.get('cols') ? Number(url.searchParams.get('cols')) : undefined;
+ const rows = url.searchParams.get('rows') ? Number(url.searchParams.get('rows')) : undefined;
const ok = server.upgrade(req, {
- data: { userId: user.id, email: user.email, role: user.role, provider, sandboxed, sessionId, cwd, command },
+ data: { userId: user.id, email: user.email, role: user.role, provider, sandboxed, sessionId, cwd, command, cols, rows },
});
if (!ok) return new Response('Upgrade failed', { status: 500 });
} catch {
diff --git a/src/servers/api/settings/projects.ts b/src/servers/api/settings/projects.ts
new file mode 100644
index 00000000..ba50282e
--- /dev/null
+++ b/src/servers/api/settings/projects.ts
@@ -0,0 +1,128 @@
+import { mkdir, readdir, rm } from 'node:fs/promises';
+import { join } from 'node:path';
+import { createRouter } from '../../create-router';
+import { getUserProjectsDir } from '@@/data-path';
+
+export const projectsRouter = createRouter();
+
+type KeyMapping = { file: string; dir?: string };
+
+function resolveKey(projDir: string, key: string): KeyMapping | null {
+ if (key === 'projects') return { file: join(projDir, 'index.json') };
+
+ const layoutMatch = key.match(/^proj-layout-(.+)$/);
+ if (layoutMatch) {
+ const id = layoutMatch[1]!;
+ const dir = join(projDir, id);
+ return { file: join(dir, 'layout.json'), dir };
+ }
+
+ const terminalsMatch = key.match(/^proj-terminals-(.+)$/);
+ if (terminalsMatch) {
+ const id = terminalsMatch[1]!;
+ const dir = join(projDir, id);
+ return { file: join(dir, 'terminals.json'), dir };
+ }
+
+ const hostTerminalsMatch = key.match(/^proj-host-terminals-(.+)$/);
+ if (hostTerminalsMatch) {
+ const id = hostTerminalsMatch[1]!;
+ const dir = join(projDir, id);
+ return { file: join(dir, 'host-terminals.json'), dir };
+ }
+
+ return null;
+}
+
+async function readJsonFile(path: string): Promise {
+ const file = Bun.file(path);
+ if (await file.exists()) return file.json();
+ return null;
+}
+
+async function writeJsonFile(path: string, data: unknown) {
+ await Bun.write(path, JSON.stringify(data, null, 2));
+}
+
+async function readProjectDir(dirPath: string, id: string, result: Record) {
+ const layout = await readJsonFile(join(dirPath, 'layout.json'));
+ if (layout !== null) result[`proj-layout-${id}`] = layout;
+
+ const terminals = await readJsonFile(join(dirPath, 'terminals.json'));
+ if (terminals !== null) result[`proj-terminals-${id}`] = terminals;
+
+ const hostTerminals = await readJsonFile(join(dirPath, 'host-terminals.json'));
+ if (hostTerminals !== null) result[`proj-host-terminals-${id}`] = hostTerminals;
+}
+
+async function readAllProjectsState(projDir: string): Promise> {
+ const result: Record = {};
+
+ const indexData = await readJsonFile(join(projDir, 'index.json'));
+ if (indexData !== null) result['projects'] = indexData;
+
+ let entries: import('node:fs').Dirent[] = [];
+ try {
+ entries = await readdir(projDir, { withFileTypes: true });
+ } catch {
+ return result;
+ }
+
+ for (const entry of entries) {
+ if (!entry.isDirectory()) continue;
+ await readProjectDir(join(projDir, entry.name), entry.name, result);
+ }
+
+ return result;
+}
+
+// GET /projects-state
+projectsRouter.get('/projects-state', async (ctx) => {
+ const email = ctx.get('user').email;
+ const projDir = getUserProjectsDir(email);
+
+ const state = await readAllProjectsState(projDir);
+ return ctx.json(state);
+});
+
+// PATCH /projects-state
+projectsRouter.patch('/projects-state', async (ctx) => {
+ const email = ctx.get('user').email;
+ const body = ctx.get('body') as Record;
+ const projDir = getUserProjectsDir(email);
+
+ await mkdir(projDir, { recursive: true });
+
+ for (const [key, value] of Object.entries(body)) {
+ // When writing the projects list, resolve cwds and create project dirs
+ if (key === 'projects' && Array.isArray(value)) {
+ for (const project of value) {
+ const projectFilesDir = join(projDir, project.id, 'files');
+ project.cwd = projectFilesDir;
+ await mkdir(projectFilesDir, { recursive: true });
+ }
+ }
+
+ const mapping = resolveKey(projDir, key);
+ if (!mapping) continue;
+
+ if (value === null) {
+ try {
+ await rm(mapping.file, { force: true });
+ if (mapping.dir) {
+ const remaining = await readdir(mapping.dir);
+ if (remaining.length === 0) await rm(mapping.dir, { recursive: true, force: true });
+ }
+ } catch {
+ // ignore
+ }
+ continue;
+ }
+
+ if (mapping.dir) await mkdir(mapping.dir, { recursive: true });
+ await writeJsonFile(mapping.file, value);
+ }
+
+ const state = await readAllProjectsState(projDir);
+ return ctx.json(state);
+});
diff --git a/src/servers/api/terminal/Dockerfile.terminal-sidecar b/src/servers/api/terminal/Dockerfile.terminal-sidecar
index f3e417dd..9685a31c 100644
--- a/src/servers/api/terminal/Dockerfile.terminal-sidecar
+++ b/src/servers/api/terminal/Dockerfile.terminal-sidecar
@@ -2,11 +2,14 @@ FROM imbios/bun-node:22-slim
RUN apt-get update \
&& apt-get install -y \
- python3 make g++ zsh git curl wget ca-certificates \
- fortune-mod cowsay sudo gosu \
+ python3 make gcc g++ zsh git curl wget ca-certificates \
+ sudo gosu locales \
zip unzip tree btop net-tools tmux \
+ && sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen \
&& apt-get clean
+ENV LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8
+
RUN curl -LO https://github.com/neovim/neovim/releases/latest/download/nvim-linux-x86_64.tar.gz \
&& tar -C /opt -xzf nvim-linux-x86_64.tar.gz \
&& rm nvim-linux-x86_64.tar.gz
@@ -36,13 +39,20 @@ RUN curl -fsSL "https://github.com/eza-community/eza/releases/download/v${EZA_VE
&& chmod +x /usr/local/bin/eza \
&& rm -rf /tmp/eza.tar.gz /tmp/completions /tmp/man
-RUN mkdir -p /home/officer
+ENV LAZYGIT_VERSION=0.44.1
+RUN curl -fsSL "https://github.com/jesseduffield/lazygit/releases/download/v${LAZYGIT_VERSION}/lazygit_${LAZYGIT_VERSION}_Linux_x86_64.tar.gz" -o /tmp/lazygit.tar.gz \
+ && tar -xzf /tmp/lazygit.tar.gz -C /tmp \
+ && mv /tmp/lazygit /usr/local/bin/lazygit \
+ && chmod +x /usr/local/bin/lazygit \
+ && rm -rf /tmp/lazygit.tar.gz /tmp/LICENSE /tmp/README.md
+
+
+RUN mkdir -p /home/officer/Documents /home/officer/Downloads /home/officer/Music /home/officer/Videos /home/officer/Pictures /home/officer/Desktop /home/officer/Projects
WORKDIR /home/officer
ENV TERMINAL_PTY_PORT=5337
-ENV PATH="/usr/games:${PATH}"
EXPOSE 5337
diff --git a/src/servers/api/terminal/templates/.zshrc b/src/servers/api/terminal/templates/.zshrc
index fbd0c2c1..56766a0b 100644
--- a/src/servers/api/terminal/templates/.zshrc
+++ b/src/servers/api/terminal/templates/.zshrc
@@ -70,4 +70,3 @@ alias setupmines="WINEPREFIX=~/wine/minesweeper winecfg"
alias httpserver="python -m http.server 8888"
clear
-fortune | cowsay
diff --git a/src/servers/api/terminal/websocket.ts b/src/servers/api/terminal/websocket.ts
index 3306ffc9..8f1bf1cb 100644
--- a/src/servers/api/terminal/websocket.ts
+++ b/src/servers/api/terminal/websocket.ts
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url';
import { getHomeDir } from '@@/data-path';
import { officerdb, Users } from 'officerdb';
-type WSData = { userId: number; email: string; role: string; sandboxed: boolean; sessionId?: string; cwd?: string };
+type WSData = { userId: number; email: string; role: string; sandboxed: boolean; sessionId?: string; cwd?: string; cols?: number; rows?: number };
type ShellInfo = { command: string; args: string[]; name: string };
type BridgeSession = {
client: ServerWebSocket;
@@ -337,6 +337,8 @@ export const terminalWebsocket = {
cwd: resolveCwd(process.env.HOME!, ws.data.cwd),
homeDir: process.env.HOME,
userLabel: email,
+ cols: ws.data.cols,
+ rows: ws.data.rows,
}),
);
@@ -397,6 +399,8 @@ export const terminalWebsocket = {
cwd: resolveCwd(containerHome, ws.data.cwd),
homeDir: containerHome,
userLabel: email,
+ cols: ws.data.cols,
+ rows: ws.data.rows,
}),
);
diff --git a/src/servers/data-path.ts b/src/servers/data-path.ts
index d6948852..a930fdcd 100644
--- a/src/servers/data-path.ts
+++ b/src/servers/data-path.ts
@@ -32,6 +32,8 @@ export const getUserStateFile = (email: string) => join(DATA_PATH, email, 'state
export const getUserWorkspacesDir = (email: string) => join(DATA_PATH, email, 'workspaces');
+export const getUserProjectsDir = (email: string) => join(DATA_PATH, email, 'projects');
+
export const getUserHomepageWorkspaceDir = (email: string) => join(DATA_PATH, email, 'ws-homepage');
export const getNativeSkillsDir = () => join(SEED_PATH, 'skills');
diff --git a/src/servers/hono.ts b/src/servers/hono.ts
index 0df9a565..0241fe7a 100644
--- a/src/servers/hono.ts
+++ b/src/servers/hono.ts
@@ -18,6 +18,7 @@ import { scrapeRouter } from './api/scrape/scrape';
import { uploadRouter } from './api/upload/upload';
import { settingsRouter } from './api/settings/settings';
import { workspacesRouter } from './api/settings/workspaces';
+import { projectsRouter } from './api/settings/projects';
import { taskLogsRouter } from './api/task-logs/task-logs';
import { router as fileBrowserRouter } from './api/file-browser/router';
import { CustomError } from './custom-errors';
@@ -59,6 +60,7 @@ protectedRouter.route('/scrape', scrapeRouter);
protectedRouter.route('/upload', uploadRouter);
protectedRouter.route('/user', settingsRouter);
protectedRouter.route('/user', workspacesRouter);
+protectedRouter.route('/user', projectsRouter);
protectedRouter.route('/task-logs', taskLogsRouter);
protectedRouter.route('/file-browser', fileBrowserRouter);
diff --git a/src/workspaces/apps/Terminal/Terminal.tsx b/src/workspaces/apps/Terminal/Terminal.tsx
index 1efba36e..9d2b1ae9 100644
--- a/src/workspaces/apps/Terminal/Terminal.tsx
+++ b/src/workspaces/apps/Terminal/Terminal.tsx
@@ -20,6 +20,7 @@ export type TerminalViewProps = {
sandboxed?: boolean;
cwd?: string;
command?: string;
+ initialInput?: string;
fontSize?: number;
fontFamily?: string;
theme?: TerminalTheme;
@@ -37,7 +38,7 @@ const DEFAULT_THEME: Required = {
selectionBackground: '#3a3a5e',
};
-const buildWsUrl = (wsPath: string, sessionId?: string, sandboxed?: boolean, cwd?: string, command?: string) => {
+const buildWsUrl = (wsPath: string, sessionId?: string, sandboxed?: boolean, cwd?: string, command?: string, cols?: number, rows?: number) => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const token = localStorage.getItem('BEARER_TOKEN') ?? '';
const separator = wsPath.includes('?') ? '&' : '?';
@@ -46,6 +47,8 @@ const buildWsUrl = (wsPath: string, sessionId?: string, sandboxed?: boolean, cwd
if (sandboxed === false) url += '&sandboxed=false';
if (cwd) url += `&cwd=${encodeURIComponent(cwd)}`;
if (command) url += `&command=${encodeURIComponent(command)}`;
+ if (cols) url += `&cols=${cols}`;
+ if (rows) url += `&rows=${rows}`;
return url;
};
@@ -57,6 +60,7 @@ export const TerminalView = ({
sandboxed = true,
cwd,
command,
+ initialInput,
fontSize = 14,
fontFamily = 'Menlo, Monaco, "Courier New", monospace',
theme,
@@ -69,19 +73,20 @@ export const TerminalView = ({
const containerRef = useRef(null);
const termRef = useRef(null);
const wsRef = useRef(null);
- const fitAddonRef = useRef(null);
const isMounted = useMounted();
const onReadyRef = useRef(onReady);
const onExitRef = useRef(onExit);
const onCommandDoneRef = useRef(onCommandDone);
const onDisconnectRef = useRef(onDisconnect);
const commandRef = useRef(command);
+ const initialInputRef = useRef(initialInput);
onReadyRef.current = onReady;
onExitRef.current = onExit;
onCommandDoneRef.current = onCommandDone;
onDisconnectRef.current = onDisconnect;
commandRef.current = command;
+ initialInputRef.current = initialInput;
const background = theme?.background ?? DEFAULT_THEME.background;
const foreground = theme?.foreground ?? DEFAULT_THEME.foreground;
@@ -94,135 +99,134 @@ export const TerminalView = ({
if (!container) return;
let disposed = false;
- const initTimeout = setTimeout(() => {
- if (disposed) return;
- const term = new XTerm({
- cursorBlink: true,
- fontSize,
- fontFamily,
- theme: {
- background,
- foreground,
- cursor,
- selectionBackground,
- },
- });
- const fitAddon = new FitAddon();
- term.loadAddon(fitAddon);
- term.open(container);
+ // Step 2: Create at 80x24, then fit after layout settles.
+ const term = new XTerm({
+ cursorBlink: true,
+ cols: 80,
+ rows: 24,
+ fontSize,
+ fontFamily,
+ theme: {
+ background,
+ foreground,
+ cursor,
+ selectionBackground,
+ },
+ });
- const viewport = container.querySelector('.xterm-viewport') as HTMLElement | null;
- if (viewport) {
- viewport.style.scrollbarWidth = 'none';
- viewport.style.overflow = 'hidden';
- }
+ const fitAddon = new FitAddon();
+ term.loadAddon(fitAddon);
+ term.open(container);
+ if (autoFocus) term.focus();
- fitAddon.fit();
- if (autoFocus) term.focus();
+ termRef.current = term;
+ onReadyRef.current?.(term);
- termRef.current = term;
- fitAddonRef.current = fitAddon;
- onReadyRef.current?.(term);
+ // Wait for layout to fully settle (double rAF), then fit + connect
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ if (disposed) return;
- const ws = new WebSocket(buildWsUrl(wsPath, sessionId, sandboxed, cwd, command));
- wsRef.current = ws;
- let commandSent = false;
- let commandDone = false;
- let commandOutput = '';
- const EXIT_MARKER = '__OFFICER_EXIT_';
- // eslint-disable-next-line no-control-regex
- const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07/g, '');
-
- const handleOpen = () => {
- ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
- };
-
- const handleMessage = (ev: MessageEvent) => {
- try {
- const msg = JSON.parse(ev.data as string);
- if (msg.type === 'output') {
- term.write(msg.data);
- if (commandRef.current && !commandSent) {
- commandSent = true;
- setTimeout(() => {
- if (ws.readyState === WebSocket.OPEN) {
- const wrapped = onCommandDoneRef.current
- ? `${commandRef.current}; echo "${EXIT_MARKER}$?__"`
- : commandRef.current;
- ws.send(JSON.stringify({ type: 'input', data: wrapped + '\r' }));
- }
- }, 100);
- }
- if (commandSent && !commandDone && onCommandDoneRef.current) {
- commandOutput += msg.data as string;
- const markerMatch = stripAnsi(commandOutput).match(/__OFFICER_EXIT_(\d+)__/);
- if (markerMatch) {
- const exitCode = Number(markerMatch[1]);
- const raw = stripAnsi(commandOutput).slice(0, markerMatch.index);
- const lines = raw.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
- const cmdLine = lines.findIndex((l) => l.includes(commandRef.current!.slice(0, 20)));
- const output = lines.slice(cmdLine >= 0 ? cmdLine + 1 : 0).join('\n').trim();
- commandDone = true;
- onCommandDoneRef.current(exitCode, output);
- }
- }
- } else if (msg.type === 'exit') {
- term.write('\r\n[Process exited]\r\n');
- onExitRef.current?.();
- } else if (msg.type === 'detached') {
- term.write('\r\n[Session taken over]\r\n');
- }
- } catch {
- // ignore
- }
- };
-
- const handleClose = () => {
- term.write('\r\n[Disconnected]\r\n');
- onDisconnectRef.current?.();
- };
-
- ws.addEventListener('open', handleOpen);
- ws.addEventListener('message', handleMessage);
- ws.addEventListener('close', handleClose);
-
- const dataDisposable = term.onData((data) => {
- if (ws.readyState === WebSocket.OPEN) {
- ws.send(JSON.stringify({ type: 'input', data }));
- }
- });
-
- const resizeObserver = new ResizeObserver(() => {
fitAddon.fit();
- if (ws.readyState === WebSocket.OPEN) {
- ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
- }
+ const cols = term.cols;
+ const rows = term.rows;
+
+ const ws = new WebSocket(buildWsUrl(wsPath, sessionId, sandboxed, cwd, command, cols, rows));
+ wsRef.current = ws;
+ let commandSent = false;
+ let commandDone = false;
+ let initialInputSent = false;
+ let commandOutput = '';
+ const EXIT_MARKER = '__OFFICER_EXIT_';
+ // eslint-disable-next-line no-control-regex
+ const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07/g, '');
+
+ const handleOpen = () => {
+ ws.send(JSON.stringify({ type: 'resize', cols, rows }));
+ };
+
+ const handleMessage = (ev: MessageEvent) => {
+ try {
+ const msg = JSON.parse(ev.data as string);
+ if (msg.type === 'output') {
+ term.write(msg.data);
+ if (commandRef.current && !commandSent) {
+ commandSent = true;
+ setTimeout(() => {
+ if (ws.readyState === WebSocket.OPEN) {
+ const wrapped = onCommandDoneRef.current
+ ? `${commandRef.current}; echo "${EXIT_MARKER}$?__"`
+ : commandRef.current;
+ ws.send(JSON.stringify({ type: 'input', data: wrapped + '\r' }));
+ }
+ }, 100);
+ }
+ if (!commandRef.current && initialInputRef.current && !initialInputSent) {
+ initialInputSent = true;
+ setTimeout(() => {
+ if (ws.readyState === WebSocket.OPEN) {
+ ws.send(JSON.stringify({ type: 'input', data: initialInputRef.current + '\r' }));
+ }
+ }, 500);
+ }
+ if (commandSent && !commandDone && onCommandDoneRef.current) {
+ commandOutput += msg.data as string;
+ const markerMatch = stripAnsi(commandOutput).match(/__OFFICER_EXIT_(\d+)__/);
+ if (markerMatch) {
+ const exitCode = Number(markerMatch[1]);
+ const raw = stripAnsi(commandOutput).slice(0, markerMatch.index);
+ const lines = raw.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
+ const cmdLine = lines.findIndex((l) => l.includes(commandRef.current!.slice(0, 20)));
+ const output = lines.slice(cmdLine >= 0 ? cmdLine + 1 : 0).join('\n').trim();
+ commandDone = true;
+ onCommandDoneRef.current(exitCode, output);
+ }
+ }
+ } else if (msg.type === 'exit') {
+ term.write('\r\n[Process exited]\r\n');
+ onExitRef.current?.();
+ } else if (msg.type === 'detached') {
+ term.write('\r\n[Session taken over]\r\n');
+ }
+ } catch {
+ // ignore
+ }
+ };
+
+ const handleClose = () => {
+ term.write('\r\n[Disconnected]\r\n');
+ onDisconnectRef.current?.();
+ };
+
+ ws.addEventListener('open', handleOpen);
+ ws.addEventListener('message', handleMessage);
+ ws.addEventListener('close', handleClose);
+
+ const dataDisposable = term.onData((data) => {
+ if (ws.readyState === WebSocket.OPEN) {
+ ws.send(JSON.stringify({ type: 'input', data }));
+ }
+ });
+
+ (container as any).__terminalCleanup = () => {
+ dataDisposable.dispose();
+ ws.removeEventListener('open', handleOpen);
+ ws.removeEventListener('message', handleMessage);
+ ws.removeEventListener('close', handleClose);
+ ws.close();
+ };
});
- resizeObserver.observe(container);
-
- const cleanup = () => {
- dataDisposable.dispose();
- resizeObserver.disconnect();
- ws.removeEventListener('open', handleOpen);
- ws.removeEventListener('message', handleMessage);
- ws.removeEventListener('close', handleClose);
- };
-
- (container as any).__terminalCleanup = cleanup;
- }, 0);
+ });
return () => {
disposed = true;
- clearTimeout(initTimeout);
const cleanup = (container as any).__terminalCleanup as (() => void) | undefined;
cleanup?.();
delete (container as any).__terminalCleanup;
- wsRef.current?.close();
wsRef.current = null;
termRef.current?.dispose();
termRef.current = null;
- fitAddonRef.current = null;
};
}, [
isMounted,
diff --git a/src/workspaces/components/Workspace/AppPicker.tsx b/src/workspaces/components/Workspace/AppPicker.tsx
index a74f1a44..652fed9e 100644
--- a/src/workspaces/components/Workspace/AppPicker.tsx
+++ b/src/workspaces/components/Workspace/AppPicker.tsx
@@ -6,19 +6,19 @@ type AppPickerProps = {
};
export const AppPicker = ({ registry, onSelect }: AppPickerProps) => {
- const entries = Object.entries(registry).filter(([, entry]) => !entry.widget);
+ const entries = Object.entries(registry).filter(([, entry]) => !entry.widget && entry.availableOnPanel !== false);
return (
-
+
{entries.map(([key, entry]) => (
))}
diff --git a/src/workspaces/components/Workspace/PanelSlot.tsx b/src/workspaces/components/Workspace/PanelSlot.tsx
index 6f07801a..80738de7 100644
--- a/src/workspaces/components/Workspace/PanelSlot.tsx
+++ b/src/workspaces/components/Workspace/PanelSlot.tsx
@@ -1,5 +1,7 @@
import type { ComponentType } from 'react';
-import { ArrowLeftRight, X } from 'lucide-react';
+import { useCallback } from 'react';
+import { createPortal } from 'react-dom';
+import { ArrowLeftRight, X, Minus } from 'lucide-react';
import type { LayoutPanel, AppRegistry, PanelComponents, PanelComponentEntry } from './types';
import { useWorkspace } from './WorkspaceContext';
import { Card } from '../Card';
@@ -37,12 +39,17 @@ const isPanelEntry = (v: ComponentType | PanelComponentEntry): v is PanelCompone
typeof v === 'object' && v !== null && 'component' in v;
const PanelContextMenu = ({ panelId, hasApp, isLastPanel, onSplit, onRemove, onClearApp, children }: PanelContextMenuProps) => {
- const { swapSourceId, setSwapSourceId } = useWorkspace();
+ const { swapSourceId, setSwapSourceId, maximizedPanelId, setMaximizedPanelId } = useWorkspace();
+ const isMaximized = maximizedPanelId === panelId;
return (
{children}
+ setMaximizedPanelId(isMaximized ? null : panelId)}>
+ {isMaximized ? 'Restore' : 'Maximize'}
+
+
onSplit(panelId, 'horizontal')}>Split horizontal
onSplit(panelId, 'vertical')}>Split vertical
{hasApp && Clear app}
@@ -101,6 +108,61 @@ const SwapSourceIndicator = ({ panelId }: { panelId: string }) => {
);
};
+const TrafficLights = ({ panelId, isLastPanel, onRemove, onClearApp }: { panelId: string; isLastPanel: boolean; onRemove: (panelId: string) => void; onClearApp: () => void }) => {
+ const { maximizedPanelId, setMaximizedPanelId } = useWorkspace();
+ const isMaximized = maximizedPanelId === panelId;
+
+ const handleClose = useCallback(() => {
+ onClearApp();
+ }, [onClearApp]);
+
+ const handleRestore = useCallback(() => {
+ setMaximizedPanelId(null);
+ }, [setMaximizedPanelId]);
+
+ const handleMaximize = useCallback(() => {
+ setMaximizedPanelId(panelId);
+ }, [panelId, setMaximizedPanelId]);
+
+ if (isMaximized) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ );
+};
+
// TODO: drag-to-reposition needs work (visual feedback, edge cases)
// const DragHandle = ({ panelId }: { panelId: string }) => {
// const { setDragSourceId, dragSourceId } = useWorkspace();
@@ -120,6 +182,9 @@ const SwapSourceIndicator = ({ panelId }: { panelId: string }) => {
// };
export const PanelSlot = ({ panel, registry, components, interactive, isLastPanel, onSetApp, onSplit, onRemove }: PanelSlotProps) => {
+ const { maximizedPanelId, transitioningPanelId } = useWorkspace();
+ const isMaximized = maximizedPanelId === panel.id;
+
const rawPanelComponent = components?.[panel.id];
const panelEntry = rawPanelComponent && isPanelEntry(rawPanelComponent) ? rawPanelComponent : null;
const PanelComponent = panelEntry ? panelEntry.component : (rawPanelComponent as ComponentType | undefined);
@@ -144,9 +209,6 @@ export const PanelSlot = ({ panel, registry, components, interactive, isLastPane
<>
- {/* TODO: re-enable when drag-to-reposition is polished */}
- {/* */}
- {/* */}
>
) : null;
@@ -180,67 +242,93 @@ export const PanelSlot = ({ panel, registry, components, interactive, isLastPane
);
}
- // App with header — render header chrome + body
- if (HeaderComponent) {
- const headerBar = (
-
-
- {onClose && (
-
- )}
-
- );
+ // All apps get a chrome header — custom HeaderComponent or default from registry icon+name
+ const DefaultHeader = entry ? () => (
+ <>
+
+ {entry.name}
+ >
+ ) : null;
- const body = (
-
- );
+ const ResolvedHeader = HeaderComponent ?? DefaultHeader;
- const inner = ProviderComponent ? (
-
- {headerBar}
- {body}
-
- ) : (
- <>
- {headerBar}
- {body}
- >
- );
+ const trafficLights = interactive ? (
+ onSetApp(panel.id, null)} />
+ ) : null;
- return contextMenu(
-
-
+ {ResolvedHeader && }
+ {onClose && (
+
+ )}
+ {trafficLights}
+
+ );
+
+ const headerBar = interactive ? (
+
onSetApp(panel.id, null)}>
+ {headerContent}
+
+ ) : headerContent;
+
+ const body = (
+
+ );
+
+ const inner = ProviderComponent ? (
+
+ {headerBar}
+ {body}
+
+ ) : (
+ <>
+ {headerBar}
+ {body}
+ >
+ );
+
+ if (isMaximized) {
+ return (
+ <>
+ {/* Placeholder to preserve layout space */}
+
- {overlays}
-
,
+ {/* Maximized overlay — portalled to escape stacking contexts */}
+ {createPortal(
+ ,
+ document.body,
+ )}
+ >
);
}
- // Default: no header
- return contextMenu(
+ return (
,
+
);
};
diff --git a/src/workspaces/components/Workspace/WorkspaceContext.ts b/src/workspaces/components/Workspace/WorkspaceContext.ts
index 42527d45..354dd4fd 100644
--- a/src/workspaces/components/Workspace/WorkspaceContext.ts
+++ b/src/workspaces/components/Workspace/WorkspaceContext.ts
@@ -11,6 +11,9 @@ type WorkspaceContextValue = {
dragSourceId: string | null;
setDragSourceId: (id: string | null) => void;
onMove: (sourceId: string, targetId: string, position: DropPosition) => void;
+ maximizedPanelId: string | null;
+ setMaximizedPanelId: (id: string | null) => void;
+ transitioningPanelId: string | null;
};
const noop = () => {};
@@ -24,6 +27,9 @@ const WorkspaceContext = createContext({
dragSourceId: null,
setDragSourceId: noop,
onMove: noop,
+ maximizedPanelId: null,
+ setMaximizedPanelId: noop,
+ transitioningPanelId: null,
});
export const WorkspaceProvider = WorkspaceContext.Provider;
diff --git a/src/workspaces/components/Workspace/WorkspaceLayout.tsx b/src/workspaces/components/Workspace/WorkspaceLayout.tsx
index 35627954..f8a33b64 100644
--- a/src/workspaces/components/Workspace/WorkspaceLayout.tsx
+++ b/src/workspaces/components/Workspace/WorkspaceLayout.tsx
@@ -24,7 +24,7 @@ export const WorkspaceLayout = ({ layout, onLayoutChange, registry, components,
);
return (
-
+
{
const [swapSourceId, setSwapSourceId] = useState(null);
const [dragSourceId, setDragSourceId] = useState(null);
+ const [maximizedPanelId, setMaximizedPanelId] = useState(null);
+ const [transitioningPanelId, setTransitioningPanelId] = useState(null);
+
+ const setMaximizedAnimated = useCallback((id: string | null) => {
+ const doc = document as Document & { startViewTransition?: (cb: () => void) => { finished: Promise } };
+ const panelId = maximizedPanelId ?? id;
+ if (doc.startViewTransition && panelId) {
+ setTransitioningPanelId(panelId);
+ requestAnimationFrame(() => {
+ const transition = doc.startViewTransition(() => flushSync(() => setMaximizedPanelId(id)));
+ transition.finished.finally(() => setTransitioningPanelId(null));
+ });
+ } else {
+ setMaximizedPanelId(id);
+ }
+ }, [maximizedPanelId]);
const handleSetApp = useCallback(
(panelId: string, appType: string | null) => {
@@ -72,16 +87,17 @@ export const WorkspaceView = ({ workspace, layout, onLayoutChange, registry }: W
);
useEffect(() => {
- if (!swapSourceId && !dragSourceId) return;
+ if (!swapSourceId && !dragSourceId && !maximizedPanelId) return;
const onKeyDown = (ev: KeyboardEvent) => {
if (ev.key === 'Escape') {
setSwapSourceId(null);
setDragSourceId(null);
+ setMaximizedAnimated(null);
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
- }, [swapSourceId, dragSourceId]);
+ }, [swapSourceId, dragSourceId, maximizedPanelId, setMaximizedAnimated]);
return (
;
diff --git a/src/workspaces/widgets/WidgetPanel/index.tsx b/src/workspaces/widgets/WidgetPanel/index.tsx
index 2750e17f..82b20a7e 100644
--- a/src/workspaces/widgets/WidgetPanel/index.tsx
+++ b/src/workspaces/widgets/WidgetPanel/index.tsx
@@ -219,7 +219,7 @@ export const WidgetPanel = ({ panelId }: { panelId: string }) => {
/>
))}
-