jev-devtools 0.1.3 → 0.3.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 (52) hide show
  1. package/README.md +50 -4
  2. package/THIRD_PARTY_NOTICES.txt +27 -0
  3. package/lib/flow/index.d.ts +82 -0
  4. package/lib/flow/index.js +588 -0
  5. package/lib/server/flows.d.ts +25 -0
  6. package/lib/server/flows.js +115 -0
  7. package/lib/server/index.d.ts +8 -0
  8. package/lib/server/index.js +163 -9
  9. package/lib/server/recorder.d.ts +12 -2
  10. package/lib/server/recorder.js +47 -9
  11. package/lib/src/ChoiceProbabilities.d.ts +4 -0
  12. package/lib/src/ChoiceProbabilities.js +19 -0
  13. package/lib/src/ConnectedInspector.js +46 -29
  14. package/lib/src/DiagramContent.d.ts +6 -0
  15. package/lib/src/DiagramContent.js +32 -0
  16. package/lib/src/FlowOverview.d.ts +7 -0
  17. package/lib/src/FlowOverview.js +228 -0
  18. package/lib/src/JevDevtools.d.ts +6 -1
  19. package/lib/src/JevDevtools.js +52 -48
  20. package/lib/src/JevDrawer.d.ts +3 -2
  21. package/lib/src/JevDrawer.js +12 -3
  22. package/lib/src/Questions.d.ts +5 -0
  23. package/lib/src/Questions.js +174 -0
  24. package/lib/src/TraceContent.d.ts +11 -0
  25. package/lib/src/TraceContent.js +32 -0
  26. package/lib/src/TraceDiagram.d.ts +4 -1
  27. package/lib/src/TraceDiagram.js +111 -25
  28. package/lib/src/condition-inputs.d.ts +9 -0
  29. package/lib/src/condition-inputs.js +100 -0
  30. package/lib/src/flow-condition.d.ts +4 -0
  31. package/lib/src/flow-condition.js +47 -0
  32. package/lib/src/flow-model.d.ts +7 -2
  33. package/lib/src/flow-model.js +3 -12
  34. package/lib/src/flow-protocol.d.ts +68 -0
  35. package/lib/src/flow-protocol.js +1 -0
  36. package/lib/src/flow-stream.d.ts +8 -0
  37. package/lib/src/flow-stream.js +80 -0
  38. package/lib/src/index.d.ts +1 -1
  39. package/lib/src/inspector-styles.js +1 -1
  40. package/lib/src/inspector.css +725 -8
  41. package/lib/src/protocol.d.ts +18 -0
  42. package/lib/src/trace-store.d.ts +6 -0
  43. package/lib/src/trace-store.js +66 -0
  44. package/lib/src/trace-stream.d.ts +2 -0
  45. package/lib/src/trace-stream.js +65 -0
  46. package/lib/src/useDiagramViewport.d.ts +5 -0
  47. package/lib/src/useDiagramViewport.js +34 -6
  48. package/package.json +7 -2
  49. package/skills/setup-jev-devtools/SKILL.md +6 -2
  50. package/skills/setup-jev-devtools/references/flows.md +150 -0
  51. package/skills/setup-jev-devtools/references/nextjs.md +1 -1
  52. package/skills/setup-jev-devtools/references/verify.md +3 -1
@@ -0,0 +1,115 @@
1
+ import { capture } from "./recorder.js";
2
+ /** Independent bounded run store. Definitions are retained only while referenced. */
3
+ export class FlowRecorder {
4
+ options;
5
+ runs = new Map();
6
+ definitions = new Map();
7
+ sessions = new Map();
8
+ definitionRevisions = new Map();
9
+ revisions = new Map();
10
+ revision = 0;
11
+ listeners = new Set();
12
+ constructor(options) {
13
+ this.options = options;
14
+ }
15
+ subscribe(fn) {
16
+ this.listeners.add(fn);
17
+ return () => {
18
+ this.listeners.delete(fn);
19
+ };
20
+ }
21
+ notify(sessions) {
22
+ for (const fn of this.listeners) {
23
+ try {
24
+ fn(sessions);
25
+ }
26
+ catch { }
27
+ }
28
+ }
29
+ start(definition, run, session) {
30
+ if (!this.definitions.has(definition.id)) {
31
+ const stored = structuredClone(definition);
32
+ for (const node of stored.nodes) {
33
+ if (node.conditionRule === undefined)
34
+ continue;
35
+ try {
36
+ node.conditionRule = capture(node.conditionRule, new Set(), this.options.redact
37
+ ? (path, value) => this.options.redact(["conditions", node.id, ...path], value)
38
+ : undefined);
39
+ }
40
+ catch {
41
+ node.conditionRule = "[rule capture unavailable]";
42
+ }
43
+ }
44
+ this.definitions.set(definition.id, stored);
45
+ this.definitionRevisions.set(definition.id, ++this.revision);
46
+ }
47
+ this.runs.set(run.id, run);
48
+ this.sessions.set(run.id, session);
49
+ this.revisions.set(run.id, ++this.revision);
50
+ const sessions = new Set([session]);
51
+ const limit = Math.max(1, Math.min(200, this.options.limit ?? 50));
52
+ while (this.runs.size > limit) {
53
+ const id = this.runs.keys().next().value;
54
+ sessions.add(this.sessions.get(id));
55
+ this.remove(id);
56
+ }
57
+ this.prune();
58
+ this.notify(sessions);
59
+ }
60
+ update(run) {
61
+ if (!this.runs.has(run.id))
62
+ return; // Clear/eviction cannot resurrect pending runs.
63
+ const stored = { ...run };
64
+ if ("value" in stored) {
65
+ try {
66
+ stored.value = capture(stored.value, new Set(), this.options.redact);
67
+ }
68
+ catch {
69
+ stored.value = "[capture unavailable]";
70
+ }
71
+ }
72
+ this.runs.set(run.id, stored);
73
+ this.revisions.set(run.id, ++this.revision);
74
+ this.notify(new Set([this.sessions.get(run.id)]));
75
+ }
76
+ remove(id) {
77
+ this.runs.delete(id);
78
+ this.sessions.delete(id);
79
+ this.revisions.delete(id);
80
+ }
81
+ prune() {
82
+ const used = new Set([...this.runs.values()].map((r) => r.definitionId));
83
+ for (const id of this.definitions.keys())
84
+ if (!used.has(id)) {
85
+ this.definitions.delete(id);
86
+ this.definitionRevisions.delete(id);
87
+ }
88
+ }
89
+ clear(session) {
90
+ for (const id of this.runs.keys())
91
+ if (this.sessions.get(id) === session)
92
+ this.remove(id);
93
+ this.prune();
94
+ this.revision++;
95
+ this.notify(new Set([session]));
96
+ }
97
+ changes(session, cursor) {
98
+ const current = [...this.runs.values()]
99
+ .filter((r) => this.sessions.get(r.id) === session)
100
+ .reverse();
101
+ const used = new Set(current.map((r) => r.definitionId));
102
+ return {
103
+ revision: this.revision,
104
+ data: structuredClone({
105
+ version: 1,
106
+ definitions: [...used]
107
+ .filter((id) => cursor === undefined ||
108
+ this.definitionRevisions.get(id) > cursor)
109
+ .map((id) => this.definitions.get(id)),
110
+ upserts: current.filter((r) => cursor === undefined || this.revisions.get(r.id) > cursor),
111
+ order: current.map((r) => r.id),
112
+ }),
113
+ };
114
+ }
115
+ }
@@ -19,6 +19,14 @@ export type ClientMetadata = {
19
19
  };
20
20
  /** Node runtime adapter: framework-neutral Request/Response plus Node middleware. */
21
21
  export declare function createJevDevtools(options: JevDevtoolsServerOptions): {
22
+ startFlow: (definition: import("../src/flow-protocol.js").FlowDefinition, run: import("../src/flow-protocol.js").FlowRun) => {
23
+ update(run: import("../src/flow-protocol.js").FlowRun): void;
24
+ request<T>(link: {
25
+ runId: string;
26
+ stepId: string;
27
+ executionId: string;
28
+ }, onTrace: (id: string) => void, callback: () => T): T;
29
+ } | undefined;
22
30
  handle: (request: Request) => Promise<Response>;
23
31
  /**
24
32
  * For Node framework routes whose Request URL uses an internal hostname.
@@ -1,3 +1,4 @@
1
+ import { FlowRecorder } from "./flows.js";
1
2
  import { AsyncLocalStorage } from "node:async_hooks";
2
3
  import { Readable } from "node:stream";
3
4
  import { pipeline } from "node:stream/promises";
@@ -17,7 +18,59 @@ export function createJevDevtools(options) {
17
18
  if (options.enabled && !local && !options.authorize)
18
19
  throw new Error("Non-loopback inspector endpoints require authorize");
19
20
  const recorder = new JevRecorder(options), scope = new AsyncLocalStorage();
21
+ const flows = new FlowRecorder(options);
20
22
  const streams = new Set();
23
+ const pending = new Set();
24
+ const encoder = new TextEncoder();
25
+ let scheduled;
26
+ const encodeUpdate = (session, incremental, cursor) => {
27
+ if (!incremental)
28
+ return {
29
+ bytes: encoder.encode(`event: snapshot\ndata: ${JSON.stringify({ version: 1, traces: recorder.snapshot(session) })}\n\n`),
30
+ revision: 0,
31
+ };
32
+ const { revision, upserts, order } = recorder.changes(session, cursor);
33
+ const initial = cursor === undefined;
34
+ const data = initial
35
+ ? { version: 2, revision, traces: upserts }
36
+ : { version: 2, base: cursor, revision, upserts, order };
37
+ return {
38
+ bytes: encoder.encode(`event: ${initial ? "snapshot" : "delta"}\ndata: ${JSON.stringify(data)}\n\n`),
39
+ revision,
40
+ };
41
+ };
42
+ function schedule(flush) {
43
+ pending.add(flush);
44
+ if (scheduled)
45
+ return;
46
+ scheduled = setImmediate(() => {
47
+ scheduled = undefined;
48
+ const batch = [...pending];
49
+ pending.clear();
50
+ // Cache only for this synchronous delivery turn and only within a session.
51
+ // Each reader owns its bytes; mutating one Response must not corrupt another.
52
+ const encoded = new Map();
53
+ const updateFor = (session, incremental, cursor) => {
54
+ // Slow readers can have older cursors even in the same session.
55
+ const key = JSON.stringify([session, incremental, cursor]);
56
+ let update = encoded.get(key);
57
+ if (!update) {
58
+ update = encodeUpdate(session, incremental, cursor);
59
+ encoded.set(key, update);
60
+ }
61
+ return { bytes: update.bytes.slice(), revision: update.revision };
62
+ };
63
+ for (const deliver of batch)
64
+ deliver(updateFor);
65
+ });
66
+ }
67
+ function unschedule(flush) {
68
+ pending.delete(flush);
69
+ if (!pending.size && scheduled) {
70
+ clearImmediate(scheduled);
71
+ scheduled = undefined;
72
+ }
73
+ }
21
74
  let disposed = false;
22
75
  const enabled = () => options.enabled === true && !disposed;
23
76
  const sessionFor = async (request) => {
@@ -40,33 +93,118 @@ export function createJevDevtools(options) {
40
93
  return json({ error: "Unauthorized" }, 403);
41
94
  const session = await sessionFor(request);
42
95
  if (url.pathname === endpoint && request.method === "GET")
43
- return json({ version: 1, enabled: true });
96
+ return json({ version: 1, enabled: true, flows: true });
44
97
  if (url.pathname === endpoint + "/clear") {
45
98
  if (request.method !== "DELETE")
46
99
  return json({ error: "Method not allowed" }, 405);
47
100
  if (request.headers.get("origin") !== origin)
48
101
  return json({ error: "Same-origin requests required" }, 403);
49
102
  recorder.clear(session);
103
+ flows.clear(session);
50
104
  return json({ ok: true });
51
105
  }
106
+ let flowPull = () => { };
107
+ if (url.pathname === endpoint + "/flows/events") {
108
+ if (request.method !== "GET")
109
+ return json({ error: "Method not allowed" }, 405);
110
+ let stop = () => { };
111
+ const body = new ReadableStream({
112
+ start(controller) {
113
+ let closed = false, dirty = true, cursor, timer;
114
+ const flush = () => {
115
+ timer = undefined;
116
+ if (closed || !dirty || (controller.desiredSize ?? 0) <= 0)
117
+ return;
118
+ dirty = false;
119
+ const update = flows.changes(session, cursor);
120
+ cursor = update.revision;
121
+ controller.enqueue(encoder.encode(`event: flows\ndata: ${JSON.stringify(update.data)}\n\n`));
122
+ };
123
+ const wake = () => {
124
+ if (!closed && dirty && !timer)
125
+ timer = setImmediate(flush);
126
+ };
127
+ const unsubscribe = flows.subscribe((sessions) => {
128
+ if (sessions.has(session)) {
129
+ dirty = true;
130
+ wake();
131
+ }
132
+ });
133
+ const heartbeat = setInterval(() => {
134
+ if (!closed && (controller.desiredSize ?? 0) > 0)
135
+ controller.enqueue(encoder.encode(": keepalive\n\n"));
136
+ }, 15000);
137
+ stop = () => {
138
+ if (closed)
139
+ return;
140
+ closed = true;
141
+ unsubscribe();
142
+ clearImmediate(timer);
143
+ clearInterval(heartbeat);
144
+ request.signal.removeEventListener("abort", stop);
145
+ streams.delete(stop);
146
+ try {
147
+ controller.close();
148
+ }
149
+ catch { }
150
+ };
151
+ streams.add(stop);
152
+ request.signal.addEventListener("abort", stop, { once: true });
153
+ flowPull = wake;
154
+ if (request.signal.aborted)
155
+ stop();
156
+ else
157
+ flush();
158
+ },
159
+ pull() {
160
+ flowPull();
161
+ },
162
+ cancel() {
163
+ stop();
164
+ },
165
+ });
166
+ return new Response(body, {
167
+ headers: {
168
+ "content-type": "text/event-stream",
169
+ "cache-control": "no-store",
170
+ "x-accel-buffering": "no",
171
+ },
172
+ });
173
+ }
52
174
  if (url.pathname !== endpoint + "/events")
53
175
  return json({ error: "Not found" }, 404);
54
176
  if (request.method !== "GET")
55
177
  return json({ error: "Method not allowed" }, 405);
56
- const encoder = new TextEncoder();
57
- let cleanup = () => { }, flush = () => { };
178
+ const incremental = url.searchParams.get("protocol") === "2";
179
+ let cursor;
180
+ let cleanup = () => { }, wake = () => { }, flush = () => { };
58
181
  const stream = new ReadableStream({
59
182
  start(controller) {
60
183
  let closed = false, dirty = true;
61
- flush = () => {
184
+ flush = (updateFor) => {
62
185
  if (closed || !dirty || (controller.desiredSize ?? 0) <= 0)
63
186
  return;
64
187
  dirty = false;
65
- controller.enqueue(encoder.encode(`event: snapshot\ndata: ${JSON.stringify({ version: 1, traces: recorder.snapshot(session) })}\n\n`));
188
+ try {
189
+ const update = updateFor(session, incremental, cursor);
190
+ controller.enqueue(update.bytes);
191
+ cursor = update.revision;
192
+ }
193
+ catch {
194
+ // Deferred observer failures must not escape into the application's
195
+ // event loop or prevent other viewers from receiving their update.
196
+ cleanup();
197
+ }
198
+ };
199
+ wake = () => {
200
+ if (!closed && dirty && (controller.desiredSize ?? 0) > 0)
201
+ schedule(flush);
66
202
  };
67
- const unsubscribe = recorder.subscribe(() => {
203
+ const unsubscribe = recorder.subscribe((sessions) => {
204
+ if (!sessions.has(session))
205
+ return;
68
206
  dirty = true;
69
- flush();
207
+ wake();
70
208
  });
71
209
  const heartbeat = setInterval(() => {
72
210
  if (!closed && (controller.desiredSize ?? 0) > 0)
@@ -77,6 +215,7 @@ export function createJevDevtools(options) {
77
215
  return;
78
216
  closed = true;
79
217
  unsubscribe();
218
+ unschedule(flush);
80
219
  clearInterval(heartbeat);
81
220
  request.signal.removeEventListener("abort", cleanup);
82
221
  streams.delete(cleanup);
@@ -92,10 +231,12 @@ export function createJevDevtools(options) {
92
231
  if (request.signal.aborted)
93
232
  cleanup();
94
233
  else
95
- flush();
234
+ flush(encodeUpdate);
96
235
  },
97
236
  pull() {
98
- flush();
237
+ // Reading must not bypass the batching boundary. A full queue retains
238
+ // only a dirty flag; the next readable turn delivers current history.
239
+ wake();
99
240
  },
100
241
  cancel() {
101
242
  cleanup();
@@ -139,7 +280,20 @@ export function createJevDevtools(options) {
139
280
  res.destroy();
140
281
  }
141
282
  }
283
+ const startFlow = (definition, run) => {
284
+ if (!enabled())
285
+ return;
286
+ flows.start(definition, run, scope.getStore() ?? "local");
287
+ return {
288
+ update: (next) => {
289
+ if (enabled())
290
+ flows.update(next);
291
+ },
292
+ request: (link, onTrace, callback) => recorder.withFlow(link, onTrace, callback),
293
+ };
294
+ };
142
295
  return {
296
+ startFlow,
143
297
  handle,
144
298
  /**
145
299
  * For Node framework routes whose Request URL uses an internal hostname.
@@ -1,19 +1,29 @@
1
1
  import { TypeSafeClient } from "@typesafe-ai/sdk";
2
2
  import type { Fetch } from "@typesafe-ai/sdk";
3
- import type { Trace } from "../src/protocol.js";
3
+ import type { Json, Trace } from "../src/protocol.js";
4
4
  export type CaptureOptions = {
5
5
  limit?: number;
6
6
  redact?: (path: string[], value: unknown) => unknown;
7
7
  };
8
+ export declare function capture(value: unknown, warnings: Set<string>, redact?: CaptureOptions["redact"]): Json;
8
9
  export declare class JevRecorder {
9
10
  private options;
10
11
  private traces;
11
12
  private listeners;
13
+ private revision;
14
+ private revisions;
12
15
  private context;
16
+ private flowContext;
17
+ withFlow<T>(link: NonNullable<Trace["flow"]>, onTrace: (id: string) => void, callback: () => T): T;
13
18
  private limit;
14
19
  constructor(options?: CaptureOptions);
15
20
  snapshot(session: string): Trace[];
16
- subscribe(listener: () => void): () => void;
21
+ changes(session: string, after?: number): {
22
+ revision: number;
23
+ order: string[];
24
+ upserts: Trace[];
25
+ };
26
+ subscribe(listener: (sessions: ReadonlySet<string>) => void): () => void;
17
27
  clear(session: string): void;
18
28
  private notify;
19
29
  private update;
@@ -5,7 +5,7 @@ const secretKey = /^(authorization|cookie|set-cookie|api[-_]?key|password|secret
5
5
  const safeMessage = (value) => value.replace(/Bearer\s+\S+/gi, "Bearer [redacted]").slice(0, 500);
6
6
  // Snapshot before the caller can mutate its request. Bounded and cycle-safe;
7
7
  // image handles become explicit previews/references instead of opaque SDK objects.
8
- function capture(value, warnings, redact) {
8
+ export function capture(value, warnings, redact) {
9
9
  const seen = new WeakSet();
10
10
  let budget = 96_000;
11
11
  const visit = (input, path, depth) => {
@@ -79,7 +79,13 @@ export class JevRecorder {
79
79
  options;
80
80
  traces = new Map();
81
81
  listeners = new Set();
82
+ revision = 0;
83
+ revisions = new WeakMap();
82
84
  context = new AsyncLocalStorage();
85
+ flowContext = new AsyncLocalStorage();
86
+ withFlow(link, onTrace, callback) {
87
+ return this.flowContext.run({ link, onTrace }, callback);
88
+ }
83
89
  limit;
84
90
  constructor(options = {}) {
85
91
  this.options = options;
@@ -88,6 +94,18 @@ export class JevRecorder {
88
94
  snapshot(session) {
89
95
  return structuredClone([...this.traces.values()].filter((t) => t.session === session).reverse());
90
96
  }
97
+ // Only changed payloads are cloned. IDs/order are bounded by the retained
98
+ // history; no tombstone log or per-session history cache is needed.
99
+ changes(session, after) {
100
+ const current = [...this.traces.values()]
101
+ .filter((t) => t.session === session)
102
+ .reverse();
103
+ return {
104
+ revision: this.revision,
105
+ order: current.map((t) => t.id),
106
+ upserts: structuredClone(current.filter((t) => after === undefined || this.revisions.get(t) > after)),
107
+ };
108
+ }
91
109
  subscribe(listener) {
92
110
  this.listeners.add(listener);
93
111
  return () => {
@@ -95,15 +113,21 @@ export class JevRecorder {
95
113
  };
96
114
  }
97
115
  clear(session) {
116
+ let changed = false;
98
117
  for (const [id, t] of this.traces)
99
- if (t.session === session)
118
+ if (t.session === session) {
100
119
  this.traces.delete(id);
101
- this.notify();
120
+ changed = true;
121
+ }
122
+ if (changed) {
123
+ this.revision++;
124
+ this.notify(new Set([session]));
125
+ }
102
126
  }
103
- notify() {
127
+ notify(sessions) {
104
128
  for (const listener of this.listeners) {
105
129
  try {
106
- listener();
130
+ listener(sessions);
107
131
  }
108
132
  catch {
109
133
  /* Observers must not affect inference. */
@@ -114,7 +138,8 @@ export class JevRecorder {
114
138
  const t = this.traces.get(id);
115
139
  if (t) {
116
140
  update(t);
117
- this.notify();
141
+ this.revisions.set(t, ++this.revision);
142
+ this.notify(new Set([t.session]));
118
143
  }
119
144
  }
120
145
  // Use this in TypeSafeClient's supported `fetch` option. It observes attempts
@@ -175,15 +200,28 @@ export class JevRecorder {
175
200
  version: 1,
176
201
  id,
177
202
  ...(typeof meta === "function" ? meta() : meta),
203
+ flow: this.flowContext.getStore()?.link,
178
204
  startedAt: Date.now(),
179
205
  status: "pending",
180
206
  request: captured,
181
207
  attempts: [],
182
208
  captureWarnings: [...warnings],
183
209
  });
184
- while (this.traces.size > this.limit)
185
- this.traces.delete(this.traces.keys().next().value);
186
- this.notify();
210
+ const trace = this.traces.get(id);
211
+ try {
212
+ this.flowContext.getStore()?.onTrace(id);
213
+ }
214
+ catch {
215
+ /* Observer only. */
216
+ }
217
+ this.revisions.set(trace, ++this.revision);
218
+ const sessions = new Set([trace.session]);
219
+ while (this.traces.size > this.limit) {
220
+ const oldest = this.traces.values().next().value;
221
+ sessions.add(oldest.session);
222
+ this.traces.delete(oldest.id);
223
+ }
224
+ this.notify(sessions);
187
225
  const fail = (error) => this.update(id, (t) => {
188
226
  const e = error;
189
227
  t.status = e.name === "APIUserAbortError" ? "cancelled" : "error";
@@ -0,0 +1,4 @@
1
+ import type { AnswerView } from "./protocol.js";
2
+ export declare function ChoiceProbabilities({ answer }: {
3
+ answer: AnswerView;
4
+ }): import("react").JSX.Element;
@@ -0,0 +1,19 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useMemo, useState } from "react";
3
+ export function ChoiceProbabilities({ answer }) {
4
+ const [search, setSearch] = useState("");
5
+ const [page, setPage] = useState(0);
6
+ const rows = useMemo(() => Object.entries(answer.probabilities ?? {}).sort((a, b) => b[1] - a[1]), [answer.probabilities]);
7
+ const filtered = useMemo(() => {
8
+ const query = search.trim().toLocaleLowerCase();
9
+ return rows.filter(([label]) => label.toLocaleLowerCase().includes(query));
10
+ }, [rows, search]);
11
+ const pages = Math.max(1, Math.ceil(filtered.length / 20));
12
+ const current = Math.min(page, pages - 1);
13
+ return (_jsxs("div", { className: "lab-choice-browser", role: "region", "aria-label": "All Choice probabilities", "data-diagram-scroll": "", children: [_jsxs("div", { className: "lab-choice-controls", children: [_jsxs("label", { children: ["Search categories", _jsx("input", { autoFocus: true, type: "search", value: search, onChange: (event) => {
14
+ setSearch(event.target.value);
15
+ setPage(0);
16
+ } })] }), _jsxs("span", { role: "status", children: [filtered.length, " of ", rows.length, " categories \u00B7 highest probability first"] })] }), _jsxs("div", { className: "lab-choice-table-scroll", tabIndex: 0, "aria-label": "Probability table", children: [_jsxs("table", { children: [_jsxs("caption", { children: ["Captured probabilities. Values are not renormalized. Returned choice: ", answer.choice ?? "unavailable", "."] }), _jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { scope: "col", children: "Category" }), _jsx("th", { scope: "col", children: "Probability" }), _jsx("th", { scope: "col", children: "Exact value (0\u20131)" })] }) }), _jsx("tbody", { children: filtered
17
+ .slice(current * 20, (current + 1) * 20)
18
+ .map(([label, value]) => (_jsxs("tr", { children: [_jsxs("th", { scope: "row", children: [label, label === answer.choice && (_jsx("span", { className: "lab-choice-returned", children: "Returned choice" }))] }), _jsxs("td", { children: [Number((value * 100).toPrecision(6)), "%"] }), _jsx("td", { children: _jsx("code", { children: String(value) }) })] }, label))) })] }), !filtered.length && _jsx("p", { children: "No matching categories." })] }), _jsxs("div", { className: "lab-choice-pagination", children: [_jsx("button", { disabled: current === 0, onClick: () => setPage(current - 1), children: "Previous categories" }), _jsxs("span", { "aria-live": "polite", children: ["Page ", current + 1, " of ", pages] }), _jsx("button", { disabled: current + 1 >= pages, onClick: () => setPage(current + 1), children: "Next categories" })] })] }));
19
+ }
@@ -1,7 +1,10 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { useEffect, useState } from "react";
2
+ import { memo, useCallback, useEffect, useRef, useState } from "react";
3
3
  import { JevPanel } from "./JevDevtools.js";
4
4
  import { JevDrawer } from "./JevDrawer.js";
5
+ import { connectTraceStream } from "./trace-stream.js";
6
+ import { connectFlowStream, emptyFlows, } from "./flow-stream.js";
7
+ const RetainedPanel = memo(JevPanel);
5
8
  function endpointUrl(endpoint) {
6
9
  const url = new URL(endpoint, window.location.origin);
7
10
  if (url.origin !== window.location.origin ||
@@ -13,12 +16,39 @@ function endpointUrl(endpoint) {
13
16
  return url.href.replace(/\/$/, "");
14
17
  }
15
18
  export default function ConnectedInspector({ endpoint = "/api/jev-devtools", defaultOpen = true, }) {
16
- const [traces, setTraces] = useState([]), [connection, setConnection] = useState("connecting");
19
+ const [open, setOpen] = useState(defaultOpen);
20
+ const [visited, setVisited] = useState(defaultOpen);
21
+ const visible = useRef(defaultOpen);
22
+ const latest = useRef({
23
+ traces: [],
24
+ connection: "connecting",
25
+ flows: emptyFlows,
26
+ });
27
+ const [snapshot, setSnapshot] = useState(latest.current);
28
+ const toggle = (value) => {
29
+ visible.current = value;
30
+ setOpen(value);
31
+ if (value) {
32
+ setVisited(true);
33
+ setSnapshot(latest.current);
34
+ }
35
+ };
17
36
  useEffect(() => {
18
- let alive = true, events;
37
+ let alive = true, disconnect = () => { }, disconnectFlows = () => { };
19
38
  const abort = new AbortController();
20
- setTraces([]);
21
- setConnection("connecting");
39
+ // Keep capture current while closed without scheduling React work. Reopening
40
+ // publishes only the latest snapshot; the retained panel keeps its UI state.
41
+ const update = (patch) => {
42
+ const next = { ...latest.current, ...patch };
43
+ if (next.traces === latest.current.traces &&
44
+ next.connection === latest.current.connection &&
45
+ next.flows === latest.current.flows)
46
+ return;
47
+ latest.current = next;
48
+ if (visible.current)
49
+ setSnapshot(latest.current);
50
+ };
51
+ update({ traces: [], connection: "connecting", flows: emptyFlows });
22
52
  void (async () => {
23
53
  try {
24
54
  const url = endpointUrl(endpoint);
@@ -35,44 +65,31 @@ export default function ConnectedInspector({ endpoint = "/api/jev-devtools", def
35
65
  throw new Error("Unsupported inspector endpoint");
36
66
  if (!alive)
37
67
  return;
38
- events = new EventSource(url + "/events");
39
- events.addEventListener("snapshot", (event) => {
40
- if (!alive)
41
- return;
42
- try {
43
- const data = JSON.parse(event.data);
44
- if (data.version !== 1 || !Array.isArray(data.traces))
45
- throw new Error();
46
- setTraces(data.traces);
47
- setConnection("connected");
48
- }
49
- catch {
50
- setConnection("Invalid inspector update");
51
- }
52
- });
53
- events.onerror = () => {
54
- if (alive)
55
- setConnection("reconnecting");
56
- };
68
+ if (config.flows)
69
+ disconnectFlows = connectFlowStream(url, (flows) => update({ flows }));
70
+ disconnect = connectTraceStream(url, (traces) => update({ traces }), (connection) => update({ connection }));
57
71
  }
58
72
  catch (error) {
59
73
  if (alive)
60
- setConnection(error instanceof Error ? error.message : "Inspector unavailable");
74
+ update({
75
+ connection: error instanceof Error ? error.message : "Inspector unavailable",
76
+ });
61
77
  }
62
78
  })();
63
79
  return () => {
64
80
  alive = false;
65
81
  abort.abort();
66
- events?.close();
82
+ disconnect();
83
+ disconnectFlows();
67
84
  };
68
85
  }, [endpoint]);
69
- async function clear() {
86
+ const clear = useCallback(async () => {
70
87
  const response = await fetch(endpointUrl(endpoint) + "/clear", {
71
88
  method: "DELETE",
72
89
  credentials: "same-origin",
73
90
  });
74
91
  if (!response.ok)
75
92
  throw new Error(`Cannot clear history (${response.status})`);
76
- }
77
- return (_jsx(JevDrawer, { defaultOpen: defaultOpen, children: _jsx(JevPanel, { traces: traces, connection: connection, onClear: clear }) }));
93
+ }, [endpoint]);
94
+ return (_jsx(JevDrawer, { open: open, onOpenChange: toggle, children: visited && _jsx(RetainedPanel, { ...snapshot, onClear: clear }) }));
78
95
  }
@@ -0,0 +1,6 @@
1
+ import type { Json } from "./protocol.js";
2
+ /** Bounded, lazy property rendering inside a fixed-size diagram card. */
3
+ export declare const DiagramContent: import("react").MemoExoticComponent<({ value, label, }: {
4
+ value: Json | undefined;
5
+ label: string;
6
+ }) => import("react").JSX.Element>;