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.
Files changed (65) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/MIGRATING.md +79 -12
  3. package/agent/rules/basalt-state.md +1 -1
  4. package/configs/lefthook.yml +4 -1
  5. package/dist/agent/ai-sdk-transport.d.ts.map +1 -1
  6. package/dist/agent/ai-sdk-transport.js +4 -1
  7. package/dist/agent/index.d.ts +1 -1
  8. package/dist/agent/index.d.ts.map +1 -1
  9. package/dist/agent/run-records.d.ts +12 -3
  10. package/dist/agent/run-records.d.ts.map +1 -1
  11. package/dist/agent/run-records.js +16 -3
  12. package/dist/agent/transport.d.ts +10 -3
  13. package/dist/agent/transport.d.ts.map +1 -1
  14. package/dist/agent/use-agent-thread-runs.d.ts.map +1 -1
  15. package/dist/agent/use-agent-thread-runs.js +23 -7
  16. package/dist/charts/CartesianChart.d.ts.map +1 -1
  17. package/dist/charts/CartesianChart.js +6 -2
  18. package/dist/charts/DualPanel.d.ts +5 -1
  19. package/dist/charts/DualPanel.d.ts.map +1 -1
  20. package/dist/charts/DualPanel.js +46 -19
  21. package/dist/charts/Heatmap.js +2 -2
  22. package/dist/charts/Lines.js +2 -2
  23. package/dist/charts/MirroredBars.d.ts.map +1 -1
  24. package/dist/charts/MirroredBars.js +14 -11
  25. package/dist/charts/compile.d.ts +19 -2
  26. package/dist/charts/compile.d.ts.map +1 -1
  27. package/dist/charts/compile.js +192 -42
  28. package/dist/charts/marks.d.ts +8 -0
  29. package/dist/charts/marks.d.ts.map +1 -1
  30. package/dist/charts/marks.js +22 -1
  31. package/dist/charts/panel.d.ts +6 -0
  32. package/dist/charts/panel.d.ts.map +1 -1
  33. package/dist/charts/panel.js +12 -3
  34. package/dist/charts/types.d.ts +39 -1
  35. package/dist/charts/types.d.ts.map +1 -1
  36. package/dist/index.d.ts +1 -1
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/provider/index.d.ts.map +1 -1
  39. package/dist/provider/index.js +8 -2
  40. package/dist/state/fields.d.ts +14 -3
  41. package/dist/state/fields.d.ts.map +1 -1
  42. package/dist/state/fields.js +41 -9
  43. package/dist/telemetry.d.ts +58 -4
  44. package/dist/telemetry.d.ts.map +1 -1
  45. package/dist/telemetry.js +13 -5
  46. package/llms.txt +2 -2
  47. package/package.json +1 -1
  48. package/src/agent/ai-sdk-transport.ts +5 -2
  49. package/src/agent/index.ts +1 -1
  50. package/src/agent/run-records.ts +27 -3
  51. package/src/agent/transport.ts +11 -5
  52. package/src/agent/use-agent-thread-runs.ts +35 -8
  53. package/src/charts/CartesianChart.tsx +7 -2
  54. package/src/charts/DualPanel.tsx +50 -19
  55. package/src/charts/Heatmap.tsx +2 -2
  56. package/src/charts/Lines.tsx +2 -2
  57. package/src/charts/MirroredBars.tsx +13 -10
  58. package/src/charts/compile.ts +261 -46
  59. package/src/charts/marks.ts +35 -1
  60. package/src/charts/panel.tsx +18 -3
  61. package/src/charts/types.ts +37 -1
  62. package/src/index.ts +1 -1
  63. package/src/provider/index.tsx +8 -2
  64. package/src/state/fields.ts +79 -21
  65. package/src/telemetry.ts +70 -7
@@ -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 { TelemetryContext, bindRouterTelemetry, createEmitter, errorName, now } from '../telemetry'
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(() => createEmitter((event) => sinkRef.current?.(event)), [])
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.
@@ -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(entry: StoreEntry, search: StoreRecord | null, record: StoreRecord): unknown
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(entry: StoreEntry, search: StoreRecord | null, record: StoreRecord): unknown {
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
- return stored === undefined ? entry.codec.fallback : stored
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 [memory.resolve(entry), (next: unknown) => memory.write(entry.name, next)] as const
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]: FieldHandle<S[K]> }
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 = (entry: StoreEntry) => (): readonly [unknown, (next: unknown) => void] => {
1120
- const [record, setRecord] = core.usePersistedRecord()
1121
- return [
1122
- core.resolve(entry, null, record),
1123
- (next: unknown) => core.writeField(setRecord, entry.name, next),
1124
- ] as const
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]: FieldHandle<S[K]> }
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[entry.name as keyof S] = core.handle(entry, use, clear) as unknown as FieldHandle<
1133
- S[keyof S]
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
- export type Telemetry = (event: BasaltEvent) => void
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
- * Wrap a sink so it is called synchronously and a throwing sink never breaks a render. No sink, no
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 createEmitter(sink: Telemetry | undefined): Telemetry {
80
- if (sink === undefined) return noop
81
- return (event) => {
134
+ export function createLiveEmitter(read: () => Telemetry | undefined): Telemetry {
135
+ const emit = (event: BasaltEvent): void => {
82
136
  try {
83
- sink(event)
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 {}