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
@@ -4,9 +4,11 @@ import { appRegistryMetas as codeEditorMetas } from '../apps/CodeEditor';
import { appRegistryMetas as chatMetas } from '../apps/Chat';
import { appRegistryMetas as fileViewerMetas } from '../apps/FileViewer';
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';
const apps = [...fileBrowserMetas, ...terminalMetas, ...codeEditorMetas, ...chatMetas, ...fileViewerMetas, ...workspaceMetas];
const apps = [...fileBrowserMetas, ...terminalMetas, ...codeEditorMetas, ...chatMetas, ...fileViewerMetas, ...workspaceMetas, ...projectMetas, ...chatHistoryMetas];
export const AppRegistry = () => {
useAppRegistry(apps);
@@ -0,0 +1,162 @@
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 '../Chat';
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;
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);
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 const 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} />;
};
@@ -0,0 +1,138 @@
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>
);
}
@@ -0,0 +1,122 @@
import { useState } from 'react';
import { MoreVertical, Edit2, Trash2 } from 'lucide-react';
import { useChatGroups } from 'state/useChatGroups';
import type { GroupEntry } from '../Chat';
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>
);
}
@@ -0,0 +1,141 @@
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 '../Chat';
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,173 @@
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 +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 { 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 = {
basePath?: string;
rootOverride?: string;
};
export const FileBrowserApp = ({ basePath = '/' }: FileBrowserAppProps) => {
const fileBrowserManager = useFileBrowserApp(basePath);
export const FileBrowserApp = ({ basePath = '/', rootOverride }: FileBrowserAppProps) => {
const fileBrowserManager = useFileBrowserApp(basePath, rootOverride);
const { handleNavigate } = fileBrowserManager;
return (
@@ -5,5 +5,6 @@ const cwdToPath = (cwd: string) => (cwd === '~' ? '/' : cwd.slice(1));
export const FileBrowserPanelWrapper = () => {
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 { useAuth } from 'hooks/useAuth';
export const useFileBrowserApp = (basePath: string) => {
export const useFileBrowserApp = (basePath: string, rootOverride?: string) => {
const { user } = useAuth();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
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 [loading, setLoading] = useState(true);
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 fileScrollRef = useRef<HTMLDivElement | null>(null);
const viewPath = searchParams.get('view');
const files = useFilesAPI(homeRoot);
const files = useFilesAPI(rootOverride ?? homeRoot);
const filesRef = useRef(files);
filesRef.current = files;
const currentPathRef = useRef(currentPath);
@@ -0,0 +1,212 @@
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 { useWorkspacesState } from 'state/useWorkspacesState';
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 { value: projects } = useWorkspacesState<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;
// Optimistic: remove from cache immediately
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);
if (selected === deleting.id) setSelected(null);
setDeleting(null);
client
.patch('/workspaces', { [`proj-meta-${deleting.id}`]: null })
.then((res) => queryClient.setQueryData(['WORKSPACES_STATE'], res))
.catch(() => {});
};
const handleClick = (p: ProjectDefinition) => {
if (isProjectsPage) {
setSelected(p.id);
setCreating(false);
setEditing(null);
} else {
navigate(`/projects/${p.id}`);
}
};
return (
<div className="flex flex-col h-full overflow-y-auto">
<div className="p-3 pb-0 flex flex-col gap-2">
<Link
to="/projects"
className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium bg-emerald-500/15 text-emerald-400"
>
<FolderKanban className="h-4 w-4" />
Projects
</Link>
<button
type="button"
onClick={() => {
setSelected(null);
setEditing(null);
setName(generateSlug());
setDescription('');
setTemplateIdx(0);
setProjectType('app');
setHasBackend(false);
setHasAuth(false);
setCreating(true);
if (!isProjectsPage) navigate('/projects');
}}
className="flex items-center justify-center gap-1 py-2 px-3 rounded-lg text-sm font-medium bg-emerald-500 hover:bg-emerald-500/90 text-white transition-all cursor-pointer"
>
<Plus className="h-4 w-4 shrink-0" />
New Project
</button>
</div>
<div className="relative px-3 pt-2">
<Search className="absolute left-6 top-1/2 h-3.5 w-3.5 text-gray-500 pointer-events-none" />
<input
type="text"
value={search}
onChange={(ev) => setSearch(ev.target.value)}
placeholder="Search projects"
className="w-full rounded-lg border border-duck-dark/15 bg-transparent py-1.5 pl-8 pr-3 text-sm text-white placeholder:text-gray-500 focus:outline-none focus:ring-1 focus:ring-emerald-500/30 focus:border-emerald-500/40"
/>
</div>
<div className="flex flex-col gap-0.5 px-3 pt-2">
{filtered.map((p) => (
<div
key={p.id}
className={`flex items-center gap-2.5 py-2 px-3 rounded-lg text-sm font-medium cursor-pointer group ${
isProjectsPage && selected === p.id
? 'bg-emerald-500/10 text-emerald-400'
: 'text-white/80 hover:bg-duck-dark/5'
}`}
onClick={() => handleClick(p)}
>
<FolderKanban className="h-4 w-4 shrink-0" />
<span className="flex-1 text-left truncate">{p.name}</span>
<span className="text-[10px] text-duck-dark/30 shrink-0">{PROJECT_TYPE_LABELS[p.projectType]}</span>
{isProjectsPage && (
<>
<button
type="button"
onClick={(ev) => handleEdit(ev, p)}
className="shrink-0 p-1 rounded text-duck-dark/20 md:opacity-0 md:group-hover:opacity-100 hover:text-emerald-400 transition-opacity cursor-pointer"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={(ev) => handleDelete(ev, p)}
className="shrink-0 p-1 rounded text-duck-dark/20 md:opacity-0 md:group-hover:opacity-100 hover:text-red-500 transition-opacity cursor-pointer"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</>
)}
</div>
))}
{filtered.length === 0 && (
<p className="text-xs text-gray-500 px-3 py-4 text-center">
{search ? 'No matches' : 'No projects yet'}
</p>
)}
</div>
<AlertDialog open={deleting !== null} onOpenChange={(open) => { if (!open) setDeleting(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete project</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete <strong>{deleting?.name}</strong>? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={confirmDelete} className="bg-red-600 hover:bg-red-700">
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};
@@ -0,0 +1,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,10 @@
export const SELECTED_PROJECT = 'SELECTED_PROJECT';
export const CREATING_PROJECT = 'CREATING_PROJECT';
export const EDITING_PROJECT = 'EDITING_PROJECT';
export const NEW_PROJ_NAME = 'NEW_PROJ_NAME';
export const NEW_PROJ_DESC = 'NEW_PROJ_DESC';
export const NEW_PROJ_TEMPLATE = 'NEW_PROJ_TEMPLATE';
export const NEW_PROJ_TYPE = 'NEW_PROJ_TYPE';
export const NEW_PROJ_HAS_BACKEND = 'NEW_PROJ_HAS_BACKEND';
export const NEW_PROJ_HAS_AUTH = 'NEW_PROJ_HAS_AUTH';
export const NEW_PROJ_PREVIEW_LAYOUT = 'NEW_PROJ_PREVIEW_LAYOUT';
@@ -0,0 +1,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;
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} />;
};
@@ -8,13 +8,14 @@ export const TerminalHeader = ({ panelId }: { panelId: string }) => {
const { user } = useAuth();
const { mode, toggle } = useTerminalMode(panelId);
const isHost = mode === 'host';
const Icon = isHost ? Monitor : TerminalSquare;
const scoped = cwd !== '~';
const Icon = isHost && !scoped ? Monitor : TerminalSquare;
return (
<>
<Icon className="h-3.5 w-3.5 shrink-0" />
<span className="text-xs font-medium shrink-0">Terminal</span>
{user?.role === 'Super Admin' && (
{user?.role === 'Super Admin' && !scoped && (
<button
type="button"
onClick={toggle}
@@ -9,7 +9,8 @@ const EMPTY_TERMINALS: Record<string, string> = {};
export const TerminalWrapper = ({ panelId }: { panelId: string }) => {
const { workspaceId, cwd } = useWorkspace();
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 { value: terminals, setValue: setTerminals } = useWorkspacesState<Record<string, string>>(stateKey, EMPTY_TERMINALS);
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 type { UseEmbeddableChatType, UsePiChatType, UseChatSessionsType, UseChatSessionType, UseAttachmentsType, UseAudioRecordingType } from './apps/Chat';
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 { useFilesAPI, useTasks, useRecentFiles, usePinnedFiles, FileBrowserApp, FileBrowserPanelWrapper, FileBrowserWidget, TaskRunnerModal } 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 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 { 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 { useUserState } from './useUserState';
export { useWorkspacesState } from './useWorkspacesState';
export { useProjectsState } from './useProjectsState';
export { usePiModels, useVisiblePiModels, modelKey } from './useModels';
export type { ModelOption } from './useModels';
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];
}