| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272 |
- // @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, 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
- // 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<string, string>)[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<SessionHistoryInspection> = {},
- ): 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<typeof TrajectoryView>,
- 'useHistory' | 'loadHistoryTail' | 'loadOlderHistory'
- > {
- const store = createSnapshotStore(snapshot)
- return {
- useHistory: bindSnapshotSelector(store),
- loadHistoryTail: () => Promise.resolve(),
- loadOlderHistory: () => Promise.resolve(false),
- }
- }
- function standaloneDuration(): Pick<
- ComponentProps<typeof TrajectoryView>, '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<ConversationSnapshot> }
- }
- /** Empty sessions-list hook; breadcrumbs therefore fall back to the raw id. */
- function emptySessions() {
- const store = createSnapshotStore<SessionListState>(
- { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined })
- return bindSnapshotSelector(store)
- }
- function emptyWorkspaces() {
- const store = createSnapshotStore<WorkspaceListState>({
- 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(() => <div data-testid="chat-body" />)
- 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, 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 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<ConversationSnapshot>
- 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<ConvViewProps>
- 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 (
- <View
- {...injectedProps}
- {...({ sessionId: SID, useSession, useSessions: emptySessions(), useWorkspaces: emptyWorkspaces() } as unknown as ConvViewProps)}
- key={key}
- />
- )
- }) as unknown as ConversationSessionProps['renderSlot']
- return render(
- <ConversationSession
- sessionId={SID}
- t={tConversation}
- SessionProvider={({ children }) => 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'])
- })
- 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<HTMLElement>(
- '[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<HTMLElement>(
- `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<HTMLButtonElement>('button', {
- name: 'Collapse turns',
- }).disabled).toBe(false)
- expect(screen.getByRole<HTMLButtonElement>('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(
- <TrajectoryTimeline
- turns={[{
- turn: 1,
- groups: [{
- title: 'Step 1',
- cells: [{
- index: 1,
- kind: 'message',
- text: 'assistant',
- startedAt: 1_000,
- timeSeconds: 2,
- assistantMetrics: {
- timingRecorded: true,
- stepStartTime: 1_000,
- firstTokenTime: 1_500,
- completedTime: 3_000,
- usageProvided: false,
- outputTokens: null,
- },
- }],
- }],
- }]}
- mode="duration"
- range={null}
- onRangeChange={vi.fn()}
- />,
- )
- const span = view.container.querySelector<HTMLElement>(
- '[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<HTMLElement>('[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<boolean>(() => {}))
- const view = render(
- <TrajectoryTimeline
- turns={turns}
- mode="sequence"
- range={null}
- hasEarlierRecords
- onLoadEarlier={onLoadEarlier}
- onRangeChange={vi.fn()}
- />,
- )
- 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(
- <TrajectoryTimeline
- turns={turns}
- mode="sequence"
- range={null}
- onRangeChange={vi.fn()}
- />,
- )
- expect(screen.queryByLabelText('Load earlier history')).toBeNull()
- expect(screen.queryByLabelText('Loading earlier history')).toBeNull()
- })
- it('cancels native scrolling across the timeline while zooming', () => {
- render(
- <TrajectoryTimeline
- turns={longTurns}
- mode="sequence"
- range={null}
- onRangeChange={vi.fn()}
- />,
- )
- 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(
- <TrajectoryTimeline
- turns={longTurns}
- mode="sequence"
- range={null}
- onRangeChange={vi.fn()}
- />,
- )
- const span = view.container.querySelector<HTMLElement>('[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(
- <TrajectoryTimeline
- turns={denseTurns}
- mode="sequence"
- range={null}
- onRangeChange={vi.fn()}
- />,
- )
- const span = view.container.querySelector<HTMLElement>('[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(
- <TrajectoryTimeline
- turns={longTurns}
- mode="sequence"
- range={{ start: 2, end: 4 }}
- hasEarlierRecords
- onRangeChange={onRangeChange}
- />,
- )
- 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<HTMLElement>('[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(
- <TrajectoryTimeline
- turns={longTurns}
- mode="sequence"
- range={{ start: 2, end: 4 }}
- onRangeChange={onRangeChange}
- />,
- )
- 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(
- <TrajectoryTimeline
- turns={longTurns}
- mode="sequence"
- range={{ start: 2, end: 4 }}
- onRangeChange={onRangeChange}
- />,
- )
- 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<HTMLElement>('[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(
- <TrajectoryTimeline
- turns={longTurns}
- mode="sequence"
- range={null}
- onRangeChange={onRangeChange}
- />,
- )
- 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(
- <TrajectoryTimeline
- turns={longTurns}
- mode="sequence"
- range={null}
- selectedIndex={1}
- onRangeChange={onRangeChange}
- />,
- )
- await vi.waitFor(() => {
- const domain = view.container.querySelector<HTMLElement>(
- '[data-timeline-domain]',
- )
- expect(domain?.style.getPropertyValue('--trajectory-domain-left')).toBe('-25%')
- })
- view.rerender(
- <TrajectoryTimeline
- turns={longTurns}
- mode="sequence"
- range={null}
- selectedIndex={8}
- onRangeChange={onRangeChange}
- />,
- )
- await vi.waitFor(() => {
- const domain = view.container.querySelector<HTMLElement>(
- '[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(
- <TrajectoryTimeline
- turns={longTurns}
- mode="sequence"
- range={null}
- onRangeChange={onRangeChange}
- />,
- )
- 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<HTMLElement>(
- '[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(
- <TrajectoryTimeline
- turns={errorTurns}
- mode="sequence"
- range={null}
- onRangeChange={() => {}}
- />,
- )
- 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(
- <TrajectoryView
- {...commonProps}
- useDuration={bindSnapshotSelector(firstDuration)}
- setActualDuration={(value) => { 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(
- <TrajectoryView
- {...commonProps}
- useDuration={bindSnapshotSelector(restoredDuration)}
- setActualDuration={(value) => { 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(
- <TrajectoryView
- {...standaloneProps([])}
- {...standaloneDuration()}
- useHistory={bindSnapshotSelector(store)}
- loadHistoryTail={vi.fn(() => 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(
- <TrajectoryView
- {...standaloneProps([])}
- {...standaloneDuration()}
- useHistory={bindSnapshotSelector(store)}
- loadHistoryTail={vi.fn(() => 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(
- <TrajectoryView
- {...standaloneProps([])}
- {...standaloneDuration()}
- useHistory={bindSnapshotSelector(store)}
- loadHistoryTail={vi.fn(() => 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(
- <TrajectoryView
- {...standaloneProps([])}
- {...standaloneDuration()}
- useHistory={bindSnapshotSelector(store)}
- loadHistoryTail={vi.fn(() => 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()
- })
- })
|