feat: component wiring integration — orphan cleanup, Memory page, WS handlers
Memory page: Added REST endpoints (routes/memory.ts, 3 GETs: list/daily/dreams), React route in App.tsx, nav link in ProjectSidebar (Brain icon). Orphan components wired: KeyboardShortcutsDialog (? key in AppShell), McpResponseDisplay (MCP tool results in ToolCallLine), CacheShapeBadge (StatsLine in MessageBubble). MessageBoundary + MessageListErrorBoundary confirmed already wired in MarkdownRenderer/MessageList. Dead code cleanup: useDraftPersistence integrated into ChatInput (localStorage draft save/restore/clear on send). message-parts barrel made canonical — MessageBubble imports from it; StatsLine updated with CacheShapeBadge parity. api.settings.inference typed wrapper added; InferenceSettings raw fetch replaced. WS frame handlers: reasoning_delta (accumulates like delta), tool_trace_start, tool_trace_finish, collision_warning, agent_message acknowledged in useSessionStream. CollisionWarningEvent + AgentMessageEvent added to sessionEvents union. Forwarding in useCoderUserEvents. reasoning_delta + collision_warning added to web WsFrame type. useSidebar default case fixes pre-existing fallthrough error. Workflow engine: services/workflow/index.ts documented as experimental; coder flow-runner (apps/coder/src/services/flow-runner.ts) is canonical. Verification: web type-check clean, server build clean, 627 tests pass.
This commit is contained in:
@@ -21,6 +21,7 @@ import { registerSkillsRoutes } from './routes/skills.js';
|
|||||||
import { registerTraceRoutes } from './routes/traces.js';
|
import { registerTraceRoutes } from './routes/traces.js';
|
||||||
import { registerToolsRoutes } from './routes/tools.js';
|
import { registerToolsRoutes } from './routes/tools.js';
|
||||||
import { registerAnalyticsRoutes } from './routes/analytics.js';
|
import { registerAnalyticsRoutes } from './routes/analytics.js';
|
||||||
|
import { registerMemoryRoutes } from './routes/memory.js';
|
||||||
|
|
||||||
import { registerInferenceSettingsRoutes } from './routes/inference-settings.js';
|
import { registerInferenceSettingsRoutes } from './routes/inference-settings.js';
|
||||||
import { createInferenceRunner, runInferenceWithModel } from './services/inference/index.js';
|
import { createInferenceRunner, runInferenceWithModel } from './services/inference/index.js';
|
||||||
@@ -155,6 +156,7 @@ async function main() {
|
|||||||
hasActiveInference: (chatId) => inference.hasActive(chatId),
|
hasActiveInference: (chatId) => inference.hasActive(chatId),
|
||||||
});
|
});
|
||||||
registerTraceRoutes(app, sql);
|
registerTraceRoutes(app, sql);
|
||||||
|
registerMemoryRoutes(app, sql);
|
||||||
registerToolsRoutes(app, sql);
|
registerToolsRoutes(app, sql);
|
||||||
registerAnalyticsRoutes(app, sql);
|
registerAnalyticsRoutes(app, sql);
|
||||||
registerInferenceSettingsRoutes(app);
|
registerInferenceSettingsRoutes(app);
|
||||||
|
|||||||
91
apps/server/src/routes/memory.ts
Normal file
91
apps/server/src/routes/memory.ts
Normal file
@@ -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=<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<MemoryEntryRow[]>`
|
||||||
|
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=<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<DailyMemoryEntryRow[]>`
|
||||||
|
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=<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<DreamEntryRow[]>`
|
||||||
|
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 }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,35 @@
|
|||||||
// v2.8.0: Dynamic Workflow Engine — public surface.
|
// 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<runId, state>) | 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
|
// Re-exports all types and classes from the workflow sub-modules so consumers
|
||||||
// import from a single entry point:
|
// import from a single entry point:
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Session } from '@/pages/Session';
|
|||||||
import { Settings } from '@/pages/Settings';
|
import { Settings } from '@/pages/Settings';
|
||||||
import { Analytics } from '@/pages/Analytics';
|
import { Analytics } from '@/pages/Analytics';
|
||||||
import { Results } from '@/pages/Results';
|
import { Results } from '@/pages/Results';
|
||||||
|
import { Memory } from '@/pages/Memory';
|
||||||
import { Toaster } from '@/components/ui/sonner';
|
import { Toaster } from '@/components/ui/sonner';
|
||||||
import { useUserEvents } from '@/hooks/useUserEvents';
|
import { useUserEvents } from '@/hooks/useUserEvents';
|
||||||
import { useCoderUserEvents } from '@/hooks/useCoderUserEvents';
|
import { useCoderUserEvents } from '@/hooks/useCoderUserEvents';
|
||||||
@@ -19,6 +20,7 @@ import { useViewport } from '@/hooks/useViewport';
|
|||||||
import { ThemeFx } from '@/components/fx/ThemeFx';
|
import { ThemeFx } from '@/components/fx/ThemeFx';
|
||||||
import { FlowLauncherDialog } from '@/components/FlowLauncherDialog';
|
import { FlowLauncherDialog } from '@/components/FlowLauncherDialog';
|
||||||
import { ArenaLauncherDialog } from '@/components/ArenaLauncherDialog';
|
import { ArenaLauncherDialog } from '@/components/ArenaLauncherDialog';
|
||||||
|
import { KeyboardShortcutsDialog } from '@/components/KeyboardShortcutsDialog';
|
||||||
|
|
||||||
function SessionRightRail() {
|
function SessionRightRail() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
@@ -75,6 +77,19 @@ function AppShell() {
|
|||||||
useTheme();
|
useTheme();
|
||||||
useUserEvents();
|
useUserEvents();
|
||||||
useCoderUserEvents();
|
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
|
// 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
|
// root height excludes the iOS URL-bar overlay area. Without this, every
|
||||||
// descendant — including the terminal pane — measures itself against a
|
// descendant — including the terminal pane — measures itself against a
|
||||||
@@ -99,6 +114,7 @@ function AppShell() {
|
|||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
<Route path="/analytics" element={<Analytics />} />
|
<Route path="/analytics" element={<Analytics />} />
|
||||||
<Route path="/results" element={<Results />} />
|
<Route path="/results" element={<Results />} />
|
||||||
|
<Route path="/memory" element={<Memory />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
<MobileRightRailBackdrop />
|
<MobileRightRailBackdrop />
|
||||||
@@ -108,6 +124,7 @@ function AppShell() {
|
|||||||
<Toaster position="bottom-right" />
|
<Toaster position="bottom-right" />
|
||||||
<FlowLauncherDialog />
|
<FlowLauncherDialog />
|
||||||
<ArenaLauncherDialog />
|
<ArenaLauncherDialog />
|
||||||
|
<KeyboardShortcutsDialog open={showShortcuts} onOpenChange={setShowShortcuts} />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -663,6 +663,14 @@ export const api = {
|
|||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}),
|
}),
|
||||||
|
inference: {
|
||||||
|
get: () => request<Record<string, unknown>>('/api/settings/inference'),
|
||||||
|
patch: (body: Record<string, unknown>) =>
|
||||||
|
request<Record<string, unknown>>('/api/settings/inference', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
sidebar: {
|
sidebar: {
|
||||||
|
|||||||
@@ -524,6 +524,7 @@ export type WsFrame =
|
|||||||
| { type: 'snapshot'; messages: Message[] }
|
| { type: 'snapshot'; messages: Message[] }
|
||||||
| { type: 'message_started'; message_id: string; chat_id?: string; role: MessageRole; compare_group_id?: string }
|
| { 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: '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_call'; message_id: string; chat_id?: string; tool_call: ToolCall }
|
||||||
| {
|
| {
|
||||||
type: 'tool_result';
|
type: 'tool_result';
|
||||||
@@ -656,6 +657,13 @@ export type WsFrame =
|
|||||||
outcome?: string;
|
outcome?: string;
|
||||||
finished_at: 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
|
// arena frames: battle lifecycle + per-contestant streaming
|
||||||
| {
|
| {
|
||||||
type: 'battle_started';
|
type: 'battle_started';
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import type { Message } from '@/api/types';
|
|||||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||||
import { chatInputsRegistry, sendToChat } from '@/lib/events';
|
import { chatInputsRegistry, sendToChat } from '@/lib/events';
|
||||||
import { useSkills } from '@/hooks/useSkills';
|
import { useSkills } from '@/hooks/useSkills';
|
||||||
|
import { useDraftPersistence } from '@/hooks/useDraftPersistence';
|
||||||
import { useViewport } from '@/hooks/useViewport';
|
import { useViewport } from '@/hooks/useViewport';
|
||||||
|
|
||||||
const MAX_ATTACHMENTS = 10;
|
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) {
|
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 { isMobile } = useViewport();
|
||||||
const [value, setValue] = useState('');
|
const [value, setValue] = useState('');
|
||||||
|
const { draft, setDraft, clearDraft } = useDraftPersistence(chatId);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||||
const [previewAttachment, setPreviewAttachment] = useState<Attachment | null>(null);
|
const [previewAttachment, setPreviewAttachment] = useState<Attachment | null>(null);
|
||||||
@@ -207,6 +209,11 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
|||||||
});
|
});
|
||||||
}, [chatId]);
|
}, [chatId]);
|
||||||
|
|
||||||
|
// Initialize textarea from saved draft on mount.
|
||||||
|
useEffect(() => {
|
||||||
|
if (draft) setValue(draft);
|
||||||
|
}, [draft]);
|
||||||
|
|
||||||
function removeAttachment(id: string) {
|
function removeAttachment(id: string) {
|
||||||
setAttachments(prev => prev.filter(a => a.id !== id));
|
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 },
|
input: { question: flowParsed.args.length > 0 ? flowParsed.args : flowParsed.cmdName },
|
||||||
});
|
});
|
||||||
setValue('');
|
setValue('');
|
||||||
|
clearDraft();
|
||||||
setAttachments([]);
|
setAttachments([]);
|
||||||
setSlashState(null);
|
setSlashState(null);
|
||||||
sessionEvents.emit({
|
sessionEvents.emit({
|
||||||
@@ -272,6 +280,7 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
|||||||
try {
|
try {
|
||||||
await onSlashCommand(parsed.cmdName, parsed.args);
|
await onSlashCommand(parsed.cmdName, parsed.args);
|
||||||
setValue('');
|
setValue('');
|
||||||
|
clearDraft();
|
||||||
setAttachments([]);
|
setAttachments([]);
|
||||||
setSlashState(null);
|
setSlashState(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -289,6 +298,7 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
|||||||
const body = flattenToMessage(attachments, text);
|
const body = flattenToMessage(attachments, text);
|
||||||
await onSend(body);
|
await onSend(body);
|
||||||
setValue('');
|
setValue('');
|
||||||
|
clearDraft();
|
||||||
setAttachments([]);
|
setAttachments([]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : 'failed to send');
|
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<HTMLTextAreaElement>) {
|
function handleChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
|
||||||
const newValue = e.target.value;
|
const newValue = e.target.value;
|
||||||
setValue(newValue);
|
setValue(newValue);
|
||||||
|
setDraft(newValue);
|
||||||
|
|
||||||
const ta = e.target;
|
const ta = e.target;
|
||||||
const pos = ta.selectionStart;
|
const pos = ta.selectionStart;
|
||||||
@@ -627,6 +638,7 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
|||||||
const body = flattenToMessage(attachments, text);
|
const body = flattenToMessage(attachments, text);
|
||||||
await onForceSend(body);
|
await onForceSend(body);
|
||||||
setValue('');
|
setValue('');
|
||||||
|
clearDraft();
|
||||||
setAttachments([]);
|
setAttachments([]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : 'force send failed');
|
toast.error(err instanceof Error ? err.message : 'force send failed');
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Database, Zap, Clock, BarChart3, Folder } from 'lucide-react';
|
import { Database, Zap, Clock, BarChart3, Folder } from 'lucide-react';
|
||||||
|
import { api } from '@/api/client';
|
||||||
|
|
||||||
interface InferenceConfig {
|
interface InferenceConfig {
|
||||||
cache_type_k: string;
|
cache_type_k: string;
|
||||||
@@ -58,9 +59,8 @@ export function InferenceSettings() {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/settings/inference')
|
api.settings.inference.get()
|
||||||
.then((r) => (r.ok ? r.json() : Promise.reject()))
|
.then((data) => setConfig(data as unknown as InferenceConfig))
|
||||||
.then((data) => setConfig(data as InferenceConfig))
|
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setConfig({ ...DEFAULTS });
|
setConfig({ ...DEFAULTS });
|
||||||
toast.error('Could not load inference config — loading defaults');
|
toast.error('Could not load inference config — loading defaults');
|
||||||
@@ -76,14 +76,8 @@ export function InferenceSettings() {
|
|||||||
if (!config || saving) return;
|
if (!config || saving) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/settings/inference', {
|
const updated = await api.settings.inference.patch(config as unknown as Record<string, unknown>);
|
||||||
method: 'PATCH',
|
setConfig(updated as unknown as InferenceConfig);
|
||||||
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);
|
|
||||||
toast.success('Inference settings saved');
|
toast.success('Inference settings saved');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : 'Save failed');
|
toast.error(err instanceof Error ? err.message : 'Save failed');
|
||||||
|
|||||||
@@ -1,97 +1,18 @@
|
|||||||
import { memo, useEffect, useMemo, useState } from 'react';
|
import { memo, useMemo } 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 type { Chat, ErrorReason, Message } from '@/api/types';
|
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 { shortenModelName } from '@/lib/modelName';
|
||||||
import { CapHitSentinel } from './CapHitSentinel';
|
import { CapHitSentinel } from './CapHitSentinel';
|
||||||
import { DoomLoopSentinel } from './DoomLoopSentinel';
|
import { DoomLoopSentinel } from './DoomLoopSentinel';
|
||||||
import { MarkdownRenderer } from './MarkdownRenderer';
|
import { MarkdownRenderer } from './MarkdownRenderer';
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
StatsLine,
|
||||||
ContextMenuContent,
|
ActionRow,
|
||||||
ContextMenuItem,
|
CompactCard,
|
||||||
ContextMenuSeparator,
|
SummaryCard,
|
||||||
ContextMenuSub,
|
ReasoningBlock,
|
||||||
ContextMenuSubContent,
|
MistakeRecoverySentinel,
|
||||||
ContextMenuSubTrigger,
|
SendToTerminalMenu,
|
||||||
ContextMenuTrigger,
|
} from './message-parts';
|
||||||
} 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 → <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).
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// v1.8.2: human labels for the machine-readable error reasons that ride on
|
// 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
|
// failed assistant messages via metadata.kind === 'error'. Kept short so the
|
||||||
@@ -137,584 +58,6 @@ interface Props {
|
|||||||
restoreDisabled?: boolean;
|
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 (
|
|
||||||
<div className="text-[10px] font-mono text-muted-foreground">
|
|
||||||
{parts.join(' · ')}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<>
|
|
||||||
<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()}
|
|
||||||
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>
|
|
||||||
)}
|
|
||||||
{!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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 (
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 (
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 (
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MessageBubble = memo(function MessageBubble({
|
export const MessageBubble = memo(function MessageBubble({
|
||||||
message,
|
message,
|
||||||
sessionChats,
|
sessionChats,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
|
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 { toast } from 'sonner';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import mascot from '@/assets/brand/banner-mascot.png';
|
import mascot from '@/assets/brand/banner-mascot.png';
|
||||||
@@ -549,6 +549,20 @@ export function ProjectSidebar() {
|
|||||||
<span className="flex-1 text-left">Token Analytics</span>
|
<span className="flex-1 text-left">Token Analytics</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
|
<NavLink
|
||||||
|
to="/memory"
|
||||||
|
onClick={() => { 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"
|
||||||
|
>
|
||||||
|
<Brain className="size-3.5 shrink-0 opacity-70" />
|
||||||
|
<span className="flex-1 text-left">Memory</span>
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
{/* v1.9: bottom-pinned Settings button. In a session, opens/focuses the
|
{/* v1.9: bottom-pinned Settings button. In a session, opens/focuses the
|
||||||
workspace settings pane via the sessionEvents bus (Session.tsx owns
|
workspace settings pane via the sessionEvents bus (Session.tsx owns
|
||||||
the panesHook). Outside a session there's no workspace to mount the
|
the panesHook). Outside a session there's no workspace to mount the
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import { useState } from 'react';
|
|||||||
import { Check, ChevronRight, Loader2, ShieldAlert, X } from 'lucide-react';
|
import { Check, ChevronRight, Loader2, ShieldAlert, X } from 'lucide-react';
|
||||||
import type { ToolCall, ToolResult } from '@/api/types';
|
import type { ToolCall, ToolResult } from '@/api/types';
|
||||||
import { linkifyPaths } from '@/lib/linkify-paths';
|
import { linkifyPaths } from '@/lib/linkify-paths';
|
||||||
|
import { isMcpTool } from '@/lib/tool-utils';
|
||||||
import { DiffSnippet } from './DiffSnippet';
|
import { DiffSnippet } from './DiffSnippet';
|
||||||
import { McpPermissionDialog } from './McpPermissionDialog';
|
import { McpPermissionDialog } from './McpPermissionDialog';
|
||||||
|
import { McpResponseDisplay } from './McpResponseDisplay';
|
||||||
|
|
||||||
// v1.8.2: cap on the inline arg-summary length. Expanded view shows full
|
// 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.
|
// args + full result, so this is purely a single-line render budget.
|
||||||
@@ -58,33 +60,6 @@ function formatToolArgs(name: string, args: Record<string, unknown>): string {
|
|||||||
ARG_SUMMARY_MAX,
|
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
|
// Unknown tool — surface first arg value or the literal {} so the user can
|
||||||
// see something happened. Forward-compatible with future tools.
|
// see something happened. Forward-compatible with future tools.
|
||||||
const keys = Object.keys(args);
|
const keys = Object.keys(args);
|
||||||
@@ -170,7 +145,9 @@ export function ToolCallLine({ run, insideGroup, chatId }: Props) {
|
|||||||
<pre className="text-[10px] text-muted-foreground font-mono whitespace-pre-wrap break-all bg-muted/30 rounded px-2 py-1">
|
<pre className="text-[10px] text-muted-foreground font-mono whitespace-pre-wrap break-all bg-muted/30 rounded px-2 py-1">
|
||||||
{JSON.stringify(args, null, 2)}
|
{JSON.stringify(args, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
{run.result && (
|
{run.result && isMcpTool(run.call.name) ? (
|
||||||
|
<McpResponseDisplay toolCall={run.call} toolResult={run.result} />
|
||||||
|
) : run.result ? (
|
||||||
<pre className="text-[11px] font-mono whitespace-pre-wrap bg-muted/30 rounded px-2 py-1 max-h-72 overflow-y-auto">
|
<pre className="text-[11px] font-mono whitespace-pre-wrap bg-muted/30 rounded px-2 py-1 max-h-72 overflow-y-auto">
|
||||||
{run.result.error ? (
|
{run.result.error ? (
|
||||||
needsApproval ? (
|
needsApproval ? (
|
||||||
@@ -205,7 +182,7 @@ export function ToolCallLine({ run, insideGroup, chatId }: Props) {
|
|||||||
<div className="text-muted-foreground/60 mt-1">— output truncated —</div>
|
<div className="text-muted-foreground/60 mt-1">— output truncated —</div>
|
||||||
)}
|
)}
|
||||||
</pre>
|
</pre>
|
||||||
)}
|
) : null}
|
||||||
{needsApproval && chatId && (
|
{needsApproval && chatId && (
|
||||||
<McpPermissionDialog
|
<McpPermissionDialog
|
||||||
toolCallId={run.call.id}
|
toolCallId={run.call.id}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Message } from '@/api/types';
|
import type { Message } from '@/api/types';
|
||||||
|
import { CacheShapeBadge } from '@/components/CacheShapeBadge';
|
||||||
|
|
||||||
export function StatsLine({ message }: { message: Message }) {
|
export function StatsLine({ message }: { message: Message }) {
|
||||||
const tokens = message.tokens_used;
|
const tokens = message.tokens_used;
|
||||||
@@ -31,8 +32,11 @@ export function StatsLine({ message }: { message: Message }) {
|
|||||||
if (reasoningPart) parts.push(reasoningPart);
|
if (reasoningPart) parts.push(reasoningPart);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="text-[10px] font-mono text-muted-foreground">
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
{parts.join(' · ')}
|
<div className="text-[10px] font-mono text-muted-foreground">
|
||||||
|
{parts.join(' · ')}
|
||||||
|
</div>
|
||||||
|
<CacheShapeBadge cacheTokens={cacheHit} totalTokens={tokens} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Barrel exports — imported by MessageBubble.tsx
|
||||||
export { StatsLine } from './StatsLine';
|
export { StatsLine } from './StatsLine';
|
||||||
export { ActionRow } from './ActionRow';
|
export { ActionRow } from './ActionRow';
|
||||||
export { CompactCard } from './CompactCard';
|
export { CompactCard } from './CompactCard';
|
||||||
|
|||||||
@@ -279,6 +279,23 @@ export interface BattleUpdatedEvent {
|
|||||||
cross_exam_id?: string;
|
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.
|
// Re-export arena API shapes for consumers that need the full battle data.
|
||||||
export type { BattleShape, ContestantShape, CrossExaminationShape };
|
export type { BattleShape, ContestantShape, CrossExaminationShape };
|
||||||
|
|
||||||
@@ -318,7 +335,9 @@ export type SessionEvent =
|
|||||||
| OpenArenaPaneEvent
|
| OpenArenaPaneEvent
|
||||||
| BattleStartedEvent
|
| BattleStartedEvent
|
||||||
| ContestantUpdatedEvent
|
| ContestantUpdatedEvent
|
||||||
| BattleUpdatedEvent;
|
| BattleUpdatedEvent
|
||||||
|
| CollisionWarningEvent
|
||||||
|
| AgentMessageEvent;
|
||||||
type Listener = (event: SessionEvent) => void;
|
type Listener = (event: SessionEvent) => void;
|
||||||
|
|
||||||
const listeners = new Set<Listener>();
|
const listeners = new Set<Listener>();
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ import { useEffect } from 'react';
|
|||||||
import { WsFrameSchema } from '@boocode/contracts/ws-frames';
|
import { WsFrameSchema } from '@boocode/contracts/ws-frames';
|
||||||
import { sessionEvents } from './sessionEvents';
|
import { sessionEvents } from './sessionEvents';
|
||||||
import type {
|
import type {
|
||||||
|
AgentMessageEvent,
|
||||||
BattleStartedEvent,
|
BattleStartedEvent,
|
||||||
BattleUpdatedEvent,
|
BattleUpdatedEvent,
|
||||||
|
CollisionWarningEvent,
|
||||||
ContestantUpdatedEvent,
|
ContestantUpdatedEvent,
|
||||||
FlowRunStartedEvent,
|
FlowRunStartedEvent,
|
||||||
FlowRunStepUpdatedEvent,
|
FlowRunStepUpdatedEvent,
|
||||||
@@ -61,6 +63,19 @@ export function useCoderUserEvents(): void {
|
|||||||
sessionEvents.emit(frame as unknown as ContestantUpdatedEvent);
|
sessionEvents.emit(frame as unknown as ContestantUpdatedEvent);
|
||||||
} else if (frame.type === 'battle_updated') {
|
} else if (frame.type === 'battle_updated') {
|
||||||
sessionEvents.emit(frame as unknown as BattleUpdatedEvent);
|
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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -324,6 +324,23 @@ function applyFrame(state: State, frame: WsFrame): State {
|
|||||||
case 'channel_delta': {
|
case 'channel_delta': {
|
||||||
return state;
|
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: {
|
default: {
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -202,6 +202,10 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
|
|||||||
case 'battle_updated':
|
case 'battle_updated':
|
||||||
// Consumed by useWorkspacePanes / ArenaPane / ArenaLauncherDialog; sidebar has no stake.
|
// Consumed by useWorkspacePanes / ArenaPane / ArenaLauncherDialog; sidebar has no stake.
|
||||||
return prev;
|
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': {
|
case 'project_archived': {
|
||||||
const next = prev.projects.filter((p) => p.id !== event.project_id);
|
const next = prev.projects.filter((p) => p.id !== event.project_id);
|
||||||
if (next.length === prev.projects.length) return prev;
|
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;
|
return changed ? { ...prev, projects } : prev;
|
||||||
}
|
}
|
||||||
|
default:
|
||||||
|
return prev;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user