basalt-ui 1.8.0 → 1.10.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 +1 -0
- package/CHANGELOG.md +25 -0
- package/README.md +64 -34
- package/agent/rules/basalt-agent.md +49 -6
- package/agent/rules/basalt-charts.md +74 -5
- package/configs/oxlint-plugin.js +279 -0
- package/configs/oxlint-plugin.test.ts +216 -1
- package/configs/oxlint.json +5 -2
- package/dist/agent/use-agent-thread-runs.d.ts.map +1 -1
- package/dist/agent/use-agent-thread-runs.js +1 -0
- package/dist/agent-chat/index.d.ts +4 -3
- package/dist/agent-chat/index.d.ts.map +1 -1
- package/dist/charts/hooks/useHoverSync.d.ts +18 -2
- package/dist/charts/hooks/useHoverSync.d.ts.map +1 -1
- package/dist/charts/hooks/useHoverSync.js +12 -7
- package/dist/charts/index.d.ts +1 -0
- package/dist/charts/index.d.ts.map +1 -1
- package/dist/charts/index.js +6 -0
- package/dist/charts/kinds/Bars.d.ts +2 -0
- package/dist/charts/kinds/Bars.d.ts.map +1 -1
- package/dist/charts/kinds/Bars.js +3 -1
- package/dist/charts/kinds/Donut.d.ts +2 -0
- package/dist/charts/kinds/Donut.d.ts.map +1 -1
- package/dist/charts/kinds/Donut.js +8 -4
- package/dist/charts/kinds/DualPanel.d.ts +2 -0
- package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
- package/dist/charts/kinds/DualPanel.js +2 -1
- package/dist/charts/kinds/Heatmap.d.ts +8 -0
- package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
- package/dist/charts/kinds/Heatmap.js +7 -3
- package/dist/charts/kinds/MultiLine.d.ts +2 -0
- package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
- package/dist/charts/kinds/MultiLine.js +2 -1
- package/dist/charts/kinds/StackedArea.d.ts +2 -0
- package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
- package/dist/charts/kinds/StackedArea.js +2 -1
- package/dist/charts/kinds/ZonedLine.d.ts +2 -0
- package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
- package/dist/charts/kinds/ZonedLine.js +2 -1
- package/dist/charts/primitives/Axes.d.ts +6 -1
- package/dist/charts/primitives/Axes.d.ts.map +1 -1
- package/dist/charts/primitives/Axes.js +3 -2
- package/dist/charts/primitives/ChartFrame.d.ts +8 -1
- package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
- package/dist/charts/primitives/ChartFrame.js +8 -4
- package/dist/charts/primitives/ChartPending.d.ts +34 -0
- package/dist/charts/primitives/ChartPending.d.ts.map +1 -0
- package/dist/charts/primitives/ChartPending.js +23 -0
- package/dist/charts/primitives/ChartTooltip.d.ts +5 -2
- package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
- package/dist/charts/primitives/ChartTooltip.js +6 -1
- package/dist/charts/primitives/HoverOverlay.d.ts +5 -4
- package/dist/charts/primitives/HoverOverlay.d.ts.map +1 -1
- package/dist/charts/primitives/HoverOverlay.js +4 -2
- package/dist/cli/index.d.ts +55 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +217 -5
- package/dist/dashboard/stat-card.d.ts.map +1 -1
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +13 -5
- package/dist/surfaces.d.ts +10 -0
- package/dist/surfaces.d.ts.map +1 -1
- package/dist/surfaces.js +21 -0
- package/llms.txt +9 -2
- package/package.json +8 -5
- package/src/agent/ai-sdk-transport.test.ts +269 -0
- package/src/agent/parts.test.ts +261 -0
- package/src/agent/thread.test.ts +147 -0
- package/src/agent/use-agent-stream.test.tsx +231 -0
- package/src/agent/use-agent-thread-runs.resume.test.tsx +261 -0
- package/src/agent/use-agent-thread-runs.test.tsx +398 -0
- package/src/agent/use-agent-thread-runs.ts +7 -1
- package/src/agent/use-agent-thread-runs.wedge.test.tsx +299 -0
- package/src/agent-chat/index.ts +4 -3
- package/src/charts/hooks/useHoverSync.ts +43 -7
- package/src/charts/index.ts +6 -0
- package/src/charts/kinds/Bars.tsx +4 -0
- package/src/charts/kinds/Donut.tsx +10 -4
- package/src/charts/kinds/DualPanel.tsx +5 -1
- package/src/charts/kinds/Heatmap.tsx +19 -2
- package/src/charts/kinds/MultiLine.tsx +4 -1
- package/src/charts/kinds/StackedArea.tsx +4 -1
- package/src/charts/kinds/ZonedLine.tsx +4 -1
- package/src/charts/primitives/Axes.tsx +7 -1
- package/src/charts/primitives/ChartFrame.test.tsx +67 -0
- package/src/charts/primitives/ChartFrame.tsx +17 -4
- package/src/charts/primitives/ChartPending.tsx +59 -0
- package/src/charts/primitives/ChartTooltip.tsx +15 -3
- package/src/charts/primitives/HoverOverlay.tsx +12 -6
- package/src/cli/index.ts +439 -5
- package/src/cli/placement-engine.test.ts +104 -0
- package/src/dashboard/stat-card.test.tsx +5 -3
- package/src/dashboard/stat-card.tsx +12 -0
- package/src/guard/check-source.test.ts +20 -0
- package/src/guard/index.ts +20 -9
- package/src/provider/index.test.tsx +6 -5
- package/src/surfaces.ts +22 -0
- package/src/theme/use-basalt-spacing.test.tsx +7 -4
- package/src/tokens/build-fonts-css.test.ts +3 -2
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* F3 — the mount-reconcile / unmount-cleanup wedge in useAgentThreadRuns.
|
|
3
|
+
*
|
|
4
|
+
* The bug: the unmount-cleanup effect (`controllersRef.current.forEach(c => c.abort())`) aborted
|
|
5
|
+
* every in-flight controller but never CLEARED the map. The mount-reconcile effect treats a thread
|
|
6
|
+
* as "orphaned" (worth a resume attempt) only when `!controllersRef.current.has(thread.id)`. If
|
|
7
|
+
* this fiber's effects re-run WITHOUT the fiber itself unmounting — React 19 StrictMode's
|
|
8
|
+
* dev-only double-invoke, or a `<Activity>` boundary being hidden then shown again — the stale,
|
|
9
|
+
* now-aborted entry survives into the second reconcile pass, `orphaned` reads `false`, no new
|
|
10
|
+
* resume is attempted, and the one live consumer that WAS attempted already returned silently on
|
|
11
|
+
* its AbortError (`consumeAndFinalize`'s guard at the top of its try block). The thread is left in
|
|
12
|
+
* `'streaming'` forever with nothing left to resolve it.
|
|
13
|
+
*
|
|
14
|
+
* Both halves matter equally here:
|
|
15
|
+
*
|
|
16
|
+
* (a) REFUTED — a genuine unmount + remount (a fresh component instance, not a re-run of THIS
|
|
17
|
+
* fiber's effects) gets a brand-new `useRef` Map. There is nothing for the fix to protect
|
|
18
|
+
* against here; the test exists so nobody "fixes" this non-bug later.
|
|
19
|
+
* (b) CONFIRMED — React 19 StrictMode's dev-only double-invoke reuses the SAME fiber (and thus
|
|
20
|
+
* the same ref) across a simulated unmount+remount within one commit.
|
|
21
|
+
* (c) CONFIRMED — React 19.2's `<Activity>` does the SAME thing in PRODUCTION: hiding an
|
|
22
|
+
* Activity boundary destroys the subtree's effects and re-creates them on show, while state,
|
|
23
|
+
* DOM, and ref identity survive. This is the reachable-in-prod half of the wedge, not a
|
|
24
|
+
* StrictMode-only curiosity — hence testing it directly against `<Activity>` from `react`
|
|
25
|
+
* rather than only through Mantine's `Collapse`. At the `@mantine/core` 9.3.0 pinned in THIS
|
|
26
|
+
* repo, `Collapse` has no `keepMounted` DEFAULT — with the prop unset it keeps `children`
|
|
27
|
+
* permanently mounted (the `else` branch below), so a bare `<Collapse>` cannot wedge and a
|
|
28
|
+
* default-Collapse-based test would not reproduce the bug. But a consumer writing
|
|
29
|
+
* `<Collapse keepMounted>` on this SAME 9.3.0 does hit the `<Activity>` path — read
|
|
30
|
+
* the installed `@mantine/core` 9.3.0 `Collapse.mjs` source directly: it imports
|
|
31
|
+
* `Activity` from `react` and, when `keepMounted === true`, renders
|
|
32
|
+
* `jsx(Activity, { mode: isExited ? 'hidden' : 'visible', … })` on the ordinary non-zero-
|
|
33
|
+
* duration path (only the `duration === 0` fast path carries a `&& env !== 'test'` guard). So
|
|
34
|
+
* the Activity hazard is live on THIS pinned version, not only on the 9.4.1 argo resolves —
|
|
35
|
+
* testing `<Activity>` directly is still the right call because it is what a `keepMounted`
|
|
36
|
+
* consumer actually gets here today, not because 9.3.0 is exempt.
|
|
37
|
+
*
|
|
38
|
+
* Verified manually while writing this file (reported alongside the test suite, not re-asserted
|
|
39
|
+
* here as a runtime check): reverting the one-line `controllersRef.current.clear()` fix makes (b)
|
|
40
|
+
* and (c) fail — the thread's status stays `'streaming'` instead of settling — while (a) passes
|
|
41
|
+
* either way, exactly as the "refutation" framing above expects.
|
|
42
|
+
*
|
|
43
|
+
* Both (b) and (c) observe `resume()` invoked TWICE post-fix (once for the pass that gets aborted,
|
|
44
|
+
* once for the pass that survives), not once — React's whole justification for double-invoking a
|
|
45
|
+
* mount effect with a side effect (a network call here) is to surface exactly this
|
|
46
|
+
* non-idempotence; a spec that anticipated "resume() attempted exactly once" for this scenario
|
|
47
|
+
* does not match observed React 19.2.7 behavior. What's invariant, and what these tests assert, is
|
|
48
|
+
* that the thread SETTLES (leaves `'streaming'`) rather than wedging — the resume() call count is
|
|
49
|
+
* asserted too, but as an observed constant with the doubling explained, not as evidence of a
|
|
50
|
+
* single attempt.
|
|
51
|
+
*/
|
|
52
|
+
import { describe, expect, test } from 'bun:test'
|
|
53
|
+
import { Activity, StrictMode, useState } from 'react'
|
|
54
|
+
import { act, render, renderHook, waitFor } from '@testing-library/react'
|
|
55
|
+
import { useAgentThreadRuns } from './use-agent-thread-runs'
|
|
56
|
+
import type { AgentThread, ThreadsStore } from './thread'
|
|
57
|
+
import type { AgentTransport } from './transport'
|
|
58
|
+
import type { AgentPart } from './parts'
|
|
59
|
+
import type { ChatMessage } from './history'
|
|
60
|
+
import type { AgentOutcome } from './outcome'
|
|
61
|
+
|
|
62
|
+
// ── test-only ThreadsStore double ────────────────────────────────────────────
|
|
63
|
+
//
|
|
64
|
+
// A plain mutable object satisfying the ThreadsStore contract — no React state, no
|
|
65
|
+
// localStorage/useSyncExternalStore. useAgentThreadRuns only ever reads `store.threads` and calls
|
|
66
|
+
// its action methods synchronously (via `storeRef.current`), so a hand-rolled mutable double gives
|
|
67
|
+
// the test full, synchronous, non-persisted control over what a "reload mid-stream" looked like —
|
|
68
|
+
// exactly what these mount-reconcile tests need to seed.
|
|
69
|
+
function createTestThreadsStore(initial: AgentThread<AgentPart>[] = []): ThreadsStore<AgentPart> {
|
|
70
|
+
let threads = initial
|
|
71
|
+
let activeId: string | null = null
|
|
72
|
+
return {
|
|
73
|
+
get threads() {
|
|
74
|
+
return threads
|
|
75
|
+
},
|
|
76
|
+
get activeId() {
|
|
77
|
+
return activeId
|
|
78
|
+
},
|
|
79
|
+
select(id) {
|
|
80
|
+
activeId = id
|
|
81
|
+
},
|
|
82
|
+
create(opts) {
|
|
83
|
+
const id = crypto.randomUUID()
|
|
84
|
+
const now = Date.now()
|
|
85
|
+
const thread: AgentThread<AgentPart> = {
|
|
86
|
+
id,
|
|
87
|
+
messages: [],
|
|
88
|
+
outcome: null,
|
|
89
|
+
status: 'pending',
|
|
90
|
+
read: false,
|
|
91
|
+
createdAt: now,
|
|
92
|
+
updatedAt: now,
|
|
93
|
+
...(opts?.meta !== undefined ? { meta: opts.meta } : {}),
|
|
94
|
+
}
|
|
95
|
+
threads = [thread, ...threads]
|
|
96
|
+
return id
|
|
97
|
+
},
|
|
98
|
+
appendMessage(id, message) {
|
|
99
|
+
threads = threads.map((thread) =>
|
|
100
|
+
thread.id === id
|
|
101
|
+
? { ...thread, messages: [...thread.messages, message], updatedAt: Date.now() }
|
|
102
|
+
: thread,
|
|
103
|
+
)
|
|
104
|
+
},
|
|
105
|
+
setOutcome(id, outcome) {
|
|
106
|
+
threads = threads.map((thread) =>
|
|
107
|
+
thread.id === id ? { ...thread, outcome, updatedAt: Date.now() } : thread,
|
|
108
|
+
)
|
|
109
|
+
},
|
|
110
|
+
setStatus(id, status) {
|
|
111
|
+
threads = threads.map((thread) =>
|
|
112
|
+
thread.id === id ? { ...thread, status, updatedAt: Date.now() } : thread,
|
|
113
|
+
)
|
|
114
|
+
},
|
|
115
|
+
setResumeToken(id, token) {
|
|
116
|
+
threads = threads.map((thread) => {
|
|
117
|
+
if (thread.id !== id) return thread
|
|
118
|
+
const { resumeToken: _resumeToken, ...rest } = thread
|
|
119
|
+
return {
|
|
120
|
+
...rest,
|
|
121
|
+
...(token !== undefined ? { resumeToken: token } : {}),
|
|
122
|
+
updatedAt: Date.now(),
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
},
|
|
126
|
+
markRead(id) {
|
|
127
|
+
threads = threads.map((thread) => (thread.id === id ? { ...thread, read: true } : thread))
|
|
128
|
+
},
|
|
129
|
+
remove(id) {
|
|
130
|
+
threads = threads.filter((thread) => thread.id !== id)
|
|
131
|
+
if (activeId === id) activeId = null
|
|
132
|
+
},
|
|
133
|
+
clear() {
|
|
134
|
+
threads = []
|
|
135
|
+
activeId = null
|
|
136
|
+
},
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function makeThread(overrides: Partial<AgentThread<AgentPart>> = {}): AgentThread<AgentPart> {
|
|
141
|
+
const now = Date.now()
|
|
142
|
+
return {
|
|
143
|
+
id: crypto.randomUUID(),
|
|
144
|
+
messages: [],
|
|
145
|
+
outcome: null,
|
|
146
|
+
status: 'pending',
|
|
147
|
+
read: false,
|
|
148
|
+
createdAt: now,
|
|
149
|
+
updatedAt: now,
|
|
150
|
+
...overrides,
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
|
|
155
|
+
let resolve!: (value: T) => void
|
|
156
|
+
const promise = new Promise<T>((r) => {
|
|
157
|
+
resolve = r
|
|
158
|
+
})
|
|
159
|
+
return { promise, resolve }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const resolveOutcome = (): AgentOutcome => ({ title: 'title', summary: 'summary', status: 'done' })
|
|
163
|
+
|
|
164
|
+
describe('useAgentThreadRuns — F3 mount-reconcile / unmount-cleanup wedge', () => {
|
|
165
|
+
test('(a) REFUTED: a genuine unmount + remount gets a fresh controllers map — resolves to interrupted, never re-enters streaming', () => {
|
|
166
|
+
const store = createTestThreadsStore()
|
|
167
|
+
const threadId = store.create()
|
|
168
|
+
|
|
169
|
+
// No `resume` on this transport: on remount, the orphaned thread must fall straight to
|
|
170
|
+
// 'interrupted' with no async wait involved.
|
|
171
|
+
const transport: AgentTransport<AgentPart, string> = {
|
|
172
|
+
// oxlint-disable-next-line require-yield -- hangs until aborted; never has anything to yield
|
|
173
|
+
async *stream(_input, signal) {
|
|
174
|
+
await new Promise((resolve) => {
|
|
175
|
+
signal?.addEventListener('abort', () => resolve(undefined))
|
|
176
|
+
})
|
|
177
|
+
},
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const { result, unmount } = renderHook(() =>
|
|
181
|
+
useAgentThreadRuns({ transport, store, resolveOutcome }),
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
act(() => {
|
|
185
|
+
result.current.start(threadId, 'hello')
|
|
186
|
+
})
|
|
187
|
+
expect(store.threads.find((thread) => thread.id === threadId)?.status).toBe('streaming')
|
|
188
|
+
|
|
189
|
+
unmount()
|
|
190
|
+
|
|
191
|
+
// A brand-new component instance against the SAME store object — a fresh fiber, fresh
|
|
192
|
+
// `useRef` Map. This must hold both before AND after the F3 fix.
|
|
193
|
+
renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
|
|
194
|
+
|
|
195
|
+
expect(store.threads.find((thread) => thread.id === threadId)?.status).toBe('interrupted')
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
test('(b) CONFIRMED: React 19 StrictMode double-invoke resumes the orphaned thread instead of wedging it in streaming', async () => {
|
|
199
|
+
const userMessage: ChatMessage<AgentPart> = {
|
|
200
|
+
id: crypto.randomUUID(),
|
|
201
|
+
role: 'user',
|
|
202
|
+
parts: [{ type: 'text', text: 'hello' }],
|
|
203
|
+
createdAt: Date.now(),
|
|
204
|
+
}
|
|
205
|
+
const thread = makeThread({
|
|
206
|
+
status: 'streaming',
|
|
207
|
+
resumeToken: 'resume-token-1',
|
|
208
|
+
messages: [userMessage],
|
|
209
|
+
})
|
|
210
|
+
const store = createTestThreadsStore([thread])
|
|
211
|
+
|
|
212
|
+
let resumeCalls = 0
|
|
213
|
+
const transport: AgentTransport<AgentPart, string> = {
|
|
214
|
+
async *stream() {},
|
|
215
|
+
async *resume() {
|
|
216
|
+
resumeCalls++
|
|
217
|
+
yield { type: 'text', text: 'resumed' }
|
|
218
|
+
},
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }), {
|
|
222
|
+
wrapper: StrictMode,
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
await waitFor(() => {
|
|
226
|
+
expect(store.threads.find((t) => t.id === thread.id)?.status).toBe('done')
|
|
227
|
+
})
|
|
228
|
+
// Observed constant for this scenario (see file doc): the aborted first pass plus the
|
|
229
|
+
// surviving second pass, not "exactly once".
|
|
230
|
+
expect(resumeCalls).toBe(2)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
test('(c) CONFIRMED: an <Activity> hide/show cycle resumes the orphaned thread instead of wedging it in streaming', async () => {
|
|
234
|
+
const userMessage: ChatMessage<AgentPart> = {
|
|
235
|
+
id: crypto.randomUUID(),
|
|
236
|
+
role: 'user',
|
|
237
|
+
parts: [{ type: 'text', text: 'hello' }],
|
|
238
|
+
createdAt: Date.now(),
|
|
239
|
+
}
|
|
240
|
+
const thread = makeThread({
|
|
241
|
+
status: 'streaming',
|
|
242
|
+
resumeToken: 'resume-token-2',
|
|
243
|
+
messages: [userMessage],
|
|
244
|
+
})
|
|
245
|
+
const store = createTestThreadsStore([thread])
|
|
246
|
+
|
|
247
|
+
// Gates the first resume() attempt so the hide happens strictly before it settles — an
|
|
248
|
+
// explicit happens-before, not a scheduler coincidence a `setTimeout(…, 0)` would only
|
|
249
|
+
// usually win.
|
|
250
|
+
const gate = deferred<void>()
|
|
251
|
+
|
|
252
|
+
let resumeCalls = 0
|
|
253
|
+
const transport: AgentTransport<AgentPart, string> = {
|
|
254
|
+
async *stream() {},
|
|
255
|
+
async *resume() {
|
|
256
|
+
resumeCalls++
|
|
257
|
+
await gate.promise
|
|
258
|
+
yield { type: 'text', text: 'resumed' }
|
|
259
|
+
},
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
let externalSetMode: ((mode: 'visible' | 'hidden') => void) | undefined
|
|
263
|
+
|
|
264
|
+
function Probe(): null {
|
|
265
|
+
useAgentThreadRuns({ transport, store, resolveOutcome })
|
|
266
|
+
return null
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function Harness(): JSX.Element {
|
|
270
|
+
const [mode, setMode] = useState<'visible' | 'hidden'>('visible')
|
|
271
|
+
externalSetMode = setMode
|
|
272
|
+
return (
|
|
273
|
+
<Activity mode={mode}>
|
|
274
|
+
<Probe />
|
|
275
|
+
</Activity>
|
|
276
|
+
)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
await act(async () => {
|
|
280
|
+
render(<Harness />)
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
// First resume attempt is in flight (its gate hasn't been released yet) — hide now to abort
|
|
284
|
+
// it via the F3-fixed cleanup effect. `gate.resolve()` only runs after this completes, so the
|
|
285
|
+
// hide is guaranteed to land before the first attempt could ever settle.
|
|
286
|
+
await act(async () => {
|
|
287
|
+
externalSetMode?.('hidden')
|
|
288
|
+
})
|
|
289
|
+
gate.resolve()
|
|
290
|
+
await act(async () => {
|
|
291
|
+
externalSetMode?.('visible')
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
await waitFor(() => {
|
|
295
|
+
expect(store.threads.find((t) => t.id === thread.id)?.status).toBe('done')
|
|
296
|
+
})
|
|
297
|
+
expect(resumeCalls).toBe(2)
|
|
298
|
+
})
|
|
299
|
+
})
|
package/src/agent-chat/index.ts
CHANGED
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
* Mantine-styled thread-chat components over the headless `basalt-ui/agent` layer, capped by the
|
|
3
3
|
* flagship `ThreadWorkspace` composite.
|
|
4
4
|
*
|
|
5
|
-
* This module is Mantine-coupled by design and
|
|
6
|
-
*
|
|
5
|
+
* This module is Mantine-coupled by design and ships its own `basalt-ui/agent-chat` subpath
|
|
6
|
+
* (also re-exported from the root entry, unchanged for existing consumers) — unlike
|
|
7
|
+
* `basalt-ui/agent`, which stays headless so it can be styled by any consumer.
|
|
7
8
|
*
|
|
8
9
|
* @example
|
|
9
|
-
* import { ThreadWorkspace } from 'basalt-ui'
|
|
10
|
+
* import { ThreadWorkspace } from 'basalt-ui/agent-chat'
|
|
10
11
|
* import { createThreadsStore, edenTransport, heuristicOutcome } from 'basalt-ui/agent'
|
|
11
12
|
*/
|
|
12
13
|
|
|
@@ -24,12 +24,29 @@ export function useHoverSync<T>({
|
|
|
24
24
|
getKey,
|
|
25
25
|
xScale,
|
|
26
26
|
marginLeft,
|
|
27
|
+
resolveKey,
|
|
27
28
|
}: {
|
|
28
29
|
data: T[]
|
|
29
30
|
chartId: string
|
|
30
31
|
getKey: (d: T) => string
|
|
31
32
|
xScale: XScale
|
|
32
33
|
marginLeft: number
|
|
34
|
+
/**
|
|
35
|
+
* Overrides how a SIBLING's broadcast key resolves to one of THIS chart's own points. Only
|
|
36
|
+
* affects reading another chart's hover — this chart's own hover (via `getKey`/`xScale`) is
|
|
37
|
+
* untouched.
|
|
38
|
+
*
|
|
39
|
+
* Cross-chart sync is exact string match by default (`pointByKey.get(ctx.key)`), so two charts
|
|
40
|
+
* share a cursor only if they emit identical key strings. A chart that downsamples/folds its
|
|
41
|
+
* domain to fit a narrow viewport (e.g. ~288 buckets folded into ~97 columns at 390px) no longer
|
|
42
|
+
* owns most of the keys its unfolded siblings broadcast — the shared crosshair then appears on
|
|
43
|
+
* roughly one hover in three, with no rule a reader can infer, which is worse than no shared
|
|
44
|
+
* cursor at all. Pass `resolveKey` to map a foreign key onto whichever of this chart's own points
|
|
45
|
+
* swallowed it (e.g. the folded bucket the key falls into).
|
|
46
|
+
*
|
|
47
|
+
* Omit it and behaviour is byte-identical to today.
|
|
48
|
+
*/
|
|
49
|
+
resolveKey?: (key: string) => T | null
|
|
33
50
|
}) {
|
|
34
51
|
const ctx = useContext(HoverContext)
|
|
35
52
|
// Latest context in a ref so the mouse callbacks stay referentially stable: the provider's value
|
|
@@ -53,13 +70,26 @@ export function useHoverSync<T>({
|
|
|
53
70
|
|
|
54
71
|
const { tip, show, hide, tooltipRef, lastDateRef } = useChartTooltip<T>()
|
|
55
72
|
|
|
73
|
+
// Latest accessors in refs, same reasoning as `ctxRef` above: the natural call style is an
|
|
74
|
+
// inline arrow for `getKey`/`xScale`, which is fresh on every render, and this file already
|
|
75
|
+
// pays for that hazard once (see the comment above `ctxRef`) without applying the fix here — a
|
|
76
|
+
// several-hundred-entry `pointByKey` Map was rebuilding on every render of a page that
|
|
77
|
+
// re-renders on a heartbeat. Trade-off: because `getKey` is read through a ref, changing key
|
|
78
|
+
// SEMANTICS without changing `data` will NOT rebuild the map. That's the intended trade — a
|
|
79
|
+
// `getKey` that changes meaning for the same data is pathological; a `getKey` that changes
|
|
80
|
+
// identity every render is the normal case.
|
|
81
|
+
const getKeyRef = useRef(getKey)
|
|
82
|
+
getKeyRef.current = getKey
|
|
83
|
+
const xScaleRef = useRef(xScale)
|
|
84
|
+
xScaleRef.current = xScale
|
|
85
|
+
|
|
56
86
|
// O(1) lookup of a point by its key. Under a provider every sibling resolves the broadcast
|
|
57
87
|
// key each frame; an O(n) `data.find` per sibling per move is N×O(M). This makes it N×O(1).
|
|
58
88
|
const pointByKey = useMemo(() => {
|
|
59
89
|
const m = new Map<string, T>()
|
|
60
|
-
for (const d of data) m.set(
|
|
90
|
+
for (const d of data) m.set(getKeyRef.current(d), d)
|
|
61
91
|
return m
|
|
62
|
-
}, [data
|
|
92
|
+
}, [data])
|
|
63
93
|
|
|
64
94
|
const handleMouse = useCallback(
|
|
65
95
|
(event: React.MouseEvent<SVGRectElement>) => {
|
|
@@ -69,7 +99,7 @@ export function useHoverSync<T>({
|
|
|
69
99
|
let closest: T = data[0] as T
|
|
70
100
|
let minDist = Infinity
|
|
71
101
|
for (const d of data) {
|
|
72
|
-
const sx =
|
|
102
|
+
const sx = xScaleRef.current(getKeyRef.current(d)) ?? 0
|
|
73
103
|
const dist = Math.abs(sx - px)
|
|
74
104
|
if (dist < minDist) {
|
|
75
105
|
minDist = dist
|
|
@@ -77,13 +107,13 @@ export function useHoverSync<T>({
|
|
|
77
107
|
}
|
|
78
108
|
}
|
|
79
109
|
show(closest, event)
|
|
80
|
-
const key =
|
|
110
|
+
const key = getKeyRef.current(closest)
|
|
81
111
|
if (lastDateRef.current !== key) {
|
|
82
112
|
lastDateRef.current = key
|
|
83
113
|
ctxRef.current.setHover(key, chartId)
|
|
84
114
|
}
|
|
85
115
|
},
|
|
86
|
-
[data,
|
|
116
|
+
[data, chartId, marginLeft, show, lastDateRef],
|
|
87
117
|
)
|
|
88
118
|
|
|
89
119
|
const handleLeave = useCallback(() => {
|
|
@@ -97,8 +127,14 @@ export function useHoverSync<T>({
|
|
|
97
127
|
// The point the crosshair + synced dots track. Inside a <ChartHoverSync> this follows the
|
|
98
128
|
// broadcast key (so every sibling paints a ghost crosshair at the same x). WITHOUT a provider the
|
|
99
129
|
// chart is standalone, so it falls back to THIS chart's own hovered point (`tip.data`) — otherwise
|
|
100
|
-
// a chart outside a provider would never draw a crosshair/dots at all.
|
|
101
|
-
|
|
130
|
+
// a chart outside a provider would never draw a crosshair/dots at all. When `resolveKey` is
|
|
131
|
+
// supplied it replaces the default exact-match lookup for reading a SIBLING's broadcast key (see
|
|
132
|
+
// its doc comment above) — this chart's own hover is unaffected either way.
|
|
133
|
+
const syncedPoint = ctx.key
|
|
134
|
+
? resolveKey
|
|
135
|
+
? resolveKey(ctx.key)
|
|
136
|
+
: (pointByKey.get(ctx.key) ?? null)
|
|
137
|
+
: (tip?.data ?? null)
|
|
102
138
|
// Which chart owns the floating tooltip. Inside a provider the hovered chart is the one whose id
|
|
103
139
|
// matches the broadcast source (siblings show a ghost crosshair only). Standalone, its own local
|
|
104
140
|
// hover (`tip`) drives the tooltip.
|
package/src/charts/index.ts
CHANGED
|
@@ -46,6 +46,12 @@ export {
|
|
|
46
46
|
// ── Primitives ───────────────────────────────────────────────────────────
|
|
47
47
|
export { ResponsiveChart, type ResponsiveChartProps } from './primitives/ResponsiveChart'
|
|
48
48
|
export { ChartFrame, type ChartFrameProps, type ChartFrameLegend } from './primitives/ChartFrame'
|
|
49
|
+
export {
|
|
50
|
+
ChartCenter,
|
|
51
|
+
type ChartCenterProps,
|
|
52
|
+
ChartPending,
|
|
53
|
+
type ChartPendingProps,
|
|
54
|
+
} from './primitives/ChartPending'
|
|
49
55
|
export { Crosshair, SeriesDot } from './primitives/Crosshair'
|
|
50
56
|
export { ChartCard } from './primitives/ChartCard'
|
|
51
57
|
export { ChartLegend, type LegendEntry } from './primitives/ChartLegend'
|
|
@@ -139,6 +139,8 @@ export type BarsProps<T> = {
|
|
|
139
139
|
legend?: ChartLegendConfig | false
|
|
140
140
|
/** Accessible text alternative, forwarded to `ChartFrame` as `aria-label` (+ `role="img"`). */
|
|
141
141
|
ariaLabel?: string
|
|
142
|
+
/** Forwarded to `ChartFrame` — see `ChartPending`'s JSDoc for the three-state rationale. */
|
|
143
|
+
isPending?: boolean
|
|
142
144
|
}
|
|
143
145
|
|
|
144
146
|
/**
|
|
@@ -164,6 +166,7 @@ function BarsInner<T>(props: BarsProps<T>) {
|
|
|
164
166
|
height,
|
|
165
167
|
legend,
|
|
166
168
|
ariaLabel,
|
|
169
|
+
isPending,
|
|
167
170
|
} = props
|
|
168
171
|
const [highlightedKey, setHighlightedKey] = useState<string | null>(null)
|
|
169
172
|
|
|
@@ -207,6 +210,7 @@ function BarsInner<T>(props: BarsProps<T>) {
|
|
|
207
210
|
chartId={chartId}
|
|
208
211
|
{...(height !== undefined && { height })}
|
|
209
212
|
{...(ariaLabel !== undefined && { ariaLabel })}
|
|
213
|
+
{...(isPending !== undefined && { isPending })}
|
|
210
214
|
legend={resolveLegend(legend, {
|
|
211
215
|
highlighted: highlightedKey,
|
|
212
216
|
onHighlight: setHighlightedKey,
|
|
@@ -53,6 +53,8 @@ export type DonutProps<K extends string = SeriesKey> = {
|
|
|
53
53
|
padAngle?: number
|
|
54
54
|
/** Accessible text alternative, forwarded to `ChartFrame` as `aria-label` (+ `role="img"`). */
|
|
55
55
|
ariaLabel?: string
|
|
56
|
+
/** Forwarded to `ChartFrame` — see `ChartPending`'s JSDoc for the three-state rationale. */
|
|
57
|
+
isPending?: boolean
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
/**
|
|
@@ -64,7 +66,7 @@ export type DonutProps<K extends string = SeriesKey> = {
|
|
|
64
66
|
* (donut ↔ bar, via the generalized key) is a distinct, deliberately deferred feature.
|
|
65
67
|
*/
|
|
66
68
|
function DonutInner<K extends string = SeriesKey>(props: DonutProps<K>) {
|
|
67
|
-
const { data, height, colorForKey, seriesLabel = (k) => k, ariaLabel } = props
|
|
69
|
+
const { data, height, colorForKey, seriesLabel = (k) => k, ariaLabel, isPending } = props
|
|
68
70
|
|
|
69
71
|
const series: SeriesStyle[] = data.map((d) => ({
|
|
70
72
|
key: d.key,
|
|
@@ -78,6 +80,7 @@ function DonutInner<K extends string = SeriesKey>(props: DonutProps<K>) {
|
|
|
78
80
|
series={series}
|
|
79
81
|
{...(height !== undefined && { height })}
|
|
80
82
|
{...(ariaLabel !== undefined && { ariaLabel })}
|
|
83
|
+
{...(isPending !== undefined && { isPending })}
|
|
81
84
|
>
|
|
82
85
|
{(plot) => <DonutPlot {...props} plot={plot} />}
|
|
83
86
|
</ChartFrame>
|
|
@@ -136,13 +139,16 @@ function DonutPlot<K extends string = SeriesKey>(props: DonutPlotProps<K>) {
|
|
|
136
139
|
return (
|
|
137
140
|
<g
|
|
138
141
|
key={key}
|
|
139
|
-
|
|
142
|
+
onPointerEnter={(event) => {
|
|
140
143
|
show(arc.data, event)
|
|
141
144
|
}}
|
|
142
|
-
|
|
145
|
+
onPointerMove={(event) => {
|
|
143
146
|
show(arc.data, event)
|
|
144
147
|
}}
|
|
145
|
-
|
|
148
|
+
onPointerLeave={() => {
|
|
149
|
+
hide()
|
|
150
|
+
}}
|
|
151
|
+
onPointerCancel={() => {
|
|
146
152
|
hide()
|
|
147
153
|
}}
|
|
148
154
|
style={{ cursor: 'pointer' }}
|
|
@@ -57,6 +57,8 @@ export type DualPanelProps<T> = {
|
|
|
57
57
|
legend?: ChartLegendConfig | false
|
|
58
58
|
/** Accessible text alternative, forwarded to `ChartFrame` as `aria-label` (+ `role="img"`). */
|
|
59
59
|
ariaLabel?: string
|
|
60
|
+
/** Forwarded to `ChartFrame` — see `ChartPending`'s JSDoc for the three-state rationale. */
|
|
61
|
+
isPending?: boolean
|
|
60
62
|
}
|
|
61
63
|
|
|
62
64
|
const PANE_GAP = 12
|
|
@@ -74,7 +76,8 @@ const PANE_GAP = 12
|
|
|
74
76
|
* when a series has nulls; lines/bars skip null points (visual gaps).
|
|
75
77
|
*/
|
|
76
78
|
function DualPanelInner<T>(props: DualPanelProps<T>) {
|
|
77
|
-
const { series, chartId, height, barLabel, barColorPositive, legend, ariaLabel } =
|
|
79
|
+
const { series, chartId, height, barLabel, barColorPositive, legend, ariaLabel, isPending } =
|
|
80
|
+
props
|
|
78
81
|
|
|
79
82
|
// Default the top-pane line overlays to the redesign's 1.9px stroke (docs/DESIGN-SPEC.md §5) —
|
|
80
83
|
// applied once here so the plotted line, the derived legend swatch, and the derived tooltip row
|
|
@@ -101,6 +104,7 @@ function DualPanelInner<T>(props: DualPanelProps<T>) {
|
|
|
101
104
|
chartId={chartId}
|
|
102
105
|
{...(height !== undefined && { height })}
|
|
103
106
|
{...(ariaLabel !== undefined && { ariaLabel })}
|
|
107
|
+
{...(isPending !== undefined && { isPending })}
|
|
104
108
|
legend={resolveLegend(legend)}
|
|
105
109
|
>
|
|
106
110
|
{(plot) => <DualPanelPlot {...props} series={styledSeries} plot={plot} />}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
TooltipRow,
|
|
9
9
|
useTooltipStyles,
|
|
10
10
|
} from '../primitives/ChartTooltip'
|
|
11
|
+
import { ChartPending } from '../primitives/ChartPending'
|
|
11
12
|
import { useChartTooltip } from '../hooks/useChartTooltip'
|
|
12
13
|
import { VX, alpha } from '../../tokens'
|
|
13
14
|
|
|
@@ -47,6 +48,14 @@ export type HeatmapProps<T> = {
|
|
|
47
48
|
renderTooltip?: (cell: HeatmapCell) => ReactNode
|
|
48
49
|
/** Optional gradient legend strip below the grid (faint → solid color). */
|
|
49
50
|
legend?: { min: string; max: string }
|
|
51
|
+
/**
|
|
52
|
+
* The query behind this chart hasn't resolved yet. Renders `ChartPending` (see its JSDoc for the
|
|
53
|
+
* three-state rationale) over the full `width` × `height` box in place of the grid, suppressing
|
|
54
|
+
* the legend strip and every cell/label/tooltip along with it. Unlike the other six kinds,
|
|
55
|
+
* `Heatmap` doesn't compose `ChartFrame` (it takes `width`/`height` directly rather than
|
|
56
|
+
* measuring), so this is handled locally instead of forwarded.
|
|
57
|
+
*/
|
|
58
|
+
isPending?: boolean
|
|
50
59
|
}
|
|
51
60
|
|
|
52
61
|
/** Build a first-seen-ordered list of unique keys from data via an accessor. */
|
|
@@ -106,6 +115,7 @@ function HeatmapInner<T>(props: HeatmapProps<T>) {
|
|
|
106
115
|
colLabel = (c) => c,
|
|
107
116
|
renderTooltip,
|
|
108
117
|
legend,
|
|
118
|
+
isPending,
|
|
109
119
|
} = props
|
|
110
120
|
|
|
111
121
|
const tooltipStyles = useTooltipStyles()
|
|
@@ -126,6 +136,12 @@ function HeatmapInner<T>(props: HeatmapProps<T>) {
|
|
|
126
136
|
return { lookup: map, max: m }
|
|
127
137
|
}, [data, getRow, getCol, getValue])
|
|
128
138
|
|
|
139
|
+
// Every hook above must still run on a pending render (Rules of Hooks) — the early return comes
|
|
140
|
+
// after them. No `ChartFrame` to forward to here (Heatmap measures via required `width`/`height`
|
|
141
|
+
// props, not `useChartSize`), so the placeholder is rendered directly in its place, same footprint,
|
|
142
|
+
// legend strip and all other content omitted along with it.
|
|
143
|
+
if (isPending) return <ChartPending width={width} height={height} />
|
|
144
|
+
|
|
129
145
|
const legendH = legend ? LEGEND_H + LEGEND_LABEL_H : 0
|
|
130
146
|
const gridW = Math.max(0, width - PAD_LEFT)
|
|
131
147
|
const gridH = Math.max(0, height - PAD_TOP - PAD_BOTTOM - legendH)
|
|
@@ -152,8 +168,9 @@ function HeatmapInner<T>(props: HeatmapProps<T>) {
|
|
|
152
168
|
rx={cellRadius}
|
|
153
169
|
fill={fill}
|
|
154
170
|
style={{ cursor: has ? 'pointer' : 'default' }}
|
|
155
|
-
|
|
156
|
-
|
|
171
|
+
onPointerMove={(e) => has && show({ row, col, value }, e)}
|
|
172
|
+
onPointerLeave={hide}
|
|
173
|
+
onPointerCancel={hide}
|
|
157
174
|
/>
|
|
158
175
|
)
|
|
159
176
|
}),
|
|
@@ -67,6 +67,8 @@ export type MultiLineProps<T> = {
|
|
|
67
67
|
legend?: ChartLegendConfig | false
|
|
68
68
|
/** Accessible text alternative, forwarded to `ChartFrame` as `aria-label` (+ `role="img"`). */
|
|
69
69
|
ariaLabel?: string
|
|
70
|
+
/** Forwarded to `ChartFrame` — see `ChartPending`'s JSDoc for the three-state rationale. */
|
|
71
|
+
isPending?: boolean
|
|
70
72
|
}
|
|
71
73
|
|
|
72
74
|
const STAR_R = 6
|
|
@@ -96,7 +98,7 @@ function starPath(cx: number, cy: number, r: number): string {
|
|
|
96
98
|
* has nulls; each series line skips null points (creating visual gaps).
|
|
97
99
|
*/
|
|
98
100
|
function MultiLineInner<T>(props: MultiLineProps<T>) {
|
|
99
|
-
const { series, chartId, height, legend, ariaLabel } = props
|
|
101
|
+
const { series, chartId, height, legend, ariaLabel, isPending } = props
|
|
100
102
|
const [highlightedKey, setHighlightedKey] = useState<string | null>(null)
|
|
101
103
|
|
|
102
104
|
// Default line overlays to the redesign's 1.9px stroke (docs/DESIGN-SPEC.md §5) — applied once
|
|
@@ -112,6 +114,7 @@ function MultiLineInner<T>(props: MultiLineProps<T>) {
|
|
|
112
114
|
chartId={chartId}
|
|
113
115
|
{...(height !== undefined && { height })}
|
|
114
116
|
{...(ariaLabel !== undefined && { ariaLabel })}
|
|
117
|
+
{...(isPending !== undefined && { isPending })}
|
|
115
118
|
legend={resolveLegend(legend, {
|
|
116
119
|
highlighted: highlightedKey,
|
|
117
120
|
onHighlight: setHighlightedKey,
|
|
@@ -40,6 +40,8 @@ export type StackedAreaProps<T> = {
|
|
|
40
40
|
legend?: ChartLegendConfig | false
|
|
41
41
|
/** Accessible text alternative, forwarded to `ChartFrame` as `aria-label` (+ `role="img"`). */
|
|
42
42
|
ariaLabel?: string
|
|
43
|
+
/** Forwarded to `ChartFrame` — see `ChartPending`'s JSDoc for the three-state rationale. */
|
|
44
|
+
isPending?: boolean
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
/**
|
|
@@ -48,7 +50,7 @@ export type StackedAreaProps<T> = {
|
|
|
48
50
|
* The tooltip renders rows in reversed (top-to-bottom) stack order so it matches the visual stack.
|
|
49
51
|
*/
|
|
50
52
|
function StackedAreaInner<T>(props: StackedAreaProps<T>) {
|
|
51
|
-
const { series, chartId, height, legend, ariaLabel } = props
|
|
53
|
+
const { series, chartId, height, legend, ariaLabel, isPending } = props
|
|
52
54
|
const [highlightedKey, setHighlightedKey] = useState<string | null>(null)
|
|
53
55
|
|
|
54
56
|
return (
|
|
@@ -57,6 +59,7 @@ function StackedAreaInner<T>(props: StackedAreaProps<T>) {
|
|
|
57
59
|
chartId={chartId}
|
|
58
60
|
{...(height !== undefined && { height })}
|
|
59
61
|
{...(ariaLabel !== undefined && { ariaLabel })}
|
|
62
|
+
{...(isPending !== undefined && { isPending })}
|
|
60
63
|
legend={resolveLegend(legend, {
|
|
61
64
|
highlighted: highlightedKey,
|
|
62
65
|
onHighlight: setHighlightedKey,
|
|
@@ -101,6 +101,8 @@ export type ZonedLineProps<T> = {
|
|
|
101
101
|
legend?: ChartLegendConfig | false
|
|
102
102
|
/** Accessible text alternative, forwarded to `ChartFrame` as `aria-label` (+ `role="img"`). */
|
|
103
103
|
ariaLabel?: string
|
|
104
|
+
/** Forwarded to `ChartFrame` — see `ChartPending`'s JSDoc for the three-state rationale. */
|
|
105
|
+
isPending?: boolean
|
|
104
106
|
}
|
|
105
107
|
|
|
106
108
|
/**
|
|
@@ -116,7 +118,7 @@ export type ZonedLineProps<T> = {
|
|
|
116
118
|
* visual gaps).
|
|
117
119
|
*/
|
|
118
120
|
function ZonedLineInner<T>(props: ZonedLineProps<T>) {
|
|
119
|
-
const { series, chartId, height, legend, ariaLabel } = props
|
|
121
|
+
const { series, chartId, height, legend, ariaLabel, isPending } = props
|
|
120
122
|
|
|
121
123
|
// Default the line overlay to the redesign's 1.9px stroke (docs/DESIGN-SPEC.md §5) — applied
|
|
122
124
|
// once here so the plotted line, the derived legend swatch, and the derived tooltip row agree.
|
|
@@ -131,6 +133,7 @@ function ZonedLineInner<T>(props: ZonedLineProps<T>) {
|
|
|
131
133
|
chartId={chartId}
|
|
132
134
|
{...(height !== undefined && { height })}
|
|
133
135
|
{...(ariaLabel !== undefined && { ariaLabel })}
|
|
136
|
+
{...(isPending !== undefined && { isPending })}
|
|
134
137
|
legend={resolveLegend(legend)}
|
|
135
138
|
>
|
|
136
139
|
{(plot) => <ZonedLinePlot {...props} series={styledSeries} plot={plot} />}
|