table.client.spec.tsx 37 KB

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