@narumitw/pi-inspect 0.1.0 → 0.2.1

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/src/web/trace.tsx CHANGED
@@ -5,6 +5,7 @@ import type { Call, EntrySummary } from "../model.js";
5
5
  import { Glyph, Status } from "./components.js";
6
6
  import { count, eventName, time } from "./format.js";
7
7
  import { flatten, hierarchy, label, reveal, withAncestors } from "./hierarchy.js";
8
+ import { type HistoryRow, historySummary } from "./history.js";
8
9
  import { InlineEntry } from "./inline-entry.js";
9
10
  import { TimelineMark } from "./timeline.js";
10
11
  import { axis } from "./timing.js";
@@ -25,6 +26,8 @@ export function Trace({
25
26
  view,
26
27
  changeView,
27
28
  calls,
29
+ historyRows,
30
+ active = true,
28
31
  }: {
29
32
  entries: EntrySummary[];
30
33
  matches: Set<string>;
@@ -35,6 +38,8 @@ export function Trace({
35
38
  view: string;
36
39
  changeView(view: string): void;
37
40
  calls: Call[];
41
+ historyRows?: HistoryRow[];
42
+ active?: boolean;
38
43
  }) {
39
44
  const tree = useMemo(() => hierarchy(entries), [entries]);
40
45
  const keep = useMemo(
@@ -49,7 +54,25 @@ export function Trace({
49
54
  const focusRequest = useRef(false);
50
55
  const scroller = useRef<HTMLDivElement>(null);
51
56
  const rowRefs = useRef(new Map<string, HTMLDivElement>());
52
- const rows = useMemo(() => flatten(tree, expanded, keep), [tree, expanded, keep]);
57
+ const rows = useMemo(
58
+ () =>
59
+ historyRows
60
+ ? historyRows
61
+ .filter(({ node }) => matches.has(node.id) || (revealSelected && node.id === selected))
62
+ .map((row, index, visible) => ({
63
+ ...row,
64
+ depth: 0,
65
+ childCount: 0,
66
+ heading:
67
+ index === 0 || visible[index - 1]?.turn !== row.turn
68
+ ? row.turn === "before-user"
69
+ ? "Before first user turn"
70
+ : "User turn"
71
+ : undefined,
72
+ }))
73
+ : flatten(tree, expanded, keep),
74
+ [tree, expanded, keep, historyRows, matches, revealSelected, selected],
75
+ );
53
76
  const pageRows = rows.slice(offset, offset + 50);
54
77
  const visibleFocused = pageRows.some((row) => row.node.id === focused) ? focused : (pageRows[0]?.node.id ?? "");
55
78
  const baseDepth = pageRows.length ? Math.min(...pageRows.map((row) => row.depth)) : 0;
@@ -58,15 +81,16 @@ export function Trace({
58
81
  const range = useMemo(() => axis(entries, calls), [entries, calls]);
59
82
  useEffect(() => {
60
83
  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);
84
+ const next = historyRows ? expanded : reveal(tree, expanded, selected);
85
+ const index = (historyRows ? rows : flatten(tree, next, keep)).findIndex((row) => row.node.id === selected);
65
86
  if (index >= 0) {
87
+ // Keep reveal pending until the selection belongs to this projection.
88
+ lastReveal.current = serial;
89
+ if (!historyRows) setExpanded(next);
66
90
  setOffset(Math.floor(index / 50) * 50);
67
91
  setFocused(selected);
68
92
  }
69
- }, [tree, selected, serial, expanded, keep]);
93
+ }, [tree, selected, serial, expanded, keep, historyRows, rows]);
70
94
  useEffect(() => {
71
95
  const element = rowRefs.current.get(visibleFocused);
72
96
  if (!element || !scroller.current) return;
@@ -97,11 +121,11 @@ export function Trace({
97
121
  rowRefs.current.get(id)?.focus({ preventScroll: true });
98
122
  }
99
123
  return (
100
- <section className={`panel trace-panel trace-${view}`}>
124
+ <section className={`panel trace-panel trace-${view} ${historyRows ? "trace-history" : "trace-branches"}`}>
101
125
  <div className="trace-toolbar">
102
- <Heading size="3">Trace explorer</Heading>
126
+ <Heading size="3">{historyRows ? "History" : "Branch view"}</Heading>
103
127
  <Text size="1" color="gray">
104
- Session log hierarchy
128
+ {historyRows ? "Conversation groups · not execution parentage" : "Actual session ancestry · not a call stack"}
105
129
  </Text>
106
130
  <div className="trace-actions">
107
131
  <Button
@@ -129,19 +153,25 @@ export function Trace({
129
153
  </div>
130
154
  </div>
131
155
  <div className="trace-hint">
132
- Row: select + toggle · Chevron: toggle only · ↑↓ navigate · ←→ collapse/expand · Enter select · Space toggle
156
+ Row: select · Chevron: toggle only · ↑↓ navigate · ←→ collapse/expand · Enter select · Space toggle
133
157
  </div>
134
158
  {view === "timeline" && (
135
159
  <div className="time-axis">
136
160
  <span>{range ? new Date(range.start).toISOString().slice(11, 23) : "No timing"}</span>
137
- <span>● Log timestamp · ▰ Observed tool interval (live drawer)</span>
161
+ <span>● Log timestamp · ▰ Observed tool interval (captured executions)</span>
138
162
  <span>{range ? new Date(range.end).toISOString().slice(11, 23) : "—"}</span>
139
163
  </div>
140
164
  )}
141
165
  <div className="trace-scroll" ref={scroller}>
142
166
  <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 }) => {
167
+ {!rows.length && (
168
+ <div className="empty-state">
169
+ {historyRows
170
+ ? "No visible entries match in this branch. Check filters or Show internal events."
171
+ : "No entries match. Filters keep actual ancestor context."}
172
+ </div>
173
+ )}
174
+ {pageRows.map(({ node: entry, depth, childCount, ...group }) => {
145
175
  const open = expanded.has(entry.id);
146
176
  const selectedRow = selected === entry.id;
147
177
  return (
@@ -154,11 +184,17 @@ export function Trace({
154
184
  data-state={open ? "open" : "closed"}
155
185
  style={{ marginLeft: (depth - baseDepth) * indent }}
156
186
  >
187
+ {"heading" in group && typeof group.heading === "string" && (
188
+ <div className="turn-heading">{group.heading}</div>
189
+ )}
157
190
  <div
158
191
  role="treeitem"
159
192
  aria-level={depth + 1}
160
193
  aria-expanded={open}
161
194
  aria-selected={selectedRow}
195
+ aria-current={selectedRow ? "true" : undefined}
196
+ data-entry-id={entry.id}
197
+ data-match={matches.has(entry.id)}
162
198
  tabIndex={visibleFocused === entry.id ? 0 : -1}
163
199
  ref={(element) => {
164
200
  if (element) rowRefs.current.set(entry.id, element);
@@ -170,7 +206,6 @@ export function Trace({
170
206
  onClick={(event) => {
171
207
  if (event.target !== event.currentTarget && (event.target as HTMLElement).closest("button")) return;
172
208
  select(entry.id);
173
- toggle(entry.id);
174
209
  }}
175
210
  onKeyDown={(event) => {
176
211
  if (event.target !== event.currentTarget || event.altKey || event.ctrlKey || event.metaKey) return;
@@ -213,13 +248,16 @@ export function Trace({
213
248
  <Glyph kind={entry.kind} />
214
249
  <span className="row-title">
215
250
  <strong>{eventName(entry.kind)}</strong>
216
- <span title={label(entry)}>{label(entry)}</span>
251
+ <span title={historySummary(entry)}>
252
+ {historyRows ? historySummary(entry) : label(entry)}
253
+ {entry.summaryTruncated && historyRows ? " · truncated" : ""}
254
+ </span>
217
255
  </span>
218
256
  <code title={entry.id}>{entry.id.slice(0, 8)}</code>
219
257
  {!matches.has(entry.id) && (
220
258
  <span className="ancestor-badge">{selectedRow ? "selected outside filters" : "ancestor"}</span>
221
259
  )}
222
- {depth === 0 && entry.parentId && tree.nodes.has(entry.parentId) && (
260
+ {!historyRows && depth === 0 && entry.parentId && tree.nodes.has(entry.parentId) && (
223
261
  <span className="ancestor-badge">recorded parent cycle</span>
224
262
  )}
225
263
  </div>
@@ -234,7 +272,7 @@ export function Trace({
234
272
  </div>
235
273
  {open && disclosed.has(entry.id) && (
236
274
  <div className="entry-expanded">
237
- <InlineEntry entry={entry} inspect={select} />
275
+ {active && <InlineEntry entry={entry} inspect={select} />}
238
276
  {childCount > 0 && (
239
277
  <span className="children-label">
240
278
  {childCount} visible child {childCount === 1 ? "entry" : "entries"}
@@ -254,7 +292,9 @@ export function Trace({
254
292
  <span>
255
293
  {baseDepth
256
294
  ? `Absolute hierarchy level ${baseDepth + 1}+ · page-local indentation`
257
- : "Original parents retained · browser-only selection"}
295
+ : historyRows
296
+ ? "Branch-scoped history · browser-only selection"
297
+ : "Original parents retained · browser-only selection"}
258
298
  </span>
259
299
  <Flex gap="2" align="center">
260
300
  <Button size="1" variant="ghost" disabled={!offset} onClick={() => setOffset(Math.max(0, offset - 50))}>
package/src/web/tree.tsx DELETED
@@ -1,281 +0,0 @@
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 (&gt; 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
- }