basalt-ui 1.10.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 (273) hide show
  1. package/AGENTS.md +21 -21
  2. package/CHANGELOG.md +25 -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.d.ts +3 -52
  11. package/dist/agent/ai-sdk-transport.d.ts.map +1 -1
  12. package/dist/agent/ai-sdk-transport.js +108 -24
  13. package/dist/agent/ai-sdk-transport.test.d.ts +2 -0
  14. package/dist/agent/ai-sdk-transport.test.d.ts.map +1 -0
  15. package/dist/agent/coalesce.d.ts +42 -0
  16. package/dist/agent/coalesce.d.ts.map +1 -0
  17. package/dist/agent/coalesce.js +137 -0
  18. package/dist/agent/coalesce.test.d.ts +2 -0
  19. package/dist/agent/coalesce.test.d.ts.map +1 -0
  20. package/dist/agent/foreign.d.ts +97 -0
  21. package/dist/agent/foreign.d.ts.map +1 -0
  22. package/dist/agent/foreign.js +18 -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/history.d.ts +2 -0
  26. package/dist/agent/history.d.ts.map +1 -1
  27. package/dist/agent/id.d.ts +65 -0
  28. package/dist/agent/id.d.ts.map +1 -0
  29. package/dist/agent/id.js +30 -0
  30. package/dist/agent/id.test.d.ts +2 -0
  31. package/dist/agent/id.test.d.ts.map +1 -0
  32. package/dist/agent/index.d.ts +11 -4
  33. package/dist/agent/index.d.ts.map +1 -1
  34. package/dist/agent/index.js +18 -3
  35. package/dist/agent/merge.d.ts +8 -0
  36. package/dist/agent/merge.d.ts.map +1 -0
  37. package/dist/agent/merge.js +35 -0
  38. package/dist/agent/merge.test.d.ts +2 -0
  39. package/dist/agent/merge.test.d.ts.map +1 -0
  40. package/dist/agent/part-list.d.ts +49 -34
  41. package/dist/agent/part-list.d.ts.map +1 -1
  42. package/dist/agent/part-list.js +53 -7
  43. package/dist/agent/part-list.test.d.ts +2 -0
  44. package/dist/agent/part-list.test.d.ts.map +1 -0
  45. package/dist/agent/parts.d.ts +135 -20
  46. package/dist/agent/parts.d.ts.map +1 -1
  47. package/dist/agent/parts.js +138 -11
  48. package/dist/agent/parts.test.d.ts +2 -0
  49. package/dist/agent/parts.test.d.ts.map +1 -0
  50. package/dist/agent/thread.d.ts +14 -0
  51. package/dist/agent/thread.d.ts.map +1 -1
  52. package/dist/agent/thread.js +8 -2
  53. package/dist/agent/thread.test.d.ts +2 -0
  54. package/dist/agent/thread.test.d.ts.map +1 -0
  55. package/dist/agent/transport.d.ts +39 -3
  56. package/dist/agent/transport.d.ts.map +1 -1
  57. package/dist/agent/transport.js +20 -17
  58. package/dist/agent/transport.test.d.ts +2 -0
  59. package/dist/agent/transport.test.d.ts.map +1 -0
  60. package/dist/agent/use-agent-stream.d.ts +2 -1
  61. package/dist/agent/use-agent-stream.d.ts.map +1 -1
  62. package/dist/agent/use-agent-stream.js +5 -2
  63. package/dist/agent/use-agent-stream.test.d.ts +2 -0
  64. package/dist/agent/use-agent-stream.test.d.ts.map +1 -0
  65. package/dist/agent/use-agent-thread-runs.d.ts +9 -3
  66. package/dist/agent/use-agent-thread-runs.d.ts.map +1 -1
  67. package/dist/agent/use-agent-thread-runs.foreign.test.d.ts +2 -0
  68. package/dist/agent/use-agent-thread-runs.foreign.test.d.ts.map +1 -0
  69. package/dist/agent/use-agent-thread-runs.js +75 -23
  70. package/dist/agent/use-agent-thread-runs.resume.test.d.ts +2 -0
  71. package/dist/agent/use-agent-thread-runs.resume.test.d.ts.map +1 -0
  72. package/dist/agent/use-agent-thread-runs.test.d.ts +2 -0
  73. package/dist/agent/use-agent-thread-runs.test.d.ts.map +1 -0
  74. package/dist/agent/use-agent-thread-runs.wedge.test.d.ts +2 -0
  75. package/dist/agent/use-agent-thread-runs.wedge.test.d.ts.map +1 -0
  76. package/dist/agent-chat/composer.d.ts +130 -8
  77. package/dist/agent-chat/composer.d.ts.map +1 -1
  78. package/dist/agent-chat/composer.js +189 -23
  79. package/dist/agent-chat/composer.test.d.ts +2 -0
  80. package/dist/agent-chat/composer.test.d.ts.map +1 -0
  81. package/dist/agent-chat/index.d.ts +3 -1
  82. package/dist/agent-chat/index.d.ts.map +1 -1
  83. package/dist/agent-chat/index.js +2 -0
  84. package/dist/agent-chat/thread-detail-panel.d.ts.map +1 -1
  85. package/dist/agent-chat/thread-detail-panel.js +1 -1
  86. package/dist/agent-chat/thread-message.d.ts +24 -7
  87. package/dist/agent-chat/thread-message.d.ts.map +1 -1
  88. package/dist/agent-chat/thread-message.js +115 -45
  89. package/dist/agent-chat/thread-message.test.d.ts +2 -0
  90. package/dist/agent-chat/thread-message.test.d.ts.map +1 -0
  91. package/dist/agent-chat/thread-workspace.js +1 -1
  92. package/dist/agent-chat/tool-chip.d.ts +21 -0
  93. package/dist/agent-chat/tool-chip.d.ts.map +1 -0
  94. package/dist/agent-chat/tool-chip.js +120 -0
  95. package/dist/agent-chat/tool-chip.test.d.ts +2 -0
  96. package/dist/agent-chat/tool-chip.test.d.ts.map +1 -0
  97. package/dist/charts/primitives/ChartFrame.test.d.ts +2 -0
  98. package/dist/charts/primitives/ChartFrame.test.d.ts.map +1 -0
  99. package/dist/charts/utils/domain.test.d.ts +2 -0
  100. package/dist/charts/utils/domain.test.d.ts.map +1 -0
  101. package/dist/cli/check-theme.test.d.ts +2 -0
  102. package/dist/cli/check-theme.test.d.ts.map +1 -0
  103. package/dist/cli/help.test.d.ts +2 -0
  104. package/dist/cli/help.test.d.ts.map +1 -0
  105. package/dist/cli/index.d.ts.map +1 -1
  106. package/dist/cli/init-sync.test.d.ts +2 -0
  107. package/dist/cli/init-sync.test.d.ts.map +1 -0
  108. package/dist/cli/placement-engine.test.d.ts +2 -0
  109. package/dist/cli/placement-engine.test.d.ts.map +1 -0
  110. package/dist/cli/tarball-parity.test.d.ts +2 -0
  111. package/dist/cli/tarball-parity.test.d.ts.map +1 -0
  112. package/dist/cli/template-format.test.d.ts +2 -0
  113. package/dist/cli/template-format.test.d.ts.map +1 -0
  114. package/dist/cli/template-ledger.test.d.ts +2 -0
  115. package/dist/cli/template-ledger.test.d.ts.map +1 -0
  116. package/dist/cli/tokens-css.test.d.ts +2 -0
  117. package/dist/cli/tokens-css.test.d.ts.map +1 -0
  118. package/dist/content/article-actions.test.d.ts +2 -0
  119. package/dist/content/article-actions.test.d.ts.map +1 -0
  120. package/dist/content/article-model.test.d.ts +2 -0
  121. package/dist/content/article-model.test.d.ts.map +1 -0
  122. package/dist/content/block-split.test.d.ts +2 -0
  123. package/dist/content/block-split.test.d.ts.map +1 -0
  124. package/dist/content/fence-block.d.ts +37 -10
  125. package/dist/content/fence-block.d.ts.map +1 -1
  126. package/dist/content/fence-block.js +80 -14
  127. package/dist/content/heading-components.d.ts.map +1 -1
  128. package/dist/content/heading-components.js +7 -4
  129. package/dist/content/heading-components.test.d.ts +2 -0
  130. package/dist/content/heading-components.test.d.ts.map +1 -0
  131. package/dist/content/index.d.ts +6 -3
  132. package/dist/content/index.d.ts.map +1 -1
  133. package/dist/content/index.js +5 -1
  134. package/dist/content/markdown.d.ts +101 -14
  135. package/dist/content/markdown.d.ts.map +1 -1
  136. package/dist/content/markdown.js +141 -28
  137. package/dist/content/markdown.test.d.ts +2 -0
  138. package/dist/content/markdown.test.d.ts.map +1 -0
  139. package/dist/content/prose.module.css.test.d.ts +2 -0
  140. package/dist/content/prose.module.css.test.d.ts.map +1 -0
  141. package/dist/content/sanitize.d.ts +170 -0
  142. package/dist/content/sanitize.d.ts.map +1 -0
  143. package/dist/content/sanitize.js +178 -0
  144. package/dist/content/sanitize.test.d.ts +2 -0
  145. package/dist/content/sanitize.test.d.ts.map +1 -0
  146. package/dist/content/slug.test.d.ts +2 -0
  147. package/dist/content/slug.test.d.ts.map +1 -0
  148. package/dist/dashboard/stat-card.test.d.ts +2 -0
  149. package/dist/dashboard/stat-card.test.d.ts.map +1 -0
  150. package/dist/guard/check-source.test.d.ts +2 -0
  151. package/dist/guard/check-source.test.d.ts.map +1 -0
  152. package/dist/guard/guard-hook.test.d.ts +2 -0
  153. package/dist/guard/guard-hook.test.d.ts.map +1 -0
  154. package/dist/guard/index.d.ts.map +1 -1
  155. package/dist/guard/index.js +1 -3
  156. package/dist/index.d.ts +4 -4
  157. package/dist/index.d.ts.map +1 -1
  158. package/dist/index.js +12 -2
  159. package/dist/provider/index.test.d.ts +2 -0
  160. package/dist/provider/index.test.d.ts.map +1 -0
  161. package/dist/provider/lab-theme.test.d.ts +2 -0
  162. package/dist/provider/lab-theme.test.d.ts.map +1 -0
  163. package/dist/router-tanstack/multi-search-param-store.test.d.ts +2 -0
  164. package/dist/router-tanstack/multi-search-param-store.test.d.ts.map +1 -0
  165. package/dist/styles.floor.test.d.ts +2 -0
  166. package/dist/styles.floor.test.d.ts.map +1 -0
  167. package/dist/surfaces.d.ts +5 -5
  168. package/dist/surfaces.d.ts.map +1 -1
  169. package/dist/surfaces.js +13 -4
  170. package/dist/theme/border-coverage.test.d.ts +18 -0
  171. package/dist/theme/border-coverage.test.d.ts.map +1 -0
  172. package/dist/theme/contrast.test.d.ts +2 -0
  173. package/dist/theme/contrast.test.d.ts.map +1 -0
  174. package/dist/theme/density-option.test.d.ts +2 -0
  175. package/dist/theme/density-option.test.d.ts.map +1 -0
  176. package/dist/theme/density-relations.test.d.ts +2 -0
  177. package/dist/theme/density-relations.test.d.ts.map +1 -0
  178. package/dist/theme/derive-theme.test.d.ts +2 -0
  179. package/dist/theme/derive-theme.test.d.ts.map +1 -0
  180. package/dist/theme/fonts-option.test.d.ts +2 -0
  181. package/dist/theme/fonts-option.test.d.ts.map +1 -0
  182. package/dist/theme/radius-option.test.d.ts +2 -0
  183. package/dist/theme/radius-option.test.d.ts.map +1 -0
  184. package/dist/theme/radius.test.d.ts +2 -0
  185. package/dist/theme/radius.test.d.ts.map +1 -0
  186. package/dist/theme/shadow-surfaces.test.d.ts +26 -0
  187. package/dist/theme/shadow-surfaces.test.d.ts.map +1 -0
  188. package/dist/theme/spacing.test.d.ts +2 -0
  189. package/dist/theme/spacing.test.d.ts.map +1 -0
  190. package/dist/theme/use-basalt-spacing.test.d.ts +2 -0
  191. package/dist/theme/use-basalt-spacing.test.d.ts.map +1 -0
  192. package/dist/theme-lab/derive-controls.test.d.ts +2 -0
  193. package/dist/theme-lab/derive-controls.test.d.ts.map +1 -0
  194. package/dist/theme-lab/derive-state.test.d.ts +2 -0
  195. package/dist/theme-lab/derive-state.test.d.ts.map +1 -0
  196. package/dist/theme-lab/theme-lab.test.d.ts +2 -0
  197. package/dist/theme-lab/theme-lab.test.d.ts.map +1 -0
  198. package/dist/tokens/build-fonts-css.test.d.ts +2 -0
  199. package/dist/tokens/build-fonts-css.test.d.ts.map +1 -0
  200. package/dist/tokens/build-radius-css.test.d.ts +2 -0
  201. package/dist/tokens/build-radius-css.test.d.ts.map +1 -0
  202. package/dist/tokens/density.test.d.ts +2 -0
  203. package/dist/tokens/density.test.d.ts.map +1 -0
  204. package/dist/tokens/derive-radius.test.d.ts +2 -0
  205. package/dist/tokens/derive-radius.test.d.ts.map +1 -0
  206. package/dist/tokens/derive.test.d.ts +2 -0
  207. package/dist/tokens/derive.test.d.ts.map +1 -0
  208. package/dist/tokens/palette.test.d.ts +2 -0
  209. package/dist/tokens/palette.test.d.ts.map +1 -0
  210. package/dist/vite-color-mix.test.d.ts +2 -0
  211. package/dist/vite-color-mix.test.d.ts.map +1 -0
  212. package/dist/vite.test.d.ts +2 -0
  213. package/dist/vite.test.d.ts.map +1 -0
  214. package/llms.txt +7 -7
  215. package/package.json +6 -1
  216. package/src/agent/adapter.test.ts +1403 -0
  217. package/src/agent/adapter.ts +887 -0
  218. package/src/agent/ai-sdk-transport.test.ts +380 -55
  219. package/src/agent/ai-sdk-transport.ts +206 -53
  220. package/src/agent/coalesce.test.ts +387 -0
  221. package/src/agent/coalesce.ts +229 -0
  222. package/src/agent/foreign.test.ts +51 -0
  223. package/src/agent/foreign.ts +140 -0
  224. package/src/agent/history.ts +2 -0
  225. package/src/agent/id.test.ts +75 -0
  226. package/src/agent/id.ts +91 -0
  227. package/src/agent/index.ts +34 -3
  228. package/src/agent/merge.test.ts +206 -0
  229. package/src/agent/merge.ts +114 -0
  230. package/src/agent/part-list.test.tsx +112 -0
  231. package/src/agent/part-list.tsx +108 -35
  232. package/src/agent/parts.test.ts +516 -57
  233. package/src/agent/parts.ts +323 -32
  234. package/src/agent/thread.test.ts +75 -2
  235. package/src/agent/thread.ts +21 -1
  236. package/src/agent/transport.test.ts +70 -0
  237. package/src/agent/transport.ts +72 -20
  238. package/src/agent/use-agent-stream.test.tsx +53 -19
  239. package/src/agent/use-agent-stream.ts +13 -3
  240. package/src/agent/use-agent-thread-runs.foreign.test.tsx +138 -0
  241. package/src/agent/use-agent-thread-runs.resume.test.tsx +44 -11
  242. package/src/agent/use-agent-thread-runs.test.tsx +418 -22
  243. package/src/agent/use-agent-thread-runs.ts +170 -26
  244. package/src/agent/use-agent-thread-runs.wedge.test.tsx +15 -7
  245. package/src/agent-chat/composer.test.tsx +924 -0
  246. package/src/agent-chat/composer.tsx +521 -52
  247. package/src/agent-chat/index.ts +5 -1
  248. package/src/agent-chat/thread-detail-panel.tsx +3 -1
  249. package/src/agent-chat/thread-message.test.tsx +351 -0
  250. package/src/agent-chat/thread-message.tsx +262 -64
  251. package/src/agent-chat/thread-workspace.tsx +1 -1
  252. package/src/agent-chat/tool-chip.test.tsx +139 -0
  253. package/src/agent-chat/tool-chip.tsx +219 -0
  254. package/src/cli/index.ts +0 -1
  255. package/src/cli/template-format.test.ts +2 -2
  256. package/src/content/fence-block.tsx +242 -15
  257. package/src/content/heading-components.test.tsx +65 -0
  258. package/src/content/heading-components.tsx +26 -5
  259. package/src/content/index.ts +19 -3
  260. package/src/content/markdown.test.tsx +603 -0
  261. package/src/content/markdown.tsx +355 -35
  262. package/src/content/sanitize.test.ts +659 -0
  263. package/src/content/sanitize.ts +510 -0
  264. package/src/dashboard/stat-card.test.tsx +6 -1
  265. package/src/guard/check-source.test.ts +8 -4
  266. package/src/guard/index.ts +1 -3
  267. package/src/index.ts +15 -1
  268. package/src/surfaces.ts +13 -4
  269. package/src/theme/contrast.test.ts +1 -4
  270. package/src/theme/density-relations.test.ts +4 -2
  271. package/src/theme/fonts-option.test.ts +7 -1
  272. package/src/theme/shadow-surfaces.test.ts +12 -6
  273. package/src/theme/use-basalt-spacing.test.tsx +2 -1
@@ -0,0 +1,114 @@
1
+ /**
2
+ * mergePart — identity-addressed accumulator over a part array.
3
+ *
4
+ * Appending a part whose `id` is already present in `parts` REWRITES that entry in place rather
5
+ * than pushing a duplicate — ordering stays stable (a rewritten part keeps its original position).
6
+ * `text`/`reasoning` splice the incoming delta into the existing text at `next.offset` (an
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.
12
+ *
13
+ * The invariant this holds, and the reason `offset` exists at all: replaying a whole run from
14
+ * character 0 rebuilds the identical parts array it produced the first time — it cannot double
15
+ * it. Splicing a full-length delta at offset 0 overwrites the existing text in place (the
16
+ * trailing slice covers nothing once the incoming text's length matches what's already there),
17
+ * so a resumed/replayed stream converges instead of duplicating content.
18
+ *
19
+ * Constrained on `PartLike` (structural: `id` + `type`), NOT `AgentPart` — a widened, backward-
20
+ * compatible relaxation so `TranscriptPart` (`AgentPart | ForeignPart`, ./foreign) flows through
21
+ * the same accumulator a transport-streamed foreign part must pass through on its way from the
22
+ * transport into `useAgentThreadRuns`'s `runs` map. `AgentPart`'s closed union is untouched —
23
+ * only the constraint widens, never the union itself.
24
+ *
25
+ * @example
26
+ * let parts: AgentPart[] = []
27
+ * parts = mergePart(parts, { id: 'p1', type: 'text', text: 'Hel' })
28
+ * parts = mergePart(parts, { id: 'p1', type: 'text', text: 'lo' }) // splices at the tail
29
+ * // parts === [{ id: 'p1', type: 'text', text: 'Hello' }]
30
+ */
31
+ import type { ReasoningPart, TextPart } from './parts'
32
+
33
+ type TextLike = TextPart | ReasoningPart
34
+
35
+ /** The structural bound `mergePart` (and the hooks that call it) actually need — an `AgentPart` or
36
+ * a `ForeignPart` both satisfy it, so this is the widened constraint, not a new closed union. */
37
+ export type PartLike = { readonly id: string; readonly type: string }
38
+
39
+ // Widened from `AgentPart['type']` to `string`: a plain type-predicate narrowing, so accepting a
40
+ // wider input (any string, including a ForeignPart's) is safe — it still only returns true for the
41
+ // two text-like literals, and a ForeignPart's type string always falls through it to the
42
+ // wholesale-replace path below.
43
+ function isTextLikeType(type: string): type is TextLike['type'] {
44
+ return type === 'text' || type === 'reasoning'
45
+ }
46
+
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
87
+ }
88
+
89
+ export function mergePart<TPart extends PartLike>(parts: readonly TPart[], next: TPart): TPart[] {
90
+ const index = parts.findIndex((part) => part.id === next.id)
91
+ const existing = index === -1 ? undefined : (parts[index] as TPart)
92
+
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)
98
+
99
+ const merged: TPart = spliceable
100
+ ? ({
101
+ ...next,
102
+ text: spliceText(
103
+ existing === undefined ? '' : (existing as unknown as TextLike).text,
104
+ next as unknown as TextLike,
105
+ ),
106
+ } as TPart)
107
+ : next
108
+
109
+ if (existing === undefined) return [...parts, merged]
110
+
111
+ const out = parts.slice()
112
+ out[index] = merged
113
+ return out
114
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * PartList's headless default tool-call renderer — a failed/denied call must stay visible.
3
+ *
4
+ * Regression coverage: after the seven-state ToolCallPart split, `output-error` carries no
5
+ * `output` field, so the old `part.state === 'output-available'`-only body left a failed call
6
+ * rendering as name+input and nothing else — the failure was silently invisible. Same gap for
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.
11
+ */
12
+ import { cleanup, render, screen } from '@testing-library/react'
13
+ import { afterEach, describe, expect, test } from 'bun:test'
14
+ import { PartList } from './part-list'
15
+ import type { AgentPart, ToolCallPart } from './parts'
16
+
17
+ afterEach(cleanup)
18
+
19
+ const BASE = { id: 'p1', type: 'tool' as const, toolCallId: 'c1', toolName: 'search' }
20
+
21
+ describe('DefaultToolCall — terminal states stay visible', () => {
22
+ test('output-error renders the errorText', () => {
23
+ const part: ToolCallPart = { ...BASE, state: 'output-error', errorText: 'boom' }
24
+ render(<PartList parts={[part]} />)
25
+ expect(screen.getByText('boom')).not.toBeNull()
26
+ })
27
+
28
+ test('output-error with no validated input falls back to rendering rawInput', () => {
29
+ const part: ToolCallPart = {
30
+ ...BASE,
31
+ state: 'output-error',
32
+ errorText: 'boom',
33
+ rawInput: { q: 'x' },
34
+ }
35
+ render(<PartList parts={[part]} />)
36
+ expect(screen.getByText(/"q": "x"/)).not.toBeNull()
37
+ })
38
+
39
+ test('output-error with validated input present does not also render rawInput', () => {
40
+ const part: ToolCallPart = {
41
+ ...BASE,
42
+ state: 'output-error',
43
+ errorText: 'boom',
44
+ input: { q: 'x' },
45
+ rawInput: { q: 'stale' },
46
+ }
47
+ render(<PartList parts={[part]} />)
48
+ expect(screen.queryByText(/stale/)).toBeNull()
49
+ })
50
+
51
+ test('output-denied is visibly denied, not indistinguishable from a pending call', () => {
52
+ const part: ToolCallPart = {
53
+ ...BASE,
54
+ state: 'output-denied',
55
+ input: { q: 'x' },
56
+ approval: { id: 'a1', approved: false, reason: 'too risky' },
57
+ }
58
+ render(<PartList parts={[part]} />)
59
+ expect(screen.getByText(/Denied/)).not.toBeNull()
60
+ expect(screen.getByText(/too risky/)).not.toBeNull()
61
+ })
62
+
63
+ test('output-denied with no reason still renders a visible denial', () => {
64
+ const part: ToolCallPart = {
65
+ ...BASE,
66
+ state: 'output-denied',
67
+ input: { q: 'x' },
68
+ approval: { id: 'a1', approved: false },
69
+ }
70
+ render(<PartList parts={[part]} />)
71
+ expect(screen.getByText('Denied')).not.toBeNull()
72
+ })
73
+
74
+ test('output-available is unaffected — still renders output, no error/denied line', () => {
75
+ const part: ToolCallPart = {
76
+ ...BASE,
77
+ state: 'output-available',
78
+ input: { q: 'x' },
79
+ output: { hits: 3 },
80
+ }
81
+ render(<PartList parts={[part]} />)
82
+ expect(screen.getByText(/"hits": 3/)).not.toBeNull()
83
+ expect(screen.queryByText(/Denied/)).toBeNull()
84
+ })
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> = {
@@ -112,6 +125,12 @@ const CODE_BLOCK_STYLE = {
112
125
  overflowX: 'auto',
113
126
  } as const
114
127
 
128
+ /** Terminal-state status line (tool-call error/denial) — plain text, no code-block chrome. */
129
+ const STATUS_LINE_STYLE = {
130
+ marginTop: 'var(--vx-space-agent-part-gap-top)',
131
+ color: VX.status.bad,
132
+ } as const
133
+
115
134
  function DefaultText({ part }: { part: TextPart; index: number }): JSX.Element {
116
135
  return (
117
136
  <div
@@ -151,11 +170,30 @@ function DefaultToolCall({ part }: { part: ToolCallPart; index: number }): JSX.E
151
170
  <pre className="basalt-agent-tool-input" style={CODE_BLOCK_STYLE}>
152
171
  {JSON.stringify(part.input, null, 2)}
153
172
  </pre>
154
- {part.output !== undefined && (
173
+ {/* output-error's input never validated — rawInput is the only surviving record of what was
174
+ actually sent, so show it whenever there is no validated input to show instead. */}
175
+ {part.state === 'output-error' && part.input === undefined && part.rawInput !== undefined && (
176
+ <pre className="basalt-agent-tool-raw-input" style={CODE_BLOCK_STYLE}>
177
+ {JSON.stringify(part.rawInput, null, 2)}
178
+ </pre>
179
+ )}
180
+ {part.state === 'output-available' && (
155
181
  <pre className="basalt-agent-tool-output" style={CODE_BLOCK_STYLE}>
156
182
  {JSON.stringify(part.output, null, 2)}
157
183
  </pre>
158
184
  )}
185
+ {/* A failed or denied call must stay visible after the seven-state split — neither carries
186
+ an `output`, so without this the call renders as name+input and nothing else. */}
187
+ {part.state === 'output-error' && (
188
+ <div className="basalt-agent-tool-error" role="alert" style={STATUS_LINE_STYLE}>
189
+ {part.errorText}
190
+ </div>
191
+ )}
192
+ {part.state === 'output-denied' && (
193
+ <div className="basalt-agent-tool-denied" style={STATUS_LINE_STYLE}>
194
+ Denied{part.approval.reason !== undefined ? `: ${part.approval.reason}` : ''}
195
+ </div>
196
+ )}
159
197
  </div>
160
198
  )
161
199
  }
@@ -206,10 +244,17 @@ export type PartListProps<TPart extends AgentPart = AgentPart> = {
206
244
  readonly parts: TPart[]
207
245
  /**
208
246
  * Override individual part renderers. Omitted keys fall back to the headless defaults.
209
- * Pass `{ text: ({ part }) => <Markdown streaming density="chat">{part.text}</Markdown> }` to
210
- * 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.
211
249
  */
212
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
213
258
  }
214
259
 
215
260
  /**
@@ -226,11 +271,19 @@ export type PartListProps<TPart extends AgentPart = AgentPart> = {
226
271
  * // With basalt's own Markdown for the text renderer:
227
272
  * import { PartList } from 'basalt-ui/agent'
228
273
  * import { Markdown } from 'basalt-ui/content'
229
- * <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
+ * />
230
282
  */
231
283
  export function PartList<TPart extends AgentPart = AgentPart>({
232
284
  parts,
233
285
  components,
286
+ settled = true,
234
287
  }: PartListProps<TPart>): JSX.Element {
235
288
  // Memoised to avoid rebuilding the renderer map on every streaming re-render (hot path).
236
289
  // Cast required because DEFAULT_RENDERERS is typed for the base AgentPart, not the generic TPart.
@@ -251,40 +304,60 @@ export function PartList<TPart extends AgentPart = AgentPart>({
251
304
  case 'text': {
252
305
  const Render = renderers.text
253
306
  return (
254
- <Fragment key={index}>
255
- <Render part={part as Extract<TPart, { type: 'text' }>} index={index} />
307
+ <Fragment key={part.id}>
308
+ <Render
309
+ part={part as Extract<TPart, { type: 'text' }>}
310
+ index={index}
311
+ settled={settled}
312
+ />
256
313
  </Fragment>
257
314
  )
258
315
  }
259
316
  case 'reasoning': {
260
317
  const Render = renderers.reasoning
261
318
  return (
262
- <Fragment key={index}>
263
- <Render part={part as Extract<TPart, { type: 'reasoning' }>} index={index} />
319
+ <Fragment key={part.id}>
320
+ <Render
321
+ part={part as Extract<TPart, { type: 'reasoning' }>}
322
+ index={index}
323
+ settled={settled}
324
+ />
264
325
  </Fragment>
265
326
  )
266
327
  }
267
328
  case 'tool': {
268
329
  const Render = renderers.tool
269
330
  return (
270
- <Fragment key={index}>
271
- <Render part={part as Extract<TPart, { type: 'tool' }>} index={index} />
331
+ <Fragment key={part.id}>
332
+ <Render
333
+ part={part as Extract<TPart, { type: 'tool' }>}
334
+ index={index}
335
+ settled={settled}
336
+ />
272
337
  </Fragment>
273
338
  )
274
339
  }
275
340
  case 'source': {
276
341
  const Render = renderers.source
277
342
  return (
278
- <Fragment key={index}>
279
- <Render part={part as Extract<TPart, { type: 'source' }>} index={index} />
343
+ <Fragment key={part.id}>
344
+ <Render
345
+ part={part as Extract<TPart, { type: 'source' }>}
346
+ index={index}
347
+ settled={settled}
348
+ />
280
349
  </Fragment>
281
350
  )
282
351
  }
283
352
  case 'error': {
284
353
  const Render = renderers.error
285
354
  return (
286
- <Fragment key={index}>
287
- <Render part={part as Extract<TPart, { type: 'error' }>} index={index} />
355
+ <Fragment key={part.id}>
356
+ <Render
357
+ part={part as Extract<TPart, { type: 'error' }>}
358
+ index={index}
359
+ settled={settled}
360
+ />
288
361
  </Fragment>
289
362
  )
290
363
  }