@dxos/react-ui-trace 0.11.1 → 0.13.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 +3 -3
  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 +3 -2
  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 +5 -6
  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 +567 -19
  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 +3 -2
  111. package/src/testing/test-trace.ts +2 -2
  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
@@ -3,183 +3,267 @@
3
3
  //
4
4
 
5
5
  import { format } from 'date-fns';
6
- import React, { type KeyboardEvent, type ReactNode, forwardRef, useEffect, useMemo, useRef, useState } from 'react';
6
+ import React, {
7
+ Fragment,
8
+ type KeyboardEvent,
9
+ type PropsWithChildren,
10
+ type ReactNode,
11
+ forwardRef,
12
+ useCallback,
13
+ useEffect,
14
+ useId,
15
+ useLayoutEffect,
16
+ useMemo,
17
+ useRef,
18
+ useState,
19
+ } from 'react';
7
20
 
8
21
  import { createContext, useComposedRefs } from '@dxos/react-hooks';
9
- import { HoverCard, type ThemedClassName, composable, composableProps } from '@dxos/react-ui';
10
- import { mx } from '@dxos/ui-theme';
11
- import { Unit } from '@dxos/util';
22
+ import * as Button from '@dxos/react-ui/Button';
23
+ import * as Hooks from '@dxos/react-ui/Hooks';
24
+ import * as HoverCard from '@dxos/react-ui/HoverCard';
25
+ import * as ScrollArea from '@dxos/react-ui/ScrollArea';
26
+ import * as Util from '@dxos/react-ui/Util';
27
+ import { type Hue, mx } from '@dxos/ui-theme';
12
28
 
13
- export type GanttLaneKind = 'session' | 'task';
29
+ import { translationKey } from '../../translations.ts';
30
+ import { type Band, type Row, orderRows } from './gantt-rows.ts';
31
+ import { type GanttAxis, type GanttScale, formatElapsed, timeScale, unitScale } from './gantt-scale.ts';
32
+ import { useEnter } from './useEnter.ts';
33
+ import { useRangeTween } from './useRangeTween.ts';
34
+
35
+ /**
36
+ * The chart's vocabulary is groups, lanes, segments and markers — deliberately not the vocabulary of
37
+ * whatever is being charted. A host maps its own nouns onto these (a process onto a group, a task
38
+ * onto a lane, a status change onto a marker) and the chart stays ignorant of what they mean.
39
+ */
14
40
  export type GanttLaneStatus = 'pending' | 'blocked' | 'running' | 'review' | 'done' | 'failed';
15
- export type GanttMarkerKind = 'request' | 'operation' | 'tool' | 'message' | 'error' | 'delegation' | 'task';
16
41
 
42
+ /**
43
+ * A band of related lanes, enclosed by one rectangle. Pure structure: the lanes inside carry the
44
+ * labels, so a group needs no name of its own.
45
+ */
46
+ export type GanttGroup = {
47
+ id: string;
48
+ /** Indents under another group; a nested group's band is drawn after its parent's. */
49
+ parentId?: string;
50
+ };
51
+
52
+ /** One stretch during which a lane was worked. `end` is absent while the stretch is still open. */
53
+ export type GanttSegment = {
54
+ start: number;
55
+ end?: number;
56
+ };
57
+
58
+ /** A fact the host wants beside a lane: the legend shows it in place of, or over, the lane's title, and never interprets it. */
59
+ export type GanttMeta = {
60
+ label: string;
61
+ title?: string;
62
+ };
63
+
64
+ /**
65
+ * One row. Its three references are deliberately distinct: `groupId` is which band it sits in,
66
+ * `parentId` is what it nests under, and `openedFrom` is what caused it. Collapsing any two of them
67
+ * into one edge is what left the previous model unable to hold a task hierarchy and a process tree
68
+ * at the same time.
69
+ */
17
70
  export type GanttLane = {
18
71
  id: string;
19
- kind: GanttLaneKind;
20
72
  label: string;
21
73
  status: GanttLaneStatus;
22
- start?: number;
23
- end?: number;
74
+ /** The band this lane sits in. */
75
+ groupId?: string;
76
+ /** Indents under another lane of the same group — nesting, nothing more. */
24
77
  parentId?: string;
25
- taskId?: string;
78
+ /** Every stretch this lane was worked. A lane with none exists but has no extent, so no bar. */
79
+ segments?: readonly GanttSegment[];
80
+ /** The node this lane opened out of, on another lane. */
81
+ openedFrom?: { laneId: string; markerId: string };
82
+ /**
83
+ * The node this lane's result came back into, on another lane — the mirror of `openedFrom`, and a
84
+ * separate fact: a lane can open out of another and end without ever reporting back.
85
+ */
86
+ closedInto?: { laneId: string; markerId: string };
87
+ /** Colours the legend dot, the bar, its nodes and its thread in place of the status colour. */
88
+ hue?: Hue;
89
+ /** Lanes this one waits on. */
26
90
  blockedOn?: readonly string[];
27
- delegatedFrom?: { laneId: string; markerId: string };
28
- tokens?: { input: number; output: number; total: number };
29
- toolCalls?: number;
91
+ meta?: readonly GanttMeta[];
30
92
  };
31
93
 
32
94
  export type GanttMarker = {
33
95
  id: string;
34
96
  laneId: string;
35
- kind: GanttMarkerKind;
97
+ /** The host's own word for what happened; shown in the hover card, never interpreted. */
98
+ kind?: string;
36
99
  timestamp: number;
37
100
  label: string;
38
101
  level?: 'info' | 'warn' | 'error';
102
+ /**
103
+ * A wait begun at this node and since ended — a question put to the reader and answered. Drawn as a
104
+ * dashed line along the lane to the node that ended it; a wait still open draws nothing, so the lane
105
+ * ends at the question until the answer lands.
106
+ */
107
+ wait?: { until: string };
108
+ /** Awaiting a response — an unanswered question. The node pings until the response arrives. */
109
+ pending?: boolean;
39
110
  };
40
111
 
41
- const ROW_HEIGHT = 28;
112
+ const PAD_X = 16;
113
+ const ROW_HEIGHT = 24;
42
114
  const HEADER_HEIGHT = 20;
43
- const PAD_X = 12;
44
- const NODE_RADIUS = 5;
45
- /** Upper bound on axis ticks; the count shrinks with the width so `HH:mm:ss` labels never overlap. */
46
- const MAX_TICKS = 5;
47
- const TICK_MIN_WIDTH = 72;
48
115
 
116
+ /** Pixels per event on the `unit` axis — wide enough that two adjacent nodes read as two. */
117
+ const UNIT_STEP = 32;
118
+ /** How long a newly arrived element takes to travel from where it came from to where it belongs. */
119
+ const ENTER_TRANSITION = 'duration-500 ease-out';
120
+
121
+ /** Within this of the live edge the chart keeps following it; past it the reader is reading history. */
122
+ const FOLLOW_SLACK = 4;
123
+ /**
124
+ * How long the chart takes to drift to the live edge. Longer than the enter transition on purpose:
125
+ * the arriving event settles into place first, and the window follows it rather than racing it.
126
+ */
127
+ const FOLLOW_DURATION = 600;
128
+
129
+ const NODE_RADIUS = 6;
49
130
  const BAR_HEIGHT = 15;
131
+ /** The thread through a lane's nodes: a hairline, so the nodes remain what the row reads as. */
132
+ const THREAD_HEIGHT = 1;
50
133
  /**
51
134
  * A bar reaches half its height past its first and last instant, so a node sitting on either one is
52
135
  * as far from the bar's end as it is from its top and bottom.
53
136
  */
54
137
  const BAR_OVERHANG = BAR_HEIGHT / 2;
55
- /** Gap between adjacent session rectangles, halved on each. */
138
+
139
+ /** Gap between adjacent band rectangles, halved on each. */
56
140
  const GROUP_INSET = 2;
57
141
  /** The rectangle's clearance around its bars, the same on every side, so its corners stay concentric with theirs. */
58
142
  const GROUP_PAD = ROW_HEIGHT / 2 - BAR_HEIGHT / 2 - GROUP_INSET;
59
143
  const GROUP_RADIUS = BAR_HEIGHT / 2 + GROUP_PAD;
60
- /** Radius of the delegation connector's bend from the drop into the child bar. */
144
+
145
+ /** Radius of the connector's bend from the drop into the lane it opens. */
61
146
  const BEND_RADIUS = BAR_HEIGHT / 2;
62
147
 
63
148
  /**
64
149
  * Bar, node, thread and label colours per status: nodes and the thread through them are the bar's
65
- * hue in a lighter shade.
150
+ * hue in a lighter shade, and `edge` — the live end of a lane still running — is the stronger shade
151
+ * the legend's own status dot uses, so the two read as the same statement about the lane.
152
+ */
153
+ /**
154
+ * A hued lane's colour: the theme's tag surface token, read as a variable because the hue is data, so
155
+ * the lane matches a `Tag` of the same hue.
66
156
  */
67
- const STATUS_COLOR: Record<GanttLaneStatus, { fill: string; node: string; thread: string; text: string }> = {
157
+ const hueColor = (hue: Hue): string => `var(--color-${hue}-surface)`;
158
+
159
+ /** The hue's strong shade, for a node that needs attention (a question, an error) within its lane. */
160
+ const hueStrongColor = (hue: Hue): string => `var(--color-${hue}-text)`;
161
+
162
+ const STATUS_COLOR: Record<
163
+ GanttLaneStatus,
164
+ { fill: string; node: string; edge: string; thread: string; text: string }
165
+ > = {
68
166
  pending: {
69
167
  fill: 'fill-neutral-500/40',
70
168
  node: 'fill-neutral-300',
169
+ edge: 'fill-neutral-400',
71
170
  thread: 'stroke-neutral-300',
72
171
  text: 'text-neutral-400',
73
172
  },
74
173
  blocked: {
75
174
  fill: 'fill-orange-500/40',
76
175
  node: 'fill-orange-300',
176
+ edge: 'fill-orange-500',
77
177
  thread: 'stroke-orange-300',
78
178
  text: 'text-orange-500',
79
179
  },
80
180
  running: {
81
181
  fill: 'fill-sky-500/40',
82
182
  node: 'fill-sky-300',
183
+ edge: 'fill-sky-500',
83
184
  thread: 'stroke-sky-300',
84
185
  text: 'text-sky-500',
85
186
  },
86
187
  review: {
87
188
  fill: 'fill-cyan-500/40',
88
189
  node: 'fill-cyan-300',
190
+ edge: 'fill-cyan-500',
89
191
  thread: 'stroke-cyan-300',
90
192
  text: 'text-cyan-500',
91
193
  },
92
194
  done: {
93
195
  fill: 'fill-green-500/40',
94
196
  node: 'fill-green-300',
197
+ edge: 'fill-green-500',
95
198
  thread: 'stroke-green-300',
96
199
  text: 'text-green-500',
97
200
  },
98
201
  failed: {
99
202
  fill: 'fill-red-500/40',
100
203
  node: 'fill-red-300',
204
+ edge: 'fill-red-500',
101
205
  thread: 'stroke-red-300',
102
206
  text: 'text-red-500',
103
207
  },
104
208
  };
105
209
 
106
- type Row = {
107
- lane: GanttLane;
108
- depth: number;
109
- index: number;
110
- sessionId: string | undefined;
111
- };
112
-
113
- /** A session's rows: the session itself followed by every task it works in-session, contiguous. */
114
- type SessionGroup = {
115
- session: GanttLane;
116
- first: number;
117
- last: number;
118
- };
119
-
120
- /**
121
- * Rows grouped by the session that processes them: a session, then the tasks (and subtasks) it
122
- * works itself, then — after that block — every session those tasks spawned, recursively. Keeping a
123
- * session's own rows contiguous is what lets one rectangle enclose them.
124
- */
125
- const orderRows = (lanes: readonly GanttLane[]): { rows: Row[]; groups: SessionGroup[] } => {
126
- const rows: Row[] = [];
127
- const groups: SessionGroup[] = [];
128
- const childrenOf = (parentId: string | undefined): GanttLane[] => lanes.filter((lane) => lane.parentId === parentId);
129
-
130
- const visitSession = (session: GanttLane, depth: number): void => {
131
- const first = rows.length;
132
- rows.push({ lane: session, depth, index: rows.length, sessionId: session.id });
133
- const spawned: { lane: GanttLane; depth: number }[] = [];
134
- const visitTasks = (parentId: string, taskDepth: number): void => {
135
- for (const child of childrenOf(parentId)) {
136
- if (child.kind === 'task') {
137
- rows.push({ lane: child, depth: taskDepth, index: rows.length, sessionId: session.id });
138
- visitTasks(child.id, taskDepth + 1);
139
- } else {
140
- spawned.push({ lane: child, depth: taskDepth });
141
- }
142
- }
143
- };
144
- visitTasks(session.id, depth + 1);
145
- groups.push({ session, first, last: rows.length - 1 });
146
- for (const child of spawned) {
147
- visitSession(child.lane, child.depth);
148
- }
149
- };
210
+ /** A node that asks for attention: a question put to the reader, or something that went wrong. */
211
+ const isAlert = (marker: GanttMarker): boolean => marker.level === 'warn' || marker.level === 'error';
150
212
 
151
- for (const lane of childrenOf(undefined)) {
152
- if (lane.kind === 'session') {
153
- visitSession(lane, 0);
154
- } else {
155
- rows.push({ lane, depth: 0, index: rows.length, sessionId: undefined });
156
- }
157
- }
158
- return { rows, groups };
159
- };
213
+ const CONNECTOR_CLASSNAME = 'stroke-fuchsia-500';
160
214
 
161
- const formatTokens = (tokens: NonNullable<GanttLane['tokens']>): string =>
162
- tokens.total >= 1_000 ? Unit.Thousand(tokens.total).toString() : String(tokens.total);
215
+ /** What the legend's column shows per lane: its title, or the facts the host attached to it. */
216
+ export type GanttLegendMode = 'title' | 'stats';
163
217
 
164
218
  export type GanttData = {
219
+ /** The bands. A chart with none draws its lanes plain, with no rectangles. */
220
+ groups?: readonly GanttGroup[];
165
221
  lanes: readonly GanttLane[];
166
222
  markers?: readonly GanttMarker[];
167
223
  range?: { start: number; end: number };
168
224
  now?: number;
169
225
  showNow?: boolean;
226
+ /**
227
+ * What the horizontal axis measures: `unit` steps once per event and scrolls to follow the newest;
228
+ * `time` fits the whole range to the width and eases to new bounds. `unit` by default.
229
+ */
230
+ axis?: GanttAxis;
231
+ /** Pixels per event on the `unit` axis. */
232
+ unitStep?: number;
233
+ /**
234
+ * Slide a newly arrived event out of the one before it — out of the node that opened its lane, for
235
+ * a lane's first — and grow its bar to meet it, on the `unit` axis. Only events that arrive while the
236
+ * chart is on screen animate, so a chart that is merely mounted is still.
237
+ */
238
+ animate?: boolean;
170
239
  onLaneSelect?: (lane: GanttLane) => void;
171
240
  onMarkerSelect?: (marker: GanttMarker) => void;
241
+ /** Called by `Gantt.AxisToggle`; without it the toggle does not render. */
242
+ onAxisChange?: (axis: GanttAxis) => void;
243
+ /** What the legend's column shows; held by the root when absent, `title` to begin with. */
244
+ legend?: GanttLegendMode;
245
+ /** Called by `Gantt.LegendToggle`. */
246
+ onLegendChange?: (legend: GanttLegendMode) => void;
172
247
  };
173
248
 
174
- /** What every part reads: the ordered rows and the shared time axis, resolved once by the root. */
249
+ /** What every part reads: the ordered rows and the shared axis, resolved once by the root. */
175
250
  type GanttContextValue = {
176
251
  rows: Row[];
177
- groups: SessionGroup[];
252
+ bands: Band[];
178
253
  rowById: Map<string, Row>;
179
254
  markers: readonly GanttMarker[];
180
255
  markerById: Map<string, GanttMarker>;
181
256
  range: { start: number; end: number };
182
- } & Pick<GanttData, 'onLaneSelect' | 'onMarkerSelect' | 'now' | 'showNow'>;
257
+ legend: GanttLegendMode;
258
+ setLegend: (legend: GanttLegendMode) => void;
259
+ /** The one scrolling element: lanes scroll vertically under the axis, the drawing horizontally beside the legend. */
260
+ viewport: HTMLDivElement | null;
261
+ /** Reports the sticky legend's width, which the horizontal bar keeps clear of since the legend does not scroll. */
262
+ setLegendWidth: (width: number) => void;
263
+ } & Pick<
264
+ GanttData,
265
+ 'onLaneSelect' | 'onMarkerSelect' | 'onAxisChange' | 'now' | 'showNow' | 'axis' | 'unitStep' | 'animate'
266
+ >;
183
267
 
184
268
  const [GanttProvider, useGanttContext] = createContext<GanttContextValue>('Gantt');
185
269
 
@@ -187,27 +271,61 @@ const [GanttProvider, useGanttContext] = createContext<GanttContextValue>('Gantt
187
271
  // Root
188
272
  //
189
273
 
190
- type GanttRootProps = ThemedClassName<GanttData & { children?: ReactNode }>;
274
+ type GanttRootProps = Util.ThemedClassName<GanttData & { children?: ReactNode }>;
191
275
 
192
276
  /**
193
- * Gantt view of sessions and tasks on a shared time axis. The root resolves the rows and the axis;
194
- * the parts lay out side by side in the order given, sharing one row grid: `Legend` names the
195
- * lanes, `Chart` draws them, `Meta` shows per-lane totals. A host that already lists the lanes
196
- * renders the chart alone.
277
+ * Gantt view of lanes on a shared axis. The root resolves the rows and the axis and fills its host;
278
+ * the parts lay out side by side in the order given, sharing one row grid: `Legend` names the lanes
279
+ * and `Chart` draws them. A host that already lists the lanes renders the chart alone.
280
+ *
281
+ * The root owns the scrolling, on both axes at once: the legend's rows and the chart's move together
282
+ * vertically, the legend stays put while the drawing scrolls horizontally, and the horizontal bar is
283
+ * at the foot of the host rather than under the last lane.
197
284
  */
198
- const GanttRoot = composable<HTMLDivElement, GanttRootProps>(
285
+ const GanttRoot = Util.composable<HTMLDivElement, GanttRootProps>(
199
286
  (
200
- { lanes, markers = [], range: rangeProp, now, showNow, onLaneSelect, onMarkerSelect, children, ...props },
287
+ {
288
+ groups = [],
289
+ lanes,
290
+ markers = [],
291
+ range: rangeProp,
292
+ now,
293
+ showNow,
294
+ axis = 'unit',
295
+ unitStep,
296
+ animate,
297
+ onLaneSelect,
298
+ onMarkerSelect,
299
+ onAxisChange,
300
+ legend: legendProp,
301
+ onLegendChange,
302
+ children,
303
+ ...props
304
+ },
201
305
  forwardedRef,
202
306
  ) => {
203
- const { rows, groups } = useMemo(() => orderRows(lanes), [lanes]);
307
+ const [legendState, setLegendState] = useState<GanttLegendMode>('title');
308
+ const legend = legendProp ?? legendState;
309
+ const setLegend = useCallback(
310
+ (next: GanttLegendMode) => {
311
+ setLegendState(next);
312
+ onLegendChange?.(next);
313
+ },
314
+ [onLegendChange],
315
+ );
316
+ const [viewport, setViewport] = useState<HTMLDivElement | null>(null);
317
+ const [legendWidth, setLegendWidth] = useState(0);
318
+ const { rows, bands } = useMemo(() => orderRows(groups, lanes), [groups, lanes]);
204
319
  const rowById = useMemo(() => new Map(rows.map((row) => [row.lane.id, row])), [rows]);
205
320
  const markerById = useMemo(() => new Map(markers.map((marker) => [marker.id, marker])), [markers]);
206
321
  const range = useMemo(() => {
207
322
  if (rangeProp) {
208
323
  return rangeProp;
209
324
  }
210
- const times = [...lanes.flatMap((lane) => [lane.start, lane.end]), ...markers.map((marker) => marker.timestamp)]
325
+ const times = [
326
+ ...lanes.flatMap((lane) => (lane.segments ?? []).flatMap((segment) => [segment.start, segment.end])),
327
+ ...markers.map((marker) => marker.timestamp),
328
+ ]
211
329
  .filter((time): time is number => time !== undefined)
212
330
  .concat(now === undefined ? [] : [now]);
213
331
  const start = times.length > 0 ? Math.min(...times) : 0;
@@ -217,25 +335,35 @@ const GanttRoot = composable<HTMLDivElement, GanttRootProps>(
217
335
  return (
218
336
  <GanttProvider
219
337
  rows={rows}
220
- groups={groups}
338
+ bands={bands}
221
339
  rowById={rowById}
222
340
  markers={markers}
223
341
  markerById={markerById}
224
342
  range={range}
225
343
  now={now}
226
344
  showNow={showNow}
345
+ axis={axis}
346
+ unitStep={unitStep}
347
+ animate={animate}
227
348
  onLaneSelect={onLaneSelect}
228
349
  onMarkerSelect={onMarkerSelect}
350
+ onAxisChange={onAxisChange}
351
+ legend={legend}
352
+ setLegend={setLegend}
353
+ viewport={viewport}
354
+ setLegendWidth={setLegendWidth}
229
355
  >
230
- <div
231
- {...composableProps(props, {
232
- // `items-start`: the parts are drawn in pixel rows, so a stretching host must not spread them.
233
- classNames: 'flex w-full items-start text-xs font-mono overflow-hidden',
234
- })}
356
+ <ScrollArea.Root
357
+ {...Util.composableProps(props, { classNames: 'dx-expand text-xs font-mono' })}
358
+ orientation='all'
359
+ trackStart={legendWidth}
235
360
  ref={forwardedRef}
236
361
  >
237
- {children}
238
- </div>
362
+ {/* `items-start`: the parts are drawn in pixel rows, so a stretching host must not spread them. */}
363
+ <ScrollArea.Viewport classNames='flex items-start' ref={setViewport}>
364
+ {children}
365
+ </ScrollArea.Viewport>
366
+ </ScrollArea.Root>
239
367
  </GanttProvider>
240
368
  );
241
369
  },
@@ -247,25 +375,54 @@ GanttRoot.displayName = 'Gantt.Root';
247
375
  // Legend
248
376
  //
249
377
 
250
- type GanttLegendProps = ThemedClassName<{}>;
378
+ type GanttLegendProps = Util.ThemedClassName<PropsWithChildren>;
379
+
380
+ /**
381
+ * The lane names, one per row, indented by depth; each row is the lane's keyboard path. Children go
382
+ * in the header row above the names, beside the chart's axis labels (e.g. `Gantt.AxisToggle`).
383
+ *
384
+ * The column shows each lane's title or its stats (`legend` on the root), and whichever it is not
385
+ * showing is in a hover card over the row, so neither costs a column of its own.
386
+ */
387
+ const GanttLegend = Util.composable<HTMLDivElement, GanttLegendProps>(({ children, ...props }, forwardedRef) => {
388
+ const { rows, legend, onLaneSelect, setLegendWidth } = useGanttContext('Gantt.Legend');
389
+ const legendRef = useRef<HTMLDivElement>(null);
390
+ const ref = useComposedRefs(forwardedRef, legendRef);
391
+ useEffect(() => {
392
+ const element = legendRef.current;
393
+ if (!element) {
394
+ return;
395
+ }
396
+ const observer = new ResizeObserver(() => setLegendWidth(element.offsetWidth));
397
+ observer.observe(element);
398
+ return () => {
399
+ observer.disconnect();
400
+ setLegendWidth(0);
401
+ };
402
+ }, [setLegendWidth]);
251
403
 
252
- /** The lane names, one per row, indented by depth; each row is the lane's keyboard path. */
253
- const GanttLegend = composable<HTMLDivElement, GanttLegendProps>((props, forwardedRef) => {
254
- const { rows, onLaneSelect } = useGanttContext('Gantt.Legend');
255
404
  return (
256
405
  <div
257
- {...composableProps(props, { classNames: 'shrink-0 w-[min(20rem,40%)] min-w-40 flex flex-col' })}
258
- ref={forwardedRef}
406
+ {...Util.composableProps(props, {
407
+ // Sticky and opaque: the drawing scrolls horizontally beneath it. Sized against the scroll
408
+ // frame (`cqw`) rather than its parent, whose width is the drawing's.
409
+ classNames:
410
+ 'sticky left-0 z-[1] shrink-0 w-[min(15rem,20cqw)] min-w-40 flex flex-col font-sans bg-(--surface-bg)',
411
+ })}
412
+ ref={ref}
259
413
  >
260
- <div style={{ height: HEADER_HEIGHT }} />
414
+ <div className='flex items-center' style={{ height: HEADER_HEIGHT }}>
415
+ {children}
416
+ </div>
261
417
  {rows.map(({ lane, depth }) => (
262
418
  <div
263
419
  key={lane.id}
264
420
  className={mx(
265
- 'flex items-center gap-2 truncate',
421
+ 'flex items-center gap-2 min-w-0',
266
422
  onLaneSelect && 'cursor-pointer hover:bg-hover-surface-subtle',
267
423
  )}
268
424
  style={{ height: ROW_HEIGHT, paddingInlineStart: `${0.5 + depth}rem` }}
425
+ data-testid='gantt.legend.row'
269
426
  // Button semantics only when there is something to select: the label row is the lane's
270
427
  // keyboard path, and an inert focus stop would be noise.
271
428
  {...(onLaneSelect && {
@@ -280,8 +437,11 @@ const GanttLegend = composable<HTMLDivElement, GanttLegendProps>((props, forward
280
437
  },
281
438
  })}
282
439
  >
283
- <span className={mx('shrink-0 w-2 h-2 rounded-full bg-current', STATUS_COLOR[lane.status].text)} />
284
- <span className='truncate text-base-fg'>{lane.label}</span>
440
+ <span
441
+ className={mx('shrink-0 w-2 h-2 rounded-full bg-current', !lane.hue && STATUS_COLOR[lane.status].text)}
442
+ style={lane.hue ? { color: hueColor(lane.hue) } : undefined}
443
+ />
444
+ <GanttLaneLabel lane={lane} legend={legend} />
285
445
  </div>
286
446
  ))}
287
447
  </div>
@@ -290,116 +450,532 @@ const GanttLegend = composable<HTMLDivElement, GanttLegendProps>((props, forward
290
450
 
291
451
  GanttLegend.displayName = 'Gantt.Legend';
292
452
 
453
+ /** A legend row's text, and a hover card holding what the column is not showing. */
454
+ const GanttLaneLabel = ({ lane, legend }: { lane: GanttLane; legend: GanttLegendMode }) => {
455
+ const meta = lane.meta ?? [];
456
+ const title = (
457
+ <span className='truncate text-fg' data-testid='gantt.legend.title'>
458
+ {lane.label}
459
+ </span>
460
+ );
461
+ // Grows across the row so a lane with no stats still has something to hover for its title.
462
+ const stats = (
463
+ <span
464
+ className='grow flex gap-2 truncate text-fg-muted whitespace-nowrap tabular-nums'
465
+ data-testid='gantt.legend.stats'
466
+ >
467
+ {meta.map(({ label }, index) => (
468
+ <span key={index}>{label}</span>
469
+ ))}
470
+ </span>
471
+ );
472
+ if (legend === 'title' && meta.length === 0) {
473
+ return title;
474
+ }
475
+
476
+ return (
477
+ <HoverCard.Root>
478
+ <HoverCard.Trigger asChild>{legend === 'title' ? title : stats}</HoverCard.Trigger>
479
+ <HoverCard.Content classNames='p-2 max-w-72 text-xs' data-testid='gantt.legend.card'>
480
+ {legend === 'title' ? (
481
+ meta.map(({ label, title }, index) => (
482
+ <div key={index} className='tabular-nums'>
483
+ {label}
484
+ {title && <span className='text-fg-muted'>{` · ${title}`}</span>}
485
+ </div>
486
+ ))
487
+ ) : (
488
+ <div className='font-medium'>{lane.label}</div>
489
+ )}
490
+ </HoverCard.Content>
491
+ </HoverCard.Root>
492
+ );
493
+ };
494
+
293
495
  //
294
- // Meta
496
+ // AxisToggle
295
497
  //
296
498
 
297
- type GanttMetaProps = ThemedClassName<{}>;
499
+ type GanttAxisToggleProps = {};
298
500
 
299
- /** Per-lane totals — tokens and tool calls — aligned to the rows. */
300
- const GanttMeta = composable<HTMLDivElement, GanttMetaProps>((props, forwardedRef) => {
301
- const { rows } = useGanttContext('Gantt.Meta');
501
+ /**
502
+ * Switches the chart between its axes: `time`, the run fitted to the width, and `unit`, one step per
503
+ * event. Renders nothing unless the root was given `onAxisChange`, since the axis is the host's state.
504
+ */
505
+ const GanttAxisToggle = (_: GanttAxisToggleProps) => {
506
+ const { t } = Hooks.useTranslation(translationKey);
507
+ const { axis = 'unit', onAxisChange } = useGanttContext('Gantt.AxisToggle');
508
+ if (!onAxisChange) {
509
+ return null;
510
+ }
511
+
512
+ // The icon names the axis in use; the label names the one a click switches to.
302
513
  return (
303
- <div {...composableProps(props, { classNames: 'shrink-0 flex flex-col' })} ref={forwardedRef}>
304
- <div style={{ height: HEADER_HEIGHT }} />
305
- {rows.map(({ lane }) => (
306
- <div
307
- key={lane.id}
308
- className='flex items-center justify-end gap-2 px-2 text-subdued whitespace-nowrap'
309
- style={{ height: ROW_HEIGHT }}
310
- >
311
- {lane.tokens && (
312
- <span title={`${lane.tokens.input} in / ${lane.tokens.output} out`}>{formatTokens(lane.tokens)}</span>
313
- )}
314
- {lane.toolCalls !== undefined && <span>{lane.toolCalls} tools</span>}
315
- </div>
316
- ))}
317
- </div>
514
+ <Button.Root
515
+ variant='ghost'
516
+ size='sm'
517
+ iconSize='xs'
518
+ iconOnly
519
+ icon={axis === 'time' ? 'ph--clock--regular' : 'ph--dots-three-outline--regular'}
520
+ label={t(axis === 'time' ? 'gantt-axis-unit.label' : 'gantt-axis-time.label')}
521
+ onClick={() => onAxisChange(axis === 'time' ? 'unit' : 'time')}
522
+ data-testid='gantt.axisToggle'
523
+ />
318
524
  );
319
- });
525
+ };
320
526
 
321
- GanttMeta.displayName = 'Gantt.Meta';
527
+ GanttAxisToggle.displayName = 'Gantt.AxisToggle';
528
+
529
+ //
530
+ // LegendToggle
531
+ //
532
+
533
+ type GanttLegendToggleProps = {};
534
+
535
+ /** Switches the legend's column between the lanes' titles and their stats. */
536
+ const GanttLegendToggle = (_: GanttLegendToggleProps) => {
537
+ const { t } = Hooks.useTranslation(translationKey);
538
+ const { legend, setLegend } = useGanttContext('Gantt.LegendToggle');
539
+
540
+ // As the axis toggle: the icon names what is shown, the label what a click shows instead.
541
+ return (
542
+ <Button.Root
543
+ variant='ghost'
544
+ size='sm'
545
+ iconSize='xs'
546
+ iconOnly
547
+ icon={legend === 'title' ? 'ph--text-aa--regular' : 'ph--chart-bar--regular'}
548
+ label={t(legend === 'title' ? 'gantt-legend-stats.label' : 'gantt-legend-title.label')}
549
+ onClick={() => setLegend(legend === 'title' ? 'stats' : 'title')}
550
+ data-testid='gantt.legendToggle'
551
+ />
552
+ );
553
+ };
554
+
555
+ GanttLegendToggle.displayName = 'Gantt.LegendToggle';
322
556
 
323
557
  //
324
558
  // Chart
325
559
  //
326
560
 
327
- type GanttChartProps = ThemedClassName<{}>;
561
+ type GanttChartProps = Util.ThemedClassName<{}>;
562
+
563
+ /** One drawn stretch of a lane: where its bar runs, and the nodes threaded through it. */
564
+ type Stretch = {
565
+ /** The instants the segment spans, in pixels. */
566
+ from: number;
567
+ to: number;
568
+ /** The drawn positions of the first and last node inside it; absent when it holds fewer than two. */
569
+ threadFrom?: number;
570
+ threadTo?: number;
571
+ };
328
572
 
329
573
  /**
330
- * The drawing: every lane is a rounded bar with its markers threaded through it as nodes, a session's
331
- * rectangle encloses the tasks it works, and connectors draw dependencies and delegations.
574
+ * The drawing: a lane is a rounded bar per segment with its markers threaded through as nodes, a
575
+ * group's rectangle encloses its band, and connectors draw dependencies and the node a lane opened
576
+ * out of.
577
+ *
578
+ * On the `unit` axis the drawing is as wide as the events need and follows the newest of them in the
579
+ * root's scroll frame, while the legend beside it stays where it is.
580
+ *
581
+ * The drawing is one keyboard stop: clicking a node focuses it, the arrows walk the nodes (left and
582
+ * right along a lane, up and down to the nearest node of the next lane), Space opens the current
583
+ * node's card and Enter selects it — the same keys a legend row answers to.
332
584
  */
333
- // `forwardRef` rather than `composable`: the drawing is an `svg`, which the HTML-element helpers do not type.
334
585
  const GanttChart = forwardRef<SVGSVGElement, GanttChartProps>(({ classNames }, forwardedRef) => {
335
- const { rows, groups, rowById, markers, markerById, range, now, showNow, onLaneSelect, onMarkerSelect } =
336
- useGanttContext('Gantt.Chart');
586
+ const {
587
+ rows,
588
+ bands,
589
+ rowById,
590
+ markers,
591
+ markerById,
592
+ range,
593
+ now,
594
+ showNow,
595
+ axis = 'unit',
596
+ unitStep = UNIT_STEP,
597
+ animate,
598
+ onLaneSelect,
599
+ onMarkerSelect,
600
+ viewport,
601
+ } = useGanttContext('Gantt.Chart');
602
+ const { t } = Hooks.useTranslation(translationKey);
337
603
  const svgRef = useRef<SVGSVGElement | null>(null);
338
- const ref = useComposedRefs(svgRef, forwardedRef);
604
+ const ref = useComposedRefs(forwardedRef, svgRef);
605
+ const nodeIdPrefix = useId();
606
+ /** The node the keyboard is on, and the node whose card is open (by hover or by Space). */
607
+ const [currentId, setCurrentId] = useState<string>();
608
+ const [openId, setOpenId] = useState<string>();
609
+ const followRef = useRef(true);
610
+ const glideRef = useRef(false);
611
+ const frameRef = useRef<number | undefined>(undefined);
612
+ const drawnRef = useRef<number | undefined>(undefined);
339
613
  const [width, setWidth] = useState(600);
340
614
  useEffect(() => {
341
- const element = svgRef.current;
615
+ const element = viewport;
342
616
  if (!element) {
343
617
  return;
344
618
  }
345
- const observer = new ResizeObserver(([entry]) => entry && setWidth(entry.contentRect.width));
619
+ // The time axis is fitted to what the viewport shows beside the legend, not to the drawing, which may be wider.
620
+ const measure = (): void => {
621
+ const svg = svgRef.current;
622
+ const offset = svg
623
+ ? svg.getBoundingClientRect().left - element.getBoundingClientRect().left + element.scrollLeft
624
+ : 0;
625
+ setWidth(Math.max(element.clientWidth - offset, 0));
626
+ };
627
+ const observer = new ResizeObserver(measure);
346
628
  observer.observe(element);
347
- return () => observer.disconnect();
348
- }, []);
629
+ // Listened for rather than taken as a prop: `ScrollArea.Viewport` accepts only what it slots.
630
+ const onScroll = (): void => {
631
+ const atEdge = element.scrollWidth - element.clientWidth - element.scrollLeft <= FOLLOW_SLACK;
632
+ // A glide of our own emits scroll events all the way there, every one of them short of the
633
+ // edge. Reading those as the reader scrolling away would unpin the chart on the very frame it
634
+ // set out to follow; only its arrival is worth acting on.
635
+ if (glideRef.current) {
636
+ glideRef.current = !atEdge;
637
+ return;
638
+ }
639
+ followRef.current = atEdge;
640
+ };
641
+ element.addEventListener('scroll', onScroll, { passive: true });
642
+ return () => {
643
+ observer.disconnect();
644
+ element.removeEventListener('scroll', onScroll);
645
+ };
646
+ }, [viewport]);
647
+
648
+ /** Each lane's markers in the order they happened: the threads, the bars and the enter origins read it. */
649
+ const laneMarkers = useMemo(() => {
650
+ const byLane = new Map<string, GanttMarker[]>();
651
+ for (const marker of markers) {
652
+ const list = byLane.get(marker.laneId);
653
+ if (list) {
654
+ list.push(marker);
655
+ } else {
656
+ byLane.set(marker.laneId, [marker]);
657
+ }
658
+ }
659
+ for (const list of byLane.values()) {
660
+ list.sort((left, right) => left.timestamp - right.timestamp);
661
+ }
662
+ return byLane;
663
+ }, [markers]);
349
664
 
350
- const span = Math.max(range.end - range.start, 1);
351
- const x = (time: number): number => PAD_X + ((time - range.start) / span) * (width - 2 * PAD_X);
665
+ // The time axis is fitted to the viewport, so moving bounds rescale everything already drawn;
666
+ // easing the range makes that a slide rather than a jump. The unit axis never rescales.
667
+ // The time axis moves only when something happens: its bounds end at the newest drawn instant (a
668
+ // node or a segment edge) rather than at the host's range, which may follow the wall clock and would
669
+ // rescale an idle chart forever. Open lanes run to that same edge, so none is clipped.
670
+ const lastEvent = useMemo(() => {
671
+ const times = [
672
+ ...markers.map((marker) => marker.timestamp),
673
+ ...rows.flatMap(({ lane }) => (lane.segments ?? []).flatMap(({ start, end }) => [start, end ?? start])),
674
+ ];
675
+ return times.length > 0 ? Math.max(...times) : undefined;
676
+ }, [markers, rows]);
677
+ const targetRange = useMemo(
678
+ () => (lastEvent === undefined ? range : { start: range.start, end: Math.max(lastEvent, range.start) }),
679
+ [range.start, range.end, lastEvent],
680
+ );
681
+ const shownRange = useRangeTween(targetRange, axis === 'time');
682
+ // CSS geometry transitions would chase every frame of that ease and trail behind it, so they run
683
+ // on the unit axis only; the stroke dash drawing a connector on is not geometry and always may.
684
+ const slide = animate === true && axis === 'unit';
685
+ const scale: GanttScale = useMemo(
686
+ () =>
687
+ axis === 'unit'
688
+ ? unitScale({ times: markers.map((marker) => marker.timestamp), step: unitStep, pad: PAD_X })
689
+ : timeScale({ range: shownRange, width, pad: PAD_X }),
690
+ [axis, markers, unitStep, shownRange, width],
691
+ );
692
+ const isEntering = useEnter(markers.map((marker) => marker.id));
693
+
694
+ // On the time axis the right edge is "now" — the newest event, where open lanes end. While the range
695
+ // eases toward new bounds, anything newer than the drawn range is held at that edge rather than drawn
696
+ // past it: the new event and the open ends stay pinned while everything older compresses leftward.
697
+ const edge = axis === 'time' ? scale.at(shownRange.end) : undefined;
698
+ const x = (time: number): number => (edge === undefined ? scale.at(time) : Math.min(scale.at(time), edge));
352
699
  const rowY = (index: number): number => HEADER_HEIGHT + index * ROW_HEIGHT + ROW_HEIGHT / 2;
353
- const laneTimes = (lane: GanttLane): number[] =>
354
- markers.filter((marker) => marker.laneId === lane.id).map((marker) => marker.timestamp);
355
- // A bar reaches its last node, not `now`: a lane without a fresh event is not shown as still
356
- // busy, and a terminated one ends where its last event did.
357
- const laneEnd = (lane: GanttLane): number | undefined => {
358
- const times = laneTimes(lane);
359
- return times.length > 0 ? Math.max(...times) : (lane.end ?? now);
700
+ const openSource = (lane: GanttLane): GanttMarker | undefined =>
701
+ lane.openedFrom && markerById.get(lane.openedFrom.markerId);
702
+
703
+ // Where each node is drawn: its own place, or — for the one frame in which it arrives — the place it
704
+ // came from, which is what the transitions below then travel out of. A lane's first node comes from
705
+ // the node the lane opened out of, so a lane reads as branching from whatever caused it.
706
+ const markerX = new Map<string, number>();
707
+ // The newest node of a lane that is still running: the end the next event will land on, and the
708
+ // one thing in the drawing that is not yet history.
709
+ const activeEdges = new Set<string>();
710
+ for (const [laneId, list] of laneMarkers) {
711
+ const row = rowById.get(laneId);
712
+ if (!row) {
713
+ continue;
714
+ }
715
+ const newest = list[list.length - 1];
716
+ if (row.lane.status === 'running' && newest) {
717
+ activeEdges.add(newest.id);
718
+ }
719
+ list.forEach((marker, index) => {
720
+ const previous = index > 0 ? list[index - 1] : undefined;
721
+ const source = previous ? undefined : openSource(row.lane);
722
+ const origin = previous ? x(previous.timestamp) : source && x(source.timestamp);
723
+ const resting = x(marker.timestamp);
724
+ markerX.set(marker.id, slide && origin !== undefined && isEntering(marker.id) ? origin : resting);
725
+ });
726
+ }
727
+
728
+ /**
729
+ * A lane's drawn stretches, one per segment. An open segment reaches its last node, taking the
730
+ * node's drawn position rather than its instant so the bar grows with an arriving event instead of
731
+ * jumping ahead of it — and on to the chart's newest event when that is later.
732
+ */
733
+ const stretchesOf = (lane: GanttLane): Stretch[] => {
734
+ const list = laneMarkers.get(lane.id) ?? [];
735
+ const segments = lane.segments ?? [];
736
+ return segments.map((segment, index) => {
737
+ const upper = segment.end ?? segments[index + 1]?.start ?? Number.MAX_SAFE_INTEGER;
738
+ const inside = list.filter((marker) => marker.timestamp >= segment.start && marker.timestamp <= upper);
739
+ const first = inside[0];
740
+ const last = inside[inside.length - 1];
741
+ const from = x(segment.start);
742
+ // An open stretch runs to its newest node, and on to the newest event anywhere on the chart when
743
+ // that is later: a lane still being worked between its own events (a parent task while its
744
+ // sub-tasks run) is not a point.
745
+ const lastX = last ? (markerX.get(last.id) ?? x(last.timestamp)) : undefined;
746
+ const edgeX = lastEvent !== undefined ? x(lastEvent) : undefined;
747
+ const to =
748
+ segment.end !== undefined
749
+ ? x(segment.end)
750
+ : lastX !== undefined && edgeX !== undefined
751
+ ? Math.max(lastX, edgeX)
752
+ : (lastX ?? edgeX ?? from);
753
+ const threaded = inside.length > 1 && first && last;
754
+ return {
755
+ from,
756
+ to: Math.max(to, from),
757
+ threadFrom: threaded ? markerX.get(first.id) : undefined,
758
+ threadTo: threaded ? markerX.get(last.id) : undefined,
759
+ };
760
+ });
360
761
  };
361
- const delegationSource = (lane: GanttLane): GanttMarker | undefined =>
362
- lane.delegatedFrom && markerById.get(lane.delegatedFrom.markerId);
363
- // A delegated lane's bar begins where the connector's bend lands, never under the drop from the
364
- // parent node: the drop is at the spawn instant and the child starts at or after it.
365
- const barStart = (lane: GanttLane, start: number): number => {
366
- const source = delegationSource(lane);
367
- const edge = x(start) - BAR_OVERHANG;
368
- // Past the bend the connector runs level for one more radius before the bar begins.
369
- return source ? Math.max(edge, x(source.timestamp) + 2 * BEND_RADIUS) : edge;
762
+
763
+ // A lane whose first node has just arrived is opening: its bar extends from its own beginning
764
+ // rather than appearing at full length, and the connector that caused it draws out to meet it.
765
+ const isOpening = (lane: GanttLane): boolean => {
766
+ const first = laneMarkers.get(lane.id)?.[0];
767
+ return slide && first !== undefined && isEntering(first.id);
370
768
  };
371
- // A node never sits outside its bar: a delegated lane's first event is the spawn instant itself,
372
- // which is where the connector drops, so that node is nudged in past the bar's edge.
373
- const nodeX = (lane: GanttLane, time: number): number =>
374
- lane.start === undefined ? x(time) : Math.max(x(time), barStart(lane, lane.start) + BAR_OVERHANG);
769
+
375
770
  const height = HEADER_HEIGHT + rows.length * ROW_HEIGHT;
376
- const tickCount = Math.max(2, Math.min(MAX_TICKS, Math.floor((width - 2 * PAD_X) / TICK_MIN_WIDTH)));
377
- const ticks = Array.from({ length: tickCount }, (_, index) => range.start + (span * index) / (tickCount - 1));
771
+ const grow = slide && mx('transition-[width]', ENTER_TRANSITION);
772
+
773
+ const nodeIds = useMemo(
774
+ () => new Map(markers.map((marker, index) => [marker.id, `${nodeIdPrefix}-node-${index}`])),
775
+ [markers, nodeIdPrefix],
776
+ );
777
+ const current = currentId === undefined ? undefined : markerById.get(currentId);
778
+
779
+ /** The node an arrow key moves to from `from`; `from` itself where there is nowhere further to go. */
780
+ const neighbour = (from: GanttMarker, key: string): GanttMarker => {
781
+ if (key === 'ArrowLeft' || key === 'ArrowRight') {
782
+ const list = laneMarkers.get(from.laneId) ?? [];
783
+ const index = list.findIndex((marker) => marker.id === from.id);
784
+ return list[index + (key === 'ArrowLeft' ? -1 : 1)] ?? from;
785
+ }
786
+ const row = rowById.get(from.laneId);
787
+ if (!row) {
788
+ return from;
789
+ }
790
+ // The nearest node in drawn position rather than in time: on the unit axis the two disagree, and
791
+ // the reader is following what they see.
792
+ const fromX = markerX.get(from.id) ?? x(from.timestamp);
793
+ const distance = (marker: GanttMarker): number => Math.abs((markerX.get(marker.id) ?? x(marker.timestamp)) - fromX);
794
+ const direction = key === 'ArrowUp' ? -1 : 1;
795
+ // Lanes with no nodes are passed over: there is nothing on them to land on.
796
+ for (let index = row.index + direction; index >= 0 && index < rows.length; index += direction) {
797
+ const list = laneMarkers.get(rows[index].lane.id) ?? [];
798
+ if (list.length > 0) {
799
+ return list.reduce((best, marker) => (distance(marker) < distance(best) ? marker : best));
800
+ }
801
+ }
802
+ return from;
803
+ };
804
+
805
+ /** Scrolls the frame just enough to show `marker` clear of the sticky legend and the frame's edges. */
806
+ const reveal = (marker: GanttMarker): void => {
807
+ const svg = svgRef.current;
808
+ const row = rowById.get(marker.laneId);
809
+ const cx = markerX.get(marker.id);
810
+ if (!viewport || !svg || !row || cx === undefined) {
811
+ return;
812
+ }
813
+ const frame = viewport.getBoundingClientRect();
814
+ const drawing = svg.getBoundingClientRect();
815
+ const legendWidth = drawing.left - frame.left + viewport.scrollLeft;
816
+ const nodeX = drawing.left - frame.left + cx;
817
+ const nodeY = drawing.top - frame.top + rowY(row.index);
818
+ const margin = ROW_HEIGHT;
819
+ const left =
820
+ nodeX - margin < legendWidth
821
+ ? nodeX - margin - legendWidth
822
+ : nodeX + margin > viewport.clientWidth
823
+ ? nodeX + margin - viewport.clientWidth
824
+ : 0;
825
+ const top =
826
+ nodeY - margin < 0
827
+ ? nodeY - margin
828
+ : nodeY + margin > viewport.clientHeight
829
+ ? nodeY + margin - viewport.clientHeight
830
+ : 0;
831
+ if (left !== 0 || top !== 0) {
832
+ viewport.scrollBy({ left, top });
833
+ }
834
+ };
835
+
836
+ const moveTo = (marker: GanttMarker): void => {
837
+ setCurrentId(marker.id);
838
+ // An open card travels with the current node.
839
+ setOpenId((open) => (open === undefined ? undefined : marker.id));
840
+ reveal(marker);
841
+ };
842
+
843
+ const handleKeyDown = (event: KeyboardEvent<SVGSVGElement>): void => {
844
+ switch (event.key) {
845
+ case 'ArrowLeft':
846
+ case 'ArrowRight':
847
+ case 'ArrowUp':
848
+ case 'ArrowDown': {
849
+ event.preventDefault();
850
+ // With no node yet, the first lane's newest: the unit axis opens on the present.
851
+ const next = current
852
+ ? neighbour(current, event.key)
853
+ : rows.map(({ lane }) => laneMarkers.get(lane.id)?.at(-1)).find((marker) => marker !== undefined);
854
+ if (next) {
855
+ moveTo(next);
856
+ }
857
+ break;
858
+ }
859
+ case ' ': {
860
+ event.preventDefault();
861
+ if (current) {
862
+ setOpenId((open) => (open === current.id ? undefined : current.id));
863
+ }
864
+ break;
865
+ }
866
+ case 'Escape': {
867
+ if (openId !== undefined) {
868
+ event.preventDefault();
869
+ setOpenId(undefined);
870
+ }
871
+ break;
872
+ }
873
+ case 'Enter': {
874
+ if (current) {
875
+ event.preventDefault();
876
+ onMarkerSelect?.(current);
877
+ }
878
+ break;
879
+ }
880
+ }
881
+ };
882
+
883
+ // The unit axis grows to the right, so the newest event has to be followed — but only while the
884
+ // reader is at its edge; having scrolled back, they are reading, not watching. A layout effect, so
885
+ // the first paint already shows the newest events rather than jumping to them from the start.
886
+ useLayoutEffect(() => {
887
+ const element = viewport;
888
+ // Recorded only once there is a frame to scroll, so the first paint with one still opens on the present.
889
+ if (!element) {
890
+ return;
891
+ }
892
+ const previous = drawnRef.current;
893
+ drawnRef.current = scale.width;
894
+
895
+ // Opened on the present: what is happening now is what the chart is looked at for. A drawing that
896
+ // fitted until now counts as opening too — its events arrived after mount, and there was no place
897
+ // in it for the reader to keep.
898
+ const grown = previous !== undefined && scale.width > previous;
899
+ if (previous === undefined || (grown && previous <= width)) {
900
+ element.scrollLeft = element.scrollWidth;
901
+ return;
902
+ }
903
+
904
+ if (!grown || !followRef.current) {
905
+ return;
906
+ }
907
+
908
+ // Glided rather than jumped: the drawing is unchanged to the left of the new event, and a jump
909
+ // asks the reader to re-find their place in it every time one arrives. A reader who asked for
910
+ // less motion gets none.
911
+ if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
912
+ element.scrollLeft = element.scrollWidth;
913
+ return;
914
+ }
915
+
916
+ // Animated here rather than by `scrollTo({ behavior: 'smooth' })`, whose curve is the browser's
917
+ // own and fixed: it arrives in a fraction of the time the event that caused it takes to settle,
918
+ // so the window lurches ahead of the drawing it is following.
919
+ const from = element.scrollLeft;
920
+ const to = element.scrollWidth - element.clientWidth;
921
+ if (to <= from) {
922
+ return;
923
+ }
924
+ const started = performance.now();
925
+ glideRef.current = true;
926
+ const step = (now: number): void => {
927
+ const progress = Math.min((now - started) / FOLLOW_DURATION, 1);
928
+ // Eased out, the shape everything else in the drawing travels on, so the two agree.
929
+ element.scrollLeft = from + (to - from) * (1 - (1 - progress) ** 3);
930
+ if (progress < 1) {
931
+ frameRef.current = requestAnimationFrame(step);
932
+ } else {
933
+ glideRef.current = false;
934
+ }
935
+ };
936
+ frameRef.current = requestAnimationFrame(step);
937
+ // A glide still running when the next event arrives is abandoned where it got to; the glide that
938
+ // replaces it starts from there, so the drift retargets rather than restarting.
939
+ return () => {
940
+ if (frameRef.current !== undefined) {
941
+ cancelAnimationFrame(frameRef.current);
942
+ }
943
+ glideRef.current = false;
944
+ };
945
+ }, [scale.width, viewport]);
378
946
 
379
947
  return (
380
948
  <svg
381
- className={mx('flex-1 min-w-0', classNames)}
949
+ // Pixel coordinates against the drawing's own width, with no viewBox: a viewBox would
950
+ // letterbox the drawing to the column's aspect ratio and shrink every node with it. Grows into
951
+ // the row the legend leaves when the events need less.
952
+ className={mx('grow shrink-0 dx-focus-ring', classNames)}
953
+ style={{ width: scale.width, height }}
954
+ tabIndex={0}
955
+ role='group'
956
+ aria-label={t('gantt-chart.label')}
957
+ aria-activedescendant={current ? nodeIds.get(current.id) : undefined}
958
+ onKeyDown={handleKeyDown}
959
+ data-testid='gantt.chart'
382
960
  ref={ref}
383
- // Pixel coordinates against the measured width, with no viewBox: a viewBox would letterbox
384
- // the drawing to the column's aspect ratio and shrink every node with it.
385
- style={{ height }}
386
961
  >
387
- {ticks.map((tick, index) => (
962
+ {scale.ticks.map(({ at, label }, index) => (
388
963
  <g key={index}>
389
- <line x1={x(tick)} x2={x(tick)} y1={HEADER_HEIGHT} y2={height} className='stroke-separator' />
964
+ <line x1={at} x2={at} y1={HEADER_HEIGHT} y2={height} className='stroke-separator' />
390
965
  <text
391
- x={x(tick)}
966
+ x={at}
392
967
  y={HEADER_HEIGHT - 6}
393
- textAnchor={index === 0 ? 'start' : index === tickCount - 1 ? 'end' : 'middle'}
394
- className='fill-current text-subdued'
968
+ // Anchored inward at the drawing's own edges, so no label is drawn outside it.
969
+ textAnchor={at <= PAD_X ? 'start' : at >= scale.width - PAD_X ? 'end' : 'middle'}
970
+ className='fill-current text-fg-subtle'
395
971
  >
396
- {format(tick, 'HH:mm:ss')}
972
+ {label}
397
973
  </text>
398
974
  </g>
399
975
  ))}
400
976
 
401
977
  {/* Under the lanes and connectors: a dependency on a still-running lane anchors at `now`
402
- and would otherwise be hidden by this line. */}
978
+ and would otherwise be hidden by this line. */}
403
979
  {now !== undefined && showNow && (
404
980
  <line
405
981
  x1={x(now)}
@@ -411,44 +987,43 @@ const GanttChart = forwardRef<SVGSVGElement, GanttChartProps>(({ classNames }, f
411
987
  />
412
988
  )}
413
989
 
414
- {/* A session's rectangle encloses its own bar and the tasks it works in-session; a task it
415
- spawned is a session of its own, drawn as a rectangle further down. */}
416
- {groups.flatMap(({ session, first, last }) => {
417
- const members = rows.slice(first, last + 1).map((row) => row.lane);
418
- const starts = members.flatMap((lane) => (lane.start === undefined ? [] : [barStart(lane, lane.start)]));
419
- // Only lanes with a bar bound the rectangle: a task not yet started has no extent.
420
- const ends = members.flatMap((lane) => {
421
- const end = lane.start === undefined ? undefined : laneEnd(lane);
422
- return end === undefined ? [] : [x(end) + BAR_OVERHANG];
423
- });
424
- if (starts.length === 0 || ends.length === 0) {
990
+ {/* A group's rectangle encloses its band — the lanes it holds, and only those: a nested
991
+ group is a band of its own, drawn further down. */}
992
+ {bands.flatMap(({ group, first, last }) => {
993
+ const stretches = rows.slice(first, last + 1).flatMap((row) => stretchesOf(row.lane));
994
+ if (stretches.length === 0) {
425
995
  return [];
426
996
  }
427
- const left = Math.min(...starts) - GROUP_PAD;
428
- const right = Math.max(...ends) + GROUP_PAD;
997
+ const left = Math.min(...stretches.map((stretch) => stretch.from)) - BAR_OVERHANG - GROUP_PAD;
998
+ const right = Math.max(...stretches.map((stretch) => stretch.to)) + BAR_OVERHANG + GROUP_PAD;
429
999
  return [
430
1000
  <rect
431
- key={`group:${session.id}`}
1001
+ key={`band:${group.id}`}
432
1002
  x={left}
433
1003
  y={rowY(first) - ROW_HEIGHT / 2 + GROUP_INSET}
434
1004
  width={Math.max(right - left, ROW_HEIGHT)}
435
1005
  height={(last - first + 1) * ROW_HEIGHT - 2 * GROUP_INSET}
436
1006
  rx={GROUP_RADIUS}
437
- className='fill-input-surface'
1007
+ className={mx('fill-input-surface', grow)}
438
1008
  />,
439
1009
  ];
440
1010
  })}
441
1011
 
442
- {/* Connectors next, so the drop to a child passes beneath any bar it crosses. */}
1012
+ {/* Connectors next, so the drop to a lane passes beneath any bar it crosses. */}
443
1013
  {rows.flatMap(({ lane, index }) =>
444
1014
  (lane.blockedOn ?? []).flatMap((depId) => {
445
1015
  const dep = rowById.get(depId);
446
- // Anchored on the dependency's last node, so the line meets a node rather than a bar edge.
447
- const anchor = dep && (laneEnd(dep.lane) ?? dep.lane.start);
448
- if (!dep || anchor === undefined) {
1016
+ if (!dep) {
449
1017
  return [];
450
1018
  }
451
- const anchorX = nodeX(dep.lane, anchor);
1019
+ // Anchored on the end of the dependency's last stretch, so the line meets a node rather
1020
+ // than a bar edge.
1021
+ const stretches = stretchesOf(dep.lane);
1022
+ const anchor = stretches[stretches.length - 1];
1023
+ if (!anchor) {
1024
+ return [];
1025
+ }
1026
+ const anchorX = anchor.to;
452
1027
  return [
453
1028
  <line
454
1029
  key={`${lane.id}:${depId}`}
@@ -461,14 +1036,13 @@ const GanttChart = forwardRef<SVGSVGElement, GanttChartProps>(({ classNames }, f
461
1036
  />,
462
1037
  // A waiter with no bar has nothing on its row for the line to reach, so it ends on a
463
1038
  // hollow node: the point the waiter is held at until the dependency resolves.
464
- ...(lane.start === undefined
1039
+ ...((lane.segments ?? []).length === 0
465
1040
  ? [
466
1041
  <circle
467
1042
  key={`${lane.id}:${depId}:hold`}
468
1043
  cx={anchorX}
469
1044
  cy={rowY(index)}
470
1045
  r={NODE_RADIUS}
471
- strokeDasharray='2 2'
472
1046
  className={mx('fill-base-surface', STATUS_COLOR.blocked.thread)}
473
1047
  />,
474
1048
  ]
@@ -477,100 +1051,228 @@ const GanttChart = forwardRef<SVGSVGElement, GanttChartProps>(({ classNames }, f
477
1051
  }),
478
1052
  )}
479
1053
 
480
- {/* Down from the parent node, a quarter bend, then right to the centre of the child's first node. */}
1054
+ {/* Down from the node a lane opened out of, a quarter bend, then right to where it begins. */}
481
1055
  {rows.flatMap(({ lane, index }) => {
482
- const source = delegationSource(lane);
483
- const sourceRow = lane.delegatedFrom && rowById.get(lane.delegatedFrom.laneId);
484
- if (!source || !sourceRow || lane.start === undefined) {
1056
+ const source = openSource(lane);
1057
+ const sourceRow = lane.openedFrom && rowById.get(lane.openedFrom.laneId);
1058
+ const stretches = stretchesOf(lane);
1059
+ if (!source || !sourceRow || stretches.length === 0) {
485
1060
  return [];
486
1061
  }
487
1062
  const sourceX = x(source.timestamp);
488
1063
  const y = rowY(index);
489
- const firstNode = Math.min(...laneTimes(lane));
490
- const targetX = Number.isFinite(firstNode) ? nodeX(lane, firstNode) : barStart(lane, lane.start) + BAR_OVERHANG;
1064
+ const targetX = stretches[0].from;
1065
+ // Down, a quarter bend, then right to where the lane begins — but only while there is
1066
+ // somewhere forward to go. A lane that begins at or before the node that opened it has no
1067
+ // run to make, and bending anyway would hook the connector sideways into the middle of its
1068
+ // own bar; the drop lands on the row instead. The bar stays where the data puts it either
1069
+ // way: a connector doubling back over itself reads as a line to somewhere else entirely.
1070
+ const path =
1071
+ targetX > sourceX + BEND_RADIUS
1072
+ ? `M ${sourceX} ${rowY(sourceRow.index)} V ${y - BEND_RADIUS} Q ${sourceX} ${y} ${sourceX + BEND_RADIUS} ${y} H ${targetX}`
1073
+ : `M ${sourceX} ${rowY(sourceRow.index)} V ${y}`;
491
1074
  return [
492
1075
  <path
493
- key={`delegation:${lane.id}`}
494
- d={`M ${sourceX} ${rowY(sourceRow.index)} V ${y - BEND_RADIUS} Q ${sourceX} ${y} ${sourceX + BEND_RADIUS} ${y} H ${targetX}`}
1076
+ key={`opened:${lane.id}`}
1077
+ d={path}
495
1078
  fill='none'
496
- className='stroke-fuchsia-500'
1079
+ // Drawn on by its dash rather than by its shape: `d` is beyond what a transition can
1080
+ // reach, while `stroke-dashoffset` is a property every renderer animates. `pathLength`
1081
+ // normalises the path to 1 so one dash covers it whatever its actual length — which is
1082
+ // also why these are inert, and the connector solid, when it is not opening.
1083
+ pathLength={1}
1084
+ strokeDasharray={1}
1085
+ strokeDashoffset={isOpening(lane) ? 1 : 0}
1086
+ className={mx(animate && mx('transition-[stroke-dashoffset]', ENTER_TRANSITION), CONNECTOR_CLASSNAME)}
497
1087
  />,
498
1088
  ];
499
1089
  })}
500
1090
 
501
- {rows.map(({ lane, index }) => {
502
- const end = laneEnd(lane);
503
- if (lane.start === undefined || end === undefined) {
504
- return null;
1091
+ {/* Back out of the lane's last stretch and up into the node its result answered. Dashed, and
1092
+ the spawn is not: one is the moment work was handed over, the other the moment an answer
1093
+ arrived, and a reader following a cascade needs to tell the two directions apart. */}
1094
+ {rows.flatMap(({ lane, index }) => {
1095
+ const target = lane.closedInto && markerById.get(lane.closedInto.markerId);
1096
+ const targetRow = lane.closedInto && rowById.get(lane.closedInto.laneId);
1097
+ const stretches = stretchesOf(lane);
1098
+ const last = stretches[stretches.length - 1];
1099
+ if (!target || !targetRow || !last) {
1100
+ return [];
505
1101
  }
506
- const bar = {
507
- x: barStart(lane, lane.start),
508
- y: rowY(index) - BAR_HEIGHT / 2,
509
- width: Math.max(x(end) + BAR_OVERHANG - barStart(lane, lane.start), BAR_HEIGHT),
510
- height: BAR_HEIGHT,
511
- rx: BAR_HEIGHT / 2,
512
- };
513
- // An opaque backing under the translucent tint: the connectors pass beneath the bars, and
514
- // without it they would show through.
515
- return (
516
- <g key={lane.id} className='cursor-pointer' onClick={() => onLaneSelect?.(lane)}>
517
- <rect {...bar} className='fill-base-surface' />
518
- <rect {...bar} className={STATUS_COLOR[lane.status].fill} />
519
- </g>
520
- );
1102
+ const targetX = x(target.timestamp);
1103
+ const y = rowY(index);
1104
+ const targetY = rowY(targetRow.index);
1105
+ // Which way the bend turns depends on whether the answer went up the chart or down it;
1106
+ // a lane is normally below the one it reports to, but nothing in the model requires it.
1107
+ const rise = Math.sign(targetY - y) * BEND_RADIUS;
1108
+ // Forward only, for the same reason the spawn connector is: a run doubling back reads as
1109
+ // a line to somewhere else. With nowhere to run, the rise happens at the answer's own node.
1110
+ const path =
1111
+ targetX > last.to + BEND_RADIUS
1112
+ ? `M ${last.to} ${y} H ${targetX - BEND_RADIUS} Q ${targetX} ${y} ${targetX} ${y + rise} V ${targetY}`
1113
+ : `M ${targetX} ${y} V ${targetY}`;
1114
+ return [
1115
+ <path key={`closed:${lane.id}`} d={path} fill='none' strokeDasharray='3 2' className={CONNECTOR_CLASSNAME} />,
1116
+ ];
521
1117
  })}
522
1118
 
523
- {/* The thread through a lane's nodes, so a row reads as a sequence rather than scattered dots. */}
524
- {rows.map(({ lane, index }) => {
525
- const times = laneTimes(lane);
526
- if (times.length < 2) {
527
- return null;
1119
+ {/* One bar per segment: the gaps between them are the stretches the lane was not worked. */}
1120
+ {rows.flatMap(({ lane, index }) =>
1121
+ stretchesOf(lane).map((stretch, segment) => {
1122
+ const start = stretch.from - BAR_OVERHANG;
1123
+ const bar = {
1124
+ x: start,
1125
+ y: rowY(index) - BAR_HEIGHT / 2,
1126
+ // An opening lane is a dot at its own beginning for one frame, so it extends from where
1127
+ // the connector lands instead of being there at full length before the connector is.
1128
+ width: isOpening(lane) ? BAR_HEIGHT : Math.max(stretch.to + BAR_OVERHANG - start, BAR_HEIGHT),
1129
+ height: BAR_HEIGHT,
1130
+ rx: BAR_HEIGHT / 2,
1131
+ };
1132
+ // An opaque backing under the translucent tint: the connectors pass beneath the bars, and
1133
+ // without it they would show through.
1134
+ return (
1135
+ <g key={`${lane.id}:${segment}`} className='cursor-pointer' onClick={() => onLaneSelect?.(lane)}>
1136
+ <rect {...bar} className={mx('fill-base-surface', grow)} />
1137
+ {lane.hue ? (
1138
+ <rect {...bar} className={mx(grow)} style={{ fill: hueColor(lane.hue), fillOpacity: 0.4 }} />
1139
+ ) : (
1140
+ <rect {...bar} className={mx(STATUS_COLOR[lane.status].fill, grow)} />
1141
+ )}
1142
+ </g>
1143
+ );
1144
+ }),
1145
+ )}
1146
+
1147
+ {/* The thread through a segment's nodes, so a stretch reads as a sequence rather than dots. */}
1148
+ {rows.flatMap(({ lane, index }) =>
1149
+ stretchesOf(lane).flatMap((stretch, segment) =>
1150
+ stretch.threadFrom === undefined || stretch.threadTo === undefined
1151
+ ? []
1152
+ : [
1153
+ // A hairline rect rather than a line: a line's `x1`/`x2` are attributes and nothing
1154
+ // else, while `x` and `width` are geometry properties a transition can reach, so the
1155
+ // thread grows with the node it is reaching for instead of arriving ahead of it.
1156
+ <rect
1157
+ key={`thread:${lane.id}:${segment}`}
1158
+ x={stretch.threadFrom}
1159
+ y={rowY(index) - THREAD_HEIGHT / 2}
1160
+ width={Math.max(stretch.threadTo - stretch.threadFrom, 0)}
1161
+ height={THREAD_HEIGHT}
1162
+ className={mx(
1163
+ slide && mx('transition-[x,width]', ENTER_TRANSITION),
1164
+ !lane.hue && STATUS_COLOR[lane.status].node,
1165
+ )}
1166
+ style={lane.hue ? { fill: hueColor(lane.hue) } : undefined}
1167
+ />,
1168
+ ],
1169
+ ),
1170
+ )}
1171
+
1172
+ {/* An answered wait, dashed from the node that began it to the one that ended it, so the stretch
1173
+ a task spent held up reads as part of its lane rather than a gap in it. */}
1174
+ {markers.flatMap((marker) => {
1175
+ const row = marker.wait && rowById.get(marker.laneId);
1176
+ const from = markerX.get(marker.id);
1177
+ const to = marker.wait && markerX.get(marker.wait.until);
1178
+ if (!row || from === undefined || to === undefined || to <= from) {
1179
+ return [];
528
1180
  }
529
- return (
1181
+ return [
530
1182
  <line
531
- key={`thread:${lane.id}`}
532
- x1={nodeX(lane, Math.min(...times))}
533
- x2={nodeX(lane, Math.max(...times))}
534
- y1={rowY(index)}
535
- y2={rowY(index)}
536
- className={STATUS_COLOR[lane.status].thread}
537
- />
538
- );
1183
+ key={`wait:${marker.id}`}
1184
+ x1={from}
1185
+ x2={to}
1186
+ y1={rowY(row.index)}
1187
+ y2={rowY(row.index)}
1188
+ strokeWidth={2}
1189
+ strokeDasharray='4 3'
1190
+ className={row.lane.hue ? undefined : STATUS_COLOR[row.lane.status].thread}
1191
+ style={row.lane.hue ? { stroke: hueStrongColor(row.lane.hue) } : undefined}
1192
+ />,
1193
+ ];
539
1194
  })}
540
1195
 
541
1196
  {/* Nodes last, over the bars and every line, so a line reads as ending at a node's centre. */}
542
1197
  {markers.map((marker) => {
543
1198
  const row = rowById.get(marker.laneId);
544
- return row ? (
545
- <HoverCard.Root key={marker.id}>
546
- <HoverCard.Trigger asChild>
1199
+ const cx = markerX.get(marker.id);
1200
+ return row && cx !== undefined ? (
1201
+ <Fragment key={marker.id}>
1202
+ {/* Pings out from behind the node until answered, so the node itself stays readable. */}
1203
+ {marker.pending && (
547
1204
  <circle
548
- cx={nodeX(row.lane, marker.timestamp)}
1205
+ cx={cx}
549
1206
  cy={rowY(row.index)}
550
1207
  r={NODE_RADIUS}
551
- className={mx(
552
- 'cursor-pointer stroke-base-surface transition-[stroke-width] hover:stroke-[3px] hover:stroke-base-fg',
553
- STATUS_COLOR[row.lane.status].node,
554
- )}
555
- onClick={() => onMarkerSelect?.(marker)}
1208
+ className='animate-ping transform-fill origin-center pointer-events-none fill-error-500'
556
1209
  />
557
- </HoverCard.Trigger>
558
- <HoverCard.Portal>
559
- <HoverCard.Content classNames='p-2 max-w-72 text-xs font-mono'>
1210
+ )}
1211
+ <HoverCard.Root
1212
+ open={openId === marker.id}
1213
+ onOpenChange={({ open }) =>
1214
+ setOpenId((previous) => (open ? marker.id : previous === marker.id ? undefined : previous))
1215
+ }
1216
+ >
1217
+ <HoverCard.Trigger asChild>
1218
+ <circle
1219
+ id={nodeIds.get(marker.id)}
1220
+ role='img'
1221
+ aria-label={marker.label}
1222
+ data-marker-id={marker.id}
1223
+ data-lane-id={marker.laneId}
1224
+ cx={cx}
1225
+ cy={rowY(row.index)}
1226
+ r={NODE_RADIUS}
1227
+ className={mx(
1228
+ 'cursor-pointer hover:stroke-[3px] hover:stroke-fg',
1229
+ // A question or an error is ringed in red; an error pulses while its lane stays
1230
+ // failed (an unanswered question pings instead — the ring drawn behind it).
1231
+ isAlert(marker) ? 'stroke-red-500 stroke-2' : 'stroke-base-surface',
1232
+ marker.level === 'error' && row.lane.status === 'failed' && 'animate-pulse',
1233
+ // `cx` as a transition: where a browser exposes SVG geometry as CSS the node
1234
+ // slides out of the one before it, and where it does not it simply appears.
1235
+ slide ? mx('transition-[stroke-width,cx]', ENTER_TRANSITION) : 'transition-[stroke-width]',
1236
+ // The live end pulses in the lane's stronger shade: scanning a wall of finished
1237
+ // lanes, the ones still moving should be findable without reading the legend.
1238
+ // A question or an error is a solid node in the lane's own hue: the same thread,
1239
+ // so it reads as that task's, but the stronger shade so it is found at a glance.
1240
+ activeEdges.has(marker.id)
1241
+ ? mx('animate-pulse', !row.lane.hue && STATUS_COLOR[row.lane.status].edge)
1242
+ : !row.lane.hue &&
1243
+ (isAlert(marker) ? STATUS_COLOR[row.lane.status].edge : STATUS_COLOR[row.lane.status].node),
1244
+ // The keyboard's place, ringed as a hovered node is.
1245
+ marker.id === currentId && 'stroke-[3px] stroke-fg',
1246
+ )}
1247
+ style={
1248
+ row.lane.hue
1249
+ ? { fill: isAlert(marker) ? hueStrongColor(row.lane.hue) : hueColor(row.lane.hue) }
1250
+ : undefined
1251
+ }
1252
+ onClick={() => {
1253
+ setCurrentId(marker.id);
1254
+ svgRef.current?.focus({ preventScroll: true });
1255
+ onMarkerSelect?.(marker);
1256
+ }}
1257
+ />
1258
+ </HoverCard.Trigger>
1259
+ <HoverCard.Content classNames='p-2 max-w-72 text-xs' data-testid='gantt.markerCard'>
560
1260
  <div className='font-medium truncate'>{marker.label}</div>
561
- <div className='text-description'>
562
- {marker.kind} · {format(marker.timestamp, 'HH:mm:ss.SSS')}
1261
+ <div className='text-fg-muted truncate'>{row.lane.label}</div>
1262
+ <div className='text-fg-muted tabular-nums'>
1263
+ {marker.kind && `${marker.kind} · `}
1264
+ {format(marker.timestamp, 'HH:mm:ss')}
1265
+ {/* Elapsed on the axis's own terms, so it reads against the tick labels. */}
1266
+ {` · +${formatElapsed(marker.timestamp - range.start, { span: marker.timestamp - range.start, step: 1_000 })}`}
563
1267
  {marker.level && marker.level !== 'info' && (
564
1268
  <span className={mx('ms-2', marker.level === 'error' ? 'text-error-text' : 'text-warning-text')}>
565
1269
  {marker.level}
566
1270
  </span>
567
1271
  )}
568
1272
  </div>
569
- <div className='text-description truncate'>{row.lane.label}</div>
570
- <HoverCard.Arrow />
571
1273
  </HoverCard.Content>
572
- </HoverCard.Portal>
573
- </HoverCard.Root>
1274
+ </HoverCard.Root>
1275
+ </Fragment>
574
1276
  ) : null;
575
1277
  })}
576
1278
  </svg>
@@ -582,8 +1284,9 @@ GanttChart.displayName = 'Gantt.Chart';
582
1284
  export const Gantt = {
583
1285
  Root: GanttRoot,
584
1286
  Legend: GanttLegend,
1287
+ AxisToggle: GanttAxisToggle,
1288
+ LegendToggle: GanttLegendToggle,
585
1289
  Chart: GanttChart,
586
- Meta: GanttMeta,
587
1290
  };
588
1291
 
589
- export type { GanttChartProps, GanttLegendProps, GanttMetaProps, GanttRootProps };
1292
+ export type { GanttAxisToggleProps, GanttChartProps, GanttLegendProps, GanttLegendToggleProps, GanttRootProps };