basalt-ui 1.10.0 → 1.11.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 (88) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +10 -0
  3. package/dist/agent/ai-sdk-transport.d.ts +3 -52
  4. package/dist/agent/ai-sdk-transport.d.ts.map +1 -1
  5. package/dist/agent/ai-sdk-transport.js +108 -24
  6. package/dist/agent/coalesce.d.ts +26 -0
  7. package/dist/agent/coalesce.d.ts.map +1 -0
  8. package/dist/agent/coalesce.js +69 -0
  9. package/dist/agent/foreign.d.ts +97 -0
  10. package/dist/agent/foreign.d.ts.map +1 -0
  11. package/dist/agent/foreign.js +18 -0
  12. package/dist/agent/history.d.ts +2 -0
  13. package/dist/agent/history.d.ts.map +1 -1
  14. package/dist/agent/id.d.ts +35 -0
  15. package/dist/agent/id.d.ts.map +1 -0
  16. package/dist/agent/id.js +14 -0
  17. package/dist/agent/index.d.ts +9 -4
  18. package/dist/agent/index.d.ts.map +1 -1
  19. package/dist/agent/index.js +15 -3
  20. package/dist/agent/merge.d.ts +8 -0
  21. package/dist/agent/merge.d.ts.map +1 -0
  22. package/dist/agent/merge.js +23 -0
  23. package/dist/agent/part-list.d.ts.map +1 -1
  24. package/dist/agent/part-list.js +16 -6
  25. package/dist/agent/parts.d.ts +100 -21
  26. package/dist/agent/parts.d.ts.map +1 -1
  27. package/dist/agent/parts.js +128 -11
  28. package/dist/agent/transport.d.ts +39 -3
  29. package/dist/agent/transport.d.ts.map +1 -1
  30. package/dist/agent/transport.js +20 -17
  31. package/dist/agent/use-agent-stream.d.ts +2 -1
  32. package/dist/agent/use-agent-stream.d.ts.map +1 -1
  33. package/dist/agent/use-agent-stream.js +5 -2
  34. package/dist/agent/use-agent-thread-runs.d.ts +9 -3
  35. package/dist/agent/use-agent-thread-runs.d.ts.map +1 -1
  36. package/dist/agent/use-agent-thread-runs.js +75 -23
  37. package/dist/agent-chat/index.d.ts +2 -0
  38. package/dist/agent-chat/index.d.ts.map +1 -1
  39. package/dist/agent-chat/index.js +2 -0
  40. package/dist/agent-chat/thread-message.d.ts +24 -7
  41. package/dist/agent-chat/thread-message.d.ts.map +1 -1
  42. package/dist/agent-chat/thread-message.js +102 -43
  43. package/dist/agent-chat/tool-chip.d.ts +21 -0
  44. package/dist/agent-chat/tool-chip.d.ts.map +1 -0
  45. package/dist/agent-chat/tool-chip.js +120 -0
  46. package/dist/guard/index.d.ts.map +1 -1
  47. package/dist/guard/index.js +1 -3
  48. package/dist/index.d.ts +2 -2
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +3 -1
  51. package/dist/surfaces.d.ts +2 -2
  52. package/dist/surfaces.js +2 -2
  53. package/llms.txt +4 -4
  54. package/package.json +1 -1
  55. package/src/agent/ai-sdk-transport.test.ts +338 -40
  56. package/src/agent/ai-sdk-transport.ts +206 -53
  57. package/src/agent/coalesce.test.ts +194 -0
  58. package/src/agent/coalesce.ts +118 -0
  59. package/src/agent/foreign.test.ts +47 -0
  60. package/src/agent/foreign.ts +140 -0
  61. package/src/agent/history.ts +2 -0
  62. package/src/agent/id.test.ts +71 -0
  63. package/src/agent/id.ts +43 -0
  64. package/src/agent/index.ts +26 -3
  65. package/src/agent/merge.test.ts +119 -0
  66. package/src/agent/merge.ts +67 -0
  67. package/src/agent/part-list.test.tsx +82 -0
  68. package/src/agent/part-list.tsx +31 -6
  69. package/src/agent/parts.test.ts +401 -59
  70. package/src/agent/parts.ts +262 -32
  71. package/src/agent/transport.test.ts +70 -0
  72. package/src/agent/transport.ts +72 -20
  73. package/src/agent/use-agent-stream.test.tsx +47 -17
  74. package/src/agent/use-agent-stream.ts +13 -3
  75. package/src/agent/use-agent-thread-runs.foreign.test.tsx +133 -0
  76. package/src/agent/use-agent-thread-runs.resume.test.tsx +34 -6
  77. package/src/agent/use-agent-thread-runs.test.tsx +397 -14
  78. package/src/agent/use-agent-thread-runs.ts +170 -26
  79. package/src/agent/use-agent-thread-runs.wedge.test.tsx +4 -2
  80. package/src/agent-chat/index.ts +4 -0
  81. package/src/agent-chat/thread-message.test.tsx +211 -0
  82. package/src/agent-chat/thread-message.tsx +235 -62
  83. package/src/agent-chat/tool-chip.test.tsx +139 -0
  84. package/src/agent-chat/tool-chip.tsx +219 -0
  85. package/src/guard/check-source.test.ts +8 -4
  86. package/src/guard/index.ts +1 -3
  87. package/src/index.ts +2 -0
  88. package/src/surfaces.ts +2 -2
@@ -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. */
@@ -76,16 +82,6 @@ const RAIL_STYLE = {
76
82
  borderLeft: `2px solid ${VX.divider}`,
77
83
  paddingLeft: 'var(--vx-space-agent-rail-inset-x)',
78
84
  } 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
85
 
90
86
  /** A dimmed, collapsed-by-default disclosure for reasoning/thinking fragments. */
91
87
  function ReasoningRenderer({ part }: { part: ReasoningPart; index: number }): JSX.Element {
@@ -106,22 +102,14 @@ function ReasoningRenderer({ part }: { part: ReasoningPart; index: number }): JS
106
102
  )
107
103
  }
108
104
 
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
- )
105
+ // Thin adapter: PartList's per-type renderer signature is `{ part, index }`, ToolChip's is a
106
+ // richer `ToolChipProps` (defaultExpanded/onApprove/onDeny) that the built-in AgentPart union has
107
+ // no slot for — a 'tool' part reaching this path is always basalt's own six-variant union, never a
108
+ // consumer-registered one, so there is nowhere upstream to source approve/deny handlers from at
109
+ // this call site. A consumer that needs approve/deny wiring renders `ToolChip` directly instead of
110
+ // going through `PartList`/`threadPartRenderers`.
111
+ function ToolChipRenderer({ part }: { part: ToolCallPart; index: number }): JSX.Element {
112
+ return <ToolChip part={part} />
125
113
  }
126
114
 
127
115
  function SourceRenderer({ part }: { part: SourcePart; index: number }): JSX.Element {
@@ -152,78 +140,246 @@ function ErrorRenderer({ part }: { part: ErrorPart; index: number }): JSX.Elemen
152
140
  export const threadPartRenderers: Partial<AgentPartRenderers> = {
153
141
  text: TextRenderer,
154
142
  reasoning: ReasoningRenderer,
155
- tool: ToolRenderer,
143
+ tool: ToolChipRenderer,
156
144
  source: SourceRenderer,
157
145
  error: ErrorRenderer,
158
146
  }
159
147
 
160
- // ── ThreadTranscript ──────────────────────────────────────────────────────────
148
+ // ── UnknownPartChip — the dev-visible default fallback ────────────────────────
149
+
150
+ /** A visible marker for a part `ThreadTranscript` could not resolve — neither a registered
151
+ * consumer renderer nor a built-in `AgentPart` variant claimed it. Dev-only by default (see
152
+ * `DEFAULT_FALLBACK_RENDERER` below): a server emitting a new part type must not blank the
153
+ * transcript, but it also shouldn't silently vanish while the app is being built against it. */
154
+ function UnknownPartChip({ part }: { part: ForeignPart }): JSX.Element {
155
+ return (
156
+ <Box style={RAIL_STYLE}>
157
+ <Text style={MICRO_LABEL_STYLE}>{`Unknown part: ${part.type}`}</Text>
158
+ </Box>
159
+ )
160
+ }
161
+
162
+ /** Consulted only when `ThreadTranscript` gets no `fallbackRenderer` prop. `process.env.NODE_ENV`
163
+ * (not `import.meta.env` — basalt-ui bans it) is read INSIDE the render so tests can flip it. */
164
+ const DEFAULT_FALLBACK_RENDERER: PartRenderer<ForeignPart> = ({ part }) =>
165
+ process.env['NODE_ENV'] !== 'production' ? <UnknownPartChip part={part} /> : null
166
+
167
+ // ── Part resolution — the three-step order that is the whole design ───────────
168
+ //
169
+ // 1. `renderers[part.type]` — a registered consumer renderer, if present. Consumer wins, always.
170
+ // 2. `narrowAgentPart(part)` non-null — one of basalt's own six variants, routed to `PartList`'s
171
+ // UNCHANGED, closed-union, exhaustive switch (`part-list.tsx:291-293`).
172
+ // 3. `fallbackRenderer` — anything left over. Never throws.
173
+ //
174
+ // `PartList` keeps its own `AgentPartRenderers` map and `assertNever` gate untouched — two
175
+ // registries, two jobs: `PartList` renders what basalt knows, exhaustively; `ThreadTranscript`
176
+ // renders what the app knows, openly. A `TPart` generic threaded through `AgentThread` →
177
+ // `ThreadsStore` → `useAgentThreadRuns` → every component would be the alternative, and it's the
178
+ // exact ripple `thread.ts:79-93` already cites as the reason `meta` stayed untyped.
179
+
180
+ type ResolvedSegment =
181
+ | { readonly kind: 'agent'; readonly key: string; readonly parts: AgentPart[] }
182
+ | {
183
+ readonly kind: 'foreign'
184
+ readonly key: string
185
+ readonly part: ForeignPart
186
+ readonly render: PartRenderer<ForeignPart>
187
+ }
188
+
189
+ /**
190
+ * Splits a message's parts into ordered, render-ready segments per the resolution order above.
191
+ * Consecutive built-in-resolved parts are batched into one 'agent' segment — coalesced, then
192
+ * handed to `PartList` as a unit — so the existing adjacent-merge behaviour (text+text, tool-by-id
193
+ * regardless of adjacency) survives a foreign part interleaving the stream. A foreign-resolved
194
+ * part always stands alone; there is nothing in the closed union for it to merge with.
195
+ */
196
+ function resolveSegments(
197
+ parts: readonly TranscriptPart[],
198
+ renderers: PartRenderers,
199
+ fallbackRenderer: PartRenderer<ForeignPart>,
200
+ ): ResolvedSegment[] {
201
+ const segments: ResolvedSegment[] = []
202
+ let buffer: AgentPart[] = []
203
+
204
+ const flushBuffer = (): void => {
205
+ const first = buffer[0]
206
+ if (first === undefined) return
207
+ segments.push({ kind: 'agent', key: first.id, parts: coalesceParts(buffer) })
208
+ buffer = []
209
+ }
161
210
 
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
211
  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)
212
+ // `PartRenderers` resolves to `Record<string, PartRenderer<ForeignPart>>` in basalt-ui's own
213
+ // (un-augmented) compilation — a plain runtime string-keyed lookup, `noUncheckedIndexedAccess`
214
+ // giving the `| undefined` this relies on.
215
+ const consumerRenderer = renderers[part.type]
216
+ if (consumerRenderer !== undefined) {
217
+ flushBuffer()
218
+ // Not narrowed to ForeignPart by the `renderers[part.type]` lookup above — asserted, not
219
+ // proven, by the registry contract (a registered key belongs to the consumer's foreign-part
220
+ // union).
221
+ segments.push({
222
+ kind: 'foreign',
223
+ key: part.id,
224
+ part: part as ForeignPart,
225
+ render: consumerRenderer,
226
+ })
227
+ continue
174
228
  }
229
+
230
+ const agentPart = narrowAgentPart(part)
231
+ if (agentPart !== null) {
232
+ buffer.push(agentPart)
233
+ continue
234
+ }
235
+
236
+ flushBuffer()
237
+ segments.push({
238
+ kind: 'foreign',
239
+ key: part.id,
240
+ part: part as ForeignPart,
241
+ render: fallbackRenderer,
242
+ })
175
243
  }
176
- return out
244
+
245
+ flushBuffer()
246
+ return segments
177
247
  }
178
248
 
249
+ // ── MessageBlock (memoized) ────────────────────────────────────────────────────
250
+
179
251
  const ROLE_LABEL: Record<ChatMessage['role'], string> = {
180
252
  user: 'You',
181
253
  assistant: 'Assistant',
182
254
  }
183
255
 
184
- function MessageBlock({
185
- author,
186
- parts,
256
+ // Terminal-state indicator (docs/AGENT-CHAT-SPEC.md §10) — `message.finish` is optional (absent on
257
+ // every user message and on pre-1.11 persisted assistant messages) and carries the distinction
258
+ // `ThreadStatus` deliberately does not: a stopped turn's thread status is `'done'`, same as a
259
+ // completed one. `'complete'` is the unremarkable case and has no entry here, so a lookup miss
260
+ // renders nothing — no badge on every ordinary message. Only 'stopped'/'error' earn a mark, in the
261
+ // same mono micro-label register as ROLE_LABEL, not a new decorative element.
262
+ const FINISH_INDICATOR: Partial<
263
+ Record<
264
+ NonNullable<ChatMessage['finish']>,
265
+ { readonly label: string; readonly statusToken: string }
266
+ >
267
+ > = {
268
+ stopped: { label: 'Stopped', statusToken: VX.status.warn },
269
+ error: { label: 'Error', statusToken: VX.status.bad },
270
+ }
271
+
272
+ type MessageBlockProps = {
273
+ readonly message: ChatMessage<TranscriptPart>
274
+ readonly streaming?: boolean
275
+ readonly renderers: PartRenderers
276
+ readonly fallbackRenderer: PartRenderer<ForeignPart>
277
+ }
278
+
279
+ /**
280
+ * Render-execution counter for `MessageBlockImpl` — increments only when React actually calls the
281
+ * function body (a `memo` bail-out never reaches it). NOT part of the public surface —
282
+ * `agent-chat/index.ts` does not re-export it. The memoization test imports this module directly
283
+ * (bypassing the barrel) to prove the memo boundary in a controlled render-count harness.
284
+ */
285
+ export const messageBlockRenderCounter = { count: 0 }
286
+
287
+ function MessageBlockImpl({
288
+ message,
187
289
  streaming = false,
188
- }: {
189
- author: ChatMessage['role']
190
- parts: AgentPart[]
191
- streaming?: boolean
192
- }): JSX.Element {
290
+ renderers,
291
+ fallbackRenderer,
292
+ }: MessageBlockProps): JSX.Element {
293
+ messageBlockRenderCounter.count += 1
294
+ const settled = !streaming
295
+ const segments = useMemo(
296
+ () => resolveSegments(message.parts, renderers, fallbackRenderer),
297
+ [message.parts, renderers, fallbackRenderer],
298
+ )
299
+ const finishIndicator =
300
+ message.finish === undefined ? undefined : FINISH_INDICATOR[message.finish]
301
+
193
302
  // Assistant/user surfaces are differentiated by subtle vs panel tokens, radius 7
194
303
  // (VX.radiusCard, docs/DESIGN-SPEC.md §5) — the user's own turn sits a shade quieter than the reply.
195
304
  return (
196
305
  <Box
197
306
  style={{
198
307
  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,
308
+ backgroundColor: message.role === 'user' ? VX.surface.subtle : VX.surface.panel,
200
309
  borderRadius: VX.radiusCard,
201
310
  boxShadow: VX.shadowCard,
202
311
  }}
203
312
  >
204
313
  <Stack gap={6}>
205
314
  <Group gap={6} align="center">
206
- <Text style={MICRO_LABEL_STYLE}>{ROLE_LABEL[author]}</Text>
315
+ <Text style={MICRO_LABEL_STYLE}>{ROLE_LABEL[message.role]}</Text>
207
316
  {streaming && <Loader size="xs" />}
317
+ {finishIndicator !== undefined && (
318
+ <Text style={{ ...MICRO_LABEL_STYLE, color: finishIndicator.statusToken }}>
319
+ {finishIndicator.label}
320
+ </Text>
321
+ )}
208
322
  </Group>
209
- <PartList parts={coalesceParts(parts)} components={threadPartRenderers} />
323
+ {segments.map((segment) =>
324
+ segment.kind === 'agent' ? (
325
+ <PartList key={segment.key} parts={segment.parts} components={threadPartRenderers} />
326
+ ) : (
327
+ <Fragment key={segment.key}>
328
+ {segment.render({
329
+ part: segment.part,
330
+ messageId: message.id,
331
+ partId: segment.part.id,
332
+ settled,
333
+ role: message.role,
334
+ })}
335
+ </Fragment>
336
+ ),
337
+ )}
210
338
  </Stack>
211
339
  </Box>
212
340
  )
213
341
  }
214
342
 
215
- export type ThreadTranscriptProps = {
343
+ /** Compares `message` BY REFERENCE plus the `streaming` flag — the props a per-token streaming
344
+ * re-render must not force through every settled message's `MessageBlock`. `renderers`/
345
+ * `fallbackRenderer` are included too: both are memoized once at the `ThreadTranscript` level (see
346
+ * below), so in practice they're stable across renders, but a genuine change to either (a consumer
347
+ * swapping its renderer map) must still invalidate the memo. */
348
+ function areMessageBlockPropsEqual(prev: MessageBlockProps, next: MessageBlockProps): boolean {
349
+ return (
350
+ prev.message === next.message &&
351
+ prev.streaming === next.streaming &&
352
+ prev.renderers === next.renderers &&
353
+ prev.fallbackRenderer === next.fallbackRenderer
354
+ )
355
+ }
356
+
357
+ const MessageBlock = memo(MessageBlockImpl, areMessageBlockPropsEqual)
358
+
359
+ // ── ThreadTranscript ──────────────────────────────────────────────────────────
360
+
361
+ const EMPTY_RENDERERS: PartRenderers = {}
362
+
363
+ type ThreadTranscriptBase = {
216
364
  /** Settled messages for the thread, oldest first. */
217
- readonly messages: ChatMessage[]
365
+ readonly messages: readonly ChatMessage<TranscriptPart>[]
218
366
  /** The live (in-flight) assistant turn's parts, when a run is streaming for this thread. */
219
- readonly liveParts?: AgentPart[]
367
+ readonly liveParts?: readonly TranscriptPart[]
220
368
  /** The live run's status — drives the in-progress indicator on the live block. */
221
369
  readonly liveStatus?: StreamStatus
370
+ /** Consumer renderers keyed by part.type. Consulted BEFORE the built-in union. */
371
+ readonly renderers?: PartRenderers
372
+ /** Called for a part whose type is neither an AgentPart variant nor a registered key. Defaults
373
+ * to a visible `UnknownPartChip` outside production, `null` in production — never throws. */
374
+ readonly fallbackRenderer?: PartRenderer<ForeignPart>
222
375
  }
223
376
 
377
+ export type ThreadTranscriptProps = ThreadTranscriptBase
378
+
224
379
  /**
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
380
+ * Renders a thread's settled messages, each role-labelled ("You" / "Assistant"). Each part
381
+ * resolves in order: a registered `renderers[part.type]` first, then basalt's own `PartList` for
382
+ * the built-in six, then `fallbackRenderer`. When `liveParts` is non-empty, an extra in-progress
227
383
  * assistant block is appended at the tail.
228
384
  *
229
385
  * @example
@@ -234,18 +390,35 @@ export function ThreadTranscript({
234
390
  messages,
235
391
  liveParts,
236
392
  liveStatus,
393
+ renderers: renderersProp,
394
+ fallbackRenderer = DEFAULT_FALLBACK_RENDERER,
237
395
  }: ThreadTranscriptProps): JSX.Element {
396
+ // Mirrors PartList's own `useMemo` (part-list.tsx:237-240) — avoids rebuilding the renderer
397
+ // lookup on every streaming re-render.
398
+ const renderers = useMemo(() => renderersProp ?? EMPTY_RENDERERS, [renderersProp])
399
+
400
+ const liveMessage = useMemo<ChatMessage<TranscriptPart> | null>(() => {
401
+ if (liveParts === undefined || liveParts.length === 0) return null
402
+ return { id: '__live__', role: 'assistant', parts: liveParts as TranscriptPart[], createdAt: 0 }
403
+ }, [liveParts])
404
+
238
405
  return (
239
406
  <Stack gap="sm">
240
407
  {messages.map((message) => (
241
- <MessageBlock key={message.id} author={message.role} parts={message.parts} />
408
+ <MessageBlock
409
+ key={message.id}
410
+ message={message}
411
+ renderers={renderers}
412
+ fallbackRenderer={fallbackRenderer}
413
+ />
242
414
  ))}
243
- {liveParts !== undefined && liveParts.length > 0 && (
415
+ {liveMessage !== null && (
244
416
  <div aria-live="polite" aria-atomic="false" aria-relevant="additions">
245
417
  <MessageBlock
246
- author="assistant"
247
- parts={liveParts}
418
+ message={liveMessage}
248
419
  streaming={liveStatus === 'streaming'}
420
+ renderers={renderers}
421
+ fallbackRenderer={fallbackRenderer}
249
422
  />
250
423
  </div>
251
424
  )}
@@ -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
+ })