basalt-ui 2.4.0 → 2.6.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 +29 -0
- package/MIGRATING.md +51 -12
- package/agent/rules/basalt-batteries.md +1 -1
- package/agent/rules/basalt-charts.md +1 -1
- package/agent/rules/basalt-mantine.md +1 -1
- package/agent/rules/basalt-state.md +2 -2
- package/agent/skills/basalt-app/SKILL.md +5 -5
- package/configs/oxlint-plugin.js +6 -3
- package/configs/oxlint-plugin.test.ts +8 -0
- 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 +52 -15
- package/dist/state/fields.d.ts.map +1 -1
- package/dist/state/fields.js +49 -3
- package/dist/state.d.ts +2 -1
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +2 -0
- 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 +112 -21
- package/src/state.ts +2 -0
- package/src/telemetry.ts +70 -7
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
|
@@ -217,6 +217,14 @@ export type FieldSetOptions = {
|
|
|
217
217
|
/** One option of an enum / multi / range field, labelled through `store.labels()`. */
|
|
218
218
|
export type FieldOption = { value: string; label: string }
|
|
219
219
|
|
|
220
|
+
/** The one label law: a declared label, else the value itself. */
|
|
221
|
+
function labelledOptions(
|
|
222
|
+
values: readonly string[],
|
|
223
|
+
labels: Readonly<Record<string, string | undefined>> | undefined,
|
|
224
|
+
): readonly FieldOption[] {
|
|
225
|
+
return values.map((value) => ({ value, label: labels?.[value] ?? value }))
|
|
226
|
+
}
|
|
227
|
+
|
|
220
228
|
/** `toWindow` exists on a range handle and nowhere else — it is not an optional convenience. */
|
|
221
229
|
type RangeHandleExtras<F> = F extends {
|
|
222
230
|
kind: 'range'
|
|
@@ -279,22 +287,14 @@ type NumberHandleExtras<F> = F extends { kind: 'number' }
|
|
|
279
287
|
readonly step?: undefined
|
|
280
288
|
}
|
|
281
289
|
|
|
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 })`. */
|
|
290
|
+
/** A `createLocalStore` field: the shared handle plus `withFallback`. */
|
|
294
291
|
export type LocalFieldHandle<F extends AnyField> = FieldHandle<F> & {
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
292
|
+
/**
|
|
293
|
+
* The same field with a call-site unset value (a prop-derived leader), for `use()` and any
|
|
294
|
+
* control as `field=`. Rule: `basalt-state`. Stable per fallback: a primitive is cached for the
|
|
295
|
+
* LAST value only, an object (range, multi) by identity — memoise objects at the caller.
|
|
296
|
+
*/
|
|
297
|
+
withFallback(fallback: FieldValue<F>): FieldHandle<F>
|
|
298
298
|
}
|
|
299
299
|
|
|
300
300
|
/**
|
|
@@ -789,7 +789,7 @@ type StoreRecord = Record<string, unknown>
|
|
|
789
789
|
export type FieldWrite = (next: unknown, opts?: FieldSetOptions) => void
|
|
790
790
|
|
|
791
791
|
/** One field's `use()`, untyped. @internal — see `FieldCodec`. */
|
|
792
|
-
export type FieldUse = (read?:
|
|
792
|
+
export type FieldUse = (read?: { fallback?: unknown }) => readonly [unknown, FieldWrite]
|
|
793
793
|
|
|
794
794
|
/** One named field plus its codec, in declaration order. @internal — see `FieldCodec`. */
|
|
795
795
|
export type StoreEntry = { readonly name: string; readonly codec: FieldCodec }
|
|
@@ -986,8 +986,7 @@ export function createStoreCore(o: StoreCoreOptions): StoreCore {
|
|
|
986
986
|
const readRecord = (): StoreRecord => toRecord(readPersistedValue(o.key, version))
|
|
987
987
|
|
|
988
988
|
const optionsFor = (entry: StoreEntry): readonly FieldOption[] => {
|
|
989
|
-
|
|
990
|
-
return entry.codec.optionValues.map((value) => ({ value, label: map?.[value] ?? value }))
|
|
989
|
+
return labelledOptions(entry.codec.optionValues, labels.get(entry.name))
|
|
991
990
|
}
|
|
992
991
|
|
|
993
992
|
const clearField = (name: string): void => {
|
|
@@ -1165,9 +1164,39 @@ export function createLocalStore<const S extends Record<string, AnyField>>(o: {
|
|
|
1165
1164
|
const persisted = entry.codec.lane.persist
|
|
1166
1165
|
const use = persisted ? persistedUse(entry) : core.memoryUse(entry)
|
|
1167
1166
|
const clear = persisted ? () => core.clearField(entry.name) : () => core.clearMemory(entry.name)
|
|
1168
|
-
field
|
|
1169
|
-
|
|
1170
|
-
|
|
1167
|
+
// Two handles over one field: the public one carries `withFallback`, a derived one is built
|
|
1168
|
+
// on the bare one, so `derived.withFallback` is undefined at runtime as well as in the type.
|
|
1169
|
+
const bare = core.handle(entry, use, clear)
|
|
1170
|
+
// Object.create, never a spread: `options` and `fallback` are live getters, and a spread would
|
|
1171
|
+
// freeze them at derivation. Only what the call-site fallback changes is shadowed.
|
|
1172
|
+
const derive = (fallback: FieldValue<AnyField>): FieldHandle<AnyField> => {
|
|
1173
|
+
const useDerived = () => use({ fallback })
|
|
1174
|
+
return Object.create(bare, {
|
|
1175
|
+
use: { value: useDerived, enumerable: true },
|
|
1176
|
+
fallback: { get: () => fallback, enumerable: true },
|
|
1177
|
+
isDefault: { value: (v: unknown) => entry.codec.equals(v, fallback), enumerable: true },
|
|
1178
|
+
}) as FieldHandle<AnyField>
|
|
1179
|
+
}
|
|
1180
|
+
// Objects by identity (weak); a primitive only for the last value, so a high-cardinality
|
|
1181
|
+
// fallback (a latest date, an id) cannot grow the cache. A function is never cached.
|
|
1182
|
+
const byIdentity = new WeakMap<object, FieldHandle<AnyField>>()
|
|
1183
|
+
let last: { value: FieldValue<AnyField>; handle: FieldHandle<AnyField> } | undefined
|
|
1184
|
+
const withFallback = (fallback: FieldValue<AnyField>): FieldHandle<AnyField> => {
|
|
1185
|
+
if (typeof fallback === 'function') return derive(fallback)
|
|
1186
|
+
if (typeof fallback === 'object') {
|
|
1187
|
+
const cached = byIdentity.get(fallback)
|
|
1188
|
+
if (cached !== undefined) return cached
|
|
1189
|
+
const handle = derive(fallback)
|
|
1190
|
+
byIdentity.set(fallback, handle)
|
|
1191
|
+
return handle
|
|
1192
|
+
}
|
|
1193
|
+
if (last !== undefined && Object.is(last.value, fallback)) return last.handle
|
|
1194
|
+
last = { value: fallback, handle: derive(fallback) }
|
|
1195
|
+
return last.handle
|
|
1196
|
+
}
|
|
1197
|
+
field[entry.name as keyof S] = Object.assign(core.handle(entry, use, clear), {
|
|
1198
|
+
withFallback,
|
|
1199
|
+
}) as unknown as LocalFieldHandle<S[keyof S]>
|
|
1171
1200
|
}
|
|
1172
1201
|
|
|
1173
1202
|
const store: LocalStore<S> = {
|
|
@@ -1181,3 +1210,65 @@ export function createLocalStore<const S extends Record<string, AnyField>>(o: {
|
|
|
1181
1210
|
|
|
1182
1211
|
return store
|
|
1183
1212
|
}
|
|
1213
|
+
|
|
1214
|
+
// ── externalField — a handle over a value another store owns ──────────────────────────────────
|
|
1215
|
+
|
|
1216
|
+
/**
|
|
1217
|
+
* The non-hook surface of a store basalt does not own — what zustand's `store.getState` /
|
|
1218
|
+
* `store.setState` / `store.subscribe` already are, and what `useSyncExternalStore` wants.
|
|
1219
|
+
*/
|
|
1220
|
+
export type ExternalStore<T> = {
|
|
1221
|
+
get: () => T
|
|
1222
|
+
set: (next: T) => void
|
|
1223
|
+
subscribe: (onChange: () => void) => () => void
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
/**
|
|
1227
|
+
* A closed-enum field whose value lives in a store basalt does not own (a zustand slice, a context)
|
|
1228
|
+
* — the bridge for a runtime mode that is neither page view state (URL) nor a preference
|
|
1229
|
+
* (localStorage), so no `createSearchStore` / `createLocalStore` field may hold it without forking
|
|
1230
|
+
* the truth. Returns the SAME `FieldHandle` those stores hand out, so `ViewTabs`, `SelectFilter`
|
|
1231
|
+
* and `EnumFilter` bind to it as `field=` with no `value`/`onChange`.
|
|
1232
|
+
*
|
|
1233
|
+
* There is no lane: the handle reads and writes ONLY through `store`, which is taken at
|
|
1234
|
+
* DEFINITION so a write never depends on a render — `clear()` calls `store.set(fallback)` directly
|
|
1235
|
+
* (a foreign store has no "unset"), mounted or not, however many controls bind the handle. Labels
|
|
1236
|
+
* are declared here like every other field's. The setter ignores `FieldSetOptions`: `patch` merges
|
|
1237
|
+
* into a URL navigate and a lane-less write has none, the same contract the local lane documents.
|
|
1238
|
+
*
|
|
1239
|
+
* @example
|
|
1240
|
+
* const timerMode = externalField({
|
|
1241
|
+
* values: ['rest', 'interval'],
|
|
1242
|
+
* fallback: 'rest',
|
|
1243
|
+
* labels: { rest: 'Rest Timer', interval: 'Interval Timer' },
|
|
1244
|
+
* store: {
|
|
1245
|
+
* get: () => useTimerStore.getState().mode,
|
|
1246
|
+
* set: (mode) => useTimerStore.getState().setMode(mode),
|
|
1247
|
+
* subscribe: useTimerStore.subscribe,
|
|
1248
|
+
* },
|
|
1249
|
+
* })
|
|
1250
|
+
* // <WidgetHeader actions={<ViewTabs field={timerMode} label="Timer mode" />} />
|
|
1251
|
+
*/
|
|
1252
|
+
export function externalField<const T extends string>(o: {
|
|
1253
|
+
values: readonly T[]
|
|
1254
|
+
fallback: NoInfer<T>
|
|
1255
|
+
/** Option labels, keyed by value; an unlabelled value shows as itself. */
|
|
1256
|
+
labels?: Partial<Record<T, string>>
|
|
1257
|
+
store: ExternalStore<T>
|
|
1258
|
+
}): FieldHandle<EnumField<T>> {
|
|
1259
|
+
const { values, fallback, store } = o
|
|
1260
|
+
if (!values.includes(fallback)) {
|
|
1261
|
+
throw new Error(
|
|
1262
|
+
`basalt-ui: externalField fallback '${fallback}' is not one of its values (${values.join(', ')}).`,
|
|
1263
|
+
)
|
|
1264
|
+
}
|
|
1265
|
+
const setter = (next: T): void => store.set(next)
|
|
1266
|
+
return {
|
|
1267
|
+
kind: 'enum',
|
|
1268
|
+
fallback,
|
|
1269
|
+
options: labelledOptions(values, o.labels),
|
|
1270
|
+
use: () => [useSyncExternalStore(store.subscribe, store.get, store.get), setter] as const,
|
|
1271
|
+
clear: () => store.set(fallback),
|
|
1272
|
+
isDefault: (v) => v === fallback,
|
|
1273
|
+
}
|
|
1274
|
+
}
|
package/src/state.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* - `createPersistedState` — the versioned localStorage primitive every basalt module persists through.
|
|
6
6
|
* - `field.*`, `FieldHandle`, `EnumField`, `RangeWindow` and `createLocalStore` — the store field
|
|
7
7
|
* vocabulary and the router-free store.
|
|
8
|
+
* - `externalField` — a `FieldHandle` over a value another store owns (a zustand slice).
|
|
8
9
|
* - `createSearchStore` — the URL-backed store (the store half of 1.x `./router-tanstack`).
|
|
9
10
|
*
|
|
10
11
|
* Internal consumers import the wider field types from `./state/fields`.
|
|
@@ -14,6 +15,7 @@ export { createPersistedState } from './state/persisted'
|
|
|
14
15
|
|
|
15
16
|
export {
|
|
16
17
|
field,
|
|
18
|
+
externalField,
|
|
17
19
|
createLocalStore,
|
|
18
20
|
type EnumField,
|
|
19
21
|
type FieldHandle,
|
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 {}
|