@telorun/debug-ui 0.3.0 → 0.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/debug-ui",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Browser-safe debug event UI for Telo: wire types, filtering, SSE client, and React components for watching a running app's event stream.",
5
5
  "keywords": [
6
6
  "telo",
@@ -46,7 +46,7 @@
46
46
  "@dagrejs/dagre": "^3.0.0",
47
47
  "@xyflow/react": "^12.10.2",
48
48
  "lucide-react": "^1.8.0",
49
- "@telorun/debug-wire": "0.1.0"
49
+ "@telorun/debug-wire": "0.3.0"
50
50
  },
51
51
  "peerDependencies": {
52
52
  "react": ">=18",
@@ -2,6 +2,7 @@ import Dagre from "@dagrejs/dagre";
2
2
  import {
3
3
  Background,
4
4
  Controls,
5
+ type Edge,
5
6
  Handle,
6
7
  type Node,
7
8
  type NodeProps,
@@ -10,12 +11,17 @@ import {
10
11
  ReactFlow,
11
12
  } from "@xyflow/react";
12
13
  import "@xyflow/react/dist/style.css";
13
- import { useEffect, useMemo, useRef, useState } from "react";
14
+ import { type CSSProperties, useEffect, useMemo, useRef, useState } from "react";
14
15
  import {
16
+ collapseTopology,
15
17
  deriveGraph,
16
18
  deriveInvocations,
19
+ type GraphEdge,
17
20
  type GraphNode,
21
+ type GraphState,
22
+ type GroupedGraphNode,
18
23
  type Invocation,
24
+ subtreeGraph,
19
25
  type TraceNode,
20
26
  traceSubgraph,
21
27
  } from "../graph.js";
@@ -38,12 +44,14 @@ const badgeClass = (outcome: Invocation["outcome"]) =>
38
44
  // ── Custom nodes ────────────────────────────────────────────────────────────
39
45
 
40
46
  interface TopologyNodeData extends Record<string, unknown> {
41
- node: GraphNode;
47
+ node: GroupedGraphNode;
42
48
  selected: boolean;
43
49
  }
44
50
 
45
51
  /** Live-topology node: status dot, name + kind, invocation badge; pulses on each
46
- * new invocation, tinted by outcome. */
52
+ * new invocation, tinted by outcome. A node that owns spawned children (a
53
+ * templated kind) shows an "n internal" badge — clicking it opens the drill-down
54
+ * pane with that resource and the children it spawned. */
47
55
  function TopologyNode({ data }: NodeProps<Node<TopologyNodeData>>) {
48
56
  const { node, selected } = data;
49
57
  const [pulse, setPulse] = useState<"" | "ok" | "bad">("");
@@ -61,6 +69,7 @@ function TopologyNode({ data }: NodeProps<Node<TopologyNodeData>>) {
61
69
  const cls = [
62
70
  "tdbg-node",
63
71
  `tdbg-node-${node.status}`,
72
+ node.childCount > 0 ? "tdbg-node-owner" : "",
64
73
  selected ? "tdbg-node-sel" : "",
65
74
  pulse ? `tdbg-node-pulse-${pulse}` : "",
66
75
  ]
@@ -84,6 +93,9 @@ function TopologyNode({ data }: NodeProps<Node<TopologyNodeData>>) {
84
93
  )}
85
94
  </div>
86
95
  <div className="tdbg-node-kind">{node.kind}</div>
96
+ {node.childCount > 0 && (
97
+ <span className="tdbg-node-internal">{node.childCount} internal ›</span>
98
+ )}
87
99
  <Handle type="source" position={Position.Right} className="tdbg-node-handle" />
88
100
  </div>
89
101
  );
@@ -114,12 +126,12 @@ function TraceFlowNode({ data }: NodeProps<Node<TraceNodeData>>) {
114
126
  <Handle type="target" position={Position.Left} className="tdbg-node-handle" />
115
127
  <div className="tdbg-node-head">
116
128
  {node.isRoot && <span className="tdbg-node-roottag">root</span>}
117
- <span className="tdbg-node-name">{node.name}</span>
129
+ <span className="tdbg-node-name">{node.label ?? node.name}</span>
118
130
  <span className={badgeClass(last.outcome)}>
119
131
  {node.invocations.length > 1 ? `${node.invocations.length}×` : last.suffix}
120
132
  </span>
121
133
  </div>
122
- <div className="tdbg-node-kind">{node.kind}</div>
134
+ <div className="tdbg-node-kind">{node.label ? `${node.kind} · ${node.name}` : node.kind}</div>
123
135
  <Handle type="source" position={Position.Right} className="tdbg-node-handle" />
124
136
  </div>
125
137
  );
@@ -149,6 +161,29 @@ function layoutPositions(graph: {
149
161
  return pos;
150
162
  }
151
163
 
164
+ /** Map graph edges to ReactFlow edges, dropping any whose endpoints aren't shown.
165
+ * Ownership edges (owner → child) render dashed + muted so they read as
166
+ * containment, not data flow. */
167
+ function buildFlowEdges(edges: readonly GraphEdge[], present: ReadonlySet<string>): Edge[] {
168
+ return edges
169
+ .filter((e) => present.has(e.source) && present.has(e.target))
170
+ .map((e) => ({
171
+ id: e.id,
172
+ source: e.source,
173
+ target: e.target,
174
+ ...(e.ownership
175
+ ? {
176
+ className: "tdbg-edge-owner",
177
+ style: { stroke: "var(--tdbg-muted)", strokeDasharray: "5 4", opacity: 0.65 },
178
+ }
179
+ : {}),
180
+ }));
181
+ }
182
+
183
+ /** The main canvas shows the topology fully collapsed — templated resources are
184
+ * one node each; their children are reached through the drill-down pane. */
185
+ const NO_EXPANSION: ReadonlySet<string> = new Set();
186
+
152
187
  // ── The view ────────────────────────────────────────────────────────────────
153
188
 
154
189
  /**
@@ -162,6 +197,10 @@ export function EventGraph({ events, resolveUrl }: EventGraphProps) {
162
197
  const [traceId, setTraceId] = useState<number | null>(null);
163
198
  const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
164
199
 
200
+ // Drill-down stack of owner ids: clicking a templated resource pushes it and
201
+ // opens the nested pane; the breadcrumb pops back. Empty = pane closed.
202
+ const [drilldown, setDrilldown] = useState<string[]>([]);
203
+
165
204
  // Default on: standalone services / providers with no dependency wiring are
166
205
  // usually noise in the topology view.
167
206
  const [hideUnconnected, setHideUnconnected] = useState(true);
@@ -176,19 +215,23 @@ export function EventGraph({ events, resolveUrl }: EventGraphProps) {
176
215
  [trace, activeTraceId],
177
216
  );
178
217
 
218
+ // The main canvas is the fully-collapsed topology: a templated resource is one
219
+ // node (absorbing its children's edges); the children are reached by clicking it.
220
+ const collapsed = useMemo(() => collapseTopology(topology, NO_EXPANSION), [topology]);
221
+
179
222
  // Topology with isolated nodes (no incoming/outgoing edge) dropped when the
180
223
  // toggle is on. Only meaningful for the live topology — trace subgraphs are all
181
224
  // connected by call edges.
182
225
  const displayTopology = useMemo(() => {
183
- if (!hideUnconnected) return topology;
226
+ if (!hideUnconnected) return collapsed;
184
227
  const connected = new Set<string>();
185
- for (const e of topology.edges) {
228
+ for (const e of collapsed.edges) {
186
229
  connected.add(e.source);
187
230
  connected.add(e.target);
188
231
  }
189
- return { nodes: topology.nodes.filter((n) => connected.has(n.id)), edges: topology.edges };
190
- }, [topology, hideUnconnected]);
191
- const hiddenCount = topology.nodes.length - displayTopology.nodes.length;
232
+ return { nodes: collapsed.nodes.filter((n) => connected.has(n.id)), edges: collapsed.edges };
233
+ }, [collapsed, hideUnconnected]);
234
+ const hiddenCount = collapsed.nodes.length - displayTopology.nodes.length;
192
235
 
193
236
  const graph = scoped ?? displayTopology;
194
237
  const topoKey = useMemo(
@@ -219,16 +262,26 @@ export function EventGraph({ events, resolveUrl }: EventGraphProps) {
219
262
  }, [scoped, displayTopology, positions, selectedNodeId]);
220
263
 
221
264
  const flowEdges = useMemo(
222
- () =>
223
- graph.edges
224
- .filter((e) => present.has(e.source) && present.has(e.target))
225
- .map((e) => ({ id: e.id, source: e.source, target: e.target })),
265
+ () => buildFlowEdges(graph.edges as readonly GraphEdge[], present),
226
266
  [graph, present],
227
267
  );
228
268
 
229
269
  const selectTrace = (id: number | null) => {
230
270
  setTraceId(id);
231
271
  setSelectedNodeId(null);
272
+ setDrilldown([]);
273
+ };
274
+
275
+ // Clicking a templated resource (one that owns children) opens the drill-down
276
+ // pane; any other node opens its inputs/outputs detail.
277
+ const onTopologyNodeClick = (id: string) => {
278
+ const node = displayTopology.nodes.find((n) => n.id === id);
279
+ if (node && node.childCount > 0) {
280
+ setDrilldown([id]);
281
+ setSelectedNodeId(null);
282
+ } else {
283
+ setSelectedNodeId(id);
284
+ }
232
285
  };
233
286
 
234
287
  return (
@@ -244,8 +297,8 @@ export function EventGraph({ events, resolveUrl }: EventGraphProps) {
244
297
  <div className="tdbg-empty">
245
298
  {activeTraceId !== null
246
299
  ? "No resources in this trace."
247
- : hideUnconnected && topology.nodes.length > 0
248
- ? `All ${topology.nodes.length} resource(s) are unconnected — turn off "Hide unconnected" to show them.`
300
+ : hideUnconnected && collapsed.nodes.length > 0
301
+ ? `All ${collapsed.nodes.length} resource(s) are unconnected — turn off "Hide unconnected" to show them.`
249
302
  : "No resources yet — waiting for the stream…"}
250
303
  </div>
251
304
  ) : (
@@ -262,7 +315,9 @@ export function EventGraph({ events, resolveUrl }: EventGraphProps) {
262
315
  fitView
263
316
  minZoom={0.2}
264
317
  proOptions={{ hideAttribution: true }}
265
- onNodeClick={(_e, node) => setSelectedNodeId(node.id)}
318
+ onNodeClick={(_e, node) =>
319
+ scoped ? setSelectedNodeId(node.id) : onTopologyNodeClick(node.id)
320
+ }
266
321
  onPaneClick={() => setSelectedNodeId(null)}
267
322
  >
268
323
  <Background />
@@ -299,10 +354,146 @@ export function EventGraph({ events, resolveUrl }: EventGraphProps) {
299
354
  onClose={() => setSelectedNodeId(null)}
300
355
  />
301
356
  ))}
357
+ {!scoped && drilldown.length > 0 && (
358
+ <>
359
+ <div className="tdbg-nested-backdrop" onClick={() => setDrilldown([])} />
360
+ {drilldown.map((ownerId, i) => (
361
+ <NestedGraphPane
362
+ key={`${ownerId}@${i}`}
363
+ topology={topology}
364
+ ownerId={ownerId}
365
+ depth={i}
366
+ isTop={i === drilldown.length - 1}
367
+ onDrill={(childId) => setDrilldown((d) => [...d.slice(0, i + 1), childId])}
368
+ onReveal={() => setDrilldown((d) => d.slice(0, i + 1))}
369
+ onClose={() => setDrilldown((d) => d.slice(0, i))}
370
+ resolveUrl={resolveUrl}
371
+ />
372
+ ))}
373
+ </>
374
+ )}
302
375
  </div>
303
376
  );
304
377
  }
305
378
 
379
+ /**
380
+ * One panel in the drill-down stack — a templated resource and the children it
381
+ * spawned (`subtreeGraph`), wired by dashed ownership edges plus the dependency
382
+ * edges among them. Each drill-in pushes a *new* panel (cascaded to the right);
383
+ * clicking a child that owns its own children opens the next panel, recursive to
384
+ * any depth. Only the top panel is interactive; clicking a panel beneath pops the
385
+ * stack back to it, and clicking a leaf (or the parent) opens its detail.
386
+ */
387
+ function NestedGraphPane({
388
+ topology,
389
+ ownerId,
390
+ depth,
391
+ isTop,
392
+ onDrill,
393
+ onReveal,
394
+ onClose,
395
+ resolveUrl,
396
+ }: {
397
+ topology: GraphState;
398
+ ownerId: string;
399
+ depth: number;
400
+ isTop: boolean;
401
+ onDrill: (childId: string) => void;
402
+ onReveal: () => void;
403
+ onClose: () => void;
404
+ resolveUrl: (rel: string) => string;
405
+ }) {
406
+ const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
407
+
408
+ const nodeById = useMemo(() => new Map(topology.nodes.map((n) => [n.id, n])), [topology]);
409
+ const sub = useMemo(() => subtreeGraph(topology, ownerId), [topology, ownerId]);
410
+ const positions = useMemo(() => layoutPositions(sub), [sub]);
411
+ const present = useMemo(() => new Set(sub.nodes.map((n) => n.id)), [sub]);
412
+
413
+ const flowNodes: Node[] = useMemo(
414
+ () =>
415
+ sub.nodes.map((n) => ({
416
+ id: n.id,
417
+ type: "topology",
418
+ position: positions.get(n.id) ?? { x: 0, y: 0 },
419
+ data: { node: n, selected: n.id === selectedNodeId } satisfies TopologyNodeData,
420
+ })),
421
+ [sub, positions, selectedNodeId],
422
+ );
423
+ const flowEdges = useMemo(() => buildFlowEdges(sub.edges, present), [sub, present]);
424
+
425
+ const onNodeClick = (id: string) => {
426
+ const node = sub.nodes.find((n) => n.id === id);
427
+ // A child that owns its own children opens the next panel; the parent itself
428
+ // or a leaf opens its detail in this panel.
429
+ if (id !== ownerId && node && node.childCount > 0) onDrill(id);
430
+ else setSelectedNodeId(id);
431
+ };
432
+
433
+ const owner = nodeById.get(ownerId);
434
+ const detailNode = selectedNodeId ? nodeById.get(selectedNodeId) : undefined;
435
+
436
+ return (
437
+ <aside
438
+ className={`tdbg-nested-pane${isTop ? "" : " tdbg-nested-pane-under"}`}
439
+ // `--tdbg-depth` drives the cascade offset (`left`) in CSS, so a media query
440
+ // can tighten it on a narrow screen; z-index stacks the panels.
441
+ style={{ zIndex: 20 + depth, ["--tdbg-depth"]: depth } as CSSProperties}
442
+ // A panel beneath the top acts as a single "go back here" surface.
443
+ onClick={isTop ? undefined : onReveal}
444
+ >
445
+ <div className="tdbg-nested-head">
446
+ <span className="tdbg-nested-title">
447
+ <span className="tdbg-node-name">{owner?.name ?? ownerId}</span>
448
+ {owner && <span className="tdbg-muted">{owner.kind}</span>}
449
+ </span>
450
+ <button
451
+ className="tdbg-btn tdbg-icon-btn"
452
+ onClick={(e) => {
453
+ e.stopPropagation();
454
+ onClose();
455
+ }}
456
+ title="Close"
457
+ >
458
+ ✕
459
+ </button>
460
+ </div>
461
+ <div className="tdbg-nested-body" style={{ pointerEvents: isTop ? "auto" : "none" }}>
462
+ <div className="tdbg-nested-canvas">
463
+ {sub.nodes.length === 0 ? (
464
+ <div className="tdbg-empty">This resource spawned no children.</div>
465
+ ) : (
466
+ <ReactFlow
467
+ key={ownerId}
468
+ nodes={flowNodes}
469
+ edges={flowEdges}
470
+ nodeTypes={nodeTypes}
471
+ nodesDraggable={false}
472
+ nodesConnectable={false}
473
+ edgesFocusable={false}
474
+ fitView
475
+ minZoom={0.2}
476
+ proOptions={{ hideAttribution: true }}
477
+ onNodeClick={(_e, node) => onNodeClick(node.id)}
478
+ onPaneClick={() => setSelectedNodeId(null)}
479
+ >
480
+ <Background />
481
+ <Controls showInteractive={false} />
482
+ </ReactFlow>
483
+ )}
484
+ </div>
485
+ {detailNode && (
486
+ <NodeDetail
487
+ node={detailNode}
488
+ resolveUrl={resolveUrl}
489
+ onClose={() => setSelectedNodeId(null)}
490
+ />
491
+ )}
492
+ </div>
493
+ </aside>
494
+ );
495
+ }
496
+
306
497
  function countSubtree(trace: ReturnType<typeof deriveInvocations>, rootId: number): number {
307
498
  let n = 0;
308
499
  const visit = (id: number) => {
@@ -369,6 +560,9 @@ function NodeDetail({
369
560
  }) {
370
561
  if (!node) return null;
371
562
  const i = node.lastInvoke;
563
+ const props = node.properties;
564
+ const hasProps =
565
+ !!props && typeof props === "object" && Object.keys(props as object).length > 0;
372
566
  return (
373
567
  <aside className="tdbg-graph-detail">
374
568
  <DetailHead name={node.name} onClose={onClose} />
@@ -377,6 +571,12 @@ function NodeDetail({
377
571
  <span className={`tdbg-badge tdbg-suffix-${node.status}`}>{node.status}</span>
378
572
  <span className="tdbg-muted">{node.invokeCount} invocation(s)</span>
379
573
  </div>
574
+ {hasProps && (
575
+ <div className="tdbg-graph-detail-body">
576
+ <div className="tdbg-graph-detail-label">Properties</div>
577
+ <PayloadInspector value={props} resolveUrl={resolveUrl} />
578
+ </div>
579
+ )}
380
580
  {i ? (
381
581
  <div className="tdbg-graph-detail-body">
382
582
  <div className="tdbg-graph-detail-sub">
@@ -420,6 +620,12 @@ function TraceNodeDetail({
420
620
  <span className="tdbg-muted">{inv.timestamp.slice(11, 23)}</span>
421
621
  </div>
422
622
  <InOut inv={inv} resolveUrl={resolveUrl} />
623
+ {inv.context !== undefined && (
624
+ <>
625
+ <div className="tdbg-graph-detail-label">Available context</div>
626
+ <PayloadInspector value={inv.context} resolveUrl={resolveUrl} />
627
+ </>
628
+ )}
423
629
  </div>
424
630
  ))}
425
631
  </div>
@@ -333,6 +333,7 @@
333
333
  display: flex;
334
334
  flex: 1;
335
335
  min-height: 0;
336
+ position: relative;
336
337
  }
337
338
  .tdbg-graph-canvas {
338
339
  flex: 1;
@@ -349,6 +350,77 @@
349
350
  --xy-controls-button-color-hover-default: var(--tdbg-accent);
350
351
  --xy-controls-button-border-color-default: var(--tdbg-line);
351
352
  background: var(--tdbg-bg);
353
+ /* xyflow ships no `touch-action`, so on touch the browser claims one-finger
354
+ * drags and d3-zoom never pans/zooms. Hand the gestures to the canvas. */
355
+ touch-action: none;
356
+ }
357
+
358
+ /* ── Drill-down stack ───────────────────────────────────────────────────── */
359
+ /* One backdrop behind the whole stack; clicking it closes every panel. */
360
+ .tdbg-nested-backdrop {
361
+ position: absolute;
362
+ inset: 0;
363
+ z-index: 19;
364
+ background: rgba(0, 0, 0, 0.45);
365
+ }
366
+ /* Each drill level is its own panel, docked right and cascaded left (via inline
367
+ * `left` + `z-index`) so the panels beneath peek out as a navigable stack. */
368
+ .tdbg-nested-pane {
369
+ position: absolute;
370
+ top: 0;
371
+ right: 0;
372
+ bottom: 0;
373
+ /* Cascade offset: each deeper panel starts further right (`--tdbg-depth` set by
374
+ * the component), leaving the panels beneath peeking on the left. */
375
+ left: calc(10% + var(--tdbg-depth, 0) * 46px);
376
+ display: flex;
377
+ flex-direction: column;
378
+ min-width: 0;
379
+ background: var(--tdbg-bg);
380
+ border-left: 1px solid var(--tdbg-line);
381
+ box-shadow: -8px 0 24px rgba(0, 0, 0, 0.3);
382
+ }
383
+ /* A panel beneath the top is a single "go back here" surface. */
384
+ .tdbg-nested-pane-under {
385
+ cursor: pointer;
386
+ }
387
+ .tdbg-nested-pane-under::after {
388
+ content: "";
389
+ position: absolute;
390
+ inset: 0;
391
+ background: rgba(0, 0, 0, 0.2);
392
+ pointer-events: none;
393
+ }
394
+ .tdbg-nested-head {
395
+ display: flex;
396
+ align-items: center;
397
+ gap: 8px;
398
+ padding: 6px 10px;
399
+ border-bottom: 1px solid var(--tdbg-line);
400
+ background: var(--tdbg-panel);
401
+ }
402
+ .tdbg-nested-title {
403
+ display: flex;
404
+ align-items: baseline;
405
+ gap: 8px;
406
+ flex: 1;
407
+ min-width: 0;
408
+ overflow: hidden;
409
+ }
410
+ .tdbg-nested-title .tdbg-node-name {
411
+ overflow: hidden;
412
+ text-overflow: ellipsis;
413
+ }
414
+ .tdbg-nested-body {
415
+ display: flex;
416
+ flex: 1;
417
+ min-height: 0;
418
+ }
419
+ .tdbg-nested-canvas {
420
+ flex: 1;
421
+ min-width: 0;
422
+ min-height: 0;
423
+ position: relative;
352
424
  }
353
425
 
354
426
  .tdbg-node {
@@ -429,6 +501,19 @@
429
501
  overflow: hidden;
430
502
  text-overflow: ellipsis;
431
503
  }
504
+ .tdbg-node-owner {
505
+ border-left: 3px solid var(--tdbg-accent);
506
+ }
507
+ .tdbg-node-internal {
508
+ display: inline-block;
509
+ margin-top: 3px;
510
+ padding: 0 6px;
511
+ border-radius: 999px;
512
+ background: var(--tdbg-accent-bg, var(--tdbg-panel));
513
+ color: var(--tdbg-accent);
514
+ font-size: 10px;
515
+ font-weight: 600;
516
+ }
432
517
  .tdbg-node-pulse-ok {
433
518
  animation: tdbg-pulse-ok 0.65s ease-out;
434
519
  }
@@ -491,7 +576,10 @@
491
576
  .tdbg-graph-detail-body {
492
577
  display: flex;
493
578
  flex-direction: column;
494
- min-height: 0;
579
+ /* Don't let flex shrink the body below its content — otherwise each payload
580
+ * (overflow: auto) collapses into a tiny nested scrollbar. The whole detail
581
+ * aside (overflow: auto) is the single scroll container. */
582
+ flex-shrink: 0;
495
583
  }
496
584
  .tdbg-graph-detail-sub {
497
585
  display: flex;
@@ -597,3 +685,79 @@
597
685
  .tdbg-graph-toggle input {
598
686
  cursor: pointer;
599
687
  }
688
+
689
+ /* ── Mobile (≤640px) ────────────────────────────────────────────────────────
690
+ * The desktop layout leans on fixed-width side rails (220px trace list, 340px
691
+ * detail) and a single-row header. On a phone we wrap the header, stack the graph
692
+ * vertically (trace list → top scroll strip, detail → bottom sheet), and let the
693
+ * drill-down panels go near-full-width. */
694
+ @media (max-width: 640px) {
695
+ .tdbg-tabs {
696
+ flex-wrap: wrap;
697
+ row-gap: 6px;
698
+ }
699
+ .tdbg-tab,
700
+ .tdbg-btn {
701
+ min-height: 34px;
702
+ }
703
+ .tdbg-bar-row {
704
+ flex-wrap: wrap;
705
+ }
706
+
707
+ /* Graph: stack the rails above/below the canvas instead of beside it. */
708
+ .tdbg-graph {
709
+ flex-direction: column;
710
+ }
711
+
712
+ /* Trace list → a horizontal scroll strip of invocation chips. */
713
+ .tdbg-trace-list {
714
+ width: 100%;
715
+ flex-direction: row;
716
+ align-items: stretch;
717
+ max-height: 30vh;
718
+ overflow-x: auto;
719
+ overflow-y: hidden;
720
+ border-right: none;
721
+ border-bottom: 1px solid var(--tdbg-line);
722
+ }
723
+ .tdbg-trace-list-head {
724
+ position: static;
725
+ flex: 0 0 auto;
726
+ display: flex;
727
+ align-items: center;
728
+ border-bottom: none;
729
+ border-right: 1px solid var(--tdbg-line);
730
+ }
731
+ .tdbg-trace-row {
732
+ width: auto;
733
+ flex: 0 0 auto;
734
+ border-bottom: none;
735
+ border-right: 1px solid var(--tdbg-line);
736
+ white-space: nowrap;
737
+ }
738
+ .tdbg-trace-row .tdbg-node-name {
739
+ flex: 0 0 auto;
740
+ }
741
+ .tdbg-trace-row-on {
742
+ box-shadow: inset 0 -2px 0 var(--tdbg-accent);
743
+ }
744
+
745
+ /* Detail → a bottom sheet overlaying the canvas, not a 340px side column. */
746
+ .tdbg-graph-detail {
747
+ position: absolute;
748
+ left: 0;
749
+ right: 0;
750
+ bottom: 0;
751
+ width: auto;
752
+ max-height: 55%;
753
+ z-index: 10;
754
+ border-left: none;
755
+ border-top: 1px solid var(--tdbg-line);
756
+ box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.3);
757
+ }
758
+
759
+ /* Drill-down panels go near-full-width with a tight cascade. */
760
+ .tdbg-nested-pane {
761
+ left: calc(var(--tdbg-depth, 0) * 14px);
762
+ }
763
+ }
package/src/filter.ts CHANGED
@@ -23,8 +23,12 @@ export function matchesFilter(event: DebugEvent, filter: EventFilter): boolean {
23
23
  if (suffixes.length > 0 && !suffixes.includes(eventSuffix(event.event))) {
24
24
  return false;
25
25
  }
26
- if (filter.kind && !event.event.toLowerCase().includes(filter.kind.toLowerCase())) {
27
- return false;
26
+ if (filter.kind) {
27
+ // Lifecycle events carry the kind in the dotted name; dispatch (trace) events
28
+ // carry it in `payload.ref.kind` (the name dropped the kind prefix). Match either.
29
+ const refKind = (event.payload as { ref?: { kind?: unknown } } | undefined)?.ref?.kind;
30
+ const haystack = `${event.event} ${typeof refKind === "string" ? refKind : ""}`.toLowerCase();
31
+ if (!haystack.includes(filter.kind.toLowerCase())) return false;
28
32
  }
29
33
  if (filter.text) {
30
34
  const needle = filter.text.toLowerCase();