@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.
- package/README.md +1 -1
- package/dist/lib/index.mjs +1722 -561
- package/dist/lib/index.mjs.map +1 -1
- package/dist/lib/testing.mjs +9 -8
- package/dist/lib/testing.mjs.map +1 -1
- package/dist/lib/translations.mjs +5 -0
- package/dist/lib/translations.mjs.map +1 -1
- package/dist/types/src/components/Gantt/Gantt.d.ts +109 -24
- package/dist/types/src/components/Gantt/Gantt.d.ts.map +1 -1
- package/dist/types/src/components/Gantt/Gantt.stories.d.ts +73 -3
- package/dist/types/src/components/Gantt/Gantt.stories.d.ts.map +1 -1
- package/dist/types/src/components/Gantt/gantt-rows.d.ts +26 -0
- package/dist/types/src/components/Gantt/gantt-rows.d.ts.map +1 -0
- package/dist/types/src/components/Gantt/gantt-rows.test.d.ts +2 -0
- package/dist/types/src/components/Gantt/gantt-rows.test.d.ts.map +1 -0
- package/dist/types/src/components/Gantt/gantt-scale.d.ts +57 -0
- package/dist/types/src/components/Gantt/gantt-scale.d.ts.map +1 -0
- package/dist/types/src/components/Gantt/gantt-scale.test.d.ts +2 -0
- package/dist/types/src/components/Gantt/gantt-scale.test.d.ts.map +1 -0
- package/dist/types/src/components/Gantt/index.d.ts +2 -0
- package/dist/types/src/components/Gantt/index.d.ts.map +1 -1
- package/dist/types/src/components/Gantt/useEnter.d.ts +13 -0
- package/dist/types/src/components/Gantt/useEnter.d.ts.map +1 -0
- package/dist/types/src/components/Gantt/useRangeTween.d.ts +12 -0
- package/dist/types/src/components/Gantt/useRangeTween.d.ts.map +1 -0
- package/dist/types/src/components/ProcessTree/ProcessTree.d.ts +3 -3
- package/dist/types/src/components/ProcessTree/ProcessTree.d.ts.map +1 -1
- package/dist/types/src/components/ProcessTree/ProcessTree.stories.d.ts.map +1 -1
- package/dist/types/src/components/Timeline/Timeline.d.ts +10 -2
- package/dist/types/src/components/Timeline/Timeline.d.ts.map +1 -1
- package/dist/types/src/components/Timeline/Timeline.stories.d.ts +8 -4
- package/dist/types/src/components/Timeline/Timeline.stories.d.ts.map +1 -1
- package/dist/types/src/components/Timeline/timeline-layout.d.ts +35 -0
- package/dist/types/src/components/Timeline/timeline-layout.d.ts.map +1 -0
- package/dist/types/src/components/Timeline/timeline-layout.test.d.ts +2 -0
- package/dist/types/src/components/Timeline/timeline-layout.test.d.ts.map +1 -0
- package/dist/types/src/components/TracePanel/SpanTreeView.d.ts +15 -0
- package/dist/types/src/components/TracePanel/SpanTreeView.d.ts.map +1 -0
- package/dist/types/src/components/TracePanel/TracePanel.d.ts +3 -3
- package/dist/types/src/components/TracePanel/TracePanel.d.ts.map +1 -1
- package/dist/types/src/components/TracePanel/TracePanel.stories.d.ts +46 -0
- package/dist/types/src/components/TracePanel/TracePanel.stories.d.ts.map +1 -0
- package/dist/types/src/components/TracePanel/trace-filter.d.ts +7 -2
- package/dist/types/src/components/TracePanel/trace-filter.d.ts.map +1 -1
- package/dist/types/src/components/TracePanel/useTraceMenu.d.ts.map +1 -1
- package/dist/types/src/execution-graph/execution-graph.d.ts +1 -1
- package/dist/types/src/execution-graph/execution-graph.d.ts.map +1 -1
- package/dist/types/src/hooks/useExecutionGraph.d.ts +2 -2
- package/dist/types/src/hooks/useExecutionGraph.d.ts.map +1 -1
- package/dist/types/src/hooks/useSessionTimeline.d.ts +1 -1
- package/dist/types/src/hooks/useSessionTimeline.d.ts.map +1 -1
- package/dist/types/src/hooks/useTraceMessages.d.ts +1 -1
- package/dist/types/src/hooks/useTraceMessages.d.ts.map +1 -1
- package/dist/types/src/session-timeline/TaskHistory.stories.d.ts +12 -0
- package/dist/types/src/session-timeline/TaskHistory.stories.d.ts.map +1 -0
- package/dist/types/src/session-timeline/gantt-mapping.d.ts +14 -0
- package/dist/types/src/session-timeline/gantt-mapping.d.ts.map +1 -0
- package/dist/types/src/session-timeline/gantt-mapping.test.d.ts +2 -0
- package/dist/types/src/session-timeline/gantt-mapping.test.d.ts.map +1 -0
- package/dist/types/src/session-timeline/index.d.ts +1 -0
- package/dist/types/src/session-timeline/index.d.ts.map +1 -1
- package/dist/types/src/session-timeline/session-timeline.d.ts +17 -2
- package/dist/types/src/session-timeline/session-timeline.d.ts.map +1 -1
- package/dist/types/src/session-timeline/types.d.ts +32 -0
- package/dist/types/src/session-timeline/types.d.ts.map +1 -1
- package/dist/types/src/testing/simulated-agent.d.ts +5 -4
- package/dist/types/src/testing/simulated-agent.d.ts.map +1 -1
- package/dist/types/src/testing/test-trace.d.ts +2 -2
- package/dist/types/src/testing/test-trace.d.ts.map +1 -1
- package/dist/types/src/translations.d.ts +5 -0
- package/dist/types/src/translations.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +30 -26
- package/src/components/Gantt/Gantt.stories.tsx +914 -96
- package/src/components/Gantt/Gantt.tsx +964 -261
- package/src/components/Gantt/gantt-rows.test.ts +123 -0
- package/src/components/Gantt/gantt-rows.ts +71 -0
- package/src/components/Gantt/gantt-scale.test.ts +105 -0
- package/src/components/Gantt/gantt-scale.ts +143 -0
- package/src/components/Gantt/index.ts +2 -0
- package/src/components/Gantt/useEnter.ts +33 -0
- package/src/components/Gantt/useRangeTween.ts +61 -0
- package/src/components/ProcessTree/ProcessTree.stories.tsx +2 -3
- package/src/components/ProcessTree/ProcessTree.tsx +99 -90
- package/src/components/Timeline/Timeline.stories.tsx +110 -22
- package/src/components/Timeline/Timeline.tsx +319 -271
- package/src/components/Timeline/timeline-layout.test.ts +177 -0
- package/src/components/Timeline/timeline-layout.ts +153 -0
- package/src/components/TracePanel/SpanTreeView.tsx +39 -0
- package/src/components/TracePanel/TracePanel.stories.tsx +106 -0
- package/src/components/TracePanel/TracePanel.tsx +113 -106
- package/src/components/TracePanel/trace-filter.test.ts +31 -2
- package/src/components/TracePanel/trace-filter.ts +28 -3
- package/src/components/TracePanel/useTraceMenu.ts +0 -1
- package/src/execution-graph/SPEC.md +10 -10
- package/src/execution-graph/execution-graph.test.ts +16 -15
- package/src/execution-graph/execution-graph.ts +108 -60
- package/src/execution-graph/pending-block-status.ts +1 -1
- package/src/execution-graph/sub-agent-delegation.test.ts +1 -1
- package/src/hooks/useExecutionGraph.ts +14 -11
- package/src/hooks/useSessionTimeline.ts +35 -9
- package/src/hooks/useTraceMessages.ts +1 -1
- package/src/session-timeline/TaskHistory.stories.tsx +142 -0
- package/src/session-timeline/gantt-mapping.test.ts +118 -0
- package/src/session-timeline/gantt-mapping.ts +158 -0
- package/src/session-timeline/index.ts +1 -0
- package/src/session-timeline/session-timeline.test.ts +585 -35
- package/src/session-timeline/session-timeline.ts +433 -62
- package/src/session-timeline/types.ts +14 -0
- package/src/testing/simulated-agent.ts +17 -16
- package/src/testing/test-trace.ts +14 -12
- package/src/translations.ts +8 -2
- package/dist/types/src/execution-graph/remote-snapshot.test.d.ts +0 -2
- package/dist/types/src/execution-graph/remote-snapshot.test.d.ts.map +0 -1
- package/src/execution-graph/remote-snapshot.test.ts +0 -116
|
@@ -4,106 +4,103 @@
|
|
|
4
4
|
|
|
5
5
|
import { type Meta, type StoryObj } from '@storybook/react-vite';
|
|
6
6
|
import React, { type ReactNode, useEffect, useState } from 'react';
|
|
7
|
+
import { expect, waitFor } from 'storybook/test';
|
|
7
8
|
|
|
8
9
|
import { random } from '@dxos/random';
|
|
9
10
|
import { JsonHighlighter } from '@dxos/react-ui-syntax-highlighter';
|
|
10
11
|
import { withLayout, withTheme } from '@dxos/react-ui/testing';
|
|
11
12
|
|
|
12
|
-
import { Gantt, type GanttData, type GanttLane, type GanttMarker } from './Gantt.tsx';
|
|
13
|
+
import { Gantt, type GanttData, type GanttGroup, type GanttLane, type GanttMarker } from './Gantt.tsx';
|
|
13
14
|
|
|
14
15
|
random.seed(1);
|
|
15
16
|
|
|
16
17
|
const T0 = Date.UTC(2026, 8, 11, 10, 0, 0);
|
|
17
18
|
const MINUTE = 60_000;
|
|
19
|
+
const SECOND = 1_000;
|
|
20
|
+
|
|
21
|
+
/** `count` markers spread evenly over `[start, end]`, which is all a static fixture needs. */
|
|
22
|
+
const events = (laneId: string, count: number, start: number, end: number, prefix = laneId): GanttMarker[] =>
|
|
23
|
+
Array.from({ length: count }, (_, index) => ({
|
|
24
|
+
id: `${prefix}:${index}`,
|
|
25
|
+
laneId,
|
|
26
|
+
kind: 'tool',
|
|
27
|
+
timestamp: count < 2 ? start : start + ((end - start) * index) / (count - 1),
|
|
28
|
+
label: random.lorem.word(),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
//
|
|
32
|
+
// Fixture: a run of processes and the tasks they work, mapped onto the chart's own nouns.
|
|
33
|
+
//
|
|
18
34
|
|
|
19
35
|
/**
|
|
20
|
-
* Process A works
|
|
21
|
-
*
|
|
22
|
-
*
|
|
36
|
+
* Process A works three tasks itself and spawns process B for one of them and process C for another.
|
|
37
|
+
* Each process is a band; the tasks it works are lanes inside it; a spawned process is a band nested
|
|
38
|
+
* under its parent's, whose own lane opens out of the exact node that spawned it.
|
|
23
39
|
*/
|
|
40
|
+
const groups: GanttGroup[] = [{ id: 'g:a' }, { id: 'g:b', parentId: 'g:a' }, { id: 'g:c', parentId: 'g:a' }];
|
|
41
|
+
|
|
24
42
|
const lanes: GanttLane[] = [
|
|
25
43
|
{
|
|
26
44
|
id: 'a',
|
|
27
|
-
kind: 'session',
|
|
28
45
|
label: 'Process A — Plan the release',
|
|
29
46
|
status: 'running',
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
47
|
+
groupId: 'g:a',
|
|
48
|
+
segments: [{ start: T0 }],
|
|
49
|
+
meta: [{ label: '15.5k', title: '12400 in / 3100 out' }, { label: '6 tools' }],
|
|
33
50
|
},
|
|
34
51
|
{
|
|
35
52
|
id: 'a:triage',
|
|
36
|
-
kind: 'task',
|
|
37
|
-
taskId: 'task:triage',
|
|
38
53
|
label: 'Triage open issues',
|
|
39
54
|
status: 'done',
|
|
40
|
-
|
|
41
|
-
start: T0 + 0.5 * MINUTE,
|
|
42
|
-
end: T0 + 1.8 * MINUTE,
|
|
55
|
+
groupId: 'g:a',
|
|
56
|
+
segments: [{ start: T0 + 0.5 * MINUTE, end: T0 + 1.8 * MINUTE }],
|
|
43
57
|
},
|
|
44
58
|
{
|
|
45
59
|
id: 'a:docs',
|
|
46
|
-
kind: 'task',
|
|
47
|
-
taskId: 'task:docs',
|
|
48
60
|
label: 'Update docs',
|
|
49
61
|
status: 'failed',
|
|
50
|
-
|
|
51
|
-
start: T0 + 2.4 * MINUTE,
|
|
52
|
-
|
|
62
|
+
groupId: 'g:a',
|
|
63
|
+
segments: [{ start: T0 + 2.4 * MINUTE, end: T0 + 3.6 * MINUTE }],
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: 'a:announce',
|
|
67
|
+
label: 'Publish announcement',
|
|
68
|
+
status: 'blocked',
|
|
69
|
+
groupId: 'g:a',
|
|
70
|
+
blockedOn: ['c'],
|
|
53
71
|
},
|
|
54
72
|
{
|
|
55
73
|
id: 'b',
|
|
56
|
-
kind: 'session',
|
|
57
|
-
taskId: 'task:changelog',
|
|
58
74
|
label: 'Process B — Draft changelog',
|
|
59
75
|
status: 'done',
|
|
60
|
-
|
|
61
|
-
start: T0 + 4.1 * MINUTE,
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
toolCalls: 4,
|
|
76
|
+
groupId: 'g:b',
|
|
77
|
+
segments: [{ start: T0 + 4.1 * MINUTE, end: T0 + 7.2 * MINUTE }],
|
|
78
|
+
openedFrom: { laneId: 'a', markerId: 'm:a-spawn-b' },
|
|
79
|
+
closedInto: { laneId: 'a', markerId: 'm:a-return-b' },
|
|
80
|
+
meta: [{ label: '49.1k', title: '40200 in / 8900 out' }, { label: '4 tools' }],
|
|
66
81
|
},
|
|
67
82
|
{
|
|
68
83
|
id: 'b:titles',
|
|
69
|
-
kind: 'task',
|
|
70
|
-
taskId: 'task:titles',
|
|
71
84
|
label: 'Collect PR titles',
|
|
72
85
|
status: 'done',
|
|
73
|
-
|
|
74
|
-
start: T0 + 4.6 * MINUTE,
|
|
75
|
-
end: T0 + 5.7 * MINUTE,
|
|
86
|
+
groupId: 'g:b',
|
|
87
|
+
segments: [{ start: T0 + 4.6 * MINUTE, end: T0 + 5.7 * MINUTE }],
|
|
76
88
|
},
|
|
77
89
|
{
|
|
78
90
|
id: 'b:highlights',
|
|
79
|
-
kind: 'task',
|
|
80
|
-
taskId: 'task:highlights',
|
|
81
91
|
label: 'Write highlights',
|
|
82
92
|
status: 'done',
|
|
83
|
-
|
|
84
|
-
start: T0 + 6 * MINUTE,
|
|
85
|
-
end: T0 + 6.9 * MINUTE,
|
|
93
|
+
groupId: 'g:b',
|
|
94
|
+
segments: [{ start: T0 + 6 * MINUTE, end: T0 + 6.9 * MINUTE }],
|
|
86
95
|
},
|
|
87
96
|
{
|
|
88
97
|
id: 'c',
|
|
89
|
-
kind: 'session',
|
|
90
|
-
taskId: 'task:notes',
|
|
91
98
|
label: 'Process C — Write release notes',
|
|
92
99
|
status: 'running',
|
|
93
|
-
|
|
94
|
-
start: T0 + 5.1 * MINUTE,
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
toolCalls: 2,
|
|
98
|
-
},
|
|
99
|
-
{
|
|
100
|
-
id: 'a:announce',
|
|
101
|
-
kind: 'task',
|
|
102
|
-
taskId: 'task:announce',
|
|
103
|
-
label: 'Publish announcement',
|
|
104
|
-
status: 'blocked',
|
|
105
|
-
parentId: 'a',
|
|
106
|
-
blockedOn: ['c'],
|
|
100
|
+
groupId: 'g:c',
|
|
101
|
+
segments: [{ start: T0 + 5.1 * MINUTE }],
|
|
102
|
+
openedFrom: { laneId: 'a', markerId: 'm:a-spawn-c' },
|
|
103
|
+
meta: [{ label: '8.9k', title: '8000 in / 900 out' }, { label: '2 tools' }],
|
|
107
104
|
},
|
|
108
105
|
];
|
|
109
106
|
|
|
@@ -113,6 +110,13 @@ const markers: GanttMarker[] = [
|
|
|
113
110
|
{ id: 'm:a-spawn-b', laneId: 'a', kind: 'delegation', timestamp: T0 + 4 * MINUTE, label: 'Spawned process B' },
|
|
114
111
|
{ id: 'm:a-spawn-c', laneId: 'a', kind: 'delegation', timestamp: T0 + 5 * MINUTE, label: 'Spawned process C' },
|
|
115
112
|
{ id: 'm:a-end', laneId: 'a', kind: 'request', timestamp: T0 + 5.4 * MINUTE, label: 'Request success' },
|
|
113
|
+
{
|
|
114
|
+
id: 'm:a-return-b',
|
|
115
|
+
laneId: 'a',
|
|
116
|
+
kind: 'delegation',
|
|
117
|
+
timestamp: T0 + 7.4 * MINUTE,
|
|
118
|
+
label: 'Returned: changelog drafted',
|
|
119
|
+
},
|
|
116
120
|
|
|
117
121
|
{ id: 'm:triage-start', laneId: 'a:triage', kind: 'operation', timestamp: T0 + 0.5 * MINUTE, label: 'Started' },
|
|
118
122
|
{ id: 'm:triage-tool', laneId: 'a:triage', kind: 'tool', timestamp: T0 + 1.1 * MINUTE, label: 'list-issues' },
|
|
@@ -152,6 +156,232 @@ const markers: GanttMarker[] = [
|
|
|
152
156
|
{ id: 'm:c-tool-2', laneId: 'c', kind: 'tool', timestamp: T0 + 8.3 * MINUTE, label: 'create-document' },
|
|
153
157
|
];
|
|
154
158
|
|
|
159
|
+
//
|
|
160
|
+
// Static fixtures: one shape of the model each, with nothing moving.
|
|
161
|
+
//
|
|
162
|
+
|
|
163
|
+
/** One band, one lane, one run of events — the smallest thing the model can say. */
|
|
164
|
+
const oneLaneGroups: GanttGroup[] = [{ id: 'g' }];
|
|
165
|
+
const oneLaneLanes: GanttLane[] = [
|
|
166
|
+
{ id: 'l', label: 'One lane', status: 'done', groupId: 'g', segments: [{ start: T0, end: T0 + 8 * MINUTE }] },
|
|
167
|
+
];
|
|
168
|
+
const oneLaneMarkers: GanttMarker[] = events('l', 6, T0, T0 + 8 * MINUTE);
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* One band, four lanes over different spans — including one worked in two stretches and one still
|
|
172
|
+
* open. Segments are what makes the gap in the third lane sayable: a lane put down and picked back up
|
|
173
|
+
* is two bars, not one long one.
|
|
174
|
+
*/
|
|
175
|
+
const manyLaneGroups: GanttGroup[] = [{ id: 'g' }];
|
|
176
|
+
const manyLaneLanes: GanttLane[] = [
|
|
177
|
+
{
|
|
178
|
+
id: 'early',
|
|
179
|
+
label: 'Early and brief',
|
|
180
|
+
status: 'done',
|
|
181
|
+
groupId: 'g',
|
|
182
|
+
segments: [{ start: T0, end: T0 + 3 * MINUTE }],
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
id: 'long',
|
|
186
|
+
label: 'Long, overlapping',
|
|
187
|
+
status: 'done',
|
|
188
|
+
groupId: 'g',
|
|
189
|
+
segments: [{ start: T0 + 1 * MINUTE, end: T0 + 9 * MINUTE }],
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
id: 'twice',
|
|
193
|
+
label: 'Put down and picked up',
|
|
194
|
+
status: 'review',
|
|
195
|
+
groupId: 'g',
|
|
196
|
+
segments: [
|
|
197
|
+
{ start: T0 + 2 * MINUTE, end: T0 + 4 * MINUTE },
|
|
198
|
+
{ start: T0 + 7 * MINUTE, end: T0 + 10 * MINUTE },
|
|
199
|
+
],
|
|
200
|
+
},
|
|
201
|
+
{ id: 'open', label: 'Still running', status: 'running', groupId: 'g', segments: [{ start: T0 + 6 * MINUTE }] },
|
|
202
|
+
];
|
|
203
|
+
const manyLaneMarkers: GanttMarker[] = [
|
|
204
|
+
...events('early', 3, T0, T0 + 3 * MINUTE),
|
|
205
|
+
...events('long', 7, T0 + 1 * MINUTE, T0 + 9 * MINUTE),
|
|
206
|
+
...events('twice', 3, T0 + 2 * MINUTE, T0 + 4 * MINUTE, 'twice-a'),
|
|
207
|
+
...events('twice', 4, T0 + 7 * MINUTE, T0 + 10 * MINUTE, 'twice-b'),
|
|
208
|
+
...events('open', 4, T0 + 6 * MINUTE, T0 + 11 * MINUTE),
|
|
209
|
+
];
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* One band whose lanes branch off one another, and answer back.
|
|
213
|
+
*
|
|
214
|
+
* Four facts about the same pairs of lanes, deliberately kept apart: `parentId` indents a branch
|
|
215
|
+
* under the lane it belongs to, `openedFrom` draws the node it came out of, `closedInto` draws the
|
|
216
|
+
* node its result landed on, and the segment says when it was actually worked. The sub-branch is
|
|
217
|
+
* still working, so it has no return edge — which is the case that makes the separation earn itself.
|
|
218
|
+
*
|
|
219
|
+
* Handing work over and hearing back are separate events at separate instants, so no node does both:
|
|
220
|
+
* a result gets a step of the axis to itself. A branch begins shortly *after* the node that opened it
|
|
221
|
+
* and returns shortly after its own last event — a child's first event is its process starting, and a
|
|
222
|
+
* supervisor folds a result in when it next runs, so neither is ever simultaneous with the node it
|
|
223
|
+
* answers. That gap is also what gives every connector the same bent shape.
|
|
224
|
+
*/
|
|
225
|
+
const branchingGroups: GanttGroup[] = [{ id: 'g' }];
|
|
226
|
+
const branchingLanes: GanttLane[] = [
|
|
227
|
+
{ id: 'root', label: 'Root', status: 'done', groupId: 'g', segments: [{ start: T0, end: T0 + 10 * MINUTE }] },
|
|
228
|
+
{
|
|
229
|
+
id: 'first',
|
|
230
|
+
label: 'Branch, returns',
|
|
231
|
+
status: 'done',
|
|
232
|
+
groupId: 'g',
|
|
233
|
+
parentId: 'root',
|
|
234
|
+
segments: [{ start: T0 + 2.2 * MINUTE, end: T0 + 3.5 * MINUTE }],
|
|
235
|
+
openedFrom: { laneId: 'root', markerId: 'root:open-first' },
|
|
236
|
+
closedInto: { laneId: 'root', markerId: 'root:return-first' },
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
id: 'second',
|
|
240
|
+
label: 'Branch, returns later',
|
|
241
|
+
status: 'done',
|
|
242
|
+
groupId: 'g',
|
|
243
|
+
parentId: 'root',
|
|
244
|
+
segments: [{ start: T0 + 5.2 * MINUTE, end: T0 + 8 * MINUTE }],
|
|
245
|
+
openedFrom: { laneId: 'root', markerId: 'root:open-second' },
|
|
246
|
+
closedInto: { laneId: 'root', markerId: 'root:return-second' },
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
id: 'sub',
|
|
250
|
+
label: 'Sub-branch, still working',
|
|
251
|
+
status: 'running',
|
|
252
|
+
groupId: 'g',
|
|
253
|
+
parentId: 'second',
|
|
254
|
+
segments: [{ start: T0 + 6.8 * MINUTE }],
|
|
255
|
+
openedFrom: { laneId: 'second', markerId: 'second:1' },
|
|
256
|
+
},
|
|
257
|
+
];
|
|
258
|
+
|
|
259
|
+
/** Written out rather than spread evenly: which node is a hand-over and which an answer is the point. */
|
|
260
|
+
const branchingMarkers: GanttMarker[] = [
|
|
261
|
+
{ id: 'root:0', laneId: 'root', kind: 'request', timestamp: T0, label: 'Request started' },
|
|
262
|
+
{
|
|
263
|
+
id: 'root:open-first',
|
|
264
|
+
laneId: 'root',
|
|
265
|
+
kind: 'delegation',
|
|
266
|
+
timestamp: T0 + 2 * MINUTE,
|
|
267
|
+
label: 'Opened first branch',
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
id: 'root:return-first',
|
|
271
|
+
laneId: 'root',
|
|
272
|
+
kind: 'delegation',
|
|
273
|
+
timestamp: T0 + 4 * MINUTE,
|
|
274
|
+
label: 'Returned: first result',
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
id: 'root:open-second',
|
|
278
|
+
laneId: 'root',
|
|
279
|
+
kind: 'delegation',
|
|
280
|
+
timestamp: T0 + 5 * MINUTE,
|
|
281
|
+
label: 'Opened second branch',
|
|
282
|
+
},
|
|
283
|
+
{
|
|
284
|
+
id: 'root:return-second',
|
|
285
|
+
laneId: 'root',
|
|
286
|
+
kind: 'delegation',
|
|
287
|
+
timestamp: T0 + 8.5 * MINUTE,
|
|
288
|
+
label: 'Returned: second result',
|
|
289
|
+
},
|
|
290
|
+
{ id: 'root:5', laneId: 'root', kind: 'request', timestamp: T0 + 10 * MINUTE, label: 'Request success' },
|
|
291
|
+
|
|
292
|
+
...events('first', 3, T0 + 2.2 * MINUTE, T0 + 3.5 * MINUTE),
|
|
293
|
+
...events('second', 3, T0 + 5.2 * MINUTE, T0 + 8 * MINUTE),
|
|
294
|
+
...events('sub', 3, T0 + 6.8 * MINUTE, T0 + 9.5 * MINUTE),
|
|
295
|
+
];
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Four bands: two nested under the first — each opening out of a node on its parent's lane — and one
|
|
299
|
+
* unrelated band of its own. Nesting a band and opening a lane are separate statements, so the fourth
|
|
300
|
+
* band sits beside the others without claiming any relation to them.
|
|
301
|
+
*
|
|
302
|
+
* The nested band that finished also answers back (`closedInto`, dashed); the one still running has
|
|
303
|
+
* not, which is the point of keeping the two edges separate — a lane can be opened and never report.
|
|
304
|
+
*/
|
|
305
|
+
const manyGroupGroups: GanttGroup[] = [
|
|
306
|
+
{ id: 'one' },
|
|
307
|
+
{ id: 'one:a', parentId: 'one' },
|
|
308
|
+
{ id: 'one:b', parentId: 'one' },
|
|
309
|
+
{ id: 'two' },
|
|
310
|
+
];
|
|
311
|
+
const manyGroupLanes: GanttLane[] = [
|
|
312
|
+
{ id: 'p1', label: 'Group one', status: 'done', groupId: 'one', segments: [{ start: T0, end: T0 + 9 * MINUTE }] },
|
|
313
|
+
{
|
|
314
|
+
id: 'p1:task',
|
|
315
|
+
label: 'Worked in-band',
|
|
316
|
+
status: 'done',
|
|
317
|
+
groupId: 'one',
|
|
318
|
+
segments: [{ start: T0 + 0.6 * MINUTE, end: T0 + 2.5 * MINUTE }],
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
id: 'p2',
|
|
322
|
+
label: 'Nested band A',
|
|
323
|
+
status: 'done',
|
|
324
|
+
groupId: 'one:a',
|
|
325
|
+
segments: [{ start: T0 + 3.2 * MINUTE, end: T0 + 5.6 * MINUTE }],
|
|
326
|
+
openedFrom: { laneId: 'p1', markerId: 'p1:open-a' },
|
|
327
|
+
closedInto: { laneId: 'p1', markerId: 'p1:return-a' },
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
id: 'p2:task',
|
|
331
|
+
label: 'Its own lane',
|
|
332
|
+
status: 'done',
|
|
333
|
+
groupId: 'one:a',
|
|
334
|
+
segments: [{ start: T0 + 3.8 * MINUTE, end: T0 + 5.4 * MINUTE }],
|
|
335
|
+
},
|
|
336
|
+
{
|
|
337
|
+
id: 'p3',
|
|
338
|
+
label: 'Nested band B',
|
|
339
|
+
status: 'running',
|
|
340
|
+
groupId: 'one:b',
|
|
341
|
+
segments: [{ start: T0 + 6.2 * MINUTE }],
|
|
342
|
+
openedFrom: { laneId: 'p1', markerId: 'p1:open-b' },
|
|
343
|
+
},
|
|
344
|
+
{
|
|
345
|
+
id: 'p4',
|
|
346
|
+
label: 'Unrelated band',
|
|
347
|
+
status: 'review',
|
|
348
|
+
groupId: 'two',
|
|
349
|
+
segments: [{ start: T0 + 1.5 * MINUTE, end: T0 + 7.5 * MINUTE }],
|
|
350
|
+
},
|
|
351
|
+
];
|
|
352
|
+
const manyGroupMarkers: GanttMarker[] = [
|
|
353
|
+
{ id: 'p1:0', laneId: 'p1', kind: 'request', timestamp: T0, label: 'Request started' },
|
|
354
|
+
{ id: 'p1:open-a', laneId: 'p1', kind: 'delegation', timestamp: T0 + 3 * MINUTE, label: 'Opened band A' },
|
|
355
|
+
{ id: 'p1:open-b', laneId: 'p1', kind: 'delegation', timestamp: T0 + 6 * MINUTE, label: 'Opened band B' },
|
|
356
|
+
{ id: 'p1:return-a', laneId: 'p1', kind: 'delegation', timestamp: T0 + 7.2 * MINUTE, label: 'Returned: band A' },
|
|
357
|
+
{ id: 'p1:4', laneId: 'p1', kind: 'request', timestamp: T0 + 9 * MINUTE, label: 'Request success' },
|
|
358
|
+
...events('p1:task', 3, T0 + 0.6 * MINUTE, T0 + 2.5 * MINUTE),
|
|
359
|
+
...events('p2', 4, T0 + 3.2 * MINUTE, T0 + 5.6 * MINUTE),
|
|
360
|
+
...events('p2:task', 3, T0 + 3.8 * MINUTE, T0 + 5.4 * MINUTE),
|
|
361
|
+
...events('p3', 4, T0 + 6.2 * MINUTE, T0 + 10 * MINUTE),
|
|
362
|
+
...events('p4', 5, T0 + 1.5 * MINUTE, T0 + 7.5 * MINUTE),
|
|
363
|
+
];
|
|
364
|
+
|
|
365
|
+
/** One band, one lane, gaining an event a second — the fixture the live stories start from. */
|
|
366
|
+
const singleLaneGroups: GanttGroup[] = [{ id: 'g' }];
|
|
367
|
+
const singleLaneLanes: GanttLane[] = [
|
|
368
|
+
{
|
|
369
|
+
id: 'lane',
|
|
370
|
+
label: 'Process — one event a second',
|
|
371
|
+
status: 'running',
|
|
372
|
+
groupId: 'g',
|
|
373
|
+
segments: [{ start: T0 }],
|
|
374
|
+
},
|
|
375
|
+
];
|
|
376
|
+
const singleLaneMarkers: GanttMarker[] = [
|
|
377
|
+
{ id: 'e:0', laneId: 'lane', kind: 'request', timestamp: T0, label: 'Request started' },
|
|
378
|
+
];
|
|
379
|
+
|
|
380
|
+
/** The same shape, named for what it does in the delegation story: hand work out and take it back. */
|
|
381
|
+
const supervisorLanes: GanttLane[] = [
|
|
382
|
+
{ id: 'lane', label: 'Supervisor', status: 'running', groupId: 'g', segments: [{ start: T0 }] },
|
|
383
|
+
];
|
|
384
|
+
|
|
155
385
|
/** The chart over the data it was drawn from, so a reader can match a bar to its lane. */
|
|
156
386
|
const Layout = ({ chart, data }: { chart: ReactNode; data: unknown }) => (
|
|
157
387
|
<div className='flex flex-col dx-fill overflow-hidden'>
|
|
@@ -160,67 +390,372 @@ const Layout = ({ chart, data }: { chart: ReactNode; data: unknown }) => (
|
|
|
160
390
|
</div>
|
|
161
391
|
);
|
|
162
392
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
393
|
+
/** How many events a lane runs, and how many lanes each depth may open, when a story says neither. */
|
|
394
|
+
const CHILD_EVENTS: readonly [number, number] = [2, 5];
|
|
395
|
+
const MAX_SPAWNS: readonly number[] = [4, 3, 2];
|
|
396
|
+
|
|
397
|
+
/** Stable empty seeds: a fresh `[]` each render would retrigger the effect that adopts them. */
|
|
398
|
+
const NO_GROUPS: readonly GanttGroup[] = [];
|
|
399
|
+
const NO_LANES: readonly GanttLane[] = [];
|
|
400
|
+
const NO_MARKERS: readonly GanttMarker[] = [];
|
|
401
|
+
|
|
402
|
+
type EventStreamOptions = {
|
|
403
|
+
/** How often an event arrives, in milliseconds. The stream is static without it. */
|
|
404
|
+
interval?: number;
|
|
405
|
+
/** The supervisor lane: what spawns the first children, and what they report back to. */
|
|
406
|
+
laneId: string;
|
|
407
|
+
/** How far each arrival advances the clock — the axis reads the instant, `interval` is real time. */
|
|
408
|
+
step: number;
|
|
409
|
+
/** The supervisor opens another child every so many arrivals. */
|
|
410
|
+
spawnEvery?: number;
|
|
411
|
+
/** How many events of its own a lane runs before it is finished, inclusive. */
|
|
412
|
+
childEvents?: readonly [number, number];
|
|
413
|
+
/** Percent chance a direct child delegates rather than works; halved at each level below it. */
|
|
414
|
+
nestedChance?: number;
|
|
415
|
+
/**
|
|
416
|
+
* How many lanes may be opened at each depth, deepest last. This is what makes the run finite: the
|
|
417
|
+
* budget is spent, everything in flight reports back, and the chart comes to rest — a demo that
|
|
418
|
+
* grows for as long as it is left open never shows what finishing looks like.
|
|
419
|
+
*/
|
|
420
|
+
maxSpawns?: readonly number[];
|
|
166
421
|
};
|
|
167
422
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
423
|
+
/** A lane still in flight: what it reports to, how far through its own work it is, and whether it has any left. */
|
|
424
|
+
type ActiveLane = {
|
|
425
|
+
laneId: string;
|
|
426
|
+
/** The lane that opened it, and the lane its result returns into. */
|
|
427
|
+
parentId: string;
|
|
428
|
+
depth: number;
|
|
429
|
+
seen: number;
|
|
430
|
+
budget: number;
|
|
431
|
+
/** Out of work of its own, but holding children it has to hear back from first. */
|
|
432
|
+
waiting: boolean;
|
|
433
|
+
};
|
|
175
434
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
435
|
+
type StreamState = {
|
|
436
|
+
groups: GanttGroup[];
|
|
437
|
+
lanes: GanttLane[];
|
|
438
|
+
markers: GanttMarker[];
|
|
439
|
+
active: ActiveLane[];
|
|
440
|
+
/** Lanes opened so far at each depth, against `maxSpawns`. */
|
|
441
|
+
opened: number[];
|
|
442
|
+
/** Nothing left to hand out and nothing left in flight: the chart holds still from here. */
|
|
443
|
+
complete: boolean;
|
|
444
|
+
/** Which lane in flight gets the next event, so several progress at once rather than in turn. */
|
|
445
|
+
turn: number;
|
|
446
|
+
tick: number;
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* The seed, plus one event every `interval` — the only moving part any of these stories has.
|
|
451
|
+
*
|
|
452
|
+
* The supervisor fans out: it opens a child every `spawnEvery` arrivals and keeps every one, so
|
|
453
|
+
* several run at once. A child spends `childEvents` events and then reports back — except that it
|
|
454
|
+
* may spend one of them delegating instead, less likely the deeper it already is, so the cascade
|
|
455
|
+
* thins out rather than running away.
|
|
456
|
+
*
|
|
457
|
+
* A lane that has delegated does not report back until its own children have: it goes `blocked`
|
|
458
|
+
* when its work runs out and finishes only once the last of them answers, which is the order the
|
|
459
|
+
* results actually arrive in. Without that a parent would close before the children it is waiting
|
|
460
|
+
* on, and its return edge would precede theirs.
|
|
461
|
+
*
|
|
462
|
+
* It returns the clock as well: on the time axis a chart cannot extend past `now`, so a live story
|
|
463
|
+
* has to carry one, while the unit axis makes its own room and ignores it.
|
|
464
|
+
*/
|
|
465
|
+
const useEventStream = (
|
|
466
|
+
seedGroups: readonly GanttGroup[],
|
|
467
|
+
seedLanes: readonly GanttLane[],
|
|
468
|
+
seedMarkers: readonly GanttMarker[],
|
|
469
|
+
{
|
|
470
|
+
interval,
|
|
471
|
+
laneId,
|
|
472
|
+
step,
|
|
473
|
+
spawnEvery,
|
|
474
|
+
childEvents = CHILD_EVENTS,
|
|
475
|
+
nestedChance = 30,
|
|
476
|
+
maxSpawns = MAX_SPAWNS,
|
|
477
|
+
}: EventStreamOptions,
|
|
478
|
+
): { groups: GanttGroup[]; lanes: GanttLane[]; markers: GanttMarker[]; now: number } => {
|
|
479
|
+
const seed = (): StreamState => ({
|
|
480
|
+
groups: [...seedGroups],
|
|
481
|
+
lanes: [...seedLanes],
|
|
482
|
+
markers: [...seedMarkers],
|
|
483
|
+
active: [],
|
|
484
|
+
opened: maxSpawns.map(() => 0),
|
|
485
|
+
complete: false,
|
|
486
|
+
turn: 0,
|
|
487
|
+
tick: 0,
|
|
488
|
+
});
|
|
489
|
+
const [state, setState] = useState(seed);
|
|
490
|
+
useEffect(() => {
|
|
491
|
+
setState(seed());
|
|
492
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
493
|
+
}, [seedGroups, seedLanes, seedMarkers, laneId]);
|
|
182
494
|
|
|
183
|
-
/** Appends a tool marker to the running sub-agent every tick, so its box grows with `now`. */
|
|
184
|
-
const LiveStory = (props: StoryArgs) => {
|
|
185
|
-
const [now, setNow] = useState(T0 + 10 * MINUTE);
|
|
186
|
-
const [live, setLive] = useState<GanttMarker[]>([...(props.markers ?? [])]);
|
|
187
495
|
useEffect(() => {
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
496
|
+
if (!interval) {
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
const [minEvents, maxEvents] = childEvents;
|
|
500
|
+
const timer = setInterval(() => {
|
|
501
|
+
setState(({ groups, lanes, markers, active, opened, complete, turn, tick }) => {
|
|
502
|
+
if (complete) {
|
|
503
|
+
return { groups, lanes, markers, active, opened, complete, turn, tick };
|
|
504
|
+
}
|
|
505
|
+
const base = Math.max(T0, ...markers.map((marker) => marker.timestamp));
|
|
506
|
+
// Every marker a tick emits gets an instant of its own: a hand-over, the first event of what
|
|
507
|
+
// it opened, and an answer are three events, and the axis counts events.
|
|
508
|
+
let slot = 0;
|
|
509
|
+
const next = (): number => base + ++slot * step;
|
|
510
|
+
|
|
511
|
+
const nextGroups = [...groups];
|
|
512
|
+
const nextLanes = [...lanes];
|
|
513
|
+
const nextMarkers = [...markers];
|
|
514
|
+
let nextActive = [...active];
|
|
515
|
+
const nextOpened = [...opened];
|
|
516
|
+
/** Depth is one-based, and a depth past the budget's length is past the deepest allowed. */
|
|
517
|
+
const canOpen = (depth: number): boolean =>
|
|
518
|
+
depth <= maxSpawns.length && nextOpened[depth - 1] < maxSpawns[depth - 1];
|
|
519
|
+
|
|
520
|
+
const indexOf = (id: string): number => nextLanes.findIndex((lane) => lane.id === id);
|
|
521
|
+
const patch = (id: string, change: Partial<GanttLane>): void => {
|
|
522
|
+
const index = indexOf(id);
|
|
523
|
+
if (index >= 0) {
|
|
524
|
+
nextLanes[index] = { ...nextLanes[index], ...change };
|
|
525
|
+
}
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
const open = (parentLaneId: string, depth: number): void => {
|
|
529
|
+
const index = nextLanes.length;
|
|
530
|
+
const child = `lane:${index}`;
|
|
531
|
+
const band = `group:${index}`;
|
|
532
|
+
const parentBand = nextLanes[indexOf(parentLaneId)]?.groupId;
|
|
533
|
+
const spawn: GanttMarker = {
|
|
534
|
+
id: `spawn:${child}`,
|
|
535
|
+
laneId: parentLaneId,
|
|
536
|
+
kind: 'delegation',
|
|
537
|
+
timestamp: next(),
|
|
538
|
+
label: `Opened ${child}`,
|
|
539
|
+
};
|
|
540
|
+
// A step after the spawn: a child's first event is its process starting, which is never
|
|
541
|
+
// simultaneous with the node that asked for it.
|
|
542
|
+
const start = next();
|
|
543
|
+
nextGroups.push({ id: band, ...(parentBand ? { parentId: parentBand } : {}) });
|
|
544
|
+
nextLanes.push({
|
|
545
|
+
id: child,
|
|
546
|
+
label: `Process ${index}`,
|
|
547
|
+
status: 'running',
|
|
548
|
+
groupId: band,
|
|
549
|
+
segments: [{ start }],
|
|
550
|
+
openedFrom: { laneId: parentLaneId, markerId: spawn.id },
|
|
551
|
+
});
|
|
552
|
+
nextMarkers.push(spawn, {
|
|
553
|
+
id: `${child}:0`,
|
|
554
|
+
laneId: child,
|
|
555
|
+
kind: 'operation',
|
|
556
|
+
timestamp: start,
|
|
557
|
+
label: 'Run Instructions',
|
|
558
|
+
});
|
|
559
|
+
nextOpened[depth - 1] += 1;
|
|
560
|
+
nextActive.push({
|
|
561
|
+
laneId: child,
|
|
562
|
+
parentId: parentLaneId,
|
|
563
|
+
depth,
|
|
564
|
+
seen: 1,
|
|
565
|
+
budget: random.number.int({ min: minEvents, max: maxEvents }),
|
|
566
|
+
waiting: false,
|
|
567
|
+
});
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
const finish = (id: string, end: number): void => {
|
|
571
|
+
const lane = nextActive.find((candidate) => candidate.laneId === id);
|
|
572
|
+
if (!lane) {
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
const returned: GanttMarker = {
|
|
576
|
+
id: `return:${id}`,
|
|
577
|
+
laneId: lane.parentId,
|
|
578
|
+
kind: 'delegation',
|
|
579
|
+
timestamp: next(),
|
|
580
|
+
label: `Returned: ${id}`,
|
|
581
|
+
};
|
|
582
|
+
nextMarkers.push(returned);
|
|
583
|
+
patch(id, {
|
|
584
|
+
status: 'done',
|
|
585
|
+
segments: [{ start: nextLanes[indexOf(id)]?.segments?.[0]?.start ?? end, end }],
|
|
586
|
+
closedInto: { laneId: lane.parentId, markerId: returned.id },
|
|
587
|
+
});
|
|
588
|
+
nextActive = nextActive.filter((candidate) => candidate.laneId !== id);
|
|
589
|
+
// The parent may have been waiting on this one alone, in which case it can answer now too —
|
|
590
|
+
// which is how a finished branch unwinds from the leaf up.
|
|
591
|
+
const parent = nextActive.find((candidate) => candidate.laneId === lane.parentId);
|
|
592
|
+
if (parent?.waiting && !nextActive.some((candidate) => candidate.parentId === parent.laneId)) {
|
|
593
|
+
finish(parent.laneId, returned.timestamp);
|
|
594
|
+
}
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
const settle = (done = false): StreamState => ({
|
|
598
|
+
groups: nextGroups,
|
|
599
|
+
lanes: nextLanes,
|
|
600
|
+
markers: nextMarkers,
|
|
601
|
+
active: nextActive,
|
|
602
|
+
opened: nextOpened,
|
|
603
|
+
complete: done,
|
|
604
|
+
turn,
|
|
605
|
+
tick: tick + 1,
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
if (spawnEvery && tick % spawnEvery === 0 && canOpen(1)) {
|
|
609
|
+
open(laneId, 1);
|
|
610
|
+
return settle();
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// A lane that is only waiting has nothing to contribute; if every one of them is, the
|
|
614
|
+
// supervisor fills the tick so the chart keeps moving.
|
|
615
|
+
const working = nextActive.filter((candidate) => !candidate.waiting);
|
|
616
|
+
if (working.length === 0) {
|
|
617
|
+
// Everything handed out has reported back and there is nothing left to hand out: the
|
|
618
|
+
// supervisor closes its own span, and the run is over.
|
|
619
|
+
if (nextActive.length === 0 && !canOpen(1)) {
|
|
620
|
+
const end = next();
|
|
621
|
+
nextMarkers.push({ id: `live:${tick}`, laneId, kind: 'request', timestamp: end, label: 'Request success' });
|
|
622
|
+
patch(laneId, {
|
|
623
|
+
status: 'done',
|
|
624
|
+
segments: [{ start: nextLanes[indexOf(laneId)]?.segments?.[0]?.start ?? end, end }],
|
|
625
|
+
});
|
|
626
|
+
return settle(true);
|
|
627
|
+
}
|
|
628
|
+
nextMarkers.push({
|
|
629
|
+
id: `live:${tick}`,
|
|
630
|
+
laneId,
|
|
196
631
|
kind: 'tool',
|
|
197
|
-
timestamp: next,
|
|
632
|
+
timestamp: next(),
|
|
198
633
|
label: random.lorem.word(),
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
|
|
634
|
+
});
|
|
635
|
+
return settle();
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const lane = working[turn % working.length];
|
|
639
|
+
const seen = lane.seen + 1;
|
|
640
|
+
// Halved at each level, so a child delegates readily and its own children rarely.
|
|
641
|
+
const chance = Math.round(nestedChance / 2 ** (lane.depth - 1));
|
|
642
|
+
const delegates = canOpen(lane.depth + 1) && random.number.int({ min: 1, max: 100 }) <= chance;
|
|
643
|
+
if (delegates) {
|
|
644
|
+
open(lane.laneId, lane.depth + 1);
|
|
645
|
+
} else {
|
|
646
|
+
nextMarkers.push({
|
|
647
|
+
id: `${lane.laneId}:${seen}`,
|
|
648
|
+
laneId: lane.laneId,
|
|
649
|
+
kind: 'tool',
|
|
650
|
+
timestamp: next(),
|
|
651
|
+
label: random.lorem.word(),
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const end = nextMarkers[nextMarkers.length - 1].timestamp;
|
|
656
|
+
nextActive = nextActive.map((candidate) =>
|
|
657
|
+
candidate.laneId === lane.laneId ? { ...candidate, seen } : candidate,
|
|
658
|
+
);
|
|
659
|
+
if (seen >= lane.budget) {
|
|
660
|
+
if (nextActive.some((candidate) => candidate.parentId === lane.laneId)) {
|
|
661
|
+
nextActive = nextActive.map((candidate) =>
|
|
662
|
+
candidate.laneId === lane.laneId ? { ...candidate, waiting: true } : candidate,
|
|
663
|
+
);
|
|
664
|
+
patch(lane.laneId, { status: 'blocked' });
|
|
665
|
+
} else {
|
|
666
|
+
finish(lane.laneId, end);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
return { ...settle(), turn: turn + 1 };
|
|
202
670
|
});
|
|
203
|
-
},
|
|
204
|
-
return () => clearInterval(
|
|
205
|
-
}, []);
|
|
206
|
-
|
|
207
|
-
return
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
671
|
+
}, interval);
|
|
672
|
+
return () => clearInterval(timer);
|
|
673
|
+
}, [interval, step, spawnEvery, laneId, childEvents, nestedChance, maxSpawns]);
|
|
674
|
+
|
|
675
|
+
return {
|
|
676
|
+
groups: state.groups,
|
|
677
|
+
lanes: state.lanes,
|
|
678
|
+
markers: state.markers,
|
|
679
|
+
now: Math.max(T0, ...state.markers.map((marker) => marker.timestamp)),
|
|
680
|
+
};
|
|
681
|
+
};
|
|
682
|
+
|
|
683
|
+
type StoryArgs = GanttData &
|
|
684
|
+
Partial<EventStreamOptions> & {
|
|
685
|
+
/** Render the drawing alone, as a host that already lists the lanes does. */
|
|
686
|
+
chartOnly?: boolean;
|
|
687
|
+
/** Show the data the chart was drawn from beneath it. */
|
|
688
|
+
inspect?: boolean;
|
|
689
|
+
};
|
|
690
|
+
|
|
691
|
+
// Every stream option is destructured, not just the ones in use: what stays in `...data` is spread
|
|
692
|
+
// onto `Gantt.Root` and forwarded to a DOM element, so a missed one both fails to reach the stream
|
|
693
|
+
// and shows up as an unknown attribute.
|
|
694
|
+
const DefaultStory = ({
|
|
695
|
+
chartOnly,
|
|
696
|
+
inspect,
|
|
697
|
+
interval,
|
|
698
|
+
laneId = 'c',
|
|
699
|
+
step = 0.5 * MINUTE,
|
|
700
|
+
spawnEvery,
|
|
701
|
+
childEvents,
|
|
702
|
+
nestedChance,
|
|
703
|
+
maxSpawns,
|
|
704
|
+
...data
|
|
705
|
+
}: StoryArgs) => {
|
|
706
|
+
const stream = useEventStream(data.groups ?? NO_GROUPS, data.lanes ?? NO_LANES, data.markers ?? NO_MARKERS, {
|
|
707
|
+
interval,
|
|
708
|
+
laneId,
|
|
709
|
+
step,
|
|
710
|
+
spawnEvery,
|
|
711
|
+
childEvents,
|
|
712
|
+
nestedChance,
|
|
713
|
+
maxSpawns,
|
|
714
|
+
});
|
|
715
|
+
// A live chart on the time axis has to be given room ahead of its newest event, and the range it
|
|
716
|
+
// was handed ends before that event ever arrives; the unit axis makes its own room.
|
|
717
|
+
const live = interval !== undefined && data.axis !== 'unit';
|
|
718
|
+
const range = live && data.range ? { start: data.range.start, end: stream.now + step } : data.range;
|
|
719
|
+
const now = live ? stream.now : data.now;
|
|
720
|
+
|
|
721
|
+
const chart = (
|
|
722
|
+
<Gantt.Root
|
|
723
|
+
{...data}
|
|
724
|
+
groups={stream.groups}
|
|
725
|
+
lanes={stream.lanes}
|
|
726
|
+
markers={stream.markers}
|
|
727
|
+
range={range}
|
|
728
|
+
now={now}
|
|
729
|
+
classNames='p-4'
|
|
730
|
+
>
|
|
731
|
+
{!chartOnly && (
|
|
732
|
+
<Gantt.Legend>
|
|
733
|
+
<Gantt.AxisToggle />
|
|
734
|
+
<Gantt.LegendToggle />
|
|
735
|
+
</Gantt.Legend>
|
|
736
|
+
)}
|
|
737
|
+
<Gantt.Chart />
|
|
738
|
+
</Gantt.Root>
|
|
739
|
+
);
|
|
740
|
+
|
|
741
|
+
return inspect ? (
|
|
742
|
+
<Layout chart={chart} data={{ groups: stream.groups, lanes: stream.lanes, markers: stream.markers, range, now }} />
|
|
743
|
+
) : (
|
|
744
|
+
chart
|
|
212
745
|
);
|
|
213
746
|
};
|
|
214
747
|
|
|
215
748
|
const meta = {
|
|
216
|
-
title: 'ui/react-ui-
|
|
749
|
+
title: 'ui/react-ui-trace/Gantt',
|
|
217
750
|
render: DefaultStory,
|
|
218
751
|
decorators: [withTheme(), withLayout({ layout: 'fullscreen' })],
|
|
219
752
|
args: {
|
|
753
|
+
groups,
|
|
220
754
|
lanes,
|
|
221
755
|
markers,
|
|
222
756
|
now: T0 + 10 * MINUTE,
|
|
223
757
|
range: { start: T0, end: T0 + 11 * MINUTE },
|
|
758
|
+
inspect: true,
|
|
224
759
|
onLaneSelect: (lane: GanttLane) => console.log('lane', lane),
|
|
225
760
|
onMarkerSelect: (marker: GanttMarker) => console.log('marker', marker),
|
|
226
761
|
},
|
|
@@ -233,9 +768,292 @@ type Story = StoryObj<typeof meta>;
|
|
|
233
768
|
export const Default: Story = {};
|
|
234
769
|
|
|
235
770
|
export const ChartOnly: Story = {
|
|
236
|
-
args: {
|
|
771
|
+
args: {
|
|
772
|
+
chartOnly: true,
|
|
773
|
+
},
|
|
237
774
|
};
|
|
238
775
|
|
|
776
|
+
/** The time axis under a stream: every arrival widens the range, so the whole history shifts left. */
|
|
239
777
|
export const Live: Story = {
|
|
240
|
-
|
|
778
|
+
args: {
|
|
779
|
+
axis: 'time',
|
|
780
|
+
interval: 1_000,
|
|
781
|
+
},
|
|
782
|
+
};
|
|
783
|
+
|
|
784
|
+
/** The same run on the fitted axis, for contrast with {@link Live}: gaps argue for space by duration. */
|
|
785
|
+
export const TimeAxis: Story = {
|
|
786
|
+
args: {
|
|
787
|
+
axis: 'time',
|
|
788
|
+
},
|
|
789
|
+
};
|
|
790
|
+
|
|
791
|
+
/** The primitives alone: one lane, one event a second, each opening out of the one before it. */
|
|
792
|
+
export const SingleLane: Story = {
|
|
793
|
+
args: {
|
|
794
|
+
groups: singleLaneGroups,
|
|
795
|
+
lanes: singleLaneLanes,
|
|
796
|
+
markers: singleLaneMarkers,
|
|
797
|
+
axis: 'unit',
|
|
798
|
+
animate: true,
|
|
799
|
+
interval: 1_000,
|
|
800
|
+
laneId: 'lane',
|
|
801
|
+
step: SECOND,
|
|
802
|
+
range: undefined,
|
|
803
|
+
now: undefined,
|
|
804
|
+
inspect: false,
|
|
805
|
+
},
|
|
806
|
+
};
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* The supervisor fans out: every third arrival opens another child, and a child may delegate again
|
|
810
|
+
* rather than work — less likely the deeper it already is. Each lane runs a handful of events,
|
|
811
|
+
* reports back into a node of its own, and waits on anything it opened before it does.
|
|
812
|
+
*
|
|
813
|
+
* The run finishes: `maxSpawns` bounds how many lanes each depth may open, so the branches unwind
|
|
814
|
+
* from the leaves up, the supervisor closes its own span, and the chart comes to rest.
|
|
815
|
+
*/
|
|
816
|
+
export const Delegation: Story = {
|
|
817
|
+
args: {
|
|
818
|
+
...SingleLane.args,
|
|
819
|
+
lanes: supervisorLanes,
|
|
820
|
+
interval: 900,
|
|
821
|
+
spawnEvery: 3,
|
|
822
|
+
childEvents: [2, 5],
|
|
823
|
+
nestedChance: 30,
|
|
824
|
+
maxSpawns: [4, 3, 2],
|
|
825
|
+
},
|
|
826
|
+
};
|
|
827
|
+
|
|
828
|
+
//
|
|
829
|
+
// Static: one shape of the model each, nothing moving.
|
|
830
|
+
//
|
|
831
|
+
|
|
832
|
+
/** One band, one lane, six events. */
|
|
833
|
+
export const OneLane: Story = {
|
|
834
|
+
args: {
|
|
835
|
+
groups: oneLaneGroups,
|
|
836
|
+
lanes: oneLaneLanes,
|
|
837
|
+
markers: oneLaneMarkers,
|
|
838
|
+
axis: 'unit',
|
|
839
|
+
range: undefined,
|
|
840
|
+
now: undefined,
|
|
841
|
+
},
|
|
842
|
+
};
|
|
843
|
+
|
|
844
|
+
/** One band, four lanes over different spans — one worked twice, one still open. */
|
|
845
|
+
export const ManyLanes: Story = {
|
|
846
|
+
args: {
|
|
847
|
+
groups: manyLaneGroups,
|
|
848
|
+
lanes: manyLaneLanes,
|
|
849
|
+
markers: manyLaneMarkers,
|
|
850
|
+
axis: 'unit',
|
|
851
|
+
range: undefined,
|
|
852
|
+
now: undefined,
|
|
853
|
+
},
|
|
854
|
+
};
|
|
855
|
+
|
|
856
|
+
/** A chart wider than its viewport opens scrolled to its newest events, not to the start of the history. */
|
|
857
|
+
export const TestOpensAtNewest: Story = {
|
|
858
|
+
args: {
|
|
859
|
+
...ManyLanes.args,
|
|
860
|
+
chartOnly: true,
|
|
861
|
+
inspect: false,
|
|
862
|
+
},
|
|
863
|
+
decorators: [(Story) => <div className='w-[240px]'>{Story()}</div>],
|
|
864
|
+
play: async ({ canvasElement }) => {
|
|
865
|
+
const viewport = await waitFor(
|
|
866
|
+
() => {
|
|
867
|
+
const element = [...canvasElement.querySelectorAll<HTMLElement>('*')].find(
|
|
868
|
+
(candidate) =>
|
|
869
|
+
candidate.scrollWidth > candidate.clientWidth + 1 && getComputedStyle(candidate).overflowX !== 'visible',
|
|
870
|
+
);
|
|
871
|
+
if (!element) {
|
|
872
|
+
throw new Error('No horizontally scrolling viewport.');
|
|
873
|
+
}
|
|
874
|
+
return element;
|
|
875
|
+
},
|
|
876
|
+
{ timeout: 10_000 },
|
|
877
|
+
);
|
|
878
|
+
await expect(viewport.scrollWidth - viewport.clientWidth - viewport.scrollLeft).toBeLessThanOrEqual(1);
|
|
879
|
+
},
|
|
880
|
+
};
|
|
881
|
+
|
|
882
|
+
/** Beside the legend column the chart takes the remaining width, rather than collapsing to nothing. */
|
|
883
|
+
export const TestChartFillsRow: Story = {
|
|
884
|
+
args: { ...ManyLanes.args, inspect: false },
|
|
885
|
+
decorators: [(Story) => <div className='w-[800px]'>{Story()}</div>],
|
|
886
|
+
play: async ({ canvasElement }) => {
|
|
887
|
+
await waitFor(
|
|
888
|
+
async () => {
|
|
889
|
+
const chart = canvasElement.querySelector<SVGSVGElement>('[data-testid="gantt.chart"]');
|
|
890
|
+
if (!chart) {
|
|
891
|
+
throw new Error('No chart.');
|
|
892
|
+
}
|
|
893
|
+
await expect(chart.getBoundingClientRect().width).toBeGreaterThan(200);
|
|
894
|
+
},
|
|
895
|
+
{ timeout: 10_000 },
|
|
896
|
+
);
|
|
897
|
+
},
|
|
898
|
+
};
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* The legend's toggle switches its column between the lanes' titles and their stats, and hovering a
|
|
902
|
+
* title shows the stats it is not showing.
|
|
903
|
+
*
|
|
904
|
+
* 1. Hover the first lane's title: its card shows the lane's token count.
|
|
905
|
+
* 2. Press the legend toggle: the column shows stats instead of titles.
|
|
906
|
+
* 3. Press it again: the titles are back.
|
|
907
|
+
*/
|
|
908
|
+
export const TestLegendStats: Story = {
|
|
909
|
+
args: { inspect: false },
|
|
910
|
+
play: async ({ canvasElement }) => {
|
|
911
|
+
const { userEvent } = await import('vitest/browser');
|
|
912
|
+
const title = await waitFor(
|
|
913
|
+
() => {
|
|
914
|
+
const element = canvasElement.querySelector<HTMLElement>('[data-testid="gantt.legend.title"]');
|
|
915
|
+
if (!element) {
|
|
916
|
+
throw new Error('No legend title.');
|
|
917
|
+
}
|
|
918
|
+
return element;
|
|
919
|
+
},
|
|
920
|
+
{ timeout: 10_000 },
|
|
921
|
+
);
|
|
922
|
+
await expect(title).toHaveTextContent('Process A — Plan the release');
|
|
923
|
+
|
|
924
|
+
await userEvent.hover(title);
|
|
925
|
+
await waitFor(
|
|
926
|
+
() => expect(document.querySelector('[data-testid="gantt.legend.card"]')).toHaveTextContent('15.5k'),
|
|
927
|
+
{ timeout: 5_000 },
|
|
928
|
+
);
|
|
929
|
+
await userEvent.unhover(title);
|
|
930
|
+
|
|
931
|
+
const toggle = canvasElement.querySelector<HTMLElement>('[data-testid="gantt.legendToggle"]');
|
|
932
|
+
if (!toggle) {
|
|
933
|
+
throw new Error('No legend toggle.');
|
|
934
|
+
}
|
|
935
|
+
await userEvent.click(toggle);
|
|
936
|
+
await waitFor(async () => {
|
|
937
|
+
await expect(canvasElement.querySelector('[data-testid="gantt.legend.title"]')).toBeNull();
|
|
938
|
+
await expect(canvasElement.querySelector('[data-testid="gantt.legend.stats"]')).toHaveTextContent('15.5k');
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
await userEvent.click(toggle);
|
|
942
|
+
await waitFor(() => expect(canvasElement.querySelector('[data-testid="gantt.legend.stats"]')).toBeNull());
|
|
943
|
+
},
|
|
944
|
+
};
|
|
945
|
+
|
|
946
|
+
/** In a host taller than its lanes the chart fills it, so the horizontal scroll bar is at the host's foot. */
|
|
947
|
+
export const TestScrollerAtFoot: Story = {
|
|
948
|
+
args: { ...ManyLanes.args, inspect: false },
|
|
949
|
+
decorators: [
|
|
950
|
+
(Story) => (
|
|
951
|
+
<div className='h-[600px] w-[400px] flex flex-col' data-testid='host'>
|
|
952
|
+
{Story()}
|
|
953
|
+
</div>
|
|
954
|
+
),
|
|
955
|
+
],
|
|
956
|
+
play: async ({ canvasElement }) => {
|
|
957
|
+
await waitFor(
|
|
958
|
+
async () => {
|
|
959
|
+
const chart = canvasElement.querySelector<SVGSVGElement>('[data-testid="gantt.chart"]');
|
|
960
|
+
const viewport = chart?.closest<HTMLElement>('[data-part="viewport"]');
|
|
961
|
+
// The frame, not the viewport: the overlay bar is pinned to the frame's foot, whatever padding the host gives it.
|
|
962
|
+
const frame = viewport?.closest<HTMLElement>('[data-part="root"]');
|
|
963
|
+
const host = canvasElement.querySelector<HTMLElement>('[data-testid="host"]');
|
|
964
|
+
if (!chart || !viewport || !frame || !host) {
|
|
965
|
+
throw new Error('No chart.');
|
|
966
|
+
}
|
|
967
|
+
// Wider than the frame, so there is a horizontal scroll at all.
|
|
968
|
+
await expect(viewport.scrollWidth).toBeGreaterThan(viewport.clientWidth);
|
|
969
|
+
await expect(
|
|
970
|
+
Math.abs(host.getBoundingClientRect().bottom - frame.getBoundingClientRect().bottom),
|
|
971
|
+
).toBeLessThanOrEqual(4);
|
|
972
|
+
// The lanes stop well short of the foot: the frame, not its content, reaches it.
|
|
973
|
+
await expect(chart.getBoundingClientRect().bottom).toBeLessThan(viewport.getBoundingClientRect().bottom - 100);
|
|
974
|
+
// The bar spans only the chart: its thumb starts right of the sticky legend, which does not scroll.
|
|
975
|
+
const thumb = frame.querySelector<HTMLElement>('[data-scroll-thumb="horizontal"]');
|
|
976
|
+
if (!thumb) {
|
|
977
|
+
throw new Error('No horizontal thumb.');
|
|
978
|
+
}
|
|
979
|
+
await expect(thumb.getBoundingClientRect().left).toBeGreaterThanOrEqual(chart.getBoundingClientRect().left);
|
|
980
|
+
},
|
|
981
|
+
{ timeout: 10_000 },
|
|
982
|
+
);
|
|
983
|
+
},
|
|
984
|
+
};
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* The drawing is one keyboard stop that walks its nodes.
|
|
988
|
+
*
|
|
989
|
+
* 1. Click the first node of the second lane: the chart takes focus and that node is current.
|
|
990
|
+
* 2. ArrowRight: the next node along the same lane.
|
|
991
|
+
* 3. ArrowDown: the nearest node on the lane below.
|
|
992
|
+
* 4. Space opens the current node's card; Space again closes it.
|
|
993
|
+
*/
|
|
994
|
+
export const TestKeyboardNodes: Story = {
|
|
995
|
+
args: { ...ManyLanes.args, inspect: false },
|
|
996
|
+
play: async ({ canvasElement }) => {
|
|
997
|
+
const { userEvent } = await import('vitest/browser');
|
|
998
|
+
const chart = await waitFor(
|
|
999
|
+
() => {
|
|
1000
|
+
const element = canvasElement.querySelector<SVGSVGElement>('[data-testid="gantt.chart"]');
|
|
1001
|
+
if (!element) {
|
|
1002
|
+
throw new Error('No chart.');
|
|
1003
|
+
}
|
|
1004
|
+
return element;
|
|
1005
|
+
},
|
|
1006
|
+
{ timeout: 10_000 },
|
|
1007
|
+
);
|
|
1008
|
+
const currentNode = () => {
|
|
1009
|
+
const id = chart.getAttribute('aria-activedescendant');
|
|
1010
|
+
const node = id ? document.getElementById(id) : null;
|
|
1011
|
+
return { marker: node?.getAttribute('data-marker-id'), lane: node?.getAttribute('data-lane-id') };
|
|
1012
|
+
};
|
|
1013
|
+
|
|
1014
|
+
const start = chart.querySelector<SVGCircleElement>('circle[data-marker-id="long:0"]');
|
|
1015
|
+
if (!start) {
|
|
1016
|
+
throw new Error('No node.');
|
|
1017
|
+
}
|
|
1018
|
+
await userEvent.click(start);
|
|
1019
|
+
await expect(chart).toHaveFocus();
|
|
1020
|
+
await waitFor(() => expect(currentNode()).toEqual({ marker: 'long:0', lane: 'long' }));
|
|
1021
|
+
|
|
1022
|
+
await userEvent.keyboard('{ArrowRight}');
|
|
1023
|
+
await waitFor(() => expect(currentNode()).toEqual({ marker: 'long:1', lane: 'long' }));
|
|
1024
|
+
|
|
1025
|
+
await userEvent.keyboard('{ArrowDown}');
|
|
1026
|
+
await waitFor(() => expect(currentNode().lane).toBe('twice'));
|
|
1027
|
+
|
|
1028
|
+
// Pointer off the drawing, so only the keyboard decides whether a card is open.
|
|
1029
|
+
await userEvent.unhover(start);
|
|
1030
|
+
await userEvent.keyboard(' ');
|
|
1031
|
+
await waitFor(() => expect(document.querySelector('[data-testid="gantt.markerCard"]')).not.toBeNull());
|
|
1032
|
+
await userEvent.keyboard(' ');
|
|
1033
|
+
await waitFor(() => expect(document.querySelector('[data-testid="gantt.markerCard"]')).toBeNull());
|
|
1034
|
+
},
|
|
1035
|
+
};
|
|
1036
|
+
|
|
1037
|
+
/** One band whose lanes branch off one another: `parentId` indents, `openedFrom` and `closedInto` connect. */
|
|
1038
|
+
export const Branching: Story = {
|
|
1039
|
+
args: {
|
|
1040
|
+
groups: branchingGroups,
|
|
1041
|
+
lanes: branchingLanes,
|
|
1042
|
+
markers: branchingMarkers,
|
|
1043
|
+
axis: 'unit',
|
|
1044
|
+
range: undefined,
|
|
1045
|
+
now: undefined,
|
|
1046
|
+
},
|
|
1047
|
+
};
|
|
1048
|
+
|
|
1049
|
+
/** Four bands: two nested under the first, one unrelated. */
|
|
1050
|
+
export const ManyGroups: Story = {
|
|
1051
|
+
args: {
|
|
1052
|
+
groups: manyGroupGroups,
|
|
1053
|
+
lanes: manyGroupLanes,
|
|
1054
|
+
markers: manyGroupMarkers,
|
|
1055
|
+
axis: 'unit',
|
|
1056
|
+
range: undefined,
|
|
1057
|
+
now: undefined,
|
|
1058
|
+
},
|
|
241
1059
|
};
|