@dxos/react-ui-trace 0.11.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.
Files changed (148) hide show
  1. package/LICENSE +105 -0
  2. package/README.md +3 -0
  3. package/dist/lib/index.mjs +2868 -0
  4. package/dist/lib/index.mjs.map +1 -0
  5. package/dist/lib/testing.mjs +338 -0
  6. package/dist/lib/testing.mjs.map +1 -0
  7. package/dist/lib/translations.mjs +19 -0
  8. package/dist/lib/translations.mjs.map +1 -0
  9. package/dist/types/src/components/Gantt/Gantt.d.ts +68 -0
  10. package/dist/types/src/components/Gantt/Gantt.d.ts.map +1 -0
  11. package/dist/types/src/components/Gantt/Gantt.stories.d.ts +30 -0
  12. package/dist/types/src/components/Gantt/Gantt.stories.d.ts.map +1 -0
  13. package/dist/types/src/components/Gantt/index.d.ts +2 -0
  14. package/dist/types/src/components/Gantt/index.d.ts.map +1 -0
  15. package/dist/types/src/components/ProcessTree/ProcessTree.d.ts +32 -0
  16. package/dist/types/src/components/ProcessTree/ProcessTree.d.ts.map +1 -0
  17. package/dist/types/src/components/ProcessTree/ProcessTree.stories.d.ts +8 -0
  18. package/dist/types/src/components/ProcessTree/ProcessTree.stories.d.ts.map +1 -0
  19. package/dist/types/src/components/ProcessTree/index.d.ts +2 -0
  20. package/dist/types/src/components/ProcessTree/index.d.ts.map +1 -0
  21. package/dist/types/src/components/Timeline/Timeline.d.ts +50 -0
  22. package/dist/types/src/components/Timeline/Timeline.d.ts.map +1 -0
  23. package/dist/types/src/components/Timeline/Timeline.stories.d.ts +24 -0
  24. package/dist/types/src/components/Timeline/Timeline.stories.d.ts.map +1 -0
  25. package/dist/types/src/components/Timeline/index.d.ts +4 -0
  26. package/dist/types/src/components/Timeline/index.d.ts.map +1 -0
  27. package/dist/types/src/components/Timeline/timeline-options.d.ts +9 -0
  28. package/dist/types/src/components/Timeline/timeline-options.d.ts.map +1 -0
  29. package/dist/types/src/components/Timeline/timeline-printer.d.ts +7 -0
  30. package/dist/types/src/components/Timeline/timeline-printer.d.ts.map +1 -0
  31. package/dist/types/src/components/Timeline/timeline-printer.test.d.ts +2 -0
  32. package/dist/types/src/components/Timeline/timeline-printer.test.d.ts.map +1 -0
  33. package/dist/types/src/components/TracePanel/TracePanel.d.ts +28 -0
  34. package/dist/types/src/components/TracePanel/TracePanel.d.ts.map +1 -0
  35. package/dist/types/src/components/TracePanel/dxn-extractor.d.ts +19 -0
  36. package/dist/types/src/components/TracePanel/dxn-extractor.d.ts.map +1 -0
  37. package/dist/types/src/components/TracePanel/dxn-extractor.test.d.ts +2 -0
  38. package/dist/types/src/components/TracePanel/dxn-extractor.test.d.ts.map +1 -0
  39. package/dist/types/src/components/TracePanel/index.d.ts +4 -0
  40. package/dist/types/src/components/TracePanel/index.d.ts.map +1 -0
  41. package/dist/types/src/components/TracePanel/trace-filter.d.ts +39 -0
  42. package/dist/types/src/components/TracePanel/trace-filter.d.ts.map +1 -0
  43. package/dist/types/src/components/TracePanel/trace-filter.test.d.ts +2 -0
  44. package/dist/types/src/components/TracePanel/trace-filter.test.d.ts.map +1 -0
  45. package/dist/types/src/components/TracePanel/useTraceMenu.d.ts +15 -0
  46. package/dist/types/src/components/TracePanel/useTraceMenu.d.ts.map +1 -0
  47. package/dist/types/src/components/index.d.ts +5 -0
  48. package/dist/types/src/components/index.d.ts.map +1 -0
  49. package/dist/types/src/execution-graph/execution-graph.d.ts +139 -0
  50. package/dist/types/src/execution-graph/execution-graph.d.ts.map +1 -0
  51. package/dist/types/src/execution-graph/execution-graph.test.d.ts +2 -0
  52. package/dist/types/src/execution-graph/execution-graph.test.d.ts.map +1 -0
  53. package/dist/types/src/execution-graph/index.d.ts +4 -0
  54. package/dist/types/src/execution-graph/index.d.ts.map +1 -0
  55. package/dist/types/src/execution-graph/pending-block-status.d.ts +26 -0
  56. package/dist/types/src/execution-graph/pending-block-status.d.ts.map +1 -0
  57. package/dist/types/src/execution-graph/pending-block-status.test.d.ts +2 -0
  58. package/dist/types/src/execution-graph/pending-block-status.test.d.ts.map +1 -0
  59. package/dist/types/src/execution-graph/remote-snapshot.test.d.ts +2 -0
  60. package/dist/types/src/execution-graph/remote-snapshot.test.d.ts.map +1 -0
  61. package/dist/types/src/execution-graph/span-tree.d.ts +106 -0
  62. package/dist/types/src/execution-graph/span-tree.d.ts.map +1 -0
  63. package/dist/types/src/execution-graph/span-tree.test.d.ts +2 -0
  64. package/dist/types/src/execution-graph/span-tree.test.d.ts.map +1 -0
  65. package/dist/types/src/execution-graph/sub-agent-delegation.test.d.ts +2 -0
  66. package/dist/types/src/execution-graph/sub-agent-delegation.test.d.ts.map +1 -0
  67. package/dist/types/src/execution-graph/testing/collect-trace-events.d.ts +40 -0
  68. package/dist/types/src/execution-graph/testing/collect-trace-events.d.ts.map +1 -0
  69. package/dist/types/src/execution-graph/testing/index.d.ts +2 -0
  70. package/dist/types/src/execution-graph/testing/index.d.ts.map +1 -0
  71. package/dist/types/src/hooks/index.d.ts +4 -0
  72. package/dist/types/src/hooks/index.d.ts.map +1 -0
  73. package/dist/types/src/hooks/useExecutionGraph.d.ts +17 -0
  74. package/dist/types/src/hooks/useExecutionGraph.d.ts.map +1 -0
  75. package/dist/types/src/hooks/useSessionTimeline.d.ts +17 -0
  76. package/dist/types/src/hooks/useSessionTimeline.d.ts.map +1 -0
  77. package/dist/types/src/hooks/useTraceMessages.d.ts +17 -0
  78. package/dist/types/src/hooks/useTraceMessages.d.ts.map +1 -0
  79. package/dist/types/src/index.d.ts +5 -0
  80. package/dist/types/src/index.d.ts.map +1 -0
  81. package/dist/types/src/session-timeline/index.d.ts +3 -0
  82. package/dist/types/src/session-timeline/index.d.ts.map +1 -0
  83. package/dist/types/src/session-timeline/session-timeline.d.ts +19 -0
  84. package/dist/types/src/session-timeline/session-timeline.d.ts.map +1 -0
  85. package/dist/types/src/session-timeline/session-timeline.test.d.ts +2 -0
  86. package/dist/types/src/session-timeline/session-timeline.test.d.ts.map +1 -0
  87. package/dist/types/src/session-timeline/types.d.ts +129 -0
  88. package/dist/types/src/session-timeline/types.d.ts.map +1 -0
  89. package/dist/types/src/testing/index.d.ts +5 -0
  90. package/dist/types/src/testing/index.d.ts.map +1 -0
  91. package/dist/types/src/testing/simulated-agent.d.ts +63 -0
  92. package/dist/types/src/testing/simulated-agent.d.ts.map +1 -0
  93. package/dist/types/src/testing/snapshot-playback.d.ts +12 -0
  94. package/dist/types/src/testing/snapshot-playback.d.ts.map +1 -0
  95. package/dist/types/src/testing/test-trace.d.ts +5 -0
  96. package/dist/types/src/testing/test-trace.d.ts.map +1 -0
  97. package/dist/types/src/translations.d.ts +19 -0
  98. package/dist/types/src/translations.d.ts.map +1 -0
  99. package/dist/types/tsconfig.tsbuildinfo +1 -0
  100. package/package.json +77 -0
  101. package/src/components/Gantt/Gantt.stories.tsx +241 -0
  102. package/src/components/Gantt/Gantt.tsx +589 -0
  103. package/src/components/Gantt/index.ts +5 -0
  104. package/src/components/ProcessTree/ProcessTree.stories.tsx +95 -0
  105. package/src/components/ProcessTree/ProcessTree.tsx +265 -0
  106. package/src/components/ProcessTree/index.ts +5 -0
  107. package/src/components/Timeline/Timeline.stories.tsx +326 -0
  108. package/src/components/Timeline/Timeline.tsx +534 -0
  109. package/src/components/Timeline/git.drawio.svg +821 -0
  110. package/src/components/Timeline/index.ts +7 -0
  111. package/src/components/Timeline/timeline-options.ts +27 -0
  112. package/src/components/Timeline/timeline-printer.test.ts +76 -0
  113. package/src/components/Timeline/timeline-printer.ts +223 -0
  114. package/src/components/TracePanel/TracePanel.tsx +248 -0
  115. package/src/components/TracePanel/dxn-extractor.test.ts +153 -0
  116. package/src/components/TracePanel/dxn-extractor.ts +166 -0
  117. package/src/components/TracePanel/index.ts +7 -0
  118. package/src/components/TracePanel/trace-filter.test.ts +137 -0
  119. package/src/components/TracePanel/trace-filter.ts +120 -0
  120. package/src/components/TracePanel/useTraceMenu.ts +95 -0
  121. package/src/components/index.ts +8 -0
  122. package/src/execution-graph/SPEC.md +749 -0
  123. package/src/execution-graph/execution-graph.test.ts +1205 -0
  124. package/src/execution-graph/execution-graph.ts +1076 -0
  125. package/src/execution-graph/index.ts +35 -0
  126. package/src/execution-graph/pending-block-status.test.ts +118 -0
  127. package/src/execution-graph/pending-block-status.ts +88 -0
  128. package/src/execution-graph/remote-snapshot.test.ts +116 -0
  129. package/src/execution-graph/span-tree.test.ts +370 -0
  130. package/src/execution-graph/span-tree.ts +404 -0
  131. package/src/execution-graph/sub-agent-delegation.test.ts +65 -0
  132. package/src/execution-graph/testing/collect-trace-events.ts +76 -0
  133. package/src/execution-graph/testing/index.ts +5 -0
  134. package/src/execution-graph/testing/sub-agent-delegation.json +1285 -0
  135. package/src/hooks/index.ts +7 -0
  136. package/src/hooks/useExecutionGraph.ts +107 -0
  137. package/src/hooks/useSessionTimeline.ts +53 -0
  138. package/src/hooks/useTraceMessages.ts +58 -0
  139. package/src/index.ts +8 -0
  140. package/src/session-timeline/index.ts +6 -0
  141. package/src/session-timeline/session-timeline.test.ts +662 -0
  142. package/src/session-timeline/session-timeline.ts +605 -0
  143. package/src/session-timeline/types.ts +100 -0
  144. package/src/testing/index.ts +8 -0
  145. package/src/testing/simulated-agent.ts +293 -0
  146. package/src/testing/snapshot-playback.ts +45 -0
  147. package/src/testing/test-trace.ts +22 -0
  148. package/src/translations.ts +29 -0
@@ -0,0 +1,589 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { format } from 'date-fns';
6
+ import React, { type KeyboardEvent, type ReactNode, forwardRef, useEffect, useMemo, useRef, useState } from 'react';
7
+
8
+ import { createContext, useComposedRefs } from '@dxos/react-hooks';
9
+ import { HoverCard, type ThemedClassName, composable, composableProps } from '@dxos/react-ui';
10
+ import { mx } from '@dxos/ui-theme';
11
+ import { Unit } from '@dxos/util';
12
+
13
+ export type GanttLaneKind = 'session' | 'task';
14
+ export type GanttLaneStatus = 'pending' | 'blocked' | 'running' | 'review' | 'done' | 'failed';
15
+ export type GanttMarkerKind = 'request' | 'operation' | 'tool' | 'message' | 'error' | 'delegation' | 'task';
16
+
17
+ export type GanttLane = {
18
+ id: string;
19
+ kind: GanttLaneKind;
20
+ label: string;
21
+ status: GanttLaneStatus;
22
+ start?: number;
23
+ end?: number;
24
+ parentId?: string;
25
+ taskId?: string;
26
+ blockedOn?: readonly string[];
27
+ delegatedFrom?: { laneId: string; markerId: string };
28
+ tokens?: { input: number; output: number; total: number };
29
+ toolCalls?: number;
30
+ };
31
+
32
+ export type GanttMarker = {
33
+ id: string;
34
+ laneId: string;
35
+ kind: GanttMarkerKind;
36
+ timestamp: number;
37
+ label: string;
38
+ level?: 'info' | 'warn' | 'error';
39
+ };
40
+
41
+ const ROW_HEIGHT = 28;
42
+ const HEADER_HEIGHT = 20;
43
+ const PAD_X = 12;
44
+ const NODE_RADIUS = 5;
45
+ /** Upper bound on axis ticks; the count shrinks with the width so `HH:mm:ss` labels never overlap. */
46
+ const MAX_TICKS = 5;
47
+ const TICK_MIN_WIDTH = 72;
48
+
49
+ const BAR_HEIGHT = 15;
50
+ /**
51
+ * A bar reaches half its height past its first and last instant, so a node sitting on either one is
52
+ * as far from the bar's end as it is from its top and bottom.
53
+ */
54
+ const BAR_OVERHANG = BAR_HEIGHT / 2;
55
+ /** Gap between adjacent session rectangles, halved on each. */
56
+ const GROUP_INSET = 2;
57
+ /** The rectangle's clearance around its bars, the same on every side, so its corners stay concentric with theirs. */
58
+ const GROUP_PAD = ROW_HEIGHT / 2 - BAR_HEIGHT / 2 - GROUP_INSET;
59
+ const GROUP_RADIUS = BAR_HEIGHT / 2 + GROUP_PAD;
60
+ /** Radius of the delegation connector's bend from the drop into the child bar. */
61
+ const BEND_RADIUS = BAR_HEIGHT / 2;
62
+
63
+ /**
64
+ * Bar, node, thread and label colours per status: nodes and the thread through them are the bar's
65
+ * hue in a lighter shade.
66
+ */
67
+ const STATUS_COLOR: Record<GanttLaneStatus, { fill: string; node: string; thread: string; text: string }> = {
68
+ pending: {
69
+ fill: 'fill-neutral-500/40',
70
+ node: 'fill-neutral-300',
71
+ thread: 'stroke-neutral-300',
72
+ text: 'text-neutral-400',
73
+ },
74
+ blocked: {
75
+ fill: 'fill-orange-500/40',
76
+ node: 'fill-orange-300',
77
+ thread: 'stroke-orange-300',
78
+ text: 'text-orange-500',
79
+ },
80
+ running: {
81
+ fill: 'fill-sky-500/40',
82
+ node: 'fill-sky-300',
83
+ thread: 'stroke-sky-300',
84
+ text: 'text-sky-500',
85
+ },
86
+ review: {
87
+ fill: 'fill-cyan-500/40',
88
+ node: 'fill-cyan-300',
89
+ thread: 'stroke-cyan-300',
90
+ text: 'text-cyan-500',
91
+ },
92
+ done: {
93
+ fill: 'fill-green-500/40',
94
+ node: 'fill-green-300',
95
+ thread: 'stroke-green-300',
96
+ text: 'text-green-500',
97
+ },
98
+ failed: {
99
+ fill: 'fill-red-500/40',
100
+ node: 'fill-red-300',
101
+ thread: 'stroke-red-300',
102
+ text: 'text-red-500',
103
+ },
104
+ };
105
+
106
+ type Row = {
107
+ lane: GanttLane;
108
+ depth: number;
109
+ index: number;
110
+ sessionId: string | undefined;
111
+ };
112
+
113
+ /** A session's rows: the session itself followed by every task it works in-session, contiguous. */
114
+ type SessionGroup = {
115
+ session: GanttLane;
116
+ first: number;
117
+ last: number;
118
+ };
119
+
120
+ /**
121
+ * Rows grouped by the session that processes them: a session, then the tasks (and subtasks) it
122
+ * works itself, then — after that block — every session those tasks spawned, recursively. Keeping a
123
+ * session's own rows contiguous is what lets one rectangle enclose them.
124
+ */
125
+ const orderRows = (lanes: readonly GanttLane[]): { rows: Row[]; groups: SessionGroup[] } => {
126
+ const rows: Row[] = [];
127
+ const groups: SessionGroup[] = [];
128
+ const childrenOf = (parentId: string | undefined): GanttLane[] => lanes.filter((lane) => lane.parentId === parentId);
129
+
130
+ const visitSession = (session: GanttLane, depth: number): void => {
131
+ const first = rows.length;
132
+ rows.push({ lane: session, depth, index: rows.length, sessionId: session.id });
133
+ const spawned: { lane: GanttLane; depth: number }[] = [];
134
+ const visitTasks = (parentId: string, taskDepth: number): void => {
135
+ for (const child of childrenOf(parentId)) {
136
+ if (child.kind === 'task') {
137
+ rows.push({ lane: child, depth: taskDepth, index: rows.length, sessionId: session.id });
138
+ visitTasks(child.id, taskDepth + 1);
139
+ } else {
140
+ spawned.push({ lane: child, depth: taskDepth });
141
+ }
142
+ }
143
+ };
144
+ visitTasks(session.id, depth + 1);
145
+ groups.push({ session, first, last: rows.length - 1 });
146
+ for (const child of spawned) {
147
+ visitSession(child.lane, child.depth);
148
+ }
149
+ };
150
+
151
+ for (const lane of childrenOf(undefined)) {
152
+ if (lane.kind === 'session') {
153
+ visitSession(lane, 0);
154
+ } else {
155
+ rows.push({ lane, depth: 0, index: rows.length, sessionId: undefined });
156
+ }
157
+ }
158
+ return { rows, groups };
159
+ };
160
+
161
+ const formatTokens = (tokens: NonNullable<GanttLane['tokens']>): string =>
162
+ tokens.total >= 1_000 ? Unit.Thousand(tokens.total).toString() : String(tokens.total);
163
+
164
+ export type GanttData = {
165
+ lanes: readonly GanttLane[];
166
+ markers?: readonly GanttMarker[];
167
+ range?: { start: number; end: number };
168
+ now?: number;
169
+ showNow?: boolean;
170
+ onLaneSelect?: (lane: GanttLane) => void;
171
+ onMarkerSelect?: (marker: GanttMarker) => void;
172
+ };
173
+
174
+ /** What every part reads: the ordered rows and the shared time axis, resolved once by the root. */
175
+ type GanttContextValue = {
176
+ rows: Row[];
177
+ groups: SessionGroup[];
178
+ rowById: Map<string, Row>;
179
+ markers: readonly GanttMarker[];
180
+ markerById: Map<string, GanttMarker>;
181
+ range: { start: number; end: number };
182
+ } & Pick<GanttData, 'onLaneSelect' | 'onMarkerSelect' | 'now' | 'showNow'>;
183
+
184
+ const [GanttProvider, useGanttContext] = createContext<GanttContextValue>('Gantt');
185
+
186
+ //
187
+ // Root
188
+ //
189
+
190
+ type GanttRootProps = ThemedClassName<GanttData & { children?: ReactNode }>;
191
+
192
+ /**
193
+ * Gantt view of sessions and tasks on a shared time axis. The root resolves the rows and the axis;
194
+ * the parts lay out side by side in the order given, sharing one row grid: `Legend` names the
195
+ * lanes, `Chart` draws them, `Meta` shows per-lane totals. A host that already lists the lanes
196
+ * renders the chart alone.
197
+ */
198
+ const GanttRoot = composable<HTMLDivElement, GanttRootProps>(
199
+ (
200
+ { lanes, markers = [], range: rangeProp, now, showNow, onLaneSelect, onMarkerSelect, children, ...props },
201
+ forwardedRef,
202
+ ) => {
203
+ const { rows, groups } = useMemo(() => orderRows(lanes), [lanes]);
204
+ const rowById = useMemo(() => new Map(rows.map((row) => [row.lane.id, row])), [rows]);
205
+ const markerById = useMemo(() => new Map(markers.map((marker) => [marker.id, marker])), [markers]);
206
+ const range = useMemo(() => {
207
+ if (rangeProp) {
208
+ return rangeProp;
209
+ }
210
+ const times = [...lanes.flatMap((lane) => [lane.start, lane.end]), ...markers.map((marker) => marker.timestamp)]
211
+ .filter((time): time is number => time !== undefined)
212
+ .concat(now === undefined ? [] : [now]);
213
+ const start = times.length > 0 ? Math.min(...times) : 0;
214
+ return { start, end: times.length > 0 ? Math.max(...times) : start };
215
+ }, [rangeProp, lanes, markers, now]);
216
+
217
+ return (
218
+ <GanttProvider
219
+ rows={rows}
220
+ groups={groups}
221
+ rowById={rowById}
222
+ markers={markers}
223
+ markerById={markerById}
224
+ range={range}
225
+ now={now}
226
+ showNow={showNow}
227
+ onLaneSelect={onLaneSelect}
228
+ onMarkerSelect={onMarkerSelect}
229
+ >
230
+ <div
231
+ {...composableProps(props, {
232
+ // `items-start`: the parts are drawn in pixel rows, so a stretching host must not spread them.
233
+ classNames: 'flex w-full items-start text-xs font-mono overflow-hidden',
234
+ })}
235
+ ref={forwardedRef}
236
+ >
237
+ {children}
238
+ </div>
239
+ </GanttProvider>
240
+ );
241
+ },
242
+ );
243
+
244
+ GanttRoot.displayName = 'Gantt.Root';
245
+
246
+ //
247
+ // Legend
248
+ //
249
+
250
+ type GanttLegendProps = ThemedClassName<{}>;
251
+
252
+ /** The lane names, one per row, indented by depth; each row is the lane's keyboard path. */
253
+ const GanttLegend = composable<HTMLDivElement, GanttLegendProps>((props, forwardedRef) => {
254
+ const { rows, onLaneSelect } = useGanttContext('Gantt.Legend');
255
+ return (
256
+ <div
257
+ {...composableProps(props, { classNames: 'shrink-0 w-[min(20rem,40%)] min-w-40 flex flex-col' })}
258
+ ref={forwardedRef}
259
+ >
260
+ <div style={{ height: HEADER_HEIGHT }} />
261
+ {rows.map(({ lane, depth }) => (
262
+ <div
263
+ key={lane.id}
264
+ className={mx(
265
+ 'flex items-center gap-2 truncate',
266
+ onLaneSelect && 'cursor-pointer hover:bg-hover-surface-subtle',
267
+ )}
268
+ style={{ height: ROW_HEIGHT, paddingInlineStart: `${0.5 + depth}rem` }}
269
+ // Button semantics only when there is something to select: the label row is the lane's
270
+ // keyboard path, and an inert focus stop would be noise.
271
+ {...(onLaneSelect && {
272
+ role: 'button',
273
+ tabIndex: 0,
274
+ onClick: () => onLaneSelect(lane),
275
+ onKeyDown: (event: KeyboardEvent<HTMLDivElement>) => {
276
+ if (event.key === 'Enter' || event.key === ' ') {
277
+ event.preventDefault();
278
+ onLaneSelect(lane);
279
+ }
280
+ },
281
+ })}
282
+ >
283
+ <span className={mx('shrink-0 w-2 h-2 rounded-full bg-current', STATUS_COLOR[lane.status].text)} />
284
+ <span className='truncate text-base-fg'>{lane.label}</span>
285
+ </div>
286
+ ))}
287
+ </div>
288
+ );
289
+ });
290
+
291
+ GanttLegend.displayName = 'Gantt.Legend';
292
+
293
+ //
294
+ // Meta
295
+ //
296
+
297
+ type GanttMetaProps = ThemedClassName<{}>;
298
+
299
+ /** Per-lane totals — tokens and tool calls — aligned to the rows. */
300
+ const GanttMeta = composable<HTMLDivElement, GanttMetaProps>((props, forwardedRef) => {
301
+ const { rows } = useGanttContext('Gantt.Meta');
302
+ return (
303
+ <div {...composableProps(props, { classNames: 'shrink-0 flex flex-col' })} ref={forwardedRef}>
304
+ <div style={{ height: HEADER_HEIGHT }} />
305
+ {rows.map(({ lane }) => (
306
+ <div
307
+ key={lane.id}
308
+ className='flex items-center justify-end gap-2 px-2 text-subdued whitespace-nowrap'
309
+ style={{ height: ROW_HEIGHT }}
310
+ >
311
+ {lane.tokens && (
312
+ <span title={`${lane.tokens.input} in / ${lane.tokens.output} out`}>{formatTokens(lane.tokens)}</span>
313
+ )}
314
+ {lane.toolCalls !== undefined && <span>{lane.toolCalls} tools</span>}
315
+ </div>
316
+ ))}
317
+ </div>
318
+ );
319
+ });
320
+
321
+ GanttMeta.displayName = 'Gantt.Meta';
322
+
323
+ //
324
+ // Chart
325
+ //
326
+
327
+ type GanttChartProps = ThemedClassName<{}>;
328
+
329
+ /**
330
+ * The drawing: every lane is a rounded bar with its markers threaded through it as nodes, a session's
331
+ * rectangle encloses the tasks it works, and connectors draw dependencies and delegations.
332
+ */
333
+ // `forwardRef` rather than `composable`: the drawing is an `svg`, which the HTML-element helpers do not type.
334
+ const GanttChart = forwardRef<SVGSVGElement, GanttChartProps>(({ classNames }, forwardedRef) => {
335
+ const { rows, groups, rowById, markers, markerById, range, now, showNow, onLaneSelect, onMarkerSelect } =
336
+ useGanttContext('Gantt.Chart');
337
+ const svgRef = useRef<SVGSVGElement | null>(null);
338
+ const ref = useComposedRefs(svgRef, forwardedRef);
339
+ const [width, setWidth] = useState(600);
340
+ useEffect(() => {
341
+ const element = svgRef.current;
342
+ if (!element) {
343
+ return;
344
+ }
345
+ const observer = new ResizeObserver(([entry]) => entry && setWidth(entry.contentRect.width));
346
+ observer.observe(element);
347
+ return () => observer.disconnect();
348
+ }, []);
349
+
350
+ const span = Math.max(range.end - range.start, 1);
351
+ const x = (time: number): number => PAD_X + ((time - range.start) / span) * (width - 2 * PAD_X);
352
+ const rowY = (index: number): number => HEADER_HEIGHT + index * ROW_HEIGHT + ROW_HEIGHT / 2;
353
+ const laneTimes = (lane: GanttLane): number[] =>
354
+ markers.filter((marker) => marker.laneId === lane.id).map((marker) => marker.timestamp);
355
+ // A bar reaches its last node, not `now`: a lane without a fresh event is not shown as still
356
+ // busy, and a terminated one ends where its last event did.
357
+ const laneEnd = (lane: GanttLane): number | undefined => {
358
+ const times = laneTimes(lane);
359
+ return times.length > 0 ? Math.max(...times) : (lane.end ?? now);
360
+ };
361
+ const delegationSource = (lane: GanttLane): GanttMarker | undefined =>
362
+ lane.delegatedFrom && markerById.get(lane.delegatedFrom.markerId);
363
+ // A delegated lane's bar begins where the connector's bend lands, never under the drop from the
364
+ // parent node: the drop is at the spawn instant and the child starts at or after it.
365
+ const barStart = (lane: GanttLane, start: number): number => {
366
+ const source = delegationSource(lane);
367
+ const edge = x(start) - BAR_OVERHANG;
368
+ // Past the bend the connector runs level for one more radius before the bar begins.
369
+ return source ? Math.max(edge, x(source.timestamp) + 2 * BEND_RADIUS) : edge;
370
+ };
371
+ // A node never sits outside its bar: a delegated lane's first event is the spawn instant itself,
372
+ // which is where the connector drops, so that node is nudged in past the bar's edge.
373
+ const nodeX = (lane: GanttLane, time: number): number =>
374
+ lane.start === undefined ? x(time) : Math.max(x(time), barStart(lane, lane.start) + BAR_OVERHANG);
375
+ const height = HEADER_HEIGHT + rows.length * ROW_HEIGHT;
376
+ const tickCount = Math.max(2, Math.min(MAX_TICKS, Math.floor((width - 2 * PAD_X) / TICK_MIN_WIDTH)));
377
+ const ticks = Array.from({ length: tickCount }, (_, index) => range.start + (span * index) / (tickCount - 1));
378
+
379
+ return (
380
+ <svg
381
+ className={mx('flex-1 min-w-0', classNames)}
382
+ ref={ref}
383
+ // Pixel coordinates against the measured width, with no viewBox: a viewBox would letterbox
384
+ // the drawing to the column's aspect ratio and shrink every node with it.
385
+ style={{ height }}
386
+ >
387
+ {ticks.map((tick, index) => (
388
+ <g key={index}>
389
+ <line x1={x(tick)} x2={x(tick)} y1={HEADER_HEIGHT} y2={height} className='stroke-separator' />
390
+ <text
391
+ x={x(tick)}
392
+ y={HEADER_HEIGHT - 6}
393
+ textAnchor={index === 0 ? 'start' : index === tickCount - 1 ? 'end' : 'middle'}
394
+ className='fill-current text-subdued'
395
+ >
396
+ {format(tick, 'HH:mm:ss')}
397
+ </text>
398
+ </g>
399
+ ))}
400
+
401
+ {/* Under the lanes and connectors: a dependency on a still-running lane anchors at `now`
402
+ and would otherwise be hidden by this line. */}
403
+ {now !== undefined && showNow && (
404
+ <line
405
+ x1={x(now)}
406
+ x2={x(now)}
407
+ y1={0}
408
+ y2={height}
409
+ strokeDasharray='3 3'
410
+ className={STATUS_COLOR.running.thread}
411
+ />
412
+ )}
413
+
414
+ {/* A session's rectangle encloses its own bar and the tasks it works in-session; a task it
415
+ spawned is a session of its own, drawn as a rectangle further down. */}
416
+ {groups.flatMap(({ session, first, last }) => {
417
+ const members = rows.slice(first, last + 1).map((row) => row.lane);
418
+ const starts = members.flatMap((lane) => (lane.start === undefined ? [] : [barStart(lane, lane.start)]));
419
+ // Only lanes with a bar bound the rectangle: a task not yet started has no extent.
420
+ const ends = members.flatMap((lane) => {
421
+ const end = lane.start === undefined ? undefined : laneEnd(lane);
422
+ return end === undefined ? [] : [x(end) + BAR_OVERHANG];
423
+ });
424
+ if (starts.length === 0 || ends.length === 0) {
425
+ return [];
426
+ }
427
+ const left = Math.min(...starts) - GROUP_PAD;
428
+ const right = Math.max(...ends) + GROUP_PAD;
429
+ return [
430
+ <rect
431
+ key={`group:${session.id}`}
432
+ x={left}
433
+ y={rowY(first) - ROW_HEIGHT / 2 + GROUP_INSET}
434
+ width={Math.max(right - left, ROW_HEIGHT)}
435
+ height={(last - first + 1) * ROW_HEIGHT - 2 * GROUP_INSET}
436
+ rx={GROUP_RADIUS}
437
+ className='fill-input-surface'
438
+ />,
439
+ ];
440
+ })}
441
+
442
+ {/* Connectors next, so the drop to a child passes beneath any bar it crosses. */}
443
+ {rows.flatMap(({ lane, index }) =>
444
+ (lane.blockedOn ?? []).flatMap((depId) => {
445
+ const dep = rowById.get(depId);
446
+ // Anchored on the dependency's last node, so the line meets a node rather than a bar edge.
447
+ const anchor = dep && (laneEnd(dep.lane) ?? dep.lane.start);
448
+ if (!dep || anchor === undefined) {
449
+ return [];
450
+ }
451
+ const anchorX = nodeX(dep.lane, anchor);
452
+ return [
453
+ <line
454
+ key={`${lane.id}:${depId}`}
455
+ x1={anchorX}
456
+ x2={anchorX}
457
+ y1={rowY(dep.index)}
458
+ y2={rowY(index)}
459
+ strokeDasharray='2 2'
460
+ className={STATUS_COLOR.blocked.thread}
461
+ />,
462
+ // A waiter with no bar has nothing on its row for the line to reach, so it ends on a
463
+ // hollow node: the point the waiter is held at until the dependency resolves.
464
+ ...(lane.start === undefined
465
+ ? [
466
+ <circle
467
+ key={`${lane.id}:${depId}:hold`}
468
+ cx={anchorX}
469
+ cy={rowY(index)}
470
+ r={NODE_RADIUS}
471
+ strokeDasharray='2 2'
472
+ className={mx('fill-base-surface', STATUS_COLOR.blocked.thread)}
473
+ />,
474
+ ]
475
+ : []),
476
+ ];
477
+ }),
478
+ )}
479
+
480
+ {/* Down from the parent node, a quarter bend, then right to the centre of the child's first node. */}
481
+ {rows.flatMap(({ lane, index }) => {
482
+ const source = delegationSource(lane);
483
+ const sourceRow = lane.delegatedFrom && rowById.get(lane.delegatedFrom.laneId);
484
+ if (!source || !sourceRow || lane.start === undefined) {
485
+ return [];
486
+ }
487
+ const sourceX = x(source.timestamp);
488
+ const y = rowY(index);
489
+ const firstNode = Math.min(...laneTimes(lane));
490
+ const targetX = Number.isFinite(firstNode) ? nodeX(lane, firstNode) : barStart(lane, lane.start) + BAR_OVERHANG;
491
+ return [
492
+ <path
493
+ key={`delegation:${lane.id}`}
494
+ d={`M ${sourceX} ${rowY(sourceRow.index)} V ${y - BEND_RADIUS} Q ${sourceX} ${y} ${sourceX + BEND_RADIUS} ${y} H ${targetX}`}
495
+ fill='none'
496
+ className='stroke-fuchsia-500'
497
+ />,
498
+ ];
499
+ })}
500
+
501
+ {rows.map(({ lane, index }) => {
502
+ const end = laneEnd(lane);
503
+ if (lane.start === undefined || end === undefined) {
504
+ return null;
505
+ }
506
+ const bar = {
507
+ x: barStart(lane, lane.start),
508
+ y: rowY(index) - BAR_HEIGHT / 2,
509
+ width: Math.max(x(end) + BAR_OVERHANG - barStart(lane, lane.start), BAR_HEIGHT),
510
+ height: BAR_HEIGHT,
511
+ rx: BAR_HEIGHT / 2,
512
+ };
513
+ // An opaque backing under the translucent tint: the connectors pass beneath the bars, and
514
+ // without it they would show through.
515
+ return (
516
+ <g key={lane.id} className='cursor-pointer' onClick={() => onLaneSelect?.(lane)}>
517
+ <rect {...bar} className='fill-base-surface' />
518
+ <rect {...bar} className={STATUS_COLOR[lane.status].fill} />
519
+ </g>
520
+ );
521
+ })}
522
+
523
+ {/* The thread through a lane's nodes, so a row reads as a sequence rather than scattered dots. */}
524
+ {rows.map(({ lane, index }) => {
525
+ const times = laneTimes(lane);
526
+ if (times.length < 2) {
527
+ return null;
528
+ }
529
+ return (
530
+ <line
531
+ key={`thread:${lane.id}`}
532
+ x1={nodeX(lane, Math.min(...times))}
533
+ x2={nodeX(lane, Math.max(...times))}
534
+ y1={rowY(index)}
535
+ y2={rowY(index)}
536
+ className={STATUS_COLOR[lane.status].thread}
537
+ />
538
+ );
539
+ })}
540
+
541
+ {/* Nodes last, over the bars and every line, so a line reads as ending at a node's centre. */}
542
+ {markers.map((marker) => {
543
+ const row = rowById.get(marker.laneId);
544
+ return row ? (
545
+ <HoverCard.Root key={marker.id}>
546
+ <HoverCard.Trigger asChild>
547
+ <circle
548
+ cx={nodeX(row.lane, marker.timestamp)}
549
+ cy={rowY(row.index)}
550
+ r={NODE_RADIUS}
551
+ className={mx(
552
+ 'cursor-pointer stroke-base-surface transition-[stroke-width] hover:stroke-[3px] hover:stroke-base-fg',
553
+ STATUS_COLOR[row.lane.status].node,
554
+ )}
555
+ onClick={() => onMarkerSelect?.(marker)}
556
+ />
557
+ </HoverCard.Trigger>
558
+ <HoverCard.Portal>
559
+ <HoverCard.Content classNames='p-2 max-w-72 text-xs font-mono'>
560
+ <div className='font-medium truncate'>{marker.label}</div>
561
+ <div className='text-description'>
562
+ {marker.kind} · {format(marker.timestamp, 'HH:mm:ss.SSS')}
563
+ {marker.level && marker.level !== 'info' && (
564
+ <span className={mx('ms-2', marker.level === 'error' ? 'text-error-text' : 'text-warning-text')}>
565
+ {marker.level}
566
+ </span>
567
+ )}
568
+ </div>
569
+ <div className='text-description truncate'>{row.lane.label}</div>
570
+ <HoverCard.Arrow />
571
+ </HoverCard.Content>
572
+ </HoverCard.Portal>
573
+ </HoverCard.Root>
574
+ ) : null;
575
+ })}
576
+ </svg>
577
+ );
578
+ });
579
+
580
+ GanttChart.displayName = 'Gantt.Chart';
581
+
582
+ export const Gantt = {
583
+ Root: GanttRoot,
584
+ Legend: GanttLegend,
585
+ Chart: GanttChart,
586
+ Meta: GanttMeta,
587
+ };
588
+
589
+ export type { GanttChartProps, GanttLegendProps, GanttMetaProps, GanttRootProps };
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * from './Gantt.tsx';
@@ -0,0 +1,95 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { type Meta, type StoryObj } from '@storybook/react-vite';
6
+ import * as Option from 'effect/Option';
7
+ import React, { useState } from 'react';
8
+
9
+ import * as Process from '@dxos/compute/Process';
10
+ import { log } from '@dxos/log';
11
+ import { withLayout, withTheme } from '@dxos/react-ui/testing';
12
+
13
+ import { makeProcess } from '../../testing/index.ts';
14
+
15
+ import { ProcessTree, type ProcessTreeProps } from './ProcessTree.tsx';
16
+
17
+ const processes: Process.Info[] = [
18
+ makeProcess({
19
+ pid: Process.ID.make('97793611-815e-4a67-bc04-60fa67b2c987'),
20
+ name: 'Trigger watcher',
21
+ state: Process.State.RUNNING,
22
+ metrics: { wallTime: 4200, inputCount: 12, outputCount: 3 },
23
+ }),
24
+ makeProcess({
25
+ pid: Process.ID.make('531e9b15-57f5-472c-8cad-f81eb2a5b564'),
26
+ parentPid: Process.ID.make('97793611-815e-4a67-bc04-60fa67b2c987'),
27
+ name: 'Summarize document',
28
+ state: Process.State.SUCCEEDED,
29
+ completedAt: Option.some(Date.now() - 2_000),
30
+ metrics: { wallTime: 1500, inputCount: 1, outputCount: 1 },
31
+ }),
32
+ makeProcess({
33
+ pid: Process.ID.make('98c8d4b6-7b20-4267-a6df-4ca227bcb86d'),
34
+ parentPid: Process.ID.make('97793611-815e-4a67-bc04-60fa67b2c987'),
35
+ name: 'Translate content',
36
+ state: Process.State.HYBERNATING,
37
+ metrics: { wallTime: 800, inputCount: 1, outputCount: 0 },
38
+ }),
39
+ makeProcess({
40
+ pid: Process.ID.make('4d72c688-750b-47f2-87da-8a464e141b74'),
41
+ name: 'Data pipeline',
42
+ state: Process.State.FAILED,
43
+ error: { name: 'Error', message: 'Connection timeout' },
44
+ metrics: { wallTime: 3100, inputCount: 5, outputCount: 2 },
45
+ }),
46
+ makeProcess({
47
+ pid: Process.ID.make('030098d2-4e77-4569-ae77-1c6c34bf0136'),
48
+ name: 'Idle listener',
49
+ state: Process.State.IDLE,
50
+ }),
51
+ makeProcess({
52
+ pid: Process.ID.make('0c4dca89-0e13-4ad7-aa7a-d10deb8333ed'),
53
+ name: 'Shutting down',
54
+ state: Process.State.TERMINATING,
55
+ }),
56
+ makeProcess({
57
+ pid: Process.ID.make('1620e57f-c9bf-4b76-b3b3-10f96c36a8b3'),
58
+ name: 'Completed cleanup',
59
+ state: Process.State.TERMINATED,
60
+ completedAt: Option.some(Date.now() - 5_000),
61
+ }),
62
+ ];
63
+
64
+ /** The story holds the selection, as the trace panel does, so a click can be seen to take. */
65
+ const DefaultStory = (props: ProcessTreeProps) => {
66
+ const [selected, setSelected] = useState<readonly string[]>([]);
67
+ return <ProcessTree {...props} selected={selected} onSelectedChange={setSelected} />;
68
+ };
69
+
70
+ const meta: Meta<typeof ProcessTree> = {
71
+ title: 'plugins/plugin-assistant/components/ProcessTree',
72
+ component: ProcessTree,
73
+ render: DefaultStory,
74
+ decorators: [withTheme(), withLayout({ layout: 'column', classNames: 'w-(--dx-complementary-sidebar-size)' })],
75
+ } satisfies Meta<typeof ProcessTree>;
76
+
77
+ export default meta;
78
+
79
+ type Story = StoryObj<typeof meta>;
80
+
81
+ export const Default: Story = {
82
+ args: {
83
+ processes: processes,
84
+ onProcessTerminate: (process) => {
85
+ log.info('terminate', process);
86
+ },
87
+ },
88
+ };
89
+
90
+ export const WithNestedChildren: Story = {
91
+ args: {
92
+ ...Default.args,
93
+ depth: 2,
94
+ },
95
+ };