This commit is contained in:
2026-02-20 18:25:33 +00:00
parent ef1530b626
commit 25f5f74b1b
29 changed files with 1560 additions and 201 deletions
+4
View File
@@ -54,6 +54,10 @@ export function App() {
<Route path="/workspaces" element={<Dashboard.WorkspaceListScreen />} />
<Route path="/workspaces/new" element={<Dashboard.NewWorkspaceRedirect />} />
<Route path="/workspaces/:id" element={<Dashboard.WorkspaceScreen />} />
<Route path="/projects" element={<Dashboard.ProjectListScreen />} />
<Route path="/projects/new" element={<Dashboard.NewProjectRedirect />} />
<Route path="/projects/:id" element={<Dashboard.ProjectScreen />} />
<Route path="/terminal" element={<Dashboard.TerminalScreen />} />
<Route path="/auth/signout" element={<Authentication.SignoutScreen />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
@@ -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 },
],
};
@@ -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 (
<div className="relative overflow-hidden h-dvh outline-none inset-0">
<Header />
<section className="relative h-dvh snap-start overflow-hidden">
<Background />
<Dock items={dockItems} />
<Dock items={visibleItems} />
<div className="absolute inset-0 z-2 pt-[52px] md:pt-[64px] pb-2 overflow-y-auto">
{children}
</div>
@@ -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' },
];
@@ -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<ProjectType, string> = {
'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<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);
const [, setName] = useGlobal<string>(NEW_PROJ_NAME, '');
const [, setDescription] = useGlobal<string>(NEW_PROJ_DESC, '');
const [, setTemplateIdx] = useGlobal<number>(NEW_PROJ_TEMPLATE, 0);
const [, setProjectType] = useGlobal<ProjectType>(NEW_PROJ_TYPE, 'app');
const [, setHasBackend] = useGlobal<boolean>(NEW_PROJ_HAS_BACKEND, false);
const [, setHasAuth] = useGlobal<boolean>(NEW_PROJ_HAS_AUTH, false);
const [search, setSearch] = useState('');
const [deleting, setDeleting] = useState<ProjectDefinition | null>(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<Record<string, unknown>>(['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 (
<div className="flex flex-col h-full overflow-y-auto">
<div className="p-3 pb-0 flex flex-col gap-2">
<Link
to="/projects"
className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium bg-emerald-500/15 text-emerald-400"
>
<FolderKanban className="h-4 w-4" />
Projects
</Link>
<button
type="button"
onClick={() => {
setSelected(null);
setEditing(null);
setName(generateSlug());
setDescription('');
setTemplateIdx(0);
setProjectType('app');
setHasBackend(false);
setHasAuth(false);
setCreating(true);
if (!isProjectsPage) navigate('/projects');
}}
className="flex items-center justify-center gap-1 py-2 px-3 rounded-lg text-sm font-medium bg-emerald-500 hover:bg-emerald-500/90 text-white transition-all cursor-pointer"
>
<Plus className="h-4 w-4 shrink-0" />
New Project
</button>
</div>
<div className="relative px-3 pt-2">
<Search className="absolute left-6 top-1/2 h-3.5 w-3.5 text-gray-500 pointer-events-none" />
<input
type="text"
value={search}
onChange={(ev) => setSearch(ev.target.value)}
placeholder="Search 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"
/>
</div>
<div className="flex flex-col gap-0.5 px-3 pt-2">
{filtered.map((p) => (
<div
key={p.id}
className={`flex items-center gap-2.5 py-2 px-3 rounded-lg text-sm font-medium cursor-pointer group ${
isProjectsPage && selected === p.id
? 'bg-emerald-500/10 text-emerald-400'
: 'text-white/80 hover:bg-duck-dark/5'
}`}
onClick={() => handleClick(p)}
>
<FolderKanban className="h-4 w-4 shrink-0" />
<span className="flex-1 text-left truncate">{p.name}</span>
<span className="text-[10px] text-duck-dark/30 shrink-0">{PROJECT_TYPE_LABELS[p.projectType]}</span>
{isProjectsPage && (
<>
<button
type="button"
onClick={(ev) => handleEdit(ev, p)}
className="shrink-0 p-1 rounded text-duck-dark/20 md:opacity-0 md:group-hover:opacity-100 hover:text-emerald-400 transition-opacity cursor-pointer"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={(ev) => handleDelete(ev, p)}
className="shrink-0 p-1 rounded text-duck-dark/20 md:opacity-0 md:group-hover:opacity-100 hover:text-red-500 transition-opacity cursor-pointer"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</>
)}
</div>
))}
{filtered.length === 0 && (
<p className="text-xs text-gray-500 px-3 py-4 text-center">
{search ? 'No matches' : 'No projects yet'}
</p>
)}
</div>
<AlertDialog open={deleting !== null} onOpenChange={(open) => { if (!open) setDeleting(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete project</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete <strong>{deleting?.name}</strong>? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={confirmDelete} className="bg-red-600 hover:bg-red-700">
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};
@@ -0,0 +1,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 <div className="h-full w-full bg-emerald-500/15 border border-emerald-500/20 rounded-sm" />;
}
const isH = node.direction === 'horizontal';
return (
<div className={`flex h-full w-full gap-1.5 ${isH ? 'flex-row' : 'flex-col'}`}>
{node.children.map((child) => (
<div key={child.node.id} style={{ flex: child.size }} className="min-h-0 min-w-0">
<ThumbnailNode node={child.node} />
</div>
))}
</div>
);
};
const TemplateCell = ({ index }: { index: number }) => {
const tpl = templates[index]!;
const [selected, setSelected] = useGlobal<number>(NEW_PROJ_TEMPLATE, 0);
const node = tpl.layout();
const isSelected = selected === index;
return (
<div className="h-full w-full">
<button
type="button"
onClick={() => setSelected(index)}
className={`flex h-full w-full flex-col items-center justify-center gap-2 p-3 cursor-pointer transition-colors ${
isSelected ? 'bg-emerald-500/10' : 'hover:bg-duck-dark/5'
}`}
>
<div className="w-full flex-1 min-h-0 rounded border border-duck-dark/20 overflow-hidden">
<ThumbnailNode node={node} />
</div>
<span className={`text-xs font-medium ${isSelected ? 'text-emerald-400' : 'text-duck-dark/60'}`}>{tpl.name}</span>
</button>
</div>
);
};
// --- 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: () => <TemplateCell index={i} /> }]),
);
const TemplatePanel = () => (
<WorkspaceLayout layout={tplPanelLayout} onLayoutChange={() => {}} 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<string>(NEW_PROJ_NAME, '');
const [description, setDescription] = useGlobal<string>(NEW_PROJ_DESC, '');
const [projectType, setProjectType] = useGlobal<ProjectType>(NEW_PROJ_TYPE, 'app');
const [hasBackend, setHasBackend] = useGlobal<boolean>(NEW_PROJ_HAS_BACKEND, false);
const [hasAuth, setHasAuth] = useGlobal<boolean>(NEW_PROJ_HAS_AUTH, false);
return (
<div className="h-full w-full overflow-y-auto">
<div className="flex h-full flex-col gap-4 p-4">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-duck-dark/50 flex items-center gap-1.5">
<Type className="h-3 w-3" />
Name
</label>
<input
type="text"
value={name}
onChange={(ev) => setName(ev.target.value)}
placeholder="My 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"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-duck-dark/50 flex items-center gap-1.5">
<FileText className="h-3 w-3" />
Description
</label>
<textarea
value={description}
onChange={(ev) => setDescription(ev.target.value)}
placeholder="What is this project for?"
rows={3}
className="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-emerald-500/30 focus:border-emerald-500/50"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-duck-dark/50">Project Type</label>
<div className="flex gap-2">
{PROJECT_TYPE_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setProjectType(opt.value)}
className={`flex-1 py-1.5 px-2 rounded-lg text-xs font-medium border transition-colors cursor-pointer ${
projectType === opt.value
? 'bg-emerald-500/15 border-emerald-500/40 text-emerald-400'
: 'border-duck-dark/20 text-duck-dark/50 hover:border-duck-dark/30'
}`}
>
{opt.label}
</button>
))}
</div>
</div>
{projectType === 'app' && (
<div className="flex flex-col gap-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={hasBackend}
onChange={(ev) => setHasBackend(ev.target.checked)}
className="accent-emerald-500"
/>
<span className="text-xs text-duck-dark/60">Has Backend</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={hasAuth}
onChange={(ev) => setHasAuth(ev.target.checked)}
className="accent-emerald-500"
/>
<span className="text-xs text-duck-dark/60">Has Auth</span>
</label>
</div>
)}
</div>
</div>
);
};
// --- Panel: Create ---
const CreatePanel = () => {
const navigate = useNavigate();
const queryClient = useQueryClient();
const client = useClient();
const [name, setName] = useGlobal<string>(NEW_PROJ_NAME, '');
const [projects, setProjects] = useProjectsState<ProjectDefinition[]>('projects', []);
const [description, setDescription] = useGlobal<string>(NEW_PROJ_DESC, '');
const [templateIdx, setTemplateIdx] = useGlobal<number>(NEW_PROJ_TEMPLATE, 0);
const [previewLayout] = useGlobal<LayoutNode>(NEW_PROJ_PREVIEW_LAYOUT, createDefaultLayout());
const [projectType] = useGlobal<ProjectType>(NEW_PROJ_TYPE, 'app');
const [hasBackend] = useGlobal<boolean>(NEW_PROJ_HAS_BACKEND, false);
const [hasAuth] = useGlobal<boolean>(NEW_PROJ_HAS_AUTH, false);
const [editingId, setEditingId] = useGlobal<string | null>(EDITING_PROJECT, null);
const [, setCreating] = useGlobal<boolean>(CREATING_PROJECT, false);
const [, setSelected] = useGlobal<string | null>(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<Record<string, unknown>>(['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<string, unknown> = { [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<Record<string, unknown>>(['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 (
<div className="h-full w-full">
<div className="flex h-full flex-col items-center justify-center gap-4 p-4">
<Rocket className="h-8 w-8 text-emerald-500/40" />
<div className="text-center">
<p className="text-sm font-medium text-duck-dark/70">{name.trim() || 'Untitled'}</p>
<p className="text-xs font-mono text-duck-dark/40 mt-1">projects/{slug}</p>
</div>
<Button
onClick={handleSubmit}
disabled={!name.trim()}
className="bg-emerald-500 hover:bg-emerald-500/90 cursor-pointer disabled:opacity-40"
>
<Plus className="h-4 w-4 mr-1" />
{isEditing ? 'Update Project' : 'Create Project'}
</Button>
{isEditing && (
<Button
onClick={() => navigate(`/projects/${editingId}`)}
variant="outline"
className="cursor-pointer"
>
<ArrowRight className="h-4 w-4 mr-1" />
Open Project
</Button>
)}
</div>
</div>
);
};
// --- 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<LayoutNode>(() => {
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 <WorkspaceView workspace={null} layout={layout} onLayoutChange={handleLayoutChange} registry={appRegistry} />;
};
const TemplatePreviewPanel = () => {
const [selectedIdx] = useGlobal<number>(NEW_PROJ_TEMPLATE, 0);
return <TemplatePreviewInner key={selectedIdx} templateIdx={selectedIdx} />;
};
// --- 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 = () => (
<WorkspaceLayout layout={newProjLayout} onLayoutChange={() => {}} registry={newProjRegistry} />
);
const ProjectPreviewEmpty = () => {
const [creating, setCreating] = useGlobal<boolean>(CREATING_PROJECT, false);
const [editingId] = useGlobal<string | null>(EDITING_PROJECT, null);
const [, setName] = useGlobal<string>(NEW_PROJ_NAME, '');
const [, setDescription] = useGlobal<string>(NEW_PROJ_DESC, '');
const [, setTemplateIdx] = useGlobal<number>(NEW_PROJ_TEMPLATE, 0);
const [, setProjectType] = useGlobal<ProjectType>(NEW_PROJ_TYPE, 'app');
const [, setHasBackend] = useGlobal<boolean>(NEW_PROJ_HAS_BACKEND, false);
const [, setHasAuth] = useGlobal<boolean>(NEW_PROJ_HAS_AUTH, false);
if (creating || editingId) return <NewProjectForm />;
const handleCreate = () => {
setName(generateSlug());
setDescription('');
setTemplateIdx(0);
setProjectType('app');
setHasBackend(false);
setHasAuth(false);
setCreating(true);
};
return (
<div className="h-full w-full">
<div className="flex h-full w-full flex-col items-center justify-center gap-4">
<FolderKanban className="h-10 w-10 text-white/80" />
<p className="text-sm text-white/80">Select a project or create a new one</p>
<Button onClick={handleCreate} className="bg-emerald-500 hover:bg-emerald-500/90 cursor-pointer">
<Plus className="h-4 w-4 mr-1" />
New Project
</Button>
</div>
</div>
);
};
const ProjectPreviewInner = ({ project }: { project: ProjectDefinition }) => {
const [layout, setLayout] = useProjectsState<LayoutNode>(`proj-layout-${project.id}`, createDefaultLayout());
const workspace = { id: project.id, name: project.name, cwd: project.cwd };
return <WorkspaceView workspace={workspace} layout={layout} onLayoutChange={setLayout} registry={appRegistry} />;
};
const ProjectPreview = () => {
const [selectedId] = useGlobal<string | null>(SELECTED_PROJECT, null);
const [projects] = useProjectsState<ProjectDefinition[]>('projects', []);
const project = selectedId ? projects.find((p) => p.id === selectedId) : null;
if (!project) return <ProjectPreviewEmpty />;
return (
<div className="relative h-full w-full">
<ProjectPreviewInner key={project.id} project={project} />
<Link
to={`/projects/${project.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-emerald-500/0 group-hover:text-emerald-500/60 transition-colors" />
</Link>
</div>
);
};
const projHomeRegistry = {
...appRegistry,
'project-list': { name: 'Projects', icon: FolderKanban, component: () => <ProjectListApp /> },
'project-preview': { name: 'Project Preview', icon: FolderKanban, component: ProjectPreview },
};
export const ProjectListScreen = () => {
const [layout, setLayout, isLoaded] = useProjectsState<LayoutNode>('proj-layout-proj-homepage', defaultLayout);
if (!isLoaded) return null;
return (
<div className="h-full w-full">
<WorkspaceLayout layout={layout} onLayoutChange={setLayout} registry={projHomeRegistry} />
</div>
);
};
export const NewProjectRedirect = () => {
const navigate = useNavigate();
const [params] = useSearchParams();
const [, setName] = useGlobal<string>(NEW_PROJ_NAME, '');
const [, setDescription] = useGlobal<string>(NEW_PROJ_DESC, '');
const [, setTemplateIdx] = useGlobal<number>(NEW_PROJ_TEMPLATE, 0);
const [, setCreating] = useGlobal<boolean>(CREATING_PROJECT, false);
const [, setEditing] = useGlobal<string | null>(EDITING_PROJECT, null);
const [, setSelected] = useGlobal<string | null>(SELECTED_PROJECT, null);
const [, setProjectType] = useGlobal<ProjectType>(NEW_PROJ_TYPE, 'app');
const [, setHasBackend] = useGlobal<boolean>(NEW_PROJ_HAS_BACKEND, false);
const [, setHasAuth] = useGlobal<boolean>(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;
};
@@ -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<ProjectDefinition[]>('projects', []);
const project = projects.find((p) => p.id === id);
if (!isLoaded) return null;
if (!project) return <Navigate to="/projects" replace />;
return <ProjectScreenInner project={project} />;
};
const ProjectScreenInner = ({ project }: { project: ProjectDefinition }) => {
const [layout, setLayout] = useProjectsState<LayoutNode>(`proj-layout-${project.id}`, createDefaultLayout());
const workspace = { id: project.id, name: project.name, cwd: project.cwd };
return (
<div className="h-full w-full">
<WorkspaceView
workspace={workspace}
layout={layout}
onLayoutChange={setLayout}
registry={appRegistry}
/>
</div>
);
};
@@ -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';
@@ -0,0 +1,2 @@
export { ProjectListScreen, NewProjectRedirect } from './ProjectListScreen';
export { ProjectScreen } from './ProjectScreen';
@@ -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<LayoutNode>(defaultLayout);
if (user?.role !== 'Super Admin') return <Navigate to="/" replace />;
return (
<div className="h-full w-full">
<WorkspaceView workspace={null} layout={layout} onLayoutChange={setLayout} registry={appRegistry} />
</div>
);
};
@@ -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 = () => <CodeEditorView className="h-full w-full" />;
const TerminalHeader = () => {
const { cwd } = useWorkspace();
return (
<>
<TerminalSquare className="h-3.5 w-3.5 shrink-0" />
<span className="text-xs font-medium shrink-0">Terminal</span>
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
</>
);
};
const HostTerminalHeader = () => {
const { cwd } = useWorkspace();
return (
<>
<Monitor className="h-3.5 w-3.5 shrink-0" />
<span className="text-xs font-medium shrink-0">Terminal</span>
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
</>
);
};
const CodeEditorHeader = () => {
const { cwd } = useWorkspace();
return (
<>
<Code className="h-3.5 w-3.5 shrink-0" />
<span className="text-xs font-medium shrink-0">Code Editor</span>
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
</>
);
};
const EMPTY_TERMINALS: Record<string, string> = {};
const TerminalWrapper = ({ panelId }: { panelId: string }) => {
@@ -105,6 +139,67 @@ const HostTerminalWrapper = ({ panelId }: { panelId: string }) => {
return <TerminalView className="h-full w-full p-2" sessionId={sessionId} sandboxed={false} cwd={cwd} />;
};
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<Record<string, string>>(stateKey, EMPTY_TERMINALS);
const setTerminalsRef = useRef(setTerminals);
setTerminalsRef.current = setTerminals;
const sessionId = terminals[panelId];
useEffect(() => {
if (!sessionId) {
setTerminals((prev) => ({ ...prev, [panelId]: crypto.randomUUID() }));
}
}, [panelId, sessionId, setTerminals]);
useEffect(() => {
return () => {
setTerminalsRef.current((prev) => {
const { [panelId]: _, ...rest } = prev;
return rest;
});
};
}, [panelId]);
if (!sessionId) return null;
const fullCommand = cwd && cwd !== '~' ? `cd ${cwd} && ${command}` : command;
return <TerminalView className="h-full w-full p-2" sessionId={sessionId} cwd={cwd} initialInput={fullCommand} />;
};
const TmuxWrapper = ({ panelId }: { panelId: string }) => (
<CommandTerminalWrapper panelId={panelId} command="tmux" statePrefix="tmux" />
);
const NvimWrapper = ({ panelId }: { panelId: string }) => (
<CommandTerminalWrapper panelId={panelId} command="nvim" statePrefix="nvim" />
);
const TmuxHeader = () => {
const { cwd } = useWorkspace();
return (
<>
<Columns2 className="h-3.5 w-3.5 shrink-0" />
<span className="text-xs font-medium shrink-0">Tmux</span>
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
</>
);
};
const NvimHeader = () => {
const { cwd } = useWorkspace();
return (
<>
<PenLine className="h-3.5 w-3.5 shrink-0" />
<span className="text-xs font-medium shrink-0">Neovim</span>
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
</>
);
};
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: () => <ChatHistory /> },
'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 /> },
'file-viewer': { name: 'File Viewer', icon: Eye, component: FileViewerBody, header: FileViewerHeader, provider: FileViewerWorkspaceProvider },
'chat-launcher': { name: 'Chat Launcher', icon: Sparkles, component: () => <ChatLauncher />, fixedHeight: 180 },
'chat-history': { name: 'Chat History', icon: History, component: () => <ChatHistory />, availableOnPanel: false },
'sound-library': { name: 'Sound Library', icon: Music, component: () => <Catalog />, 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: () => <WorkspaceListApp />, availableOnPanel: false },
'project-list': { name: 'Projects', icon: FolderKanban, component: () => <ProjectListApp />, 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: () => <ChatLauncher />, fixedHeight: 180, availableOnPanel: false },
'widget-panel': { name: 'Widget Panel', icon: LayoutDashboard, component: WidgetPanel, transparent: true },
...widgetRegistry,
};
@@ -16,3 +16,5 @@ export * from './Files';
export * from './ChatHistory';
export * from './CodeEditor';
export * from './Workspaces';
export * from './Projects';
export * from './Terminal';
@@ -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<T>(key: string, defaultValue: T): [T, (value: T | ((prev: T) => T)) => void, boolean] {
const client = useClient();
const { isAuthenticated } = useAuth();
const queryClient = useQueryClient();
const clientRef = useRef(client);
clientRef.current = client;
const { data: state = {}, isSuccess } = useQuery<UserState>({
queryKey: QUERY_KEY,
enabled: isAuthenticated,
queryFn: () => client.get<UserState>('/user/projects-state'),
staleTime: Infinity,
});
const value = key in state ? (state[key] as T) : defaultValue;
const setValue = useCallback(
(update: T | ((prev: T) => T)) => {
const currentState = queryClient.getQueryData<UserState>(QUERY_KEY) ?? {};
const currentValue = key in currentState ? (currentState[key] as T) : defaultValue;
const newValue = typeof update === 'function' ? (update as (prev: T) => T)(currentValue) : update;
queryClient.setQueryData(QUERY_KEY, { ...currentState, [key]: newValue });
clientRef.current
.patch<UserState>('/user/projects-state', { [key]: newValue })
.then((serverState) => {
if (serverState) queryClient.setQueryData(QUERY_KEY, serverState);
})
.catch(() => {});
},
[key, defaultValue, queryClient],
);
return [value, setValue, isSuccess];
}
+5 -1
View File
@@ -22,6 +22,8 @@ type WSData = {
sessionId?: string;
cwd?: string;
command?: string;
cols?: number;
rows?: number;
};
const handlers: Record<string, typeof piMonoWebsocket> = {
@@ -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 {
+128
View File
@@ -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<unknown | null> {
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<string, unknown>) {
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<Record<string, unknown>> {
const result: Record<string, unknown> = {};
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<string, unknown>;
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);
});
@@ -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
@@ -70,4 +70,3 @@ alias setupmines="WINEPREFIX=~/wine/minesweeper winecfg"
alias httpserver="python -m http.server 8888"
clear
fortune | cowsay
+5 -1
View File
@@ -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<WSData>;
@@ -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,
}),
);
+2
View File
@@ -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');
+2
View File
@@ -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);
+119 -115
View File
@@ -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<TerminalTheme> = {
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<HTMLDivElement>(null);
const termRef = useRef<XTerm | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const isMounted = useMounted();
const onReadyRef = useRef<TerminalViewProps['onReady']>(onReady);
const onExitRef = useRef<TerminalViewProps['onExit']>(onExit);
const onCommandDoneRef = useRef<TerminalViewProps['onCommandDone']>(onCommandDone);
const onDisconnectRef = useRef<TerminalViewProps['onDisconnect']>(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,
@@ -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 (
<div className="grid grid-cols-3 gap-2 max-w-xs">
<div className="flex flex-wrap gap-1.5 max-w-md">
{entries.map(([key, entry]) => (
<button
key={key}
type="button"
className="flex flex-col items-center gap-1.5 rounded-lg border border-duck-teal/25 bg-background/80 backdrop-blur-sm px-3 py-3 text-duck-teal hover:border-duck-teal/40 hover:bg-background/90 transition-colors cursor-pointer"
className="flex items-center gap-1.5 rounded-full border border-duck-teal/25 bg-background/80 backdrop-blur-sm px-3 py-1.5 text-duck-teal hover:border-duck-teal/40 hover:bg-background/90 transition-colors cursor-pointer"
onClick={() => onSelect(key)}
>
<entry.icon className="h-5 w-5" />
<span className="text-xs font-medium leading-tight text-center">{entry.name}</span>
<entry.icon className="h-3.5 w-3.5 shrink-0" />
<span className="text-xs font-medium whitespace-nowrap">{entry.name}</span>
</button>
))}
</div>
+142 -54
View File
@@ -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 (
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={() => setMaximizedPanelId(isMaximized ? null : panelId)}>
{isMaximized ? 'Restore' : 'Maximize'}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => onSplit(panelId, 'horizontal')}>Split horizontal</ContextMenuItem>
<ContextMenuItem onClick={() => onSplit(panelId, 'vertical')}>Split vertical</ContextMenuItem>
{hasApp && <ContextMenuItem onClick={onClearApp}>Clear app</ContextMenuItem>}
@@ -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 (
<div className="flex items-center gap-1.5 shrink-0 ml-auto">
<button
type="button"
onClick={handleRestore}
className="group/btn h-3 w-3 rounded-full bg-[#febc2e] hover:brightness-90 transition-all cursor-pointer flex items-center justify-center"
title="Restore"
>
<Minus className="h-2 w-2 text-[#5f4a00] opacity-0 group-hover/btn:opacity-100 transition-opacity" strokeWidth={3} />
</button>
</div>
);
}
return (
<div className="flex items-center gap-1.5 shrink-0 ml-auto">
<button
type="button"
onClick={handleClose}
className="group/btn h-3 w-3 rounded-full bg-[#ff5f57] hover:brightness-90 transition-all cursor-pointer flex items-center justify-center"
title={isLastPanel ? 'Clear app' : 'Close panel'}
>
<X className="h-2 w-2 text-[#4a0002] opacity-0 group-hover/btn:opacity-100 transition-opacity" strokeWidth={3} />
</button>
<button
type="button"
onClick={handleMaximize}
className="group/btn h-3 w-3 rounded-full bg-[#28c840] hover:brightness-90 transition-all cursor-pointer flex items-center justify-center"
title="Maximize"
>
<svg viewBox="0 0 10 10" className="h-1.5 w-1.5 text-[#006500] opacity-0 group-hover/btn:opacity-100 transition-opacity">
<path d="M0 3.5L5 0L10 3.5V10H0Z" fill="currentColor" />
</svg>
</button>
</div>
);
};
// 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
<>
<SwapOverlay panelId={panel.id} />
<SwapSourceIndicator panelId={panel.id} />
{/* TODO: re-enable when drag-to-reposition is polished */}
{/* <DragHandle panelId={panel.id} /> */}
{/* <DragSourceDimmer panelId={panel.id} /> */}
</>
) : null;
@@ -180,67 +242,93 @@ export const PanelSlot = ({ panel, registry, components, interactive, isLastPane
);
}
// App with header — render header chrome + body
if (HeaderComponent) {
const headerBar = (
<div className="shrink-0 flex items-center gap-2 px-3 py-1.5 border-b border-black/10 text-black font-semibold">
<HeaderComponent panelId={panel.id} />
{onClose && (
<button
onClick={onClose}
className="p-1 rounded hover:bg-black/10 transition-colors cursor-pointer"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
);
// All apps get a chrome header — custom HeaderComponent or default from registry icon+name
const DefaultHeader = entry ? () => (
<>
<entry.icon className="h-3.5 w-3.5 shrink-0" />
<span className="text-xs font-medium truncate flex-1">{entry.name}</span>
</>
) : null;
const body = (
<div className="flex-1 min-h-0">
<Card className="h-full w-full overflow-hidden p-0 rounded-none border-0 shadow-none">
<AppComponent panelId={panel.id} />
</Card>
</div>
);
const ResolvedHeader = HeaderComponent ?? DefaultHeader;
const inner = ProviderComponent ? (
<ProviderComponent panelId={panel.id}>
{headerBar}
{body}
</ProviderComponent>
) : (
<>
{headerBar}
{body}
</>
);
const trafficLights = interactive ? (
<TrafficLights panelId={panel.id} isLastPanel={isLastPanel} onRemove={onRemove} onClearApp={() => onSetApp(panel.id, null)} />
) : null;
return contextMenu(
<div data-panel-id={panel.id} className="group/panel relative h-full w-full p-1">
<div
className="relative h-full w-full overflow-hidden rounded-lg border backdrop-blur-xl p-2 flex flex-col"
style={{ backgroundColor: 'rgba(255, 255, 255, 0.12)', borderColor: 'rgba(255, 255, 255, 0.2)' }}
const headerContent = (
<div className="shrink-0 flex items-center gap-2 px-3 py-1.5 border-b border-black/10 text-black font-semibold">
{ResolvedHeader && <ResolvedHeader panelId={panel.id} />}
{onClose && (
<button
onClick={onClose}
className="p-1 rounded hover:bg-black/10 transition-colors cursor-pointer"
>
{inner}
<X className="h-3.5 w-3.5" />
</button>
)}
{trafficLights}
</div>
);
const headerBar = interactive ? (
<PanelContextMenu panelId={panel.id} hasApp={!!AppComponent} isLastPanel={isLastPanel} onSplit={onSplit} onRemove={onRemove} onClearApp={() => onSetApp(panel.id, null)}>
{headerContent}
</PanelContextMenu>
) : headerContent;
const body = (
<div className="flex-1 min-h-0">
<Card className="h-full w-full overflow-hidden p-0 rounded-none border-0 shadow-none">
<AppComponent panelId={panel.id} />
</Card>
</div>
);
const inner = ProviderComponent ? (
<ProviderComponent panelId={panel.id}>
{headerBar}
{body}
</ProviderComponent>
) : (
<>
{headerBar}
{body}
</>
);
if (isMaximized) {
return (
<>
{/* Placeholder to preserve layout space */}
<div data-panel-id={panel.id} className="h-full w-full p-1">
<div className="h-full w-full rounded-lg border border-dashed border-white/20" />
</div>
{overlays}
</div>,
{/* Maximized overlay — portalled to escape stacking contexts */}
{createPortal(
<div className="fixed inset-0 z-50 p-2">
<div
className="relative h-full w-full overflow-hidden rounded-lg border backdrop-blur-xl p-2 flex flex-col shadow-2xl"
style={{ backgroundColor: 'rgba(30, 30, 30, 0.95)', borderColor: 'rgba(255, 255, 255, 0.2)', ...(transitioningPanelId === panel.id ? { viewTransitionName: `panel-${panel.id}` } : {}) } as React.CSSProperties}
>
{inner}
</div>
</div>,
document.body,
)}
</>
);
}
// Default: no header
return contextMenu(
return (
<div data-panel-id={panel.id} className="group/panel relative h-full w-full p-1">
<div
className="relative h-full w-full overflow-hidden rounded-lg border backdrop-blur-xl p-2"
style={{ backgroundColor: 'rgba(255, 255, 255, 0.12)', borderColor: 'rgba(255, 255, 255, 0.2)' }}
className="relative h-full w-full overflow-hidden rounded-lg border backdrop-blur-xl p-2 flex flex-col"
style={{ backgroundColor: 'rgba(255, 255, 255, 0.12)', borderColor: 'rgba(255, 255, 255, 0.2)', ...(transitioningPanelId === panel.id ? { viewTransitionName: `panel-${panel.id}` } : {}) } as React.CSSProperties}
>
<Card className="h-full w-full overflow-hidden p-0 [&>*]:!h-full [&>*]:!flex [&>*]:!flex-col [&>*]:!rounded-none [&>*]:!border-0 [&>*]:!shadow-none [&>*>*:last-child]:!flex-1 [&>*>*:last-child]:!min-h-0 [&>*>*:last-child]:!max-h-none [&>*>*:last-child]:!overflow-auto">
<AppComponent panelId={panel.id} />
</Card>
{inner}
</div>
{overlays}
</div>,
</div>
);
};
@@ -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<WorkspaceContextValue>({
dragSourceId: null,
setDragSourceId: noop,
onMove: noop,
maximizedPanelId: null,
setMaximizedPanelId: noop,
transitioningPanelId: null,
});
export const WorkspaceProvider = WorkspaceContext.Provider;
@@ -24,7 +24,7 @@ export const WorkspaceLayout = ({ layout, onLayoutChange, registry, components,
);
return (
<WorkspaceProvider value={{ workspaceId: workspaceId ?? null, cwd: cwd ?? '~', swapSourceId: null, setSwapSourceId: noop, onSwap: noop, dragSourceId: null, setDragSourceId: noop, onMove: noop }}>
<WorkspaceProvider value={{ workspaceId: workspaceId ?? null, cwd: cwd ?? '~', swapSourceId: null, setSwapSourceId: noop, onSwap: noop, dragSourceId: null, setDragSourceId: noop, onMove: noop, maximizedPanelId: null, setMaximizedPanelId: noop, transitioningPanelId: null }}>
<WorkspaceRenderer
layout={layout}
registry={registry}
@@ -1,11 +1,10 @@
import { useState, useCallback, useEffect } from 'react';
import { flushSync } from 'react-dom';
import type { LayoutNode, WorkspaceDefinition, AppRegistry } from './types';
import type { DropPosition } from './layout-utils';
import { splitPanel, removePanel, setApp, updateSizes, swapPanels, movePanel, countPanels } from './layout-utils';
import { WorkspaceProvider } from './WorkspaceContext';
import { WorkspaceRenderer } from './WorkspaceRenderer';
// TODO: re-enable when drag-to-reposition is polished
// import { DragOverlay } from './DragOverlay';
type WorkspaceViewProps = {
workspace: WorkspaceDefinition | null;
@@ -17,6 +16,22 @@ type WorkspaceViewProps = {
export const WorkspaceView = ({ workspace, layout, onLayoutChange, registry }: WorkspaceViewProps) => {
const [swapSourceId, setSwapSourceId] = useState<string | null>(null);
const [dragSourceId, setDragSourceId] = useState<string | null>(null);
const [maximizedPanelId, setMaximizedPanelId] = useState<string | null>(null);
const [transitioningPanelId, setTransitioningPanelId] = useState<string | null>(null);
const setMaximizedAnimated = useCallback((id: string | null) => {
const doc = document as Document & { startViewTransition?: (cb: () => void) => { finished: Promise<void> } };
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 (
<WorkspaceProvider
@@ -94,6 +110,9 @@ export const WorkspaceView = ({ workspace, layout, onLayoutChange, registry }: W
dragSourceId,
setDragSourceId: startDrag,
onMove: handleMove,
maximizedPanelId,
setMaximizedPanelId: setMaximizedAnimated,
transitioningPanelId,
}}
>
<WorkspaceRenderer
+1 -1
View File
@@ -1,4 +1,4 @@
export type { LayoutNode, LayoutGroup, LayoutPanel, WorkspaceDefinition, AppRegistry, AppRegistryEntry, PanelComponents, PanelComponentEntry } from './types';
export type { LayoutNode, LayoutGroup, LayoutPanel, WorkspaceDefinition, ProjectType, ProjectDefinition, AppRegistry, AppRegistryEntry, PanelComponents, PanelComponentEntry } from './types';
export type { DropPosition } from './layout-utils';
export { createDefaultLayout, splitPanel, removePanel, setApp, updateSizes, swapPanels, movePanel, pruneEmptyPanels, countPanels, hasAnyApp } from './layout-utils';
export { WorkspaceProvider, useWorkspace } from './WorkspaceContext';
@@ -24,6 +24,20 @@ export type WorkspaceDefinition = {
templateIdx?: number;
};
export type ProjectType = 'landing-page' | 'website' | 'app';
export type ProjectDefinition = {
id: string;
name: string;
cwd: string;
description?: string;
projectType: ProjectType;
hasBackend?: boolean;
hasAuth?: boolean;
gitRepo?: string;
templateIdx?: number;
};
export type AppRegistryEntry = {
name: string;
icon: LucideIcon;
@@ -33,6 +47,7 @@ export type AppRegistryEntry = {
transparent?: boolean;
fixedHeight?: number;
widget?: boolean;
availableOnPanel?: boolean;
};
export type AppRegistry = Record<string, AppRegistryEntry>;
+1 -1
View File
@@ -219,7 +219,7 @@ export const WidgetPanel = ({ panelId }: { panelId: string }) => {
/>
))}
<div className="absolute bottom-4 right-4 flex flex-col items-end">
<div className={`absolute bottom-4 right-4 flex flex-col items-end transition-opacity ${showPicker ? 'opacity-100' : 'opacity-0 group-hover/panel:opacity-100'}`}>
{showPicker && (
<div className="mb-2 rounded-xl border border-border bg-card p-3 shadow-lg">
<WidgetPicker onSelect={addWidget} />