@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.
- package/LICENSE +21 -0
- package/README.md +107 -2
- package/dist/app.css +1 -0
- package/dist/app.js +129 -0
- package/dist/index.html +1 -0
- package/docs/REVIEW.md +189 -0
- package/docs/VERIFICATION.md +186 -0
- package/docs/viewer.md +63 -0
- package/package.json +67 -4
- package/src/ancestry.ts +32 -0
- package/src/collector.ts +143 -0
- package/src/context.ts +115 -0
- package/src/correlation.ts +27 -0
- package/src/entry-index.ts +17 -0
- package/src/extension.ts +259 -0
- package/src/feed.ts +145 -0
- package/src/identity.ts +112 -0
- package/src/index.ts +1 -0
- package/src/model.ts +113 -0
- package/src/native-projection.ts +28 -0
- package/src/privacy.ts +136 -0
- package/src/projection.ts +361 -0
- package/src/server.ts +202 -0
- package/src/system-message.ts +117 -0
- package/src/web/api.ts +137 -0
- package/src/web/app.tsx +439 -0
- package/src/web/components.css +261 -0
- package/src/web/components.tsx +289 -0
- package/src/web/composition.css +554 -0
- package/src/web/context-composition.tsx +324 -0
- package/src/web/entry-calls.ts +14 -0
- package/src/web/format.ts +92 -0
- package/src/web/hierarchy.ts +117 -0
- package/src/web/inline-entry.tsx +124 -0
- package/src/web/inspector.tsx +337 -0
- package/src/web/json-tree.tsx +74 -0
- package/src/web/live-drawer.tsx +175 -0
- package/src/web/overview.tsx +67 -0
- package/src/web/panels.css +267 -0
- package/src/web/panes.tsx +123 -0
- package/src/web/search.ts +16 -0
- package/src/web/style.css +332 -0
- package/src/web/styles.d.ts +1 -0
- package/src/web/timeline.tsx +33 -0
- package/src/web/timing.ts +26 -0
- package/src/web/trace.css +410 -0
- package/src/web/trace.tsx +273 -0
- package/src/web/tree.tsx +281 -0
- package/src/web/virtual-context.ts +138 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { ChevronDownIcon, ChevronRightIcon, ListBulletIcon, RowsIcon } from "@radix-ui/react-icons";
|
|
2
|
+
import { Button, Flex, Heading, Text } from "@radix-ui/themes";
|
|
3
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import type { Call, EntrySummary } from "../model.js";
|
|
5
|
+
import { Glyph, Status } from "./components.js";
|
|
6
|
+
import { count, eventName, time } from "./format.js";
|
|
7
|
+
import { flatten, hierarchy, label, reveal, withAncestors } from "./hierarchy.js";
|
|
8
|
+
import { InlineEntry } from "./inline-entry.js";
|
|
9
|
+
import { TimelineMark } from "./timeline.js";
|
|
10
|
+
import { axis } from "./timing.js";
|
|
11
|
+
|
|
12
|
+
export function scrollWithin(container: HTMLElement, element: HTMLElement): void {
|
|
13
|
+
const parent = container.getBoundingClientRect();
|
|
14
|
+
const child = element.getBoundingClientRect();
|
|
15
|
+
if (child.top < parent.top) container.scrollTop -= Math.ceil(parent.top - child.top);
|
|
16
|
+
else if (child.bottom > parent.bottom) container.scrollTop += Math.ceil(child.bottom - parent.bottom);
|
|
17
|
+
}
|
|
18
|
+
export function Trace({
|
|
19
|
+
entries,
|
|
20
|
+
matches,
|
|
21
|
+
selected,
|
|
22
|
+
serial,
|
|
23
|
+
revealSelected,
|
|
24
|
+
select,
|
|
25
|
+
view,
|
|
26
|
+
changeView,
|
|
27
|
+
calls,
|
|
28
|
+
}: {
|
|
29
|
+
entries: EntrySummary[];
|
|
30
|
+
matches: Set<string>;
|
|
31
|
+
selected: string;
|
|
32
|
+
serial: number;
|
|
33
|
+
revealSelected: boolean;
|
|
34
|
+
select(id: string): void;
|
|
35
|
+
view: string;
|
|
36
|
+
changeView(view: string): void;
|
|
37
|
+
calls: Call[];
|
|
38
|
+
}) {
|
|
39
|
+
const tree = useMemo(() => hierarchy(entries), [entries]);
|
|
40
|
+
const keep = useMemo(
|
|
41
|
+
() => withAncestors(tree, new Set([...matches, ...(revealSelected && selected ? [selected] : [])])),
|
|
42
|
+
[tree, matches, revealSelected, selected],
|
|
43
|
+
);
|
|
44
|
+
const [expanded, setExpanded] = useState(new Set<string>());
|
|
45
|
+
const [disclosed, setDisclosed] = useState(new Set<string>());
|
|
46
|
+
const [offset, setOffset] = useState(0);
|
|
47
|
+
const [focused, setFocused] = useState("");
|
|
48
|
+
const lastReveal = useRef(-1);
|
|
49
|
+
const focusRequest = useRef(false);
|
|
50
|
+
const scroller = useRef<HTMLDivElement>(null);
|
|
51
|
+
const rowRefs = useRef(new Map<string, HTMLDivElement>());
|
|
52
|
+
const rows = useMemo(() => flatten(tree, expanded, keep), [tree, expanded, keep]);
|
|
53
|
+
const pageRows = rows.slice(offset, offset + 50);
|
|
54
|
+
const visibleFocused = pageRows.some((row) => row.node.id === focused) ? focused : (pageRows[0]?.node.id ?? "");
|
|
55
|
+
const baseDepth = pageRows.length ? Math.min(...pageRows.map((row) => row.depth)) : 0;
|
|
56
|
+
const relativeDepth = Math.max(1, ...pageRows.map((row) => row.depth - baseDepth));
|
|
57
|
+
const indent = Math.min(14, 140 / relativeDepth); // Scale the visual rail, never cap hierarchy depth.
|
|
58
|
+
const range = useMemo(() => axis(entries, calls), [entries, calls]);
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (!tree.nodes.has(selected) || serial === lastReveal.current) return;
|
|
61
|
+
lastReveal.current = serial;
|
|
62
|
+
const next = reveal(tree, expanded, selected);
|
|
63
|
+
setExpanded(next);
|
|
64
|
+
const index = flatten(tree, next, keep).findIndex((row) => row.node.id === selected);
|
|
65
|
+
if (index >= 0) {
|
|
66
|
+
setOffset(Math.floor(index / 50) * 50);
|
|
67
|
+
setFocused(selected);
|
|
68
|
+
}
|
|
69
|
+
}, [tree, selected, serial, expanded, keep]);
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
const element = rowRefs.current.get(visibleFocused);
|
|
72
|
+
if (!element || !scroller.current) return;
|
|
73
|
+
if (focusRequest.current) {
|
|
74
|
+
element.focus({ preventScroll: true });
|
|
75
|
+
focusRequest.current = false;
|
|
76
|
+
}
|
|
77
|
+
scrollWithin(scroller.current, element);
|
|
78
|
+
}, [visibleFocused]);
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (offset >= rows.length && offset) setOffset(Math.max(0, Math.floor((rows.length - 1) / 50) * 50));
|
|
81
|
+
}, [offset, rows.length]);
|
|
82
|
+
function toggle(id: string): void {
|
|
83
|
+
setExpanded((previous) => {
|
|
84
|
+
const next = new Set(previous);
|
|
85
|
+
if (next.has(id)) next.delete(id);
|
|
86
|
+
else next.add(id);
|
|
87
|
+
return next;
|
|
88
|
+
});
|
|
89
|
+
setDisclosed((previous) => new Set([...previous, id]));
|
|
90
|
+
}
|
|
91
|
+
function focus(id: string): void {
|
|
92
|
+
const index = rows.findIndex((row) => row.node.id === id);
|
|
93
|
+
if (index === -1) return;
|
|
94
|
+
focusRequest.current = true;
|
|
95
|
+
setOffset(Math.floor(index / 50) * 50);
|
|
96
|
+
setFocused(id);
|
|
97
|
+
rowRefs.current.get(id)?.focus({ preventScroll: true });
|
|
98
|
+
}
|
|
99
|
+
return (
|
|
100
|
+
<section className={`panel trace-panel trace-${view}`}>
|
|
101
|
+
<div className="trace-toolbar">
|
|
102
|
+
<Heading size="3">Trace explorer</Heading>
|
|
103
|
+
<Text size="1" color="gray">
|
|
104
|
+
Session log hierarchy
|
|
105
|
+
</Text>
|
|
106
|
+
<div className="trace-actions">
|
|
107
|
+
<Button
|
|
108
|
+
size="1"
|
|
109
|
+
variant="ghost"
|
|
110
|
+
disabled={!keep.size}
|
|
111
|
+
onClick={() => {
|
|
112
|
+
setExpanded(new Set(keep));
|
|
113
|
+
setDisclosed(new Set(keep));
|
|
114
|
+
}}
|
|
115
|
+
>
|
|
116
|
+
Expand all
|
|
117
|
+
</Button>
|
|
118
|
+
<Button size="1" variant="ghost" disabled={!expanded.size} onClick={() => setExpanded(new Set())}>
|
|
119
|
+
Collapse all
|
|
120
|
+
</Button>
|
|
121
|
+
<div className="view-switch">
|
|
122
|
+
{["list", "timeline"].map((mode) => (
|
|
123
|
+
<button type="button" key={mode} aria-pressed={view === mode} onClick={() => changeView(mode)}>
|
|
124
|
+
{mode === "list" ? <ListBulletIcon /> : <RowsIcon />}
|
|
125
|
+
{mode === "list" ? "List" : "Timeline"}
|
|
126
|
+
</button>
|
|
127
|
+
))}
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
<div className="trace-hint">
|
|
132
|
+
Row: select + toggle · Chevron: toggle only · ↑↓ navigate · ←→ collapse/expand · Enter select · Space toggle
|
|
133
|
+
</div>
|
|
134
|
+
{view === "timeline" && (
|
|
135
|
+
<div className="time-axis">
|
|
136
|
+
<span>{range ? new Date(range.start).toISOString().slice(11, 23) : "No timing"}</span>
|
|
137
|
+
<span>● Log timestamp · ▰ Observed tool interval (live drawer)</span>
|
|
138
|
+
<span>{range ? new Date(range.end).toISOString().slice(11, 23) : "—"}</span>
|
|
139
|
+
</div>
|
|
140
|
+
)}
|
|
141
|
+
<div className="trace-scroll" ref={scroller}>
|
|
142
|
+
<div role="tree" aria-label="Session trace" className="trace-tree">
|
|
143
|
+
{!rows.length && <div className="empty-state">No entries match. Filters keep actual ancestor context.</div>}
|
|
144
|
+
{pageRows.map(({ node: entry, depth, childCount }) => {
|
|
145
|
+
const open = expanded.has(entry.id);
|
|
146
|
+
const selectedRow = selected === entry.id;
|
|
147
|
+
return (
|
|
148
|
+
<div
|
|
149
|
+
className="trace-item"
|
|
150
|
+
key={entry.id}
|
|
151
|
+
data-trace-entry-id={entry.id}
|
|
152
|
+
data-parent-id={entry.parentId ?? ""}
|
|
153
|
+
data-depth={depth}
|
|
154
|
+
data-state={open ? "open" : "closed"}
|
|
155
|
+
style={{ marginLeft: (depth - baseDepth) * indent }}
|
|
156
|
+
>
|
|
157
|
+
<div
|
|
158
|
+
role="treeitem"
|
|
159
|
+
aria-level={depth + 1}
|
|
160
|
+
aria-expanded={open}
|
|
161
|
+
aria-selected={selectedRow}
|
|
162
|
+
tabIndex={visibleFocused === entry.id ? 0 : -1}
|
|
163
|
+
ref={(element) => {
|
|
164
|
+
if (element) rowRefs.current.set(entry.id, element);
|
|
165
|
+
else rowRefs.current.delete(entry.id);
|
|
166
|
+
}}
|
|
167
|
+
className={`trace-row ${selectedRow ? "selected" : ""} ${open ? "expanded" : ""}`}
|
|
168
|
+
data-trace-id={entry.id}
|
|
169
|
+
onFocus={() => setFocused(entry.id)}
|
|
170
|
+
onClick={(event) => {
|
|
171
|
+
if (event.target !== event.currentTarget && (event.target as HTMLElement).closest("button")) return;
|
|
172
|
+
select(entry.id);
|
|
173
|
+
toggle(entry.id);
|
|
174
|
+
}}
|
|
175
|
+
onKeyDown={(event) => {
|
|
176
|
+
if (event.target !== event.currentTarget || event.altKey || event.ctrlKey || event.metaKey) return;
|
|
177
|
+
const index = rows.findIndex((row) => row.node.id === entry.id);
|
|
178
|
+
if (!["ArrowDown", "ArrowUp", "ArrowRight", "ArrowLeft", "Enter", " "].includes(event.key)) return;
|
|
179
|
+
event.preventDefault();
|
|
180
|
+
if (event.key === "ArrowDown") {
|
|
181
|
+
const next = rows[index + 1];
|
|
182
|
+
if (next) focus(next.node.id);
|
|
183
|
+
} else if (event.key === "ArrowUp") {
|
|
184
|
+
const prev = rows[index - 1];
|
|
185
|
+
if (prev) focus(prev.node.id);
|
|
186
|
+
} else if (event.key === "ArrowRight") {
|
|
187
|
+
if (!open) toggle(entry.id);
|
|
188
|
+
else {
|
|
189
|
+
const child = rows[index + 1];
|
|
190
|
+
if (child && child.depth > depth) focus(child.node.id);
|
|
191
|
+
}
|
|
192
|
+
} else if (event.key === "ArrowLeft") {
|
|
193
|
+
if (open) toggle(entry.id);
|
|
194
|
+
else if (entry.parentId) focus(entry.parentId);
|
|
195
|
+
} else if (event.key === "Enter") select(entry.id);
|
|
196
|
+
else toggle(entry.id);
|
|
197
|
+
}}
|
|
198
|
+
>
|
|
199
|
+
<button
|
|
200
|
+
type="button"
|
|
201
|
+
tabIndex={-1}
|
|
202
|
+
className="expand-button"
|
|
203
|
+
aria-label={`Expand event ${entry.id}`}
|
|
204
|
+
aria-expanded={open}
|
|
205
|
+
onClick={(event) => {
|
|
206
|
+
event.stopPropagation();
|
|
207
|
+
toggle(entry.id);
|
|
208
|
+
}}
|
|
209
|
+
>
|
|
210
|
+
{open ? <ChevronDownIcon /> : <ChevronRightIcon />}
|
|
211
|
+
</button>
|
|
212
|
+
<div className="trace-event">
|
|
213
|
+
<Glyph kind={entry.kind} />
|
|
214
|
+
<span className="row-title">
|
|
215
|
+
<strong>{eventName(entry.kind)}</strong>
|
|
216
|
+
<span title={label(entry)}>{label(entry)}</span>
|
|
217
|
+
</span>
|
|
218
|
+
<code title={entry.id}>{entry.id.slice(0, 8)}</code>
|
|
219
|
+
{!matches.has(entry.id) && (
|
|
220
|
+
<span className="ancestor-badge">{selectedRow ? "selected outside filters" : "ancestor"}</span>
|
|
221
|
+
)}
|
|
222
|
+
{depth === 0 && entry.parentId && tree.nodes.has(entry.parentId) && (
|
|
223
|
+
<span className="ancestor-badge">recorded parent cycle</span>
|
|
224
|
+
)}
|
|
225
|
+
</div>
|
|
226
|
+
<div className="row-metrics">
|
|
227
|
+
{entry.status && <Status value={entry.status} />}
|
|
228
|
+
{entry.tokens !== undefined && (
|
|
229
|
+
<span title="Recorded assistant totalTokens">{count(entry.tokens)} tok</span>
|
|
230
|
+
)}
|
|
231
|
+
<time title={entry.timestamp}>{time(entry.timestamp)}</time>
|
|
232
|
+
</div>
|
|
233
|
+
{view === "timeline" && <TimelineMark range={range} logTime={entry.timestamp} />}
|
|
234
|
+
</div>
|
|
235
|
+
{open && disclosed.has(entry.id) && (
|
|
236
|
+
<div className="entry-expanded">
|
|
237
|
+
<InlineEntry entry={entry} inspect={select} />
|
|
238
|
+
{childCount > 0 && (
|
|
239
|
+
<span className="children-label">
|
|
240
|
+
{childCount} visible child {childCount === 1 ? "entry" : "entries"}
|
|
241
|
+
</span>
|
|
242
|
+
)}
|
|
243
|
+
{entry.parentId && !tree.nodes.has(entry.parentId) && (
|
|
244
|
+
<span className="children-label">Parent outside indexed data: {entry.parentId}</span>
|
|
245
|
+
)}
|
|
246
|
+
</div>
|
|
247
|
+
)}
|
|
248
|
+
</div>
|
|
249
|
+
);
|
|
250
|
+
})}
|
|
251
|
+
</div>
|
|
252
|
+
</div>
|
|
253
|
+
<div className="trace-pagination">
|
|
254
|
+
<span>
|
|
255
|
+
{baseDepth
|
|
256
|
+
? `Absolute hierarchy level ${baseDepth + 1}+ · page-local indentation`
|
|
257
|
+
: "Original parents retained · browser-only selection"}
|
|
258
|
+
</span>
|
|
259
|
+
<Flex gap="2" align="center">
|
|
260
|
+
<Button size="1" variant="ghost" disabled={!offset} onClick={() => setOffset(Math.max(0, offset - 50))}>
|
|
261
|
+
Previous
|
|
262
|
+
</Button>
|
|
263
|
+
<Text size="1">
|
|
264
|
+
{rows.length ? offset + 1 : 0}–{Math.min(offset + 50, rows.length)} / {rows.length}
|
|
265
|
+
</Text>
|
|
266
|
+
<Button size="1" variant="ghost" disabled={offset + 50 >= rows.length} onClick={() => setOffset(offset + 50)}>
|
|
267
|
+
Next
|
|
268
|
+
</Button>
|
|
269
|
+
</Flex>
|
|
270
|
+
</div>
|
|
271
|
+
</section>
|
|
272
|
+
);
|
|
273
|
+
}
|
package/src/web/tree.tsx
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import * as Collapsible from "@radix-ui/react-collapsible";
|
|
2
|
+
import {
|
|
3
|
+
ChevronDownIcon,
|
|
4
|
+
ChevronRightIcon,
|
|
5
|
+
MagnifyingGlassIcon,
|
|
6
|
+
MixerHorizontalIcon,
|
|
7
|
+
Share2Icon,
|
|
8
|
+
} from "@radix-ui/react-icons";
|
|
9
|
+
import { Checkbox, Flex, Heading, IconButton, Select, Text, TextField } from "@radix-ui/themes";
|
|
10
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
11
|
+
import type { EntrySummary } from "../model.js";
|
|
12
|
+
import { Glyph } from "./components.js";
|
|
13
|
+
import { eventName, type Filters } from "./format.js";
|
|
14
|
+
import { flatten, hierarchy, label, reveal, withAncestors } from "./hierarchy.js";
|
|
15
|
+
import { scrollWithin } from "./trace.js";
|
|
16
|
+
|
|
17
|
+
function Tree({
|
|
18
|
+
nodes,
|
|
19
|
+
matches,
|
|
20
|
+
selected,
|
|
21
|
+
serial,
|
|
22
|
+
revealSelected,
|
|
23
|
+
choose,
|
|
24
|
+
}: {
|
|
25
|
+
nodes: EntrySummary[];
|
|
26
|
+
matches: Set<string>;
|
|
27
|
+
selected: string;
|
|
28
|
+
serial: number;
|
|
29
|
+
revealSelected: boolean;
|
|
30
|
+
choose(id: string): void;
|
|
31
|
+
}) {
|
|
32
|
+
const tree = useMemo(() => hierarchy(nodes), [nodes]);
|
|
33
|
+
const keep = useMemo(
|
|
34
|
+
() => withAncestors(tree, new Set([...matches, ...(revealSelected && selected ? [selected] : [])])),
|
|
35
|
+
[tree, matches, revealSelected, selected],
|
|
36
|
+
);
|
|
37
|
+
const [expanded, setExpanded] = useState(new Set<string>());
|
|
38
|
+
const [offset, setOffset] = useState(0);
|
|
39
|
+
const previous = useRef<{ selected: string; serial: number; revealSelected: boolean } | undefined>(undefined);
|
|
40
|
+
const nav = useRef<HTMLElement>(null);
|
|
41
|
+
const rows = flatten(tree, expanded, keep);
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
const old = previous.current;
|
|
44
|
+
if (
|
|
45
|
+
!tree.nodes.has(selected) ||
|
|
46
|
+
(old?.selected === selected && old.serial === serial && old.revealSelected === revealSelected)
|
|
47
|
+
)
|
|
48
|
+
return;
|
|
49
|
+
previous.current = { selected, serial, revealSelected };
|
|
50
|
+
const next = reveal(tree, expanded, selected);
|
|
51
|
+
setExpanded(next);
|
|
52
|
+
const index = flatten(tree, next, keep).findIndex((row) => row.node.id === selected);
|
|
53
|
+
if (index >= 0) setOffset(Math.floor(index / 100) * 100);
|
|
54
|
+
}, [selected, serial, revealSelected, tree, expanded, keep]);
|
|
55
|
+
const selectedVisible = rows.slice(offset, offset + 100).some((row) => row.node.id === selected);
|
|
56
|
+
const lastScroll = useRef<{ selected: string; offset: number; element: HTMLElement } | undefined>(undefined);
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
const element = nav.current?.querySelector<HTMLElement>('[aria-current="true"]');
|
|
59
|
+
if (!selectedVisible || !nav.current || !element || element.dataset.entryId !== selected) return;
|
|
60
|
+
const last = lastScroll.current;
|
|
61
|
+
if (last?.selected === selected && last.offset === offset && last.element === element) return;
|
|
62
|
+
lastScroll.current = { selected, offset, element };
|
|
63
|
+
scrollWithin(nav.current, element);
|
|
64
|
+
}, [selected, offset, selectedVisible]);
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (offset >= rows.length && offset) setOffset(0);
|
|
67
|
+
}, [offset, rows.length]);
|
|
68
|
+
const pageRows = rows.slice(offset, offset + 100);
|
|
69
|
+
const base = pageRows.length ? Math.min(...pageRows.map((row) => row.depth)) : 0;
|
|
70
|
+
const rail = Math.min(8, 64 / Math.max(1, ...pageRows.map((row) => row.depth - base)));
|
|
71
|
+
return (
|
|
72
|
+
<>
|
|
73
|
+
<nav ref={nav} className="tree-scroll" aria-label="Session branches">
|
|
74
|
+
{pageRows.map(({ node, depth, childCount }) => (
|
|
75
|
+
<div key={node.id} className="tree-row" style={{ paddingLeft: (depth - base) * rail }}>
|
|
76
|
+
{childCount ? (
|
|
77
|
+
<IconButton
|
|
78
|
+
variant="ghost"
|
|
79
|
+
size="1"
|
|
80
|
+
aria-label={`Toggle children of ${node.id}`}
|
|
81
|
+
aria-expanded={expanded.has(node.id)}
|
|
82
|
+
onClick={() =>
|
|
83
|
+
setExpanded((previous) => {
|
|
84
|
+
const next = new Set(previous);
|
|
85
|
+
if (next.has(node.id)) next.delete(node.id);
|
|
86
|
+
else next.add(node.id);
|
|
87
|
+
return next;
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
>
|
|
91
|
+
{expanded.has(node.id) ? <ChevronDownIcon /> : <ChevronRightIcon />}
|
|
92
|
+
</IconButton>
|
|
93
|
+
) : (
|
|
94
|
+
<span className="tree-spacer" />
|
|
95
|
+
)}
|
|
96
|
+
<button
|
|
97
|
+
type="button"
|
|
98
|
+
className="node"
|
|
99
|
+
data-entry-id={node.id}
|
|
100
|
+
data-match={matches.has(node.id)}
|
|
101
|
+
aria-current={selected === node.id ? "true" : undefined}
|
|
102
|
+
onClick={() => choose(node.id)}
|
|
103
|
+
title={`${node.kind} · ${node.id}\n${label(node)}${node.nameTruncated ? " [truncated; inspect Raw for the complete captured value]" : ""}`}
|
|
104
|
+
>
|
|
105
|
+
<Glyph kind={node.kind} />
|
|
106
|
+
<span className="node-copy">
|
|
107
|
+
<span className="node-title">
|
|
108
|
+
<strong>{eventName(node.kind)}</strong>
|
|
109
|
+
<small>{node.id.slice(0, 8)}</small>
|
|
110
|
+
</span>
|
|
111
|
+
<span className="node-description">{label(node)}</span>
|
|
112
|
+
</span>
|
|
113
|
+
</button>
|
|
114
|
+
</div>
|
|
115
|
+
))}
|
|
116
|
+
</nav>
|
|
117
|
+
{rows.length > 100 && (
|
|
118
|
+
<div className="nav-pagination">
|
|
119
|
+
<button type="button" disabled={!offset} onClick={() => setOffset(Math.max(0, offset - 100))}>
|
|
120
|
+
Previous nodes
|
|
121
|
+
</button>
|
|
122
|
+
<span>
|
|
123
|
+
{offset + 1}–{Math.min(offset + 100, rows.length)}
|
|
124
|
+
</span>
|
|
125
|
+
<button type="button" disabled={offset + 100 >= rows.length} onClick={() => setOffset(offset + 100)}>
|
|
126
|
+
Next nodes
|
|
127
|
+
</button>
|
|
128
|
+
</div>
|
|
129
|
+
)}
|
|
130
|
+
</>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
export function Sidebar({
|
|
134
|
+
nodes,
|
|
135
|
+
matches,
|
|
136
|
+
total,
|
|
137
|
+
selected,
|
|
138
|
+
serial,
|
|
139
|
+
revealSelected,
|
|
140
|
+
choose,
|
|
141
|
+
filters,
|
|
142
|
+
change,
|
|
143
|
+
}: {
|
|
144
|
+
nodes: EntrySummary[];
|
|
145
|
+
matches: Set<string>;
|
|
146
|
+
total: number;
|
|
147
|
+
selected: string;
|
|
148
|
+
serial: number;
|
|
149
|
+
revealSelected: boolean;
|
|
150
|
+
choose(id: string): void;
|
|
151
|
+
filters: Filters;
|
|
152
|
+
change(filters: Filters): void;
|
|
153
|
+
}) {
|
|
154
|
+
const toggleGroup = (group: string) =>
|
|
155
|
+
change({
|
|
156
|
+
...filters,
|
|
157
|
+
groups: filters.groups.includes(group)
|
|
158
|
+
? filters.groups.filter((item) => item !== group)
|
|
159
|
+
: [...filters.groups, group],
|
|
160
|
+
});
|
|
161
|
+
return (
|
|
162
|
+
<aside className="sidebar">
|
|
163
|
+
<section className="panel tree-panel">
|
|
164
|
+
<div className="panel-heading">
|
|
165
|
+
<Heading size="3">
|
|
166
|
+
<Share2Icon />
|
|
167
|
+
Session tree
|
|
168
|
+
</Heading>
|
|
169
|
+
<Text size="1" color="gray">
|
|
170
|
+
{total} entries
|
|
171
|
+
</Text>
|
|
172
|
+
</div>
|
|
173
|
+
<div className="tree-controls">
|
|
174
|
+
<TextField.Root
|
|
175
|
+
aria-label="Search session"
|
|
176
|
+
placeholder="Search entries or content…"
|
|
177
|
+
value={filters.query}
|
|
178
|
+
onChange={(event) => change({ ...filters, query: event.target.value })}
|
|
179
|
+
>
|
|
180
|
+
<TextField.Slot>
|
|
181
|
+
<MagnifyingGlassIcon />
|
|
182
|
+
</TextField.Slot>
|
|
183
|
+
</TextField.Root>
|
|
184
|
+
<div className="filter-pills">
|
|
185
|
+
{["All", "Model", "Tool", "Custom"].map((group) => (
|
|
186
|
+
<button
|
|
187
|
+
type="button"
|
|
188
|
+
key={group}
|
|
189
|
+
aria-pressed={
|
|
190
|
+
group === "All" ? !filters.groups.length : filters.groups.length === 1 && filters.groups[0] === group
|
|
191
|
+
}
|
|
192
|
+
onClick={() => change({ ...filters, kind: "all", groups: group === "All" ? [] : [group] })}
|
|
193
|
+
>
|
|
194
|
+
{group}
|
|
195
|
+
</button>
|
|
196
|
+
))}
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
<Tree
|
|
200
|
+
nodes={nodes}
|
|
201
|
+
matches={matches}
|
|
202
|
+
selected={selected}
|
|
203
|
+
serial={serial}
|
|
204
|
+
revealSelected={revealSelected}
|
|
205
|
+
choose={choose}
|
|
206
|
+
/>
|
|
207
|
+
<div className="tree-count">
|
|
208
|
+
{matches.size} matches / {total} entries
|
|
209
|
+
</div>
|
|
210
|
+
</section>
|
|
211
|
+
<Collapsible.Root className="panel filter-panel" defaultOpen={false}>
|
|
212
|
+
<Collapsible.Trigger asChild>
|
|
213
|
+
<button className="filters-toggle" type="button">
|
|
214
|
+
<MixerHorizontalIcon />
|
|
215
|
+
Filters
|
|
216
|
+
<ChevronDownIcon />
|
|
217
|
+
</button>
|
|
218
|
+
</Collapsible.Trigger>
|
|
219
|
+
<Collapsible.Content>
|
|
220
|
+
<label className="filter-check" htmlFor="filter-errors">
|
|
221
|
+
<span>Errors only</span>
|
|
222
|
+
<Checkbox
|
|
223
|
+
id="filter-errors"
|
|
224
|
+
checked={filters.errorsOnly}
|
|
225
|
+
onCheckedChange={(checked) => change({ ...filters, errorsOnly: checked === true })}
|
|
226
|
+
/>
|
|
227
|
+
</label>
|
|
228
|
+
<label className="filter-check" htmlFor="filter-slow">
|
|
229
|
+
<span>Slow tool calls (> 10s)</span>
|
|
230
|
+
<Checkbox
|
|
231
|
+
id="filter-slow"
|
|
232
|
+
checked={filters.slowOnly}
|
|
233
|
+
onCheckedChange={(checked) => change({ ...filters, slowOnly: checked === true })}
|
|
234
|
+
/>
|
|
235
|
+
</label>
|
|
236
|
+
{[
|
|
237
|
+
["Tool", "Tool events"],
|
|
238
|
+
["Model", "Model events"],
|
|
239
|
+
["Custom", "Custom events"],
|
|
240
|
+
].map(([group, label]) => (
|
|
241
|
+
<label className="filter-check" key={group} htmlFor={`filter-${group}`}>
|
|
242
|
+
<span>{label}</span>
|
|
243
|
+
<Checkbox
|
|
244
|
+
id={`filter-${group}`}
|
|
245
|
+
checked={filters.groups.includes(group)}
|
|
246
|
+
onCheckedChange={() => toggleGroup(group)}
|
|
247
|
+
/>
|
|
248
|
+
</label>
|
|
249
|
+
))}
|
|
250
|
+
<Flex justify="between" align="center" gap="2">
|
|
251
|
+
<Text size="1" color="gray">
|
|
252
|
+
Entry type
|
|
253
|
+
</Text>
|
|
254
|
+
<Select.Root value={filters.kind} onValueChange={(kind) => change({ ...filters, kind })}>
|
|
255
|
+
<Select.Trigger aria-label="Filter entry type" variant="soft" />
|
|
256
|
+
<Select.Content>
|
|
257
|
+
{[
|
|
258
|
+
"all",
|
|
259
|
+
"user",
|
|
260
|
+
"assistant",
|
|
261
|
+
"toolResult",
|
|
262
|
+
"system",
|
|
263
|
+
"custom",
|
|
264
|
+
"compaction",
|
|
265
|
+
"branch_summary",
|
|
266
|
+
"model_change",
|
|
267
|
+
"thinking_level_change",
|
|
268
|
+
"context_edit",
|
|
269
|
+
].map((kind) => (
|
|
270
|
+
<Select.Item key={kind} value={kind}>
|
|
271
|
+
{kind}
|
|
272
|
+
</Select.Item>
|
|
273
|
+
))}
|
|
274
|
+
</Select.Content>
|
|
275
|
+
</Select.Root>
|
|
276
|
+
</Flex>
|
|
277
|
+
</Collapsible.Content>
|
|
278
|
+
</Collapsible.Root>
|
|
279
|
+
</aside>
|
|
280
|
+
);
|
|
281
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
// Variable-height virtualization. The top visible source ID anchors measurements and tail appends.
|
|
4
|
+
export function useVirtualContext(ids: string[]) {
|
|
5
|
+
const scroller = useRef<HTMLElement>(null);
|
|
6
|
+
const elements = useRef(new Map<string, HTMLDivElement>());
|
|
7
|
+
const anchor = useRef<{ id: string; offset: number } | undefined>(undefined);
|
|
8
|
+
const resetting = useRef(false);
|
|
9
|
+
const [resetPending, setResetPending] = useState(false);
|
|
10
|
+
const pendingFocus = useRef<string | undefined>(undefined);
|
|
11
|
+
const [heights, measure] = useState(new Map<string, number>());
|
|
12
|
+
const [range, setRange] = useState({ start: 0, end: 20 });
|
|
13
|
+
const [viewport, setViewport] = useState({ start: 0, end: 0 });
|
|
14
|
+
const positions = useMemo(() => {
|
|
15
|
+
let top = 0;
|
|
16
|
+
const rows = ids.map((id) => {
|
|
17
|
+
const height = heights.get(id) ?? 52;
|
|
18
|
+
const row = { id, top, height };
|
|
19
|
+
top += height;
|
|
20
|
+
return row;
|
|
21
|
+
});
|
|
22
|
+
return { rows, total: top };
|
|
23
|
+
}, [ids, heights]);
|
|
24
|
+
const current = useRef(positions);
|
|
25
|
+
current.current = positions;
|
|
26
|
+
const refresh = useCallback(() => {
|
|
27
|
+
const node = scroller.current;
|
|
28
|
+
if (!node) return;
|
|
29
|
+
const rows = current.current.rows;
|
|
30
|
+
const at = (top: number) => {
|
|
31
|
+
let lo = 0;
|
|
32
|
+
let hi = rows.length;
|
|
33
|
+
while (lo < hi) {
|
|
34
|
+
const mid = (lo + hi) >>> 1;
|
|
35
|
+
const row = rows[mid];
|
|
36
|
+
if (row && row.top + row.height <= top) lo = mid + 1;
|
|
37
|
+
else hi = mid;
|
|
38
|
+
}
|
|
39
|
+
return Math.min(lo, Math.max(0, rows.length - 1));
|
|
40
|
+
};
|
|
41
|
+
const index = at(node.scrollTop);
|
|
42
|
+
const row = rows[index];
|
|
43
|
+
if (!resetting.current) anchor.current = row ? { id: row.id, offset: node.scrollTop - row.top } : undefined;
|
|
44
|
+
const visible = { start: index, end: Math.min(rows.length, at(node.scrollTop + node.clientHeight) + 1) };
|
|
45
|
+
setViewport((old) => (old.start === visible.start && old.end === visible.end ? old : visible));
|
|
46
|
+
const next = {
|
|
47
|
+
start: Math.max(0, index - 5),
|
|
48
|
+
end: Math.min(rows.length, at(node.scrollTop + node.clientHeight) + 6),
|
|
49
|
+
};
|
|
50
|
+
setRange((old) => (old.start === next.start && old.end === next.end ? old : next));
|
|
51
|
+
}, []);
|
|
52
|
+
useLayoutEffect(() => {
|
|
53
|
+
const node = scroller.current;
|
|
54
|
+
if (!node) return;
|
|
55
|
+
const point = anchor.current;
|
|
56
|
+
const row = point && positions.rows.find((item) => item.id === point.id);
|
|
57
|
+
if (resetPending) {
|
|
58
|
+
node.scrollTop = 0;
|
|
59
|
+
anchor.current = undefined;
|
|
60
|
+
resetting.current = false;
|
|
61
|
+
setResetPending(false);
|
|
62
|
+
} else if (row) node.scrollTop = row.top + point.offset;
|
|
63
|
+
refresh();
|
|
64
|
+
}, [positions, refresh, resetPending]);
|
|
65
|
+
useLayoutEffect(() => {
|
|
66
|
+
const valid = new Set(ids);
|
|
67
|
+
measure((old) => {
|
|
68
|
+
if ([...old.keys()].every((key) => valid.has(key))) return old;
|
|
69
|
+
return new Map([...old].filter(([key]) => valid.has(key)));
|
|
70
|
+
});
|
|
71
|
+
}, [ids]);
|
|
72
|
+
useLayoutEffect(() => {
|
|
73
|
+
let disposed = false;
|
|
74
|
+
const observer = new ResizeObserver((entries) => {
|
|
75
|
+
if (disposed) return;
|
|
76
|
+
const measured = entries.flatMap((entry) => {
|
|
77
|
+
const id = (entry.target as HTMLElement).dataset.segmentId;
|
|
78
|
+
if (!id) {
|
|
79
|
+
refresh();
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
const height = Math.ceil(entry.target.getBoundingClientRect().height);
|
|
83
|
+
return height > 0 ? [{ id, height }] : [];
|
|
84
|
+
});
|
|
85
|
+
if (measured.length)
|
|
86
|
+
measure((old) => {
|
|
87
|
+
if (measured.every((item) => old.get(item.id) === item.height)) return old;
|
|
88
|
+
const next = new Map(old);
|
|
89
|
+
for (const item of measured) next.set(item.id, item.height);
|
|
90
|
+
return next;
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
if (scroller.current) observer.observe(scroller.current);
|
|
94
|
+
for (const id of ids.slice(range.start, range.end)) {
|
|
95
|
+
const element = elements.current.get(id);
|
|
96
|
+
if (element) observer.observe(element);
|
|
97
|
+
}
|
|
98
|
+
const focused =
|
|
99
|
+
pendingFocus.current && elements.current.get(pendingFocus.current)?.querySelector<HTMLElement>(".segment-row");
|
|
100
|
+
if (focused) {
|
|
101
|
+
focused.focus({ preventScroll: true });
|
|
102
|
+
pendingFocus.current = undefined;
|
|
103
|
+
}
|
|
104
|
+
return () => {
|
|
105
|
+
disposed = true;
|
|
106
|
+
observer.disconnect();
|
|
107
|
+
};
|
|
108
|
+
}, [range.start, range.end, ids, refresh]);
|
|
109
|
+
function jump(index: number, focus = false): void {
|
|
110
|
+
const row = positions.rows[index];
|
|
111
|
+
if (!row || !scroller.current) return;
|
|
112
|
+
if (focus) {
|
|
113
|
+
const element = elements.current.get(row.id)?.querySelector<HTMLElement>(".segment-row");
|
|
114
|
+
if (element) element.focus({ preventScroll: true });
|
|
115
|
+
else pendingFocus.current = row.id;
|
|
116
|
+
}
|
|
117
|
+
scroller.current.scrollTop = row.top;
|
|
118
|
+
refresh();
|
|
119
|
+
}
|
|
120
|
+
const reset = useCallback(() => {
|
|
121
|
+
resetting.current = true;
|
|
122
|
+
setResetPending(true);
|
|
123
|
+
if (scroller.current) scroller.current.scrollTop = 0;
|
|
124
|
+
anchor.current = undefined;
|
|
125
|
+
refresh();
|
|
126
|
+
}, [refresh]);
|
|
127
|
+
return {
|
|
128
|
+
scroller,
|
|
129
|
+
elements,
|
|
130
|
+
positions: positions.rows,
|
|
131
|
+
totalHeight: positions.total,
|
|
132
|
+
range,
|
|
133
|
+
viewport,
|
|
134
|
+
refresh,
|
|
135
|
+
jump,
|
|
136
|
+
reset,
|
|
137
|
+
};
|
|
138
|
+
}
|