// @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 { act, 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, ConversationSessionHeader, type ConversationSessionHeaderProps, 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 { createTrajectoryDurationStore } from '../src/client/duration-store.ts' import { deriveTrajectoryTimeline } from '../src/client/timeline.ts' const SID = 's1' as SessionId const tConversation: ConversationSessionHeaderProps['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, baseSeq: nodes[0]?.seq ?? 0, inspection: { eventNodes: nodes, contexts: [{ id: 0, nodes }], requests: [], callSchemas: new Map(), interruptedNodes: [], partial: null, runningCalls: [], codeDispatches: new Map(), ...inspection, }, } } function standaloneHistory( snapshot: SessionHistorySnapshot, ): Pick< ComponentProps, 'useHistory' | 'loadHistoryTail' | 'loadOlderHistory' > { const store = createSnapshotStore(snapshot) return { useHistory: bindSnapshotSelector(store), loadHistoryTail: () => Promise.resolve(), loadOlderHistory: () => Promise.resolve(false), } } function standaloneDuration(): Pick< ComponentProps, 'useDuration' | 'setActualDuration' > { const duration = createSnapshotStore(false) return { useDuration: bindSnapshotSelector(duration), setActualDuration: (value) => { duration.set(value) }, } } 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', subagentsByParent: {}, currentAddress: undefined }) 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 loadHistoryTail = vi.fn((_signal: AbortSignal) => Promise.resolve()) const loadOlderHistory = vi.fn((_signal: AbortSignal) => Promise.resolve(false)) const historyStore = createSnapshotStore(snapshot) const history: SessionHistoryFace = { sessionId: SID, getSnapshot: () => historyStore.getSnapshot(), subscribe: listener => historyStore.subscribe(listener), loadTail: loadHistoryTail, loadOlder: loadOlderHistory, } // 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) ctx.provide('sessionHistory', { source: () => history }) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() return { ctx, slots, fiber, loadHistoryTail, loadOlderHistory } } /** 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 header/body over the ring ledger with outlet-faithful render shares. */ 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() const views = { list: () => tabsOf(slots), subscribe: (fn: () => void) => slots.subscribe('conversation.view', fn), version: () => slots.getVersion('conversation.view'), } const useInput = bindSnapshotSelector(createSnapshotStore({ draft: '', draftRev: 0, phase: 'plain', queue: [] })) as never const inputActions = { setDraft: vi.fn(), submit: vi.fn() } // 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 ? (() => { const trajectory = injected as TrajectoryViewInjected return { loadHistoryTail: trajectory.loadHistoryTail, loadOlderHistory: trajectory.loadOlderHistory, setActualDuration: trajectory.setActualDuration, useHistory: bindSnapshotSelector(trajectory.hooks.history), useDuration: bindSnapshotSelector(trajectory.hooks.duration), } })() : 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={() => null} views={views} useInput={useInput} inputActions={inputActions} open={vi.fn()} t={tConversation} /> children(SID)} useSession={useSession} useSessions={emptySessions()} useWorkspaces={emptyWorkspaces()} useProjection={(() => undefined)} useStore={bindSnapshotSelector(chat)} actions={chat.actions} renderSlot={renderSlot} views={views} useInput={useInput} inputActions={inputActions} bindDraftMirror={() => () => {}} /> , ) } 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']) }) it('shares one browser-wide duration preference across session injections', async () => { const b = await bench() const entry = b.slots.entries('conversation.view') .find(candidate => candidate.options.id === 'trajectory') expect(entry).toBeDefined() const injectEntry = entry!.inject as unknown as ( sessionId: SessionId, ) => TrajectoryViewInjected const first = injectEntry(SID) const second = injectEntry('s2' as SessionId) expect(second.hooks.duration).toBe(first.hooks.duration) first.setActualDuration(true) expect(second.hooks.duration.getSnapshot()).toBe(true) expect(localStorage.getItem('dsh.trajectory.duration')).toBe('true') expect(localStorage.getItem(`dsh.trajectory.duration.${SID}`)).toBeNull() }) }) 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() expect(view.container.querySelector('[data-conversation-composer-overlay]')).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.loadHistoryTail).toHaveBeenCalledOnce() }) const signal = b.loadHistoryTail.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('labels a standalone compaction as between-turn work in the ledger and inspector', async () => { const nodes = [ { kind: 'user', seq: 1, time: 1_000, content: [], source: null }, { kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [{ kind: 'text', text: 'before' }], }, { kind: 'user', seq: 5, time: 5_000, content: [], source: null }, { kind: 'assistant', seq: 6, time: 6_000, turn: 2, step: 1, blocks: [{ kind: 'text', text: 'after' }], }, ] as unknown as ConversationSnapshot['nodes'] const compaction: RequestView = { purpose: 'compaction', startSeq: 3, turn: null, step: 0, startedAt: 3_000, completedAt: 4_000, status: 'complete', summary: [{ type: 'text', text: 'standalone summary' }], } const b = await bench(historySnapshot(nodes, { requests: [compaction] })) const view = mount(b.slots, nodes) fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) expect(screen.getByText('Between turns')).toBeTruthy() expect(view.container.textContent).not.toContain('Turn null') fireEvent.click(screen.getByRole('button', { name: 'Request #2 · Compaction' })) expect(screen.getByText('Compaction · Between turns')).toBeTruthy() expect(view.container.textContent).not.toContain('Turn null') }) it('activates only the selected standalone compaction section', async () => { const nodes = [ { kind: 'user', seq: 1, time: 1_000, content: [], source: null }, { kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [{ kind: 'text', text: 'before first compaction' }], }, { kind: 'user', seq: 5, time: 5_000, content: [], source: null }, { kind: 'assistant', seq: 6, time: 6_000, turn: 2, step: 1, blocks: [{ kind: 'text', text: 'between compactions' }], }, { kind: 'user', seq: 9, time: 9_000, content: [], source: null }, { kind: 'assistant', seq: 10, time: 10_000, turn: 3, step: 1, blocks: [{ kind: 'text', text: 'after second compaction' }], }, ] as unknown as ConversationSnapshot['nodes'] const compactions: RequestView[] = [ { purpose: 'compaction', startSeq: 3, turn: null, step: 0, startedAt: 3_000, completedAt: 4_000, status: 'complete', summary: [{ type: 'text', text: 'first standalone summary' }], }, { purpose: 'compaction', startSeq: 7, turn: null, step: 0, startedAt: 7_000, completedAt: 8_000, status: 'complete', summary: [{ type: 'text', text: 'second standalone summary' }], }, ] const b = await bench(historySnapshot(nodes, { requests: compactions })) mount(b.slots, nodes) fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) const firstRequest = screen.getByRole('button', { name: 'Request #2 · Compaction' }) const secondRequest = screen.getByRole('button', { name: 'Request #4 · Compaction' }) const firstSection = firstRequest.closest('tr')?.querySelector('span') const secondSection = secondRequest.closest('tr')?.querySelector('span') expect(firstSection?.textContent).toBe('Between turns') expect(secondSection?.textContent).toBe('Between turns') fireEvent.click(firstRequest) expect(firstSection?.className).toMatch(/turnLabelActive/) expect(secondSection?.className).not.toMatch(/turnLabelActive/) expect(screen.getByText('Request #2')).toBeTruthy() expect(screen.getByText('Compaction · Between turns')).toBeTruthy() fireEvent.click(secondRequest) expect(firstSection?.className).not.toMatch(/turnLabelActive/) expect(secondSection?.className).toMatch(/turnLabelActive/) expect(screen.getByText('Request #4')).toBeTruthy() expect(screen.getByText('Compaction · Between turns')).toBeTruthy() }) 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')) .toBe('outside') }) 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.getByRole('button', { name: 'Collapse turns', }).disabled).toBe(false) expect(screen.getByRole('button', { name: 'Collapse calls', }).disabled).toBe(false) 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('splits assistant time into recorded TTFT and decoding proportions with a delayed tooltip', () => { vi.useFakeTimers() try { const view = render( , ) const span = view.container.querySelector( '[data-timeline-span="message"]', ) expect(span?.getAttribute('title')).toBeNull() expect(span?.getAttribute('data-assistant-timing')).toBe('true') expect(span?.style.getPropertyValue('--trajectory-assistant-ttft')).toBe('25%') fireEvent.mouseEnter(span as HTMLElement) act(() => { vi.advanceTimersByTime(499) }) expect(view.container.querySelector('[role="tooltip"]')).toBeNull() act(() => { vi.advanceTimersByTime(1) }) const tooltip = view.container.querySelector('[role="tooltip"]') expect(tooltip?.textContent).toContain('Total 2,000 ms') expect(tooltip?.textContent).toContain('TTFT 500 ms') expect(tooltip?.textContent).toContain('Decoding 1,500 ms') } finally { vi.useRealTimers() } }) it('marks an unloaded history prefix without inventing timeline duration', () => { const onLoadEarlier = vi.fn(() => new Promise(() => {})) const view = render( , ) const boundary = screen.getByLabelText('Load earlier history') expect(boundary.getAttribute('data-earlier-history')).not.toBeNull() const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events') fireEvent.pointerMove(plot, { clientX: 50, pointerId: 1 }) expect(view.container.querySelector('[data-timeline-hover-line]')).toBeTruthy() fireEvent.pointerEnter(boundary) expect(view.container.querySelector('[data-timeline-hover-line]')).toBeNull() fireEvent.focus(boundary) expect(screen.getByRole('tooltip').textContent) .toContain('Click to load earlier history') fireEvent.click(boundary) expect(onLoadEarlier).toHaveBeenCalledOnce() expect(screen.getByLabelText('Loading earlier history')).toBeTruthy() view.rerender( , ) expect(screen.queryByLabelText('Load earlier history')).toBeNull() expect(screen.queryByLabelText('Loading earlier history')).toBeNull() }) it('cancels native scrolling across the timeline while zooming', () => { render( , ) const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events') vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({ x: 44, y: 0, left: 44, top: 0, right: 144, bottom: 50, width: 100, height: 50, toJSON: () => ({}), }) expect(fireEvent.wheel(plot, { clientX: 94, deltaY: -100 })).toBe(false) expect(fireEvent.wheel(screen.getByText('Input'), { clientX: 20, deltaY: -100, })).toBe(false) }) it('scales sequence gutters with narrow operation spans', () => { const view = render( , ) const span = view.container.querySelector('[data-timeline-span]') expect(span?.style.getPropertyValue('--trajectory-span-width')).toBe('10%') expect(span?.style.getPropertyValue('--trajectory-span-gap')) .toBe('min(0.8%, 1px)') }) it('keeps dense sequence spans proportional before applying the pixel floor', () => { const denseTurns = [{ turn: 1, groups: [{ title: 'Step 1', cells: Array.from({ length: 400 }, (_, index) => ({ index, kind: 'message' as const, text: `message ${index}`, timeSeconds: 1, })), }], }] const view = render( , ) const span = view.container.querySelector('[data-timeline-span]') expect(span?.style.getPropertyValue('--trajectory-span-width')).toBe('0.25%') expect(span?.style.getPropertyValue('--trajectory-span-gap')) .toBe('min(0.02%, 1px)') }) it('clears the selection without changing zoom on a zoomed right click', () => { const onRangeChange = vi.fn() const view = render( , ) const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events') expect(screen.getByLabelText('Load earlier history')).toBeTruthy() 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 }) expect(screen.queryByLabelText('Load earlier history')).toBeNull() const domain = view.container.querySelector('[data-timeline-domain]') const domainWidth = domain?.style.getPropertyValue('--trajectory-domain-width') expect(domainWidth).not.toBe('100%') fireEvent.pointerDown(plot, { button: 2, clientX: 50, pointerId: 1 }) expect(fireEvent.contextMenu(plot)).toBe(false) fireEvent.pointerUp(plot, { button: 2, clientX: 50, pointerId: 1 }) expect(onRangeChange).toHaveBeenCalledOnce() expect(onRangeChange).toHaveBeenCalledWith(null) expect(domain?.style.getPropertyValue('--trajectory-domain-width')).toBe(domainWidth) }) it('clears the selection and suppresses the context menu at full zoom', () => { const onRangeChange = vi.fn() render( , ) const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events') fireEvent.pointerDown(plot, { button: 2, clientX: 50, pointerId: 1 }) expect(fireEvent.contextMenu(plot)).toBe(false) fireEvent.pointerUp(plot, { button: 2, clientX: 50, pointerId: 1 }) expect(onRangeChange).toHaveBeenCalledOnce() expect(onRangeChange).toHaveBeenCalledWith(null) }) it('pans the zoomed viewport with a right-button drag without changing the selection', () => { 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 }) const domain = view.container.querySelector('[data-timeline-domain]') const before = domain?.style.getPropertyValue('--trajectory-domain-left') fireEvent.pointerDown(plot, { button: 2, clientX: 50, pointerId: 1 }) expect(plot.getAttribute('data-panning')).toBe('true') expect(fireEvent.contextMenu(plot)).toBe(false) fireEvent.pointerMove(plot, { buttons: 2, clientX: 75, pointerId: 1 }) fireEvent.pointerUp(plot, { button: 2, clientX: 75, pointerId: 1 }) expect(domain?.style.getPropertyValue('--trajectory-domain-left')).not.toBe(before) expect(onRangeChange).not.toHaveBeenCalled() expect(plot.getAttribute('data-panning')).toBeNull() }) 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() 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 }) fireEvent.pointerDown(plot, { button: 0, clientX: 50, pointerId: 1 }) for (let index = 0; index < 24; index++) { fireEvent.pointerMove(plot, { clientX: 99, pointerId: 1 }) } const draftSelection = view.container.querySelectorAll( '[data-dragging="true"]', ) expect(draftSelection).toHaveLength(2) for (const overlay of draftSelection) { expect(Number.parseFloat( overlay.style.getPropertyValue('--trajectory-selection-left'), )).toBeLessThan(0) } fireEvent.pointerUp(plot, { clientX: 99, pointerId: 1 }) const selectedRange = onRangeChange.mock.calls.at(-1)?.[0] as | { start: number; end: number } | undefined const fullRange = deriveTrajectoryTimeline(longTurns) expect(selectedRange).toBeDefined() expect(fullRange).not.toBeNull() expect((selectedRange?.end ?? 0) - (selectedRange?.start ?? 0)).toBeGreaterThan(4) expect(selectedRange?.start).toBeGreaterThanOrEqual(fullRange?.start ?? 0) expect(selectedRange?.end).toBeLessThanOrEqual(fullRange?.end ?? 0) }) 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('compresses every idle gap in duration mode while actual mode retains wall time', () => { 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, 'duration')).toMatchObject({ start: 1_000, end: 4_000, spans: [ { index: 1, start: 1_000, end: 2_000 }, { index: 2, start: 2_000, end: 3_000 }, { index: 3, start: 3_000, end: 4_000 }, ], turnBoundaries: [ { turn: 1, time: 1_000 }, { turn: 2, time: 3_000 }, ], }) expect(deriveTrajectoryTimeline(separatedTurns, 'actual')).toMatchObject({ start: 1_000, end: 41_000, spans: [ { index: 1, start: 1_000, end: 2_000 }, { index: 2, start: 4_000, end: 5_000 }, { index: 3, start: 40_000, end: 41_000 }, ], }) }) it('projects between-turn compaction without inventing a turn boundary', () => { const withStandaloneCompaction = [ { turn: 1, groups: [{ title: 'Step 1', cells: [{ index: 1, kind: 'message', text: 'before', timeSeconds: 0 }], }], }, { turn: null, groups: [{ title: 'Compaction 3', cells: [{ index: 2, kind: 'compacted', text: 'summary', timeSeconds: 0 }], }], }, { turn: 2, groups: [{ title: 'Step 1', cells: [{ index: 3, kind: 'message', text: 'after', timeSeconds: 0 }], }], }, ] satisfies readonly TrajectoryTurnModel[] expect(deriveTrajectoryTimeline(withStandaloneCompaction)).toMatchObject({ 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([])), ...standaloneDuration(), }, )) expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy() expect(screen.queryByRole('row')).toBeNull() }) }) describe('TrajectoryView branches', () => { it('persists the duration preference through the runtime snapshot-store seam', () => { const firstDuration = createTrajectoryDurationStore() const commonProps = { ...standaloneProps(NODES), ...standaloneHistory(historySnapshot(NODES)), } const first = render( { firstDuration.set(value) }} />, ) const duration = screen.getByRole('button', { name: 'Use actual duration' }) expect(duration.getAttribute('aria-pressed')).toBe('false') fireEvent.click(duration) expect(localStorage.getItem('dsh.trajectory.duration')).toBe('true') first.unmount() const restoredDuration = createTrajectoryDurationStore() render( { restoredDuration.set(value) }} />, ) expect(screen.getByRole('button', { name: 'Use actual duration' }).getAttribute('aria-pressed')) .toBe('true') }) 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())} loadOlderHistory={vi.fn(() => Promise.resolve(false))} />, ) 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('does not remount the ledger when prepending shifts a rewind generation id', () => { const current = { kind: 'assistant', seq: 5, time: 5_000, turn: 2, step: 1, blocks: [{ kind: 'text', text: 'stable rewind response' }], } as unknown as ConversationSnapshot['nodes'][number] const snapshot = (id: number) => historySnapshot([current], { contexts: [{ id, origin: 'rewind' as const, originSeq: 4, nodes: [current], }], }) const store = createSnapshotStore(snapshot(1)) render( Promise.resolve())} loadOlderHistory={vi.fn(() => Promise.resolve(false))} />, ) const row = screen.getByRole('row', { name: /stable rewind response/ }) fireEvent.click(row) expect(row.getAttribute('aria-selected')).toBe('true') act(() => { store.set(snapshot(2)) }) expect(screen.getByRole('row', { name: /stable rewind response/ }) .getAttribute('aria-selected')).toBe('true') }) it('keeps ledger and timeline selection on the same event after prepend', () => { const older = { kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'older prompt' }], source: null, } as unknown as ConversationSnapshot['nodes'][number] const current = { kind: 'assistant', seq: 100, time: 5_000, turn: 2, step: 1, blocks: [{ kind: 'text', text: 'selected current response' }], } as unknown as ConversationSnapshot['nodes'][number] const store = createSnapshotStore(historySnapshot([current])) const view = render( Promise.resolve())} loadOlderHistory={vi.fn(() => Promise.resolve(false))} />, ) fireEvent.click(screen.getByRole('row', { name: /selected current response/ })) act(() => { store.set(historySnapshot([older, current])) }) const row = screen.getByRole('row', { name: /selected current response/ }) expect(row.getAttribute('aria-selected')).toBe('true') const currentIndex = row.getAttribute('data-record-index') expect(view.container.querySelector( `[data-timeline-record-index="${currentIndex}"][data-current="true"]`, )).toBeTruthy() }) 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())} loadOlderHistory={vi.fn(() => Promise.resolve(false))} />, ) 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() }) })