diff --git a/apps/web/CLAUDE.md b/apps/web/CLAUDE.md index a65572e..c371f80 100644 --- a/apps/web/CLAUDE.md +++ b/apps/web/CLAUDE.md @@ -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/`. diff --git a/apps/web/package.json b/apps/web/package.json index 2e467a1..c6b3b6d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -20,12 +20,14 @@ "@xterm/xterm": "5.5.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "framer-motion": "^12.40.0", "lucide-react": "^1.16.0", "radix-ui": "^1.4.3", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^10.1.0", "react-router-dom": "^6.26.0", + "react-virtuoso": "^4.18.7", "remark-gfm": "^4.0.1", "shiki": "^1.29.2", "sonner": "^2.0.7", @@ -39,10 +41,12 @@ "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", + "jsdom": "^29.1.1", "shadcn": "^4.7.0", "tailwindcss": "^4.3.0", "typescript": "^5.5.0", - "vite": "^5.3.4" + "vite": "^5.3.4", + "vitest": "^3.2.4" }, "license": "MIT" } diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 871673d..806a3ef 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -275,7 +275,7 @@ export const api = { method: 'POST', body: JSON.stringify(body ?? {}), }), - update: (chatId: string, body: { name: string }) => + update: (chatId: string, body: { name?: string; model?: string }) => request(`/api/chats/${chatId}`, { method: 'PATCH', body: JSON.stringify(body), @@ -331,6 +331,17 @@ export const api = { method: 'POST', 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 // 'allow' the server re-resolves the grant root and appends it to // sessions.allowed_read_paths; the returned list reflects the post- @@ -348,6 +359,14 @@ export const api = { request( `/api/chats/${chatId}/traces?limit=${limit}&offset=${offset}`, ), + exportChat: (chatId: string, format: 'json' | 'markdown') => + request(`/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: { @@ -388,6 +407,11 @@ export const api = { request<{ html_content: string; char_count: number; title: string }>( `/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('/api/models'), @@ -654,17 +678,27 @@ export const api = { // 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 // born with the correct PTY dimensions instead of tmux's 80x24 default. - start: (sessionId: string, paneId: string, cols?: number, rows?: number) => - request<{ tmux_session: string }>( + start: ( + sessionId: string, + paneId: string, + cols?: number, + rows?: number, + description?: string, + parentAgent?: string, + ) => { + const body: Record = {}; + 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`, { method: 'POST', - body: - cols !== undefined && rows !== undefined - ? JSON.stringify({ cols, rows }) - : undefined, + body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined, }, - ), + ); + }, kill: (sessionId: string, paneId: string) => request<{ ok: true }>( `/api/term/sessions/${sessionId}/panes/${paneId}/kill`, diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index 5471936..ebce2bd 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -623,6 +623,14 @@ export type WsFrame = run_status?: 'running' | 'completed' | 'failed' | 'cancelled'; 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 | { type: 'tool_trace_start'; diff --git a/apps/web/src/components/AgentPicker.tsx b/apps/web/src/components/AgentPicker.tsx index 8daf08f..bdd0632 100644 --- a/apps/web/src/components/AgentPicker.tsx +++ b/apps/web/src/components/AgentPicker.tsx @@ -90,7 +90,7 @@ export function AgentPicker({ projectId, value, onChange }: Props) { @@ -707,7 +707,7 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session aria-expanded={cmdMenuOpen} aria-label="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]" > {slashItems.length} @@ -720,7 +720,7 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session onClick={() => sessionEvents.emit({ type: 'open_flow_launcher', project_id: projectId })} aria-label="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]" > Flows @@ -739,7 +739,7 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session }} aria-pressed={webSearchEnabled === true} 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 ? 'border-primary/40 bg-primary/10 text-primary' : 'border-border text-muted-foreground hover:bg-muted hover:text-foreground' diff --git a/apps/web/src/components/CodeBlock.tsx b/apps/web/src/components/CodeBlock.tsx index 21d0078..c188191 100644 --- a/apps/web/src/components/CodeBlock.tsx +++ b/apps/web/src/components/CodeBlock.tsx @@ -1,5 +1,5 @@ -import { useEffect, useRef, useState } from 'react'; -import { Check, Copy } from 'lucide-react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Check, Copy, Moon, Sun, WrapText } from 'lucide-react'; import { codeToHtml } from 'shiki'; // NOTE: spec calls for syntax-highlighted code blocks. Added Shiki in v1.1. @@ -45,35 +45,111 @@ const LANG_MAP: Record = { 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(); +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) { const [copied, setCopied] = useState(false); const [html, setHtml] = useState(null); - const highlightRef = useRef(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(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(() => { let cancelled = false; - const mappedLang = (lang && LANG_MAP[lang.toLowerCase()]) ?? null; + if (!mappedLang) { setHtml(null); return; } + + const cacheKey = `${cleanCode}|${theme}|${mappedLang}`; + const cached = cacheGet(cacheKey); + if (cached !== undefined) { + setHtml(cached); + return; + } + (async () => { 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); } catch (err) { - console.warn('shiki failed', err); + console.warn('shiki highlight failed:', err); if (!cancelled) setHtml(null); } })(); + return () => { 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(() => { if (highlightRef.current) { // Shiki generates sanitized HTML spans — not user-supplied content. @@ -82,39 +158,138 @@ export function CodeBlock({ code, lang }: Props) { } }, [html]); - async function copy() { + // Sync word-wrap state to the injected
 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 {
-      await navigator.clipboard.writeText(code);
+      await navigator.clipboard.writeText(cleanCode);
       setCopied(true);
       setTimeout(() => setCopied(false), 1200);
     } catch {
       /* 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 (
     
+ {/* ── Toolbar ──────────────────────────────────────────── */}
- {lang || 'code'} + {actualLang || lang || 'code'} +
+ {/* Theme toggle — persists to localStorage key 'codeblock-theme' */} + + + {/* Word-wrap toggle */} + + + {/* Copy button — existing behavior (Check icon, 1200ms revert) */} + +
+
+ + {/* ── Code body (flex row: gutter + code) ──────────────── */} +
+ {/* Gutter — line numbers or diff markers */} + {showGutter && ( + + )} + + {/* Code area */} +
+ {html !== null ? ( +
+ ) : ( +
+              {collapsed ? codeLines.slice(0, 15).join('\n') : code}
+            
+ )} + + {/* Gradient fade overlay for collapsed state */} + {collapsed && ( +
+ )} +
+
+ + {/* "Show N more" button for collapsed state */} + {collapsed && ( -
- {html !== null ? ( -
- ) : ( -
-          {code}
-        
)}
); diff --git a/apps/web/src/components/ComparePane.tsx b/apps/web/src/components/ComparePane.tsx new file mode 100644 index 0000000..4f46dfe --- /dev/null +++ b/apps/web/src/components/ComparePane.tsx @@ -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(); + 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 ( +
+ {/* Header */} +
+ Compare Models + + {models.length} models + +
+ +
+
+ + {/* Grid of response panels */} +
+ {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 ( +
{ 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 */} +
+ {model} +
+ + {/* Empty / loading state */} + {resp.content.length === 0 && isStreaming && ( +
+ + Generating… +
+ )} + + {/* Error state */} + {isError && resp.content.length === 0 && ( +
+ Failed to generate +
+ )} + + {/* Content */} + {resp.content.length > 0 && ( +
+ +
+ )} + + {/* Streaming indicator at bottom */} + {isStreaming && resp.content.length > 0 && ( +
+ + Streaming… +
+ )} +
+ ); + })} +
+
+ ); +} diff --git a/apps/web/src/components/ContextMeter.tsx b/apps/web/src/components/ContextMeter.tsx index 49787cd..aa98818 100644 --- a/apps/web/src/components/ContextMeter.tsx +++ b/apps/web/src/components/ContextMeter.tsx @@ -118,7 +118,7 @@ export function ContextMeter({ messages, modelContextLimit, sessionCostUsd }: Pr cy={CENTER} r={RADIUS} fill="none" - className={cn('transition-all duration-300', progressClass)} + className={cn('transition-all duration-200 motion-reduce:transition-none', progressClass)} strokeWidth={STROKE} strokeLinecap="round" strokeDasharray={CIRCUMFERENCE} diff --git a/apps/web/src/components/EmptyState.tsx b/apps/web/src/components/EmptyState.tsx new file mode 100644 index 0000000..f6a2f04 --- /dev/null +++ b/apps/web/src/components/EmptyState.tsx @@ -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 ( +
+
+ {icon ?? } +
+

{title}

+ {description && ( +

{description}

+ )} + {action && ( + + )} +
+ ); +} diff --git a/apps/web/src/components/InferenceSettings.tsx b/apps/web/src/components/InferenceSettings.tsx index 860ff69..8e9feea 100644 --- a/apps/web/src/components/InferenceSettings.tsx +++ b/apps/web/src/components/InferenceSettings.tsx @@ -37,11 +37,11 @@ function Switch({ checked, onCheckedChange, id }: { role="switch" aria-checked={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' }`} > - diff --git a/apps/web/src/components/KeyboardShortcutsDialog.tsx b/apps/web/src/components/KeyboardShortcutsDialog.tsx new file mode 100644 index 0000000..582e42a --- /dev/null +++ b/apps/web/src/components/KeyboardShortcutsDialog.tsx @@ -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 ( + + + + Keyboard Shortcuts + +
+
+ {KEYBOARD_SHORTCUTS.map((group) => ( +
+

+ {group.category} +

+
+ {group.shortcuts.map((shortcut, i) => ( +
+ {shortcut.description} + + {shortcut.keys.map((key, ki) => ( + + {ki > 0 && ( + + + )} + + {key === 'Ctrl' ? (isMac ? '⌘' : 'Ctrl') : key} + + + ))} + +
+ ))} +
+
+ ))} +
+
+
+
+ ); +} + +export { KeyboardShortcutsDialog }; diff --git a/apps/web/src/components/MarkdownRenderer.tsx b/apps/web/src/components/MarkdownRenderer.tsx index f8716db..a6b7506 100644 --- a/apps/web/src/components/MarkdownRenderer.tsx +++ b/apps/web/src/components/MarkdownRenderer.tsx @@ -8,6 +8,7 @@ import Markdown from 'react-markdown'; import type { Components } from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { CodeBlock } from './CodeBlock'; +import { MessageBoundary } from './MessageBoundary'; import { linkifyPaths } from '@/lib/linkify-paths'; 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 isBlock = !!langMatch || text.includes('\n'); if (isBlock) { - return ; + return ( + {text}
}> + + + ); } return ( - {content} - + + + {content} + + ); }); diff --git a/apps/web/src/components/McpPermissionDialog.tsx b/apps/web/src/components/McpPermissionDialog.tsx new file mode 100644 index 0000000..adfdea0 --- /dev/null +++ b/apps/web/src/components/McpPermissionDialog.tsx @@ -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; + chatId: string; + open: boolean; + onClose: () => void; +} + +function parseServerName(toolName: string): string { + // Tool name format is _ + 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 { + 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(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 ( + { if (!open) onClose(); }}> + + + + + MCP Tool Approval + + +
+
+
+ Server:{' '} + {serverName} +
+
+ Tool:{' '} + {shortName} +
+
+ Args:{' '} + {argsSummary} +
+
+

+ This MCP server requires approval before running tools. Choose how to proceed: +

+
+
+ + + +
+
+
+ ); +} diff --git a/apps/web/src/components/McpResponseDisplay.tsx b/apps/web/src/components/McpResponseDisplay.tsx new file mode 100644 index 0000000..22ab3f6 --- /dev/null +++ b/apps/web/src/components/McpResponseDisplay.tsx @@ -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 → tag + * - Markdown image syntax `![alt](url)` → 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('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 ( +
+ {/* Server badge + tool name + permission dot */} +
+ {serverName && ( + + {serverName} + + )} + {toolDisplayName} + +
+ + {/* Display mode toggle */} +
+ {modes.map((m) => ( + + ))} +
+ + {/* Content */} + {mode === 'plain' && ( +
+          {toolResult.error ? (
+            {toolResult.error}
+          ) : (
+            linkifyPaths(output)
+          )}
+          {toolResult.truncated && (
+            
— output truncated —
+ )} +
+ )} + + {mode === 'markdown' && ( +
+ {toolResult.error ? ( + {toolResult.error} + ) : ( + + )} + {toolResult.truncated && ( +
— output truncated —
+ )} +
+ )} + + {mode === 'rich' && ( +
+ {toolResult.error ? ( + {toolResult.error} + ) : ( + parseRichContent(output).map((seg, i) => { + if (seg.type === 'image') { + return ( +
+ Tool result image +
+ ); + } + if (seg.type === 'link') { + return ( + + {seg.content} + + ); + } + return ( +

+ {linkifyPaths(seg.content)} +

+ ); + }) + )} + {toolResult.truncated && ( +
— output truncated —
+ )} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/MessageBoundary.tsx b/apps/web/src/components/MessageBoundary.tsx new file mode 100644 index 0000000..f8abb30 --- /dev/null +++ b/apps/web/src/components/MessageBoundary.tsx @@ -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 { + 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 ( +
+ + Rendering failed + +
+ ); + } + return this.props.children; + } +} diff --git a/apps/web/src/components/MessageBubble.tsx b/apps/web/src/components/MessageBubble.tsx index 7f50505..c85d3fa 100644 --- a/apps/web/src/components/MessageBubble.tsx +++ b/apps/web/src/components/MessageBubble.tsx @@ -299,7 +299,7 @@ function ActionRow({ return ( <> -
+
+
+ ); + } + return this.props.children; + } +} diff --git a/apps/web/src/components/ModelPicker.tsx b/apps/web/src/components/ModelPicker.tsx index 3116f71..4314911 100644 --- a/apps/web/src/components/ModelPicker.tsx +++ b/apps/web/src/components/ModelPicker.tsx @@ -13,7 +13,7 @@ import { useViewport } from '@/hooks/useViewport'; import { formatModelLabel } from '@/lib/model-label'; interface Props { - value: string; + value: string | null; onChange: (model: string) => void | Promise; } @@ -27,7 +27,7 @@ function ModelList({ }: { models: ModelInfo[] | null; error: string | null; - value: string; + value: string | null; onPick: (id: string) => void; }) { if (error) { @@ -82,8 +82,8 @@ export function ModelPicker({ value, onChange }: Props) { diff --git a/apps/web/src/components/NewPaneMenu.tsx b/apps/web/src/components/NewPaneMenu.tsx index bc7fa08..a4c5458 100644 --- a/apps/web/src/components/NewPaneMenu.tsx +++ b/apps/web/src/components/NewPaneMenu.tsx @@ -1,5 +1,5 @@ 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 type { FlowRunRow } from '@/api/types'; import { sessionEvents } from '@/hooks/sessionEvents'; @@ -90,6 +90,19 @@ export function NewPaneMenu({ onAddPane, disabled, projectId }: Props) { New Orchestrator )} + {projectId && ( + + sessionEvents.emit({ + type: 'open_arena_launcher', + project_id: projectId, + placement: 'new', + }) + } + > + New Arena + + )} {projectId && ( <> diff --git a/apps/web/src/components/ProjectSidebar.tsx b/apps/web/src/components/ProjectSidebar.tsx index 8eaa466..7dd3c32 100644 --- a/apps/web/src/components/ProjectSidebar.tsx +++ b/apps/web/src/components/ProjectSidebar.tsx @@ -273,7 +273,7 @@ export function ProjectSidebar() { const asideCls = isMobile ? cn( 'fixed inset-y-0 left-0 z-40 w-60 border-r bg-sidebar text-sidebar-foreground flex flex-col', - 'transition-transform duration-200 ease-out', + 'motion-reduce:transition-none transition-transform duration-200 ease-out', drawerOpen ? 'translate-x-0' : '-translate-x-full', ) : 'w-60 shrink-0 border-r bg-sidebar text-sidebar-foreground flex flex-col h-screen'; @@ -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" style={{ 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" > diff --git a/apps/web/src/components/RightRail.tsx b/apps/web/src/components/RightRail.tsx index 7c51fed..61bc7a9 100644 --- a/apps/web/src/components/RightRail.tsx +++ b/apps/web/src/components/RightRail.tsx @@ -282,7 +282,7 @@ export function RightRail({ projectId, sessionId }: Props) { const asideCls = isMobile ? cn( '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', ) : 'w-64 shrink-0 border-l bg-sidebar flex flex-col h-full overflow-hidden'; diff --git a/apps/web/src/components/SessionLandingPage.tsx b/apps/web/src/components/SessionLandingPage.tsx index 95952ff..d4e3ac3 100644 --- a/apps/web/src/components/SessionLandingPage.tsx +++ b/apps/web/src/components/SessionLandingPage.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'; import { Archive, ChevronLeft, Code, MessageSquare, RotateCcw, Terminal, Trash2 } from 'lucide-react'; import { toast } from 'sonner'; import mascot from '@/assets/brand/banner-mascot.png'; +import { EmptyState } from '@/components/EmptyState'; import { ChatInput } from '@/components/ChatInput'; import { Button } from '@/components/ui/button'; import { @@ -167,9 +168,11 @@ export function SessionLandingPage({

Session history

{isEmpty ? ( -

- No conversations yet. Send a message to start. -

+ } + title="No conversations" + description="Your chat history will appear here" + /> ) : (<> {openChats.length > 0 && ( <> @@ -200,7 +203,7 @@ export function SessionLandingPage({ {formatRelative(c.updated_at)} -
+
+ {canResend && ( + + )} + {isAssistant && ( + + )} + {isAssistant && ( + <> + + + + )} + {!hiddenSet.has('fork') && ( + + )} + {!hiddenSet.has('delete') && ( + + )} + {canRestore && ( + + )} +
+ { + if (!deleting) setDeleteOpen(open); + }} + > + + + Delete this message and all messages after it? + + This removes the selected message and every later message in this chat. This cannot be undone. + + + + + + + + + { + if (!restoring) setRestoreOpen(open); + }} + > + + + Restore to this point? + + 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. + + + + + + + + + + ); +} diff --git a/apps/web/src/components/message-parts/CompactCard.tsx b/apps/web/src/components/message-parts/CompactCard.tsx new file mode 100644 index 0000000..254ce1f --- /dev/null +++ b/apps/web/src/components/message-parts/CompactCard.tsx @@ -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 ( +
+
+ + +
+ + {shareOpen && ( +
+ {otherChats.length === 0 ? ( +
+ No other chats in this session +
+ ) : ( + otherChats.map((c) => ( + + )) + )} +
+ )} +
+ +
+ {expanded && ( +
+ {summaryText} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/message-parts/MistakeRecoverySentinel.tsx b/apps/web/src/components/message-parts/MistakeRecoverySentinel.tsx new file mode 100644 index 0000000..e5d13be --- /dev/null +++ b/apps/web/src/components/message-parts/MistakeRecoverySentinel.tsx @@ -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 ( +
+
+ +
+
+ {escalated ? 'Repeated errors — turn stopped' : 'Recovering from repeated errors'} +
+
+ {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.'} +
+ {escalated && canContinue && ( +
+ +
+ )} +
+
+
+ ); +} diff --git a/apps/web/src/components/message-parts/ReasoningBlock.tsx b/apps/web/src/components/message-parts/ReasoningBlock.tsx new file mode 100644 index 0000000..5d91a1d --- /dev/null +++ b/apps/web/src/components/message-parts/ReasoningBlock.tsx @@ -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 ( +
+ + {expanded && ( +
+ {text} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/message-parts/SendToTerminalMenu.tsx b/apps/web/src/components/message-parts/SendToTerminalMenu.tsx new file mode 100644 index 0000000..f3c17d9 --- /dev/null +++ b/apps/web/src/components/message-parts/SendToTerminalMenu.tsx @@ -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 → ". 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 ( + { + if (open) { + const sel = typeof window !== 'undefined' ? window.getSelection()?.toString() ?? '' : ''; + setSelection(sel); + } + }} + > + {children} + + { + void navigator.clipboard.writeText(selection).catch((err) => { + toast.error(err instanceof Error ? err.message : 'copy failed'); + }); + }} + > + Copy + + + + Send to terminal + + {terminals.length === 0 ? ( + No terminal panes open + ) : ( + terminals.map((t) => ( + sendToTerminal.emit({ pane_id: t.paneId, text: selection })} + > + {t.label} + + )) + )} + + + + + ); +} diff --git a/apps/web/src/components/message-parts/StatsLine.tsx b/apps/web/src/components/message-parts/StatsLine.tsx new file mode 100644 index 0000000..942b3d3 --- /dev/null +++ b/apps/web/src/components/message-parts/StatsLine.tsx @@ -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 ( +
+ {parts.join(' · ')} +
+ ); +} diff --git a/apps/web/src/components/message-parts/SummaryCard.tsx b/apps/web/src/components/message-parts/SummaryCard.tsx new file mode 100644 index 0000000..04f2ea8 --- /dev/null +++ b/apps/web/src/components/message-parts/SummaryCard.tsx @@ -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 ( +
+
+ + +
+ {expanded && ( +
+ +
+ )} +
+ ); +} diff --git a/apps/web/src/components/message-parts/index.ts b/apps/web/src/components/message-parts/index.ts new file mode 100644 index 0000000..080900f --- /dev/null +++ b/apps/web/src/components/message-parts/index.ts @@ -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'; diff --git a/apps/web/src/components/panes/ChatPane.tsx b/apps/web/src/components/panes/ChatPane.tsx index 0881ecd..726d583 100644 --- a/apps/web/src/components/panes/ChatPane.tsx +++ b/apps/web/src/components/panes/ChatPane.tsx @@ -1,14 +1,31 @@ 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 { api } from '@/api/client'; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, +} from '@/components/ui/dropdown-menu'; import { useSessionStream } from '@/hooks/useSessionStream'; import { MessageList } from '@/components/MessageList'; import { ChatInput } from '@/components/ChatInput'; +import { ModelPicker } from '@/components/ModelPicker'; import { StaleStreamBanner } from '@/components/StaleStreamBanner'; import { SessionTimeline } from '@/components/SessionTimeline'; import { TraceViewer } from '@/components/TraceViewer'; 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 { sessionId: string; @@ -31,6 +48,107 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange, const [queue, setQueue] = useState<{ id: string; text: string }[]>([]); const queueIdRef = useRef(0); 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([]); + const [compareResponses, setCompareResponses] = useState([]); + const [compareGroupId, setCompareGroupId] = useState(null); + const compareMsgIdToModelRef = useRef>(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([]); + const [sendingCompare, setSendingCompare] = useState(false); + const [availableModels, setAvailableModels] = useState([]); + + // 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(() => { if (stream.error && stream.error !== lastErrorRef.current) { @@ -42,9 +160,6 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange, } }, [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 // 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 @@ -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 (
{chatMessages.length > 0 && ( -
+
+ 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'); + } + }} + /> + + + + + + + handleExport('json')}> + Export as JSON + + handleExport('markdown')}> + Export as Markdown + + +
)} {/* v1.11.5: ContextBar moved into ChatInput (above the agent picker). */} - + {compareActive ? ( + + ) : ( + + )} {/* Queued messages */} - {queue.length > 0 && ( + {!compareActive && queue.length > 0 && (
{queue.map((item, i) => ( @@ -282,7 +467,7 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
)} - {stale && streamingId && ( + {!compareActive && stale && streamingId && ( void handleRetryStale()} onDiscard={() => void handleDiscardStale()} @@ -296,7 +481,7 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange, agentId={agentId} onAgentChange={onAgentChange} webSearchEnabled={webSearchEnabled} - onSend={handleSend} + onSend={compareActive ? handleCompareFromInput : handleSend} onForceSend={streaming ? handleForceSend : undefined} generating={streaming} onStop={handleStop} @@ -318,6 +503,79 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange, onScrollToMessage={handleScrollToMessage} /> )} + + {/* Compare model selector dialog */} + {showCompareSelector && ( + { if (!open) setShowCompareSelector(false); }}> + + + Compare Models + + Select 2-3 models to compare. Each model receives the same message and you see responses side by side. + + +
+