jev-devtools 0.1.3 → 0.2.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/README.md CHANGED
@@ -1,11 +1,13 @@
1
1
  # Jev DevTools
2
2
 
3
- [![npm version 0.1.3](https://img.shields.io/badge/npm-v0.1.3-E551BA?labelColor=0D0A0F&logo=npm)](https://www.npmjs.com/package/jev-devtools/v/0.1.3)
3
+ [![npm version 0.2.0](https://img.shields.io/badge/npm-v0.2.0-E551BA?labelColor=0D0A0F&logo=npm)](https://www.npmjs.com/package/jev-devtools/v/0.2.0)
4
4
 
5
5
  A React drawer and Node adapter for inspecting TypeSafe Jev requests with animated diagrams. Includes request history, context, questions, typed answers, transport attempts, and raw snapshots.
6
6
 
7
7
  **Experimental 0.1.x.** React 18/19 and Node 22.12+ are supported. APIs may change before 1.0. Verification uses sample responses through the real TypeSafe SDK, not live inference.
8
8
 
9
+ [Changelog and release history](https://github.com/diegoag64/jev-devtools/blob/main/CHANGELOG.md) — published versions and unreleased work.
10
+
9
11
  ## Demo
10
12
 
11
13
  ![Full 28-second Jev DevTools demo showing sample results, the request drawer, and animated diagrams.](https://raw.githubusercontent.com/diegoag64/jev-devtools/main/docs/assets/jev-devtools-preview.gif)
@@ -42,7 +44,7 @@ To try unpublished local changes instead:
42
44
  npm ci
43
45
  npm run pack:devtools
44
46
  # From the consuming app:
45
- npm install /absolute/path/to/jev-devtools-0.1.3.tgz
47
+ npm install /absolute/path/to/jev-devtools-0.2.0.tgz
46
48
  ```
47
49
 
48
50
  Mount once:
@@ -69,7 +71,7 @@ app.use(inspector.middleware); // Mount at root, before fallback handlers.
69
71
  // Call inspector.dispose() on shutdown.
70
72
  ```
71
73
 
72
- Only calls through that client are captured. The React component subscribes via same-origin SSE. Both sides default to disabled. API keys remain server-side. Browser code is SSR-safe. The drawer uses a Shadow DOM boundary and its own stylesheet so host page selectors cannot alter its internal layout. Existing stylesheet imports remain supported; no Tailwind or shadcn installation is required. Drag the Flow canvas to pan, scroll/pinch to zoom, or use Fit.
74
+ Only calls through that client are captured. The React component subscribes via same-origin SSE, receiving an initial snapshot followed by changed traces. Older client/server combinations remain compatible through full snapshots. Both sides default to disabled. API keys remain server-side. Browser code is SSR-safe. The drawer uses a Shadow DOM boundary and its own stylesheet so host page selectors cannot alter its internal layout. Existing stylesheet imports remain supported; no Tailwind or shadcn installation is required. Drag the Flow canvas to pan, scroll/pinch to zoom, or use Fit.
73
75
 
74
76
  History is bounded to 50 calls in memory by default. API routes and inspector endpoints must share an adapter instance; distributed processes and edge runtimes are not supported. Non-loopback use requires explicit authorization. Captures include bounded/redacted context and answers, not hidden model reasoning.
75
77
 
@@ -48,3 +48,30 @@ trademark rights or imply endorsement.
48
48
  The playground uses shadcn/ui components. Its MIT notice is preserved in
49
49
  apps/playground/SHADCN_LICENSE.md. Those components are not bundled in the
50
50
  DevTools package. No TanStack DevTools shell is included.
51
+
52
+ TanStack Virtual
53
+
54
+ Questions virtualization uses @tanstack/react-virtual and @tanstack/virtual-core.
55
+ https://github.com/TanStack/virtual
56
+
57
+ MIT License
58
+
59
+ Copyright (c) 2021-present Tanner Linsley
60
+
61
+ Permission is hereby granted, free of charge, to any person obtaining a copy
62
+ of this software and associated documentation files (the "Software"), to deal
63
+ in the Software without restriction, including without limitation the rights
64
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
65
+ copies of the Software, and to permit persons to whom the Software is
66
+ furnished to do so, subject to the following conditions:
67
+
68
+ The above copyright notice and this permission notice shall be included in all
69
+ copies or substantial portions of the Software.
70
+
71
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
72
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
73
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
74
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
75
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
76
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
77
+ SOFTWARE.
@@ -18,6 +18,57 @@ export function createJevDevtools(options) {
18
18
  throw new Error("Non-loopback inspector endpoints require authorize");
19
19
  const recorder = new JevRecorder(options), scope = new AsyncLocalStorage();
20
20
  const streams = new Set();
21
+ const pending = new Set();
22
+ const encoder = new TextEncoder();
23
+ let scheduled;
24
+ const encodeUpdate = (session, incremental, cursor) => {
25
+ if (!incremental)
26
+ return {
27
+ bytes: encoder.encode(`event: snapshot\ndata: ${JSON.stringify({ version: 1, traces: recorder.snapshot(session) })}\n\n`),
28
+ revision: 0,
29
+ };
30
+ const { revision, upserts, order } = recorder.changes(session, cursor);
31
+ const initial = cursor === undefined;
32
+ const data = initial
33
+ ? { version: 2, revision, traces: upserts }
34
+ : { version: 2, base: cursor, revision, upserts, order };
35
+ return {
36
+ bytes: encoder.encode(`event: ${initial ? "snapshot" : "delta"}\ndata: ${JSON.stringify(data)}\n\n`),
37
+ revision,
38
+ };
39
+ };
40
+ function schedule(flush) {
41
+ pending.add(flush);
42
+ if (scheduled)
43
+ return;
44
+ scheduled = setImmediate(() => {
45
+ scheduled = undefined;
46
+ const batch = [...pending];
47
+ pending.clear();
48
+ // Cache only for this synchronous delivery turn and only within a session.
49
+ // Each reader owns its bytes; mutating one Response must not corrupt another.
50
+ const encoded = new Map();
51
+ const updateFor = (session, incremental, cursor) => {
52
+ // Slow readers can have older cursors even in the same session.
53
+ const key = JSON.stringify([session, incremental, cursor]);
54
+ let update = encoded.get(key);
55
+ if (!update) {
56
+ update = encodeUpdate(session, incremental, cursor);
57
+ encoded.set(key, update);
58
+ }
59
+ return { bytes: update.bytes.slice(), revision: update.revision };
60
+ };
61
+ for (const deliver of batch)
62
+ deliver(updateFor);
63
+ });
64
+ }
65
+ function unschedule(flush) {
66
+ pending.delete(flush);
67
+ if (!pending.size && scheduled) {
68
+ clearImmediate(scheduled);
69
+ scheduled = undefined;
70
+ }
71
+ }
21
72
  let disposed = false;
22
73
  const enabled = () => options.enabled === true && !disposed;
23
74
  const sessionFor = async (request) => {
@@ -53,20 +104,36 @@ export function createJevDevtools(options) {
53
104
  return json({ error: "Not found" }, 404);
54
105
  if (request.method !== "GET")
55
106
  return json({ error: "Method not allowed" }, 405);
56
- const encoder = new TextEncoder();
57
- let cleanup = () => { }, flush = () => { };
107
+ const incremental = url.searchParams.get("protocol") === "2";
108
+ let cursor;
109
+ let cleanup = () => { }, wake = () => { }, flush = () => { };
58
110
  const stream = new ReadableStream({
59
111
  start(controller) {
60
112
  let closed = false, dirty = true;
61
- flush = () => {
113
+ flush = (updateFor) => {
62
114
  if (closed || !dirty || (controller.desiredSize ?? 0) <= 0)
63
115
  return;
64
116
  dirty = false;
65
- controller.enqueue(encoder.encode(`event: snapshot\ndata: ${JSON.stringify({ version: 1, traces: recorder.snapshot(session) })}\n\n`));
117
+ try {
118
+ const update = updateFor(session, incremental, cursor);
119
+ controller.enqueue(update.bytes);
120
+ cursor = update.revision;
121
+ }
122
+ catch {
123
+ // Deferred observer failures must not escape into the application's
124
+ // event loop or prevent other viewers from receiving their update.
125
+ cleanup();
126
+ }
127
+ };
128
+ wake = () => {
129
+ if (!closed && dirty && (controller.desiredSize ?? 0) > 0)
130
+ schedule(flush);
66
131
  };
67
- const unsubscribe = recorder.subscribe(() => {
132
+ const unsubscribe = recorder.subscribe((sessions) => {
133
+ if (!sessions.has(session))
134
+ return;
68
135
  dirty = true;
69
- flush();
136
+ wake();
70
137
  });
71
138
  const heartbeat = setInterval(() => {
72
139
  if (!closed && (controller.desiredSize ?? 0) > 0)
@@ -77,6 +144,7 @@ export function createJevDevtools(options) {
77
144
  return;
78
145
  closed = true;
79
146
  unsubscribe();
147
+ unschedule(flush);
80
148
  clearInterval(heartbeat);
81
149
  request.signal.removeEventListener("abort", cleanup);
82
150
  streams.delete(cleanup);
@@ -92,10 +160,12 @@ export function createJevDevtools(options) {
92
160
  if (request.signal.aborted)
93
161
  cleanup();
94
162
  else
95
- flush();
163
+ flush(encodeUpdate);
96
164
  },
97
165
  pull() {
98
- flush();
166
+ // Reading must not bypass the batching boundary. A full queue retains
167
+ // only a dirty flag; the next readable turn delivers current history.
168
+ wake();
99
169
  },
100
170
  cancel() {
101
171
  cleanup();
@@ -9,11 +9,18 @@ export declare class JevRecorder {
9
9
  private options;
10
10
  private traces;
11
11
  private listeners;
12
+ private revision;
13
+ private revisions;
12
14
  private context;
13
15
  private limit;
14
16
  constructor(options?: CaptureOptions);
15
17
  snapshot(session: string): Trace[];
16
- subscribe(listener: () => void): () => void;
18
+ changes(session: string, after?: number): {
19
+ revision: number;
20
+ order: string[];
21
+ upserts: Trace[];
22
+ };
23
+ subscribe(listener: (sessions: ReadonlySet<string>) => void): () => void;
17
24
  clear(session: string): void;
18
25
  private notify;
19
26
  private update;
@@ -79,6 +79,8 @@ 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();
83
85
  limit;
84
86
  constructor(options = {}) {
@@ -88,6 +90,18 @@ export class JevRecorder {
88
90
  snapshot(session) {
89
91
  return structuredClone([...this.traces.values()].filter((t) => t.session === session).reverse());
90
92
  }
93
+ // Only changed payloads are cloned. IDs/order are bounded by the retained
94
+ // history; no tombstone log or per-session history cache is needed.
95
+ changes(session, after) {
96
+ const current = [...this.traces.values()]
97
+ .filter((t) => t.session === session)
98
+ .reverse();
99
+ return {
100
+ revision: this.revision,
101
+ order: current.map((t) => t.id),
102
+ upserts: structuredClone(current.filter((t) => after === undefined || this.revisions.get(t) > after)),
103
+ };
104
+ }
91
105
  subscribe(listener) {
92
106
  this.listeners.add(listener);
93
107
  return () => {
@@ -95,15 +109,21 @@ export class JevRecorder {
95
109
  };
96
110
  }
97
111
  clear(session) {
112
+ let changed = false;
98
113
  for (const [id, t] of this.traces)
99
- if (t.session === session)
114
+ if (t.session === session) {
100
115
  this.traces.delete(id);
101
- this.notify();
116
+ changed = true;
117
+ }
118
+ if (changed) {
119
+ this.revision++;
120
+ this.notify(new Set([session]));
121
+ }
102
122
  }
103
- notify() {
123
+ notify(sessions) {
104
124
  for (const listener of this.listeners) {
105
125
  try {
106
- listener();
126
+ listener(sessions);
107
127
  }
108
128
  catch {
109
129
  /* Observers must not affect inference. */
@@ -114,7 +134,8 @@ export class JevRecorder {
114
134
  const t = this.traces.get(id);
115
135
  if (t) {
116
136
  update(t);
117
- this.notify();
137
+ this.revisions.set(t, ++this.revision);
138
+ this.notify(new Set([t.session]));
118
139
  }
119
140
  }
120
141
  // Use this in TypeSafeClient's supported `fetch` option. It observes attempts
@@ -181,9 +202,15 @@ export class JevRecorder {
181
202
  attempts: [],
182
203
  captureWarnings: [...warnings],
183
204
  });
184
- while (this.traces.size > this.limit)
185
- this.traces.delete(this.traces.keys().next().value);
186
- this.notify();
205
+ const trace = this.traces.get(id);
206
+ this.revisions.set(trace, ++this.revision);
207
+ const sessions = new Set([trace.session]);
208
+ while (this.traces.size > this.limit) {
209
+ const oldest = this.traces.values().next().value;
210
+ sessions.add(oldest.session);
211
+ this.traces.delete(oldest.id);
212
+ }
213
+ this.notify(sessions);
187
214
  const fail = (error) => this.update(id, (t) => {
188
215
  const e = error;
189
216
  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", 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,9 @@
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
+ const RetainedPanel = memo(JevPanel);
5
7
  function endpointUrl(endpoint) {
6
8
  const url = new URL(endpoint, window.location.origin);
7
9
  if (url.origin !== window.location.origin ||
@@ -13,12 +15,34 @@ function endpointUrl(endpoint) {
13
15
  return url.href.replace(/\/$/, "");
14
16
  }
15
17
  export default function ConnectedInspector({ endpoint = "/api/jev-devtools", defaultOpen = true, }) {
16
- const [traces, setTraces] = useState([]), [connection, setConnection] = useState("connecting");
18
+ const [open, setOpen] = useState(defaultOpen);
19
+ const [visited, setVisited] = useState(defaultOpen);
20
+ const visible = useRef(defaultOpen);
21
+ const latest = useRef({ traces: [], connection: "connecting" });
22
+ const [snapshot, setSnapshot] = useState(latest.current);
23
+ const toggle = (value) => {
24
+ visible.current = value;
25
+ setOpen(value);
26
+ if (value) {
27
+ setVisited(true);
28
+ setSnapshot(latest.current);
29
+ }
30
+ };
17
31
  useEffect(() => {
18
- let alive = true, events;
32
+ let alive = true, disconnect = () => { };
19
33
  const abort = new AbortController();
20
- setTraces([]);
21
- setConnection("connecting");
34
+ // Keep capture current while closed without scheduling React work. Reopening
35
+ // publishes only the latest snapshot; the retained panel keeps its UI state.
36
+ const update = (patch) => {
37
+ const next = { ...latest.current, ...patch };
38
+ if (next.traces === latest.current.traces &&
39
+ next.connection === latest.current.connection)
40
+ return;
41
+ latest.current = next;
42
+ if (visible.current)
43
+ setSnapshot(latest.current);
44
+ };
45
+ update({ traces: [], connection: "connecting" });
22
46
  void (async () => {
23
47
  try {
24
48
  const url = endpointUrl(endpoint);
@@ -35,44 +59,28 @@ export default function ConnectedInspector({ endpoint = "/api/jev-devtools", def
35
59
  throw new Error("Unsupported inspector endpoint");
36
60
  if (!alive)
37
61
  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
- };
62
+ disconnect = connectTraceStream(url, (traces) => update({ traces }), (connection) => update({ connection }));
57
63
  }
58
64
  catch (error) {
59
65
  if (alive)
60
- setConnection(error instanceof Error ? error.message : "Inspector unavailable");
66
+ update({
67
+ connection: error instanceof Error ? error.message : "Inspector unavailable",
68
+ });
61
69
  }
62
70
  })();
63
71
  return () => {
64
72
  alive = false;
65
73
  abort.abort();
66
- events?.close();
74
+ disconnect();
67
75
  };
68
76
  }, [endpoint]);
69
- async function clear() {
77
+ const clear = useCallback(async () => {
70
78
  const response = await fetch(endpointUrl(endpoint) + "/clear", {
71
79
  method: "DELETE",
72
80
  credentials: "same-origin",
73
81
  });
74
82
  if (!response.ok)
75
83
  throw new Error(`Cannot clear history (${response.status})`);
76
- }
77
- return (_jsx(JevDrawer, { defaultOpen: defaultOpen, children: _jsx(JevPanel, { traces: traces, connection: connection, onClear: clear }) }));
84
+ }, [endpoint]);
85
+ return (_jsx(JevDrawer, { open: open, onOpenChange: toggle, children: visited && _jsx(RetainedPanel, { ...snapshot, onClear: clear }) }));
78
86
  }
@@ -1,58 +1,48 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useState } from "react";
2
+ import { lazy, memo, startTransition, Suspense, useMemo, useState, } from "react";
3
3
  import { TraceDiagram } from "./TraceDiagram.js";
4
- const pretty = (value) => JSON.stringify(value, null, 2);
4
+ import { JsonBlock, findImage } from "./TraceContent.js";
5
+ let questionsLoaded = false;
6
+ const Questions = lazy(() => import("./Questions.js").then((module) => {
7
+ questionsLoaded = true;
8
+ return { default: module.Questions };
9
+ }));
5
10
  const ms = (value) => value === undefined ? "—" : `${Math.round(value)} ms`;
6
- const percent = (value) => `${Math.round(value * 100)}%`;
7
- function findImage(value) {
8
- if (!value || typeof value !== "object")
9
- return;
10
- if (!Array.isArray(value) &&
11
- typeof value.preview === "string" &&
12
- /^data:image\/(png|jpeg);base64,/.test(value.preview))
13
- return value.preview;
14
- for (const child of Object.values(value)) {
15
- const found = findImage(child);
16
- if (found)
17
- return found;
18
- }
19
- }
20
- function JsonBlock({ value }) {
21
- return _jsx("pre", { children: pretty(value) });
22
- }
23
- function Answer({ answer, image }) {
24
- if (!answer)
25
- return _jsx("p", { className: "muted", children: "No answer received." });
26
- return (_jsxs("div", { className: "answer", children: [answer.choice !== undefined && (_jsx("strong", { className: "answer-value", children: answer.choice })), answer.score !== undefined && (_jsxs("strong", { className: "answer-value", children: [answer.score.toFixed(2), " ", _jsx("small", { children: "score" })] })), answer.noul !== undefined && (_jsxs(_Fragment, { children: [_jsxs("strong", { className: "answer-value", children: [percent(answer.noul), " ", _jsx("small", { children: "probability of true" })] }), _jsx("div", { className: "bar", children: _jsx("i", { style: { width: percent(answer.noul) } }) })] })), answer.probabilities &&
27
- Object.entries(answer.probabilities).map(([label, value]) => (_jsxs("div", { className: "probability", children: [_jsxs("div", { children: [_jsx("span", { children: label }), _jsx("b", { children: percent(value) })] }), _jsx("div", { className: "bar", children: _jsx("i", { style: { width: percent(value) } }) })] }, label))), answer.legend && (_jsxs("details", { children: [_jsx("summary", { children: "Score legend" }), _jsx(JsonBlock, { value: answer.legend })] })), answer.box && (_jsxs(_Fragment, { children: [_jsxs("div", { className: "box-preview", children: [image ? (_jsx("img", { src: image, alt: "Captured request image" })) : (_jsx("span", { children: "Image preview unavailable" })), _jsx("div", { className: "bounding-box", style: {
28
- left: percent(answer.box.left),
29
- top: percent(answer.box.top),
30
- width: percent(answer.box.right - answer.box.left),
31
- height: percent(answer.box.bottom - answer.box.top),
32
- } })] }), _jsx("code", { className: "coordinates", children: Object.entries(answer.box)
33
- .map(([k, v]) => `${k} ${v.toFixed(2)}`)
34
- .join(" · ") })] })), answer.confidence !== undefined && (_jsxs("p", { className: "confidence", children: ["Confidence ", _jsx("b", { children: percent(answer.confidence) })] }))] }));
35
- }
11
+ // Delta updates retain unchanged Trace references. Default shallow comparison
12
+ // skips their render work while changed traces and selection still update.
13
+ const RequestRow = memo(function RequestRow({ trace, selected, onSelect, }) {
14
+ return (_jsxs("button", { className: `request-row ${selected ? "selected" : ""}`, onClick: () => onSelect(trace.id), children: [_jsx("span", { className: `status-dot ${trace.status}` }), _jsxs("span", { children: [_jsx("strong", { children: trace.label }), _jsxs("small", { children: [new Date(trace.startedAt).toLocaleTimeString(), " \u00B7 ", trace.source, " \u00B7", " ", ms(trace.durationMs)] })] }), _jsx("span", { className: "row-status", children: trace.status })] }));
15
+ });
16
+ const RequestDetail = memo(function RequestDetail({ trace, }) {
17
+ // Keep tab selection across request switches and clear, as before. Local tab
18
+ // changes no longer rerender the history list.
19
+ const [tab, setTab] = useState("Flow");
20
+ return (_jsx("section", { className: "request-detail", children: trace ? (_jsxs(_Fragment, { children: [_jsxs("header", { className: "detail-header", children: [_jsxs("div", { children: [_jsxs("span", { className: "eyebrow", children: [trace.source, " response \u00B7 ", trace.status] }), _jsx("h2", { children: trace.label })] }), _jsxs("div", { className: "metrics", children: [_jsxs("span", { children: [ms(trace.durationMs), _jsx("small", { children: "Total duration" })] }), _jsxs("span", { children: [trace.attempts.length, _jsx("small", { children: "Attempts" })] }), _jsxs("span", { children: [trace.result?.usage
21
+ ? `${trace.result.usage.input_tokens} / ${trace.result.usage.output_tokens}`
22
+ : "—", _jsx("small", { children: "Tokens in / out" })] })] })] }), _jsxs("div", { className: "detail-meta", children: [_jsx("code", { children: trace.result?.model || trace.request.model }), _jsx("code", { children: trace.requestId ||
23
+ (trace.status === "pending"
24
+ ? "Request ID pending"
25
+ : "No request ID") })] }), _jsx("nav", { className: "detail-tabs", "aria-label": "Request views", children: ["Flow", "Questions", "Context", "Transport", "Raw"].map((name) => (_jsx("button", { "aria-pressed": tab === name, onClick: () => {
26
+ // Keep the current view while its chunk loads, but don't
27
+ // defer subsequent opens once the component is available.
28
+ if (name === "Questions" && !questionsLoaded)
29
+ startTransition(() => setTab(name));
30
+ else
31
+ setTab(name);
32
+ }, children: name }, name))) }), _jsx("div", { className: `detail-body ${tab === "Flow" ? "detail-body-flow" : tab === "Questions" ? "detail-body-questions" : ""}`, children: _jsxs(Suspense, { fallback: _jsx("p", { className: "muted", role: "status", children: "Loading questions\u2026" }), children: [trace.error && (_jsxs("div", { role: "alert", className: "error-banner", children: [_jsx("strong", { children: trace.error.name }), " ", trace.error.message] })), trace.captureWarnings.map((w) => (_jsx("p", { className: "warning", children: w }, w))), tab === "Flow" && _jsx(TraceDiagram, { trace: trace }, trace.id), tab === "Questions" && (_jsx(Questions, { trace: trace }, trace.id)), tab === "Context" && (_jsxs(_Fragment, { children: [_jsx("p", { className: "muted", children: "Captured before dispatch. Known credential fields and email are redacted; captures are size limited." }), findImage(trace.request.state) && (_jsx("img", { className: "context-image", src: findImage(trace.request.state), alt: "Captured context" })), _jsx(JsonBlock, { value: trace.request.state }), trace.request.assets && (_jsx(JsonBlock, { value: trace.request.assets }))] })), tab === "Transport" && (_jsxs(_Fragment, { children: [_jsx("h3", { children: "HTTP attempts" }), _jsx("p", { className: "muted", children: "Attempt duration ends at response headers. Total duration includes SDK processing and retry waits." }), _jsxs("table", { children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { children: "Attempt" }), _jsx("th", { children: "Status" }), _jsx("th", { children: "Duration" })] }) }), _jsx("tbody", { children: trace.attempts.map((a) => (_jsxs("tr", { children: [_jsx("td", { children: a.number }), _jsx("td", { children: a.error || a.status || "Pending" }), _jsx("td", { children: ms(a.durationMs) })] }, a.number))) })] }), !trace.attempts.length && _jsx("p", { children: "No HTTP attempt recorded." })] })), tab === "Raw" && _jsx(JsonBlock, { value: trace })] }) })] })) : (_jsxs("div", { className: "empty-detail", children: [_jsx("span", { className: "empty-symbol", children: "\u21B3" }), _jsx("h2", { children: "Follow a Jev request" }), _jsxs("p", { children: ["Context, parallel questions, typed answers.", _jsx("br", {}), "Run a sample above to see the complete flow."] })] })) }));
33
+ });
36
34
  export function JevPanel({ traces, connection, onClear, }) {
37
35
  const [selected, setSelected] = useState();
38
- const [tab, setTab] = useState("Flow");
39
36
  const [search, setSearch] = useState("");
40
37
  const [status, setStatus] = useState("all");
41
38
  const [error, setError] = useState("");
42
- const filtered = traces.filter((t) => (status === "all" || status === t.status) &&
43
- `${t.label} ${t.request.model} ${t.id}`
44
- .toLowerCase()
45
- .includes(search.toLowerCase()));
39
+ const filtered = useMemo(() => {
40
+ const query = search.toLowerCase();
41
+ return traces.filter((t) => (status === "all" || status === t.status) &&
42
+ `${t.label} ${t.request.model} ${t.id}`.toLowerCase().includes(query));
43
+ }, [traces, search, status]);
46
44
  const trace = filtered.find((t) => t.id === selected) || filtered[0];
47
- return (_jsxs("div", { className: "jev-inspector", children: [_jsxs("aside", { className: "request-list", children: [_jsxs("header", { children: [_jsxs("h2", { children: ["Jev requests ", _jsx("span", { children: traces.length })] }), _jsx("button", { onClick: () => void onClear().catch((e) => setError(e.message)), disabled: !traces.length, children: "Clear" })] }), _jsxs("div", { className: "list-filters", children: [_jsx("input", { "aria-label": "Search requests", placeholder: "Search requests\u2026", value: search, onChange: (e) => setSearch(e.target.value) }), _jsx("select", { "aria-label": "Filter request status", value: status, onChange: (e) => setStatus(e.target.value), children: ["all", "pending", "success", "error", "cancelled"].map((s) => (_jsx("option", { children: s }, s))) })] }), _jsxs("div", { className: "request-items", children: [filtered.map((t) => (_jsxs("button", { className: `request-row ${trace?.id === t.id ? "selected" : ""}`, onClick: () => setSelected(t.id), children: [_jsx("span", { className: `status-dot ${t.status}` }), _jsxs("span", { children: [_jsx("strong", { children: t.label }), _jsxs("small", { children: [new Date(t.startedAt).toLocaleTimeString(), " \u00B7 ", t.source, " \u00B7", " ", ms(t.durationMs)] })] }), _jsx("span", { className: "row-status", children: t.status })] }, t.id))), !filtered.length && (_jsx("p", { className: "empty", children: traces.length
45
+ return (_jsxs("div", { className: "jev-inspector", children: [_jsxs("aside", { className: "request-list", children: [_jsxs("header", { children: [_jsxs("h2", { children: ["Jev requests ", _jsx("span", { children: traces.length })] }), _jsx("button", { onClick: () => void onClear().catch((e) => setError(e.message)), disabled: !traces.length, children: "Clear" })] }), _jsxs("div", { className: "list-filters", children: [_jsx("input", { "aria-label": "Search requests", placeholder: "Search requests\u2026", value: search, onChange: (e) => setSearch(e.target.value) }), _jsx("select", { "aria-label": "Filter request status", value: status, onChange: (e) => setStatus(e.target.value), children: ["all", "pending", "success", "error", "cancelled"].map((s) => (_jsx("option", { children: s }, s))) })] }), _jsxs("div", { className: "request-items", children: [filtered.map((t) => (_jsx(RequestRow, { trace: t, selected: trace?.id === t.id, onSelect: setSelected }, t.id))), !filtered.length && (_jsx("p", { className: "empty", children: traces.length
48
46
  ? "No matching requests."
49
- : "Run a scenario to capture your first request." }))] }), (connection !== "connected" || error) && (_jsxs("footer", { children: [connection !== "connected" && (_jsx("span", { role: "status", children: connection })), error && _jsx("p", { role: "alert", children: error })] }))] }), _jsx("section", { className: "request-detail", children: trace ? (_jsxs(_Fragment, { children: [_jsxs("header", { className: "detail-header", children: [_jsxs("div", { children: [_jsxs("span", { className: "eyebrow", children: [trace.source, " response \u00B7 ", trace.status] }), _jsx("h2", { children: trace.label })] }), _jsxs("div", { className: "metrics", children: [_jsxs("span", { children: [ms(trace.durationMs), _jsx("small", { children: "Total duration" })] }), _jsxs("span", { children: [trace.attempts.length, _jsx("small", { children: "Attempts" })] }), _jsxs("span", { children: [trace.result?.usage
50
- ? `${trace.result.usage.input_tokens} / ${trace.result.usage.output_tokens}`
51
- : "—", _jsx("small", { children: "Tokens in / out" })] })] })] }), _jsxs("div", { className: "detail-meta", children: [_jsx("code", { children: trace.result?.model || trace.request.model }), _jsx("code", { children: trace.requestId ||
52
- (trace.status === "pending"
53
- ? "Request ID pending"
54
- : "No request ID") })] }), _jsx("nav", { className: "detail-tabs", "aria-label": "Request views", children: ["Flow", "Questions", "Context", "Transport", "Raw"].map((name) => (_jsx("button", { "aria-pressed": tab === name, onClick: () => setTab(name), children: name }, name))) }), _jsxs("div", { className: `detail-body ${tab === "Flow" ? "detail-body-flow" : ""}`, children: [trace.error && (_jsxs("div", { role: "alert", className: "error-banner", children: [_jsx("strong", { children: trace.error.name }), " ", trace.error.message] })), trace.captureWarnings.map((w) => (_jsx("p", { className: "warning", children: w }, w))), tab === "Flow" && _jsx(TraceDiagram, { trace: trace }, trace.id), tab === "Questions" &&
55
- Object.entries(trace.request.questions ?? {})
56
- .filter(([, q]) => q && typeof q === "object" && typeof q.type === "string")
57
- .map(([key, q]) => (_jsxs("article", { className: "question-node standalone", children: [_jsxs("header", { children: [_jsx("h3", { children: key }), _jsx("span", { className: "type", children: q.type })] }), _jsx(JsonBlock, { value: q }), _jsx(Answer, { answer: trace.result?.answers[key], image: findImage(trace.request.state) })] }, key))), tab === "Context" && (_jsxs(_Fragment, { children: [_jsx("p", { className: "muted", children: "Captured before dispatch. Known credential fields and email are redacted; captures are size limited." }), findImage(trace.request.state) && (_jsx("img", { className: "context-image", src: findImage(trace.request.state), alt: "Captured context" })), _jsx(JsonBlock, { value: trace.request.state }), trace.request.assets && (_jsx(JsonBlock, { value: trace.request.assets }))] })), tab === "Transport" && (_jsxs(_Fragment, { children: [_jsx("h3", { children: "HTTP attempts" }), _jsx("p", { className: "muted", children: "Attempt duration ends at response headers. Total duration includes SDK processing and retry waits." }), _jsxs("table", { children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { children: "Attempt" }), _jsx("th", { children: "Status" }), _jsx("th", { children: "Duration" })] }) }), _jsx("tbody", { children: trace.attempts.map((a) => (_jsxs("tr", { children: [_jsx("td", { children: a.number }), _jsx("td", { children: a.error || a.status || "Pending" }), _jsx("td", { children: ms(a.durationMs) })] }, a.number))) })] }), !trace.attempts.length && _jsx("p", { children: "No HTTP attempt recorded." })] })), tab === "Raw" && _jsx(JsonBlock, { value: trace })] })] })) : (_jsxs("div", { className: "empty-detail", children: [_jsx("span", { className: "empty-symbol", children: "\u21B3" }), _jsx("h2", { children: "Follow a Jev request" }), _jsxs("p", { children: ["Context, parallel questions, typed answers.", _jsx("br", {}), "Run a sample above to see the complete flow."] })] })) })] }));
47
+ : "Run a scenario to capture your first request." }))] }), (connection !== "connected" || error) && (_jsxs("footer", { children: [connection !== "connected" && (_jsx("span", { role: "status", children: connection })), error && _jsx("p", { role: "alert", children: error })] }))] }), _jsx(RequestDetail, { trace: trace })] }));
58
48
  }
@@ -1,6 +1,7 @@
1
1
  import { type ReactNode } from "react";
2
2
  /** Non-modal drawer: the app remains usable and children stay mounted when closed. */
3
- export declare function JevDrawer({ children, defaultOpen, }: {
3
+ export declare function JevDrawer({ children, open, onOpenChange, }: {
4
4
  children: ReactNode;
5
- defaultOpen?: boolean;
5
+ open: boolean;
6
+ onOpenChange: (open: boolean) => void;
6
7
  }): import("react").ReactPortal | null;
@@ -25,10 +25,9 @@ function initialHeight() {
25
25
  return clamp(Math.min(420, viewportHeight() * 0.56), viewportHeight());
26
26
  }
27
27
  /** Non-modal drawer: the app remains usable and children stay mounted when closed. */
28
- export function JevDrawer({ children, defaultOpen = true, }) {
28
+ export function JevDrawer({ children, open, onOpenChange, }) {
29
29
  const id = useId();
30
30
  const [root, setRoot] = useState(null);
31
- const [open, setOpen] = useState(defaultOpen);
32
31
  const [expanded, setExpanded] = useState(false);
33
32
  const [viewport, setViewport] = useState(viewportHeight);
34
33
  const [height, setHeight] = useState(initialHeight);
@@ -39,6 +38,16 @@ export function JevDrawer({ children, defaultOpen = true, }) {
39
38
  const drag = useRef(null);
40
39
  const { min, max } = limits(viewport);
41
40
  const visibleHeight = expanded ? max : clamp(height, viewport);
41
+ useEffect(() => {
42
+ // Retain the diagram and its camera, but suspend native SVG timelines while
43
+ // hidden. This does not change the user's Pause/Play flow preference.
44
+ panel.current?.querySelectorAll("svg").forEach((svg) => {
45
+ if (open)
46
+ svg.unpauseAnimations();
47
+ else
48
+ svg.pauseAnimations();
49
+ });
50
+ }, [open, root]);
42
51
  useEffect(() => {
43
52
  const host = document.createElement("jev-devtools-root");
44
53
  // The host is the only element exposed to page selectors. The overlay itself
@@ -76,7 +85,7 @@ export function JevDrawer({ children, defaultOpen = true, }) {
76
85
  }, [open, root]);
77
86
  const toggle = (value) => {
78
87
  interacted.current = true;
79
- setOpen(value);
88
+ onOpenChange(value);
80
89
  };
81
90
  const resizeTo = (value) => {
82
91
  setExpanded(false);
@@ -0,0 +1,5 @@
1
+ import type { Trace } from "./protocol.js";
2
+ /** Variable-height rows retain full answers; only offscreen DOM is omitted. */
3
+ export declare function Questions({ trace }: {
4
+ trace: Trace;
5
+ }): import("react").JSX.Element;