basalt-ui 1.9.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +18 -0
  3. package/README.md +64 -34
  4. package/agent/rules/basalt-agent.md +49 -6
  5. package/configs/oxlint-plugin.js +279 -0
  6. package/configs/oxlint-plugin.test.ts +216 -1
  7. package/configs/oxlint.json +5 -2
  8. package/dist/agent/ai-sdk-transport.d.ts +3 -52
  9. package/dist/agent/ai-sdk-transport.d.ts.map +1 -1
  10. package/dist/agent/ai-sdk-transport.js +108 -24
  11. package/dist/agent/coalesce.d.ts +26 -0
  12. package/dist/agent/coalesce.d.ts.map +1 -0
  13. package/dist/agent/coalesce.js +69 -0
  14. package/dist/agent/foreign.d.ts +97 -0
  15. package/dist/agent/foreign.d.ts.map +1 -0
  16. package/dist/agent/foreign.js +18 -0
  17. package/dist/agent/history.d.ts +2 -0
  18. package/dist/agent/history.d.ts.map +1 -1
  19. package/dist/agent/id.d.ts +35 -0
  20. package/dist/agent/id.d.ts.map +1 -0
  21. package/dist/agent/id.js +14 -0
  22. package/dist/agent/index.d.ts +9 -4
  23. package/dist/agent/index.d.ts.map +1 -1
  24. package/dist/agent/index.js +15 -3
  25. package/dist/agent/merge.d.ts +8 -0
  26. package/dist/agent/merge.d.ts.map +1 -0
  27. package/dist/agent/merge.js +23 -0
  28. package/dist/agent/part-list.d.ts.map +1 -1
  29. package/dist/agent/part-list.js +16 -6
  30. package/dist/agent/parts.d.ts +100 -21
  31. package/dist/agent/parts.d.ts.map +1 -1
  32. package/dist/agent/parts.js +128 -11
  33. package/dist/agent/transport.d.ts +39 -3
  34. package/dist/agent/transport.d.ts.map +1 -1
  35. package/dist/agent/transport.js +20 -17
  36. package/dist/agent/use-agent-stream.d.ts +2 -1
  37. package/dist/agent/use-agent-stream.d.ts.map +1 -1
  38. package/dist/agent/use-agent-stream.js +5 -2
  39. package/dist/agent/use-agent-thread-runs.d.ts +9 -3
  40. package/dist/agent/use-agent-thread-runs.d.ts.map +1 -1
  41. package/dist/agent/use-agent-thread-runs.js +76 -23
  42. package/dist/agent-chat/index.d.ts +6 -3
  43. package/dist/agent-chat/index.d.ts.map +1 -1
  44. package/dist/agent-chat/index.js +2 -0
  45. package/dist/agent-chat/thread-message.d.ts +24 -7
  46. package/dist/agent-chat/thread-message.d.ts.map +1 -1
  47. package/dist/agent-chat/thread-message.js +102 -43
  48. package/dist/agent-chat/tool-chip.d.ts +21 -0
  49. package/dist/agent-chat/tool-chip.d.ts.map +1 -0
  50. package/dist/agent-chat/tool-chip.js +120 -0
  51. package/dist/cli/index.d.ts +33 -0
  52. package/dist/cli/index.d.ts.map +1 -1
  53. package/dist/cli/index.js +131 -0
  54. package/dist/guard/index.d.ts.map +1 -1
  55. package/dist/guard/index.js +1 -3
  56. package/dist/index.d.ts +2 -2
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +3 -1
  59. package/dist/surfaces.d.ts +11 -1
  60. package/dist/surfaces.d.ts.map +1 -1
  61. package/dist/surfaces.js +22 -1
  62. package/llms.txt +10 -3
  63. package/package.json +6 -1
  64. package/src/agent/ai-sdk-transport.test.ts +567 -0
  65. package/src/agent/ai-sdk-transport.ts +206 -53
  66. package/src/agent/coalesce.test.ts +194 -0
  67. package/src/agent/coalesce.ts +118 -0
  68. package/src/agent/foreign.test.ts +47 -0
  69. package/src/agent/foreign.ts +140 -0
  70. package/src/agent/history.ts +2 -0
  71. package/src/agent/id.test.ts +71 -0
  72. package/src/agent/id.ts +43 -0
  73. package/src/agent/index.ts +26 -3
  74. package/src/agent/merge.test.ts +119 -0
  75. package/src/agent/merge.ts +67 -0
  76. package/src/agent/part-list.test.tsx +82 -0
  77. package/src/agent/part-list.tsx +31 -6
  78. package/src/agent/parts.test.ts +603 -0
  79. package/src/agent/parts.ts +262 -32
  80. package/src/agent/thread.test.ts +147 -0
  81. package/src/agent/transport.test.ts +70 -0
  82. package/src/agent/transport.ts +72 -20
  83. package/src/agent/use-agent-stream.test.tsx +261 -0
  84. package/src/agent/use-agent-stream.ts +13 -3
  85. package/src/agent/use-agent-thread-runs.foreign.test.tsx +133 -0
  86. package/src/agent/use-agent-thread-runs.resume.test.tsx +289 -0
  87. package/src/agent/use-agent-thread-runs.test.tsx +781 -0
  88. package/src/agent/use-agent-thread-runs.ts +177 -27
  89. package/src/agent/use-agent-thread-runs.wedge.test.tsx +301 -0
  90. package/src/agent-chat/index.ts +8 -3
  91. package/src/agent-chat/thread-message.test.tsx +211 -0
  92. package/src/agent-chat/thread-message.tsx +235 -62
  93. package/src/agent-chat/tool-chip.test.tsx +139 -0
  94. package/src/agent-chat/tool-chip.tsx +219 -0
  95. package/src/charts/primitives/ChartFrame.test.tsx +8 -5
  96. package/src/cli/index.ts +285 -0
  97. package/src/dashboard/stat-card.test.tsx +5 -3
  98. package/src/guard/check-source.test.ts +8 -4
  99. package/src/guard/index.ts +1 -3
  100. package/src/index.ts +2 -0
  101. package/src/provider/index.test.tsx +6 -5
  102. package/src/surfaces.ts +23 -1
  103. package/src/theme/use-basalt-spacing.test.tsx +7 -4
  104. package/src/tokens/build-fonts-css.test.ts +3 -2
@@ -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
+ })
@@ -0,0 +1,219 @@
1
+ /**
2
+ * ToolChip — a one-line collapsed row for a tool-call part, expanding to its input/output JSON.
3
+ *
4
+ * Replaces `thread-message.tsx`'s old always-expanded `ToolRenderer`, which unconditionally
5
+ * rendered both input and output `<pre>` blocks. Collapsed, it shows a mono micro-label, the tool
6
+ * name, a state dot, and `durationMs` when present; expanded, it shows whatever the part's state
7
+ * actually carries — input, output, the surviving `rawInput` for a call whose input never
8
+ * validated, the error text, the denial reason, or the approve/deny affordances.
9
+ *
10
+ * @example
11
+ * import { ToolChip } from 'basalt-ui/agent-chat'
12
+ *
13
+ * <ToolChip
14
+ * part={part}
15
+ * onApprove={(id) => api.runs.approve.post({ approvalId: id })}
16
+ * onDeny={(id, reason) => api.runs.deny.post({ approvalId: id, reason })}
17
+ * />
18
+ */
19
+ import { Box, Button, Collapse, Group, Stack, Text, UnstyledButton } from '@mantine/core'
20
+ import { useDisclosure } from '@mantine/hooks'
21
+ import type { JSX } from 'react'
22
+ import { isToolCallSettled } from '../agent'
23
+ import type { ToolCallPart } from '../agent'
24
+ import { alpha, VX } from '../tokens'
25
+
26
+ // Mirrors thread-message.tsx's micro-label/rail/code-block idiom (docs/DESIGN-SPEC.md §3/§5) —
27
+ // duplicated locally rather than imported, matching the existing part-list.tsx/thread-message.tsx
28
+ // precedent of each Mantine-facing renderer file owning its own copy of these style objects.
29
+ const MICRO_LABEL_STYLE = {
30
+ fontFamily: 'var(--basalt-font-mono)',
31
+ fontSize: VX.text.micro,
32
+ fontWeight: 500,
33
+ letterSpacing: '0.06em',
34
+ textTransform: 'uppercase',
35
+ color: VX.faint,
36
+ } as const
37
+ const RAIL_STYLE = {
38
+ borderLeft: `2px solid ${VX.divider}`,
39
+ paddingLeft: 'var(--vx-space-agent-rail-inset-x)',
40
+ } as const
41
+ const CODE_BLOCK_STYLE = {
42
+ margin: 0,
43
+ marginTop: 'var(--vx-space-agent-part-gap-top)',
44
+ fontFamily: 'var(--basalt-font-mono)',
45
+ backgroundColor: alpha(VX.ink, 0.06),
46
+ borderRadius: 'var(--vx-radius-card)',
47
+ padding: 'var(--vx-space-agent-code-inset)',
48
+ whiteSpace: 'pre-wrap',
49
+ overflowWrap: 'anywhere',
50
+ } as const
51
+
52
+ /** Per-state (label, dot color) — the exhaustive switch that drives the collapsed row. */
53
+ function stateMeta(part: ToolCallPart): { readonly label: string; readonly dotToken: string } {
54
+ switch (part.state) {
55
+ case 'input-streaming':
56
+ return { label: 'streaming', dotToken: VX.status.neutral }
57
+ case 'input-available':
58
+ // No 'running' state on the wire — 'input-available' IS the running state (input complete,
59
+ // no output yet). This label is a UI-only gloss on that wire state, not a new state.
60
+ return { label: 'running', dotToken: VX.status.neutral }
61
+ case 'approval-requested':
62
+ return { label: 'needs approval', dotToken: VX.status.warn }
63
+ case 'approval-responded':
64
+ return part.approval.approved
65
+ ? { label: 'approved', dotToken: VX.status.neutral }
66
+ : { label: 'declined', dotToken: VX.status.bad }
67
+ case 'output-available':
68
+ // preliminary vs final must be visually distinguishable — the label carries that here; the
69
+ // expanded output body below is identical either way.
70
+ return part.preliminary === true
71
+ ? { label: 'preliminary', dotToken: VX.status.warn }
72
+ : { label: 'done', dotToken: VX.status.good }
73
+ case 'output-error':
74
+ return { label: 'error', dotToken: VX.status.bad }
75
+ case 'output-denied':
76
+ return { label: 'denied', dotToken: VX.status.bad }
77
+ default: {
78
+ // `part` is `never` here ONLY at compile time — this switch is genuinely exhaustive today,
79
+ // so adding an eighth ToolCallPart state without a case above makes this assignment a tsc
80
+ // error, the same gate `assertNever` provides. But `part` at RUNTIME can be a value this
81
+ // build never declared: `createThreadsStore` persists to localStorage with no shape
82
+ // validation, and `edenTransport` trusts the wire with zero validation, so a 1.10.0-shaped
83
+ // flat tool part (`state` undefined) or a future/foreign `state` string both reach here.
84
+ // That must never THROW during render — mirrors ThreadTranscript's fallbackRenderer
85
+ // doctrine (unknown data renders inert, it doesn't blank the transcript) — so, unlike a
86
+ // genuinely-exhaustive switch elsewhere, this default returns a neutral descriptor instead
87
+ // of calling assertNever.
88
+ const exhaustive: never = part
89
+ void exhaustive
90
+ return { label: 'unknown', dotToken: VX.status.neutral }
91
+ }
92
+ }
93
+ }
94
+
95
+ function formatDuration(ms: number): string {
96
+ return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.round(ms)}ms`
97
+ }
98
+
99
+ export type ToolChipProps = {
100
+ readonly part: ToolCallPart
101
+ readonly defaultExpanded?: boolean
102
+ /** Rendered only in 'approval-requested'. Omit both to render a read-only chip. */
103
+ readonly onApprove?: (approvalId: string) => void
104
+ readonly onDeny?: (approvalId: string, reason?: string) => void
105
+ }
106
+
107
+ /**
108
+ * A collapsed-by-default row for a single tool-call part. All seven wire states render something
109
+ * sane; the switch above still gates an eighth (compile-time-unhandled) state as a tsc error via a
110
+ * `const _: never = part` assignment, but unlike a truly-closed switch, its `default` branch never
111
+ * throws at RUNTIME — a value from persisted/wire data that reaches here with a `state` this build
112
+ * doesn't recognize renders an inert 'unknown' chip instead of blanking the transcript.
113
+ *
114
+ * @example
115
+ * <ToolChip part={part} />
116
+ */
117
+ export function ToolChip({
118
+ part,
119
+ defaultExpanded = false,
120
+ onApprove,
121
+ onDeny,
122
+ }: ToolChipProps): JSX.Element {
123
+ const [expanded, { toggle }] = useDisclosure(defaultExpanded)
124
+ const { label, dotToken } = stateMeta(part)
125
+ const settled = isToolCallSettled(part)
126
+
127
+ const input = 'input' in part ? part.input : undefined
128
+ const rawInput = part.state === 'output-error' ? part.rawInput : undefined
129
+
130
+ return (
131
+ <Box style={RAIL_STYLE}>
132
+ <Stack gap={4}>
133
+ <UnstyledButton onClick={toggle}>
134
+ <Group gap={6} align="center" wrap="nowrap">
135
+ <Box
136
+ aria-hidden
137
+ style={{
138
+ width: 6,
139
+ height: 6,
140
+ flexShrink: 0,
141
+ borderRadius: '50%', // theme-allow: a 6px state dot is a circle, not a surface corner — no radius token expresses it
142
+ backgroundColor: dotToken,
143
+ }}
144
+ />
145
+ <Text style={MICRO_LABEL_STYLE}>{part.toolName}</Text>
146
+ <Text size="xs" c="dimmed">
147
+ {label}
148
+ </Text>
149
+ {part.durationMs !== undefined && (
150
+ <Text
151
+ style={{
152
+ fontFamily: 'var(--basalt-font-mono)',
153
+ fontSize: VX.text.micro,
154
+ color: VX.faint,
155
+ }}
156
+ >
157
+ {formatDuration(part.durationMs)}
158
+ </Text>
159
+ )}
160
+ {!settled && (
161
+ <Text size="xs" c="dimmed">
162
+ …
163
+ </Text>
164
+ )}
165
+ </Group>
166
+ </UnstyledButton>
167
+ <Collapse expanded={expanded}>
168
+ <Stack gap={6}>
169
+ {input !== undefined && (
170
+ <Text component="pre" size="xs" style={CODE_BLOCK_STYLE}>
171
+ {JSON.stringify(input, null, 2)}
172
+ </Text>
173
+ )}
174
+ {rawInput !== undefined && (
175
+ <Text component="pre" size="xs" style={CODE_BLOCK_STYLE}>
176
+ {JSON.stringify(rawInput, null, 2)}
177
+ </Text>
178
+ )}
179
+ {part.state === 'output-available' && (
180
+ <Text component="pre" size="xs" style={CODE_BLOCK_STYLE}>
181
+ {JSON.stringify(part.output, null, 2)}
182
+ </Text>
183
+ )}
184
+ {part.state === 'output-error' && (
185
+ <Text size="xs" style={{ color: VX.status.bad }}>
186
+ {part.errorText}
187
+ </Text>
188
+ )}
189
+ {part.state === 'output-denied' && part.approval.reason !== undefined && (
190
+ <Text size="xs" c="dimmed">
191
+ {part.approval.reason}
192
+ </Text>
193
+ )}
194
+ {part.state === 'approval-requested' &&
195
+ (onApprove !== undefined || onDeny !== undefined) && (
196
+ <Group gap={6}>
197
+ {onApprove !== undefined && (
198
+ <Button size="compact-sm" onClick={() => onApprove(part.approval.id)}>
199
+ Approve
200
+ </Button>
201
+ )}
202
+ {onDeny !== undefined && (
203
+ <Button
204
+ size="compact-sm"
205
+ variant="light"
206
+ color="red"
207
+ onClick={() => onDeny(part.approval.id)}
208
+ >
209
+ Deny
210
+ </Button>
211
+ )}
212
+ </Group>
213
+ )}
214
+ </Stack>
215
+ </Collapse>
216
+ </Stack>
217
+ </Box>
218
+ )
219
+ }
@@ -4,11 +4,14 @@
4
4
  * shape back on screen exactly where `ChartPending` is supposed to reserve a static, contentless
5
5
  * placeholder (see `ChartPending`'s JSDoc for the three-state "nothing to draw" rationale).
6
6
  *
7
- * No jsdom is configured in this repo (see `theme/use-basalt-spacing.test.tsx`'s doc) —
8
- * `renderToStaticMarkup` is enough here: `ChartFrame`/`ChartPending` live in `src/charts/**`, which
9
- * is Mantine-free, so no `MantineProvider` wrapper is needed either. `useChartSize` never measures
10
- * under SSR (no `ResizeObserver`), so the plot rect falls back to `minWidth` × the resolved fixed
11
- * height — non-zero, which is what lets `children`/`ChartPending` render at all in this harness.
7
+ * A DOM harness now exists (`tests/setup/dom.ts`, preloaded via the root `bunfig.toml`; see
8
+ * `theme/use-basalt-spacing.test.tsx`'s doc) — `renderToStaticMarkup` is used deliberately here
9
+ * instead: `ChartFrame`/`ChartPending` live in `src/charts/**`, which is Mantine-free, so no
10
+ * `MantineProvider` wrapper is needed either. `useChartSize` never measures under SSR (no
11
+ * `ResizeObserver`), so the plot rect falls back to `minWidth` × the resolved fixed height —
12
+ * non-zero, which is what lets `children`/`ChartPending` render at all in this harness. Converting
13
+ * to the DOM harness would only be worth it if a future assertion here needed a real measured size
14
+ * (a live `ResizeObserver` reading) rather than this SSR fallback rect.
12
15
  */
13
16
  import { describe, expect, test } from 'bun:test'
14
17
  import { renderToStaticMarkup } from 'react-dom/server'