table.client.spec.tsx 41 KB

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