basalt-ui 2.4.0 → 2.5.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/CHANGELOG.md +16 -0
- package/MIGRATING.md +34 -12
- package/agent/rules/basalt-state.md +1 -1
- package/dist/agent/ai-sdk-transport.d.ts.map +1 -1
- package/dist/agent/ai-sdk-transport.js +4 -1
- package/dist/agent/index.d.ts +1 -1
- package/dist/agent/index.d.ts.map +1 -1
- package/dist/agent/run-records.d.ts +12 -3
- package/dist/agent/run-records.d.ts.map +1 -1
- package/dist/agent/run-records.js +16 -3
- package/dist/agent/transport.d.ts +10 -3
- package/dist/agent/transport.d.ts.map +1 -1
- package/dist/agent/use-agent-thread-runs.d.ts.map +1 -1
- package/dist/agent/use-agent-thread-runs.js +23 -7
- package/dist/charts/DualPanel.d.ts.map +1 -1
- package/dist/charts/DualPanel.js +14 -5
- package/dist/charts/MirroredBars.d.ts.map +1 -1
- package/dist/charts/MirroredBars.js +6 -4
- package/dist/charts/compile.d.ts +4 -0
- package/dist/charts/compile.d.ts.map +1 -1
- package/dist/charts/compile.js +40 -11
- package/dist/charts/panel.d.ts +6 -0
- package/dist/charts/panel.d.ts.map +1 -1
- package/dist/charts/panel.js +10 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/provider/index.d.ts.map +1 -1
- package/dist/provider/index.js +8 -2
- package/dist/state/fields.d.ts +10 -15
- package/dist/state/fields.d.ts.map +1 -1
- package/dist/state/fields.js +27 -1
- package/dist/telemetry.d.ts +58 -4
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +13 -5
- package/llms.txt +2 -2
- package/package.json +1 -1
- package/src/agent/ai-sdk-transport.ts +5 -2
- package/src/agent/index.ts +1 -1
- package/src/agent/run-records.ts +27 -3
- package/src/agent/transport.ts +11 -5
- package/src/agent/use-agent-thread-runs.ts +35 -8
- package/src/charts/DualPanel.tsx +13 -4
- package/src/charts/MirroredBars.tsx +5 -3
- package/src/charts/compile.ts +59 -12
- package/src/charts/panel.tsx +16 -1
- package/src/index.ts +1 -1
- package/src/provider/index.tsx +8 -2
- package/src/state/fields.ts +41 -19
- package/src/telemetry.ts +70 -7
package/src/agent/run-records.ts
CHANGED
|
@@ -12,10 +12,12 @@
|
|
|
12
12
|
* covers: it outlives the run only until a render shows the outcome (`sweepFlushed`).
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import type { ChatTurnEnd, ChatTurnTrace } from '../telemetry'
|
|
16
|
+
|
|
15
17
|
/** The part of a rendered `runs` entry this module reads. */
|
|
16
18
|
type RenderedRun<TPart> = { readonly status: string; readonly parts: TPart[] }
|
|
17
19
|
|
|
18
|
-
export type RunEnd =
|
|
20
|
+
export type RunEnd = ChatTurnEnd
|
|
19
21
|
|
|
20
22
|
export type RunRecord<TPart> = {
|
|
21
23
|
readonly controller: AbortController
|
|
@@ -28,15 +30,22 @@ export type RunRecord<TPart> = {
|
|
|
28
30
|
ended: RunEnd | undefined
|
|
29
31
|
/** A newer run began on the same thread (set by `beginRun` on the record it replaces). */
|
|
30
32
|
superseded: boolean
|
|
33
|
+
/** The turn's telemetry `settle`, run once by `settleTrace`. */
|
|
34
|
+
settle: ChatTurnTrace['settle']
|
|
31
35
|
}
|
|
32
36
|
|
|
33
37
|
export type RunRecords<TPart> = Map<string, RunRecord<TPart>>
|
|
34
38
|
|
|
35
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* Registers a live run for `threadId`, replacing whatever record the previous run left. A previous
|
|
41
|
+
* run still being driven is cut short: its trace settles as `stopped`. `settle` is the new turn's
|
|
42
|
+
* telemetry hook (absent for a resume).
|
|
43
|
+
*/
|
|
36
44
|
export function beginRun<TPart>(
|
|
37
45
|
records: RunRecords<TPart>,
|
|
38
46
|
threadId: string,
|
|
39
47
|
controller: AbortController,
|
|
48
|
+
settle?: ChatTurnTrace['settle'],
|
|
40
49
|
): RunRecord<TPart> {
|
|
41
50
|
const record: RunRecord<TPart> = {
|
|
42
51
|
controller,
|
|
@@ -45,13 +54,28 @@ export function beginRun<TPart>(
|
|
|
45
54
|
resolving: false,
|
|
46
55
|
ended: undefined,
|
|
47
56
|
superseded: false,
|
|
57
|
+
settle,
|
|
48
58
|
}
|
|
49
59
|
const previous = records.get(threadId)
|
|
50
|
-
if (previous !== undefined)
|
|
60
|
+
if (previous !== undefined) {
|
|
61
|
+
previous.superseded = true
|
|
62
|
+
settleTrace(previous, 'stopped')
|
|
63
|
+
}
|
|
51
64
|
records.set(threadId, record)
|
|
52
65
|
return record
|
|
53
66
|
}
|
|
54
67
|
|
|
68
|
+
/** Runs the turn's telemetry `settle` once; a throwing consumer hook never breaks the run. */
|
|
69
|
+
export function settleTrace<TPart>(record: RunRecord<TPart>, end: RunEnd): void {
|
|
70
|
+
const { settle } = record
|
|
71
|
+
record.settle = undefined
|
|
72
|
+
try {
|
|
73
|
+
settle?.(end)
|
|
74
|
+
} catch {
|
|
75
|
+
// a throwing sink must never break the app (§ 7 delivery rules)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
55
79
|
/** Marks a run as no longer driven and drops the content it held. */
|
|
56
80
|
export function endRun<TPart>(record: RunRecord<TPart>, reason: RunEnd): void {
|
|
57
81
|
record.ended = reason
|
package/src/agent/transport.ts
CHANGED
|
@@ -16,6 +16,16 @@ import type { AgentPartDraft } from './parts'
|
|
|
16
16
|
|
|
17
17
|
// ── AgentTransport ────────────────────────────────────────────────────────────
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* The third `stream` argument. `headers` are extra request headers for THIS turn's request to the
|
|
21
|
+
* transport's own endpoint — today the W3C trace context a telemetry sink's `startChatTurn`
|
|
22
|
+
* returned. Absent unless a sink contributed; a transport that ignores them keeps working.
|
|
23
|
+
*/
|
|
24
|
+
export type AgentStreamContext = {
|
|
25
|
+
readonly messageId: string
|
|
26
|
+
readonly headers?: Readonly<Record<string, string>> | undefined
|
|
27
|
+
}
|
|
28
|
+
|
|
19
29
|
/**
|
|
20
30
|
* The injected transport seam. `stream(input, signal, ctx)` returns an async generator of parts.
|
|
21
31
|
* Implement this interface for any backend — fetch-based SSE, AI SDK, or a mock.
|
|
@@ -43,11 +53,7 @@ import type { AgentPartDraft } from './parts'
|
|
|
43
53
|
* }
|
|
44
54
|
*/
|
|
45
55
|
export type AgentTransport<TPart = AgentPartDraft, TInput = string> = {
|
|
46
|
-
stream: (
|
|
47
|
-
input: TInput,
|
|
48
|
-
signal?: AbortSignal,
|
|
49
|
-
ctx?: { readonly messageId: string },
|
|
50
|
-
) => AsyncGenerator<TPart>
|
|
56
|
+
stream: (input: TInput, signal?: AbortSignal, ctx?: AgentStreamContext) => AsyncGenerator<TPart>
|
|
51
57
|
/** Optional: resume a previously-started run from its resumeToken (see StartPart). Transports that don't support resumption simply omit this. */
|
|
52
58
|
resume?: ((resumeToken: string, signal?: AbortSignal) => AsyncGenerator<TPart>) | undefined
|
|
53
59
|
}
|
|
@@ -50,6 +50,7 @@ import { createRunCommitter, isContentPart } from './run-committer'
|
|
|
50
50
|
import {
|
|
51
51
|
beginRun,
|
|
52
52
|
endRun,
|
|
53
|
+
settleTrace,
|
|
53
54
|
failedThreadIds,
|
|
54
55
|
dismissRun,
|
|
55
56
|
hasLiveRun,
|
|
@@ -62,8 +63,8 @@ import type { RunRecord, RunRecords } from './run-records'
|
|
|
62
63
|
import type { AgentThread, LiveRunStatus, ThreadStatus, ThreadsStore } from './thread'
|
|
63
64
|
import { isResumable } from './transport'
|
|
64
65
|
import type { AgentTransport } from './transport'
|
|
65
|
-
import { TelemetryContext,
|
|
66
|
-
import type { BasaltEvent, Telemetry } from '../telemetry'
|
|
66
|
+
import { TelemetryContext, createLiveEmitter, errorName, now } from '../telemetry'
|
|
67
|
+
import type { BasaltEvent, Telemetry, TraceHeaders } from '../telemetry'
|
|
67
68
|
|
|
68
69
|
// ── ThreadRunState ────────────────────────────────────────────────────────────
|
|
69
70
|
|
|
@@ -388,6 +389,7 @@ async function consumeAndFinalize<TPart extends PartLike>(args: {
|
|
|
388
389
|
status: outcome.status,
|
|
389
390
|
}),
|
|
390
391
|
)
|
|
392
|
+
settleTrace(record, 'settled')
|
|
391
393
|
} catch (err) {
|
|
392
394
|
// Guard: the run ended (stop(), unmount — which abort the controller, so their AbortError lands
|
|
393
395
|
// here) or a newer stream took the thread over. Only an abort from somewhere else gets past.
|
|
@@ -421,6 +423,7 @@ async function consumeAndFinalize<TPart extends PartLike>(args: {
|
|
|
421
423
|
failure: { error: err, agentError },
|
|
422
424
|
}),
|
|
423
425
|
)
|
|
426
|
+
settleTrace(record, 'failed')
|
|
424
427
|
onError?.({ threadId, error: err })
|
|
425
428
|
} finally {
|
|
426
429
|
committer.cancel()
|
|
@@ -555,6 +558,24 @@ async function finalizeStop<TPart extends PartLike>(args: {
|
|
|
555
558
|
}
|
|
556
559
|
}
|
|
557
560
|
|
|
561
|
+
const TRACEPARENT = /^[\da-f]{2}-[\da-f]{32}-[\da-f]{16}-[\da-f]{2}$/
|
|
562
|
+
const TRACESTATE_MEMBER = /^[\da-z][\w*/@-]{0,255}=[^\s,=]([^,=]{0,254}[^\s,=])?$/
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* The wire form of a turn's trace context: a well-formed `traceparent` (else none — the request
|
|
566
|
+
* goes out untraced, the sink's `settle` still runs) and a `tracestate` only if valid.
|
|
567
|
+
*/
|
|
568
|
+
function traceRequestHeaders(headers: TraceHeaders): Record<string, string> | undefined {
|
|
569
|
+
const { traceparent, tracestate } = headers
|
|
570
|
+
if (typeof traceparent !== 'string' || !TRACEPARENT.test(traceparent)) return undefined
|
|
571
|
+
const members = typeof tracestate === 'string' ? tracestate.split(',') : []
|
|
572
|
+
const valid =
|
|
573
|
+
members.length > 0 &&
|
|
574
|
+
members.length <= 32 &&
|
|
575
|
+
members.every((member) => TRACESTATE_MEMBER.test(member.trim()))
|
|
576
|
+
return valid ? { traceparent, tracestate: tracestate as string } : { traceparent }
|
|
577
|
+
}
|
|
578
|
+
|
|
558
579
|
/** The `chat.turn` event for a turn that completed (`failure` absent) or failed. */
|
|
559
580
|
function buildTurnEvent(args: {
|
|
560
581
|
threadId: string
|
|
@@ -654,10 +675,7 @@ export function useAgentThreadRuns<TPart extends PartLike = AgentPart>({
|
|
|
654
675
|
toUserPartsRef.current = resolvedToUserParts
|
|
655
676
|
|
|
656
677
|
// Stable over the sink ref, so an inline `telemetry` function never changes what `start` closes over.
|
|
657
|
-
const emitTelemetry = useMemo<Telemetry>(
|
|
658
|
-
() => createEmitter((event) => telemetryRef.current?.(event)),
|
|
659
|
-
[],
|
|
660
|
-
)
|
|
678
|
+
const emitTelemetry = useMemo<Telemetry>(() => createLiveEmitter(() => telemetryRef.current), [])
|
|
661
679
|
|
|
662
680
|
// Resolves the transport for `threadId`: the shared object as-is for the plain-object form
|
|
663
681
|
// (unchanged behavior), or the factory's result — resolved once per thread id and cached in
|
|
@@ -707,6 +725,7 @@ export function useAgentThreadRuns<TPart extends PartLike = AgentPart>({
|
|
|
707
725
|
if (record.ended !== undefined) continue
|
|
708
726
|
abortedThreadIds.push(threadId)
|
|
709
727
|
record.controller.abort()
|
|
728
|
+
settleTrace(record, 'stopped')
|
|
710
729
|
}
|
|
711
730
|
recordsRef.current.clear()
|
|
712
731
|
if (abortedThreadIds.length === 0) return
|
|
@@ -841,8 +860,12 @@ export function useAgentThreadRuns<TPart extends PartLike = AgentPart>({
|
|
|
841
860
|
})
|
|
842
861
|
|
|
843
862
|
const controller = new AbortController()
|
|
844
|
-
|
|
863
|
+
// Opened before the request so its context rides on it; closed by `settleTrace` (a resume is
|
|
864
|
+
// the same turn's reconnect, not a new one, and carries none).
|
|
865
|
+
const trace = emitTelemetry.startChatTurn?.({ threadId, messageId: userMessage.id })
|
|
866
|
+
const record = beginRun(recordsRef.current, threadId, controller, trace?.settle)
|
|
845
867
|
const resolvedTransport = resolveTransport(threadId)
|
|
868
|
+
const headers = trace?.headers === undefined ? undefined : traceRequestHeaders(trace.headers)
|
|
846
869
|
|
|
847
870
|
// Non-awaited: this consumer runs in the background so start() returns immediately and
|
|
848
871
|
// multiple threads can stream at once.
|
|
@@ -855,7 +878,10 @@ export function useAgentThreadRuns<TPart extends PartLike = AgentPart>({
|
|
|
855
878
|
// id instead of a second, unrelated one (R1 — see AgentTransport.stream's doc).
|
|
856
879
|
generator: withPartIds(
|
|
857
880
|
threadId,
|
|
858
|
-
resolvedTransport.stream(input, controller.signal, {
|
|
881
|
+
resolvedTransport.stream(input, controller.signal, {
|
|
882
|
+
messageId: userMessage.id,
|
|
883
|
+
...(headers !== undefined ? { headers } : {}),
|
|
884
|
+
}),
|
|
859
885
|
),
|
|
860
886
|
userMessage,
|
|
861
887
|
recordsRef,
|
|
@@ -913,6 +939,7 @@ export function useAgentThreadRuns<TPart extends PartLike = AgentPart>({
|
|
|
913
939
|
const { appended, resolving } = record
|
|
914
940
|
endRun(record, 'stopped')
|
|
915
941
|
record.controller.abort()
|
|
942
|
+
settleTrace(record, 'stopped')
|
|
916
943
|
void finalizeStop({
|
|
917
944
|
threadId,
|
|
918
945
|
parts,
|
package/src/charts/DualPanel.tsx
CHANGED
|
@@ -16,7 +16,7 @@ import { VX, alpha } from '../tokens'
|
|
|
16
16
|
import { resolveContainerClass } from '../tokens/size-classes'
|
|
17
17
|
import { defaultFormatX, defaultNumberFormat, engineRow, seriesRow, visibleSeries } from './compile'
|
|
18
18
|
import { barMark, emphasis, lineMark, markersOf, thinFill } from './marks'
|
|
19
|
-
import { PanelRung, paneX, paneY, rowsOf } from './panel'
|
|
19
|
+
import { PanelRung, chartHeightOf, paneX, paneY, rowsOf } from './panel'
|
|
20
20
|
import type { PaneBuilder, PanelInput, PanelPlan } from './panel'
|
|
21
21
|
import { xScaleFor } from './scales'
|
|
22
22
|
import { rowX } from './types'
|
|
@@ -269,13 +269,21 @@ export function DualPanel<T, X extends Date | number = Date | number>(
|
|
|
269
269
|
],
|
|
270
270
|
scales: {
|
|
271
271
|
x: { scale: xScale(), axis: false },
|
|
272
|
-
y: paneY({
|
|
272
|
+
y: paneY({
|
|
273
|
+
scale: topY,
|
|
274
|
+
domain: topDomain,
|
|
275
|
+
format: topFormat,
|
|
276
|
+
ticks: TOP_TICKS,
|
|
277
|
+
cls,
|
|
278
|
+
height: chartHeightOf(h, topFraction, PANE_GAP),
|
|
279
|
+
}),
|
|
273
280
|
},
|
|
274
281
|
}
|
|
275
282
|
}
|
|
276
283
|
|
|
277
|
-
const barPane: PaneBuilder = ({ width }) => {
|
|
284
|
+
const barPane: PaneBuilder = ({ width, height: h }) => {
|
|
278
285
|
const cls = resolveContainerClass(width)
|
|
286
|
+
const whole = chartHeightOf(h, 1 - topFraction, PANE_GAP)
|
|
279
287
|
const barRows = barOn ? rowsOf(data, x, barSeries) : []
|
|
280
288
|
const dim = emphasis(highlighted, { key: BAR_KEY })
|
|
281
289
|
return {
|
|
@@ -290,13 +298,14 @@ export function DualPanel<T, X extends Date | number = Date | number>(
|
|
|
290
298
|
}),
|
|
291
299
|
],
|
|
292
300
|
scales: {
|
|
293
|
-
x: paneX({ scale: xScale(), model, format, cls }),
|
|
301
|
+
x: paneX({ scale: xScale(), model, format, cls, height: whole }),
|
|
294
302
|
y: paneY({
|
|
295
303
|
scale: barScale,
|
|
296
304
|
domain: barDomain,
|
|
297
305
|
format: bar.format,
|
|
298
306
|
ticks: BAR_TICKS,
|
|
299
307
|
cls,
|
|
308
|
+
height: whole,
|
|
300
309
|
}),
|
|
301
310
|
},
|
|
302
311
|
}
|
|
@@ -23,7 +23,7 @@ import { VX, alpha } from '../tokens'
|
|
|
23
23
|
import { resolveContainerClass } from '../tokens/size-classes'
|
|
24
24
|
import { defaultFormatX, defaultNumberFormat, seriesRow, visibleSeries } from './compile'
|
|
25
25
|
import { barMark, emphasis } from './marks'
|
|
26
|
-
import { PanelRung, paneX, paneY, rowsOf } from './panel'
|
|
26
|
+
import { PanelRung, chartHeightOf, paneX, paneY, rowsOf } from './panel'
|
|
27
27
|
import type { PaneBuilder, PanelInput, PanelPlan } from './panel'
|
|
28
28
|
import { xScaleFor } from './scales'
|
|
29
29
|
import { rowX } from './types'
|
|
@@ -174,8 +174,9 @@ export function MirroredBars<T, X extends ChartX = ChartX>(
|
|
|
174
174
|
on: boolean,
|
|
175
175
|
max: number,
|
|
176
176
|
): PaneBuilder =>
|
|
177
|
-
({ width: w }) => {
|
|
177
|
+
({ width: w, height: h }) => {
|
|
178
178
|
const cls = resolveContainerClass(w)
|
|
179
|
+
const whole = chartHeightOf(h, side === 'up' ? upFraction : 1 - upFraction, 0)
|
|
179
180
|
// The down pane's zero is at its top.
|
|
180
181
|
const domain: [number, number] = side === 'up' ? [0, max] : [max, 0]
|
|
181
182
|
const rows: PlotRow<T, X>[] = on ? rowsOf(drawn, x, s) : []
|
|
@@ -249,7 +250,7 @@ export function MirroredBars<T, X extends ChartX = ChartX>(
|
|
|
249
250
|
scales: {
|
|
250
251
|
x:
|
|
251
252
|
side === 'down'
|
|
252
|
-
? paneX({ scale: bandScale(), model, format, cls })
|
|
253
|
+
? paneX({ scale: bandScale(), model, format, cls, height: whole })
|
|
253
254
|
: { scale: bandScale(), axis: false },
|
|
254
255
|
y: paneY({
|
|
255
256
|
scale: scaleLinear().domain(domain),
|
|
@@ -257,6 +258,7 @@ export function MirroredBars<T, X extends ChartX = ChartX>(
|
|
|
257
258
|
format: cfg.format,
|
|
258
259
|
ticks: cfg.ticks ?? DEFAULT_PANE_TICKS,
|
|
259
260
|
cls,
|
|
261
|
+
height: whole,
|
|
260
262
|
}),
|
|
261
263
|
zx: { channel: 'x', scale: scaleLinear().domain([0, 1]), axis: false },
|
|
262
264
|
},
|
package/src/charts/compile.ts
CHANGED
|
@@ -23,6 +23,7 @@ import type {
|
|
|
23
23
|
ResponsiveChartDefinition,
|
|
24
24
|
StoredChartSpec,
|
|
25
25
|
} from '@tanstack/charts/types/core'
|
|
26
|
+
import { SHORT_CHART_HEIGHT } from './box'
|
|
26
27
|
import { fmtCompact } from '../format/number'
|
|
27
28
|
import { VX } from '../tokens'
|
|
28
29
|
import type { ContainerClass } from '../tokens/size-classes'
|
|
@@ -97,6 +98,14 @@ const LADDER = {
|
|
|
97
98
|
const INSIDE_LABEL_DX = 4
|
|
98
99
|
const INSIDE_LABEL_DY = -6
|
|
99
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Y labels go inside the plot only for the compact class under `SHORT_CHART_HEIGHT` (the box's own
|
|
103
|
+
* short signal); otherwise they sit in a left gutter. `height` is the whole chart's, `0` before it
|
|
104
|
+
* is measured, which keeps the gutter rather than flipping a first paint inside.
|
|
105
|
+
*/
|
|
106
|
+
const labelsInside = (cls: ContainerClass, height: number): boolean =>
|
|
107
|
+
cls === 'compact' && height > 0 && height < SHORT_CHART_HEIGHT
|
|
108
|
+
|
|
100
109
|
export function defaultNumberFormat(v: number): string {
|
|
101
110
|
return fmtCompact(v, { digits: Math.abs(v) >= 1000 ? 1 : 2 })
|
|
102
111
|
}
|
|
@@ -180,6 +189,22 @@ const REF_KEY_PREFIX = '__ref-'
|
|
|
180
189
|
/** The legend key of a reference line that names itself (`RefLine.legend`). */
|
|
181
190
|
const refLegendKey = (index: number): string => `${REF_KEY_PREFIX}${index}`
|
|
182
191
|
|
|
192
|
+
/**
|
|
193
|
+
* A reference line is drawn translucent (`*-ref`, 20-30% alpha) so it recedes behind the data, but
|
|
194
|
+
* a 16x8 swatch at that alpha vanishes on the dark surface. The legend takes the status solid,
|
|
195
|
+
* which the color gate holds to 3:1 on the panel in both schemes, like a series swatch.
|
|
196
|
+
*/
|
|
197
|
+
const SWATCH_SOLID: ReadonlyMap<string, string> = new Map([
|
|
198
|
+
[VX.goodRef, VX.goodSolid],
|
|
199
|
+
[VX.badRef, VX.badSolid],
|
|
200
|
+
[VX.warnRef, VX.warnSolid],
|
|
201
|
+
// The tinted status fills are as translucent, and a consumer may pass either as a line color.
|
|
202
|
+
[VX.good, VX.goodSolid],
|
|
203
|
+
[VX.goodSoft, VX.goodSolid],
|
|
204
|
+
[VX.bad, VX.badSolid],
|
|
205
|
+
[VX.warn, VX.warnSolid],
|
|
206
|
+
])
|
|
207
|
+
|
|
183
208
|
/** Legend-only entries for the reference lines that name themselves, in line order. */
|
|
184
209
|
export function refLegendSeries<T>(refLines: readonly RefLine[] | undefined): ChartSeries<T>[] {
|
|
185
210
|
return (refLines ?? []).flatMap((r, i) =>
|
|
@@ -189,7 +214,7 @@ export function refLegendSeries<T>(refLines: readonly RefLine[] | undefined): Ch
|
|
|
189
214
|
{
|
|
190
215
|
key: refLegendKey(i),
|
|
191
216
|
label: r.legend.label,
|
|
192
|
-
color: r.color,
|
|
217
|
+
color: SWATCH_SOLID.get(r.color) ?? r.color,
|
|
193
218
|
role: 'reference' as const,
|
|
194
219
|
tooltip: false,
|
|
195
220
|
getValue: () => null,
|
|
@@ -218,8 +243,10 @@ export function yGuide<T>(opts: {
|
|
|
218
243
|
cls: ContainerClass
|
|
219
244
|
side: 'left' | 'right'
|
|
220
245
|
grid: boolean
|
|
246
|
+
/** The whole chart's height: decides inside or gutter labels. */
|
|
247
|
+
height: number
|
|
221
248
|
}): Omit<ChartPositionScaleOptions<number>, 'scale'> {
|
|
222
|
-
const { cfg, axis, cls, side, grid } = opts
|
|
249
|
+
const { cfg, axis, cls, side, grid, height } = opts
|
|
223
250
|
const ladder = LADDER[cls]
|
|
224
251
|
const ticks =
|
|
225
252
|
typeof cfg?.ticks === 'object'
|
|
@@ -227,8 +254,8 @@ export function yGuide<T>(opts: {
|
|
|
227
254
|
: { count: typeof cfg?.ticks === 'number' ? cfg.ticks : ladder.yTicks }
|
|
228
255
|
const gridLine = grid ? { grid: GRID_LINE } : {}
|
|
229
256
|
if (cls === 'micro') return { ...gridLine, axis: false }
|
|
230
|
-
//
|
|
231
|
-
const inside = cls
|
|
257
|
+
// Labels sit INSIDE the plot, above their grid line, so the plot keeps the full width.
|
|
258
|
+
const inside = labelsInside(cls, height)
|
|
232
259
|
return {
|
|
233
260
|
...gridLine,
|
|
234
261
|
...(side === 'right' && { side: 'right' as const }),
|
|
@@ -276,9 +303,12 @@ export function xGuide<X extends ChartX>(opts: {
|
|
|
276
303
|
xTicks: number | readonly X[] | undefined
|
|
277
304
|
format: (v: ChartValue) => string
|
|
278
305
|
cls: ContainerClass
|
|
306
|
+
/** The whole chart's height: decides whether the left margin is locked to zero. */
|
|
307
|
+
height: number
|
|
279
308
|
}): Omit<ChartPositionScaleOptions<ChartValue>, 'scale'> {
|
|
280
|
-
const { model, xTicks, format, cls } = opts
|
|
309
|
+
const { model, xTicks, format, cls, height } = opts
|
|
281
310
|
const ladder = LADDER[cls]
|
|
311
|
+
const inside = labelsInside(cls, height)
|
|
282
312
|
const ticks = xTickPolicy(model, xTicks ?? ladder.xTicks)
|
|
283
313
|
return {
|
|
284
314
|
axis: {
|
|
@@ -288,8 +318,8 @@ export function xGuide<X extends ChartX>(opts: {
|
|
|
288
318
|
fontSize: ladder.font,
|
|
289
319
|
opacity: 1,
|
|
290
320
|
thin: true,
|
|
291
|
-
// A locked-zero left margin (
|
|
292
|
-
...(
|
|
321
|
+
// A locked-zero left margin (inside labels) must not clip the first label.
|
|
322
|
+
...(inside && {
|
|
293
323
|
anchor: ({ index }: { index: number }) => (index === 0 ? ('start' as const) : undefined),
|
|
294
324
|
}),
|
|
295
325
|
},
|
|
@@ -634,18 +664,26 @@ function buildScales<T, X extends ChartX>(opts: {
|
|
|
634
664
|
axes: Axes<T>
|
|
635
665
|
formatX: (v: ChartValue) => string
|
|
636
666
|
cls: ContainerClass
|
|
667
|
+
height: number
|
|
637
668
|
zoneScale: boolean
|
|
638
669
|
}): StoredChartSpec['scales'] {
|
|
639
|
-
const { input, model, axes, formatX, cls, zoneScale } = opts
|
|
670
|
+
const { input, model, axes, formatX, cls, height, zoneScale } = opts
|
|
640
671
|
const { left, right } = axes
|
|
641
672
|
return {
|
|
642
673
|
x: {
|
|
643
674
|
scale: model.scale,
|
|
644
|
-
...xGuide({ model, xTicks: input.xTicks, format: formatX, cls }),
|
|
675
|
+
...xGuide({ model, xTicks: input.xTicks, format: formatX, cls, height }),
|
|
645
676
|
},
|
|
646
677
|
y: {
|
|
647
678
|
scale: yScaleFor(input.y, left.domain),
|
|
648
|
-
...yGuide({
|
|
679
|
+
...yGuide({
|
|
680
|
+
cfg: input.y,
|
|
681
|
+
axis: left,
|
|
682
|
+
cls,
|
|
683
|
+
height,
|
|
684
|
+
side: 'left',
|
|
685
|
+
grid: input.y?.grid ?? true,
|
|
686
|
+
}),
|
|
649
687
|
},
|
|
650
688
|
...(right && {
|
|
651
689
|
y2: {
|
|
@@ -655,6 +693,7 @@ function buildScales<T, X extends ChartX>(opts: {
|
|
|
655
693
|
cfg: input.y2,
|
|
656
694
|
axis: right,
|
|
657
695
|
cls,
|
|
696
|
+
height,
|
|
658
697
|
side: 'right',
|
|
659
698
|
grid: input.y2?.grid ?? false,
|
|
660
699
|
}),
|
|
@@ -816,11 +855,19 @@ export function compileCartesian<T, X extends ChartX>(
|
|
|
816
855
|
})
|
|
817
856
|
return {
|
|
818
857
|
marks: [...under, ...kindMarks, ...dots, ...over, crosshair({ x: CROSSHAIR, y: false })],
|
|
819
|
-
scales: buildScales({
|
|
858
|
+
scales: buildScales({
|
|
859
|
+
input,
|
|
860
|
+
model,
|
|
861
|
+
axes,
|
|
862
|
+
formatX,
|
|
863
|
+
cls,
|
|
864
|
+
height,
|
|
865
|
+
zoneScale: under.length > 0,
|
|
866
|
+
}),
|
|
820
867
|
theme: CHART_THEME,
|
|
821
868
|
...(gradients.length > 0 && { gradients }),
|
|
822
869
|
clip: true,
|
|
823
|
-
...(cls
|
|
870
|
+
...(labelsInside(cls, height) && { margin: { left: 0, ...(axes.right && { right: 0 }) } }),
|
|
824
871
|
}
|
|
825
872
|
},
|
|
826
873
|
// Followers paint the crosshair only, unless the chart opts into following tooltips.
|
package/src/charts/panel.tsx
CHANGED
|
@@ -83,12 +83,15 @@ export const paneY = (opts: {
|
|
|
83
83
|
format: (v: number) => string
|
|
84
84
|
ticks: number
|
|
85
85
|
cls: ContainerClass
|
|
86
|
+
/** The whole chart's height ({@link chartHeightOf}). */
|
|
87
|
+
height: number
|
|
86
88
|
}): StoredChartSpec['scales'][string] => ({
|
|
87
89
|
scale: opts.scale,
|
|
88
90
|
...yGuide({
|
|
89
91
|
cfg: { ticks: opts.ticks },
|
|
90
92
|
axis: { domain: opts.domain, format: opts.format },
|
|
91
93
|
cls: opts.cls,
|
|
94
|
+
height: opts.height,
|
|
92
95
|
side: 'left',
|
|
93
96
|
grid: true,
|
|
94
97
|
}),
|
|
@@ -102,11 +105,23 @@ export const paneX = <X extends ChartX>(opts: {
|
|
|
102
105
|
model: XScaleModel<X>
|
|
103
106
|
format: (v: ChartValue) => string
|
|
104
107
|
cls: ContainerClass
|
|
108
|
+
/** The whole chart's height ({@link chartHeightOf}). */
|
|
109
|
+
height: number
|
|
105
110
|
}): StoredChartSpec['scales'][string] => ({
|
|
106
111
|
scale: opts.scale,
|
|
107
|
-
...xGuide({
|
|
112
|
+
...xGuide({
|
|
113
|
+
model: opts.model,
|
|
114
|
+
xTicks: undefined,
|
|
115
|
+
format: opts.format,
|
|
116
|
+
cls: opts.cls,
|
|
117
|
+
height: opts.height,
|
|
118
|
+
}),
|
|
108
119
|
})
|
|
109
120
|
|
|
121
|
+
/** The whole chart's height from one pane's: a pane gets `share` of the height less the gap. */
|
|
122
|
+
export const chartHeightOf = (paneHeight: number, share: number, gap: number): number =>
|
|
123
|
+
paneHeight > 0 && share > 0 ? paneHeight / share + gap : 0
|
|
124
|
+
|
|
110
125
|
/** One row per datum for a series: a `null` or non-finite value is kept as a gap. */
|
|
111
126
|
export function rowsOf<T, X extends ChartX>(
|
|
112
127
|
data: readonly T[],
|
package/src/index.ts
CHANGED
|
@@ -12,7 +12,7 @@ export {
|
|
|
12
12
|
type BasaltRouter,
|
|
13
13
|
type BasaltRouterContext,
|
|
14
14
|
} from './provider/create-router'
|
|
15
|
-
export type { BasaltEvent } from './telemetry'
|
|
15
|
+
export type { BasaltEvent, ChatTurnEnd, ChatTurnTrace, Telemetry, TraceHeaders } from './telemetry'
|
|
16
16
|
export {
|
|
17
17
|
defineNav,
|
|
18
18
|
navGroup,
|
package/src/provider/index.tsx
CHANGED
|
@@ -36,7 +36,13 @@ import { useIsomorphicLayoutEffect } from '../common/isomorphic-layout-effect'
|
|
|
36
36
|
import { PageTitle } from '../shell/page-title'
|
|
37
37
|
import { SizeClassHintContext } from '../shell/use-size-class'
|
|
38
38
|
import type { SizeClass } from '../shell/use-size-class'
|
|
39
|
-
import {
|
|
39
|
+
import {
|
|
40
|
+
TelemetryContext,
|
|
41
|
+
bindRouterTelemetry,
|
|
42
|
+
createLiveEmitter,
|
|
43
|
+
errorName,
|
|
44
|
+
now,
|
|
45
|
+
} from '../telemetry'
|
|
40
46
|
import type { Telemetry } from '../telemetry'
|
|
41
47
|
import type { BasaltDevDockTool } from '../dev-dock'
|
|
42
48
|
import type { BasaltRouter, BasaltRouterContext } from './create-router'
|
|
@@ -426,7 +432,7 @@ export function BasaltProvider({
|
|
|
426
432
|
useEffect(() => {
|
|
427
433
|
sinkRef.current = telemetry
|
|
428
434
|
})
|
|
429
|
-
const emit = useMemo(() =>
|
|
435
|
+
const emit = useMemo(() => createLiveEmitter(() => sinkRef.current), [])
|
|
430
436
|
useWindowErrors(emit)
|
|
431
437
|
useRegistryHotkeys()
|
|
432
438
|
// `overlays.confirm` is an imperative call; this tells it a `ModalsProvider` is live.
|
package/src/state/fields.ts
CHANGED
|
@@ -279,22 +279,14 @@ type NumberHandleExtras<F> = F extends { kind: 'number' }
|
|
|
279
279
|
readonly step?: undefined
|
|
280
280
|
}
|
|
281
281
|
|
|
282
|
-
/**
|
|
283
|
-
* `createLocalStore`'s `use()` read options. `fallback` is a CALL-SITE default for a value that
|
|
284
|
-
* arrives as a prop (a leader, a latest date): it applies only while the field is unset (nothing
|
|
285
|
-
* stored), is applied in render AFTER the store read, and is never persisted — `clear()` unsets
|
|
286
|
-
* back to it. `handle.fallback` and `isDefault` still report the definition's.
|
|
287
|
-
*
|
|
288
|
-
* An object fallback (range, multi) must be memoised by the caller: the value returned while unset
|
|
289
|
-
* IS that object, so a fresh literal per render changes identity every render.
|
|
290
|
-
*/
|
|
291
|
-
export type FieldReadOptions<F extends AnyField> = { fallback?: FieldValue<F> }
|
|
292
|
-
|
|
293
|
-
/** A `createLocalStore` field: the shared handle, plus `use({ fallback })`. */
|
|
282
|
+
/** A `createLocalStore` field: the shared handle plus `withFallback`. */
|
|
294
283
|
export type LocalFieldHandle<F extends AnyField> = FieldHandle<F> & {
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
284
|
+
/**
|
|
285
|
+
* The same field with a call-site unset value (a prop-derived leader), for `use()` and any
|
|
286
|
+
* control as `field=`. Rule: `basalt-state`. Stable per fallback: a primitive is cached for the
|
|
287
|
+
* LAST value only, an object (range, multi) by identity — memoise objects at the caller.
|
|
288
|
+
*/
|
|
289
|
+
withFallback(fallback: FieldValue<F>): FieldHandle<F>
|
|
298
290
|
}
|
|
299
291
|
|
|
300
292
|
/**
|
|
@@ -789,7 +781,7 @@ type StoreRecord = Record<string, unknown>
|
|
|
789
781
|
export type FieldWrite = (next: unknown, opts?: FieldSetOptions) => void
|
|
790
782
|
|
|
791
783
|
/** One field's `use()`, untyped. @internal — see `FieldCodec`. */
|
|
792
|
-
export type FieldUse = (read?:
|
|
784
|
+
export type FieldUse = (read?: { fallback?: unknown }) => readonly [unknown, FieldWrite]
|
|
793
785
|
|
|
794
786
|
/** One named field plus its codec, in declaration order. @internal — see `FieldCodec`. */
|
|
795
787
|
export type StoreEntry = { readonly name: string; readonly codec: FieldCodec }
|
|
@@ -1165,9 +1157,39 @@ export function createLocalStore<const S extends Record<string, AnyField>>(o: {
|
|
|
1165
1157
|
const persisted = entry.codec.lane.persist
|
|
1166
1158
|
const use = persisted ? persistedUse(entry) : core.memoryUse(entry)
|
|
1167
1159
|
const clear = persisted ? () => core.clearField(entry.name) : () => core.clearMemory(entry.name)
|
|
1168
|
-
field
|
|
1169
|
-
|
|
1170
|
-
|
|
1160
|
+
// Two handles over one field: the public one carries `withFallback`, a derived one is built
|
|
1161
|
+
// on the bare one, so `derived.withFallback` is undefined at runtime as well as in the type.
|
|
1162
|
+
const bare = core.handle(entry, use, clear)
|
|
1163
|
+
// Object.create, never a spread: `options` and `fallback` are live getters, and a spread would
|
|
1164
|
+
// freeze them at derivation. Only what the call-site fallback changes is shadowed.
|
|
1165
|
+
const derive = (fallback: FieldValue<AnyField>): FieldHandle<AnyField> => {
|
|
1166
|
+
const useDerived = () => use({ fallback })
|
|
1167
|
+
return Object.create(bare, {
|
|
1168
|
+
use: { value: useDerived, enumerable: true },
|
|
1169
|
+
fallback: { get: () => fallback, enumerable: true },
|
|
1170
|
+
isDefault: { value: (v: unknown) => entry.codec.equals(v, fallback), enumerable: true },
|
|
1171
|
+
}) as FieldHandle<AnyField>
|
|
1172
|
+
}
|
|
1173
|
+
// Objects by identity (weak); a primitive only for the last value, so a high-cardinality
|
|
1174
|
+
// fallback (a latest date, an id) cannot grow the cache. A function is never cached.
|
|
1175
|
+
const byIdentity = new WeakMap<object, FieldHandle<AnyField>>()
|
|
1176
|
+
let last: { value: FieldValue<AnyField>; handle: FieldHandle<AnyField> } | undefined
|
|
1177
|
+
const withFallback = (fallback: FieldValue<AnyField>): FieldHandle<AnyField> => {
|
|
1178
|
+
if (typeof fallback === 'function') return derive(fallback)
|
|
1179
|
+
if (typeof fallback === 'object') {
|
|
1180
|
+
const cached = byIdentity.get(fallback)
|
|
1181
|
+
if (cached !== undefined) return cached
|
|
1182
|
+
const handle = derive(fallback)
|
|
1183
|
+
byIdentity.set(fallback, handle)
|
|
1184
|
+
return handle
|
|
1185
|
+
}
|
|
1186
|
+
if (last !== undefined && Object.is(last.value, fallback)) return last.handle
|
|
1187
|
+
last = { value: fallback, handle: derive(fallback) }
|
|
1188
|
+
return last.handle
|
|
1189
|
+
}
|
|
1190
|
+
field[entry.name as keyof S] = Object.assign(core.handle(entry, use, clear), {
|
|
1191
|
+
withFallback,
|
|
1192
|
+
}) as unknown as LocalFieldHandle<S[keyof S]>
|
|
1171
1193
|
}
|
|
1172
1194
|
|
|
1173
1195
|
const store: LocalStore<S> = {
|