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
@@ -1,165 +0,0 @@
import { useEffect } from 'react';
import { useLocation } from 'react-router';
import { Trash2 } from 'lucide-react';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { useChatSessions } from 'state/useChatSessions';
import { usePiChat, EmbeddableChat } from 'officerdev';
export type SelectedSession = {
id: string;
model?: string | null;
} | null;
const CHANNEL = 'chat:selected-session';
type ChatLocationState = {
initialMessage?: string;
prefillInput?: string;
model?: string;
cwd?: { root?: string; path: string };
attachmentIds?: string[];
images?: { filename: string; dataUrl: string }[];
} | null;
type DetailBarProps = {
sessionTitle: string | undefined;
isConnected: boolean;
isGenerating: boolean;
onDelete: (() => void) | undefined;
};
function DetailBar({ sessionTitle, isConnected, isGenerating, onDelete }: DetailBarProps) {
return (
<div className="shrink-0 flex items-center px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60">
<div className="flex items-center gap-1">
{onDelete && (
<button
onClick={onDelete}
className="p-1 text-duck-dark/40 dark:text-foreground/40 hover:text-red-500 transition-colors cursor-pointer"
>
<Trash2 className="h-4 w-4" />
</button>
)}
</div>
<div className="flex-1 min-w-0 text-center text-sm font-medium text-duck-dark/70 dark:text-foreground/70 truncate px-3">
{sessionTitle ?? 'New chat'}
</div>
<div className="flex items-center gap-2 text-xs text-duck-dark/50 dark:text-foreground/50">
{!isConnected ? (
<span className="inline-block h-2 w-2 rounded-full bg-red-500" />
) : isGenerating ? (
<span className="inline-block h-2 w-2 rounded-full bg-duck-orange animate-pulse" />
) : (
<span className="inline-block h-2 w-2 rounded-full bg-green-500" />
)}
<span>{!isConnected ? 'Disconnected' : isGenerating ? 'Working...' : ''}</span>
</div>
</div>
);
}
type SessionChatProps = {
sessionId: string;
model?: string | null;
};
function SessionChat({ sessionId, model }: SessionChatProps) {
const { sessions, deleteSession } = useChatSessions();
const [, setSelected] = usePanelChannel<SelectedSession>(CHANNEL, null);
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 });
return (
<div className="flex flex-col h-full">
<DetailBar
sessionTitle={sessionTitle}
isConnected={chat.isConnected}
isGenerating={chat.isGenerating}
onDelete={async () => {
await deleteSession(sessionId);
setSelected(null);
window.history.replaceState(null, '', '/chat');
}}
/>
<EmbeddableChat
sessionId={sessionId}
initialModel={model ?? undefined}
className="flex-1 min-h-0"
/>
</div>
);
}
function NewChat() {
const location = useLocation();
const locationState = location.state as ChatLocationState;
const [, setSelected] = usePanelChannel<SelectedSession>(CHANNEL, null);
// We need connection status for DetailBar, so call usePi
const chat = usePiChat();
useEffect(() => {
if (chat.sessionId) {
setSelected({ id: chat.sessionId, model: chat.model });
}
}, [chat.sessionId]);
const initialMessage = locationState?.initialMessage
? {
text: locationState.initialMessage,
attachmentIds: locationState.attachmentIds,
images: locationState.images,
cwd: locationState.cwd,
}
: undefined;
return (
<div className="flex flex-col h-full">
<DetailBar
sessionTitle={undefined}
isConnected={chat.isConnected}
isGenerating={chat.isGenerating}
onDelete={undefined}
/>
<EmbeddableChat
sessionId={undefined}
initialModel={locationState?.model ?? undefined}
initialMessage={initialMessage}
defaultInput={locationState?.prefillInput ?? ''}
cwd={locationState?.cwd}
className="flex-1 min-h-0"
/>
</div>
);
}
function NewChatPanel() {
const [selected] = usePanelChannel<SelectedSession>(CHANNEL, null);
if (selected && selected.id !== 'new') {
return <SessionChat key={selected.id} sessionId={selected.id} model={selected.model} />;
}
return <NewChat key="new" />;
}
export function ChatDetailPanel() {
const [selected] = usePanelChannel<SelectedSession>(CHANNEL, null);
if (!selected) {
return (
<div className="h-full flex items-center justify-center text-duck-dark/30 dark:text-foreground/30 text-sm">
Select a session to view
</div>
);
}
if (selected.id === 'new') {
return <NewChatPanel key="new" />;
}
return <SessionChat key={selected.id} sessionId={selected.id} model={selected.model} />;
}
@@ -1,138 +0,0 @@
import { useState } from 'react';
import { X } from 'lucide-react';
import { useChatGroups } from 'state/useChatGroups';
type CreateGroupDialogProps = {
onClose: () => void;
};
function toSlug(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}
export function CreateGroupDialog({ onClose }: CreateGroupDialogProps) {
const { createGroup } = useChatGroups();
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!name.trim()) {
setError('Name is required');
return;
}
const slug = toSlug(name);
if (!slug) {
setError('Name must contain at least one alphanumeric character');
return;
}
setIsSubmitting(true);
setError(null);
try {
await createGroup(name.trim(), slug, description.trim() || undefined);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create group');
setIsSubmitting(false);
}
}
function handleBackdropClick(e: React.MouseEvent) {
if (e.target === e.currentTarget) {
onClose();
}
}
return (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onClick={handleBackdropClick}
>
<div className="bg-background border border-duck-dark/10 dark:border-foreground/10 rounded-lg shadow-xl w-full max-w-md mx-4">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-duck-dark/10 dark:border-foreground/10">
<h2 className="text-lg font-semibold text-duck-dark dark:text-foreground">Create Group</h2>
<button
onClick={onClose}
className="p-1 rounded-md hover:bg-duck-dark/10 dark:hover:bg-foreground/10 transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<div>
<label htmlFor="group-name" className="block text-sm font-medium text-duck-dark/70 dark:text-foreground/70 mb-1.5">
Name <span className="text-red-500">*</span>
</label>
<input
id="group-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Work Projects"
className="w-full px-3 py-2 rounded-md border border-duck-dark/20 dark:border-foreground/20 bg-background text-duck-dark dark:text-foreground placeholder:text-duck-dark/40 dark:placeholder:text-foreground/40 focus:outline-none focus:ring-2 focus:ring-duck-teal/50"
autoFocus
disabled={isSubmitting}
/>
{name && (
<p className="mt-1 text-xs text-duck-dark/40 dark:text-foreground/40">
Slug: <span className="font-mono">{toSlug(name) || '(invalid)'}</span>
</p>
)}
</div>
<div>
<label htmlFor="group-description" className="block text-sm font-medium text-duck-dark/70 dark:text-foreground/70 mb-1.5">
Description <span className="text-duck-dark/40 dark:text-foreground/40">(optional)</span>
</label>
<textarea
id="group-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Add a description for this group..."
className="w-full px-3 py-2 rounded-md border border-duck-dark/20 dark:border-foreground/20 bg-background text-duck-dark dark:text-foreground placeholder:text-duck-dark/40 dark:placeholder:text-foreground/40 focus:outline-none focus:ring-2 focus:ring-duck-teal/50 resize-none"
rows={3}
disabled={isSubmitting}
/>
</div>
{error && (
<div className="p-3 rounded-md bg-red-500/10 border border-red-500/20 text-red-600 dark:text-red-400 text-sm">
{error}
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-3 pt-2">
<button
type="button"
onClick={onClose}
disabled={isSubmitting}
className="px-4 py-2 text-sm font-medium text-duck-dark/70 dark:text-foreground/70 hover:bg-duck-dark/10 dark:hover:bg-foreground/10 rounded-md transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting || !name.trim()}
className="px-4 py-2 text-sm font-medium bg-duck-teal hover:bg-duck-teal/90 text-duck-yellow rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting ? 'Creating...' : 'Create Group'}
</button>
</div>
</form>
</div>
</div>
);
}
@@ -1,122 +0,0 @@
import { useState } from 'react';
import { MoreVertical, Edit2, Trash2 } from 'lucide-react';
import { useChatGroups } from 'state/useChatGroups';
import type { GroupEntry } from 'officerdev';
type GroupContextMenuProps = {
group: GroupEntry;
};
export function GroupContextMenu({ group }: GroupContextMenuProps) {
const { updateGroup, deleteGroup } = useChatGroups();
const [isOpen, setIsOpen] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [editName, setEditName] = useState(group.name);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
async function handleRename() {
if (editName.trim() && editName !== group.name) {
await updateGroup(group.slug, { name: editName.trim() });
}
setIsEditing(false);
setIsOpen(false);
}
async function handleDelete() {
await deleteGroup(group.slug);
setShowDeleteConfirm(false);
setIsOpen(false);
}
function handleButtonClick(e: React.MouseEvent) {
e.stopPropagation();
setIsOpen(!isOpen);
}
function handleBackdropClick() {
setIsOpen(false);
setShowDeleteConfirm(false);
}
if (isEditing) {
return (
<div className="flex items-center gap-2 ml-auto" onClick={(e) => e.stopPropagation()}>
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleRename();
if (e.key === 'Escape') {
setEditName(group.name);
setIsEditing(false);
}
}}
className="px-2 py-1 text-sm border border-duck-dark/20 dark:border-foreground/20 rounded bg-background focus:outline-none focus:ring-2 focus:ring-duck-teal/50"
autoFocus
onBlur={handleRename}
/>
</div>
);
}
return (
<div className="relative ml-auto" onClick={(e) => e.stopPropagation()}>
<button
onClick={handleButtonClick}
className="p-1 rounded-md hover:bg-duck-dark/10 dark:hover:bg-foreground/10 transition-colors"
>
<MoreVertical className="h-4 w-4" />
</button>
{isOpen && (
<>
<div className="fixed inset-0 z-40" onClick={handleBackdropClick} />
<div className="absolute right-0 top-full mt-1 z-50 min-w-[160px] bg-background border border-duck-dark/10 dark:border-foreground/10 rounded-md shadow-lg overflow-hidden">
{!showDeleteConfirm ? (
<>
<button
onClick={() => {
setIsEditing(true);
setIsOpen(false);
}}
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors text-left"
>
<Edit2 className="h-4 w-4" />
Rename
</button>
<button
onClick={() => setShowDeleteConfirm(true)}
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-red-500/10 text-red-600 dark:text-red-400 transition-colors text-left"
>
<Trash2 className="h-4 w-4" />
Delete
</button>
</>
) : (
<div className="p-3 space-y-2">
<p className="text-sm text-duck-dark/70 dark:text-foreground/70">
Delete "{group.name}"? Sessions will be ungrouped.
</p>
<div className="flex gap-2">
<button
onClick={() => setShowDeleteConfirm(false)}
className="flex-1 px-3 py-1.5 text-xs rounded-md bg-duck-dark/10 dark:bg-foreground/10 hover:bg-duck-dark/20 dark:hover:bg-foreground/20 transition-colors"
>
Cancel
</button>
<button
onClick={handleDelete}
className="flex-1 px-3 py-1.5 text-xs rounded-md bg-red-500 hover:bg-red-600 text-white transition-colors"
>
Delete
</button>
</div>
</div>
)}
</div>
</>
)}
</div>
);
}
@@ -1,173 +0,0 @@
import { useEffect, useRef, useCallback, useState } from 'react';
import { Plus, MessageSquare, Folder, ChevronRight, FolderPlus } from 'lucide-react';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { useChatSessions } from 'state/useChatSessions';
import { useChatGroups } from 'state/useChatGroups';
import type { SelectedSession } from './ChatDetailPanel';
import { CreateGroupDialog } from './CreateGroupDialog';
import { GroupContextMenu } from './GroupContextMenu';
import { SessionContextMenu } from './SessionContextMenu';
export const SessionList = () => {
const { sessions, deleteSession } = useChatSessions();
const { groups } = useChatGroups();
const [selected, setSelected] = usePanelChannel<SelectedSession>('chat:selected-session', null);
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const [showCreateGroup, setShowCreateGroup] = useState(false);
const scrolledRef = useRef(false);
const selectedRef = useCallback(
(node: HTMLDivElement | null) => {
if (node && !scrolledRef.current) {
scrolledRef.current = true;
node.scrollIntoView({ block: 'center' });
}
},
[],
);
useEffect(() => {
scrolledRef.current = false;
}, [selected?.id]);
const toggleGroup = (slug: string) => {
setCollapsed(prev => {
const next = new Set(prev);
next.has(slug) ? next.delete(slug) : next.add(slug);
return next;
});
};
const ungrouped = sessions.filter(s => !s.groupSlug);
const grouped = groups.map(g => ({
...g,
sessions: sessions.filter(s => s.groupSlug === g.slug)
}));
const handleSelect = (session: (typeof sessions)[number]) => {
setSelected({ id: session.id, model: session.model ?? null });
window.history.replaceState(null, '', `/chat/${session.id}`);
};
const handleDelete = async (id: string) => {
if (selected?.id === id) {
setSelected(null);
window.history.replaceState(null, '', '/chat');
}
await deleteSession(id);
};
const renderSession = (session: (typeof sessions)[number]) => {
const isSelected = selected?.id === session.id;
return (
<div
key={session.id}
ref={isSelected ? selectedRef : undefined}
className={`group flex items-center rounded-lg border transition-colors cursor-pointer ${
isSelected
? 'border-duck-teal/30 bg-duck-teal/5 dark:bg-duck-teal/10'
: 'border-duck-dark/10 dark:border-foreground/10 bg-background/80 hover:bg-background/90'
}`}
>
<button
onClick={() => handleSelect(session)}
className="flex-1 flex items-center gap-3 px-4 py-3 min-w-0 text-left cursor-pointer"
>
<MessageSquare className="h-4 w-4 shrink-0 text-duck-teal/60" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-duck-dark/80 dark:text-foreground/80 truncate">
{session.title}
<span className="ml-1.5 font-mono text-xs font-normal text-duck-dark/25 dark:text-foreground/25">
({session.id.slice(0, 8)})
</span>
</div>
<div className="text-xs text-duck-dark/40 dark:text-foreground/40">
{new Date(session.createdAt).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</div>
{session.model && (
<div className="text-xs text-duck-teal/70 dark:text-duck-teal/60 truncate">
{session.model.includes('/') ? session.model.replace('/', ' - ') : session.model}
</div>
)}
</div>
</button>
<div className="shrink-0 mr-2">
<SessionContextMenu session={session} onDelete={handleDelete} />
</div>
</div>
);
};
return (
<div className="flex flex-col h-full overflow-hidden">
{/* Header */}
<div className="shrink-0 flex items-center justify-between px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60">
<h2 className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Sessions</h2>
<div className="flex gap-2">
<button
onClick={() => setShowCreateGroup(true)}
className="flex items-center gap-1.5 rounded-md bg-duck-dark/10 hover:bg-duck-dark/20 dark:bg-foreground/10 dark:hover:bg-foreground/20 text-duck-dark dark:text-foreground cursor-pointer h-7 px-3 text-xs font-medium"
>
<FolderPlus className="h-3.5 w-3.5" />
New Group
</button>
<button
onClick={() => {
setSelected({ id: 'new' });
window.history.replaceState(null, '', '/chat/new');
}}
className="flex items-center gap-1.5 rounded-md bg-duck-teal hover:bg-duck-teal/90 text-duck-yellow cursor-pointer h-7 px-3 text-xs font-medium"
>
<Plus className="h-3.5 w-3.5" />
New Chat
</button>
</div>
</div>
{/* Session list */}
<div className="flex-1 min-h-0 overflow-y-auto p-3 space-y-3">
{sessions.length === 0 && (
<div className="text-center py-16 text-duck-dark/30 dark:text-foreground/30 text-sm">
No sessions yet. Start a new chat!
</div>
)}
{/* Ungrouped sessions */}
{ungrouped.length > 0 && (
<div className="space-y-1.5">
{ungrouped.map(renderSession)}
</div>
)}
{/* Grouped sessions */}
{grouped.map((group) => (
<div key={group.slug} className="space-y-1.5">
<div className="flex items-center gap-2 px-3 py-2 cursor-pointer rounded-md hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors">
<div onClick={() => toggleGroup(group.slug)} className="flex items-center gap-2 flex-1 min-w-0">
<ChevronRight
className={`h-4 w-4 transition-transform ${!collapsed.has(group.slug) ? 'rotate-90' : ''}`}
/>
<Folder className="h-4 w-4 text-duck-teal" />
<span className="font-medium text-sm text-duck-dark/70 dark:text-foreground/70">{group.name}</span>
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">({group.sessionCount})</span>
</div>
<GroupContextMenu group={group} />
</div>
{!collapsed.has(group.slug) && (
<div className="ml-6 space-y-1.5">
{group.sessions.map(renderSession)}
</div>
)}
</div>
))}
</div>
{showCreateGroup && <CreateGroupDialog onClose={() => setShowCreateGroup(false)} />}
</div>
);
};
@@ -1,141 +0,0 @@
import { useState } from 'react';
import { MoreVertical, FolderInput, Edit2, Trash2 } from 'lucide-react';
import { useChatGroups } from 'state/useChatGroups';
import { useChatSessions } from 'state/useChatSessions';
import type { SessionEntry } from 'officerdev';
type SessionContextMenuProps = {
session: SessionEntry;
onDelete: (id: string) => void;
};
export function SessionContextMenu({ session, onDelete }: SessionContextMenuProps) {
const { groups, moveSession } = useChatGroups();
const { renameSession } = useChatSessions();
const [isOpen, setIsOpen] = useState(false);
const [showMoveMenu, setShowMoveMenu] = useState(false);
const [isRenaming, setIsRenaming] = useState(false);
const [editTitle, setEditTitle] = useState(session.title);
async function handleMove(groupSlug: string | null) {
await moveSession(session.id, groupSlug);
setIsOpen(false);
setShowMoveMenu(false);
}
async function handleRename() {
if (editTitle.trim() && editTitle !== session.title) {
await renameSession(session.id, editTitle.trim());
}
setIsRenaming(false);
setIsOpen(false);
}
function handleButtonClick(e: React.MouseEvent) {
e.stopPropagation();
setIsOpen(!isOpen);
}
function handleBackdropClick() {
setIsOpen(false);
setShowMoveMenu(false);
}
if (isRenaming) {
return (
<div className="flex items-center gap-2 ml-2" onClick={(e) => e.stopPropagation()}>
<input
type="text"
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleRename();
if (e.key === 'Escape') {
setEditTitle(session.title);
setIsRenaming(false);
}
}}
className="px-2 py-1 text-sm border border-duck-dark/20 dark:border-foreground/20 rounded bg-background focus:outline-none focus:ring-2 focus:ring-duck-teal/50"
autoFocus
onBlur={handleRename}
/>
</div>
);
}
return (
<div className="relative" onClick={(e) => e.stopPropagation()}>
<button
onClick={handleButtonClick}
className="p-1 rounded-md hover:bg-duck-dark/10 dark:hover:bg-foreground/10 transition-colors opacity-0 group-hover:opacity-100"
>
<MoreVertical className="h-4 w-4" />
</button>
{isOpen && (
<>
<div className="fixed inset-0 z-40" onClick={handleBackdropClick} />
<div className="absolute right-0 top-full mt-1 z-50 min-w-[160px] bg-background border border-duck-dark/10 dark:border-foreground/10 rounded-md shadow-lg overflow-hidden">
{!showMoveMenu ? (
<>
<button
onClick={() => setShowMoveMenu(true)}
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors text-left"
>
<FolderInput className="h-4 w-4" />
Move to group
</button>
<button
onClick={() => {
setIsRenaming(true);
setIsOpen(false);
}}
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors text-left"
>
<Edit2 className="h-4 w-4" />
Rename
</button>
<button
onClick={() => onDelete(session.id)}
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-red-500/10 text-red-600 dark:text-red-400 transition-colors text-left"
>
<Trash2 className="h-4 w-4" />
Delete
</button>
</>
) : (
<>
<div className="px-3 py-2 text-xs font-medium text-duck-dark/50 dark:text-foreground/50 border-b border-duck-dark/10 dark:border-foreground/10">
Move to:
</div>
<button
onClick={() => handleMove(null)}
className="w-full px-3 py-2 text-sm hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors text-left"
>
Ungrouped
</button>
{groups.map((group) => (
<button
key={group.slug}
onClick={() => handleMove(group.slug)}
className="w-full px-3 py-2 text-sm hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors text-left"
>
{group.name}
</button>
))}
<div className="border-t border-duck-dark/10 dark:border-foreground/10">
<button
onClick={() => setShowMoveMenu(false)}
className="w-full px-3 py-2 text-xs text-duck-dark/50 dark:text-foreground/50 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors text-left"
>
Back
</button>
</div>
</>
)}
</div>
</>
)}
</div>
);
}
@@ -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 type { LayoutNode, PanelComponents } from '@/components/Workspace';
import { WorkspaceLayout } from '@/components/Workspace';
import { WorkspaceView } from '@/components/Workspace';
import type { LayoutNode } from '@/components/Workspace';
import { useWorkspacesState } from 'state/useWorkspacesState';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { useChatSessions } from 'state/useChatSessions';
import { SessionList } from './Screen';
import { ChatDetailPanel, type SelectedSession } from './ChatDetailPanel';
import type { SelectedSession } from 'officerdev';
import { defaultLayout } from './defaultLayout';
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 = {
isNew?: boolean;
@@ -29,6 +18,7 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
const { sessionId } = useParams<{ sessionId: string }>();
const { sessions } = useChatSessions();
const [, setSelected] = usePanelChannel<SelectedSession>('chat:selected-session', null);
const workspace = useWorkspacesState<LayoutNode>('screens/chat', defaultLayout);
useEffect(() => {
if (isNew) {
@@ -40,17 +30,9 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
setSelected({ id: sessionId, model: session?.model ?? null });
}, [sessionId, isNew]);
const panelComponents: PanelComponents = useMemo(
() => ({
'chat-list': SessionList,
'chat-detail': ChatDetailPanel,
}),
[],
);
return (
<div className="h-full w-full pt-2">
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} components={panelComponents} />
<WorkspaceView workspace={workspace} />
</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,206 +0,0 @@
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>
);
};
@@ -1,15 +1,10 @@
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 { useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router';
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 { useAppRegistry } from 'officerdev';
import { useWorkspacesState } from 'state/useWorkspacesState';
import { WorkspaceView } from '@/components/Workspace';
import type { LayoutNode, ProjectType } from '@/components/Workspace';
import { generateSlug } from 'helpers/slug';
import {
SELECTED_PROJECT,
CREATING_PROJECT,
@@ -20,600 +15,15 @@ import {
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} />;
};
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>
);
};
} from 'officerdev';
import { defaultLayout } from './defaultLayout';
export const ProjectListScreen = () => {
const [layout, setLayout, isLoaded] = useProjectsState<LayoutNode>('proj-layout-proj-homepage', 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;
const workspace = useWorkspacesState<LayoutNode>('screens/projects', defaultLayout);
return (
<div className="h-full w-full">
<WorkspaceLayout layout={layout} onLayoutChange={setLayout} registry={projHomeRegistry} />
<WorkspaceView workspace={workspace} />
</div>
);
};
@@ -1,12 +1,11 @@
import { useParams, Navigate } from 'react-router';
import { useProjectsState } from 'state/useProjectsState';
import { useWorkspacesState } from 'state/useWorkspacesState';
import { WorkspaceView, createDefaultLayout } from '@/components/Workspace';
import type { LayoutNode, ProjectDefinition } from '@/components/Workspace';
export const ProjectScreen = () => {
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);
if (!isLoaded) return null;
@@ -16,17 +15,11 @@ export const ProjectScreen = () => {
};
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 };
const workspace = useWorkspacesState<LayoutNode>(`proj-layout-${project.id}`, createDefaultLayout());
return (
<div className="h-full w-full">
<WorkspaceView
workspace={workspace}
layout={layout}
onLayoutChange={setLayout}
/>
<WorkspaceView workspace={workspace} cwd={project.cwd} />
</div>
);
};
@@ -1,10 +0,0 @@
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,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 { WorkspaceView } from '@/components/Workspace';
import { useParams, Navigate } from 'react-router';
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 = () => {
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 (
<div className="h-full w-full">
<WorkspaceView workspace={workspace} />
<WorkspaceView workspace={wsState} cwd={workspace.cwd} />
</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';
@@ -1,6 +1,5 @@
export * from './Layout';
export * from './Home';
export * from './ChatScreen';
export * from './OnboardingAdmin';
export * from './PasskeyGate';
export * from './Plans';
@@ -14,7 +13,6 @@ export * from './Tasks';
export * from './Files';
export * from './ChatHistory';
export * from './CodeEditor';
export * from './Workspaces';
export * from './Projects';
export * from './Terminal';