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
|
@@ -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. */
|
|
@@ -59,12 +65,32 @@ const MICRO_LABEL_STYLE = {
|
|
|
59
65
|
|
|
60
66
|
// ── Per-type Mantine renderers ────────────────────────────────────────────────
|
|
61
67
|
|
|
62
|
-
function TextRenderer({
|
|
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
|
+
// `` 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
|
-
|
|
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
|
-
)
|
|
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:
|
|
163
|
+
tool: ToolChipRenderer,
|
|
156
164
|
source: SourceRenderer,
|
|
157
165
|
error: ErrorRenderer,
|
|
158
166
|
}
|
|
159
167
|
|
|
160
|
-
// ──
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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:
|
|
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[
|
|
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
|
-
|
|
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
|
-
|
|
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?:
|
|
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")
|
|
226
|
-
*
|
|
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
|
|
433
|
+
<MessageBlock
|
|
434
|
+
key={message.id}
|
|
435
|
+
message={message}
|
|
436
|
+
renderers={renderers}
|
|
437
|
+
fallbackRenderer={fallbackRenderer}
|
|
438
|
+
/>
|
|
242
439
|
))}
|
|
243
|
-
{
|
|
440
|
+
{liveMessage !== null && (
|
|
244
441
|
<div aria-live="polite" aria-atomic="false" aria-relevant="additions">
|
|
245
442
|
<MessageBlock
|
|
246
|
-
|
|
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
|
+
})
|