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:
@@ -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/`.
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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<Chat>(`/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<ToolTraceResponse>(
|
||||
`/api/chats/${chatId}/traces?limit=${limit}&offset=${offset}`,
|
||||
),
|
||||
exportChat: (chatId: string, format: 'json' | 'markdown') =>
|
||||
request<string>(`/api/chats/${chatId}/export?format=${format}`),
|
||||
// MCP permission: approve/deny a tool call from an 'ask' state server.
|
||||
mcpApprove: (chatId: string, toolCallId: string, permission: 'allow_once' | 'allow_always' | 'deny') =>
|
||||
request<{ ok: true }>(`/api/chats/${chatId}/mcp-approve`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ tool_call_id: toolCallId, permission }),
|
||||
}),
|
||||
},
|
||||
|
||||
messages: {
|
||||
@@ -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<ModelInfo[]>('/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<string, unknown> = {};
|
||||
if (cols !== undefined) body.cols = cols;
|
||||
if (rows !== undefined) body.rows = rows;
|
||||
if (description !== undefined) body.description = description;
|
||||
if (parentAgent !== undefined) body.parentAgent = parentAgent;
|
||||
return request<{ tmux_session: string }>(
|
||||
`/api/term/sessions/${sessionId}/panes/${paneId}/start`,
|
||||
{
|
||||
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`,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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}`}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,23 +158,76 @@ 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 {
|
||||
await navigator.clipboard.writeText(code);
|
||||
localStorage.setItem('codeblock-theme', next);
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
try {
|
||||
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={() => void copy()}
|
||||
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"
|
||||
>
|
||||
@@ -106,16 +235,62 @@ export function CodeBlock({ code, lang }: Props) {
|
||||
<span>{copied ? 'Copied' : 'Copy'}</span>
|
||||
</button>
|
||||
</div>
|
||||
{html !== null ? (
|
||||
</div>
|
||||
|
||||
{/* ── Code body (flex row: gutter + code) ──────────────── */}
|
||||
<div className="flex">
|
||||
{/* Gutter — line numbers or diff markers */}
|
||||
{showGutter && (
|
||||
<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"
|
||||
/>
|
||||
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="overflow-x-auto px-3 py-2 font-mono text-xs leading-relaxed">
|
||||
{code}
|
||||
<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={() => setExpanded(true)}
|
||||
className="w-full text-xs text-muted-foreground hover:text-foreground py-1 border-t border-border/30 bg-muted/20"
|
||||
>
|
||||
Show {totalLines - 15} more {totalLines - 15 === 1 ? 'line' : 'lines'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
143
apps/web/src/components/ComparePane.tsx
Normal file
143
apps/web/src/components/ComparePane.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
68
apps/web/src/components/EmptyState.tsx
Normal file
68
apps/web/src/components/EmptyState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
64
apps/web/src/components/KeyboardShortcutsDialog.tsx
Normal file
64
apps/web/src/components/KeyboardShortcutsDialog.tsx
Normal 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 };
|
||||
@@ -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 (
|
||||
<MessageBoundary>
|
||||
<Markdown remarkPlugins={[remarkGfm]} components={MARKDOWN_COMPONENTS}>
|
||||
{content}
|
||||
</Markdown>
|
||||
</MessageBoundary>
|
||||
);
|
||||
});
|
||||
|
||||
113
apps/web/src/components/McpPermissionDialog.tsx
Normal file
113
apps/web/src/components/McpPermissionDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
212
apps/web/src/components/McpResponseDisplay.tsx
Normal file
212
apps/web/src/components/McpResponseDisplay.tsx
Normal 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 `` → <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: 
|
||||
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>
|
||||
);
|
||||
}
|
||||
56
apps/web/src/components/MessageBoundary.tsx
Normal file
56
apps/web/src/components/MessageBoundary.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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()}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
56
apps/web/src/components/MessageListErrorBoundary.tsx
Normal file
56
apps/web/src/components/MessageListErrorBoundary.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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',
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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
|
||||
|
||||
327
apps/web/src/components/message-parts/ActionRow.tsx
Normal file
327
apps/web/src/components/message-parts/ActionRow.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
125
apps/web/src/components/message-parts/CompactCard.tsx
Normal file
125
apps/web/src/components/message-parts/CompactCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
33
apps/web/src/components/message-parts/ReasoningBlock.tsx
Normal file
33
apps/web/src/components/message-parts/ReasoningBlock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
69
apps/web/src/components/message-parts/SendToTerminalMenu.tsx
Normal file
69
apps/web/src/components/message-parts/SendToTerminalMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
apps/web/src/components/message-parts/StatsLine.tsx
Normal file
38
apps/web/src/components/message-parts/StatsLine.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
62
apps/web/src/components/message-parts/SummaryCard.tsx
Normal file
62
apps/web/src/components/message-parts/SummaryCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
7
apps/web/src/components/message-parts/index.ts
Normal file
7
apps/web/src/components/message-parts/index.ts
Normal 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';
|
||||
@@ -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). */}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -23,6 +23,8 @@ interface SocketDeps {
|
||||
termRef: React.MutableRefObject<Terminal | null>;
|
||||
sessionId: string;
|
||||
paneId: string;
|
||||
description?: string;
|
||||
parentAgent?: string;
|
||||
fit: TerminalFit['fit'];
|
||||
getSize: TerminalFit['getSize'];
|
||||
setSize: TerminalFit['setSize'];
|
||||
@@ -40,6 +42,8 @@ export function useTerminalSocket({
|
||||
termRef,
|
||||
sessionId,
|
||||
paneId,
|
||||
description,
|
||||
parentAgent,
|
||||
fit,
|
||||
getSize,
|
||||
setSize,
|
||||
@@ -276,7 +280,7 @@ export function useTerminalSocket({
|
||||
fit();
|
||||
const { cols, rows } = getSize();
|
||||
api.terminals
|
||||
.start(sessionId, paneId, cols, rows)
|
||||
.start(sessionId, paneId, cols, rows, description, parentAgent)
|
||||
.catch(() => {
|
||||
/* WS handler will ensureSession itself — non-fatal */
|
||||
})
|
||||
|
||||
98
apps/web/src/hooks/useDraftPersistence.ts
Normal file
98
apps/web/src/hooks/useDraftPersistence.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
const STORAGE_PREFIX = 'boocode_draft_';
|
||||
const SAVE_DEBOUNCE_MS = 500;
|
||||
|
||||
function getKey(chatId: string): string {
|
||||
return `${STORAGE_PREFIX}${chatId}`;
|
||||
}
|
||||
|
||||
function readDraft(key: string): string {
|
||||
if (typeof window === 'undefined') return '';
|
||||
try {
|
||||
return localStorage.getItem(key) ?? '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function writeDraft(key: string, text: string): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
if (text) {
|
||||
localStorage.setItem(key, text);
|
||||
} else {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
} catch {
|
||||
// storage full or unavailable — silently ignore
|
||||
}
|
||||
}
|
||||
|
||||
function removeDraft(key: string): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch {
|
||||
// silently ignore
|
||||
}
|
||||
}
|
||||
|
||||
export interface DraftPersistenceResult {
|
||||
/** Current draft state, initialized from localStorage on mount. */
|
||||
draft: string;
|
||||
/** Update draft with 500ms debounced persistence to localStorage. */
|
||||
setDraft: (text: string) => void;
|
||||
/** Clear draft state and remove localStorage entry immediately. */
|
||||
clearDraft: () => void;
|
||||
/** Re-read from localStorage, update state, and return saved value. */
|
||||
restoreDraft: () => string;
|
||||
}
|
||||
|
||||
export function useDraftPersistence(chatId: string | undefined): DraftPersistenceResult {
|
||||
const key = chatId ? getKey(chatId) : null;
|
||||
const [draft, setDraftState] = useState(() => (key ? readDraft(key) : ''));
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const keyRef = useRef(key);
|
||||
keyRef.current = key;
|
||||
|
||||
// Cleanup timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current !== null) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const setDraft = useCallback((text: string) => {
|
||||
setDraftState(text);
|
||||
if (timerRef.current !== null) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
timerRef.current = setTimeout(() => {
|
||||
const k = keyRef.current;
|
||||
if (k) writeDraft(k, text);
|
||||
}, SAVE_DEBOUNCE_MS);
|
||||
}, []);
|
||||
|
||||
const clearDraft = useCallback(() => {
|
||||
setDraftState('');
|
||||
if (timerRef.current !== null) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
const k = keyRef.current;
|
||||
if (k) removeDraft(k);
|
||||
}, []);
|
||||
|
||||
const restoreDraft = useCallback((): string => {
|
||||
const k = keyRef.current;
|
||||
if (!k) return '';
|
||||
const saved = readDraft(k);
|
||||
setDraftState(saved);
|
||||
return saved;
|
||||
}, []);
|
||||
|
||||
return { draft, setDraft, clearDraft, restoreDraft };
|
||||
}
|
||||
616
apps/web/src/hooks/useSessionStream.test.ts
Normal file
616
apps/web/src/hooks/useSessionStream.test.ts
Normal file
@@ -0,0 +1,616 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import React, { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import type { Message, WsFrame } from '@/api/types';
|
||||
import { useSessionStream } from './useSessionStream';
|
||||
|
||||
// ── Hoisted mock values ──────────────────────────────────────────────────────
|
||||
|
||||
const { mockMessagesList, mockEmit, mockSubscribe, mockRecordUsage } = vi.hoisted(
|
||||
() => ({
|
||||
mockMessagesList: vi.fn(),
|
||||
mockEmit: vi.fn(),
|
||||
mockSubscribe: vi.fn().mockReturnValue(vi.fn()),
|
||||
mockRecordUsage: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Module mocks ─────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
api: { messages: { list: mockMessagesList } },
|
||||
}));
|
||||
|
||||
vi.mock('./sessionEvents', () => ({
|
||||
sessionEvents: { emit: mockEmit, subscribe: mockSubscribe },
|
||||
}));
|
||||
|
||||
vi.mock('./useChatThroughput', () => ({
|
||||
recordUsage: mockRecordUsage,
|
||||
}));
|
||||
|
||||
// ── Test constants ───────────────────────────────────────────────────────────
|
||||
|
||||
const SESSION_ID = '00000000-0000-0000-0000-000000000001';
|
||||
const CHAT_ID = '00000000-0000-0000-0000-000000000002';
|
||||
const MSG_ID = '00000000-0000-0000-0000-000000000003';
|
||||
const TOOL_MSG_ID = '00000000-0000-0000-0000-000000000004';
|
||||
|
||||
// ── Frame builder helpers ────────────────────────────────────────────────────
|
||||
|
||||
function textDelta(seq: number, content: string): WsFrame {
|
||||
return {
|
||||
type: 'channel_delta', seq, channel: 'text', message_id: MSG_ID, chat_id: CHAT_ID, content,
|
||||
} as unknown as WsFrame;
|
||||
}
|
||||
|
||||
function toolCallDelta(
|
||||
seq: number,
|
||||
tc: { id: string; name: string; args: Record<string, unknown> },
|
||||
): WsFrame {
|
||||
return {
|
||||
type: 'channel_delta', seq, channel: 'tool_call', message_id: MSG_ID, chat_id: CHAT_ID, tool_call: tc,
|
||||
} as unknown as WsFrame;
|
||||
}
|
||||
|
||||
function toolResultDelta(seq: number, callId: string, output: unknown): WsFrame {
|
||||
return {
|
||||
type: 'channel_delta', seq, channel: 'tool_result',
|
||||
tool_message_id: TOOL_MSG_ID, chat_id: CHAT_ID, tool_call_id: callId,
|
||||
output, truncated: false,
|
||||
} as unknown as WsFrame;
|
||||
}
|
||||
|
||||
function statusDelta(
|
||||
seq: number, status: 'running' | 'complete' | 'cancelled' | 'failed',
|
||||
overrides?: Record<string, unknown>,
|
||||
): WsFrame {
|
||||
return {
|
||||
type: 'channel_delta', seq, channel: 'status', message_id: MSG_ID, chat_id: CHAT_ID, status,
|
||||
...(overrides ?? {}),
|
||||
} as unknown as WsFrame;
|
||||
}
|
||||
|
||||
function errorDelta(seq: number, error: string): WsFrame {
|
||||
return {
|
||||
type: 'channel_delta', seq, channel: 'error', message_id: MSG_ID, chat_id: CHAT_ID, error,
|
||||
} as unknown as WsFrame;
|
||||
}
|
||||
|
||||
// ── WebSocket mock globals ───────────────────────────────────────────────────
|
||||
|
||||
interface MockWs {
|
||||
onopen: (() => void) | null;
|
||||
onmessage: ((ev: { data: string }) => void) | null;
|
||||
onclose: ((ev: { code?: number; reason?: string }) => void) | null;
|
||||
onerror: (() => void) | null;
|
||||
send: ReturnType<typeof vi.fn>;
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
readyState: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
let currentMockWs: MockWs | null = null;
|
||||
let wsConstructCount = 0;
|
||||
|
||||
function createWsMock(): MockWs {
|
||||
return {
|
||||
onopen: null, onmessage: null, onclose: null, onerror: null,
|
||||
send: vi.fn(), close: vi.fn(), readyState: 1, url: '',
|
||||
};
|
||||
}
|
||||
|
||||
function triggerWsOpen(): void { currentMockWs?.onopen?.(); }
|
||||
function triggerWsMessage(frame: WsFrame): void {
|
||||
currentMockWs?.onmessage?.({ data: JSON.stringify(frame) });
|
||||
}
|
||||
function triggerWsClose(): void { currentMockWs?.onclose?.({}); }
|
||||
function getWsSendCalls(): string[] {
|
||||
return (currentMockWs?.send.mock.calls ?? []).map((c: unknown[]) => String(c[0]));
|
||||
}
|
||||
|
||||
// ── React test harness ───────────────────────────────────────────────────────
|
||||
|
||||
function createHarness() {
|
||||
const states: Array<{ messages: Message[]; connected: boolean; error: string | null }> = [];
|
||||
|
||||
function Wrapper({ sessionId }: { sessionId: string | undefined }) {
|
||||
const state = useSessionStream(sessionId);
|
||||
states.push(state);
|
||||
return React.createElement('div');
|
||||
}
|
||||
|
||||
return {
|
||||
Wrapper,
|
||||
lastState: () => states[states.length - 1] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
let root: Root;
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
currentMockWs = null;
|
||||
wsConstructCount = 0;
|
||||
mockMessagesList.mockReset().mockResolvedValue([]);
|
||||
mockEmit.mockReset();
|
||||
mockSubscribe.mockReset().mockReturnValue(vi.fn());
|
||||
vi.clearAllMocks();
|
||||
|
||||
vi.spyOn(window, 'WebSocket').mockImplementation(function (this: WebSocket, url: string | URL) {
|
||||
const ws = createWsMock();
|
||||
ws.url = String(url);
|
||||
currentMockWs = ws;
|
||||
wsConstructCount++;
|
||||
const proto = {
|
||||
get onopen() { return ws.onopen; },
|
||||
set onopen(fn) { ws.onopen = fn as () => void; },
|
||||
get onmessage() { return ws.onmessage; },
|
||||
set onmessage(fn) { ws.onmessage = fn as (ev: { data: string }) => void; },
|
||||
get onclose() { return ws.onclose; },
|
||||
set onclose(fn) { ws.onclose = fn as (ev: { code?: number; reason?: string }) => void; },
|
||||
get onerror() { return ws.onerror; },
|
||||
set onerror(fn) { ws.onerror = fn as () => void; },
|
||||
send: vi.fn((d: string) => ws.send(d)),
|
||||
close: vi.fn(() => { ws.close(); ws.onclose?.({}); }),
|
||||
readyState: 1, url: ws.url,
|
||||
CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3,
|
||||
};
|
||||
return proto as unknown as WebSocket;
|
||||
});
|
||||
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => { root.unmount(); });
|
||||
}
|
||||
if (container.parentNode) {
|
||||
container.parentNode.removeChild(container);
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
currentMockWs = null;
|
||||
});
|
||||
|
||||
async function renderHook(sessionId: string | undefined) {
|
||||
const harness = createHarness();
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(harness.Wrapper, {
|
||||
sessionId: sessionId ?? (null as unknown as undefined),
|
||||
}),
|
||||
);
|
||||
});
|
||||
await act(async () => {});
|
||||
return harness;
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
await act(async () => {});
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('useSessionStream', () => {
|
||||
describe('connection lifecycle', () => {
|
||||
it('does not connect when sessionId is undefined', async () => {
|
||||
const h = await renderHook(undefined);
|
||||
const s = h.lastState();
|
||||
expect(s).not.toBeNull();
|
||||
expect(s!.connected).toBe(false);
|
||||
expect(s!.messages).toEqual([]);
|
||||
});
|
||||
|
||||
it('connects when sessionId is provided', async () => {
|
||||
await renderHook(SESSION_ID);
|
||||
expect(wsConstructCount).toBe(1);
|
||||
});
|
||||
|
||||
it('sets connected=true on WebSocket open', async () => {
|
||||
const h = await renderHook(SESSION_ID);
|
||||
act(() => { triggerWsOpen(); });
|
||||
await flush();
|
||||
expect(h.lastState()!.connected).toBe(true);
|
||||
expect(h.lastState()!.error).toBeNull();
|
||||
});
|
||||
|
||||
it('sends reconnect frame with lastSeqPerChannel on open', async () => {
|
||||
await renderHook(SESSION_ID);
|
||||
act(() => { triggerWsOpen(); });
|
||||
await flush();
|
||||
const sends = getWsSendCalls();
|
||||
const reconnectMsg = sends.find((s) => s.includes('reconnect'));
|
||||
expect(reconnectMsg).toBeDefined();
|
||||
if (reconnectMsg) {
|
||||
const parsed = JSON.parse(reconnectMsg);
|
||||
expect(parsed.type).toBe('reconnect');
|
||||
expect(parsed.lastSeqPerChannel).toEqual({});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('non-channel frames', () => {
|
||||
it('processes snapshot frame', async () => {
|
||||
await renderHook(SESSION_ID);
|
||||
act(() => { triggerWsOpen(); });
|
||||
await flush();
|
||||
const msg: Message = {
|
||||
id: MSG_ID, session_id: SESSION_ID, chat_id: CHAT_ID, role: 'user', content: 'hi',
|
||||
kind: 'message', tool_calls: null, tool_results: null, status: 'complete',
|
||||
last_seq: 0, tokens_used: null, ctx_used: null, ctx_max: null,
|
||||
cache_tokens: null, reasoning_tokens: null, model: null,
|
||||
started_at: null, finished_at: null, created_at: '2026-01-01T00:00:00Z', metadata: null,
|
||||
};
|
||||
act(() => { triggerWsMessage({ type: 'snapshot', messages: [msg] } as WsFrame); });
|
||||
await flush();
|
||||
});
|
||||
|
||||
it('processes delta frame for existing message', async () => {
|
||||
await renderHook(SESSION_ID);
|
||||
act(() => { triggerWsOpen(); });
|
||||
await flush();
|
||||
const streamingMsg: Message = {
|
||||
id: MSG_ID, session_id: SESSION_ID, chat_id: CHAT_ID, role: 'assistant', content: '',
|
||||
kind: 'message', tool_calls: null, tool_results: null, status: 'streaming',
|
||||
last_seq: 0, tokens_used: null, ctx_used: null, ctx_max: null,
|
||||
cache_tokens: null, reasoning_tokens: null, model: null,
|
||||
started_at: null, finished_at: null, created_at: '2026-01-01T00:00:00Z', metadata: null,
|
||||
};
|
||||
act(() => { triggerWsMessage({ type: 'snapshot', messages: [streamingMsg] } as WsFrame); });
|
||||
await flush();
|
||||
act(() => { triggerWsMessage({ type: 'delta', message_id: MSG_ID, content: 'Hello!' } as WsFrame); });
|
||||
await flush();
|
||||
});
|
||||
|
||||
it('emits git_diff_refresh on message_complete', async () => {
|
||||
await renderHook(SESSION_ID);
|
||||
act(() => { triggerWsOpen(); });
|
||||
await flush();
|
||||
act(() => {
|
||||
triggerWsMessage({
|
||||
type: 'message_started', message_id: MSG_ID, chat_id: CHAT_ID, role: 'assistant',
|
||||
} as WsFrame);
|
||||
});
|
||||
await flush();
|
||||
act(() => {
|
||||
triggerWsMessage({
|
||||
type: 'message_complete', message_id: MSG_ID, chat_id: CHAT_ID,
|
||||
} as WsFrame);
|
||||
});
|
||||
await flush();
|
||||
expect(mockEmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'git_diff_refresh' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('processes messages_deleted frame', async () => {
|
||||
await renderHook(SESSION_ID);
|
||||
act(() => { triggerWsOpen(); });
|
||||
await flush();
|
||||
act(() => {
|
||||
triggerWsMessage({
|
||||
type: 'messages_deleted', message_ids: [MSG_ID], chat_id: CHAT_ID,
|
||||
} as WsFrame);
|
||||
});
|
||||
await flush();
|
||||
});
|
||||
|
||||
it('processes compacted frame and refetches messages', async () => {
|
||||
await renderHook(SESSION_ID);
|
||||
act(() => { triggerWsOpen(); });
|
||||
await flush();
|
||||
act(() => {
|
||||
triggerWsMessage({
|
||||
type: 'compacted', session_id: SESSION_ID, chat_id: CHAT_ID, summary_message_id: MSG_ID,
|
||||
} as WsFrame);
|
||||
});
|
||||
await flush();
|
||||
expect(mockMessagesList).toHaveBeenCalledWith(SESSION_ID);
|
||||
});
|
||||
|
||||
it('processes usage frame and calls recordUsage', async () => {
|
||||
await renderHook(SESSION_ID);
|
||||
act(() => { triggerWsOpen(); });
|
||||
await flush();
|
||||
act(() => {
|
||||
triggerWsMessage({
|
||||
type: 'usage', message_id: MSG_ID, chat_id: CHAT_ID,
|
||||
completion_tokens: 100, ctx_used: 5000, ctx_max: 32768,
|
||||
} as WsFrame);
|
||||
});
|
||||
await flush();
|
||||
expect(mockRecordUsage).toHaveBeenCalledWith(CHAT_ID, {
|
||||
completion_tokens: 100, ctx_used: 5000, ctx_max: 32768,
|
||||
});
|
||||
});
|
||||
|
||||
it('emits chat_updated on chat_renamed frame', async () => {
|
||||
await renderHook(SESSION_ID);
|
||||
act(() => { triggerWsOpen(); });
|
||||
await flush();
|
||||
act(() => {
|
||||
triggerWsMessage({
|
||||
type: 'chat_renamed', chat_id: CHAT_ID, name: 'New Chat Name',
|
||||
} as WsFrame);
|
||||
});
|
||||
await flush();
|
||||
expect(mockEmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'chat_updated', chat_id: CHAT_ID, name: 'New Chat Name' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('handles pass-through frames: agent_snapshot, agent_status_updated, flow_run*, battle*', async () => {
|
||||
await renderHook(SESSION_ID);
|
||||
act(() => { triggerWsOpen(); });
|
||||
await flush();
|
||||
|
||||
act(() => {
|
||||
triggerWsMessage({ type: 'agent_snapshot', chat_id: CHAT_ID, model: 'qwen', turn_number: 3 } as WsFrame);
|
||||
triggerWsMessage({
|
||||
type: 'agent_status_updated', chat_id: CHAT_ID, agent: 'coder', status: 'working',
|
||||
at: '2026-01-01T00:00:00Z',
|
||||
} as WsFrame);
|
||||
triggerWsMessage({
|
||||
type: 'flow_run_started', run_id: MSG_ID, flow_name: 'test', band: 'small', steps: [],
|
||||
} as unknown as WsFrame);
|
||||
triggerWsMessage({
|
||||
type: 'flow_run_step_updated', run_id: MSG_ID, step_id: 's1', status: 'completed',
|
||||
} as unknown as WsFrame);
|
||||
triggerWsMessage({
|
||||
type: 'battle_started', battle_id: MSG_ID, battle_type: 'coding', prompt: 'test', contestants: [],
|
||||
} as unknown as WsFrame);
|
||||
triggerWsMessage({
|
||||
type: 'contestant_updated', battle_id: MSG_ID, contestant_id: CHAT_ID,
|
||||
} as unknown as WsFrame);
|
||||
triggerWsMessage({ type: 'battle_updated', battle_id: MSG_ID } as unknown as WsFrame);
|
||||
});
|
||||
await flush();
|
||||
});
|
||||
});
|
||||
|
||||
describe('channel_delta frames', () => {
|
||||
async function setup() {
|
||||
const h = await renderHook(SESSION_ID);
|
||||
act(() => { triggerWsOpen(); });
|
||||
await flush();
|
||||
act(() => {
|
||||
triggerWsMessage({
|
||||
type: 'message_started', message_id: MSG_ID, chat_id: CHAT_ID, role: 'assistant',
|
||||
} as WsFrame);
|
||||
});
|
||||
await flush();
|
||||
return h;
|
||||
}
|
||||
|
||||
it('processes text channel delta', async () => {
|
||||
await setup();
|
||||
act(() => { triggerWsMessage(textDelta(0, 'Hello ')); });
|
||||
await flush();
|
||||
act(() => { triggerWsMessage(textDelta(1, 'World!')); });
|
||||
await flush();
|
||||
});
|
||||
|
||||
it('processes out-of-order deltas (seq=1 before seq=0)', async () => {
|
||||
await setup();
|
||||
act(() => { triggerWsMessage(textDelta(1, 'World!')); });
|
||||
await flush();
|
||||
act(() => { triggerWsMessage(textDelta(0, 'Hello ')); });
|
||||
await flush();
|
||||
});
|
||||
|
||||
it('ignores duplicate seq', async () => {
|
||||
await setup();
|
||||
act(() => { triggerWsMessage(textDelta(0, 'Hello ')); });
|
||||
await flush();
|
||||
act(() => { triggerWsMessage(textDelta(0, 'Hello ')); });
|
||||
await flush();
|
||||
});
|
||||
|
||||
it('fills gap and flushes in order (seq=0, seq=2, then seq=1)', async () => {
|
||||
await setup();
|
||||
act(() => { triggerWsMessage(textDelta(0, 'First ')); });
|
||||
await flush();
|
||||
act(() => { triggerWsMessage(textDelta(2, 'Third ')); });
|
||||
await flush();
|
||||
act(() => { triggerWsMessage(textDelta(1, 'Second ')); });
|
||||
await flush();
|
||||
});
|
||||
|
||||
it('processes tool_call channel delta', async () => {
|
||||
await setup();
|
||||
act(() => {
|
||||
triggerWsMessage(toolCallDelta(0, { id: 'call_1', name: 'read', args: { path: '/' } }));
|
||||
});
|
||||
await flush();
|
||||
});
|
||||
|
||||
it('processes tool_result channel delta', async () => {
|
||||
await renderHook(SESSION_ID);
|
||||
act(() => { triggerWsOpen(); });
|
||||
await flush();
|
||||
act(() => { triggerWsMessage(toolResultDelta(0, 'call_1', { data: 'file content' })); });
|
||||
await flush();
|
||||
});
|
||||
|
||||
it('processes error channel delta', async () => {
|
||||
await renderHook(SESSION_ID);
|
||||
act(() => { triggerWsOpen(); });
|
||||
await flush();
|
||||
act(() => { triggerWsMessage(errorDelta(0, 'Something went wrong')); });
|
||||
await flush();
|
||||
});
|
||||
|
||||
it('processes status delta: running creates message, complete terminates it', async () => {
|
||||
await renderHook(SESSION_ID);
|
||||
act(() => { triggerWsOpen(); });
|
||||
await flush();
|
||||
act(() => { triggerWsMessage(statusDelta(0, 'running')); });
|
||||
await flush();
|
||||
act(() => {
|
||||
triggerWsMessage(statusDelta(1, 'complete', { tokens_used: 42, ctx_used: 1000 }));
|
||||
});
|
||||
await flush();
|
||||
});
|
||||
|
||||
it('handles multi-channel interleaved deltas with independent seq', async () => {
|
||||
await renderHook(SESSION_ID);
|
||||
act(() => { triggerWsOpen(); });
|
||||
await flush();
|
||||
const tId1 = '00000000-0000-0000-0000-000000000010';
|
||||
const tId2 = '00000000-0000-0000-0000-000000000011';
|
||||
act(() => {
|
||||
triggerWsMessage({ type: 'message_started', message_id: tId1, chat_id: CHAT_ID, role: 'assistant' } as WsFrame);
|
||||
triggerWsMessage({ type: 'message_started', message_id: tId2, chat_id: CHAT_ID, role: 'assistant' } as WsFrame);
|
||||
});
|
||||
await flush();
|
||||
act(() => {
|
||||
triggerWsMessage({ type: 'channel_delta', seq: 0, channel: 'text', message_id: tId1, chat_id: CHAT_ID, content: 'A' } as unknown as WsFrame);
|
||||
triggerWsMessage({ type: 'channel_delta', seq: 1, channel: 'text', message_id: tId1, chat_id: CHAT_ID, content: 'B' } as unknown as WsFrame);
|
||||
triggerWsMessage({ type: 'channel_delta', seq: 0, channel: 'tool_call', message_id: tId2, chat_id: CHAT_ID, tool_call: { id: 'c1', name: 'ls', args: {} } } as unknown as WsFrame);
|
||||
});
|
||||
await flush();
|
||||
});
|
||||
});
|
||||
|
||||
describe('status delta with metadata', () => {
|
||||
it('applies tokens_used, ctx_used, model to message via status delta', async () => {
|
||||
await renderHook(SESSION_ID);
|
||||
act(() => { triggerWsOpen(); });
|
||||
await flush();
|
||||
act(() => { triggerWsMessage(statusDelta(0, 'running')); });
|
||||
await flush();
|
||||
act(() => {
|
||||
triggerWsMessage(
|
||||
statusDelta(1, 'running', {
|
||||
tokens_used: 150, ctx_used: 5000, ctx_max: 32768, model: 'qwen-2.5-32b',
|
||||
}),
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reconnection behavior', () => {
|
||||
it('reconnects on WebSocket close with backoff', async () => {
|
||||
vi.useFakeTimers();
|
||||
await renderHook(SESSION_ID);
|
||||
act(() => { triggerWsOpen(); });
|
||||
await flush();
|
||||
const initialCount = wsConstructCount;
|
||||
act(() => { triggerWsClose(); });
|
||||
// Before 1000ms, no reconnect yet
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(wsConstructCount).toBe(initialCount);
|
||||
// After 1000ms from close, reconnect fires
|
||||
await vi.advanceTimersByTimeAsync(600);
|
||||
expect(wsConstructCount).toBe(initialCount + 1);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('sends lastSeqPerChannel on reconnect', async () => {
|
||||
vi.useFakeTimers();
|
||||
await renderHook(SESSION_ID);
|
||||
act(() => { triggerWsOpen(); });
|
||||
await flush();
|
||||
act(() => { triggerWsMessage(statusDelta(0, 'running')); });
|
||||
await flush();
|
||||
act(() => { triggerWsMessage(statusDelta(1, 'complete')); });
|
||||
await flush();
|
||||
act(() => { triggerWsClose(); });
|
||||
await vi.advanceTimersByTimeAsync(1100);
|
||||
vi.clearAllMocks();
|
||||
act(() => { triggerWsOpen(); });
|
||||
await flush();
|
||||
const sends = getWsSendCalls();
|
||||
const reconnectMsg = sends.find((s) => s.includes('reconnect'));
|
||||
expect(reconnectMsg).toBeDefined();
|
||||
if (reconnectMsg) {
|
||||
const parsed = JSON.parse(reconnectMsg);
|
||||
expect(parsed.type).toBe('reconnect');
|
||||
expect(parsed.lastSeqPerChannel).toBeDefined();
|
||||
expect(parsed.lastSeqPerChannel.status).toBe(2);
|
||||
}
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('channel stall detection', () => {
|
||||
it('emits refetch_messages when channel stalls for 5s', async () => {
|
||||
vi.useFakeTimers();
|
||||
await renderHook(SESSION_ID);
|
||||
act(() => { triggerWsOpen(); });
|
||||
await flush();
|
||||
act(() => { triggerWsMessage(statusDelta(1, 'complete')); });
|
||||
await flush();
|
||||
await vi.advanceTimersByTimeAsync(6000);
|
||||
const refetchCalls = mockEmit.mock.calls.filter(
|
||||
(c: unknown[]) => (c[0] as { type: string }).type === 'refetch_messages',
|
||||
);
|
||||
expect(refetchCalls.length).toBeGreaterThanOrEqual(1);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('does not stall when buffer is empty', async () => {
|
||||
vi.useFakeTimers();
|
||||
await renderHook(SESSION_ID);
|
||||
act(() => { triggerWsOpen(); });
|
||||
await flush();
|
||||
await vi.advanceTimersByTimeAsync(10000);
|
||||
const refetchCalls = mockEmit.mock.calls.filter(
|
||||
(c: unknown[]) => (c[0] as { type: string }).type === 'refetch_messages',
|
||||
);
|
||||
expect(refetchCalls.length).toBe(0);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sessionEvents subscription', () => {
|
||||
it('calls api.messages.list when refetch_messages event fires', async () => {
|
||||
await renderHook(SESSION_ID);
|
||||
act(() => { triggerWsOpen(); });
|
||||
await flush();
|
||||
expect(mockSubscribe).toHaveBeenCalled();
|
||||
const fn = mockSubscribe.mock.calls[0]?.[0] as (e: { type: string }) => void;
|
||||
fn({ type: 'refetch_messages' });
|
||||
await flush();
|
||||
expect(mockMessagesList).toHaveBeenCalledWith(SESSION_ID);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid frames', () => {
|
||||
it('ignores bad JSON', async () => {
|
||||
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
await renderHook(SESSION_ID);
|
||||
act(() => { triggerWsOpen(); });
|
||||
await flush();
|
||||
currentMockWs?.onmessage?.({ data: 'not-json-at-all' });
|
||||
await flush();
|
||||
expect(spy).toHaveBeenCalled();
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('ignores schema-invalid frames', async () => {
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
await renderHook(SESSION_ID);
|
||||
act(() => { triggerWsOpen(); });
|
||||
await flush();
|
||||
currentMockWs?.onmessage?.({ data: JSON.stringify({ type: 'unknown_type' }) });
|
||||
await flush();
|
||||
expect(spy).toHaveBeenCalled();
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// Note: git_diff_refresh on status delta 'complete' is not testable through
|
||||
// the hook because the ChannelDeltaFrame Zod schema strips the `status` field
|
||||
// (it's not in the schema's field list — only StatusChannelPayload has it).
|
||||
// The message_complete → git_diff_refresh path is tested above and passes.
|
||||
// The status delta path requires a schema fix in @boocode/contracts/ws-frames.
|
||||
});
|
||||
11
apps/web/src/hooks/useTerminals.ts
Normal file
11
apps/web/src/hooks/useTerminals.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { terminalsRegistry, type TerminalRegistration } from '@/lib/events';
|
||||
|
||||
// v1.10 booterm: tiny subscription hook for the mounted-terminals registry.
|
||||
// Used by the right-click "Send to terminal" submenu so it always reflects
|
||||
// currently-open terminal panes without prop drilling from Workspace.
|
||||
export function useTerminals(): TerminalRegistration[] {
|
||||
const [list, setList] = useState(() => terminalsRegistry.list());
|
||||
useEffect(() => terminalsRegistry.subscribe(() => setList(terminalsRegistry.list())), []);
|
||||
return list;
|
||||
}
|
||||
43
apps/web/src/lib/keyboard-shortcuts.ts
Normal file
43
apps/web/src/lib/keyboard-shortcuts.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
export interface ShortcutGroup {
|
||||
category: string;
|
||||
shortcuts: { keys: string[]; description: string }[];
|
||||
}
|
||||
|
||||
export const KEYBOARD_SHORTCUTS: ShortcutGroup[] = [
|
||||
{
|
||||
category: 'Navigation',
|
||||
shortcuts: [
|
||||
{ keys: ['Ctrl', '`'], description: 'Jump to terminal pane' },
|
||||
{ keys: ['Ctrl', 'T'], description: 'New terminal pane' },
|
||||
{ keys: ['Ctrl', 'C'], description: 'New chat pane' },
|
||||
{ keys: ['Ctrl', 'W'], description: 'Close active pane' },
|
||||
{ keys: ['Tab'], description: 'Next pane' },
|
||||
{ keys: ['Shift', 'Tab'], description: 'Previous pane' },
|
||||
{ keys: ['Ctrl', '1-9'], description: 'Jump to pane by number' },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'Chat',
|
||||
shortcuts: [
|
||||
{ keys: ['Enter'], description: 'Send message' },
|
||||
{ keys: ['Shift', 'Enter'], description: 'New line' },
|
||||
{ keys: ['@'], description: 'Mention file' },
|
||||
{ keys: ['/'], description: 'Slash commands' },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'Terminal',
|
||||
shortcuts: [
|
||||
{ keys: ['Ctrl', 'Shift', 'C'], description: 'Copy from terminal' },
|
||||
{ keys: ['Ctrl', 'F'], description: 'Search terminal' },
|
||||
{ keys: ['Esc'], description: 'Close terminal search' },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'General',
|
||||
shortcuts: [
|
||||
{ keys: ['Ctrl', '/'], description: 'Toggle this shortcuts panel' },
|
||||
{ keys: ['?'], description: 'Toggle this shortcuts panel' },
|
||||
],
|
||||
},
|
||||
];
|
||||
49
apps/web/src/lib/tool-utils.ts
Normal file
49
apps/web/src/lib/tool-utils.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
// Set of built-in tool names (from formatToolArgs in ToolCallLine.tsx).
|
||||
// Any tool not in this set that has a `server_tool` name pattern is an MCP tool.
|
||||
export const BUILT_IN_TOOLS = new Set([
|
||||
'view_file',
|
||||
'list_dir',
|
||||
'grep',
|
||||
'find_files',
|
||||
'git_status',
|
||||
'skill_use',
|
||||
'get_codebase_overview',
|
||||
'get_file_analysis',
|
||||
'get_symbol_info',
|
||||
'search_symbols',
|
||||
'get_dependencies',
|
||||
'watch_changes',
|
||||
'get_semantic_neighborhoods',
|
||||
'get_framework_analysis',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Returns true if the tool name follows the `<server>_<tool>` MCP pattern.
|
||||
* Built-in tools (view_file, grep, skill_use, etc.) are excluded even if
|
||||
* they happen to contain an underscore (e.g. 'git_status', 'skill_use').
|
||||
*/
|
||||
export function isMcpTool(name: string): boolean {
|
||||
return name.includes('_') && !BUILT_IN_TOOLS.has(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the MCP server name from a tool call name.
|
||||
* For 'context7_searchWeb' returns 'context7'.
|
||||
* Returns null for native tools.
|
||||
*/
|
||||
export function extractServerName(name: string): string | null {
|
||||
const idx = name.indexOf('_');
|
||||
if (idx === -1) return null;
|
||||
return name.slice(0, idx);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the tool name (without server prefix) from an MCP tool call name.
|
||||
* For 'context7_searchWeb' returns 'searchWeb'.
|
||||
* Returns null for native tools.
|
||||
*/
|
||||
export function extractToolName(name: string): string | null {
|
||||
const idx = name.indexOf('_');
|
||||
if (idx === -1) return null;
|
||||
return name.slice(idx + 1);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ChevronDown, ChevronRight, Folder, FolderTree, Menu, RotateCcw } from 'lucide-react';
|
||||
import { ChevronDown, ChevronRight, Folder, FolderTree, Menu, RotateCcw, Terminal } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { EmptyState } from '@/components/EmptyState';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import mascot from '@/assets/brand/banner-mascot.png';
|
||||
import wordmark from '@/assets/brand/banner-wordmark.png';
|
||||
@@ -111,12 +112,12 @@ export function Home() {
|
||||
<div className="w-full max-w-md space-y-6">
|
||||
<div className="text-center space-y-3">
|
||||
{empty ? (
|
||||
<>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">No projects yet</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Add a project from /opt or create a new one.
|
||||
</p>
|
||||
</>
|
||||
<EmptyState
|
||||
icon={<Terminal size={40} strokeWidth={1.5} />}
|
||||
title="No projects yet"
|
||||
description="Create a project to get started with BooCode"
|
||||
action={{ label: "Create Project", onClick: () => setCreateOpen(true) }}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col items-center gap-4 pb-1">
|
||||
|
||||
427
apps/web/src/pages/Memory.tsx
Normal file
427
apps/web/src/pages/Memory.tsx
Normal file
@@ -0,0 +1,427 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ArrowLeft, BrainCircuit, CalendarDays, CloudMoon } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '@/api/client';
|
||||
import type { MemoryEntry, DailyMemoryEntry, DreamEntry } from '@/api/types';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useSidebar } from '@/hooks/useSidebar';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ─── Independent section data fetcher (same pattern as Analytics.tsx) ────────
|
||||
|
||||
function useFetch<T>(fetcher: () => Promise<T>): {
|
||||
data: T | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
retry: () => void;
|
||||
} {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function load() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
fetcher()
|
||||
.then(setData)
|
||||
.catch((err: unknown) => {
|
||||
setError(err instanceof Error ? err.message : 'failed to load data');
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
useEffect(() => { load(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return { data, loading, error, retry: load };
|
||||
}
|
||||
|
||||
// ─── Skeleton pulse placeholder ─────────────────────────────────────────────
|
||||
|
||||
function SkeletonBar({ className }: { className?: string }) {
|
||||
return <div className={cn('animate-pulse rounded bg-muted/40', className)} />;
|
||||
}
|
||||
|
||||
// ─── Formatters ─────────────────────────────────────────────────────────────
|
||||
|
||||
function formatDate(iso: string | null | undefined): string {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function formatDateShort(iso: string | null | undefined): string {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
function truncate(str: string, max: number): string {
|
||||
if (str.length <= max) return str;
|
||||
return str.slice(0, max) + '…';
|
||||
}
|
||||
|
||||
function relTime(iso: string | null | undefined): string {
|
||||
if (!iso) return '—';
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 30) return `${days}d ago`;
|
||||
return formatDate(iso);
|
||||
}
|
||||
|
||||
// ─── Empty state ────────────────────────────────────────────────────────────
|
||||
|
||||
function EmptyState({ message }: { message: string }) {
|
||||
return <p className="text-sm text-muted-foreground py-8 text-center">{message}</p>;
|
||||
}
|
||||
|
||||
// ─── Tab bar (same pattern as Results.tsx) ──────────────────────────────────
|
||||
|
||||
type TabId = 'all' | 'daily' | 'dreams';
|
||||
|
||||
function TabBar({ active, onChange }: { active: TabId; onChange: (t: TabId) => void }) {
|
||||
return (
|
||||
<div className="flex gap-1 border-b pb-px">
|
||||
{[
|
||||
{ id: 'all' as TabId, label: 'All Memory', icon: BrainCircuit },
|
||||
{ id: 'daily' as TabId, label: 'Daily Log', icon: CalendarDays },
|
||||
{ id: 'dreams' as TabId, label: 'Dreams', icon: CloudMoon },
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => onChange(tab.id)}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-t-md border border-b-0 -mb-px transition-colors',
|
||||
active === tab.id
|
||||
? 'bg-background border-border text-foreground'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:bg-muted/30',
|
||||
)}
|
||||
>
|
||||
<tab.icon className="size-3.5" />
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── All Memory Tab ─────────────────────────────────────────────────────────
|
||||
|
||||
function AllMemoryTab({ projectId }: { projectId: string }) {
|
||||
const { data, loading, error, retry } = useFetch(() => api.memory.list(projectId).then((r) => r.entries));
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="pt-4 space-y-3">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<Card key={i} size="sm">
|
||||
<CardContent className="pt-4 space-y-2">
|
||||
<SkeletonBar className="h-4 w-16" />
|
||||
<SkeletonBar className="h-5 w-3/4" />
|
||||
<SkeletonBar className="h-3 w-full" />
|
||||
<div className="flex gap-1.5">
|
||||
<SkeletonBar className="h-4 w-12" />
|
||||
<SkeletonBar className="h-4 w-16" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 text-sm pt-4">
|
||||
<span className="text-destructive">{error}</span>
|
||||
<Button size="sm" variant="outline" onClick={retry}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return <EmptyState message="No topic-based memory entries yet." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pt-4 space-y-3">
|
||||
{data.map((entry: MemoryEntry) => (
|
||||
<Card key={entry.id}>
|
||||
<CardContent className="pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(expanded === entry.id ? null : entry.id)}
|
||||
className="w-full text-left"
|
||||
>
|
||||
{/* Topic badge */}
|
||||
<span className="inline-block text-[10px] uppercase tracking-wider font-medium text-muted-foreground bg-muted/50 px-1.5 py-0.5 rounded mb-1.5">
|
||||
{entry.topic}
|
||||
</span>
|
||||
<h3 className="text-sm font-medium">{entry.title}</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||
{truncate(entry.content, 200)}
|
||||
</p>
|
||||
{entry.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{entry.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="text-[10px] bg-secondary/50 text-secondary-foreground px-1.5 py-0.5 rounded"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
{expanded === entry.id && (
|
||||
<div className="mt-3 pt-3 border-t text-xs text-foreground/80 leading-relaxed whitespace-pre-wrap">
|
||||
{entry.content}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Daily Log Tab ──────────────────────────────────────────────────────────
|
||||
|
||||
function DailyLogTab({ projectId }: { projectId: string }) {
|
||||
const { data, loading, error, retry } = useFetch(() => api.memory.daily(projectId).then((r) => r.entries));
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
if (!data) return [];
|
||||
const groups: Record<string, DailyMemoryEntry[]> = {};
|
||||
for (const entry of data) {
|
||||
const g = groups[entry.date];
|
||||
if (g) {
|
||||
g.push(entry);
|
||||
} else {
|
||||
groups[entry.date] = [entry];
|
||||
}
|
||||
}
|
||||
return Object.entries(groups).sort((a, b) => b[0].localeCompare(a[0]));
|
||||
}, [data]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="pt-4 space-y-4">
|
||||
{[0, 1].map((day) => (
|
||||
<div key={day}>
|
||||
<SkeletonBar className="h-4 w-24 mb-2" />
|
||||
<div className="space-y-2">
|
||||
{[0, 1].map((e) => (
|
||||
<Card key={e} size="sm">
|
||||
<CardContent className="pt-3 space-y-1">
|
||||
<SkeletonBar className="h-3 w-20" />
|
||||
<SkeletonBar className="h-3 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 text-sm pt-4">
|
||||
<span className="text-destructive">{error}</span>
|
||||
<Button size="sm" variant="outline" onClick={retry}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return <EmptyState message="No daily log entries for the last 7 days." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pt-4 space-y-4">
|
||||
{grouped.map(([date, entries]) => (
|
||||
<div key={date}>
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-2">
|
||||
{formatDateShort(date)}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{entries.map((entry: DailyMemoryEntry) => (
|
||||
<Card key={entry.id} size="sm">
|
||||
<CardContent className="pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(expanded === entry.id ? null : entry.id)}
|
||||
className="w-full text-left"
|
||||
>
|
||||
<span className="text-xs font-mono text-muted-foreground">
|
||||
{entry.title}
|
||||
</span>
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-1">
|
||||
{truncate(entry.content, 150)}
|
||||
</p>
|
||||
</button>
|
||||
{expanded === entry.id && (
|
||||
<div className="mt-2 pt-2 border-t text-xs text-foreground/80 leading-relaxed whitespace-pre-wrap">
|
||||
{entry.content}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Dreams Tab ─────────────────────────────────────────────────────────────
|
||||
|
||||
function DreamsTab({ projectId }: { projectId: string }) {
|
||||
const { data, loading, error, retry } = useFetch(() => api.memory.dreams(projectId).then((r) => r.entries));
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="pt-4 space-y-3">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="pt-4 space-y-2">
|
||||
<SkeletonBar className="h-4 w-32" />
|
||||
<SkeletonBar className="h-3 w-full" />
|
||||
<SkeletonBar className="h-3 w-5/6" />
|
||||
<SkeletonBar className="h-3 w-2/3" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 text-sm pt-4">
|
||||
<span className="text-destructive">{error}</span>
|
||||
<Button size="sm" variant="outline" onClick={retry}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return <EmptyState message="No dream consolidation diaries yet." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pt-4 space-y-3">
|
||||
{data.map((entry: DreamEntry, i: number) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="pt-4">
|
||||
<h3 className="text-sm font-medium mb-2">{formatDateShort(entry.date)}</h3>
|
||||
<pre className="text-xs font-mono leading-relaxed whitespace-pre-wrap bg-muted/20 p-3 rounded-md border border-border/50 overflow-x-auto">
|
||||
{entry.content}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Page ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function Memory() {
|
||||
const navigate = useNavigate();
|
||||
const { data: sidebar, activeSession } = useSidebar();
|
||||
|
||||
const [tab, setTab] = useState<TabId>('all');
|
||||
const [projectId, setProjectId] = useState<string | null>(null);
|
||||
|
||||
// Derive default project from active session or first project.
|
||||
const projects = useMemo(() => {
|
||||
return sidebar?.projects?.map((p: { id: string; name: string }) => p) ?? [];
|
||||
}, [sidebar]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId && projects.length > 0) {
|
||||
// Prefer active session's project, else first project.
|
||||
const defaultId = activeSession?.project_id ?? projects[0]!.id;
|
||||
setProjectId(defaultId);
|
||||
}
|
||||
}, [projects, activeSession, projectId]);
|
||||
|
||||
function handleBack() {
|
||||
if (window.history.length > 1) {
|
||||
navigate(-1);
|
||||
} else {
|
||||
navigate('/');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-[1000px] mx-auto w-full px-6 py-6 space-y-6">
|
||||
{/* Header */}
|
||||
<header className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBack}
|
||||
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground -ml-1 px-1 py-0.5 rounded"
|
||||
aria-label="Back"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
<span>Back</span>
|
||||
</button>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold flex items-center gap-2">
|
||||
<BrainCircuit className="size-5" />
|
||||
Memory Browser
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Topic-based memories, daily logs, and dream consolidation diaries.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Tab bar */}
|
||||
<TabBar active={tab} onChange={setTab} />
|
||||
|
||||
{/* Tab content */}
|
||||
{!projectId ? (
|
||||
<EmptyState message="Select a project to view memory." />
|
||||
) : tab === 'all' ? (
|
||||
<AllMemoryTab projectId={projectId} />
|
||||
) : tab === 'daily' ? (
|
||||
<DailyLogTab projectId={projectId} />
|
||||
) : (
|
||||
<DreamsTab projectId={projectId} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { Plus, MessageSquare, Trash2, ChevronDown, ChevronRight, RotateCcw, Menu, Code } from 'lucide-react';
|
||||
import { Inbox, Plus, MessageSquare, Trash2, ChevronDown, ChevronRight, RotateCcw, Menu, Code } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api/client';
|
||||
import type { Project as ProjectType, Session } from '@/api/types';
|
||||
import { EmptyState } from '@/components/EmptyState';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||
import { useSessions } from '@/hooks/useSessions';
|
||||
@@ -116,9 +117,11 @@ export function Project() {
|
||||
<div className="text-sm text-muted-foreground">Loading…</div>
|
||||
)}
|
||||
{sessions && sessions.length === 0 && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
No sessions yet. Click <span className="font-medium">New session</span> to start.
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={<Inbox size={40} strokeWidth={1.5} />}
|
||||
title="No sessions"
|
||||
description="Start a new chat session to begin working"
|
||||
/>
|
||||
)}
|
||||
{sessions && sessions.length > 0 && (
|
||||
<ul className="divide-y rounded-md border">
|
||||
|
||||
17
apps/web/vitest.config.ts
Normal file
17
apps/web/vitest.config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'node:path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: false,
|
||||
include: ['src/**/*.test.{ts,tsx}'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user