basalt-ui 1.11.0 → 1.12.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 (237) hide show
  1. package/AGENTS.md +21 -21
  2. package/CHANGELOG.md +15 -0
  3. package/README.md +36 -27
  4. package/agent/rules/basalt-content.md +16 -3
  5. package/dist/agent/adapter.d.ts +146 -0
  6. package/dist/agent/adapter.d.ts.map +1 -0
  7. package/dist/agent/adapter.js +473 -0
  8. package/dist/agent/adapter.test.d.ts +2 -0
  9. package/dist/agent/adapter.test.d.ts.map +1 -0
  10. package/dist/agent/ai-sdk-transport.test.d.ts +2 -0
  11. package/dist/agent/ai-sdk-transport.test.d.ts.map +1 -0
  12. package/dist/agent/coalesce.d.ts +20 -4
  13. package/dist/agent/coalesce.d.ts.map +1 -1
  14. package/dist/agent/coalesce.js +76 -8
  15. package/dist/agent/coalesce.test.d.ts +2 -0
  16. package/dist/agent/coalesce.test.d.ts.map +1 -0
  17. package/dist/agent/foreign.test.d.ts +2 -0
  18. package/dist/agent/foreign.test.d.ts.map +1 -0
  19. package/dist/agent/id.d.ts +30 -0
  20. package/dist/agent/id.d.ts.map +1 -1
  21. package/dist/agent/id.js +16 -0
  22. package/dist/agent/id.test.d.ts +2 -0
  23. package/dist/agent/id.test.d.ts.map +1 -0
  24. package/dist/agent/index.d.ts +2 -0
  25. package/dist/agent/index.d.ts.map +1 -1
  26. package/dist/agent/index.js +3 -0
  27. package/dist/agent/merge.d.ts.map +1 -1
  28. package/dist/agent/merge.js +19 -7
  29. package/dist/agent/merge.test.d.ts +2 -0
  30. package/dist/agent/merge.test.d.ts.map +1 -0
  31. package/dist/agent/part-list.d.ts +49 -34
  32. package/dist/agent/part-list.d.ts.map +1 -1
  33. package/dist/agent/part-list.js +42 -6
  34. package/dist/agent/part-list.test.d.ts +2 -0
  35. package/dist/agent/part-list.test.d.ts.map +1 -0
  36. package/dist/agent/parts.d.ts +46 -10
  37. package/dist/agent/parts.d.ts.map +1 -1
  38. package/dist/agent/parts.js +16 -6
  39. package/dist/agent/parts.test.d.ts +2 -0
  40. package/dist/agent/parts.test.d.ts.map +1 -0
  41. package/dist/agent/thread.d.ts +14 -0
  42. package/dist/agent/thread.d.ts.map +1 -1
  43. package/dist/agent/thread.js +8 -2
  44. package/dist/agent/thread.test.d.ts +2 -0
  45. package/dist/agent/thread.test.d.ts.map +1 -0
  46. package/dist/agent/transport.test.d.ts +2 -0
  47. package/dist/agent/transport.test.d.ts.map +1 -0
  48. package/dist/agent/use-agent-stream.test.d.ts +2 -0
  49. package/dist/agent/use-agent-stream.test.d.ts.map +1 -0
  50. package/dist/agent/use-agent-thread-runs.foreign.test.d.ts +2 -0
  51. package/dist/agent/use-agent-thread-runs.foreign.test.d.ts.map +1 -0
  52. package/dist/agent/use-agent-thread-runs.resume.test.d.ts +2 -0
  53. package/dist/agent/use-agent-thread-runs.resume.test.d.ts.map +1 -0
  54. package/dist/agent/use-agent-thread-runs.test.d.ts +2 -0
  55. package/dist/agent/use-agent-thread-runs.test.d.ts.map +1 -0
  56. package/dist/agent/use-agent-thread-runs.wedge.test.d.ts +2 -0
  57. package/dist/agent/use-agent-thread-runs.wedge.test.d.ts.map +1 -0
  58. package/dist/agent-chat/composer.d.ts +130 -8
  59. package/dist/agent-chat/composer.d.ts.map +1 -1
  60. package/dist/agent-chat/composer.js +189 -23
  61. package/dist/agent-chat/composer.test.d.ts +2 -0
  62. package/dist/agent-chat/composer.test.d.ts.map +1 -0
  63. package/dist/agent-chat/index.d.ts +1 -1
  64. package/dist/agent-chat/index.d.ts.map +1 -1
  65. package/dist/agent-chat/thread-detail-panel.d.ts.map +1 -1
  66. package/dist/agent-chat/thread-detail-panel.js +1 -1
  67. package/dist/agent-chat/thread-message.d.ts.map +1 -1
  68. package/dist/agent-chat/thread-message.js +14 -3
  69. package/dist/agent-chat/thread-message.test.d.ts +2 -0
  70. package/dist/agent-chat/thread-message.test.d.ts.map +1 -0
  71. package/dist/agent-chat/thread-workspace.js +1 -1
  72. package/dist/agent-chat/tool-chip.test.d.ts +2 -0
  73. package/dist/agent-chat/tool-chip.test.d.ts.map +1 -0
  74. package/dist/charts/primitives/ChartFrame.test.d.ts +2 -0
  75. package/dist/charts/primitives/ChartFrame.test.d.ts.map +1 -0
  76. package/dist/charts/utils/domain.test.d.ts +2 -0
  77. package/dist/charts/utils/domain.test.d.ts.map +1 -0
  78. package/dist/cli/check-theme.test.d.ts +2 -0
  79. package/dist/cli/check-theme.test.d.ts.map +1 -0
  80. package/dist/cli/help.test.d.ts +2 -0
  81. package/dist/cli/help.test.d.ts.map +1 -0
  82. package/dist/cli/index.d.ts.map +1 -1
  83. package/dist/cli/init-sync.test.d.ts +2 -0
  84. package/dist/cli/init-sync.test.d.ts.map +1 -0
  85. package/dist/cli/placement-engine.test.d.ts +2 -0
  86. package/dist/cli/placement-engine.test.d.ts.map +1 -0
  87. package/dist/cli/tarball-parity.test.d.ts +2 -0
  88. package/dist/cli/tarball-parity.test.d.ts.map +1 -0
  89. package/dist/cli/template-format.test.d.ts +2 -0
  90. package/dist/cli/template-format.test.d.ts.map +1 -0
  91. package/dist/cli/template-ledger.test.d.ts +2 -0
  92. package/dist/cli/template-ledger.test.d.ts.map +1 -0
  93. package/dist/cli/tokens-css.test.d.ts +2 -0
  94. package/dist/cli/tokens-css.test.d.ts.map +1 -0
  95. package/dist/content/article-actions.test.d.ts +2 -0
  96. package/dist/content/article-actions.test.d.ts.map +1 -0
  97. package/dist/content/article-model.test.d.ts +2 -0
  98. package/dist/content/article-model.test.d.ts.map +1 -0
  99. package/dist/content/block-split.test.d.ts +2 -0
  100. package/dist/content/block-split.test.d.ts.map +1 -0
  101. package/dist/content/fence-block.d.ts +37 -10
  102. package/dist/content/fence-block.d.ts.map +1 -1
  103. package/dist/content/fence-block.js +80 -14
  104. package/dist/content/heading-components.d.ts.map +1 -1
  105. package/dist/content/heading-components.js +7 -4
  106. package/dist/content/heading-components.test.d.ts +2 -0
  107. package/dist/content/heading-components.test.d.ts.map +1 -0
  108. package/dist/content/index.d.ts +6 -3
  109. package/dist/content/index.d.ts.map +1 -1
  110. package/dist/content/index.js +5 -1
  111. package/dist/content/markdown.d.ts +101 -14
  112. package/dist/content/markdown.d.ts.map +1 -1
  113. package/dist/content/markdown.js +141 -28
  114. package/dist/content/markdown.test.d.ts +2 -0
  115. package/dist/content/markdown.test.d.ts.map +1 -0
  116. package/dist/content/prose.module.css.test.d.ts +2 -0
  117. package/dist/content/prose.module.css.test.d.ts.map +1 -0
  118. package/dist/content/sanitize.d.ts +170 -0
  119. package/dist/content/sanitize.d.ts.map +1 -0
  120. package/dist/content/sanitize.js +178 -0
  121. package/dist/content/sanitize.test.d.ts +2 -0
  122. package/dist/content/sanitize.test.d.ts.map +1 -0
  123. package/dist/content/slug.test.d.ts +2 -0
  124. package/dist/content/slug.test.d.ts.map +1 -0
  125. package/dist/dashboard/stat-card.test.d.ts +2 -0
  126. package/dist/dashboard/stat-card.test.d.ts.map +1 -0
  127. package/dist/guard/check-source.test.d.ts +2 -0
  128. package/dist/guard/check-source.test.d.ts.map +1 -0
  129. package/dist/guard/guard-hook.test.d.ts +2 -0
  130. package/dist/guard/guard-hook.test.d.ts.map +1 -0
  131. package/dist/index.d.ts +3 -3
  132. package/dist/index.d.ts.map +1 -1
  133. package/dist/index.js +9 -1
  134. package/dist/provider/index.test.d.ts +2 -0
  135. package/dist/provider/index.test.d.ts.map +1 -0
  136. package/dist/provider/lab-theme.test.d.ts +2 -0
  137. package/dist/provider/lab-theme.test.d.ts.map +1 -0
  138. package/dist/router-tanstack/multi-search-param-store.test.d.ts +2 -0
  139. package/dist/router-tanstack/multi-search-param-store.test.d.ts.map +1 -0
  140. package/dist/styles.floor.test.d.ts +2 -0
  141. package/dist/styles.floor.test.d.ts.map +1 -0
  142. package/dist/surfaces.d.ts +4 -4
  143. package/dist/surfaces.d.ts.map +1 -1
  144. package/dist/surfaces.js +12 -3
  145. package/dist/theme/border-coverage.test.d.ts +18 -0
  146. package/dist/theme/border-coverage.test.d.ts.map +1 -0
  147. package/dist/theme/contrast.test.d.ts +2 -0
  148. package/dist/theme/contrast.test.d.ts.map +1 -0
  149. package/dist/theme/density-option.test.d.ts +2 -0
  150. package/dist/theme/density-option.test.d.ts.map +1 -0
  151. package/dist/theme/density-relations.test.d.ts +2 -0
  152. package/dist/theme/density-relations.test.d.ts.map +1 -0
  153. package/dist/theme/derive-theme.test.d.ts +2 -0
  154. package/dist/theme/derive-theme.test.d.ts.map +1 -0
  155. package/dist/theme/fonts-option.test.d.ts +2 -0
  156. package/dist/theme/fonts-option.test.d.ts.map +1 -0
  157. package/dist/theme/radius-option.test.d.ts +2 -0
  158. package/dist/theme/radius-option.test.d.ts.map +1 -0
  159. package/dist/theme/radius.test.d.ts +2 -0
  160. package/dist/theme/radius.test.d.ts.map +1 -0
  161. package/dist/theme/shadow-surfaces.test.d.ts +26 -0
  162. package/dist/theme/shadow-surfaces.test.d.ts.map +1 -0
  163. package/dist/theme/spacing.test.d.ts +2 -0
  164. package/dist/theme/spacing.test.d.ts.map +1 -0
  165. package/dist/theme/use-basalt-spacing.test.d.ts +2 -0
  166. package/dist/theme/use-basalt-spacing.test.d.ts.map +1 -0
  167. package/dist/theme-lab/derive-controls.test.d.ts +2 -0
  168. package/dist/theme-lab/derive-controls.test.d.ts.map +1 -0
  169. package/dist/theme-lab/derive-state.test.d.ts +2 -0
  170. package/dist/theme-lab/derive-state.test.d.ts.map +1 -0
  171. package/dist/theme-lab/theme-lab.test.d.ts +2 -0
  172. package/dist/theme-lab/theme-lab.test.d.ts.map +1 -0
  173. package/dist/tokens/build-fonts-css.test.d.ts +2 -0
  174. package/dist/tokens/build-fonts-css.test.d.ts.map +1 -0
  175. package/dist/tokens/build-radius-css.test.d.ts +2 -0
  176. package/dist/tokens/build-radius-css.test.d.ts.map +1 -0
  177. package/dist/tokens/density.test.d.ts +2 -0
  178. package/dist/tokens/density.test.d.ts.map +1 -0
  179. package/dist/tokens/derive-radius.test.d.ts +2 -0
  180. package/dist/tokens/derive-radius.test.d.ts.map +1 -0
  181. package/dist/tokens/derive.test.d.ts +2 -0
  182. package/dist/tokens/derive.test.d.ts.map +1 -0
  183. package/dist/tokens/palette.test.d.ts +2 -0
  184. package/dist/tokens/palette.test.d.ts.map +1 -0
  185. package/dist/vite-color-mix.test.d.ts +2 -0
  186. package/dist/vite-color-mix.test.d.ts.map +1 -0
  187. package/dist/vite.test.d.ts +2 -0
  188. package/dist/vite.test.d.ts.map +1 -0
  189. package/llms.txt +6 -6
  190. package/package.json +6 -1
  191. package/src/agent/adapter.test.ts +1403 -0
  192. package/src/agent/adapter.ts +887 -0
  193. package/src/agent/ai-sdk-transport.test.ts +53 -26
  194. package/src/agent/coalesce.test.ts +202 -9
  195. package/src/agent/coalesce.ts +126 -15
  196. package/src/agent/foreign.test.ts +5 -1
  197. package/src/agent/id.test.ts +6 -2
  198. package/src/agent/id.ts +48 -0
  199. package/src/agent/index.ts +8 -0
  200. package/src/agent/merge.test.ts +87 -0
  201. package/src/agent/merge.ts +57 -10
  202. package/src/agent/part-list.test.tsx +31 -1
  203. package/src/agent/part-list.tsx +77 -29
  204. package/src/agent/parts.test.ts +121 -4
  205. package/src/agent/parts.ts +78 -17
  206. package/src/agent/thread.test.ts +75 -2
  207. package/src/agent/thread.ts +21 -1
  208. package/src/agent/use-agent-stream.test.tsx +9 -5
  209. package/src/agent/use-agent-thread-runs.foreign.test.tsx +5 -0
  210. package/src/agent/use-agent-thread-runs.resume.test.tsx +16 -11
  211. package/src/agent/use-agent-thread-runs.test.tsx +30 -17
  212. package/src/agent/use-agent-thread-runs.wedge.test.tsx +13 -7
  213. package/src/agent-chat/composer.test.tsx +924 -0
  214. package/src/agent-chat/composer.tsx +521 -52
  215. package/src/agent-chat/index.ts +1 -1
  216. package/src/agent-chat/thread-detail-panel.tsx +3 -1
  217. package/src/agent-chat/thread-message.test.tsx +141 -1
  218. package/src/agent-chat/thread-message.tsx +29 -4
  219. package/src/agent-chat/thread-workspace.tsx +1 -1
  220. package/src/cli/index.ts +0 -1
  221. package/src/cli/template-format.test.ts +2 -2
  222. package/src/content/fence-block.tsx +242 -15
  223. package/src/content/heading-components.test.tsx +65 -0
  224. package/src/content/heading-components.tsx +26 -5
  225. package/src/content/index.ts +19 -3
  226. package/src/content/markdown.test.tsx +603 -0
  227. package/src/content/markdown.tsx +355 -35
  228. package/src/content/sanitize.test.ts +659 -0
  229. package/src/content/sanitize.ts +510 -0
  230. package/src/dashboard/stat-card.test.tsx +6 -1
  231. package/src/index.ts +13 -1
  232. package/src/surfaces.ts +12 -3
  233. package/src/theme/contrast.test.ts +1 -4
  234. package/src/theme/density-relations.test.ts +4 -2
  235. package/src/theme/fonts-option.test.ts +7 -1
  236. package/src/theme/shadow-surfaces.test.ts +12 -6
  237. package/src/theme/use-basalt-spacing.test.tsx +2 -1
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { describe, expect, test } from 'bun:test'
6
6
  import { withPartIds } from './id'
7
+ import type { AgentPartDraft } from './parts'
7
8
 
8
9
  async function* gen<T>(values: T[]): AsyncGenerator<T> {
9
10
  for (const value of values) yield value
@@ -17,7 +18,10 @@ async function collect<T>(source: AsyncGenerator<T>): Promise<T[]> {
17
18
 
18
19
  describe('withPartIds', () => {
19
20
  test('stamps a sequential id on every draft missing one', async () => {
20
- const drafts = [
21
+ // Annotated: all three literals uniformly omit `id`, so without this TS infers an `id`-less
22
+ // element type that has no properties in common with withPartIds' `{ id?: string }`
23
+ // constraint — a weak-type check failure, not a real shape mismatch.
24
+ const drafts: AgentPartDraft[] = [
21
25
  { type: 'text', text: 'a' },
22
26
  { type: 'text', text: 'b' },
23
27
  { type: 'text', text: 'c' },
@@ -37,7 +41,7 @@ describe('withPartIds', () => {
37
41
  })
38
42
 
39
43
  test('is idempotent: re-running withPartIds over already-identified parts changes nothing', async () => {
40
- const drafts = [{ type: 'text', text: 'a' }]
44
+ const drafts: AgentPartDraft[] = [{ type: 'text', text: 'a' }]
41
45
  const once = await collect(withPartIds('run-1', gen(drafts)))
42
46
  const twice = await collect(withPartIds('run-1', gen(once)))
43
47
  expect(twice).toEqual(once)
package/src/agent/id.ts CHANGED
@@ -1,3 +1,51 @@
1
+ /**
2
+ * Id-minting helpers shared by the thread stores: `mintThreadId` (a client-side thread id, used by
3
+ * both `./thread`'s `createThreadsStore` and `./adapter`'s `createAdapterThreadsStore`) and
4
+ * `withPartIds` (stamps sequence ids onto streamed parts). Related in theme — both mint an id a
5
+ * store needs — unrelated in mechanism; grouped here because they're the two id-minting leaves the
6
+ * two thread stores both sit above.
7
+ */
8
+
9
+ /**
10
+ * Mints a client-side thread id. `crypto.randomUUID()` exists only in SECURE CONTEXTS (HTTPS or
11
+ * localhost) — a consumer served over plain HTTP on a LAN hostname (a homelab dashboard on a bare
12
+ * IP, a staging box) would otherwise have `create()`, about the most basic user action a thread
13
+ * store has, throw a plain TypeError. A store used from a browser must not throw on a user gesture
14
+ * — the same principle already applied twice this release (`spliceText` clamps rather than throws,
15
+ * `coalesceParts` degrades rather than throws).
16
+ *
17
+ * Fallback chain, each rung reached only when the one above it is unavailable:
18
+ * 1. `crypto.randomUUID()` — cryptographically random, RFC 4122 UUID. The normal path.
19
+ * 2. `crypto.getRandomValues()` — still cryptographically random; hand-assembled into a UUIDv4
20
+ * (the version/variant bits are set per RFC 4122, since this rung hands back raw bytes, not a
21
+ * formatted UUID).
22
+ * 3. No usable `crypto` at all (missing entirely, or missing both methods above — an old WebView,
23
+ * an SSR shim). NOT cryptographically random and NOT collision-resistant in general. This rung
24
+ * exists solely so `create()` never throws on such a host. It is acceptable ONLY because this
25
+ * id is minted a handful of times per client session and is not the idempotency key any write
26
+ * here depends on — `ThreadsStoreAdapter.appendMessage`'s idempotency key is the MESSAGE id,
27
+ * not this thread id, so a collision on this rung costs a locally merged/duplicated thread, not
28
+ * silent data loss on the append path. Do not reuse this rung for anything with a higher
29
+ * collision cost than that.
30
+ */
31
+ export function mintThreadId(): string {
32
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
33
+ return crypto.randomUUID()
34
+ }
35
+ if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
36
+ const bytes = crypto.getRandomValues(new Uint8Array(16))
37
+ const hex = Array.from({ length: 16 }, (_, i) => {
38
+ const raw = bytes.at(i) ?? 0
39
+ // Version 4 (bits 12-15 of time_hi_and_version) and variant 10 (bits 6-7 of
40
+ // clock_seq_hi_and_reserved) — the only structure a UUIDv4 promises over raw random bytes.
41
+ const byte = i === 6 ? (raw & 0x0f) | 0x40 : i === 8 ? (raw & 0x3f) | 0x80 : raw
42
+ return byte.toString(16).padStart(2, '0')
43
+ }).join('')
44
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
45
+ }
46
+ return `id-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
47
+ }
48
+
1
49
  /**
2
50
  * withPartIds — stamps a stable id onto every draft part arriving without one.
3
51
  *
@@ -113,3 +113,11 @@ export type { AgentThread, ThreadStatus, ThreadsStore, ThreadsStoreOptions } fro
113
113
  // ── AgentOutcome + heuristicOutcome ────────────────────────────────────────────
114
114
  export { heuristicOutcome } from './outcome'
115
115
  export type { AgentOutcome, OutcomeResolver } from './outcome'
116
+
117
+ // ── createAdapterThreadsStore + threadsStoreAdapterContract ──────────────────
118
+ export { createAdapterThreadsStore, threadsStoreAdapterContract } from './adapter'
119
+ export type {
120
+ AdapterThreadsStoreOptions,
121
+ ThreadsStoreAdapter,
122
+ ThreadsStoreAdapterContractCase,
123
+ } from './adapter'
@@ -57,6 +57,93 @@ describe('mergePart', () => {
57
57
  expect(replayed).toEqual(firstPassResult.map((p) => ({ ...p, offset: 0 })))
58
58
  })
59
59
 
60
+ // ── out-of-range offsets ───────────────────────────────────────────────────
61
+ // spliceText supports exactly two shapes (append at the end, in-range replace). Everything else
62
+ // is CLAMPED into [0, existing.text.length] with a dev warning — never thrown on, because a
63
+ // throw on the render path turns a transient wire anomaly into a permanently dead transcript.
64
+
65
+ test('an offset past the end of the existing text clamps to the tail (appends, leaves no hole)', () => {
66
+ const parts: TextPart[] = [{ id: 'p1', type: 'text', text: 'Hi' }]
67
+ const [merged] = mergePart(parts, { id: 'p1', type: 'text', text: '!', offset: 10 })
68
+ expect(merged?.text).toBe('Hi!')
69
+ })
70
+
71
+ test('a negative offset clamps to 0 rather than slicing from the end', () => {
72
+ const parts: TextPart[] = [{ id: 'p1', type: 'text', text: 'Hello world' }]
73
+ const [merged] = mergePart(parts, { id: 'p1', type: 'text', text: 'Hey', offset: -3 })
74
+ expect(merged?.text).toBe('Heylo world')
75
+ })
76
+
77
+ test('an out-of-order (already-covered) offset replaces in place — it never re-appends', () => {
78
+ // 'Hello world' is fully accumulated; a straggler delta for offset 6 arrives after the tail
79
+ // has moved past it. In-range, so it is honoured as a replace, not clamped.
80
+ const parts: TextPart[] = [{ id: 'p1', type: 'text', text: 'Hello world' }]
81
+ const [merged] = mergePart(parts, { id: 'p1', type: 'text', text: 'WORLD', offset: 6 })
82
+ expect(merged?.text).toBe('Hello WORLD')
83
+ })
84
+
85
+ test('a non-finite offset clamps to the tail rather than blanking the part', () => {
86
+ const parts: TextPart[] = [{ id: 'p1', type: 'text', text: 'Hi' }]
87
+ const [merged] = mergePart(parts, { id: 'p1', type: 'text', text: '!', offset: Number.NaN })
88
+ expect(merged?.text).toBe('Hi!')
89
+ })
90
+
91
+ test('a FIRST insertion with a nonzero offset clamps and warns like any other out-of-range one', () => {
92
+ // A first insertion splices against an EMPTY existing text, so the offset is out of range
93
+ // [0, 0]: mergePart never invents the 2 missing characters, it stores what arrived — but the
94
+ // anomaly is announced, exactly as it is for an existing part. One contract, one code path.
95
+ const warnings: string[] = []
96
+ const original = console.warn
97
+ console.warn = (...args: unknown[]) => void warnings.push(String(args[0]))
98
+ let first: TextPart[]
99
+ try {
100
+ first = mergePart<TextPart>([], { id: 'p1', type: 'text', text: 'llo', offset: 2 })
101
+ } finally {
102
+ console.warn = original
103
+ }
104
+ expect(warnings).toHaveLength(1)
105
+ expect(warnings[0]).toContain('out of range [0, 0]')
106
+ expect(first).toEqual([{ id: 'p1', type: 'text', text: 'llo', offset: 2 }])
107
+
108
+ // The offset rides along inert; the next delta for this id splices relative to the STORED text.
109
+ const [merged] = mergePart(first, { id: 'p1', type: 'text', text: ' there' })
110
+ expect(merged?.text).toBe('llo there')
111
+ })
112
+
113
+ test('a FIRST insertion with an in-range (0 or undefined) offset stays silent and stores the text as-is', () => {
114
+ const warnings: string[] = []
115
+ const original = console.warn
116
+ console.warn = (...args: unknown[]) => void warnings.push(String(args[0]))
117
+ try {
118
+ expect(mergePart<TextPart>([], { id: 'p1', type: 'text', text: 'Hello', offset: 0 })).toEqual(
119
+ [{ id: 'p1', type: 'text', text: 'Hello', offset: 0 }],
120
+ )
121
+ expect(mergePart<TextPart>([], { id: 'p2', type: 'text', text: 'Hello' })).toEqual([
122
+ { id: 'p2', type: 'text', text: 'Hello' },
123
+ ])
124
+ } finally {
125
+ console.warn = original
126
+ }
127
+ expect(warnings).toHaveLength(0)
128
+ })
129
+
130
+ test('warns in dev on an out-of-range offset, and stays silent on an in-range one', () => {
131
+ const warnings: string[] = []
132
+ const original = console.warn
133
+ console.warn = (...args: unknown[]) => void warnings.push(String(args[0]))
134
+ try {
135
+ const parts: TextPart[] = [{ id: 'p1', type: 'text', text: 'Hi' }]
136
+ mergePart(parts, { id: 'p1', type: 'text', text: '!', offset: 1 })
137
+ expect(warnings).toHaveLength(0)
138
+
139
+ mergePart(parts, { id: 'p1', type: 'text', text: '!', offset: 10 })
140
+ expect(warnings).toHaveLength(1)
141
+ expect(warnings[0]).toContain('out of range')
142
+ } finally {
143
+ console.warn = original
144
+ }
145
+ })
146
+
60
147
  test('non-text/reasoning variants replace wholesale rather than merging fields', () => {
61
148
  const parts: ToolCallPart[] = [
62
149
  {
@@ -4,8 +4,11 @@
4
4
  * Appending a part whose `id` is already present in `parts` REWRITES that entry in place rather
5
5
  * than pushing a duplicate — ordering stays stable (a rewritten part keeps its original position).
6
6
  * `text`/`reasoning` splice the incoming delta into the existing text at `next.offset` (an
7
- * undefined offset means "append at the tail"); every other variant — including any `ForeignPart`,
8
- * which is never text-like — replaces wholesale.
7
+ * undefined offset means "append at the tail", and an out-of-range one is clamped rather than
8
+ * honoured — see `spliceText` for the exact supported shapes); every other variant — including any
9
+ * `ForeignPart`, which is never text-like — replaces wholesale. A FIRST insertion of a text-like
10
+ * part goes through the same splice against an empty text, so an offset that claims a prefix this
11
+ * accumulator never saw is clamped and warned about there too — never honoured, never padded.
9
12
  *
10
13
  * The invariant this holds, and the reason `offset` exists at all: replaying a whole run from
11
14
  * character 0 rebuilds the identical parts array it produced the first time — it cannot double
@@ -41,26 +44,70 @@ function isTextLikeType(type: string): type is TextLike['type'] {
41
44
  return type === 'text' || type === 'reasoning'
42
45
  }
43
46
 
44
- /** Splices `next`'s text into `existing`'s text at `next.offset` (tail-append when undefined). */
45
- function spliceText(existing: TextLike, next: TextLike): string {
46
- const offset = next.offset ?? existing.text.length
47
- return existing.text.slice(0, offset) + next.text + existing.text.slice(offset + next.text.length)
47
+ /**
48
+ * Splices `next`'s text into `existingText` at `next.offset`. Exactly two shapes are supported,
49
+ * and they are the only two any basalt transport emits (`diffPart` always sets
50
+ * `offset = prevText.length`; `diffChunkStream` restarts its accumulator per call):
51
+ *
52
+ * 1. **append at the end** — `offset` undefined, or equal to `existingText.length`;
53
+ * 2. **in-range replace** — `0 <= offset <= existingText.length`, overwriting the span the
54
+ * incoming text covers (this is what makes an offset-0 full replay converge instead of double).
55
+ *
56
+ * An offset outside that range — negative, past the current length, or non-finite — is **clamped**
57
+ * into `[0, existingText.length]`, not honoured and not thrown on. A throw here would turn a
58
+ * transient wire anomaly into a permanently dead transcript; this is stream-reduction code on the
59
+ * render path. Each clamp is announced in dev.
60
+ *
61
+ * A FIRST insertion (no entry with this id yet) runs through here too, against an empty
62
+ * `existingText`: the stored text is the incoming text either way, but a nonzero offset — the wire
63
+ * claiming text this accumulator never saw — is clamped to 0 and announced like any other
64
+ * out-of-range value rather than passing silently. `mergePart` never invents the missing prefix.
65
+ *
66
+ * Note what is NOT special-cased: an out-of-order delta whose offset is still in range is an
67
+ * ordinary in-range replace, so it overwrites whatever already occupies that span. `mergePart`
68
+ * has no way to tell a stale delta from a deliberate rewrite, and inventing one would break the
69
+ * offset-0 replay convergence this whole mechanism exists for.
70
+ */
71
+ function spliceText(existingText: string, next: TextLike): string {
72
+ const offset = clampOffset(next.offset ?? existingText.length, existingText.length)
73
+ return existingText.slice(0, offset) + next.text + existingText.slice(offset + next.text.length)
74
+ }
75
+
76
+ /** Clamps a wire offset into `[0, length]`, warning in dev when the raw value was out of range. */
77
+ function clampOffset(offset: number, length: number): number {
78
+ // A non-finite offset would make every slice() return '' and blank the part outright — it is
79
+ // out of range like any other, and appending is the safe reading of "position unknown".
80
+ const clamped = Number.isFinite(offset) ? Math.min(Math.max(offset, 0), length) : length
81
+ if (clamped !== offset && process.env['NODE_ENV'] !== 'production') {
82
+ console.warn(
83
+ `[basalt] mergePart: text offset ${offset} out of range [0, ${length}] — clamped to ${clamped}`,
84
+ )
85
+ }
86
+ return clamped
48
87
  }
49
88
 
50
89
  export function mergePart<TPart extends PartLike>(parts: readonly TPart[], next: TPart): TPart[] {
51
90
  const index = parts.findIndex((part) => part.id === next.id)
52
- if (index === -1) return [...parts, next]
91
+ const existing = index === -1 ? undefined : (parts[index] as TPart)
53
92
 
54
- const existing = parts[index] as TPart
55
- const spliceable = existing.type === next.type && isTextLikeType(next.type)
93
+ // A first insertion of a text-like part is spliced too — against an empty existing text — so the
94
+ // offset contract is one path with one set of rules: same clamp, same dev warning, and the same
95
+ // stored result (`'' + next.text`) an unconditional append would have produced.
96
+ const spliceable =
97
+ isTextLikeType(next.type) && (existing === undefined || existing.type === next.type)
56
98
 
57
99
  const merged: TPart = spliceable
58
100
  ? ({
59
101
  ...next,
60
- text: spliceText(existing as unknown as TextLike, next as unknown as TextLike),
102
+ text: spliceText(
103
+ existing === undefined ? '' : (existing as unknown as TextLike).text,
104
+ next as unknown as TextLike,
105
+ ),
61
106
  } as TPart)
62
107
  : next
63
108
 
109
+ if (existing === undefined) return [...parts, merged]
110
+
64
111
  const out = parts.slice()
65
112
  out[index] = merged
66
113
  return out
@@ -5,11 +5,14 @@
5
5
  * `output` field, so the old `part.state === 'output-available'`-only body left a failed call
6
6
  * rendering as name+input and nothing else — the failure was silently invisible. Same gap for
7
7
  * `output-denied`, indistinguishable from a still-pending call.
8
+ *
9
+ * Plus the `settled` flag's forwarding contract — the field that lets a text renderer drop out of
10
+ * streaming mode once the message it belongs to has finished.
8
11
  */
9
12
  import { cleanup, render, screen } from '@testing-library/react'
10
13
  import { afterEach, describe, expect, test } from 'bun:test'
11
14
  import { PartList } from './part-list'
12
- import type { ToolCallPart } from './parts'
15
+ import type { AgentPart, ToolCallPart } from './parts'
13
16
 
14
17
  afterEach(cleanup)
15
18
 
@@ -80,3 +83,30 @@ describe('DefaultToolCall — terminal states stay visible', () => {
80
83
  expect(screen.queryByText(/Denied/)).toBeNull()
81
84
  })
82
85
  })
86
+
87
+ describe('settled — forwarded to every renderer', () => {
88
+ const TEXT: AgentPart = { id: 't1', type: 'text', text: 'hello' }
89
+
90
+ function settledProbe() {
91
+ const seen: boolean[] = []
92
+ const components = {
93
+ text: ({ settled }: { settled: boolean }) => {
94
+ seen.push(settled)
95
+ return <div data-testid="probe">{String(settled)}</div>
96
+ },
97
+ }
98
+ return { seen, components }
99
+ }
100
+
101
+ test('defaults to true — a caller that knows nothing about a run renders a finished list', () => {
102
+ const { seen, components } = settledProbe()
103
+ render(<PartList parts={[TEXT]} components={components} />)
104
+ expect(seen).toEqual([true])
105
+ })
106
+
107
+ test('an explicit settled={false} reaches the renderer', () => {
108
+ const { seen, components } = settledProbe()
109
+ render(<PartList parts={[TEXT]} components={components} settled={false} />)
110
+ expect(seen).toEqual([false])
111
+ })
112
+ })
@@ -19,7 +19,9 @@
19
19
  * <PartList
20
20
  * parts={parts}
21
21
  * components={{
22
- * text: ({ part }) => <Markdown streaming density="chat">{part.text}</Markdown>,
22
+ * text: ({ part, settled }) => (
23
+ * <Markdown streaming={!settled} contentTrust="untrusted" density="chat">{part.text}</Markdown>
24
+ * ),
23
25
  * }}
24
26
  * />
25
27
  */
@@ -38,37 +40,48 @@ import type {
38
40
 
39
41
  // ── Per-type renderer signatures ──────────────────────────────────────────────
40
42
 
41
- export type TextPartRenderer<TPart extends AgentPart = AgentPart> = (props: {
42
- part: Extract<TPart, { type: 'text' }>
43
+ /**
44
+ * The per-renderer argument. `settled` is `false` only while the message these parts belong to is
45
+ * still streaming — it converges this map toward the `PartRenderContext` a foreign renderer
46
+ * already receives (`./foreign`), rather than adding a third shape. Additive and
47
+ * source-compatible: an existing `({ part }) => …` renderer simply ignores it.
48
+ *
49
+ * Settlement is MONOTONE — a message that has settled never un-settles.
50
+ */
51
+ type PartRenderArgs<TPart> = {
52
+ part: TPart
43
53
  index: number
44
- }) => JSX.Element | null
54
+ settled: boolean
55
+ }
45
56
 
46
- export type ReasoningPartRenderer<TPart extends AgentPart = AgentPart> = (props: {
47
- part: Extract<TPart, { type: 'reasoning' }>
48
- index: number
49
- }) => JSX.Element | null
57
+ export type TextPartRenderer<TPart extends AgentPart = AgentPart> = (
58
+ props: PartRenderArgs<Extract<TPart, { type: 'text' }>>,
59
+ ) => JSX.Element | null
50
60
 
51
- export type ToolCallPartRenderer<TPart extends AgentPart = AgentPart> = (props: {
52
- part: Extract<TPart, { type: 'tool' }>
53
- index: number
54
- }) => JSX.Element | null
61
+ export type ReasoningPartRenderer<TPart extends AgentPart = AgentPart> = (
62
+ props: PartRenderArgs<Extract<TPart, { type: 'reasoning' }>>,
63
+ ) => JSX.Element | null
55
64
 
56
- export type SourcePartRenderer<TPart extends AgentPart = AgentPart> = (props: {
57
- part: Extract<TPart, { type: 'source' }>
58
- index: number
59
- }) => JSX.Element | null
65
+ export type ToolCallPartRenderer<TPart extends AgentPart = AgentPart> = (
66
+ props: PartRenderArgs<Extract<TPart, { type: 'tool' }>>,
67
+ ) => JSX.Element | null
60
68
 
61
- export type ErrorPartRenderer<TPart extends AgentPart = AgentPart> = (props: {
62
- part: Extract<TPart, { type: 'error' }>
63
- index: number
64
- }) => JSX.Element | null
69
+ export type SourcePartRenderer<TPart extends AgentPart = AgentPart> = (
70
+ props: PartRenderArgs<Extract<TPart, { type: 'source' }>>,
71
+ ) => JSX.Element | null
72
+
73
+ export type ErrorPartRenderer<TPart extends AgentPart = AgentPart> = (
74
+ props: PartRenderArgs<Extract<TPart, { type: 'error' }>>,
75
+ ) => JSX.Element | null
65
76
 
66
77
  /**
67
78
  * Partial map of per-type renderers. Any omitted key falls back to the headless default.
68
79
  *
69
80
  * @example
70
81
  * const renderers: Partial<AgentPartRenderers> = {
71
- * text: ({ part }) => <Markdown streaming density="chat">{part.text}</Markdown>,
82
+ * text: ({ part, settled }) => (
83
+ * <Markdown streaming={!settled} contentTrust="untrusted" density="chat">{part.text}</Markdown>
84
+ * ),
72
85
  * }
73
86
  */
74
87
  export type AgentPartRenderers<TPart extends AgentPart = AgentPart> = {
@@ -231,10 +244,17 @@ export type PartListProps<TPart extends AgentPart = AgentPart> = {
231
244
  readonly parts: TPart[]
232
245
  /**
233
246
  * Override individual part renderers. Omitted keys fall back to the headless defaults.
234
- * Pass `{ text: ({ part }) => <Markdown streaming density="chat">{part.text}</Markdown> }` to
235
- * enable rich markdown rendering.
247
+ * Pass `{ text: ({ part, settled }) => <Markdown streaming={!settled} contentTrust="untrusted" density="chat">{part.text}</Markdown> }`
248
+ * to enable rich markdown rendering.
236
249
  */
237
250
  readonly components?: Partial<AgentPartRenderers<TPart>>
251
+ /**
252
+ * `false` while the message these parts belong to is still streaming — forwarded to every
253
+ * renderer so a text renderer can drop out of streaming mode (and a fenced mermaid/diagram can
254
+ * upgrade) once the turn finishes. Defaults to `true`: a caller that knows nothing about a run
255
+ * is rendering a finished list, and un-settled must be opted into by the thing that does know.
256
+ */
257
+ readonly settled?: boolean
238
258
  }
239
259
 
240
260
  /**
@@ -251,11 +271,19 @@ export type PartListProps<TPart extends AgentPart = AgentPart> = {
251
271
  * // With basalt's own Markdown for the text renderer:
252
272
  * import { PartList } from 'basalt-ui/agent'
253
273
  * import { Markdown } from 'basalt-ui/content'
254
- * <PartList parts={parts} components={{ text: ({ part }) => <Markdown streaming density="chat">{part.text}</Markdown> }} />
274
+ * <PartList
275
+ * parts={parts}
276
+ * components={{
277
+ * text: ({ part, settled }) => (
278
+ * <Markdown streaming={!settled} contentTrust="untrusted" density="chat">{part.text}</Markdown>
279
+ * ),
280
+ * }}
281
+ * />
255
282
  */
256
283
  export function PartList<TPart extends AgentPart = AgentPart>({
257
284
  parts,
258
285
  components,
286
+ settled = true,
259
287
  }: PartListProps<TPart>): JSX.Element {
260
288
  // Memoised to avoid rebuilding the renderer map on every streaming re-render (hot path).
261
289
  // Cast required because DEFAULT_RENDERERS is typed for the base AgentPart, not the generic TPart.
@@ -277,7 +305,11 @@ export function PartList<TPart extends AgentPart = AgentPart>({
277
305
  const Render = renderers.text
278
306
  return (
279
307
  <Fragment key={part.id}>
280
- <Render part={part as Extract<TPart, { type: 'text' }>} index={index} />
308
+ <Render
309
+ part={part as Extract<TPart, { type: 'text' }>}
310
+ index={index}
311
+ settled={settled}
312
+ />
281
313
  </Fragment>
282
314
  )
283
315
  }
@@ -285,7 +317,11 @@ export function PartList<TPart extends AgentPart = AgentPart>({
285
317
  const Render = renderers.reasoning
286
318
  return (
287
319
  <Fragment key={part.id}>
288
- <Render part={part as Extract<TPart, { type: 'reasoning' }>} index={index} />
320
+ <Render
321
+ part={part as Extract<TPart, { type: 'reasoning' }>}
322
+ index={index}
323
+ settled={settled}
324
+ />
289
325
  </Fragment>
290
326
  )
291
327
  }
@@ -293,7 +329,11 @@ export function PartList<TPart extends AgentPart = AgentPart>({
293
329
  const Render = renderers.tool
294
330
  return (
295
331
  <Fragment key={part.id}>
296
- <Render part={part as Extract<TPart, { type: 'tool' }>} index={index} />
332
+ <Render
333
+ part={part as Extract<TPart, { type: 'tool' }>}
334
+ index={index}
335
+ settled={settled}
336
+ />
297
337
  </Fragment>
298
338
  )
299
339
  }
@@ -301,7 +341,11 @@ export function PartList<TPart extends AgentPart = AgentPart>({
301
341
  const Render = renderers.source
302
342
  return (
303
343
  <Fragment key={part.id}>
304
- <Render part={part as Extract<TPart, { type: 'source' }>} index={index} />
344
+ <Render
345
+ part={part as Extract<TPart, { type: 'source' }>}
346
+ index={index}
347
+ settled={settled}
348
+ />
305
349
  </Fragment>
306
350
  )
307
351
  }
@@ -309,7 +353,11 @@ export function PartList<TPart extends AgentPart = AgentPart>({
309
353
  const Render = renderers.error
310
354
  return (
311
355
  <Fragment key={part.id}>
312
- <Render part={part as Extract<TPart, { type: 'error' }>} index={index} />
356
+ <Render
357
+ part={part as Extract<TPart, { type: 'error' }>}
358
+ index={index}
359
+ settled={settled}
360
+ />
313
361
  </Fragment>
314
362
  )
315
363
  }