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.
- package/AGENTS.md +2 -2
- package/CHANGELOG.md +10 -0
- package/dist/agent/ai-sdk-transport.d.ts +3 -52
- package/dist/agent/ai-sdk-transport.d.ts.map +1 -1
- package/dist/agent/ai-sdk-transport.js +108 -24
- package/dist/agent/coalesce.d.ts +26 -0
- package/dist/agent/coalesce.d.ts.map +1 -0
- package/dist/agent/coalesce.js +69 -0
- package/dist/agent/foreign.d.ts +97 -0
- package/dist/agent/foreign.d.ts.map +1 -0
- package/dist/agent/foreign.js +18 -0
- package/dist/agent/history.d.ts +2 -0
- package/dist/agent/history.d.ts.map +1 -1
- package/dist/agent/id.d.ts +35 -0
- package/dist/agent/id.d.ts.map +1 -0
- package/dist/agent/id.js +14 -0
- package/dist/agent/index.d.ts +9 -4
- package/dist/agent/index.d.ts.map +1 -1
- package/dist/agent/index.js +15 -3
- package/dist/agent/merge.d.ts +8 -0
- package/dist/agent/merge.d.ts.map +1 -0
- package/dist/agent/merge.js +23 -0
- package/dist/agent/part-list.d.ts.map +1 -1
- package/dist/agent/part-list.js +16 -6
- package/dist/agent/parts.d.ts +100 -21
- package/dist/agent/parts.d.ts.map +1 -1
- package/dist/agent/parts.js +128 -11
- package/dist/agent/transport.d.ts +39 -3
- package/dist/agent/transport.d.ts.map +1 -1
- package/dist/agent/transport.js +20 -17
- package/dist/agent/use-agent-stream.d.ts +2 -1
- package/dist/agent/use-agent-stream.d.ts.map +1 -1
- package/dist/agent/use-agent-stream.js +5 -2
- package/dist/agent/use-agent-thread-runs.d.ts +9 -3
- package/dist/agent/use-agent-thread-runs.d.ts.map +1 -1
- package/dist/agent/use-agent-thread-runs.js +75 -23
- package/dist/agent-chat/index.d.ts +2 -0
- package/dist/agent-chat/index.d.ts.map +1 -1
- package/dist/agent-chat/index.js +2 -0
- package/dist/agent-chat/thread-message.d.ts +24 -7
- package/dist/agent-chat/thread-message.d.ts.map +1 -1
- package/dist/agent-chat/thread-message.js +102 -43
- package/dist/agent-chat/tool-chip.d.ts +21 -0
- package/dist/agent-chat/tool-chip.d.ts.map +1 -0
- package/dist/agent-chat/tool-chip.js +120 -0
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +1 -3
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/surfaces.d.ts +2 -2
- package/dist/surfaces.js +2 -2
- package/llms.txt +4 -4
- package/package.json +1 -1
- package/src/agent/ai-sdk-transport.test.ts +338 -40
- package/src/agent/ai-sdk-transport.ts +206 -53
- package/src/agent/coalesce.test.ts +194 -0
- package/src/agent/coalesce.ts +118 -0
- package/src/agent/foreign.test.ts +47 -0
- package/src/agent/foreign.ts +140 -0
- package/src/agent/history.ts +2 -0
- package/src/agent/id.test.ts +71 -0
- package/src/agent/id.ts +43 -0
- package/src/agent/index.ts +26 -3
- package/src/agent/merge.test.ts +119 -0
- package/src/agent/merge.ts +67 -0
- package/src/agent/part-list.test.tsx +82 -0
- package/src/agent/part-list.tsx +31 -6
- package/src/agent/parts.test.ts +401 -59
- package/src/agent/parts.ts +262 -32
- package/src/agent/transport.test.ts +70 -0
- package/src/agent/transport.ts +72 -20
- package/src/agent/use-agent-stream.test.tsx +47 -17
- package/src/agent/use-agent-stream.ts +13 -3
- package/src/agent/use-agent-thread-runs.foreign.test.tsx +133 -0
- package/src/agent/use-agent-thread-runs.resume.test.tsx +34 -6
- package/src/agent/use-agent-thread-runs.test.tsx +397 -14
- package/src/agent/use-agent-thread-runs.ts +170 -26
- package/src/agent/use-agent-thread-runs.wedge.test.tsx +4 -2
- package/src/agent-chat/index.ts +4 -0
- package/src/agent-chat/thread-message.test.tsx +211 -0
- package/src/agent-chat/thread-message.tsx +235 -62
- package/src/agent-chat/tool-chip.test.tsx +139 -0
- package/src/agent-chat/tool-chip.tsx +219 -0
- package/src/guard/check-source.test.ts +8 -4
- package/src/guard/index.ts +1 -3
- package/src/index.ts +2 -0
- 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 {
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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:
|
|
143
|
+
tool: ToolChipRenderer,
|
|
156
144
|
source: SourceRenderer,
|
|
157
145
|
error: ErrorRenderer,
|
|
158
146
|
}
|
|
159
147
|
|
|
160
|
-
// ──
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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:
|
|
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[
|
|
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
|
-
|
|
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
|
-
|
|
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?:
|
|
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")
|
|
226
|
-
*
|
|
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
|
|
408
|
+
<MessageBlock
|
|
409
|
+
key={message.id}
|
|
410
|
+
message={message}
|
|
411
|
+
renderers={renderers}
|
|
412
|
+
fallbackRenderer={fallbackRenderer}
|
|
413
|
+
/>
|
|
242
414
|
))}
|
|
243
|
-
{
|
|
415
|
+
{liveMessage !== null && (
|
|
244
416
|
<div aria-live="polite" aria-atomic="false" aria-relevant="additions">
|
|
245
417
|
<MessageBlock
|
|
246
|
-
|
|
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
|
+
})
|