@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,265 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { RegistryContext } from '@effect/atom-react/RegistryContext';
6
+ import * as Match from 'effect/Match';
7
+ import * as Option from 'effect/Option';
8
+ import React, { useCallback, useContext, useMemo, useRef } from 'react';
9
+
10
+ import * as Process from '@dxos/compute/Process';
11
+ import { Icon, IconButton, ScrollArea, Tooltip, composable, composableProps } from '@dxos/react-ui';
12
+ import { type ColumnRenderer, type IconRenderer, Tree, createStaticTreeModel } from '@dxos/react-ui-list';
13
+ import { mx } from '@dxos/ui-theme';
14
+ import { Unit } from '@dxos/util';
15
+
16
+ const DEFAULT_DEPTH = 1;
17
+ /** Nested subprocess rows (level > 1) only surface still-active processes. */
18
+ const NESTED_ACTIVE_STATES = new Set<Process.State>([
19
+ Process.State.RUNNING,
20
+ Process.State.HYBERNATING,
21
+ Process.State.TERMINATING,
22
+ ]);
23
+
24
+ export type ProcessTreeProps = {
25
+ // TODO(burdon): Atom.
26
+ processes: readonly Process.Info[];
27
+ /**
28
+ * Maximum nesting depth from the root (1 = top-level processes only).
29
+ *
30
+ * @default 1
31
+ */
32
+ depth?: number;
33
+ /**
34
+ * Overrides the row label for a process (e.g. the name of the chat it is serving), falling back to
35
+ * the process name when it returns `undefined`.
36
+ *
37
+ * Must be referentially stable — `ProcessTree` is memoized on its props.
38
+ */
39
+ resolveLabel?: (process: Process.Info) => string | undefined;
40
+ /** Pids drawn as selected; the tree is controlled, so a click reports through `onSelectedChange`. */
41
+ selected?: readonly string[];
42
+ /** The selection after a click: the clicked pid alone, or toggled among the others on a meta-click. */
43
+ onSelectedChange?: (selected: string[]) => void;
44
+ onProcessTerminate?: (process: Process.Info) => void;
45
+ };
46
+
47
+ /** Node of the pruned process forest handed to the tree model. */
48
+ type ProcessNode = {
49
+ id: string;
50
+ /** Absent on the synthetic root, which anchors the top-level processes and is never rendered. */
51
+ process?: Process.Info;
52
+ children: ProcessNode[];
53
+ };
54
+
55
+ /** Synthetic root; the tree renders its children, never the root itself. */
56
+ const ROOT_ID = 'processes';
57
+
58
+ const NO_SELECTION: readonly string[] = [];
59
+
60
+ /**
61
+ * The agent's running processes as a tree, one row per process with its status glyph and metrics
62
+ * in trailing columns. The forest is rebuilt on every metrics tick, so open state lives outside the
63
+ * model and is re-seeded — a collapse must survive the next tick. Renders through `Tree`.
64
+ */
65
+ export const ProcessTree = React.memo(
66
+ composable<HTMLDivElement, ProcessTreeProps>(
67
+ (
68
+ {
69
+ processes,
70
+ depth = DEFAULT_DEPTH,
71
+ resolveLabel,
72
+ selected = NO_SELECTION,
73
+ onSelectedChange,
74
+ onProcessTerminate,
75
+ ...props
76
+ },
77
+ forwardedRef,
78
+ ) => {
79
+ // Open state lives outside the model: `processes` carries live metrics, so the forest (and with
80
+ // it the model) is rebuilt on every tick, and a collapse held only inside the model would be
81
+ // undone by the next one.
82
+ const openRef = useRef(new Map<string, boolean>());
83
+ const registry = useContext(RegistryContext);
84
+ const root = useMemo(() => buildProcessForest(processes, depth), [processes, depth]);
85
+
86
+ const model = useMemo(
87
+ () =>
88
+ createStaticTreeModel<ProcessNode>(root, {
89
+ getChildren: (node) => node.children,
90
+ getProps: (node) => ({
91
+ label: (node.process && resolveLabel?.(node.process)) ?? node.process?.params.name ?? node.id,
92
+ }),
93
+ // Expanded by default, matching the flattened view this replaced.
94
+ isOpen: (_node, path) => openRef.current.get(path.join('/')) ?? true,
95
+ isCurrent: (node) => node.process !== undefined && selected.includes(node.process.pid),
96
+ }),
97
+ [root, resolveLabel, selected],
98
+ );
99
+
100
+ // The ref survives model rebuilds; the atom is what the controlled tree actually reads, so a
101
+ // toggle has to land in both.
102
+ const handleOpenChange = useCallback(
103
+ ({ path, open }: { path: string[]; open: boolean }) => {
104
+ openRef.current.set(path.join('/'), open);
105
+ const atom = model.stateAtom(path);
106
+ registry.set(atom, { ...registry.get(atom), open });
107
+ },
108
+ [model, registry],
109
+ );
110
+
111
+ const handleSelect = useCallback(
112
+ ({ item, current, meta }: { item: ProcessNode; current: boolean; meta: boolean }) => {
113
+ if (!item.process) {
114
+ return;
115
+ }
116
+ const pid = item.process.pid.toString();
117
+ onSelectedChange?.(
118
+ !meta ? [pid] : current ? [...selected, pid] : selected.filter((candidate) => candidate !== pid),
119
+ );
120
+ },
121
+ [selected, onSelectedChange],
122
+ );
123
+
124
+ const renderIcon = useMemo(() => makeIconRenderer(), []);
125
+ const renderColumns = useMemo(() => makeColumnRenderer(onProcessTerminate), [onProcessTerminate]);
126
+
127
+ return (
128
+ <ScrollArea.Root {...composableProps(props)} thin ref={forwardedRef}>
129
+ <ScrollArea.Viewport>
130
+ <Tree<ProcessNode>
131
+ id={ROOT_ID}
132
+ model={model}
133
+ density='sm'
134
+ selectionMode='multiple'
135
+ classNames='text-sm tabular-nums font-thin'
136
+ gridTemplateColumns='[tree-row-start] var(--dx-control) minmax(0, 1fr) min-content min-content [tree-row-end]'
137
+ renderIcon={renderIcon}
138
+ renderColumns={renderColumns}
139
+ onOpenChange={handleOpenChange}
140
+ onSelect={handleSelect}
141
+ />
142
+ </ScrollArea.Viewport>
143
+ </ScrollArea.Root>
144
+ );
145
+ },
146
+ ),
147
+ );
148
+
149
+ /** Status glyph — animated, coloured and tooltipped per state, which `TreeItemDataProps.icon` cannot express. */
150
+ const makeIconRenderer =
151
+ (): IconRenderer<ProcessNode> =>
152
+ ({ item: { process } }) =>
153
+ process === undefined ? null : (
154
+ <Tooltip.Trigger content={process.state.toString()}>
155
+ <Icon
156
+ size={4}
157
+ synchronized
158
+ classNames={mx(
159
+ 'shrink-0',
160
+ process.state === Process.State.RUNNING && 'animate-spin',
161
+ process.state === Process.State.FAILED && 'text-error-text',
162
+ process.state === Process.State.SUCCEEDED && 'text-success-text',
163
+ )}
164
+ icon={Match.value(process.state).pipe(
165
+ Match.when(Process.State.RUNNING, () => 'ph--spinner-gap--regular'),
166
+ Match.when(Process.State.SUCCEEDED, () => 'ph--check-circle--regular'),
167
+ Match.when(Process.State.FAILED, () => 'ph--warning--regular'),
168
+ Match.when(Process.State.HYBERNATING, () => 'ph--spinner--regular'),
169
+ Match.when(Process.State.IDLE, () => 'ph--moon-stars--regular'),
170
+ Match.when(Process.State.TERMINATING, () => 'ph--x-circle--regular'),
171
+ Match.when(Process.State.TERMINATED, () => 'ph--x-circle--regular'),
172
+ Match.orElse(() => 'ph--spinner-gap--regular'),
173
+ )}
174
+ />
175
+ </Tooltip.Trigger>
176
+ );
177
+
178
+ /** Trailing columns: elapsed time for finished processes, and the terminate control. */
179
+ const makeColumnRenderer =
180
+ (onProcessTerminate?: (process: Process.Info) => void): ColumnRenderer<ProcessNode> =>
181
+ ({ item: { process } }) =>
182
+ process === undefined ? null : (
183
+ <>
184
+ <div className='flex items-center justify-end text-xs text-description tabular-nums'>
185
+ {[Process.State.FAILED, Process.State.SUCCEEDED].includes(process.state) && (
186
+ <span className='whitespace-nowrap'>{Unit.Duration(process.metrics.wallTime).toString()}</span>
187
+ )}
188
+ </div>
189
+ <div className='flex items-center'>
190
+ {onProcessTerminate && (
191
+ <IconButton
192
+ classNames='min-h-0 p-1'
193
+ icon='ph--x--regular'
194
+ iconOnly
195
+ density='sm'
196
+ variant='ghost'
197
+ label='Actions'
198
+ onClick={(event) => {
199
+ event.stopPropagation();
200
+ onProcessTerminate(process);
201
+ }}
202
+ />
203
+ )}
204
+ </div>
205
+ </>
206
+ );
207
+
208
+ const sortProcesses = (processes: readonly Process.Info[]): Process.Info[] => {
209
+ return [
210
+ ...processes.filter((process) => [Process.State.RUNNING, Process.State.HYBERNATING].includes(process.state)),
211
+ ...processes.filter((process) => [Process.State.IDLE].includes(process.state)).slice(0, 3),
212
+ ...processes.filter((process) =>
213
+ [Process.State.SUCCEEDED, Process.State.FAILED, Process.State.TERMINATED].includes(process.state),
214
+ ),
215
+ ].sort((left, right) => {
216
+ const leftCompletedAt = Option.getOrElse(left.completedAt, () => Infinity);
217
+ const rightCompletedAt = Option.getOrElse(right.completedAt, () => Infinity);
218
+ return rightCompletedAt - leftCompletedAt;
219
+ });
220
+ };
221
+
222
+ const sortNestedActive = (processes: readonly Process.Info[]): Process.Info[] =>
223
+ processes
224
+ .filter((process) => NESTED_ACTIVE_STATES.has(process.state))
225
+ .sort((left, right) => {
226
+ const priority = (state: Process.State) =>
227
+ state === Process.State.RUNNING ? 0 : state === Process.State.HYBERNATING ? 1 : 2;
228
+ return priority(left.state) - priority(right.state);
229
+ });
230
+
231
+ /**
232
+ * Builds the process forest, pruned to `maxDepth` from each root. Nested levels surface only still-
233
+ * active processes, so a deep tree stays readable while completed work collapses out of view.
234
+ */
235
+ const buildProcessForest = (processes: readonly Process.Info[], maxDepth: number): ProcessNode => {
236
+ const pidSet = new Set(processes.map((process) => String(process.pid)));
237
+ const childrenByParent = new Map<string, Process.Info[]>();
238
+ const roots: Process.Info[] = [];
239
+
240
+ for (const process of processes) {
241
+ const parent = process.parentPid;
242
+ if (parent === null || !pidSet.has(String(parent))) {
243
+ roots.push(process);
244
+ continue;
245
+ }
246
+ const key = String(parent);
247
+ const siblings = childrenByParent.get(key) ?? [];
248
+ siblings.push(process);
249
+ childrenByParent.set(key, siblings);
250
+ }
251
+
252
+ const visit = (process: Process.Info, level: number): ProcessNode => {
253
+ const children = level >= maxDepth ? [] : sortNestedActive(childrenByParent.get(String(process.pid)) ?? []);
254
+ return {
255
+ id: String(process.pid),
256
+ process,
257
+ children: children.map((child) => visit(child, level + 1)),
258
+ };
259
+ };
260
+
261
+ return {
262
+ id: ROOT_ID,
263
+ children: sortProcesses(roots).map((process) => visit(process, 1)),
264
+ };
265
+ };
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ export { ProcessTree, type ProcessTreeProps } from './ProcessTree.tsx';
@@ -0,0 +1,326 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { type Meta, type StoryObj } from '@storybook/react-vite';
6
+ import React, { useRef, useState } from 'react';
7
+
8
+ import { LogLevel } from '@dxos/log';
9
+ import { random } from '@dxos/random';
10
+ import { Button, Panel, ScrollArea, ScrollContainer, Toolbar, useInterval } from '@dxos/react-ui';
11
+ import { type ScrollController } from '@dxos/react-ui';
12
+ import { withLayout, withTheme } from '@dxos/react-ui/testing';
13
+
14
+ import { type Commit, Timeline, TimelineProps } from './Timeline.tsx';
15
+
16
+ random.seed(1);
17
+
18
+ enum IconType {
19
+ // General status.
20
+ WARN = 'ph--warning-circle--regular',
21
+ CHECK = 'ph--check-circle--regular',
22
+ ROCKET = 'ph--rocket--regular',
23
+ X = 'ph--x-circle--regular',
24
+ FLAG = 'ph--flag--regular',
25
+ TIMER = 'ph--timer--regular',
26
+
27
+ // Interactions.
28
+ USER = 'ph--user--regular',
29
+ USER_INTERACTION = 'ph--user-sound--regular',
30
+ AGENT = 'ph--drone--regular',
31
+ THINK = 'ph--brain--regular',
32
+ LINK = 'ph--link--regular',
33
+ TOOL = 'ph--wrench--regular',
34
+ }
35
+
36
+ const generateCommit = (
37
+ commits: Commit[],
38
+ branches: string[],
39
+ lastBranch: string,
40
+ lastCommit: string | undefined,
41
+ closedBranches: Set<string>,
42
+ ): {
43
+ commit: Commit | undefined;
44
+ branch: string | undefined;
45
+ } => {
46
+ let commit: Commit | undefined = undefined;
47
+ let branch: string | undefined = undefined;
48
+
49
+ const p = Math.random();
50
+ if (commits.length > 3) {
51
+ if (p < 0.2 && branches.length < 6) {
52
+ // New branch.
53
+ branch = random.lorem.word();
54
+ lastBranch = branch;
55
+ } else if (p < 0.4) {
56
+ // Switch branch.
57
+ const branch = branches[Math.floor(Math.random() * branches.length)];
58
+ if (!closedBranches.has(branch)) {
59
+ lastBranch = branch;
60
+ }
61
+ } else if (p < 0.5 && branches.length > 3 && lastCommit && lastBranch !== branches[0]) {
62
+ // Merge branch.
63
+ closedBranches.add(lastBranch);
64
+ const lastBranchCommit = commits.findLast((c) => c.branch === lastBranch);
65
+ lastBranch = branches[0];
66
+ if (lastBranchCommit) {
67
+ commit = {
68
+ id: random.string.uuid(),
69
+ branch: lastBranch,
70
+ icon: IconType.TIMER,
71
+ level: LogLevel.INFO,
72
+ message: 'Merge',
73
+ parents: [lastBranchCommit.id, lastCommit],
74
+ };
75
+ }
76
+ }
77
+ }
78
+
79
+ if (!commit) {
80
+ commit = {
81
+ id: random.string.uuid(),
82
+ timestamp: new Date(),
83
+ branch: lastBranch,
84
+ icon: random.helpers.arrayElement([
85
+ IconType.WARN,
86
+ IconType.CHECK,
87
+ IconType.ROCKET,
88
+ IconType.X,
89
+ IconType.FLAG,
90
+ IconType.TIMER,
91
+ IconType.USER,
92
+ IconType.USER_INTERACTION,
93
+ IconType.AGENT,
94
+ ]),
95
+ level: random.helpers.arrayElement([
96
+ LogLevel.TRACE,
97
+ LogLevel.DEBUG,
98
+ LogLevel.VERBOSE,
99
+ LogLevel.INFO,
100
+ LogLevel.WARN,
101
+ LogLevel.ERROR,
102
+ ]),
103
+ message: random.lorem.paragraph(),
104
+ parents: lastCommit ? [lastCommit] : [],
105
+ };
106
+ }
107
+
108
+ return { commit, branch };
109
+ };
110
+
111
+ const generateCommits = (n: number): Pick<TimelineProps, 'commits' | 'branches'> => {
112
+ const commits = [];
113
+ const branches = ['main'];
114
+ let lastBranch = branches[0];
115
+ let lastCommit: string | undefined;
116
+ const closedBranches = new Set<string>();
117
+
118
+ for (let i = 0; i < n; i++) {
119
+ const { commit, branch } = generateCommit(commits, branches, lastBranch, lastCommit, closedBranches);
120
+ if (commit) {
121
+ commits.push(commit);
122
+ lastCommit = commit.id;
123
+ }
124
+ if (branch) {
125
+ branches.push(branch);
126
+ lastBranch = branch;
127
+ }
128
+ }
129
+
130
+ return { commits, branches };
131
+ };
132
+
133
+ const DefaultStory = (props: TimelineProps) => {
134
+ return (
135
+ <ScrollArea.Root>
136
+ <ScrollArea.Viewport>
137
+ <Timeline {...props} />
138
+ </ScrollArea.Viewport>
139
+ </ScrollArea.Root>
140
+ );
141
+ };
142
+
143
+ const meta = {
144
+ title: 'ui/react-ui-components/Timeline',
145
+ component: Timeline,
146
+ render: DefaultStory,
147
+ decorators: [withTheme(), withLayout({ layout: 'column' })],
148
+ } satisfies Meta<typeof Timeline>;
149
+
150
+ export default meta;
151
+
152
+ type Story = StoryObj<typeof meta>;
153
+
154
+ export const Default: Story = {
155
+ args: {
156
+ debug: true,
157
+ showIcon: false,
158
+ commits: [
159
+ { id: 'c1', message: random.lorem.paragraph(), branch: 'main' },
160
+ { id: 'c2', message: random.lorem.paragraph(), branch: 'main', parents: ['c1'] },
161
+ { id: 'c3', message: random.lorem.paragraph(), branch: 'feature-a', parents: ['c2'] },
162
+ { id: 'c4', message: random.lorem.paragraph(), branch: 'main', parents: ['c2'] },
163
+ { id: 'c5', message: random.lorem.paragraph(), branch: 'feature-b', parents: ['c2'] },
164
+ { id: 'c6', message: random.lorem.paragraph(), branch: 'feature-a', parents: ['c3'] },
165
+ { id: 'c7', message: random.lorem.paragraph(), branch: 'feature-a', parents: ['c6'] },
166
+ { id: 'c8', message: random.lorem.paragraph(), branch: 'feature-c', parents: ['c6'] },
167
+ { id: 'c9', message: random.lorem.paragraph(), branch: 'main', parents: ['c4'] },
168
+ ],
169
+ },
170
+ };
171
+
172
+ export const Branch: Story = {
173
+ args: {
174
+ debug: true,
175
+ showIcon: false,
176
+ commits: [
177
+ { id: 'c1', message: random.lorem.paragraph(), branch: 'main' },
178
+ { id: 'c2', message: random.lorem.paragraph(), branch: 'main', parents: ['c1'] },
179
+ { id: 'c3', message: random.lorem.paragraph(), branch: 'feature-a', parents: ['c2'] },
180
+ ],
181
+ },
182
+ };
183
+
184
+ export const Timestamp: Story = {
185
+ args: {
186
+ debug: true,
187
+ showIcon: false,
188
+ showTimestamp: true,
189
+ commits: [
190
+ {
191
+ id: 'c1',
192
+ timestamp: new Date(Date.now()),
193
+ message: random.lorem.paragraph(),
194
+ branch: 'main',
195
+ },
196
+ {
197
+ id: 'c2',
198
+ timestamp: new Date(Date.now() + 100),
199
+ message: random.lorem.paragraph(),
200
+ branch: 'main',
201
+ parents: ['c1'],
202
+ },
203
+ {
204
+ id: 'c3',
205
+ timestamp: new Date(Date.now() + 120),
206
+ message: random.lorem.paragraph(),
207
+ branch: 'feature-a',
208
+ parents: ['c2'],
209
+ },
210
+ ],
211
+ },
212
+ };
213
+
214
+ export const Merge: Story = {
215
+ args: {
216
+ debug: true,
217
+ showIcon: false,
218
+ branches: ['main', 'feature-a', 'feature-b'],
219
+ commits: [
220
+ { id: 'c1', message: random.lorem.paragraph(), branch: 'main' },
221
+ { id: 'c2', message: random.lorem.paragraph(), branch: 'main', parents: ['c1'] },
222
+ { id: 'c3', message: random.lorem.paragraph(), branch: 'feature-a', parents: ['c2'] },
223
+ { id: 'c4', message: random.lorem.paragraph(), branch: 'feature-a', parents: ['c3'] },
224
+ { id: 'c5', message: random.lorem.paragraph(), branch: 'feature-b', parents: ['c3'] },
225
+ { id: 'c6', message: random.lorem.paragraph(), branch: 'main', parents: ['c2'] },
226
+ { id: 'c7', message: random.lorem.paragraph(), branch: 'main', parents: ['c6', 'c4', 'c5'] },
227
+ ],
228
+ },
229
+ };
230
+
231
+ export const Linear: Story = {
232
+ args: {
233
+ branches: ['main'],
234
+ commits: [
235
+ { id: 'c1', message: random.lorem.paragraph(), branch: 'main' },
236
+ { id: 'c2', message: random.lorem.paragraph(), branch: 'main', parents: ['c1'] },
237
+ { id: 'c3', message: random.lorem.paragraph(), branch: 'main', parents: ['c2'] },
238
+ { id: 'c4', message: random.lorem.paragraph(), branch: 'main', parents: ['c3'] },
239
+ ],
240
+ },
241
+ };
242
+
243
+ export const Empty: Story = {};
244
+
245
+ export const Random: Story = {
246
+ args: generateCommits(100),
247
+ };
248
+
249
+ export const Compact: Story = {
250
+ args: { ...generateCommits(100), compact: true },
251
+ };
252
+
253
+ export const Streaming: Story = {
254
+ render: () => {
255
+ const [branches, setBranches] = useState<string[]>(['main']);
256
+ const [commits, setCommits] = useState<Commit[]>([
257
+ {
258
+ id: random.string.uuid(),
259
+ branch: branches[0],
260
+ message: random.lorem.paragraph(),
261
+ },
262
+ ]);
263
+ const lastCommit = useRef<string | undefined>(commits[0].id);
264
+ const lastBranch = useRef<string>(branches[0]);
265
+ const closedBranches = useRef<Set<string>>(new Set());
266
+
267
+ const [running, setRunning] = useState(true);
268
+ useInterval(
269
+ () => {
270
+ if (!running) {
271
+ return;
272
+ }
273
+
274
+ const { commit, branch } = generateCommit(
275
+ commits,
276
+ branches,
277
+ lastBranch.current,
278
+ lastCommit.current,
279
+ closedBranches.current,
280
+ );
281
+ if (!commit) {
282
+ return;
283
+ }
284
+
285
+ lastBranch.current = commit.branch;
286
+ if (branch) {
287
+ setBranches((branches) => [...branches, branch]);
288
+ }
289
+
290
+ lastCommit.current = commit.id;
291
+ setCommits((commits) => [...commits, commit]);
292
+ },
293
+ 1_000,
294
+ [running],
295
+ );
296
+
297
+ const scrollerRef = useRef<ScrollController>(null);
298
+
299
+ return (
300
+ <Panel.Root>
301
+ <Panel.Toolbar asChild>
302
+ <Toolbar.Root>
303
+ <Button onClick={() => setRunning(true)}>Start</Button>
304
+ <Button onClick={() => setRunning(false)}>Stop</Button>
305
+ <Button onClick={() => scrollerRef.current?.scrollToTop()}>Top</Button>
306
+ <Button onClick={() => scrollerRef.current?.scrollToBottom()}>Bottom</Button>
307
+ </Toolbar.Root>
308
+ </Panel.Toolbar>
309
+ <Panel.Content asChild>
310
+ <ScrollContainer.Root pin ref={scrollerRef}>
311
+ <ScrollContainer.Content thin>
312
+ <ScrollContainer.Viewport>
313
+ <Timeline branches={branches} commits={commits} showTimestamp />
314
+ </ScrollContainer.Viewport>
315
+ <ScrollContainer.ScrollDownButton />
316
+ </ScrollContainer.Content>
317
+ </ScrollContainer.Root>
318
+ </Panel.Content>
319
+ </Panel.Root>
320
+ );
321
+ },
322
+ };
323
+
324
+ //
325
+ // Test data
326
+ //