basalt-ui 2.3.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 +34 -0
- package/MIGRATING.md +79 -12
- package/agent/rules/basalt-state.md +1 -1
- package/configs/lefthook.yml +4 -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/CartesianChart.d.ts.map +1 -1
- package/dist/charts/CartesianChart.js +6 -2
- package/dist/charts/DualPanel.d.ts +5 -1
- package/dist/charts/DualPanel.d.ts.map +1 -1
- package/dist/charts/DualPanel.js +46 -19
- package/dist/charts/Heatmap.js +2 -2
- package/dist/charts/Lines.js +2 -2
- package/dist/charts/MirroredBars.d.ts.map +1 -1
- package/dist/charts/MirroredBars.js +14 -11
- package/dist/charts/compile.d.ts +19 -2
- package/dist/charts/compile.d.ts.map +1 -1
- package/dist/charts/compile.js +192 -42
- package/dist/charts/marks.d.ts +8 -0
- package/dist/charts/marks.d.ts.map +1 -1
- package/dist/charts/marks.js +22 -1
- package/dist/charts/panel.d.ts +6 -0
- package/dist/charts/panel.d.ts.map +1 -1
- package/dist/charts/panel.js +12 -3
- package/dist/charts/types.d.ts +39 -1
- package/dist/charts/types.d.ts.map +1 -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 +14 -3
- package/dist/state/fields.d.ts.map +1 -1
- package/dist/state/fields.js +41 -9
- 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/CartesianChart.tsx +7 -2
- package/src/charts/DualPanel.tsx +50 -19
- package/src/charts/Heatmap.tsx +2 -2
- package/src/charts/Lines.tsx +2 -2
- package/src/charts/MirroredBars.tsx +13 -10
- package/src/charts/compile.ts +261 -46
- package/src/charts/marks.ts +35 -1
- package/src/charts/panel.tsx +18 -3
- package/src/charts/types.ts +37 -1
- package/src/index.ts +1 -1
- package/src/provider/index.tsx +8 -2
- package/src/state/fields.ts +79 -21
- package/src/telemetry.ts +70 -7
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,6 +279,16 @@ type NumberHandleExtras<F> = F extends { kind: 'number' }
|
|
|
279
279
|
readonly step?: undefined
|
|
280
280
|
}
|
|
281
281
|
|
|
282
|
+
/** A `createLocalStore` field: the shared handle plus `withFallback`. */
|
|
283
|
+
export type LocalFieldHandle<F extends AnyField> = FieldHandle<F> & {
|
|
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>
|
|
290
|
+
}
|
|
291
|
+
|
|
282
292
|
/**
|
|
283
293
|
* The binding every basalt control takes instead of `value`/`onChange` (C2). One field, both lanes,
|
|
284
294
|
* one resolution law. Handed out by `createSearchStore().field.<name>` /
|
|
@@ -771,7 +781,7 @@ type StoreRecord = Record<string, unknown>
|
|
|
771
781
|
export type FieldWrite = (next: unknown, opts?: FieldSetOptions) => void
|
|
772
782
|
|
|
773
783
|
/** One field's `use()`, untyped. @internal — see `FieldCodec`. */
|
|
774
|
-
export type FieldUse = () => readonly [unknown, FieldWrite]
|
|
784
|
+
export type FieldUse = (read?: { fallback?: unknown }) => readonly [unknown, FieldWrite]
|
|
775
785
|
|
|
776
786
|
/** One named field plus its codec, in declaration order. @internal — see `FieldCodec`. */
|
|
777
787
|
export type StoreEntry = { readonly name: string; readonly codec: FieldCodec }
|
|
@@ -798,7 +808,12 @@ export type StoreCore = {
|
|
|
798
808
|
setLabels(map: Record<string, Record<string, string> | undefined>): void
|
|
799
809
|
optionsFor(entry: StoreEntry): readonly FieldOption[]
|
|
800
810
|
/** The resolution law, once: URL ⊳ localStorage ⊳ fallback. `search: null` skips the URL lane. */
|
|
801
|
-
resolve(
|
|
811
|
+
resolve(
|
|
812
|
+
entry: StoreEntry,
|
|
813
|
+
search: StoreRecord | null,
|
|
814
|
+
record: StoreRecord,
|
|
815
|
+
fallback?: unknown,
|
|
816
|
+
): unknown
|
|
802
817
|
writeField(setRecord: (next: StoreRecord) => void, name: string, value: unknown): void
|
|
803
818
|
/**
|
|
804
819
|
* The IN-MEMORY lane's `use()` for one field — a field with neither the URL nor the mirror.
|
|
@@ -841,14 +856,19 @@ function assignDefined(into: StoreRecord, from: StoreRecord): void {
|
|
|
841
856
|
* The resolution law, in one place: URL ⊳ localStorage ⊳ fallback (C4). `search: null` skips the
|
|
842
857
|
* URL lane — what the local lane and `linkSearch` pass.
|
|
843
858
|
*/
|
|
844
|
-
function resolveField(
|
|
859
|
+
function resolveField(
|
|
860
|
+
entry: StoreEntry,
|
|
861
|
+
search: StoreRecord | null,
|
|
862
|
+
record: StoreRecord,
|
|
863
|
+
fallback?: unknown,
|
|
864
|
+
): unknown {
|
|
845
865
|
const fromUrl = search === null ? null : entry.codec.fromSearch(search)
|
|
846
866
|
if (fromUrl !== null) return fromUrl
|
|
847
867
|
if (entry.codec.lane.persist) {
|
|
848
868
|
const stored = entry.codec.decode(record[entry.name])
|
|
849
869
|
if (stored !== null) return stored
|
|
850
870
|
}
|
|
851
|
-
return entry.codec.fallback
|
|
871
|
+
return fallback === undefined ? entry.codec.fallback : fallback
|
|
852
872
|
}
|
|
853
873
|
|
|
854
874
|
/**
|
|
@@ -865,7 +885,7 @@ function resolveField(entry: StoreEntry, search: StoreRecord | null, record: Sto
|
|
|
865
885
|
function createMemoryLane(): {
|
|
866
886
|
subscribe: (cb: () => void) => () => void
|
|
867
887
|
getVersion: () => number
|
|
868
|
-
resolve: (entry: StoreEntry) => unknown
|
|
888
|
+
resolve: (entry: StoreEntry, fallback?: unknown) => unknown
|
|
869
889
|
write: (name: string, value: unknown) => void
|
|
870
890
|
remove: (name: string) => void
|
|
871
891
|
clear: () => void
|
|
@@ -889,9 +909,10 @@ function createMemoryLane(): {
|
|
|
889
909
|
getVersion: () => version,
|
|
890
910
|
// `undefined` is the only "never written" marker the lane needs: no field kind has `undefined`
|
|
891
911
|
// as a legal value, so it can never collide with a written one.
|
|
892
|
-
resolve: (entry) => {
|
|
912
|
+
resolve: (entry, fallback) => {
|
|
893
913
|
const stored = values.get(entry.name)
|
|
894
|
-
|
|
914
|
+
if (stored !== undefined) return stored
|
|
915
|
+
return fallback === undefined ? entry.codec.fallback : fallback
|
|
895
916
|
},
|
|
896
917
|
write: (name, value) => {
|
|
897
918
|
values.set(name, value)
|
|
@@ -1036,11 +1057,14 @@ export function createStoreCore(o: StoreCoreOptions): StoreCore {
|
|
|
1036
1057
|
},
|
|
1037
1058
|
readRecord,
|
|
1038
1059
|
memoryUse(entry) {
|
|
1039
|
-
return () => {
|
|
1060
|
+
return (read) => {
|
|
1040
1061
|
// Subscribing to the VERSION (never to the value) is what re-renders every other mount of
|
|
1041
1062
|
// this field when one of them writes, with no snapshot-stability trap.
|
|
1042
1063
|
useSyncExternalStore(memory.subscribe, memory.getVersion, memory.getVersion)
|
|
1043
|
-
return [
|
|
1064
|
+
return [
|
|
1065
|
+
memory.resolve(entry, read?.fallback),
|
|
1066
|
+
(next: unknown) => memory.write(entry.name, next),
|
|
1067
|
+
] as const
|
|
1044
1068
|
}
|
|
1045
1069
|
},
|
|
1046
1070
|
readMemoryValue: (entry) => memory.resolve(entry),
|
|
@@ -1074,7 +1098,7 @@ export function createStoreCore(o: StoreCoreOptions): StoreCore {
|
|
|
1074
1098
|
|
|
1075
1099
|
/** `Pick<SearchStore<S>, 'field' | 'readStored' | 'labels'>` — the same handles, no router. */
|
|
1076
1100
|
export type LocalStore<S extends Record<string, AnyField>> = {
|
|
1077
|
-
readonly field: { [K in keyof S]:
|
|
1101
|
+
readonly field: { [K in keyof S]: LocalFieldHandle<S[K]> }
|
|
1078
1102
|
readStored: () => Partial<StoredValues<S>>
|
|
1079
1103
|
/**
|
|
1080
1104
|
* Option labels for enum / multi / range fields — the same chainable contract
|
|
@@ -1116,22 +1140,56 @@ export function createLocalStore<const S extends Record<string, AnyField>>(o: {
|
|
|
1116
1140
|
}): LocalStore<S> {
|
|
1117
1141
|
const core = createStoreCore({ key: o.key, fields: o.fields, version: o.version })
|
|
1118
1142
|
|
|
1119
|
-
const persistedUse =
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
core.
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1143
|
+
const persistedUse =
|
|
1144
|
+
(entry: StoreEntry): FieldUse =>
|
|
1145
|
+
(read) => {
|
|
1146
|
+
const [record, setRecord] = core.usePersistedRecord()
|
|
1147
|
+
return [
|
|
1148
|
+
// The call-site fallback is applied here, in render, after the store read — never inside
|
|
1149
|
+
// the snapshot — so React's out-of-render snapshot reads cannot disagree with it.
|
|
1150
|
+
core.resolve(entry, null, record, read?.fallback),
|
|
1151
|
+
(next: unknown) => core.writeField(setRecord, entry.name, next),
|
|
1152
|
+
] as const
|
|
1153
|
+
}
|
|
1126
1154
|
|
|
1127
|
-
const field = {} as { [K in keyof S]:
|
|
1155
|
+
const field = {} as { [K in keyof S]: LocalFieldHandle<S[K]> }
|
|
1128
1156
|
for (const entry of core.entries) {
|
|
1129
1157
|
const persisted = entry.codec.lane.persist
|
|
1130
1158
|
const use = persisted ? persistedUse(entry) : core.memoryUse(entry)
|
|
1131
1159
|
const clear = persisted ? () => core.clearField(entry.name) : () => core.clearMemory(entry.name)
|
|
1132
|
-
field
|
|
1133
|
-
|
|
1134
|
-
|
|
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]>
|
|
1135
1193
|
}
|
|
1136
1194
|
|
|
1137
1195
|
const store: LocalStore<S> = {
|
package/src/telemetry.ts
CHANGED
|
@@ -65,7 +65,61 @@ export type BasaltEvent =
|
|
|
65
65
|
| Ok<'chat.turn', ChatAttrs>
|
|
66
66
|
| Err<'chat.turn', ChatAttrs, Span>
|
|
67
67
|
|
|
68
|
-
|
|
68
|
+
/** W3C trace-context request headers a sink contributes to one chat turn's request. */
|
|
69
|
+
export type TraceHeaders = {
|
|
70
|
+
readonly traceparent: string
|
|
71
|
+
readonly tracestate?: string | undefined
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* How a traced chat turn ended: its `chat.turn` event fired (`settled`/`failed`), or `stopped` — a
|
|
76
|
+
* `stop()`, an unmount or a newer run taking the thread over — which emits no event. The one
|
|
77
|
+
* lifecycle vocabulary of the run records too.
|
|
78
|
+
*/
|
|
79
|
+
export type ChatTurnEnd = 'settled' | 'failed' | 'stopped'
|
|
80
|
+
|
|
81
|
+
/** The turn a sink is asked to trace. */
|
|
82
|
+
type ChatTurnInit = { readonly threadId: string; readonly messageId: string }
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* What a sink returns from `startChatTurn`. `settle` runs once, synchronously, right after the
|
|
86
|
+
* turn's `chat.turn` event (`settled`/`failed`), or when the turn is cut short (`stopped`) — the
|
|
87
|
+
* point to end a span opened in `startChatTurn`. It runs even when `headers` is absent or
|
|
88
|
+
* malformed (dropped, the request goes out untraced), so the span never leaks.
|
|
89
|
+
*/
|
|
90
|
+
export type ChatTurnTrace = {
|
|
91
|
+
readonly headers?: TraceHeaders | undefined
|
|
92
|
+
readonly settle?: ((end: ChatTurnEnd) => void) | undefined
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The event sink. It may carry one optional hook, `startChatTurn`, called when a new chat turn
|
|
97
|
+
* begins (not on a resume): open your span there and return its `traceparent` (and `tracestate`)
|
|
98
|
+
* and `aiSdkTransport` sends them with that turn's request to the chat endpoint — never anywhere
|
|
99
|
+
* else. A sink without the hook, or no sink, changes nothing on the wire. basalt has no OTel
|
|
100
|
+
* dependency; the hook is where the consumer's SDK does the work.
|
|
101
|
+
*
|
|
102
|
+
* @example
|
|
103
|
+
* export const telemetry: Telemetry = Object.assign(onEvent, {
|
|
104
|
+
* startChatTurn({ threadId }) {
|
|
105
|
+
* const span = tracer.startSpan('chat.turn', {
|
|
106
|
+
* attributes: { 'basalt.chat.thread_id': threadId },
|
|
107
|
+
* })
|
|
108
|
+
* const carrier: Record<string, string> = {}
|
|
109
|
+
* propagation.inject(trace.setSpan(context.active(), span), carrier)
|
|
110
|
+
* return {
|
|
111
|
+
* headers: { traceparent: carrier.traceparent ?? '', tracestate: carrier.tracestate },
|
|
112
|
+
* settle(end) {
|
|
113
|
+
* if (end === 'failed') span.setStatus({ code: SpanStatusCode.ERROR })
|
|
114
|
+
* span.end()
|
|
115
|
+
* },
|
|
116
|
+
* }
|
|
117
|
+
* },
|
|
118
|
+
* })
|
|
119
|
+
*/
|
|
120
|
+
export type Telemetry = ((event: BasaltEvent) => void) & {
|
|
121
|
+
readonly startChatTurn?: ((turn: ChatTurnInit) => ChatTurnTrace | undefined) | undefined
|
|
122
|
+
}
|
|
69
123
|
|
|
70
124
|
export const now = (): number => performance.timeOrigin + performance.now()
|
|
71
125
|
|
|
@@ -73,18 +127,27 @@ export const now = (): number => performance.timeOrigin + performance.now()
|
|
|
73
127
|
export const errorName = (error: unknown): string => (error instanceof Error ? error.name : 'Error')
|
|
74
128
|
|
|
75
129
|
/**
|
|
76
|
-
*
|
|
77
|
-
* events.
|
|
130
|
+
* The wrapped emitter over a sink read at call time (a ref), so a swapped sink is never stale. It is
|
|
131
|
+
* called synchronously and a throwing sink never breaks a render; no sink, no events. Its
|
|
132
|
+
* `startChatTurn` forwards the sink's hook; the hook's consumer validates the headers.
|
|
78
133
|
*/
|
|
79
|
-
export function
|
|
80
|
-
|
|
81
|
-
return (event) => {
|
|
134
|
+
export function createLiveEmitter(read: () => Telemetry | undefined): Telemetry {
|
|
135
|
+
const emit = (event: BasaltEvent): void => {
|
|
82
136
|
try {
|
|
83
|
-
|
|
137
|
+
read()?.(event)
|
|
84
138
|
} catch {
|
|
85
139
|
// a throwing sink must never break the app (§ 7 delivery rules)
|
|
86
140
|
}
|
|
87
141
|
}
|
|
142
|
+
return Object.assign(emit, {
|
|
143
|
+
startChatTurn(turn: ChatTurnInit): ChatTurnTrace | undefined {
|
|
144
|
+
try {
|
|
145
|
+
return read()?.startChatTurn?.(turn)
|
|
146
|
+
} catch {
|
|
147
|
+
return undefined
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
})
|
|
88
151
|
}
|
|
89
152
|
|
|
90
153
|
function noop(): void {}
|