diff --git a/app/shared/src/workbench/useWorkbenchSessionChrome.test.ts b/app/shared/src/workbench/useWorkbenchSessionChrome.test.ts new file mode 100644 index 000000000..10d8b80bc --- /dev/null +++ b/app/shared/src/workbench/useWorkbenchSessionChrome.test.ts @@ -0,0 +1,626 @@ +// real_tested=true +import { act, renderHook } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { + AgentHubPlatform, + LocalCliDiscoveryManifest, + RuntimeSessionSummary, + WorkbenchAgent, + WorkbenchConversation, +} from '../platform'; +import type { TranscriptBlock } from '../transcript'; +import { INSPECTOR_DEFAULT_COLLAPSE_EVENT } from './workbenchLayoutConstants'; +import { WORKBENCH_MOCK_SETTINGS_DEFAULTS } from './mockData'; +import { + LOCAL_CLI_DISCOVERY_FALLBACK, + useWorkbenchSessionChrome, + type UseWorkbenchSessionChromeOptions, +} from './useWorkbenchSessionChrome'; + +/* ═══════════════════════════════════════════════════════════════════════ + useWorkbenchSessionChrome — hook-level wiring over the #674 helpers. + + Covers default state for an empty shell, conversation id resolution + (controlled / local / fallback), selectConversation wiring, composer + state + draft persistence, agent mention mapping, transcript evidence + + mainchain summary derivation, inspector transcript projections, + execution-target auto-clear, local CLI discovery and runtime session + import gates (desktop settings only), chat search shortcut, settings + service creation + inspector collapse default, theme toggle, inspector + review/deploy callbacks, and the evidence export callback. + ═══════════════════════════════════════════════════════════════════════ */ + +/** Key-echo translator matching the helper test convention. */ +function t(key: string, options?: Record): string { + if (options && 'name' in options) return `${key}:${String(options.name)}`; + if (options && 'count' in options) return `${key}:${String(options.count)}`; + return key; +} + +function conversation( + partial: Partial & Pick, +): WorkbenchConversation { + return { kind: 'direct', ...partial }; +} + +function routeDecisionBlock(id: string): TranscriptBlock { + return { + id, + kind: 'route_decision', + author: { id: 'router', name: 'Router', role: 'system' }, + action: 'delegate', + targetAgent: 'coder', + }; +} + +function contextUsageBlock(id: string): TranscriptBlock { + return { + id, + kind: 'context_usage', + author: { id: 'ctx', name: 'Ctx', role: 'system' }, + modelLabel: 'gpt-test', + inputTokens: 100, + outputTokens: 50, + }; +} + +function previewBlock(id: string, url: string): TranscriptBlock { + return { + id, + kind: 'preview', + author: { id: 'builder', name: 'Builder', role: 'agent' }, + previewId: `pv-${id}`, + status: 'completed', + url, + }; +} + +function resultBlock(id: string): TranscriptBlock { + return { + id, + kind: 'result', + author: { id: 'builder', name: 'Builder', role: 'agent' }, + success: true, + summary: '任务完成', + duration: '8m12s', + }; +} + +function runSessionBlock(id: string): TranscriptBlock { + return { + id, + kind: 'run_session', + author: { id: 'builder', name: 'Builder', role: 'agent' }, + title: '构建任务', + taskId: 'task-1', + agentLabel: 'Builder', + }; +} + +function makePlatform(overrides: Partial = {}): AgentHubPlatform { + return { + surface: 'web', + capabilities: { localEdge: false, localFiles: false, browserPreview: false }, + conversations: { list: async () => [] }, + runs: { + submitComposerIntent: async () => ({ intentId: 'mock-intent' }), + }, + ...overrides, + }; +} + +/** + * Stable default platform. The hook's discovery/import effects key on + * `platform` identity, so a fresh object per render would re-run those + * effects (and their state resets) forever. + */ +const DEFAULT_WEB_PLATFORM: AgentHubPlatform = makePlatform(); + +/** Flush promise chains scheduled by host-port effects. */ +async function flushAsyncWork(): Promise { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +function renderSessionChrome(initialProps: Partial = {}) { + const openInspector = vi.fn(); + const showWorkbenchToast = vi.fn(); + const copyText = vi.fn(); + const resetSelection = vi.fn(); + const onActiveConversationChange = vi.fn(); + + const rendered = renderHook( + (props: Partial) => useWorkbenchSessionChrome({ + platform: DEFAULT_WEB_PLATFORM, + conversations: [], + transcript: [], + activePage: 'chat', + isChatPage: true, + openInspector, + showWorkbenchToast, + copyText, + resetSelection, + t, + ...props, + }), + { initialProps }, + ); + + return { + ...rendered, + openInspector, + showWorkbenchToast, + copyText, + resetSelection, + onActiveConversationChange, + }; +} + +describe('useWorkbenchSessionChrome', () => { + afterEach(() => { + document.documentElement.removeAttribute('data-theme'); + window.localStorage.clear(); + }); + + it('exposes default session chrome state for an empty shell', () => { + const { result } = renderSessionChrome(); + + expect(result.current.settingsService).toBeNull(); + expect(result.current.currentConversationId).toBe('default'); + expect(result.current.activeConversation).toBeUndefined(); + expect(result.current.selectedExecutionTargetId).toBe(''); + expect(result.current.dismissedPinnedIds).toEqual(new Set()); + expect(result.current.localCliDiscovery).toBeNull(); + expect(result.current.sessionImportItems).toEqual([]); + expect(result.current.sessionImportLoading).toBe(false); + expect(result.current.sessionImportError).toBeNull(); + expect(result.current.sessionImportVisible).toBe(false); + expect(result.current.reviewFileRequest).toBeNull(); + expect(result.current.searchOpen).toBe(false); + expect(result.current.searchHighlightId).toBeNull(); + expect(result.current.composer).toMatchObject({ + conversationId: 'default', + text: '', + mode: 'ask', + mentions: [], + attachments: [], + }); + expect(result.current.evidence).toEqual([]); + expect(result.current.mainchainSummary.exportEnabled).toBe(false); + expect(result.current.mainchainSummary.exportLabel).toBe('mainchain.waitingEvidence'); + expect(result.current.mainchainSummary.nodes).toHaveLength(9); + expect(result.current.inspectorRouteBlocks).toEqual([]); + expect(result.current.inspectorContextBlocks).toEqual([]); + expect(result.current.inspectorDeployPreviewUrl).toBeUndefined(); + expect(result.current.inspectorRunResult).toBeUndefined(); + expect(result.current.mentionableAgents).toEqual([]); + + expect(typeof result.current.selectConversation).toBe('function'); + expect(typeof result.current.setSelectedExecutionTargetId).toBe('function'); + expect(typeof result.current.setDismissedPinnedIds).toBe('function'); + expect(typeof result.current.refreshSessionImport).toBe('function'); + expect(typeof result.current.setSearchOpen).toBe('function'); + expect(typeof result.current.setSearchHighlightId).toBe('function'); + expect(typeof result.current.dispatchComposer).toBe('function'); + expect(typeof result.current.handleToggleTheme).toBe('function'); + expect(typeof result.current.openReviewFile).toBe('function'); + expect(typeof result.current.handleDeploySubmit).toBe('function'); + expect(typeof result.current.exportMainchainEvidence).toBe('function'); + }); + + it('resolves the first conversation as the fallback session', () => { + const conversations = [conversation({ id: 'a', title: 'A' }), conversation({ id: 'b', title: 'B' })]; + const { result } = renderSessionChrome({ conversations }); + + expect(result.current.currentConversationId).toBe('a'); + expect(result.current.activeConversation).toMatchObject({ id: 'a', title: 'A' }); + expect(result.current.composer.conversationId).toBe('a'); + }); + + it('prefers a valid controlled activeConversationId', () => { + const conversations = [conversation({ id: 'a', title: 'A' }), conversation({ id: 'b', title: 'B' })]; + const { result } = renderSessionChrome({ conversations, activeConversationId: 'b' }); + + expect(result.current.currentConversationId).toBe('b'); + expect(result.current.activeConversation?.title).toBe('B'); + }); + + it('falls back when the controlled activeConversationId is stale', () => { + const conversations = [conversation({ id: 'a', title: 'A' })]; + const { result } = renderSessionChrome({ conversations, activeConversationId: 'missing' }); + expect(result.current.currentConversationId).toBe('a'); + + const empty = renderSessionChrome({ conversations: [], activeConversationId: 'missing' }); + expect(empty.result.current.currentConversationId).toBe('default'); + expect(empty.result.current.activeConversation).toBeUndefined(); + }); + + it('selectConversation updates the session, resets selection, and notifies the shell', () => { + const conversations = [conversation({ id: 'a', title: 'A' }), conversation({ id: 'b', title: 'B' })]; + const onConversationChange = vi.fn(); + const { result, resetSelection } = renderSessionChrome({ + conversations, + onActiveConversationChange: onConversationChange, + }); + + act(() => { + result.current.selectConversation('b'); + }); + + expect(result.current.currentConversationId).toBe('b'); + expect(result.current.activeConversation?.id).toBe('b'); + expect(resetSelection).toHaveBeenCalledTimes(1); + expect(onConversationChange).toHaveBeenCalledWith('b'); + }); + + it('dispatches composer actions against the current conversation', () => { + const { result } = renderSessionChrome(); + + act(() => { + result.current.dispatchComposer({ type: 'setText', text: 'hello agent' }); + result.current.dispatchComposer({ type: 'setMode', mode: 'code' }); + result.current.dispatchComposer({ + type: 'addMention', + mention: { id: 'agent-1', label: 'Coder', dispatchRole: 'dispatch' }, + }); + }); + + expect(result.current.composer).toMatchObject({ + conversationId: 'default', + text: 'hello agent', + mode: 'code', + mentions: [{ id: 'agent-1', label: 'Coder' }], + }); + }); + + it('saves a draft before resetting the composer on conversation switch', () => { + const conversations = [conversation({ id: 'a', title: 'A' }), conversation({ id: 'b', title: 'B' })]; + const { result } = renderSessionChrome({ conversations }); + + act(() => { + result.current.dispatchComposer({ type: 'setText', text: 'draft text' }); + }); + act(() => { + result.current.selectConversation('b'); + }); + + expect(result.current.composer.conversationId).toBe('b'); + expect(result.current.composer.text).toBe(''); + expect(window.localStorage.getItem('agenthub.composer.draft.a')).toBe( + JSON.stringify({ text: 'draft text', mentions: [] }), + ); + }); + + it('maps runtime agents into composer mentions', () => { + const agents: WorkbenchAgent[] = [ + { + id: 'builder', + name: 'Builder', + description: '代码实现', + status: 'available', + model: 'glm', + }, + { id: 'sparse', name: 'Sparse' }, + ]; + const { result } = renderSessionChrome({ agents }); + + expect(result.current.mentionableAgents).toEqual([ + { + id: 'builder', + label: 'Builder', + description: '代码实现', + status: 'available', + model: 'glm', + dispatchRole: 'dispatch', + }, + { id: 'sparse', label: 'Sparse', dispatchRole: 'dispatch' }, + ]); + }); + + it('collects transcript evidence into the mainchain summary', () => { + const evidenceBlock: TranscriptBlock = { + id: 'approval-1', + kind: 'approval', + author: { id: 'builder', name: 'Builder', role: 'agent' }, + title: '允许写入', + status: 'completed', + evidenceRefs: [{ id: 'ev-1', kind: 'approval', label: 'Allow bash' }], + }; + const transcript = [runSessionBlock('run-1'), evidenceBlock]; + const { result } = renderSessionChrome({ transcript }); + + expect(result.current.evidence).toEqual([ + { id: 'ev-1', kind: 'approval', label: 'Allow bash' }, + ]); + expect(result.current.mainchainSummary.exportEnabled).toBe(true); + expect(result.current.mainchainSummary.exportLabel).toBe('mainchain.exportJson'); + + const nodes = result.current.mainchainSummary.nodes; + expect(nodes.find((node) => node.id === 'hub-task')).toMatchObject({ + detail: 'task-1', + state: 'done', + }); + expect(nodes.find((node) => node.id === 'replay')).toMatchObject({ + detail: '2 transcript blocks', + state: 'done', + }); + // Approval evidence makes the evidence path active. + expect(nodes.find((node) => node.id === 'evidence-path')?.state).toBe('active'); + }); + + it('projects inspector route/context/preview/result views from the transcript', () => { + const transcript = [ + routeDecisionBlock('route-1'), + contextUsageBlock('ctx-1'), + previewBlock('pv-old', 'http://127.0.0.1/old'), + resultBlock('result-1'), + previewBlock('pv-new', 'http://127.0.0.1/new'), + ]; + const { result } = renderSessionChrome({ transcript }); + + expect(result.current.inspectorRouteBlocks.map((block) => block.id)).toEqual(['route-1']); + expect(result.current.inspectorContextBlocks.map((block) => block.id)).toEqual(['ctx-1']); + // The last preview block wins. + expect(result.current.inspectorDeployPreviewUrl).toBe('http://127.0.0.1/new'); + expect(result.current.inspectorRunResult).toEqual({ + success: true, + summary: '任务完成', + duration: '8m12s', + }); + }); + + it('clears the selected execution target when it disappears from the list', () => { + const { result, rerender } = renderSessionChrome({ + composerExecutionTargets: [{ id: 't1', label: 'Target 1' }], + }); + + act(() => { + result.current.setSelectedExecutionTargetId('t1'); + }); + expect(result.current.selectedExecutionTargetId).toBe('t1'); + expect(result.current.mainchainSummary.nodes.find((node) => node.id === 'target')) + .toMatchObject({ detail: 'Target 1', state: 'done' }); + + rerender({ composerExecutionTargets: [{ id: 't2', label: 'Target 2' }] }); + expect(result.current.selectedExecutionTargetId).toBe(''); + }); + + it('loads local CLI discovery on desktop settings and clears it elsewhere', async () => { + const discovery: LocalCliDiscoveryManifest = { + mode: 'no-spend-discovery', + readinessManifest: '.tmp/evidence/manifest.json', + readinessScript: 'scripts/verify.py', + generatedAt: '2026-08-19T00:00:00.000Z', + items: [ + { id: 'codex', name: 'Codex CLI', installed: true, version: '1.2.3', path: '/usr/bin/codex', noSpend: true }, + ], + }; + const listDiscovery = vi.fn().mockResolvedValue(discovery); + const platform = makePlatform({ + surface: 'desktop', + host: { localCliDiscovery: listDiscovery }, + }); + const { result, rerender } = renderSessionChrome({ platform, activePage: 'settings' }); + + // Optimistic fallback appears before the host resolves. + expect(result.current.localCliDiscovery).toBe(LOCAL_CLI_DISCOVERY_FALLBACK); + await flushAsyncWork(); + expect(result.current.localCliDiscovery).toBe(discovery); + expect(listDiscovery).toHaveBeenCalledTimes(1); + + rerender({ platform, activePage: 'chat' }); + expect(result.current.localCliDiscovery).toBeNull(); + }); + + it('keeps the local CLI fallback when discovery rejects', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const platform = makePlatform({ + surface: 'desktop', + host: { localCliDiscovery: vi.fn().mockRejectedValue(new Error('host down')) }, + }); + const { result } = renderSessionChrome({ platform, activePage: 'settings' }); + + await flushAsyncWork(); + expect(result.current.localCliDiscovery).toBe(LOCAL_CLI_DISCOVERY_FALLBACK); + errorSpy.mockRestore(); + }); + + it('loads runtime session import only for desktop localEdge settings', async () => { + const items: RuntimeSessionSummary[] = [ + { runtime: 'codex', id: 's1', title: 'Session 1' }, + ]; + const listRuntimeSessions = vi.fn().mockResolvedValue(items); + const platform = makePlatform({ + surface: 'desktop', + capabilities: { localEdge: true, localFiles: false, browserPreview: false }, + host: { listRuntimeSessions }, + }); + const { result } = renderSessionChrome({ platform, activePage: 'settings' }); + + expect(result.current.sessionImportVisible).toBe(true); + expect(result.current.sessionImportLoading).toBe(true); + await flushAsyncWork(); + expect(result.current.sessionImportItems).toEqual(items); + expect(result.current.sessionImportLoading).toBe(false); + expect(result.current.sessionImportError).toBeNull(); + + // A web surface without localEdge stays inert. + const web = renderSessionChrome({ activePage: 'settings' }); + expect(web.result.current.sessionImportVisible).toBe(false); + expect(web.result.current.sessionImportItems).toEqual([]); + expect(web.result.current.sessionImportLoading).toBe(false); + }); + + it('surfaces session import errors and refreshes the list', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const listRuntimeSessions = vi.fn() + .mockRejectedValueOnce(new Error('edge offline')) + .mockResolvedValueOnce([{ runtime: 'codex', id: 's9', title: 'Recovered' }]); + const platform = makePlatform({ + surface: 'desktop', + capabilities: { localEdge: true, localFiles: false, browserPreview: false }, + host: { listRuntimeSessions }, + }); + const { result } = renderSessionChrome({ platform, activePage: 'settings' }); + + await flushAsyncWork(); + expect(result.current.sessionImportError).toBe('edge offline'); + expect(result.current.sessionImportItems).toEqual([]); + expect(result.current.sessionImportLoading).toBe(false); + + act(() => { + result.current.refreshSessionImport(); + }); + await flushAsyncWork(); + expect(listRuntimeSessions).toHaveBeenCalledTimes(2); + expect(result.current.sessionImportError).toBeNull(); + expect(result.current.sessionImportItems).toEqual([ + { runtime: 'codex', id: 's9', title: 'Recovered' }, + ]); + errorSpy.mockRestore(); + }); + + it('opens search with Ctrl/Cmd+F only while on the chat page', () => { + const { result, rerender } = renderSessionChrome({ isChatPage: false }); + + act(() => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true })); + }); + expect(result.current.searchOpen).toBe(false); + + rerender({ isChatPage: true }); + act(() => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true })); + }); + expect(result.current.searchOpen).toBe(true); + + act(() => { + result.current.setSearchOpen(false); + }); + expect(result.current.searchOpen).toBe(false); + + act(() => { + result.current.setSearchHighlightId('b1'); + }); + expect(result.current.searchHighlightId).toBe('b1'); + }); + + it('creates the settings service from the platform port with mock defaults', () => { + const readSettings = vi.fn().mockResolvedValue({}); + const writeSettings = vi.fn().mockResolvedValue(undefined); + const { result } = renderSessionChrome({ + platform: makePlatform({ settings: { readSettings, writeSettings } }), + }); + + expect(result.current.settingsService).not.toBeNull(); + expect(result.current.settingsService?.initialized).toBe(false); + expect(result.current.settingsService?.readAll().theme).toBe(WORKBENCH_MOCK_SETTINGS_DEFAULTS.theme); + expect(result.current.settingsService?.readAll().inspectorVisible).toBe( + WORKBENCH_MOCK_SETTINGS_DEFAULTS.inspectorVisible, + ); + }); + + it('asks the layout to collapse the inspector when inspectorVisible is false', async () => { + const collapseListener = vi.fn(); + window.addEventListener(INSPECTOR_DEFAULT_COLLAPSE_EVENT, collapseListener); + const readSettings = vi.fn().mockResolvedValue({ inspectorVisible: 'false' }); + const writeSettings = vi.fn().mockResolvedValue(undefined); + const { result } = renderSessionChrome({ + platform: makePlatform({ settings: { readSettings, writeSettings } }), + }); + + expect(collapseListener).not.toHaveBeenCalled(); + await act(async () => { + await result.current.settingsService!.init(); + }); + expect(result.current.settingsService?.initialized).toBe(true); + expect(result.current.settingsService?.readAll().inspectorVisible).toBe(false); + expect(collapseListener).toHaveBeenCalledTimes(1); + window.removeEventListener(INSPECTOR_DEFAULT_COLLAPSE_EVENT, collapseListener); + }); + + it('never force-opens the inspector from settings', async () => { + const collapseListener = vi.fn(); + window.addEventListener(INSPECTOR_DEFAULT_COLLAPSE_EVENT, collapseListener); + const readSettings = vi.fn().mockResolvedValue({ inspectorVisible: 'true' }); + const writeSettings = vi.fn().mockResolvedValue(undefined); + const { result } = renderSessionChrome({ + platform: makePlatform({ settings: { readSettings, writeSettings } }), + }); + + await act(async () => { + await result.current.settingsService!.init(); + }); + expect(result.current.settingsService?.readAll().inspectorVisible).toBe(true); + expect(collapseListener).not.toHaveBeenCalled(); + window.removeEventListener(INSPECTOR_DEFAULT_COLLAPSE_EVENT, collapseListener); + }); + + it('toggles the applied theme via handleToggleTheme', () => { + const { result } = renderSessionChrome(); + expect(document.documentElement.getAttribute('data-theme')).toBeNull(); + + act(() => { + result.current.handleToggleTheme(); + }); + expect(document.documentElement.getAttribute('data-theme')).toBe('dark'); + expect(window.localStorage.getItem('agenthub-v4-theme')).toBe('dark'); + + act(() => { + result.current.handleToggleTheme(); + }); + expect(document.documentElement.getAttribute('data-theme')).toBe('light'); + expect(window.localStorage.getItem('agenthub-v4-theme')).toBe('light'); + }); + + it('opens the inspector for review files and deploy submissions', () => { + const { result, openInspector, showWorkbenchToast } = renderSessionChrome(); + const file = { name: 'README.md', type: 'md', isPrimary: true }; + + act(() => { + result.current.openReviewFile(file); + }); + expect(openInspector).toHaveBeenCalledTimes(1); + expect(result.current.reviewFileRequest).toEqual(file); + + act(() => { + result.current.handleDeploySubmit('run-1'); + }); + expect(openInspector).toHaveBeenCalledTimes(2); + expect(showWorkbenchToast).toHaveBeenCalledWith('toast.deployPreviewOpened'); + }); + + it('toasts instead of copying when there is no evidence to export', () => { + const { result, showWorkbenchToast, copyText } = renderSessionChrome(); + + act(() => { + result.current.exportMainchainEvidence(); + }); + + expect(showWorkbenchToast).toHaveBeenCalledWith('toast.noEvidence'); + expect(copyText).not.toHaveBeenCalled(); + }); + + it('copies serialized mainchain evidence when export is enabled', () => { + const { result, showWorkbenchToast, copyText } = renderSessionChrome({ + transcript: [runSessionBlock('run-1')], + workbenchStatus: { replayLabel: 'replay-9' }, + }); + + act(() => { + result.current.exportMainchainEvidence(); + }); + + expect(copyText).toHaveBeenCalledTimes(1); + const serialized = copyText.mock.calls[0]?.[0]; + expect(typeof serialized).toBe('string'); + const payload = JSON.parse(serialized as string) as Record; + expect(payload.surface).toBe('web'); + expect(payload.exportedAt).toEqual(expect.any(String)); + expect(payload.status).toEqual({ replayLabel: 'replay-9' }); + expect(payload.nodes).toEqual(result.current.mainchainSummary.nodes); + expect(payload.evidence).toEqual([]); + expect(showWorkbenchToast).toHaveBeenCalledWith('toast.evidenceCopied'); + }); +}); diff --git a/app/shared/src/workbench/useWorkbenchSettingsRoute.test.ts b/app/shared/src/workbench/useWorkbenchSettingsRoute.test.ts new file mode 100644 index 000000000..9ccbeca85 --- /dev/null +++ b/app/shared/src/workbench/useWorkbenchSettingsRoute.test.ts @@ -0,0 +1,474 @@ +// real_tested=true +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + WORKBENCH_DATA_MODE_STORAGE_KEY, + readWorkbenchDataModeOverride, +} from '../demo/dataMode'; +import type { SettingsPort } from '../platform/types'; +import { WORKBENCH_MOCK_SETTINGS_DEFAULTS } from './mockData'; +import { createSettingsService } from './settingsService'; +import type { SettingsService } from './settingsService'; +import { + createSettingsDefaults, + useWorkbenchSettingsRoute, +} from './useWorkbenchSettingsRoute'; +import { + WORKBENCH_COMPOSER_SUBMIT_BEHAVIOR_KEY, + readComposerSubmitBehavior, +} from './workbenchPreferences'; + +/** + * Controllable SettingsService double: same surface as the real service but + * with mutable loading/error fields and inspectable listener set, so the hook + * wiring (subscribe/init/emit) can be asserted directly. + */ +interface FakeSettingsService { + listeners: Set<() => void>; + emit: () => void; + init: () => Promise; + readAll: () => Record; + write: (key: string, value: unknown) => void; + writeBatch: (values: Record) => void; + subscribe: (listener: () => void) => () => void; + clearError: () => void; + initialized: boolean; + loading: boolean; + error: string | null; + errorKind: SettingsService['errorKind']; +} + +function createFakeSettingsService( + initial: Record = {}, + overrides: { + loading?: boolean; + error?: string | null; + errorKind?: SettingsService['errorKind']; + } = {}, +): FakeSettingsService { + let snapshot: Record = { ...initial }; + const listeners = new Set<() => void>(); + const service: FakeSettingsService = { + listeners, + emit: () => { + for (const listener of listeners) listener(); + }, + init: vi.fn(async () => { + service.initialized = true; + service.emit(); + }), + readAll: vi.fn(() => snapshot), + write: vi.fn((key: string, value: unknown) => { + snapshot = { ...snapshot, [key]: value }; + service.emit(); + }), + writeBatch: vi.fn((values: Record) => { + snapshot = { ...snapshot, ...values }; + service.emit(); + }), + subscribe: vi.fn((listener: () => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }), + clearError: vi.fn(() => { + service.error = null; + service.errorKind = null; + service.emit(); + }), + initialized: false, + loading: overrides.loading ?? false, + error: overrides.error ?? null, + errorKind: overrides.errorKind ?? null, + }; + return service; +} + +function createPort(overrides?: Partial): SettingsPort { + return { + readSettings: vi.fn(async () => ({})), + writeSettings: vi.fn(async () => undefined), + ...overrides, + }; +} + +beforeEach(() => { + window.localStorage.clear(); +}); + +describe('useWorkbenchSettingsRoute without a settingsService', () => { + it('returns mock defaults and a neutral status', () => { + const { result } = renderHook(() => useWorkbenchSettingsRoute({})); + + expect(result.current.hasSettingsService).toBe(false); + expect(result.current.realDataMode).toBe(false); + expect(result.current.settingsPane).toBe('appearance'); + expect(result.current.settingsLoading).toBe(false); + expect(result.current.settingsError).toBeNull(); + expect(result.current.settingsErrorKind).toBeNull(); + expect(result.current.settings).toEqual(createSettingsDefaults()); + expect(result.current.settings.theme).toBe('浅色'); + expect(result.current.settings.dataMode).toBe('Auto'); + expect(result.current.settings.composerSubmitBehavior).toBe('Enter 发送'); + expect(result.current.settings.permissions.Read).toBe('允许'); + expect(result.current.settings.stateStrategies.empty).toBe(true); + }); + + it('starts on the appearance pane and switches panes', () => { + const { result } = renderHook(() => useWorkbenchSettingsRoute({})); + + expect(result.current.settingsPane).toBe('appearance'); + + act(() => { + result.current.setSettingsPane('notify'); + }); + expect(result.current.settingsPane).toBe('notify'); + + act(() => { + result.current.setSettingsPane('states'); + }); + expect(result.current.settingsPane).toBe('states'); + }); + + it('treats observed/approved-real (and their aliases) as real data mode', () => { + for (const dataMode of ['observed', 'approved-real', 'real', '正常']) { + const { result } = renderHook(() => useWorkbenchSettingsRoute({ dataMode })); + expect(result.current.realDataMode).toBe(true); + } + }); + + it('treats mock/fixture/auto/undefined as non-real data mode', () => { + for (const dataMode of ['mock', 'fixture', 'auto', undefined]) { + const { result } = renderHook(() => useWorkbenchSettingsRoute({ dataMode })); + expect(result.current.realDataMode).toBe(false); + } + }); + + it('updates settings locally via handleSettingChange', () => { + const { result } = renderHook(() => useWorkbenchSettingsRoute({})); + + act(() => { + result.current.handleSettingChange('theme', '深色'); + }); + expect(result.current.settings.theme).toBe('深色'); + + act(() => { + result.current.handleSettingChange('stackedAvatars', false); + }); + expect(result.current.settings.stackedAvatars).toBe(false); + + act(() => { + result.current.handleSettingChange('perm_Shell', '允许'); + }); + expect(result.current.settings.permissions.Shell).toBe('允许'); + expect(result.current.settings.permissions.Read).toBe('允许'); + expect(result.current.settings.permissions.Write).toBe('需确认'); + + act(() => { + result.current.handleSettingChange('stateStrategy_invalid', false); + }); + expect(result.current.settings.stateStrategies.invalid).toBe(false); + expect(result.current.settings.stateStrategies.empty).toBe(true); + expect(result.current.settings.stateStrategies.missing).toBe(true); + }); + + it('persists dataMode changes to the localStorage override', () => { + const { result } = renderHook(() => useWorkbenchSettingsRoute({})); + + act(() => { + result.current.handleSettingChange('dataMode', '模拟'); + }); + expect(result.current.settings.dataMode).toBe('模拟'); + expect(window.localStorage.getItem(WORKBENCH_DATA_MODE_STORAGE_KEY)).toBe('mock'); + expect(readWorkbenchDataModeOverride()).toBe('mock'); + }); + + it('persists composerSubmitBehavior changes to localStorage', () => { + const { result } = renderHook(() => useWorkbenchSettingsRoute({})); + + act(() => { + result.current.handleSettingChange('composerSubmitBehavior', 'Ctrl+Enter 发送'); + }); + expect(result.current.settings.composerSubmitBehavior).toBe('Ctrl+Enter 发送'); + expect( + window.localStorage.getItem(WORKBENCH_COMPOSER_SUBMIT_BEHAVIOR_KEY), + ).toBe('ctrl-enter-send'); + expect(readComposerSubmitBehavior()).toBe('ctrl-enter-send'); + }); + + it('seeds defaults from localStorage overrides', () => { + window.localStorage.setItem(WORKBENCH_DATA_MODE_STORAGE_KEY, 'fixture'); + window.localStorage.setItem(WORKBENCH_COMPOSER_SUBMIT_BEHAVIOR_KEY, 'ctrl-enter-send'); + + const { result } = renderHook(() => useWorkbenchSettingsRoute({})); + + expect(result.current.settings.dataMode).toBe('Fixture'); + expect(result.current.settings.composerSubmitBehavior).toBe('Ctrl+Enter 发送'); + }); + + it('keeps retry and dismiss inert without a settingsService', () => { + const { result } = renderHook(() => useWorkbenchSettingsRoute({})); + + act(() => { + result.current.handleRetrySettingsLoad(); + result.current.handleDismissSettingsError(); + }); + expect(result.current.settingsError).toBeNull(); + expect(result.current.settingsErrorKind).toBeNull(); + expect(result.current.settings).toEqual(createSettingsDefaults()); + }); +}); + +describe('useWorkbenchSettingsRoute with a settingsService', () => { + it('initializes and subscribes to the service on mount', () => { + const service = createFakeSettingsService({ theme: '深色' }); + const { result } = renderHook(() => useWorkbenchSettingsRoute({ settingsService: service })); + + expect(result.current.hasSettingsService).toBe(true); + expect(service.subscribe).toHaveBeenCalledTimes(1); + expect(service.init).toHaveBeenCalledTimes(1); + expect(service.initialized).toBe(true); + expect(result.current.settings.theme).toBe('深色'); + expect(result.current.settingsLoading).toBe(false); + expect(result.current.settingsError).toBeNull(); + }); + + it('mirrors loading and error state from the service', () => { + const service = createFakeSettingsService( + {}, + { loading: true, error: '设置加载失败', errorKind: 'init' }, + ); + const { result } = renderHook(() => useWorkbenchSettingsRoute({ settingsService: service })); + + expect(result.current.settingsLoading).toBe(true); + expect(result.current.settingsError).toBe('设置加载失败'); + expect(result.current.settingsErrorKind).toBe('init'); + }); + + it('writes plain setting changes through to the service', () => { + const service = createFakeSettingsService(WORKBENCH_MOCK_SETTINGS_DEFAULTS); + const { result } = renderHook(() => useWorkbenchSettingsRoute({ settingsService: service })); + + act(() => { + result.current.handleSettingChange('theme', '深色'); + }); + expect(service.write).toHaveBeenCalledWith('theme', '深色'); + expect(result.current.settings.theme).toBe('深色'); + }); + + it('writes perm_ and stateStrategy_ changes as their parent objects', () => { + const service = createFakeSettingsService(WORKBENCH_MOCK_SETTINGS_DEFAULTS); + const { result } = renderHook(() => useWorkbenchSettingsRoute({ settingsService: service })); + + act(() => { + result.current.handleSettingChange('perm_Write', '允许'); + }); + expect(result.current.settings.permissions.Write).toBe('允许'); + expect(service.write).toHaveBeenCalledWith( + 'permissions', + expect.objectContaining({ Write: '允许', Read: '允许' }), + ); + + act(() => { + result.current.handleSettingChange('stateStrategy_empty', false); + }); + expect(result.current.settings.stateStrategies.empty).toBe(false); + expect(service.write).toHaveBeenCalledWith( + 'stateStrategies', + expect.objectContaining({ empty: false, invalid: true }), + ); + }); + + it('persists dataMode to both localStorage and the service when present', () => { + const service = createFakeSettingsService(WORKBENCH_MOCK_SETTINGS_DEFAULTS); + const { result } = renderHook(() => useWorkbenchSettingsRoute({ settingsService: service })); + + act(() => { + result.current.handleSettingChange('dataMode', '模拟'); + }); + expect(result.current.settings.dataMode).toBe('模拟'); + expect(service.write).toHaveBeenCalledWith('dataMode', '模拟'); + expect(window.localStorage.getItem(WORKBENCH_DATA_MODE_STORAGE_KEY)).toBe('mock'); + }); + + it('picks up external service writes via subscription', () => { + const service = createFakeSettingsService({ density: '标准' }); + const { result } = renderHook(() => useWorkbenchSettingsRoute({ settingsService: service })); + + expect(result.current.settings.density).toBe('标准'); + + act(() => { + service.write('density', '紧凑'); + }); + expect(result.current.settings.density).toBe('紧凑'); + }); + + it('re-inits the service on handleRetrySettingsLoad', async () => { + const service = createFakeSettingsService({ theme: '深色' }); + const { result } = renderHook(() => useWorkbenchSettingsRoute({ settingsService: service })); + + await act(async () => { + result.current.handleRetrySettingsLoad(); + }); + expect(service.init).toHaveBeenCalledTimes(2); + }); + + it('dismisses the settings error via handleDismissSettingsError', () => { + const service = createFakeSettingsService( + {}, + { error: '设置保存失败', errorKind: 'write' }, + ); + const { result } = renderHook(() => useWorkbenchSettingsRoute({ settingsService: service })); + + expect(result.current.settingsError).toBe('设置保存失败'); + expect(result.current.settingsErrorKind).toBe('write'); + + act(() => { + result.current.handleDismissSettingsError(); + }); + expect(service.clearError).toHaveBeenCalledTimes(1); + expect(result.current.settingsError).toBeNull(); + expect(result.current.settingsErrorKind).toBeNull(); + }); + + it('unsubscribes from the service on unmount', () => { + const service = createFakeSettingsService(); + const { unmount } = renderHook(() => useWorkbenchSettingsRoute({ settingsService: service })); + + expect(service.listeners.size).toBe(1); + + unmount(); + expect(service.listeners.size).toBe(0); + }); +}); + +describe('useWorkbenchSettingsRoute with createSettingsService', () => { + it('loads remote settings once init resolves', async () => { + let resolveRead!: (value: Record) => void; + const port = createPort({ + readSettings: vi.fn( + () => + new Promise>((resolve) => { + resolveRead = resolve; + }), + ), + }); + const service = createSettingsService(port, WORKBENCH_MOCK_SETTINGS_DEFAULTS); + const { result } = renderHook(() => useWorkbenchSettingsRoute({ settingsService: service })); + + expect(result.current.hasSettingsService).toBe(true); + expect(result.current.settingsLoading).toBe(true); + expect(result.current.settings.theme).toBe('浅色'); + + await act(async () => { + resolveRead({ theme: '深色', density: '紧凑' }); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(result.current.settingsLoading).toBe(false); + expect(result.current.settings.theme).toBe('深色'); + expect(result.current.settings.density).toBe('紧凑'); + expect(result.current.settingsError).toBeNull(); + }); + + it('surfaces an init error and recovers on retry', async () => { + let rejectFirstRead!: (reason: unknown) => void; + let resolveSecondRead!: (value: Record) => void; + let readCallCount = 0; + const readSettings = vi.fn( + () => + new Promise>((resolve, reject) => { + readCallCount += 1; + if (readCallCount === 1) { + rejectFirstRead = reject; + } else { + resolveSecondRead = resolve; + } + }), + ); + const port = createPort({ readSettings }); + const service = createSettingsService(port, WORKBENCH_MOCK_SETTINGS_DEFAULTS); + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + const { result } = renderHook(() => useWorkbenchSettingsRoute({ settingsService: service })); + + expect(result.current.settingsLoading).toBe(true); + + await act(async () => { + rejectFirstRead(new Error('backend down')); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(result.current.settingsError).toBe('backend down'); + expect(result.current.settingsErrorKind).toBe('init'); + expect(result.current.settingsLoading).toBe(false); + expect(result.current.settings.theme).toBe('浅色'); + + act(() => { + result.current.handleRetrySettingsLoad(); + }); + + await act(async () => { + resolveSecondRead({ theme: '深色' }); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(readSettings).toHaveBeenCalledTimes(2); + expect(result.current.settingsError).toBeNull(); + expect(result.current.settingsErrorKind).toBeNull(); + expect(result.current.settings.theme).toBe('深色'); + + consoleErrorSpy.mockRestore(); + }); + + it('surfaces a write error, rolls back the value, and dismiss clears it', async () => { + let rejectWrite!: (reason: unknown) => void; + const port = createPort({ + writeSettings: vi.fn( + () => + new Promise((_, reject) => { + rejectWrite = reject; + }), + ), + }); + const service = createSettingsService(port, WORKBENCH_MOCK_SETTINGS_DEFAULTS); + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + const { result } = renderHook(() => useWorkbenchSettingsRoute({ settingsService: service })); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(result.current.settingsLoading).toBe(false); + + act(() => { + result.current.handleSettingChange('theme', '深色'); + }); + expect(result.current.settings.theme).toBe('深色'); + expect(port.writeSettings).toHaveBeenCalledWith({ theme: '深色' }); + + await act(async () => { + rejectWrite(new Error('persist failed')); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(result.current.settingsError).toBe('persist failed'); + expect(result.current.settingsErrorKind).toBe('write'); + expect(result.current.settings.theme).toBe('浅色'); + + act(() => { + result.current.handleDismissSettingsError(); + }); + expect(result.current.settingsError).toBeNull(); + expect(result.current.settingsErrorKind).toBeNull(); + expect(result.current.settings.theme).toBe('浅色'); + + consoleErrorSpy.mockRestore(); + }); +}); diff --git a/app/shared/src/workbench/useWorkbenchTranscriptChrome.test.ts b/app/shared/src/workbench/useWorkbenchTranscriptChrome.test.ts new file mode 100644 index 000000000..f32c953ca --- /dev/null +++ b/app/shared/src/workbench/useWorkbenchTranscriptChrome.test.ts @@ -0,0 +1,666 @@ +// real_tested=true +import { act, renderHook } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { ReactElement } from 'react'; +import type { WorkbenchConversation } from '../platform'; +import type { TranscriptBlock } from '../transcript'; +import type { TranscriptContextMenuEvent, TranscriptPointerEvent } from './transcriptEventTypes'; +import { + SELECTION_HOLD_CANCEL_DISTANCE, + SELECTION_HOLD_DELAY_MS, + WORKBENCH_PULSE_MS, + WORKBENCH_TOAST_MS, +} from './workbenchTranscriptChromeHelpers'; +import { + useWorkbenchTranscriptChrome, + type UseWorkbenchTranscriptChromeOptions, +} from './useWorkbenchTranscriptChrome'; + +/* ═══════════════════════════════════════════════════════════════════════ + useWorkbenchTranscriptChrome — hook-level wiring over the #615/#627/ + #650/#755 controller. + + Covers default chrome state, selection enter/toggle/range/reset, the + toast window, block context menus, copy/regenerate/approval block + actions, Hub REST message actions (pin/unpin/react/recall/forward) with + and without a session id, multi-select bar actions, selection hotkeys, + hold-to-select pointer flows, and the selection bar rect. + ═══════════════════════════════════════════════════════════════════════ */ + +/** Key-echo translator matching the helper test convention. */ +function t(key: string, options?: Record): string { + return options?.count !== undefined ? `${key}:${String(options.count)}` : key; +} + +function textBlock( + overrides: Partial> = {}, +): TranscriptBlock { + return { + id: 'b1', + kind: 'text', + author: { id: 'agent-1', role: 'agent', name: 'Agent' }, + text: 'hello world from agent', + createdAt: '2026-01-01T00:00:00.000Z', + ...overrides, + }; +} + +function userTextBlock( + overrides: Partial> = {}, +): TranscriptBlock { + return textBlock({ + id: 'u1', + author: { id: 'user-1', role: 'human', name: 'You' }, + text: 'please build the thing', + ...overrides, + }); +} + +function permissionBlock( + overrides: Partial> = {}, +): Extract { + return { + id: 'perm-1', + kind: 'permission_request', + requestId: 'req-1', + title: 'Allow bash?', + status: 'pending', + author: { id: 'agent-1', role: 'agent', name: 'Agent' }, + createdAt: '2026-01-01T00:00:00.000Z', + ...overrides, + }; +} + +function pointerEvent(partial: Partial = {}): TranscriptPointerEvent { + return { + preventDefault: vi.fn(), + clientX: 10, + clientY: 10, + button: 0, + shiftKey: false, + ctrlKey: false, + metaKey: false, + target: null, + currentTarget: document.createElement('div'), + ...partial, + }; +} + +function contextMenuEvent(partial: Partial = {}): TranscriptContextMenuEvent { + return { + preventDefault: vi.fn(), + clientX: 10, + clientY: 20, + ...partial, + }; +} + +/** jsdom has no clipboard; install a recording writeText stub. */ +function stubClipboard(): ReturnType { + const writeText = vi.fn(); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + configurable: true, + writable: true, + }); + return writeText; +} + +function findMenuAction( + groups: Array void }>>, + label: string, +): (() => void) | undefined { + for (const group of groups) { + const item = group.find((entry) => entry.label === label); + if (item) return item.onClick; + } + return undefined; +} + +function renderTranscriptChrome(initialProps: Partial = {}) { + const dispatchComposer = vi.fn(); + const composerInputRef: { current: HTMLTextAreaElement | null } = { current: null }; + const workspaceRef: { current: HTMLElement | null } = { current: null }; + + const rendered = renderHook( + (props: Partial) => useWorkbenchTranscriptChrome({ + transcript: [], + t, + dispatchComposer, + composerInputRef, + workspaceRef, + inspectorCollapsed: false, + inspectorWidth: 400, + ...props, + }), + { initialProps }, + ); + + return { ...rendered, dispatchComposer, composerInputRef, workspaceRef }; +} + +describe('useWorkbenchTranscriptChrome', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('exposes default transcript chrome state and every handler', () => { + const { result } = renderTranscriptChrome({ transcript: [textBlock()] }); + + expect(result.current.selectionMode).toBe(false); + expect(result.current.selectedBlockIds).toEqual([]); + expect(result.current.softHiddenBlockIds).toEqual([]); + expect(result.current.actionedBlockIds).toEqual([]); + expect(result.current.contextMenu).toBeNull(); + expect(result.current.toastMessage).toBe(''); + expect(result.current.toastVisible).toBe(false); + expect(result.current.selectBarRect).toBeNull(); + expect(result.current.selectionModeRef.current).toBe(false); + + expect(result.current.multiSelectActions.map((action) => action.label)).toEqual([ + 'bar.selectAll', + 'bar.clear', + 'context.copy', + 'context.forward', + 'context.addTask', + 'context.exportDoc', + 'context.delete', + 'bar.exit', + ]); + + const groups = result.current.contextMenuGroups('b1'); + expect(groups).toHaveLength(3); + expect(groups[0]?.map((item) => item.label)).toEqual([ + 'context.copy', + 'context.react', + 'context.reply', + 'context.quote', + 'context.forward', + ]); + + expect(typeof result.current.setContextMenu).toBe('function'); + expect(typeof result.current.showWorkbenchToast).toBe('function'); + expect(typeof result.current.openBlockContextMenu).toBe('function'); + expect(typeof result.current.handleBlockSelect).toBe('function'); + expect(typeof result.current.handleTranscriptBlockAction).toBe('function'); + expect(typeof result.current.beginBlockHoldSelection).toBe('function'); + expect(typeof result.current.updateBlockHoldSelection).toBe('function'); + expect(typeof result.current.handleBlockPointerUp).toBe('function'); + expect(typeof result.current.copyText).toBe('function'); + expect(typeof result.current.resetSelection).toBe('function'); + }); + + it('builds context menu groups shaped for agent and user blocks', () => { + const { result } = renderTranscriptChrome({ + transcript: [textBlock(), userTextBlock()], + }); + + const agentGroups = result.current.contextMenuGroups('b1'); + // Agent text gets quote/regenerate; user text additionally gets edit/recall. + expect(agentGroups[0]?.some((item) => item.label === 'context.quote')).toBe(true); + expect(agentGroups[2]?.some((item) => item.label === 'context.regenerate')).toBe(true); + expect(agentGroups[2]?.some((item) => item.label === 'context.recall')).toBe(false); + + const userGroups = result.current.contextMenuGroups('u1'); + expect(userGroups[0]?.some((item) => item.label === 'context.edit')).toBe(true); + expect(userGroups[2]?.some((item) => item.label === 'context.recall')).toBe(true); + expect(userGroups[2]?.some((item) => item.label === 'context.regenerate')).toBe(false); + + // Non-text blocks drop the quote entry. + const permGroups = result.current.contextMenuGroups('perm-1'); + expect(permGroups[0]?.some((item) => item.label === 'context.quote')).toBe(false); + }); + + it('toggles selection by block id without forcing the selection bar', () => { + const { result } = renderTranscriptChrome({ transcript: [textBlock()] }); + + act(() => { + result.current.handleBlockSelect('b1'); + }); + expect(result.current.selectedBlockIds).toEqual(['b1']); + expect(result.current.selectionMode).toBe(false); + + act(() => { + result.current.handleBlockSelect('b1'); + }); + expect(result.current.selectedBlockIds).toEqual([]); + }); + + it('extends the selection across a shift-click range', () => { + const blocks = [ + textBlock({ id: 'b1' }), + textBlock({ id: 'b2' }), + textBlock({ id: 'b3' }), + ]; + const { result } = renderTranscriptChrome({ transcript: blocks }); + + act(() => { + result.current.handleBlockSelect('b1', { shiftKey: true }); + }); + expect(result.current.selectionMode).toBe(true); + expect(result.current.selectedBlockIds).toEqual(['b1']); + + act(() => { + result.current.handleBlockSelect('b3', { shiftKey: true }); + }); + expect(result.current.selectedBlockIds).toEqual(['b1', 'b2', 'b3']); + }); + + it('resets every selection surface via resetSelection', () => { + const blocks = [textBlock({ id: 'b1' }), textBlock({ id: 'b2' })]; + const { result } = renderTranscriptChrome({ transcript: blocks }); + + act(() => { + result.current.handleBlockSelect('b1', { shiftKey: true }); + result.current.handleBlockSelect('b2', { shiftKey: true }); + result.current.openBlockContextMenu(textBlock(), contextMenuEvent()); + findMenuAction(result.current.contextMenuGroups('b1'), 'context.delete')?.(); + }); + expect(result.current.selectionMode).toBe(true); + expect(result.current.contextMenu).not.toBeNull(); + expect(result.current.softHiddenBlockIds).toEqual(['b1']); + + act(() => { + result.current.resetSelection(); + }); + expect(result.current.selectionMode).toBe(false); + expect(result.current.selectedBlockIds).toEqual([]); + expect(result.current.actionedBlockIds).toEqual([]); + expect(result.current.softHiddenBlockIds).toEqual([]); + expect(result.current.contextMenu).toBeNull(); + }); + + it('shows a toast that auto-hides after the toast window', () => { + vi.useFakeTimers(); + const { result, unmount } = renderTranscriptChrome({ transcript: [textBlock()] }); + + act(() => { + result.current.showWorkbenchToast('hello toast'); + }); + expect(result.current.toastMessage).toBe('hello toast'); + expect(result.current.toastVisible).toBe(true); + + act(() => { + vi.advanceTimersByTime(WORKBENCH_TOAST_MS); + }); + expect(result.current.toastVisible).toBe(false); + + // A second toast before the window expires resets the timer. + act(() => { + result.current.showWorkbenchToast('first'); + }); + act(() => { + vi.advanceTimersByTime(WORKBENCH_TOAST_MS - 500); + result.current.showWorkbenchToast('second'); + }); + expect(result.current.toastMessage).toBe('second'); + act(() => { + vi.advanceTimersByTime(WORKBENCH_TOAST_MS - 500); + }); + expect(result.current.toastVisible).toBe(true); + act(() => { + vi.advanceTimersByTime(500); + }); + expect(result.current.toastVisible).toBe(false); + + unmount(); + }); + + it('opens a block context menu with the event coordinates', () => { + const { result } = renderTranscriptChrome({ transcript: [textBlock({ id: 'b1' })] }); + const event = contextMenuEvent({ clientX: 42, clientY: 77 }); + + act(() => { + result.current.openBlockContextMenu(textBlock({ id: 'b1' }), event); + }); + + expect(event.preventDefault).toHaveBeenCalledTimes(1); + expect(result.current.contextMenu).toEqual({ + blockId: 'b1', + title: 'hello world from agent', + x: 42, + y: 77, + }); + }); + + it('copies block text on the copy action and pulses the block', () => { + vi.useFakeTimers(); + const writeText = stubClipboard(); + const { result } = renderTranscriptChrome({ transcript: [textBlock()] }); + + act(() => { + result.current.handleTranscriptBlockAction('copy', 'b1'); + }); + + expect(writeText).toHaveBeenCalledWith('hello world from agent'); + expect(result.current.toastMessage).toBe('toast.cardCopied'); + expect(result.current.toastVisible).toBe(true); + expect(result.current.actionedBlockIds).toEqual(['b1']); + + act(() => { + vi.advanceTimersByTime(WORKBENCH_PULSE_MS); + }); + expect(result.current.actionedBlockIds).toEqual([]); + }); + + it('regenerates agent text and soft-hides the block', () => { + const onRegenerate = vi.fn(); + const { result } = renderTranscriptChrome({ + transcript: [textBlock(), userTextBlock()], + onRegenerate, + }); + + act(() => { + result.current.handleTranscriptBlockAction('regenerate', 'b1'); + }); + expect(onRegenerate).toHaveBeenCalledWith('b1'); + expect(result.current.softHiddenBlockIds).toEqual(['b1']); + expect(result.current.toastMessage).toBe('action.regenerating'); + + // Human-authored blocks never regenerate. + act(() => { + result.current.handleTranscriptBlockAction('regenerate', 'u1'); + }); + expect(onRegenerate).toHaveBeenCalledTimes(1); + expect(result.current.softHiddenBlockIds).toEqual(['b1']); + }); + + it('routes approve/deny decisions for permission requests', () => { + const onApprovalDecision = vi.fn(); + const { result } = renderTranscriptChrome({ + transcript: [ + permissionBlock({ + teamId: 'team-1', + teamRunId: 'team-run-1', + targetId: 'target-9', + }), + ], + onApprovalDecision, + }); + + act(() => { + result.current.handleTranscriptBlockAction('approve', 'perm-1'); + }); + expect(onApprovalDecision).toHaveBeenCalledWith({ + approvalId: 'req-1', + decision: 'allow', + teamId: 'team-1', + teamRunId: 'team-run-1', + targetId: 'target-9', + }); + expect(result.current.toastMessage).toBe('action.approved'); + + act(() => { + result.current.handleTranscriptBlockAction('deny', 'perm-1'); + }); + expect(onApprovalDecision).toHaveBeenLastCalledWith({ + approvalId: 'req-1', + decision: 'deny', + teamId: 'team-1', + teamRunId: 'team-run-1', + targetId: 'target-9', + }); + expect(result.current.toastMessage).toBe('action.denied'); + + // Non-permission blocks never produce approval decisions. + act(() => { + result.current.handleTranscriptBlockAction('approve', 'b1'); + }); + expect(onApprovalDecision).toHaveBeenCalledTimes(2); + }); + + it('wires pin/unpin/react/recall through the REST handlers with a session id', () => { + const onPinMessage = vi.fn(); + const onUnpinMessage = vi.fn(); + const onAddMessageReaction = vi.fn(); + const onRecallMessage = vi.fn(); + const { result } = renderTranscriptChrome({ + transcript: [textBlock(), textBlock({ id: 'pinned-1', pinned: true }), userTextBlock()], + sessionId: 's1', + onPinMessage, + onUnpinMessage, + onAddMessageReaction, + onRecallMessage, + }); + + act(() => { + findMenuAction(result.current.contextMenuGroups('b1'), 'context.pinMessage')?.(); + }); + expect(onPinMessage).toHaveBeenCalledWith('b1', 's1'); + expect(result.current.toastMessage).toBe('toast.pinUpdated'); + + act(() => { + findMenuAction(result.current.contextMenuGroups('pinned-1'), 'context.unpin')?.(); + }); + expect(onUnpinMessage).toHaveBeenCalledWith('pinned-1', 's1'); + expect(result.current.toastMessage).toBe('toast.unpinned'); + + act(() => { + findMenuAction(result.current.contextMenuGroups('b1'), 'context.react')?.(); + }); + expect(onAddMessageReaction).toHaveBeenCalledWith('b1', 's1', '👍'); + expect(result.current.toastMessage).toBe('toast.reactionAdded'); + + act(() => { + findMenuAction(result.current.contextMenuGroups('u1'), 'context.recall')?.(); + }); + expect(onRecallMessage).toHaveBeenCalledWith('u1'); + expect(result.current.toastMessage).toBe('toast.recalled'); + }); + + it('keeps placeholder toasts for REST actions without a session id', () => { + const onPinMessage = vi.fn(); + const { result } = renderTranscriptChrome({ + transcript: [textBlock(), userTextBlock()], + onPinMessage, + }); + + act(() => { + findMenuAction(result.current.contextMenuGroups('b1'), 'context.pinMessage')?.(); + }); + expect(onPinMessage).not.toHaveBeenCalled(); + expect(result.current.toastMessage).toBe('toast.pinUpdated'); + expect(result.current.actionedBlockIds).toEqual(['b1']); + + // Plain forward (no picker conversations) keeps the select-target toast. + act(() => { + findMenuAction(result.current.contextMenuGroups('b1'), 'context.forward')?.(); + }); + expect(result.current.toastMessage).toBe('toast.forwardSelectTarget'); + }); + + it('forwards to chosen targets through the context menu picker submenu', () => { + const onForwardMessage = vi.fn(); + const conversations: WorkbenchConversation[] = [ + { id: 'c1', title: 'C1', kind: 'direct' }, + { id: 'c2', title: 'C2', kind: 'group' }, + ]; + const { result } = renderTranscriptChrome({ + transcript: [textBlock()], + sessionId: 's1', + onForwardMessage, + }); + + const groups = result.current.contextMenuGroups('b1', conversations); + const forwardItem = groups[0]?.find((item) => item.label === 'context.forward'); + expect(forwardItem?.chevron).toBe(true); + expect(typeof forwardItem?.submenu).toBe('function'); + + const close = vi.fn(); + const picker = forwardItem?.submenu?.(close) as ReactElement<{ onConfirm: (targetSessionIds: string[]) => void }> | undefined; + act(() => { + picker?.props.onConfirm(['c1', 'c2']); + }); + expect(onForwardMessage).toHaveBeenCalledWith('b1', ['c1', 'c2']); + expect(result.current.toastMessage).toBe('toast.forwardQueued'); + expect(close).toHaveBeenCalledTimes(1); + }); + + it('multi-select bar actions select, clear, copy, delete, and exit', () => { + const writeText = stubClipboard(); + const blocks = [textBlock({ id: 'b1' }), textBlock({ id: 'b2' })]; + const { result } = renderTranscriptChrome({ transcript: blocks }); + + act(() => { + result.current.multiSelectActions.find((action) => action.label === 'bar.selectAll')?.onClick(); + }); + expect(result.current.selectedBlockIds).toEqual(['b1', 'b2']); + + act(() => { + result.current.multiSelectActions.find((action) => action.label === 'context.copy')?.onClick(); + }); + expect(writeText).toHaveBeenCalledWith('hello world from agent\nhello world from agent'); + expect(result.current.toastMessage).toBe('toast.multiCopy:2'); + + act(() => { + result.current.multiSelectActions.find((action) => action.label === 'bar.clear')?.onClick(); + }); + expect(result.current.selectedBlockIds).toEqual([]); + + act(() => { + result.current.handleBlockSelect('b1', { shiftKey: true }); + result.current.handleBlockSelect('b2', { shiftKey: true }); + }); + act(() => { + result.current.multiSelectActions.find((action) => action.label === 'context.delete')?.onClick(); + }); + expect(result.current.softHiddenBlockIds).toEqual(['b1', 'b2']); + expect(result.current.selectionMode).toBe(false); + expect(result.current.selectedBlockIds).toEqual([]); + expect(result.current.toastMessage).toBe('toast.multiDelete:2'); + + act(() => { + result.current.handleBlockSelect('b1', { shiftKey: true }); + }); + act(() => { + result.current.multiSelectActions.find((action) => action.label === 'bar.exit')?.onClick(); + }); + expect(result.current.selectionMode).toBe(false); + expect(result.current.selectedBlockIds).toEqual([]); + }); + + it('Ctrl+A, Escape, and Delete hotkeys drive the selection', () => { + const blocks = [textBlock({ id: 'b1' }), textBlock({ id: 'b2' })]; + const { result } = renderTranscriptChrome({ transcript: blocks }); + + // Enter selection through the context menu multi-select entry. + act(() => { + findMenuAction(result.current.contextMenuGroups('b1'), 'context.multiSelect')?.(); + }); + expect(result.current.selectionMode).toBe(true); + expect(result.current.selectedBlockIds).toEqual(['b1']); + + act(() => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', ctrlKey: true })); + }); + expect(result.current.selectedBlockIds).toEqual(['b1', 'b2']); + + act(() => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + }); + expect(result.current.selectionMode).toBe(false); + expect(result.current.selectedBlockIds).toEqual([]); + + // Hotkeys are inert while the selection bar is closed. + act(() => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete' })); + }); + expect(result.current.softHiddenBlockIds).toEqual([]); + + act(() => { + result.current.handleBlockSelect('b1', { shiftKey: true }); + result.current.handleBlockSelect('b2', { shiftKey: true }); + }); + act(() => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete' })); + }); + expect(result.current.softHiddenBlockIds).toEqual(['b1', 'b2']); + expect(result.current.selectionMode).toBe(false); + expect(result.current.toastMessage).toBe('toast.multiDelete:2'); + }); + + it('enters selection after the hold delay and consumes the pointer up', () => { + vi.useFakeTimers(); + const { result, unmount } = renderTranscriptChrome({ transcript: [textBlock()] }); + const event = pointerEvent({ clientX: 10, clientY: 10 }); + + act(() => { + result.current.beginBlockHoldSelection(textBlock(), event); + }); + expect(result.current.selectionMode).toBe(false); + + act(() => { + vi.advanceTimersByTime(SELECTION_HOLD_DELAY_MS); + }); + expect(result.current.selectionMode).toBe(true); + expect(result.current.selectedBlockIds).toEqual(['b1']); + expect(result.current.selectionModeRef.current).toBe(true); + + // The pointer up right after the hold is suppressed, not a new selection. + act(() => { + result.current.handleBlockPointerUp(textBlock(), event); + }); + expect(result.current.selectedBlockIds).toEqual(['b1']); + + // A later pointer up in selection mode selects the block. + act(() => { + result.current.handleBlockPointerUp(textBlock({ id: 'b2' }), pointerEvent()); + }); + expect(result.current.selectedBlockIds).toEqual(['b1', 'b2']); + + unmount(); + }); + + it('cancels the hold when the pointer moves beyond the cancel distance', () => { + vi.useFakeTimers(); + const { result, unmount } = renderTranscriptChrome({ transcript: [textBlock()] }); + + act(() => { + result.current.beginBlockHoldSelection(textBlock(), pointerEvent({ clientX: 10, clientY: 10 })); + }); + act(() => { + result.current.updateBlockHoldSelection( + pointerEvent({ clientX: 10 + SELECTION_HOLD_CANCEL_DISTANCE + 1, clientY: 10 }), + ); + }); + act(() => { + vi.advanceTimersByTime(SELECTION_HOLD_DELAY_MS); + }); + expect(result.current.selectionMode).toBe(false); + expect(result.current.selectedBlockIds).toEqual([]); + + // Non-left buttons never begin a hold. + act(() => { + result.current.beginBlockHoldSelection(textBlock(), pointerEvent({ button: 2 })); + }); + act(() => { + vi.advanceTimersByTime(SELECTION_HOLD_DELAY_MS); + }); + expect(result.current.selectionMode).toBe(false); + + unmount(); + }); + + it('tracks the selection bar rect from the workspace element', () => { + const workspace = document.createElement('div'); + vi.spyOn(workspace, 'getBoundingClientRect').mockReturnValue({ + left: 120, + width: 640, + top: 0, + right: 760, + bottom: 0, + x: 120, + y: 0, + height: 0, + toJSON: () => ({}), + } as DOMRect); + const { result, workspaceRef } = renderTranscriptChrome({ transcript: [textBlock()] }); + workspaceRef.current = workspace; + + act(() => { + findMenuAction(result.current.contextMenuGroups('b1'), 'context.multiSelect')?.(); + }); + expect(result.current.selectionMode).toBe(true); + expect(result.current.selectBarRect).toEqual({ left: 120, width: 640 }); + }); +});