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.
- package/AGENTS.md +21 -21
- package/CHANGELOG.md +25 -0
- package/README.md +36 -27
- package/agent/rules/basalt-content.md +16 -3
- package/dist/agent/adapter.d.ts +146 -0
- package/dist/agent/adapter.d.ts.map +1 -0
- package/dist/agent/adapter.js +473 -0
- package/dist/agent/adapter.test.d.ts +2 -0
- package/dist/agent/adapter.test.d.ts.map +1 -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/ai-sdk-transport.test.d.ts +2 -0
- package/dist/agent/ai-sdk-transport.test.d.ts.map +1 -0
- package/dist/agent/coalesce.d.ts +42 -0
- package/dist/agent/coalesce.d.ts.map +1 -0
- package/dist/agent/coalesce.js +137 -0
- package/dist/agent/coalesce.test.d.ts +2 -0
- package/dist/agent/coalesce.test.d.ts.map +1 -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/foreign.test.d.ts +2 -0
- package/dist/agent/foreign.test.d.ts.map +1 -0
- package/dist/agent/history.d.ts +2 -0
- package/dist/agent/history.d.ts.map +1 -1
- package/dist/agent/id.d.ts +65 -0
- package/dist/agent/id.d.ts.map +1 -0
- package/dist/agent/id.js +30 -0
- package/dist/agent/id.test.d.ts +2 -0
- package/dist/agent/id.test.d.ts.map +1 -0
- package/dist/agent/index.d.ts +11 -4
- package/dist/agent/index.d.ts.map +1 -1
- package/dist/agent/index.js +18 -3
- package/dist/agent/merge.d.ts +8 -0
- package/dist/agent/merge.d.ts.map +1 -0
- package/dist/agent/merge.js +35 -0
- package/dist/agent/merge.test.d.ts +2 -0
- package/dist/agent/merge.test.d.ts.map +1 -0
- package/dist/agent/part-list.d.ts +49 -34
- package/dist/agent/part-list.d.ts.map +1 -1
- package/dist/agent/part-list.js +53 -7
- package/dist/agent/part-list.test.d.ts +2 -0
- package/dist/agent/part-list.test.d.ts.map +1 -0
- package/dist/agent/parts.d.ts +135 -20
- package/dist/agent/parts.d.ts.map +1 -1
- package/dist/agent/parts.js +138 -11
- package/dist/agent/parts.test.d.ts +2 -0
- package/dist/agent/parts.test.d.ts.map +1 -0
- package/dist/agent/thread.d.ts +14 -0
- package/dist/agent/thread.d.ts.map +1 -1
- package/dist/agent/thread.js +8 -2
- package/dist/agent/thread.test.d.ts +2 -0
- package/dist/agent/thread.test.d.ts.map +1 -0
- 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/transport.test.d.ts +2 -0
- package/dist/agent/transport.test.d.ts.map +1 -0
- 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-stream.test.d.ts +2 -0
- package/dist/agent/use-agent-stream.test.d.ts.map +1 -0
- 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.foreign.test.d.ts +2 -0
- package/dist/agent/use-agent-thread-runs.foreign.test.d.ts.map +1 -0
- package/dist/agent/use-agent-thread-runs.js +75 -23
- package/dist/agent/use-agent-thread-runs.resume.test.d.ts +2 -0
- package/dist/agent/use-agent-thread-runs.resume.test.d.ts.map +1 -0
- package/dist/agent/use-agent-thread-runs.test.d.ts +2 -0
- package/dist/agent/use-agent-thread-runs.test.d.ts.map +1 -0
- package/dist/agent/use-agent-thread-runs.wedge.test.d.ts +2 -0
- package/dist/agent/use-agent-thread-runs.wedge.test.d.ts.map +1 -0
- package/dist/agent-chat/composer.d.ts +130 -8
- package/dist/agent-chat/composer.d.ts.map +1 -1
- package/dist/agent-chat/composer.js +189 -23
- package/dist/agent-chat/composer.test.d.ts +2 -0
- package/dist/agent-chat/composer.test.d.ts.map +1 -0
- package/dist/agent-chat/index.d.ts +3 -1
- package/dist/agent-chat/index.d.ts.map +1 -1
- package/dist/agent-chat/index.js +2 -0
- package/dist/agent-chat/thread-detail-panel.d.ts.map +1 -1
- package/dist/agent-chat/thread-detail-panel.js +1 -1
- 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 +115 -45
- package/dist/agent-chat/thread-message.test.d.ts +2 -0
- package/dist/agent-chat/thread-message.test.d.ts.map +1 -0
- package/dist/agent-chat/thread-workspace.js +1 -1
- 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/agent-chat/tool-chip.test.d.ts +2 -0
- package/dist/agent-chat/tool-chip.test.d.ts.map +1 -0
- package/dist/charts/primitives/ChartFrame.test.d.ts +2 -0
- package/dist/charts/primitives/ChartFrame.test.d.ts.map +1 -0
- package/dist/charts/utils/domain.test.d.ts +2 -0
- package/dist/charts/utils/domain.test.d.ts.map +1 -0
- package/dist/cli/check-theme.test.d.ts +2 -0
- package/dist/cli/check-theme.test.d.ts.map +1 -0
- package/dist/cli/help.test.d.ts +2 -0
- package/dist/cli/help.test.d.ts.map +1 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/init-sync.test.d.ts +2 -0
- package/dist/cli/init-sync.test.d.ts.map +1 -0
- package/dist/cli/placement-engine.test.d.ts +2 -0
- package/dist/cli/placement-engine.test.d.ts.map +1 -0
- package/dist/cli/tarball-parity.test.d.ts +2 -0
- package/dist/cli/tarball-parity.test.d.ts.map +1 -0
- package/dist/cli/template-format.test.d.ts +2 -0
- package/dist/cli/template-format.test.d.ts.map +1 -0
- package/dist/cli/template-ledger.test.d.ts +2 -0
- package/dist/cli/template-ledger.test.d.ts.map +1 -0
- package/dist/cli/tokens-css.test.d.ts +2 -0
- package/dist/cli/tokens-css.test.d.ts.map +1 -0
- package/dist/content/article-actions.test.d.ts +2 -0
- package/dist/content/article-actions.test.d.ts.map +1 -0
- package/dist/content/article-model.test.d.ts +2 -0
- package/dist/content/article-model.test.d.ts.map +1 -0
- package/dist/content/block-split.test.d.ts +2 -0
- package/dist/content/block-split.test.d.ts.map +1 -0
- package/dist/content/fence-block.d.ts +37 -10
- package/dist/content/fence-block.d.ts.map +1 -1
- package/dist/content/fence-block.js +80 -14
- package/dist/content/heading-components.d.ts.map +1 -1
- package/dist/content/heading-components.js +7 -4
- package/dist/content/heading-components.test.d.ts +2 -0
- package/dist/content/heading-components.test.d.ts.map +1 -0
- package/dist/content/index.d.ts +6 -3
- package/dist/content/index.d.ts.map +1 -1
- package/dist/content/index.js +5 -1
- package/dist/content/markdown.d.ts +101 -14
- package/dist/content/markdown.d.ts.map +1 -1
- package/dist/content/markdown.js +141 -28
- package/dist/content/markdown.test.d.ts +2 -0
- package/dist/content/markdown.test.d.ts.map +1 -0
- package/dist/content/prose.module.css.test.d.ts +2 -0
- package/dist/content/prose.module.css.test.d.ts.map +1 -0
- package/dist/content/sanitize.d.ts +170 -0
- package/dist/content/sanitize.d.ts.map +1 -0
- package/dist/content/sanitize.js +178 -0
- package/dist/content/sanitize.test.d.ts +2 -0
- package/dist/content/sanitize.test.d.ts.map +1 -0
- package/dist/content/slug.test.d.ts +2 -0
- package/dist/content/slug.test.d.ts.map +1 -0
- package/dist/dashboard/stat-card.test.d.ts +2 -0
- package/dist/dashboard/stat-card.test.d.ts.map +1 -0
- package/dist/guard/check-source.test.d.ts +2 -0
- package/dist/guard/check-source.test.d.ts.map +1 -0
- package/dist/guard/guard-hook.test.d.ts +2 -0
- package/dist/guard/guard-hook.test.d.ts.map +1 -0
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +1 -3
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -2
- package/dist/provider/index.test.d.ts +2 -0
- package/dist/provider/index.test.d.ts.map +1 -0
- package/dist/provider/lab-theme.test.d.ts +2 -0
- package/dist/provider/lab-theme.test.d.ts.map +1 -0
- package/dist/router-tanstack/multi-search-param-store.test.d.ts +2 -0
- package/dist/router-tanstack/multi-search-param-store.test.d.ts.map +1 -0
- package/dist/styles.floor.test.d.ts +2 -0
- package/dist/styles.floor.test.d.ts.map +1 -0
- package/dist/surfaces.d.ts +5 -5
- package/dist/surfaces.d.ts.map +1 -1
- package/dist/surfaces.js +13 -4
- package/dist/theme/border-coverage.test.d.ts +18 -0
- package/dist/theme/border-coverage.test.d.ts.map +1 -0
- package/dist/theme/contrast.test.d.ts +2 -0
- package/dist/theme/contrast.test.d.ts.map +1 -0
- package/dist/theme/density-option.test.d.ts +2 -0
- package/dist/theme/density-option.test.d.ts.map +1 -0
- package/dist/theme/density-relations.test.d.ts +2 -0
- package/dist/theme/density-relations.test.d.ts.map +1 -0
- package/dist/theme/derive-theme.test.d.ts +2 -0
- package/dist/theme/derive-theme.test.d.ts.map +1 -0
- package/dist/theme/fonts-option.test.d.ts +2 -0
- package/dist/theme/fonts-option.test.d.ts.map +1 -0
- package/dist/theme/radius-option.test.d.ts +2 -0
- package/dist/theme/radius-option.test.d.ts.map +1 -0
- package/dist/theme/radius.test.d.ts +2 -0
- package/dist/theme/radius.test.d.ts.map +1 -0
- package/dist/theme/shadow-surfaces.test.d.ts +26 -0
- package/dist/theme/shadow-surfaces.test.d.ts.map +1 -0
- package/dist/theme/spacing.test.d.ts +2 -0
- package/dist/theme/spacing.test.d.ts.map +1 -0
- package/dist/theme/use-basalt-spacing.test.d.ts +2 -0
- package/dist/theme/use-basalt-spacing.test.d.ts.map +1 -0
- package/dist/theme-lab/derive-controls.test.d.ts +2 -0
- package/dist/theme-lab/derive-controls.test.d.ts.map +1 -0
- package/dist/theme-lab/derive-state.test.d.ts +2 -0
- package/dist/theme-lab/derive-state.test.d.ts.map +1 -0
- package/dist/theme-lab/theme-lab.test.d.ts +2 -0
- package/dist/theme-lab/theme-lab.test.d.ts.map +1 -0
- package/dist/tokens/build-fonts-css.test.d.ts +2 -0
- package/dist/tokens/build-fonts-css.test.d.ts.map +1 -0
- package/dist/tokens/build-radius-css.test.d.ts +2 -0
- package/dist/tokens/build-radius-css.test.d.ts.map +1 -0
- package/dist/tokens/density.test.d.ts +2 -0
- package/dist/tokens/density.test.d.ts.map +1 -0
- package/dist/tokens/derive-radius.test.d.ts +2 -0
- package/dist/tokens/derive-radius.test.d.ts.map +1 -0
- package/dist/tokens/derive.test.d.ts +2 -0
- package/dist/tokens/derive.test.d.ts.map +1 -0
- package/dist/tokens/palette.test.d.ts +2 -0
- package/dist/tokens/palette.test.d.ts.map +1 -0
- package/dist/vite-color-mix.test.d.ts +2 -0
- package/dist/vite-color-mix.test.d.ts.map +1 -0
- package/dist/vite.test.d.ts +2 -0
- package/dist/vite.test.d.ts.map +1 -0
- package/llms.txt +7 -7
- package/package.json +6 -1
- package/src/agent/adapter.test.ts +1403 -0
- package/src/agent/adapter.ts +887 -0
- package/src/agent/ai-sdk-transport.test.ts +380 -55
- package/src/agent/ai-sdk-transport.ts +206 -53
- package/src/agent/coalesce.test.ts +387 -0
- package/src/agent/coalesce.ts +229 -0
- package/src/agent/foreign.test.ts +51 -0
- package/src/agent/foreign.ts +140 -0
- package/src/agent/history.ts +2 -0
- package/src/agent/id.test.ts +75 -0
- package/src/agent/id.ts +91 -0
- package/src/agent/index.ts +34 -3
- package/src/agent/merge.test.ts +206 -0
- package/src/agent/merge.ts +114 -0
- package/src/agent/part-list.test.tsx +112 -0
- package/src/agent/part-list.tsx +108 -35
- package/src/agent/parts.test.ts +516 -57
- package/src/agent/parts.ts +323 -32
- package/src/agent/thread.test.ts +75 -2
- package/src/agent/thread.ts +21 -1
- package/src/agent/transport.test.ts +70 -0
- package/src/agent/transport.ts +72 -20
- package/src/agent/use-agent-stream.test.tsx +53 -19
- package/src/agent/use-agent-stream.ts +13 -3
- package/src/agent/use-agent-thread-runs.foreign.test.tsx +138 -0
- package/src/agent/use-agent-thread-runs.resume.test.tsx +44 -11
- package/src/agent/use-agent-thread-runs.test.tsx +418 -22
- package/src/agent/use-agent-thread-runs.ts +170 -26
- package/src/agent/use-agent-thread-runs.wedge.test.tsx +15 -7
- package/src/agent-chat/composer.test.tsx +924 -0
- package/src/agent-chat/composer.tsx +521 -52
- package/src/agent-chat/index.ts +5 -1
- package/src/agent-chat/thread-detail-panel.tsx +3 -1
- package/src/agent-chat/thread-message.test.tsx +351 -0
- package/src/agent-chat/thread-message.tsx +262 -64
- package/src/agent-chat/thread-workspace.tsx +1 -1
- package/src/agent-chat/tool-chip.test.tsx +139 -0
- package/src/agent-chat/tool-chip.tsx +219 -0
- package/src/cli/index.ts +0 -1
- package/src/cli/template-format.test.ts +2 -2
- package/src/content/fence-block.tsx +242 -15
- package/src/content/heading-components.test.tsx +65 -0
- package/src/content/heading-components.tsx +26 -5
- package/src/content/index.ts +19 -3
- package/src/content/markdown.test.tsx +603 -0
- package/src/content/markdown.tsx +355 -35
- package/src/content/sanitize.test.ts +659 -0
- package/src/content/sanitize.ts +510 -0
- package/src/dashboard/stat-card.test.tsx +6 -1
- package/src/guard/check-source.test.ts +8 -4
- package/src/guard/index.ts +1 -3
- package/src/index.ts +15 -1
- package/src/surfaces.ts +13 -4
- package/src/theme/contrast.test.ts +1 -4
- package/src/theme/density-relations.test.ts +4 -2
- package/src/theme/fonts-option.test.ts +7 -1
- package/src/theme/shadow-surfaces.test.ts +12 -6
- package/src/theme/use-basalt-spacing.test.tsx +2 -1
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mergePart — identity-addressed accumulator over a part array.
|
|
3
|
+
*
|
|
4
|
+
* Appending a part whose `id` is already present in `parts` REWRITES that entry in place rather
|
|
5
|
+
* than pushing a duplicate — ordering stays stable (a rewritten part keeps its original position).
|
|
6
|
+
* `text`/`reasoning` splice the incoming delta into the existing text at `next.offset` (an
|
|
7
|
+
* undefined offset means "append at the tail", and an out-of-range one is clamped rather than
|
|
8
|
+
* honoured — see `spliceText` for the exact supported shapes); every other variant — including any
|
|
9
|
+
* `ForeignPart`, which is never text-like — replaces wholesale. A FIRST insertion of a text-like
|
|
10
|
+
* part goes through the same splice against an empty text, so an offset that claims a prefix this
|
|
11
|
+
* accumulator never saw is clamped and warned about there too — never honoured, never padded.
|
|
12
|
+
*
|
|
13
|
+
* The invariant this holds, and the reason `offset` exists at all: replaying a whole run from
|
|
14
|
+
* character 0 rebuilds the identical parts array it produced the first time — it cannot double
|
|
15
|
+
* it. Splicing a full-length delta at offset 0 overwrites the existing text in place (the
|
|
16
|
+
* trailing slice covers nothing once the incoming text's length matches what's already there),
|
|
17
|
+
* so a resumed/replayed stream converges instead of duplicating content.
|
|
18
|
+
*
|
|
19
|
+
* Constrained on `PartLike` (structural: `id` + `type`), NOT `AgentPart` — a widened, backward-
|
|
20
|
+
* compatible relaxation so `TranscriptPart` (`AgentPart | ForeignPart`, ./foreign) flows through
|
|
21
|
+
* the same accumulator a transport-streamed foreign part must pass through on its way from the
|
|
22
|
+
* transport into `useAgentThreadRuns`'s `runs` map. `AgentPart`'s closed union is untouched —
|
|
23
|
+
* only the constraint widens, never the union itself.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* let parts: AgentPart[] = []
|
|
27
|
+
* parts = mergePart(parts, { id: 'p1', type: 'text', text: 'Hel' })
|
|
28
|
+
* parts = mergePart(parts, { id: 'p1', type: 'text', text: 'lo' }) // splices at the tail
|
|
29
|
+
* // parts === [{ id: 'p1', type: 'text', text: 'Hello' }]
|
|
30
|
+
*/
|
|
31
|
+
import type { ReasoningPart, TextPart } from './parts'
|
|
32
|
+
|
|
33
|
+
type TextLike = TextPart | ReasoningPart
|
|
34
|
+
|
|
35
|
+
/** The structural bound `mergePart` (and the hooks that call it) actually need — an `AgentPart` or
|
|
36
|
+
* a `ForeignPart` both satisfy it, so this is the widened constraint, not a new closed union. */
|
|
37
|
+
export type PartLike = { readonly id: string; readonly type: string }
|
|
38
|
+
|
|
39
|
+
// Widened from `AgentPart['type']` to `string`: a plain type-predicate narrowing, so accepting a
|
|
40
|
+
// wider input (any string, including a ForeignPart's) is safe — it still only returns true for the
|
|
41
|
+
// two text-like literals, and a ForeignPart's type string always falls through it to the
|
|
42
|
+
// wholesale-replace path below.
|
|
43
|
+
function isTextLikeType(type: string): type is TextLike['type'] {
|
|
44
|
+
return type === 'text' || type === 'reasoning'
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Splices `next`'s text into `existingText` at `next.offset`. Exactly two shapes are supported,
|
|
49
|
+
* and they are the only two any basalt transport emits (`diffPart` always sets
|
|
50
|
+
* `offset = prevText.length`; `diffChunkStream` restarts its accumulator per call):
|
|
51
|
+
*
|
|
52
|
+
* 1. **append at the end** — `offset` undefined, or equal to `existingText.length`;
|
|
53
|
+
* 2. **in-range replace** — `0 <= offset <= existingText.length`, overwriting the span the
|
|
54
|
+
* incoming text covers (this is what makes an offset-0 full replay converge instead of double).
|
|
55
|
+
*
|
|
56
|
+
* An offset outside that range — negative, past the current length, or non-finite — is **clamped**
|
|
57
|
+
* into `[0, existingText.length]`, not honoured and not thrown on. A throw here would turn a
|
|
58
|
+
* transient wire anomaly into a permanently dead transcript; this is stream-reduction code on the
|
|
59
|
+
* render path. Each clamp is announced in dev.
|
|
60
|
+
*
|
|
61
|
+
* A FIRST insertion (no entry with this id yet) runs through here too, against an empty
|
|
62
|
+
* `existingText`: the stored text is the incoming text either way, but a nonzero offset — the wire
|
|
63
|
+
* claiming text this accumulator never saw — is clamped to 0 and announced like any other
|
|
64
|
+
* out-of-range value rather than passing silently. `mergePart` never invents the missing prefix.
|
|
65
|
+
*
|
|
66
|
+
* Note what is NOT special-cased: an out-of-order delta whose offset is still in range is an
|
|
67
|
+
* ordinary in-range replace, so it overwrites whatever already occupies that span. `mergePart`
|
|
68
|
+
* has no way to tell a stale delta from a deliberate rewrite, and inventing one would break the
|
|
69
|
+
* offset-0 replay convergence this whole mechanism exists for.
|
|
70
|
+
*/
|
|
71
|
+
function spliceText(existingText: string, next: TextLike): string {
|
|
72
|
+
const offset = clampOffset(next.offset ?? existingText.length, existingText.length)
|
|
73
|
+
return existingText.slice(0, offset) + next.text + existingText.slice(offset + next.text.length)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Clamps a wire offset into `[0, length]`, warning in dev when the raw value was out of range. */
|
|
77
|
+
function clampOffset(offset: number, length: number): number {
|
|
78
|
+
// A non-finite offset would make every slice() return '' and blank the part outright — it is
|
|
79
|
+
// out of range like any other, and appending is the safe reading of "position unknown".
|
|
80
|
+
const clamped = Number.isFinite(offset) ? Math.min(Math.max(offset, 0), length) : length
|
|
81
|
+
if (clamped !== offset && process.env['NODE_ENV'] !== 'production') {
|
|
82
|
+
console.warn(
|
|
83
|
+
`[basalt] mergePart: text offset ${offset} out of range [0, ${length}] — clamped to ${clamped}`,
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
return clamped
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function mergePart<TPart extends PartLike>(parts: readonly TPart[], next: TPart): TPart[] {
|
|
90
|
+
const index = parts.findIndex((part) => part.id === next.id)
|
|
91
|
+
const existing = index === -1 ? undefined : (parts[index] as TPart)
|
|
92
|
+
|
|
93
|
+
// A first insertion of a text-like part is spliced too — against an empty existing text — so the
|
|
94
|
+
// offset contract is one path with one set of rules: same clamp, same dev warning, and the same
|
|
95
|
+
// stored result (`'' + next.text`) an unconditional append would have produced.
|
|
96
|
+
const spliceable =
|
|
97
|
+
isTextLikeType(next.type) && (existing === undefined || existing.type === next.type)
|
|
98
|
+
|
|
99
|
+
const merged: TPart = spliceable
|
|
100
|
+
? ({
|
|
101
|
+
...next,
|
|
102
|
+
text: spliceText(
|
|
103
|
+
existing === undefined ? '' : (existing as unknown as TextLike).text,
|
|
104
|
+
next as unknown as TextLike,
|
|
105
|
+
),
|
|
106
|
+
} as TPart)
|
|
107
|
+
: next
|
|
108
|
+
|
|
109
|
+
if (existing === undefined) return [...parts, merged]
|
|
110
|
+
|
|
111
|
+
const out = parts.slice()
|
|
112
|
+
out[index] = merged
|
|
113
|
+
return out
|
|
114
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PartList's headless default tool-call renderer — a failed/denied call must stay visible.
|
|
3
|
+
*
|
|
4
|
+
* Regression coverage: after the seven-state ToolCallPart split, `output-error` carries no
|
|
5
|
+
* `output` field, so the old `part.state === 'output-available'`-only body left a failed call
|
|
6
|
+
* rendering as name+input and nothing else — the failure was silently invisible. Same gap for
|
|
7
|
+
* `output-denied`, indistinguishable from a still-pending call.
|
|
8
|
+
*
|
|
9
|
+
* Plus the `settled` flag's forwarding contract — the field that lets a text renderer drop out of
|
|
10
|
+
* streaming mode once the message it belongs to has finished.
|
|
11
|
+
*/
|
|
12
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
13
|
+
import { afterEach, describe, expect, test } from 'bun:test'
|
|
14
|
+
import { PartList } from './part-list'
|
|
15
|
+
import type { AgentPart, ToolCallPart } from './parts'
|
|
16
|
+
|
|
17
|
+
afterEach(cleanup)
|
|
18
|
+
|
|
19
|
+
const BASE = { id: 'p1', type: 'tool' as const, toolCallId: 'c1', toolName: 'search' }
|
|
20
|
+
|
|
21
|
+
describe('DefaultToolCall — terminal states stay visible', () => {
|
|
22
|
+
test('output-error renders the errorText', () => {
|
|
23
|
+
const part: ToolCallPart = { ...BASE, state: 'output-error', errorText: 'boom' }
|
|
24
|
+
render(<PartList parts={[part]} />)
|
|
25
|
+
expect(screen.getByText('boom')).not.toBeNull()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('output-error with no validated input falls back to rendering rawInput', () => {
|
|
29
|
+
const part: ToolCallPart = {
|
|
30
|
+
...BASE,
|
|
31
|
+
state: 'output-error',
|
|
32
|
+
errorText: 'boom',
|
|
33
|
+
rawInput: { q: 'x' },
|
|
34
|
+
}
|
|
35
|
+
render(<PartList parts={[part]} />)
|
|
36
|
+
expect(screen.getByText(/"q": "x"/)).not.toBeNull()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('output-error with validated input present does not also render rawInput', () => {
|
|
40
|
+
const part: ToolCallPart = {
|
|
41
|
+
...BASE,
|
|
42
|
+
state: 'output-error',
|
|
43
|
+
errorText: 'boom',
|
|
44
|
+
input: { q: 'x' },
|
|
45
|
+
rawInput: { q: 'stale' },
|
|
46
|
+
}
|
|
47
|
+
render(<PartList parts={[part]} />)
|
|
48
|
+
expect(screen.queryByText(/stale/)).toBeNull()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('output-denied is visibly denied, not indistinguishable from a pending call', () => {
|
|
52
|
+
const part: ToolCallPart = {
|
|
53
|
+
...BASE,
|
|
54
|
+
state: 'output-denied',
|
|
55
|
+
input: { q: 'x' },
|
|
56
|
+
approval: { id: 'a1', approved: false, reason: 'too risky' },
|
|
57
|
+
}
|
|
58
|
+
render(<PartList parts={[part]} />)
|
|
59
|
+
expect(screen.getByText(/Denied/)).not.toBeNull()
|
|
60
|
+
expect(screen.getByText(/too risky/)).not.toBeNull()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('output-denied with no reason still renders a visible denial', () => {
|
|
64
|
+
const part: ToolCallPart = {
|
|
65
|
+
...BASE,
|
|
66
|
+
state: 'output-denied',
|
|
67
|
+
input: { q: 'x' },
|
|
68
|
+
approval: { id: 'a1', approved: false },
|
|
69
|
+
}
|
|
70
|
+
render(<PartList parts={[part]} />)
|
|
71
|
+
expect(screen.getByText('Denied')).not.toBeNull()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('output-available is unaffected — still renders output, no error/denied line', () => {
|
|
75
|
+
const part: ToolCallPart = {
|
|
76
|
+
...BASE,
|
|
77
|
+
state: 'output-available',
|
|
78
|
+
input: { q: 'x' },
|
|
79
|
+
output: { hits: 3 },
|
|
80
|
+
}
|
|
81
|
+
render(<PartList parts={[part]} />)
|
|
82
|
+
expect(screen.getByText(/"hits": 3/)).not.toBeNull()
|
|
83
|
+
expect(screen.queryByText(/Denied/)).toBeNull()
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe('settled — forwarded to every renderer', () => {
|
|
88
|
+
const TEXT: AgentPart = { id: 't1', type: 'text', text: 'hello' }
|
|
89
|
+
|
|
90
|
+
function settledProbe() {
|
|
91
|
+
const seen: boolean[] = []
|
|
92
|
+
const components = {
|
|
93
|
+
text: ({ settled }: { settled: boolean }) => {
|
|
94
|
+
seen.push(settled)
|
|
95
|
+
return <div data-testid="probe">{String(settled)}</div>
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
return { seen, components }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
test('defaults to true — a caller that knows nothing about a run renders a finished list', () => {
|
|
102
|
+
const { seen, components } = settledProbe()
|
|
103
|
+
render(<PartList parts={[TEXT]} components={components} />)
|
|
104
|
+
expect(seen).toEqual([true])
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('an explicit settled={false} reaches the renderer', () => {
|
|
108
|
+
const { seen, components } = settledProbe()
|
|
109
|
+
render(<PartList parts={[TEXT]} components={components} settled={false} />)
|
|
110
|
+
expect(seen).toEqual([false])
|
|
111
|
+
})
|
|
112
|
+
})
|
package/src/agent/part-list.tsx
CHANGED
|
@@ -19,7 +19,9 @@
|
|
|
19
19
|
* <PartList
|
|
20
20
|
* parts={parts}
|
|
21
21
|
* components={{
|
|
22
|
-
* text: ({ part }) =>
|
|
22
|
+
* text: ({ part, settled }) => (
|
|
23
|
+
* <Markdown streaming={!settled} contentTrust="untrusted" density="chat">{part.text}</Markdown>
|
|
24
|
+
* ),
|
|
23
25
|
* }}
|
|
24
26
|
* />
|
|
25
27
|
*/
|
|
@@ -38,37 +40,48 @@ import type {
|
|
|
38
40
|
|
|
39
41
|
// ── Per-type renderer signatures ──────────────────────────────────────────────
|
|
40
42
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
+
/**
|
|
44
|
+
* The per-renderer argument. `settled` is `false` only while the message these parts belong to is
|
|
45
|
+
* still streaming — it converges this map toward the `PartRenderContext` a foreign renderer
|
|
46
|
+
* already receives (`./foreign`), rather than adding a third shape. Additive and
|
|
47
|
+
* source-compatible: an existing `({ part }) => …` renderer simply ignores it.
|
|
48
|
+
*
|
|
49
|
+
* Settlement is MONOTONE — a message that has settled never un-settles.
|
|
50
|
+
*/
|
|
51
|
+
type PartRenderArgs<TPart> = {
|
|
52
|
+
part: TPart
|
|
43
53
|
index: number
|
|
44
|
-
|
|
54
|
+
settled: boolean
|
|
55
|
+
}
|
|
45
56
|
|
|
46
|
-
export type
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}) => JSX.Element | null
|
|
57
|
+
export type TextPartRenderer<TPart extends AgentPart = AgentPart> = (
|
|
58
|
+
props: PartRenderArgs<Extract<TPart, { type: 'text' }>>,
|
|
59
|
+
) => JSX.Element | null
|
|
50
60
|
|
|
51
|
-
export type
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}) => JSX.Element | null
|
|
61
|
+
export type ReasoningPartRenderer<TPart extends AgentPart = AgentPart> = (
|
|
62
|
+
props: PartRenderArgs<Extract<TPart, { type: 'reasoning' }>>,
|
|
63
|
+
) => JSX.Element | null
|
|
55
64
|
|
|
56
|
-
export type
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}) => JSX.Element | null
|
|
65
|
+
export type ToolCallPartRenderer<TPart extends AgentPart = AgentPart> = (
|
|
66
|
+
props: PartRenderArgs<Extract<TPart, { type: 'tool' }>>,
|
|
67
|
+
) => JSX.Element | null
|
|
60
68
|
|
|
61
|
-
export type
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
69
|
+
export type SourcePartRenderer<TPart extends AgentPart = AgentPart> = (
|
|
70
|
+
props: PartRenderArgs<Extract<TPart, { type: 'source' }>>,
|
|
71
|
+
) => JSX.Element | null
|
|
72
|
+
|
|
73
|
+
export type ErrorPartRenderer<TPart extends AgentPart = AgentPart> = (
|
|
74
|
+
props: PartRenderArgs<Extract<TPart, { type: 'error' }>>,
|
|
75
|
+
) => JSX.Element | null
|
|
65
76
|
|
|
66
77
|
/**
|
|
67
78
|
* Partial map of per-type renderers. Any omitted key falls back to the headless default.
|
|
68
79
|
*
|
|
69
80
|
* @example
|
|
70
81
|
* const renderers: Partial<AgentPartRenderers> = {
|
|
71
|
-
* text: ({ part }) =>
|
|
82
|
+
* text: ({ part, settled }) => (
|
|
83
|
+
* <Markdown streaming={!settled} contentTrust="untrusted" density="chat">{part.text}</Markdown>
|
|
84
|
+
* ),
|
|
72
85
|
* }
|
|
73
86
|
*/
|
|
74
87
|
export type AgentPartRenderers<TPart extends AgentPart = AgentPart> = {
|
|
@@ -112,6 +125,12 @@ const CODE_BLOCK_STYLE = {
|
|
|
112
125
|
overflowX: 'auto',
|
|
113
126
|
} as const
|
|
114
127
|
|
|
128
|
+
/** Terminal-state status line (tool-call error/denial) — plain text, no code-block chrome. */
|
|
129
|
+
const STATUS_LINE_STYLE = {
|
|
130
|
+
marginTop: 'var(--vx-space-agent-part-gap-top)',
|
|
131
|
+
color: VX.status.bad,
|
|
132
|
+
} as const
|
|
133
|
+
|
|
115
134
|
function DefaultText({ part }: { part: TextPart; index: number }): JSX.Element {
|
|
116
135
|
return (
|
|
117
136
|
<div
|
|
@@ -151,11 +170,30 @@ function DefaultToolCall({ part }: { part: ToolCallPart; index: number }): JSX.E
|
|
|
151
170
|
<pre className="basalt-agent-tool-input" style={CODE_BLOCK_STYLE}>
|
|
152
171
|
{JSON.stringify(part.input, null, 2)}
|
|
153
172
|
</pre>
|
|
154
|
-
{
|
|
173
|
+
{/* output-error's input never validated — rawInput is the only surviving record of what was
|
|
174
|
+
actually sent, so show it whenever there is no validated input to show instead. */}
|
|
175
|
+
{part.state === 'output-error' && part.input === undefined && part.rawInput !== undefined && (
|
|
176
|
+
<pre className="basalt-agent-tool-raw-input" style={CODE_BLOCK_STYLE}>
|
|
177
|
+
{JSON.stringify(part.rawInput, null, 2)}
|
|
178
|
+
</pre>
|
|
179
|
+
)}
|
|
180
|
+
{part.state === 'output-available' && (
|
|
155
181
|
<pre className="basalt-agent-tool-output" style={CODE_BLOCK_STYLE}>
|
|
156
182
|
{JSON.stringify(part.output, null, 2)}
|
|
157
183
|
</pre>
|
|
158
184
|
)}
|
|
185
|
+
{/* A failed or denied call must stay visible after the seven-state split — neither carries
|
|
186
|
+
an `output`, so without this the call renders as name+input and nothing else. */}
|
|
187
|
+
{part.state === 'output-error' && (
|
|
188
|
+
<div className="basalt-agent-tool-error" role="alert" style={STATUS_LINE_STYLE}>
|
|
189
|
+
{part.errorText}
|
|
190
|
+
</div>
|
|
191
|
+
)}
|
|
192
|
+
{part.state === 'output-denied' && (
|
|
193
|
+
<div className="basalt-agent-tool-denied" style={STATUS_LINE_STYLE}>
|
|
194
|
+
Denied{part.approval.reason !== undefined ? `: ${part.approval.reason}` : ''}
|
|
195
|
+
</div>
|
|
196
|
+
)}
|
|
159
197
|
</div>
|
|
160
198
|
)
|
|
161
199
|
}
|
|
@@ -206,10 +244,17 @@ export type PartListProps<TPart extends AgentPart = AgentPart> = {
|
|
|
206
244
|
readonly parts: TPart[]
|
|
207
245
|
/**
|
|
208
246
|
* Override individual part renderers. Omitted keys fall back to the headless defaults.
|
|
209
|
-
* Pass `{ text: ({ part }) => <Markdown streaming density="chat">{part.text}</Markdown> }`
|
|
210
|
-
* enable rich markdown rendering.
|
|
247
|
+
* Pass `{ text: ({ part, settled }) => <Markdown streaming={!settled} contentTrust="untrusted" density="chat">{part.text}</Markdown> }`
|
|
248
|
+
* to enable rich markdown rendering.
|
|
211
249
|
*/
|
|
212
250
|
readonly components?: Partial<AgentPartRenderers<TPart>>
|
|
251
|
+
/**
|
|
252
|
+
* `false` while the message these parts belong to is still streaming — forwarded to every
|
|
253
|
+
* renderer so a text renderer can drop out of streaming mode (and a fenced mermaid/diagram can
|
|
254
|
+
* upgrade) once the turn finishes. Defaults to `true`: a caller that knows nothing about a run
|
|
255
|
+
* is rendering a finished list, and un-settled must be opted into by the thing that does know.
|
|
256
|
+
*/
|
|
257
|
+
readonly settled?: boolean
|
|
213
258
|
}
|
|
214
259
|
|
|
215
260
|
/**
|
|
@@ -226,11 +271,19 @@ export type PartListProps<TPart extends AgentPart = AgentPart> = {
|
|
|
226
271
|
* // With basalt's own Markdown for the text renderer:
|
|
227
272
|
* import { PartList } from 'basalt-ui/agent'
|
|
228
273
|
* import { Markdown } from 'basalt-ui/content'
|
|
229
|
-
* <PartList
|
|
274
|
+
* <PartList
|
|
275
|
+
* parts={parts}
|
|
276
|
+
* components={{
|
|
277
|
+
* text: ({ part, settled }) => (
|
|
278
|
+
* <Markdown streaming={!settled} contentTrust="untrusted" density="chat">{part.text}</Markdown>
|
|
279
|
+
* ),
|
|
280
|
+
* }}
|
|
281
|
+
* />
|
|
230
282
|
*/
|
|
231
283
|
export function PartList<TPart extends AgentPart = AgentPart>({
|
|
232
284
|
parts,
|
|
233
285
|
components,
|
|
286
|
+
settled = true,
|
|
234
287
|
}: PartListProps<TPart>): JSX.Element {
|
|
235
288
|
// Memoised to avoid rebuilding the renderer map on every streaming re-render (hot path).
|
|
236
289
|
// Cast required because DEFAULT_RENDERERS is typed for the base AgentPart, not the generic TPart.
|
|
@@ -251,40 +304,60 @@ export function PartList<TPart extends AgentPart = AgentPart>({
|
|
|
251
304
|
case 'text': {
|
|
252
305
|
const Render = renderers.text
|
|
253
306
|
return (
|
|
254
|
-
<Fragment key={
|
|
255
|
-
<Render
|
|
307
|
+
<Fragment key={part.id}>
|
|
308
|
+
<Render
|
|
309
|
+
part={part as Extract<TPart, { type: 'text' }>}
|
|
310
|
+
index={index}
|
|
311
|
+
settled={settled}
|
|
312
|
+
/>
|
|
256
313
|
</Fragment>
|
|
257
314
|
)
|
|
258
315
|
}
|
|
259
316
|
case 'reasoning': {
|
|
260
317
|
const Render = renderers.reasoning
|
|
261
318
|
return (
|
|
262
|
-
<Fragment key={
|
|
263
|
-
<Render
|
|
319
|
+
<Fragment key={part.id}>
|
|
320
|
+
<Render
|
|
321
|
+
part={part as Extract<TPart, { type: 'reasoning' }>}
|
|
322
|
+
index={index}
|
|
323
|
+
settled={settled}
|
|
324
|
+
/>
|
|
264
325
|
</Fragment>
|
|
265
326
|
)
|
|
266
327
|
}
|
|
267
328
|
case 'tool': {
|
|
268
329
|
const Render = renderers.tool
|
|
269
330
|
return (
|
|
270
|
-
<Fragment key={
|
|
271
|
-
<Render
|
|
331
|
+
<Fragment key={part.id}>
|
|
332
|
+
<Render
|
|
333
|
+
part={part as Extract<TPart, { type: 'tool' }>}
|
|
334
|
+
index={index}
|
|
335
|
+
settled={settled}
|
|
336
|
+
/>
|
|
272
337
|
</Fragment>
|
|
273
338
|
)
|
|
274
339
|
}
|
|
275
340
|
case 'source': {
|
|
276
341
|
const Render = renderers.source
|
|
277
342
|
return (
|
|
278
|
-
<Fragment key={
|
|
279
|
-
<Render
|
|
343
|
+
<Fragment key={part.id}>
|
|
344
|
+
<Render
|
|
345
|
+
part={part as Extract<TPart, { type: 'source' }>}
|
|
346
|
+
index={index}
|
|
347
|
+
settled={settled}
|
|
348
|
+
/>
|
|
280
349
|
</Fragment>
|
|
281
350
|
)
|
|
282
351
|
}
|
|
283
352
|
case 'error': {
|
|
284
353
|
const Render = renderers.error
|
|
285
354
|
return (
|
|
286
|
-
<Fragment key={
|
|
287
|
-
<Render
|
|
355
|
+
<Fragment key={part.id}>
|
|
356
|
+
<Render
|
|
357
|
+
part={part as Extract<TPart, { type: 'error' }>}
|
|
358
|
+
index={index}
|
|
359
|
+
settled={settled}
|
|
360
|
+
/>
|
|
288
361
|
</Fragment>
|
|
289
362
|
)
|
|
290
363
|
}
|