@narumitw/pi-inspect 0.0.0-stage → 0.1.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 (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +107 -2
  3. package/dist/app.css +1 -0
  4. package/dist/app.js +129 -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 +63 -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/extension.ts +259 -0
  16. package/src/feed.ts +145 -0
  17. package/src/identity.ts +112 -0
  18. package/src/index.ts +1 -0
  19. package/src/model.ts +113 -0
  20. package/src/native-projection.ts +28 -0
  21. package/src/privacy.ts +136 -0
  22. package/src/projection.ts +361 -0
  23. package/src/server.ts +202 -0
  24. package/src/system-message.ts +117 -0
  25. package/src/web/api.ts +137 -0
  26. package/src/web/app.tsx +439 -0
  27. package/src/web/components.css +261 -0
  28. package/src/web/components.tsx +289 -0
  29. package/src/web/composition.css +554 -0
  30. package/src/web/context-composition.tsx +324 -0
  31. package/src/web/entry-calls.ts +14 -0
  32. package/src/web/format.ts +92 -0
  33. package/src/web/hierarchy.ts +117 -0
  34. package/src/web/inline-entry.tsx +124 -0
  35. package/src/web/inspector.tsx +337 -0
  36. package/src/web/json-tree.tsx +74 -0
  37. package/src/web/live-drawer.tsx +175 -0
  38. package/src/web/overview.tsx +67 -0
  39. package/src/web/panels.css +267 -0
  40. package/src/web/panes.tsx +123 -0
  41. package/src/web/search.ts +16 -0
  42. package/src/web/style.css +332 -0
  43. package/src/web/styles.d.ts +1 -0
  44. package/src/web/timeline.tsx +33 -0
  45. package/src/web/timing.ts +26 -0
  46. package/src/web/trace.css +410 -0
  47. package/src/web/trace.tsx +273 -0
  48. package/src/web/tree.tsx +281 -0
  49. package/src/web/virtual-context.ts +138 -0
package/src/web/api.ts ADDED
@@ -0,0 +1,137 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import type { DetailView } from "../model.js";
3
+
4
+ // Storage is optional reload persistence; a valid fragment must work in restricted profiles.
5
+ function stored(key: string): string | null {
6
+ try {
7
+ return sessionStorage.getItem(key);
8
+ } catch {
9
+ return null;
10
+ }
11
+ }
12
+ function remember(key: string, value: string): void {
13
+ if (!value) return;
14
+ try {
15
+ sessionStorage.setItem(key, value);
16
+ } catch {
17
+ // Keep credentials in memory for this page; fragment-free reloads must reopen from Pi.
18
+ }
19
+ }
20
+ const fragment = location.hash.slice(1);
21
+ const params = new URLSearchParams(fragment);
22
+ // Continue accepting legacy #token=...&generation=... URLs and fragment-free browser reloads.
23
+ const token = /^[\w-]{43}$/.test(fragment) ? fragment : (params.get("token") ?? stored("inspector-token") ?? "");
24
+ const shellGeneration = document.querySelector<HTMLMetaElement>('meta[name="inspector-generation"]')?.content;
25
+ export const generation =
26
+ params.get("generation") ??
27
+ (shellGeneration ? decodeURIComponent(shellGeneration) : (stored("inspector-generation") ?? ""));
28
+ remember("inspector-token", token);
29
+ remember("inspector-generation", generation);
30
+ history.replaceState(null, "", location.pathname);
31
+ export function headers(): HeadersInit {
32
+ return { "X-Inspector-Token": token };
33
+ }
34
+ export class RequestFailure extends Error {
35
+ constructor(
36
+ message: string,
37
+ readonly terminal: boolean,
38
+ ) {
39
+ super(message);
40
+ }
41
+ }
42
+ export async function request<T>(route: string, signal: AbortSignal): Promise<T> {
43
+ const response = await fetch(
44
+ `/api/${route}${route.includes("?") ? "&" : "?"}generation=${encodeURIComponent(generation)}`,
45
+ { headers: headers(), signal, cache: "no-store" },
46
+ );
47
+ if (!response.ok)
48
+ throw new RequestFailure(
49
+ [401, 403, 409, 410].includes(response.status)
50
+ ? "Session expired; open a new viewer from Pi."
51
+ : "Viewer unavailable; reconnecting requires a running Pi session.",
52
+ [401, 403, 409, 410].includes(response.status),
53
+ );
54
+ return response.json() as Promise<T>;
55
+ }
56
+ interface Job {
57
+ signal: AbortSignal;
58
+ run(): Promise<void>;
59
+ cancel(): void;
60
+ }
61
+ const queue: Job[] = [];
62
+ const cache = new Map<string, DetailView>();
63
+ let active = 0;
64
+ function pump(): void {
65
+ while (active < 4 && queue.length) {
66
+ const job = queue.shift();
67
+ if (!job || job.signal.aborted) continue;
68
+ active++;
69
+ void job.run().finally(() => {
70
+ active--;
71
+ pump();
72
+ });
73
+ }
74
+ }
75
+ function loadDetail(id: string, signal: AbortSignal): Promise<DetailView> {
76
+ signal.throwIfAborted();
77
+ const cached = cache.get(id);
78
+ if (cached) return Promise.resolve(cached);
79
+ return new Promise((resolve, reject) => {
80
+ const job: Job = {
81
+ signal,
82
+ cancel: () => {
83
+ const index = queue.indexOf(job);
84
+ if (index !== -1) queue.splice(index, 1);
85
+ reject(new DOMException("Cancelled", "AbortError"));
86
+ },
87
+ run: async () => {
88
+ try {
89
+ const value = await request<DetailView>(
90
+ `detail?id=${encodeURIComponent(id)}&leaf=${encodeURIComponent(id)}`,
91
+ signal,
92
+ );
93
+ if (signal.aborted) return;
94
+ // Raw entries and their own historical projection are immutable; live calls use the latest snapshot.
95
+ const result = cache.get(id) ?? { ...value, calls: [] };
96
+ cache.set(id, result);
97
+ while (cache.size > 64) {
98
+ const first = cache.keys().next().value;
99
+ if (first === undefined) break;
100
+ cache.delete(first);
101
+ }
102
+ resolve(result);
103
+ } catch (error) {
104
+ reject(error);
105
+ } finally {
106
+ signal.removeEventListener("abort", job.cancel);
107
+ }
108
+ },
109
+ };
110
+ signal.addEventListener("abort", job.cancel, { once: true });
111
+ queue.push(job);
112
+ pump();
113
+ });
114
+ }
115
+ export function useDetail(id: string | undefined): { detail?: DetailView; error: string; retry(): void } {
116
+ const [attempt, setAttempt] = useState(0);
117
+ const owner = useRef<{ id: string; attempt: number } | undefined>(undefined);
118
+ const retry = () => setAttempt((value) => value + 1);
119
+ const [state, setState] = useState<{ id?: string; detail?: DetailView; error: string }>({ error: "" });
120
+ useEffect(() => {
121
+ if (!id) return;
122
+ const requestOwner = { id, attempt };
123
+ owner.current = requestOwner;
124
+ const controller = new AbortController();
125
+ setState({ id, error: "" });
126
+ void loadDetail(id, controller.signal)
127
+ .then((detail) => {
128
+ if (!controller.signal.aborted && owner.current === requestOwner) setState({ id, detail, error: "" });
129
+ })
130
+ .catch(() => {
131
+ if (!controller.signal.aborted && owner.current === requestOwner)
132
+ setState({ id, error: "Could not load entry details." });
133
+ });
134
+ return () => controller.abort();
135
+ }, [id, attempt]);
136
+ return { ...(state.id === id ? state : { error: "" }), retry };
137
+ }
@@ -0,0 +1,439 @@
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 type { BranchView, Snapshot } from "../model.js";
9
+ import { generation, headers, RequestFailure, request, useDetail } from "./api.js";
10
+ import { ContextComposition } from "./context-composition.js";
11
+ import { type Filters, matches } from "./format.js";
12
+ import { Inspector } from "./inspector.js";
13
+ import { LiveDrawer } from "./live-drawer.js";
14
+ import { Overview } from "./overview.js";
15
+ import { PaneDrawer, ResizeHandle, useNarrow } from "./panes.js";
16
+ import { boundedSearch, searchNeedle } from "./search.js";
17
+ import { Trace } from "./trace.js";
18
+ import { Sidebar } from "./tree.js";
19
+
20
+ type Selection = { kind: "entry" | "call"; id: string; serial: number; filterVersion?: number; anchor?: string | null };
21
+ function App() {
22
+ const [appearance, setAppearance] = useState<"dark" | "light">("light");
23
+ const [revision, setRevision] = useState(-1);
24
+ const [snapshot, setSnapshot] = useState<Snapshot>();
25
+ const [branch, setBranch] = useState<BranchView>();
26
+ const [branchError, setBranchError] = useState("");
27
+ const [branchAttempt, setBranchAttempt] = useState(0);
28
+ const branchOwner = useRef<{ entryId: string; attempt: number } | undefined>(undefined);
29
+ const [selection, setSelection] = useState<Selection>();
30
+ const [view, setView] = useState("list");
31
+ const [surface, setSurface] = useState("context");
32
+ const [liveOpen, setLiveOpen] = useState(false);
33
+ const [filters, setFilters] = useState<Filters>({
34
+ query: "",
35
+ kind: "all",
36
+ groups: [],
37
+ errorsOnly: false,
38
+ slowOnly: false,
39
+ });
40
+ const [filterVersion, setFilterVersion] = useState(0);
41
+ const filterEpoch = useRef(0);
42
+ function changeFilters(value: Filters) {
43
+ setFilters({ ...value, query: boundedSearch(value.query) });
44
+ setFilterVersion(++filterEpoch.current);
45
+ }
46
+ const [error, setError] = useState("");
47
+ const [connected, setConnected] = useState(false);
48
+ const lifecycle = useRef<{ terminal: boolean; stream?: AbortController }>({ terminal: false });
49
+ const [tab, setTab] = useState("raw");
50
+ const navTrigger = useRef<HTMLButtonElement>(null);
51
+ const inspectorTrigger = useRef<HTMLButtonElement>(null);
52
+ const [navOpen, setNavOpen] = useState(false);
53
+ const [inspectorOpen, setInspectorOpen] = useState(false);
54
+ const [navDrawer, setNavDrawer] = useState(false);
55
+ const [inspectorDrawer, setInspectorDrawer] = useState(false);
56
+ const [navWidth, setNavWidth] = useState(() => Math.max(200, Math.min(360, window.innerWidth * 0.185)));
57
+ const [inspectorWidth, setInspectorWidth] = useState(() => Math.max(280, Math.min(520, window.innerWidth * 0.235)));
58
+ const narrow = useNarrow();
59
+ const sideBudget = Math.max(0, window.innerWidth - 600);
60
+ const navSize = navOpen ? Math.min(navWidth, inspectorOpen ? sideBudget * 0.4 : sideBudget) : 0;
61
+ const inspectorSize = inspectorOpen ? Math.min(inspectorWidth, navOpen ? sideBudget * 0.6 : sideBudget) : 0;
62
+ const calls = snapshot?.calls ?? [];
63
+ const entries = snapshot?.nodes ?? [];
64
+ const selectedCall =
65
+ selection?.kind === "call" ? calls.find((call) => call.occurrenceId === selection.id) : undefined;
66
+ const represented = useMemo(() => new Set(entries.map((entry) => entry.id)), [entries]);
67
+ const callAnchorAvailable = Boolean(selectedCall?.branchAnchor && represented.has(selectedCall.branchAnchor));
68
+ const entryId =
69
+ selection?.kind === "entry" ? selection.id : callAnchorAvailable ? (selectedCall?.branchAnchor ?? "") : "";
70
+ const {
71
+ detail,
72
+ error: detailError,
73
+ retry: retryDetails,
74
+ } = useDetail(selection?.kind === "entry" ? entryId : undefined);
75
+ const selectedNode = entries.find((node) => node.id === entryId);
76
+ const preparedFilters = useMemo(() => ({ ...filters, query: searchNeedle(filters.query) }), [filters]);
77
+ const matching = useMemo(
78
+ () => new Set(entries.filter((node) => matches(node, preparedFilters, calls)).map((node) => node.id)),
79
+ [entries, calls, preparedFilters],
80
+ );
81
+ function selectEntry(id: string) {
82
+ setSurface("events");
83
+ setSelection((old) => ({ kind: "entry", id, serial: (old?.serial ?? 0) + 1, filterVersion }));
84
+ }
85
+ function selectCall(id: string) {
86
+ if (narrow) setInspectorDrawer(true);
87
+ else setInspectorOpen(true);
88
+ setSelection((old) => ({
89
+ kind: "call",
90
+ id,
91
+ serial: (old?.serial ?? 0) + 1,
92
+ filterVersion,
93
+ anchor: calls.find((call) => call.occurrenceId === id)?.branchAnchor,
94
+ }));
95
+ setLiveOpen(true);
96
+ }
97
+ useEffect(() => {
98
+ const controller = new AbortController();
99
+ let retry: ReturnType<typeof setTimeout> | undefined;
100
+ let refresh: ReturnType<typeof setTimeout> | undefined;
101
+ lifecycle.current.stream = controller;
102
+ const stop = (message: string) => {
103
+ lifecycle.current.terminal = true;
104
+ setRevision(-1);
105
+ controller.abort();
106
+ clearTimeout(refresh);
107
+ setConnected(false);
108
+ setError(message);
109
+ };
110
+ const connect = async () => {
111
+ try {
112
+ const response = await fetch(`/api/events?generation=${encodeURIComponent(generation)}`, {
113
+ headers: headers(),
114
+ signal: controller.signal,
115
+ });
116
+ if (controller.signal.aborted) return;
117
+ if ([401, 403, 409, 410].includes(response.status)) {
118
+ stop("Session expired or unauthorized; open the viewer again from Pi.");
119
+ return;
120
+ }
121
+ if (!response.ok || !response.body) throw new Error("Transient viewer refusal");
122
+ setConnected(true);
123
+ const reader = response.body.getReader();
124
+ const decoder = new TextDecoder();
125
+ let buffer = "";
126
+ try {
127
+ while (!controller.signal.aborted) {
128
+ const { value, done } = await reader.read();
129
+ if (done || controller.signal.aborted) break;
130
+ buffer += decoder.decode(value, { stream: true });
131
+ let end = buffer.indexOf("\n\n");
132
+ while (end !== -1) {
133
+ const frame = buffer.slice(0, end);
134
+ buffer = buffer.slice(end + 2);
135
+ const line = frame.split("\n").find((s) => s.startsWith("data: "));
136
+ if (line) {
137
+ const data = JSON.parse(line.slice(6)) as { protocol: number; generation: string; revision: number };
138
+ if (data.protocol !== 1 || data.generation !== generation) {
139
+ stop("Session changed; open the viewer again from Pi.");
140
+ return;
141
+ }
142
+ if (!refresh)
143
+ refresh = setTimeout(() => {
144
+ refresh = undefined;
145
+ setRevision((previous) => previous + 1);
146
+ }, 50);
147
+ }
148
+ end = buffer.indexOf("\n\n");
149
+ }
150
+ }
151
+ } finally {
152
+ await reader.cancel().catch(() => {});
153
+ reader.releaseLock();
154
+ }
155
+ } catch {
156
+ /* Offline state is visible; reconnect uses a fresh snapshot, not stale replay. */
157
+ }
158
+ if (!controller.signal.aborted) {
159
+ setConnected(false);
160
+ retry = setTimeout(() => void connect(), 1000);
161
+ }
162
+ };
163
+ void connect();
164
+ return () => {
165
+ controller.abort();
166
+ clearTimeout(retry);
167
+ clearTimeout(refresh);
168
+ };
169
+ }, []);
170
+
171
+ useEffect(() => {
172
+ if (revision < 0) return;
173
+ const controller = new AbortController();
174
+ let retry: ReturnType<typeof setTimeout> | undefined;
175
+ const load = () => {
176
+ if (lifecycle.current.terminal || controller.signal.aborted) return;
177
+ void request<Snapshot>("snapshot", controller.signal)
178
+ .then((value) => {
179
+ if (controller.signal.aborted || lifecycle.current.terminal) return;
180
+ setSnapshot(value);
181
+ setError("");
182
+ const represented = new Set(value.nodes.map((node) => node.id));
183
+ const id = value.leafId && represented.has(value.leafId) ? value.leafId : value.nodes[0]?.id;
184
+ setSelection((old) => {
185
+ if (old?.kind === "call" && !value.calls.some((call) => call.occurrenceId === old.id)) {
186
+ const fallback = old.anchor && represented.has(old.anchor) ? old.anchor : id;
187
+ return fallback
188
+ ? { kind: "entry", id: fallback, serial: old.serial + 1, filterVersion: filterEpoch.current }
189
+ : undefined;
190
+ }
191
+ if (old?.kind === "entry" && !represented.has(old.id)) {
192
+ return id ? { kind: "entry", id, serial: old.serial + 1, filterVersion: filterEpoch.current } : undefined;
193
+ }
194
+ return old ?? (id ? { kind: "entry", id, serial: 0 } : undefined);
195
+ });
196
+ })
197
+ .catch((error) => {
198
+ if (!controller.signal.aborted && !lifecycle.current.terminal) {
199
+ setError(String(error.message));
200
+ if (error instanceof RequestFailure && error.terminal) {
201
+ lifecycle.current.terminal = true;
202
+ lifecycle.current.stream?.abort();
203
+ setConnected(false);
204
+ setRevision(-1);
205
+ } else retry = setTimeout(load, 1000);
206
+ }
207
+ });
208
+ };
209
+ load();
210
+ return () => {
211
+ controller.abort();
212
+ clearTimeout(retry);
213
+ };
214
+ }, [revision]);
215
+ useEffect(() => {
216
+ setBranchError("");
217
+ if (!entryId) {
218
+ setBranch(undefined);
219
+ return;
220
+ }
221
+ const controller = new AbortController();
222
+ setBranch(undefined);
223
+ const owner = { entryId, attempt: branchAttempt };
224
+ branchOwner.current = owner;
225
+ void request<BranchView>(`branch?leaf=${encodeURIComponent(entryId)}&offset=0`, controller.signal)
226
+ .then((value) => {
227
+ if (!controller.signal.aborted && branchOwner.current === owner) setBranch(value);
228
+ })
229
+ .catch(() => {
230
+ if (!controller.signal.aborted && branchOwner.current === owner)
231
+ setBranchError("Could not load selected branch context.");
232
+ });
233
+ return () => controller.abort();
234
+ }, [entryId, branchAttempt]);
235
+
236
+ const sidebar = (
237
+ <Sidebar
238
+ nodes={entries}
239
+ matches={matching}
240
+ total={snapshot?.totalEntries ?? 0}
241
+ selected={entryId}
242
+ serial={selection?.serial ?? 0}
243
+ filters={filters}
244
+ change={changeFilters}
245
+ revealSelected={selection?.filterVersion === filterVersion}
246
+ choose={selectEntry}
247
+ />
248
+ );
249
+ const inspector = (
250
+ <Inspector
251
+ selected={selection?.id ?? ""}
252
+ node={selectedNode}
253
+ detail={detail}
254
+ detailError={detailError}
255
+ retryDetails={retryDetails}
256
+ call={selectedCall}
257
+ branch={branch}
258
+ branchError={branchError}
259
+ retryBranch={() => setBranchAttempt((attempt) => attempt + 1)}
260
+ snapshot={snapshot}
261
+ tab={tab}
262
+ changeTab={setTab}
263
+ select={selectEntry}
264
+ selectCall={selectCall}
265
+ />
266
+ );
267
+ return (
268
+ <Theme
269
+ className="inspector-app"
270
+ appearance={appearance}
271
+ accentColor="blue"
272
+ grayColor="slate"
273
+ radius="medium"
274
+ scaling="100%"
275
+ >
276
+ <header className="app-header">
277
+ <div className="brand">
278
+ <div className="brand-logo" aria-hidden="true">
279
+ π
280
+ </div>
281
+ <Heading size="4">Pi Session Inspector</Heading>
282
+ </div>
283
+ <div className="header-context">
284
+ <span title={snapshot?.nameTruncated ? "Session name truncated to 512 characters" : undefined}>
285
+ {snapshot?.name ?? "Connecting"}
286
+ {snapshot?.nameTruncated && " · truncated"}
287
+ </span>
288
+ <span title={snapshot?.sessionId}>
289
+ Session: <strong>{typeof snapshot?.sessionId === "string" ? snapshot.sessionId.slice(0, 8) : "—"}</strong>
290
+ </span>
291
+ </div>
292
+ <div className="header-actions">
293
+ <fieldset className="surface-switch" aria-label="Inspection source">
294
+ <button type="button" aria-pressed={surface === "context"} onClick={() => setSurface("context")}>
295
+ Context
296
+ </button>
297
+ <button type="button" aria-pressed={surface === "events"} onClick={() => setSurface("events")}>
298
+ Session events
299
+ </button>
300
+ </fieldset>
301
+ <IconButton
302
+ ref={navTrigger}
303
+ aria-label="Toggle navigator"
304
+ aria-expanded={narrow ? navDrawer : navOpen}
305
+ variant="ghost"
306
+ onClick={() => (narrow ? setNavDrawer(!navDrawer) : setNavOpen(!navOpen))}
307
+ >
308
+ <HamburgerMenuIcon />
309
+ </IconButton>
310
+ <IconButton
311
+ ref={inspectorTrigger}
312
+ aria-label="Toggle inspector"
313
+ aria-expanded={narrow ? inspectorDrawer : inspectorOpen}
314
+ variant="ghost"
315
+ onClick={() => (narrow ? setInspectorDrawer(!inspectorDrawer) : setInspectorOpen(!inspectorOpen))}
316
+ >
317
+ <ReaderIcon />
318
+ </IconButton>
319
+ <Badge color={connected ? "green" : "amber"}>{connected ? "Live" : "Disconnected"}</Badge>
320
+ <Badge>Read-only</Badge>
321
+ <IconButton
322
+ aria-label="Toggle appearance"
323
+ variant="ghost"
324
+ onClick={() => setAppearance(appearance === "dark" ? "light" : "dark")}
325
+ >
326
+ {appearance === "dark" ? <SunIcon /> : <MoonIcon />}
327
+ </IconButton>
328
+ </div>
329
+ </header>
330
+ {(error || snapshot?.incomplete) && (
331
+ <div className="app-notices">
332
+ {error && (
333
+ <Callout.Root color="red">
334
+ <Callout.Text>{error}</Callout.Text>
335
+ </Callout.Root>
336
+ )}
337
+ {snapshot?.incomplete && (
338
+ <Callout.Root color="amber">
339
+ <Callout.Text>Indexed data is incomplete; existing entry/capture limits apply.</Callout.Text>
340
+ </Callout.Root>
341
+ )}
342
+ </div>
343
+ )}
344
+ <main
345
+ className={`app-grid ${narrow ? "narrow-layout" : ""}`}
346
+ style={
347
+ narrow
348
+ ? undefined
349
+ : {
350
+ gridTemplateColumns: `${navSize}px ${navOpen ? 6 : 0}px minmax(0, 1fr) ${inspectorOpen ? 6 : 0}px ${inspectorSize}px`,
351
+ }
352
+ }
353
+ >
354
+ {!narrow && (
355
+ <>
356
+ <div className="side-slot nav-slot" hidden={!navOpen}>
357
+ {sidebar}
358
+ </div>
359
+ {navOpen && (
360
+ <ResizeHandle name="Resize navigator" value={navWidth} change={setNavWidth} min={180} max={420} />
361
+ )}
362
+ </>
363
+ )}
364
+ <div className="center-column">
365
+ <div className="context-surface" hidden={surface !== "context"}>
366
+ <ContextComposition context={snapshot?.context} payload={snapshot?.providerObservation} />
367
+ </div>
368
+ <div className="events-surface" hidden={surface !== "events"}>
369
+ <Overview snapshot={snapshot} />
370
+ <Trace
371
+ entries={entries}
372
+ matches={matching}
373
+ selected={entryId}
374
+ serial={selection?.serial ?? 0}
375
+ revealSelected={selection?.filterVersion === filterVersion}
376
+ select={selectEntry}
377
+ view={view}
378
+ changeView={setView}
379
+ calls={calls}
380
+ />
381
+ </div>
382
+ {(calls.length > 0 || surface === "events") && (
383
+ <LiveDrawer
384
+ entries={entries}
385
+ calls={calls}
386
+ selected={selectedCall?.occurrenceId}
387
+ select={selectCall}
388
+ open={liveOpen}
389
+ changeOpen={setLiveOpen}
390
+ view={view}
391
+ dropped={snapshot?.droppedCalls ?? 0}
392
+ invalidEvents={snapshot?.invalidCallEvents ?? 0}
393
+ filters={filters}
394
+ />
395
+ )}
396
+ </div>
397
+ {!narrow && (
398
+ <>
399
+ {inspectorOpen ? (
400
+ <ResizeHandle
401
+ name="Resize inspector"
402
+ value={inspectorWidth}
403
+ change={setInspectorWidth}
404
+ min={260}
405
+ max={600}
406
+ inverse
407
+ />
408
+ ) : null}
409
+ <div className="side-slot inspector-slot" hidden={!inspectorOpen}>
410
+ {inspector}
411
+ </div>
412
+ </>
413
+ )}
414
+ </main>
415
+ {narrow && (
416
+ <>
417
+ <PaneDrawer trigger={navTrigger} name="Session navigator" side="left" open={navDrawer} change={setNavDrawer}>
418
+ {sidebar}
419
+ </PaneDrawer>
420
+ <PaneDrawer
421
+ trigger={inspectorTrigger}
422
+ name="Event inspector"
423
+ side="right"
424
+ open={inspectorDrawer}
425
+ change={setInspectorDrawer}
426
+ >
427
+ {inspector}
428
+ </PaneDrawer>
429
+ </>
430
+ )}
431
+ <footer className="app-footer">
432
+ <span>{connected ? "Connected · observing live events" : "Disconnected · reopen from Pi"}</span>
433
+ <span>Read-only · bounded display copies · no provider transport spans</span>
434
+ </footer>
435
+ </Theme>
436
+ );
437
+ }
438
+ const root = document.getElementById("root");
439
+ if (root) createRoot(root).render(<App />);