Ship Paseo-equivalent provider snapshot, AgentComposerBar, ACP dispatch rewrite with streaming/persist, permission prompts, and agent commands. Follow-up: pane-scoped chat resolution, CoderMessageList tool timeline, WS user-delta replace, and inference orphan tool_call stripping. Archive openspec v2-2; update CHANGELOG and CURRENT. Co-authored-by: Cursor <cursoragent@cursor.com>
218 lines
8.3 KiB
TypeScript
218 lines
8.3 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { Link, useNavigate, useParams } from 'react-router-dom';
|
|
import { Plus, MessageSquare, Trash2, ChevronDown, ChevronRight, RotateCcw, Menu, Code } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import { api } from '@/api/client';
|
|
import type { Project as ProjectType, Session } from '@/api/types';
|
|
import { Button } from '@/components/ui/button';
|
|
import { sessionEvents } from '@/hooks/sessionEvents';
|
|
import { useSessions } from '@/hooks/useSessions';
|
|
import { isCoderSessionName } from '@/lib/coder-session';
|
|
import { useSidebarDrawer } from '@/hooks/useSidebarDrawer';
|
|
import { useViewport } from '@/hooks/useViewport';
|
|
|
|
export function Project() {
|
|
const { id } = useParams<{ id: string }>();
|
|
const navigate = useNavigate();
|
|
const { sessions, create, remove } = useSessions(id);
|
|
const [project, setProject] = useState<ProjectType | null>(null);
|
|
const [creating, setCreating] = useState(false);
|
|
const [archivedSessions, setArchivedSessions] = useState<Session[] | null>(null);
|
|
const [showArchived, setShowArchived] = useState(false);
|
|
const { setOpen: setDrawerOpen } = useSidebarDrawer();
|
|
const { isMobile } = useViewport();
|
|
|
|
useEffect(() => {
|
|
if (!id) return;
|
|
api.projects
|
|
.list()
|
|
.then((list) => setProject(list.find((p) => p.id === id) ?? null))
|
|
.catch(() => {});
|
|
}, [id]);
|
|
|
|
useEffect(() => {
|
|
if (!id) return;
|
|
api.sessions.listForProject(id, 'archived')
|
|
.then(setArchivedSessions)
|
|
.catch(() => {});
|
|
}, [id]);
|
|
|
|
useEffect(() => {
|
|
return sessionEvents.subscribe((event) => {
|
|
if (event.type === 'session_archived' && event.project_id === id) {
|
|
setArchivedSessions((prev) => {
|
|
if (!prev) return prev;
|
|
if (prev.some((s) => s.id === event.session_id)) return prev;
|
|
const session = sessions?.find((s) => s.id === event.session_id);
|
|
if (!session) return prev;
|
|
return [{ ...session, status: 'archived' as const }, ...prev];
|
|
});
|
|
}
|
|
if (event.type === 'session_deleted' && event.project_id === id) {
|
|
setArchivedSessions((prev) =>
|
|
prev ? prev.filter((s) => s.id !== event.session_id) : prev
|
|
);
|
|
}
|
|
});
|
|
}, [id, sessions]);
|
|
|
|
async function handleNew() {
|
|
if (!id || creating) return;
|
|
setCreating(true);
|
|
try {
|
|
const s = await create({});
|
|
// Server publishes session_created via WS; let useUserEvents deliver it.
|
|
navigate(`/session/${s.id}`);
|
|
} finally {
|
|
setCreating(false);
|
|
}
|
|
}
|
|
|
|
async function handleUnarchive(sessionId: string) {
|
|
try {
|
|
await api.sessions.unarchive(sessionId);
|
|
setArchivedSessions((prev) =>
|
|
prev ? prev.filter((s) => s.id !== sessionId) : prev
|
|
);
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'failed to unarchive');
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="flex-1 flex flex-col">
|
|
<header
|
|
className="border-b px-3 sm:px-6 py-2 sm:py-3 flex items-center justify-between gap-2"
|
|
style={{ paddingTop: 'max(0.5rem, env(safe-area-inset-top))' }}
|
|
>
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
{isMobile && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setDrawerOpen(true)}
|
|
className="inline-flex items-center justify-center -ml-1 min-w-[44px] min-h-[44px] rounded text-muted-foreground hover:bg-muted hover:text-foreground shrink-0"
|
|
aria-label="Open sidebar"
|
|
>
|
|
<Menu className="size-5" />
|
|
</button>
|
|
)}
|
|
<div className="min-w-0">
|
|
<h1 className="text-base sm:text-lg font-semibold tracking-tight truncate">
|
|
{project?.name ?? '…'}
|
|
</h1>
|
|
<div className="text-xs text-muted-foreground font-mono truncate hidden sm:block">
|
|
{project?.path}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<Button onClick={handleNew} disabled={creating} className="shrink-0" aria-label="New session">
|
|
<Plus />
|
|
<span className="hidden sm:inline">New session</span>
|
|
</Button>
|
|
</header>
|
|
|
|
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
|
|
{sessions === null && (
|
|
<div className="text-sm text-muted-foreground">Loading…</div>
|
|
)}
|
|
{sessions && sessions.length === 0 && (
|
|
<div className="text-sm text-muted-foreground">
|
|
No sessions yet. Click <span className="font-medium">New session</span> to start.
|
|
</div>
|
|
)}
|
|
{sessions && sessions.length > 0 && (
|
|
<ul className="divide-y rounded-md border">
|
|
{sessions.map((s) => (
|
|
<li key={s.id} className="flex items-center justify-between px-3 py-2 hover:bg-muted/50">
|
|
<Link to={`/session/${s.id}`} className="flex-1 flex items-center gap-2 min-w-0">
|
|
{isCoderSessionName(s.name) ? (
|
|
<Code className="size-3.5 opacity-70 shrink-0" />
|
|
) : (
|
|
<MessageSquare className="size-3.5 opacity-70 shrink-0" />
|
|
)}
|
|
<span className="truncate text-sm">{s.name}</span>
|
|
<span className="text-xs text-muted-foreground font-mono ml-2 shrink-0">
|
|
{s.model}
|
|
</span>
|
|
</Link>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
aria-label="Delete session"
|
|
onClick={async () => {
|
|
try {
|
|
await remove(s.id);
|
|
// Server publishes session_deleted via WS.
|
|
} catch (err) {
|
|
toast.error(
|
|
err instanceof Error ? err.message : 'failed to delete session'
|
|
);
|
|
}
|
|
}}
|
|
>
|
|
<Trash2 />
|
|
</Button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
|
|
{/* Archived sessions */}
|
|
{archivedSessions && archivedSessions.length > 0 && (
|
|
<div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowArchived(!showArchived)}
|
|
className="flex items-center gap-1 text-xs font-medium text-muted-foreground mb-2 hover:text-foreground"
|
|
>
|
|
{showArchived ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
|
Closed sessions ({archivedSessions.length})
|
|
</button>
|
|
{showArchived && (
|
|
<ul className="divide-y rounded-md border">
|
|
{archivedSessions.map((s) => (
|
|
<li key={s.id} className="flex items-center justify-between px-3 py-2 hover:bg-muted/50">
|
|
<div className="flex-1 flex items-center gap-2 min-w-0">
|
|
<MessageSquare className="size-3.5 opacity-40 shrink-0" />
|
|
<span className="truncate text-sm text-muted-foreground">{s.name}</span>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
aria-label="Reopen session"
|
|
onClick={() => void handleUnarchive(s.id)}
|
|
>
|
|
<RotateCcw size={14} />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
aria-label="Delete session permanently"
|
|
onClick={async () => {
|
|
try {
|
|
await api.sessions.remove(s.id);
|
|
setArchivedSessions((prev) =>
|
|
prev ? prev.filter((a) => a.id !== s.id) : prev
|
|
);
|
|
} catch (err) {
|
|
toast.error(
|
|
err instanceof Error ? err.message : 'failed to delete'
|
|
);
|
|
}
|
|
}}
|
|
>
|
|
<Trash2 />
|
|
</Button>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|