table.client.spec.tsx 36 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132
  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 type { ComponentProps } from 'react'
  6. import type { RenderMessageImages } from '@deepseek-ai/dsh-client-ui-conversation/client'
  7. import { TrajectoryTable as LocalizedTrajectoryTable } from '../src/client/TrajectoryTable.tsx'
  8. import type { TrajectoryTurnModel } from '../src/client/layout.ts'
  9. import { trajectoryRecordId } from '../src/client/trajectory-record.ts'
  10. import { t, tZh } from './locale.client.ts'
  11. const renderImagesStub: RenderMessageImages = ({ images }) => (
  12. <div data-testid="record-images" data-count={images.length}>
  13. {images.map((image, index) => (
  14. <span key={index} data-attachment-id={image.attachment.attachmentId} />
  15. ))}
  16. </div>
  17. )
  18. function TrajectoryTable(
  19. props: Omit<ComponentProps<typeof LocalizedTrajectoryTable>, 't' | 'renderImages'>
  20. & { renderImages?: RenderMessageImages },
  21. ) {
  22. const inferred: Array<NonNullable<typeof props.requestNumbers>[number] & { firstIndex: number }> = []
  23. for (const turn of props.turns) {
  24. for (const group of turn.groups) {
  25. const step = /^Step (\d+)$/.exec(group.title)?.[1]
  26. const compaction = /^Compaction (\d+)$/.exec(group.title)?.[1]
  27. const firstIndex = group.cells[0]?.index ?? Number.MAX_SAFE_INTEGER
  28. if (compaction !== undefined) {
  29. inferred.push({
  30. turn: turn.turn,
  31. step: 0,
  32. seq: Number(compaction),
  33. group: group.title,
  34. number: 0,
  35. purpose: 'compaction',
  36. firstIndex,
  37. })
  38. } else if (step !== undefined && turn.turn !== null) {
  39. inferred.push({
  40. turn: turn.turn,
  41. step: Number(step),
  42. group: group.title,
  43. number: 0,
  44. firstIndex,
  45. })
  46. }
  47. }
  48. }
  49. const requestNumbers = props.requestNumbers ?? inferred
  50. .sort((left, right) => left.firstIndex - right.firstIndex)
  51. .map(({ firstIndex: _firstIndex, ...request }, index) => ({ ...request, number: index + 1 }))
  52. return (
  53. <LocalizedTrajectoryTable
  54. renderImages={renderImagesStub}
  55. {...props}
  56. requestNumbers={requestNumbers}
  57. t={t}
  58. />
  59. )
  60. }
  61. afterEach(() => {
  62. cleanup()
  63. vi.restoreAllMocks()
  64. Reflect.deleteProperty(HTMLElement.prototype, 'scrollTo')
  65. })
  66. const TURNS: readonly TrajectoryTurnModel[] = [{
  67. turn: 1,
  68. groups: [{
  69. title: 'Step 1',
  70. description: '1.5s bash×2',
  71. cells: [
  72. {
  73. index: 1,
  74. kind: 'message',
  75. text: 'Checking files',
  76. outputDetail: 'Checking files',
  77. input: 10,
  78. output: 20,
  79. think: 5,
  80. timeSeconds: 1.5,
  81. assistantMetrics: {
  82. timingRecorded: true,
  83. stepStartTime: 1_000,
  84. firstTokenTime: 1_500,
  85. completedTime: 2_500,
  86. usageProvided: true,
  87. outputTokens: 20,
  88. },
  89. },
  90. {
  91. index: 2,
  92. kind: 'tool',
  93. text: 'bash · {"command":"pwd"}',
  94. inputDetail: '{"command":"pwd"}',
  95. timeSeconds: null,
  96. },
  97. {
  98. index: 3,
  99. kind: 'tool',
  100. text: 'bash · {"command":"false"}',
  101. inputDetail: '{"command":"false"}',
  102. outputDetail: 'ToolError: non_zero_exit',
  103. result: 'non_zero_exit',
  104. isError: true,
  105. timeSeconds: 0.2,
  106. },
  107. ],
  108. }],
  109. }]
  110. const FOLD_PROPS = {
  111. collapsedTurns: new Set<number>(),
  112. onToggleTurn: () => {},
  113. collapsedAssistants: new Set<string>(),
  114. onToggleAssistant: () => {},
  115. }
  116. describe('TrajectoryTable', () => {
  117. it('shows a muted placeholder for an assistant response containing only tool calls', () => {
  118. const turns: readonly TrajectoryTurnModel[] = [{
  119. turn: 1,
  120. groups: [{
  121. title: 'Step 1',
  122. cells: [{
  123. index: 1,
  124. kind: 'message',
  125. text: 'Tool call only',
  126. sourceBlocks: [{
  127. type: 'tool-call', content: '{}', callId: 'call-1', toolName: 'read',
  128. }],
  129. timeSeconds: 1,
  130. }],
  131. }],
  132. }]
  133. render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
  134. expect(screen.getByText('(tool call only)')).toBeTruthy()
  135. })
  136. it('localizes the summary for folded Assistant tool calls', () => {
  137. const assistant = TURNS[0]!.groups[0]!.cells[0]!
  138. render(
  139. <LocalizedTrajectoryTable
  140. t={tZh}
  141. renderImages={renderImagesStub}
  142. turns={TURNS}
  143. collapsedTurns={new Set<number>()}
  144. onToggleTurn={() => {}}
  145. collapsedAssistants={new Set([trajectoryRecordId(assistant)])}
  146. onToggleAssistant={() => {}}
  147. />,
  148. )
  149. expect(screen.getByText('2 个工具调用 · bash')).toBeTruthy()
  150. })
  151. it('shows assistant timing facts after keyboard selection', () => {
  152. render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
  153. fireEvent.keyDown(screen.getByRole('row', { name: /ASSISTANT/ }), { key: 'Enter' })
  154. fireEvent.click(screen.getByRole('button', { name: 'Request Timing' }))
  155. expect(screen.getByText('500 ms')).toBeTruthy()
  156. expect(screen.getByText('1.00 s')).toBeTruthy()
  157. expect(screen.getByText('20.0 tok/s')).toBeTruthy()
  158. })
  159. it('shows a tool record Duration as exact milliseconds', () => {
  160. const turns: readonly TrajectoryTurnModel[] = [{
  161. turn: 1,
  162. groups: [{
  163. title: 'Step 1',
  164. cells: [{
  165. index: 1,
  166. kind: 'tool',
  167. text: 'bash · {"command":"pwd"}',
  168. inputDetail: '{"command":"pwd"}',
  169. timeSeconds: 1.5,
  170. }],
  171. }],
  172. }]
  173. render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
  174. fireEvent.click(screen.getByRole('row', { name: /TOOL/ }))
  175. expect(screen.getByText('1,500 ms', { selector: 'dd' })).toBeTruthy()
  176. })
  177. it('breaks output tokens into labeled reasoning and content rows', () => {
  178. render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
  179. fireEvent.click(screen.getByRole('row', { name: /ASSISTANT/ }))
  180. expect(screen.getByText('Tokens')).toBeTruthy()
  181. expect(screen.getByText('20 tok')).toBeTruthy()
  182. expect(screen.getByText('Reasoning')).toBeTruthy()
  183. expect(screen.getByText('5 tok')).toBeTruthy()
  184. expect(screen.getByText('Content')).toBeTruthy()
  185. expect(screen.getByText('15 tok')).toBeTruthy()
  186. })
  187. it('marks Summary scroll regions for interaction-only scrollbar thumbs', () => {
  188. render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
  189. fireEvent.click(screen.getByRole('row', { name: /ASSISTANT/ }))
  190. const panel = screen.getByRole('tabpanel')
  191. expect(panel.querySelectorAll('[data-summary-scroll-region]').length).toBeGreaterThan(1)
  192. fireEvent.click(screen.getByRole('tab', { name: 'Preview' }))
  193. expect(panel.querySelector('[data-summary-scroll-region]')).toBeNull()
  194. })
  195. it('keeps long thinking collapsed until the user asks to render it', () => {
  196. const thinking = 'private chain '.repeat(1_000)
  197. const turns: readonly TrajectoryTurnModel[] = [{
  198. turn: 1,
  199. groups: [{
  200. title: 'Step 1',
  201. cells: [{
  202. index: 1,
  203. kind: 'message',
  204. text: 'private chain…',
  205. thinkingDetail: thinking,
  206. timeSeconds: 1,
  207. }],
  208. }],
  209. }]
  210. render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
  211. fireEvent.click(screen.getByRole('row', { name: /ASSISTANT/ }))
  212. const toggle = screen.getByRole('button', { name: 'Thinking' })
  213. expect(toggle.getAttribute('aria-expanded')).toBe('false')
  214. expect(screen.queryByText(thinking)).toBeNull()
  215. fireEvent.click(toggle)
  216. expect(screen.getByRole('button', { name: 'Thinking' })).toBe(toggle)
  217. expect(toggle.getAttribute('aria-expanded')).toBe('true')
  218. expect(toggle.parentElement?.textContent?.length).toBeGreaterThan(thinking.length)
  219. })
  220. it('keeps raw HTML tags in a Markdown-derived context preview', () => {
  221. const html = [
  222. '<background-job-complete id="trajectory-ui-watch">',
  223. 'Command: pnpm test',
  224. 'Exit code: 0',
  225. '</background-job-complete>',
  226. ].join('\n')
  227. const turns: readonly TrajectoryTurnModel[] = [{
  228. turn: 1,
  229. groups: [{
  230. title: 'Message',
  231. cells: [{
  232. index: 1,
  233. kind: 'context',
  234. text: '',
  235. inputDetail: html,
  236. timeSeconds: 0,
  237. }],
  238. }],
  239. }]
  240. render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
  241. expect(screen.getByText(
  242. '<background-job-complete id="trajectory-ui-watch"> Command: pnpm test Exit code: 0 </background-job-complete>',
  243. )).toBeTruthy()
  244. })
  245. it('clears the selected row when ledger whitespace is clicked', () => {
  246. const onClearSelection = vi.fn()
  247. render(
  248. <TrajectoryTable
  249. turns={TURNS}
  250. {...FOLD_PROPS}
  251. onClearSelection={onClearSelection}
  252. />,
  253. )
  254. const row = screen.getByRole('row', { name: /ASSISTANT/ })
  255. fireEvent.click(row)
  256. expect(row.getAttribute('aria-selected')).toBe('true')
  257. expect(screen.getByRole('complementary', { name: 'Event details' })).toBeTruthy()
  258. const tablePane = screen.getByRole('table').parentElement
  259. expect(tablePane).not.toBeNull()
  260. fireEvent.click(tablePane as HTMLElement)
  261. expect(row.getAttribute('aria-selected')).toBe('false')
  262. expect(screen.queryByRole('complementary', { name: 'Event details' })).toBeNull()
  263. expect(onClearSelection).toHaveBeenCalledOnce()
  264. })
  265. it('keeps the selected record when older rows shift projection indexes', () => {
  266. const tail = (index: number): TrajectoryTurnModel => ({
  267. turn: 2,
  268. groups: [{
  269. title: 'Step 1',
  270. cells: [{
  271. index,
  272. kind: 'message',
  273. sourceSeq: 100,
  274. text: 'selected tail response',
  275. outputDetail: 'selected tail response detail',
  276. timeSeconds: 1,
  277. }],
  278. }],
  279. })
  280. const view = render(
  281. <TrajectoryTable turns={[tail(1)]} {...FOLD_PROPS} />,
  282. )
  283. fireEvent.click(screen.getByRole('row', { name: /selected tail response/ }))
  284. view.rerender(
  285. <TrajectoryTable
  286. turns={[{
  287. turn: 1,
  288. groups: [{
  289. title: 'Message',
  290. cells: [{
  291. index: 1,
  292. kind: 'user',
  293. sourceSeq: 1,
  294. text: 'older prompt',
  295. timeSeconds: 0,
  296. }],
  297. }],
  298. }, tail(2)]}
  299. {...FOLD_PROPS}
  300. />,
  301. )
  302. expect(screen.getByRole('row', { name: /selected tail response/ })
  303. .getAttribute('aria-selected')).toBe('true')
  304. expect(screen.getByText('selected tail response detail')).toBeTruthy()
  305. })
  306. it('keeps a selected request when prepending changes its display number', () => {
  307. const tail = (index: number): TrajectoryTurnModel => ({
  308. turn: 2,
  309. groups: [{
  310. title: 'Step 1',
  311. cells: [{
  312. index,
  313. kind: 'message',
  314. sourceSeq: 100,
  315. text: 'tail response',
  316. timeSeconds: 1,
  317. }],
  318. }],
  319. })
  320. const view = render(
  321. <TrajectoryTable turns={[tail(1)]} {...FOLD_PROPS} />,
  322. )
  323. fireEvent.click(screen.getByRole('button', { name: 'Request #1' }))
  324. view.rerender(
  325. <TrajectoryTable
  326. turns={[{
  327. turn: 1,
  328. groups: [{
  329. title: 'Step 1',
  330. cells: [{
  331. index: 1,
  332. kind: 'message',
  333. sourceSeq: 1,
  334. text: 'older response',
  335. timeSeconds: 1,
  336. }],
  337. }],
  338. }, tail(2)]}
  339. {...FOLD_PROPS}
  340. />,
  341. )
  342. expect(screen.getByRole('button', { name: 'Request #2' })
  343. .getAttribute('aria-pressed')).toBe('true')
  344. expect(screen.getByText('Request #2')).toBeTruthy()
  345. })
  346. it('keeps a selected request when its localized group label changes', () => {
  347. const turn = (group: string): TrajectoryTurnModel => ({
  348. turn: 1,
  349. groups: [{
  350. title: group,
  351. cells: [{
  352. index: 1,
  353. kind: 'message',
  354. sourceSeq: 10,
  355. text: 'response',
  356. timeSeconds: 1,
  357. }],
  358. }],
  359. })
  360. const request = (group: string) => [{
  361. turn: 1,
  362. step: 1,
  363. seq: 10,
  364. group,
  365. number: 1,
  366. }] as const
  367. const view = render(
  368. <TrajectoryTable turns={[turn('Step 1')]} requestNumbers={request('Step 1')} {...FOLD_PROPS} />,
  369. )
  370. fireEvent.click(screen.getByRole('button', { name: 'Request #1' }))
  371. view.rerender(
  372. <TrajectoryTable turns={[turn('步骤 1')]} requestNumbers={request('步骤 1')} {...FOLD_PROPS} />,
  373. )
  374. expect(screen.getByRole('button', { name: 'Request #1' })
  375. .getAttribute('aria-pressed')).toBe('true')
  376. expect(screen.getByText('Request #1')).toBeTruthy()
  377. })
  378. it('places the request boundary after leading steering input', () => {
  379. const turns: readonly TrajectoryTurnModel[] = [{
  380. turn: 1,
  381. groups: [{
  382. title: 'Step 2',
  383. cells: [{
  384. index: 1,
  385. kind: 'user',
  386. sourceSeq: 3,
  387. text: 'change direction',
  388. timeSeconds: 0,
  389. }, {
  390. index: 2,
  391. kind: 'message',
  392. sourceSeq: 4,
  393. text: 'continued',
  394. timeSeconds: 1,
  395. }],
  396. }],
  397. }]
  398. render(<TrajectoryTable
  399. turns={turns}
  400. requestNumbers={[{
  401. seq: 2,
  402. turn: 1,
  403. step: 2,
  404. group: 'Step 2',
  405. number: 1,
  406. }]}
  407. {...FOLD_PROPS}
  408. />)
  409. const request = screen.getByRole('button', { name: 'Request #1' })
  410. expect(request.closest('tr')?.getAttribute('aria-label')).toContain('ASSISTANT')
  411. })
  412. it('follows appended records only while the ledger is already at the bottom', () => {
  413. const view = render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
  414. const tablePane = screen.getByRole('table').parentElement as HTMLElement
  415. let scrollHeight = 200
  416. Object.defineProperties(tablePane, {
  417. clientHeight: { configurable: true, get: () => 100 },
  418. scrollHeight: { configurable: true, get: () => scrollHeight },
  419. })
  420. tablePane.scrollTop = 100
  421. fireEvent.scroll(tablePane)
  422. scrollHeight = 260
  423. view.rerender(
  424. <TrajectoryTable
  425. turns={[...TURNS, {
  426. turn: 2,
  427. groups: [{
  428. title: 'Step 1',
  429. cells: [{ index: 4, kind: 'message', text: 'new reply', timeSeconds: 0.1 }],
  430. }],
  431. }]}
  432. {...FOLD_PROPS}
  433. />,
  434. )
  435. expect(tablePane.scrollTop).toBe(260)
  436. tablePane.scrollTop = 20
  437. fireEvent.scroll(tablePane)
  438. scrollHeight = 320
  439. view.rerender(
  440. <TrajectoryTable
  441. turns={[...TURNS, {
  442. turn: 2,
  443. groups: [{
  444. title: 'Step 1',
  445. cells: [
  446. { index: 4, kind: 'message', text: 'new reply', timeSeconds: 0.1 },
  447. { index: 5, kind: 'tool', text: 'new tool', timeSeconds: 0.1 },
  448. ],
  449. }],
  450. }]}
  451. {...FOLD_PROPS}
  452. />,
  453. )
  454. expect(tablePane.scrollTop).toBe(20)
  455. })
  456. it('preserves the visible anchor when the last older page disables virtualization', async () => {
  457. let resolveOlder: ((advanced: boolean) => void) | undefined
  458. const older = new Promise<boolean>((resolve) => { resolveOlder = resolve })
  459. const onLoadOlder = vi.fn(() => older)
  460. const view = render(
  461. <TrajectoryTable
  462. turns={TURNS}
  463. {...FOLD_PROPS}
  464. historyStartSeq={1}
  465. hasOlderRecords
  466. onLoadOlder={onLoadOlder}
  467. />,
  468. )
  469. const tablePane = screen.getByRole('table').parentElement as HTMLElement
  470. let scrollHeight = 200
  471. Object.defineProperties(tablePane, {
  472. clientHeight: { configurable: true, get: () => 100 },
  473. scrollHeight: { configurable: true, get: () => scrollHeight },
  474. })
  475. tablePane.scrollTop = 0
  476. fireEvent.scroll(tablePane)
  477. fireEvent.scroll(tablePane)
  478. await waitFor(() => { expect(onLoadOlder).toHaveBeenCalledOnce() })
  479. expect(screen.getByRole('status').textContent).toContain('Loading earlier history…')
  480. resolveOlder?.(true)
  481. await waitFor(() => {
  482. expect(screen.getByRole('status').textContent).toBe('')
  483. })
  484. scrollHeight = 260
  485. view.rerender(
  486. <TrajectoryTable
  487. turns={[{
  488. turn: 0,
  489. groups: [{
  490. title: 'Step 1',
  491. cells: [{ index: 0, kind: 'user', text: 'older prompt', timeSeconds: 0 }],
  492. }],
  493. }, ...TURNS]}
  494. {...FOLD_PROPS}
  495. historyStartSeq={0}
  496. onLoadOlder={onLoadOlder}
  497. />,
  498. )
  499. expect(tablePane.scrollTop).toBe(60)
  500. })
  501. it('keeps an idle older-history control as the first row until paging completes', async () => {
  502. vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(600)
  503. Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
  504. configurable: true,
  505. value: vi.fn(),
  506. })
  507. let resolveOlder: ((advanced: boolean) => void) | undefined
  508. const older = new Promise<boolean>((resolve) => { resolveOlder = resolve })
  509. const onLoadOlder = vi.fn(() => older)
  510. const view = render(
  511. <TrajectoryTable
  512. turns={TURNS}
  513. {...FOLD_PROPS}
  514. hasOlderRecords
  515. onLoadOlder={onLoadOlder}
  516. />,
  517. )
  518. const table = screen.getByRole('table')
  519. const loadButton = screen.getByRole('button', { name: 'Load earlier history' })
  520. const loadRow = table.querySelector('tbody > tr:first-child')
  521. expect(loadRow?.contains(loadButton)).toBe(true)
  522. expect(loadRow?.getAttribute('aria-rowindex')).toBe('1')
  523. expect(screen.getByRole('status').textContent).toBe('')
  524. expect(table.getAttribute('aria-rowcount')).toBe('4')
  525. expect((await screen.findByRole('row', { name: /ASSISTANT/ })).getAttribute('aria-rowindex'))
  526. .toBe('2')
  527. fireEvent.click(loadButton)
  528. expect(onLoadOlder).toHaveBeenCalledOnce()
  529. expect(loadButton.hasAttribute('disabled')).toBe(true)
  530. expect(screen.getByRole('status').textContent).toBe('Loading earlier history…')
  531. resolveOlder?.(false)
  532. await waitFor(() => {
  533. expect(screen.getByRole('button', { name: 'Load earlier history' })
  534. .hasAttribute('disabled')).toBe(false)
  535. })
  536. view.rerender(
  537. <TrajectoryTable turns={TURNS} {...FOLD_PROPS} />,
  538. )
  539. expect(screen.queryByRole('button', { name: 'Load earlier history' })).toBeNull()
  540. expect(table.getAttribute('aria-rowcount')).toBe('3')
  541. })
  542. it('reflects an older page started outside the ledger in the persistent control', () => {
  543. render(
  544. <TrajectoryTable
  545. turns={TURNS}
  546. {...FOLD_PROPS}
  547. hasOlderRecords
  548. olderHistoryLoading
  549. onLoadOlder={vi.fn(async () => true)}
  550. />,
  551. )
  552. expect(screen.getByRole('button', { name: 'Loading earlier history…' })
  553. .hasAttribute('disabled')).toBe(true)
  554. expect(screen.getByRole('status').textContent).toBe('Loading earlier history…')
  555. })
  556. it('covers the ledger while the initial tail is loading', () => {
  557. const view = render(
  558. <TrajectoryTable turns={TURNS} {...FOLD_PROPS} historyLoading />,
  559. )
  560. expect(screen.getByRole('status').textContent).toContain('Loading trajectory…')
  561. expect(screen.getByRole('table').getAttribute('data-scroll-ready')).toBeNull()
  562. view.rerender(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
  563. expect(screen.queryByRole('status')).toBeNull()
  564. expect(screen.getByRole('table').getAttribute('data-scroll-ready')).toBe('true')
  565. })
  566. it('keeps a paged tail virtualized before its loaded window crosses the row threshold', async () => {
  567. vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(600)
  568. Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
  569. configurable: true,
  570. value: vi.fn(),
  571. })
  572. const view = render(
  573. <TrajectoryTable turns={TURNS} {...FOLD_PROPS} hasOlderRecords />,
  574. )
  575. await waitFor(() => {
  576. expect(view.container.querySelector('tr[data-virtual-position]')).toBeTruthy()
  577. })
  578. })
  579. it('mounts only the visible window for a long ledger', async () => {
  580. vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(600)
  581. const scrollTo = vi.fn()
  582. Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
  583. configurable: true,
  584. value: scrollTo,
  585. })
  586. const cells = Array.from({ length: 500 }, (_, index) => ({
  587. index: index + 1,
  588. kind: 'context' as const,
  589. text: `Context ${index + 1}`,
  590. timeSeconds: 0,
  591. }))
  592. const turns: readonly TrajectoryTurnModel[] = [{
  593. turn: 1,
  594. groups: [{ title: 'Context', cells }],
  595. }]
  596. const view = render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
  597. await waitFor(() => {
  598. expect(view.container.querySelectorAll('tr[data-virtual-position]').length)
  599. .toBeGreaterThan(0)
  600. })
  601. expect(view.container.querySelectorAll('tr[data-virtual-position]').length)
  602. .toBeLessThan(cells.length)
  603. expect(screen.getByRole('table').getAttribute('aria-rowcount')).toBe('500')
  604. expect(view.container.querySelector('tr[data-trajectory-row-key]')
  605. ?.getAttribute('aria-rowindex')).toBe('1')
  606. expect(scrollTo).toHaveBeenCalled()
  607. expect(view.container.querySelector('tr[data-virtual-spacer="bottom"]')).toBeTruthy()
  608. expect(screen.getByText('Context 1')).toBeTruthy()
  609. expect(screen.queryByText('Context 500')).toBeNull()
  610. const tablePane = screen.getByRole('table').parentElement as HTMLElement
  611. tablePane.scrollTop = 9_000
  612. fireEvent.scroll(tablePane)
  613. await waitFor(() => {
  614. expect(Number(view.container.querySelector(
  615. 'tr[data-virtual-position]',
  616. )?.getAttribute('data-virtual-position'))).toBeGreaterThan(0)
  617. })
  618. expect(view.container.querySelector('tr[data-virtual-spacer="top"]')).toBeTruthy()
  619. expect(screen.queryByText('Context 1')).toBeNull()
  620. })
  621. it('does not re-scroll a virtual ledger when streaming only changes row content', async () => {
  622. vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(600)
  623. const scrollTo = vi.fn()
  624. Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
  625. configurable: true,
  626. value: scrollTo,
  627. })
  628. const cells = Array.from({ length: 500 }, (_, index) => ({
  629. index: index + 1,
  630. kind: 'context' as const,
  631. sourceSeq: index + 1,
  632. text: `Context ${index + 1}`,
  633. timeSeconds: 0,
  634. }))
  635. const turns: readonly TrajectoryTurnModel[] = [{
  636. turn: 1,
  637. groups: [{ title: 'Context', cells }],
  638. }]
  639. const view = render(
  640. <TrajectoryTable
  641. turns={turns}
  642. {...FOLD_PROPS}
  643. />,
  644. )
  645. await waitFor(() => {
  646. expect(view.container.querySelector('tr[data-virtual-position]')).toBeTruthy()
  647. })
  648. scrollTo.mockClear()
  649. view.rerender(
  650. <TrajectoryTable
  651. turns={turns}
  652. streamingCells={[{ ...cells[0]!, text: 'Context 1 streaming update' }]}
  653. {...FOLD_PROPS}
  654. />,
  655. )
  656. expect(scrollTo).not.toHaveBeenCalled()
  657. expect(screen.getByText('Context 1 streaming update')).toBeTruthy()
  658. })
  659. it('keeps the virtual tail reachable with collapsed-summary row heights', async () => {
  660. vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(600)
  661. Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
  662. configurable: true,
  663. value: vi.fn(),
  664. })
  665. const turns: readonly TrajectoryTurnModel[] = Array.from(
  666. { length: 101 },
  667. (_, index) => ({
  668. turn: index + 1,
  669. groups: [{
  670. title: 'Step 1',
  671. cells: [
  672. {
  673. index: index * 2 + 1,
  674. kind: 'message' as const,
  675. sourceSeq: index * 2 + 1,
  676. text: `Message ${index + 1}`,
  677. timeSeconds: 1,
  678. },
  679. {
  680. index: index * 2 + 2,
  681. kind: 'tool' as const,
  682. callId: `call-${index + 1}`,
  683. text: `Tool ${index + 1}`,
  684. timeSeconds: 1,
  685. },
  686. ],
  687. }],
  688. }),
  689. )
  690. const collapsedTurns = new Set(turns.flatMap(turn =>
  691. turn.turn === null ? [] : [turn.turn]))
  692. const view = render(
  693. <TrajectoryTable
  694. turns={turns}
  695. {...FOLD_PROPS}
  696. collapsedTurns={collapsedTurns}
  697. />,
  698. )
  699. const tablePane = screen.getByRole('table').parentElement as HTMLElement
  700. tablePane.scrollTop = 5_000
  701. fireEvent.scroll(tablePane)
  702. await waitFor(() => {
  703. expect(view.container.querySelector('tr[data-virtual-position="201"]')).toBeTruthy()
  704. })
  705. })
  706. it('keeps running and failure semantics distinct from record roles', () => {
  707. const view = render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
  708. expect(view.container.querySelector('tr[data-kind="tool"][data-running="true"]')).toBeTruthy()
  709. expect(view.container.querySelector('tr[data-kind="tool"][data-error="true"]')).toBeTruthy()
  710. fireEvent.click(screen.getByRole('row', { name: /TOOL, bash \{"command":"pwd"\}/ }))
  711. expect(screen.getByText('Pending')).toBeTruthy()
  712. fireEvent.click(screen.getByRole('row', { name: /TOOL, bash \{"command":"false"\}/ }))
  713. expect(screen.getByText('Failed')).toBeTruthy()
  714. expect(screen.getByText('Failed').className).toContain('error')
  715. fireEvent.click(screen.getByRole('tab', { name: 'Result' }))
  716. const errorResult = screen.getByText('ToolError: non_zero_exit')
  717. expect(errorResult.closest('[class*="errorPayload"]')).toBeTruthy()
  718. })
  719. it('marks failed requests and lays coincident request markers left to right', () => {
  720. const turns: readonly TrajectoryTurnModel[] = [
  721. {
  722. turn: 1,
  723. groups: [{
  724. title: 'Step 1',
  725. cells: [{
  726. index: 1,
  727. kind: 'message',
  728. text: '',
  729. requestOnly: true,
  730. isError: true,
  731. timeSeconds: 0.1,
  732. }],
  733. }],
  734. },
  735. {
  736. turn: 2,
  737. groups: [{
  738. title: 'Step 1',
  739. cells: [{
  740. index: 2,
  741. kind: 'message',
  742. text: '',
  743. requestOnly: true,
  744. isError: true,
  745. timeSeconds: 0.1,
  746. }],
  747. }],
  748. },
  749. {
  750. turn: 3,
  751. groups: [{
  752. title: 'Step 1',
  753. cells: [{
  754. index: 3,
  755. kind: 'message',
  756. text: 'Recovered response',
  757. timeSeconds: 0.1,
  758. }],
  759. }],
  760. },
  761. ]
  762. render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
  763. const failed = screen.getByRole('button', { name: 'Request #1' })
  764. const retry = screen.getByRole('button', { name: 'Request #2' })
  765. const recovered = screen.getByRole('button', { name: 'Request #3' })
  766. expect(failed.getAttribute('data-request-status')).toBe('error')
  767. expect(failed.getAttribute('data-request-run-index')).toBe('0')
  768. expect(failed.style.getPropertyValue('--request-boundary-offset')).toBe('0px')
  769. expect(retry.getAttribute('data-request-run-index')).toBe('1')
  770. expect(retry.style.getPropertyValue('--request-boundary-offset')).toBe('8px')
  771. expect(recovered.getAttribute('data-request-run-index')).toBe('2')
  772. expect(recovered.style.getPropertyValue('--request-boundary-offset')).toBe('16px')
  773. })
  774. it('localizes a sanitized AUTH request failure from its stable code', () => {
  775. const turns: readonly TrajectoryTurnModel[] = [{
  776. turn: 1,
  777. groups: [{
  778. title: 'Step 1',
  779. cells: [{
  780. index: 1,
  781. kind: 'message',
  782. text: '',
  783. requestOnly: true,
  784. isError: true,
  785. timeSeconds: 0.1,
  786. }],
  787. }],
  788. }]
  789. render(
  790. <TrajectoryTable
  791. turns={turns}
  792. requestNumbers={[{
  793. turn: 1,
  794. step: 1,
  795. seq: 1,
  796. group: 'Step 1',
  797. number: 1,
  798. status: 'error',
  799. error: '',
  800. errorCode: 'AUTH',
  801. }]}
  802. {...FOLD_PROPS}
  803. />,
  804. )
  805. fireEvent.click(screen.getByRole('button', { name: 'Request #1' }))
  806. expect(screen.getByText('API key is invalid')).toBeTruthy()
  807. })
  808. it('shows the custom role tooltip only from the responsive icon', () => {
  809. const view = render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
  810. const toolTag = view.container.querySelector<HTMLElement>('[data-role-kind="tool"]')
  811. const toolIcon = toolTag?.querySelector<HTMLElement>('[data-role-icon="wrench"]')
  812. expect(toolTag).not.toBeNull()
  813. expect(toolTag?.getAttribute('title')).toBeNull()
  814. expect(toolIcon).toBeTruthy()
  815. fireEvent.mouseEnter(toolTag as HTMLElement)
  816. expect(screen.queryByRole('tooltip')).toBeNull()
  817. fireEvent.mouseEnter(toolIcon as HTMLElement)
  818. const tooltip = screen.getByRole('tooltip')
  819. expect(tooltip.textContent).toBe('TOOL')
  820. expect(tooltip.getAttribute('data-side')).toBe('right')
  821. fireEvent.mouseLeave(toolIcon as HTMLElement)
  822. expect(screen.queryByRole('tooltip')).toBeNull()
  823. })
  824. it('uses information and compression glyphs for injected and compacted context', () => {
  825. const turns: readonly TrajectoryTurnModel[] = [{
  826. turn: 1,
  827. groups: [{
  828. title: 'Context',
  829. cells: [
  830. { index: 1, kind: 'context', text: 'Workspace context', timeSeconds: 0 },
  831. { index: 2, kind: 'compacted', text: 'Compacted history', timeSeconds: 0 },
  832. ],
  833. }],
  834. }]
  835. const view = render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
  836. expect(view.container.querySelector(
  837. '[data-role-kind="context"] [data-role-icon="information"]',
  838. )).toBeTruthy()
  839. expect(view.container.querySelector(
  840. '[data-role-kind="compacted"] [data-role-icon="compacted"]',
  841. )).toBeTruthy()
  842. })
  843. it('keeps a compact turn label available for narrow layouts', () => {
  844. render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
  845. const turnLabel = screen.getByLabelText('Turn 1')
  846. expect(turnLabel.textContent).toContain('Turn 1')
  847. expect(turnLabel.textContent).toContain('#1')
  848. })
  849. it('renders a single-text JSON tool result as a JSON tree', () => {
  850. const turns: readonly TrajectoryTurnModel[] = [{
  851. turn: 1,
  852. groups: [{
  853. title: 'Step 1',
  854. cells: [{
  855. index: 1,
  856. kind: 'tool',
  857. text: 'read {"path":"result.json"}',
  858. outputDetail: '{"value":1,"nested":{"ok":true}}',
  859. outputBlocks: [{
  860. type: 'text',
  861. content: '{"value":1,"nested":{"ok":true}}',
  862. }],
  863. timeSeconds: 0.1,
  864. }],
  865. }],
  866. }]
  867. render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
  868. fireEvent.click(screen.getByRole('row', { name: /TOOL/ }))
  869. fireEvent.click(screen.getByRole('tab', { name: 'Result' }))
  870. expect(screen.getByRole('tree', { name: 'Result JSON' })).toBeTruthy()
  871. expect(screen.getByText('value:')).toBeTruthy()
  872. })
  873. it('renders user image attachments through the shared gallery in the details panel', () => {
  874. const attachment = {
  875. attachmentId: `sha256:${'a'.repeat(64)}`,
  876. mediaType: 'image/png',
  877. bytes: 68,
  878. width: 640,
  879. height: 320,
  880. name: 'screenshot.png',
  881. } as unknown as NonNullable<
  882. NonNullable<TrajectoryTurnModel['groups'][number]['cells'][number]['sourceBlocks']>[number]['attachment']
  883. >
  884. const turns: readonly TrajectoryTurnModel[] = [{
  885. turn: 1,
  886. groups: [{
  887. title: 'Message',
  888. cells: [{
  889. index: 1,
  890. kind: 'user',
  891. text: 'Images ×2',
  892. sourceBlocks: [
  893. { type: 'image', content: '', attachment },
  894. { type: 'image', content: '', attachment },
  895. ],
  896. timeSeconds: 0,
  897. }],
  898. }],
  899. }]
  900. render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
  901. fireEvent.click(screen.getByRole('row', { name: /USER/ }))
  902. const preview = screen.getAllByTestId('record-images')
  903. expect(preview.length).toBeGreaterThan(0)
  904. expect(preview[0]?.getAttribute('data-count')).toBe('2')
  905. fireEvent.click(screen.getByRole('tab', { name: 'Raw' }))
  906. const rawGalleries = screen.getAllByTestId('record-images')
  907. expect(rawGalleries).toHaveLength(2)
  908. expect(rawGalleries[0]?.querySelector('[data-attachment-id]')?.getAttribute('data-attachment-id'))
  909. .toBe(String(attachment.attachmentId))
  910. })
  911. it('renders a tool-result image through the shared gallery in the Result tab', () => {
  912. const attachment = {
  913. attachmentId: `sha256:${'b'.repeat(64)}`,
  914. mediaType: 'image/png',
  915. bytes: 68,
  916. width: 320,
  917. height: 640,
  918. name: 'capture.png',
  919. } as unknown as NonNullable<
  920. NonNullable<TrajectoryTurnModel['groups'][number]['cells'][number]['outputBlocks']>[number]['attachment']
  921. >
  922. const turns: readonly TrajectoryTurnModel[] = [{
  923. turn: 1,
  924. groups: [{
  925. title: 'Step 1',
  926. cells: [{
  927. index: 1,
  928. kind: 'tool',
  929. text: 'read_image {"path":"a.png"}',
  930. outputDetail: 'Images ×1',
  931. outputBlocks: [{ type: 'image', content: '', attachment }],
  932. timeSeconds: 0.1,
  933. }],
  934. }],
  935. }]
  936. render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
  937. fireEvent.click(screen.getByRole('row', { name: /TOOL/ }))
  938. fireEvent.click(screen.getByRole('tab', { name: 'Result' }))
  939. const gallery = screen.getAllByTestId('record-images').at(-1)
  940. expect(gallery?.getAttribute('data-count')).toBe('1')
  941. expect(gallery?.querySelector('[data-attachment-id]')?.getAttribute('data-attachment-id'))
  942. .toBe(String(attachment.attachmentId))
  943. })
  944. it('keeps the failure name beside an image-only error result', () => {
  945. const attachment = {
  946. attachmentId: `sha256:${'c'.repeat(64)}`,
  947. mediaType: 'image/png',
  948. bytes: 68,
  949. width: 320,
  950. height: 320,
  951. name: 'failed.png',
  952. } as unknown as NonNullable<
  953. NonNullable<TrajectoryTurnModel['groups'][number]['cells'][number]['outputBlocks']>[number]['attachment']
  954. >
  955. const turns: readonly TrajectoryTurnModel[] = [{
  956. turn: 1,
  957. groups: [{
  958. title: 'Step 1',
  959. cells: [{
  960. index: 1,
  961. kind: 'tool',
  962. text: 'render {"target":"chart"}',
  963. outputDetail: 'ToolError: RENDER_TRUNCATED',
  964. outputBlocks: [{ type: 'image', content: '', attachment }],
  965. isError: true,
  966. timeSeconds: 0.1,
  967. }],
  968. }],
  969. }]
  970. render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
  971. fireEvent.click(screen.getByRole('row', { name: /TOOL/ }))
  972. fireEvent.click(screen.getByRole('tab', { name: 'Result' }))
  973. expect(screen.getByText('ToolError: RENDER_TRUNCATED')).toBeTruthy()
  974. const gallery = screen.getAllByTestId('record-images').at(-1)
  975. expect(gallery?.getAttribute('data-count')).toBe('1')
  976. })
  977. it('keeps the first row and a compact summary when a turn is collapsed', () => {
  978. render(
  979. <TrajectoryTable
  980. turns={TURNS}
  981. {...FOLD_PROPS}
  982. collapsedTurns={new Set([1])}
  983. />,
  984. )
  985. expect(screen.queryByRole('columnheader')).toBeNull()
  986. expect(screen.getByRole('row', { name: /ASSISTANT/ })).toBeTruthy()
  987. expect(screen.getByRole('row', { name: /Collapsed turn summary/ })).toBeTruthy()
  988. })
  989. const CALL_TURNS: readonly TrajectoryTurnModel[] = [{
  990. turn: 1,
  991. groups: [{
  992. title: 'Step 1',
  993. cells: [{
  994. index: 1,
  995. kind: 'tool',
  996. text: 'bash · {"command":"pwd"}',
  997. inputDetail: '{"command":"pwd"}',
  998. callId: 'call-1',
  999. timeSeconds: 0.1,
  1000. }],
  1001. }],
  1002. }]
  1003. it('an inspect target opens the matching record and acknowledges once', () => {
  1004. const onInspectApplied = vi.fn()
  1005. render(
  1006. <TrajectoryTable
  1007. turns={CALL_TURNS}
  1008. {...FOLD_PROPS}
  1009. inspectCallId="call-1"
  1010. onInspectApplied={onInspectApplied}
  1011. />,
  1012. )
  1013. expect(screen.getByRole('row', { name: /TOOL/ }).getAttribute('aria-selected')).toBe('true')
  1014. expect(screen.getByRole('complementary', { name: 'Event details' })).toBeTruthy()
  1015. expect(onInspectApplied).toHaveBeenCalledOnce()
  1016. })
  1017. it('an unmatched inspect target stays pending without acknowledgement', () => {
  1018. const onInspectApplied = vi.fn()
  1019. render(
  1020. <TrajectoryTable
  1021. turns={CALL_TURNS}
  1022. {...FOLD_PROPS}
  1023. inspectCallId="call-missing"
  1024. onInspectApplied={onInspectApplied}
  1025. />,
  1026. )
  1027. expect(screen.getByRole('row', { name: /TOOL/ }).getAttribute('aria-selected')).toBe('false')
  1028. expect(onInspectApplied).not.toHaveBeenCalled()
  1029. })
  1030. })