@narumitw/pi-inspect 0.0.0-stage → 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.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +110 -2
  3. package/dist/app.css +1 -0
  4. package/dist/app.js +128 -0
  5. package/dist/index.html +1 -0
  6. package/docs/REVIEW.md +189 -0
  7. package/docs/VERIFICATION.md +186 -0
  8. package/docs/viewer.md +74 -0
  9. package/package.json +67 -4
  10. package/src/ancestry.ts +32 -0
  11. package/src/collector.ts +143 -0
  12. package/src/context.ts +115 -0
  13. package/src/correlation.ts +27 -0
  14. package/src/entry-index.ts +17 -0
  15. package/src/entry-summary.ts +34 -0
  16. package/src/extension.ts +259 -0
  17. package/src/feed.ts +145 -0
  18. package/src/identity.ts +112 -0
  19. package/src/index.ts +1 -0
  20. package/src/model.ts +117 -0
  21. package/src/native-projection.ts +28 -0
  22. package/src/privacy.ts +136 -0
  23. package/src/projection.ts +374 -0
  24. package/src/server.ts +202 -0
  25. package/src/system-message.ts +117 -0
  26. package/src/web/api.ts +137 -0
  27. package/src/web/app.tsx +570 -0
  28. package/src/web/captured-executions.tsx +177 -0
  29. package/src/web/components.css +261 -0
  30. package/src/web/components.tsx +241 -0
  31. package/src/web/composition.css +554 -0
  32. package/src/web/context-composition.tsx +329 -0
  33. package/src/web/context-inventory.tsx +65 -0
  34. package/src/web/entry-calls.ts +14 -0
  35. package/src/web/format.ts +94 -0
  36. package/src/web/hierarchy.ts +117 -0
  37. package/src/web/history.ts +56 -0
  38. package/src/web/inline-entry.tsx +124 -0
  39. package/src/web/inspector.tsx +386 -0
  40. package/src/web/json-tree.tsx +74 -0
  41. package/src/web/overview.tsx +67 -0
  42. package/src/web/panels.css +267 -0
  43. package/src/web/panes.tsx +123 -0
  44. package/src/web/recorded-content.tsx +49 -0
  45. package/src/web/search.ts +16 -0
  46. package/src/web/session-filters.tsx +100 -0
  47. package/src/web/session.css +114 -0
  48. package/src/web/style.css +332 -0
  49. package/src/web/styles.d.ts +1 -0
  50. package/src/web/timeline.tsx +33 -0
  51. package/src/web/timing.ts +26 -0
  52. package/src/web/trace.css +411 -0
  53. package/src/web/trace.tsx +313 -0
  54. package/src/web/virtual-context.ts +138 -0
@@ -0,0 +1,570 @@
1
+ import { HamburgerMenuIcon, MoonIcon, ReaderIcon, SunIcon } from "@radix-ui/react-icons";
2
+ import { Badge, Callout, Heading, IconButton, Theme } from "@radix-ui/themes";
3
+ import { useEffect, useMemo, useRef, useState } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import "@radix-ui/themes/styles.css";
6
+ import "./style.css";
7
+ import "./composition.css";
8
+ import "./session.css";
9
+ import type { BranchView, ContextComposition as Composition, Snapshot } from "../model.js";
10
+ import { generation, headers, RequestFailure, request, useDetail } from "./api.js";
11
+ import { CapturedExecutions } from "./captured-executions.js";
12
+ import { ContextComposition } from "./context-composition.js";
13
+ import { ContextInventory } from "./context-inventory.js";
14
+ import { type Filters, matches } from "./format.js";
15
+ import { history, internalEntry } from "./history.js";
16
+ import { Inspector } from "./inspector.js";
17
+ import { Overview } from "./overview.js";
18
+ import { PaneDrawer, ResizeHandle, useNarrow } from "./panes.js";
19
+ import { boundedSearch, searchNeedle } from "./search.js";
20
+ import { SessionFilters } from "./session-filters.js";
21
+ import { Trace } from "./trace.js";
22
+
23
+ type Selection = { kind: "entry" | "call"; id: string; serial: number; filterVersion?: number; anchor?: string | null };
24
+ function App() {
25
+ const [appearance, setAppearance] = useState<"dark" | "light">("light");
26
+ const [revision, setRevision] = useState(-1);
27
+ const [snapshot, setSnapshot] = useState<Snapshot>();
28
+ const [branch, setBranch] = useState<BranchView>();
29
+ const [branchError, setBranchError] = useState("");
30
+ const [branchAttempt, setBranchAttempt] = useState(0);
31
+ const branchOwner = useRef<{ entryId: string; attempt: number } | undefined>(undefined);
32
+ const [selection, setSelection] = useState<Selection>();
33
+ const [view, setView] = useState("list");
34
+ const [surface, setSurface] = useState("context");
35
+ const [sessionView, setSessionView] = useState("history");
36
+ const [historyLeaf, setHistoryLeaf] = useState<string | null>(null);
37
+ const [showInternal, setShowInternal] = useState(false);
38
+ const [preview, setPreview] = useState<Composition>();
39
+ const [filters, setFilters] = useState<Filters>({
40
+ query: "",
41
+ kind: "all",
42
+ groups: [],
43
+ errorsOnly: false,
44
+ slowOnly: false,
45
+ });
46
+ const [filterVersion, setFilterVersion] = useState(0);
47
+ const filterEpoch = useRef(0);
48
+ function changeFilters(value: Filters) {
49
+ setFilters({ ...value, query: boundedSearch(value.query) });
50
+ setFilterVersion(++filterEpoch.current);
51
+ }
52
+ const [error, setError] = useState("");
53
+ const [connected, setConnected] = useState(false);
54
+ const lifecycle = useRef<{ terminal: boolean; stream?: AbortController }>({ terminal: false });
55
+ const [tab, setTab] = useState("content");
56
+ const navTrigger = useRef<HTMLButtonElement>(null);
57
+ const inspectorTrigger = useRef<HTMLButtonElement>(null);
58
+ const [navOpen, setNavOpen] = useState(false);
59
+ const [inspectorOpen, setInspectorOpen] = useState(false);
60
+ const [navDrawer, setNavDrawer] = useState(false);
61
+ const [inspectorDrawer, setInspectorDrawer] = useState(false);
62
+ const [navWidth, setNavWidth] = useState(() => Math.max(200, Math.min(360, window.innerWidth * 0.185)));
63
+ const [inspectorWidth, setInspectorWidth] = useState(() => Math.max(280, Math.min(520, window.innerWidth * 0.235)));
64
+ const narrow = useNarrow();
65
+ const sideBudget = Math.max(0, window.innerWidth - 600);
66
+ const navSize = navOpen ? Math.min(navWidth, inspectorOpen ? sideBudget * 0.4 : sideBudget) : 0;
67
+ const inspectorSize = inspectorOpen ? Math.min(inspectorWidth, navOpen ? sideBudget * 0.6 : sideBudget) : 0;
68
+ const calls = snapshot?.calls ?? [];
69
+ const entries = snapshot?.nodes ?? [];
70
+ const selectedCall =
71
+ selection?.kind === "call" ? calls.find((call) => call.occurrenceId === selection.id) : undefined;
72
+ const represented = useMemo(() => new Set(entries.map((entry) => entry.id)), [entries]);
73
+ const callAnchorAvailable = Boolean(selectedCall?.branchAnchor && represented.has(selectedCall.branchAnchor));
74
+ const entryId =
75
+ selection?.kind === "entry" ? selection.id : callAnchorAvailable ? (selectedCall?.branchAnchor ?? "") : "";
76
+ const paneActive =
77
+ surface === "events" && revision >= 0 && (inspectorOpen || inspectorDrawer) && selection?.kind === "entry";
78
+ const { detail, error: detailError, retry: retryDetails } = useDetail(paneActive ? entryId : undefined);
79
+ const previousSelection = useRef<Selection | undefined>(undefined);
80
+ useEffect(() => {
81
+ if (sessionView === "executions" && previousSelection.current?.kind === "call" && selection?.kind === "entry")
82
+ setSessionView("branches");
83
+ previousSelection.current = selection;
84
+ }, [selection, sessionView]);
85
+ const selectedNode = entries.find((node) => node.id === entryId);
86
+ const preparedFilters = useMemo(() => ({ ...filters, query: searchNeedle(filters.query) }), [filters]);
87
+ const matching = useMemo(
88
+ () => new Set(entries.filter((node) => matches(node, preparedFilters, calls)).map((node) => node.id)),
89
+ [entries, calls, preparedFilters],
90
+ );
91
+ function selectEntry(id: string) {
92
+ setSurface("events");
93
+ if (sessionView === "executions") setSessionView("branches");
94
+ if (narrow) setInspectorDrawer(true);
95
+ else setInspectorOpen(true);
96
+ setSelection((old) => ({ kind: "entry", id, serial: (old?.serial ?? 0) + 1, filterVersion }));
97
+ }
98
+ function selectCall(id: string) {
99
+ if (narrow) setInspectorDrawer(true);
100
+ else setInspectorOpen(true);
101
+ setSelection((old) => ({
102
+ kind: "call",
103
+ id,
104
+ serial: (old?.serial ?? 0) + 1,
105
+ filterVersion,
106
+ anchor: calls.find((call) => call.occurrenceId === id)?.branchAnchor,
107
+ }));
108
+ setSurface("events");
109
+ setSessionView("executions");
110
+ setTab("content");
111
+ }
112
+ useEffect(() => {
113
+ const controller = new AbortController();
114
+ let retry: ReturnType<typeof setTimeout> | undefined;
115
+ let refresh: ReturnType<typeof setTimeout> | undefined;
116
+ lifecycle.current.stream = controller;
117
+ const stop = (message: string) => {
118
+ lifecycle.current.terminal = true;
119
+ setRevision(-1);
120
+ controller.abort();
121
+ clearTimeout(refresh);
122
+ setConnected(false);
123
+ setError(message);
124
+ };
125
+ const connect = async () => {
126
+ try {
127
+ const response = await fetch(`/api/events?generation=${encodeURIComponent(generation)}`, {
128
+ headers: headers(),
129
+ signal: controller.signal,
130
+ });
131
+ if (controller.signal.aborted) return;
132
+ if ([401, 403, 409, 410].includes(response.status)) {
133
+ stop("Session expired or unauthorized; open the viewer again from Pi.");
134
+ return;
135
+ }
136
+ if (!response.ok || !response.body) throw new Error("Transient viewer refusal");
137
+ setConnected(true);
138
+ const reader = response.body.getReader();
139
+ const decoder = new TextDecoder();
140
+ let buffer = "";
141
+ try {
142
+ while (!controller.signal.aborted) {
143
+ const { value, done } = await reader.read();
144
+ if (done || controller.signal.aborted) break;
145
+ buffer += decoder.decode(value, { stream: true });
146
+ let end = buffer.indexOf("\n\n");
147
+ while (end !== -1) {
148
+ const frame = buffer.slice(0, end);
149
+ buffer = buffer.slice(end + 2);
150
+ const line = frame.split("\n").find((s) => s.startsWith("data: "));
151
+ if (line) {
152
+ const data = JSON.parse(line.slice(6)) as { protocol: number; generation: string; revision: number };
153
+ if (data.protocol !== 1 || data.generation !== generation) {
154
+ stop("Session changed; open the viewer again from Pi.");
155
+ return;
156
+ }
157
+ if (!refresh)
158
+ refresh = setTimeout(() => {
159
+ refresh = undefined;
160
+ setRevision((previous) => previous + 1);
161
+ }, 50);
162
+ }
163
+ end = buffer.indexOf("\n\n");
164
+ }
165
+ }
166
+ } finally {
167
+ await reader.cancel().catch(() => {});
168
+ reader.releaseLock();
169
+ }
170
+ } catch {
171
+ /* Offline state is visible; reconnect uses a fresh snapshot, not stale replay. */
172
+ }
173
+ if (!controller.signal.aborted) {
174
+ setConnected(false);
175
+ retry = setTimeout(() => void connect(), 1000);
176
+ }
177
+ };
178
+ void connect();
179
+ return () => {
180
+ controller.abort();
181
+ clearTimeout(retry);
182
+ clearTimeout(refresh);
183
+ };
184
+ }, []);
185
+
186
+ useEffect(() => {
187
+ if (revision < 0) return;
188
+ const controller = new AbortController();
189
+ let retry: ReturnType<typeof setTimeout> | undefined;
190
+ const load = () => {
191
+ if (lifecycle.current.terminal || controller.signal.aborted) return;
192
+ void request<Snapshot>("snapshot", controller.signal)
193
+ .then((value) => {
194
+ if (controller.signal.aborted || lifecycle.current.terminal) return;
195
+ setSnapshot(value);
196
+ setError("");
197
+ const represented = new Set(value.nodes.map((node) => node.id));
198
+ const id = value.leafId && represented.has(value.leafId) ? value.leafId : value.nodes[0]?.id;
199
+ setSelection((old) => {
200
+ if (old?.kind === "call" && !value.calls.some((call) => call.occurrenceId === old.id)) {
201
+ const fallback = old.anchor && represented.has(old.anchor) ? old.anchor : id;
202
+ return fallback
203
+ ? { kind: "entry", id: fallback, serial: old.serial + 1, filterVersion: filterEpoch.current }
204
+ : undefined;
205
+ }
206
+ if (old?.kind === "entry" && !represented.has(old.id)) {
207
+ return id ? { kind: "entry", id, serial: old.serial + 1, filterVersion: filterEpoch.current } : undefined;
208
+ }
209
+ return old ?? (id ? { kind: "entry", id, serial: 0 } : undefined);
210
+ });
211
+ })
212
+ .catch((error) => {
213
+ if (!controller.signal.aborted && !lifecycle.current.terminal) {
214
+ setError(String(error.message));
215
+ if (error instanceof RequestFailure && error.terminal) {
216
+ lifecycle.current.terminal = true;
217
+ lifecycle.current.stream?.abort();
218
+ setConnected(false);
219
+ setRevision(-1);
220
+ } else retry = setTimeout(load, 1000);
221
+ }
222
+ });
223
+ };
224
+ load();
225
+ return () => {
226
+ controller.abort();
227
+ clearTimeout(retry);
228
+ };
229
+ }, [revision]);
230
+ useEffect(() => {
231
+ setBranchError("");
232
+ if (!entryId || !paneActive) {
233
+ branchOwner.current = undefined;
234
+ setBranch(undefined);
235
+ return;
236
+ }
237
+ const controller = new AbortController();
238
+ setBranch(undefined);
239
+ const owner = { entryId, attempt: branchAttempt };
240
+ branchOwner.current = owner;
241
+ void request<BranchView>(`branch?leaf=${encodeURIComponent(entryId)}&offset=0`, controller.signal)
242
+ .then((value) => {
243
+ if (!controller.signal.aborted && branchOwner.current === owner) setBranch(value);
244
+ })
245
+ .catch(() => {
246
+ if (!controller.signal.aborted && branchOwner.current === owner)
247
+ setBranchError("Could not load selected branch context.");
248
+ });
249
+ return () => controller.abort();
250
+ }, [entryId, branchAttempt, paneActive]);
251
+
252
+ const scoped = useMemo(
253
+ () => history(entries, historyLeaf ?? snapshot?.leafId ?? null),
254
+ [entries, historyLeaf, snapshot?.leafId],
255
+ );
256
+ const historyRows = useMemo(
257
+ () =>
258
+ scoped.rows.filter(
259
+ ({ node }) =>
260
+ showInternal || !internalEntry(node) || (selection?.filterVersion === filterVersion && node.id === entryId),
261
+ ),
262
+ [scoped.rows, showInternal, selection?.filterVersion, filterVersion, entryId],
263
+ );
264
+ const visibleHistory = useMemo(() => new Set(historyRows.map(({ node }) => node.id)), [historyRows]);
265
+ const hiddenMatches = scoped.rows.filter(
266
+ ({ node }) => internalEntry(node) && matching.has(node.id) && !visibleHistory.has(node.id),
267
+ ).length;
268
+ const sidebar = <SessionFilters filters={filters} change={changeFilters} />;
269
+ const selectedBranch = branch?.leafId === entryId ? branch : undefined;
270
+ const inspector = (
271
+ <Inspector
272
+ selected={selection?.id ?? ""}
273
+ node={selectedNode}
274
+ detail={detail}
275
+ detailError={detailError}
276
+ retryDetails={retryDetails}
277
+ call={selectedCall}
278
+ branch={selectedBranch}
279
+ branchError={branchOwner.current?.entryId === entryId ? branchError : ""}
280
+ retryBranch={() => setBranchAttempt((attempt) => attempt + 1)}
281
+ snapshot={snapshot}
282
+ tab={tab}
283
+ changeTab={setTab}
284
+ select={selectEntry}
285
+ selectCall={selectCall}
286
+ preview={() => {
287
+ if (selectedBranch?.context) {
288
+ setPreview(selectedBranch.context);
289
+ setSurface("context");
290
+ setInspectorDrawer(false);
291
+ setNavDrawer(false);
292
+ }
293
+ }}
294
+ />
295
+ );
296
+ return (
297
+ <Theme
298
+ className="inspector-app"
299
+ appearance={appearance}
300
+ accentColor="blue"
301
+ grayColor="slate"
302
+ radius="medium"
303
+ scaling="100%"
304
+ >
305
+ <header className="app-header">
306
+ <div className="brand">
307
+ <div className="brand-logo" aria-hidden="true">
308
+ π
309
+ </div>
310
+ <Heading size="4">Pi Session Inspector</Heading>
311
+ </div>
312
+ <div className="header-context">
313
+ <span title={snapshot?.nameTruncated ? "Session name truncated to 512 characters" : undefined}>
314
+ {snapshot?.name ?? "Connecting"}
315
+ {snapshot?.nameTruncated && " · truncated"}
316
+ </span>
317
+ <span title={snapshot?.sessionId}>
318
+ Session: <strong>{typeof snapshot?.sessionId === "string" ? snapshot.sessionId.slice(0, 8) : "—"}</strong>
319
+ </span>
320
+ </div>
321
+ <div className="header-actions">
322
+ <fieldset className="surface-switch" aria-label="Inspection source">
323
+ <button
324
+ type="button"
325
+ aria-pressed={surface === "context"}
326
+ onClick={() => {
327
+ setPreview(undefined);
328
+ setSurface("context");
329
+ setInspectorDrawer(false);
330
+ setNavDrawer(false);
331
+ }}
332
+ >
333
+ Context
334
+ </button>
335
+ <button type="button" aria-pressed={surface === "events"} onClick={() => setSurface("events")}>
336
+ Session
337
+ </button>
338
+ </fieldset>
339
+ <IconButton
340
+ hidden={surface !== "events"}
341
+ ref={navTrigger}
342
+ aria-label="Toggle filters"
343
+ aria-expanded={narrow ? navDrawer : navOpen}
344
+ variant="ghost"
345
+ onClick={() => (narrow ? setNavDrawer(!navDrawer) : setNavOpen(!navOpen))}
346
+ >
347
+ <HamburgerMenuIcon />
348
+ </IconButton>
349
+ <IconButton
350
+ hidden={surface !== "events"}
351
+ ref={inspectorTrigger}
352
+ aria-label="Toggle details"
353
+ aria-expanded={narrow ? inspectorDrawer : inspectorOpen}
354
+ variant="ghost"
355
+ onClick={() => (narrow ? setInspectorDrawer(!inspectorDrawer) : setInspectorOpen(!inspectorOpen))}
356
+ >
357
+ <ReaderIcon />
358
+ </IconButton>
359
+ <Badge color={connected ? "green" : "amber"}>{connected ? "Live" : "Disconnected"}</Badge>
360
+ <Badge>Read-only</Badge>
361
+ <IconButton
362
+ aria-label="Toggle appearance"
363
+ variant="ghost"
364
+ onClick={() => setAppearance(appearance === "dark" ? "light" : "dark")}
365
+ >
366
+ {appearance === "dark" ? <SunIcon /> : <MoonIcon />}
367
+ </IconButton>
368
+ </div>
369
+ </header>
370
+ {(error || snapshot?.incomplete) && (
371
+ <div className="app-notices">
372
+ {error && (
373
+ <Callout.Root color="red">
374
+ <Callout.Text>{error}</Callout.Text>
375
+ </Callout.Root>
376
+ )}
377
+ {snapshot?.incomplete && (
378
+ <Callout.Root color="amber">
379
+ <Callout.Text>Indexed data is incomplete; existing entry/capture limits apply.</Callout.Text>
380
+ </Callout.Root>
381
+ )}
382
+ </div>
383
+ )}
384
+ <main
385
+ className={`app-grid ${narrow ? "narrow-layout" : ""}`}
386
+ style={
387
+ narrow
388
+ ? undefined
389
+ : {
390
+ gridTemplateColumns:
391
+ surface === "context"
392
+ ? "0px 0px minmax(0, 1fr) 0px 0px"
393
+ : `${navSize}px ${navOpen ? 6 : 0}px minmax(0, 1fr) ${inspectorOpen ? 6 : 0}px ${inspectorSize}px`,
394
+ }
395
+ }
396
+ >
397
+ {!narrow && surface === "events" && (
398
+ <>
399
+ <div className="side-slot nav-slot" hidden={!navOpen}>
400
+ {sidebar}
401
+ </div>
402
+ {navOpen && (
403
+ <ResizeHandle name="Resize filters" value={navWidth} change={setNavWidth} min={180} max={420} />
404
+ )}
405
+ </>
406
+ )}
407
+ <div className="center-column">
408
+ <div className="context-surface" hidden={surface !== "context"}>
409
+ <div className="observed-context" hidden={Boolean(preview)}>
410
+ <ContextComposition context={snapshot?.context} payload={snapshot?.providerObservation} />
411
+ <ContextInventory snapshot={snapshot} />
412
+ </div>
413
+ {preview && (
414
+ <div className="preview-context">
415
+ <div className="preview-heading">
416
+ <strong>Session-derived preview · leaf {preview.leafId}</strong>
417
+ <button type="button" onClick={() => setPreview(undefined)}>
418
+ Back to last observed context
419
+ </button>
420
+ </div>
421
+ <ContextComposition context={preview} preview />
422
+ </div>
423
+ )}
424
+ </div>
425
+ <div className="events-surface" hidden={surface !== "events"}>
426
+ <Overview snapshot={snapshot} />
427
+ <div className="session-toolbar">
428
+ <fieldset className="view-switch" aria-label="Session navigation">
429
+ {[
430
+ ["history", "History"],
431
+ ["branches", "Branch view"],
432
+ ["executions", "Captured executions"],
433
+ ].map(([value, label]) => (
434
+ <button
435
+ type="button"
436
+ key={value}
437
+ aria-pressed={sessionView === value}
438
+ onClick={() => setSessionView(value ?? "history")}
439
+ >
440
+ {label}
441
+ {value === "executions"
442
+ ? ` · ${calls.filter((call) => call.status === "running").length} running · ${calls.filter((call) => call.status === "error").length} errors`
443
+ : ""}
444
+ </button>
445
+ ))}
446
+ </fieldset>
447
+ {sessionView === "history" && (
448
+ <>
449
+ <span>Branch leaf: {historyLeaf ?? snapshot?.leafId ?? "unavailable"}</span>
450
+ <label>
451
+ <input
452
+ type="checkbox"
453
+ checked={showInternal}
454
+ onChange={(event) => setShowInternal(event.target.checked)}
455
+ />{" "}
456
+ Show internal events
457
+ </label>
458
+ {!showInternal && <span>{hiddenMatches} matching internal events hidden</span>}
459
+ {historyLeaf && (
460
+ <button type="button" onClick={() => setHistoryLeaf(null)}>
461
+ Follow active branch
462
+ </button>
463
+ )}
464
+ {scoped.issue && <span role="status">{scoped.issue}</span>}
465
+ {entryId && !scoped.rows.some(({ node }) => node.id === entryId) && (
466
+ <span role="status">Selected entry is outside this branch; use Branch view to locate it.</span>
467
+ )}
468
+ </>
469
+ )}
470
+ {sessionView === "branches" && entryId && (
471
+ <button
472
+ type="button"
473
+ onClick={() => {
474
+ setHistoryLeaf(entryId);
475
+ setSessionView("history");
476
+ }}
477
+ >
478
+ History at selected entry
479
+ </button>
480
+ )}
481
+ </div>
482
+ <div className="session-primary" hidden={sessionView !== "history"}>
483
+ <Trace
484
+ entries={entries}
485
+ matches={matching}
486
+ selected={entryId}
487
+ serial={selection?.serial ?? 0}
488
+ revealSelected={selection?.filterVersion === filterVersion}
489
+ select={selectEntry}
490
+ view={view}
491
+ changeView={setView}
492
+ calls={calls}
493
+ historyRows={historyRows}
494
+ active={surface === "events" && sessionView === "history" && revision >= 0}
495
+ />
496
+ </div>
497
+ <div className="session-primary" hidden={sessionView !== "branches"}>
498
+ <Trace
499
+ entries={entries}
500
+ matches={matching}
501
+ selected={entryId}
502
+ serial={selection?.serial ?? 0}
503
+ revealSelected={selection?.filterVersion === filterVersion}
504
+ select={selectEntry}
505
+ view={view}
506
+ changeView={setView}
507
+ calls={calls}
508
+ active={surface === "events" && sessionView === "branches" && revision >= 0}
509
+ />
510
+ </div>
511
+ <div className="session-primary execution-primary" hidden={sessionView !== "executions"}>
512
+ <CapturedExecutions
513
+ entries={entries}
514
+ calls={calls}
515
+ selected={selectedCall?.occurrenceId}
516
+ select={selectCall}
517
+ open={surface === "events" && sessionView === "executions"}
518
+ view={view}
519
+ changeView={setView}
520
+ dropped={snapshot?.droppedCalls ?? 0}
521
+ invalidEvents={snapshot?.invalidCallEvents ?? 0}
522
+ filters={filters}
523
+ revealSelected={selection?.filterVersion === filterVersion}
524
+ />
525
+ </div>
526
+ </div>
527
+ </div>
528
+ {!narrow && surface === "events" && (
529
+ <>
530
+ {inspectorOpen ? (
531
+ <ResizeHandle
532
+ name="Resize details"
533
+ value={inspectorWidth}
534
+ change={setInspectorWidth}
535
+ min={260}
536
+ max={600}
537
+ inverse
538
+ />
539
+ ) : null}
540
+ <div className="side-slot inspector-slot" hidden={!inspectorOpen}>
541
+ {inspector}
542
+ </div>
543
+ </>
544
+ )}
545
+ </main>
546
+ {narrow && surface === "events" && (
547
+ <>
548
+ <PaneDrawer trigger={navTrigger} name="Session filters" side="left" open={navDrawer} change={setNavDrawer}>
549
+ {sidebar}
550
+ </PaneDrawer>
551
+ <PaneDrawer
552
+ trigger={inspectorTrigger}
553
+ name="Details"
554
+ side="right"
555
+ open={inspectorDrawer}
556
+ change={setInspectorDrawer}
557
+ >
558
+ {inspector}
559
+ </PaneDrawer>
560
+ </>
561
+ )}
562
+ <footer className="app-footer">
563
+ <span>{connected ? "Connected · observing live events" : "Disconnected · reopen from Pi"}</span>
564
+ <span>Read-only · bounded display copies · no provider transport spans</span>
565
+ </footer>
566
+ </Theme>
567
+ );
568
+ }
569
+ const root = document.getElementById("root");
570
+ if (root) createRoot(root).render(<App />);