// @vitest-environment jsdom /** * View registration acceptance on the real framework stack: the plugin fiber * registers Trajectory into a real SlotsService view ring, tabs * switch inside ConversationRoot (renderSlot share driven by the same tab * projection apply uses) without collapsing chat, trajectory renders the * event ledger with its timing overview, and fiber disposal removes the tab. * Timeline projection and inclusive focus edge cases ride along. */ import { Context } from 'cordis' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { createElement, type ComponentProps, type FC, type ReactNode } from 'react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { UseSession } from '@deepseek-ai/dsh-client-web-react' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSnapshot, RequestView, SessionHistoryFace, SessionHistoryInspection, SessionHistorySnapshot, SessionId, SessionListState, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' import type { ConvViewProps, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client' import { ConversationSession, type ConversationSessionProps } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationSession.tsx' import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts' import { zh as conversationZh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client' import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory' import type { TrajectoryTurnModel } from '../src/client/layout.ts' import { TrajectoryTimeline } from '../src/client/TrajectoryTimeline.tsx' import { TrajectoryView, type TrajectoryViewInjected, } from '../src/client/TrajectoryView.tsx' import { deriveTrajectoryTimeline } from '../src/client/timeline.ts' const SID = 's1' as SessionId // Stub of the conversation package's standard locale seat (this spec mounts // its ConversationSession chrome); answers from the zh dictionary and falls // back to the key like the real chain. const tConversation: ConversationSessionProps['t'] = key => (conversationZh as Record)[key] ?? key afterEach(cleanup) // The chat store persists under its declared key; clear so one case's active // view cannot rehydrate into the next. beforeEach(() => { localStorage.clear() }) /** Node fixture: user prologue, two turns, one tool result inside turn 1. */ const NODES = [ { kind: 'user', seq: 1, time: 1_000, content: [], source: null }, { kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [], timing: { stepStartTime: 1_800, firstTokenTime: 1_900, completedTime: 2_000 }, }, { kind: 'tool-result', seq: 3, time: 3_000, callId: 'c1', call: null, callTime: 2_200, content: [], isError: false, callView: null, resultView: null, }, { kind: 'assistant', seq: 4, time: 4_000, turn: 2, step: 1, blocks: [], timing: { stepStartTime: 3_500, firstTokenTime: 3_700, completedTime: 4_000 }, }, ] as unknown as ConversationSnapshot['nodes'] function historySnapshot( nodes: ConversationSnapshot['nodes'], inspection: Partial = {}, ): SessionHistorySnapshot { return { state: 'ready', error: null, hasMore: false, inspection: { eventNodes: nodes, contexts: [{ id: 0, nodes }], requests: [], callSchemas: new Map(), interruptedNodes: [], partial: null, runningCalls: [], codeDispatches: new Map(), ...inspection, }, } } function standaloneHistory( snapshot: SessionHistorySnapshot, ): Pick, 'useHistory' | 'loadAllHistory'> { const store = createSnapshotStore(snapshot) return { useHistory: bindSnapshotSelector(store), loadAllHistory: () => Promise.resolve(), } } function fakeSession(nodes: ConversationSnapshot['nodes']) { const store = createSnapshotStore({ nodes, pending: [], partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches: new Map(), }) return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession } } /** Empty sessions-list hook; breadcrumbs therefore fall back to the raw id. */ function emptySessions() { const store = createSnapshotStore( { ids: [], byId: {}, current: undefined, phase: 'ready' }) return bindSnapshotSelector(store) } function emptyWorkspaces() { const store = createSnapshotStore({ items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }) return bindSnapshotSelector(store) } /** Standalone view props: the session-scope standard kit the outlet would bake. */ function standaloneProps(nodes: ConversationSnapshot['nodes']): ConvViewProps { return { sessionId: SID, useSession: fakeSession(nodes).useSession, useSessions: emptySessions(), useWorkspaces: emptyWorkspaces(), useProjection: (() => undefined) as never, } as unknown as ConvViewProps } /** Real-stack bench: root Context + real SlotsService ring + the plugin fiber. */ async function bench(snapshot = historySnapshot(NODES)) { const ctx = new Context() const slots = new SlotsService(ctx) const loadAllHistory = vi.fn((_signal: AbortSignal) => Promise.resolve()) const historyStore = createSnapshotStore(snapshot) const history: SessionHistoryFace = { sessionId: SID, getSnapshot: () => historyStore.getSnapshot(), subscribe: listener => historyStore.subscribe(listener), loadAll: loadAllHistory, } // The conversation entry's role: declare the ring, then seed the chat entry. slots.register({ name: 'root', children: { 'conversation.view': { kind: 'list', scope: 'session' } }, }, (_p: { renderSlot?: unknown }) => null) const chatBody = vi.fn(() =>
) slots.register( { name: 'conversation.view', id: 'chat', order: 0, label: 'Chat' } as never, chatBody as never) // 'conversation' inject is an ordering edge; the bench declares the ring // itself, so a stub satisfies the wait. ctx.provide('conversation', {}) ctx.provide('sessionHistory', { source: () => history }) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() return { ctx, slots, fiber, loadAllHistory } } /** Tab projection twin of apply's viewTabs (the render-side consumption path). */ function tabsOf(slots: SlotsService): ViewTab[] { return slots.entries('conversation.view') .map(e => ({ id: e.options.id!, label: resolveSlotLabel(e.options.label) ?? e.options.id! })) } /** Mount the strict session content over the ring ledger with an outlet-faithful renderSlot. */ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES) { const sessionSnapshot = createSnapshotStore({ running: false, removed: false, promptError: null, nodes, pending: [], openState: 'open' as const, hasMore: true, loadingOlder: false, partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches: new Map(), }) const useSession = bindSnapshotSelector(sessionSnapshot) as unknown as UseSession const chat = createChatStore().create() // Minimal outlet twin: resolve the ring entry by the `only` filter and // render it with the session standard kit (what SlotOutlet does for a // list-kind session slot, minus machinery). const renderSlot = ((key: string, _owner: object, opts?: { only?: string }): ReactNode => { const entry = slots.entries('conversation.view').find(e => e.options.id === opts?.only) if (entry === undefined) return null const View = entry.component as FC const injectEntry = entry.inject as ((sessionId: SessionId) => object) | undefined const injected = injectEntry === undefined ? {} : injectEntry(SID) const injectedProps = 'hooks' in injected ? { loadAllHistory: (injected as TrajectoryViewInjected).loadAllHistory, useHistory: bindSnapshotSelector( (injected as TrajectoryViewInjected).hooks.history, ), } : injected return ( ) }) as unknown as ConversationSessionProps['renderSlot'] return render( children(SID)} useSession={useSession} useSessions={emptySessions()} useWorkspaces={emptyWorkspaces()} useProjection={(() => undefined)} useStore={bindSnapshotSelector(chat)} actions={chat.actions} renderSlot={renderSlot} views={{ list: () => tabsOf(slots), subscribe: (fn: () => void) => slots.subscribe('conversation.view', fn), version: () => slots.getVersion('conversation.view'), }} useInput={bindSnapshotSelector(createSnapshotStore({ draft: '', draftRev: 0, phase: 'plain', queue: [] })) as never} inputActions={{ setDraft: vi.fn(), submit: vi.fn() }} bindDraftMirror={() => () => {}} open={vi.fn()} />, ) } describe('plugin registration', () => { it('registers trajectory after chat on the ring', async () => { const b = await bench() expect(tabsOf(b.slots)).toEqual([ { id: 'chat', label: 'Chat' }, { id: 'trajectory', label: 'Trajectory' }, ]) }) it('fiber disposal removes the tab and leaves chat standing', async () => { const b = await bench() await b.fiber.dispose() expect(tabsOf(b.slots).map(v => v.id)).toEqual(['chat']) }) }) describe('tab switching in ConversationRoot', () => { it('renders two tabs, defaults to chat, and switches to the trajectory ledger', async () => { const b = await bench() const view = mount(b.slots) expect(screen.getByTestId('chat-body')).toBeTruthy() expect(screen.getAllByRole('tab').map(t => t.textContent)).toEqual(['Chat', 'Trajectory']) fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) expect(screen.queryByText(/turns ·/)).toBeNull() expect(view.container.querySelectorAll('tr[data-turn-start="true"]')).toHaveLength(2) expect(screen.queryByRole('columnheader')).toBeNull() expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy() expect(screen.getByRole('region', { name: 'Trajectory timeline' })).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: 'Collapse turns' })) expect(view.container.querySelector('[data-collapsed-summary="turn"]')).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: 'Expand turns' })) expect(screen.getByRole('row', { name: /USER/ })).toBeTruthy() expect(screen.queryByTestId('chat-body')).toBeNull() await vi.waitFor(() => { expect(b.loadAllHistory).toHaveBeenCalledOnce() }) const signal = b.loadAllHistory.mock.calls[0]?.[0] expect(signal?.aborted).toBe(false) fireEvent.click(screen.getByRole('tab', { name: 'Chat' })) expect(signal?.aborted).toBe(true) }) it('opens a local record inspector and switches payload tabs without opening chat details', async () => { const b = await bench() mount(b.slots) fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) fireEvent.keyDown(screen.getByRole('row', { name: /TOOL/ }), { key: 'Enter' }) expect(screen.getByRole('complementary', { name: 'Event details' })).toBeTruthy() expect(screen.getByText('Turn 1 · Step 1')).toBeTruthy() expect(screen.getByText('Completed')).toBeTruthy() expect(screen.getByRole('tab', { name: 'Result' })).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: 'Close details' })) expect(screen.queryByRole('complementary', { name: 'Event details' })).toBeNull() }) it('dragging the overview focuses overlapping records without filtering the ledger', async () => { const b = await bench() mount(b.slots) fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events') vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({ x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72, toJSON: () => ({}), }) fireEvent.pointerDown(plot, { button: 0, clientX: 55, pointerId: 1 }) fireEvent.pointerMove(plot, { clientX: 95, pointerId: 1 }) fireEvent.pointerUp(plot, { clientX: 95, pointerId: 1 }) expect(screen.getByRole('row', { name: /USER/ }).getAttribute('data-timeline-focus')) .toBe('outside') const tablePane = screen.getByRole('table').parentElement expect(tablePane).not.toBeNull() fireEvent.click(tablePane as HTMLElement) expect(screen.getByRole('row', { name: /USER/ }).getAttribute('data-timeline-focus')) .toBeNull() fireEvent.pointerDown(plot, { button: 0, clientX: 55, pointerId: 2 }) fireEvent.pointerMove(plot, { clientX: 95, pointerId: 2 }) fireEvent.pointerUp(plot, { clientX: 95, pointerId: 2 }) expect(screen.getByRole('row', { name: /USER/ }).getAttribute('data-timeline-focus')) .toBe('outside') fireEvent.contextMenu(plot) expect(screen.getByRole('row', { name: /USER/ }).getAttribute('data-timeline-focus')) .toBeNull() }) it('clicking a timeline block clears the range, selects the record, and opens its inspector', async () => { const b = await bench() const view = mount(b.slots) fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events') vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({ x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72, toJSON: () => ({}), }) const toolSpan = view.container.querySelector( '[data-timeline-span="tool"]', ) expect(toolSpan).not.toBeNull() const recordIndex = toolSpan?.dataset.timelineRecordIndex expect(recordIndex).toBeTruthy() fireEvent.pointerMove(toolSpan as HTMLElement, { clientX: 50, pointerId: 1 }) expect(view.container.querySelector('[data-timeline-hover-line]')).toBeNull() expect(toolSpan?.getAttribute('data-hovered')).toBe('true') fireEvent.pointerDown(plot, { button: 0, clientX: 5, pointerId: 1 }) fireEvent.pointerMove(plot, { clientX: 95, pointerId: 1 }) fireEvent.pointerUp(plot, { clientX: 95, pointerId: 1 }) expect(view.container.querySelector('tr[data-timeline-focus]')).toBeTruthy() fireEvent.pointerDown(toolSpan as HTMLElement, { button: 0, clientX: 50, pointerId: 2, }) fireEvent.pointerUp(toolSpan as HTMLElement, { clientX: 50, pointerId: 2 }) const selectedRow = view.container.querySelector( `tr[data-record-index="${recordIndex}"]`, ) expect(selectedRow?.getAttribute('aria-selected')).toBe('true') expect(view.container.querySelector('tr[data-timeline-focus]')).toBeNull() expect(screen.getByRole('complementary', { name: 'Event details' })).toBeTruthy() }) it('empty window keeps the toolbar and reports no timing data', async () => { const b = await bench(historySnapshot([])) mount(b.slots) fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy() expect(screen.getByText('No timing data')).toBeTruthy() expect(screen.queryByRole('row')).toBeNull() expect(screen.queryByText(/turns ·/)).toBeNull() }) }) describe('timeline projection', () => { const turns = [{ turn: 1, groups: [{ title: 'Step 1', cells: [ { index: 1, kind: 'message', text: 'assistant', startedAt: 1_000, timeSeconds: 1 }, { index: 2, kind: 'tool', text: 'bash', startedAt: 2_000, timeSeconds: 1 }, { index: 3, kind: 'user', text: 'unknown', timeSeconds: 0 }, ], }], }] satisfies readonly TrajectoryTurnModel[] const longTurns = [{ turn: 1, groups: [{ title: 'Step 1', cells: Array.from({ length: 10 }, (_, index) => ({ index, kind: 'message' as const, text: `record ${index}`, timeSeconds: 1, })), }], }] satisfies readonly TrajectoryTurnModel[] it('pans the zoomed viewport only far enough to reveal a newly selected record', async () => { const onRangeChange = vi.fn() const view = render( , ) const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events') vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({ x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72, toJSON: () => ({}), }) fireEvent.wheel(plot, { clientX: 50, deltaY: -1_000 }) view.rerender( , ) await vi.waitFor(() => { const domain = view.container.querySelector( '[data-timeline-domain]', ) expect(domain?.style.getPropertyValue('--trajectory-domain-left')).toBe('-25%') }) view.rerender( , ) await vi.waitFor(() => { const domain = view.container.querySelector( '[data-timeline-domain]', ) expect(domain?.style.getPropertyValue('--trajectory-domain-left')).toBe('-125%') }) }) it('auto-pans a zoomed viewport while a range drag pushes against an edge', () => { const onRangeChange = vi.fn() render( , ) const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events') vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({ x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72, toJSON: () => ({}), }) fireEvent.wheel(plot, { clientX: 50, deltaY: -1_000 }) fireEvent.pointerDown(plot, { button: 0, clientX: 50, pointerId: 1 }) for (let index = 0; index < 24; index++) { fireEvent.pointerMove(plot, { clientX: 99, pointerId: 1 }) } fireEvent.pointerUp(plot, { clientX: 99, pointerId: 1 }) const selectedRange = onRangeChange.mock.calls.at(-1)?.[0] as | { start: number; end: number } | undefined expect(selectedRange).toBeDefined() expect((selectedRange?.end ?? 0) - (selectedRange?.start ?? 0)).toBeGreaterThan(4) }) it('uses equal-width operation slots and stable semantic lanes', () => { expect(deriveTrajectoryTimeline(turns)).toEqual({ start: 0, end: 3, spans: [ { index: 1, isError: false, kind: 'message', label: 'assistant', lane: 1, start: 0, end: 1, }, { index: 2, isError: false, kind: 'tool', label: 'bash', lane: 2, start: 1, end: 2, }, { index: 3, isError: false, kind: 'user', label: 'unknown', lane: 0, start: 2, end: 3, }, ], turnBoundaries: [{ turn: 1, time: 0 }], }) }) it('marks error records directly on timeline spans', () => { const errorTurns = [{ turn: 1, groups: [{ title: 'Step 1', cells: [{ index: 1, kind: 'tool' as const, text: 'failed tool', timeSeconds: 0.1, isError: true, }], }], }] satisfies readonly TrajectoryTurnModel[] const view = render( {}} />, ) expect(view.container.querySelector( '[data-timeline-span="tool"][data-error="true"]', )).toBeTruthy() }) it('ignores durations and idle gaps while retaining turn boundaries', () => { const separatedTurns = [ { turn: 1, groups: [{ title: 'Step 1', cells: [ { index: 1, kind: 'message', text: 'first', startedAt: 1_000, timeSeconds: 1 }, { index: 2, kind: 'tool', text: 'within-turn gap', startedAt: 4_000, timeSeconds: 1 }, ], }], }, { turn: 2, groups: [{ title: 'Step 1', cells: [ { index: 3, kind: 'message', text: 'after user idle', startedAt: 40_000, timeSeconds: 1 }, ], }], }, ] satisfies readonly TrajectoryTurnModel[] expect(deriveTrajectoryTimeline(separatedTurns)).toMatchObject({ start: 0, end: 3, spans: [ { index: 1, start: 0, end: 1 }, { index: 2, start: 1, end: 2 }, { index: 3, start: 2, end: 3 }, ], turnBoundaries: [ { turn: 1, time: 0 }, { turn: 2, time: 2 }, ], }) }) it('empty inputs produce no model and the standalone view reports its empty form', () => { expect(deriveTrajectoryTimeline([])).toBeNull() render(createElement( TrajectoryView, { ...standaloneProps([]), ...standaloneHistory(historySnapshot([])), }, )) expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy() expect(screen.queryByRole('row')).toBeNull() }) }) describe('TrajectoryView branches', () => { it('renders only the selected rewind branch while retaining session-global requests', () => { const retained = { kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'retained user' }], source: null, } as unknown as ConversationSnapshot['nodes'][number] const abandoned = { kind: 'assistant', seq: 3, time: 3_000, turn: 1, step: 1, blocks: [{ kind: 'text', text: 'abandoned response' }], } as unknown as ConversationSnapshot['nodes'][number] const current = { kind: 'assistant', seq: 5, time: 5_000, turn: 2, step: 1, blocks: [{ kind: 'text', text: 'current response' }], } as unknown as ConversationSnapshot['nodes'][number] const request = (startSeq: number, turn: number): RequestView => ({ purpose: 'assistant', startSeq, turn, step: 1, startedAt: startSeq * 1_000, completedAt: startSeq * 1_000 + 100, status: 'complete', }) const store = createSnapshotStore(historySnapshot( [retained, abandoned, current], { eventNodes: [retained, abandoned, current], contexts: [ { id: 0, nodes: [retained, abandoned] }, { id: 1, parentId: 0, origin: 'rewind' as const, originSeq: 4, nodes: [retained, current], }, ], requests: [request(2, 1), request(4, 2)], callSchemas: new Map(), }, )) const view = render( Promise.resolve())} />, ) expect(screen.queryByText('abandoned response')).toBeNull() expect(screen.getByText('current response')).toBeTruthy() expect(screen.getByRole('row', { name: /Request 2, ASSISTANT/ })).toBeTruthy() expect(view.container.querySelectorAll('[data-request-only="true"]')).toHaveLength(0) }) it('retains cancellation-frozen assistant and tool nodes outside raw contexts', () => { const retained = { kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'stop the task' }], source: null, } as unknown as ConversationSnapshot['nodes'][number] const interruptedAssistant = { kind: 'assistant', seq: 2.1, time: 2_000, turn: 1, step: 1, blocks: [{ kind: 'text', text: 'partial response retained' }], interrupted: true, } as unknown as ConversationSnapshot['nodes'][number] const interruptedTool = { kind: 'tool-result', seq: 2.2, time: 2_100, callId: 'slow-call', call: { name: 'bash', argsRaw: '{"command":"sleep 30"}' }, callTime: 1_900, content: [], isError: true, error: { name: 'Interrupted', code: 'interrupted' }, callView: null, resultView: null, } as unknown as ConversationSnapshot['nodes'][number] const store = createSnapshotStore(historySnapshot( [retained], { eventNodes: [retained], contexts: [{ id: 0, nodes: [retained] }], requests: [], callSchemas: new Map(), interruptedNodes: [interruptedAssistant, interruptedTool], }, )) render( Promise.resolve())} />, ) expect(screen.getByText('partial response retained')).toBeTruthy() expect(screen.getByRole('row', { name: /TOOL, bash/ })).toBeTruthy() }) }) describe('node half', () => { it('node apply is an intentional no-op (loader-managed lifecycle only)', () => { expect(() => { nodeApply() }).not.toThrow() }) })