diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index e614199..48b3378 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -21,6 +21,7 @@ import { registerSkillsRoutes } from './routes/skills.js'; import { registerTraceRoutes } from './routes/traces.js'; import { registerToolsRoutes } from './routes/tools.js'; import { registerAnalyticsRoutes } from './routes/analytics.js'; +import { registerMemoryRoutes } from './routes/memory.js'; import { registerInferenceSettingsRoutes } from './routes/inference-settings.js'; import { createInferenceRunner, runInferenceWithModel } from './services/inference/index.js'; @@ -155,6 +156,7 @@ async function main() { hasActiveInference: (chatId) => inference.hasActive(chatId), }); registerTraceRoutes(app, sql); + registerMemoryRoutes(app, sql); registerToolsRoutes(app, sql); registerAnalyticsRoutes(app, sql); registerInferenceSettingsRoutes(app); diff --git a/apps/server/src/routes/memory.ts b/apps/server/src/routes/memory.ts new file mode 100644 index 0000000..2dd9385 --- /dev/null +++ b/apps/server/src/routes/memory.ts @@ -0,0 +1,91 @@ +import type { FastifyInstance } from 'fastify'; +import type { Sql } from '../db.js'; + +// ── Row types matching memory_entries table columns ─────────────────────── +// These mirror the frontend types in apps/web/src/api/types.ts. + +interface MemoryEntryRow { + id: string; + topic: string; + title: string; + content: string; + tags: string[]; +} + +interface DailyMemoryEntryRow extends MemoryEntryRow { + date: string; +} + +interface DreamEntryRow { + date: string; + content: string; +} + +export function registerMemoryRoutes(app: FastifyInstance, sql: Sql): void { + // GET /api/memory?project_id= — topic-based memory entries + app.get<{ Querystring: { project_id?: string } }>( + '/api/memory', + async (req) => { + const projectId = req.query.project_id + if (!projectId) { + return { entries: [] } + } + + const rows = await sql` + SELECT id, topic, title, content, COALESCE(tags, ARRAY[]::text[]) AS tags + FROM memory_entries + WHERE project_id = ${projectId} + AND date IS NULL + ORDER BY created_at DESC + ` + + return { entries: rows } + }, + ) + + // GET /api/memory/daily?project_id= — daily log entries + app.get<{ Querystring: { project_id?: string } }>( + '/api/memory/daily', + async (req) => { + const projectId = req.query.project_id + if (!projectId) { + return { entries: [] } + } + + const rows = await sql` + SELECT + id, topic, title, content, + COALESCE(tags, ARRAY[]::text[]) AS tags, + date::text AS date + FROM memory_entries + WHERE project_id = ${projectId} + AND date IS NOT NULL + AND mood IS NULL + ORDER BY date DESC, created_at DESC + ` + + return { entries: rows } + }, + ) + + // GET /api/memory/dreams?project_id= — dream consolidation diaries + app.get<{ Querystring: { project_id?: string } }>( + '/api/memory/dreams', + async (req) => { + const projectId = req.query.project_id + if (!projectId) { + return { entries: [] } + } + + const rows = await sql` + SELECT date::text AS date, content + FROM memory_entries + WHERE project_id = ${projectId} + AND mood IS NOT NULL + ORDER BY date DESC, created_at DESC + ` + + return { entries: rows } + }, + ) +} diff --git a/apps/server/src/services/workflow/index.ts b/apps/server/src/services/workflow/index.ts index 189c6fc..50fa278 100644 --- a/apps/server/src/services/workflow/index.ts +++ b/apps/server/src/services/workflow/index.ts @@ -1,5 +1,35 @@ // v2.8.0: Dynamic Workflow Engine — public surface. // +// ## Status: experimental / intentionally decoupled from the coder flow-runner +// +// This module is an in-process multi-agent orchestrator that creates BooChat +// sessions+chats and dispatches inference via the native `runInference` +// pipeline. It is NOT currently wired into the server (`apps/server/src/index.ts`) +// — no routes import it, no service initialises it, and the server has no +// `projectRoot`/`projectId` concept at startup. All code is preserved for future +// evaluation but is not in use. +// +// ## Relationship to the coder flow-runner +// +// The canonical orchestrator implementation lives at: +// `apps/coder/src/services/flow-runner.ts` (1102 lines, actively wired) +// +// The two modules serve different dispatch strategies: +// +// | Dimension | Server WorkflowManager (this) | Coder flow-runner | +// |-------------------|-----------------------------------|------------------------------------| +// | Dispatch | In-process via `runInference` | Task rows → external agent binary | +// | Agent target | BooChat native inference | qwen via PTY (--approval-mode plan)| +// | Session model | Per-agent BooChat sessions+chats | Per-step synthetic sessions | +// | Persistence | In-memory (Map) | DB-backed (flow_runs/flow_steps) | +// | Lifecycle | Polling loop + AbortController | Dispatcher hook (onTaskTerminal) | +// | Status | Experimental, not wired | Active, production | +// +// These two engines are NOT competitors — they are alternative approaches for +// different dispatch surfaces. Use the coder flow-runner for the current +// orchestrator; revisit this module if in-process BooChat-native multi-agent +// orchestration becomes a requirement. +// // Re-exports all types and classes from the workflow sub-modules so consumers // import from a single entry point: // diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 3fa6007..bf5876a 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -9,6 +9,7 @@ import { Session } from '@/pages/Session'; import { Settings } from '@/pages/Settings'; import { Analytics } from '@/pages/Analytics'; import { Results } from '@/pages/Results'; +import { Memory } from '@/pages/Memory'; import { Toaster } from '@/components/ui/sonner'; import { useUserEvents } from '@/hooks/useUserEvents'; import { useCoderUserEvents } from '@/hooks/useCoderUserEvents'; @@ -19,6 +20,7 @@ import { useViewport } from '@/hooks/useViewport'; import { ThemeFx } from '@/components/fx/ThemeFx'; import { FlowLauncherDialog } from '@/components/FlowLauncherDialog'; import { ArenaLauncherDialog } from '@/components/ArenaLauncherDialog'; +import { KeyboardShortcutsDialog } from '@/components/KeyboardShortcutsDialog'; function SessionRightRail() { const { id } = useParams<{ id: string }>(); @@ -75,6 +77,19 @@ function AppShell() { useTheme(); useUserEvents(); useCoderUserEvents(); + const [showShortcuts, setShowShortcuts] = useState(false); + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === '?' && !e.metaKey && !e.ctrlKey && !e.altKey) { + const tag = (e.target as HTMLElement)?.tagName; + if (tag !== 'INPUT' && tag !== 'TEXTAREA' && !(e.target as HTMLElement)?.isContentEditable) { + setShowShortcuts((v) => !v); + } + } + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, []); // v1.10.8c: h-dvh (dynamic viewport) instead of h-screen (100vh) so the // root height excludes the iOS URL-bar overlay area. Without this, every // descendant — including the terminal pane — measures itself against a @@ -99,6 +114,7 @@ function AppShell() { } /> } /> } /> + } /> @@ -108,6 +124,7 @@ function AppShell() { + ); diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 806a3ef..045ab56 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -663,6 +663,14 @@ export const api = { method: 'PATCH', body: JSON.stringify(body), }), + inference: { + get: () => request>('/api/settings/inference'), + patch: (body: Record) => + request>('/api/settings/inference', { + method: 'PATCH', + body: JSON.stringify(body), + }), + }, }, sidebar: { diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index ebce2bd..d7a4f51 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -524,6 +524,7 @@ export type WsFrame = | { type: 'snapshot'; messages: Message[] } | { type: 'message_started'; message_id: string; chat_id?: string; role: MessageRole; compare_group_id?: string } | { type: 'delta'; message_id: string; chat_id?: string; content: string; compare_group_id?: string } + | { type: 'reasoning_delta'; message_id: string; chat_id?: string; content: string } | { type: 'tool_call'; message_id: string; chat_id?: string; tool_call: ToolCall } | { type: 'tool_result'; @@ -656,6 +657,13 @@ export type WsFrame = outcome?: string; finished_at: string; } + | { + type: 'collision_warning'; + file_path: string; + worktrees: string[]; + agents: string[]; + severity: 'same_line' | 'adjacent_line' | 'different_area'; + } // arena frames: battle lifecycle + per-contestant streaming | { type: 'battle_started'; diff --git a/apps/web/src/components/ChatInput.tsx b/apps/web/src/components/ChatInput.tsx index fad159f..13c08a9 100644 --- a/apps/web/src/components/ChatInput.tsx +++ b/apps/web/src/components/ChatInput.tsx @@ -24,6 +24,7 @@ import type { Message } from '@/api/types'; import { sessionEvents } from '@/hooks/sessionEvents'; import { chatInputsRegistry, sendToChat } from '@/lib/events'; import { useSkills } from '@/hooks/useSkills'; +import { useDraftPersistence } from '@/hooks/useDraftPersistence'; import { useViewport } from '@/hooks/useViewport'; const MAX_ATTACHMENTS = 10; @@ -99,6 +100,7 @@ interface Props { export function ChatInput({ disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend, generating, onStop, stopDisabled, onSlashCommand, slashGroups, chatId, chatLabel, messages, modelContextLimit }: Props) { const { isMobile } = useViewport(); const [value, setValue] = useState(''); + const { draft, setDraft, clearDraft } = useDraftPersistence(chatId); const [busy, setBusy] = useState(false); const [attachments, setAttachments] = useState([]); const [previewAttachment, setPreviewAttachment] = useState(null); @@ -207,6 +209,11 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session }); }, [chatId]); + // Initialize textarea from saved draft on mount. + useEffect(() => { + if (draft) setValue(draft); + }, [draft]); + function removeAttachment(id: string) { setAttachments(prev => prev.filter(a => a.id !== id)); } @@ -247,6 +254,7 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session input: { question: flowParsed.args.length > 0 ? flowParsed.args : flowParsed.cmdName }, }); setValue(''); + clearDraft(); setAttachments([]); setSlashState(null); sessionEvents.emit({ @@ -272,6 +280,7 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session try { await onSlashCommand(parsed.cmdName, parsed.args); setValue(''); + clearDraft(); setAttachments([]); setSlashState(null); } catch (err) { @@ -289,6 +298,7 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session const body = flattenToMessage(attachments, text); await onSend(body); setValue(''); + clearDraft(); setAttachments([]); } catch (err) { toast.error(err instanceof Error ? err.message : 'failed to send'); @@ -356,6 +366,7 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session function handleChange(e: React.ChangeEvent) { const newValue = e.target.value; setValue(newValue); + setDraft(newValue); const ta = e.target; const pos = ta.selectionStart; @@ -627,6 +638,7 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session const body = flattenToMessage(attachments, text); await onForceSend(body); setValue(''); + clearDraft(); setAttachments([]); } catch (err) { toast.error(err instanceof Error ? err.message : 'force send failed'); diff --git a/apps/web/src/components/InferenceSettings.tsx b/apps/web/src/components/InferenceSettings.tsx index 8e9feea..8a07d7d 100644 --- a/apps/web/src/components/InferenceSettings.tsx +++ b/apps/web/src/components/InferenceSettings.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { Database, Zap, Clock, BarChart3, Folder } from 'lucide-react'; +import { api } from '@/api/client'; interface InferenceConfig { cache_type_k: string; @@ -58,9 +59,8 @@ export function InferenceSettings() { const [saving, setSaving] = useState(false); useEffect(() => { - fetch('/api/settings/inference') - .then((r) => (r.ok ? r.json() : Promise.reject())) - .then((data) => setConfig(data as InferenceConfig)) + api.settings.inference.get() + .then((data) => setConfig(data as unknown as InferenceConfig)) .catch(() => { setConfig({ ...DEFAULTS }); toast.error('Could not load inference config — loading defaults'); @@ -76,14 +76,8 @@ export function InferenceSettings() { if (!config || saving) return; setSaving(true); try { - const res = await fetch('/api/settings/inference', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(config), - }); - if (!res.ok) throw new Error('Save failed'); - const updated = (await res.json()) as InferenceConfig; - setConfig(updated); + const updated = await api.settings.inference.patch(config as unknown as Record); + setConfig(updated as unknown as InferenceConfig); toast.success('Inference settings saved'); } catch (err) { toast.error(err instanceof Error ? err.message : 'Save failed'); diff --git a/apps/web/src/components/MessageBubble.tsx b/apps/web/src/components/MessageBubble.tsx index c85d3fa..c700410 100644 --- a/apps/web/src/components/MessageBubble.tsx +++ b/apps/web/src/components/MessageBubble.tsx @@ -1,97 +1,18 @@ -import { memo, useEffect, useMemo, useState } from 'react'; -import type { ReactNode } from 'react'; -import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw, GitFork, Trash2, Brain, History, AlertCircle } from 'lucide-react'; -import { toast } from 'sonner'; +import { memo, useMemo } from 'react'; import type { Chat, ErrorReason, Message } from '@/api/types'; -import { api } from '@/api/client'; -import { sessionEvents } from '@/hooks/sessionEvents'; -import { sendToTerminal, terminalsRegistry, type TerminalRegistration } from '@/lib/events'; import { shortenModelName } from '@/lib/modelName'; import { CapHitSentinel } from './CapHitSentinel'; import { DoomLoopSentinel } from './DoomLoopSentinel'; import { MarkdownRenderer } from './MarkdownRenderer'; -import { Button } from '@/components/ui/button'; import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuSub, - ContextMenuSubContent, - ContextMenuSubTrigger, - ContextMenuTrigger, -} from '@/components/ui/context-menu'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; - -// 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. -function useTerminals(): TerminalRegistration[] { - const [list, setList] = useState(() => terminalsRegistry.list()); - useEffect(() => terminalsRegistry.subscribe(() => setList(terminalsRegistry.list())), []); - return list; -} - -// Wrap a message body with a right-click context menu offering Copy and -// "Send to terminal → ". Send is disabled when nothing is -// selected or no terminal panes are open; clicking a target emits a -// sendToTerminal event that TerminalPane subscribes to (filtered by pane_id). -function SendToTerminalMenu({ children }: { children: ReactNode }) { - const [selection, setSelection] = useState(''); - const terminals = useTerminals(); - const hasSelection = selection.length > 0; - const canSend = hasSelection && terminals.length > 0; - - return ( - { - if (open) { - const sel = typeof window !== 'undefined' ? window.getSelection()?.toString() ?? '' : ''; - setSelection(sel); - } - }} - > - {children} - - { - void navigator.clipboard.writeText(selection).catch((err) => { - toast.error(err instanceof Error ? err.message : 'copy failed'); - }); - }} - > - Copy - - - - Send to terminal - - {terminals.length === 0 ? ( - No terminal panes open - ) : ( - terminals.map((t) => ( - sendToTerminal.emit({ pane_id: t.paneId, text: selection })} - > - {t.label} - - )) - )} - - - - - ); -} + StatsLine, + ActionRow, + CompactCard, + SummaryCard, + ReasoningBlock, + MistakeRecoverySentinel, + SendToTerminalMenu, +} from './message-parts'; // v1.8.2: human labels for the machine-readable error reasons that ride on // failed assistant messages via metadata.kind === 'error'. Kept short so the @@ -137,584 +58,6 @@ interface Props { restoreDisabled?: boolean; } -function StatsLine({ message }: { message: Message }) { - const tokens = message.tokens_used; - if (typeof tokens !== 'number' || tokens <= 0) return null; - const started = message.started_at ? Date.parse(message.started_at) : NaN; - const finished = message.finished_at ? Date.parse(message.finished_at) : NaN; - let tps: number | null = null; - if (!Number.isNaN(started) && !Number.isNaN(finished) && finished > started) { - const seconds = (finished - started) / 1000; - if (seconds > 0) tps = Math.round((tokens / seconds) * 10) / 10; - } - const ctxUsed = message.ctx_used; - const ctxMax = message.ctx_max; - const ctxPart = - typeof ctxUsed === 'number' - ? typeof ctxMax === 'number' && ctxMax > 0 - ? `${ctxUsed} / ${ctxMax} ctx` - : `${ctxUsed} ctx` - : null; - - const cacheHit = message.cache_tokens; - const reasoning = message.reasoning_tokens; - const cachePart = typeof cacheHit === 'number' && cacheHit > 0 ? `cache ${cacheHit}` : null; - const reasoningPart = typeof reasoning === 'number' && reasoning > 0 ? `think ${reasoning}` : null; - - const parts: string[] = [`${tokens} tokens`]; - if (tps !== null) parts.push(`${tps.toFixed(1)} tok/s`); - if (ctxPart) parts.push(ctxPart); - if (cachePart) parts.push(cachePart); - if (reasoningPart) parts.push(reasoningPart); - - return ( -
- {parts.join(' · ')} -
- ); -} - -function ActionRow({ - message, - actions, - hiddenSet, - hasCheckpoint = false, - restoreDisabled = false, -}: { - message: Message; - actions?: MessageActions; - hiddenSet: Set; - 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); - - 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); - } - } - - 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 ( - <> -
- - {canResend && ( - - )} - {isAssistant && ( - - )} - {!hiddenSet.has('fork') && ( - - )} - {!hiddenSet.has('delete') && ( - - )} - {canRestore && ( - - )} -
- { - if (!deleting) setDeleteOpen(open); - }} - > - - - Delete this message and all messages after it? - - This removes the selected message and every later message in this chat. This cannot be undone. - - - - - - - - - { - if (!restoring) setRestoreOpen(open); - }} - > - - - Restore to this point? - - This resets the worktree to before this turn, removes every later - message in this chat, and resets the agent's session. This cannot - be undone. - - - - - - - - - - ); -} - -function CompactCard({ message, sessionChats }: { message: Message; sessionChats?: Chat[] }) { - const [expanded, setExpanded] = useState(false); - const [copied, setCopied] = useState(false); - const [shareOpen, setShareOpen] = useState(false); - const [rerunning, setRerunning] = useState(false); - - const headerMatch = message.content.match(/^\[Context compacted — (\d+) messages summarized\]/); - const headerText = headerMatch ? headerMatch[0] : 'Context compacted'; - const summaryText = headerMatch - ? message.content.slice(headerMatch[0].length).trim() - : message.content; - - async function handleCopy() { - try { - await navigator.clipboard.writeText(summaryText); - setCopied(true); - setTimeout(() => setCopied(false), 1200); - toast.success('Summary copied to clipboard'); - } catch { - toast.error('Copy failed'); - } - } - - async function handleShareToChat(chat: Chat) { - try { - await api.messages.send(chat.id, summaryText); - toast.success(`Summary sent to ${chat.name ?? 'New chat'}`); - setShareOpen(false); - } catch (err) { - toast.error(err instanceof Error ? err.message : 'Failed to share'); - } - } - - async function handleRerun() { - if (rerunning) return; - setRerunning(true); - try { - await api.chats.compact(message.chat_id); - } catch (err) { - toast.error(err instanceof Error ? err.message : 'Re-run failed'); - } finally { - setRerunning(false); - } - } - - const otherChats = (sessionChats ?? []).filter( - (c) => c.id !== message.chat_id && c.status === 'open' - ); - - return ( -
-
- - -
- - {shareOpen && ( -
- {otherChats.length === 0 ? ( -
- No other chats in this session -
- ) : ( - otherChats.map((c) => ( - - )) - )} -
- )} -
- -
- {expanded && ( -
- {summaryText} -
- )} -
- ); -} - -// 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. -function SummaryCard({ message }: { message: Message }) { - const [expanded, setExpanded] = useState(false); - const [copied, setCopied] = useState(false); - - // Use finished_at when available (that's when the summary actually landed); - // fall back to created_at for any row missing it. Both are ISO strings. - const ts = message.finished_at ?? message.created_at; - const headerTs = ts ? new Date(ts).toLocaleString() : ''; - - async function handleCopy() { - try { - await navigator.clipboard.writeText(message.content); - setCopied(true); - setTimeout(() => setCopied(false), 1200); - toast.success('Summary copied to clipboard'); - } catch { - toast.error('Copy failed'); - } - } - - return ( -
-
- - -
- {expanded && ( -
- -
- )} -
- ); -} - -// 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. -function ReasoningBlock({ text, streaming }: { text: string; streaming: boolean }) { - const [expanded, setExpanded] = useState(false); - return ( -
- - {expanded && ( -
- {text} -
- )} -
- ); -} - -// 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). -function MistakeRecoverySentinel({ message }: { message: Message }) { - const meta = message.metadata; - const isMistakeRecovery = - meta != null && typeof meta === 'object' && meta.kind === 'mistake_recovery'; - const failureKinds = isMistakeRecovery ? meta.failure_kinds : []; - const escalated = isMistakeRecovery ? meta.escalated : false; - const canContinue = isMistakeRecovery ? meta.can_continue === true : false; - - const [continuing, setContinuing] = useState(false); - - async function handleContinue() { - if (continuing || !canContinue) return; - setContinuing(true); - try { - await api.chats.continue(message.chat_id, message.id); - } catch (err) { - toast.error(err instanceof Error ? err.message : 'continue failed'); - } finally { - setContinuing(false); - } - } - - const kindsLabel = - Array.isArray(failureKinds) && failureKinds.length > 0 - ? failureKinds.join(', ') - : null; - - return ( -
-
- -
-
- {escalated ? 'Repeated errors — turn stopped' : 'Recovering from repeated errors'} -
-
- {escalated - ? 'Repeated errors persisted — stopped the turn.' - : kindsLabel - ? `Hit repeated different errors (${kindsLabel}) — recovery guidance injected, continuing.` - : 'Hit repeated different errors — recovery guidance injected, continuing.'} -
- {escalated && canContinue && ( -
- -
- )} -
-
-
- ); -} - export const MessageBubble = memo(function MessageBubble({ message, sessionChats, diff --git a/apps/web/src/components/ProjectSidebar.tsx b/apps/web/src/components/ProjectSidebar.tsx index 7dd3c32..b39b8c7 100644 --- a/apps/web/src/components/ProjectSidebar.tsx +++ b/apps/web/src/components/ProjectSidebar.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { NavLink, useLocation, useNavigate } from 'react-router-dom'; -import { BarChart3, ChevronRight, ExternalLink, Folder, MessageSquare, Plus, ScrollText, Settings as SettingsIcon, X, Code } from 'lucide-react'; +import { BarChart3, Brain, ChevronRight, ExternalLink, Folder, MessageSquare, Plus, ScrollText, Settings as SettingsIcon, X, Code } from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import mascot from '@/assets/brand/banner-mascot.png'; @@ -549,6 +549,20 @@ export function ProjectSidebar() { Token Analytics + { if (isMobile) setDrawerOpen(false); }} + className={({ isActive }) => + `w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm hover:bg-sidebar-accent/60 text-sidebar-foreground ${ + isActive ? 'bg-sidebar-accent text-sidebar-accent-foreground' : '' + }` + } + aria-label="Memory" + > + + Memory + + {/* v1.9: bottom-pinned Settings button. In a session, opens/focuses the workspace settings pane via the sessionEvents bus (Session.tsx owns the panesHook). Outside a session there's no workspace to mount the diff --git a/apps/web/src/components/ToolCallLine.tsx b/apps/web/src/components/ToolCallLine.tsx index 580b456..750ecfe 100644 --- a/apps/web/src/components/ToolCallLine.tsx +++ b/apps/web/src/components/ToolCallLine.tsx @@ -2,8 +2,10 @@ import { useState } from 'react'; import { Check, ChevronRight, Loader2, ShieldAlert, X } from 'lucide-react'; import type { ToolCall, ToolResult } from '@/api/types'; import { linkifyPaths } from '@/lib/linkify-paths'; +import { isMcpTool } from '@/lib/tool-utils'; import { DiffSnippet } from './DiffSnippet'; import { McpPermissionDialog } from './McpPermissionDialog'; +import { McpResponseDisplay } from './McpResponseDisplay'; // v1.8.2: cap on the inline arg-summary length. Expanded view shows full // args + full result, so this is purely a single-line render budget. @@ -58,33 +60,6 @@ function formatToolArgs(name: string, args: Record): string { ARG_SUMMARY_MAX, ); } - // v1.12 Track B.2: codecontext tool pills. Format is "most-identifying-arg", - // matching view_file/grep precedent — surface the path/symbol/query that - // makes the call meaningful at a glance. - if (name === 'get_codebase_overview') { - return ''; - } - if (name === 'get_file_analysis') { - return truncate(String(args.file_path ?? ''), ARG_SUMMARY_MAX); - } - if (name === 'get_symbol_info') { - return truncate(String(args.symbol_name ?? ''), ARG_SUMMARY_MAX); - } - if (name === 'search_symbols') { - return truncate(`"${String(args.query ?? '')}"`, ARG_SUMMARY_MAX); - } - if (name === 'get_dependencies') { - return truncate(String(args.file_path ?? '(project-wide)'), ARG_SUMMARY_MAX); - } - if (name === 'watch_changes') { - return args.enable ? 'enable' : 'disable'; - } - if (name === 'get_semantic_neighborhoods') { - return truncate(String(args.file_path ?? '(project-wide)'), ARG_SUMMARY_MAX); - } - if (name === 'get_framework_analysis') { - return truncate(String(args.framework ?? '(auto-detect)'), ARG_SUMMARY_MAX); - } // Unknown tool — surface first arg value or the literal {} so the user can // see something happened. Forward-compatible with future tools. const keys = Object.keys(args); @@ -170,7 +145,9 @@ export function ToolCallLine({ run, insideGroup, chatId }: Props) {
             {JSON.stringify(args, null, 2)}
           
- {run.result && ( + {run.result && isMcpTool(run.call.name) ? ( + + ) : run.result ? (
               {run.result.error ? (
                 needsApproval ? (
@@ -205,7 +182,7 @@ export function ToolCallLine({ run, insideGroup, chatId }: Props) {
                 
— output truncated —
)}
- )} + ) : null} {needsApproval && chatId && ( - {parts.join(' · ')} +
+
+ {parts.join(' · ')} +
+
); } diff --git a/apps/web/src/components/message-parts/index.ts b/apps/web/src/components/message-parts/index.ts index 080900f..54096e6 100644 --- a/apps/web/src/components/message-parts/index.ts +++ b/apps/web/src/components/message-parts/index.ts @@ -1,3 +1,4 @@ +// Barrel exports — imported by MessageBubble.tsx export { StatsLine } from './StatsLine'; export { ActionRow } from './ActionRow'; export { CompactCard } from './CompactCard'; diff --git a/apps/web/src/hooks/sessionEvents.ts b/apps/web/src/hooks/sessionEvents.ts index 3ff7f78..e1af32f 100644 --- a/apps/web/src/hooks/sessionEvents.ts +++ b/apps/web/src/hooks/sessionEvents.ts @@ -279,6 +279,23 @@ export interface BattleUpdatedEvent { cross_exam_id?: string; } +// Collision warning: published when the BooCoder detects multiple agents +// editing the same file concurrently. Advisory only — writes are not blocked. +export interface CollisionWarningEvent { + type: 'collision_warning'; + file_path: string; + agents: string[]; +} + +// Inter-agent message: one agent step sends a live message to another step +// in the same flow run. +export interface AgentMessageEvent { + type: 'agent_message'; + from_agent: string; + to_agent: string; + content: string; +} + // Re-export arena API shapes for consumers that need the full battle data. export type { BattleShape, ContestantShape, CrossExaminationShape }; @@ -318,7 +335,9 @@ export type SessionEvent = | OpenArenaPaneEvent | BattleStartedEvent | ContestantUpdatedEvent - | BattleUpdatedEvent; + | BattleUpdatedEvent + | CollisionWarningEvent + | AgentMessageEvent; type Listener = (event: SessionEvent) => void; const listeners = new Set(); diff --git a/apps/web/src/hooks/useCoderUserEvents.ts b/apps/web/src/hooks/useCoderUserEvents.ts index 530dd23..4c3133a 100644 --- a/apps/web/src/hooks/useCoderUserEvents.ts +++ b/apps/web/src/hooks/useCoderUserEvents.ts @@ -9,8 +9,10 @@ import { useEffect } from 'react'; import { WsFrameSchema } from '@boocode/contracts/ws-frames'; import { sessionEvents } from './sessionEvents'; import type { + AgentMessageEvent, BattleStartedEvent, BattleUpdatedEvent, + CollisionWarningEvent, ContestantUpdatedEvent, FlowRunStartedEvent, FlowRunStepUpdatedEvent, @@ -61,6 +63,19 @@ export function useCoderUserEvents(): void { sessionEvents.emit(frame as unknown as ContestantUpdatedEvent); } else if (frame.type === 'battle_updated') { sessionEvents.emit(frame as unknown as BattleUpdatedEvent); + } else if (frame.type === 'agent_message') { + sessionEvents.emit({ + type: 'agent_message', + from_agent: frame.sender_step_id, + to_agent: frame.channel ?? '', + content: frame.content, + } as AgentMessageEvent); + } else if (frame.type === 'collision_warning') { + sessionEvents.emit({ + type: 'collision_warning', + file_path: frame.file_path, + agents: frame.agents, + } as CollisionWarningEvent); } }; diff --git a/apps/web/src/hooks/useSessionStream.ts b/apps/web/src/hooks/useSessionStream.ts index aea378c..6c921cd 100644 --- a/apps/web/src/hooks/useSessionStream.ts +++ b/apps/web/src/hooks/useSessionStream.ts @@ -324,6 +324,23 @@ function applyFrame(state: State, frame: WsFrame): State { case 'channel_delta': { return state; } + case 'reasoning_delta': { + const next = state.messages.map((m) => { + if (m.id !== frame.message_id) return m; + const chunk = frame.content ?? ''; + return { ...m, reasoning_text: (m.reasoning_text ?? '') + chunk }; + }); + return { ...state, messages: next }; + } + case 'tool_trace_start': + case 'tool_trace_finish': + case 'collision_warning': + case 'agent_message': { + if (typeof console !== 'undefined') { + console.debug(`ws-frame (acknowledged): ${frame.type}`, frame); + } + return state; + } default: { return state; } diff --git a/apps/web/src/hooks/useSidebar.ts b/apps/web/src/hooks/useSidebar.ts index 8096d08..469de05 100644 --- a/apps/web/src/hooks/useSidebar.ts +++ b/apps/web/src/hooks/useSidebar.ts @@ -202,6 +202,10 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess case 'battle_updated': // Consumed by useWorkspacePanes / ArenaPane / ArenaLauncherDialog; sidebar has no stake. return prev; + case 'collision_warning': + case 'agent_message': + // Published by BooCoder on the coder user channel; sidebar has no stake. + return prev; case 'project_archived': { const next = prev.projects.filter((p) => p.id !== event.project_id); if (next.length === prev.projects.length) return prev; @@ -229,6 +233,8 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess }); return changed ? { ...prev, projects } : prev; } + default: + return prev; } }