Files
boocode/apps/web/src/components/ProjectSidebar.tsx
indifferentketchup 3a26563be2 feat(coder): guard session delete against worktree work loss
Deleting a BooChat session CASCADE-wipes its session_worktrees row, which would silently orphan uncommitted/unpushed/unmerged work in the worktree. Add a pre-DELETE gate: the server reads session_worktrees from the shared DB first (no row = chat-only session = delete immediately, zero round-trip), and for worktree-backed sessions calls a new BooCoder endpoint that runs git on the host (only the host systemd service can see /tmp/booworktrees). checkWorktreeWorkAtRisk reports dirty/unpushed/unmerged via the audited hostExec+shellEscape path; default branch is detected from refs/remotes/origin/HEAD (not the worktree's own branch), never hardcoded. Any at-risk worktree returns 409 with per-worktree RiskReport[]; force=true bypasses the check entirely. Fail-closed: coder unreachable/errored also blocks (force still escapes). The sidebar renders a block dialog distinguishing work-at-risk (Commit/Stash/Force) from couldn't-verify (Cancel/Force only); stash uses -u and re-blocks on remaining commits with an explanatory message. Commit never auto-commits — it routes the user to the session.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 22:01:25 +00:00

675 lines
28 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from 'react';
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
import { ChevronRight, ExternalLink, Folder, MessageSquare, Plus, Settings as SettingsIcon, X, Code } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { sessionEvents } from '@/hooks/sessionEvents';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/components/ui/context-menu';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { AddProjectModal } from './AddProjectModal';
import { api, ApiError } from '@/api/client';
import { useSidebar } from '@/hooks/useSidebar';
import { useSidebarDrawer } from '@/hooks/useSidebarDrawer';
import { useViewport } from '@/hooks/useViewport';
import { usePullToRefresh } from '@/hooks/usePullToRefresh';
import type { SidebarProject, WorktreeRiskReport } from '@/api/types';
import { giteaUrlFor } from '@/lib/projectUrls';
import { isCoderSessionName } from '@/lib/coder-session';
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 [deleteConfirm, setDeleteConfirm] = useState<{ id: string; name: string } | null>(null);
const [renamingProject, setRenamingProject] = useState<string | null>(null);
const [renameProjectValue, setRenameProjectValue] = useState('');
const [archiveProjectConfirm, setArchiveProjectConfirm] = useState<{ id: string; name: string } | null>(null);
// Work-at-risk dialog: shown when a delete is blocked (409) because the
// session's worktree holds uncommitted/unpushed/unmerged work.
const [riskState, setRiskState] = useState<{
sessionId: string;
projectId: string;
name: string;
message: string;
reports: WorktreeRiskReport[];
} | null>(null);
const [riskBusy, setRiskBusy] = useState(false);
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 handleArchiveProject(id: string) {
try {
await api.projects.archive(id);
// Server publishes project_archived via WS.
if (activeProject === id) navigate('/');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'failed to archive project');
}
}
async function handleRenameProject(id: string) {
const trimmed = renameProjectValue.trim();
setRenamingProject(null);
if (!trimmed) return;
try {
await api.projects.update(id, { name: trimmed });
// Server publishes project_updated via WS.
} catch (err) {
toast.error(err instanceof Error ? err.message : 'failed to rename project');
}
}
async function handleArchiveSession(sessionId: string, projectId: string) {
try {
await api.sessions.archive(sessionId);
// Server publishes session_archived via WS; useUserEvents delivers it.
if (activeSession === sessionId) navigate(`/project/${projectId}`);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'failed to archive session');
}
}
async function handleDeleteSession(
sessionId: string,
projectId: string,
name: string,
force = false,
) {
try {
await api.sessions.remove(sessionId, force);
// Server publishes session_deleted via WS; useUserEvents delivers it.
setRiskState(null);
if (activeSession === sessionId) navigate(`/project/${projectId}`);
} catch (err) {
// 409 => the server's work-loss guard blocked the delete. Open the
// work-at-risk dialog with the per-worktree reports instead of toasting.
if (
err instanceof ApiError &&
err.status === 409 &&
err.body && typeof err.body === 'object' && 'reports' in err.body
) {
const body = err.body as { error?: string; reports?: WorktreeRiskReport[] };
setRiskState({
sessionId,
projectId,
name,
message: body.error ?? 'This session has work at risk.',
reports: body.reports ?? [],
});
return;
}
toast.error(err instanceof Error ? err.message : 'failed to delete session');
}
}
// Stash the worktree's uncommitted changes (recoverable), then re-attempt the
// delete. If unpushed/unmerged commits remain, the retry 409s again and the
// dialog re-renders with the narrowed risk.
async function handleStashAndRetry() {
if (!riskState || riskBusy) return;
setRiskBusy(true);
try {
const { results } = await api.sessions.worktreeStash(riskState.sessionId);
const failed = results.find((r) => r.error);
if (failed) {
toast.error(`stash failed: ${failed.error}`);
return;
}
await handleDeleteSession(riskState.sessionId, riskState.projectId, riskState.name, false);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'stash failed');
} finally {
setRiskBusy(false);
}
}
// Explicit, destructive override — deletes despite work at risk.
async function handleForceDelete() {
if (!riskState || riskBusy) return;
setRiskBusy(true);
try {
await handleDeleteSession(riskState.sessionId, riskState.projectId, riskState.name, true);
} finally {
setRiskBusy(false);
}
}
// Route the user to commit it themselves — never auto-commit. Opens the
// session workspace where they can use a terminal or agent pane.
function handleGoCommit() {
if (!riskState) return;
const sessionId = riskState.sessionId;
setRiskState(null);
navigate(`/session/${sessionId}`);
toast.info('Open a terminal or agent in this session, commit and push your work, then delete again.');
}
async function handleRenameSession(sessionId: string) {
const trimmed = renameValue.trim();
setRenamingSession(null);
if (!trimmed) return;
try {
await api.sessions.update(sessionId, { name: trimmed });
// Server publishes session_renamed via broker.publishUser; useUserEvents
// forwards onto the bus. No local emit needed.
} 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';
const { open: drawerOpen, setOpen: setDrawerOpen } = useSidebarDrawer();
const { isMobile } = useViewport();
const pull = usePullToRefresh(() => retry(), { enabled: isMobile });
// On mobile the sidebar is a slide-in drawer (fixed, z-40, off-screen by
// default). On desktop it sits inline as a normal flex column. The
// backdrop is rendered by AppShell; drawer-open state lives in
// SidebarDrawerProvider.
const asideCls = isMobile
? cn(
'fixed inset-y-0 left-0 z-40 w-60 border-r bg-sidebar text-sidebar-foreground flex flex-col',
'transition-transform duration-200 ease-out',
drawerOpen ? 'translate-x-0' : '-translate-x-full',
)
: 'w-60 shrink-0 border-r bg-sidebar text-sidebar-foreground flex flex-col h-screen';
// Work-at-risk dialog framing. The server returns 409 in two distinct
// situations: (1) work genuinely at risk (reports has ≥1 atRisk entry), or
// (2) it couldn't verify (BooCoder down/errored → reports is empty). These
// are different user stories — "your work is in danger" vs "the checker is
// offline" — so the dialog must not show one generic message for both.
const atRiskReports = riskState?.reports.filter((r) => r.atRisk) ?? [];
const verifyFailed = riskState !== null && atRiskReports.length === 0;
const anyDirty = atRiskReports.some((r) => r.dirty);
// Commit-based risk (unpushed/unmerged) that stash can NOT clear. When this is
// all that remains (e.g. after a stash cleared the dirty changes), the dialog
// explains why it re-blocked and hides the Stash button so it doesn't look
// like stash "didn't work".
const anyCommits = atRiskReports.some((r) => r.unpushed !== 0 || r.unmerged > 0);
return (
<aside className={asideCls}>
<div className="px-4 py-3 border-b flex items-center justify-between">
<NavLink to="/" className="font-semibold tracking-tight text-base">
BooCode
</NavLink>
<div className="flex items-center gap-1">
<Button size="icon-sm" variant="ghost" onClick={() => setAddOpen(true)} aria-label="Add project">
<Plus />
</Button>
{isMobile && (
<Button
size="icon-sm"
variant="ghost"
onClick={() => setDrawerOpen(false)}
aria-label="Close sidebar"
>
<X />
</Button>
)}
</div>
</div>
{isMobile && (pull.pullDist > 0 || pull.refreshing) && (
<div
className="flex items-center justify-center text-[10px] uppercase tracking-wide text-muted-foreground border-b overflow-hidden shrink-0"
style={{
height: pull.refreshing ? 32 : Math.min(pull.pullDist, 80),
transition: pull.pullDist === 0 && !pull.refreshing ? 'height 0.2s ease' : undefined,
}}
aria-live="polite"
>
{pull.refreshing
? 'Refreshing…'
: pull.pullDist >= 80
? 'Release to refresh'
: 'Pull to refresh'}
</div>
)}
<nav
className="flex-1 overflow-y-auto py-2"
onTouchStart={isMobile ? pull.onTouchStart : undefined}
onTouchMove={isMobile ? pull.onTouchMove : undefined}
onTouchEnd={isMobile ? pull.onTouchEnd : undefined}
onTouchCancel={isMobile ? pull.onTouchEnd : undefined}
>
{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">
<ContextMenu>
<ContextMenuTrigger asChild>
<div
className={`group flex items-center gap-1 rounded-md px-2 py-1.5 text-sm ${rowCls(isActiveProject)}`}
>
<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>
{renamingProject === p.id ? (
<div className="flex items-center gap-2 min-w-0 flex-1">
<Folder className="size-3.5 shrink-0 opacity-70" />
<input
autoFocus
value={renameProjectValue}
onChange={(e) => setRenameProjectValue(e.target.value)}
onBlur={() => void handleRenameProject(p.id)}
onKeyDown={(e) => {
if (e.key === 'Enter') void handleRenameProject(p.id);
if (e.key === 'Escape') setRenamingProject(null);
}}
className="bg-transparent border-b border-border text-sm outline-none flex-1 min-w-0"
/>
</div>
) : (
<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>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onSelect={() => {
setRenamingProject(p.id);
setRenameProjectValue(p.name);
}}>
Rename
</ContextMenuItem>
<ContextMenuItem onSelect={() => setArchiveProjectConfirm({ id: p.id, name: p.name })}>
Archive
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onSelect={() => {
const url = giteaUrlFor({ path: p.path, gitea_remote: p.gitea_remote });
window.open(url, '_blank', 'noopener');
}}>
<ExternalLink size={12} /> Open in Gitea
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
{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)}`}
>
{isCoderSessionName(s.name) ? (
<Code className="size-3.5 shrink-0 opacity-70" />
) : (
<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>
<ContextMenuItem onSelect={() => void handleArchiveSession(s.id, p.id)}>
Archive
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
variant="destructive"
onSelect={() => setDeleteConfirm({ id: s.id, name: s.name })}
>
Delete
</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>
{/* v1.9: bottom-pinned Settings button. In a session, opens/focuses the
workspace settings pane via the sessionEvents bus (Session.tsx owns
the panesHook). Outside a session there's no workspace to mount the
pane in, so we navigate to /settings (themes page) instead. */}
<div className="border-t shrink-0 p-2">
<button
type="button"
onClick={() => {
if (activeSession) {
sessionEvents.emit({ type: 'open_settings_pane' });
if (isMobile) setDrawerOpen(false);
} else {
navigate('/settings');
if (isMobile) setDrawerOpen(false);
}
}}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm hover:bg-sidebar-accent/60 text-sidebar-foreground"
aria-label="Settings"
>
<SettingsIcon className="size-3.5 shrink-0 opacity-70" />
<span className="flex-1 text-left">Settings</span>
</button>
</div>
<AddProjectModal open={addOpen} onOpenChange={setAddOpen} onAdded={() => {}} />
<Dialog open={archiveProjectConfirm !== null} onOpenChange={(open) => { if (!open) setArchiveProjectConfirm(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Archive project?</DialogTitle>
<DialogDescription>
Removes {archiveProjectConfirm ? `"${archiveProjectConfirm.name}"` : 'this project'} from the sidebar. Files on disk are untouched. You can restore it later from the Archived Projects view.
</DialogDescription>
</DialogHeader>
<div className="flex gap-2 justify-end pt-2">
<Button variant="outline" onClick={() => setArchiveProjectConfirm(null)}>
Cancel
</Button>
<Button
onClick={() => {
if (archiveProjectConfirm) void handleArchiveProject(archiveProjectConfirm.id);
setArchiveProjectConfirm(null);
}}
>
Archive
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog open={deleteConfirm !== null} onOpenChange={(open) => { if (!open) setDeleteConfirm(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete session?</DialogTitle>
<DialogDescription>
This will permanently delete {deleteConfirm ? `"${deleteConfirm.name}"` : 'this session'} and all its chats and messages. This cannot be undone.
</DialogDescription>
</DialogHeader>
<div className="flex gap-2 justify-end pt-2">
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => {
if (deleteConfirm) {
const projectId = projects.find((p) =>
p.recent_sessions.some((s) => s.id === deleteConfirm.id)
)?.id;
if (projectId) void handleDeleteSession(deleteConfirm.id, projectId, deleteConfirm.name);
}
setDeleteConfirm(null);
}}
>
Delete
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog open={riskState !== null} onOpenChange={(open) => { if (!open && !riskBusy) setRiskState(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{verifyFailed ? 'Could not verify worktree safety' : 'This session has work at risk'}
</DialogTitle>
<DialogDescription>
{verifyFailed ? (
<>
{riskState?.message ?? 'The worktree safety check is unavailable.'} Your work may be
fine, but it couldn&apos;t be checked only force-delete if you&apos;re sure.
</>
) : anyDirty && anyCommits ? (
<>
Deleting {riskState ? `"${riskState.name}"` : 'this session'} would orphan uncommitted
changes <em>and</em> commits that aren&apos;t pushed or merged. Stash clears the
changes (recoverable), but the commits will still block push them or force-delete.
</>
) : anyDirty ? (
<>
Deleting {riskState ? `"${riskState.name}"` : 'this session'} would orphan uncommitted
changes in its worktree. Stash them (recoverable), commit them, or force-delete.
</>
) : (
<>
Deleting {riskState ? `"${riskState.name}"` : 'this session'} would orphan commits that
aren&apos;t pushed or merged. Stashing won&apos;t recover these push them, or
force-delete.
</>
)}
</DialogDescription>
</DialogHeader>
{!verifyFailed && (
<div className="flex flex-col gap-2 py-1 text-sm">
{atRiskReports.map((r) => (
<div key={r.worktreePath} className="rounded border border-border/60 px-3 py-2">
<div className="font-mono text-xs text-muted-foreground truncate" title={r.worktreePath}>
{r.branch || r.worktreePath}
</div>
<ul className="mt-1 list-disc pl-5 text-foreground/90">
{r.error && <li className="text-destructive">git error: {r.error}</li>}
{r.dirty && <li>uncommitted changes</li>}
{r.unpushed === -1 && <li>local-only branch (no upstream)</li>}
{r.unpushed > 0 && <li>{r.unpushed} unpushed commit{r.unpushed === 1 ? '' : 's'}</li>}
{r.unmerged > 0 && <li>{r.unmerged} unmerged commit{r.unmerged === 1 ? '' : 's'}</li>}
</ul>
</div>
))}
</div>
)}
<div className="flex flex-wrap gap-2 justify-end pt-2">
<Button variant="outline" disabled={riskBusy} onClick={() => setRiskState(null)}>
Cancel
</Button>
{!verifyFailed && (
<Button variant="outline" disabled={riskBusy} onClick={handleGoCommit}>
Commit&hellip;
</Button>
)}
{!verifyFailed && anyDirty && (
<Button variant="outline" disabled={riskBusy} onClick={() => void handleStashAndRetry()}>
Stash &amp; delete
</Button>
)}
<Button variant="destructive" disabled={riskBusy} onClick={() => void handleForceDelete()}>
Force delete
</Button>
</div>
</DialogContent>
</Dialog>
</aside>
);
}