This commit is contained in:
2026-02-22 01:50:15 +00:00
parent 23b369c7e1
commit ab03b175e7
40 changed files with 829 additions and 956 deletions
+2 -1
View File
@@ -51,7 +51,8 @@ export function App() {
<Route path="/tasks" element={<Dashboard.Tasks />} /> <Route path="/tasks" element={<Dashboard.Tasks />} />
<Route path="/processes" element={<Dashboard.Processes />} /> <Route path="/processes" element={<Dashboard.Processes />} />
<Route path="/task-logs" element={<Dashboard.TaskLogs />} /> <Route path="/task-logs" element={<Dashboard.TaskLogs />} />
<Route path="/workspaces" element={<Dashboard.WorkspaceScreen />} /> <Route path="/workspaces" element={<Dashboard.WorkspacesScreen />} />
<Route path="/workspaces/:id" element={<Dashboard.WorkspaceScreen />} />
<Route path="/projects" element={<Dashboard.ProjectListScreen />} /> <Route path="/projects" element={<Dashboard.ProjectListScreen />} />
<Route path="/projects/new" element={<Dashboard.NewProjectRedirect />} /> <Route path="/projects/new" element={<Dashboard.NewProjectRedirect />} />
<Route path="/projects/:id" element={<Dashboard.ProjectScreen />} /> <Route path="/projects/:id" element={<Dashboard.ProjectScreen />} />
@@ -0,0 +1,11 @@
import type { LayoutNode } from '@/components/Workspace';
export const defaultLayout: LayoutNode = {
type: 'group',
id: 'chat-history-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'chat-list', appType: 'chat-session-list' }, size: 35 },
{ node: { type: 'panel', id: 'chat-detail', appType: 'chat-detail' }, size: 65 },
],
};
@@ -1,25 +1,14 @@
import { useMemo, useEffect } from 'react'; import { useEffect } from 'react';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
import type { LayoutNode, PanelComponents } from '@/components/Workspace'; import { WorkspaceView } from '@/components/Workspace';
import { WorkspaceLayout } from '@/components/Workspace'; import type { LayoutNode } from '@/components/Workspace';
import { useWorkspacesState } from 'state/useWorkspacesState';
import { usePanelChannel } from 'hooks/usePanelChannel'; import { usePanelChannel } from 'hooks/usePanelChannel';
import { useChatSessions } from 'state/useChatSessions'; import { useChatSessions } from 'state/useChatSessions';
import type { SelectedSession } from 'officerdev';
import { SessionList } from './Screen'; import { defaultLayout } from './defaultLayout';
import { ChatDetailPanel, type SelectedSession } from './ChatDetailPanel';
export { ChatHistory as ChatHistoryApp } from './Widget'; export { ChatHistory as ChatHistoryApp } from './Widget';
export { SessionList };
const layout: LayoutNode = {
type: 'group',
id: 'chat-history-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'chat-list', appType: null }, size: 35 },
{ node: { type: 'panel', id: 'chat-detail', appType: null }, size: 65 },
],
};
type SessionListPageProps = { type SessionListPageProps = {
isNew?: boolean; isNew?: boolean;
@@ -29,6 +18,7 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
const { sessionId } = useParams<{ sessionId: string }>(); const { sessionId } = useParams<{ sessionId: string }>();
const { sessions } = useChatSessions(); const { sessions } = useChatSessions();
const [, setSelected] = usePanelChannel<SelectedSession>('chat:selected-session', null); const [, setSelected] = usePanelChannel<SelectedSession>('chat:selected-session', null);
const workspace = useWorkspacesState<LayoutNode>('screens/chat', defaultLayout);
useEffect(() => { useEffect(() => {
if (isNew) { if (isNew) {
@@ -40,17 +30,9 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
setSelected({ id: sessionId, model: session?.model ?? null }); setSelected({ id: sessionId, model: session?.model ?? null });
}, [sessionId, isNew]); }, [sessionId, isNew]);
const panelComponents: PanelComponents = useMemo(
() => ({
'chat-list': SessionList,
'chat-detail': ChatDetailPanel,
}),
[],
);
return ( return (
<div className="h-full w-full pt-2"> <div className="h-full w-full pt-2">
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} components={panelComponents} /> <WorkspaceView workspace={workspace} />
</div> </div>
); );
}; };
@@ -1,81 +0,0 @@
import { useRef, useEffect, useState } from 'react';
import { useNavigate, useLocation } from 'react-router';
import { useChatSessions } from 'state/useChatSessions';
import { SessionBar, EmbeddableChat, type UsePiChatType, type Attachment } from 'officerdev';
import { Card } from '@/components/Card';
export type { Attachment };
type ChatPanelProps = {
chat: UsePiChatType;
};
export const ChatScreen = ({ chat }: ChatPanelProps) => {
const { isConnected, isGenerating, sessionId, setSelectedModel, sendPrompt } = chat;
const location = useLocation();
const navigate = useNavigate();
const [fullscreen, setFullscreen] = useState(false);
const { sessions, deleteSession } = useChatSessions();
const sessionTitle = sessionId ? sessions.find((s) => s.id === sessionId)?.title : undefined;
const listPath = '/chat';
// Capture initial state from navigation
const locationState = location.state as {
initialMessage?: string;
prefillInput?: string;
model?: string;
cwd?: { root?: string; path: string };
attachmentIds?: string[];
images?: { filename: string; dataUrl: string }[];
} | null;
const initialMessage = locationState?.initialMessage
? {
text: locationState.initialMessage,
attachmentIds: locationState.attachmentIds,
images: locationState.images,
cwd: locationState.cwd,
}
: undefined;
const defaultInput = locationState?.prefillInput ?? '';
const initialModel = locationState?.model ?? null;
// Clear location state after capturing
useEffect(() => {
if (locationState) {
window.history.replaceState({}, '', location.pathname);
}
}, []);
return (
<Card
className={`flex flex-col overflow-hidden transition-all duration-200 rounded-none border-0 md:rounded-xl md:border-2 ${fullscreen ? 'fixed inset-0 m-auto z-50 w-[90vw] h-[calc(90vh-4.5rem)]' : 'h-full'
}`}
>
<SessionBar
listPath={listPath}
sessionTitle={sessionTitle}
isConnected={isConnected}
isGenerating={isGenerating}
fullscreen={fullscreen}
onDelete={async () => {
if (!sessionId) return;
await deleteSession(sessionId);
navigate(listPath);
}}
onToggleFullscreen={() => setFullscreen((f) => !f)}
/>
<EmbeddableChat
sessionId={sessionId ?? undefined}
initialModel={initialModel}
initialMessage={initialMessage}
defaultInput={defaultInput}
className="flex-1 min-h-0"
/>
</Card>
);
};
@@ -1 +0,0 @@
export * from './ChatScreen';
@@ -1,10 +0,0 @@
import { CodeEditorView } from 'officerdev';
import { Widget } from 'widgets/Widget';
export const CodeEditor = () => (
<div className="h-full w-full flex items-center justify-center">
<Widget title="Code Editor" className="h-[70vh] w-[70vw] overflow-hidden" resizable moveable>
<CodeEditorView className="h-full w-full" />
</Widget>
</div>
);
@@ -1,15 +1,10 @@
import { useEffect, useState } from 'react'; import { useEffect } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router'; import { 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 { useGlobal } from 'hooks/useGlobal';
import { useClient } from 'hooks/useClient'; import { useWorkspacesState } from 'state/useWorkspacesState';
import { useProjectsState } from 'state/useProjectsState'; import { WorkspaceView } from '@/components/Workspace';
import { WorkspaceLayout, WorkspaceView, createDefaultLayout } from '@/components/Workspace'; import type { LayoutNode, ProjectType } from '@/components/Workspace';
import type { LayoutNode, ProjectDefinition, ProjectType } from '@/components/Workspace'; import { generateSlug } from 'helpers/slug';
import { Button } from '@/components/ui/button';
import { generateSlug, slugify } from 'helpers/slug';
import { useAppRegistry } from 'officerdev';
import { import {
SELECTED_PROJECT, SELECTED_PROJECT,
CREATING_PROJECT, CREATING_PROJECT,
@@ -20,600 +15,15 @@ import {
NEW_PROJ_TYPE, NEW_PROJ_TYPE,
NEW_PROJ_HAS_BACKEND, NEW_PROJ_HAS_BACKEND,
NEW_PROJ_HAS_AUTH, NEW_PROJ_HAS_AUTH,
NEW_PROJ_PREVIEW_LAYOUT, } from 'officerdev';
} from './constants'; import { defaultLayout } from './defaultLayout';
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} />;
};
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 NewProjectForm = () => {
const { registry } = useAppRegistry();
const newProjRegistry = {
...registry,
'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 },
};
return <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} />;
};
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>
);
};
export const ProjectListScreen = () => { export const ProjectListScreen = () => {
const [layout, setLayout, isLoaded] = useProjectsState<LayoutNode>('proj-layout-proj-homepage', defaultLayout); const workspace = useWorkspacesState<LayoutNode>('screens/projects', defaultLayout);
const { registry } = useAppRegistry();
const projHomeRegistry = {
...registry,
'project-list': { name: 'Projects', icon: FolderKanban, component: () => <ProjectListApp /> },
'project-preview': { name: 'Project Preview', icon: FolderKanban, component: ProjectPreview },
};
if (!isLoaded) return null;
return ( return (
<div className="h-full w-full"> <div className="h-full w-full">
<WorkspaceLayout layout={layout} onLayoutChange={setLayout} registry={projHomeRegistry} /> <WorkspaceView workspace={workspace} />
</div> </div>
); );
}; };
@@ -1,12 +1,11 @@
import { useParams, Navigate } from 'react-router'; import { useParams, Navigate } from 'react-router';
import { useProjectsState } from 'state/useProjectsState'; import { useWorkspacesState } from 'state/useWorkspacesState';
import { WorkspaceView, createDefaultLayout } from '@/components/Workspace'; import { WorkspaceView, createDefaultLayout } from '@/components/Workspace';
import type { LayoutNode, ProjectDefinition } from '@/components/Workspace'; import type { LayoutNode, ProjectDefinition } from '@/components/Workspace';
export const ProjectScreen = () => { export const ProjectScreen = () => {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const [projects, , isLoaded] = useProjectsState<ProjectDefinition[]>('projects', []); const { value: projects, isLoaded } = useWorkspacesState<ProjectDefinition[]>('projects', []);
const project = projects.find((p) => p.id === id); const project = projects.find((p) => p.id === id);
if (!isLoaded) return null; if (!isLoaded) return null;
@@ -16,17 +15,11 @@ export const ProjectScreen = () => {
}; };
const ProjectScreenInner = ({ project }: { project: ProjectDefinition }) => { const ProjectScreenInner = ({ project }: { project: ProjectDefinition }) => {
const [layout, setLayout] = useProjectsState<LayoutNode>(`proj-layout-${project.id}`, createDefaultLayout()); const workspace = useWorkspacesState<LayoutNode>(`proj-layout-${project.id}`, createDefaultLayout());
const workspace = { id: project.id, name: project.name, cwd: project.cwd };
return ( return (
<div className="h-full w-full"> <div className="h-full w-full">
<WorkspaceView <WorkspaceView workspace={workspace} cwd={project.cwd} />
workspace={workspace}
layout={layout}
onLayoutChange={setLayout}
/>
</div> </div>
); );
}; };
@@ -0,0 +1,11 @@
import type { LayoutNode } from '@/components/Workspace';
export 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 },
],
};
@@ -1,14 +1,25 @@
import type { LayoutNode } from '@/components/Workspace'; import { useParams, Navigate } from 'react-router';
import { WorkspaceView } from '@/components/Workspace';
import { useWorkspacesState } from 'state/useWorkspacesState'; import { useWorkspacesState } from 'state/useWorkspacesState';
import { defaultLayout } from './defaultLayout'; import { WorkspaceView, createDefaultLayout } from '@/components/Workspace';
import type { LayoutNode, WorkspaceDefinition } from '@/components/Workspace';
export const WorkspaceScreen = () => { export const WorkspaceScreen = () => {
const workspace = useWorkspacesState<LayoutNode>('screens/workspaces', defaultLayout); const { id } = useParams<{ id: string }>();
const { value: workspaces, isLoaded } = useWorkspacesState<WorkspaceDefinition[]>('workspaces', []);
const ws = workspaces.find((w) => w.id === id);
if (!isLoaded) return null;
if (!ws) return <Navigate to="/workspaces" replace />;
return <WorkspaceScreenInner workspace={ws} />;
};
const WorkspaceScreenInner = ({ workspace }: { workspace: WorkspaceDefinition }) => {
const wsState = useWorkspacesState<LayoutNode>(`ws-layout-${workspace.id}`, createDefaultLayout());
return ( return (
<div className="h-full w-full"> <div className="h-full w-full">
<WorkspaceView workspace={workspace} /> <WorkspaceView workspace={wsState} cwd={workspace.cwd} />
</div> </div>
); );
}; };
@@ -0,0 +1,14 @@
import type { LayoutNode } from '@/components/Workspace';
import { WorkspaceView } from '@/components/Workspace';
import { useWorkspacesState } from 'state/useWorkspacesState';
import { defaultLayout } from './defaultLayout';
export const WorkspacesScreen = () => {
const workspace = useWorkspacesState<LayoutNode>('screens/workspaces', defaultLayout);
return (
<div className="h-full w-full">
<WorkspaceView workspace={workspace} />
</div>
);
};
@@ -1 +1,2 @@
export { WorkspacesScreen } from './WorkspacesScreen';
export { WorkspaceScreen } from './WorkspaceScreen'; export { WorkspaceScreen } from './WorkspaceScreen';
@@ -1,6 +1,5 @@
export * from './Layout'; export * from './Layout';
export * from './Home'; export * from './Home';
export * from './ChatScreen';
export * from './OnboardingAdmin'; export * from './OnboardingAdmin';
export * from './PasskeyGate'; export * from './PasskeyGate';
export * from './Plans'; export * from './Plans';
@@ -14,7 +13,6 @@ export * from './Tasks';
export * from './Files'; export * from './Files';
export * from './ChatHistory'; export * from './ChatHistory';
export * from './CodeEditor';
export * from './Workspaces'; export * from './Workspaces';
export * from './Projects'; export * from './Projects';
export * from './Terminal'; export * from './Terminal';
-128
View File
@@ -1,128 +0,0 @@
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);
});
+1
View File
@@ -288,6 +288,7 @@ const containerHome = '/home/officer';
const resolveCwd = (home: string, cwd?: string) => { const resolveCwd = (home: string, cwd?: string) => {
if (!cwd || cwd === '~') return home; if (!cwd || cwd === '~') return home;
if (cwd.startsWith('~/')) return join(home, cwd.slice(2)); if (cwd.startsWith('~/')) return join(home, cwd.slice(2));
if (cwd.startsWith('/')) return join(home, cwd.slice(1));
return home; return home;
}; };
+1 -1
View File
@@ -1,3 +1,3 @@
export type KeyMapping = { file: string; dir?: string }; export type KeyMapping = { file: string; dir?: string };
export type ResolveDirs = { wsDir: string; screensDir: string }; export type ResolveDirs = { wsDir: string; screensDir: string; projDir: string };
+65 -1
View File
@@ -1,6 +1,6 @@
import { mkdir, readdir, rename, rm } from 'node:fs/promises'; import { mkdir, readdir, rename, rm } from 'node:fs/promises';
import { join } from 'node:path'; import { join } from 'node:path';
import { getUserWorkspacesDir, getUserHomepageWorkspaceDir, getUserStateFile } from '@@/data-path'; import { getUserWorkspacesDir, getUserHomepageWorkspaceDir, getUserStateFile, getUserProjectsDir } from '@@/data-path';
import type { KeyMapping, ResolveDirs } from './types'; import type { KeyMapping, ResolveDirs } from './types';
const RESERVED_DIRS = new Set(['screens']); const RESERVED_DIRS = new Set(['screens']);
@@ -39,6 +39,35 @@ export function resolveKey(dirs: ResolveDirs, key: string): KeyMapping | null {
return { file: join(dir, 'host-terminals.json'), dir }; return { file: join(dir, 'host-terminals.json'), dir };
} }
// Project keys — stored in {projDir}/{slug}/.officerdev/
const projMetaMatch = key.match(/^proj-meta-(.+)$/);
if (projMetaMatch) {
const slug = projMetaMatch[1]!;
const dir = join(dirs.projDir, slug, '.officerdev');
return { file: join(dir, 'meta.json'), dir };
}
const projLayoutMatch = key.match(/^proj-layout-(.+)$/);
if (projLayoutMatch) {
const slug = projLayoutMatch[1]!;
const dir = join(dirs.projDir, slug, '.officerdev');
return { file: join(dir, 'layout.json'), dir };
}
const projTerminalsMatch = key.match(/^proj-terminals-(.+)$/);
if (projTerminalsMatch) {
const slug = projTerminalsMatch[1]!;
const dir = join(dirs.projDir, slug, '.officerdev');
return { file: join(dir, 'terminals.json'), dir };
}
const projHostTerminalsMatch = key.match(/^proj-host-terminals-(.+)$/);
if (projHostTerminalsMatch) {
const slug = projHostTerminalsMatch[1]!;
const dir = join(dirs.projDir, slug, '.officerdev');
return { file: join(dir, 'host-terminals.json'), dir };
}
return null; return null;
} }
@@ -125,6 +154,23 @@ async function readWorkspaceDir(dirPath: string, id: string, result: Record<stri
if (hostTerminals !== null) result[`ws-host-terminals-${id}`] = hostTerminals; if (hostTerminals !== null) result[`ws-host-terminals-${id}`] = hostTerminals;
} }
async function readProjectDir(projectPath: string, slug: string, result: Record<string, unknown>): Promise<unknown | null> {
const officerdevDir = join(projectPath, '.officerdev');
const meta = await readJsonFile(join(officerdevDir, 'meta.json'));
if (!meta) return null;
const layout = await readJsonFile(join(officerdevDir, 'layout.json'));
if (layout !== null) result[`proj-layout-${slug}`] = layout;
const terminals = await readJsonFile(join(officerdevDir, 'terminals.json'));
if (terminals !== null) result[`proj-terminals-${slug}`] = terminals;
const hostTerminals = await readJsonFile(join(officerdevDir, 'host-terminals.json'));
if (hostTerminals !== null) result[`proj-host-terminals-${slug}`] = hostTerminals;
return { ...(meta as object), id: slug, cwd: `/Projects/${slug}` };
}
async function readScreensDir(screensDir: string, result: Record<string, unknown>) { async function readScreensDir(screensDir: string, result: Record<string, unknown>) {
let entries: import('node:fs').Dirent[] = []; let entries: import('node:fs').Dirent[] = [];
try { try {
@@ -168,6 +214,23 @@ export async function readAllWorkspacesState(dirs: ResolveDirs): Promise<Record<
await readWorkspaceDir(join(dirs.wsDir, entry.name), entry.name, result); await readWorkspaceDir(join(dirs.wsDir, entry.name), entry.name, result);
} }
// Read project data — scan directories containing .officerdev/meta.json
const projects: unknown[] = [];
let projEntries: import('node:fs').Dirent[] = [];
try {
projEntries = await readdir(dirs.projDir, { withFileTypes: true });
} catch {
result['projects'] = projects;
return result;
}
for (const entry of projEntries) {
if (!entry.isDirectory()) continue;
const meta = await readProjectDir(join(dirs.projDir, entry.name), entry.name, result);
if (meta) projects.push(meta);
}
result['projects'] = projects;
return result; return result;
} }
@@ -175,5 +238,6 @@ export function getDirs(email: string): ResolveDirs {
return { return {
wsDir: getUserWorkspacesDir(email), wsDir: getUserWorkspacesDir(email),
screensDir: join(getUserWorkspacesDir(email), 'screens'), screensDir: join(getUserWorkspacesDir(email), 'screens'),
projDir: getUserProjectsDir(email),
}; };
} }
+25 -8
View File
@@ -1,14 +1,7 @@
import { mkdir, readdir, rm } from 'node:fs/promises'; import { mkdir, readdir, rm } from 'node:fs/promises';
import { join } from 'node:path'; import { join } from 'node:path';
import { createRouter } from '@@/create-router'; import { createRouter } from '@@/create-router';
import { import { getDirs, migrateFromState, migrateHomepageToScreens, readAllWorkspacesState, resolveKey, writeJsonFile } from './utils';
getDirs,
migrateFromState,
migrateHomepageToScreens,
readAllWorkspacesState,
resolveKey,
writeJsonFile,
} from './utils';
export const workspacesRouter = createRouter(); export const workspacesRouter = createRouter();
@@ -37,6 +30,30 @@ workspacesRouter.patch('/', async (ctx) => {
await mkdir(dirs.wsDir, { recursive: true }); await mkdir(dirs.wsDir, { recursive: true });
for (const [key, value] of Object.entries(body)) { for (const [key, value] of Object.entries(body)) {
// Handle proj-meta-{slug}: create/update/delete project
const projMetaMatch = key.match(/^proj-meta-(.+)$/);
if (projMetaMatch) {
const slug = projMetaMatch[1]!;
const projectDir = join(dirs.projDir, slug);
if (value === null) {
await rm(projectDir, { recursive: true, force: true });
continue;
}
const officerdevDir = join(projectDir, '.officerdev');
const metaFile = join(officerdevDir, 'meta.json');
const isNew = !(await Bun.file(metaFile).exists());
await mkdir(officerdevDir, { recursive: true });
await writeJsonFile(metaFile, value);
if (isNew) {
const proc = Bun.spawn(['git', 'init', projectDir]);
await proc.exited;
}
continue;
}
const mapping = resolveKey(dirs, key); const mapping = resolveKey(dirs, key);
if (!mapping) continue; if (!mapping) continue;
+1 -1
View File
@@ -36,7 +36,7 @@ export const getUserStateFile = (email: string) => join(DATA_PATH, email, 'state
export const getUserWorkspacesDir = (email: string) => join(DATA_PATH, email, 'workspaces'); export const getUserWorkspacesDir = (email: string) => join(DATA_PATH, email, 'workspaces');
export const getUserProjectsDir = (email: string) => join(DATA_PATH, email, 'projects'); export const getUserProjectsDir = (email: string) => join(DATA_PATH, email, 'home', 'Projects');
export const getUserHomepageWorkspaceDir = (email: string) => join(DATA_PATH, email, 'ws-homepage'); export const getUserHomepageWorkspaceDir = (email: string) => join(DATA_PATH, email, 'ws-homepage');
-2
View File
@@ -15,7 +15,6 @@ import { scrapeRouter } from './api/scrape/scrape';
import { uploadRouter } from './api/upload/upload'; import { uploadRouter } from './api/upload/upload';
import { settingsRouter } from './api/settings/settings'; import { settingsRouter } from './api/settings/settings';
import { workspacesRouter } from './api/workspaces'; import { workspacesRouter } from './api/workspaces';
import { projectsRouter } from './api/settings/projects';
import { taskLogsRouter } from './api/task-logs/task-logs'; import { taskLogsRouter } from './api/task-logs/task-logs';
import { router as fileBrowserRouter } from './api/file-browser/router'; import { router as fileBrowserRouter } from './api/file-browser/router';
import { piRestRouter } from './api/pi/rest'; import { piRestRouter } from './api/pi/rest';
@@ -55,7 +54,6 @@ protectedRouter.route('/scrape', scrapeRouter);
protectedRouter.route('/upload', uploadRouter); protectedRouter.route('/upload', uploadRouter);
protectedRouter.route('/user', settingsRouter); protectedRouter.route('/user', settingsRouter);
protectedRouter.route('/workspaces', workspacesRouter); protectedRouter.route('/workspaces', workspacesRouter);
protectedRouter.route('/user', projectsRouter);
protectedRouter.route('/task-logs', taskLogsRouter); protectedRouter.route('/task-logs', taskLogsRouter);
protectedRouter.route('/file-browser', fileBrowserRouter); protectedRouter.route('/file-browser', fileBrowserRouter);
protectedRouter.route('/', piRestRouter); protectedRouter.route('/', piRestRouter);
@@ -4,9 +4,11 @@ import { appRegistryMetas as codeEditorMetas } from '../apps/CodeEditor';
import { appRegistryMetas as chatMetas } from '../apps/Chat'; import { appRegistryMetas as chatMetas } from '../apps/Chat';
import { appRegistryMetas as fileViewerMetas } from '../apps/FileViewer'; import { appRegistryMetas as fileViewerMetas } from '../apps/FileViewer';
import { appRegistryMetas as workspaceMetas } from '../apps/Workspaces'; import { appRegistryMetas as workspaceMetas } from '../apps/Workspaces';
import { appRegistryMetas as projectMetas } from '../apps/Projects';
import { appRegistryMetas as chatHistoryMetas } from '../apps/ChatHistory';
import { useAppRegistry } from './useAppRegistry'; import { useAppRegistry } from './useAppRegistry';
const apps = [...fileBrowserMetas, ...terminalMetas, ...codeEditorMetas, ...chatMetas, ...fileViewerMetas, ...workspaceMetas]; const apps = [...fileBrowserMetas, ...terminalMetas, ...codeEditorMetas, ...chatMetas, ...fileViewerMetas, ...workspaceMetas, ...projectMetas, ...chatHistoryMetas];
export const AppRegistry = () => { export const AppRegistry = () => {
useAppRegistry(apps); useAppRegistry(apps);
@@ -3,7 +3,7 @@ import { useLocation } from 'react-router';
import { Trash2 } from 'lucide-react'; import { Trash2 } from 'lucide-react';
import { usePanelChannel } from 'hooks/usePanelChannel'; import { usePanelChannel } from 'hooks/usePanelChannel';
import { useChatSessions } from 'state/useChatSessions'; import { useChatSessions } from 'state/useChatSessions';
import { usePiChat, EmbeddableChat } from 'officerdev'; import { usePiChat, EmbeddableChat } from '../Chat';
export type SelectedSession = { export type SelectedSession = {
id: string; id: string;
@@ -68,8 +68,6 @@ function SessionChat({ sessionId, model }: SessionChatProps) {
const [, setSelected] = usePanelChannel<SelectedSession>(CHANNEL, null); const [, setSelected] = usePanelChannel<SelectedSession>(CHANNEL, null);
const sessionTitle = sessions.find((s) => s.id === sessionId)?.title; const sessionTitle = sessions.find((s) => s.id === sessionId)?.title;
// We need connection status for the DetailBar, so we still call usePi here
// TODO: Consider moving DetailBar into EmbeddableChat or exposing status from it
const chat = usePiChat(sessionId, model, { replaceUrl: false }); const chat = usePiChat(sessionId, model, { replaceUrl: false });
return ( return (
@@ -84,10 +82,10 @@ function SessionChat({ sessionId, model }: SessionChatProps) {
window.history.replaceState(null, '', '/chat'); window.history.replaceState(null, '', '/chat');
}} }}
/> />
<EmbeddableChat <EmbeddableChat
sessionId={sessionId} sessionId={sessionId}
initialModel={model ?? undefined} initialModel={model ?? undefined}
className="flex-1 min-h-0" className="flex-1 min-h-0"
/> />
</div> </div>
); );
@@ -98,7 +96,6 @@ function NewChat() {
const locationState = location.state as ChatLocationState; const locationState = location.state as ChatLocationState;
const [, setSelected] = usePanelChannel<SelectedSession>(CHANNEL, null); const [, setSelected] = usePanelChannel<SelectedSession>(CHANNEL, null);
// We need connection status for DetailBar, so call usePi
const chat = usePiChat(); const chat = usePiChat();
useEffect(() => { useEffect(() => {
@@ -146,7 +143,7 @@ function NewChatPanel() {
return <NewChat key="new" />; return <NewChat key="new" />;
} }
export function ChatDetailPanel() { export const ChatDetailPanel = () => {
const [selected] = usePanelChannel<SelectedSession>(CHANNEL, null); const [selected] = usePanelChannel<SelectedSession>(CHANNEL, null);
if (!selected) { if (!selected) {
@@ -162,4 +159,4 @@ export function ChatDetailPanel() {
} }
return <SessionChat key={selected.id} sessionId={selected.id} model={selected.model} />; return <SessionChat key={selected.id} sessionId={selected.id} model={selected.model} />;
} };
@@ -22,7 +22,7 @@ export function CreateGroupDialog({ onClose }: CreateGroupDialogProps) {
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
if (!name.trim()) { if (!name.trim()) {
setError('Name is required'); setError('Name is required');
return; return;
@@ -1,7 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { MoreVertical, Edit2, Trash2 } from 'lucide-react'; import { MoreVertical, Edit2, Trash2 } from 'lucide-react';
import { useChatGroups } from 'state/useChatGroups'; import { useChatGroups } from 'state/useChatGroups';
import type { GroupEntry } from 'officerdev'; import type { GroupEntry } from '../Chat';
type GroupContextMenuProps = { type GroupContextMenuProps = {
group: GroupEntry; group: GroupEntry;
@@ -2,7 +2,7 @@ import { useState } from 'react';
import { MoreVertical, FolderInput, Edit2, Trash2 } from 'lucide-react'; import { MoreVertical, FolderInput, Edit2, Trash2 } from 'lucide-react';
import { useChatGroups } from 'state/useChatGroups'; import { useChatGroups } from 'state/useChatGroups';
import { useChatSessions } from 'state/useChatSessions'; import { useChatSessions } from 'state/useChatSessions';
import type { SessionEntry } from 'officerdev'; import type { SessionEntry } from '../Chat';
type SessionContextMenuProps = { type SessionContextMenuProps = {
session: SessionEntry; session: SessionEntry;
@@ -14,7 +14,7 @@ export const SessionList = () => {
const [selected, setSelected] = usePanelChannel<SelectedSession>('chat:selected-session', null); const [selected, setSelected] = usePanelChannel<SelectedSession>('chat:selected-session', null);
const [collapsed, setCollapsed] = useState<Set<string>>(new Set()); const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const [showCreateGroup, setShowCreateGroup] = useState(false); const [showCreateGroup, setShowCreateGroup] = useState(false);
const scrolledRef = useRef(false); const scrolledRef = useRef(false);
const selectedRef = useCallback( const selectedRef = useCallback(
(node: HTMLDivElement | null) => { (node: HTMLDivElement | null) => {
@@ -1 +1,26 @@
import type { AppRegistryMeta } from '../../AppRegistry';
import { MessageSquare, List } from 'lucide-react';
import { SessionList } from './SessionList';
import { ChatDetailPanel } from './ChatDetailPanel';
export { SessionBar } from './SessionBar'; export { SessionBar } from './SessionBar';
export { SessionList };
export { ChatDetailPanel };
export type { SelectedSession } from './ChatDetailPanel';
export const appRegistryMetas: AppRegistryMeta[] = [
{
key: 'chat-session-list',
name: 'Sessions',
icon: List,
component: SessionList,
availableOnPanel: false,
},
{
key: 'chat-detail',
name: 'Chat',
icon: MessageSquare,
component: ChatDetailPanel,
availableOnPanel: false,
},
];
@@ -9,10 +9,11 @@ import { useFileBrowserApp } from './useFileBrowserApp';
type FileBrowserAppProps = { type FileBrowserAppProps = {
basePath?: string; basePath?: string;
rootOverride?: string;
}; };
export const FileBrowserApp = ({ basePath = '/' }: FileBrowserAppProps) => { export const FileBrowserApp = ({ basePath = '/', rootOverride }: FileBrowserAppProps) => {
const fileBrowserManager = useFileBrowserApp(basePath); const fileBrowserManager = useFileBrowserApp(basePath, rootOverride);
const { handleNavigate } = fileBrowserManager; const { handleNavigate } = fileBrowserManager;
return ( return (
@@ -5,5 +5,6 @@ const cwdToPath = (cwd: string) => (cwd === '~' ? '/' : cwd.slice(1));
export const FileBrowserPanelWrapper = () => { export const FileBrowserPanelWrapper = () => {
const { cwd } = useWorkspace(); const { cwd } = useWorkspace();
return <FileBrowserApp basePath={cwdToPath(cwd)} />; const basePath = cwdToPath(cwd);
return <FileBrowserApp basePath={basePath} rootOverride={basePath !== '/' ? 'home' : undefined} />;
}; };
@@ -6,12 +6,16 @@ import { useTasks, type TaskSummary } from '../useTasks';
import { useUserState } from 'state/useUserState'; import { useUserState } from 'state/useUserState';
import { useAuth } from 'hooks/useAuth'; import { useAuth } from 'hooks/useAuth';
export const useFileBrowserApp = (basePath: string) => { export const useFileBrowserApp = (basePath: string, rootOverride?: string) => {
const { user } = useAuth(); const { user } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [homeRoot, setHomeRoot] = useUserState<HomeRoot>('files/homeRoot', 'home'); const [homeRoot, setHomeRoot] = useUserState<HomeRoot>('files/homeRoot', 'home');
const [currentPath, setCurrentPath] = useUserState<string>('files/currentPath', '/'); const [globalPath, setGlobalPath] = useUserState<string>('files/currentPath', '/');
const [localPath, setLocalPath] = useState(basePath);
const scoped = basePath !== '/';
const currentPath = scoped ? localPath : globalPath;
const setCurrentPath = scoped ? setLocalPath : setGlobalPath;
const [entries, setEntries] = useState<DirEntry[]>([]); const [entries, setEntries] = useState<DirEntry[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [viewMode, setViewMode] = useUserState<'grid' | 'list'>('files/viewMode', 'grid'); const [viewMode, setViewMode] = useUserState<'grid' | 'list'>('files/viewMode', 'grid');
@@ -37,7 +41,7 @@ export const useFileBrowserApp = (basePath: string) => {
const searchInputRef = useRef<HTMLInputElement | null>(null); const searchInputRef = useRef<HTMLInputElement | null>(null);
const fileScrollRef = useRef<HTMLDivElement | null>(null); const fileScrollRef = useRef<HTMLDivElement | null>(null);
const viewPath = searchParams.get('view'); const viewPath = searchParams.get('view');
const files = useFilesAPI(homeRoot); const files = useFilesAPI(rootOverride ?? homeRoot);
const filesRef = useRef(files); const filesRef = useRef(files);
filesRef.current = files; filesRef.current = files;
const currentPathRef = useRef(currentPath); const currentPathRef = useRef(currentPath);
@@ -5,7 +5,7 @@ import { useQueryClient } from '@tanstack/react-query';
import { useGlobal } from 'hooks/useGlobal'; import { useGlobal } from 'hooks/useGlobal';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { generateSlug } from 'helpers/slug'; import { generateSlug } from 'helpers/slug';
import { useProjectsState } from 'state/useProjectsState'; import { useWorkspacesState } from 'state/useWorkspacesState';
import type { ProjectDefinition, ProjectType } from '@/components/Workspace'; import type { ProjectDefinition, ProjectType } from '@/components/Workspace';
import { import {
AlertDialog, AlertDialog,
@@ -40,7 +40,7 @@ export const ProjectListApp = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const client = useClient(); const client = useClient();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [projects, setProjects] = useProjectsState<ProjectDefinition[]>('projects', []); const { value: projects } = useWorkspacesState<ProjectDefinition[]>('projects', []);
const [selected, setSelected] = useGlobal<string | null>(SELECTED_PROJECT, null); const [selected, setSelected] = useGlobal<string | null>(SELECTED_PROJECT, null);
const [, setCreating] = useGlobal<boolean>(CREATING_PROJECT, false); const [, setCreating] = useGlobal<boolean>(CREATING_PROJECT, false);
const [, setEditing] = useGlobal<string | null>(EDITING_PROJECT, null); const [, setEditing] = useGlobal<string | null>(EDITING_PROJECT, null);
@@ -83,15 +83,21 @@ export const ProjectListApp = () => {
const confirmDelete = () => { const confirmDelete = () => {
if (!deleting) return; if (!deleting) return;
setProjects((prev) => prev.filter((p) => p.id !== deleting.id)); // Optimistic: remove from cache immediately
if (selected === deleting.id) setSelected(null); const current = queryClient.getQueryData<Record<string, unknown>>(['WORKSPACES_STATE']) ?? {};
const optimistic = { ...current };
optimistic['projects'] = (optimistic['projects'] as ProjectDefinition[]).filter((p) => p.id !== deleting.id);
delete optimistic[`proj-layout-${deleting.id}`];
delete optimistic[`proj-terminals-${deleting.id}`];
delete optimistic[`proj-host-terminals-${deleting.id}`];
queryClient.setQueryData(['WORKSPACES_STATE'], optimistic);
const layoutKey = `proj-layout-${deleting.id}`; if (selected === deleting.id) setSelected(null);
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); setDeleting(null);
client
.patch('/workspaces', { [`proj-meta-${deleting.id}`]: null })
.then((res) => queryClient.setQueryData(['WORKSPACES_STATE'], res))
.catch(() => {});
}; };
const handleClick = (p: ProjectDefinition) => { const handleClick = (p: ProjectDefinition) => {
@@ -0,0 +1,549 @@
import { useState } from 'react';
import { Link, useNavigate } 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 { useWorkspacesState } from 'state/useWorkspacesState';
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 { useAppRegistry } from '../../AppRegistry';
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';
// --- 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 { value: projects } = useWorkspacesState<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 = isEditing ? editingId : slugify(name.trim()) || generateSlug();
const handleSubmit = () => {
const trimmed = name.trim();
if (!trimmed) return;
const desc = description.trim();
const meta = {
name: trimmed,
description: desc || undefined,
projectType,
hasBackend: projectType === 'app' ? hasBackend : undefined,
hasAuth: projectType === 'app' ? hasAuth : undefined,
templateIdx,
};
if (isEditing) {
client
.patch('/workspaces', { [`proj-meta-${editingId}`]: meta, [`proj-layout-${editingId}`]: previewLayout })
.then((res) => queryClient.setQueryData(['WORKSPACES_STATE'], res))
.catch(() => {});
setEditingId(null);
setSelected(editingId);
} else {
const existingIds = new Set(projects.map((p) => p.id));
let id = slugify(trimmed) || generateSlug();
while (existingIds.has(id)) id = `${id}-${generateSlug(1)}`;
setName('');
setDescription('');
setTemplateIdx(0);
client
.patch('/workspaces', { [`proj-meta-${id}`]: meta, [`proj-layout-${id}`]: previewLayout })
.then((res) => {
queryClient.setQueryData(['WORKSPACES_STATE'], res);
navigate(`/projects/${id}`);
})
.catch(() => {});
}
};
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) ---
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 <WorkspaceLayout layout={layout} onLayoutChange={handleLayoutChange} />;
};
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 NewProjectForm = () => {
const { registry } = useAppRegistry();
const newProjRegistry = {
...registry,
'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 },
};
return <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 ws = useWorkspacesState<LayoutNode>(`proj-layout-${project.id}`, createDefaultLayout());
return <WorkspaceView workspace={ws} cwd={project.cwd} />;
};
export const ProjectPreview = () => {
const [selectedId] = useGlobal<string | null>(SELECTED_PROJECT, null);
const { value: projects } = useWorkspacesState<ProjectDefinition[]>('projects', []);
const 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>
);
};
@@ -0,0 +1,36 @@
import type { AppRegistryMeta } from '../../AppRegistry';
import { FolderKanban } from 'lucide-react';
import { ProjectListApp } from './ProjectListApp';
import { ProjectPreview } from './ProjectPreview';
export { ProjectListApp };
export { ProjectPreview };
export {
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';
export const appRegistryMetas: AppRegistryMeta[] = [
{
key: 'project-list',
name: 'Projects',
icon: FolderKanban,
component: ProjectListApp,
availableOnPanel: false,
},
{
key: 'project-preview',
name: 'Project Preview',
icon: FolderKanban,
component: ProjectPreview,
availableOnPanel: false,
},
];
@@ -37,7 +37,8 @@ export const CommandTerminalWrapper = ({ panelId, command, statePrefix }: Comman
if (!sessionId) return null; if (!sessionId) return null;
const fullCommand = cwd && cwd !== '~' ? `cd ${cwd} && ${command}` : command; const cwdPath = cwd && cwd !== '~' ? `~/${cwd.replace(/^\//, '')}` : null;
const fullCommand = cwdPath ? `cd ${cwdPath} && ${command}` : command;
return <TerminalView className="h-full w-full p-2" sessionId={sessionId} cwd={cwd} initialInput={fullCommand} />; return <TerminalView className="h-full w-full p-2" sessionId={sessionId} cwd={cwd} initialInput={fullCommand} />;
}; };
@@ -8,13 +8,14 @@ export const TerminalHeader = ({ panelId }: { panelId: string }) => {
const { user } = useAuth(); const { user } = useAuth();
const { mode, toggle } = useTerminalMode(panelId); const { mode, toggle } = useTerminalMode(panelId);
const isHost = mode === 'host'; const isHost = mode === 'host';
const Icon = isHost ? Monitor : TerminalSquare; const scoped = cwd !== '~';
const Icon = isHost && !scoped ? Monitor : TerminalSquare;
return ( return (
<> <>
<Icon className="h-3.5 w-3.5 shrink-0" /> <Icon className="h-3.5 w-3.5 shrink-0" />
<span className="text-xs font-medium shrink-0">Terminal</span> <span className="text-xs font-medium shrink-0">Terminal</span>
{user?.role === 'Super Admin' && ( {user?.role === 'Super Admin' && !scoped && (
<button <button
type="button" type="button"
onClick={toggle} onClick={toggle}
@@ -9,7 +9,8 @@ const EMPTY_TERMINALS: Record<string, string> = {};
export const TerminalWrapper = ({ panelId }: { panelId: string }) => { export const TerminalWrapper = ({ panelId }: { panelId: string }) => {
const { workspaceId, cwd } = useWorkspace(); const { workspaceId, cwd } = useWorkspace();
const { mode } = useTerminalMode(panelId); const { mode } = useTerminalMode(panelId);
const sandboxed = mode === 'sandboxed'; const scoped = cwd !== '~';
const sandboxed = scoped || mode === 'sandboxed';
const stateKey = workspaceId ? `ws-terminals-${mode}-${workspaceId}` : `ws-terminals-${mode}-default`; const stateKey = workspaceId ? `ws-terminals-${mode}-${workspaceId}` : `ws-terminals-${mode}-default`;
const { value: terminals, setValue: setTerminals } = useWorkspacesState<Record<string, string>>(stateKey, EMPTY_TERMINALS); const { value: terminals, setValue: setTerminals } = useWorkspacesState<Record<string, string>>(stateKey, EMPTY_TERMINALS);
const setTerminalsRef = useRef(setTerminals); const setTerminalsRef = useRef(setTerminals);
+3 -1
View File
@@ -5,7 +5,8 @@ export * from './AppRegistry';
export { MessageList, MessageBubble, StreamingBubble, ToolActivity, QuestionActivity, ModelSelector, InputArea, ChatLauncher, AttachmentList, AttachButton, WebpageDialog, EmbeddableChat, usePiChat, ChatList, useSlashCommands, useChatSessions, useChatSession, useAttachments, useAudioRecording } from './apps/Chat'; export { MessageList, MessageBubble, StreamingBubble, ToolActivity, QuestionActivity, ModelSelector, InputArea, ChatLauncher, AttachmentList, AttachButton, WebpageDialog, EmbeddableChat, usePiChat, ChatList, useSlashCommands, useChatSessions, useChatSession, useAttachments, useAudioRecording } from './apps/Chat';
export type { UseEmbeddableChatType, UsePiChatType, UseChatSessionsType, UseChatSessionType, UseAttachmentsType, UseAudioRecordingType } from './apps/Chat'; export type { UseEmbeddableChatType, UsePiChatType, UseChatSessionsType, UseChatSessionType, UseAttachmentsType, UseAudioRecordingType } from './apps/Chat';
export * from './apps/Chat/types'; export * from './apps/Chat/types';
export { SessionBar } from './apps/ChatHistory'; export { SessionBar, SessionList, ChatDetailPanel } from './apps/ChatHistory';
export type { SelectedSession } from './apps/ChatHistory';
export { CodeEditorView } from './apps/CodeEditor'; export { CodeEditorView } from './apps/CodeEditor';
export { useFilesAPI, useTasks, useRecentFiles, usePinnedFiles, FileBrowserApp, FileBrowserPanelWrapper, FileBrowserWidget, TaskRunnerModal } from './apps/FileBrowser'; export { useFilesAPI, useTasks, useRecentFiles, usePinnedFiles, FileBrowserApp, FileBrowserPanelWrapper, FileBrowserWidget, TaskRunnerModal } from './apps/FileBrowser';
export type { DirEntry, TaskSummary } from './apps/FileBrowser'; export type { DirEntry, TaskSummary } from './apps/FileBrowser';
@@ -14,3 +15,4 @@ export type { FileType } from './apps/FileViewer';
export { TerminalView } from './apps/Terminal'; export { TerminalView } from './apps/Terminal';
export type { TerminalViewProps } from './apps/Terminal'; export type { TerminalViewProps } from './apps/Terminal';
export { WorkspaceListApp, WorkspacePreview, SELECTED_WORKSPACE_KEY, CREATING_WORKSPACE_KEY, EDITING_WORKSPACE_KEY, NEW_WS_NAME_KEY, NEW_WS_DESC_KEY, NEW_WS_TEMPLATE_KEY } from './apps/Workspaces'; export { WorkspaceListApp, WorkspacePreview, SELECTED_WORKSPACE_KEY, CREATING_WORKSPACE_KEY, EDITING_WORKSPACE_KEY, NEW_WS_NAME_KEY, NEW_WS_DESC_KEY, NEW_WS_TEMPLATE_KEY } from './apps/Workspaces';
export { ProjectListApp, ProjectPreview, SELECTED_PROJECT, CREATING_PROJECT, EDITING_PROJECT, NEW_PROJ_NAME, NEW_PROJ_DESC, NEW_PROJ_TEMPLATE, NEW_PROJ_TYPE, NEW_PROJ_HAS_BACKEND, NEW_PROJ_HAS_AUTH, NEW_PROJ_PREVIEW_LAYOUT } from './apps/Projects';
-1
View File
@@ -2,7 +2,6 @@ export { useSettings, DEFAULT_SETTINGS } from './useSettings';
export type { UseSettingsType, UserSettings, UserState } from './useSettings'; export type { UseSettingsType, UserSettings, UserState } from './useSettings';
export { useUserState } from './useUserState'; export { useUserState } from './useUserState';
export { useWorkspacesState } from './useWorkspacesState'; export { useWorkspacesState } from './useWorkspacesState';
export { useProjectsState } from './useProjectsState';
export { usePiModels, useVisiblePiModels, modelKey } from './useModels'; export { usePiModels, useVisiblePiModels, modelKey } from './useModels';
export type { ModelOption } from './useModels'; export type { ModelOption } from './useModels';
export { useRecentModels } from './useRecentModels'; export { useRecentModels } from './useRecentModels';
@@ -1,44 +0,0 @@
import { useCallback, useRef } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
import { useAuth } from 'hooks/useAuth';
import type { UserState } from './useSettings';
const QUERY_KEY = ['PROJECTS_STATE'];
export function useProjectsState<T>(key: string, defaultValue: T): [T, (value: T | ((prev: T) => T)) => void, boolean] {
const client = useClient();
const { isAuthenticated } = useAuth();
const queryClient = useQueryClient();
const clientRef = useRef(client);
clientRef.current = client;
const { data: state = {}, isSuccess } = useQuery<UserState>({
queryKey: QUERY_KEY,
enabled: isAuthenticated,
queryFn: () => client.get<UserState>('/user/projects-state'),
staleTime: Infinity,
});
const value = key in state ? (state[key] as T) : defaultValue;
const setValue = useCallback(
(update: T | ((prev: T) => T)) => {
const currentState = queryClient.getQueryData<UserState>(QUERY_KEY) ?? {};
const currentValue = key in currentState ? (currentState[key] as T) : defaultValue;
const newValue = typeof update === 'function' ? (update as (prev: T) => T)(currentValue) : update;
queryClient.setQueryData(QUERY_KEY, { ...currentState, [key]: newValue });
clientRef.current
.patch<UserState>('/user/projects-state', { [key]: newValue })
.then((serverState) => {
if (serverState) queryClient.setQueryData(QUERY_KEY, serverState);
})
.catch(() => {});
},
[key, defaultValue, queryClient],
);
return [value, setValue, isSuccess];
}