import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { GraphMemberLogPreviewHud } from '@features/agent-graph/renderer/ui/GraphMemberLogPreviewHud'; import type { GraphNode } from '@claude-teams/agent-graph'; import type { MemberLogPreviewMember } from '@features/member-log-stream/contracts/dto'; const basePreviewsByMember = new Map([ [ 'team-lead', { memberName: 'team-lead', items: [ { id: 'lead-preview-1', kind: 'text' as const, provider: 'claude_transcript' as const, timestamp: '2026-04-03T00:00:00.000Z', title: 'Assistant', preview: 'lead log preview', tone: 'neutral' as const, }, ], coverage: [{ provider: 'claude_transcript' as const, status: 'included' as const }], warnings: [], truncated: false, overflowCount: 0, generatedAt: '2026-04-03T00:00:00.000Z', }, ], [ 'alice', { memberName: 'alice', items: [ { id: 'preview-1', kind: 'tool_use' as const, provider: 'claude_transcript' as const, timestamp: '2026-04-03T00:00:00.000Z', title: 'Bash', preview: 'pnpm test', tone: 'warning' as const, }, { id: 'preview-2', kind: 'tool_result' as const, provider: 'opencode_runtime' as const, timestamp: '2026-04-03T00:00:30.000Z', title: 'Send message error', preview: 'OpenCode tool failed without output', tone: 'error' as const, }, { id: 'preview-3', kind: 'tool_result' as const, provider: 'opencode_runtime' as const, timestamp: '2026-04-03T00:00:40.000Z', title: 'Bash result', preview: 'Tests passed', tone: 'success' as const, }, ], coverage: [{ provider: 'claude_transcript' as const, status: 'included' as const }], warnings: [], truncated: true, overflowCount: 2, generatedAt: '2026-04-03T00:00:00.000Z', }, ], ]); let mockedPreviewsByMember = basePreviewsByMember; vi.mock('@features/agent-graph/renderer/hooks/useGraphMemberLogPreviews', () => ({ buildGraphLogPreviewLaneIdsByMember: () => ({ alice: 'secondary:opencode:alice' }), useGraphMemberLogPreviews: () => ({ previewsByMember: mockedPreviewsByMember, loading: false, error: null, reload: vi.fn(), }), })); vi.mock('@features/agent-graph/renderer/hooks/useGraphActivityContext', () => ({ useGraphActivityContext: () => ({ teamData: { members: [ { name: 'alice', status: 'active', currentTaskId: null, taskCount: 0, lastActiveAt: null, messageCount: 0, providerId: 'opencode', laneOwnerProviderId: 'opencode', laneId: 'secondary:opencode:alice', }, ], }, }), })); describe('GraphMemberLogPreviewHud', () => { beforeEach(() => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.stubGlobal( 'requestAnimationFrame', vi.fn(() => 1) ); vi.stubGlobal('cancelAnimationFrame', vi.fn()); vi.useFakeTimers(); vi.setSystemTime(new Date('2026-04-03T00:01:00.000Z')); mockedPreviewsByMember = basePreviewsByMember; }); afterEach(() => { document.body.innerHTML = ''; vi.useRealTimers(); vi.unstubAllGlobals(); }); it('opens the member profile on the logs tab when a preview row or overflow is clicked', async () => { const node: GraphNode = { id: 'member:alpha-team:alice', kind: 'member', label: 'alice', state: 'active', domainRef: { kind: 'member', teamName: 'alpha-team', memberName: 'alice' }, }; const onOpenMemberProfile = vi.fn(); const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); await act(async () => { root.render( ({ left: 40, top: 80, right: 300, bottom: 372, width: 260, height: 292, })} getCameraZoom={() => 1} worldToScreen={(x, y) => ({ x, y })} getViewportSize={() => ({ width: 1200, height: 800 })} focusNodeIds={null} onOpenMemberProfile={onOpenMemberProfile} /> ); await Promise.resolve(); }); const row = Array.from(host.querySelectorAll('button')).find((button) => button.textContent?.includes('pnpm test') ); expect(row).not.toBeUndefined(); expect(row?.querySelector('.float-left')).not.toBeNull(); expect(row?.querySelector('.line-clamp-3')).toBeNull(); expect(row?.className).toContain('h-[72px]'); expect(row?.querySelector('span.text-slate-200')?.className).toContain('leading-5'); expect(row?.textContent).toContain('pnpm test'); const errorRow = Array.from(host.querySelectorAll('button')).find((button) => button.textContent?.includes('OpenCode tool failed') ); expect(errorRow?.className).toContain('border-rose-400/35'); expect(errorRow?.querySelector('svg.text-rose-300')).not.toBeNull(); expect(errorRow?.querySelector('.text-rose-100')).not.toBeNull(); const resultRow = Array.from(host.querySelectorAll('button')).find((button) => button.textContent?.includes('Tests passed') ); expect(resultRow?.textContent).toContain('Bash'); expect(resultRow?.textContent).not.toContain('Bash result'); await act(async () => { row?.dispatchEvent(new MouseEvent('click', { bubbles: true })); await Promise.resolve(); }); expect(onOpenMemberProfile).toHaveBeenCalledWith('alice', { initialTab: 'logs' }); const moreButton = Array.from(host.querySelectorAll('button')).find((button) => button.textContent?.includes('+2 more') ); expect(moreButton).not.toBeUndefined(); await act(async () => { moreButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); await Promise.resolve(); }); expect(onOpenMemberProfile).toHaveBeenCalledTimes(2); act(() => { root.unmount(); }); }); it('caps long visible rows while preserving the full preview in the title', async () => { const node: GraphNode = { id: 'member:alpha-team:alice', kind: 'member', label: 'alice', state: 'active', domainRef: { kind: 'member', teamName: 'alpha-team', memberName: 'alice' }, }; const longPreview = 'to team-lead inbox - #68a3a8cc blocked by dependencies and needs a follow-up investigation before merge final-token'; const alicePreview = basePreviewsByMember.get('alice')!; mockedPreviewsByMember = new Map(basePreviewsByMember); mockedPreviewsByMember.set('alice', { ...alicePreview, items: [ { id: 'preview-long', kind: 'tool_use' as const, provider: 'claude_transcript' as const, timestamp: '2026-04-03T00:00:00.000Z', title: 'Send message', preview: longPreview, tone: 'warning' as const, }, ], overflowCount: 0, }); const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); await act(async () => { root.render( ({ left: 40, top: 80, right: 300, bottom: 372, width: 260, height: 292, })} getCameraZoom={() => 1} worldToScreen={(x, y) => ({ x, y })} getViewportSize={() => ({ width: 1200, height: 800 })} focusNodeIds={null} /> ); await Promise.resolve(); }); const row = Array.from(host.querySelectorAll('button')).find((button) => button.textContent?.includes('Send message') ); expect(row?.textContent).toContain('...'); expect(row?.textContent).not.toContain('final-token'); expect(row?.getAttribute('title')).toContain('final-token'); act(() => { root.unmount(); }); }); it('briefly highlights a newly appeared preview row', async () => { const node: GraphNode = { id: 'member:alpha-team:alice', kind: 'member', label: 'alice', state: 'active', domainRef: { kind: 'member', teamName: 'alpha-team', memberName: 'alice' }, }; const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); const renderHud = (): void => { root.render( ({ left: 40, top: 80, right: 300, bottom: 372, width: 260, height: 292, })} getCameraZoom={() => 1} worldToScreen={(x, y) => ({ x, y })} getViewportSize={() => ({ width: 1200, height: 800 })} focusNodeIds={null} /> ); }; await act(async () => { renderHud(); await Promise.resolve(); }); const alicePreview = basePreviewsByMember.get('alice')!; mockedPreviewsByMember = new Map(basePreviewsByMember); mockedPreviewsByMember.set('alice', { ...alicePreview, items: [ { id: 'preview-new', kind: 'text' as const, provider: 'claude_transcript' as const, timestamp: '2026-04-03T00:01:00.000Z', title: 'Assistant', preview: 'new compact log', tone: 'neutral' as const, }, ...alicePreview.items, ], }); await act(async () => { renderHud(); await Promise.resolve(); }); const newRow = Array.from(host.querySelectorAll('button')).find((button) => button.textContent?.includes('new compact log') ); expect(newRow?.className).toContain('border-sky-300/70'); await act(async () => { vi.advanceTimersByTime(1_000); await Promise.resolve(); }); expect(newRow?.className).not.toContain('border-sky-300/70'); act(() => { root.unmount(); }); }); it('does not highlight existing rows when logs are toggled off and on', async () => { const node: GraphNode = { id: 'member:alpha-team:alice', kind: 'member', label: 'alice', state: 'active', domainRef: { kind: 'member', teamName: 'alpha-team', memberName: 'alice' }, }; const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); const renderHud = (enabled: boolean): void => { root.render( ({ left: 40, top: 80, right: 300, bottom: 372, width: 260, height: 292, })} getCameraZoom={() => 1} worldToScreen={(x, y) => ({ x, y })} getViewportSize={() => ({ width: 1200, height: 800 })} focusNodeIds={null} enabled={enabled} /> ); }; await act(async () => { renderHud(true); await Promise.resolve(); }); const initialRow = Array.from(host.querySelectorAll('button')).find((button) => button.textContent?.includes('pnpm test') ); expect(initialRow?.className).not.toContain('border-sky-300/70'); await act(async () => { renderHud(false); await Promise.resolve(); }); expect(host.querySelectorAll('button')).toHaveLength(0); await act(async () => { renderHud(true); await Promise.resolve(); }); const restoredRow = Array.from(host.querySelectorAll('button')).find((button) => button.textContent?.includes('pnpm test') ); expect(restoredRow?.className).not.toContain('border-sky-300/70'); const alicePreview = basePreviewsByMember.get('alice')!; mockedPreviewsByMember = new Map(basePreviewsByMember); mockedPreviewsByMember.set('alice', { ...alicePreview, items: [ { id: 'preview-after-toggle', kind: 'text' as const, provider: 'claude_transcript' as const, timestamp: '2026-04-03T00:01:00.000Z', title: 'Assistant', preview: 'new log after toggle', tone: 'neutral' as const, }, ...alicePreview.items, ], }); await act(async () => { renderHud(true); await Promise.resolve(); }); const newRow = Array.from(host.querySelectorAll('button')).find((button) => button.textContent?.includes('new log after toggle') ); expect(newRow?.className).toContain('border-sky-300/70'); act(() => { root.unmount(); }); }); it('scopes new-row highlights by member when preview ids collide', async () => { const aliceNode: GraphNode = { id: 'member:alpha-team:alice', kind: 'member', label: 'alice', state: 'active', domainRef: { kind: 'member', teamName: 'alpha-team', memberName: 'alice' }, }; const bobNode: GraphNode = { id: 'member:alpha-team:bob', kind: 'member', label: 'bob', state: 'active', domainRef: { kind: 'member', teamName: 'alpha-team', memberName: 'bob' }, }; const sharedId = 'preview-shared-id'; mockedPreviewsByMember = new Map([ [ 'alice', { memberName: 'alice', items: [ { id: sharedId, kind: 'text', provider: 'claude_transcript', timestamp: '2026-04-03T00:00:00.000Z', title: 'Assistant', preview: 'alice already known', tone: 'neutral', }, ], coverage: [{ provider: 'claude_transcript', status: 'included' }], warnings: [], truncated: false, overflowCount: 0, generatedAt: '2026-04-03T00:00:00.000Z', }, ], [ 'bob', { memberName: 'bob', items: [], coverage: [{ provider: 'claude_transcript', status: 'included' }], warnings: [], truncated: false, overflowCount: 0, generatedAt: '2026-04-03T00:00:00.000Z', }, ], ]); const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); const renderHud = (): void => { root.render( ({ left: ownerNodeId.includes('bob') ? 360 : 40, top: 80, right: ownerNodeId.includes('bob') ? 620 : 300, bottom: 372, width: 260, height: 292, })} getCameraZoom={() => 1} worldToScreen={(x, y) => ({ x, y })} getViewportSize={() => ({ width: 1200, height: 800 })} focusNodeIds={null} /> ); }; await act(async () => { renderHud(); await Promise.resolve(); }); mockedPreviewsByMember = new Map(mockedPreviewsByMember); mockedPreviewsByMember.set('bob', { memberName: 'bob', items: [ { id: sharedId, kind: 'text', provider: 'claude_transcript', timestamp: '2026-04-03T00:01:00.000Z', title: 'Assistant', preview: 'bob new shared id', tone: 'neutral', }, ], coverage: [{ provider: 'claude_transcript', status: 'included' }], warnings: [], truncated: false, overflowCount: 0, generatedAt: '2026-04-03T00:01:00.000Z', }); await act(async () => { renderHud(); await Promise.resolve(); }); const aliceRow = Array.from(host.querySelectorAll('button')).find((button) => button.textContent?.includes('alice already known') ); const bobRow = Array.from(host.querySelectorAll('button')).find((button) => button.textContent?.includes('bob new shared id') ); expect(aliceRow?.className).not.toContain('border-sky-300/70'); expect(bobRow?.className).toContain('border-sky-300/70'); act(() => { root.unmount(); }); }); it('renders distinct empty states for unsupported and simply empty members', async () => { const codexNode: GraphNode = { id: 'member:alpha-team:codex-dev', kind: 'member', label: 'codex-dev', state: 'idle', domainRef: { kind: 'member', teamName: 'alpha-team', memberName: 'codex-dev' }, }; const quietNode: GraphNode = { id: 'member:alpha-team:quiet-dev', kind: 'member', label: 'quiet-dev', state: 'idle', domainRef: { kind: 'member', teamName: 'alpha-team', memberName: 'quiet-dev' }, }; mockedPreviewsByMember = new Map([ [ 'codex-dev', { memberName: 'codex-dev', items: [], coverage: [{ provider: 'codex_native_trace', status: 'skipped' }], warnings: [ { code: 'codex_member_wide_not_supported', message: 'Codex member-wide native trace is not available in this variant yet.', }, ], truncated: false, overflowCount: 0, generatedAt: '2026-04-03T00:00:00.000Z', }, ], [ 'quiet-dev', { memberName: 'quiet-dev', items: [], coverage: [{ provider: 'claude_transcript', status: 'skipped' }], warnings: [], truncated: false, overflowCount: 0, generatedAt: '2026-04-03T00:00:00.000Z', }, ], ]); const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); await act(async () => { root.render( ({ left: ownerNodeId.includes('quiet') ? 360 : 40, top: 80, right: ownerNodeId.includes('quiet') ? 620 : 300, bottom: 372, width: 260, height: 292, })} getCameraZoom={() => 1} worldToScreen={(x, y) => ({ x, y })} getViewportSize={() => ({ width: 1200, height: 800 })} focusNodeIds={null} /> ); await Promise.resolve(); }); expect(host.textContent).toContain('Unsupported provider'); expect(host.textContent).toContain('No recent logs'); act(() => { root.unmount(); }); }); it('renders lead log previews and opens the lead profile logs tab', async () => { const leadNode: GraphNode = { id: 'lead:alpha-team', kind: 'lead', label: 'alpha-team', state: 'active', domainRef: { kind: 'lead', teamName: 'alpha-team', memberName: 'team-lead' }, }; const onOpenMemberProfile = vi.fn(); const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); await act(async () => { root.render( ({ left: 40, top: 80, right: 300, bottom: 372, width: 260, height: 292, })} getCameraZoom={() => 1} worldToScreen={(x, y) => ({ x, y })} getViewportSize={() => ({ width: 1200, height: 800 })} focusNodeIds={null} onOpenMemberProfile={onOpenMemberProfile} /> ); await Promise.resolve(); }); const row = Array.from(host.querySelectorAll('button')).find((button) => button.textContent?.includes('lead log preview') ); expect(row).not.toBeUndefined(); await act(async () => { row?.dispatchEvent(new MouseEvent('click', { bubbles: true })); await Promise.resolve(); }); expect(onOpenMemberProfile).toHaveBeenCalledWith('team-lead', { initialTab: 'logs' }); act(() => { root.unmount(); }); }); it('keeps compact event text readable without repeating the title prefix', async () => { mockedPreviewsByMember = new Map([ [ 'alice', { memberName: 'alice', items: [ { id: 'message-sent-preview', kind: 'tool_result', provider: 'claude_transcript', timestamp: '2026-04-03T00:01:00.000Z', title: 'Message sent', preview: 'Message sent to team-lead - #abc done', tone: 'success', }, { id: 'generic-tool-result-preview', kind: 'tool_result', provider: 'claude_transcript', timestamp: '2026-04-03T00:00:50.000Z', title: 'Tool result', preview: 'stored', tone: 'success', }, { id: 'bash-result-preview', kind: 'tool_result', provider: 'claude_transcript', timestamp: '2026-04-03T00:00:40.000Z', title: 'Bash result', preview: 'Bash result app/components/Calculator.tsx app/components/Display.tsx', tone: 'success', }, ], coverage: [{ provider: 'claude_transcript', status: 'included' }], warnings: [], truncated: false, overflowCount: 0, generatedAt: '2026-04-03T00:01:00.000Z', }, ], ]); const node: GraphNode = { id: 'member:alpha-team:alice', kind: 'member', label: 'alice', state: 'active', domainRef: { kind: 'member', teamName: 'alpha-team', memberName: 'alice' }, }; const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); await act(async () => { root.render( ({ left: 40, top: 80, right: 300, bottom: 372, width: 260, height: 292, })} getCameraZoom={() => 1} worldToScreen={(x, y) => ({ x, y })} getViewportSize={() => ({ width: 1200, height: 800 })} focusNodeIds={null} /> ); await Promise.resolve(); }); const messageRow = Array.from(host.querySelectorAll('button')).find((button) => button.textContent?.includes('#abc done') ); expect(messageRow?.textContent).toContain('Message sent'); expect(messageRow?.textContent).toContain('to team-lead - #abc done'); expect(messageRow?.textContent).not.toContain('Message sentMessage sent'); expect(messageRow?.textContent).not.toContain('Message sent now Message sent'); const genericResultRow = Array.from(host.querySelectorAll('button')).find((button) => button.textContent?.includes('stored') ); expect(genericResultRow?.textContent).toContain('Tool result'); const bashResultRow = Array.from(host.querySelectorAll('button')).find((button) => button.textContent?.includes('app/components/Calculator.tsx') ); expect(bashResultRow?.textContent).toContain('Bash'); expect(bashResultRow?.textContent).not.toContain('Bash result'); expect(bashResultRow?.textContent).not.toContain('result app/components'); act(() => { root.unmount(); }); }); it('truncates long event titles without losing the full tooltip context', async () => { mockedPreviewsByMember = new Map([ [ 'alice', { memberName: 'alice', items: [ { id: 'long-title-preview', kind: 'tool_use', provider: 'claude_transcript', timestamp: '2026-04-03T00:01:00.000Z', title: 'Very long custom provider tool name', preview: 'Very long custom provider tool name: compact body should still remain visible', tone: 'warning', }, ], coverage: [{ provider: 'claude_transcript', status: 'included' }], warnings: [], truncated: false, overflowCount: 0, generatedAt: '2026-04-03T00:01:00.000Z', }, ], ]); const node: GraphNode = { id: 'member:alpha-team:alice', kind: 'member', label: 'alice', state: 'active', domainRef: { kind: 'member', teamName: 'alpha-team', memberName: 'alice' }, }; const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); await act(async () => { root.render( ({ left: 40, top: 80, right: 300, bottom: 372, width: 260, height: 292, })} getCameraZoom={() => 1} worldToScreen={(x, y) => ({ x, y })} getViewportSize={() => ({ width: 1200, height: 800 })} focusNodeIds={null} /> ); await Promise.resolve(); }); const row = Array.from(host.querySelectorAll('button')).find((button) => button.textContent?.includes('compact body') ); expect(row?.textContent).toContain('Very long custom prov...'); expect(row?.textContent).toContain('compact body should still remain visible'); expect(row?.textContent).not.toContain('Very long custom provider tool nameVery long'); expect(row?.getAttribute('title')).toContain('Very long custom provider tool name'); expect(row?.getAttribute('aria-label')).toContain('Very long custom provider tool name'); act(() => { root.unmount(); }); }); it('uses safe fallback titles for malformed compact events', async () => { mockedPreviewsByMember = new Map([ [ 'alice', { memberName: 'alice', items: [ { id: 'empty-title-result', kind: 'tool_result', provider: 'claude_transcript', timestamp: '2026-04-03T00:01:00.000Z', title: ' ', preview: 'stored', tone: 'success', }, { id: 'empty-title-error', kind: 'text', provider: 'claude_transcript', timestamp: 'bad timestamp', title: '', preview: 'provider returned malformed timestamp', tone: 'error', }, ], coverage: [{ provider: 'claude_transcript', status: 'included' }], warnings: [], truncated: false, overflowCount: 0, generatedAt: '2026-04-03T00:01:00.000Z', }, ], ]); const node: GraphNode = { id: 'member:alpha-team:alice', kind: 'member', label: 'alice', state: 'active', domainRef: { kind: 'member', teamName: 'alpha-team', memberName: 'alice' }, }; const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); await act(async () => { root.render( ({ left: 40, top: 80, right: 300, bottom: 372, width: 260, height: 292, })} getCameraZoom={() => 1} worldToScreen={(x, y) => ({ x, y })} getViewportSize={() => ({ width: 1200, height: 800 })} focusNodeIds={null} /> ); await Promise.resolve(); }); expect(host.textContent).toContain('Tool result'); expect(host.textContent).toContain('stored'); expect(host.textContent).toContain('Error'); expect(host.textContent).toContain('provider returned malformed timestamp'); expect(host.textContent).not.toContain('undefined'); act(() => { root.unmount(); }); }); });