Files
boocode/apps/web/src/pages/Project.tsx
indifferentketchup 9434338911 batch4.1-5.1: dedup audit, archive 400 fix, sidebar Delete, landing-page enrichment, auto-name tool-call fix
- Fastify global empty-JSON-body parser fixes archive/unarchive/stop 400s
- Removed redundant local sessionEvents.emit at all 5+2 sites with server-side WS publishers; added dedupe guards in useSidebar/Workspace/Project handlers
- Sidebar session right-click adds Delete (destructive) with confirm Dialog
- Session.tsx navigates away on session_deleted/session_archived for the active session
- SessionLandingPage chat rows show message_count, effective_context_tokens, last_message_preview via LATERAL joins on GET /api/sessions/:id/chats
- Workspace.tsx pane drag-to-reorder using native HTML5 events (no new deps)
- CompactCard: Copy toast, Send-to-chat with target chat name, empty-state in share popover, Re-run button
- auto_name.ts: filter count gate and assistant-fetch by content <> '' so tool-call assistant rows don't trip the once-and-only-once guard
- Adds CLAUDE.md and apps/web/src/lib/format.ts
2026-05-15 23:36:01 +00:00

194 lines
7.1 KiB
TypeScript

import { useEffect, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { Plus, MessageSquare, Trash2, ChevronDown, ChevronRight, RotateCcw } 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';
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);
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-6 py-3 flex items-center justify-between">
<div>
<h1 className="text-lg font-semibold tracking-tight">
{project?.name ?? '…'}
</h1>
<div className="text-xs text-muted-foreground font-mono">
{project?.path}
</div>
</div>
<Button onClick={handleNew} disabled={creating}>
<Plus />
New session
</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">
<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>
);
}