feat(web): workspace components — ComparePane, Memory page, McpDialog, error boundaries, message-parts

- Add ComparePane.tsx: side-by-side AI response comparison
- Add Memory.tsx: memory management page with CRUD UI
- Add McpPermissionDialog.tsx: MCP tool permission approval dialog
- Add McpResponseDisplay.tsx: MCP response visualization
- Add MessageBoundary.tsx + MessageListErrorBoundary.tsx: error resilience
- Add EmptyState.tsx: contextual empty state component
- Add KeyboardShortcutsDialog.tsx: keyboard shortcut reference
- Add message-parts/: ActionRow, CompactCard, MistakeRecoverySentinel, ReasoningBlock, SendToTerminalMenu, StatsLine, SummaryCard
- Add useDraftPersistence.ts: draft message persistence hook
- Add useTerminals.ts: terminal session management hook
- Add keyboard-shortcuts.ts + tool-utils.ts: shared utilities
- Extend components: ChatInput, MessageBubble, MessageList, Workspace, panes
- Extend hooks: useTerminalSocket, useSessionStream test suite
- Update pages: Home, Project — workspace layout and session flow
This commit is contained in:
2026-06-08 03:49:22 +00:00
parent d3c7d286fc
commit aec209310e
51 changed files with 3352 additions and 96 deletions

View File

@@ -23,6 +23,8 @@ interface SocketDeps {
termRef: React.MutableRefObject<Terminal | null>;
sessionId: string;
paneId: string;
description?: string;
parentAgent?: string;
fit: TerminalFit['fit'];
getSize: TerminalFit['getSize'];
setSize: TerminalFit['setSize'];
@@ -40,6 +42,8 @@ export function useTerminalSocket({
termRef,
sessionId,
paneId,
description,
parentAgent,
fit,
getSize,
setSize,
@@ -276,7 +280,7 @@ export function useTerminalSocket({
fit();
const { cols, rows } = getSize();
api.terminals
.start(sessionId, paneId, cols, rows)
.start(sessionId, paneId, cols, rows, description, parentAgent)
.catch(() => {
/* WS handler will ensureSession itself — non-fatal */
})

View File

@@ -0,0 +1,98 @@
import { useCallback, useEffect, useRef, useState } from 'react';
const STORAGE_PREFIX = 'boocode_draft_';
const SAVE_DEBOUNCE_MS = 500;
function getKey(chatId: string): string {
return `${STORAGE_PREFIX}${chatId}`;
}
function readDraft(key: string): string {
if (typeof window === 'undefined') return '';
try {
return localStorage.getItem(key) ?? '';
} catch {
return '';
}
}
function writeDraft(key: string, text: string): void {
if (typeof window === 'undefined') return;
try {
if (text) {
localStorage.setItem(key, text);
} else {
localStorage.removeItem(key);
}
} catch {
// storage full or unavailable — silently ignore
}
}
function removeDraft(key: string): void {
if (typeof window === 'undefined') return;
try {
localStorage.removeItem(key);
} catch {
// silently ignore
}
}
export interface DraftPersistenceResult {
/** Current draft state, initialized from localStorage on mount. */
draft: string;
/** Update draft with 500ms debounced persistence to localStorage. */
setDraft: (text: string) => void;
/** Clear draft state and remove localStorage entry immediately. */
clearDraft: () => void;
/** Re-read from localStorage, update state, and return saved value. */
restoreDraft: () => string;
}
export function useDraftPersistence(chatId: string | undefined): DraftPersistenceResult {
const key = chatId ? getKey(chatId) : null;
const [draft, setDraftState] = useState(() => (key ? readDraft(key) : ''));
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const keyRef = useRef(key);
keyRef.current = key;
// Cleanup timer on unmount
useEffect(() => {
return () => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
}
};
}, []);
const setDraft = useCallback((text: string) => {
setDraftState(text);
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
}
timerRef.current = setTimeout(() => {
const k = keyRef.current;
if (k) writeDraft(k, text);
}, SAVE_DEBOUNCE_MS);
}, []);
const clearDraft = useCallback(() => {
setDraftState('');
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
const k = keyRef.current;
if (k) removeDraft(k);
}, []);
const restoreDraft = useCallback((): string => {
const k = keyRef.current;
if (!k) return '';
const saved = readDraft(k);
setDraftState(saved);
return saved;
}, []);
return { draft, setDraft, clearDraft, restoreDraft };
}

View File

@@ -0,0 +1,616 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import React, { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import type { Message, WsFrame } from '@/api/types';
import { useSessionStream } from './useSessionStream';
// ── Hoisted mock values ──────────────────────────────────────────────────────
const { mockMessagesList, mockEmit, mockSubscribe, mockRecordUsage } = vi.hoisted(
() => ({
mockMessagesList: vi.fn(),
mockEmit: vi.fn(),
mockSubscribe: vi.fn().mockReturnValue(vi.fn()),
mockRecordUsage: vi.fn(),
}),
);
// ── Module mocks ─────────────────────────────────────────────────────────────
vi.mock('sonner', () => ({
toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() },
}));
vi.mock('@/api/client', () => ({
api: { messages: { list: mockMessagesList } },
}));
vi.mock('./sessionEvents', () => ({
sessionEvents: { emit: mockEmit, subscribe: mockSubscribe },
}));
vi.mock('./useChatThroughput', () => ({
recordUsage: mockRecordUsage,
}));
// ── Test constants ───────────────────────────────────────────────────────────
const SESSION_ID = '00000000-0000-0000-0000-000000000001';
const CHAT_ID = '00000000-0000-0000-0000-000000000002';
const MSG_ID = '00000000-0000-0000-0000-000000000003';
const TOOL_MSG_ID = '00000000-0000-0000-0000-000000000004';
// ── Frame builder helpers ────────────────────────────────────────────────────
function textDelta(seq: number, content: string): WsFrame {
return {
type: 'channel_delta', seq, channel: 'text', message_id: MSG_ID, chat_id: CHAT_ID, content,
} as unknown as WsFrame;
}
function toolCallDelta(
seq: number,
tc: { id: string; name: string; args: Record<string, unknown> },
): WsFrame {
return {
type: 'channel_delta', seq, channel: 'tool_call', message_id: MSG_ID, chat_id: CHAT_ID, tool_call: tc,
} as unknown as WsFrame;
}
function toolResultDelta(seq: number, callId: string, output: unknown): WsFrame {
return {
type: 'channel_delta', seq, channel: 'tool_result',
tool_message_id: TOOL_MSG_ID, chat_id: CHAT_ID, tool_call_id: callId,
output, truncated: false,
} as unknown as WsFrame;
}
function statusDelta(
seq: number, status: 'running' | 'complete' | 'cancelled' | 'failed',
overrides?: Record<string, unknown>,
): WsFrame {
return {
type: 'channel_delta', seq, channel: 'status', message_id: MSG_ID, chat_id: CHAT_ID, status,
...(overrides ?? {}),
} as unknown as WsFrame;
}
function errorDelta(seq: number, error: string): WsFrame {
return {
type: 'channel_delta', seq, channel: 'error', message_id: MSG_ID, chat_id: CHAT_ID, error,
} as unknown as WsFrame;
}
// ── WebSocket mock globals ───────────────────────────────────────────────────
interface MockWs {
onopen: (() => void) | null;
onmessage: ((ev: { data: string }) => void) | null;
onclose: ((ev: { code?: number; reason?: string }) => void) | null;
onerror: (() => void) | null;
send: ReturnType<typeof vi.fn>;
close: ReturnType<typeof vi.fn>;
readyState: number;
url: string;
}
let currentMockWs: MockWs | null = null;
let wsConstructCount = 0;
function createWsMock(): MockWs {
return {
onopen: null, onmessage: null, onclose: null, onerror: null,
send: vi.fn(), close: vi.fn(), readyState: 1, url: '',
};
}
function triggerWsOpen(): void { currentMockWs?.onopen?.(); }
function triggerWsMessage(frame: WsFrame): void {
currentMockWs?.onmessage?.({ data: JSON.stringify(frame) });
}
function triggerWsClose(): void { currentMockWs?.onclose?.({}); }
function getWsSendCalls(): string[] {
return (currentMockWs?.send.mock.calls ?? []).map((c: unknown[]) => String(c[0]));
}
// ── React test harness ───────────────────────────────────────────────────────
function createHarness() {
const states: Array<{ messages: Message[]; connected: boolean; error: string | null }> = [];
function Wrapper({ sessionId }: { sessionId: string | undefined }) {
const state = useSessionStream(sessionId);
states.push(state);
return React.createElement('div');
}
return {
Wrapper,
lastState: () => states[states.length - 1] ?? null,
};
}
let root: Root;
let container: HTMLDivElement;
beforeEach(() => {
currentMockWs = null;
wsConstructCount = 0;
mockMessagesList.mockReset().mockResolvedValue([]);
mockEmit.mockReset();
mockSubscribe.mockReset().mockReturnValue(vi.fn());
vi.clearAllMocks();
vi.spyOn(window, 'WebSocket').mockImplementation(function (this: WebSocket, url: string | URL) {
const ws = createWsMock();
ws.url = String(url);
currentMockWs = ws;
wsConstructCount++;
const proto = {
get onopen() { return ws.onopen; },
set onopen(fn) { ws.onopen = fn as () => void; },
get onmessage() { return ws.onmessage; },
set onmessage(fn) { ws.onmessage = fn as (ev: { data: string }) => void; },
get onclose() { return ws.onclose; },
set onclose(fn) { ws.onclose = fn as (ev: { code?: number; reason?: string }) => void; },
get onerror() { return ws.onerror; },
set onerror(fn) { ws.onerror = fn as () => void; },
send: vi.fn((d: string) => ws.send(d)),
close: vi.fn(() => { ws.close(); ws.onclose?.({}); }),
readyState: 1, url: ws.url,
CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3,
};
return proto as unknown as WebSocket;
});
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
if (root) {
act(() => { root.unmount(); });
}
if (container.parentNode) {
container.parentNode.removeChild(container);
}
vi.restoreAllMocks();
currentMockWs = null;
});
async function renderHook(sessionId: string | undefined) {
const harness = createHarness();
await act(async () => {
root.render(
React.createElement(harness.Wrapper, {
sessionId: sessionId ?? (null as unknown as undefined),
}),
);
});
await act(async () => {});
return harness;
}
async function flush() {
await act(async () => {});
}
// ── Tests ────────────────────────────────────────────────────────────────────
describe('useSessionStream', () => {
describe('connection lifecycle', () => {
it('does not connect when sessionId is undefined', async () => {
const h = await renderHook(undefined);
const s = h.lastState();
expect(s).not.toBeNull();
expect(s!.connected).toBe(false);
expect(s!.messages).toEqual([]);
});
it('connects when sessionId is provided', async () => {
await renderHook(SESSION_ID);
expect(wsConstructCount).toBe(1);
});
it('sets connected=true on WebSocket open', async () => {
const h = await renderHook(SESSION_ID);
act(() => { triggerWsOpen(); });
await flush();
expect(h.lastState()!.connected).toBe(true);
expect(h.lastState()!.error).toBeNull();
});
it('sends reconnect frame with lastSeqPerChannel on open', async () => {
await renderHook(SESSION_ID);
act(() => { triggerWsOpen(); });
await flush();
const sends = getWsSendCalls();
const reconnectMsg = sends.find((s) => s.includes('reconnect'));
expect(reconnectMsg).toBeDefined();
if (reconnectMsg) {
const parsed = JSON.parse(reconnectMsg);
expect(parsed.type).toBe('reconnect');
expect(parsed.lastSeqPerChannel).toEqual({});
}
});
});
describe('non-channel frames', () => {
it('processes snapshot frame', async () => {
await renderHook(SESSION_ID);
act(() => { triggerWsOpen(); });
await flush();
const msg: Message = {
id: MSG_ID, session_id: SESSION_ID, chat_id: CHAT_ID, role: 'user', content: 'hi',
kind: 'message', tool_calls: null, tool_results: null, status: 'complete',
last_seq: 0, tokens_used: null, ctx_used: null, ctx_max: null,
cache_tokens: null, reasoning_tokens: null, model: null,
started_at: null, finished_at: null, created_at: '2026-01-01T00:00:00Z', metadata: null,
};
act(() => { triggerWsMessage({ type: 'snapshot', messages: [msg] } as WsFrame); });
await flush();
});
it('processes delta frame for existing message', async () => {
await renderHook(SESSION_ID);
act(() => { triggerWsOpen(); });
await flush();
const streamingMsg: Message = {
id: MSG_ID, session_id: SESSION_ID, chat_id: CHAT_ID, role: 'assistant', content: '',
kind: 'message', tool_calls: null, tool_results: null, status: 'streaming',
last_seq: 0, tokens_used: null, ctx_used: null, ctx_max: null,
cache_tokens: null, reasoning_tokens: null, model: null,
started_at: null, finished_at: null, created_at: '2026-01-01T00:00:00Z', metadata: null,
};
act(() => { triggerWsMessage({ type: 'snapshot', messages: [streamingMsg] } as WsFrame); });
await flush();
act(() => { triggerWsMessage({ type: 'delta', message_id: MSG_ID, content: 'Hello!' } as WsFrame); });
await flush();
});
it('emits git_diff_refresh on message_complete', async () => {
await renderHook(SESSION_ID);
act(() => { triggerWsOpen(); });
await flush();
act(() => {
triggerWsMessage({
type: 'message_started', message_id: MSG_ID, chat_id: CHAT_ID, role: 'assistant',
} as WsFrame);
});
await flush();
act(() => {
triggerWsMessage({
type: 'message_complete', message_id: MSG_ID, chat_id: CHAT_ID,
} as WsFrame);
});
await flush();
expect(mockEmit).toHaveBeenCalledWith(
expect.objectContaining({ type: 'git_diff_refresh' }),
);
});
it('processes messages_deleted frame', async () => {
await renderHook(SESSION_ID);
act(() => { triggerWsOpen(); });
await flush();
act(() => {
triggerWsMessage({
type: 'messages_deleted', message_ids: [MSG_ID], chat_id: CHAT_ID,
} as WsFrame);
});
await flush();
});
it('processes compacted frame and refetches messages', async () => {
await renderHook(SESSION_ID);
act(() => { triggerWsOpen(); });
await flush();
act(() => {
triggerWsMessage({
type: 'compacted', session_id: SESSION_ID, chat_id: CHAT_ID, summary_message_id: MSG_ID,
} as WsFrame);
});
await flush();
expect(mockMessagesList).toHaveBeenCalledWith(SESSION_ID);
});
it('processes usage frame and calls recordUsage', async () => {
await renderHook(SESSION_ID);
act(() => { triggerWsOpen(); });
await flush();
act(() => {
triggerWsMessage({
type: 'usage', message_id: MSG_ID, chat_id: CHAT_ID,
completion_tokens: 100, ctx_used: 5000, ctx_max: 32768,
} as WsFrame);
});
await flush();
expect(mockRecordUsage).toHaveBeenCalledWith(CHAT_ID, {
completion_tokens: 100, ctx_used: 5000, ctx_max: 32768,
});
});
it('emits chat_updated on chat_renamed frame', async () => {
await renderHook(SESSION_ID);
act(() => { triggerWsOpen(); });
await flush();
act(() => {
triggerWsMessage({
type: 'chat_renamed', chat_id: CHAT_ID, name: 'New Chat Name',
} as WsFrame);
});
await flush();
expect(mockEmit).toHaveBeenCalledWith(
expect.objectContaining({ type: 'chat_updated', chat_id: CHAT_ID, name: 'New Chat Name' }),
);
});
it('handles pass-through frames: agent_snapshot, agent_status_updated, flow_run*, battle*', async () => {
await renderHook(SESSION_ID);
act(() => { triggerWsOpen(); });
await flush();
act(() => {
triggerWsMessage({ type: 'agent_snapshot', chat_id: CHAT_ID, model: 'qwen', turn_number: 3 } as WsFrame);
triggerWsMessage({
type: 'agent_status_updated', chat_id: CHAT_ID, agent: 'coder', status: 'working',
at: '2026-01-01T00:00:00Z',
} as WsFrame);
triggerWsMessage({
type: 'flow_run_started', run_id: MSG_ID, flow_name: 'test', band: 'small', steps: [],
} as unknown as WsFrame);
triggerWsMessage({
type: 'flow_run_step_updated', run_id: MSG_ID, step_id: 's1', status: 'completed',
} as unknown as WsFrame);
triggerWsMessage({
type: 'battle_started', battle_id: MSG_ID, battle_type: 'coding', prompt: 'test', contestants: [],
} as unknown as WsFrame);
triggerWsMessage({
type: 'contestant_updated', battle_id: MSG_ID, contestant_id: CHAT_ID,
} as unknown as WsFrame);
triggerWsMessage({ type: 'battle_updated', battle_id: MSG_ID } as unknown as WsFrame);
});
await flush();
});
});
describe('channel_delta frames', () => {
async function setup() {
const h = await renderHook(SESSION_ID);
act(() => { triggerWsOpen(); });
await flush();
act(() => {
triggerWsMessage({
type: 'message_started', message_id: MSG_ID, chat_id: CHAT_ID, role: 'assistant',
} as WsFrame);
});
await flush();
return h;
}
it('processes text channel delta', async () => {
await setup();
act(() => { triggerWsMessage(textDelta(0, 'Hello ')); });
await flush();
act(() => { triggerWsMessage(textDelta(1, 'World!')); });
await flush();
});
it('processes out-of-order deltas (seq=1 before seq=0)', async () => {
await setup();
act(() => { triggerWsMessage(textDelta(1, 'World!')); });
await flush();
act(() => { triggerWsMessage(textDelta(0, 'Hello ')); });
await flush();
});
it('ignores duplicate seq', async () => {
await setup();
act(() => { triggerWsMessage(textDelta(0, 'Hello ')); });
await flush();
act(() => { triggerWsMessage(textDelta(0, 'Hello ')); });
await flush();
});
it('fills gap and flushes in order (seq=0, seq=2, then seq=1)', async () => {
await setup();
act(() => { triggerWsMessage(textDelta(0, 'First ')); });
await flush();
act(() => { triggerWsMessage(textDelta(2, 'Third ')); });
await flush();
act(() => { triggerWsMessage(textDelta(1, 'Second ')); });
await flush();
});
it('processes tool_call channel delta', async () => {
await setup();
act(() => {
triggerWsMessage(toolCallDelta(0, { id: 'call_1', name: 'read', args: { path: '/' } }));
});
await flush();
});
it('processes tool_result channel delta', async () => {
await renderHook(SESSION_ID);
act(() => { triggerWsOpen(); });
await flush();
act(() => { triggerWsMessage(toolResultDelta(0, 'call_1', { data: 'file content' })); });
await flush();
});
it('processes error channel delta', async () => {
await renderHook(SESSION_ID);
act(() => { triggerWsOpen(); });
await flush();
act(() => { triggerWsMessage(errorDelta(0, 'Something went wrong')); });
await flush();
});
it('processes status delta: running creates message, complete terminates it', async () => {
await renderHook(SESSION_ID);
act(() => { triggerWsOpen(); });
await flush();
act(() => { triggerWsMessage(statusDelta(0, 'running')); });
await flush();
act(() => {
triggerWsMessage(statusDelta(1, 'complete', { tokens_used: 42, ctx_used: 1000 }));
});
await flush();
});
it('handles multi-channel interleaved deltas with independent seq', async () => {
await renderHook(SESSION_ID);
act(() => { triggerWsOpen(); });
await flush();
const tId1 = '00000000-0000-0000-0000-000000000010';
const tId2 = '00000000-0000-0000-0000-000000000011';
act(() => {
triggerWsMessage({ type: 'message_started', message_id: tId1, chat_id: CHAT_ID, role: 'assistant' } as WsFrame);
triggerWsMessage({ type: 'message_started', message_id: tId2, chat_id: CHAT_ID, role: 'assistant' } as WsFrame);
});
await flush();
act(() => {
triggerWsMessage({ type: 'channel_delta', seq: 0, channel: 'text', message_id: tId1, chat_id: CHAT_ID, content: 'A' } as unknown as WsFrame);
triggerWsMessage({ type: 'channel_delta', seq: 1, channel: 'text', message_id: tId1, chat_id: CHAT_ID, content: 'B' } as unknown as WsFrame);
triggerWsMessage({ type: 'channel_delta', seq: 0, channel: 'tool_call', message_id: tId2, chat_id: CHAT_ID, tool_call: { id: 'c1', name: 'ls', args: {} } } as unknown as WsFrame);
});
await flush();
});
});
describe('status delta with metadata', () => {
it('applies tokens_used, ctx_used, model to message via status delta', async () => {
await renderHook(SESSION_ID);
act(() => { triggerWsOpen(); });
await flush();
act(() => { triggerWsMessage(statusDelta(0, 'running')); });
await flush();
act(() => {
triggerWsMessage(
statusDelta(1, 'running', {
tokens_used: 150, ctx_used: 5000, ctx_max: 32768, model: 'qwen-2.5-32b',
}),
);
});
await flush();
});
});
describe('reconnection behavior', () => {
it('reconnects on WebSocket close with backoff', async () => {
vi.useFakeTimers();
await renderHook(SESSION_ID);
act(() => { triggerWsOpen(); });
await flush();
const initialCount = wsConstructCount;
act(() => { triggerWsClose(); });
// Before 1000ms, no reconnect yet
await vi.advanceTimersByTimeAsync(500);
expect(wsConstructCount).toBe(initialCount);
// After 1000ms from close, reconnect fires
await vi.advanceTimersByTimeAsync(600);
expect(wsConstructCount).toBe(initialCount + 1);
vi.useRealTimers();
});
it('sends lastSeqPerChannel on reconnect', async () => {
vi.useFakeTimers();
await renderHook(SESSION_ID);
act(() => { triggerWsOpen(); });
await flush();
act(() => { triggerWsMessage(statusDelta(0, 'running')); });
await flush();
act(() => { triggerWsMessage(statusDelta(1, 'complete')); });
await flush();
act(() => { triggerWsClose(); });
await vi.advanceTimersByTimeAsync(1100);
vi.clearAllMocks();
act(() => { triggerWsOpen(); });
await flush();
const sends = getWsSendCalls();
const reconnectMsg = sends.find((s) => s.includes('reconnect'));
expect(reconnectMsg).toBeDefined();
if (reconnectMsg) {
const parsed = JSON.parse(reconnectMsg);
expect(parsed.type).toBe('reconnect');
expect(parsed.lastSeqPerChannel).toBeDefined();
expect(parsed.lastSeqPerChannel.status).toBe(2);
}
vi.useRealTimers();
});
});
describe('channel stall detection', () => {
it('emits refetch_messages when channel stalls for 5s', async () => {
vi.useFakeTimers();
await renderHook(SESSION_ID);
act(() => { triggerWsOpen(); });
await flush();
act(() => { triggerWsMessage(statusDelta(1, 'complete')); });
await flush();
await vi.advanceTimersByTimeAsync(6000);
const refetchCalls = mockEmit.mock.calls.filter(
(c: unknown[]) => (c[0] as { type: string }).type === 'refetch_messages',
);
expect(refetchCalls.length).toBeGreaterThanOrEqual(1);
vi.useRealTimers();
});
it('does not stall when buffer is empty', async () => {
vi.useFakeTimers();
await renderHook(SESSION_ID);
act(() => { triggerWsOpen(); });
await flush();
await vi.advanceTimersByTimeAsync(10000);
const refetchCalls = mockEmit.mock.calls.filter(
(c: unknown[]) => (c[0] as { type: string }).type === 'refetch_messages',
);
expect(refetchCalls.length).toBe(0);
vi.useRealTimers();
});
});
describe('sessionEvents subscription', () => {
it('calls api.messages.list when refetch_messages event fires', async () => {
await renderHook(SESSION_ID);
act(() => { triggerWsOpen(); });
await flush();
expect(mockSubscribe).toHaveBeenCalled();
const fn = mockSubscribe.mock.calls[0]?.[0] as (e: { type: string }) => void;
fn({ type: 'refetch_messages' });
await flush();
expect(mockMessagesList).toHaveBeenCalledWith(SESSION_ID);
});
});
describe('invalid frames', () => {
it('ignores bad JSON', async () => {
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
await renderHook(SESSION_ID);
act(() => { triggerWsOpen(); });
await flush();
currentMockWs?.onmessage?.({ data: 'not-json-at-all' });
await flush();
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
it('ignores schema-invalid frames', async () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
await renderHook(SESSION_ID);
act(() => { triggerWsOpen(); });
await flush();
currentMockWs?.onmessage?.({ data: JSON.stringify({ type: 'unknown_type' }) });
await flush();
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
});
// Note: git_diff_refresh on status delta 'complete' is not testable through
// the hook because the ChannelDeltaFrame Zod schema strips the `status` field
// (it's not in the schema's field list — only StatusChannelPayload has it).
// The message_complete → git_diff_refresh path is tested above and passes.
// The status delta path requires a schema fix in @boocode/contracts/ws-frames.
});

View File

@@ -0,0 +1,11 @@
import { useEffect, useState } from 'react';
import { terminalsRegistry, type TerminalRegistration } from '@/lib/events';
// v1.10 booterm: tiny subscription hook for the mounted-terminals registry.
// Used by the right-click "Send to terminal" submenu so it always reflects
// currently-open terminal panes without prop drilling from Workspace.
export function useTerminals(): TerminalRegistration[] {
const [list, setList] = useState(() => terminalsRegistry.list());
useEffect(() => terminalsRegistry.subscribe(() => setList(terminalsRegistry.list())), []);
return list;
}