views.spec.tsx 47 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272
  1. // @vitest-environment jsdom
  2. /**
  3. * View registration acceptance on the real framework stack: the plugin fiber
  4. * registers Trajectory into a real SlotsService view ring, tabs
  5. * switch inside ConversationRoot (renderSlot share driven by the same tab
  6. * projection apply uses) without collapsing chat, trajectory renders the
  7. * event ledger with its timing overview, and fiber disposal removes the tab.
  8. * Timeline projection and inclusive focus edge cases ride along.
  9. */
  10. import { Context } from 'cordis'
  11. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  12. import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
  13. import { createElement, type ComponentProps, type FC, type ReactNode } from 'react'
  14. import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
  15. import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
  16. import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
  17. import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
  18. import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
  19. import type {
  20. ConversationSnapshot, RequestView, SessionHistoryFace, SessionHistoryInspection,
  21. SessionHistorySnapshot, SessionId, SessionListState, WorkspaceListState,
  22. } from '@deepseek-ai/dsh-client-runtime/client'
  23. import type { ConvViewProps, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
  24. import { ConversationSession, type ConversationSessionProps } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationSession.tsx'
  25. import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts'
  26. import { zh as conversationZh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
  27. import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client'
  28. import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory'
  29. import type { TrajectoryTurnModel } from '../src/client/layout.ts'
  30. import { TrajectoryTimeline } from '../src/client/TrajectoryTimeline.tsx'
  31. import {
  32. TrajectoryView, type TrajectoryViewInjected,
  33. } from '../src/client/TrajectoryView.tsx'
  34. import { createTrajectoryDurationStore } from '../src/client/duration-store.ts'
  35. import { deriveTrajectoryTimeline } from '../src/client/timeline.ts'
  36. const SID = 's1' as SessionId
  37. // Stub of the conversation package's standard locale seat (this spec mounts
  38. // its ConversationSession chrome); answers from the zh dictionary and falls
  39. // back to the key like the real chain.
  40. const tConversation: ConversationSessionProps['t'] =
  41. key => (conversationZh as Record<string, string>)[key] ?? key
  42. afterEach(cleanup)
  43. // The chat store persists under its declared key; clear so one case's active
  44. // view cannot rehydrate into the next.
  45. beforeEach(() => {
  46. localStorage.clear()
  47. })
  48. /** Node fixture: user prologue, two turns, one tool result inside turn 1. */
  49. const NODES = [
  50. { kind: 'user', seq: 1, time: 1_000, content: [], source: null },
  51. {
  52. kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [],
  53. timing: { stepStartTime: 1_800, firstTokenTime: 1_900, completedTime: 2_000 },
  54. },
  55. {
  56. kind: 'tool-result', seq: 3, time: 3_000, callId: 'c1', call: null, callTime: 2_200,
  57. content: [], isError: false, callView: null, resultView: null,
  58. },
  59. {
  60. kind: 'assistant', seq: 4, time: 4_000, turn: 2, step: 1, blocks: [],
  61. timing: { stepStartTime: 3_500, firstTokenTime: 3_700, completedTime: 4_000 },
  62. },
  63. ] as unknown as ConversationSnapshot['nodes']
  64. function historySnapshot(
  65. nodes: ConversationSnapshot['nodes'],
  66. inspection: Partial<SessionHistoryInspection> = {},
  67. ): SessionHistorySnapshot {
  68. return {
  69. state: 'ready',
  70. error: null,
  71. hasMore: false,
  72. baseSeq: nodes[0]?.seq ?? 0,
  73. inspection: {
  74. eventNodes: nodes,
  75. contexts: [{ id: 0, nodes }],
  76. requests: [],
  77. callSchemas: new Map(),
  78. interruptedNodes: [],
  79. partial: null,
  80. runningCalls: [],
  81. codeDispatches: new Map(),
  82. ...inspection,
  83. },
  84. }
  85. }
  86. function standaloneHistory(
  87. snapshot: SessionHistorySnapshot,
  88. ): Pick<
  89. ComponentProps<typeof TrajectoryView>,
  90. 'useHistory' | 'loadHistoryTail' | 'loadOlderHistory'
  91. > {
  92. const store = createSnapshotStore(snapshot)
  93. return {
  94. useHistory: bindSnapshotSelector(store),
  95. loadHistoryTail: () => Promise.resolve(),
  96. loadOlderHistory: () => Promise.resolve(false),
  97. }
  98. }
  99. function standaloneDuration(): Pick<
  100. ComponentProps<typeof TrajectoryView>, 'useDuration' | 'setActualDuration'
  101. > {
  102. const duration = createSnapshotStore(false)
  103. return {
  104. useDuration: bindSnapshotSelector(duration),
  105. setActualDuration: (value) => { duration.set(value) },
  106. }
  107. }
  108. function fakeSession(nodes: ConversationSnapshot['nodes']) {
  109. const store = createSnapshotStore({
  110. nodes, pending: [], partial: null,
  111. runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches: new Map(),
  112. })
  113. return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot> }
  114. }
  115. /** Empty sessions-list hook; breadcrumbs therefore fall back to the raw id. */
  116. function emptySessions() {
  117. const store = createSnapshotStore<SessionListState>(
  118. { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined })
  119. return bindSnapshotSelector(store)
  120. }
  121. function emptyWorkspaces() {
  122. const store = createSnapshotStore<WorkspaceListState>({
  123. items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true,
  124. recentWorkspaceId: undefined,
  125. })
  126. return bindSnapshotSelector(store)
  127. }
  128. /** Standalone view props: the session-scope standard kit the outlet would bake. */
  129. function standaloneProps(nodes: ConversationSnapshot['nodes']): ConvViewProps {
  130. return {
  131. sessionId: SID,
  132. useSession: fakeSession(nodes).useSession,
  133. useSessions: emptySessions(),
  134. useWorkspaces: emptyWorkspaces(),
  135. useProjection: (() => undefined) as never,
  136. } as unknown as ConvViewProps
  137. }
  138. /** Real-stack bench: root Context + real SlotsService ring + the plugin fiber. */
  139. async function bench(snapshot = historySnapshot(NODES)) {
  140. const ctx = new Context()
  141. const slots = new SlotsService(ctx)
  142. const loadHistoryTail = vi.fn((_signal: AbortSignal) => Promise.resolve())
  143. const loadOlderHistory = vi.fn((_signal: AbortSignal) => Promise.resolve(false))
  144. const historyStore = createSnapshotStore(snapshot)
  145. const history: SessionHistoryFace = {
  146. sessionId: SID,
  147. getSnapshot: () => historyStore.getSnapshot(),
  148. subscribe: listener => historyStore.subscribe(listener),
  149. loadTail: loadHistoryTail,
  150. loadOlder: loadOlderHistory,
  151. }
  152. // The conversation entry's role: declare the ring, then seed the chat entry.
  153. slots.register({
  154. name: 'root',
  155. children: { 'conversation.view': { kind: 'list', scope: 'session' } },
  156. }, (_p: { renderSlot?: unknown }) => null)
  157. const chatBody = vi.fn(() => <div data-testid="chat-body" />)
  158. slots.register(
  159. { name: 'conversation.view', id: 'chat', order: 0, label: 'Chat' } as never, chatBody as never)
  160. // 'conversation' inject is an ordering edge; the bench declares the ring
  161. // itself, so a stub satisfies the wait.
  162. ctx.provide('conversation', {})
  163. ctx.provide('sessionHistory', { source: () => history })
  164. const fiber = ctx.plugin({ inject: [...inject], apply })
  165. await fiber.await()
  166. return { ctx, slots, fiber, loadHistoryTail, loadOlderHistory }
  167. }
  168. /** Tab projection twin of apply's viewTabs (the render-side consumption path). */
  169. function tabsOf(slots: SlotsService): ViewTab[] {
  170. return slots.entries('conversation.view')
  171. .map(e => ({ id: e.options.id!, label: resolveSlotLabel(e.options.label) ?? e.options.id! }))
  172. }
  173. /** Mount the strict session content over the ring ledger with an outlet-faithful renderSlot. */
  174. function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES) {
  175. const sessionSnapshot = createSnapshotStore({
  176. running: false, removed: false, promptError: null, nodes,
  177. pending: [],
  178. openState: 'open' as const, hasMore: true, loadingOlder: false,
  179. partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches: new Map(),
  180. })
  181. const useSession = bindSnapshotSelector(sessionSnapshot) as unknown as UseSession<ConversationSnapshot>
  182. const chat = createChatStore().create()
  183. // Minimal outlet twin: resolve the ring entry by the `only` filter and
  184. // render it with the session standard kit (what SlotOutlet does for a
  185. // list-kind session slot, minus machinery).
  186. const renderSlot = ((key: string, _owner: object, opts?: { only?: string }): ReactNode => {
  187. const entry = slots.entries('conversation.view').find(e => e.options.id === opts?.only)
  188. if (entry === undefined) return null
  189. const View = entry.component as FC<ConvViewProps>
  190. const injectEntry = entry.inject as ((sessionId: SessionId) => object) | undefined
  191. const injected = injectEntry === undefined
  192. ? {}
  193. : injectEntry(SID)
  194. const injectedProps = 'hooks' in injected
  195. ? (() => {
  196. const trajectory = injected as TrajectoryViewInjected
  197. return {
  198. loadHistoryTail: trajectory.loadHistoryTail,
  199. loadOlderHistory: trajectory.loadOlderHistory,
  200. setActualDuration: trajectory.setActualDuration,
  201. useHistory: bindSnapshotSelector(trajectory.hooks.history),
  202. useDuration: bindSnapshotSelector(trajectory.hooks.duration),
  203. }
  204. })()
  205. : injected
  206. return (
  207. <View
  208. {...injectedProps}
  209. {...({ sessionId: SID, useSession, useSessions: emptySessions(), useWorkspaces: emptyWorkspaces() } as unknown as ConvViewProps)}
  210. key={key}
  211. />
  212. )
  213. }) as unknown as ConversationSessionProps['renderSlot']
  214. return render(
  215. <ConversationSession
  216. sessionId={SID}
  217. t={tConversation}
  218. SessionProvider={({ children }) => children(SID)}
  219. useSession={useSession}
  220. useSessions={emptySessions()}
  221. useWorkspaces={emptyWorkspaces()}
  222. useProjection={(() => undefined)}
  223. useStore={bindSnapshotSelector(chat)}
  224. actions={chat.actions}
  225. renderSlot={renderSlot}
  226. views={{
  227. list: () => tabsOf(slots),
  228. subscribe: (fn: () => void) => slots.subscribe('conversation.view', fn),
  229. version: () => slots.getVersion('conversation.view'),
  230. }}
  231. useInput={bindSnapshotSelector(createSnapshotStore({ draft: '', draftRev: 0, phase: 'plain', queue: [] })) as never}
  232. inputActions={{ setDraft: vi.fn(), submit: vi.fn() }}
  233. bindDraftMirror={() => () => {}}
  234. open={vi.fn()}
  235. />,
  236. )
  237. }
  238. describe('plugin registration', () => {
  239. it('registers trajectory after chat on the ring', async () => {
  240. const b = await bench()
  241. expect(tabsOf(b.slots)).toEqual([
  242. { id: 'chat', label: 'Chat' },
  243. { id: 'trajectory', label: 'Trajectory' },
  244. ])
  245. })
  246. it('fiber disposal removes the tab and leaves chat standing', async () => {
  247. const b = await bench()
  248. await b.fiber.dispose()
  249. expect(tabsOf(b.slots).map(v => v.id)).toEqual(['chat'])
  250. })
  251. it('shares one browser-wide duration preference across session injections', async () => {
  252. const b = await bench()
  253. const entry = b.slots.entries('conversation.view')
  254. .find(candidate => candidate.options.id === 'trajectory')
  255. expect(entry).toBeDefined()
  256. const injectEntry = entry!.inject as unknown as (
  257. sessionId: SessionId,
  258. ) => TrajectoryViewInjected
  259. const first = injectEntry(SID)
  260. const second = injectEntry('s2' as SessionId)
  261. expect(second.hooks.duration).toBe(first.hooks.duration)
  262. first.setActualDuration(true)
  263. expect(second.hooks.duration.getSnapshot()).toBe(true)
  264. expect(localStorage.getItem('dsh.trajectory.duration')).toBe('true')
  265. expect(localStorage.getItem(`dsh.trajectory.duration.${SID}`)).toBeNull()
  266. })
  267. })
  268. describe('tab switching in ConversationRoot', () => {
  269. it('renders two tabs, defaults to chat, and switches to the trajectory ledger', async () => {
  270. const b = await bench()
  271. const view = mount(b.slots)
  272. expect(screen.getByTestId('chat-body')).toBeTruthy()
  273. expect(screen.getAllByRole('tab').map(t => t.textContent)).toEqual(['Chat', 'Trajectory'])
  274. fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
  275. expect(screen.queryByText(/turns ·/)).toBeNull()
  276. expect(view.container.querySelectorAll('tr[data-turn-start="true"]')).toHaveLength(2)
  277. expect(screen.queryByRole('columnheader')).toBeNull()
  278. expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy()
  279. expect(screen.getByRole('region', { name: 'Trajectory timeline' })).toBeTruthy()
  280. expect(view.container.querySelector('[data-conversation-composer-overlay]')).toBeTruthy()
  281. fireEvent.click(screen.getByRole('button', { name: 'Collapse turns' }))
  282. expect(view.container.querySelector('[data-collapsed-summary="turn"]')).toBeTruthy()
  283. fireEvent.click(screen.getByRole('button', { name: 'Expand turns' }))
  284. expect(screen.getByRole('row', { name: /USER/ })).toBeTruthy()
  285. expect(screen.queryByTestId('chat-body')).toBeNull()
  286. await vi.waitFor(() => {
  287. expect(b.loadHistoryTail).toHaveBeenCalledOnce()
  288. })
  289. const signal = b.loadHistoryTail.mock.calls[0]?.[0]
  290. expect(signal?.aborted).toBe(false)
  291. fireEvent.click(screen.getByRole('tab', { name: 'Chat' }))
  292. expect(signal?.aborted).toBe(true)
  293. })
  294. it('opens a local record inspector and switches payload tabs without opening chat details', async () => {
  295. const b = await bench()
  296. mount(b.slots)
  297. fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
  298. fireEvent.keyDown(screen.getByRole('row', { name: /TOOL/ }), { key: 'Enter' })
  299. expect(screen.getByRole('complementary', { name: 'Event details' })).toBeTruthy()
  300. expect(screen.getByText('Turn 1 · Step 1')).toBeTruthy()
  301. expect(screen.getByText('Completed')).toBeTruthy()
  302. expect(screen.getByRole('tab', { name: 'Result' })).toBeTruthy()
  303. fireEvent.click(screen.getByRole('button', { name: 'Close details' }))
  304. expect(screen.queryByRole('complementary', { name: 'Event details' })).toBeNull()
  305. })
  306. it('labels a standalone compaction as between-turn work in the ledger and inspector', async () => {
  307. const nodes = [
  308. { kind: 'user', seq: 1, time: 1_000, content: [], source: null },
  309. {
  310. kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1,
  311. blocks: [{ kind: 'text', text: 'before' }],
  312. },
  313. { kind: 'user', seq: 5, time: 5_000, content: [], source: null },
  314. {
  315. kind: 'assistant', seq: 6, time: 6_000, turn: 2, step: 1,
  316. blocks: [{ kind: 'text', text: 'after' }],
  317. },
  318. ] as unknown as ConversationSnapshot['nodes']
  319. const compaction: RequestView = {
  320. purpose: 'compaction',
  321. startSeq: 3,
  322. turn: null,
  323. step: 0,
  324. startedAt: 3_000,
  325. completedAt: 4_000,
  326. status: 'complete',
  327. summary: [{ type: 'text', text: 'standalone summary' }],
  328. }
  329. const b = await bench(historySnapshot(nodes, { requests: [compaction] }))
  330. const view = mount(b.slots, nodes)
  331. fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
  332. expect(screen.getByText('Between turns')).toBeTruthy()
  333. expect(view.container.textContent).not.toContain('Turn null')
  334. fireEvent.click(screen.getByRole('button', { name: 'Request #2 · Compaction' }))
  335. expect(screen.getByText('Compaction · Between turns')).toBeTruthy()
  336. expect(view.container.textContent).not.toContain('Turn null')
  337. })
  338. it('activates only the selected standalone compaction section', async () => {
  339. const nodes = [
  340. { kind: 'user', seq: 1, time: 1_000, content: [], source: null },
  341. {
  342. kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1,
  343. blocks: [{ kind: 'text', text: 'before first compaction' }],
  344. },
  345. { kind: 'user', seq: 5, time: 5_000, content: [], source: null },
  346. {
  347. kind: 'assistant', seq: 6, time: 6_000, turn: 2, step: 1,
  348. blocks: [{ kind: 'text', text: 'between compactions' }],
  349. },
  350. { kind: 'user', seq: 9, time: 9_000, content: [], source: null },
  351. {
  352. kind: 'assistant', seq: 10, time: 10_000, turn: 3, step: 1,
  353. blocks: [{ kind: 'text', text: 'after second compaction' }],
  354. },
  355. ] as unknown as ConversationSnapshot['nodes']
  356. const compactions: RequestView[] = [
  357. {
  358. purpose: 'compaction',
  359. startSeq: 3,
  360. turn: null,
  361. step: 0,
  362. startedAt: 3_000,
  363. completedAt: 4_000,
  364. status: 'complete',
  365. summary: [{ type: 'text', text: 'first standalone summary' }],
  366. },
  367. {
  368. purpose: 'compaction',
  369. startSeq: 7,
  370. turn: null,
  371. step: 0,
  372. startedAt: 7_000,
  373. completedAt: 8_000,
  374. status: 'complete',
  375. summary: [{ type: 'text', text: 'second standalone summary' }],
  376. },
  377. ]
  378. const b = await bench(historySnapshot(nodes, { requests: compactions }))
  379. mount(b.slots, nodes)
  380. fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
  381. const firstRequest = screen.getByRole('button', { name: 'Request #2 · Compaction' })
  382. const secondRequest = screen.getByRole('button', { name: 'Request #4 · Compaction' })
  383. const firstSection = firstRequest.closest('tr')?.querySelector('span')
  384. const secondSection = secondRequest.closest('tr')?.querySelector('span')
  385. expect(firstSection?.textContent).toBe('Between turns')
  386. expect(secondSection?.textContent).toBe('Between turns')
  387. fireEvent.click(firstRequest)
  388. expect(firstSection?.className).toMatch(/turnLabelActive/)
  389. expect(secondSection?.className).not.toMatch(/turnLabelActive/)
  390. expect(screen.getByText('Request #2')).toBeTruthy()
  391. expect(screen.getByText('Compaction · Between turns')).toBeTruthy()
  392. fireEvent.click(secondRequest)
  393. expect(firstSection?.className).not.toMatch(/turnLabelActive/)
  394. expect(secondSection?.className).toMatch(/turnLabelActive/)
  395. expect(screen.getByText('Request #4')).toBeTruthy()
  396. expect(screen.getByText('Compaction · Between turns')).toBeTruthy()
  397. })
  398. it('dragging the overview focuses overlapping records without filtering the ledger', async () => {
  399. const b = await bench()
  400. mount(b.slots)
  401. fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
  402. const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
  403. vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({
  404. x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72,
  405. toJSON: () => ({}),
  406. })
  407. fireEvent.pointerDown(plot, { button: 0, clientX: 55, pointerId: 1 })
  408. fireEvent.pointerMove(plot, { clientX: 95, pointerId: 1 })
  409. fireEvent.pointerUp(plot, { clientX: 95, pointerId: 1 })
  410. expect(screen.getByRole('row', { name: /USER/ }).getAttribute('data-timeline-focus'))
  411. .toBe('outside')
  412. const tablePane = screen.getByRole('table').parentElement
  413. expect(tablePane).not.toBeNull()
  414. fireEvent.click(tablePane as HTMLElement)
  415. expect(screen.getByRole('row', { name: /USER/ }).getAttribute('data-timeline-focus'))
  416. .toBeNull()
  417. fireEvent.pointerDown(plot, { button: 0, clientX: 55, pointerId: 2 })
  418. fireEvent.pointerMove(plot, { clientX: 95, pointerId: 2 })
  419. fireEvent.pointerUp(plot, { clientX: 95, pointerId: 2 })
  420. expect(screen.getByRole('row', { name: /USER/ }).getAttribute('data-timeline-focus'))
  421. .toBe('outside')
  422. fireEvent.contextMenu(plot)
  423. expect(screen.getByRole('row', { name: /USER/ }).getAttribute('data-timeline-focus'))
  424. .toBe('outside')
  425. })
  426. it('clicking a timeline block clears the range, selects the record, and opens its inspector', async () => {
  427. const b = await bench()
  428. const view = mount(b.slots)
  429. fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
  430. const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
  431. vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({
  432. x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72,
  433. toJSON: () => ({}),
  434. })
  435. const toolSpan = view.container.querySelector<HTMLElement>(
  436. '[data-timeline-span="tool"]',
  437. )
  438. expect(toolSpan).not.toBeNull()
  439. const recordIndex = toolSpan?.dataset.timelineRecordIndex
  440. expect(recordIndex).toBeTruthy()
  441. fireEvent.pointerMove(toolSpan as HTMLElement, { clientX: 50, pointerId: 1 })
  442. expect(view.container.querySelector('[data-timeline-hover-line]')).toBeNull()
  443. expect(toolSpan?.getAttribute('data-hovered')).toBe('true')
  444. fireEvent.pointerDown(plot, { button: 0, clientX: 5, pointerId: 1 })
  445. fireEvent.pointerMove(plot, { clientX: 95, pointerId: 1 })
  446. fireEvent.pointerUp(plot, { clientX: 95, pointerId: 1 })
  447. expect(view.container.querySelector('tr[data-timeline-focus]')).toBeTruthy()
  448. fireEvent.pointerDown(toolSpan as HTMLElement, {
  449. button: 0, clientX: 50, pointerId: 2,
  450. })
  451. fireEvent.pointerUp(toolSpan as HTMLElement, { clientX: 50, pointerId: 2 })
  452. const selectedRow = view.container.querySelector<HTMLElement>(
  453. `tr[data-record-index="${recordIndex}"]`,
  454. )
  455. expect(selectedRow?.getAttribute('aria-selected')).toBe('true')
  456. expect(view.container.querySelector('tr[data-timeline-focus]')).toBeNull()
  457. expect(screen.getByRole('complementary', { name: 'Event details' })).toBeTruthy()
  458. })
  459. it('empty window keeps the toolbar and reports no timing data', async () => {
  460. const b = await bench(historySnapshot([]))
  461. mount(b.slots)
  462. fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
  463. expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy()
  464. expect(screen.getByText('No timing data')).toBeTruthy()
  465. expect(screen.getByRole<HTMLButtonElement>('button', {
  466. name: 'Collapse turns',
  467. }).disabled).toBe(false)
  468. expect(screen.getByRole<HTMLButtonElement>('button', {
  469. name: 'Collapse calls',
  470. }).disabled).toBe(false)
  471. expect(screen.queryByRole('row')).toBeNull()
  472. expect(screen.queryByText(/turns ·/)).toBeNull()
  473. })
  474. })
  475. describe('timeline projection', () => {
  476. const turns = [{
  477. turn: 1,
  478. groups: [{
  479. title: 'Step 1',
  480. cells: [
  481. { index: 1, kind: 'message', text: 'assistant', startedAt: 1_000, timeSeconds: 1 },
  482. { index: 2, kind: 'tool', text: 'bash', startedAt: 2_000, timeSeconds: 1 },
  483. { index: 3, kind: 'user', text: 'unknown', timeSeconds: 0 },
  484. ],
  485. }],
  486. }] satisfies readonly TrajectoryTurnModel[]
  487. const longTurns = [{
  488. turn: 1,
  489. groups: [{
  490. title: 'Step 1',
  491. cells: Array.from({ length: 10 }, (_, index) => ({
  492. index,
  493. kind: 'message' as const,
  494. text: `record ${index}`,
  495. timeSeconds: 1,
  496. })),
  497. }],
  498. }] satisfies readonly TrajectoryTurnModel[]
  499. it('splits assistant time into recorded TTFT and decoding proportions with a delayed tooltip', () => {
  500. vi.useFakeTimers()
  501. try {
  502. const view = render(
  503. <TrajectoryTimeline
  504. turns={[{
  505. turn: 1,
  506. groups: [{
  507. title: 'Step 1',
  508. cells: [{
  509. index: 1,
  510. kind: 'message',
  511. text: 'assistant',
  512. startedAt: 1_000,
  513. timeSeconds: 2,
  514. assistantMetrics: {
  515. timingRecorded: true,
  516. stepStartTime: 1_000,
  517. firstTokenTime: 1_500,
  518. completedTime: 3_000,
  519. usageProvided: false,
  520. outputTokens: null,
  521. },
  522. }],
  523. }],
  524. }]}
  525. mode="duration"
  526. range={null}
  527. onRangeChange={vi.fn()}
  528. />,
  529. )
  530. const span = view.container.querySelector<HTMLElement>(
  531. '[data-timeline-span="message"]',
  532. )
  533. expect(span?.getAttribute('title')).toBeNull()
  534. expect(span?.getAttribute('data-assistant-timing')).toBe('true')
  535. expect(span?.style.getPropertyValue('--trajectory-assistant-ttft')).toBe('25%')
  536. fireEvent.mouseEnter(span as HTMLElement)
  537. act(() => { vi.advanceTimersByTime(499) })
  538. expect(view.container.querySelector('[role="tooltip"]')).toBeNull()
  539. act(() => { vi.advanceTimersByTime(1) })
  540. const tooltip = view.container.querySelector<HTMLElement>('[role="tooltip"]')
  541. expect(tooltip?.textContent).toContain('Total 2,000 ms')
  542. expect(tooltip?.textContent).toContain('TTFT 500 ms')
  543. expect(tooltip?.textContent).toContain('Decoding 1,500 ms')
  544. } finally {
  545. vi.useRealTimers()
  546. }
  547. })
  548. it('marks an unloaded history prefix without inventing timeline duration', () => {
  549. const onLoadEarlier = vi.fn(() => new Promise<boolean>(() => {}))
  550. const view = render(
  551. <TrajectoryTimeline
  552. turns={turns}
  553. mode="sequence"
  554. range={null}
  555. hasEarlierRecords
  556. onLoadEarlier={onLoadEarlier}
  557. onRangeChange={vi.fn()}
  558. />,
  559. )
  560. const boundary = screen.getByLabelText('Load earlier history')
  561. expect(boundary.getAttribute('data-earlier-history')).not.toBeNull()
  562. const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
  563. fireEvent.pointerMove(plot, { clientX: 50, pointerId: 1 })
  564. expect(view.container.querySelector('[data-timeline-hover-line]')).toBeTruthy()
  565. fireEvent.pointerEnter(boundary)
  566. expect(view.container.querySelector('[data-timeline-hover-line]')).toBeNull()
  567. fireEvent.focus(boundary)
  568. expect(screen.getByRole('tooltip').textContent)
  569. .toContain('Click to load earlier history')
  570. fireEvent.click(boundary)
  571. expect(onLoadEarlier).toHaveBeenCalledOnce()
  572. expect(screen.getByLabelText('Loading earlier history')).toBeTruthy()
  573. view.rerender(
  574. <TrajectoryTimeline
  575. turns={turns}
  576. mode="sequence"
  577. range={null}
  578. onRangeChange={vi.fn()}
  579. />,
  580. )
  581. expect(screen.queryByLabelText('Load earlier history')).toBeNull()
  582. expect(screen.queryByLabelText('Loading earlier history')).toBeNull()
  583. })
  584. it('cancels native scrolling across the timeline while zooming', () => {
  585. render(
  586. <TrajectoryTimeline
  587. turns={longTurns}
  588. mode="sequence"
  589. range={null}
  590. onRangeChange={vi.fn()}
  591. />,
  592. )
  593. const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
  594. vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({
  595. x: 44, y: 0, left: 44, top: 0, right: 144, bottom: 50, width: 100, height: 50,
  596. toJSON: () => ({}),
  597. })
  598. expect(fireEvent.wheel(plot, { clientX: 94, deltaY: -100 })).toBe(false)
  599. expect(fireEvent.wheel(screen.getByText('Input'), {
  600. clientX: 20,
  601. deltaY: -100,
  602. })).toBe(false)
  603. })
  604. it('scales sequence gutters with narrow operation spans', () => {
  605. const view = render(
  606. <TrajectoryTimeline
  607. turns={longTurns}
  608. mode="sequence"
  609. range={null}
  610. onRangeChange={vi.fn()}
  611. />,
  612. )
  613. const span = view.container.querySelector<HTMLElement>('[data-timeline-span]')
  614. expect(span?.style.getPropertyValue('--trajectory-span-width')).toBe('10%')
  615. expect(span?.style.getPropertyValue('--trajectory-span-gap'))
  616. .toBe('min(0.8%, 1px)')
  617. })
  618. it('keeps dense sequence spans proportional before applying the pixel floor', () => {
  619. const denseTurns = [{
  620. turn: 1,
  621. groups: [{
  622. title: 'Step 1',
  623. cells: Array.from({ length: 400 }, (_, index) => ({
  624. index,
  625. kind: 'message' as const,
  626. text: `message ${index}`,
  627. timeSeconds: 1,
  628. })),
  629. }],
  630. }]
  631. const view = render(
  632. <TrajectoryTimeline
  633. turns={denseTurns}
  634. mode="sequence"
  635. range={null}
  636. onRangeChange={vi.fn()}
  637. />,
  638. )
  639. const span = view.container.querySelector<HTMLElement>('[data-timeline-span]')
  640. expect(span?.style.getPropertyValue('--trajectory-span-width')).toBe('0.25%')
  641. expect(span?.style.getPropertyValue('--trajectory-span-gap'))
  642. .toBe('min(0.02%, 1px)')
  643. })
  644. it('clears the selection without changing zoom on a zoomed right click', () => {
  645. const onRangeChange = vi.fn()
  646. const view = render(
  647. <TrajectoryTimeline
  648. turns={longTurns}
  649. mode="sequence"
  650. range={{ start: 2, end: 4 }}
  651. hasEarlierRecords
  652. onRangeChange={onRangeChange}
  653. />,
  654. )
  655. const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
  656. expect(screen.getByLabelText('Load earlier history')).toBeTruthy()
  657. vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({
  658. x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72,
  659. toJSON: () => ({}),
  660. })
  661. fireEvent.wheel(plot, { clientX: 50, deltaY: -1_000 })
  662. expect(screen.queryByLabelText('Load earlier history')).toBeNull()
  663. const domain = view.container.querySelector<HTMLElement>('[data-timeline-domain]')
  664. const domainWidth = domain?.style.getPropertyValue('--trajectory-domain-width')
  665. expect(domainWidth).not.toBe('100%')
  666. fireEvent.pointerDown(plot, { button: 2, clientX: 50, pointerId: 1 })
  667. expect(fireEvent.contextMenu(plot)).toBe(false)
  668. fireEvent.pointerUp(plot, { button: 2, clientX: 50, pointerId: 1 })
  669. expect(onRangeChange).toHaveBeenCalledOnce()
  670. expect(onRangeChange).toHaveBeenCalledWith(null)
  671. expect(domain?.style.getPropertyValue('--trajectory-domain-width')).toBe(domainWidth)
  672. })
  673. it('clears the selection and suppresses the context menu at full zoom', () => {
  674. const onRangeChange = vi.fn()
  675. render(
  676. <TrajectoryTimeline
  677. turns={longTurns}
  678. mode="sequence"
  679. range={{ start: 2, end: 4 }}
  680. onRangeChange={onRangeChange}
  681. />,
  682. )
  683. const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
  684. fireEvent.pointerDown(plot, { button: 2, clientX: 50, pointerId: 1 })
  685. expect(fireEvent.contextMenu(plot)).toBe(false)
  686. fireEvent.pointerUp(plot, { button: 2, clientX: 50, pointerId: 1 })
  687. expect(onRangeChange).toHaveBeenCalledOnce()
  688. expect(onRangeChange).toHaveBeenCalledWith(null)
  689. })
  690. it('pans the zoomed viewport with a right-button drag without changing the selection', () => {
  691. const onRangeChange = vi.fn()
  692. const view = render(
  693. <TrajectoryTimeline
  694. turns={longTurns}
  695. mode="sequence"
  696. range={{ start: 2, end: 4 }}
  697. onRangeChange={onRangeChange}
  698. />,
  699. )
  700. const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
  701. vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({
  702. x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72,
  703. toJSON: () => ({}),
  704. })
  705. fireEvent.wheel(plot, { clientX: 50, deltaY: -1_000 })
  706. const domain = view.container.querySelector<HTMLElement>('[data-timeline-domain]')
  707. const before = domain?.style.getPropertyValue('--trajectory-domain-left')
  708. fireEvent.pointerDown(plot, { button: 2, clientX: 50, pointerId: 1 })
  709. expect(plot.getAttribute('data-panning')).toBe('true')
  710. expect(fireEvent.contextMenu(plot)).toBe(false)
  711. fireEvent.pointerMove(plot, { buttons: 2, clientX: 75, pointerId: 1 })
  712. fireEvent.pointerUp(plot, { button: 2, clientX: 75, pointerId: 1 })
  713. expect(domain?.style.getPropertyValue('--trajectory-domain-left')).not.toBe(before)
  714. expect(onRangeChange).not.toHaveBeenCalled()
  715. expect(plot.getAttribute('data-panning')).toBeNull()
  716. })
  717. it('pans the zoomed viewport only far enough to reveal a newly selected record', async () => {
  718. const onRangeChange = vi.fn()
  719. const view = render(
  720. <TrajectoryTimeline
  721. turns={longTurns}
  722. mode="sequence"
  723. range={null}
  724. onRangeChange={onRangeChange}
  725. />,
  726. )
  727. const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
  728. vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({
  729. x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72,
  730. toJSON: () => ({}),
  731. })
  732. fireEvent.wheel(plot, { clientX: 50, deltaY: -1_000 })
  733. view.rerender(
  734. <TrajectoryTimeline
  735. turns={longTurns}
  736. mode="sequence"
  737. range={null}
  738. selectedIndex={1}
  739. onRangeChange={onRangeChange}
  740. />,
  741. )
  742. await vi.waitFor(() => {
  743. const domain = view.container.querySelector<HTMLElement>(
  744. '[data-timeline-domain]',
  745. )
  746. expect(domain?.style.getPropertyValue('--trajectory-domain-left')).toBe('-25%')
  747. })
  748. view.rerender(
  749. <TrajectoryTimeline
  750. turns={longTurns}
  751. mode="sequence"
  752. range={null}
  753. selectedIndex={8}
  754. onRangeChange={onRangeChange}
  755. />,
  756. )
  757. await vi.waitFor(() => {
  758. const domain = view.container.querySelector<HTMLElement>(
  759. '[data-timeline-domain]',
  760. )
  761. expect(domain?.style.getPropertyValue('--trajectory-domain-left')).toBe('-125%')
  762. })
  763. })
  764. it('auto-pans a zoomed viewport while a range drag pushes against an edge', () => {
  765. const onRangeChange = vi.fn()
  766. const view = render(
  767. <TrajectoryTimeline
  768. turns={longTurns}
  769. mode="sequence"
  770. range={null}
  771. onRangeChange={onRangeChange}
  772. />,
  773. )
  774. const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
  775. vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({
  776. x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72,
  777. toJSON: () => ({}),
  778. })
  779. fireEvent.wheel(plot, { clientX: 50, deltaY: -1_000 })
  780. fireEvent.pointerDown(plot, { button: 0, clientX: 50, pointerId: 1 })
  781. for (let index = 0; index < 24; index++) {
  782. fireEvent.pointerMove(plot, { clientX: 99, pointerId: 1 })
  783. }
  784. const draftSelection = view.container.querySelectorAll<HTMLElement>(
  785. '[data-dragging="true"]',
  786. )
  787. expect(draftSelection).toHaveLength(2)
  788. for (const overlay of draftSelection) {
  789. expect(Number.parseFloat(
  790. overlay.style.getPropertyValue('--trajectory-selection-left'),
  791. )).toBeLessThan(0)
  792. }
  793. fireEvent.pointerUp(plot, { clientX: 99, pointerId: 1 })
  794. const selectedRange = onRangeChange.mock.calls.at(-1)?.[0] as
  795. | { start: number; end: number }
  796. | undefined
  797. const fullRange = deriveTrajectoryTimeline(longTurns)
  798. expect(selectedRange).toBeDefined()
  799. expect(fullRange).not.toBeNull()
  800. expect((selectedRange?.end ?? 0) - (selectedRange?.start ?? 0)).toBeGreaterThan(4)
  801. expect(selectedRange?.start).toBeGreaterThanOrEqual(fullRange?.start ?? 0)
  802. expect(selectedRange?.end).toBeLessThanOrEqual(fullRange?.end ?? 0)
  803. })
  804. it('uses equal-width operation slots and stable semantic lanes', () => {
  805. expect(deriveTrajectoryTimeline(turns)).toEqual({
  806. start: 0,
  807. end: 3,
  808. spans: [
  809. {
  810. index: 1, isError: false, kind: 'message', label: 'assistant',
  811. lane: 1, start: 0, end: 1,
  812. },
  813. {
  814. index: 2, isError: false, kind: 'tool', label: 'bash',
  815. lane: 2, start: 1, end: 2,
  816. },
  817. {
  818. index: 3, isError: false, kind: 'user', label: 'unknown',
  819. lane: 0, start: 2, end: 3,
  820. },
  821. ],
  822. turnBoundaries: [{ turn: 1, time: 0 }],
  823. })
  824. })
  825. it('marks error records directly on timeline spans', () => {
  826. const errorTurns = [{
  827. turn: 1,
  828. groups: [{
  829. title: 'Step 1',
  830. cells: [{
  831. index: 1,
  832. kind: 'tool' as const,
  833. text: 'failed tool',
  834. timeSeconds: 0.1,
  835. isError: true,
  836. }],
  837. }],
  838. }] satisfies readonly TrajectoryTurnModel[]
  839. const view = render(
  840. <TrajectoryTimeline
  841. turns={errorTurns}
  842. mode="sequence"
  843. range={null}
  844. onRangeChange={() => {}}
  845. />,
  846. )
  847. expect(view.container.querySelector(
  848. '[data-timeline-span="tool"][data-error="true"]',
  849. )).toBeTruthy()
  850. })
  851. it('ignores durations and idle gaps while retaining turn boundaries', () => {
  852. const separatedTurns = [
  853. {
  854. turn: 1,
  855. groups: [{
  856. title: 'Step 1',
  857. cells: [
  858. { index: 1, kind: 'message', text: 'first', startedAt: 1_000, timeSeconds: 1 },
  859. { index: 2, kind: 'tool', text: 'within-turn gap', startedAt: 4_000, timeSeconds: 1 },
  860. ],
  861. }],
  862. },
  863. {
  864. turn: 2,
  865. groups: [{
  866. title: 'Step 1',
  867. cells: [
  868. { index: 3, kind: 'message', text: 'after user idle', startedAt: 40_000, timeSeconds: 1 },
  869. ],
  870. }],
  871. },
  872. ] satisfies readonly TrajectoryTurnModel[]
  873. expect(deriveTrajectoryTimeline(separatedTurns)).toMatchObject({
  874. start: 0,
  875. end: 3,
  876. spans: [
  877. { index: 1, start: 0, end: 1 },
  878. { index: 2, start: 1, end: 2 },
  879. { index: 3, start: 2, end: 3 },
  880. ],
  881. turnBoundaries: [
  882. { turn: 1, time: 0 },
  883. { turn: 2, time: 2 },
  884. ],
  885. })
  886. })
  887. it('compresses every idle gap in duration mode while actual mode retains wall time', () => {
  888. const separatedTurns = [
  889. {
  890. turn: 1,
  891. groups: [{
  892. title: 'Step 1',
  893. cells: [
  894. { index: 1, kind: 'message', text: 'first', startedAt: 1_000, timeSeconds: 1 },
  895. { index: 2, kind: 'tool', text: 'within-turn gap', startedAt: 4_000, timeSeconds: 1 },
  896. ],
  897. }],
  898. },
  899. {
  900. turn: 2,
  901. groups: [{
  902. title: 'Step 1',
  903. cells: [
  904. { index: 3, kind: 'message', text: 'after user idle', startedAt: 40_000, timeSeconds: 1 },
  905. ],
  906. }],
  907. },
  908. ] satisfies readonly TrajectoryTurnModel[]
  909. expect(deriveTrajectoryTimeline(separatedTurns, 'duration')).toMatchObject({
  910. start: 1_000,
  911. end: 4_000,
  912. spans: [
  913. { index: 1, start: 1_000, end: 2_000 },
  914. { index: 2, start: 2_000, end: 3_000 },
  915. { index: 3, start: 3_000, end: 4_000 },
  916. ],
  917. turnBoundaries: [
  918. { turn: 1, time: 1_000 },
  919. { turn: 2, time: 3_000 },
  920. ],
  921. })
  922. expect(deriveTrajectoryTimeline(separatedTurns, 'actual')).toMatchObject({
  923. start: 1_000,
  924. end: 41_000,
  925. spans: [
  926. { index: 1, start: 1_000, end: 2_000 },
  927. { index: 2, start: 4_000, end: 5_000 },
  928. { index: 3, start: 40_000, end: 41_000 },
  929. ],
  930. })
  931. })
  932. it('projects between-turn compaction without inventing a turn boundary', () => {
  933. const withStandaloneCompaction = [
  934. {
  935. turn: 1,
  936. groups: [{
  937. title: 'Step 1',
  938. cells: [{ index: 1, kind: 'message', text: 'before', timeSeconds: 0 }],
  939. }],
  940. },
  941. {
  942. turn: null,
  943. groups: [{
  944. title: 'Compaction 3',
  945. cells: [{ index: 2, kind: 'compacted', text: 'summary', timeSeconds: 0 }],
  946. }],
  947. },
  948. {
  949. turn: 2,
  950. groups: [{
  951. title: 'Step 1',
  952. cells: [{ index: 3, kind: 'message', text: 'after', timeSeconds: 0 }],
  953. }],
  954. },
  955. ] satisfies readonly TrajectoryTurnModel[]
  956. expect(deriveTrajectoryTimeline(withStandaloneCompaction)).toMatchObject({
  957. spans: [
  958. { index: 1, start: 0, end: 1 },
  959. { index: 2, start: 1, end: 2 },
  960. { index: 3, start: 2, end: 3 },
  961. ],
  962. turnBoundaries: [
  963. { turn: 1, time: 0 },
  964. { turn: 2, time: 2 },
  965. ],
  966. })
  967. })
  968. it('empty inputs produce no model and the standalone view reports its empty form', () => {
  969. expect(deriveTrajectoryTimeline([])).toBeNull()
  970. render(createElement(
  971. TrajectoryView,
  972. {
  973. ...standaloneProps([]),
  974. ...standaloneHistory(historySnapshot([])),
  975. ...standaloneDuration(),
  976. },
  977. ))
  978. expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy()
  979. expect(screen.queryByRole('row')).toBeNull()
  980. })
  981. })
  982. describe('TrajectoryView branches', () => {
  983. it('persists the duration preference through the runtime snapshot-store seam', () => {
  984. const firstDuration = createTrajectoryDurationStore()
  985. const commonProps = {
  986. ...standaloneProps(NODES),
  987. ...standaloneHistory(historySnapshot(NODES)),
  988. }
  989. const first = render(
  990. <TrajectoryView
  991. {...commonProps}
  992. useDuration={bindSnapshotSelector(firstDuration)}
  993. setActualDuration={(value) => { firstDuration.set(value) }}
  994. />,
  995. )
  996. const duration = screen.getByRole('button', { name: 'Use actual duration' })
  997. expect(duration.getAttribute('aria-pressed')).toBe('false')
  998. fireEvent.click(duration)
  999. expect(localStorage.getItem('dsh.trajectory.duration')).toBe('true')
  1000. first.unmount()
  1001. const restoredDuration = createTrajectoryDurationStore()
  1002. render(
  1003. <TrajectoryView
  1004. {...commonProps}
  1005. useDuration={bindSnapshotSelector(restoredDuration)}
  1006. setActualDuration={(value) => { restoredDuration.set(value) }}
  1007. />,
  1008. )
  1009. expect(screen.getByRole('button', { name: 'Use actual duration' }).getAttribute('aria-pressed'))
  1010. .toBe('true')
  1011. })
  1012. it('renders only the selected rewind branch while retaining session-global requests', () => {
  1013. const retained = {
  1014. kind: 'user',
  1015. seq: 1,
  1016. time: 1_000,
  1017. content: [{ type: 'text', text: 'retained user' }],
  1018. source: null,
  1019. } as unknown as ConversationSnapshot['nodes'][number]
  1020. const abandoned = {
  1021. kind: 'assistant',
  1022. seq: 3,
  1023. time: 3_000,
  1024. turn: 1,
  1025. step: 1,
  1026. blocks: [{ kind: 'text', text: 'abandoned response' }],
  1027. } as unknown as ConversationSnapshot['nodes'][number]
  1028. const current = {
  1029. kind: 'assistant',
  1030. seq: 5,
  1031. time: 5_000,
  1032. turn: 2,
  1033. step: 1,
  1034. blocks: [{ kind: 'text', text: 'current response' }],
  1035. } as unknown as ConversationSnapshot['nodes'][number]
  1036. const request = (startSeq: number, turn: number): RequestView => ({
  1037. purpose: 'assistant',
  1038. startSeq,
  1039. turn,
  1040. step: 1,
  1041. startedAt: startSeq * 1_000,
  1042. completedAt: startSeq * 1_000 + 100,
  1043. status: 'complete',
  1044. })
  1045. const store = createSnapshotStore(historySnapshot(
  1046. [retained, abandoned, current],
  1047. {
  1048. eventNodes: [retained, abandoned, current],
  1049. contexts: [
  1050. { id: 0, nodes: [retained, abandoned] },
  1051. {
  1052. id: 1,
  1053. parentId: 0,
  1054. origin: 'rewind' as const,
  1055. originSeq: 4,
  1056. nodes: [retained, current],
  1057. },
  1058. ],
  1059. requests: [request(2, 1), request(4, 2)],
  1060. callSchemas: new Map(),
  1061. },
  1062. ))
  1063. const view = render(
  1064. <TrajectoryView
  1065. {...standaloneProps([])}
  1066. {...standaloneDuration()}
  1067. useHistory={bindSnapshotSelector(store)}
  1068. loadHistoryTail={vi.fn(() => Promise.resolve())}
  1069. loadOlderHistory={vi.fn(() => Promise.resolve(false))}
  1070. />,
  1071. )
  1072. expect(screen.queryByText('abandoned response')).toBeNull()
  1073. expect(screen.getByText('current response')).toBeTruthy()
  1074. expect(screen.getByRole('row', { name: /Request 2, ASSISTANT/ })).toBeTruthy()
  1075. expect(view.container.querySelectorAll('[data-request-only="true"]')).toHaveLength(0)
  1076. })
  1077. it('does not remount the ledger when prepending shifts a rewind generation id', () => {
  1078. const current = {
  1079. kind: 'assistant',
  1080. seq: 5,
  1081. time: 5_000,
  1082. turn: 2,
  1083. step: 1,
  1084. blocks: [{ kind: 'text', text: 'stable rewind response' }],
  1085. } as unknown as ConversationSnapshot['nodes'][number]
  1086. const snapshot = (id: number) => historySnapshot([current], {
  1087. contexts: [{
  1088. id,
  1089. origin: 'rewind' as const,
  1090. originSeq: 4,
  1091. nodes: [current],
  1092. }],
  1093. })
  1094. const store = createSnapshotStore(snapshot(1))
  1095. render(
  1096. <TrajectoryView
  1097. {...standaloneProps([])}
  1098. {...standaloneDuration()}
  1099. useHistory={bindSnapshotSelector(store)}
  1100. loadHistoryTail={vi.fn(() => Promise.resolve())}
  1101. loadOlderHistory={vi.fn(() => Promise.resolve(false))}
  1102. />,
  1103. )
  1104. const row = screen.getByRole('row', { name: /stable rewind response/ })
  1105. fireEvent.click(row)
  1106. expect(row.getAttribute('aria-selected')).toBe('true')
  1107. act(() => { store.set(snapshot(2)) })
  1108. expect(screen.getByRole('row', { name: /stable rewind response/ })
  1109. .getAttribute('aria-selected')).toBe('true')
  1110. })
  1111. it('keeps ledger and timeline selection on the same event after prepend', () => {
  1112. const older = {
  1113. kind: 'user', seq: 1, time: 1_000,
  1114. content: [{ type: 'text', text: 'older prompt' }], source: null,
  1115. } as unknown as ConversationSnapshot['nodes'][number]
  1116. const current = {
  1117. kind: 'assistant', seq: 100, time: 5_000, turn: 2, step: 1,
  1118. blocks: [{ kind: 'text', text: 'selected current response' }],
  1119. } as unknown as ConversationSnapshot['nodes'][number]
  1120. const store = createSnapshotStore(historySnapshot([current]))
  1121. const view = render(
  1122. <TrajectoryView
  1123. {...standaloneProps([])}
  1124. {...standaloneDuration()}
  1125. useHistory={bindSnapshotSelector(store)}
  1126. loadHistoryTail={vi.fn(() => Promise.resolve())}
  1127. loadOlderHistory={vi.fn(() => Promise.resolve(false))}
  1128. />,
  1129. )
  1130. fireEvent.click(screen.getByRole('row', { name: /selected current response/ }))
  1131. act(() => { store.set(historySnapshot([older, current])) })
  1132. const row = screen.getByRole('row', { name: /selected current response/ })
  1133. expect(row.getAttribute('aria-selected')).toBe('true')
  1134. const currentIndex = row.getAttribute('data-record-index')
  1135. expect(view.container.querySelector(
  1136. `[data-timeline-record-index="${currentIndex}"][data-current="true"]`,
  1137. )).toBeTruthy()
  1138. })
  1139. it('retains cancellation-frozen assistant and tool nodes outside raw contexts', () => {
  1140. const retained = {
  1141. kind: 'user', seq: 1, time: 1_000,
  1142. content: [{ type: 'text', text: 'stop the task' }], source: null,
  1143. } as unknown as ConversationSnapshot['nodes'][number]
  1144. const interruptedAssistant = {
  1145. kind: 'assistant', seq: 2.1, time: 2_000, turn: 1, step: 1,
  1146. blocks: [{ kind: 'text', text: 'partial response retained' }],
  1147. interrupted: true,
  1148. } as unknown as ConversationSnapshot['nodes'][number]
  1149. const interruptedTool = {
  1150. kind: 'tool-result', seq: 2.2, time: 2_100, callId: 'slow-call',
  1151. call: { name: 'bash', argsRaw: '{"command":"sleep 30"}' }, callTime: 1_900,
  1152. content: [], isError: true,
  1153. error: { name: 'Interrupted', code: 'interrupted' },
  1154. callView: null, resultView: null,
  1155. } as unknown as ConversationSnapshot['nodes'][number]
  1156. const store = createSnapshotStore(historySnapshot(
  1157. [retained],
  1158. {
  1159. eventNodes: [retained],
  1160. contexts: [{ id: 0, nodes: [retained] }],
  1161. requests: [],
  1162. callSchemas: new Map(),
  1163. interruptedNodes: [interruptedAssistant, interruptedTool],
  1164. },
  1165. ))
  1166. render(
  1167. <TrajectoryView
  1168. {...standaloneProps([])}
  1169. {...standaloneDuration()}
  1170. useHistory={bindSnapshotSelector(store)}
  1171. loadHistoryTail={vi.fn(() => Promise.resolve())}
  1172. loadOlderHistory={vi.fn(() => Promise.resolve(false))}
  1173. />,
  1174. )
  1175. expect(screen.getByText('partial response retained')).toBeTruthy()
  1176. expect(screen.getByRole('row', { name: /TOOL, bash/ })).toBeTruthy()
  1177. })
  1178. })
  1179. describe('node half', () => {
  1180. it('node apply is an intentional no-op (loader-managed lifecycle only)', () => {
  1181. expect(() => { nodeApply() }).not.toThrow()
  1182. })
  1183. })