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.
322 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|