feat(web): workspace components — ComparePane, Memory page, McpDialog, error boundaries, message-parts

- Add ComparePane.tsx: side-by-side AI response comparison
- Add Memory.tsx: memory management page with CRUD UI
- Add McpPermissionDialog.tsx: MCP tool permission approval dialog
- Add McpResponseDisplay.tsx: MCP response visualization
- Add MessageBoundary.tsx + MessageListErrorBoundary.tsx: error resilience
- Add EmptyState.tsx: contextual empty state component
- Add KeyboardShortcutsDialog.tsx: keyboard shortcut reference
- Add message-parts/: ActionRow, CompactCard, MistakeRecoverySentinel, ReasoningBlock, SendToTerminalMenu, StatsLine, SummaryCard
- Add useDraftPersistence.ts: draft message persistence hook
- Add useTerminals.ts: terminal session management hook
- Add keyboard-shortcuts.ts + tool-utils.ts: shared utilities
- Extend components: ChatInput, MessageBubble, MessageList, Workspace, panes
- Extend hooks: useTerminalSocket, useSessionStream test suite
- Update pages: Home, Project — workspace layout and session flow
This commit is contained in:
2026-06-08 03:49:22 +00:00
parent 51733c1338
commit 50de80ee75
51 changed files with 3352 additions and 96 deletions

View File

@@ -1,4 +1,4 @@
# apps/web — BooChat frontend (deep reference) # apps/web — BooChat frontend (deep reference) — v2.7.x (last meaningful update: 2026-06)
> Per-app engineering notes for `apps/web/src/`. The frontend is a single React SPA that also hosts the BooCoder `'coder'` pane. Cross-cutting commands, database, environment, workflow, and cross-app contracts (WS-frame / provider-type parity, sentinels) live in the **root `CLAUDE.md`**. This file auto-loads when you read/edit files under `apps/web/`. > Per-app engineering notes for `apps/web/src/`. The frontend is a single React SPA that also hosts the BooCoder `'coder'` pane. Cross-cutting commands, database, environment, workflow, and cross-app contracts (WS-frame / provider-type parity, sentinels) live in the **root `CLAUDE.md`**. This file auto-loads when you read/edit files under `apps/web/`.

View File

@@ -20,12 +20,14 @@
"@xterm/xterm": "5.5.0", "@xterm/xterm": "5.5.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "^12.40.0",
"lucide-react": "^1.16.0", "lucide-react": "^1.16.0",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router-dom": "^6.26.0", "react-router-dom": "^6.26.0",
"react-virtuoso": "^4.18.7",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"shiki": "^1.29.2", "shiki": "^1.29.2",
"sonner": "^2.0.7", "sonner": "^2.0.7",
@@ -39,10 +41,12 @@
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1", "@vitejs/plugin-react": "^4.3.1",
"jsdom": "^29.1.1",
"shadcn": "^4.7.0", "shadcn": "^4.7.0",
"tailwindcss": "^4.3.0", "tailwindcss": "^4.3.0",
"typescript": "^5.5.0", "typescript": "^5.5.0",
"vite": "^5.3.4" "vite": "^5.3.4",
"vitest": "^3.2.4"
}, },
"license": "MIT" "license": "MIT"
} }

View File

@@ -275,7 +275,7 @@ export const api = {
method: 'POST', method: 'POST',
body: JSON.stringify(body ?? {}), body: JSON.stringify(body ?? {}),
}), }),
update: (chatId: string, body: { name: string }) => update: (chatId: string, body: { name?: string; model?: string }) =>
request<Chat>(`/api/chats/${chatId}`, { request<Chat>(`/api/chats/${chatId}`, {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify(body), body: JSON.stringify(body),
@@ -331,6 +331,17 @@ export const api = {
method: 'POST', method: 'POST',
body: JSON.stringify({ skill_name: skillName, user_message: userMessage }), body: JSON.stringify({ skill_name: skillName, user_message: userMessage }),
}), }),
// v2.8-compare: send the same message to N models and stream back
// parallel responses. Returns compare_group_id + per-model message ids.
compare: (chatId: string, message: string, models: string[]) =>
request<{
compare_group_id: string;
user_message_id: string;
responses: Array<{ model: string; assistant_message_id: string }>;
}>(`/api/chats/${chatId}/compare`, {
method: 'POST',
body: JSON.stringify({ message, models }),
}),
// v1.13.17-cross-repo-reads: resume a paused request_read_access. On // v1.13.17-cross-repo-reads: resume a paused request_read_access. On
// 'allow' the server re-resolves the grant root and appends it to // 'allow' the server re-resolves the grant root and appends it to
// sessions.allowed_read_paths; the returned list reflects the post- // sessions.allowed_read_paths; the returned list reflects the post-
@@ -348,6 +359,14 @@ export const api = {
request<ToolTraceResponse>( request<ToolTraceResponse>(
`/api/chats/${chatId}/traces?limit=${limit}&offset=${offset}`, `/api/chats/${chatId}/traces?limit=${limit}&offset=${offset}`,
), ),
exportChat: (chatId: string, format: 'json' | 'markdown') =>
request<string>(`/api/chats/${chatId}/export?format=${format}`),
// MCP permission: approve/deny a tool call from an 'ask' state server.
mcpApprove: (chatId: string, toolCallId: string, permission: 'allow_once' | 'allow_always' | 'deny') =>
request<{ ok: true }>(`/api/chats/${chatId}/mcp-approve`, {
method: 'POST',
body: JSON.stringify({ tool_call_id: toolCallId, permission }),
}),
}, },
messages: { messages: {
@@ -388,6 +407,11 @@ export const api = {
request<{ html_content: string; char_count: number; title: string }>( request<{ html_content: string; char_count: number; title: string }>(
`/api/chats/${chatId}/messages/${messageId}/html_artifact`, `/api/chats/${chatId}/messages/${messageId}/html_artifact`,
), ),
feedback: (chatId: string, messageId: string, value: 'up' | 'down') =>
request<{ ok: boolean }>(
`/api/chats/${chatId}/messages/${messageId}/feedback`,
{ method: 'POST', body: JSON.stringify({ value }) },
),
}, },
models: () => request<ModelInfo[]>('/api/models'), models: () => request<ModelInfo[]>('/api/models'),
@@ -654,17 +678,27 @@ export const api = {
// cols/rows are optional. When passed, booterm sizes the per-pane tmux // cols/rows are optional. When passed, booterm sizes the per-pane tmux
// session at creation time so the inner bash (and any TUI it spawns) is // session at creation time so the inner bash (and any TUI it spawns) is
// born with the correct PTY dimensions instead of tmux's 80x24 default. // born with the correct PTY dimensions instead of tmux's 80x24 default.
start: (sessionId: string, paneId: string, cols?: number, rows?: number) => start: (
request<{ tmux_session: string }>( sessionId: string,
paneId: string,
cols?: number,
rows?: number,
description?: string,
parentAgent?: string,
) => {
const body: Record<string, unknown> = {};
if (cols !== undefined) body.cols = cols;
if (rows !== undefined) body.rows = rows;
if (description !== undefined) body.description = description;
if (parentAgent !== undefined) body.parentAgent = parentAgent;
return request<{ tmux_session: string }>(
`/api/term/sessions/${sessionId}/panes/${paneId}/start`, `/api/term/sessions/${sessionId}/panes/${paneId}/start`,
{ {
method: 'POST', method: 'POST',
body: body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined,
cols !== undefined && rows !== undefined
? JSON.stringify({ cols, rows })
: undefined,
}, },
), );
},
kill: (sessionId: string, paneId: string) => kill: (sessionId: string, paneId: string) =>
request<{ ok: true }>( request<{ ok: true }>(
`/api/term/sessions/${sessionId}/panes/${paneId}/kill`, `/api/term/sessions/${sessionId}/panes/${paneId}/kill`,

View File

@@ -623,6 +623,14 @@ export type WsFrame =
run_status?: 'running' | 'completed' | 'failed' | 'cancelled'; run_status?: 'running' | 'completed' | 'failed' | 'cancelled';
report?: string; report?: string;
} }
// inter-agent message frame
| {
type: 'agent_message';
run_id: string;
sender_step_id: string;
content: string;
channel?: string;
}
// tool trace frames: per-tool-call lifecycle tracking // tool trace frames: per-tool-call lifecycle tracking
| { | {
type: 'tool_trace_start'; type: 'tool_trace_start';

View File

@@ -90,7 +90,7 @@ export function AgentPicker({ projectId, value, onChange }: Props) {
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <button
type="button" type="button"
className="inline-flex items-center gap-1.5 rounded-full border border-border px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground max-md:min-h-[36px] max-md:min-w-[36px]" className="inline-flex items-center gap-1.5 rounded-full border border-border px-2.5 py-1 text-xs text-muted-foreground motion-reduce:transition-none transition-colors hover:bg-muted hover:text-foreground active:scale-[0.97] max-md:min-h-[36px] max-md:min-w-[36px]"
title={selectedAgent?.name ? `Agent: ${selectedAgent.name}` : 'No agent'} title={selectedAgent?.name ? `Agent: ${selectedAgent.name}` : 'No agent'}
aria-label={`Agent: ${triggerLabel}`} aria-label={`Agent: ${triggerLabel}`}
> >

View File

@@ -20,7 +20,7 @@ export function AttachmentChip({ attachment, onRemove, onPreview }: Props) {
<button <button
type="button" type="button"
onClick={() => onPreview(attachment)} onClick={() => onPreview(attachment)}
className="flex items-center gap-1.5 hover:bg-muted/60 transition-colors min-w-0" className="flex items-center gap-1.5 hover:bg-muted/60 motion-reduce:transition-none transition-colors active:scale-[0.97] min-w-0"
> >
<FileText className="size-3 shrink-0 text-muted-foreground" /> <FileText className="size-3 shrink-0 text-muted-foreground" />
<span className="truncate max-w-[200px]">{label}</span> <span className="truncate max-w-[200px]">{label}</span>

View File

@@ -66,7 +66,7 @@ export function BottomSheet({ open, onClose, children, title }: Props) {
aria-modal="true" aria-modal="true"
className={cn( className={cn(
'fixed inset-x-0 bottom-0 z-50 rounded-t-2xl border-t border-border bg-popover text-popover-foreground shadow-2xl', 'fixed inset-x-0 bottom-0 z-50 rounded-t-2xl border-t border-border bg-popover text-popover-foreground shadow-2xl',
'transition-transform duration-150 will-change-transform', 'motion-reduce:transition-none transition-transform duration-150 will-change-transform',
'max-h-[70vh] flex flex-col', 'max-h-[70vh] flex flex-col',
)} )}
style={{ style={{

View File

@@ -694,7 +694,7 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
disabled={disabled || busy || attachments.length >= MAX_ATTACHMENTS} disabled={disabled || busy || attachments.length >= MAX_ATTACHMENTS}
aria-label="Attach file" aria-label="Attach file"
title="Attach file" title="Attach file"
className="inline-flex items-center justify-center rounded-full border border-border px-2.5 py-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-50 max-md:min-h-[36px] max-md:min-w-[36px]" className="inline-flex items-center justify-center rounded-full border border-border px-2.5 py-1 text-muted-foreground motion-reduce:transition-none transition-colors hover:bg-muted hover:text-foreground active:scale-[0.97] disabled:opacity-50 max-md:min-h-[36px] max-md:min-w-[36px]"
> >
<Paperclip className="size-3.5" /> <Paperclip className="size-3.5" />
</button> </button>
@@ -707,7 +707,7 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
aria-expanded={cmdMenuOpen} aria-expanded={cmdMenuOpen}
aria-label="Slash commands" aria-label="Slash commands"
title="Slash commands" title="Slash commands"
className="inline-flex items-center gap-1.5 rounded-full border border-border px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground max-md:min-h-[36px] max-md:min-w-[36px]" className="inline-flex items-center gap-1.5 rounded-full border border-border px-2.5 py-1 text-xs text-muted-foreground motion-reduce:transition-none transition-colors hover:bg-muted hover:text-foreground active:scale-[0.97] aria-expanded:bg-muted aria-expanded:text-foreground max-md:min-h-[36px] max-md:min-w-[36px]"
> >
<SquareSlash className="size-3.5" /> <SquareSlash className="size-3.5" />
<span className="max-md:hidden">{slashItems.length}</span> <span className="max-md:hidden">{slashItems.length}</span>
@@ -720,7 +720,7 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
onClick={() => sessionEvents.emit({ type: 'open_flow_launcher', project_id: projectId })} onClick={() => sessionEvents.emit({ type: 'open_flow_launcher', project_id: projectId })}
aria-label="Flow launcher" aria-label="Flow launcher"
title="Open flow launcher" title="Open flow launcher"
className="inline-flex items-center gap-1.5 rounded-full border border-border px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground max-md:min-h-[36px] max-md:min-w-[36px]" className="inline-flex items-center gap-1.5 rounded-full border border-border px-2.5 py-1 text-xs text-muted-foreground motion-reduce:transition-none transition-colors hover:bg-muted hover:text-foreground active:scale-[0.97] max-md:min-h-[36px] max-md:min-w-[36px]"
> >
<Workflow className="size-3.5" /> <Workflow className="size-3.5" />
<span className="max-md:hidden">Flows</span> <span className="max-md:hidden">Flows</span>
@@ -739,7 +739,7 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
}} }}
aria-pressed={webSearchEnabled === true} aria-pressed={webSearchEnabled === true}
title="Web search & fetch" title="Web search & fetch"
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs transition-colors max-md:min-h-[36px] max-md:min-w-[36px] ${ className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs motion-reduce:transition-none transition-colors active:scale-[0.97] max-md:min-h-[36px] max-md:min-w-[36px] ${
webSearchEnabled === true webSearchEnabled === true
? 'border-primary/40 bg-primary/10 text-primary' ? 'border-primary/40 bg-primary/10 text-primary'
: 'border-border text-muted-foreground hover:bg-muted hover:text-foreground' : 'border-border text-muted-foreground hover:bg-muted hover:text-foreground'

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Check, Copy } from 'lucide-react'; import { Check, Copy, Moon, Sun, WrapText } from 'lucide-react';
import { codeToHtml } from 'shiki'; import { codeToHtml } from 'shiki';
// NOTE: spec calls for syntax-highlighted code blocks. Added Shiki in v1.1. // NOTE: spec calls for syntax-highlighted code blocks. Added Shiki in v1.1.
@@ -45,35 +45,111 @@ const LANG_MAP: Record<string, string> = {
css: 'css', css: 'css',
}; };
const SHIKI_THEME = 'github-dark'; // ── LRU highlight cache (module-scoped) ──────────────────────────
// Key = `${code}|${theme}|${mappedLang}`, max 50 entries.
// Avoids redundant codeToHtml calls when the same code/theme/lang
// combination is rendered multiple times (e.g. across messages).
const HIGHLIGHT_CACHE = new Map<string, string>();
const MAX_CACHE_ENTRIES = 50;
function cacheGet(key: string): string | undefined {
if (!HIGHLIGHT_CACHE.has(key)) return undefined;
const val = HIGHLIGHT_CACHE.get(key)!;
// LRU touch — delete & re-set to move to end (most recently used)
HIGHLIGHT_CACHE.delete(key);
HIGHLIGHT_CACHE.set(key, val);
return val;
}
function cacheSet(key: string, html: string): void {
if (HIGHLIGHT_CACHE.size >= MAX_CACHE_ENTRIES) {
const oldest = HIGHLIGHT_CACHE.keys().next().value;
if (oldest !== undefined) HIGHLIGHT_CACHE.delete(oldest);
}
HIGHLIGHT_CACHE.set(key, html);
}
export function CodeBlock({ code, lang }: Props) { export function CodeBlock({ code, lang }: Props) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [html, setHtml] = useState<string | null>(null); const [html, setHtml] = useState<string | null>(null);
const highlightRef = useRef<HTMLDivElement | null>(null); const [theme, setTheme] = useState<'github-dark' | 'github-light'>(() => {
try {
if (localStorage.getItem('codeblock-theme') === 'github-light') return 'github-light';
} catch {
/* localStorage unavailable */
}
return 'github-dark';
});
const [wordWrap, setWordWrap] = useState(false);
const [expanded, setExpanded] = useState(false);
const highlightRef = useRef<HTMLDivElement>(null);
// ── Derived state ──────────────────────────────────────────────
// Diff mode: detect `diff-` prefix (e.g. diff-ts, diff-py).
// The actual lang for highlighting is the part after `diff-`.
const isDiff = !!lang && lang.startsWith('diff-');
const actualLang = isDiff && lang ? lang.slice('diff-'.length) : lang ?? '';
const mappedLang = actualLang ? (LANG_MAP[actualLang.toLowerCase()] ?? null) : null;
// Strip leading `+`/`-` from code lines when in diff mode.
// The markers are rendered in the gutter instead.
const cleanCode = useMemo(
() => (isDiff ? code.replace(/^[+-]/gm, '') : code),
[code, isDiff],
);
const codeLines = useMemo(() => code.split('\n'), [code]);
const totalLines = codeLines.length;
// Gutter is hidden entirely when code has >= 1000 lines.
const showGutter = totalLines < 1000;
// Collapsible: auto-collapse to 15 lines when >= 30 lines total.
const isLong = totalLines >= 30;
const collapsed = isLong && !expanded;
const visibleLines = collapsed ? codeLines.slice(0, 15) : codeLines;
// Diff marker array: '+' / '-' / '' per line for the gutter.
const diffMarkers = useMemo(
() => (isDiff ? codeLines.map((l) => (l[0] === '+' ? '+' : l[0] === '-' ? '-' : '')) : null),
[isDiff, codeLines],
);
// ── Shiki highlighting ─────────────────────────────────────────
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
const mappedLang = (lang && LANG_MAP[lang.toLowerCase()]) ?? null;
if (!mappedLang) { if (!mappedLang) {
setHtml(null); setHtml(null);
return; return;
} }
const cacheKey = `${cleanCode}|${theme}|${mappedLang}`;
const cached = cacheGet(cacheKey);
if (cached !== undefined) {
setHtml(cached);
return;
}
(async () => { (async () => {
try { try {
const result = await codeToHtml(code, { lang: mappedLang, theme: SHIKI_THEME }); const result = await codeToHtml(cleanCode, { lang: mappedLang, theme });
cacheSet(cacheKey, result);
if (!cancelled) setHtml(result); if (!cancelled) setHtml(result);
} catch (err) { } catch (err) {
console.warn('shiki failed', err); console.warn('shiki highlight failed:', err);
if (!cancelled) setHtml(null); if (!cancelled) setHtml(null);
} }
})(); })();
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [code, lang]); }, [cleanCode, mappedLang, theme]);
// Inject Shiki HTML via ref; output is compiler-generated, not user input. // Inject Shiki HTML via ref (output is compiler-generated, not user input)
useEffect(() => { useEffect(() => {
if (highlightRef.current) { if (highlightRef.current) {
// Shiki generates sanitized HTML spans — not user-supplied content. // Shiki generates sanitized HTML spans — not user-supplied content.
@@ -82,39 +158,138 @@ export function CodeBlock({ code, lang }: Props) {
} }
}, [html]); }, [html]);
async function copy() { // Sync word-wrap state to the injected <pre> element inside shiki's output
useEffect(() => {
const pre = highlightRef.current?.querySelector('pre');
if (pre) {
pre.style.whiteSpace = wordWrap ? 'pre-wrap' : 'nowrap';
}
}, [html, wordWrap]);
// ── Handlers ───────────────────────────────────────────────────
const handleToggleTheme = useCallback(() => {
setTheme((prev) => {
const next = prev === 'github-dark' ? 'github-light' : 'github-dark';
try {
localStorage.setItem('codeblock-theme', next);
} catch {
/* noop */
}
return next;
});
}, []);
const handleCopy = useCallback(async () => {
try { try {
await navigator.clipboard.writeText(code); await navigator.clipboard.writeText(cleanCode);
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 1200); setTimeout(() => setCopied(false), 1200);
} catch { } catch {
/* ignore */ /* ignore */
} }
} }, [cleanCode]);
// ── Shared class segments ──────────────────────────────────────
const preBaseClass = 'overflow-x-auto px-3 py-2 font-mono text-xs leading-relaxed';
const shikiWrapperClass = `${preBaseClass} [&>pre]:!bg-transparent [&>pre]:!m-0 [&>pre]:!p-0`;
const collapsedClass = collapsed ? 'max-h-[calc(15*1.625em)] overflow-hidden' : '';
// ── Render ─────────────────────────────────────────────────────
return ( return (
<div className="rounded-md border bg-muted/40 overflow-hidden text-sm my-1"> <div className="rounded-md border bg-muted/40 overflow-hidden text-sm my-1">
{/* ── Toolbar ──────────────────────────────────────────── */}
<div className="flex items-center justify-between px-2 py-1 border-b text-xs text-muted-foreground"> <div className="flex items-center justify-between px-2 py-1 border-b text-xs text-muted-foreground">
<span className="font-mono">{lang || 'code'}</span> <span className="font-mono">{actualLang || lang || 'code'}</span>
<div className="flex items-center gap-0.5">
{/* Theme toggle — persists to localStorage key 'codeblock-theme' */}
<button
type="button"
onClick={handleToggleTheme}
className="flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-muted text-foreground"
aria-label={`Switch to ${theme === 'github-dark' ? 'light' : 'dark'} theme`}
>
{theme === 'github-dark' ? <Sun className="size-3" /> : <Moon className="size-3" />}
</button>
{/* Word-wrap toggle */}
<button
type="button"
onClick={() => setWordWrap((prev) => !prev)}
className={`flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-muted text-foreground ${wordWrap ? 'bg-muted' : ''}`}
aria-label={wordWrap ? 'Disable word wrap' : 'Enable word wrap'}
>
<WrapText className="size-3" />
</button>
{/* Copy button — existing behavior (Check icon, 1200ms revert) */}
<button
type="button"
onClick={() => void handleCopy()}
className="flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-muted text-foreground"
aria-label="Copy code"
>
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
<span>{copied ? 'Copied' : 'Copy'}</span>
</button>
</div>
</div>
{/* ── Code body (flex row: gutter + code) ──────────────── */}
<div className="flex">
{/* Gutter — line numbers or diff markers */}
{showGutter && (
<div
className="flex-none select-none text-right text-muted-foreground/50 font-mono text-xs leading-relaxed py-2 border-r border-border/30"
aria-hidden="true"
>
{visibleLines.map((line, i) => {
const isPlus = diffMarkers?.[i] === '+';
const isMinus = diffMarkers?.[i] === '-';
let gutterCellClass = 'px-2 leading-relaxed';
if (isPlus) gutterCellClass += ' bg-green-500/10 border-l-2 border-green-500';
if (isMinus) gutterCellClass += ' bg-red-500/10 border-l-2 border-red-500';
const content = diffMarkers ? diffMarkers[i] : String(i + 1);
return (
<div key={i} className={gutterCellClass}>
{content}
</div>
);
})}
</div>
)}
{/* Code area */}
<div className="relative flex-1 min-w-0">
{html !== null ? (
<div ref={highlightRef} className={`${shikiWrapperClass} ${collapsedClass}`} />
) : (
<pre
className={`${preBaseClass} ${collapsedClass}`}
style={{ whiteSpace: wordWrap ? 'pre-wrap' : 'nowrap' }}
>
{collapsed ? codeLines.slice(0, 15).join('\n') : code}
</pre>
)}
{/* Gradient fade overlay for collapsed state */}
{collapsed && (
<div className="absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-b from-transparent to-background pointer-events-none" />
)}
</div>
</div>
{/* "Show N more" button for collapsed state */}
{collapsed && (
<button <button
type="button" type="button"
onClick={() => void copy()} onClick={() => setExpanded(true)}
className="flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-muted text-foreground" className="w-full text-xs text-muted-foreground hover:text-foreground py-1 border-t border-border/30 bg-muted/20"
aria-label="Copy code"
> >
{copied ? <Check className="size-3" /> : <Copy className="size-3" />} Show {totalLines - 15} more {totalLines - 15 === 1 ? 'line' : 'lines'}
<span>{copied ? 'Copied' : 'Copy'}</span>
</button> </button>
</div>
{html !== null ? (
<div
ref={highlightRef}
className="overflow-x-auto px-3 py-2 font-mono text-xs leading-relaxed [&>pre]:!bg-transparent [&>pre]:!m-0 [&>pre]:!p-0"
/>
) : (
<pre className="overflow-x-auto px-3 py-2 font-mono text-xs leading-relaxed">
{code}
</pre>
)} )}
</div> </div>
); );

View File

@@ -0,0 +1,143 @@
import { useCallback, useEffect, useRef } from 'react';
import { Loader2 } from 'lucide-react';
import { MarkdownRenderer } from './MarkdownRenderer';
import { cn } from '@/lib/utils';
export interface CompareResponse {
model: string;
assistantMessageId: string;
content: string;
status: 'streaming' | 'complete' | 'error';
}
interface Props {
models: string[];
responses: CompareResponse[];
onClose: () => void;
}
export function ComparePane({ models, responses, onClose }: Props) {
const panelsRef = useRef<(HTMLDivElement | null)[]>([]);
const isSyncingRef = useRef(false);
// Build a map for quick lookup
const responseMap = new Map<string, CompareResponse>();
for (const r of responses) {
responseMap.set(r.model, r);
}
// Synced scroll: when one panel scrolls, scroll all others to the same ratio
const handleScroll = useCallback((sourceIndex: number) => {
if (isSyncingRef.current) return;
const source = panelsRef.current[sourceIndex];
if (!source) return;
const ratio = source.scrollTop / (source.scrollHeight - source.clientHeight);
isSyncingRef.current = true;
for (let i = 0; i < panelsRef.current.length; i++) {
if (i === sourceIndex) continue;
const target = panelsRef.current[i];
if (!target) continue;
const targetMax = target.scrollHeight - target.clientHeight;
target.scrollTop = targetMax > 0 ? ratio * targetMax : 0;
}
isSyncingRef.current = false;
}, []);
// Refresh scroll sync when content changes (streaming updates)
useEffect(() => {
if (responses.length === 0) return;
const anyStreaming = responses.some((r) => r.status === 'streaming');
if (!anyStreaming) return;
// Sync scroll to bottom when any panel is still streaming
if (!isSyncingRef.current) {
for (const panel of panelsRef.current) {
if (!panel) continue;
panel.scrollTop = panel.scrollHeight;
}
}
}, [responses]);
const gridCols = models.length === 2 ? 'grid-cols-2' : 'grid-cols-3';
return (
<div className="flex flex-col h-full min-h-0 relative">
{/* Header */}
<div className="flex items-center gap-2 border-b border-border bg-muted/20 px-3 py-2 shrink-0">
<span className="text-sm font-medium">Compare Models</span>
<span className="text-xs text-muted-foreground">
{models.length} models
</span>
<div className="ml-auto flex items-center gap-1 shrink-0">
<button
type="button"
onClick={onClose}
className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded border border-border text-muted-foreground hover:text-foreground hover:bg-muted"
>
Back to single
</button>
</div>
</div>
{/* Grid of response panels */}
<div className={cn('flex-1 grid min-h-0', gridCols)}>
{models.map((model, idx) => {
const resp = responseMap.get(model) ?? {
model,
assistantMessageId: '',
content: '',
status: 'streaming' as const,
};
const isStreaming = resp.status === 'streaming';
const isError = resp.status === 'error';
return (
<div
key={model}
ref={(el) => { panelsRef.current[idx] = el; }}
onScroll={() => handleScroll(idx)}
className={cn(
'overflow-y-auto border-r border-border/50 last:border-r-0',
'flex flex-col',
)}
>
{/* Model header */}
<div className="sticky top-0 z-10 bg-background/95 backdrop-blur border-b border-border/50 px-3 py-2 text-xs font-medium text-foreground truncate">
{model}
</div>
{/* Empty / loading state */}
{resp.content.length === 0 && isStreaming && (
<div className="flex items-center justify-center flex-1 gap-2 text-sm text-muted-foreground">
<Loader2 size={14} className="animate-spin" />
Generating
</div>
)}
{/* Error state */}
{isError && resp.content.length === 0 && (
<div className="flex items-center justify-center flex-1 text-sm text-destructive px-3">
Failed to generate
</div>
)}
{/* Content */}
{resp.content.length > 0 && (
<div className="px-3 py-2 text-sm">
<MarkdownRenderer content={resp.content} />
</div>
)}
{/* Streaming indicator at bottom */}
{isStreaming && resp.content.length > 0 && (
<div className="sticky bottom-0 bg-background/80 backdrop-blur px-3 py-1.5 flex items-center gap-1.5 text-xs text-muted-foreground border-t border-border/30">
<Loader2 size={10} className="animate-spin" />
Streaming
</div>
)}
</div>
);
})}
</div>
</div>
);
}

View File

@@ -118,7 +118,7 @@ export function ContextMeter({ messages, modelContextLimit, sessionCostUsd }: Pr
cy={CENTER} cy={CENTER}
r={RADIUS} r={RADIUS}
fill="none" fill="none"
className={cn('transition-all duration-300', progressClass)} className={cn('transition-all duration-200 motion-reduce:transition-none', progressClass)}
strokeWidth={STROKE} strokeWidth={STROKE}
strokeLinecap="round" strokeLinecap="round"
strokeDasharray={CIRCUMFERENCE} strokeDasharray={CIRCUMFERENCE}

View File

@@ -0,0 +1,68 @@
import type { ReactNode } from 'react';
import { Inbox } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
interface EmptyStateProps {
/** Optional icon node shown above the title. Defaults to a muted Inbox icon. */
icon?: ReactNode;
/** Main heading text (bold, base font-size). */
title: string;
/** Optional descriptive text shown below the title (muted, sm font-size). */
description?: string;
/** Optional CTA button rendered below the description. */
action?: {
label: string;
onClick: () => void;
variant?: 'default' | 'outline';
};
className?: string;
}
/**
* Reusable empty state for lists, search results, and landing pages.
*
* Renders a centered column with:
* 1. Optional icon (default: `Inbox` from lucide-react, drawn at 50% muted
* opacity so it sits subtly in the background).
* 2. Bold title.
* 3. Optional description (constrained to `max-w-sm` for readability).
* 4. Optional action button (outline by default).
*
* Design follows The Data Terminal aesthetic: charcoal canvas, low-chrome
* muted icon, high-contrast text, and an outline button that gets an ember
* glow on interaction.
*/
export function EmptyState({
icon,
title,
description,
action,
className,
}: EmptyStateProps) {
return (
<div
className={cn(
'flex flex-col items-center justify-center text-center gap-3 px-4 py-12',
className,
)}
>
<div className="text-muted-foreground/50">
{icon ?? <Inbox size={40} strokeWidth={1.5} />}
</div>
<h3 className="text-base font-semibold text-foreground">{title}</h3>
{description && (
<p className="text-sm text-muted-foreground max-w-sm">{description}</p>
)}
{action && (
<Button
variant={action.variant ?? 'outline'}
onClick={action.onClick}
className="mt-1"
>
{action.label}
</Button>
)}
</div>
);
}

View File

@@ -37,11 +37,11 @@ function Switch({ checked, onCheckedChange, id }: {
role="switch" role="switch"
aria-checked={checked} aria-checked={checked}
onClick={() => onCheckedChange(!checked)} onClick={() => onCheckedChange(!checked)}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full transition-colors ${ className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full motion-reduce:transition-none transition-colors ${
checked ? 'bg-primary' : 'bg-muted' checked ? 'bg-primary' : 'bg-muted'
}`} }`}
> >
<span className={`inline-block h-4 w-4 transform rounded-full bg-background transition-transform ${ <span className={`inline-block h-4 w-4 transform rounded-full bg-background motion-reduce:transition-none transition-transform ${
checked ? 'translate-x-[1.125rem]' : 'translate-x-0.5' checked ? 'translate-x-[1.125rem]' : 'translate-x-0.5'
}`} /> }`} />
</button> </button>

View File

@@ -0,0 +1,64 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { KEYBOARD_SHORTCUTS } from '@/lib/keyboard-shortcuts';
function KeyboardShortcutsDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const isMac =
typeof navigator !== 'undefined' &&
navigator.platform.toLowerCase().includes('mac');
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Keyboard Shortcuts</DialogTitle>
</DialogHeader>
<div className="-mx-4 -mb-4 max-h-[70vh] overflow-y-auto overscroll-contain px-4 pb-4">
<div className="space-y-6">
{KEYBOARD_SHORTCUTS.map((group) => (
<div key={group.category}>
<h4 className="mb-3 text-xs font-medium uppercase tracking-wider text-muted-foreground">
{group.category}
</h4>
<div className="space-y-2">
{group.shortcuts.map((shortcut, i) => (
<div
key={i}
className="flex items-center justify-between gap-4"
>
<span className="text-sm">{shortcut.description}</span>
<span className="flex shrink-0 items-center gap-1">
{shortcut.keys.map((key, ki) => (
<span key={ki} className="flex items-center gap-0.5">
{ki > 0 && (
<span className="text-muted-foreground/50">+</span>
)}
<kbd className="rounded border bg-muted px-1.5 py-0.5 font-mono text-xs">
{key === 'Ctrl' ? (isMac ? '⌘' : 'Ctrl') : key}
</kbd>
</span>
))}
</span>
</div>
))}
</div>
</div>
))}
</div>
</div>
</DialogContent>
</Dialog>
);
}
export { KeyboardShortcutsDialog };

View File

@@ -8,6 +8,7 @@ import Markdown from 'react-markdown';
import type { Components } from 'react-markdown'; import type { Components } from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import { CodeBlock } from './CodeBlock'; import { CodeBlock } from './CodeBlock';
import { MessageBoundary } from './MessageBoundary';
import { linkifyPaths } from '@/lib/linkify-paths'; import { linkifyPaths } from '@/lib/linkify-paths';
function linkifyChildren(children: ReactNode, keyPrefix = 'l'): ReactNode { function linkifyChildren(children: ReactNode, keyPrefix = 'l'): ReactNode {
@@ -40,7 +41,11 @@ const codeRenderer = (props: { children?: unknown; className?: string }) => {
const langMatch = /language-([\w-]+)/.exec(className ?? ''); const langMatch = /language-([\w-]+)/.exec(className ?? '');
const isBlock = !!langMatch || text.includes('\n'); const isBlock = !!langMatch || text.includes('\n');
if (isBlock) { if (isBlock) {
return <CodeBlock code={text} lang={langMatch?.[1]} />; return (
<MessageBoundary fallback={<pre className="overflow-x-auto px-3 py-2 font-mono text-xs leading-relaxed">{text}</pre>}>
<CodeBlock code={text} lang={langMatch?.[1]} />
</MessageBoundary>
);
} }
return ( return (
<code <code
@@ -102,8 +107,10 @@ const MARKDOWN_COMPONENTS: Components = {
export const MarkdownRenderer = memo(function MarkdownRenderer({ content }: { content: string }) { export const MarkdownRenderer = memo(function MarkdownRenderer({ content }: { content: string }) {
return ( return (
<Markdown remarkPlugins={[remarkGfm]} components={MARKDOWN_COMPONENTS}> <MessageBoundary>
{content} <Markdown remarkPlugins={[remarkGfm]} components={MARKDOWN_COMPONENTS}>
</Markdown> {content}
</Markdown>
</MessageBoundary>
); );
}); });

View File

@@ -0,0 +1,113 @@
import { useState } from 'react';
import { AlertTriangle, Check, X } from 'lucide-react';
import { api } from '@/api/client';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
interface Props {
toolCallId: string;
toolName: string;
toolArgs: Record<string, unknown>;
chatId: string;
open: boolean;
onClose: () => void;
}
function parseServerName(toolName: string): string {
// Tool name format is <serverName>_<originalToolName>
const idx = toolName.indexOf('_');
if (idx === -1) return toolName;
return toolName.slice(0, idx);
}
function parseToolShortName(toolName: string): string {
const idx = toolName.indexOf('_');
if (idx === -1) return toolName;
return toolName.slice(idx + 1);
}
function summarizeArgs(args: Record<string, unknown>): string {
const keys = Object.keys(args);
if (keys.length === 0) return 'no arguments';
const first = keys[0]!;
const val = typeof args[first] === 'string' ? args[first] as string : JSON.stringify(args[first]);
return `${first}: ${val.length > 60 ? val.slice(0, 59) + '…' : val}`;
}
export function McpPermissionDialog({ toolCallId, toolName, toolArgs, chatId, open, onClose }: Props) {
const [loading, setLoading] = useState<string | null>(null);
const serverName = parseServerName(toolName);
const shortName = parseToolShortName(toolName);
const argsSummary = summarizeArgs(toolArgs);
const handleApprove = async (permission: 'allow_once' | 'allow_always' | 'deny') => {
setLoading(permission);
try {
await api.chats.mcpApprove(chatId, toolCallId, permission);
onClose();
} catch {
// Error is already surfaced by api client
} finally {
setLoading(null);
}
};
return (
<Dialog open={open} onOpenChange={(open) => { if (!open) onClose(); }}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="size-4 text-amber-500" />
MCP Tool Approval
</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="rounded-lg bg-muted/50 p-3 text-xs space-y-1.5">
<div>
<span className="text-muted-foreground">Server:</span>{' '}
<span className="font-mono font-medium">{serverName}</span>
</div>
<div>
<span className="text-muted-foreground">Tool:</span>{' '}
<span className="font-mono">{shortName}</span>
</div>
<div>
<span className="text-muted-foreground">Args:</span>{' '}
<span className="font-mono text-muted-foreground">{argsSummary}</span>
</div>
</div>
<p className="text-xs text-muted-foreground">
This MCP server requires approval before running tools. Choose how to proceed:
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
<Button
variant="outline"
size="sm"
onClick={() => handleApprove('allow_once')}
disabled={loading !== null}
>
{loading === 'allow_once' ? '…' : <><Check className="size-3 mr-1" /> Allow Once</>}
</Button>
<Button
variant="default"
size="sm"
onClick={() => handleApprove('allow_always')}
disabled={loading !== null}
>
{loading === 'allow_always' ? '…' : <><Check className="size-3 mr-1" /> Always Allow</>}
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => handleApprove('deny')}
disabled={loading !== null}
>
{loading === 'deny' ? '…' : <><X className="size-3 mr-1" /> Deny</>}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,212 @@
import { useState } from 'react';
import type { ToolCall, ToolResult } from '@/api/types';
import { linkifyPaths } from '@/lib/linkify-paths';
import { MarkdownRenderer } from './MarkdownRenderer';
import { extractServerName, extractToolName } from '@/lib/tool-utils';
interface McpResponseDisplayProps {
toolCall: ToolCall;
toolResult: ToolResult;
}
type DisplayMode = 'plain' | 'markdown' | 'rich';
const URL_REGEX = /https?:\/\/[^\s<>"']+/g;
const IMAGE_EXT_REGEX = /\.(png|jpg|jpeg|gif|webp|svg)(\?.*)?$/i;
function isImageUrl(url: string): boolean {
return IMAGE_EXT_REGEX.test(url);
}
interface ContentSegment {
type: 'text' | 'image' | 'link' | 'image_line';
content: string;
url?: string;
}
/**
* Splits tool output into content segments for rich display mode:
* - Standalone image URLs → <img> tag
* - Markdown image syntax `![alt](url)` → <img> tag
* - Standalone non-image URLs → link card
* - Everything else → text paragraph
*/
function parseRichContent(output: string): ContentSegment[] {
const segments: ContentSegment[] = [];
const lines = output.split('\n');
for (const line of lines) {
const trimmed = line.trimEnd();
// Empty line
if (!trimmed) {
segments.push({ type: 'text', content: line });
continue;
}
// Markdown image syntax: ![alt](url)
const inlineImgMatch = trimmed.match(/^!\[.*?\]\((https?:\/\/[^\s)]+)\)$/);
if (inlineImgMatch) {
segments.push({ type: 'image', content: '', url: inlineImgMatch[1] });
continue;
}
// Standalone URL
const urlMatch = trimmed.match(URL_REGEX);
if (urlMatch && urlMatch[0] === trimmed) {
const url = urlMatch[0];
if (isImageUrl(url)) {
segments.push({ type: 'image', content: '', url });
} else {
segments.push({ type: 'link', content: url });
}
continue;
}
// Inline image URLs on their own line (no markdown, just raw URL)
const imgMatch = trimmed.match(IMAGE_EXT_REGEX);
if (imgMatch) {
const possibleUrl = trimmed.match(URL_REGEX);
if (possibleUrl && possibleUrl[0].length >= trimmed.length * 0.8) {
segments.push({ type: 'image', content: '', url: possibleUrl[0] });
continue;
}
}
// Inline URLs in text — detect and wrap them
const inlineUrls = trimmed.match(URL_REGEX);
if (inlineUrls) {
// Render as text with linkified URLs
segments.push({ type: 'text', content: trimmed });
} else {
segments.push({ type: 'text', content: line });
}
}
return segments;
}
export function McpResponseDisplay({ toolCall, toolResult }: McpResponseDisplayProps) {
const [mode, setMode] = useState<DisplayMode>('plain');
const serverName = extractServerName(toolCall.name);
const toolDisplayName = extractToolName(toolCall.name) ?? toolCall.name;
const output =
typeof toolResult.output === 'string'
? toolResult.output
: JSON.stringify(toolResult.output, null, 2);
const modes: { key: DisplayMode; label: string }[] = [
{ key: 'plain', label: 'Plain' },
{ key: 'markdown', label: 'MD' },
{ key: 'rich', label: 'Rich' },
];
return (
<div className="space-y-1.5">
{/* Server badge + tool name + permission dot */}
<div className="flex items-center gap-1.5 flex-wrap">
{serverName && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-mono bg-primary/10 text-primary">
{serverName}
</span>
)}
<span className="text-[11px] font-mono text-muted-foreground">{toolDisplayName}</span>
<span
className="size-2 rounded-full bg-emerald-500 shrink-0"
aria-label="allowed"
title="Permission: allowed"
/>
</div>
{/* Display mode toggle */}
<div className="flex items-center gap-0.5">
{modes.map((m) => (
<button
key={m.key}
type="button"
onClick={() => setMode(m.key)}
className={`px-2 py-0.5 text-[10px] font-mono rounded transition-colors ${
mode === m.key
? 'bg-primary/15 text-primary'
: 'text-muted-foreground/50 hover:text-muted-foreground'
}`}
>
{m.label}
</button>
))}
</div>
{/* Content */}
{mode === 'plain' && (
<pre className="text-[11px] font-mono whitespace-pre-wrap bg-muted/30 rounded px-2 py-1 max-h-72 overflow-y-auto">
{toolResult.error ? (
<span className="text-destructive">{toolResult.error}</span>
) : (
linkifyPaths(output)
)}
{toolResult.truncated && (
<div className="text-muted-foreground/60 mt-1"> output truncated </div>
)}
</pre>
)}
{mode === 'markdown' && (
<div className="text-[13px] bg-muted/30 rounded px-2 py-1 max-h-96 overflow-y-auto">
{toolResult.error ? (
<span className="text-destructive">{toolResult.error}</span>
) : (
<MarkdownRenderer content={output} />
)}
{toolResult.truncated && (
<div className="text-muted-foreground/60 mt-1 text-xs"> output truncated </div>
)}
</div>
)}
{mode === 'rich' && (
<div className="bg-muted/30 rounded px-2 py-1 max-h-96 overflow-y-auto space-y-1.5">
{toolResult.error ? (
<span className="text-destructive text-[11px]">{toolResult.error}</span>
) : (
parseRichContent(output).map((seg, i) => {
if (seg.type === 'image') {
return (
<div key={i} className="rounded overflow-hidden">
<img
src={seg.url}
alt="Tool result image"
className="max-w-full h-auto rounded"
loading="lazy"
/>
</div>
);
}
if (seg.type === 'link') {
return (
<a
key={i}
href={seg.content}
target="_blank"
rel="noreferrer"
className="block text-[11px] font-mono text-primary underline hover:text-primary/80 bg-muted/20 rounded px-2 py-1.5 break-all"
>
{seg.content}
</a>
);
}
return (
<p key={i} className="text-[11px] leading-relaxed">
{linkifyPaths(seg.content)}
</p>
);
})
)}
{toolResult.truncated && (
<div className="text-muted-foreground/60 mt-1 text-xs"> output truncated </div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,56 @@
import { Component } from 'react';
import type { ErrorInfo, ReactNode } from 'react';
import { AlertCircle, RefreshCw } from 'lucide-react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
error: Error | null;
}
export class MessageBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { error: null };
}
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.warn('MessageBoundary caught:', error.message, info.componentStack);
}
handleRetry = () => {
this.setState({ error: null });
};
render() {
if (this.state.error) {
if (this.props.fallback !== undefined) {
return <>{this.props.fallback}</>;
}
return (
<div className="flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs">
<AlertCircle className="size-3.5 text-destructive shrink-0" />
<span className="text-muted-foreground flex-1">Rendering failed</span>
<button
type="button"
onClick={this.handleRetry}
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-foreground hover:bg-muted"
aria-label="Retry rendering"
title="Retry"
>
<RefreshCw className="size-3" />
Retry
</button>
</div>
);
}
return this.props.children;
}
}

View File

@@ -299,7 +299,7 @@ function ActionRow({
return ( return (
<> <>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity max-md:opacity-100"> <div className="flex gap-1 opacity-0 group-hover:opacity-100 motion-reduce:transition-none transition-opacity max-md:opacity-100">
<button <button
type="button" type="button"
onClick={() => void copy()} onClick={() => void copy()}

View File

@@ -274,7 +274,7 @@ export function MessageList({ messages, sessionChats }: Props) {
chatId={item.chatId} chatId={item.chatId}
/> />
) : ( ) : (
<ToolCallLine run={item.run} /> <ToolCallLine run={item.run} chatId={item.chatId} />
) )
) : ( ) : (
<ToolCallGroup runs={item.runs} /> <ToolCallGroup runs={item.runs} />

View File

@@ -0,0 +1,56 @@
import { Component } from 'react';
import type { ErrorInfo, ReactNode } from 'react';
import { AlertCircle } from 'lucide-react';
interface Props {
children: ReactNode;
}
interface State {
error: Error | null;
}
export class MessageListErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { error: null };
}
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error('MessageListErrorBoundary caught:', error.message, info.componentStack);
}
handleRetry = () => {
this.setState({ error: null });
};
render() {
if (this.state.error) {
return (
<div className="flex-1 flex flex-col items-center justify-center gap-3 px-4">
<AlertCircle className="size-8 text-destructive" />
<div className="text-center space-y-1">
<p className="text-sm font-medium">Something went wrong</p>
<p className="text-xs text-muted-foreground">
The message list encountered an unexpected error.
</p>
</div>
<button
type="button"
onClick={this.handleRetry}
className="inline-flex items-center gap-1 rounded-md border px-3 py-1.5 text-xs font-medium hover:bg-muted"
aria-label="Try again"
title="Try again"
>
Try again
</button>
</div>
);
}
return this.props.children;
}
}

View File

@@ -13,7 +13,7 @@ import { useViewport } from '@/hooks/useViewport';
import { formatModelLabel } from '@/lib/model-label'; import { formatModelLabel } from '@/lib/model-label';
interface Props { interface Props {
value: string; value: string | null;
onChange: (model: string) => void | Promise<void>; onChange: (model: string) => void | Promise<void>;
} }
@@ -27,7 +27,7 @@ function ModelList({
}: { }: {
models: ModelInfo[] | null; models: ModelInfo[] | null;
error: string | null; error: string | null;
value: string; value: string | null;
onPick: (id: string) => void; onPick: (id: string) => void;
}) { }) {
if (error) { if (error) {
@@ -82,8 +82,8 @@ export function ModelPicker({ value, onChange }: Props) {
<button <button
type="button" type="button"
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
aria-label={`Model: ${value}`} aria-label={`Model: ${value ?? 'default'}`}
title={value} title={value ?? undefined}
className="inline-flex items-center justify-center min-h-[36px] min-w-[44px] rounded text-muted-foreground hover:text-foreground" className="inline-flex items-center justify-center min-h-[36px] min-w-[44px] rounded text-muted-foreground hover:text-foreground"
> >
<Cpu className="size-4" /> <Cpu className="size-4" />
@@ -104,7 +104,7 @@ export function ModelPicker({ value, onChange }: Props) {
type="button" type="button"
className="text-xs font-mono text-muted-foreground hover:text-foreground flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-muted/60" className="text-xs font-mono text-muted-foreground hover:text-foreground flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-muted/60"
> >
{formatModelLabel(value)} {value ? formatModelLabel(value) : 'Model'}
<ChevronDown className="size-3 opacity-70" /> <ChevronDown className="size-3 opacity-70" />
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>

View File

@@ -1,5 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { Code, History, MessageSquare, Plus, Terminal, Workflow } from 'lucide-react'; import { Code, History, MessageSquare, Plus, Swords, Terminal, Workflow } from 'lucide-react';
import { api } from '@/api/client'; import { api } from '@/api/client';
import type { FlowRunRow } from '@/api/types'; import type { FlowRunRow } from '@/api/types';
import { sessionEvents } from '@/hooks/sessionEvents'; import { sessionEvents } from '@/hooks/sessionEvents';
@@ -90,6 +90,19 @@ export function NewPaneMenu({ onAddPane, disabled, projectId }: Props) {
<Workflow size={14} /> New Orchestrator <Workflow size={14} /> New Orchestrator
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{projectId && (
<DropdownMenuItem
onSelect={() =>
sessionEvents.emit({
type: 'open_arena_launcher',
project_id: projectId,
placement: 'new',
})
}
>
<Swords size={14} /> New Arena
</DropdownMenuItem>
)}
{projectId && ( {projectId && (
<> <>

View File

@@ -273,7 +273,7 @@ export function ProjectSidebar() {
const asideCls = isMobile const asideCls = isMobile
? cn( ? cn(
'fixed inset-y-0 left-0 z-40 w-60 border-r bg-sidebar text-sidebar-foreground flex flex-col', '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', 'motion-reduce:transition-none transition-transform duration-200 ease-out',
drawerOpen ? 'translate-x-0' : '-translate-x-full', drawerOpen ? 'translate-x-0' : '-translate-x-full',
) )
: 'w-60 shrink-0 border-r bg-sidebar text-sidebar-foreground flex flex-col h-screen'; : 'w-60 shrink-0 border-r bg-sidebar text-sidebar-foreground flex flex-col h-screen';
@@ -333,7 +333,7 @@ export function ProjectSidebar() {
className="flex items-center justify-center text-[10px] uppercase tracking-wide text-muted-foreground border-b overflow-hidden shrink-0" className="flex items-center justify-center text-[10px] uppercase tracking-wide text-muted-foreground border-b overflow-hidden shrink-0"
style={{ style={{
height: pull.refreshing ? 32 : Math.min(pull.pullDist, 80), height: pull.refreshing ? 32 : Math.min(pull.pullDist, 80),
transition: pull.pullDist === 0 && !pull.refreshing ? 'height 0.2s ease' : undefined, transition: pull.pullDist === 0 && !pull.refreshing ? 'height 0.2s ease-out' : undefined,
}} }}
aria-live="polite" aria-live="polite"
> >

View File

@@ -282,7 +282,7 @@ export function RightRail({ projectId, sessionId }: Props) {
const asideCls = isMobile const asideCls = isMobile
? cn( ? cn(
'fixed inset-y-0 right-0 z-40 w-[85vw] max-w-sm border-l bg-sidebar flex flex-col overflow-hidden', 'fixed inset-y-0 right-0 z-40 w-[85vw] max-w-sm border-l bg-sidebar flex flex-col overflow-hidden',
'transition-transform duration-200 ease-out', 'motion-reduce:transition-none transition-transform duration-200 ease-out',
drawerOpen ? 'translate-x-0' : 'translate-x-full', drawerOpen ? 'translate-x-0' : 'translate-x-full',
) )
: 'w-64 shrink-0 border-l bg-sidebar flex flex-col h-full overflow-hidden'; : 'w-64 shrink-0 border-l bg-sidebar flex flex-col h-full overflow-hidden';

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
import { Archive, ChevronLeft, Code, MessageSquare, RotateCcw, Terminal, Trash2 } from 'lucide-react'; import { Archive, ChevronLeft, Code, MessageSquare, RotateCcw, Terminal, Trash2 } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import mascot from '@/assets/brand/banner-mascot.png'; import mascot from '@/assets/brand/banner-mascot.png';
import { EmptyState } from '@/components/EmptyState';
import { ChatInput } from '@/components/ChatInput'; import { ChatInput } from '@/components/ChatInput';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
@@ -167,9 +168,11 @@ export function SessionLandingPage({
<h2 className="text-sm font-medium ml-auto mr-1">Session history</h2> <h2 className="text-sm font-medium ml-auto mr-1">Session history</h2>
</div> </div>
{isEmpty ? ( {isEmpty ? (
<p className="text-sm text-muted-foreground text-center py-8"> <EmptyState
No conversations yet. Send a message to start. icon={<MessageSquare size={40} strokeWidth={1.5} />}
</p> title="No conversations"
description="Your chat history will appear here"
/>
) : (<> ) : (<>
{openChats.length > 0 && ( {openChats.length > 0 && (
<> <>
@@ -200,7 +203,7 @@ export function SessionLandingPage({
{formatRelative(c.updated_at)} {formatRelative(c.updated_at)}
</span> </span>
</button> </button>
<div className="shrink-0 flex items-center gap-0.5 opacity-0 group-hover/row:opacity-100 focus-within:opacity-100 max-md:opacity-100 transition-opacity"> <div className="shrink-0 flex items-center gap-0.5 opacity-0 group-hover/row:opacity-100 focus-within:opacity-100 max-md:opacity-100 motion-reduce:transition-none transition-opacity">
<button <button
type="button" type="button"
onClick={(e) => { e.stopPropagation(); void onArchiveChat(c.id); }} onClick={(e) => { e.stopPropagation(); void onArchiveChat(c.id); }}
@@ -254,7 +257,7 @@ export function SessionLandingPage({
<button <button
type="button" type="button"
onClick={(e) => { e.stopPropagation(); setDeleteConfirm({ id: c.id, name: c.name }); }} onClick={(e) => { e.stopPropagation(); setDeleteConfirm({ id: c.id, name: c.name }); }}
className="shrink-0 inline-flex items-center justify-center size-7 rounded hover:bg-destructive/20 hover:text-destructive max-md:size-9 opacity-0 group-hover/arch:opacity-100 focus-within:opacity-100 max-md:opacity-100 transition-opacity" className="shrink-0 inline-flex items-center justify-center size-7 rounded hover:bg-destructive/20 hover:text-destructive max-md:size-9 opacity-0 group-hover/arch:opacity-100 focus-within:opacity-100 max-md:opacity-100 motion-reduce:transition-none transition-opacity"
aria-label="Delete chat" aria-label="Delete chat"
title="Delete" title="Delete"
> >

View File

@@ -132,13 +132,13 @@ export function ThemePicker() {
onClick={() => setAnimBg(!animOn)} onClick={() => setAnimBg(!animOn)}
className={cn( className={cn(
'relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent', 'relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent',
'transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', 'motion-reduce:transition-none transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
animOn ? 'bg-primary' : 'bg-input', animOn ? 'bg-primary' : 'bg-input',
)} )}
> >
<span <span
className={cn( className={cn(
'pointer-events-none block h-4 w-4 rounded-full bg-white shadow-lg ring-0 transition-transform duration-200', 'pointer-events-none block h-4 w-4 rounded-full bg-white shadow-lg ring-0 motion-reduce:transition-none transition-transform duration-200',
animOn ? 'translate-x-4' : 'translate-x-0', animOn ? 'translate-x-4' : 'translate-x-0',
)} )}
/> />

View File

@@ -33,11 +33,20 @@ export function ToolCallGroup({ runs }: Props) {
<div className="rounded border border-border/60 bg-muted/20 text-xs"> <div className="rounded border border-border/60 bg-muted/20 text-xs">
<button <button
type="button" type="button"
tabIndex={0}
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
className="w-full flex items-center gap-1.5 px-2 py-1 hover:bg-muted/40 text-left" onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setOpen((v) => !v);
} else if (e.key === 'Escape') {
setOpen(false);
}
}}
className="w-full flex items-center gap-1.5 px-2 py-1 hover:bg-muted/40 text-left focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
> >
<ChevronRight <ChevronRight
className={`size-3 text-muted-foreground/60 shrink-0 transition-transform ${open ? 'rotate-90' : ''}`} className={`size-3 text-muted-foreground/60 shrink-0 motion-reduce:transition-none transition-transform ${open ? 'rotate-90' : ''}`}
/> />
<span className="text-muted-foreground/60 select-none shrink-0"></span> <span className="text-muted-foreground/60 select-none shrink-0"></span>
<span className="font-mono text-foreground/90"> <span className="font-mono text-foreground/90">

View File

@@ -123,8 +123,17 @@ export function ToolCallLine({ run, insideGroup, chatId }: Props) {
<div className="text-xs"> <div className="text-xs">
<button <button
type="button" type="button"
tabIndex={0}
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
className="flex items-center gap-1.5 w-full text-left hover:bg-muted/40 rounded px-1 py-0.5 -mx-1" onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setOpen((v) => !v);
} else if (e.key === 'Escape') {
setOpen(false);
}
}}
className="flex items-center gap-1.5 w-full text-left hover:bg-muted/40 rounded px-1 py-0.5 -mx-1 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
> >
{/* BooCode 2.0: glowing activity indicator (was ↳ / >_) */} {/* BooCode 2.0: glowing activity indicator (was ↳ / >_) */}
{!insideGroup && ( {!insideGroup && (

View File

@@ -274,6 +274,8 @@ export function Workspace({
paneId={activePaneChatId(pane) ?? pane.id} paneId={activePaneChatId(pane) ?? pane.id}
label={terminalLabels.get(activePaneChatId(pane) ?? pane.id) ?? 'Terminal'} label={terminalLabels.get(activePaneChatId(pane) ?? pane.id) ?? 'Terminal'}
active={idx === activePaneIdx} active={idx === activePaneIdx}
description={undefined}
parentAgent={undefined}
/> />
) : pane.kind === 'coder' ? ( ) : pane.kind === 'coder' ? (
<CoderPane <CoderPane

View File

@@ -0,0 +1,327 @@
import { useState } from 'react';
import { Copy, RefreshCw, Check, GitFork, Trash2, History, ThumbsUp, ThumbsDown } from 'lucide-react';
import { toast } from 'sonner';
import type { Message } from '@/api/types';
import type { MessageActions } from '@/components/MessageBubble';
import { api } from '@/api/client';
import { sessionEvents } from '@/hooks/sessionEvents';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
export function ActionRow({
message,
actions,
hiddenSet,
hasCheckpoint = false,
restoreDisabled = false,
}: {
message: Message;
actions?: MessageActions;
hiddenSet: Set<string>;
hasCheckpoint?: boolean;
restoreDisabled?: boolean;
}) {
const [justCopied, setJustCopied] = useState(false);
const [regenerating, setRegenerating] = useState(false);
const [forking, setForking] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const [restoreOpen, setRestoreOpen] = useState(false);
const [restoring, setRestoring] = useState(false);
const [feedbackValue, setFeedbackValue] = useState<'up' | 'down' | null>(() => {
const m = message.metadata;
return m && m.kind === 'feedback' ? m.value : null;
});
async function copy() {
try {
await navigator.clipboard.writeText(message.content);
setJustCopied(true);
setTimeout(() => setJustCopied(false), 1200);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'copy failed');
}
}
async function regenerate() {
if (regenerating || message.status === 'streaming') return;
setRegenerating(true);
try {
if (actions?.onRegenerate) {
await actions.onRegenerate(message.chat_id, message.id);
} else {
await api.messages.regenerate(message.chat_id, message.id);
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'regenerate failed');
} finally {
setRegenerating(false);
}
}
async function resend() {
if (!canResend) return;
try {
if (actions?.onResend) {
await actions.onResend(message.chat_id, message.content!);
} else {
await api.messages.send(message.chat_id, message.content!);
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'resend failed');
}
}
async function fork() {
if (forking || message.status !== 'complete') return;
setForking(true);
try {
if (actions?.onFork) {
await actions.onFork(message.chat_id, message.id);
} else {
const chat = await api.chats.fork(message.chat_id, { messageId: message.id });
sessionEvents.emit({ type: 'refetch_messages' });
sessionEvents.emit({ type: 'open_chat_in_new_pane', chat_id: chat.id });
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'fork failed');
} finally {
setForking(false);
}
}
async function confirmDelete() {
if (deleting) return;
setDeleting(true);
try {
if (actions?.onDelete) {
await actions.onDelete(message.chat_id, message.id);
} else {
await api.messages.remove(message.chat_id, message.id);
}
setDeleteOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'delete failed');
} finally {
setDeleting(false);
}
}
async function confirmRestore() {
if (restoring || !actions?.onRestoreCheckpoint) return;
setRestoring(true);
try {
await actions.onRestoreCheckpoint(message.chat_id, message.id);
setRestoreOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'restore failed');
} finally {
setRestoring(false);
}
}
async function handleFeedback(value: 'up' | 'down') {
if (feedbackValue) return; // already voted
setFeedbackValue(value);
try {
await api.messages.feedback(message.chat_id, message.id, value);
toast.success('Feedback recorded');
} catch (err) {
setFeedbackValue(null); // revert optimistic update
toast.error(err instanceof Error ? err.message : 'Failed to submit feedback');
}
}
const isAssistant = message.role === 'assistant';
const isUser = message.role === 'user';
const canRegen = isAssistant && message.status !== 'streaming';
const canResend = isUser && message.status === 'complete' && !!message.content?.trim();
const canFork = message.status === 'complete';
const canDelete = message.status !== 'streaming';
// write-edit-robustness #4: show "Restore to here" only for a completed
// assistant message that has a checkpoint AND when the coder wired the
// callback. Disabled (but visible) during an active turn.
const canRestore =
isAssistant &&
hasCheckpoint &&
message.status === 'complete' &&
!!actions?.onRestoreCheckpoint;
return (
<>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity max-md:opacity-100">
<button
type="button"
onClick={() => void copy()}
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Copy message"
title="Copy"
>
{justCopied ? <Check className="size-3" /> : <Copy className="size-3" />}
</button>
{canResend && (
<button
type="button"
onClick={() => void resend()}
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Resend message"
title="Resend"
>
<RefreshCw className="size-3" />
</button>
)}
{isAssistant && (
<button
type="button"
onClick={() => void regenerate()}
disabled={!canRegen || regenerating}
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Regenerate message"
title="Regenerate"
>
<RefreshCw className={`size-3 ${regenerating ? 'animate-spin' : ''}`} />
</button>
)}
{isAssistant && (
<>
<button
type="button"
onClick={() => void handleFeedback('up')}
disabled={feedbackValue !== null}
className={`inline-flex items-center justify-center size-6 rounded hover:bg-muted max-md:min-h-[44px] max-md:min-w-[44px] ${
feedbackValue === 'up'
? 'text-green-500'
: 'text-muted-foreground hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed'
}`}
aria-label="Rate as good"
title="Good response"
>
<ThumbsUp className="size-3" />
</button>
<button
type="button"
onClick={() => void handleFeedback('down')}
disabled={feedbackValue !== null}
className={`inline-flex items-center justify-center size-6 rounded hover:bg-muted max-md:min-h-[44px] max-md:min-w-[44px] ${
feedbackValue === 'down'
? 'text-red-500'
: 'text-muted-foreground hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed'
}`}
aria-label="Rate as bad"
title="Bad response"
>
<ThumbsDown className="size-3" />
</button>
</>
)}
{!hiddenSet.has('fork') && (
<button
type="button"
onClick={() => void fork()}
disabled={!canFork || forking}
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Fork from here"
title="Fork from here"
>
<GitFork className="size-3" />
</button>
)}
{!hiddenSet.has('delete') && (
<button
type="button"
onClick={() => setDeleteOpen(true)}
disabled={!canDelete}
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-destructive disabled:opacity-40 disabled:cursor-not-allowed max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Delete message"
title="Delete message"
>
<Trash2 className="size-3" />
</button>
)}
{canRestore && (
<button
type="button"
onClick={() => setRestoreOpen(true)}
disabled={restoreDisabled || restoring}
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Restore to here"
title="Restore worktree to this point"
>
<History className="size-3" />
</button>
)}
</div>
<Dialog
open={deleteOpen}
onOpenChange={(open) => {
if (!deleting) setDeleteOpen(open);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete this message and all messages after it?</DialogTitle>
<DialogDescription>
This removes the selected message and every later message in this chat. This cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDeleteOpen(false)}
disabled={deleting}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => void confirmDelete()}
disabled={deleting}
>
{deleting ? 'Deleting…' : 'Delete'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={restoreOpen}
onOpenChange={(open) => {
if (!restoring) setRestoreOpen(open);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Restore to this point?</DialogTitle>
<DialogDescription>
This resets the worktree to before this turn, removes every later
message in this chat, and resets the agent's session. This cannot
be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setRestoreOpen(false)}
disabled={restoring}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => void confirmRestore()}
disabled={restoring}
>
{restoring ? 'Restoring' : 'Restore'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,125 @@
import { useState } from 'react';
import { ChevronDown, ChevronRight, Copy, Check, Share2, RotateCw } from 'lucide-react';
import { toast } from 'sonner';
import type { Chat, Message } from '@/api/types';
import { api } from '@/api/client';
export function CompactCard({ message, sessionChats }: { message: Message; sessionChats?: Chat[] }) {
const [expanded, setExpanded] = useState(false);
const [copied, setCopied] = useState(false);
const [shareOpen, setShareOpen] = useState(false);
const [rerunning, setRerunning] = useState(false);
const headerMatch = message.content.match(/^\[Context compacted — (\d+) messages summarized\]/);
const headerText = headerMatch ? headerMatch[0] : 'Context compacted';
const summaryText = headerMatch
? message.content.slice(headerMatch[0].length).trim()
: message.content;
async function handleCopy() {
try {
await navigator.clipboard.writeText(summaryText);
setCopied(true);
setTimeout(() => setCopied(false), 1200);
toast.success('Summary copied to clipboard');
} catch {
toast.error('Copy failed');
}
}
async function handleShareToChat(chat: Chat) {
try {
await api.messages.send(chat.id, summaryText);
toast.success(`Summary sent to ${chat.name ?? 'New chat'}`);
setShareOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to share');
}
}
async function handleRerun() {
if (rerunning) return;
setRerunning(true);
try {
await api.chats.compact(message.chat_id);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Re-run failed');
} finally {
setRerunning(false);
}
}
const otherChats = (sessionChats ?? []).filter(
(c) => c.id !== message.chat_id && c.status === 'open'
);
return (
<div className="rounded-lg border bg-muted/30 text-sm">
<div className="flex items-center gap-2 px-3 py-2">
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-1.5 flex-1 min-w-0 text-left text-muted-foreground hover:text-foreground"
>
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
<span className="text-xs font-medium truncate">{headerText}</span>
</button>
<button
type="button"
onClick={() => void handleCopy()}
className="p-1 rounded hover:bg-muted text-muted-foreground"
aria-label="Copy summary"
title="Copy summary"
>
{copied ? <Check size={12} /> : <Copy size={12} />}
</button>
<div className="relative">
<button
type="button"
onClick={() => setShareOpen(!shareOpen)}
className="p-1 rounded hover:bg-muted text-muted-foreground"
aria-label="Send to chat"
title="Send to chat"
>
<Share2 size={12} />
</button>
{shareOpen && (
<div className="absolute right-0 top-full mt-1 z-50 bg-popover border rounded-md shadow-md min-w-[180px] py-1">
{otherChats.length === 0 ? (
<div className="px-3 py-1.5 text-xs text-muted-foreground">
No other chats in this session
</div>
) : (
otherChats.map((c) => (
<button
key={c.id}
type="button"
onClick={() => void handleShareToChat(c)}
className="w-full text-left px-3 py-1.5 text-xs hover:bg-accent truncate"
>
{c.name ?? 'New chat'}
</button>
))
)}
</div>
)}
</div>
<button
type="button"
onClick={() => void handleRerun()}
disabled={rerunning}
className="p-1 rounded hover:bg-muted text-muted-foreground disabled:opacity-40"
aria-label="Re-run compact"
title="Re-run compact"
>
<RotateCw size={12} className={rerunning ? 'animate-spin' : ''} />
</button>
</div>
{expanded && (
<div className="px-3 pb-3 text-xs leading-relaxed text-muted-foreground whitespace-pre-wrap border-t pt-2">
{summaryText}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,76 @@
import { useState } from 'react';
import { AlertCircle } from 'lucide-react';
import { toast } from 'sonner';
import type { Message } from '@/api/types';
import { api } from '@/api/client';
import { Button } from '@/components/ui/button';
// feature #12: mistake-recovery sentinel. Inserted by the backend as a
// role='system', metadata.kind='mistake_recovery' row when the model hit
// repeated *different* errors (distinct from doom_loop, which is the same
// call repeated). Visual treatment mirrors CapHitSentinel / DoomLoopSentinel
// (amber card + alert icon). Non-escalated → recovery guidance was injected
// and the turn continues. Escalated → the turn was stopped; if can_continue
// is set, offer the same Continue affordance as the cap-hit sentinel.
// Loose `!= null` guards per the CLAUDE.md coder-message note (coder rows pass
// metadata as undefined, not null).
export function MistakeRecoverySentinel({ message }: { message: Message }) {
const meta = message.metadata;
const isMistakeRecovery =
meta != null && typeof meta === 'object' && meta.kind === 'mistake_recovery';
const failureKinds = isMistakeRecovery ? meta.failure_kinds : [];
const escalated = isMistakeRecovery ? meta.escalated : false;
const canContinue = isMistakeRecovery ? meta.can_continue === true : false;
const [continuing, setContinuing] = useState(false);
async function handleContinue() {
if (continuing || !canContinue) return;
setContinuing(true);
try {
await api.chats.continue(message.chat_id, message.id);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'continue failed');
} finally {
setContinuing(false);
}
}
const kindsLabel =
Array.isArray(failureKinds) && failureKinds.length > 0
? failureKinds.join(', ')
: null;
return (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 text-sm">
<div className="px-3 py-2 flex items-start gap-2">
<AlertCircle className="size-4 text-amber-500 shrink-0 mt-0.5" />
<div className="flex-1 min-w-0 space-y-1">
<div className="text-xs font-medium text-amber-700 dark:text-amber-300">
{escalated ? 'Repeated errors — turn stopped' : 'Recovering from repeated errors'}
</div>
<div className="text-xs text-muted-foreground">
{escalated
? 'Repeated errors persisted — stopped the turn.'
: kindsLabel
? `Hit repeated different errors (${kindsLabel}) — recovery guidance injected, continuing.`
: 'Hit repeated different errors — recovery guidance injected, continuing.'}
</div>
{escalated && canContinue && (
<div className="pt-1">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => void handleContinue()}
disabled={continuing}
>
{continuing ? 'Continuing…' : 'Continue'}
</Button>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,33 @@
import { useState } from 'react';
import { ChevronDown, ChevronRight, Brain } from 'lucide-react';
// Collapsible "Thinking" block for assistant reasoning. Fed by either
// reasoning_text (coder wire / live reasoning_delta stream) or reasoning_parts
// (native inference, persisted from message_parts). Starts COLLAPSED to start
// (a quiet chip) — for native BooChat/BooCode and the external agents (opencode,
// claude SDK) alike — so the transcript stays tidy; click to expand. The
// `streaming` pulse still animates while the turn runs.
export function ReasoningBlock({ text, streaming }: { text: string; streaming: boolean }) {
const [expanded, setExpanded] = useState(false);
return (
<div className="max-w-[90%] rounded-lg border bg-muted/30 text-sm">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="flex items-center gap-1.5 w-full px-3 py-1.5 text-left text-muted-foreground hover:text-foreground"
>
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
<Brain size={13} />
<span className="text-xs font-medium">Thinking</span>
{streaming && (
<span className="ml-1 inline-block w-1.5 h-3 align-baseline bg-muted-foreground/60 animate-pulse" />
)}
</button>
{expanded && (
<div className="px-3 pb-2.5 pt-0.5 text-xs leading-relaxed text-muted-foreground whitespace-pre-wrap break-words border-t">
{text}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,69 @@
import { useState } from 'react';
import type { ReactNode } from 'react';
import { toast } from 'sonner';
import { sendToTerminal } from '@/lib/events';
import { useTerminals } from '@/hooks/useTerminals';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from '@/components/ui/context-menu';
// Wrap a message body with a right-click context menu offering Copy and
// "Send to terminal → <pane name>". Send is disabled when nothing is
// selected or no terminal panes are open; clicking a target emits a
// sendToTerminal event that TerminalPane subscribes to (filtered by pane_id).
export function SendToTerminalMenu({ children }: { children: ReactNode }) {
const [selection, setSelection] = useState('');
const terminals = useTerminals();
const hasSelection = selection.length > 0;
const canSend = hasSelection && terminals.length > 0;
return (
<ContextMenu
onOpenChange={(open) => {
if (open) {
const sel = typeof window !== 'undefined' ? window.getSelection()?.toString() ?? '' : '';
setSelection(sel);
}
}}
>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
disabled={!hasSelection}
onSelect={() => {
void navigator.clipboard.writeText(selection).catch((err) => {
toast.error(err instanceof Error ? err.message : 'copy failed');
});
}}
>
Copy
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuSub>
<ContextMenuSubTrigger disabled={!canSend}>Send to terminal</ContextMenuSubTrigger>
<ContextMenuSubContent>
{terminals.length === 0 ? (
<ContextMenuItem disabled>No terminal panes open</ContextMenuItem>
) : (
terminals.map((t) => (
<ContextMenuItem
key={t.paneId}
onSelect={() => sendToTerminal.emit({ pane_id: t.paneId, text: selection })}
>
{t.label}
</ContextMenuItem>
))
)}
</ContextMenuSubContent>
</ContextMenuSub>
</ContextMenuContent>
</ContextMenu>
);
}

View File

@@ -0,0 +1,38 @@
import type { Message } from '@/api/types';
export function StatsLine({ message }: { message: Message }) {
const tokens = message.tokens_used;
if (typeof tokens !== 'number' || tokens <= 0) return null;
const started = message.started_at ? Date.parse(message.started_at) : NaN;
const finished = message.finished_at ? Date.parse(message.finished_at) : NaN;
let tps: number | null = null;
if (!Number.isNaN(started) && !Number.isNaN(finished) && finished > started) {
const seconds = (finished - started) / 1000;
if (seconds > 0) tps = Math.round((tokens / seconds) * 10) / 10;
}
const ctxUsed = message.ctx_used;
const ctxMax = message.ctx_max;
const ctxPart =
typeof ctxUsed === 'number'
? typeof ctxMax === 'number' && ctxMax > 0
? `${ctxUsed} / ${ctxMax} ctx`
: `${ctxUsed} ctx`
: null;
const cacheHit = message.cache_tokens;
const reasoning = message.reasoning_tokens;
const cachePart = typeof cacheHit === 'number' && cacheHit > 0 ? `cache ${cacheHit}` : null;
const reasoningPart = typeof reasoning === 'number' && reasoning > 0 ? `think ${reasoning}` : null;
const parts: string[] = [`${tokens} tokens`];
if (tps !== null) parts.push(`${tps.toFixed(1)} tok/s`);
if (ctxPart) parts.push(ctxPart);
if (cachePart) parts.push(cachePart);
if (reasoningPart) parts.push(reasoningPart);
return (
<div className="text-[10px] font-mono text-muted-foreground">
{parts.join(' · ')}
</div>
);
}

View File

@@ -0,0 +1,62 @@
import { useState } from 'react';
import { ChevronDown, ChevronRight, Copy, Check } from 'lucide-react';
import { toast } from 'sonner';
import type { Message } from '@/api/types';
import { MarkdownRenderer } from '@/components/MarkdownRenderer';
// v1.11 anchored rolling summary. Inserted by services/compaction.ts as a
// role='assistant', summary=true row. Distinct from legacy CompactCard
// (which renders the kind='compact' system rows produced by v1.10 /compact).
// Collapsed by default; header shows the timestamp; body renders the
// summary markdown when expanded. Copy button matches CompactCard's affordance.
export function SummaryCard({ message }: { message: Message }) {
const [expanded, setExpanded] = useState(false);
const [copied, setCopied] = useState(false);
// Use finished_at when available (that's when the summary actually landed);
// fall back to created_at for any row missing it. Both are ISO strings.
const ts = message.finished_at ?? message.created_at;
const headerTs = ts ? new Date(ts).toLocaleString() : '';
async function handleCopy() {
try {
await navigator.clipboard.writeText(message.content);
setCopied(true);
setTimeout(() => setCopied(false), 1200);
toast.success('Summary copied to clipboard');
} catch {
toast.error('Copy failed');
}
}
return (
<div className="rounded-lg border border-primary/30 bg-primary/5 text-sm">
<div className="flex items-center gap-2 px-3 py-2">
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-1.5 flex-1 min-w-0 text-left text-muted-foreground hover:text-foreground"
>
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
<span className="text-xs font-medium truncate">
Compacted summary {headerTs}
</span>
</button>
<button
type="button"
onClick={() => void handleCopy()}
className="p-1 rounded hover:bg-muted text-muted-foreground"
aria-label="Copy summary"
title="Copy summary"
>
{copied ? <Check size={12} /> : <Copy size={12} />}
</button>
</div>
{expanded && (
<div className="px-3 pb-3 text-xs leading-relaxed border-t pt-2">
<MarkdownRenderer content={message.content} />
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,7 @@
export { StatsLine } from './StatsLine';
export { ActionRow } from './ActionRow';
export { CompactCard } from './CompactCard';
export { SummaryCard } from './SummaryCard';
export { ReasoningBlock } from './ReasoningBlock';
export { MistakeRecoverySentinel } from './MistakeRecoverySentinel';
export { SendToTerminalMenu } from './SendToTerminalMenu';

View File

@@ -1,14 +1,31 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { History, Pencil, Send, X } from 'lucide-react'; import { Columns, Download, History, Pencil, Send, X } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api } from '@/api/client'; import { api } from '@/api/client';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from '@/components/ui/dropdown-menu';
import { useSessionStream } from '@/hooks/useSessionStream'; import { useSessionStream } from '@/hooks/useSessionStream';
import { MessageList } from '@/components/MessageList'; import { MessageList } from '@/components/MessageList';
import { ChatInput } from '@/components/ChatInput'; import { ChatInput } from '@/components/ChatInput';
import { ModelPicker } from '@/components/ModelPicker';
import { StaleStreamBanner } from '@/components/StaleStreamBanner'; import { StaleStreamBanner } from '@/components/StaleStreamBanner';
import { SessionTimeline } from '@/components/SessionTimeline'; import { SessionTimeline } from '@/components/SessionTimeline';
import { TraceViewer } from '@/components/TraceViewer'; import { TraceViewer } from '@/components/TraceViewer';
import { sendToChat } from '@/lib/events'; import { sendToChat } from '@/lib/events';
import { ComparePane, type CompareResponse } from '@/components/ComparePane';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
interface Props { interface Props {
sessionId: string; sessionId: string;
@@ -31,6 +48,107 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
const [queue, setQueue] = useState<{ id: string; text: string }[]>([]); const [queue, setQueue] = useState<{ id: string; text: string }[]>([]);
const queueIdRef = useRef(0); const queueIdRef = useRef(0);
const processingRef = useRef(false); const processingRef = useRef(false);
const chatMessages = stream.messages.filter((m) => m.chat_id === chatId);
const streaming = chatMessages.some((m) => m.status === 'streaming');
// v2.8-compare: compare mode state
const [showCompareSelector, setShowCompareSelector] = useState(false);
const [compareModels, setCompareModels] = useState<string[]>([]);
const [compareResponses, setCompareResponses] = useState<CompareResponse[]>([]);
const [compareGroupId, setCompareGroupId] = useState<string | null>(null);
const compareMsgIdToModelRef = useRef<Map<string, string>>(new Map());
// v2.8-compare: derive compare responses from streaming messages.
// Watches stream.messages for message IDs tracked in compareMsgIdToModelRef.
const compareActive = compareGroupId !== null;
useEffect(() => {
if (!compareActive) return;
const idToModel = compareMsgIdToModelRef.current;
if (idToModel.size === 0) return;
setCompareResponses((prev) => {
let changed = false;
const next = prev.map((r) => r);
for (const msg of chatMessages) {
const model = idToModel.get(msg.id);
if (!model) continue;
const idx = next.findIndex((r) => r.model === model);
if (idx === -1) continue;
const entry = next[idx]!;
if (entry.content !== msg.content || entry.status !== (msg.status === 'streaming' ? 'streaming' : msg.status === 'failed' ? 'error' : 'complete')) {
changed = true;
next[idx] = {
...entry,
content: msg.content,
status: msg.status === 'streaming' ? 'streaming' : msg.status === 'failed' ? 'error' : 'complete',
};
}
}
return changed ? next : prev;
});
}, [chatMessages, compareActive]);
const handleExitCompare = useCallback(() => {
setCompareModels([]);
setCompareResponses([]);
setCompareGroupId(null);
compareMsgIdToModelRef.current.clear();
}, []);
const [compareInput, setCompareInput] = useState('');
const [selectedCompareModels, setSelectedCompareModels] = useState<string[]>([]);
const [sendingCompare, setSendingCompare] = useState(false);
const [availableModels, setAvailableModels] = useState<string[]>([]);
// Fetch available models when the compare selector opens.
useEffect(() => {
if (!showCompareSelector) return;
api.models()
.then((mods) => setAvailableModels(mods.map((m) => m.id).sort()))
.catch(() => {
// Fallback: use session model if API fails
const sessionModel = sessionChats?.find((c) => c.id === chatId)?.model;
setAvailableModels(sessionModel ? [sessionModel] : []);
});
}, [showCompareSelector, sessionChats, chatId]);
// v2.8-compare: when user types in ChatInput during compare mode, open the
// model selector dialog with the typed message.
const handleCompareFromInput = useCallback((content: string) => {
setCompareInput(content);
setShowCompareSelector(true);
}, []);
async function handleCompareSend() {
const trimmed = compareInput.trim();
if (!trimmed || selectedCompareModels.length < 2 || sendingCompare) return;
setSendingCompare(true);
try {
const result = await api.chats.compare(chatId, trimmed, selectedCompareModels);
const idToModel = compareMsgIdToModelRef.current;
idToModel.clear();
for (const r of result.responses) {
idToModel.set(r.assistant_message_id, r.model);
}
setCompareResponses(
selectedCompareModels.map((model) => ({
model,
assistantMessageId: result.responses.find((r) => r.model === model)?.assistant_message_id ?? '',
content: '',
status: 'streaming' as const,
})),
);
setCompareGroupId(result.compare_group_id);
setCompareModels([...selectedCompareModels]);
setShowCompareSelector(false);
setCompareInput('');
setSelectedCompareModels([]);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Compare failed');
} finally {
setSendingCompare(false);
}
}
useEffect(() => { useEffect(() => {
if (stream.error && stream.error !== lastErrorRef.current) { if (stream.error && stream.error !== lastErrorRef.current) {
@@ -42,9 +160,6 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
} }
}, [stream.error]); }, [stream.error]);
const chatMessages = stream.messages.filter((m) => m.chat_id === chatId);
const streaming = chatMessages.some((m) => m.status === 'streaming');
// v1.12.3: stale-stream detection. Watches the (at most one) streaming // v1.12.3: stale-stream detection. Watches the (at most one) streaming
// assistant row. If its content length doesn't grow for STALE_THRESHOLD_MS, // assistant row. If its content length doesn't grow for STALE_THRESHOLD_MS,
// assume the upstream call is dead and surface the recovery banner. We use // assume the upstream call is dead and surface the recovery banner. We use
@@ -213,10 +328,52 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
} }
}, []); }, []);
async function handleExport(format: 'json' | 'markdown') {
try {
const content = await api.chats.exportChat(chatId, format);
const blob = new Blob([content], { type: format === 'json' ? 'application/json' : 'text/markdown' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `chat-${chatId}.${format === 'json' ? 'json' : 'md'}`;
a.click();
URL.revokeObjectURL(url);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Export failed');
}
}
return ( return (
<div className="flex flex-col h-full min-h-0 relative"> <div className="flex flex-col h-full min-h-0 relative">
{chatMessages.length > 0 && ( {chatMessages.length > 0 && (
<div className="absolute top-2 right-2 z-10"> <div className="absolute top-2 right-2 z-10 flex items-center gap-2">
<ModelPicker
value={sessionChats?.find((c) => c.id === chatId)?.model ?? null}
onChange={async (model) => {
try {
await api.chats.update(chatId, { model });
toast.success(`Model set to ${model}`);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to update model');
}
}}
/>
<button
type="button"
onClick={() => setShowCompareSelector(true)}
disabled={streaming}
className={`
inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium
transition-colors border
bg-background text-muted-foreground border-border hover:bg-muted hover:text-foreground
disabled:opacity-40 disabled:cursor-not-allowed
`}
aria-label="Compare models"
title="Compare models"
>
<Columns size={12} />
Compare
</button>
<button <button
type="button" type="button"
onClick={() => setShowTimeline((v) => !v)} onClick={() => setShowTimeline((v) => !v)}
@@ -233,16 +390,44 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
<History size={12} /> <History size={12} />
Timeline Timeline
</button> </button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="p-1 rounded hover:bg-muted text-muted-foreground"
aria-label="Export chat"
title="Export chat"
>
<Download className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => handleExport('json')}>
Export as JSON
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => handleExport('markdown')}>
Export as Markdown
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
)} )}
{/* v1.11.5: ContextBar moved into ChatInput (above the agent picker). */} {/* v1.11.5: ContextBar moved into ChatInput (above the agent picker). */}
<MessageList messages={chatMessages} sessionChats={sessionChats} /> {compareActive ? (
<ComparePane
models={compareModels}
responses={compareResponses}
onClose={handleExitCompare}
/>
) : (
<MessageList messages={chatMessages} sessionChats={sessionChats} />
)}
<TraceViewer chatId={chatId} /> <TraceViewer chatId={chatId} />
{/* Queued messages */} {/* Queued messages */}
{queue.length > 0 && ( {!compareActive && queue.length > 0 && (
<div className="border-t"> <div className="border-t">
<div className="max-w-[1000px] mx-auto w-full px-4 py-1 space-y-1"> <div className="max-w-[1000px] mx-auto w-full px-4 py-1 space-y-1">
{queue.map((item, i) => ( {queue.map((item, i) => (
@@ -282,7 +467,7 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
</div> </div>
)} )}
{stale && streamingId && ( {!compareActive && stale && streamingId && (
<StaleStreamBanner <StaleStreamBanner
onRetry={() => void handleRetryStale()} onRetry={() => void handleRetryStale()}
onDiscard={() => void handleDiscardStale()} onDiscard={() => void handleDiscardStale()}
@@ -296,7 +481,7 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
agentId={agentId} agentId={agentId}
onAgentChange={onAgentChange} onAgentChange={onAgentChange}
webSearchEnabled={webSearchEnabled} webSearchEnabled={webSearchEnabled}
onSend={handleSend} onSend={compareActive ? handleCompareFromInput : handleSend}
onForceSend={streaming ? handleForceSend : undefined} onForceSend={streaming ? handleForceSend : undefined}
generating={streaming} generating={streaming}
onStop={handleStop} onStop={handleStop}
@@ -318,6 +503,79 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
onScrollToMessage={handleScrollToMessage} onScrollToMessage={handleScrollToMessage}
/> />
)} )}
{/* Compare model selector dialog */}
{showCompareSelector && (
<Dialog open={showCompareSelector} onOpenChange={(open) => { if (!open) setShowCompareSelector(false); }}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Compare Models</DialogTitle>
<DialogDescription>
Select 2-3 models to compare. Each model receives the same message and you see responses side by side.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3 py-4">
<textarea
value={compareInput}
onChange={(e) => setCompareInput(e.target.value)}
placeholder="Type your message to compare across models…"
rows={3}
className="w-full resize-none rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
/>
<div className="text-xs text-muted-foreground mb-1">Select 2-3 models:</div>
{availableModels.length === 0 && (
<div className="text-xs text-muted-foreground px-1">Loading models</div>
)}
{availableModels.map((model) => {
const isSelected = selectedCompareModels.includes(model);
return (
<label
key={model}
className={`
flex items-center gap-3 px-3 py-2 rounded-md border text-sm cursor-pointer transition-colors
${isSelected
? 'border-primary bg-primary/5 text-foreground'
: 'border-border hover:bg-muted/50 text-muted-foreground'
}
${selectedCompareModels.length >= 3 && !isSelected ? 'opacity-40 pointer-events-none' : ''}
`}
>
<input
type="checkbox"
checked={isSelected}
onChange={() => {
setSelectedCompareModels((prev) =>
isSelected
? prev.filter((m) => m !== model)
: prev.length < 3
? [...prev, model]
: prev,
);
}}
className="size-4 accent-primary"
/>
<span className="flex-1">{model}</span>
{isSelected && (
<span className="text-[10px] text-muted-foreground shrink-0">
{selectedCompareModels.indexOf(model) + 1}
</span>
)}
</label>
);
})}
</div>
<DialogFooter>
<Button
variant="default"
disabled={selectedCompareModels.length < 2 || sendingCompare || !compareInput.trim()}
onClick={() => void handleCompareSend()}
>
{sendingCompare ? 'Starting…' : `Compare (${selectedCompareModels.length})`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</div> </div>
); );
} }

View File

@@ -48,10 +48,12 @@ interface Props {
sessionId: string; sessionId: string;
paneId: string; paneId: string;
label: string; label: string;
description?: string;
parentAgent?: string;
active?: boolean; active?: boolean;
} }
export function TerminalPane({ sessionId, paneId, label, active = false }: Props) { export function TerminalPane({ sessionId, paneId, label, description, parentAgent, active = false }: Props) {
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const termRef = useRef<Terminal | null>(null); const termRef = useRef<Terminal | null>(null);
const searchRef = useRef<SearchAddon | null>(null); const searchRef = useRef<SearchAddon | null>(null);
@@ -112,6 +114,8 @@ export function TerminalPane({ sessionId, paneId, label, active = false }: Props
termRef, termRef,
sessionId, sessionId,
paneId, paneId,
description,
parentAgent,
fit: fit.fit, fit: fit.fit,
getSize: fit.getSize, getSize: fit.getSize,
setSize: fit.setSize, setSize: fit.setSize,
@@ -148,6 +152,18 @@ export function TerminalPane({ sessionId, paneId, label, active = false }: Props
onArmCtrl={socket.armCtrl} onArmCtrl={socket.armCtrl}
onFit={fit.fit} onFit={fit.fit}
/> />
{(description || parentAgent) && (
<div className="flex items-center gap-2 px-3 py-1 text-xs border-b border-border/30 bg-[#0b0f14] shrink-0">
{parentAgent && (
<span className="inline-flex items-center rounded-full bg-primary/10 text-primary px-2 py-0.5 text-[10px] font-medium leading-none">
{parentAgent}
</span>
)}
{description && (
<span className="text-muted-foreground truncate">{description}</span>
)}
</div>
)}
<div <div
ref={containerRef} ref={containerRef}
className="flex-1 min-h-0 w-full overflow-hidden" className="flex-1 min-h-0 w-full overflow-hidden"

View File

@@ -5,7 +5,7 @@ import { Slot } from "radix-ui"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const buttonVariants = cva( const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-colors motion-reduce:transition-none outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:scale-[0.97] disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{ {
variants: { variants: {
variant: { variant: {

View File

@@ -23,6 +23,8 @@ interface SocketDeps {
termRef: React.MutableRefObject<Terminal | null>; termRef: React.MutableRefObject<Terminal | null>;
sessionId: string; sessionId: string;
paneId: string; paneId: string;
description?: string;
parentAgent?: string;
fit: TerminalFit['fit']; fit: TerminalFit['fit'];
getSize: TerminalFit['getSize']; getSize: TerminalFit['getSize'];
setSize: TerminalFit['setSize']; setSize: TerminalFit['setSize'];
@@ -40,6 +42,8 @@ export function useTerminalSocket({
termRef, termRef,
sessionId, sessionId,
paneId, paneId,
description,
parentAgent,
fit, fit,
getSize, getSize,
setSize, setSize,
@@ -276,7 +280,7 @@ export function useTerminalSocket({
fit(); fit();
const { cols, rows } = getSize(); const { cols, rows } = getSize();
api.terminals api.terminals
.start(sessionId, paneId, cols, rows) .start(sessionId, paneId, cols, rows, description, parentAgent)
.catch(() => { .catch(() => {
/* WS handler will ensureSession itself — non-fatal */ /* WS handler will ensureSession itself — non-fatal */
}) })

View File

@@ -0,0 +1,98 @@
import { useCallback, useEffect, useRef, useState } from 'react';
const STORAGE_PREFIX = 'boocode_draft_';
const SAVE_DEBOUNCE_MS = 500;
function getKey(chatId: string): string {
return `${STORAGE_PREFIX}${chatId}`;
}
function readDraft(key: string): string {
if (typeof window === 'undefined') return '';
try {
return localStorage.getItem(key) ?? '';
} catch {
return '';
}
}
function writeDraft(key: string, text: string): void {
if (typeof window === 'undefined') return;
try {
if (text) {
localStorage.setItem(key, text);
} else {
localStorage.removeItem(key);
}
} catch {
// storage full or unavailable — silently ignore
}
}
function removeDraft(key: string): void {
if (typeof window === 'undefined') return;
try {
localStorage.removeItem(key);
} catch {
// silently ignore
}
}
export interface DraftPersistenceResult {
/** Current draft state, initialized from localStorage on mount. */
draft: string;
/** Update draft with 500ms debounced persistence to localStorage. */
setDraft: (text: string) => void;
/** Clear draft state and remove localStorage entry immediately. */
clearDraft: () => void;
/** Re-read from localStorage, update state, and return saved value. */
restoreDraft: () => string;
}
export function useDraftPersistence(chatId: string | undefined): DraftPersistenceResult {
const key = chatId ? getKey(chatId) : null;
const [draft, setDraftState] = useState(() => (key ? readDraft(key) : ''));
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const keyRef = useRef(key);
keyRef.current = key;
// Cleanup timer on unmount
useEffect(() => {
return () => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
}
};
}, []);
const setDraft = useCallback((text: string) => {
setDraftState(text);
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
}
timerRef.current = setTimeout(() => {
const k = keyRef.current;
if (k) writeDraft(k, text);
}, SAVE_DEBOUNCE_MS);
}, []);
const clearDraft = useCallback(() => {
setDraftState('');
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
const k = keyRef.current;
if (k) removeDraft(k);
}, []);
const restoreDraft = useCallback((): string => {
const k = keyRef.current;
if (!k) return '';
const saved = readDraft(k);
setDraftState(saved);
return saved;
}, []);
return { draft, setDraft, clearDraft, restoreDraft };
}

View File

@@ -0,0 +1,616 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import React, { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import type { Message, WsFrame } from '@/api/types';
import { useSessionStream } from './useSessionStream';
// ── Hoisted mock values ──────────────────────────────────────────────────────
const { mockMessagesList, mockEmit, mockSubscribe, mockRecordUsage } = vi.hoisted(
() => ({
mockMessagesList: vi.fn(),
mockEmit: vi.fn(),
mockSubscribe: vi.fn().mockReturnValue(vi.fn()),
mockRecordUsage: vi.fn(),
}),
);
// ── Module mocks ─────────────────────────────────────────────────────────────
vi.mock('sonner', () => ({
toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() },
}));
vi.mock('@/api/client', () => ({
api: { messages: { list: mockMessagesList } },
}));
vi.mock('./sessionEvents', () => ({
sessionEvents: { emit: mockEmit, subscribe: mockSubscribe },
}));
vi.mock('./useChatThroughput', () => ({
recordUsage: mockRecordUsage,
}));
// ── Test constants ───────────────────────────────────────────────────────────
const SESSION_ID = '00000000-0000-0000-0000-000000000001';
const CHAT_ID = '00000000-0000-0000-0000-000000000002';
const MSG_ID = '00000000-0000-0000-0000-000000000003';
const TOOL_MSG_ID = '00000000-0000-0000-0000-000000000004';
// ── Frame builder helpers ────────────────────────────────────────────────────
function textDelta(seq: number, content: string): WsFrame {
return {
type: 'channel_delta', seq, channel: 'text', message_id: MSG_ID, chat_id: CHAT_ID, content,
} as unknown as WsFrame;
}
function toolCallDelta(
seq: number,
tc: { id: string; name: string; args: Record<string, unknown> },
): WsFrame {
return {
type: 'channel_delta', seq, channel: 'tool_call', message_id: MSG_ID, chat_id: CHAT_ID, tool_call: tc,
} as unknown as WsFrame;
}
function toolResultDelta(seq: number, callId: string, output: unknown): WsFrame {
return {
type: 'channel_delta', seq, channel: 'tool_result',
tool_message_id: TOOL_MSG_ID, chat_id: CHAT_ID, tool_call_id: callId,
output, truncated: false,
} as unknown as WsFrame;
}
function statusDelta(
seq: number, status: 'running' | 'complete' | 'cancelled' | 'failed',
overrides?: Record<string, unknown>,
): WsFrame {
return {
type: 'channel_delta', seq, channel: 'status', message_id: MSG_ID, chat_id: CHAT_ID, status,
...(overrides ?? {}),
} as unknown as WsFrame;
}
function errorDelta(seq: number, error: string): WsFrame {
return {
type: 'channel_delta', seq, channel: 'error', message_id: MSG_ID, chat_id: CHAT_ID, error,
} as unknown as WsFrame;
}
// ── WebSocket mock globals ───────────────────────────────────────────────────
interface MockWs {
onopen: (() => void) | null;
onmessage: ((ev: { data: string }) => void) | null;
onclose: ((ev: { code?: number; reason?: string }) => void) | null;
onerror: (() => void) | null;
send: ReturnType<typeof vi.fn>;
close: ReturnType<typeof vi.fn>;
readyState: number;
url: string;
}
let currentMockWs: MockWs | null = null;
let wsConstructCount = 0;
function createWsMock(): MockWs {
return {
onopen: null, onmessage: null, onclose: null, onerror: null,
send: vi.fn(), close: vi.fn(), readyState: 1, url: '',
};
}
function triggerWsOpen(): void { currentMockWs?.onopen?.(); }
function triggerWsMessage(frame: WsFrame): void {
currentMockWs?.onmessage?.({ data: JSON.stringify(frame) });
}
function triggerWsClose(): void { currentMockWs?.onclose?.({}); }
function getWsSendCalls(): string[] {
return (currentMockWs?.send.mock.calls ?? []).map((c: unknown[]) => String(c[0]));
}
// ── React test harness ───────────────────────────────────────────────────────
function createHarness() {
const states: Array<{ messages: Message[]; connected: boolean; error: string | null }> = [];
function Wrapper({ sessionId }: { sessionId: string | undefined }) {
const state = useSessionStream(sessionId);
states.push(state);
return React.createElement('div');
}
return {
Wrapper,
lastState: () => states[states.length - 1] ?? null,
};
}
let root: Root;
let container: HTMLDivElement;
beforeEach(() => {
currentMockWs = null;
wsConstructCount = 0;
mockMessagesList.mockReset().mockResolvedValue([]);
mockEmit.mockReset();
mockSubscribe.mockReset().mockReturnValue(vi.fn());
vi.clearAllMocks();
vi.spyOn(window, 'WebSocket').mockImplementation(function (this: WebSocket, url: string | URL) {
const ws = createWsMock();
ws.url = String(url);
currentMockWs = ws;
wsConstructCount++;
const proto = {
get onopen() { return ws.onopen; },
set onopen(fn) { ws.onopen = fn as () => void; },
get onmessage() { return ws.onmessage; },
set onmessage(fn) { ws.onmessage = fn as (ev: { data: string }) => void; },
get onclose() { return ws.onclose; },
set onclose(fn) { ws.onclose = fn as (ev: { code?: number; reason?: string }) => void; },
get onerror() { return ws.onerror; },
set onerror(fn) { ws.onerror = fn as () => void; },
send: vi.fn((d: string) => ws.send(d)),
close: vi.fn(() => { ws.close(); ws.onclose?.({}); }),
readyState: 1, url: ws.url,
CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3,
};
return proto as unknown as WebSocket;
});
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
if (root) {
act(() => { root.unmount(); });
}
if (container.parentNode) {
container.parentNode.removeChild(container);
}
vi.restoreAllMocks();
currentMockWs = null;
});
async function renderHook(sessionId: string | undefined) {
const harness = createHarness();
await act(async () => {
root.render(
React.createElement(harness.Wrapper, {
sessionId: sessionId ?? (null as unknown as undefined),
}),
);
});
await act(async () => {});
return harness;
}
async function flush() {
await act(async () => {});
}
// ── Tests ────────────────────────────────────────────────────────────────────
describe('useSessionStream', () => {
describe('connection lifecycle', () => {
it('does not connect when sessionId is undefined', async () => {
const h = await renderHook(undefined);
const s = h.lastState();
expect(s).not.toBeNull();
expect(s!.connected).toBe(false);
expect(s!.messages).toEqual([]);
});
it('connects when sessionId is provided', async () => {
await renderHook(SESSION_ID);
expect(wsConstructCount).toBe(1);
});
it('sets connected=true on WebSocket open', async () => {
const h = await renderHook(SESSION_ID);
act(() => { triggerWsOpen(); });
await flush();
expect(h.lastState()!.connected).toBe(true);
expect(h.lastState()!.error).toBeNull();
});
it('sends reconnect frame with lastSeqPerChannel on open', async () => {
await renderHook(SESSION_ID);
act(() => { triggerWsOpen(); });
await flush();
const sends = getWsSendCalls();
const reconnectMsg = sends.find((s) => s.includes('reconnect'));
expect(reconnectMsg).toBeDefined();
if (reconnectMsg) {
const parsed = JSON.parse(reconnectMsg);
expect(parsed.type).toBe('reconnect');
expect(parsed.lastSeqPerChannel).toEqual({});
}
});
});
describe('non-channel frames', () => {
it('processes snapshot frame', async () => {
await renderHook(SESSION_ID);
act(() => { triggerWsOpen(); });
await flush();
const msg: Message = {
id: MSG_ID, session_id: SESSION_ID, chat_id: CHAT_ID, role: 'user', content: 'hi',
kind: 'message', tool_calls: null, tool_results: null, status: 'complete',
last_seq: 0, tokens_used: null, ctx_used: null, ctx_max: null,
cache_tokens: null, reasoning_tokens: null, model: null,
started_at: null, finished_at: null, created_at: '2026-01-01T00:00:00Z', metadata: null,
};
act(() => { triggerWsMessage({ type: 'snapshot', messages: [msg] } as WsFrame); });
await flush();
});
it('processes delta frame for existing message', async () => {
await renderHook(SESSION_ID);
act(() => { triggerWsOpen(); });
await flush();
const streamingMsg: Message = {
id: MSG_ID, session_id: SESSION_ID, chat_id: CHAT_ID, role: 'assistant', content: '',
kind: 'message', tool_calls: null, tool_results: null, status: 'streaming',
last_seq: 0, tokens_used: null, ctx_used: null, ctx_max: null,
cache_tokens: null, reasoning_tokens: null, model: null,
started_at: null, finished_at: null, created_at: '2026-01-01T00:00:00Z', metadata: null,
};
act(() => { triggerWsMessage({ type: 'snapshot', messages: [streamingMsg] } as WsFrame); });
await flush();
act(() => { triggerWsMessage({ type: 'delta', message_id: MSG_ID, content: 'Hello!' } as WsFrame); });
await flush();
});
it('emits git_diff_refresh on message_complete', async () => {
await renderHook(SESSION_ID);
act(() => { triggerWsOpen(); });
await flush();
act(() => {
triggerWsMessage({
type: 'message_started', message_id: MSG_ID, chat_id: CHAT_ID, role: 'assistant',
} as WsFrame);
});
await flush();
act(() => {
triggerWsMessage({
type: 'message_complete', message_id: MSG_ID, chat_id: CHAT_ID,
} as WsFrame);
});
await flush();
expect(mockEmit).toHaveBeenCalledWith(
expect.objectContaining({ type: 'git_diff_refresh' }),
);
});
it('processes messages_deleted frame', async () => {
await renderHook(SESSION_ID);
act(() => { triggerWsOpen(); });
await flush();
act(() => {
triggerWsMessage({
type: 'messages_deleted', message_ids: [MSG_ID], chat_id: CHAT_ID,
} as WsFrame);
});
await flush();
});
it('processes compacted frame and refetches messages', async () => {
await renderHook(SESSION_ID);
act(() => { triggerWsOpen(); });
await flush();
act(() => {
triggerWsMessage({
type: 'compacted', session_id: SESSION_ID, chat_id: CHAT_ID, summary_message_id: MSG_ID,
} as WsFrame);
});
await flush();
expect(mockMessagesList).toHaveBeenCalledWith(SESSION_ID);
});
it('processes usage frame and calls recordUsage', async () => {
await renderHook(SESSION_ID);
act(() => { triggerWsOpen(); });
await flush();
act(() => {
triggerWsMessage({
type: 'usage', message_id: MSG_ID, chat_id: CHAT_ID,
completion_tokens: 100, ctx_used: 5000, ctx_max: 32768,
} as WsFrame);
});
await flush();
expect(mockRecordUsage).toHaveBeenCalledWith(CHAT_ID, {
completion_tokens: 100, ctx_used: 5000, ctx_max: 32768,
});
});
it('emits chat_updated on chat_renamed frame', async () => {
await renderHook(SESSION_ID);
act(() => { triggerWsOpen(); });
await flush();
act(() => {
triggerWsMessage({
type: 'chat_renamed', chat_id: CHAT_ID, name: 'New Chat Name',
} as WsFrame);
});
await flush();
expect(mockEmit).toHaveBeenCalledWith(
expect.objectContaining({ type: 'chat_updated', chat_id: CHAT_ID, name: 'New Chat Name' }),
);
});
it('handles pass-through frames: agent_snapshot, agent_status_updated, flow_run*, battle*', async () => {
await renderHook(SESSION_ID);
act(() => { triggerWsOpen(); });
await flush();
act(() => {
triggerWsMessage({ type: 'agent_snapshot', chat_id: CHAT_ID, model: 'qwen', turn_number: 3 } as WsFrame);
triggerWsMessage({
type: 'agent_status_updated', chat_id: CHAT_ID, agent: 'coder', status: 'working',
at: '2026-01-01T00:00:00Z',
} as WsFrame);
triggerWsMessage({
type: 'flow_run_started', run_id: MSG_ID, flow_name: 'test', band: 'small', steps: [],
} as unknown as WsFrame);
triggerWsMessage({
type: 'flow_run_step_updated', run_id: MSG_ID, step_id: 's1', status: 'completed',
} as unknown as WsFrame);
triggerWsMessage({
type: 'battle_started', battle_id: MSG_ID, battle_type: 'coding', prompt: 'test', contestants: [],
} as unknown as WsFrame);
triggerWsMessage({
type: 'contestant_updated', battle_id: MSG_ID, contestant_id: CHAT_ID,
} as unknown as WsFrame);
triggerWsMessage({ type: 'battle_updated', battle_id: MSG_ID } as unknown as WsFrame);
});
await flush();
});
});
describe('channel_delta frames', () => {
async function setup() {
const h = await renderHook(SESSION_ID);
act(() => { triggerWsOpen(); });
await flush();
act(() => {
triggerWsMessage({
type: 'message_started', message_id: MSG_ID, chat_id: CHAT_ID, role: 'assistant',
} as WsFrame);
});
await flush();
return h;
}
it('processes text channel delta', async () => {
await setup();
act(() => { triggerWsMessage(textDelta(0, 'Hello ')); });
await flush();
act(() => { triggerWsMessage(textDelta(1, 'World!')); });
await flush();
});
it('processes out-of-order deltas (seq=1 before seq=0)', async () => {
await setup();
act(() => { triggerWsMessage(textDelta(1, 'World!')); });
await flush();
act(() => { triggerWsMessage(textDelta(0, 'Hello ')); });
await flush();
});
it('ignores duplicate seq', async () => {
await setup();
act(() => { triggerWsMessage(textDelta(0, 'Hello ')); });
await flush();
act(() => { triggerWsMessage(textDelta(0, 'Hello ')); });
await flush();
});
it('fills gap and flushes in order (seq=0, seq=2, then seq=1)', async () => {
await setup();
act(() => { triggerWsMessage(textDelta(0, 'First ')); });
await flush();
act(() => { triggerWsMessage(textDelta(2, 'Third ')); });
await flush();
act(() => { triggerWsMessage(textDelta(1, 'Second ')); });
await flush();
});
it('processes tool_call channel delta', async () => {
await setup();
act(() => {
triggerWsMessage(toolCallDelta(0, { id: 'call_1', name: 'read', args: { path: '/' } }));
});
await flush();
});
it('processes tool_result channel delta', async () => {
await renderHook(SESSION_ID);
act(() => { triggerWsOpen(); });
await flush();
act(() => { triggerWsMessage(toolResultDelta(0, 'call_1', { data: 'file content' })); });
await flush();
});
it('processes error channel delta', async () => {
await renderHook(SESSION_ID);
act(() => { triggerWsOpen(); });
await flush();
act(() => { triggerWsMessage(errorDelta(0, 'Something went wrong')); });
await flush();
});
it('processes status delta: running creates message, complete terminates it', async () => {
await renderHook(SESSION_ID);
act(() => { triggerWsOpen(); });
await flush();
act(() => { triggerWsMessage(statusDelta(0, 'running')); });
await flush();
act(() => {
triggerWsMessage(statusDelta(1, 'complete', { tokens_used: 42, ctx_used: 1000 }));
});
await flush();
});
it('handles multi-channel interleaved deltas with independent seq', async () => {
await renderHook(SESSION_ID);
act(() => { triggerWsOpen(); });
await flush();
const tId1 = '00000000-0000-0000-0000-000000000010';
const tId2 = '00000000-0000-0000-0000-000000000011';
act(() => {
triggerWsMessage({ type: 'message_started', message_id: tId1, chat_id: CHAT_ID, role: 'assistant' } as WsFrame);
triggerWsMessage({ type: 'message_started', message_id: tId2, chat_id: CHAT_ID, role: 'assistant' } as WsFrame);
});
await flush();
act(() => {
triggerWsMessage({ type: 'channel_delta', seq: 0, channel: 'text', message_id: tId1, chat_id: CHAT_ID, content: 'A' } as unknown as WsFrame);
triggerWsMessage({ type: 'channel_delta', seq: 1, channel: 'text', message_id: tId1, chat_id: CHAT_ID, content: 'B' } as unknown as WsFrame);
triggerWsMessage({ type: 'channel_delta', seq: 0, channel: 'tool_call', message_id: tId2, chat_id: CHAT_ID, tool_call: { id: 'c1', name: 'ls', args: {} } } as unknown as WsFrame);
});
await flush();
});
});
describe('status delta with metadata', () => {
it('applies tokens_used, ctx_used, model to message via status delta', async () => {
await renderHook(SESSION_ID);
act(() => { triggerWsOpen(); });
await flush();
act(() => { triggerWsMessage(statusDelta(0, 'running')); });
await flush();
act(() => {
triggerWsMessage(
statusDelta(1, 'running', {
tokens_used: 150, ctx_used: 5000, ctx_max: 32768, model: 'qwen-2.5-32b',
}),
);
});
await flush();
});
});
describe('reconnection behavior', () => {
it('reconnects on WebSocket close with backoff', async () => {
vi.useFakeTimers();
await renderHook(SESSION_ID);
act(() => { triggerWsOpen(); });
await flush();
const initialCount = wsConstructCount;
act(() => { triggerWsClose(); });
// Before 1000ms, no reconnect yet
await vi.advanceTimersByTimeAsync(500);
expect(wsConstructCount).toBe(initialCount);
// After 1000ms from close, reconnect fires
await vi.advanceTimersByTimeAsync(600);
expect(wsConstructCount).toBe(initialCount + 1);
vi.useRealTimers();
});
it('sends lastSeqPerChannel on reconnect', async () => {
vi.useFakeTimers();
await renderHook(SESSION_ID);
act(() => { triggerWsOpen(); });
await flush();
act(() => { triggerWsMessage(statusDelta(0, 'running')); });
await flush();
act(() => { triggerWsMessage(statusDelta(1, 'complete')); });
await flush();
act(() => { triggerWsClose(); });
await vi.advanceTimersByTimeAsync(1100);
vi.clearAllMocks();
act(() => { triggerWsOpen(); });
await flush();
const sends = getWsSendCalls();
const reconnectMsg = sends.find((s) => s.includes('reconnect'));
expect(reconnectMsg).toBeDefined();
if (reconnectMsg) {
const parsed = JSON.parse(reconnectMsg);
expect(parsed.type).toBe('reconnect');
expect(parsed.lastSeqPerChannel).toBeDefined();
expect(parsed.lastSeqPerChannel.status).toBe(2);
}
vi.useRealTimers();
});
});
describe('channel stall detection', () => {
it('emits refetch_messages when channel stalls for 5s', async () => {
vi.useFakeTimers();
await renderHook(SESSION_ID);
act(() => { triggerWsOpen(); });
await flush();
act(() => { triggerWsMessage(statusDelta(1, 'complete')); });
await flush();
await vi.advanceTimersByTimeAsync(6000);
const refetchCalls = mockEmit.mock.calls.filter(
(c: unknown[]) => (c[0] as { type: string }).type === 'refetch_messages',
);
expect(refetchCalls.length).toBeGreaterThanOrEqual(1);
vi.useRealTimers();
});
it('does not stall when buffer is empty', async () => {
vi.useFakeTimers();
await renderHook(SESSION_ID);
act(() => { triggerWsOpen(); });
await flush();
await vi.advanceTimersByTimeAsync(10000);
const refetchCalls = mockEmit.mock.calls.filter(
(c: unknown[]) => (c[0] as { type: string }).type === 'refetch_messages',
);
expect(refetchCalls.length).toBe(0);
vi.useRealTimers();
});
});
describe('sessionEvents subscription', () => {
it('calls api.messages.list when refetch_messages event fires', async () => {
await renderHook(SESSION_ID);
act(() => { triggerWsOpen(); });
await flush();
expect(mockSubscribe).toHaveBeenCalled();
const fn = mockSubscribe.mock.calls[0]?.[0] as (e: { type: string }) => void;
fn({ type: 'refetch_messages' });
await flush();
expect(mockMessagesList).toHaveBeenCalledWith(SESSION_ID);
});
});
describe('invalid frames', () => {
it('ignores bad JSON', async () => {
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
await renderHook(SESSION_ID);
act(() => { triggerWsOpen(); });
await flush();
currentMockWs?.onmessage?.({ data: 'not-json-at-all' });
await flush();
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
it('ignores schema-invalid frames', async () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
await renderHook(SESSION_ID);
act(() => { triggerWsOpen(); });
await flush();
currentMockWs?.onmessage?.({ data: JSON.stringify({ type: 'unknown_type' }) });
await flush();
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
});
// Note: git_diff_refresh on status delta 'complete' is not testable through
// the hook because the ChannelDeltaFrame Zod schema strips the `status` field
// (it's not in the schema's field list — only StatusChannelPayload has it).
// The message_complete → git_diff_refresh path is tested above and passes.
// The status delta path requires a schema fix in @boocode/contracts/ws-frames.
});

View File

@@ -0,0 +1,11 @@
import { useEffect, useState } from 'react';
import { terminalsRegistry, type TerminalRegistration } from '@/lib/events';
// v1.10 booterm: tiny subscription hook for the mounted-terminals registry.
// Used by the right-click "Send to terminal" submenu so it always reflects
// currently-open terminal panes without prop drilling from Workspace.
export function useTerminals(): TerminalRegistration[] {
const [list, setList] = useState(() => terminalsRegistry.list());
useEffect(() => terminalsRegistry.subscribe(() => setList(terminalsRegistry.list())), []);
return list;
}

View File

@@ -0,0 +1,43 @@
export interface ShortcutGroup {
category: string;
shortcuts: { keys: string[]; description: string }[];
}
export const KEYBOARD_SHORTCUTS: ShortcutGroup[] = [
{
category: 'Navigation',
shortcuts: [
{ keys: ['Ctrl', '`'], description: 'Jump to terminal pane' },
{ keys: ['Ctrl', 'T'], description: 'New terminal pane' },
{ keys: ['Ctrl', 'C'], description: 'New chat pane' },
{ keys: ['Ctrl', 'W'], description: 'Close active pane' },
{ keys: ['Tab'], description: 'Next pane' },
{ keys: ['Shift', 'Tab'], description: 'Previous pane' },
{ keys: ['Ctrl', '1-9'], description: 'Jump to pane by number' },
],
},
{
category: 'Chat',
shortcuts: [
{ keys: ['Enter'], description: 'Send message' },
{ keys: ['Shift', 'Enter'], description: 'New line' },
{ keys: ['@'], description: 'Mention file' },
{ keys: ['/'], description: 'Slash commands' },
],
},
{
category: 'Terminal',
shortcuts: [
{ keys: ['Ctrl', 'Shift', 'C'], description: 'Copy from terminal' },
{ keys: ['Ctrl', 'F'], description: 'Search terminal' },
{ keys: ['Esc'], description: 'Close terminal search' },
],
},
{
category: 'General',
shortcuts: [
{ keys: ['Ctrl', '/'], description: 'Toggle this shortcuts panel' },
{ keys: ['?'], description: 'Toggle this shortcuts panel' },
],
},
];

View File

@@ -0,0 +1,49 @@
// Set of built-in tool names (from formatToolArgs in ToolCallLine.tsx).
// Any tool not in this set that has a `server_tool` name pattern is an MCP tool.
export const BUILT_IN_TOOLS = new Set([
'view_file',
'list_dir',
'grep',
'find_files',
'git_status',
'skill_use',
'get_codebase_overview',
'get_file_analysis',
'get_symbol_info',
'search_symbols',
'get_dependencies',
'watch_changes',
'get_semantic_neighborhoods',
'get_framework_analysis',
]);
/**
* Returns true if the tool name follows the `<server>_<tool>` MCP pattern.
* Built-in tools (view_file, grep, skill_use, etc.) are excluded even if
* they happen to contain an underscore (e.g. 'git_status', 'skill_use').
*/
export function isMcpTool(name: string): boolean {
return name.includes('_') && !BUILT_IN_TOOLS.has(name);
}
/**
* Extracts the MCP server name from a tool call name.
* For 'context7_searchWeb' returns 'context7'.
* Returns null for native tools.
*/
export function extractServerName(name: string): string | null {
const idx = name.indexOf('_');
if (idx === -1) return null;
return name.slice(0, idx);
}
/**
* Extracts the tool name (without server prefix) from an MCP tool call name.
* For 'context7_searchWeb' returns 'searchWeb'.
* Returns null for native tools.
*/
export function extractToolName(name: string): string | null {
const idx = name.indexOf('_');
if (idx === -1) return null;
return name.slice(idx + 1);
}

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { ChevronDown, ChevronRight, Folder, FolderTree, Menu, RotateCcw } from 'lucide-react'; import { ChevronDown, ChevronRight, Folder, FolderTree, Menu, RotateCcw, Terminal } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { EmptyState } from '@/components/EmptyState';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import mascot from '@/assets/brand/banner-mascot.png'; import mascot from '@/assets/brand/banner-mascot.png';
import wordmark from '@/assets/brand/banner-wordmark.png'; import wordmark from '@/assets/brand/banner-wordmark.png';
@@ -111,12 +112,12 @@ export function Home() {
<div className="w-full max-w-md space-y-6"> <div className="w-full max-w-md space-y-6">
<div className="text-center space-y-3"> <div className="text-center space-y-3">
{empty ? ( {empty ? (
<> <EmptyState
<h1 className="text-2xl font-semibold tracking-tight">No projects yet</h1> icon={<Terminal size={40} strokeWidth={1.5} />}
<p className="text-sm text-muted-foreground"> title="No projects yet"
Add a project from /opt or create a new one. description="Create a project to get started with BooCode"
</p> action={{ label: "Create Project", onClick: () => setCreateOpen(true) }}
</> />
) : ( ) : (
<> <>
<div className="flex flex-col items-center gap-4 pb-1"> <div className="flex flex-col items-center gap-4 pb-1">

View File

@@ -0,0 +1,427 @@
import { useEffect, useMemo, useState } from 'react';
import { ArrowLeft, BrainCircuit, CalendarDays, CloudMoon } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { api } from '@/api/client';
import type { MemoryEntry, DailyMemoryEntry, DreamEntry } from '@/api/types';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { useSidebar } from '@/hooks/useSidebar';
import { cn } from '@/lib/utils';
// ─── Independent section data fetcher (same pattern as Analytics.tsx) ────────
function useFetch<T>(fetcher: () => Promise<T>): {
data: T | null;
loading: boolean;
error: string | null;
retry: () => void;
} {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
function load() {
setLoading(true);
setError(null);
fetcher()
.then(setData)
.catch((err: unknown) => {
setError(err instanceof Error ? err.message : 'failed to load data');
})
.finally(() => setLoading(false));
}
useEffect(() => { load(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
return { data, loading, error, retry: load };
}
// ─── Skeleton pulse placeholder ─────────────────────────────────────────────
function SkeletonBar({ className }: { className?: string }) {
return <div className={cn('animate-pulse rounded bg-muted/40', className)} />;
}
// ─── Formatters ─────────────────────────────────────────────────────────────
function formatDate(iso: string | null | undefined): string {
if (!iso) return '—';
return new Date(iso).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function formatDateShort(iso: string | null | undefined): string {
if (!iso) return '—';
return new Date(iso).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
function truncate(str: string, max: number): string {
if (str.length <= max) return str;
return str.slice(0, max) + '…';
}
function relTime(iso: string | null | undefined): string {
if (!iso) return '—';
const diff = Date.now() - new Date(iso).getTime();
const seconds = Math.floor(diff / 1000);
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
return formatDate(iso);
}
// ─── Empty state ────────────────────────────────────────────────────────────
function EmptyState({ message }: { message: string }) {
return <p className="text-sm text-muted-foreground py-8 text-center">{message}</p>;
}
// ─── Tab bar (same pattern as Results.tsx) ──────────────────────────────────
type TabId = 'all' | 'daily' | 'dreams';
function TabBar({ active, onChange }: { active: TabId; onChange: (t: TabId) => void }) {
return (
<div className="flex gap-1 border-b pb-px">
{[
{ id: 'all' as TabId, label: 'All Memory', icon: BrainCircuit },
{ id: 'daily' as TabId, label: 'Daily Log', icon: CalendarDays },
{ id: 'dreams' as TabId, label: 'Dreams', icon: CloudMoon },
].map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => onChange(tab.id)}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-t-md border border-b-0 -mb-px transition-colors',
active === tab.id
? 'bg-background border-border text-foreground'
: 'border-transparent text-muted-foreground hover:text-foreground hover:bg-muted/30',
)}
>
<tab.icon className="size-3.5" />
<span>{tab.label}</span>
</button>
))}
</div>
);
}
// ─── All Memory Tab ─────────────────────────────────────────────────────────
function AllMemoryTab({ projectId }: { projectId: string }) {
const { data, loading, error, retry } = useFetch(() => api.memory.list(projectId).then((r) => r.entries));
const [expanded, setExpanded] = useState<string | null>(null);
if (loading) {
return (
<div className="pt-4 space-y-3">
{[0, 1, 2].map((i) => (
<Card key={i} size="sm">
<CardContent className="pt-4 space-y-2">
<SkeletonBar className="h-4 w-16" />
<SkeletonBar className="h-5 w-3/4" />
<SkeletonBar className="h-3 w-full" />
<div className="flex gap-1.5">
<SkeletonBar className="h-4 w-12" />
<SkeletonBar className="h-4 w-16" />
</div>
</CardContent>
</Card>
))}
</div>
);
}
if (error) {
return (
<div className="flex items-center gap-3 text-sm pt-4">
<span className="text-destructive">{error}</span>
<Button size="sm" variant="outline" onClick={retry}>
Retry
</Button>
</div>
);
}
if (!data || data.length === 0) {
return <EmptyState message="No topic-based memory entries yet." />;
}
return (
<div className="pt-4 space-y-3">
{data.map((entry: MemoryEntry) => (
<Card key={entry.id}>
<CardContent className="pt-4">
<button
type="button"
onClick={() => setExpanded(expanded === entry.id ? null : entry.id)}
className="w-full text-left"
>
{/* Topic badge */}
<span className="inline-block text-[10px] uppercase tracking-wider font-medium text-muted-foreground bg-muted/50 px-1.5 py-0.5 rounded mb-1.5">
{entry.topic}
</span>
<h3 className="text-sm font-medium">{entry.title}</h3>
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
{truncate(entry.content, 200)}
</p>
{entry.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{entry.tags.map((tag) => (
<span
key={tag}
className="text-[10px] bg-secondary/50 text-secondary-foreground px-1.5 py-0.5 rounded"
>
{tag}
</span>
))}
</div>
)}
</button>
{expanded === entry.id && (
<div className="mt-3 pt-3 border-t text-xs text-foreground/80 leading-relaxed whitespace-pre-wrap">
{entry.content}
</div>
)}
</CardContent>
</Card>
))}
</div>
);
}
// ─── Daily Log Tab ──────────────────────────────────────────────────────────
function DailyLogTab({ projectId }: { projectId: string }) {
const { data, loading, error, retry } = useFetch(() => api.memory.daily(projectId).then((r) => r.entries));
const [expanded, setExpanded] = useState<string | null>(null);
const grouped = useMemo(() => {
if (!data) return [];
const groups: Record<string, DailyMemoryEntry[]> = {};
for (const entry of data) {
const g = groups[entry.date];
if (g) {
g.push(entry);
} else {
groups[entry.date] = [entry];
}
}
return Object.entries(groups).sort((a, b) => b[0].localeCompare(a[0]));
}, [data]);
if (loading) {
return (
<div className="pt-4 space-y-4">
{[0, 1].map((day) => (
<div key={day}>
<SkeletonBar className="h-4 w-24 mb-2" />
<div className="space-y-2">
{[0, 1].map((e) => (
<Card key={e} size="sm">
<CardContent className="pt-3 space-y-1">
<SkeletonBar className="h-3 w-20" />
<SkeletonBar className="h-3 w-full" />
</CardContent>
</Card>
))}
</div>
</div>
))}
</div>
);
}
if (error) {
return (
<div className="flex items-center gap-3 text-sm pt-4">
<span className="text-destructive">{error}</span>
<Button size="sm" variant="outline" onClick={retry}>
Retry
</Button>
</div>
);
}
if (!data || data.length === 0) {
return <EmptyState message="No daily log entries for the last 7 days." />;
}
return (
<div className="pt-4 space-y-4">
{grouped.map(([date, entries]) => (
<div key={date}>
<h3 className="text-sm font-medium text-muted-foreground mb-2">
{formatDateShort(date)}
</h3>
<div className="space-y-2">
{entries.map((entry: DailyMemoryEntry) => (
<Card key={entry.id} size="sm">
<CardContent className="pt-3">
<button
type="button"
onClick={() => setExpanded(expanded === entry.id ? null : entry.id)}
className="w-full text-left"
>
<span className="text-xs font-mono text-muted-foreground">
{entry.title}
</span>
<p className="text-xs text-muted-foreground mt-1 line-clamp-1">
{truncate(entry.content, 150)}
</p>
</button>
{expanded === entry.id && (
<div className="mt-2 pt-2 border-t text-xs text-foreground/80 leading-relaxed whitespace-pre-wrap">
{entry.content}
</div>
)}
</CardContent>
</Card>
))}
</div>
</div>
))}
</div>
);
}
// ─── Dreams Tab ─────────────────────────────────────────────────────────────
function DreamsTab({ projectId }: { projectId: string }) {
const { data, loading, error, retry } = useFetch(() => api.memory.dreams(projectId).then((r) => r.entries));
if (loading) {
return (
<div className="pt-4 space-y-3">
{[0, 1, 2].map((i) => (
<Card key={i}>
<CardContent className="pt-4 space-y-2">
<SkeletonBar className="h-4 w-32" />
<SkeletonBar className="h-3 w-full" />
<SkeletonBar className="h-3 w-5/6" />
<SkeletonBar className="h-3 w-2/3" />
</CardContent>
</Card>
))}
</div>
);
}
if (error) {
return (
<div className="flex items-center gap-3 text-sm pt-4">
<span className="text-destructive">{error}</span>
<Button size="sm" variant="outline" onClick={retry}>
Retry
</Button>
</div>
);
}
if (!data || data.length === 0) {
return <EmptyState message="No dream consolidation diaries yet." />;
}
return (
<div className="pt-4 space-y-3">
{data.map((entry: DreamEntry, i: number) => (
<Card key={i}>
<CardContent className="pt-4">
<h3 className="text-sm font-medium mb-2">{formatDateShort(entry.date)}</h3>
<pre className="text-xs font-mono leading-relaxed whitespace-pre-wrap bg-muted/20 p-3 rounded-md border border-border/50 overflow-x-auto">
{entry.content}
</pre>
</CardContent>
</Card>
))}
</div>
);
}
// ─── Main Page ──────────────────────────────────────────────────────────────
export function Memory() {
const navigate = useNavigate();
const { data: sidebar, activeSession } = useSidebar();
const [tab, setTab] = useState<TabId>('all');
const [projectId, setProjectId] = useState<string | null>(null);
// Derive default project from active session or first project.
const projects = useMemo(() => {
return sidebar?.projects?.map((p: { id: string; name: string }) => p) ?? [];
}, [sidebar]);
useEffect(() => {
if (!projectId && projects.length > 0) {
// Prefer active session's project, else first project.
const defaultId = activeSession?.project_id ?? projects[0]!.id;
setProjectId(defaultId);
}
}, [projects, activeSession, projectId]);
function handleBack() {
if (window.history.length > 1) {
navigate(-1);
} else {
navigate('/');
}
}
return (
<div className="flex-1 overflow-y-auto">
<div className="max-w-[1000px] mx-auto w-full px-6 py-6 space-y-6">
{/* Header */}
<header className="space-y-2">
<button
type="button"
onClick={handleBack}
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground -ml-1 px-1 py-0.5 rounded"
aria-label="Back"
>
<ArrowLeft className="size-4" />
<span>Back</span>
</button>
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="text-xl font-semibold flex items-center gap-2">
<BrainCircuit className="size-5" />
Memory Browser
</h1>
<p className="text-sm text-muted-foreground mt-1">
Topic-based memories, daily logs, and dream consolidation diaries.
</p>
</div>
</div>
</header>
{/* Tab bar */}
<TabBar active={tab} onChange={setTab} />
{/* Tab content */}
{!projectId ? (
<EmptyState message="Select a project to view memory." />
) : tab === 'all' ? (
<AllMemoryTab projectId={projectId} />
) : tab === 'daily' ? (
<DailyLogTab projectId={projectId} />
) : (
<DreamsTab projectId={projectId} />
)}
</div>
</div>
);
}

View File

@@ -1,9 +1,10 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom'; import { Link, useNavigate, useParams } from 'react-router-dom';
import { Plus, MessageSquare, Trash2, ChevronDown, ChevronRight, RotateCcw, Menu, Code } from 'lucide-react'; import { Inbox, Plus, MessageSquare, Trash2, ChevronDown, ChevronRight, RotateCcw, Menu, Code } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api } from '@/api/client'; import { api } from '@/api/client';
import type { Project as ProjectType, Session } from '@/api/types'; import type { Project as ProjectType, Session } from '@/api/types';
import { EmptyState } from '@/components/EmptyState';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { sessionEvents } from '@/hooks/sessionEvents'; import { sessionEvents } from '@/hooks/sessionEvents';
import { useSessions } from '@/hooks/useSessions'; import { useSessions } from '@/hooks/useSessions';
@@ -116,9 +117,11 @@ export function Project() {
<div className="text-sm text-muted-foreground">Loading</div> <div className="text-sm text-muted-foreground">Loading</div>
)} )}
{sessions && sessions.length === 0 && ( {sessions && sessions.length === 0 && (
<div className="text-sm text-muted-foreground"> <EmptyState
No sessions yet. Click <span className="font-medium">New session</span> to start. icon={<Inbox size={40} strokeWidth={1.5} />}
</div> title="No sessions"
description="Start a new chat session to begin working"
/>
)} )}
{sessions && sessions.length > 0 && ( {sessions && sessions.length > 0 && (
<ul className="divide-y rounded-md border"> <ul className="divide-y rounded-md border">

17
apps/web/vitest.config.ts Normal file
View File

@@ -0,0 +1,17 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'node:path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
test: {
environment: 'jsdom',
globals: false,
include: ['src/**/*.test.{ts,tsx}'],
},
});