table.spec.tsx 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773
  1. // @vitest-environment jsdom
  2. /** Trajectory ledger selection, details, status, and fold behavior. */
  3. import { afterEach, describe, expect, it, vi } from 'vitest'
  4. import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
  5. import { TrajectoryTable } from '../src/client/TrajectoryTable.tsx'
  6. import type { TrajectoryTurnModel } from '../src/client/layout.ts'
  7. afterEach(() => {
  8. cleanup()
  9. vi.restoreAllMocks()
  10. Reflect.deleteProperty(HTMLElement.prototype, 'scrollTo')
  11. })
  12. const TURNS: readonly TrajectoryTurnModel[] = [{
  13. turn: 1,
  14. groups: [{
  15. title: 'Step 1',
  16. description: '1.5s bash×2',
  17. cells: [
  18. {
  19. index: 1,
  20. kind: 'message',
  21. text: 'Checking files',
  22. outputDetail: 'Checking files',
  23. input: 10,
  24. output: 20,
  25. think: 5,
  26. timeSeconds: 1.5,
  27. assistantMetrics: {
  28. timingRecorded: true,
  29. stepStartTime: 1_000,
  30. firstTokenTime: 1_500,
  31. completedTime: 2_500,
  32. usageProvided: true,
  33. outputTokens: 20,
  34. },
  35. },
  36. {
  37. index: 2,
  38. kind: 'tool',
  39. text: 'bash · {"command":"pwd"}',
  40. inputDetail: '{"command":"pwd"}',
  41. timeSeconds: null,
  42. },
  43. {
  44. index: 3,
  45. kind: 'tool',
  46. text: 'bash · {"command":"false"}',
  47. inputDetail: '{"command":"false"}',
  48. outputDetail: 'ToolError: non_zero_exit',
  49. result: 'non_zero_exit',
  50. isError: true,
  51. timeSeconds: 0.2,
  52. },
  53. ],
  54. }],
  55. }]
  56. const FOLD_PROPS = {
  57. collapsedTurns: new Set<number>(),
  58. onToggleTurn: () => {},
  59. collapsedAssistants: new Set<string>(),
  60. onToggleAssistant: () => {},
  61. }
  62. describe('TrajectoryTable', () => {
  63. it('shows a muted placeholder for an assistant response containing only tool calls', () => {
  64. const turns: readonly TrajectoryTurnModel[] = [{
  65. turn: 1,
  66. groups: [{
  67. title: 'Step 1',
  68. cells: [{
  69. index: 1,
  70. kind: 'message',
  71. text: 'Tool call only',
  72. sourceBlocks: [{
  73. type: 'tool-call', content: '{}', callId: 'call-1', toolName: 'read',
  74. }],
  75. timeSeconds: 1,
  76. }],
  77. }],
  78. }]
  79. render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
  80. expect(screen.getByText('(tool call only)')).toBeTruthy()
  81. })
  82. it('shows assistant timing facts after keyboard selection', () => {
  83. render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
  84. fireEvent.keyDown(screen.getByRole('row', { name: /ASSISTANT/ }), { key: 'Enter' })
  85. fireEvent.click(screen.getByRole('button', { name: 'Request Timing' }))
  86. expect(screen.getByText('500 ms')).toBeTruthy()
  87. expect(screen.getByText('1.00 s')).toBeTruthy()
  88. expect(screen.getByText('20.0 tok/s')).toBeTruthy()
  89. })
  90. it('shows a tool record Duration as exact milliseconds', () => {
  91. const turns: readonly TrajectoryTurnModel[] = [{
  92. turn: 1,
  93. groups: [{
  94. title: 'Step 1',
  95. cells: [{
  96. index: 1,
  97. kind: 'tool',
  98. text: 'bash · {"command":"pwd"}',
  99. inputDetail: '{"command":"pwd"}',
  100. timeSeconds: 1.5,
  101. }],
  102. }],
  103. }]
  104. render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
  105. fireEvent.click(screen.getByRole('row', { name: /TOOL/ }))
  106. expect(screen.getByText('1,500 ms', { selector: 'dd' })).toBeTruthy()
  107. })
  108. it('breaks output tokens into labeled reasoning and content rows', () => {
  109. render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
  110. fireEvent.click(screen.getByRole('row', { name: /ASSISTANT/ }))
  111. expect(screen.getByText('Tokens')).toBeTruthy()
  112. expect(screen.getByText('20 tok')).toBeTruthy()
  113. expect(screen.getByText('Reasoning')).toBeTruthy()
  114. expect(screen.getByText('5 tok')).toBeTruthy()
  115. expect(screen.getByText('Content')).toBeTruthy()
  116. expect(screen.getByText('15 tok')).toBeTruthy()
  117. })
  118. it('marks Summary scroll regions for interaction-only scrollbar thumbs', () => {
  119. render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
  120. fireEvent.click(screen.getByRole('row', { name: /ASSISTANT/ }))
  121. const panel = screen.getByRole('tabpanel')
  122. expect(panel.querySelectorAll('[data-summary-scroll-region]').length).toBeGreaterThan(1)
  123. fireEvent.click(screen.getByRole('tab', { name: 'Preview' }))
  124. expect(panel.querySelector('[data-summary-scroll-region]')).toBeNull()
  125. })
  126. it('keeps long thinking collapsed until the user asks to render it', () => {
  127. const thinking = 'private chain '.repeat(1_000)
  128. const turns: readonly TrajectoryTurnModel[] = [{
  129. turn: 1,
  130. groups: [{
  131. title: 'Step 1',
  132. cells: [{
  133. index: 1,
  134. kind: 'message',
  135. text: 'private chain…',
  136. thinkingDetail: thinking,
  137. timeSeconds: 1,
  138. }],
  139. }],
  140. }]
  141. render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
  142. fireEvent.click(screen.getByRole('row', { name: /ASSISTANT/ }))
  143. const toggle = screen.getByRole('button', { name: 'Thinking' })
  144. expect(toggle.getAttribute('aria-expanded')).toBe('false')
  145. expect(screen.queryByText(thinking)).toBeNull()
  146. fireEvent.click(toggle)
  147. expect(screen.getByRole('button', { name: 'Thinking' })).toBe(toggle)
  148. expect(toggle.getAttribute('aria-expanded')).toBe('true')
  149. expect(toggle.parentElement?.textContent?.length).toBeGreaterThan(thinking.length)
  150. })
  151. it('keeps raw HTML tags in a Markdown-derived context preview', () => {
  152. const html = [
  153. '<background-task-complete id="trajectory-ui-watch">',
  154. 'Command: pnpm test',
  155. 'Exit code: 0',
  156. '</background-task-complete>',
  157. ].join('\n')
  158. const turns: readonly TrajectoryTurnModel[] = [{
  159. turn: 1,
  160. groups: [{
  161. title: 'Message',
  162. cells: [{
  163. index: 1,
  164. kind: 'context',
  165. text: '',
  166. inputDetail: html,
  167. timeSeconds: 0,
  168. }],
  169. }],
  170. }]
  171. render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
  172. expect(screen.getByText(
  173. '<background-task-complete id="trajectory-ui-watch"> Command: pnpm test Exit code: 0 </background-task-complete>',
  174. )).toBeTruthy()
  175. })
  176. it('clears the selected row when ledger whitespace is clicked', () => {
  177. const onClearSelection = vi.fn()
  178. render(
  179. <TrajectoryTable
  180. turns={TURNS}
  181. {...FOLD_PROPS}
  182. onClearSelection={onClearSelection}
  183. />,
  184. )
  185. const row = screen.getByRole('row', { name: /ASSISTANT/ })
  186. fireEvent.click(row)
  187. expect(row.getAttribute('aria-selected')).toBe('true')
  188. expect(screen.getByRole('complementary', { name: 'Event details' })).toBeTruthy()
  189. const tablePane = screen.getByRole('table').parentElement
  190. expect(tablePane).not.toBeNull()
  191. fireEvent.click(tablePane as HTMLElement)
  192. expect(row.getAttribute('aria-selected')).toBe('false')
  193. expect(screen.queryByRole('complementary', { name: 'Event details' })).toBeNull()
  194. expect(onClearSelection).toHaveBeenCalledOnce()
  195. })
  196. it('keeps the selected record when older rows shift projection indexes', () => {
  197. const tail = (index: number): TrajectoryTurnModel => ({
  198. turn: 2,
  199. groups: [{
  200. title: 'Step 1',
  201. cells: [{
  202. index,
  203. kind: 'message',
  204. sourceSeq: 100,
  205. text: 'selected tail response',
  206. outputDetail: 'selected tail response detail',
  207. timeSeconds: 1,
  208. }],
  209. }],
  210. })
  211. const view = render(
  212. <TrajectoryTable turns={[tail(1)]} {...FOLD_PROPS} />,
  213. )
  214. fireEvent.click(screen.getByRole('row', { name: /selected tail response/ }))
  215. view.rerender(
  216. <TrajectoryTable
  217. turns={[{
  218. turn: 1,
  219. groups: [{
  220. title: 'Message',
  221. cells: [{
  222. index: 1,
  223. kind: 'user',
  224. sourceSeq: 1,
  225. text: 'older prompt',
  226. timeSeconds: 0,
  227. }],
  228. }],
  229. }, tail(2)]}
  230. {...FOLD_PROPS}
  231. />,
  232. )
  233. expect(screen.getByRole('row', { name: /selected tail response/ })
  234. .getAttribute('aria-selected')).toBe('true')
  235. expect(screen.getByText('selected tail response detail')).toBeTruthy()
  236. })
  237. it('keeps a selected request when prepending changes its display number', () => {
  238. const tail = (index: number): TrajectoryTurnModel => ({
  239. turn: 2,
  240. groups: [{
  241. title: 'Step 1',
  242. cells: [{
  243. index,
  244. kind: 'message',
  245. sourceSeq: 100,
  246. text: 'tail response',
  247. timeSeconds: 1,
  248. }],
  249. }],
  250. })
  251. const view = render(
  252. <TrajectoryTable turns={[tail(1)]} {...FOLD_PROPS} />,
  253. )
  254. fireEvent.click(screen.getByRole('button', { name: 'Request #1' }))
  255. view.rerender(
  256. <TrajectoryTable
  257. turns={[{
  258. turn: 1,
  259. groups: [{
  260. title: 'Step 1',
  261. cells: [{
  262. index: 1,
  263. kind: 'message',
  264. sourceSeq: 1,
  265. text: 'older response',
  266. timeSeconds: 1,
  267. }],
  268. }],
  269. }, tail(2)]}
  270. {...FOLD_PROPS}
  271. />,
  272. )
  273. expect(screen.getByRole('button', { name: 'Request #2' })
  274. .getAttribute('aria-pressed')).toBe('true')
  275. expect(screen.getByText('Request #2')).toBeTruthy()
  276. })
  277. it('follows appended records only while the ledger is already at the bottom', () => {
  278. const view = render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
  279. const tablePane = screen.getByRole('table').parentElement as HTMLElement
  280. let scrollHeight = 200
  281. Object.defineProperties(tablePane, {
  282. clientHeight: { configurable: true, get: () => 100 },
  283. scrollHeight: { configurable: true, get: () => scrollHeight },
  284. })
  285. tablePane.scrollTop = 100
  286. fireEvent.scroll(tablePane)
  287. scrollHeight = 260
  288. view.rerender(
  289. <TrajectoryTable
  290. turns={[...TURNS, {
  291. turn: 2,
  292. groups: [{
  293. title: 'Step 1',
  294. cells: [{ index: 4, kind: 'message', text: 'new reply', timeSeconds: 0.1 }],
  295. }],
  296. }]}
  297. {...FOLD_PROPS}
  298. />,
  299. )
  300. expect(tablePane.scrollTop).toBe(260)
  301. tablePane.scrollTop = 20
  302. fireEvent.scroll(tablePane)
  303. scrollHeight = 320
  304. view.rerender(
  305. <TrajectoryTable
  306. turns={[...TURNS, {
  307. turn: 2,
  308. groups: [{
  309. title: 'Step 1',
  310. cells: [
  311. { index: 4, kind: 'message', text: 'new reply', timeSeconds: 0.1 },
  312. { index: 5, kind: 'tool', text: 'new tool', timeSeconds: 0.1 },
  313. ],
  314. }],
  315. }]}
  316. {...FOLD_PROPS}
  317. />,
  318. )
  319. expect(tablePane.scrollTop).toBe(20)
  320. })
  321. it('preserves the visible anchor when the last older page disables virtualization', async () => {
  322. let resolveOlder: ((advanced: boolean) => void) | undefined
  323. const older = new Promise<boolean>((resolve) => { resolveOlder = resolve })
  324. const onLoadOlder = vi.fn(() => older)
  325. const view = render(
  326. <TrajectoryTable
  327. turns={TURNS}
  328. {...FOLD_PROPS}
  329. historyStartSeq={1}
  330. hasOlderRecords
  331. onLoadOlder={onLoadOlder}
  332. />,
  333. )
  334. const tablePane = screen.getByRole('table').parentElement as HTMLElement
  335. let scrollHeight = 200
  336. Object.defineProperties(tablePane, {
  337. clientHeight: { configurable: true, get: () => 100 },
  338. scrollHeight: { configurable: true, get: () => scrollHeight },
  339. })
  340. tablePane.scrollTop = 0
  341. fireEvent.scroll(tablePane)
  342. fireEvent.scroll(tablePane)
  343. await waitFor(() => { expect(onLoadOlder).toHaveBeenCalledOnce() })
  344. expect(screen.getByRole('status').textContent).toContain('Loading earlier history…')
  345. resolveOlder?.(true)
  346. await waitFor(() => { expect(screen.queryByRole('status')).toBeNull() })
  347. scrollHeight = 260
  348. view.rerender(
  349. <TrajectoryTable
  350. turns={[{
  351. turn: 0,
  352. groups: [{
  353. title: 'Step 1',
  354. cells: [{ index: 0, kind: 'user', text: 'older prompt', timeSeconds: 0 }],
  355. }],
  356. }, ...TURNS]}
  357. {...FOLD_PROPS}
  358. historyStartSeq={0}
  359. onLoadOlder={onLoadOlder}
  360. />,
  361. )
  362. expect(tablePane.scrollTop).toBe(60)
  363. })
  364. it('covers the ledger while the initial tail is loading', () => {
  365. const view = render(
  366. <TrajectoryTable turns={TURNS} {...FOLD_PROPS} historyLoading />,
  367. )
  368. expect(screen.getByRole('status').textContent).toContain('Loading trajectory…')
  369. expect(screen.getByRole('table').getAttribute('data-scroll-ready')).toBeNull()
  370. view.rerender(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
  371. expect(screen.queryByRole('status')).toBeNull()
  372. expect(screen.getByRole('table').getAttribute('data-scroll-ready')).toBe('true')
  373. })
  374. it('keeps a paged tail virtualized before its loaded window crosses the row threshold', async () => {
  375. vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(600)
  376. Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
  377. configurable: true,
  378. value: vi.fn(),
  379. })
  380. const view = render(
  381. <TrajectoryTable turns={TURNS} {...FOLD_PROPS} hasOlderRecords />,
  382. )
  383. await waitFor(() => {
  384. expect(view.container.querySelector('tr[data-virtual-position]')).toBeTruthy()
  385. })
  386. })
  387. it('mounts only the visible window for a long ledger', async () => {
  388. vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(600)
  389. const scrollTo = vi.fn()
  390. Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
  391. configurable: true,
  392. value: scrollTo,
  393. })
  394. const cells = Array.from({ length: 500 }, (_, index) => ({
  395. index: index + 1,
  396. kind: 'context' as const,
  397. text: `Context ${index + 1}`,
  398. timeSeconds: 0,
  399. }))
  400. const turns: readonly TrajectoryTurnModel[] = [{
  401. turn: 1,
  402. groups: [{ title: 'Context', cells }],
  403. }]
  404. const view = render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
  405. await waitFor(() => {
  406. expect(view.container.querySelectorAll('tr[data-virtual-position]').length)
  407. .toBeGreaterThan(0)
  408. })
  409. expect(view.container.querySelectorAll('tr[data-virtual-position]').length)
  410. .toBeLessThan(cells.length)
  411. expect(screen.getByRole('table').getAttribute('aria-rowcount')).toBe('500')
  412. expect(view.container.querySelector('tr[data-trajectory-row-key]')
  413. ?.getAttribute('aria-rowindex')).toBe('1')
  414. expect(scrollTo).toHaveBeenCalled()
  415. expect(view.container.querySelector('tr[data-virtual-spacer="bottom"]')).toBeTruthy()
  416. expect(screen.getByText('Context 1')).toBeTruthy()
  417. expect(screen.queryByText('Context 500')).toBeNull()
  418. const tablePane = screen.getByRole('table').parentElement as HTMLElement
  419. tablePane.scrollTop = 9_000
  420. fireEvent.scroll(tablePane)
  421. await waitFor(() => {
  422. expect(Number(view.container.querySelector(
  423. 'tr[data-virtual-position]',
  424. )?.getAttribute('data-virtual-position'))).toBeGreaterThan(0)
  425. })
  426. expect(view.container.querySelector('tr[data-virtual-spacer="top"]')).toBeTruthy()
  427. expect(screen.queryByText('Context 1')).toBeNull()
  428. })
  429. it('does not re-scroll a virtual ledger when streaming only changes row content', async () => {
  430. vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(600)
  431. const scrollTo = vi.fn()
  432. Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
  433. configurable: true,
  434. value: scrollTo,
  435. })
  436. const cells = Array.from({ length: 500 }, (_, index) => ({
  437. index: index + 1,
  438. kind: 'context' as const,
  439. sourceSeq: index + 1,
  440. text: `Context ${index + 1}`,
  441. timeSeconds: 0,
  442. }))
  443. const turns: readonly TrajectoryTurnModel[] = [{
  444. turn: 1,
  445. groups: [{ title: 'Context', cells }],
  446. }]
  447. const view = render(
  448. <TrajectoryTable
  449. turns={turns}
  450. {...FOLD_PROPS}
  451. />,
  452. )
  453. await waitFor(() => {
  454. expect(view.container.querySelector('tr[data-virtual-position]')).toBeTruthy()
  455. })
  456. scrollTo.mockClear()
  457. view.rerender(
  458. <TrajectoryTable
  459. turns={turns}
  460. streamingCells={[{ ...cells[0]!, text: 'Context 1 streaming update' }]}
  461. {...FOLD_PROPS}
  462. />,
  463. )
  464. expect(scrollTo).not.toHaveBeenCalled()
  465. expect(screen.getByText('Context 1 streaming update')).toBeTruthy()
  466. })
  467. it('keeps the virtual tail reachable with collapsed-summary row heights', async () => {
  468. vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(600)
  469. Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
  470. configurable: true,
  471. value: vi.fn(),
  472. })
  473. const turns: readonly TrajectoryTurnModel[] = Array.from(
  474. { length: 101 },
  475. (_, index) => ({
  476. turn: index + 1,
  477. groups: [{
  478. title: 'Step 1',
  479. cells: [
  480. {
  481. index: index * 2 + 1,
  482. kind: 'message' as const,
  483. sourceSeq: index * 2 + 1,
  484. text: `Message ${index + 1}`,
  485. timeSeconds: 1,
  486. },
  487. {
  488. index: index * 2 + 2,
  489. kind: 'tool' as const,
  490. callId: `call-${index + 1}`,
  491. text: `Tool ${index + 1}`,
  492. timeSeconds: 1,
  493. },
  494. ],
  495. }],
  496. }),
  497. )
  498. const collapsedTurns = new Set(turns.flatMap(turn =>
  499. turn.turn === null ? [] : [turn.turn]))
  500. const view = render(
  501. <TrajectoryTable
  502. turns={turns}
  503. {...FOLD_PROPS}
  504. collapsedTurns={collapsedTurns}
  505. />,
  506. )
  507. const tablePane = screen.getByRole('table').parentElement as HTMLElement
  508. tablePane.scrollTop = 5_000
  509. fireEvent.scroll(tablePane)
  510. await waitFor(() => {
  511. expect(view.container.querySelector('tr[data-virtual-position="201"]')).toBeTruthy()
  512. })
  513. })
  514. it('keeps running and failure semantics distinct from record roles', () => {
  515. const view = render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
  516. expect(view.container.querySelector('tr[data-kind="tool"][data-running="true"]')).toBeTruthy()
  517. expect(view.container.querySelector('tr[data-kind="tool"][data-error="true"]')).toBeTruthy()
  518. fireEvent.click(screen.getByRole('row', { name: /TOOL, bash \{"command":"pwd"\}/ }))
  519. expect(screen.getByText('Pending')).toBeTruthy()
  520. fireEvent.click(screen.getByRole('row', { name: /TOOL, bash \{"command":"false"\}/ }))
  521. expect(screen.getByText('Failed')).toBeTruthy()
  522. expect(screen.getByText('Failed').className).toContain('error')
  523. fireEvent.click(screen.getByRole('tab', { name: 'Result' }))
  524. const errorResult = screen.getByText('ToolError: non_zero_exit')
  525. expect(errorResult.closest('[class*="errorPayload"]')).toBeTruthy()
  526. })
  527. it('marks failed requests and lays coincident request markers left to right', () => {
  528. const turns: readonly TrajectoryTurnModel[] = [
  529. {
  530. turn: 1,
  531. groups: [{
  532. title: 'Step 1',
  533. cells: [{
  534. index: 1,
  535. kind: 'message',
  536. text: '',
  537. requestOnly: true,
  538. isError: true,
  539. timeSeconds: 0.1,
  540. }],
  541. }],
  542. },
  543. {
  544. turn: 2,
  545. groups: [{
  546. title: 'Step 1',
  547. cells: [{
  548. index: 2,
  549. kind: 'message',
  550. text: '',
  551. requestOnly: true,
  552. isError: true,
  553. timeSeconds: 0.1,
  554. }],
  555. }],
  556. },
  557. {
  558. turn: 3,
  559. groups: [{
  560. title: 'Step 1',
  561. cells: [{
  562. index: 3,
  563. kind: 'message',
  564. text: 'Recovered response',
  565. timeSeconds: 0.1,
  566. }],
  567. }],
  568. },
  569. ]
  570. render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
  571. const failed = screen.getByRole('button', { name: 'Request #1' })
  572. const retry = screen.getByRole('button', { name: 'Request #2' })
  573. const recovered = screen.getByRole('button', { name: 'Request #3' })
  574. expect(failed.getAttribute('data-request-status')).toBe('error')
  575. expect(failed.getAttribute('data-request-run-index')).toBe('0')
  576. expect(failed.style.getPropertyValue('--request-boundary-offset')).toBe('0px')
  577. expect(retry.getAttribute('data-request-run-index')).toBe('1')
  578. expect(retry.style.getPropertyValue('--request-boundary-offset')).toBe('8px')
  579. expect(recovered.getAttribute('data-request-run-index')).toBe('2')
  580. expect(recovered.style.getPropertyValue('--request-boundary-offset')).toBe('16px')
  581. })
  582. it('shows the custom role tooltip only from the responsive icon', () => {
  583. const view = render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
  584. const toolTag = view.container.querySelector<HTMLElement>('[data-role-kind="tool"]')
  585. const toolIcon = toolTag?.querySelector<HTMLElement>('[data-role-icon="wrench"]')
  586. expect(toolTag).not.toBeNull()
  587. expect(toolTag?.getAttribute('title')).toBeNull()
  588. expect(toolIcon).toBeTruthy()
  589. fireEvent.mouseEnter(toolTag as HTMLElement)
  590. expect(screen.queryByRole('tooltip')).toBeNull()
  591. fireEvent.mouseEnter(toolIcon as HTMLElement)
  592. const tooltip = screen.getByRole('tooltip')
  593. expect(tooltip.textContent).toBe('TOOL')
  594. expect(tooltip.getAttribute('data-side')).toBe('right')
  595. fireEvent.mouseLeave(toolIcon as HTMLElement)
  596. expect(screen.queryByRole('tooltip')).toBeNull()
  597. })
  598. it('uses information and compression glyphs for injected and compacted context', () => {
  599. const turns: readonly TrajectoryTurnModel[] = [{
  600. turn: 1,
  601. groups: [{
  602. title: 'Context',
  603. cells: [
  604. { index: 1, kind: 'context', text: 'Workspace context', timeSeconds: 0 },
  605. { index: 2, kind: 'compacted', text: 'Compacted history', timeSeconds: 0 },
  606. ],
  607. }],
  608. }]
  609. const view = render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
  610. expect(view.container.querySelector(
  611. '[data-role-kind="context"] [data-role-icon="information"]',
  612. )).toBeTruthy()
  613. expect(view.container.querySelector(
  614. '[data-role-kind="compacted"] [data-role-icon="compacted"]',
  615. )).toBeTruthy()
  616. })
  617. it('keeps a compact turn label available for narrow layouts', () => {
  618. render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
  619. const turnLabel = screen.getByLabelText('Turn 1')
  620. expect(turnLabel.textContent).toContain('Turn 1')
  621. expect(turnLabel.textContent).toContain('#1')
  622. })
  623. it('renders a single-text JSON tool result as a JSON tree', () => {
  624. const turns: readonly TrajectoryTurnModel[] = [{
  625. turn: 1,
  626. groups: [{
  627. title: 'Step 1',
  628. cells: [{
  629. index: 1,
  630. kind: 'tool',
  631. text: 'read {"path":"result.json"}',
  632. outputDetail: '{"value":1,"nested":{"ok":true}}',
  633. outputBlocks: [{
  634. type: 'text',
  635. content: '{"value":1,"nested":{"ok":true}}',
  636. }],
  637. timeSeconds: 0.1,
  638. }],
  639. }],
  640. }]
  641. render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
  642. fireEvent.click(screen.getByRole('row', { name: /TOOL/ }))
  643. fireEvent.click(screen.getByRole('tab', { name: 'Result' }))
  644. expect(screen.getByRole('tree', { name: 'Result JSON' })).toBeTruthy()
  645. expect(screen.getByText('value:')).toBeTruthy()
  646. })
  647. it('keeps the first row and a compact summary when a turn is collapsed', () => {
  648. render(
  649. <TrajectoryTable
  650. turns={TURNS}
  651. {...FOLD_PROPS}
  652. collapsedTurns={new Set([1])}
  653. />,
  654. )
  655. expect(screen.queryByRole('columnheader')).toBeNull()
  656. expect(screen.getByRole('row', { name: /ASSISTANT/ })).toBeTruthy()
  657. expect(screen.getByRole('row', { name: /Collapsed turn summary/ })).toBeTruthy()
  658. })
  659. const CALL_TURNS: readonly TrajectoryTurnModel[] = [{
  660. turn: 1,
  661. groups: [{
  662. title: 'Step 1',
  663. cells: [{
  664. index: 1,
  665. kind: 'tool',
  666. text: 'bash · {"command":"pwd"}',
  667. inputDetail: '{"command":"pwd"}',
  668. callId: 'call-1',
  669. timeSeconds: 0.1,
  670. }],
  671. }],
  672. }]
  673. it('an inspect target opens the matching record and acknowledges once', () => {
  674. const onInspectApplied = vi.fn()
  675. render(
  676. <TrajectoryTable
  677. turns={CALL_TURNS}
  678. {...FOLD_PROPS}
  679. inspectCallId="call-1"
  680. onInspectApplied={onInspectApplied}
  681. />,
  682. )
  683. expect(screen.getByRole('row', { name: /TOOL/ }).getAttribute('aria-selected')).toBe('true')
  684. expect(screen.getByRole('complementary', { name: 'Event details' })).toBeTruthy()
  685. expect(onInspectApplied).toHaveBeenCalledOnce()
  686. })
  687. it('an unmatched inspect target stays pending without acknowledgement', () => {
  688. const onInspectApplied = vi.fn()
  689. render(
  690. <TrajectoryTable
  691. turns={CALL_TURNS}
  692. {...FOLD_PROPS}
  693. inspectCallId="call-missing"
  694. onInspectApplied={onInspectApplied}
  695. />,
  696. )
  697. expect(screen.getByRole('row', { name: /TOOL/ }).getAttribute('aria-selected')).toBe('false')
  698. expect(onInspectApplied).not.toHaveBeenCalled()
  699. })
  700. })