From 2262de3342e4c241b5c78f4699908f35deb11707 Mon Sep 17 00:00:00 2001 From: Delicious233 <101502465+DeliciousBuding@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:02:58 +0800 Subject: [PATCH] =?UTF-8?q?test(shared):=20buildMainchainSummary=20+=204?= =?UTF-8?q?=20=E4=B8=AA=20workbench=20route/hook=20=E8=A1=A5=20119=20?= =?UTF-8?q?=E4=B8=AA=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95=EF=BC=88Lane=20D?= =?UTF-8?q?=20#1764=20=E7=AC=AC=E5=8D=81=E6=89=B9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mainchain/buildMainchainSummary.ts:+53(纯逻辑,9 态空输入/平台 surface/label 回退链/计数矩阵) - useWorkbenchAgentsRoute/useWorkbenchDocsRoute:+29(renderHook 模式,guard/状态快照/草稿语义) - useWorkbenchPanelLayout/useWorkbenchProfileChrome:+37(localStorage 恢复/拖拽阈值/DM 分支/头像计划) 不改任何产品代码。Lane D #1764 Co-authored-by: Cursor --- .../mainchain/buildMainchainSummary.test.ts | 904 ++++++++++++++++++ .../workbench/useWorkbenchAgentsRoute.test.ts | 395 ++++++++ .../workbench/useWorkbenchDocsRoute.test.ts | 148 +++ .../workbench/useWorkbenchPanelLayout.test.ts | 403 ++++++++ .../useWorkbenchProfileChrome.test.ts | 490 ++++++++++ 5 files changed, 2340 insertions(+) create mode 100644 app/shared/src/workbench/mainchain/buildMainchainSummary.test.ts create mode 100644 app/shared/src/workbench/useWorkbenchAgentsRoute.test.ts create mode 100644 app/shared/src/workbench/useWorkbenchDocsRoute.test.ts create mode 100644 app/shared/src/workbench/useWorkbenchPanelLayout.test.ts create mode 100644 app/shared/src/workbench/useWorkbenchProfileChrome.test.ts diff --git a/app/shared/src/workbench/mainchain/buildMainchainSummary.test.ts b/app/shared/src/workbench/mainchain/buildMainchainSummary.test.ts new file mode 100644 index 000000000..23c5d12cb --- /dev/null +++ b/app/shared/src/workbench/mainchain/buildMainchainSummary.test.ts @@ -0,0 +1,904 @@ +// real_tested=true +import { describe, expect, it } from 'vitest'; +import type { AgentHubPlatform } from '../../platform'; +import type { + AgentTimelineTranscriptBlock, + ApprovalTranscriptBlock, + ChildAgentTranscriptBlock, + EvidenceRef, + PermissionRequestTranscriptBlock, + RouteDecisionTranscriptBlock, + RunSessionTranscriptBlock, + RunStepGroupTranscriptBlock, + SubagentTranscriptBlock, + SubtaskTranscriptBlock, + TextTranscriptBlock, + ToolCallTranscriptBlock, +} from '../../transcript'; +import type { Artifact, Preview } from '../../types'; +import type { FileDiff } from '../../types/chat'; +import type { RuntimeEvidenceSnapshot } from '../../inspector'; +import { buildMainchainSummary, runtimeEvidenceSourceSummary } from './buildMainchainSummary'; +import type { MainchainSummary } from './types'; + +type SummaryInput = Parameters[0]; + +const EXPORT_DETAIL = + 'Copy Web -> Hub task -> target -> Edge -> replay/artifact/approval evidence JSON'; + +function t(key: string): string { + return key; +} + +function author(name: string) { + return { id: `author-${name}`, name, role: 'agent' as const }; +} + +function textBlock(overrides: Partial = {}): TextTranscriptBlock { + return { + kind: 'text', + id: 'text-1', + author: author('assistant'), + text: 'hello', + ...overrides, + }; +} + +function runSessionBlock( + overrides: Partial = {}, +): RunSessionTranscriptBlock { + return { + kind: 'run_session', + id: 'run-session-1', + author: author('orchestrator'), + title: 'Run session', + ...overrides, + }; +} + +function routeBlock( + overrides: Partial = {}, +): RouteDecisionTranscriptBlock { + return { + kind: 'route_decision', + id: 'route-1', + author: author('dispatcher'), + action: 'dispatch', + ...overrides, + }; +} + +function subagentBlock( + overrides: Partial = {}, +): SubagentTranscriptBlock { + return { + kind: 'subagent', + id: 'subagent-1', + author: author('agent'), + title: 'Subagent', + worker: 'builder', + status: 'running', + ...overrides, + }; +} + +function subtaskBlock(overrides: Partial = {}): SubtaskTranscriptBlock { + return { + kind: 'subtask', + id: 'subtask-1', + author: author('agent'), + title: 'Subtask', + status: 'running', + ...overrides, + }; +} + +function childAgentBlock( + overrides: Partial = {}, +): ChildAgentTranscriptBlock { + return { + kind: 'child_agent', + id: 'child-1', + author: author('agent'), + title: 'Child agent', + agent: 'inspector', + status: 'running', + ...overrides, + }; +} + +function toolCallBlock( + overrides: Partial = {}, +): ToolCallTranscriptBlock { + return { + kind: 'tool_call', + id: 'tool-1', + author: author('agent'), + toolName: 'bash', + status: 'completed', + ...overrides, + }; +} + +function agentTimelineBlock( + overrides: Partial = {}, +): AgentTimelineTranscriptBlock { + return { + kind: 'agent_timeline', + id: 'timeline-1', + author: author('agent'), + items: [], + ...overrides, + }; +} + +function runStepGroupBlock( + overrides: Partial = {}, +): RunStepGroupTranscriptBlock { + return { + kind: 'run_step_group', + id: 'group-1', + author: author('agent'), + icon: 'steps', + title: 'Steps', + status: 'completed', + children: [], + ...overrides, + }; +} + +function approvalBlock( + overrides: Partial = {}, +): ApprovalTranscriptBlock { + return { + kind: 'approval', + id: 'approval-1', + author: author('reviewer'), + title: 'Approve file', + status: 'pending', + ...overrides, + }; +} + +function permissionRequestBlock( + overrides: Partial = {}, +): PermissionRequestTranscriptBlock { + return { + kind: 'permission_request', + id: 'permission-1', + author: author('guard'), + requestId: 'req-1', + title: 'Permission request', + status: 'pending', + ...overrides, + }; +} + +function evidenceRef(kind: EvidenceRef['kind']): EvidenceRef { + return { id: `ref-${kind}`, kind, label: `label-${kind}` }; +} + +function fileDiffFixture(overrides: Partial = {}): FileDiff { + return { + filePath: 'src/app.ts', + status: 'modified', + additions: 2, + deletions: 1, + hunks: [], + ...overrides, + }; +} + +function artifactFixture(overrides: Partial = {}): Artifact { + return { + id: 'artifact-1', + runId: 'run-1', + threadId: 'thread-1', + kind: 'file', + path: 'out/report.md', + sizeBytes: 128, + createdAt: '2026-01-01T00:00:00Z', + ...overrides, + }; +} + +function previewFixture(overrides: Partial = {}): Preview { + return { + id: 'preview-1', + runId: 'run-1', + threadId: 'thread-1', + status: 'ready', + createdAt: '2026-01-01T00:00:00Z', + ...overrides, + }; +} + +function runtimeSnapshot(overrides: Partial = {}): RuntimeEvidenceSnapshot { + return { + diffs: [], + artifacts: [], + previews: [], + ...overrides, + }; +} + +function baseProps(overrides: Partial = {}): SummaryInput { + return { + evidence: [], + platformSurface: 'web', + selectedExecutionTargetId: '', + targetRequired: false, + transcript: [], + t, + ...overrides, + }; +} + +function nodeOf(summary: MainchainSummary, id: string) { + const found = summary.nodes.find((candidate) => candidate.id === id); + if (!found) { + throw new Error(`Missing node: ${id}`); + } + return found; +} + +describe('buildMainchainSummary', () => { + it('builds nine waiting/empty nodes and disables export for fully empty inputs', () => { + const summary = buildMainchainSummary(baseProps()); + + expect(summary.nodes.map((node) => node.id)).toEqual([ + 'web', + 'hub-task', + 'supervisor', + 'worker', + 'route-event', + 'target', + 'edge', + 'replay', + 'evidence-path', + ]); + expect(nodeOf(summary, 'web')).toEqual({ + id: 'web', + label: 'Web', + detail: 'Shared/Web workbench', + state: 'done', + }); + expect(nodeOf(summary, 'hub-task')).toEqual({ + id: 'hub-task', + label: 'Hub task', + detail: 'mainchain.waitingTask', + state: 'waiting', + }); + expect(nodeOf(summary, 'supervisor')).toEqual({ + id: 'supervisor', + label: 'Supervisor', + detail: 'Supervisor', + state: 'waiting', + }); + expect(nodeOf(summary, 'worker')).toEqual({ + id: 'worker', + label: 'Worker', + detail: 'mainchain.waitingWorker', + state: 'waiting', + }); + expect(nodeOf(summary, 'route-event')).toEqual({ + id: 'route-event', + label: 'Route + event', + detail: '0 route / 0 event', + state: 'empty', + }); + expect(nodeOf(summary, 'target')).toEqual({ + id: 'target', + label: 'Exact target', + detail: 'mainchain.pickTarget', + state: 'empty', + }); + expect(nodeOf(summary, 'edge')).toEqual({ + id: 'edge', + label: 'Active run', + detail: 'mainchain.waitingEdgeEvidence', + state: 'waiting', + }); + expect(nodeOf(summary, 'replay')).toEqual({ + id: 'replay', + label: 'Replay', + detail: 'mainchain.noTranscript', + state: 'empty', + }); + expect(nodeOf(summary, 'evidence-path')).toEqual({ + id: 'evidence-path', + label: 'Approval/artifact', + detail: 'mainchain.noApprovalArtifact', + state: 'empty', + }); + expect(summary.exportEnabled).toBe(false); + expect(summary.exportLabel).toBe('mainchain.waitingEvidence'); + expect(summary.exportDetail).toBe('mainchain.noRuntimeEvidence'); + }); + + it.each([ + ['web', 'Web', 'Shared/Web workbench'], + ['desktop', 'Shared UI', 'Desktop shared workbench'], + ['mobile', 'Shared UI', 'Desktop shared workbench'], + ] satisfies Array<[AgentHubPlatform['surface'], string, string]>)( + 'labels the surface node for the %s surface', + (surface, expectedLabel, expectedDetail) => { + const summary = buildMainchainSummary(baseProps({ platformSurface: surface })); + const surfaceNode = nodeOf(summary, 'web'); + expect(surfaceNode.label).toBe(expectedLabel); + expect(surfaceNode.detail).toBe(expectedDetail); + expect(surfaceNode.state).toBe('done'); + }, + ); + + it('marks hub task done from the run_session taskId', () => { + const summary = buildMainchainSummary( + baseProps({ transcript: [runSessionBlock({ taskId: 'task-7' })] }), + ); + expect(nodeOf(summary, 'hub-task')).toEqual({ + id: 'hub-task', + label: 'Hub task', + detail: 'task-7', + state: 'done', + }); + }); + + it('uses the workbench replayLabel as an active hub task when there is no taskId', () => { + const summary = buildMainchainSummary( + baseProps({ + transcript: [runSessionBlock()], + workbenchStatus: { replayLabel: 'Replaying task-3' }, + }), + ); + expect(nodeOf(summary, 'hub-task').detail).toBe('Replaying task-3'); + expect(nodeOf(summary, 'hub-task').state).toBe('active'); + }); + + it('keeps the hub task waiting when a run_session has no taskId and no replayLabel', () => { + const summary = buildMainchainSummary( + baseProps({ + transcript: [runSessionBlock()], + workbenchStatus: { initialLoading: true }, + }), + ); + expect(nodeOf(summary, 'hub-task').detail).toBe('mainchain.waitingTask'); + expect(nodeOf(summary, 'hub-task').state).toBe('waiting'); + }); + + it('prefers the run_session agentLabel for the supervisor', () => { + const summary = buildMainchainSummary( + baseProps({ + transcript: [runSessionBlock({ agentLabel: 'Chief', author: author('ignored') })], + }), + ); + expect(nodeOf(summary, 'supervisor').detail).toBe('Chief'); + expect(nodeOf(summary, 'supervisor').state).toBe('done'); + }); + + it('falls back to the run_session author name for the supervisor', () => { + const summary = buildMainchainSummary( + baseProps({ transcript: [runSessionBlock({ author: author('orchestrator-9') })] }), + ); + expect(nodeOf(summary, 'supervisor').detail).toBe('orchestrator-9'); + expect(nodeOf(summary, 'supervisor').state).toBe('done'); + }); + + it('falls back to the first route_decision author name when there is no run_session', () => { + const summary = buildMainchainSummary( + baseProps({ + transcript: [textBlock(), routeBlock({ author: author('router-7') })], + }), + ); + expect(nodeOf(summary, 'supervisor').detail).toBe('router-7'); + expect(nodeOf(summary, 'supervisor').state).toBe('done'); + }); + + it('defaults the supervisor to "Supervisor" with a waiting state when nothing is known', () => { + const summary = buildMainchainSummary(baseProps({ transcript: [textBlock()] })); + expect(nodeOf(summary, 'supervisor').detail).toBe('Supervisor'); + expect(nodeOf(summary, 'supervisor').state).toBe('waiting'); + expect(nodeOf(summary, 'edge').detail).toBe('mainchain.waitingEdgeEvidence'); + }); + + it('uses a subagent worker name for the worker node', () => { + const summary = buildMainchainSummary( + baseProps({ transcript: [subagentBlock({ worker: 'builder-1' })] }), + ); + expect(nodeOf(summary, 'worker').detail).toBe('builder-1'); + expect(nodeOf(summary, 'worker').state).toBe('active'); + }); + + it('uses a subtask worker name for the worker node', () => { + const summary = buildMainchainSummary( + baseProps({ transcript: [subtaskBlock({ worker: 'tester' })] }), + ); + expect(nodeOf(summary, 'worker').detail).toBe('tester'); + expect(nodeOf(summary, 'worker').state).toBe('active'); + }); + + it('skips worker blocks with missing names and falls through to the next one', () => { + const summary = buildMainchainSummary( + baseProps({ + transcript: [ + subtaskBlock({ worker: undefined }), + childAgentBlock({ agent: 'inspector-2' }), + ], + }), + ); + expect(nodeOf(summary, 'worker').detail).toBe('inspector-2'); + expect(nodeOf(summary, 'worker').state).toBe('active'); + }); + + it('uses a child_agent agent name for the worker node', () => { + const summary = buildMainchainSummary( + baseProps({ transcript: [childAgentBlock({ agent: 'auditor' })] }), + ); + expect(nodeOf(summary, 'worker').detail).toBe('auditor'); + expect(nodeOf(summary, 'worker').state).toBe('active'); + }); + + it('falls back to the route targetAgent when no worker block has a name', () => { + const summary = buildMainchainSummary( + baseProps({ + transcript: [routeBlock({ targetAgent: 'planner' }), subtaskBlock({ worker: undefined })], + }), + ); + expect(nodeOf(summary, 'worker').detail).toBe('planner'); + expect(nodeOf(summary, 'worker').state).toBe('active'); + }); + + it('keeps the worker waiting when no worker or route target is known', () => { + const summary = buildMainchainSummary(baseProps({ transcript: [textBlock()] })); + expect(nodeOf(summary, 'worker').detail).toBe('mainchain.waitingWorker'); + expect(nodeOf(summary, 'worker').state).toBe('waiting'); + }); + + it('counts route_decision blocks against event blocks for the route-event node', () => { + const summary = buildMainchainSummary( + baseProps({ + transcript: [ + routeBlock(), + routeBlock({ id: 'route-2' }), + toolCallBlock(), + agentTimelineBlock(), + runStepGroupBlock(), + ], + }), + ); + expect(nodeOf(summary, 'route-event').detail).toBe('2 route / 3 event'); + expect(nodeOf(summary, 'route-event').state).toBe('done'); + }); + + it('blocks the target when required, nothing is selected, and the state is no-target', () => { + const summary = buildMainchainSummary( + baseProps({ + targetRequired: true, + workbenchStatus: { targetState: 'no-target' }, + }), + ); + expect(nodeOf(summary, 'target').detail).toBe('mainchain.noTarget'); + expect(nodeOf(summary, 'target').state).toBe('blocked'); + }); + + it('blocks the target when required, nothing is selected, and no label exists', () => { + const summary = buildMainchainSummary(baseProps({ targetRequired: true })); + expect(nodeOf(summary, 'target').detail).toBe('mainchain.noTarget'); + expect(nodeOf(summary, 'target').state).toBe('blocked'); + }); + + it('does not block the target when one is already selected', () => { + const summary = buildMainchainSummary( + baseProps({ + targetRequired: true, + selectedExecutionTargetId: 'exec-1', + workbenchStatus: { targetState: 'no-target' }, + }), + ); + expect(nodeOf(summary, 'target').detail).toBe('mainchain.pickTarget'); + expect(nodeOf(summary, 'target').state).toBe('waiting'); + }); + + it('blocks the target on no-target state even when a label is available', () => { + const summary = buildMainchainSummary( + baseProps({ + targetRequired: true, + composerTargetLabel: 'picked-label', + workbenchStatus: { targetState: 'no-target' }, + }), + ); + expect(nodeOf(summary, 'target').detail).toBe('picked-label'); + expect(nodeOf(summary, 'target').state).toBe('blocked'); + }); + + it('marks the target done from the composer target label', () => { + const summary = buildMainchainSummary( + baseProps({ composerTargetLabel: 'Composer target' }), + ); + expect(nodeOf(summary, 'target').detail).toBe('Composer target'); + expect(nodeOf(summary, 'target').state).toBe('done'); + }); + + it('prefers composerTargetLabel over workbenchStatus.targetLabel over run_session targetLabel', () => { + const summary = buildMainchainSummary( + baseProps({ + composerTargetLabel: 'composer-label', + workbenchStatus: { targetLabel: 'status-label' }, + transcript: [runSessionBlock({ targetLabel: 'session-label' })], + }), + ); + expect(nodeOf(summary, 'target').detail).toBe('composer-label'); + expect(nodeOf(summary, 'target').state).toBe('done'); + }); + + it('falls back to workbenchStatus.targetLabel', () => { + const summary = buildMainchainSummary( + baseProps({ + workbenchStatus: { targetLabel: 'status-label' }, + transcript: [runSessionBlock({ targetLabel: 'session-label' })], + }), + ); + expect(nodeOf(summary, 'target').detail).toBe('status-label'); + expect(nodeOf(summary, 'target').state).toBe('done'); + }); + + it('falls back to the run_session targetLabel', () => { + const summary = buildMainchainSummary( + baseProps({ transcript: [runSessionBlock({ targetLabel: 'session-label' })] }), + ); + expect(nodeOf(summary, 'target').detail).toBe('session-label'); + expect(nodeOf(summary, 'target').state).toBe('done'); + }); + + it('ignores the no-target state when a target is not required', () => { + const summary = buildMainchainSummary( + baseProps({ + targetRequired: false, + workbenchStatus: { targetState: 'no-target' }, + }), + ); + expect(nodeOf(summary, 'target').detail).toBe('mainchain.pickTarget'); + expect(nodeOf(summary, 'target').state).toBe('empty'); + }); + + it('activates the edge from the run_session runId when there is no runtime evidence', () => { + const summary = buildMainchainSummary( + baseProps({ transcript: [runSessionBlock({ runId: 'run-9' })] }), + ); + expect(nodeOf(summary, 'edge').detail).toBe('run-9'); + expect(nodeOf(summary, 'edge').state).toBe('active'); + }); + + it('prefers the runtime evidence runId over the run_session runId', () => { + const summary = buildMainchainSummary( + baseProps({ + transcript: [runSessionBlock({ runId: 'run-1' })], + runtimeEvidence: runtimeSnapshot({ runId: 'run-2' }), + }), + ); + expect(nodeOf(summary, 'edge').detail).toBe('run-2'); + expect(nodeOf(summary, 'edge').state).toBe('active'); + }); + + it('prefers the run_session edgeRunId over the runId for the edge detail', () => { + const summary = buildMainchainSummary( + baseProps({ + transcript: [runSessionBlock({ edgeRunId: 'edge-9', runId: 'run-9' })], + }), + ); + expect(nodeOf(summary, 'edge').detail).toBe('edge-9'); + expect(nodeOf(summary, 'edge').state).toBe('active'); + }); + + it('marks the edge done when runtime diffs exist even without a runId', () => { + const summary = buildMainchainSummary( + baseProps({ runtimeEvidence: runtimeSnapshot({ diffs: [fileDiffFixture()] }) }), + ); + expect(nodeOf(summary, 'edge').detail).toBe('Edge evidence empty'); + expect(nodeOf(summary, 'edge').state).toBe('done'); + expect(summary.exportEnabled).toBe(true); + }); + + it('keeps the edge waiting for an empty runtime snapshot', () => { + const summary = buildMainchainSummary( + baseProps({ runtimeEvidence: runtimeSnapshot() }), + ); + expect(nodeOf(summary, 'edge').detail).toBe('Edge evidence empty'); + expect(nodeOf(summary, 'edge').state).toBe('waiting'); + expect(summary.exportEnabled).toBe(false); + }); + + it('marks the edge done and surfaces loading text when a channel is loading', () => { + const summary = buildMainchainSummary( + baseProps({ runtimeEvidence: runtimeSnapshot({ loading: { diff: true } }) }), + ); + expect(nodeOf(summary, 'edge').detail).toBe('diff loading'); + expect(nodeOf(summary, 'edge').state).toBe('done'); + }); + + it('marks the edge done and surfaces error text when a channel errored', () => { + const summary = buildMainchainSummary( + baseProps({ runtimeEvidence: runtimeSnapshot({ errors: { previews: true } }) }), + ); + expect(nodeOf(summary, 'edge').detail).toBe('preview error'); + expect(nodeOf(summary, 'edge').state).toBe('done'); + }); + + it('activates the edge from a runtime evidence runId', () => { + const summary = buildMainchainSummary( + baseProps({ runtimeEvidence: runtimeSnapshot({ runId: 'runtime-run' }) }), + ); + expect(nodeOf(summary, 'edge').detail).toBe('runtime-run'); + expect(nodeOf(summary, 'edge').state).toBe('active'); + }); + + it('counts transcript blocks for the replay node', () => { + const summary = buildMainchainSummary( + baseProps({ + transcript: [textBlock(), routeBlock(), toolCallBlock()], + }), + ); + expect(nodeOf(summary, 'replay').detail).toBe('3 transcript blocks'); + expect(nodeOf(summary, 'replay').state).toBe('done'); + }); + + it('activates the evidence path when approval evidence exists', () => { + const summary = buildMainchainSummary( + baseProps({ evidence: [evidenceRef('approval')] }), + ); + expect(nodeOf(summary, 'evidence-path').detail).toBe( + '1 approval / 0 artifact / 0 diff / 0 preview', + ); + expect(nodeOf(summary, 'evidence-path').state).toBe('active'); + }); + + it('marks the evidence path done for artifact/file/preview evidence without approvals', () => { + const summary = buildMainchainSummary( + baseProps({ + evidence: [ + evidenceRef('artifact'), + evidenceRef('file'), + evidenceRef('preview'), + ], + }), + ); + expect(nodeOf(summary, 'evidence-path').detail).toBe( + '0 approval / 1 artifact / 1 diff / 1 preview', + ); + expect(nodeOf(summary, 'evidence-path').state).toBe('done'); + }); + + it('adds approval and permission_request transcript blocks to the approval count', () => { + const summary = buildMainchainSummary( + baseProps({ + transcript: [approvalBlock(), permissionRequestBlock(), textBlock()], + }), + ); + expect(nodeOf(summary, 'evidence-path').detail).toBe( + '2 approval / 0 artifact / 0 diff / 0 preview', + ); + expect(nodeOf(summary, 'evidence-path').state).toBe('active'); + }); + + it('prefers runtime evidence arrays over evidence refs for counts', () => { + const summary = buildMainchainSummary( + baseProps({ + evidence: [ + evidenceRef('artifact'), + evidenceRef('artifact'), + evidenceRef('file'), + evidenceRef('preview'), + ], + runtimeEvidence: runtimeSnapshot({ + artifacts: [artifactFixture()], + diffs: [fileDiffFixture(), fileDiffFixture()], + previews: [], + }), + }), + ); + expect(nodeOf(summary, 'evidence-path').detail).toBe( + '0 approval / 1 artifact / 2 diff / 0 preview', + ); + expect(nodeOf(summary, 'evidence-path').state).toBe('done'); + }); + + it('lets an empty runtime snapshot zero artifact/diff/preview counts while approvals still count', () => { + const summary = buildMainchainSummary( + baseProps({ + evidence: [ + evidenceRef('artifact'), + evidenceRef('file'), + evidenceRef('preview'), + evidenceRef('approval'), + ], + runtimeEvidence: runtimeSnapshot(), + }), + ); + expect(nodeOf(summary, 'evidence-path').detail).toBe( + '1 approval / 0 artifact / 0 diff / 0 preview', + ); + expect(nodeOf(summary, 'evidence-path').state).toBe('active'); + expect(summary.exportEnabled).toBe(true); + }); + + it('enables export when evidence refs exist', () => { + const summary = buildMainchainSummary( + baseProps({ evidence: [evidenceRef('run')] }), + ); + expect(summary.exportEnabled).toBe(true); + expect(summary.exportLabel).toBe('mainchain.exportJson'); + expect(summary.exportDetail).toBe(EXPORT_DETAIL); + }); + + it('enables export when a run_session block exists', () => { + const summary = buildMainchainSummary( + baseProps({ transcript: [runSessionBlock()] }), + ); + expect(summary.exportEnabled).toBe(true); + expect(summary.exportLabel).toBe('mainchain.exportJson'); + expect(summary.exportDetail).toBe(EXPORT_DETAIL); + }); + + it('enables export when runtime evidence has content', () => { + const summary = buildMainchainSummary( + baseProps({ runtimeEvidence: runtimeSnapshot({ previews: [previewFixture()] }) }), + ); + expect(summary.exportEnabled).toBe(true); + expect(summary.exportLabel).toBe('mainchain.exportJson'); + expect(summary.exportDetail).toBe(EXPORT_DETAIL); + }); + + it('uses the first run_session block when several exist', () => { + const summary = buildMainchainSummary( + baseProps({ + transcript: [ + runSessionBlock({ id: 'first', taskId: 'task-first' }), + runSessionBlock({ id: 'second', taskId: 'task-second' }), + ], + }), + ); + expect(nodeOf(summary, 'hub-task').detail).toBe('task-first'); + }); + + it('combines transcript, evidence, and status into a fully resolved summary', () => { + const summary = buildMainchainSummary( + baseProps({ + composerTargetLabel: 'Composer target', + evidence: [evidenceRef('artifact'), evidenceRef('preview')], + platformSurface: 'web', + selectedExecutionTargetId: 'exec-1', + targetRequired: true, + transcript: [ + runSessionBlock({ + taskId: 'task-42', + runId: 'run-42', + edgeRunId: 'edge-42', + agentLabel: 'Boss', + targetLabel: 'contracts/target.json', + }), + routeBlock({ author: author('dispatcher'), targetAgent: 'worker-x' }), + subagentBlock({ worker: 'worker-x' }), + toolCallBlock(), + approvalBlock(), + ], + }), + ); + + expect(nodeOf(summary, 'web')).toEqual({ + id: 'web', + label: 'Web', + detail: 'Shared/Web workbench', + state: 'done', + }); + expect(nodeOf(summary, 'hub-task')).toEqual({ + id: 'hub-task', + label: 'Hub task', + detail: 'task-42', + state: 'done', + }); + expect(nodeOf(summary, 'supervisor')).toEqual({ + id: 'supervisor', + label: 'Supervisor', + detail: 'Boss', + state: 'done', + }); + expect(nodeOf(summary, 'worker')).toEqual({ + id: 'worker', + label: 'Worker', + detail: 'worker-x', + state: 'active', + }); + expect(nodeOf(summary, 'route-event')).toEqual({ + id: 'route-event', + label: 'Route + event', + detail: '1 route / 1 event', + state: 'done', + }); + expect(nodeOf(summary, 'target')).toEqual({ + id: 'target', + label: 'Exact target', + detail: 'Composer target', + state: 'done', + }); + expect(nodeOf(summary, 'edge')).toEqual({ + id: 'edge', + label: 'Active run', + detail: 'edge-42', + state: 'active', + }); + expect(nodeOf(summary, 'replay')).toEqual({ + id: 'replay', + label: 'Replay', + detail: '5 transcript blocks', + state: 'done', + }); + expect(nodeOf(summary, 'evidence-path')).toEqual({ + id: 'evidence-path', + label: 'Approval/artifact', + detail: '1 approval / 1 artifact / 0 diff / 1 preview', + state: 'active', + }); + expect(summary.exportEnabled).toBe(true); + expect(summary.exportLabel).toBe('mainchain.exportJson'); + expect(summary.exportDetail).toBe(EXPORT_DETAIL); + }); +}); + +describe('runtimeEvidenceSourceSummary', () => { + it('translates a waiting message when the snapshot is undefined', () => { + expect(runtimeEvidenceSourceSummary(undefined, t)).toBe('mainchain.waitingEdgeEvidence'); + }); + + it('joins every flagged loading channel', () => { + const summary = runtimeEvidenceSourceSummary( + runtimeSnapshot({ + loading: { diff: true, artifacts: true, previews: true }, + }), + t, + ); + expect(summary).toBe('diff loading / artifact loading / preview loading'); + }); + + it('reports only the flagged loading channels', () => { + const summary = runtimeEvidenceSourceSummary( + runtimeSnapshot({ loading: { previews: true } }), + t, + ); + expect(summary).toBe('preview loading'); + }); + + it('prefers loading flags over error flags', () => { + const summary = runtimeEvidenceSourceSummary( + runtimeSnapshot({ + loading: { artifacts: true }, + errors: { diff: true, artifacts: true }, + }), + t, + ); + expect(summary).toBe('artifact loading'); + }); + + it('joins every flagged error channel', () => { + const summary = runtimeEvidenceSourceSummary( + runtimeSnapshot({ errors: { diff: true, previews: true } }), + t, + ); + expect(summary).toBe('diff error / preview error'); + }); + + it('reports an empty edge for a snapshot without flags', () => { + expect(runtimeEvidenceSourceSummary(runtimeSnapshot(), t)).toBe('Edge evidence empty'); + }); + + it('reports an empty edge even with populated arrays when no flags are set', () => { + const summary = runtimeEvidenceSourceSummary( + runtimeSnapshot({ + diffs: [fileDiffFixture()], + artifacts: [artifactFixture()], + previews: [previewFixture()], + runId: 'run-1', + }), + t, + ); + expect(summary).toBe('Edge evidence empty'); + }); +}); diff --git a/app/shared/src/workbench/useWorkbenchAgentsRoute.test.ts b/app/shared/src/workbench/useWorkbenchAgentsRoute.test.ts new file mode 100644 index 000000000..b59223c76 --- /dev/null +++ b/app/shared/src/workbench/useWorkbenchAgentsRoute.test.ts @@ -0,0 +1,395 @@ +// real_tested=true +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { WorkbenchAgent } from '../platform'; +import { + WORKBENCH_MOCK_AGENT_CONFIGS, + WORKBENCH_MOCK_AGENT_MODELS, + WORKBENCH_MOCK_AGENT_SKILL_OPTIONS, + WORKBENCH_MOCK_AGENT_TOOL_OPTIONS, +} from './mockData'; +import { + useWorkbenchAgentsRoute, + type UseWorkbenchAgentsRouteOptions, + type WorkbenchAgentsModelCatalogItem, +} from './useWorkbenchAgentsRoute'; + +/* ═══════════════════════════════════════════════════════════════════════ + useWorkbenchAgentsRoute — demo-mode mock fallback, draft lifecycle, + selection sync, save-state labels and parent-driven real-data mapping. + + The route has no pagination/loadMore surface; its reentry guards are + the empty-selection no-ops, the save/delete failure short-circuits and + the selection-repair effect. + ═══════════════════════════════════════════════════════════════════════ */ + +function renderAgentsRoute(options: UseWorkbenchAgentsRouteOptions) { + return renderHook((props: UseWorkbenchAgentsRouteOptions) => useWorkbenchAgentsRoute(props), { + initialProps: options, + }); +} + +function hubAgent(id: string, name: string, overrides: Partial = {}): WorkbenchAgent { + return { + id, + name, + ...overrides, + }; +} + +describe('useWorkbenchAgentsRoute — demo mode mock fallback', () => { + it('loads mock agent configs, selects the first agent and exposes mock catalogs', () => { + const { result } = renderAgentsRoute({ realDataMode: false }); + + expect(result.current.agentConfigs).toHaveLength(WORKBENCH_MOCK_AGENT_CONFIGS.length); + expect(result.current.agentConfigs[0]?.id).toBe('builder-agent'); + expect(result.current.agentConfigs[0]?.name).toBe('Builder'); + expect(result.current.effectiveSelectedAgentId).toBe('builder-agent'); + expect(result.current.agentsPane).toBe('installed'); + expect(result.current.selectedAgentIsDirty).toBe(false); + expect(result.current.resolvedModels).toEqual(WORKBENCH_MOCK_AGENT_MODELS); + expect(result.current.resolvedSkills).toEqual(WORKBENCH_MOCK_AGENT_SKILL_OPTIONS); + expect(result.current.resolvedTools).toEqual(WORKBENCH_MOCK_AGENT_TOOL_OPTIONS); + expect(result.current.agentSaveStateLabel()).toBe('已同步'); + }); + + it('steers the initial selection from focusedAgentId and re-syncs on rerender', () => { + const { result, rerender } = renderAgentsRoute({ + realDataMode: false, + focusedAgentId: 'reviewer-agent', + }); + + expect(result.current.effectiveSelectedAgentId).toBe('reviewer-agent'); + + rerender({ realDataMode: false, focusedAgentId: 'researcher-agent' }); + expect(result.current.effectiveSelectedAgentId).toBe('researcher-agent'); + }); + + it('uses parent-supplied agents even in demo mode (mock fallback only when agents is undefined)', () => { + const { result } = renderAgentsRoute({ realDataMode: false, agents: [] }); + + expect(result.current.agentConfigs).toEqual([]); + expect(result.current.effectiveSelectedAgentId).toBe(''); + }); +}); + +describe('useWorkbenchAgentsRoute — real data mode', () => { + it('shows an empty, inert route when real mode has no agents yet', async () => { + const onAgentCreate = vi.fn(); + const onAgentUpdate = vi.fn(); + const onAgentDelete = vi.fn(); + const { result } = renderAgentsRoute({ + realDataMode: true, + onAgentCreate, + onAgentUpdate, + onAgentDelete, + }); + + expect(result.current.agentConfigs).toEqual([]); + expect(result.current.effectiveSelectedAgentId).toBe(''); + expect(result.current.selectedAgentIsDirty).toBe(false); + expect(result.current.agentSaveStateLabel()).toBe('已同步'); + // Mock catalogs still back the pickers when the list is empty. + expect(result.current.resolvedSkills).toEqual(WORKBENCH_MOCK_AGENT_SKILL_OPTIONS); + expect(result.current.resolvedModels).toEqual(WORKBENCH_MOCK_AGENT_MODELS); + + // Save/delete are guarded no-ops without a selection. + await act(async () => { + await result.current.handleAgentSave(); + await result.current.handleAgentDelete(); + }); + expect(onAgentCreate).not.toHaveBeenCalled(); + expect(onAgentUpdate).not.toHaveBeenCalled(); + expect(onAgentDelete).not.toHaveBeenCalled(); + }); + + it('maps parent WorkbenchAgent[] to configs and derives skill/tool catalogs from them', () => { + const { result } = renderAgentsRoute({ + realDataMode: true, + agents: [ + hubAgent('hub-1', 'Hub Alpha', { + description: 'alpha agent', + status: 'available', + runtimeId: 'codex', + provider: 'OpenAI', + model: 'gpt-5-codex', + reasoningEffort: 'medium', + skills: ['Search', 'Read File'], + mcpServers: ['fs'], + toolAllowlist: ['Read File', 'Shell'], + memorySources: ['agents-md'], + targetPreferences: ['local-edge'], + approvalPolicy: 'ask-before-write', + }), + hubAgent('hub-2', 'Hub Beta', { + status: 'unavailable', + skills: ['Read File'], + }), + ], + }); + + expect(result.current.agentConfigs).toHaveLength(2); + expect(result.current.effectiveSelectedAgentId).toBe('hub-1'); + + const first = result.current.agentConfigs.find((agent) => agent.id === 'hub-1'); + expect(first?.state).toBe('ready'); + expect(first?.model).toBe('OpenAI / gpt-5-codex'); + expect(first?.mode).toBe('推理 medium'); + expect(first?.tools['Read File']).toBe('允许'); + expect(first?.tools['Shell']).toBe('允许'); + + // Skills derive from agent configs (sorted unique), tools from tool keys. + expect(result.current.resolvedSkills).toEqual(['Read File', 'Search']); + expect(result.current.resolvedTools).toEqual(WORKBENCH_MOCK_AGENT_TOOL_OPTIONS); + }); + + it('maps a model catalog to ModelInfo rows with states and assigned agents', () => { + const modelCatalog: WorkbenchAgentsModelCatalogItem[] = [ + { id: 'm1', label: 'DeepSeek-V4-Pro', value: 'DeepSeek-V4-Pro', status: 'healthy' }, + { id: 'm2', label: 'gpt-5-codex', value: 'gpt-5-codex', status: 'experimental' }, + { id: 'm3', label: 'glm-5.1', value: 'glm-5.1', status: 'down' }, + { id: 'm4', label: 'Default X', value: 'x-default', status: 'down', default: true }, + ]; + const { result } = renderAgentsRoute({ realDataMode: false, modelCatalog }); + + expect(result.current.resolvedModels).toHaveLength(4); + expect(result.current.resolvedModels[0]).toEqual({ + name: 'DeepSeek-V4-Pro', + state: '默认', + description: '', + assignedAgents: 'Builder, Reviewer, Deployer', + }); + expect(result.current.resolvedModels[1]?.state).toBe('实验'); + expect(result.current.resolvedModels[1]?.assignedAgents).toBe('Researcher'); + expect(result.current.resolvedModels[2]?.state).toBe('备选'); + expect(result.current.resolvedModels[2]?.assignedAgents).toBe('—'); + // The `default` flag wins over an unhealthy status. + expect(result.current.resolvedModels[3]?.state).toBe('默认'); + }); +}); + +describe('useWorkbenchAgentsRoute — draft lifecycle', () => { + it('handleAgentAdd creates a draft at the top, selects it and marks it dirty', () => { + const { result } = renderAgentsRoute({ realDataMode: false }); + + act(() => { + result.current.handleAgentAdd(); + }); + + expect(result.current.agentConfigs[0]?.id).toBe('draft-agent-1'); + expect(result.current.agentConfigs[0]?.name).toBe('新 Agent 1'); + expect(result.current.effectiveSelectedAgentId).toBe('draft-agent-1'); + expect(result.current.selectedAgentIsDirty).toBe(true); + expect(result.current.agentSaveStateLabel()).toBe('草稿'); + }); + + it('handleAgentFieldChange patches the selected agent and marks it dirty', () => { + const { result } = renderAgentsRoute({ realDataMode: false }); + + act(() => { + result.current.handleAgentFieldChange('name', 'Builder Prime'); + }); + + const patched = result.current.agentConfigs.find((agent) => agent.id === 'builder-agent'); + expect(patched?.name).toBe('Builder Prime'); + expect(result.current.selectedAgentIsDirty).toBe(true); + expect(result.current.agentSaveStateLabel()).toBe('未保存'); + }); + + it('handleAgentSave saves an existing agent through onAgentUpdate and clears dirty', async () => { + const onAgentUpdate = vi.fn(); + const { result } = renderAgentsRoute({ realDataMode: false, onAgentUpdate }); + + act(() => { + result.current.handleAgentFieldChange('name', 'Builder Prime'); + }); + + await act(async () => { + await result.current.handleAgentSave(); + }); + + expect(onAgentUpdate).toHaveBeenCalledWith( + expect.objectContaining({ id: 'builder-agent', name: 'Builder Prime' }), + ); + expect(result.current.selectedAgentIsDirty).toBe(false); + expect(result.current.agentSaveStateLabel()).toBe('已同步'); + // The local draft override for a source agent survives the save. + expect(result.current.agentConfigs.find((agent) => agent.id === 'builder-agent')?.name).toBe('Builder Prime'); + }); + + it('handleAgentSave creates a draft through onAgentCreate, removes the draft and repairs selection', async () => { + const onAgentCreate = vi.fn(); + const { result } = renderAgentsRoute({ realDataMode: false, onAgentCreate }); + + act(() => { + result.current.handleAgentAdd(); + }); + act(() => { + result.current.handleAgentFieldChange('role', '自动化'); + }); + + await act(async () => { + await result.current.handleAgentSave(); + }); + + expect(onAgentCreate).toHaveBeenCalledWith( + expect.objectContaining({ id: 'draft-agent-1', role: '自动化' }), + ); + expect(result.current.agentConfigs.some((agent) => agent.id === 'draft-agent-1')).toBe(false); + expect(result.current.agentConfigs).toHaveLength(WORKBENCH_MOCK_AGENT_CONFIGS.length); + expect(result.current.effectiveSelectedAgentId).toBe('builder-agent'); + expect(result.current.selectedAgentIsDirty).toBe(false); + }); + + it('keeps the draft selected and dirty when onAgentCreate fails (reentry guard)', async () => { + const onAgentCreate = vi.fn(() => { + throw new Error('create exploded'); + }); + const { result } = renderAgentsRoute({ realDataMode: false, onAgentCreate }); + + act(() => { + result.current.handleAgentAdd(); + }); + + await act(async () => { + await result.current.handleAgentSave(); + }); + + expect(onAgentCreate).toHaveBeenCalledTimes(1); + // Failure short-circuits before any cleanup: draft, selection and dirty survive. + expect(result.current.agentConfigs[0]?.id).toBe('draft-agent-1'); + expect(result.current.effectiveSelectedAgentId).toBe('draft-agent-1'); + expect(result.current.selectedAgentIsDirty).toBe(true); + expect(result.current.agentSaveStateLabel()).toBe('草稿'); + }); + + it('handleAgentDelete removes a draft without calling onAgentDelete and moves selection to the first live agent', async () => { + const onAgentDelete = vi.fn(); + const { result } = renderAgentsRoute({ realDataMode: false, onAgentDelete }); + + act(() => { + result.current.handleAgentAdd(); + }); + + await act(async () => { + await result.current.handleAgentDelete(); + }); + + expect(onAgentDelete).not.toHaveBeenCalled(); + expect(result.current.agentConfigs.some((agent) => agent.id === 'draft-agent-1')).toBe(false); + expect(result.current.effectiveSelectedAgentId).toBe('builder-agent'); + }); + + it('handleAgentDelete deletes a live agent through onAgentDelete and selects the adjacent agent', async () => { + const onAgentDelete = vi.fn(); + const { result } = renderAgentsRoute({ realDataMode: false, onAgentDelete }); + + await act(async () => { + await result.current.handleAgentDelete(); + }); + + expect(onAgentDelete).toHaveBeenCalledWith('builder-agent'); + expect(result.current.effectiveSelectedAgentId).toBe('reviewer-agent'); + // Source agents stay in the list: the parent owns the actual removal. + expect(result.current.agentConfigs.some((agent) => agent.id === 'builder-agent')).toBe(true); + }); + + it('keeps the live agent selected when onAgentDelete fails', async () => { + const onAgentDelete = vi.fn(() => { + throw new Error('delete exploded'); + }); + const { result } = renderAgentsRoute({ realDataMode: false, onAgentDelete }); + + await act(async () => { + await result.current.handleAgentDelete(); + }); + + expect(onAgentDelete).toHaveBeenCalledTimes(1); + expect(result.current.effectiveSelectedAgentId).toBe('builder-agent'); + }); +}); + +describe('useWorkbenchAgentsRoute — edits, installs and save-state labels', () => { + it('handleAgentSkillToggle toggles a skill on the selected agent', () => { + const { result } = renderAgentsRoute({ realDataMode: false }); + + act(() => { + result.current.handleAgentSkillToggle('Shell'); + }); + const afterRemove = result.current.agentConfigs.find((agent) => agent.id === 'builder-agent'); + expect(afterRemove?.skills).not.toContain('Shell'); + expect(result.current.selectedAgentIsDirty).toBe(true); + + act(() => { + result.current.handleAgentSkillToggle('Shell'); + }); + const afterAdd = result.current.agentConfigs.find((agent) => agent.id === 'builder-agent'); + expect(afterAdd?.skills).toContain('Shell'); + }); + + it('handleToolPermissionSet sets a tool permission on the selected agent', () => { + const { result } = renderAgentsRoute({ realDataMode: false }); + + act(() => { + result.current.handleToolPermissionSet('Shell', '禁止'); + }); + + const patched = result.current.agentConfigs.find((agent) => agent.id === 'builder-agent'); + expect(patched?.tools['Shell']).toBe('禁止'); + expect(result.current.selectedAgentIsDirty).toBe(true); + }); + + it('handleMarketInstall installs a market agent, switches pane and selects it', () => { + const { result } = renderAgentsRoute({ realDataMode: false }); + + act(() => { + result.current.handleMarketInstall('Browser Copilot', 'does browser stuff', '研发'); + }); + + const installed = result.current.agentConfigs[0]; + expect(installed?.id).toBe('installed-market-1'); + expect(installed?.name).toBe('Browser Copilot'); + expect(installed?.engine).toBe('claude-code'); + expect(installed?.model).toBe('anthropic / sonnet'); + expect(installed?.tools['Write File']).toBe('禁止'); + expect(installed?.tools['Browser Screenshot']).toBe('需确认'); + expect(result.current.agentsPane).toBe('installed'); + expect(result.current.effectiveSelectedAgentId).toBe('installed-market-1'); + expect(result.current.selectedAgentIsDirty).toBe(false); + expect(result.current.agentSaveStateLabel()).toBe('草稿'); + }); + + it('reflects saving/deleting/actionError statuses in the save-state label', () => { + const { result, rerender } = renderAgentsRoute({ + realDataMode: false, + agentProfilesStatus: { savingAgentId: 'builder-agent' }, + }); + expect(result.current.agentSaveStateLabel()).toBe('保存中'); + + rerender({ + realDataMode: false, + agentProfilesStatus: { deletingAgentId: 'builder-agent' }, + }); + expect(result.current.agentSaveStateLabel()).toBe('删除中'); + + rerender({ + realDataMode: false, + agentProfilesStatus: { actionError: 'boom' }, + }); + expect(result.current.agentSaveStateLabel()).toBe('保存失败'); + }); + + it('labels a saving draft as 创建中', () => { + const { result, rerender } = renderAgentsRoute({ realDataMode: false }); + + act(() => { + result.current.handleAgentAdd(); + }); + + rerender({ + realDataMode: false, + agentProfilesStatus: { savingAgentId: 'draft-agent-1' }, + }); + expect(result.current.agentSaveStateLabel()).toBe('创建中'); + }); +}); diff --git a/app/shared/src/workbench/useWorkbenchDocsRoute.test.ts b/app/shared/src/workbench/useWorkbenchDocsRoute.test.ts new file mode 100644 index 000000000..b2178ff58 --- /dev/null +++ b/app/shared/src/workbench/useWorkbenchDocsRoute.test.ts @@ -0,0 +1,148 @@ +// real_tested=true +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { DocRow } from './pages'; +import { WORKBENCH_MOCK_DOC_ROWS } from './mockData'; +import { useWorkbenchDocsRoute } from './useWorkbenchDocsRoute'; + +/* ═══════════════════════════════════════════════════════════════════════ + useWorkbenchDocsRoute — nav/tab state, mock-row fallback, real-mode + loading skeleton, preview open/close and action passthrough. + + The route has no pagination/loadMore surface; the load guard is the + realDataMode + undefined-documents skeleton branch. + ═══════════════════════════════════════════════════════════════════════ */ + +function docRow(id: string, title: string, overrides: Partial = {}): DocRow { + return { + id, + title, + location: '我的文档库', + owner: 'demo-user', + time: '今天 10:00', + ...overrides, + }; +} + +describe('useWorkbenchDocsRoute — defaults and demo-mode rows', () => { + it('defaults to the home nav, recent tab and the mock doc rows without loading', () => { + const { result } = renderHook(() => useWorkbenchDocsRoute({})); + + expect(result.current.docsNav).toBe('home'); + expect(result.current.docsTab).toBe('recent'); + expect(result.current.rows).toEqual(WORKBENCH_MOCK_DOC_ROWS); + expect(result.current.documentsLoading).toBe(false); + expect(result.current.docsPreview).toBeNull(); + expect(result.current.documentsActions).toBeUndefined(); + }); + + it('updates the nav and tab through their setters', () => { + const { result } = renderHook(() => useWorkbenchDocsRoute({})); + + act(() => { + result.current.setDocsNav('archive'); + }); + expect(result.current.docsNav).toBe('archive'); + + act(() => { + result.current.setDocsTab('shared'); + }); + expect(result.current.docsTab).toBe('shared'); + }); + + it('uses parent-supplied documents instead of the mock rows', () => { + const supplied: DocRow[] = [docRow('d1', 'Custom Doc')]; + const { result } = renderHook(() => useWorkbenchDocsRoute({ documents: supplied })); + + expect(result.current.rows).toEqual(supplied); + expect(result.current.documentsLoading).toBe(false); + }); + + it('passes documentsActions through unchanged', () => { + const actions = { + onCreateDoc: vi.fn(), + onDeleteDoc: vi.fn(async () => undefined), + }; + const { result } = renderHook(() => useWorkbenchDocsRoute({ documentsActions: actions })); + + expect(result.current.documentsActions).toBe(actions); + }); +}); + +describe('useWorkbenchDocsRoute — preview open/close', () => { + it('opens a preview built from a tagged doc row', () => { + const { result } = renderHook(() => useWorkbenchDocsRoute({})); + const tagged = WORKBENCH_MOCK_DOC_ROWS[0]; + + act(() => { + if (tagged) result.current.openDocPreview(tagged); + }); + + const preview = result.current.docsPreview; + expect(preview?.id).toBe('doc:desktop-design-system'); + // Title without an extension gets a `.md` filename. + expect(preview?.name).toBe('AgentHub Desktop 设计系统对齐清单.md'); + expect(preview?.type).toBe('md'); + expect(preview?.owner).toBe('demo-user'); + expect(preview?.sourceLabel).toBe('我的文档库'); + expect(preview?.content).toContain('# AgentHub Desktop 设计系统对齐清单'); + expect(preview?.content).toContain('- 标签:内部'); + }); + + it('keeps an existing file extension in the preview filename and marks untagged docs', () => { + const { result } = renderHook(() => useWorkbenchDocsRoute({})); + const withExtension = WORKBENCH_MOCK_DOC_ROWS.find((row) => row.id === 'session-handoff'); + + act(() => { + if (withExtension) result.current.openDocPreview(withExtension); + }); + + const preview = result.current.docsPreview; + expect(preview?.name).toBe('SESSION-HANDOFF-2026-06-05.md'); + expect(preview?.type).toBe('md'); + expect(preview?.content).toContain('- 标签:未标记'); + expect(preview?.content).toContain('# SESSION-HANDOFF-2026-06-05.md'); + }); + + it('closes the preview', () => { + const { result } = renderHook(() => useWorkbenchDocsRoute({})); + const tagged = WORKBENCH_MOCK_DOC_ROWS[0]; + + act(() => { + if (tagged) result.current.openDocPreview(tagged); + }); + expect(result.current.docsPreview).not.toBeNull(); + + act(() => { + result.current.closeDocPreview(); + }); + expect(result.current.docsPreview).toBeNull(); + }); +}); + +describe('useWorkbenchDocsRoute — real data mode', () => { + it('shows a loading skeleton when real mode has no documents yet', () => { + const { result } = renderHook(() => useWorkbenchDocsRoute({ realDataMode: true })); + + expect(result.current.documentsLoading).toBe(true); + expect(result.current.rows).toEqual([]); + }); + + it('stops loading once real-mode documents arrive', () => { + const supplied: DocRow[] = [docRow('d1', 'Real Doc')]; + const { result } = renderHook(() => useWorkbenchDocsRoute({ + realDataMode: true, + documents: supplied, + })); + + expect(result.current.documentsLoading).toBe(false); + expect(result.current.rows).toEqual(supplied); + }); + + it('never loads in demo mode, even when documents is undefined', () => { + const { result } = renderHook(() => useWorkbenchDocsRoute({ realDataMode: false })); + + expect(result.current.documentsLoading).toBe(false); + expect(result.current.rows).toEqual(WORKBENCH_MOCK_DOC_ROWS); + }); +}); diff --git a/app/shared/src/workbench/useWorkbenchPanelLayout.test.ts b/app/shared/src/workbench/useWorkbenchPanelLayout.test.ts new file mode 100644 index 000000000..6f886bf4d --- /dev/null +++ b/app/shared/src/workbench/useWorkbenchPanelLayout.test.ts @@ -0,0 +1,403 @@ +// real_tested=true +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AgentHubPlatform } from '../platform'; +import { DESKTOP_TOGGLE_SIDEBAR_EVENT } from './desktopChromeEvents'; +import type { GlobalRailPage } from './GlobalRail'; +import { useWorkbenchPanelLayout } from './useWorkbenchPanelLayout'; +import { + INSPECTOR_COLLAPSED_STORAGE_KEY, + INSPECTOR_DEFAULT_COLLAPSE_EVENT, + INSPECTOR_DEFAULT_WIDTH, + INSPECTOR_MAX_WIDTH, + INSPECTOR_MIN_WIDTH, + INSPECTOR_WIDTH_STORAGE_KEY, + SIDEBAR_DEFAULT_WIDTH, + SIDEBAR_MIN_WIDTH, +} from './workbenchLayoutConstants'; +import { GLOBAL_RAIL_WIDTH } from './workbenchPanelLayoutHelpers'; + +/* ═══════════════════════════════════════════════════════════════════════ + useWorkbenchPanelLayout — hook-level wiring over the #721 layout helpers. + + Covers default state, localStorage restore/persistence (including + throwing storage), toggle/restore semantics, pointer + delta resizes, + workspace-pressure sidebar collapse, and both window event shortcuts. + ═══════════════════════════════════════════════════════════════════════ */ + +const DEFAULT_VIEWPORT_WIDTH = 1024; + +interface PanelLayoutRenderOptions { + activePage?: GlobalRailPage; + isChatPage?: boolean; + platformSurface?: AgentHubPlatform['surface']; +} + +function renderPanelLayout(options: PanelLayoutRenderOptions = {}) { + const setActivePage = vi.fn(); + const rendered = renderHook(() => useWorkbenchPanelLayout({ + activePage: options.activePage ?? 'chat', + isChatPage: options.isChatPage ?? true, + platformSurface: options.platformSurface ?? 'desktop', + setActivePage, + })); + return { ...rendered, setActivePage }; +} + +/** Fire window pointer events, matching attachPanelPointerResizeListeners. */ +function dispatchPointerEvent(type: string, clientX?: number): void { + window.dispatchEvent(new PointerEvent(type, clientX === undefined ? {} : { clientX })); +} + +/** Synchronous rAF so scheduled panel collapses land inside the same act block. */ +function stubSynchronousRequestAnimationFrame(): void { + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback): number => { + callback(0); + return 1; + }); +} + +describe('useWorkbenchPanelLayout', () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('initializes with default panel state, refs, and shell CSS variables', () => { + const { result } = renderPanelLayout(); + + expect(result.current.inspectorWidth).toBe(INSPECTOR_DEFAULT_WIDTH); + expect(result.current.inspectorCollapsed).toBe(false); + expect(result.current.inspectorResizing).toBe(false); + expect(result.current.sidebarWidth).toBe(SIDEBAR_DEFAULT_WIDTH); + expect(result.current.sidebarCollapsed).toBe(false); + expect(result.current.sidebarResizing).toBe(false); + expect(result.current.inspectorWidthRef.current).toBe(INSPECTOR_DEFAULT_WIDTH); + expect(result.current.sidebarWidthRef.current).toBe(SIDEBAR_DEFAULT_WIDTH); + expect(result.current.sidebarShouldCollapseRef.current).toBe(false); + expect(typeof result.current.setInspectorResizing).toBe('function'); + expect(typeof result.current.setSidebarResizing).toBe('function'); + expect(result.current.shellStyle).toEqual({ + '--inspector-w': `${INSPECTOR_DEFAULT_WIDTH}px`, + '--sidebar-w': `${SIDEBAR_DEFAULT_WIDTH}px`, + }); + }); + + it('restores inspector width and collapsed state from localStorage', () => { + window.localStorage.setItem(INSPECTOR_WIDTH_STORAGE_KEY, '520'); + window.localStorage.setItem(INSPECTOR_COLLAPSED_STORAGE_KEY, 'true'); + + const { result } = renderPanelLayout(); + expect(result.current.inspectorWidth).toBe(520); + expect(result.current.inspectorCollapsed).toBe(true); + expect(result.current.inspectorWidthRef.current).toBe(520); + }); + + it('falls back to defaults for invalid or out-of-range stored widths', () => { + window.localStorage.setItem(INSPECTOR_WIDTH_STORAGE_KEY, 'not-a-number'); + const invalid = renderPanelLayout(); + expect(invalid.result.current.inspectorWidth).toBe(INSPECTOR_DEFAULT_WIDTH); + + window.localStorage.clear(); + window.localStorage.setItem(INSPECTOR_WIDTH_STORAGE_KEY, '5000'); + const tooWide = renderPanelLayout(); + expect(tooWide.result.current.inspectorWidth).toBe(INSPECTOR_MAX_WIDTH); + + window.localStorage.clear(); + window.localStorage.setItem(INSPECTOR_WIDTH_STORAGE_KEY, '5'); + const tooNarrow = renderPanelLayout(); + expect(tooNarrow.result.current.inspectorWidth).toBe(INSPECTOR_MIN_WIDTH); + }); + + it('survives a throwing localStorage and keeps default panel state', () => { + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('storage unavailable'); + }); + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('storage unavailable'); + }); + + const { result } = renderPanelLayout(); + expect(result.current.inspectorWidth).toBe(INSPECTOR_DEFAULT_WIDTH); + expect(result.current.inspectorCollapsed).toBe(false); + expect(result.current.sidebarCollapsed).toBe(false); + }); + + it('toggles the inspector collapsed state and persists it', () => { + const { result } = renderPanelLayout(); + + act(() => { + result.current.toggleInspector(); + }); + expect(result.current.inspectorCollapsed).toBe(true); + expect(window.localStorage.getItem(INSPECTOR_COLLAPSED_STORAGE_KEY)).toBe('true'); + + act(() => { + result.current.toggleInspector(); + }); + expect(result.current.inspectorCollapsed).toBe(false); + expect(result.current.inspectorWidth).toBe(INSPECTOR_DEFAULT_WIDTH); + expect(window.localStorage.getItem(INSPECTOR_COLLAPSED_STORAGE_KEY)).toBe('false'); + }); + + it('toggles the sidebar collapsed state and restores its default width on expand', () => { + const { result } = renderPanelLayout(); + + act(() => { + result.current.toggleSidebar(); + }); + expect(result.current.sidebarCollapsed).toBe(true); + + act(() => { + result.current.toggleSidebar(); + }); + expect(result.current.sidebarCollapsed).toBe(false); + expect(result.current.sidebarWidth).toBe(SIDEBAR_DEFAULT_WIDTH); + }); + + it('expands the sidebar when navigating to chat and only changes the page otherwise', () => { + const { result, setActivePage } = renderPanelLayout(); + act(() => { + result.current.toggleSidebar(); + }); + expect(result.current.sidebarCollapsed).toBe(true); + + act(() => { + result.current.navigateRail('chat'); + }); + expect(setActivePage).toHaveBeenCalledWith('chat'); + expect(result.current.sidebarCollapsed).toBe(false); + + act(() => { + result.current.toggleSidebar(); + }); + act(() => { + result.current.navigateRail('agents'); + }); + expect(setActivePage).toHaveBeenLastCalledWith('agents'); + expect(result.current.sidebarCollapsed).toBe(true); + }); + + it('begins, moves, and stops an inspector pointer resize', () => { + const { result } = renderPanelLayout({ isChatPage: false }); + + act(() => { + result.current.beginInspectorResize(500); + }); + expect(result.current.inspectorResizing).toBe(true); + expect(result.current.inspectorWidth).toBe(DEFAULT_VIEWPORT_WIDTH - 500); + + act(() => { + dispatchPointerEvent('pointermove', 700); + }); + expect(result.current.inspectorWidth).toBe(DEFAULT_VIEWPORT_WIDTH - 700); + expect(result.current.inspectorCollapsed).toBe(false); + + act(() => { + dispatchPointerEvent('pointercancel'); + }); + expect(result.current.inspectorResizing).toBe(false); + expect(result.current.inspectorCollapsed).toBe(false); + }); + + it('ignores beginInspectorResize while the inspector is collapsed', () => { + const { result } = renderPanelLayout(); + act(() => { + result.current.toggleInspector(); + }); + expect(result.current.inspectorCollapsed).toBe(true); + + act(() => { + result.current.beginInspectorResize(300); + }); + expect(result.current.inspectorResizing).toBe(false); + expect(result.current.inspectorWidth).toBe(INSPECTOR_DEFAULT_WIDTH); + }); + + it('snap-collapses the inspector when dragged below the collapse threshold', () => { + stubSynchronousRequestAnimationFrame(); + const { result } = renderPanelLayout(); + + // clientX leaves only 50px (< INSPECTOR_COLLAPSE_SNAP_WIDTH=96). + act(() => { + result.current.beginInspectorResize(DEFAULT_VIEWPORT_WIDTH - 50); + }); + expect(result.current.inspectorWidth).toBe(INSPECTOR_MIN_WIDTH); + expect(result.current.inspectorResizing).toBe(false); + expect(result.current.inspectorCollapsed).toBe(true); + }); + + it('begins and stops a sidebar pointer resize, collapsing on the pending snap', () => { + stubSynchronousRequestAnimationFrame(); + const { result } = renderPanelLayout(); + + act(() => { + result.current.beginSidebarResize(GLOBAL_RAIL_WIDTH + 240); + }); + expect(result.current.sidebarResizing).toBe(true); + expect(result.current.sidebarWidth).toBe(240); + + // clientX leaves only 50px (< SIDEBAR_COLLAPSE_SNAP_WIDTH=96). + act(() => { + dispatchPointerEvent('pointermove', GLOBAL_RAIL_WIDTH + 50); + }); + expect(result.current.sidebarWidth).toBe(SIDEBAR_MIN_WIDTH); + expect(result.current.sidebarCollapsed).toBe(false); + expect(result.current.sidebarShouldCollapseRef.current).toBe(true); + + act(() => { + dispatchPointerEvent('pointerup'); + }); + expect(result.current.sidebarResizing).toBe(false); + expect(result.current.sidebarCollapsed).toBe(true); + }); + + it('ignores beginSidebarResize while the sidebar is collapsed', () => { + const { result } = renderPanelLayout(); + act(() => { + result.current.toggleSidebar(); + }); + expect(result.current.sidebarCollapsed).toBe(true); + + act(() => { + result.current.beginSidebarResize(GLOBAL_RAIL_WIDTH + 240); + }); + expect(result.current.sidebarResizing).toBe(false); + expect(result.current.sidebarWidth).toBe(SIDEBAR_DEFAULT_WIDTH); + }); + + it('resizes panels by delta, persisting widths and collapsing below thresholds', () => { + const { result } = renderPanelLayout({ isChatPage: false }); + + act(() => { + result.current.resizeInspectorBy(50); + }); + expect(result.current.inspectorWidth).toBe(INSPECTOR_DEFAULT_WIDTH + 50); + expect(window.localStorage.getItem(INSPECTOR_WIDTH_STORAGE_KEY)) + .toBe(String(INSPECTOR_DEFAULT_WIDTH + 50)); + + act(() => { + result.current.resizeSidebarBy(40); + }); + expect(result.current.sidebarWidth).toBe(SIDEBAR_DEFAULT_WIDTH + 40); + + act(() => { + result.current.resizeInspectorBy(-1000); + }); + expect(result.current.inspectorCollapsed).toBe(true); + expect(result.current.inspectorWidth).toBe(INSPECTOR_MIN_WIDTH); + + act(() => { + result.current.resizeSidebarBy(-1000); + }); + expect(result.current.sidebarCollapsed).toBe(true); + expect(result.current.sidebarWidth).toBe(SIDEBAR_MIN_WIDTH); + }); + + it('opens the inspector and restores only unreadable panel widths', () => { + const { result } = renderPanelLayout(); + + // A readable width is left untouched by an explicit restore. + act(() => { + result.current.restoreInspectorWidth(500); + }); + expect(result.current.inspectorWidth).toBe(INSPECTOR_DEFAULT_WIDTH); + + // Collapse below the readable threshold, then restore with an explicit width. + act(() => { + result.current.resizeInspectorBy(-1000); + }); + expect(result.current.inspectorCollapsed).toBe(true); + expect(result.current.inspectorWidth).toBe(INSPECTOR_MIN_WIDTH); + + act(() => { + result.current.restoreInspectorWidth(500); + }); + expect(result.current.inspectorWidth).toBe(500); + + // openInspector restores the default width and always expands. + act(() => { + result.current.resizeInspectorBy(-1000); + }); + act(() => { + result.current.openInspector(); + }); + expect(result.current.inspectorWidth).toBe(INSPECTOR_DEFAULT_WIDTH); + expect(result.current.inspectorCollapsed).toBe(false); + + // Sidebar restores are no-ops at valid widths. + act(() => { + result.current.resizeSidebarBy(40); + }); + act(() => { + result.current.restoreSidebarWidth(); + }); + expect(result.current.sidebarWidth).toBe(SIDEBAR_DEFAULT_WIDTH + 40); + }); + + it('auto-collapses the sidebar under workspace pressure on the chat page', () => { + const { result } = renderPanelLayout(); + // 1024 - 52 rail - 260 sidebar - 600 inspector = 112 < 560 → collapse. + act(() => { + result.current.resizeInspectorBy(200); + }); + expect(result.current.inspectorWidth).toBe(600); + expect(result.current.sidebarCollapsed).toBe(true); + }); + + it('keeps the sidebar open on non-chat pages regardless of workspace pressure', () => { + const { result } = renderPanelLayout({ isChatPage: false }); + + act(() => { + result.current.resizeInspectorBy(200); + }); + expect(result.current.inspectorWidth).toBe(600); + expect(result.current.sidebarCollapsed).toBe(false); + }); + + it('collapses the inspector when the settings default-collapse event fires', () => { + const { result } = renderPanelLayout(); + + act(() => { + window.dispatchEvent(new Event(INSPECTOR_DEFAULT_COLLAPSE_EVENT)); + }); + expect(result.current.inspectorCollapsed).toBe(true); + }); + + it('reacts to the desktop sidebar shortcut only on the active chat page', () => { + const setActivePage = vi.fn(); + const { result, rerender } = renderHook( + ({ activePage }: { activePage: GlobalRailPage }) => useWorkbenchPanelLayout({ + activePage, + isChatPage: true, + platformSurface: 'desktop', + setActivePage, + }), + { initialProps: { activePage: 'agents' as GlobalRailPage } }, + ); + + act(() => { + window.dispatchEvent(new Event(DESKTOP_TOGGLE_SIDEBAR_EVENT)); + }); + expect(result.current.sidebarCollapsed).toBe(false); + + rerender({ activePage: 'chat' }); + act(() => { + window.dispatchEvent(new Event(DESKTOP_TOGGLE_SIDEBAR_EVENT)); + }); + expect(result.current.sidebarCollapsed).toBe(true); + }); + + it('never registers the desktop sidebar shortcut on non-desktop surfaces', () => { + const { result } = renderPanelLayout({ platformSurface: 'web' }); + + act(() => { + window.dispatchEvent(new Event(DESKTOP_TOGGLE_SIDEBAR_EVENT)); + }); + expect(result.current.sidebarCollapsed).toBe(false); + }); +}); diff --git a/app/shared/src/workbench/useWorkbenchProfileChrome.test.ts b/app/shared/src/workbench/useWorkbenchProfileChrome.test.ts new file mode 100644 index 000000000..dafc94f86 --- /dev/null +++ b/app/shared/src/workbench/useWorkbenchProfileChrome.test.ts @@ -0,0 +1,490 @@ +// real_tested=true +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { WorkbenchAgent, WorkbenchConversation } from '../platform'; +import { useWorkbenchProfileChrome } from './useWorkbenchProfileChrome'; +import type { UseWorkbenchProfileChromeOptions } from './workbenchProfileChromeHelpers'; + +/* ═══════════════════════════════════════════════════════════════════════ + useWorkbenchProfileChrome — hook-level wiring over the #709 helpers. + + Covers default state, agent/human/group profile opening (configured, + runtime, and fallback branches), conversation-avatar plans, the three + direct-message branches (select/navigate/toast), agent config focus, + group conversation opening, and human profile-link copying. + ═══════════════════════════════════════════════════════════════════════ */ + +/** 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)}`; + return key; +} + +function conversation( + partial: Partial & Pick, +): WorkbenchConversation { + return { kind: 'direct', ...partial }; +} + +function createAnchor(): HTMLElement { + return document.createElement('div'); +} + +/** Fixture human profile matching the mock contact member "Johnny". */ +function johnnyProfile(anchor: HTMLElement = createAnchor()) { + return { + id: 'johnny', + name: 'Johnny', + initials: 'J', + org: 'AgentHub Desktop', + status: '刚刚活跃', + tag: '维护者', + subtitle: 'AgentHub Desktop', + anchor, + }; +} + +function renderProfileChrome(overrides: Partial = {}) { + const selectConversation = vi.fn(); + const setActivePage = vi.fn(); + const showWorkbenchToast = vi.fn(); + const copyText = vi.fn(); + const composerInputRef: { current: HTMLTextAreaElement | null } = { current: null }; + + const rendered = renderHook(() => useWorkbenchProfileChrome({ + conversations: [], + t, + selectConversation, + setActivePage, + showWorkbenchToast, + copyText, + composerInputRef, + ...overrides, + })); + + return { + ...rendered, + selectConversation, + setActivePage, + showWorkbenchToast, + copyText, + composerInputRef, + }; +} + +describe('useWorkbenchProfileChrome', () => { + it('exposes default empty profile state and every handler', () => { + const { result } = renderProfileChrome(); + + expect(result.current.activeAgentProfile).toBeNull(); + expect(result.current.activeHumanProfile).toBeNull(); + expect(result.current.activeGroupProfile).toBeNull(); + expect(result.current.focusedAgentId).toBeUndefined(); + expect(typeof result.current.setActiveAgentProfile).toBe('function'); + expect(typeof result.current.setActiveHumanProfile).toBe('function'); + expect(typeof result.current.setActiveGroupProfile).toBe('function'); + expect(typeof result.current.openAgentProfile).toBe('function'); + expect(typeof result.current.openAgentProfileFromConfig).toBe('function'); + expect(typeof result.current.openConversationAvatar).toBe('function'); + expect(typeof result.current.openAgentDirectMessage).toBe('function'); + expect(typeof result.current.openHumanDirectMessage).toBe('function'); + expect(typeof result.current.openAgentConfig).toBe('function'); + expect(typeof result.current.openGroupConversation).toBe('function'); + expect(typeof result.current.copyHumanProfileLink).toBe('function'); + }); + + it('opens a configured agent profile by name and attaches the anchor', () => { + const anchor = createAnchor(); + const { result } = renderProfileChrome(); + + act(() => { + result.current.openAgentProfile('Builder', anchor); + }); + + expect(result.current.activeHumanProfile).toBeNull(); + expect(result.current.activeGroupProfile).toBeNull(); + expect(result.current.activeAgentProfile).toMatchObject({ + id: 'builder-agent', + name: 'Builder', + role: '代码实现', + anchor, + }); + expect(result.current.activeAgentProfile?.engine).toEqual(expect.any(String)); + expect(result.current.activeAgentProfile?.model).toEqual(expect.any(String)); + expect(result.current.activeAgentProfile?.state).toEqual(expect.any(String)); + expect(result.current.activeAgentProfile?.skills).toEqual(expect.any(Array)); + }); + + it('resolves runtime agent fields when the opened name is not configured', () => { + const runtimeAgent: WorkbenchAgent = { + id: 'rt-1', + name: 'Runtime Only', + description: 'does runtime things', + model: 'gpt-test', + status: 'available', + }; + const { result } = renderProfileChrome({ agents: [runtimeAgent] }); + + // Case-insensitive name lookup. + act(() => { + result.current.openAgentProfile('runtime only', createAnchor()); + }); + + expect(result.current.activeAgentProfile).toMatchObject({ + id: 'rt-1', + name: 'Runtime Only', + role: 'does runtime things', + engine: 'label.agentHub', + model: 'gpt-test', + state: 'available', + skills: [], + }); + }); + + it('falls back to a human profile when the name matches no agent', () => { + const anchor = createAnchor(); + const { result } = renderProfileChrome(); + + act(() => { + result.current.openAgentProfile('Johnny', anchor); + }); + + expect(result.current.activeAgentProfile).toBeNull(); + expect(result.current.activeGroupProfile).toBeNull(); + expect(result.current.activeHumanProfile).toMatchObject({ + id: 'johnny', + name: 'Johnny', + initials: 'J', + org: 'AgentHub Desktop', + status: '刚刚活跃', + tag: '维护者', + subtitle: 'AgentHub Desktop', + anchor, + }); + }); + + it('builds an agent profile directly from a config object', () => { + const anchor = createAnchor(); + const { result } = renderProfileChrome(); + + act(() => { + result.current.openAgentProfileFromConfig({ + id: 'cfg-1', + name: 'Custom Agent', + role: 'R', + engine: 'E', + model: 'M', + state: 'ready', + skills: ['s1'], + }, anchor); + }); + + expect(result.current.activeHumanProfile).toBeNull(); + expect(result.current.activeGroupProfile).toBeNull(); + expect(result.current.activeAgentProfile).toEqual({ + id: 'cfg-1', + name: 'Custom Agent', + role: 'R', + engine: 'E', + model: 'M', + state: 'ready', + skills: ['s1'], + anchor, + }); + }); + + it('opens a group profile from a group conversation avatar, replacing any open profile', () => { + const anchor = createAnchor(); + const { result } = renderProfileChrome(); + + act(() => { + result.current.openAgentProfile('Builder', createAnchor()); + }); + act(() => { + result.current.openConversationAvatar( + conversation({ id: 'g1', title: 'AI 游戏项目', kind: 'group', members: ['demo-user', 'Johnny'] }), + anchor, + ); + }); + + expect(result.current.activeAgentProfile).toBeNull(); + expect(result.current.activeHumanProfile).toBeNull(); + expect(result.current.activeGroupProfile).toEqual({ + id: 'g1', + name: 'AI 游戏项目', + memberNames: ['demo-user', 'Johnny'], + anchor, + }); + }); + + it('opens an agent profile from a direct conversation avatar', () => { + const { result } = renderProfileChrome(); + + act(() => { + result.current.openConversationAvatar( + conversation({ id: 'c1', title: 'Builder' }), + createAnchor(), + ); + }); + + expect(result.current.activeAgentProfile?.name).toBe('Builder'); + expect(result.current.activeHumanProfile).toBeNull(); + expect(result.current.activeGroupProfile).toBeNull(); + }); + + it('opens a human profile from a conversation avatar for an unknown name', () => { + const anchor = createAnchor(); + const listedConversation = conversation({ + id: 'c-new', + title: 'Someone New', + subtitle: 'sub text', + avatarColor: '#abc', + }); + const { result } = renderProfileChrome({ conversations: [listedConversation] }); + + act(() => { + result.current.openConversationAvatar(listedConversation, anchor); + }); + + // A conversation present in the list enriches the profile with metadata. + expect(result.current.activeAgentProfile).toBeNull(); + expect(result.current.activeHumanProfile).toMatchObject({ + id: 'c-new', + name: 'Someone New', + initials: 'S', + org: 'label.contact', + status: 'status.online', + tag: 'chat.kind.friend', + subtitle: 'sub text', + avatarColor: '#abc', + anchor, + }); + + // A conversation missing from the list falls back to name-derived fields. + act(() => { + result.current.openConversationAvatar( + conversation({ id: 'c-other', title: 'Zeta Q', subtitle: 'unlisted sub', avatarColor: '#def' }), + createAnchor(), + ); + }); + expect(result.current.activeHumanProfile).toMatchObject({ + id: 'zeta q', + name: 'Zeta Q', + initials: 'Z', + org: 'label.contact', + status: 'status.online', + tag: 'chat.kind.friend', + subtitle: 'chat.kind.friend', + }); + }); + + it('selects an existing conversation for an agent direct message and focuses the composer', async () => { + const textarea = document.createElement('textarea'); + const focusSpy = vi.spyOn(textarea, 'focus'); + const { result, selectConversation, setActivePage, composerInputRef } = renderProfileChrome({ + conversations: [conversation({ id: 'c-builder', title: 'Builder' })], + }); + composerInputRef.current = textarea; + + act(() => { + result.current.openAgentProfile('Builder', createAnchor()); + }); + act(() => { + result.current.openAgentDirectMessage(); + }); + + expect(selectConversation).toHaveBeenCalledWith('c-builder'); + expect(setActivePage).toHaveBeenCalledWith('chat'); + expect(result.current.activeAgentProfile).toBeNull(); + expect(result.current.activeHumanProfile).toBeNull(); + expect(result.current.activeGroupProfile).toBeNull(); + expect(focusSpy).not.toHaveBeenCalled(); + + // The composer focus is deferred one macrotask after navigation. + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + expect(focusSpy).toHaveBeenCalledTimes(1); + }); + + it('navigates via the handler when no conversation matches the agent profile', () => { + const onNavigateToConversation = vi.fn(); + const { result, selectConversation, setActivePage } = renderProfileChrome({ + conversations: [], + onNavigateToConversation, + }); + + act(() => { + result.current.openAgentProfile('Builder', createAnchor()); + }); + act(() => { + result.current.openAgentDirectMessage(); + }); + + expect(onNavigateToConversation).toHaveBeenCalledWith({ + name: 'Builder', + id: 'builder-agent', + kind: 'dm', + }); + expect(selectConversation).not.toHaveBeenCalled(); + expect(setActivePage).toHaveBeenCalledWith('chat'); + expect(result.current.activeAgentProfile).toBeNull(); + }); + + it('shows a toast without navigating when no conversation and no handler exist', () => { + const { result, selectConversation, setActivePage, showWorkbenchToast } = renderProfileChrome(); + + act(() => { + result.current.openAgentProfile('Builder', createAnchor()); + }); + act(() => { + result.current.openAgentDirectMessage(); + }); + + expect(showWorkbenchToast).toHaveBeenCalledWith('toast.noDmSession:Builder'); + expect(selectConversation).not.toHaveBeenCalled(); + expect(setActivePage).not.toHaveBeenCalled(); + // The toast branch keeps the profile open. + expect(result.current.activeAgentProfile).not.toBeNull(); + }); + + it('selects an existing conversation for a human direct message', () => { + const { result, selectConversation, setActivePage } = renderProfileChrome({ + conversations: [conversation({ id: 'c-j', title: 'Johnny' })], + }); + + act(() => { + result.current.setActiveHumanProfile(johnnyProfile()); + }); + act(() => { + result.current.openHumanDirectMessage(); + }); + + expect(selectConversation).toHaveBeenCalledWith('c-j'); + expect(setActivePage).toHaveBeenCalledWith('chat'); + expect(result.current.activeHumanProfile).toBeNull(); + }); + + it('navigates via the handler when no conversation matches the human profile', () => { + const onNavigateToConversation = vi.fn(); + const { result, selectConversation, setActivePage } = renderProfileChrome({ + conversations: [], + onNavigateToConversation, + }); + + act(() => { + result.current.setActiveHumanProfile(johnnyProfile()); + }); + act(() => { + result.current.openHumanDirectMessage(); + }); + + expect(onNavigateToConversation).toHaveBeenCalledWith({ + name: 'Johnny', + id: 'johnny', + kind: 'dm', + }); + expect(selectConversation).not.toHaveBeenCalled(); + expect(setActivePage).toHaveBeenCalledWith('chat'); + expect(result.current.activeHumanProfile).toBeNull(); + }); + + it('shows a toast for a human direct message without conversation or handler', () => { + const { result, selectConversation, setActivePage, showWorkbenchToast } = renderProfileChrome(); + + act(() => { + result.current.setActiveHumanProfile(johnnyProfile()); + }); + act(() => { + result.current.openHumanDirectMessage(); + }); + + expect(showWorkbenchToast).toHaveBeenCalledWith('toast.noDmSession:Johnny'); + expect(selectConversation).not.toHaveBeenCalled(); + expect(setActivePage).not.toHaveBeenCalled(); + expect(result.current.activeHumanProfile).not.toBeNull(); + }); + + it('stays inert when opening a direct message without an active profile', () => { + const { result, selectConversation, setActivePage, showWorkbenchToast } = renderProfileChrome(); + + act(() => { + result.current.openAgentDirectMessage(); + }); + act(() => { + result.current.openHumanDirectMessage(); + }); + + expect(selectConversation).not.toHaveBeenCalled(); + expect(setActivePage).not.toHaveBeenCalled(); + expect(showWorkbenchToast).not.toHaveBeenCalled(); + }); + + it('opens agent config from an active agent profile and remembers the focused id', () => { + const { result, setActivePage, showWorkbenchToast } = renderProfileChrome(); + + // No active profile → inert. + act(() => { + result.current.openAgentConfig(); + }); + expect(result.current.focusedAgentId).toBeUndefined(); + expect(setActivePage).not.toHaveBeenCalled(); + + act(() => { + result.current.openAgentProfile('Builder', createAnchor()); + }); + act(() => { + result.current.openAgentConfig(); + }); + + expect(result.current.focusedAgentId).toBe('builder-agent'); + expect(setActivePage).toHaveBeenCalledWith('agents'); + expect(showWorkbenchToast).toHaveBeenCalledWith('toast.agentConfigOpened:Builder'); + expect(result.current.activeAgentProfile).toBeNull(); + }); + + it('opens a group conversation and clears the group profile', () => { + const { result, selectConversation } = renderProfileChrome(); + + // No active group profile → inert. + act(() => { + result.current.openGroupConversation(); + }); + expect(selectConversation).not.toHaveBeenCalled(); + + act(() => { + result.current.setActiveGroupProfile({ + id: 'g1', + name: 'AI 游戏项目', + memberNames: ['demo-user'], + anchor: createAnchor(), + }); + }); + act(() => { + result.current.openGroupConversation(); + }); + + expect(selectConversation).toHaveBeenCalledWith('g1'); + expect(result.current.activeGroupProfile).toBeNull(); + }); + + it('copies a deep link for an active human profile', () => { + const { result, copyText, showWorkbenchToast } = renderProfileChrome(); + + // No active human profile → inert. + act(() => { + result.current.copyHumanProfileLink(); + }); + expect(copyText).not.toHaveBeenCalled(); + + act(() => { + result.current.setActiveHumanProfile(johnnyProfile()); + }); + act(() => { + result.current.copyHumanProfileLink(); + }); + + expect(copyText).toHaveBeenCalledWith('agenthub://user/johnny'); + expect(showWorkbenchToast).toHaveBeenCalledWith('toast.contactLinkCopied'); + }); +});