Files
boocode/apps/web/src/components/ProjectSidebar.tsx
indifferentketchup 337bbad973 batch4: chats-in-sessions, force-send, /compact, right-rail file browser
Session 1:N Chat data model with backfill. Workspace switches to client-side
multi-tab pane management. Right-rail file browser with float-over viewer and
click-drag line selection replaces FileBrowserPane. Adds /compact streaming
summarizer (respects compact markers in context builder), force-send (cancels
in-flight, persists partial as 'cancelled', awaits cancellation completion via
deferred Promise + 5s timeout), message queue, stop generation, chat
auto-rename, session archive/unarchive with Closed Sessions section on repo
landing page. CHECK constraints on sessions.status, messages.role,
messages.status with KEEP IN SYNC comments tying to MESSAGE_ROLES /
MESSAGE_STATUSES const arrays. Deletes dead pane routes/hook and the
api.panes.* client block.
2026-05-15 20:39:48 +00:00

322 lines
12 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from 'react';
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
import { ChevronRight, Folder, MessageSquare, Plus } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/components/ui/context-menu';
import { AddProjectModal } from './AddProjectModal';
import { api } from '@/api/client';
import { sessionEvents } from '@/hooks/sessionEvents';
import { useSidebar } from '@/hooks/useSidebar';
import type { SidebarProject } from '@/api/types';
import { cn } from '@/lib/utils';
const EXPANDED_KEY = 'boocode.sidebar.expanded';
const MAX_VISIBLE_SESSIONS = 5;
function readExpanded(): Set<string> {
try {
const raw = localStorage.getItem(EXPANDED_KEY);
if (!raw) return new Set();
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return new Set();
return new Set(parsed.filter((v): v is string => typeof v === 'string'));
} catch {
return new Set();
}
}
function writeExpanded(ids: Set<string>): void {
try {
localStorage.setItem(EXPANDED_KEY, JSON.stringify(Array.from(ids)));
} catch {
/* quota or disabled storage — ignore */
}
}
function relTime(iso: string): string {
const now = Date.now();
const t = Date.parse(iso);
if (Number.isNaN(t)) return '';
const sec = Math.max(0, Math.floor((now - t) / 1000));
if (sec < 60) return `${sec}s`;
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h`;
const day = Math.floor(hr / 24);
if (day < 30) return `${day}d`;
const mo = Math.floor(day / 30);
if (mo < 12) return `${mo}mo`;
return `${Math.floor(mo / 12)}y`;
}
function activeProjectId(
pathname: string,
projects: SidebarProject[],
activeSession: { session_id: string; project_id: string } | null
): string | null {
const pm = pathname.match(/^\/project\/([^/]+)/);
if (pm?.[1]) return pm[1];
const sm = pathname.match(/^\/session\/([^/]+)/);
const sid = sm?.[1];
if (!sid) return null;
// Prefer the cache lookup so we resolve correctly even when an older
// activeSession (from a prior route) hasn't been cleared yet.
const fromCache = projects.find((p) =>
p.recent_sessions.some((s) => s.id === sid)
)?.id;
if (fromCache) return fromCache;
// Fallback: the session was loaded via deep link (not in cache) and
// emitted session_loaded — use that. Guard against stale values by
// matching the current URL sid.
if (activeSession && activeSession.session_id === sid) {
return activeSession.project_id;
}
return null;
}
function activeSessionId(pathname: string): string | null {
const m = pathname.match(/^\/session\/([^/]+)/);
return m?.[1] ?? null;
}
export function ProjectSidebar() {
const { data, error, loading, retry, activeSession: loadedActiveSession } =
useSidebar();
const [addOpen, setAddOpen] = useState(false);
const [expanded, setExpanded] = useState<Set<string>>(() => readExpanded());
const [renamingSession, setRenamingSession] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState('');
const navigate = useNavigate();
const location = useLocation();
const lastToastedError = useRef<string | null>(null);
useEffect(() => {
if (error && !data && error !== lastToastedError.current) {
toast.error(error);
lastToastedError.current = error;
}
if (!error) lastToastedError.current = null;
}, [error, data]);
const projects = data?.projects ?? [];
const activeProject = useMemo(
() => activeProjectId(location.pathname, projects, loadedActiveSession),
[location.pathname, projects, loadedActiveSession]
);
const activeSession = useMemo(
() => activeSessionId(location.pathname),
[location.pathname]
);
function toggle(id: string) {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
writeExpanded(next);
return next;
});
}
async function handleRemove(id: string) {
try {
await api.projects.remove(id);
sessionEvents.emit({ type: 'project_deleted', project_id: id });
navigate('/');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'failed to remove project');
}
}
async function handleArchiveSession(sessionId: string, projectId: string) {
try {
await api.sessions.archive(sessionId);
sessionEvents.emit({ type: 'session_archived', session_id: sessionId, project_id: projectId });
if (activeSession === sessionId) navigate(`/project/${projectId}`);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'failed to archive session');
}
}
async function handleRenameSession(sessionId: string) {
const trimmed = renameValue.trim();
setRenamingSession(null);
if (!trimmed) return;
try {
await api.sessions.update(sessionId, { name: trimmed });
sessionEvents.emit({ type: 'session_renamed', session_id: sessionId, name: trimmed });
} catch (err) {
toast.error(err instanceof Error ? err.message : 'failed to rename session');
}
}
const rowCls = (active: boolean) =>
active ? 'bg-sidebar-accent text-sidebar-accent-foreground' : 'hover:bg-sidebar-accent/60';
return (
<aside className="w-60 shrink-0 border-r bg-sidebar text-sidebar-foreground flex flex-col h-screen">
<div className="px-4 py-3 border-b flex items-center justify-between">
<NavLink to="/" className="font-semibold tracking-tight text-base">
BooCode
</NavLink>
<Button size="icon-sm" variant="ghost" onClick={() => setAddOpen(true)} aria-label="Add project">
<Plus />
</Button>
</div>
<nav className="flex-1 overflow-y-auto py-2">
{loading && data == null && (
<div className="space-y-2 px-2">
{[0, 1, 2, 3].map((i) => (
<div key={i} className="bg-muted/40 animate-pulse rounded h-6" />
))}
</div>
)}
{data != null && projects.length === 0 && (
<div className="px-4 py-2 text-xs text-muted-foreground">
No projects yet. Click + to add one.
</div>
)}
{error != null && !data && (
<div className="px-4 py-2 space-y-2">
<div className="text-xs text-muted-foreground">{error}</div>
<Button size="sm" variant="outline" onClick={retry}>
Retry
</Button>
</div>
)}
{data != null &&
projects.map((p) => {
const isActiveProject = activeProject === p.id;
const isExpanded = isActiveProject || expanded.has(p.id);
const visible = p.recent_sessions.slice(0, MAX_VISIBLE_SESSIONS);
return (
<div key={p.id} className="px-2">
<DropdownMenu>
<div
className={`group flex items-center gap-1 rounded-md px-2 py-1.5 text-sm ${rowCls(isActiveProject)}`}
onContextMenu={(e) => {
e.preventDefault();
(
e.currentTarget.parentElement?.querySelector(
'[data-ctxtrigger]'
) as HTMLElement | null
)?.click();
}}
>
<button
type="button"
aria-label={isExpanded ? 'Collapse' : 'Expand'}
aria-expanded={isExpanded}
disabled={isActiveProject}
onClick={(e) => {
e.stopPropagation();
if (isActiveProject) return;
toggle(p.id);
}}
className={cn(
'flex items-center justify-center size-4 shrink-0 opacity-70 hover:opacity-100',
isActiveProject &&
'opacity-50 cursor-not-allowed hover:opacity-50'
)}
>
<ChevronRight
className={`size-3.5 transition-transform ${isExpanded ? 'rotate-90' : ''}`}
/>
</button>
<NavLink to={`/project/${p.id}`} className="flex items-center gap-2 min-w-0 flex-1">
<Folder className="size-3.5 shrink-0 opacity-70" />
<span className="truncate" title={p.name}>{p.name}</span>
</NavLink>
</div>
<DropdownMenuTrigger asChild>
<button data-ctxtrigger className="hidden" aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem variant="destructive" onClick={() => void handleRemove(p.id)}>
Remove from sidebar
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{isExpanded && (
<div className="ml-5 mt-0.5 space-y-0.5">
{visible.map((s) => (
<ContextMenu key={s.id}>
<ContextMenuTrigger asChild>
{renamingSession === s.id ? (
<div className={`flex items-center gap-2 rounded-md px-2 py-1 text-sm min-w-0 ${rowCls(activeSession === s.id)}`}>
<MessageSquare className="size-3.5 shrink-0 opacity-70" />
<input
autoFocus
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={() => void handleRenameSession(s.id)}
onKeyDown={(e) => {
if (e.key === 'Enter') void handleRenameSession(s.id);
if (e.key === 'Escape') setRenamingSession(null);
}}
className="bg-transparent border-b border-border text-sm outline-none flex-1 min-w-0"
/>
</div>
) : (
<NavLink
to={`/session/${s.id}`}
className={`flex items-center gap-2 rounded-md px-2 py-1 text-sm min-w-0 ${rowCls(activeSession === s.id)}`}
>
<MessageSquare className="size-3.5 shrink-0 opacity-70" />
<span className="truncate flex-1" title={s.name}>{s.name}</span>
<span className="text-xs text-muted-foreground shrink-0 tabular-nums">
{relTime(s.updated_at)}
</span>
</NavLink>
)}
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onSelect={() => {
setRenamingSession(s.id);
setRenameValue(s.name);
}}>
Rename
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onSelect={() => void handleArchiveSession(s.id, p.id)}>
Archive
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
))}
{p.total_sessions > MAX_VISIBLE_SESSIONS && (
<NavLink
to={`/project/${p.id}`}
className="block rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-sidebar-accent/60"
>
View all ({p.total_sessions})
</NavLink>
)}
</div>
)}
</div>
);
})}
</nav>
<AddProjectModal open={addOpen} onOpenChange={setAddOpen} onAdded={() => {}} />
</aside>
);
}