basalt-ui 1.12.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/AGENTS.md +21 -21
  2. package/CHANGELOG.md +17 -0
  3. package/README.md +38 -35
  4. package/agent/rules/basalt-agent.md +14 -0
  5. package/configs/oxlint-plugin.js +7 -3
  6. package/configs/oxlint-plugin.test.ts +34 -5
  7. package/configs/oxlint.json +4 -4
  8. package/dist/agent/adapter.d.ts +5 -1
  9. package/dist/agent/adapter.d.ts.map +1 -1
  10. package/dist/agent/adapter.js +22 -13
  11. package/dist/agent/ai-sdk-transport.d.ts.map +1 -1
  12. package/dist/agent/ai-sdk-transport.js +6 -2
  13. package/dist/agent/id.d.ts +67 -19
  14. package/dist/agent/id.d.ts.map +1 -1
  15. package/dist/agent/id.js +21 -7
  16. package/dist/agent/thread.d.ts +4 -0
  17. package/dist/agent/thread.d.ts.map +1 -1
  18. package/dist/agent/use-agent-stream.d.ts.map +1 -1
  19. package/dist/agent/use-agent-stream.js +2 -2
  20. package/dist/agent/use-agent-thread-runs.d.ts +24 -3
  21. package/dist/agent/use-agent-thread-runs.d.ts.map +1 -1
  22. package/dist/agent/use-agent-thread-runs.js +55 -15
  23. package/dist/agent-chat/index.d.ts +4 -0
  24. package/dist/agent-chat/index.d.ts.map +1 -1
  25. package/dist/agent-chat/index.js +2 -0
  26. package/dist/agent-chat/message-affordances.d.ts +59 -0
  27. package/dist/agent-chat/message-affordances.d.ts.map +1 -0
  28. package/dist/agent-chat/message-affordances.js +7 -0
  29. package/dist/agent-chat/relative-time.d.ts +27 -0
  30. package/dist/agent-chat/relative-time.d.ts.map +1 -0
  31. package/dist/agent-chat/relative-time.js +27 -0
  32. package/dist/agent-chat/relative-time.test.d.ts +2 -0
  33. package/dist/agent-chat/relative-time.test.d.ts.map +1 -0
  34. package/dist/agent-chat/thread-feed-row.d.ts +65 -0
  35. package/dist/agent-chat/thread-feed-row.d.ts.map +1 -0
  36. package/dist/agent-chat/thread-feed-row.js +156 -0
  37. package/dist/agent-chat/thread-feed-row.test.d.ts +2 -0
  38. package/dist/agent-chat/thread-feed-row.test.d.ts.map +1 -0
  39. package/dist/agent-chat/thread-feed.d.ts +34 -5
  40. package/dist/agent-chat/thread-feed.d.ts.map +1 -1
  41. package/dist/agent-chat/thread-feed.js +52 -19
  42. package/dist/agent-chat/thread-feed.test.d.ts +2 -0
  43. package/dist/agent-chat/thread-feed.test.d.ts.map +1 -0
  44. package/dist/agent-chat/thread-message.d.ts +183 -2
  45. package/dist/agent-chat/thread-message.d.ts.map +1 -1
  46. package/dist/agent-chat/thread-message.js +315 -34
  47. package/dist/agent-chat/thread-outcome-card.d.ts.map +1 -1
  48. package/dist/agent-chat/thread-outcome-card.js +1 -16
  49. package/dist/agent-chat/thread-workspace.d.ts +14 -2
  50. package/dist/agent-chat/thread-workspace.d.ts.map +1 -1
  51. package/dist/agent-chat/thread-workspace.js +29 -3
  52. package/dist/agent-chat/thread-workspace.test.d.ts +2 -0
  53. package/dist/agent-chat/thread-workspace.test.d.ts.map +1 -0
  54. package/dist/agent-chat/virtualize.d.ts +123 -0
  55. package/dist/agent-chat/virtualize.d.ts.map +1 -0
  56. package/dist/agent-chat/virtualize.js +9 -0
  57. package/dist/agent-chat/virtualize.type-guard.test.d.ts +2 -0
  58. package/dist/agent-chat/virtualize.type-guard.test.d.ts.map +1 -0
  59. package/dist/data/index.d.ts +5 -1
  60. package/dist/data/index.d.ts.map +1 -1
  61. package/dist/data/virtual-list.d.ts.map +1 -1
  62. package/dist/data/virtual.d.ts +2 -1
  63. package/dist/data/virtual.d.ts.map +1 -1
  64. package/dist/index.d.ts +2 -2
  65. package/dist/index.d.ts.map +1 -1
  66. package/dist/index.js +2 -0
  67. package/dist/surfaces.d.ts +2 -2
  68. package/dist/surfaces.d.ts.map +1 -1
  69. package/dist/surfaces.js +3 -2
  70. package/dist/theme/shadow-surfaces.test.d.ts.map +1 -1
  71. package/llms.txt +6 -6
  72. package/package.json +2 -2
  73. package/src/agent/adapter.test.ts +237 -0
  74. package/src/agent/adapter.ts +72 -14
  75. package/src/agent/ai-sdk-transport.test.ts +64 -0
  76. package/src/agent/ai-sdk-transport.ts +8 -2
  77. package/src/agent/id.test.ts +95 -1
  78. package/src/agent/id.ts +98 -28
  79. package/src/agent/thread.ts +4 -0
  80. package/src/agent/use-agent-stream.test.tsx +22 -0
  81. package/src/agent/use-agent-stream.ts +6 -3
  82. package/src/agent/use-agent-thread-runs.test.tsx +326 -0
  83. package/src/agent/use-agent-thread-runs.ts +159 -23
  84. package/src/agent/use-agent-thread-runs.wedge.test.tsx +80 -0
  85. package/src/agent-chat/index.ts +12 -0
  86. package/src/agent-chat/message-affordances.ts +61 -0
  87. package/src/agent-chat/relative-time.test.ts +95 -0
  88. package/src/agent-chat/relative-time.ts +55 -0
  89. package/src/agent-chat/thread-feed-row.test.tsx +510 -0
  90. package/src/agent-chat/thread-feed-row.tsx +274 -0
  91. package/src/agent-chat/thread-feed.test.tsx +241 -0
  92. package/src/agent-chat/thread-feed.tsx +119 -18
  93. package/src/agent-chat/thread-message.test.tsx +937 -3
  94. package/src/agent-chat/thread-message.tsx +766 -38
  95. package/src/agent-chat/thread-outcome-card.tsx +1 -25
  96. package/src/agent-chat/thread-workspace.test.tsx +169 -0
  97. package/src/agent-chat/thread-workspace.tsx +69 -5
  98. package/src/agent-chat/virtualize.ts +132 -0
  99. package/src/agent-chat/virtualize.type-guard.test.ts +49 -0
  100. package/src/data/index.ts +5 -1
  101. package/src/data/virtual-list.tsx +2 -1
  102. package/src/data/virtual.ts +2 -1
  103. package/src/index.ts +5 -0
  104. package/src/surfaces.ts +2 -1
  105. package/src/theme/shadow-surfaces.test.ts +9 -0
@@ -0,0 +1,510 @@
1
+ /**
2
+ * ThreadFeedRow — the lazy-mount / keep-mounted invariant this component exists to guarantee
3
+ * (AGENT-CHAT-SPEC.md §12): never render the transcript before the first expand, never unmount it
4
+ * after. The `effectFireCount` probe is what makes the "no effect re-fire" half of that provable —
5
+ * a re-mount would bump it, a CSS-only hide/show never does.
6
+ */
7
+ import { MantineProvider } from '@mantine/core'
8
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
9
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
10
+ import { useEffect } from 'react'
11
+ import { definePartRenderers } from '../agent'
12
+ import type {
13
+ AgentThread,
14
+ ChatMessage,
15
+ ForeignPart,
16
+ PartRenderer,
17
+ PartRenderers,
18
+ TranscriptPart,
19
+ } from '../agent'
20
+ import { ThreadFeedRow } from './thread-feed-row'
21
+
22
+ afterEach(cleanup)
23
+
24
+ function buildThread(parts: TranscriptPart[]): AgentThread<TranscriptPart> {
25
+ return {
26
+ id: 'thread-1',
27
+ messages: [{ id: 'm1', role: 'assistant', parts, createdAt: 0 }],
28
+ outcome: { title: 'A resolved thread', summary: 'Summary text', status: 'done' },
29
+ status: 'done',
30
+ read: true,
31
+ createdAt: 0,
32
+ updatedAt: 0,
33
+ }
34
+ }
35
+
36
+ let effectFireCount = 0
37
+
38
+ /** Mounted once per real mount of the subtree it lives in — proves whether a hide/show cycle
39
+ * re-mounted it (increments again) or merely hid it with CSS (stays put). */
40
+ function EffectProbe(): null {
41
+ useEffect(() => {
42
+ effectFireCount += 1
43
+ }, [])
44
+ return null
45
+ }
46
+
47
+ const probeRenderers: PartRenderers = definePartRenderers({
48
+ probe: () => <EffectProbe />,
49
+ })
50
+
51
+ /** Renders whatever `part.type` reached it — used to prove `fallbackRenderer` crossed the row's
52
+ * seam into `ThreadTranscript`, since a dropped prop falls back to the built-in dev-only chip
53
+ * instead (a different testid, not this one). */
54
+ const rowFallbackRenderer: PartRenderer<ForeignPart> = ({ part }) => (
55
+ <span data-testid="row-fallback-part">{part.type}</span>
56
+ )
57
+
58
+ function renderRow(expanded: boolean) {
59
+ const thread = buildThread([{ id: 'p1', type: 'probe' }])
60
+ return render(
61
+ <MantineProvider>
62
+ <ThreadFeedRow
63
+ thread={thread}
64
+ expanded={expanded}
65
+ onToggle={() => {}}
66
+ renderers={probeRenderers}
67
+ onSend={() => {}}
68
+ />
69
+ </MantineProvider>,
70
+ )
71
+ }
72
+
73
+ describe('ThreadFeedRow — lazy mount, kept mounted', () => {
74
+ test('collapsed initially: the transcript body is not in the DOM', () => {
75
+ renderRow(false)
76
+
77
+ expect(screen.queryByTestId('thread-feed-row-body')).toBeNull()
78
+ })
79
+
80
+ test('expanding mounts the transcript body', () => {
81
+ const { rerender } = render(
82
+ <MantineProvider>
83
+ <ThreadFeedRow
84
+ thread={buildThread([{ id: 'p1', type: 'probe' }])}
85
+ expanded={false}
86
+ onToggle={() => {}}
87
+ renderers={probeRenderers}
88
+ onSend={() => {}}
89
+ />
90
+ </MantineProvider>,
91
+ )
92
+ expect(screen.queryByTestId('thread-feed-row-body')).toBeNull()
93
+
94
+ rerender(
95
+ <MantineProvider>
96
+ <ThreadFeedRow
97
+ thread={buildThread([{ id: 'p1', type: 'probe' }])}
98
+ expanded
99
+ onToggle={() => {}}
100
+ renderers={probeRenderers}
101
+ onSend={() => {}}
102
+ />
103
+ </MantineProvider>,
104
+ )
105
+
106
+ expect(screen.getByTestId('thread-feed-row-body')).toBeDefined()
107
+ })
108
+
109
+ test('collapsing again keeps the subtree mounted, hidden via CSS only', () => {
110
+ const thread = buildThread([{ id: 'p1', type: 'probe' }])
111
+ const { rerender } = render(
112
+ <MantineProvider>
113
+ <ThreadFeedRow
114
+ thread={thread}
115
+ expanded
116
+ onToggle={() => {}}
117
+ renderers={probeRenderers}
118
+ onSend={() => {}}
119
+ />
120
+ </MantineProvider>,
121
+ )
122
+ expect(screen.getByTestId('thread-feed-row-body')).toBeDefined()
123
+
124
+ rerender(
125
+ <MantineProvider>
126
+ <ThreadFeedRow
127
+ thread={thread}
128
+ expanded={false}
129
+ onToggle={() => {}}
130
+ renderers={probeRenderers}
131
+ onSend={() => {}}
132
+ />
133
+ </MantineProvider>,
134
+ )
135
+
136
+ // Still present — a `queryByTestId`-returns-null assertion here would be the "unmounted"
137
+ // behavior this test exists to rule out.
138
+ const body = screen.getByTestId('thread-feed-row-body')
139
+ expect(body).toBeDefined()
140
+ expect(body.style.display).toBe('none')
141
+ })
142
+
143
+ test('re-expanding after a collapse does NOT re-fire the subtree effect', () => {
144
+ effectFireCount = 0
145
+ const thread = buildThread([{ id: 'p1', type: 'probe' }])
146
+ const props = {
147
+ thread,
148
+ onToggle: () => {},
149
+ renderers: probeRenderers,
150
+ onSend: () => {},
151
+ } as const
152
+
153
+ const { rerender } = render(
154
+ <MantineProvider>
155
+ <ThreadFeedRow {...props} expanded />
156
+ </MantineProvider>,
157
+ )
158
+ expect(effectFireCount).toBe(1)
159
+
160
+ rerender(
161
+ <MantineProvider>
162
+ <ThreadFeedRow {...props} expanded={false} />
163
+ </MantineProvider>,
164
+ )
165
+ rerender(
166
+ <MantineProvider>
167
+ <ThreadFeedRow {...props} expanded />
168
+ </MantineProvider>,
169
+ )
170
+
171
+ // A re-mount (the defect this invariant guards against) would bump this to 2.
172
+ expect(effectFireCount).toBe(1)
173
+ })
174
+
175
+ test('clicking the header calls onToggle with the thread id', () => {
176
+ // A plain `let` narrows to `null` at the assertion below (TS can't prove the callback ran) —
177
+ // a mutable holder object sidesteps that, since object property writes aren't narrowed the
178
+ // same way across the closure boundary.
179
+ const toggled: { id: string | null } = { id: null }
180
+ const thread: AgentThread<TranscriptPart> = {
181
+ ...buildThread([{ id: 'p1', type: 'text', text: 'hi' }]),
182
+ outcome: null,
183
+ }
184
+ render(
185
+ <MantineProvider>
186
+ <ThreadFeedRow
187
+ thread={thread}
188
+ expanded={false}
189
+ onToggle={(id) => {
190
+ toggled.id = id
191
+ }}
192
+ onSend={() => {}}
193
+ />
194
+ </MantineProvider>,
195
+ )
196
+
197
+ screen.getByText('Untitled thread')
198
+ screen.getByRole('button').click()
199
+
200
+ expect(toggled.id).toBe('thread-1')
201
+ })
202
+ })
203
+
204
+ // ── The composer half of `liveStatus` ────────────────────────────────────────────────────────
205
+ // `onStop` was documented ("shown as the composer's Stop action while liveStatus === 'streaming'")
206
+ // but never forwarded together with the `streaming` flag `Composer` actually gates on
207
+ // (`composer.tsx`'s `showStop`/`inputDisabled`) — so Stop never appeared and a second turn could
208
+ // still be typed into a live thread. These assert the composer itself reflects `liveStatus`.
209
+
210
+ describe('ThreadFeedRow — composer reflects liveStatus', () => {
211
+ test('liveStatus="streaming" with onStop shows Stop instead of Send, and calls onStop', () => {
212
+ let stops = 0
213
+ render(
214
+ <MantineProvider>
215
+ <ThreadFeedRow
216
+ thread={buildThread([])}
217
+ expanded
218
+ onToggle={() => {}}
219
+ liveStatus="streaming"
220
+ onStop={() => {
221
+ stops += 1
222
+ }}
223
+ onSend={() => {}}
224
+ />
225
+ </MantineProvider>,
226
+ )
227
+
228
+ expect(screen.queryByLabelText('Send message')).toBeNull()
229
+ fireEvent.click(screen.getByLabelText('Stop generating'))
230
+ expect(stops).toBe(1)
231
+ })
232
+
233
+ test('liveStatus="streaming" disables the composer textarea', () => {
234
+ render(
235
+ <MantineProvider>
236
+ <ThreadFeedRow
237
+ thread={buildThread([])}
238
+ expanded
239
+ onToggle={() => {}}
240
+ liveStatus="streaming"
241
+ onStop={() => {}}
242
+ onSend={() => {}}
243
+ />
244
+ </MantineProvider>,
245
+ )
246
+
247
+ expect((screen.getByRole('textbox') as HTMLTextAreaElement).disabled).toBe(true)
248
+ })
249
+
250
+ test('liveStatus="done" (or unset) shows Send, not Stop, and leaves the textarea enabled', () => {
251
+ render(
252
+ <MantineProvider>
253
+ <ThreadFeedRow
254
+ thread={buildThread([])}
255
+ expanded
256
+ onToggle={() => {}}
257
+ liveStatus="done"
258
+ onStop={() => {}}
259
+ onSend={() => {}}
260
+ />
261
+ </MantineProvider>,
262
+ )
263
+
264
+ expect(screen.getByLabelText('Send message')).toBeDefined()
265
+ expect(screen.queryByLabelText('Stop generating')).toBeNull()
266
+ expect((screen.getByRole('textbox') as HTMLTextAreaElement).disabled).toBe(false)
267
+ })
268
+ })
269
+
270
+ // ── The row → transcript seam ────────────────────────────────────────────────────────────────
271
+ // `ThreadFeedRow` and `ThreadTranscript` were built in parallel: the row declared the shared
272
+ // contract (`affordances`, and later `groupConsecutive`/`virtualize`) while the transcript grew the
273
+ // props that consume it. Each half was correct alone and the pair still compiled with the row
274
+ // silently DROPPING all three on the floor — a defect no type or single-component test can see.
275
+ // These assert the forwarding itself: every one of them goes red if a prop stops being passed
276
+ // through, which is exactly the failure that shipped.
277
+
278
+ function threadWith(messages: ChatMessage<TranscriptPart>[]): AgentThread<TranscriptPart> {
279
+ return { ...buildThread([]), messages }
280
+ }
281
+
282
+ const T0 = 1_000_000_000
283
+
284
+ function textMessage(
285
+ id: string,
286
+ role: ChatMessage<TranscriptPart>['role'],
287
+ createdAt: number,
288
+ ): ChatMessage<TranscriptPart> {
289
+ return { id, role, parts: [{ id: `${id}-p1`, type: 'text', text: `body ${id}` }], createdAt }
290
+ }
291
+
292
+ describe('ThreadFeedRow forwards the transcript contract it declares', () => {
293
+ test('affordances reach the row’s messages — a custom action renders inside the body', () => {
294
+ render(
295
+ <MantineProvider>
296
+ <ThreadFeedRow
297
+ thread={threadWith([textMessage('fm1', 'assistant', T0)])}
298
+ expanded
299
+ onToggle={() => {}}
300
+ affordances={{ actions: () => <span data-testid="row-custom-action">extra</span> }}
301
+ onSend={() => {}}
302
+ />
303
+ </MantineProvider>,
304
+ )
305
+
306
+ // Dropping the prop leaves the DEFAULT affordance row (timestamp + copy) rendering perfectly —
307
+ // which is why the assertion is on the CONSUMER-supplied action, the one thing that cannot
308
+ // appear unless `affordances` actually crossed the seam.
309
+ expect(screen.getByTestId('row-custom-action')).toBeDefined()
310
+ })
311
+
312
+ test('affordances’ onRegenerate reaches the last assistant message', () => {
313
+ const regenerated: { id: string | null } = { id: null }
314
+ render(
315
+ <MantineProvider>
316
+ <ThreadFeedRow
317
+ thread={threadWith([
318
+ textMessage('fm1', 'user', T0),
319
+ textMessage('fm2', 'assistant', T0 + 60_000),
320
+ ])}
321
+ expanded
322
+ onToggle={() => {}}
323
+ affordances={{
324
+ onRegenerate: (id) => {
325
+ regenerated.id = id
326
+ },
327
+ }}
328
+ onSend={() => {}}
329
+ />
330
+ </MantineProvider>,
331
+ )
332
+
333
+ screen.getByText('Regenerate').click()
334
+
335
+ expect(regenerated.id).toBe('fm2')
336
+ })
337
+
338
+ test('liveParts + liveStatus reach the transcript as an in-flight live message', () => {
339
+ render(
340
+ <MantineProvider>
341
+ <ThreadFeedRow
342
+ thread={threadWith([textMessage('fm1', 'user', T0)])}
343
+ expanded
344
+ onToggle={() => {}}
345
+ liveParts={[{ id: 'live-p1', type: 'text', text: 'partial reply' }]}
346
+ liveStatus="streaming"
347
+ onSend={() => {}}
348
+ />
349
+ </MantineProvider>,
350
+ )
351
+
352
+ // `ThreadTranscript` synthesizes a `__live__` message from `liveParts` only when it actually
353
+ // receives both props — dropping either leaves this testid absent.
354
+ expect(screen.getByTestId('agent-message-__live__')).toBeDefined()
355
+ expect(screen.getByText('partial reply')).toBeDefined()
356
+ })
357
+
358
+ test('renderers reaches the transcript — a registered part type renders the consumer renderer', () => {
359
+ const rowRenderers: PartRenderers = definePartRenderers({
360
+ 'row-custom': () => <span data-testid="row-custom-part">custom</span>,
361
+ })
362
+
363
+ render(
364
+ <MantineProvider>
365
+ <ThreadFeedRow
366
+ thread={threadWith([
367
+ {
368
+ id: 'fm1',
369
+ role: 'assistant',
370
+ parts: [{ id: 'p1', type: 'row-custom' }],
371
+ createdAt: T0,
372
+ },
373
+ ])}
374
+ expanded
375
+ onToggle={() => {}}
376
+ renderers={rowRenderers}
377
+ onSend={() => {}}
378
+ />
379
+ </MantineProvider>,
380
+ )
381
+
382
+ expect(screen.getByTestId('row-custom-part')).toBeDefined()
383
+ })
384
+
385
+ test('fallbackRenderer reaches the transcript for an unregistered foreign part', () => {
386
+ render(
387
+ <MantineProvider>
388
+ <ThreadFeedRow
389
+ thread={threadWith([
390
+ {
391
+ id: 'fm1',
392
+ role: 'assistant',
393
+ parts: [{ id: 'p1', type: 'unregistered-foreign-type' }],
394
+ createdAt: T0,
395
+ },
396
+ ])}
397
+ expanded
398
+ onToggle={() => {}}
399
+ fallbackRenderer={rowFallbackRenderer}
400
+ onSend={() => {}}
401
+ />
402
+ </MantineProvider>,
403
+ )
404
+
405
+ expect(screen.getByTestId('row-fallback-part').textContent).toBe('unregistered-foreign-type')
406
+ })
407
+
408
+ test('composerProps reaches the row’s Composer', () => {
409
+ render(
410
+ <MantineProvider>
411
+ <ThreadFeedRow
412
+ thread={threadWith([])}
413
+ expanded
414
+ onToggle={() => {}}
415
+ onSend={() => {}}
416
+ composerProps={{ placeholder: 'row-composer-placeholder' }}
417
+ />
418
+ </MantineProvider>,
419
+ )
420
+
421
+ expect(screen.getByPlaceholderText('row-composer-placeholder')).toBeDefined()
422
+ })
423
+
424
+ test('groupConsecutive={false} reaches the transcript', () => {
425
+ const messages = [
426
+ textMessage('fm1', 'assistant', T0),
427
+ textMessage('fm2', 'assistant', T0 + 60_000),
428
+ ]
429
+
430
+ render(
431
+ <MantineProvider>
432
+ <ThreadFeedRow
433
+ thread={threadWith(messages)}
434
+ expanded
435
+ onToggle={() => {}}
436
+ onSend={() => {}}
437
+ />
438
+ </MantineProvider>,
439
+ )
440
+ // Default (`true`, forwarded as `undefined` and defaulted inside the transcript): the second
441
+ // same-role message inside the 5-minute window groups.
442
+ expect(screen.getByTestId('agent-message-fm2').getAttribute('data-grouped')).toBe('true')
443
+
444
+ // A fresh mount, not a `rerender` — the first tree is torn down here, so the second render
445
+ // starts from a clean DOM and `getByTestId` can't match the stale row.
446
+ cleanup()
447
+ render(
448
+ <MantineProvider>
449
+ <ThreadFeedRow
450
+ thread={threadWith(messages)}
451
+ expanded
452
+ onToggle={() => {}}
453
+ groupConsecutive={false}
454
+ onSend={() => {}}
455
+ />
456
+ </MantineProvider>,
457
+ )
458
+
459
+ expect(screen.getByTestId('agent-message-fm2').getAttribute('data-grouped')).toBe('false')
460
+ })
461
+ })
462
+
463
+ describe('ThreadFeedRow forwards virtualize/height to the transcript', () => {
464
+ let originalOffsetHeight: PropertyDescriptor | undefined
465
+
466
+ beforeEach(() => {
467
+ originalOffsetHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight')
468
+ // Same reason as thread-message.test.tsx's virtualization block: happy-dom has no layout
469
+ // engine, so an unpatched offsetHeight of 0 makes TanStack Virtual compute an empty viewport.
470
+ Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, value: 300 })
471
+ })
472
+
473
+ afterEach(() => {
474
+ if (originalOffsetHeight !== undefined) {
475
+ Object.defineProperty(HTMLElement.prototype, 'offsetHeight', originalOffsetHeight)
476
+ } else {
477
+ delete (HTMLElement.prototype as { offsetHeight?: number }).offsetHeight
478
+ }
479
+ })
480
+
481
+ test('an expanded row with virtualize windows its thread instead of rendering every message', async () => {
482
+ const messages = Array.from({ length: 120 }, (_, i) =>
483
+ // 10 minutes apart — never groups, keeping this orthogonal to the grouping seam above.
484
+ textMessage(`vfm${i}`, i % 2 === 0 ? 'user' : 'assistant', T0 + i * 10 * 60_000),
485
+ )
486
+
487
+ const { container } = render(
488
+ <MantineProvider>
489
+ <ThreadFeedRow
490
+ thread={threadWith(messages)}
491
+ expanded
492
+ onToggle={() => {}}
493
+ virtualize
494
+ height={300}
495
+ onSend={() => {}}
496
+ />
497
+ </MantineProvider>,
498
+ )
499
+
500
+ // Deliberately asserts WINDOWING, not which end is anchored. `waitFor` also rides out the
501
+ // lazy `@tanstack/react-virtual` import: until it resolves, the Suspense fallback renders all
502
+ // 120 rows unwindowed — the same count a row that DROPPED `virtualize` would render forever,
503
+ // which is what makes the timeout a real failure rather than a slow pass.
504
+ await waitFor(() => {
505
+ const rendered = container.querySelectorAll('[data-testid^="agent-message-"]').length
506
+ expect(rendered).toBeGreaterThan(0)
507
+ expect(rendered).toBeLessThan(120)
508
+ })
509
+ })
510
+ })