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:
@@ -23,6 +23,8 @@ interface SocketDeps {
|
||||
termRef: React.MutableRefObject<Terminal | null>;
|
||||
sessionId: string;
|
||||
paneId: string;
|
||||
description?: string;
|
||||
parentAgent?: string;
|
||||
fit: TerminalFit['fit'];
|
||||
getSize: TerminalFit['getSize'];
|
||||
setSize: TerminalFit['setSize'];
|
||||
@@ -40,6 +42,8 @@ export function useTerminalSocket({
|
||||
termRef,
|
||||
sessionId,
|
||||
paneId,
|
||||
description,
|
||||
parentAgent,
|
||||
fit,
|
||||
getSize,
|
||||
setSize,
|
||||
@@ -276,7 +280,7 @@ export function useTerminalSocket({
|
||||
fit();
|
||||
const { cols, rows } = getSize();
|
||||
api.terminals
|
||||
.start(sessionId, paneId, cols, rows)
|
||||
.start(sessionId, paneId, cols, rows, description, parentAgent)
|
||||
.catch(() => {
|
||||
/* WS handler will ensureSession itself — non-fatal */
|
||||
})
|
||||
|
||||
98
apps/web/src/hooks/useDraftPersistence.ts
Normal file
98
apps/web/src/hooks/useDraftPersistence.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
const STORAGE_PREFIX = 'boocode_draft_';
|
||||
const SAVE_DEBOUNCE_MS = 500;
|
||||
|
||||
function getKey(chatId: string): string {
|
||||
return `${STORAGE_PREFIX}${chatId}`;
|
||||
}
|
||||
|
||||
function readDraft(key: string): string {
|
||||
if (typeof window === 'undefined') return '';
|
||||
try {
|
||||
return localStorage.getItem(key) ?? '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function writeDraft(key: string, text: string): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
if (text) {
|
||||
localStorage.setItem(key, text);
|
||||
} else {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
} catch {
|
||||
// storage full or unavailable — silently ignore
|
||||
}
|
||||
}
|
||||
|
||||
function removeDraft(key: string): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch {
|
||||
// silently ignore
|
||||
}
|
||||
}
|
||||
|
||||
export interface DraftPersistenceResult {
|
||||
/** Current draft state, initialized from localStorage on mount. */
|
||||
draft: string;
|
||||
/** Update draft with 500ms debounced persistence to localStorage. */
|
||||
setDraft: (text: string) => void;
|
||||
/** Clear draft state and remove localStorage entry immediately. */
|
||||
clearDraft: () => void;
|
||||
/** Re-read from localStorage, update state, and return saved value. */
|
||||
restoreDraft: () => string;
|
||||
}
|
||||
|
||||
export function useDraftPersistence(chatId: string | undefined): DraftPersistenceResult {
|
||||
const key = chatId ? getKey(chatId) : null;
|
||||
const [draft, setDraftState] = useState(() => (key ? readDraft(key) : ''));
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const keyRef = useRef(key);
|
||||
keyRef.current = key;
|
||||
|
||||
// Cleanup timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current !== null) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const setDraft = useCallback((text: string) => {
|
||||
setDraftState(text);
|
||||
if (timerRef.current !== null) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
timerRef.current = setTimeout(() => {
|
||||
const k = keyRef.current;
|
||||
if (k) writeDraft(k, text);
|
||||
}, SAVE_DEBOUNCE_MS);
|
||||
}, []);
|
||||
|
||||
const clearDraft = useCallback(() => {
|
||||
setDraftState('');
|
||||
if (timerRef.current !== null) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
const k = keyRef.current;
|
||||
if (k) removeDraft(k);
|
||||
}, []);
|
||||
|
||||
const restoreDraft = useCallback((): string => {
|
||||
const k = keyRef.current;
|
||||
if (!k) return '';
|
||||
const saved = readDraft(k);
|
||||
setDraftState(saved);
|
||||
return saved;
|
||||
}, []);
|
||||
|
||||
return { draft, setDraft, clearDraft, restoreDraft };
|
||||
}
|
||||
616
apps/web/src/hooks/useSessionStream.test.ts
Normal file
616
apps/web/src/hooks/useSessionStream.test.ts
Normal file
@@ -0,0 +1,616 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import React, { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import type { Message, WsFrame } from '@/api/types';
|
||||
import { useSessionStream } from './useSessionStream';
|
||||
|
||||
// ── Hoisted mock values ──────────────────────────────────────────────────────
|
||||
|
||||
const { mockMessagesList, mockEmit, mockSubscribe, mockRecordUsage } = vi.hoisted(
|
||||
() => ({
|
||||
mockMessagesList: vi.fn(),
|
||||
mockEmit: vi.fn(),
|
||||
mockSubscribe: vi.fn().mockReturnValue(vi.fn()),
|
||||
mockRecordUsage: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Module mocks ─────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
api: { messages: { list: mockMessagesList } },
|
||||
}));
|
||||
|
||||
vi.mock('./sessionEvents', () => ({
|
||||
sessionEvents: { emit: mockEmit, subscribe: mockSubscribe },
|
||||
}));
|
||||
|
||||
vi.mock('./useChatThroughput', () => ({
|
||||
recordUsage: mockRecordUsage,
|
||||
}));
|
||||
|
||||
// ── Test constants ───────────────────────────────────────────────────────────
|
||||
|
||||
const SESSION_ID = '00000000-0000-0000-0000-000000000001';
|
||||
const CHAT_ID = '00000000-0000-0000-0000-000000000002';
|
||||
const MSG_ID = '00000000-0000-0000-0000-000000000003';
|
||||
const TOOL_MSG_ID = '00000000-0000-0000-0000-000000000004';
|
||||
|
||||
// ── Frame builder helpers ────────────────────────────────────────────────────
|
||||
|
||||
function textDelta(seq: number, content: string): WsFrame {
|
||||
return {
|
||||
type: 'channel_delta', seq, channel: 'text', message_id: MSG_ID, chat_id: CHAT_ID, content,
|
||||
} as unknown as WsFrame;
|
||||
}
|
||||
|
||||
function toolCallDelta(
|
||||
seq: number,
|
||||
tc: { id: string; name: string; args: Record<string, unknown> },
|
||||
): WsFrame {
|
||||
return {
|
||||
type: 'channel_delta', seq, channel: 'tool_call', message_id: MSG_ID, chat_id: CHAT_ID, tool_call: tc,
|
||||
} as unknown as WsFrame;
|
||||
}
|
||||
|
||||
function toolResultDelta(seq: number, callId: string, output: unknown): WsFrame {
|
||||
return {
|
||||
type: 'channel_delta', seq, channel: 'tool_result',
|
||||
tool_message_id: TOOL_MSG_ID, chat_id: CHAT_ID, tool_call_id: callId,
|
||||
output, truncated: false,
|
||||
} as unknown as WsFrame;
|
||||
}
|
||||
|
||||
function statusDelta(
|
||||
seq: number, status: 'running' | 'complete' | 'cancelled' | 'failed',
|
||||
overrides?: Record<string, unknown>,
|
||||
): WsFrame {
|
||||
return {
|
||||
type: 'channel_delta', seq, channel: 'status', message_id: MSG_ID, chat_id: CHAT_ID, status,
|
||||
...(overrides ?? {}),
|
||||
} as unknown as WsFrame;
|
||||
}
|
||||
|
||||
function errorDelta(seq: number, error: string): WsFrame {
|
||||
return {
|
||||
type: 'channel_delta', seq, channel: 'error', message_id: MSG_ID, chat_id: CHAT_ID, error,
|
||||
} as unknown as WsFrame;
|
||||
}
|
||||
|
||||
// ── WebSocket mock globals ───────────────────────────────────────────────────
|
||||
|
||||
interface MockWs {
|
||||
onopen: (() => void) | null;
|
||||
onmessage: ((ev: { data: string }) => void) | null;
|
||||
onclose: ((ev: { code?: number; reason?: string }) => void) | null;
|
||||
onerror: (() => void) | null;
|
||||
send: ReturnType<typeof vi.fn>;
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
readyState: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
let currentMockWs: MockWs | null = null;
|
||||
let wsConstructCount = 0;
|
||||
|
||||
function createWsMock(): MockWs {
|
||||
return {
|
||||
onopen: null, onmessage: null, onclose: null, onerror: null,
|
||||
send: vi.fn(), close: vi.fn(), readyState: 1, url: '',
|
||||
};
|
||||
}
|
||||
|
||||
function triggerWsOpen(): void { currentMockWs?.onopen?.(); }
|
||||
function triggerWsMessage(frame: WsFrame): void {
|
||||
currentMockWs?.onmessage?.({ data: JSON.stringify(frame) });
|
||||
}
|
||||
function triggerWsClose(): void { currentMockWs?.onclose?.({}); }
|
||||
function getWsSendCalls(): string[] {
|
||||
return (currentMockWs?.send.mock.calls ?? []).map((c: unknown[]) => String(c[0]));
|
||||
}
|
||||
|
||||
// ── React test harness ───────────────────────────────────────────────────────
|
||||
|
||||
function createHarness() {
|
||||
const states: Array<{ messages: Message[]; connected: boolean; error: string | null }> = [];
|
||||
|
||||
function Wrapper({ sessionId }: { sessionId: string | undefined }) {
|
||||
const state = useSessionStream(sessionId);
|
||||
states.push(state);
|
||||
return React.createElement('div');
|
||||
}
|
||||
|
||||
return {
|
||||
Wrapper,
|
||||
lastState: () => states[states.length - 1] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
let root: Root;
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
currentMockWs = null;
|
||||
wsConstructCount = 0;
|
||||
mockMessagesList.mockReset().mockResolvedValue([]);
|
||||
mockEmit.mockReset();
|
||||
mockSubscribe.mockReset().mockReturnValue(vi.fn());
|
||||
vi.clearAllMocks();
|
||||
|
||||
vi.spyOn(window, 'WebSocket').mockImplementation(function (this: WebSocket, url: string | URL) {
|
||||
const ws = createWsMock();
|
||||
ws.url = String(url);
|
||||
currentMockWs = ws;
|
||||
wsConstructCount++;
|
||||
const proto = {
|
||||
get onopen() { return ws.onopen; },
|
||||
set onopen(fn) { ws.onopen = fn as () => void; },
|
||||
get onmessage() { return ws.onmessage; },
|
||||
set onmessage(fn) { ws.onmessage = fn as (ev: { data: string }) => void; },
|
||||
get onclose() { return ws.onclose; },
|
||||
set onclose(fn) { ws.onclose = fn as (ev: { code?: number; reason?: string }) => void; },
|
||||
get onerror() { return ws.onerror; },
|
||||
set onerror(fn) { ws.onerror = fn as () => void; },
|
||||
send: vi.fn((d: string) => ws.send(d)),
|
||||
close: vi.fn(() => { ws.close(); ws.onclose?.({}); }),
|
||||
readyState: 1, url: ws.url,
|
||||
CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3,
|
||||
};
|
||||
return proto as unknown as WebSocket;
|
||||
});
|
||||
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => { root.unmount(); });
|
||||
}
|
||||
if (container.parentNode) {
|
||||
container.parentNode.removeChild(container);
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
currentMockWs = null;
|
||||
});
|
||||
|
||||
async function renderHook(sessionId: string | undefined) {
|
||||
const harness = createHarness();
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(harness.Wrapper, {
|
||||
sessionId: sessionId ?? (null as unknown as undefined),
|
||||
}),
|
||||
);
|
||||
});
|
||||
await act(async () => {});
|
||||
return harness;
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
await act(async () => {});
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('useSessionStream', () => {
|
||||
describe('connection lifecycle', () => {
|
||||
it('does not connect when sessionId is undefined', async () => {
|
||||
const h = await renderHook(undefined);
|
||||
const s = h.lastState();
|
||||
expect(s).not.toBeNull();
|
||||
expect(s!.connected).toBe(false);
|
||||
expect(s!.messages).toEqual([]);
|
||||
});
|
||||
|
||||
it('connects when sessionId is provided', async () => {
|
||||
await renderHook(SESSION_ID);
|
||||
expect(wsConstructCount).toBe(1);
|
||||
});
|
||||
|
||||
it('sets connected=true on WebSocket open', async () => {
|
||||
const h = await renderHook(SESSION_ID);
|
||||
act(() => { triggerWsOpen(); });
|
||||
await flush();
|
||||
expect(h.lastState()!.connected).toBe(true);
|
||||
expect(h.lastState()!.error).toBeNull();
|
||||
});
|
||||
|
||||
it('sends reconnect frame with lastSeqPerChannel on open', async () => {
|
||||
await renderHook(SESSION_ID);
|
||||
act(() => { triggerWsOpen(); });
|
||||
await flush();
|
||||
const sends = getWsSendCalls();
|
||||
const reconnectMsg = sends.find((s) => s.includes('reconnect'));
|
||||
expect(reconnectMsg).toBeDefined();
|
||||
if (reconnectMsg) {
|
||||
const parsed = JSON.parse(reconnectMsg);
|
||||
expect(parsed.type).toBe('reconnect');
|
||||
expect(parsed.lastSeqPerChannel).toEqual({});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('non-channel frames', () => {
|
||||
it('processes snapshot frame', async () => {
|
||||
await renderHook(SESSION_ID);
|
||||
act(() => { triggerWsOpen(); });
|
||||
await flush();
|
||||
const msg: Message = {
|
||||
id: MSG_ID, session_id: SESSION_ID, chat_id: CHAT_ID, role: 'user', content: 'hi',
|
||||
kind: 'message', tool_calls: null, tool_results: null, status: 'complete',
|
||||
last_seq: 0, tokens_used: null, ctx_used: null, ctx_max: null,
|
||||
cache_tokens: null, reasoning_tokens: null, model: null,
|
||||
started_at: null, finished_at: null, created_at: '2026-01-01T00:00:00Z', metadata: null,
|
||||
};
|
||||
act(() => { triggerWsMessage({ type: 'snapshot', messages: [msg] } as WsFrame); });
|
||||
await flush();
|
||||
});
|
||||
|
||||
it('processes delta frame for existing message', async () => {
|
||||
await renderHook(SESSION_ID);
|
||||
act(() => { triggerWsOpen(); });
|
||||
await flush();
|
||||
const streamingMsg: Message = {
|
||||
id: MSG_ID, session_id: SESSION_ID, chat_id: CHAT_ID, role: 'assistant', content: '',
|
||||
kind: 'message', tool_calls: null, tool_results: null, status: 'streaming',
|
||||
last_seq: 0, tokens_used: null, ctx_used: null, ctx_max: null,
|
||||
cache_tokens: null, reasoning_tokens: null, model: null,
|
||||
started_at: null, finished_at: null, created_at: '2026-01-01T00:00:00Z', metadata: null,
|
||||
};
|
||||
act(() => { triggerWsMessage({ type: 'snapshot', messages: [streamingMsg] } as WsFrame); });
|
||||
await flush();
|
||||
act(() => { triggerWsMessage({ type: 'delta', message_id: MSG_ID, content: 'Hello!' } as WsFrame); });
|
||||
await flush();
|
||||
});
|
||||
|
||||
it('emits git_diff_refresh on message_complete', async () => {
|
||||
await renderHook(SESSION_ID);
|
||||
act(() => { triggerWsOpen(); });
|
||||
await flush();
|
||||
act(() => {
|
||||
triggerWsMessage({
|
||||
type: 'message_started', message_id: MSG_ID, chat_id: CHAT_ID, role: 'assistant',
|
||||
} as WsFrame);
|
||||
});
|
||||
await flush();
|
||||
act(() => {
|
||||
triggerWsMessage({
|
||||
type: 'message_complete', message_id: MSG_ID, chat_id: CHAT_ID,
|
||||
} as WsFrame);
|
||||
});
|
||||
await flush();
|
||||
expect(mockEmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'git_diff_refresh' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('processes messages_deleted frame', async () => {
|
||||
await renderHook(SESSION_ID);
|
||||
act(() => { triggerWsOpen(); });
|
||||
await flush();
|
||||
act(() => {
|
||||
triggerWsMessage({
|
||||
type: 'messages_deleted', message_ids: [MSG_ID], chat_id: CHAT_ID,
|
||||
} as WsFrame);
|
||||
});
|
||||
await flush();
|
||||
});
|
||||
|
||||
it('processes compacted frame and refetches messages', async () => {
|
||||
await renderHook(SESSION_ID);
|
||||
act(() => { triggerWsOpen(); });
|
||||
await flush();
|
||||
act(() => {
|
||||
triggerWsMessage({
|
||||
type: 'compacted', session_id: SESSION_ID, chat_id: CHAT_ID, summary_message_id: MSG_ID,
|
||||
} as WsFrame);
|
||||
});
|
||||
await flush();
|
||||
expect(mockMessagesList).toHaveBeenCalledWith(SESSION_ID);
|
||||
});
|
||||
|
||||
it('processes usage frame and calls recordUsage', async () => {
|
||||
await renderHook(SESSION_ID);
|
||||
act(() => { triggerWsOpen(); });
|
||||
await flush();
|
||||
act(() => {
|
||||
triggerWsMessage({
|
||||
type: 'usage', message_id: MSG_ID, chat_id: CHAT_ID,
|
||||
completion_tokens: 100, ctx_used: 5000, ctx_max: 32768,
|
||||
} as WsFrame);
|
||||
});
|
||||
await flush();
|
||||
expect(mockRecordUsage).toHaveBeenCalledWith(CHAT_ID, {
|
||||
completion_tokens: 100, ctx_used: 5000, ctx_max: 32768,
|
||||
});
|
||||
});
|
||||
|
||||
it('emits chat_updated on chat_renamed frame', async () => {
|
||||
await renderHook(SESSION_ID);
|
||||
act(() => { triggerWsOpen(); });
|
||||
await flush();
|
||||
act(() => {
|
||||
triggerWsMessage({
|
||||
type: 'chat_renamed', chat_id: CHAT_ID, name: 'New Chat Name',
|
||||
} as WsFrame);
|
||||
});
|
||||
await flush();
|
||||
expect(mockEmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'chat_updated', chat_id: CHAT_ID, name: 'New Chat Name' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('handles pass-through frames: agent_snapshot, agent_status_updated, flow_run*, battle*', async () => {
|
||||
await renderHook(SESSION_ID);
|
||||
act(() => { triggerWsOpen(); });
|
||||
await flush();
|
||||
|
||||
act(() => {
|
||||
triggerWsMessage({ type: 'agent_snapshot', chat_id: CHAT_ID, model: 'qwen', turn_number: 3 } as WsFrame);
|
||||
triggerWsMessage({
|
||||
type: 'agent_status_updated', chat_id: CHAT_ID, agent: 'coder', status: 'working',
|
||||
at: '2026-01-01T00:00:00Z',
|
||||
} as WsFrame);
|
||||
triggerWsMessage({
|
||||
type: 'flow_run_started', run_id: MSG_ID, flow_name: 'test', band: 'small', steps: [],
|
||||
} as unknown as WsFrame);
|
||||
triggerWsMessage({
|
||||
type: 'flow_run_step_updated', run_id: MSG_ID, step_id: 's1', status: 'completed',
|
||||
} as unknown as WsFrame);
|
||||
triggerWsMessage({
|
||||
type: 'battle_started', battle_id: MSG_ID, battle_type: 'coding', prompt: 'test', contestants: [],
|
||||
} as unknown as WsFrame);
|
||||
triggerWsMessage({
|
||||
type: 'contestant_updated', battle_id: MSG_ID, contestant_id: CHAT_ID,
|
||||
} as unknown as WsFrame);
|
||||
triggerWsMessage({ type: 'battle_updated', battle_id: MSG_ID } as unknown as WsFrame);
|
||||
});
|
||||
await flush();
|
||||
});
|
||||
});
|
||||
|
||||
describe('channel_delta frames', () => {
|
||||
async function setup() {
|
||||
const h = await renderHook(SESSION_ID);
|
||||
act(() => { triggerWsOpen(); });
|
||||
await flush();
|
||||
act(() => {
|
||||
triggerWsMessage({
|
||||
type: 'message_started', message_id: MSG_ID, chat_id: CHAT_ID, role: 'assistant',
|
||||
} as WsFrame);
|
||||
});
|
||||
await flush();
|
||||
return h;
|
||||
}
|
||||
|
||||
it('processes text channel delta', async () => {
|
||||
await setup();
|
||||
act(() => { triggerWsMessage(textDelta(0, 'Hello ')); });
|
||||
await flush();
|
||||
act(() => { triggerWsMessage(textDelta(1, 'World!')); });
|
||||
await flush();
|
||||
});
|
||||
|
||||
it('processes out-of-order deltas (seq=1 before seq=0)', async () => {
|
||||
await setup();
|
||||
act(() => { triggerWsMessage(textDelta(1, 'World!')); });
|
||||
await flush();
|
||||
act(() => { triggerWsMessage(textDelta(0, 'Hello ')); });
|
||||
await flush();
|
||||
});
|
||||
|
||||
it('ignores duplicate seq', async () => {
|
||||
await setup();
|
||||
act(() => { triggerWsMessage(textDelta(0, 'Hello ')); });
|
||||
await flush();
|
||||
act(() => { triggerWsMessage(textDelta(0, 'Hello ')); });
|
||||
await flush();
|
||||
});
|
||||
|
||||
it('fills gap and flushes in order (seq=0, seq=2, then seq=1)', async () => {
|
||||
await setup();
|
||||
act(() => { triggerWsMessage(textDelta(0, 'First ')); });
|
||||
await flush();
|
||||
act(() => { triggerWsMessage(textDelta(2, 'Third ')); });
|
||||
await flush();
|
||||
act(() => { triggerWsMessage(textDelta(1, 'Second ')); });
|
||||
await flush();
|
||||
});
|
||||
|
||||
it('processes tool_call channel delta', async () => {
|
||||
await setup();
|
||||
act(() => {
|
||||
triggerWsMessage(toolCallDelta(0, { id: 'call_1', name: 'read', args: { path: '/' } }));
|
||||
});
|
||||
await flush();
|
||||
});
|
||||
|
||||
it('processes tool_result channel delta', async () => {
|
||||
await renderHook(SESSION_ID);
|
||||
act(() => { triggerWsOpen(); });
|
||||
await flush();
|
||||
act(() => { triggerWsMessage(toolResultDelta(0, 'call_1', { data: 'file content' })); });
|
||||
await flush();
|
||||
});
|
||||
|
||||
it('processes error channel delta', async () => {
|
||||
await renderHook(SESSION_ID);
|
||||
act(() => { triggerWsOpen(); });
|
||||
await flush();
|
||||
act(() => { triggerWsMessage(errorDelta(0, 'Something went wrong')); });
|
||||
await flush();
|
||||
});
|
||||
|
||||
it('processes status delta: running creates message, complete terminates it', async () => {
|
||||
await renderHook(SESSION_ID);
|
||||
act(() => { triggerWsOpen(); });
|
||||
await flush();
|
||||
act(() => { triggerWsMessage(statusDelta(0, 'running')); });
|
||||
await flush();
|
||||
act(() => {
|
||||
triggerWsMessage(statusDelta(1, 'complete', { tokens_used: 42, ctx_used: 1000 }));
|
||||
});
|
||||
await flush();
|
||||
});
|
||||
|
||||
it('handles multi-channel interleaved deltas with independent seq', async () => {
|
||||
await renderHook(SESSION_ID);
|
||||
act(() => { triggerWsOpen(); });
|
||||
await flush();
|
||||
const tId1 = '00000000-0000-0000-0000-000000000010';
|
||||
const tId2 = '00000000-0000-0000-0000-000000000011';
|
||||
act(() => {
|
||||
triggerWsMessage({ type: 'message_started', message_id: tId1, chat_id: CHAT_ID, role: 'assistant' } as WsFrame);
|
||||
triggerWsMessage({ type: 'message_started', message_id: tId2, chat_id: CHAT_ID, role: 'assistant' } as WsFrame);
|
||||
});
|
||||
await flush();
|
||||
act(() => {
|
||||
triggerWsMessage({ type: 'channel_delta', seq: 0, channel: 'text', message_id: tId1, chat_id: CHAT_ID, content: 'A' } as unknown as WsFrame);
|
||||
triggerWsMessage({ type: 'channel_delta', seq: 1, channel: 'text', message_id: tId1, chat_id: CHAT_ID, content: 'B' } as unknown as WsFrame);
|
||||
triggerWsMessage({ type: 'channel_delta', seq: 0, channel: 'tool_call', message_id: tId2, chat_id: CHAT_ID, tool_call: { id: 'c1', name: 'ls', args: {} } } as unknown as WsFrame);
|
||||
});
|
||||
await flush();
|
||||
});
|
||||
});
|
||||
|
||||
describe('status delta with metadata', () => {
|
||||
it('applies tokens_used, ctx_used, model to message via status delta', async () => {
|
||||
await renderHook(SESSION_ID);
|
||||
act(() => { triggerWsOpen(); });
|
||||
await flush();
|
||||
act(() => { triggerWsMessage(statusDelta(0, 'running')); });
|
||||
await flush();
|
||||
act(() => {
|
||||
triggerWsMessage(
|
||||
statusDelta(1, 'running', {
|
||||
tokens_used: 150, ctx_used: 5000, ctx_max: 32768, model: 'qwen-2.5-32b',
|
||||
}),
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reconnection behavior', () => {
|
||||
it('reconnects on WebSocket close with backoff', async () => {
|
||||
vi.useFakeTimers();
|
||||
await renderHook(SESSION_ID);
|
||||
act(() => { triggerWsOpen(); });
|
||||
await flush();
|
||||
const initialCount = wsConstructCount;
|
||||
act(() => { triggerWsClose(); });
|
||||
// Before 1000ms, no reconnect yet
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(wsConstructCount).toBe(initialCount);
|
||||
// After 1000ms from close, reconnect fires
|
||||
await vi.advanceTimersByTimeAsync(600);
|
||||
expect(wsConstructCount).toBe(initialCount + 1);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('sends lastSeqPerChannel on reconnect', async () => {
|
||||
vi.useFakeTimers();
|
||||
await renderHook(SESSION_ID);
|
||||
act(() => { triggerWsOpen(); });
|
||||
await flush();
|
||||
act(() => { triggerWsMessage(statusDelta(0, 'running')); });
|
||||
await flush();
|
||||
act(() => { triggerWsMessage(statusDelta(1, 'complete')); });
|
||||
await flush();
|
||||
act(() => { triggerWsClose(); });
|
||||
await vi.advanceTimersByTimeAsync(1100);
|
||||
vi.clearAllMocks();
|
||||
act(() => { triggerWsOpen(); });
|
||||
await flush();
|
||||
const sends = getWsSendCalls();
|
||||
const reconnectMsg = sends.find((s) => s.includes('reconnect'));
|
||||
expect(reconnectMsg).toBeDefined();
|
||||
if (reconnectMsg) {
|
||||
const parsed = JSON.parse(reconnectMsg);
|
||||
expect(parsed.type).toBe('reconnect');
|
||||
expect(parsed.lastSeqPerChannel).toBeDefined();
|
||||
expect(parsed.lastSeqPerChannel.status).toBe(2);
|
||||
}
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('channel stall detection', () => {
|
||||
it('emits refetch_messages when channel stalls for 5s', async () => {
|
||||
vi.useFakeTimers();
|
||||
await renderHook(SESSION_ID);
|
||||
act(() => { triggerWsOpen(); });
|
||||
await flush();
|
||||
act(() => { triggerWsMessage(statusDelta(1, 'complete')); });
|
||||
await flush();
|
||||
await vi.advanceTimersByTimeAsync(6000);
|
||||
const refetchCalls = mockEmit.mock.calls.filter(
|
||||
(c: unknown[]) => (c[0] as { type: string }).type === 'refetch_messages',
|
||||
);
|
||||
expect(refetchCalls.length).toBeGreaterThanOrEqual(1);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('does not stall when buffer is empty', async () => {
|
||||
vi.useFakeTimers();
|
||||
await renderHook(SESSION_ID);
|
||||
act(() => { triggerWsOpen(); });
|
||||
await flush();
|
||||
await vi.advanceTimersByTimeAsync(10000);
|
||||
const refetchCalls = mockEmit.mock.calls.filter(
|
||||
(c: unknown[]) => (c[0] as { type: string }).type === 'refetch_messages',
|
||||
);
|
||||
expect(refetchCalls.length).toBe(0);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sessionEvents subscription', () => {
|
||||
it('calls api.messages.list when refetch_messages event fires', async () => {
|
||||
await renderHook(SESSION_ID);
|
||||
act(() => { triggerWsOpen(); });
|
||||
await flush();
|
||||
expect(mockSubscribe).toHaveBeenCalled();
|
||||
const fn = mockSubscribe.mock.calls[0]?.[0] as (e: { type: string }) => void;
|
||||
fn({ type: 'refetch_messages' });
|
||||
await flush();
|
||||
expect(mockMessagesList).toHaveBeenCalledWith(SESSION_ID);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid frames', () => {
|
||||
it('ignores bad JSON', async () => {
|
||||
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
await renderHook(SESSION_ID);
|
||||
act(() => { triggerWsOpen(); });
|
||||
await flush();
|
||||
currentMockWs?.onmessage?.({ data: 'not-json-at-all' });
|
||||
await flush();
|
||||
expect(spy).toHaveBeenCalled();
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('ignores schema-invalid frames', async () => {
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
await renderHook(SESSION_ID);
|
||||
act(() => { triggerWsOpen(); });
|
||||
await flush();
|
||||
currentMockWs?.onmessage?.({ data: JSON.stringify({ type: 'unknown_type' }) });
|
||||
await flush();
|
||||
expect(spy).toHaveBeenCalled();
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// Note: git_diff_refresh on status delta 'complete' is not testable through
|
||||
// the hook because the ChannelDeltaFrame Zod schema strips the `status` field
|
||||
// (it's not in the schema's field list — only StatusChannelPayload has it).
|
||||
// The message_complete → git_diff_refresh path is tested above and passes.
|
||||
// The status delta path requires a schema fix in @boocode/contracts/ws-frames.
|
||||
});
|
||||
11
apps/web/src/hooks/useTerminals.ts
Normal file
11
apps/web/src/hooks/useTerminals.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { terminalsRegistry, type TerminalRegistration } from '@/lib/events';
|
||||
|
||||
// v1.10 booterm: tiny subscription hook for the mounted-terminals registry.
|
||||
// Used by the right-click "Send to terminal" submenu so it always reflects
|
||||
// currently-open terminal panes without prop drilling from Workspace.
|
||||
export function useTerminals(): TerminalRegistration[] {
|
||||
const [list, setList] = useState(() => terminalsRegistry.list());
|
||||
useEffect(() => terminalsRegistry.subscribe(() => setList(terminalsRegistry.list())), []);
|
||||
return list;
|
||||
}
|
||||
Reference in New Issue
Block a user