@dxos/react-ui-trace 0.11.1 → 0.14.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.
Files changed (115) hide show
  1. package/README.md +1 -1
  2. package/dist/lib/index.mjs +1722 -561
  3. package/dist/lib/index.mjs.map +1 -1
  4. package/dist/lib/testing.mjs +9 -8
  5. package/dist/lib/testing.mjs.map +1 -1
  6. package/dist/lib/translations.mjs +5 -0
  7. package/dist/lib/translations.mjs.map +1 -1
  8. package/dist/types/src/components/Gantt/Gantt.d.ts +109 -24
  9. package/dist/types/src/components/Gantt/Gantt.d.ts.map +1 -1
  10. package/dist/types/src/components/Gantt/Gantt.stories.d.ts +73 -3
  11. package/dist/types/src/components/Gantt/Gantt.stories.d.ts.map +1 -1
  12. package/dist/types/src/components/Gantt/gantt-rows.d.ts +26 -0
  13. package/dist/types/src/components/Gantt/gantt-rows.d.ts.map +1 -0
  14. package/dist/types/src/components/Gantt/gantt-rows.test.d.ts +2 -0
  15. package/dist/types/src/components/Gantt/gantt-rows.test.d.ts.map +1 -0
  16. package/dist/types/src/components/Gantt/gantt-scale.d.ts +57 -0
  17. package/dist/types/src/components/Gantt/gantt-scale.d.ts.map +1 -0
  18. package/dist/types/src/components/Gantt/gantt-scale.test.d.ts +2 -0
  19. package/dist/types/src/components/Gantt/gantt-scale.test.d.ts.map +1 -0
  20. package/dist/types/src/components/Gantt/index.d.ts +2 -0
  21. package/dist/types/src/components/Gantt/index.d.ts.map +1 -1
  22. package/dist/types/src/components/Gantt/useEnter.d.ts +13 -0
  23. package/dist/types/src/components/Gantt/useEnter.d.ts.map +1 -0
  24. package/dist/types/src/components/Gantt/useRangeTween.d.ts +12 -0
  25. package/dist/types/src/components/Gantt/useRangeTween.d.ts.map +1 -0
  26. package/dist/types/src/components/ProcessTree/ProcessTree.d.ts +3 -3
  27. package/dist/types/src/components/ProcessTree/ProcessTree.d.ts.map +1 -1
  28. package/dist/types/src/components/ProcessTree/ProcessTree.stories.d.ts.map +1 -1
  29. package/dist/types/src/components/Timeline/Timeline.d.ts +10 -2
  30. package/dist/types/src/components/Timeline/Timeline.d.ts.map +1 -1
  31. package/dist/types/src/components/Timeline/Timeline.stories.d.ts +8 -4
  32. package/dist/types/src/components/Timeline/Timeline.stories.d.ts.map +1 -1
  33. package/dist/types/src/components/Timeline/timeline-layout.d.ts +35 -0
  34. package/dist/types/src/components/Timeline/timeline-layout.d.ts.map +1 -0
  35. package/dist/types/src/components/Timeline/timeline-layout.test.d.ts +2 -0
  36. package/dist/types/src/components/Timeline/timeline-layout.test.d.ts.map +1 -0
  37. package/dist/types/src/components/TracePanel/SpanTreeView.d.ts +15 -0
  38. package/dist/types/src/components/TracePanel/SpanTreeView.d.ts.map +1 -0
  39. package/dist/types/src/components/TracePanel/TracePanel.d.ts +3 -3
  40. package/dist/types/src/components/TracePanel/TracePanel.d.ts.map +1 -1
  41. package/dist/types/src/components/TracePanel/TracePanel.stories.d.ts +46 -0
  42. package/dist/types/src/components/TracePanel/TracePanel.stories.d.ts.map +1 -0
  43. package/dist/types/src/components/TracePanel/trace-filter.d.ts +7 -2
  44. package/dist/types/src/components/TracePanel/trace-filter.d.ts.map +1 -1
  45. package/dist/types/src/components/TracePanel/useTraceMenu.d.ts.map +1 -1
  46. package/dist/types/src/execution-graph/execution-graph.d.ts +1 -1
  47. package/dist/types/src/execution-graph/execution-graph.d.ts.map +1 -1
  48. package/dist/types/src/hooks/useExecutionGraph.d.ts +2 -2
  49. package/dist/types/src/hooks/useExecutionGraph.d.ts.map +1 -1
  50. package/dist/types/src/hooks/useSessionTimeline.d.ts +1 -1
  51. package/dist/types/src/hooks/useSessionTimeline.d.ts.map +1 -1
  52. package/dist/types/src/hooks/useTraceMessages.d.ts +1 -1
  53. package/dist/types/src/hooks/useTraceMessages.d.ts.map +1 -1
  54. package/dist/types/src/session-timeline/TaskHistory.stories.d.ts +12 -0
  55. package/dist/types/src/session-timeline/TaskHistory.stories.d.ts.map +1 -0
  56. package/dist/types/src/session-timeline/gantt-mapping.d.ts +14 -0
  57. package/dist/types/src/session-timeline/gantt-mapping.d.ts.map +1 -0
  58. package/dist/types/src/session-timeline/gantt-mapping.test.d.ts +2 -0
  59. package/dist/types/src/session-timeline/gantt-mapping.test.d.ts.map +1 -0
  60. package/dist/types/src/session-timeline/index.d.ts +1 -0
  61. package/dist/types/src/session-timeline/index.d.ts.map +1 -1
  62. package/dist/types/src/session-timeline/session-timeline.d.ts +17 -2
  63. package/dist/types/src/session-timeline/session-timeline.d.ts.map +1 -1
  64. package/dist/types/src/session-timeline/types.d.ts +32 -0
  65. package/dist/types/src/session-timeline/types.d.ts.map +1 -1
  66. package/dist/types/src/testing/simulated-agent.d.ts +5 -4
  67. package/dist/types/src/testing/simulated-agent.d.ts.map +1 -1
  68. package/dist/types/src/testing/test-trace.d.ts +2 -2
  69. package/dist/types/src/testing/test-trace.d.ts.map +1 -1
  70. package/dist/types/src/translations.d.ts +5 -0
  71. package/dist/types/src/translations.d.ts.map +1 -1
  72. package/dist/types/tsconfig.tsbuildinfo +1 -1
  73. package/package.json +30 -26
  74. package/src/components/Gantt/Gantt.stories.tsx +914 -96
  75. package/src/components/Gantt/Gantt.tsx +964 -261
  76. package/src/components/Gantt/gantt-rows.test.ts +123 -0
  77. package/src/components/Gantt/gantt-rows.ts +71 -0
  78. package/src/components/Gantt/gantt-scale.test.ts +105 -0
  79. package/src/components/Gantt/gantt-scale.ts +143 -0
  80. package/src/components/Gantt/index.ts +2 -0
  81. package/src/components/Gantt/useEnter.ts +33 -0
  82. package/src/components/Gantt/useRangeTween.ts +61 -0
  83. package/src/components/ProcessTree/ProcessTree.stories.tsx +2 -3
  84. package/src/components/ProcessTree/ProcessTree.tsx +99 -90
  85. package/src/components/Timeline/Timeline.stories.tsx +110 -22
  86. package/src/components/Timeline/Timeline.tsx +319 -271
  87. package/src/components/Timeline/timeline-layout.test.ts +177 -0
  88. package/src/components/Timeline/timeline-layout.ts +153 -0
  89. package/src/components/TracePanel/SpanTreeView.tsx +39 -0
  90. package/src/components/TracePanel/TracePanel.stories.tsx +106 -0
  91. package/src/components/TracePanel/TracePanel.tsx +113 -106
  92. package/src/components/TracePanel/trace-filter.test.ts +31 -2
  93. package/src/components/TracePanel/trace-filter.ts +28 -3
  94. package/src/components/TracePanel/useTraceMenu.ts +0 -1
  95. package/src/execution-graph/SPEC.md +10 -10
  96. package/src/execution-graph/execution-graph.test.ts +16 -15
  97. package/src/execution-graph/execution-graph.ts +108 -60
  98. package/src/execution-graph/pending-block-status.ts +1 -1
  99. package/src/execution-graph/sub-agent-delegation.test.ts +1 -1
  100. package/src/hooks/useExecutionGraph.ts +14 -11
  101. package/src/hooks/useSessionTimeline.ts +35 -9
  102. package/src/hooks/useTraceMessages.ts +1 -1
  103. package/src/session-timeline/TaskHistory.stories.tsx +142 -0
  104. package/src/session-timeline/gantt-mapping.test.ts +118 -0
  105. package/src/session-timeline/gantt-mapping.ts +158 -0
  106. package/src/session-timeline/index.ts +1 -0
  107. package/src/session-timeline/session-timeline.test.ts +585 -35
  108. package/src/session-timeline/session-timeline.ts +433 -62
  109. package/src/session-timeline/types.ts +14 -0
  110. package/src/testing/simulated-agent.ts +17 -16
  111. package/src/testing/test-trace.ts +14 -12
  112. package/src/translations.ts +8 -2
  113. package/dist/types/src/execution-graph/remote-snapshot.test.d.ts +0 -2
  114. package/dist/types/src/execution-graph/remote-snapshot.test.d.ts.map +0 -1
  115. package/src/execution-graph/remote-snapshot.test.ts +0 -116
@@ -8,9 +8,11 @@ import * as Option from 'effect/Option';
8
8
  import React, { useCallback, useContext, useMemo, useRef } from 'react';
9
9
 
10
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';
11
+ import { Tree, type TreeNode, type TreeSelectEvent, createStaticTreeModel } from '@dxos/react-ui-list';
12
+ import * as Button from '@dxos/react-ui/Button';
13
+ import * as Icon from '@dxos/react-ui/Icon';
14
+ import * as Tooltip from '@dxos/react-ui/Tooltip';
15
+ import * as Util from '@dxos/react-ui/Util';
14
16
  import { Unit } from '@dxos/util';
15
17
 
16
18
  const DEFAULT_DEPTH = 1;
@@ -23,7 +25,7 @@ const NESTED_ACTIVE_STATES = new Set<Process.State>([
23
25
 
24
26
  export type ProcessTreeProps = {
25
27
  // TODO(burdon): Atom.
26
- processes: readonly Process.Info[];
28
+ processes: readonly Process.Process[];
27
29
  /**
28
30
  * Maximum nesting depth from the root (1 = top-level processes only).
29
31
  *
@@ -36,19 +38,19 @@ export type ProcessTreeProps = {
36
38
  *
37
39
  * Must be referentially stable — `ProcessTree` is memoized on its props.
38
40
  */
39
- resolveLabel?: (process: Process.Info) => string | undefined;
41
+ resolveLabel?: (process: Process.Process) => string | undefined;
40
42
  /** Pids drawn as selected; the tree is controlled, so a click reports through `onSelectedChange`. */
41
43
  selected?: readonly string[];
42
44
  /** The selection after a click: the clicked pid alone, or toggled among the others on a meta-click. */
43
45
  onSelectedChange?: (selected: string[]) => void;
44
- onProcessTerminate?: (process: Process.Info) => void;
46
+ onProcessTerminate?: (process: Process.Process) => void;
45
47
  };
46
48
 
47
49
  /** Node of the pruned process forest handed to the tree model. */
48
50
  type ProcessNode = {
49
51
  id: string;
50
52
  /** Absent on the synthetic root, which anchors the top-level processes and is never rendered. */
51
- process?: Process.Info;
53
+ process?: Process.Process;
52
54
  children: ProcessNode[];
53
55
  };
54
56
 
@@ -63,7 +65,7 @@ const NO_SELECTION: readonly string[] = [];
63
65
  * model and is re-seeded — a collapse must survive the next tick. Renders through `Tree`.
64
66
  */
65
67
  export const ProcessTree = React.memo(
66
- composable<HTMLDivElement, ProcessTreeProps>(
68
+ Util.composable<HTMLDivElement, ProcessTreeProps>(
67
69
  (
68
70
  {
69
71
  processes,
@@ -109,7 +111,7 @@ export const ProcessTree = React.memo(
109
111
  );
110
112
 
111
113
  const handleSelect = useCallback(
112
- ({ item, current, meta }: { item: ProcessNode; current: boolean; meta: boolean }) => {
114
+ ({ item, current, meta }: TreeSelectEvent<ProcessNode>) => {
113
115
  if (!item.process) {
114
116
  return;
115
117
  }
@@ -121,91 +123,98 @@ export const ProcessTree = React.memo(
121
123
  [selected, onSelectedChange],
122
124
  );
123
125
 
124
- const renderIcon = useMemo(() => makeIconRenderer(), []);
125
- const renderColumns = useMemo(() => makeColumnRenderer(onProcessTerminate), [onProcessTerminate]);
126
+ const renderRow = useCallback(
127
+ (node: TreeNode<ProcessNode>) => <ProcessRow node={node} onProcessTerminate={onProcessTerminate} />,
128
+ [onProcessTerminate],
129
+ );
126
130
 
127
131
  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>
132
+ <div {...Util.composableProps(props, { classNames: 'flex flex-col min-h-0' })} ref={forwardedRef}>
133
+ <Tree.Root
134
+ id={ROOT_ID}
135
+ model={model}
136
+ virtual='fixed'
137
+ size='sm'
138
+ selectionMode='multiple'
139
+ columns={COLUMNS}
140
+ onOpenChange={handleOpenChange}
141
+ onSelect={handleSelect}
142
+ >
143
+ <Tree.Content>{renderRow}</Tree.Content>
144
+ </Tree.Root>
145
+ </div>
144
146
  );
145
147
  },
146
148
  ),
147
149
  );
148
150
 
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
- );
151
+ /** Disclosure, status glyph, label, then the elapsed time and the terminate control. */
152
+ const COLUMNS = 'var(--dx-half-block-size) var(--dx-block-size) minmax(0, 1fr) min-content min-content';
177
153
 
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
- );
154
+ type ProcessRowProps = {
155
+ node: TreeNode<ProcessNode>;
156
+ onProcessTerminate?: (process: Process.Process) => void;
157
+ };
158
+
159
+ /** One process: its status glyph (animated, coloured and tooltipped per state), elapsed time and terminate control. */
160
+ const ProcessRow = ({ node, onProcessTerminate }: ProcessRowProps) => {
161
+ const process = node.item?.process;
162
+ return (
163
+ <Tree.Item node={node}>
164
+ <Tree.ItemIndicator />
165
+ <Tree.ItemIcon>{process && <StatusIcon process={process} />}</Tree.ItemIcon>
166
+ <Tree.ItemText />
167
+ <span className='text-end ps-1 text-xs text-fg-muted tabular-nums whitespace-nowrap'>
168
+ {process &&
169
+ [Process.State.FAILED, Process.State.SUCCEEDED].includes(process.state) &&
170
+ Unit.Duration(process.metrics.wallTime).toString()}
171
+ </span>
172
+ <span>
173
+ {process && onProcessTerminate && process.state !== Process.State.TERMINATED && (
174
+ <Button.Root
175
+ icon='ph--x--regular'
176
+ iconOnly
177
+ size='sm'
178
+ variant='ghost'
179
+ label='Actions'
180
+ onClick={(event) => {
181
+ event.stopPropagation();
182
+ onProcessTerminate(process);
183
+ }}
184
+ />
185
+ )}
186
+ </span>
187
+ </Tree.Item>
188
+ );
189
+ };
190
+
191
+ const StatusIcon = ({ process }: { process: Process.Process }) => (
192
+ <Tooltip.Trigger content={process.state.toString()}>
193
+ <Icon.Icon
194
+ size='md'
195
+ spin={process.state === Process.State.RUNNING}
196
+ valence={
197
+ process.state === Process.State.FAILED
198
+ ? 'error'
199
+ : process.state === Process.State.SUCCEEDED
200
+ ? 'success'
201
+ : undefined
202
+ }
203
+ icon={Match.value(process.state).pipe(
204
+ Match.when(Process.State.RUNNING, () => 'ph--spinner-gap--regular'),
205
+ Match.when(Process.State.SUCCEEDED, () => 'ph--check-circle--regular'),
206
+ Match.when(Process.State.FAILED, () => 'ph--warning--regular'),
207
+ Match.when(Process.State.HYBERNATING, () => 'ph--spinner--regular'),
208
+ Match.when(Process.State.IDLE, () => 'ph--moon-stars--regular'),
209
+ Match.when(Process.State.TERMINATING, () => 'ph--x-circle--regular'),
210
+ Match.when(Process.State.TERMINATED, () => 'ph--x-circle--regular'),
211
+ Match.orElse(() => 'ph--spinner-gap--regular'),
212
+ )}
213
+ />
214
+ </Tooltip.Trigger>
215
+ );
207
216
 
208
- const sortProcesses = (processes: readonly Process.Info[]): Process.Info[] => {
217
+ const sortProcesses = (processes: readonly Process.Process[]): Process.Process[] => {
209
218
  return [
210
219
  ...processes.filter((process) => [Process.State.RUNNING, Process.State.HYBERNATING].includes(process.state)),
211
220
  ...processes.filter((process) => [Process.State.IDLE].includes(process.state)).slice(0, 3),
@@ -219,7 +228,7 @@ const sortProcesses = (processes: readonly Process.Info[]): Process.Info[] => {
219
228
  });
220
229
  };
221
230
 
222
- const sortNestedActive = (processes: readonly Process.Info[]): Process.Info[] =>
231
+ const sortNestedActive = (processes: readonly Process.Process[]): Process.Process[] =>
223
232
  processes
224
233
  .filter((process) => NESTED_ACTIVE_STATES.has(process.state))
225
234
  .sort((left, right) => {
@@ -232,10 +241,10 @@ const sortNestedActive = (processes: readonly Process.Info[]): Process.Info[] =>
232
241
  * Builds the process forest, pruned to `maxDepth` from each root. Nested levels surface only still-
233
242
  * active processes, so a deep tree stays readable while completed work collapses out of view.
234
243
  */
235
- const buildProcessForest = (processes: readonly Process.Info[], maxDepth: number): ProcessNode => {
244
+ const buildProcessForest = (processes: readonly Process.Process[], maxDepth: number): ProcessNode => {
236
245
  const pidSet = new Set(processes.map((process) => String(process.pid)));
237
- const childrenByParent = new Map<string, Process.Info[]>();
238
- const roots: Process.Info[] = [];
246
+ const childrenByParent = new Map<string, Process.Process[]>();
247
+ const roots: Process.Process[] = [];
239
248
 
240
249
  for (const process of processes) {
241
250
  const parent = process.parentPid;
@@ -249,7 +258,7 @@ const buildProcessForest = (processes: readonly Process.Info[], maxDepth: number
249
258
  childrenByParent.set(key, siblings);
250
259
  }
251
260
 
252
- const visit = (process: Process.Info, level: number): ProcessNode => {
261
+ const visit = (process: Process.Process, level: number): ProcessNode => {
253
262
  const children = level >= maxDepth ? [] : sortNestedActive(childrenByParent.get(String(process.pid)) ?? []);
254
263
  return {
255
264
  id: String(process.pid),
@@ -4,13 +4,19 @@
4
4
 
5
5
  import { type Meta, type StoryObj } from '@storybook/react-vite';
6
6
  import React, { useRef, useState } from 'react';
7
+ import { expect, userEvent, waitFor } from 'storybook/test';
7
8
 
8
9
  import { LogLevel } from '@dxos/log';
9
10
  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';
11
+ import * as Button from '@dxos/react-ui/Button';
12
+ import * as Hooks from '@dxos/react-ui/Hooks';
13
+ import * as Panel from '@dxos/react-ui/Panel';
14
+ import * as ScrollArea from '@dxos/react-ui/ScrollArea';
15
+ import * as ScrollContainer from '@dxos/react-ui/ScrollContainer';
12
16
  import { withLayout, withTheme } from '@dxos/react-ui/testing';
17
+ import * as Toolbar from '@dxos/react-ui/Toolbar';
13
18
 
19
+ import { defaultOptions } from './timeline-options.ts';
14
20
  import { type Commit, Timeline, TimelineProps } from './Timeline.tsx';
15
21
 
16
22
  random.seed(1);
@@ -130,22 +136,23 @@ const generateCommits = (n: number): Pick<TimelineProps, 'commits' | 'branches'>
130
136
  return { commits, branches };
131
137
  };
132
138
 
133
- const DefaultStory = (props: TimelineProps) => {
139
+ const DefaultStory = (props: Omit<TimelineProps, 'scroller'>) => {
140
+ const [viewport, setViewport] = useState<HTMLDivElement | null>(null);
141
+
134
142
  return (
135
143
  <ScrollArea.Root>
136
- <ScrollArea.Viewport>
137
- <Timeline {...props} />
144
+ <ScrollArea.Viewport ref={setViewport}>
145
+ <Timeline {...props} scroller={viewport} />
138
146
  </ScrollArea.Viewport>
139
147
  </ScrollArea.Root>
140
148
  );
141
149
  };
142
150
 
143
151
  const meta = {
144
- title: 'ui/react-ui-components/Timeline',
145
- component: Timeline,
152
+ title: 'ui/react-ui-trace/Timeline',
146
153
  render: DefaultStory,
147
- decorators: [withTheme(), withLayout({ layout: 'column' })],
148
- } satisfies Meta<typeof Timeline>;
154
+ decorators: [withTheme(), withLayout({ layout: 'column', classNames: 'w-(--dx-complementary-sidebar-size)' })],
155
+ } satisfies Meta<typeof DefaultStory>;
149
156
 
150
157
  export default meta;
151
158
 
@@ -250,6 +257,86 @@ export const Compact: Story = {
250
257
  args: { ...generateCommits(100), compact: true },
251
258
  };
252
259
 
260
+ /** A history far longer than any viewport; only the rows in view are in the DOM. */
261
+ const LARGE_HISTORY_LENGTH = 5_000;
262
+
263
+ const generateLargeHistory = (): Pick<TimelineProps, 'commits' | 'branches'> => {
264
+ const branches = ['main', 'worker'];
265
+ const commits: Commit[] = [];
266
+ for (let index = 0; index < LARGE_HISTORY_LENGTH; index++) {
267
+ const branch = index % 20 < 4 ? 'worker' : 'main';
268
+ commits.push({
269
+ id: `commit-${index}`,
270
+ branch,
271
+ message: `Commit ${index}`,
272
+ parents: index > 0 ? [`commit-${index - 1}`] : [],
273
+ });
274
+ }
275
+
276
+ return { commits, branches };
277
+ };
278
+
279
+ const mountedRows = (canvasElement: HTMLElement) => canvasElement.querySelectorAll<HTMLElement>('[role="listitem"]');
280
+
281
+ export const Large: Story = {
282
+ args: { ...generateLargeHistory(), showTimestamp: true },
283
+ play: async ({ canvasElement }) => {
284
+ await waitFor(() => expect(mountedRows(canvasElement).length).toBeGreaterThan(0));
285
+
286
+ const rows = mountedRows(canvasElement);
287
+ // Windowed: a viewport's worth of rows plus the overscan, never the whole history.
288
+ await expect(rows.length).toBeLessThan(LARGE_HISTORY_LENGTH / 10);
289
+ // Rows are exactly `lineHeight` tall, which is what makes the declared extent exact.
290
+ for (const row of rows) {
291
+ await expect(row.getBoundingClientRect().height).toBe(defaultOptions.lineHeight);
292
+ }
293
+ },
294
+ };
295
+
296
+ /**
297
+ * Windowing must not cost the graph: a row deep in the history still draws every lane crossing it,
298
+ * because the spans it reads are computed over the whole history, not over the mounted rows.
299
+ */
300
+ export const Branching: Story = {
301
+ args: generateLargeHistory(),
302
+ play: async ({ canvasElement }) => {
303
+ await waitFor(() => expect(mountedRows(canvasElement).length).toBeGreaterThan(0));
304
+
305
+ // Jump to the end, so every mounted row was windowed in rather than rendered at mount.
306
+ const timeline = canvasElement.querySelector<HTMLElement>('[tabindex="0"]');
307
+ timeline?.focus();
308
+ await userEvent.keyboard('{Meta>}{ArrowDown}{/Meta}');
309
+ await waitFor(() =>
310
+ expect(canvasElement.querySelector(`[data-commit-index="${LARGE_HISTORY_LENGTH - 1}"]`)).not.toBeNull(),
311
+ );
312
+
313
+ const rows = [...mountedRows(canvasElement)];
314
+ // Each row carries its own node, and a connector for every lane that runs through it.
315
+ const nodesPerRow = rows.map((row) => row.querySelectorAll('svg circle').length);
316
+ await expect(Math.min(...nodesPerRow)).toBeGreaterThan(0);
317
+ // Somewhere in the window a second lane is open, so its through-line is drawn beside the node.
318
+ const connectorsPerRow = rows.map((row) => row.querySelectorAll('svg path').length);
319
+ await expect(Math.max(...connectorsPerRow)).toBeGreaterThan(1);
320
+ },
321
+ };
322
+
323
+ export const Keyboard: Story = {
324
+ args: generateLargeHistory(),
325
+ play: async ({ canvasElement }) => {
326
+ await waitFor(() => expect(mountedRows(canvasElement).length).toBeGreaterThan(0));
327
+
328
+ // The last row is thousands of rows down, so it is only reachable if the move scrolls to it.
329
+ const timeline = canvasElement.querySelector<HTMLElement>('[tabindex="0"]');
330
+ await expect(timeline).not.toBeNull();
331
+ timeline?.focus();
332
+ await userEvent.keyboard('{Meta>}{ArrowDown}{/Meta}');
333
+
334
+ const lastRow = () => canvasElement.querySelector(`[data-commit-index="${LARGE_HISTORY_LENGTH - 1}"]`);
335
+ await waitFor(() => expect(lastRow()).not.toBeNull());
336
+ await waitFor(() => expect(lastRow()?.getAttribute('aria-current')).toBe('true'));
337
+ },
338
+ };
339
+
253
340
  export const Streaming: Story = {
254
341
  render: () => {
255
342
  const [branches, setBranches] = useState<string[]>(['main']);
@@ -265,7 +352,7 @@ export const Streaming: Story = {
265
352
  const closedBranches = useRef<Set<string>>(new Set());
266
353
 
267
354
  const [running, setRunning] = useState(true);
268
- useInterval(
355
+ Hooks.useInterval(
269
356
  () => {
270
357
  if (!running) {
271
358
  return;
@@ -294,28 +381,29 @@ export const Streaming: Story = {
294
381
  [running],
295
382
  );
296
383
 
297
- const scrollerRef = useRef<ScrollController>(null);
384
+ const scrollerRef = useRef<ScrollContainer.ScrollController>(null);
385
+ const [viewport, setViewport] = useState<HTMLDivElement | null>(null);
298
386
 
299
387
  return (
300
388
  <Panel.Root>
301
- <Panel.Toolbar asChild>
389
+ <Panel.Header>
302
390
  <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>
391
+ <Button.Root onClick={() => setRunning(true)}>Start</Button.Root>
392
+ <Button.Root onClick={() => setRunning(false)}>Stop</Button.Root>
393
+ <Button.Root onClick={() => scrollerRef.current?.scrollToTop()}>Top</Button.Root>
394
+ <Button.Root onClick={() => scrollerRef.current?.scrollToBottom()}>Bottom</Button.Root>
307
395
  </Toolbar.Root>
308
- </Panel.Toolbar>
309
- <Panel.Content asChild>
396
+ </Panel.Header>
397
+ <Panel.Body>
310
398
  <ScrollContainer.Root pin ref={scrollerRef}>
311
- <ScrollContainer.Content thin>
312
- <ScrollContainer.Viewport>
313
- <Timeline branches={branches} commits={commits} showTimestamp />
399
+ <ScrollContainer.Content width='thin'>
400
+ <ScrollContainer.Viewport ref={setViewport}>
401
+ <Timeline branches={branches} commits={commits} showTimestamp scroller={viewport} />
314
402
  </ScrollContainer.Viewport>
315
403
  <ScrollContainer.ScrollDownButton />
316
404
  </ScrollContainer.Content>
317
405
  </ScrollContainer.Root>
318
- </Panel.Content>
406
+ </Panel.Body>
319
407
  </Panel.Root>
320
408
  );
321
409
  },