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

@@ -90,7 +90,7 @@ export function AgentPicker({ projectId, value, onChange }: Props) {
<DropdownMenuTrigger asChild>
<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'}
aria-label={`Agent: ${triggerLabel}`}
>

View File

@@ -20,7 +20,7 @@ export function AttachmentChip({ attachment, onRemove, onPreview }: Props) {
<button
type="button"
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" />
<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"
className={cn(
'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',
)}
style={{

View File

@@ -694,7 +694,7 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
disabled={disabled || busy || attachments.length >= MAX_ATTACHMENTS}
aria-label="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" />
</button>
@@ -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]"
>
<SquareSlash className="size-3.5" />
<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 })}
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]"
>
<Workflow className="size-3.5" />
<span className="max-md:hidden">Flows</span>
@@ -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'

View File

@@ -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<string, string> = {
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) {
const [copied, setCopied] = useState(false);
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(() => {
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 <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 {
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 (
<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">
<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
type="button"
onClick={() => void copy()}
className="flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-muted text-foreground"
aria-label="Copy code"
onClick={() => setExpanded(true)}
className="w-full text-xs text-muted-foreground hover:text-foreground py-1 border-t border-border/30 bg-muted/20"
>
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
<span>{copied ? 'Copied' : 'Copy'}</span>
Show {totalLines - 15} more {totalLines - 15 === 1 ? 'line' : 'lines'}
</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>
);

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}
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}

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"
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'
}`}
>
<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'
}`} />
</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 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 <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 (
<code
@@ -102,8 +107,10 @@ const MARKDOWN_COMPONENTS: Components = {
export const MarkdownRenderer = memo(function MarkdownRenderer({ content }: { content: string }) {
return (
<Markdown remarkPlugins={[remarkGfm]} components={MARKDOWN_COMPONENTS}>
{content}
</Markdown>
<MessageBoundary>
<Markdown remarkPlugins={[remarkGfm]} components={MARKDOWN_COMPONENTS}>
{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 (
<>
<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
type="button"
onClick={() => void copy()}

View File

@@ -274,7 +274,7 @@ export function MessageList({ messages, sessionChats }: Props) {
chatId={item.chatId}
/>
) : (
<ToolCallLine run={item.run} />
<ToolCallLine run={item.run} chatId={item.chatId} />
)
) : (
<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';
interface Props {
value: string;
value: string | null;
onChange: (model: string) => void | Promise<void>;
}
@@ -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) {
<button
type="button"
onClick={() => setOpen(true)}
aria-label={`Model: ${value}`}
title={value}
aria-label={`Model: ${value ?? 'default'}`}
title={value ?? undefined}
className="inline-flex items-center justify-center min-h-[36px] min-w-[44px] rounded text-muted-foreground hover:text-foreground"
>
<Cpu className="size-4" />
@@ -104,7 +104,7 @@ export function ModelPicker({ value, onChange }: Props) {
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"
>
{formatModelLabel(value)}
{value ? formatModelLabel(value) : 'Model'}
<ChevronDown className="size-3 opacity-70" />
</button>
</DropdownMenuTrigger>

View File

@@ -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) {
<Workflow size={14} /> New Orchestrator
</DropdownMenuItem>
)}
{projectId && (
<DropdownMenuItem
onSelect={() =>
sessionEvents.emit({
type: 'open_arena_launcher',
project_id: projectId,
placement: 'new',
})
}
>
<Swords size={14} /> New Arena
</DropdownMenuItem>
)}
{projectId && (
<>

View File

@@ -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"
>

View File

@@ -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';

View File

@@ -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({
<h2 className="text-sm font-medium ml-auto mr-1">Session history</h2>
</div>
{isEmpty ? (
<p className="text-sm text-muted-foreground text-center py-8">
No conversations yet. Send a message to start.
</p>
<EmptyState
icon={<MessageSquare size={40} strokeWidth={1.5} />}
title="No conversations"
description="Your chat history will appear here"
/>
) : (<>
{openChats.length > 0 && (
<>
@@ -200,7 +203,7 @@ export function SessionLandingPage({
{formatRelative(c.updated_at)}
</span>
</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
type="button"
onClick={(e) => { e.stopPropagation(); void onArchiveChat(c.id); }}
@@ -254,7 +257,7 @@ export function SessionLandingPage({
<button
type="button"
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"
title="Delete"
>

View File

@@ -132,13 +132,13 @@ export function ThemePicker() {
onClick={() => setAnimBg(!animOn)}
className={cn(
'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',
)}
>
<span
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',
)}
/>

View File

@@ -33,11 +33,20 @@ export function ToolCallGroup({ runs }: Props) {
<div className="rounded border border-border/60 bg-muted/20 text-xs">
<button
type="button"
tabIndex={0}
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
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="font-mono text-foreground/90">

View File

@@ -123,8 +123,17 @@ export function ToolCallLine({ run, insideGroup, chatId }: Props) {
<div className="text-xs">
<button
type="button"
tabIndex={0}
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 ↳ / >_) */}
{!insideGroup && (

View File

@@ -274,6 +274,8 @@ export function Workspace({
paneId={activePaneChatId(pane) ?? pane.id}
label={terminalLabels.get(activePaneChatId(pane) ?? pane.id) ?? 'Terminal'}
active={idx === activePaneIdx}
description={undefined}
parentAgent={undefined}
/>
) : pane.kind === 'coder' ? (
<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 { 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<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(() => {
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 (
<div className="flex flex-col h-full min-h-0 relative">
{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
type="button"
onClick={() => setShowTimeline((v) => !v)}
@@ -233,16 +390,44 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
<History size={12} />
Timeline
</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>
)}
{/* 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} />
{/* Queued messages */}
{queue.length > 0 && (
{!compareActive && queue.length > 0 && (
<div className="border-t">
<div className="max-w-[1000px] mx-auto w-full px-4 py-1 space-y-1">
{queue.map((item, i) => (
@@ -282,7 +467,7 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
</div>
)}
{stale && streamingId && (
{!compareActive && stale && streamingId && (
<StaleStreamBanner
onRetry={() => 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 && (
<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>
);
}

View File

@@ -48,10 +48,12 @@ interface Props {
sessionId: string;
paneId: string;
label: string;
description?: string;
parentAgent?: string;
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 termRef = useRef<Terminal | null>(null);
const searchRef = useRef<SearchAddon | null>(null);
@@ -112,6 +114,8 @@ export function TerminalPane({ sessionId, paneId, label, active = false }: Props
termRef,
sessionId,
paneId,
description,
parentAgent,
fit: fit.fit,
getSize: fit.getSize,
setSize: fit.setSize,
@@ -148,6 +152,18 @@ export function TerminalPane({ sessionId, paneId, label, active = false }: Props
onArmCtrl={socket.armCtrl}
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
ref={containerRef}
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"
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: {
variant: {