@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
@@ -7,13 +7,14 @@ import * as Schema from 'effect/Schema';
7
7
 
8
8
  import * as Process from '@dxos/compute/Process';
9
9
  import * as Trace from '@dxos/compute/Trace';
10
- import { Annotation } from '@dxos/echo';
10
+ import { Annotation, Obj } from '@dxos/echo';
11
11
  import { EID } from '@dxos/keys';
12
12
  import { Task } from '@dxos/types';
13
+ import { getHashHue } from '@dxos/ui-theme';
13
14
 
14
15
  import { type Span, buildSpanTree, flattenSpanTree } from '../execution-graph/index.ts';
15
-
16
16
  import {
17
+ type DelegationSource,
17
18
  type Lane,
18
19
  type LaneStatus,
19
20
  type Marker,
@@ -24,19 +25,99 @@ import {
24
25
 
25
26
  export interface BuildSessionTimelineInput {
26
27
  traceMessages: readonly Trace.Message[];
27
- processes?: readonly Process.Info[];
28
+ processes?: readonly Process.Process[];
28
29
  /** The sessions to draw; each contributes a lane and its checklist's task lanes. */
29
30
  sessions?: readonly Session[];
30
31
  tasks?: readonly Task.Task[];
32
+ /** Per task id, the status moves its edit history records, oldest first (see {@link readTaskStatusChanges}). */
33
+ taskStatusChanges?: ReadonlyMap<string, readonly TaskStatusChange[]>;
31
34
  /** Reference time; extends the range past open lanes. */
32
35
  now?: number;
33
36
  }
34
37
 
38
+ /** One move of a task's status, as its edit history records it. */
39
+ export type TaskStatusChange = {
40
+ /** Epoch ms. */
41
+ timestamp: number;
42
+ status: Task.Status;
43
+ /** Absent when the task held no status before. */
44
+ previousStatus?: Task.Status;
45
+ };
46
+
47
+ const isStatus = Schema.is(Task.Status);
48
+
49
+ /**
50
+ * The status moves in a task's edit history, oldest first; none for a task not stored in a database,
51
+ * which keeps no history.
52
+ */
53
+ export const readTaskStatusChanges = (task: Task.Task): TaskStatusChange[] =>
54
+ Obj.getDatabase(task) === undefined
55
+ ? []
56
+ : Obj.getChanges(task, { property: 'status' }).flatMap(({ time, before, after }) =>
57
+ isStatus(after)
58
+ ? [{ timestamp: time, status: after, ...(isStatus(before) ? { previousStatus: before } : {}) }]
59
+ : [],
60
+ );
61
+
62
+ /** Statuses of a task not yet picked up. */
63
+ const PENDING_STATUSES = new Set<Task.Status>(['todo', 'backlog']);
64
+
65
+ /**
66
+ * The edit history's status moves, each timed by the trace event recording the same move when there is
67
+ * one. History times are whole seconds, rounded down, so on their own they start a lane up to a second
68
+ * before the node that marks its start; everything derived from them — spans, nodes — reads these.
69
+ */
70
+ const refineStatusChanges = (
71
+ changes: ReadonlyMap<string, readonly TaskStatusChange[]>,
72
+ events: readonly Trace.FlatEvent[],
73
+ ): ReadonlyMap<string, readonly TaskStatusChange[]> => {
74
+ const traced = new Map<string, { timestamp: number; status: string }[]>();
75
+ for (const event of events) {
76
+ if (event.type === Trace.TaskStatusChanged.key) {
77
+ const data = decode(Trace.TaskStatusChanged.schema, event.data);
78
+ if (data) {
79
+ traced.set(data.taskId, [
80
+ ...(traced.get(data.taskId) ?? []),
81
+ { timestamp: event.timestamp, status: data.status },
82
+ ]);
83
+ }
84
+ }
85
+ }
86
+ return new Map(
87
+ [...changes].map(([taskId, list]) => {
88
+ const candidates = [...(traced.get(taskId) ?? [])];
89
+ return [
90
+ taskId,
91
+ list.map((change) => {
92
+ const index = candidates.findIndex(
93
+ ({ timestamp, status }) =>
94
+ status === change.status && Math.abs(timestamp - change.timestamp) <= HISTORY_MATCH_MS,
95
+ );
96
+ if (index < 0) {
97
+ return change;
98
+ }
99
+ const [match] = candidates.splice(index, 1);
100
+ return { ...change, timestamp: match.timestamp };
101
+ }),
102
+ ];
103
+ }),
104
+ );
105
+ };
106
+
107
+ /** Statuses that end a stretch of work on a task. */
108
+ const CLOSED_STATUSES = new Set<Task.Status>(['done', 'review', 'failed', 'cancelled']);
109
+
110
+ const isStatusChange = (detail: unknown): detail is TaskStatusChange =>
111
+ typeof detail === 'object' && detail !== null && 'status' in detail && isStatus(detail.status);
112
+
35
113
  const ACTIVE_STATES = new Set<Process.State>([Process.State.RUNNING, Process.State.HYBERNATING]);
36
114
 
37
115
  /** How long an open lane may be silent before the axis stops following `now`. */
38
116
  const OPEN_LANE_STALE_MS = 10 * 60_000;
39
117
 
118
+ /** How far apart a task's recorded status move and the trace event recording the same move may land. */
119
+ const HISTORY_MATCH_MS = 5_000;
120
+
40
121
  const TASK_STATUS: Partial<Record<Task.Status, LaneStatus>> = {
41
122
  todo: 'pending',
42
123
  backlog: 'pending',
@@ -83,21 +164,19 @@ interface TaskSegment {
83
164
  }
84
165
 
85
166
  /**
86
- * Cuts a session's events into one segment per task worked, from the status events its task tools
87
- * write. Only tasks in `taskIds` — the session's own checklist — take part: an agent is free to move
88
- * a task belonging to nothing on this chart, and such an event must not close the segment that is
89
- * open or move the boundary.
167
+ * Cuts a session's events into segments of task work, from the status events its task tools write.
168
+ * Only tasks in `taskIds` — the session's own checklist — take part: an agent is free to move a task
169
+ * belonging to nothing on this chart, and such an event must not open or close anything here.
90
170
  *
91
- * A `started` event opens a segment and closes the one still open — the agent keeps exactly one task
92
- * in progress. Any other status ends the agent's work on the task: `todo` puts it down as surely as
93
- * `done`, and whatever the agent does next is no longer that task's. A task can also finish without
94
- * a start: delegation marks every task it hands over
95
- * `started` before the agent's first turn, so the run's only event for that task is the one closing
96
- * it. Such a task gets the stretch since the last boundary, which is where its work happened — but
97
- * only on the transition out of `started` and only while no other task holds the stretch: a task
98
- * merely dismissed (`todo` → `blocked`) claims nothing, a second close (`review` → `done`) mints
99
- * nothing, and a close arriving after the next task has started leaves that task's segment alone
100
- * rather than overlapping it.
171
+ * An agent may hold several tasks in progress at once, and may put one down and pick it up again as
172
+ * it discovers what depends on what: a `started` event opens a segment for its task and leaves every
173
+ * other open segment alone, and any other status closes that task's open segment. A task can also
174
+ * finish without a start: delegation marks every task it hands over `started` before the agent's
175
+ * first turn, so the run's only event for that task is the one closing it. Such a task gets the
176
+ * stretch since the last boundary, which is where its work happened — but only on the transition out
177
+ * of `started` and only while no other task holds the stretch: a task merely dismissed
178
+ * (`todo` → `blocked`) claims nothing, a second close (`review` → `done`) mints nothing, and a close
179
+ * arriving while other work is open leaves that work alone rather than overlapping it.
101
180
  */
102
181
  const buildTaskSegments = (
103
182
  events: readonly Trace.FlatEvent[],
@@ -105,15 +184,8 @@ const buildTaskSegments = (
105
184
  taskIds: ReadonlySet<string>,
106
185
  ): TaskSegment[] => {
107
186
  const segments: TaskSegment[] = [];
108
- let open: TaskSegment | undefined;
187
+ const open = new Map<string, TaskSegment>();
109
188
  let boundary = sessionStart;
110
- const close = (segment: TaskSegment, timestamp: number): void => {
111
- segment.end = timestamp;
112
- boundary = timestamp;
113
- if (open === segment) {
114
- open = undefined;
115
- }
116
- };
117
189
 
118
190
  for (const event of events) {
119
191
  if (event.type !== Trace.TaskStatusChanged.key) {
@@ -123,37 +195,37 @@ const buildTaskSegments = (
123
195
  if (!data || !taskIds.has(data.taskId)) {
124
196
  continue;
125
197
  }
198
+ const current = open.get(data.taskId);
126
199
  if (data.status === 'started') {
127
- if (open && open.taskId !== data.taskId) {
128
- close(open, event.timestamp);
129
- }
130
- if (!open) {
131
- open = { taskId: data.taskId, laneId: taskLaneId(data.taskId), start: event.timestamp };
132
- segments.push(open);
133
- boundary = event.timestamp;
134
- }
135
- } else {
136
- const started = segments.findLast((candidate) => candidate.taskId === data.taskId && candidate.end === undefined);
137
- if (started) {
138
- close(started, event.timestamp);
139
- } else if (data.previousStatus === 'started' && open === undefined) {
140
- const segment = {
141
- taskId: data.taskId,
142
- laneId: taskLaneId(data.taskId),
143
- start: Math.min(boundary ?? event.timestamp, event.timestamp),
144
- };
200
+ if (!current) {
201
+ const segment = { taskId: data.taskId, laneId: taskLaneId(data.taskId), start: event.timestamp };
145
202
  segments.push(segment);
146
- close(segment, event.timestamp);
203
+ open.set(data.taskId, segment);
147
204
  }
205
+ boundary = event.timestamp;
206
+ } else if (current) {
207
+ current.end = event.timestamp;
208
+ open.delete(data.taskId);
209
+ boundary = event.timestamp;
210
+ } else if (data.previousStatus === 'started' && open.size === 0) {
211
+ segments.push({
212
+ taskId: data.taskId,
213
+ laneId: taskLaneId(data.taskId),
214
+ start: Math.min(boundary ?? event.timestamp, event.timestamp),
215
+ end: event.timestamp,
216
+ });
217
+ boundary = event.timestamp;
148
218
  }
149
219
  }
150
220
  return segments;
151
221
  };
152
222
 
153
223
  /**
154
- * The segment an event belongs to. Segments meet where one task starts over another, so a
155
- * status event is matched to its own task's segment, and any other event at a shared boundary
156
- * goes to the later segment — the newcomer's start is the first thing the agent did on it.
224
+ * The segment an event belongs to. A status event goes to its own task's segment. Any other event
225
+ * goes to the one segment in progress when it happened; where segments merely meet, the one ending
226
+ * there yields to the one beginning — the newcomer's start is the first thing the agent did on it.
227
+ * With several tasks genuinely in progress at once the event belongs to none of them in particular,
228
+ * so it stays with the session rather than being credited to whichever started last.
157
229
  */
158
230
  const segmentFor = (segments: readonly TaskSegment[] | undefined, event: Trace.FlatEvent): TaskSegment | undefined => {
159
231
  if (!segments) {
@@ -165,7 +237,76 @@ const segmentFor = (segments: readonly TaskSegment[] | undefined, event: Trace.F
165
237
  const data = decode(Trace.TaskStatusChanged.schema, event.data);
166
238
  return data && segments.findLast((segment) => segment.taskId === data.taskId && contains(segment));
167
239
  }
168
- return segments.findLast(contains);
240
+ const candidates = segments.filter(contains);
241
+ const inside = candidates.filter((segment) => segment.end === undefined || event.timestamp < segment.end);
242
+ if (inside.length > 1) {
243
+ return undefined;
244
+ }
245
+ return inside[0] ?? candidates.at(-1);
246
+ };
247
+
248
+ /**
249
+ * The stretch a task's edit history says it was worked: from its first move to `started` to the last
250
+ * move out of it. `end` is absent while the history leaves it `started`; `last` is its last move.
251
+ * Absent when the task was never started.
252
+ */
253
+ const historySpan = (
254
+ changes: readonly TaskStatusChange[],
255
+ ): { start: number; end?: number; last: number } | undefined => {
256
+ let start: number | undefined;
257
+ let end: number | undefined;
258
+ let open = false;
259
+ for (const change of changes) {
260
+ if (change.status === 'started') {
261
+ start ??= change.timestamp;
262
+ open = true;
263
+ } else if (open) {
264
+ end = change.timestamp;
265
+ open = false;
266
+ }
267
+ }
268
+ const last = changes.at(-1)?.timestamp;
269
+ return start === undefined || last === undefined ? undefined : { start, end: open ? undefined : end, last };
270
+ };
271
+
272
+ /**
273
+ * The stretches a task was put down between two runs of work: from a move out of `started` that did
274
+ * not finish it (a question blocking it, say) to the next move back. One still open is no gap — the
275
+ * lane simply ends there — and nothing before the first start is one either.
276
+ */
277
+ const workGaps = (changes: readonly TaskStatusChange[]): { start: number; end: number }[] => {
278
+ const gaps: { start: number; end: number }[] = [];
279
+ let started = false;
280
+ let pausedAt: number | undefined;
281
+ for (const change of changes) {
282
+ if (change.status === 'started') {
283
+ if (pausedAt !== undefined && change.timestamp > pausedAt) {
284
+ gaps.push({ start: pausedAt, end: change.timestamp });
285
+ }
286
+ started = true;
287
+ pausedAt = undefined;
288
+ } else if (started && pausedAt === undefined && !CLOSED_STATUSES.has(change.status)) {
289
+ pausedAt = change.timestamp;
290
+ }
291
+ }
292
+ return gaps;
293
+ };
294
+
295
+ /** A question or answer in the task's log; other entries are edits the edit history already holds. */
296
+ const exchangeMarker = (entry: Task.HistoryEntry, id: string, laneId: string): Marker | undefined => {
297
+ const timestamp = Date.parse(entry.date);
298
+ if (Number.isNaN(timestamp)) {
299
+ return undefined;
300
+ }
301
+ const base = { id, laneId, kind: 'task' as const, timestamp, detail: entry };
302
+ switch (entry.event) {
303
+ case 'question':
304
+ return { ...base, label: entry.text, level: 'warn' };
305
+ case 'answer':
306
+ return { ...base, label: `Answered: ${entry.answer}` };
307
+ default:
308
+ return undefined;
309
+ }
169
310
  };
170
311
 
171
312
  interface SubAgentSpan {
@@ -183,11 +324,13 @@ export const buildSessionTimeline = ({
183
324
  processes = [],
184
325
  sessions,
185
326
  tasks = [],
327
+ taskStatusChanges: recordedChanges,
186
328
  now,
187
329
  }: BuildSessionTimelineInput): SessionTimeline => {
188
330
  const root = buildSpanTree(traceMessages);
189
331
  const spans = flattenSpanTree(root);
190
332
  const events = spans.flatMap((span) => span.events).sort((a, b) => a.timestamp - b.timestamp);
333
+ const taskStatusChanges = recordedChanges && refineStatusChanges(recordedChanges, events);
191
334
  const requestBegins = events.filter(
192
335
  (event): event is Trace.FlatEvent & { meta: { pid: string } } =>
193
336
  event.type === Trace.AgentRequestBegin.key && event.meta.pid !== undefined,
@@ -243,7 +386,7 @@ export const buildSessionTimeline = ({
243
386
  sources.push(...byKey.values());
244
387
  }
245
388
 
246
- const processByPid = new Map<string, Process.Info>(processes.map((process) => [process.pid, process]));
389
+ const processByPid = new Map<string, Process.Process>(processes.map((process) => [process.pid, process]));
247
390
  const taskById = new Map(tasks.map((task) => [task.id, task]));
248
391
  const laneByPid = new Map<string, string>();
249
392
  const lanes: MutableLane[] = [];
@@ -325,12 +468,15 @@ export const buildSessionTimeline = ({
325
468
  const chatTaskIds = new Set(chatTasks.map((task) => task.id));
326
469
  const taskLanes = new Map<string, MutableLane>();
327
470
  for (const task of chatTasks) {
471
+ // A sub-task of a task on the same checklist nests under it, so the chart keeps the hierarchy
472
+ // the ledger shows; any other task hangs off the session.
473
+ const parentTask = Task.getParentTask(task);
328
474
  const taskLane: MutableLane = {
329
475
  id: taskLaneId(task.id),
330
476
  kind: 'task',
331
477
  label: task.title,
332
478
  status: taskLaneStatus(task, tasks),
333
- parentId: laneId,
479
+ parentId: parentTask && chatTaskIds.has(parentTask.id) ? taskLaneId(parentTask.id) : laneId,
334
480
  sessionId: source.session?.id,
335
481
  taskId: task.id,
336
482
  };
@@ -371,6 +517,25 @@ export const buildSessionTimeline = ({
371
517
  taskLane.end = segment.end === undefined ? undefined : Math.max(taskLane.end ?? segment.end, segment.end);
372
518
  }
373
519
 
520
+ // The task's edit history bounds its lane too, so a task worked where the trace does not reach
521
+ // (another device, a pruned feed, a person's edit) still gets a span. The lane stays open only while
522
+ // the task itself is `started`, whatever the history last says.
523
+ for (const task of chatTasks) {
524
+ const taskLane = taskLanes.get(task.id);
525
+ const span = historySpan(taskStatusChanges?.get(task.id) ?? []);
526
+ if (!taskLane || !span) {
527
+ continue;
528
+ }
529
+ const tracedEnd = taskLane.start === undefined ? undefined : taskLane.end;
530
+ taskLane.start = Math.min(taskLane.start ?? span.start, span.start);
531
+ if (span.end === undefined && task.status === 'started') {
532
+ taskLane.end = undefined;
533
+ } else {
534
+ const end = span.end ?? tracedEnd ?? span.last;
535
+ taskLane.end = tracedEnd === undefined ? end : Math.max(tracedEnd, end);
536
+ }
537
+ }
538
+
374
539
  for (const subPid of subAgentPids) {
375
540
  const match = subAgentSpans.find((candidate) => candidate.pid === subPid);
376
541
  const process = processByPid.get(subPid);
@@ -410,6 +575,36 @@ export const buildSessionTimeline = ({
410
575
  }
411
576
  }
412
577
 
578
+ // A task no drawn session works (an external harness's, a person's) is drawn from its edit history
579
+ // alone, as a lane of its own, so the chart still shows it; one never started has no span to draw.
580
+ const lanedTaskIds = new Set(lanes.flatMap((lane) => (lane.taskId === undefined ? [] : [lane.taskId])));
581
+ const unownedLanes = new Map<string, MutableLane>();
582
+ for (const task of tasks) {
583
+ const span = lanedTaskIds.has(task.id) ? undefined : historySpan(taskStatusChanges?.get(task.id) ?? []);
584
+ if (!span) {
585
+ continue;
586
+ }
587
+ unownedLanes.set(task.id, {
588
+ id: taskLaneId(task.id),
589
+ kind: 'task',
590
+ label: task.title,
591
+ status: taskLaneStatus(task, tasks),
592
+ start: span.start,
593
+ end: span.end === undefined && task.status === 'started' ? undefined : (span.end ?? span.last),
594
+ taskId: task.id,
595
+ });
596
+ }
597
+ for (const [taskId, lane] of unownedLanes) {
598
+ const blockedOn = (taskById.get(taskId)?.dependsOn ?? [])
599
+ .map((ref) => Task.refEntityId(ref))
600
+ .filter((id): id is string => id !== undefined && unownedLanes.has(id))
601
+ .map(taskLaneId);
602
+ if (blockedOn.length > 0) {
603
+ lane.blockedOn = blockedOn;
604
+ }
605
+ lanes.push(lane);
606
+ }
607
+
413
608
  // A delegated task IS its child session, drawn with the child's own span; the parent's markers in
414
609
  // that stretch are the parent's work of handing it over, so the segment goes rather than moving.
415
610
  for (const [sessionId, segments] of segmentsBySession) {
@@ -419,6 +614,67 @@ export const buildSessionTimeline = ({
419
614
  );
420
615
  }
421
616
 
617
+ // Each status move in a task's edit history is a node on its lane, and so is each question and answer
618
+ // in its log. A move the trace also recorded is drawn once, from the history, taking the trace event's
619
+ // pid. Only a trace event on a drawn lane is matched, so none is hidden that had a node.
620
+ const tracedChanges = new Map<string, Trace.FlatEvent[]>();
621
+ for (const event of events) {
622
+ const drawn =
623
+ (event.meta.pid !== undefined && laneByPid.has(event.meta.pid)) ||
624
+ (event.meta.parentPid !== undefined && laneByPid.has(event.meta.parentPid));
625
+ if (drawn && event.type === Trace.TaskStatusChanged.key) {
626
+ const data = decode(Trace.TaskStatusChanged.schema, event.data);
627
+ if (data) {
628
+ tracedChanges.set(data.taskId, [...(tracedChanges.get(data.taskId) ?? []), event]);
629
+ }
630
+ }
631
+ }
632
+ const mergedEvents = new Set<Trace.FlatEvent>();
633
+ // A delegated task's lane is its child session, which carries the task id, so it gets the nodes too.
634
+ for (const lane of lanes) {
635
+ const task = lane.taskId === undefined ? undefined : taskById.get(lane.taskId);
636
+ if (!task) {
637
+ continue;
638
+ }
639
+ const traced = tracedChanges.get(task.id) ?? [];
640
+ const changes = taskStatusChanges?.get(task.id) ?? [];
641
+ // Waiting to be picked up is the lane's absence, not an event on it: a node for it would sit
642
+ // alone before the bar, reading as work that happened and then a pause.
643
+ const firstStart = changes.findIndex((change) => change.status === 'started');
644
+ for (const [index, change] of changes.entries()) {
645
+ if (PENDING_STATUSES.has(change.status) && (firstStart < 0 || index < firstStart)) {
646
+ continue;
647
+ }
648
+ const match = traced.find(
649
+ (event) =>
650
+ !mergedEvents.has(event) &&
651
+ decode(Trace.TaskStatusChanged.schema, event.data)?.status === change.status &&
652
+ Math.abs(event.timestamp - change.timestamp) <= HISTORY_MATCH_MS,
653
+ );
654
+ if (match) {
655
+ mergedEvents.add(match);
656
+ }
657
+ markers.push({
658
+ id: `${lane.id}:${markers.length}`,
659
+ laneId: lane.id,
660
+ kind: 'task',
661
+ // The trace's instant when it recorded the same move: edit-history times are whole seconds, so
662
+ // on their own they land before the work they mark.
663
+ timestamp: match?.timestamp ?? change.timestamp,
664
+ label: `Task ${change.status}`,
665
+ level: change.status === 'failed' ? 'error' : undefined,
666
+ pid: match?.meta.pid,
667
+ detail: change,
668
+ });
669
+ }
670
+ for (const entry of task.history ?? []) {
671
+ const marker = exchangeMarker(entry, `${lane.id}:${markers.length}`, lane.id);
672
+ if (marker) {
673
+ markers.push(marker);
674
+ }
675
+ }
676
+ }
677
+
422
678
  // Dependencies named the task lane; they follow it to the session that replaced it.
423
679
  for (const lane of lanes) {
424
680
  if (lane.blockedOn) {
@@ -428,7 +684,11 @@ export const buildSessionTimeline = ({
428
684
 
429
685
  // Markers and token totals, attributed to the lane owning the event's pid or its parent pid.
430
686
  const tokens = new Map<string, { usage: TokenUsage; toolCalls: number }>();
431
- const spawnMarkerByPid = new Map<string, string>();
687
+ // The marker's own lane travels with its id: `markerLaneId` routes an event to whichever task
688
+ // segment was open at the time, so a delegation node does not always land on the session's lane —
689
+ // and a connector drawn to a row that does not hold the node points at nothing.
690
+ const spawnMarkerByPid = new Map<string, DelegationSource>();
691
+ const returnMarkerByPid = new Map<string, DelegationSource>();
432
692
  for (const event of events) {
433
693
  const laneId =
434
694
  (event.meta.pid && laneByPid.get(event.meta.pid)) ??
@@ -443,13 +703,21 @@ export const buildSessionTimeline = ({
443
703
  event.type === Trace.AgentRequestBegin.key || event.type === Trace.AgentRequestEnd.key
444
704
  ? laneId
445
705
  : (segmentFor(segmentsBySession.get(laneId), event)?.laneId ?? laneId);
446
- const marker = toMarker(event, `${markerLaneId}:${markers.length}`, markerLaneId);
706
+ const marker = mergedEvents.has(event)
707
+ ? undefined
708
+ : toMarker(event, `${markerLaneId}:${markers.length}`, markerLaneId);
447
709
  if (marker) {
448
710
  markers.push(marker);
449
- if (marker.kind === 'delegation') {
711
+ if (event.type === Trace.DelegationSpawned.key) {
450
712
  const data = decode(Trace.DelegationSpawned.schema, event.data);
451
713
  if (data) {
452
- spawnMarkerByPid.set(data.pid, marker.id);
714
+ spawnMarkerByPid.set(data.pid, { laneId: marker.laneId, markerId: marker.id });
715
+ }
716
+ }
717
+ if (event.type === Trace.DelegationCompleted.key) {
718
+ const data = decode(Trace.DelegationCompleted.schema, event.data);
719
+ if (data) {
720
+ returnMarkerByPid.set(data.pid, { laneId: marker.laneId, markerId: marker.id });
453
721
  }
454
722
  }
455
723
  }
@@ -481,26 +749,45 @@ export const buildSessionTimeline = ({
481
749
 
482
750
  // The connector starts at the spawn marker, else at the supervisor's last node before the child began.
483
751
  for (const { lane, sessionLaneId } of childSessions) {
484
- const markerId =
485
- (lane.pid && spawnMarkerByPid.get(lane.pid)) ??
486
- markers.filter((marker) => marker.laneId === sessionLaneId && marker.timestamp <= (lane.start ?? 0)).at(-1)?.id;
487
- if (markerId !== undefined) {
488
- lane.delegatedFrom = { laneId: sessionLaneId, markerId };
752
+ const fallbackId = markers
753
+ .filter((marker) => marker.laneId === sessionLaneId && marker.timestamp <= (lane.start ?? 0))
754
+ .at(-1)?.id;
755
+ const spawn =
756
+ (lane.pid === undefined ? undefined : spawnMarkerByPid.get(lane.pid)) ??
757
+ (fallbackId === undefined ? undefined : { laneId: sessionLaneId, markerId: fallbackId });
758
+ if (spawn) {
759
+ lane.delegatedFrom = spawn;
760
+ }
761
+ // The return connector has no such fallback: only the completion event says the child reported
762
+ // back, and guessing from the supervisor's next node would invent a causal edge that may not
763
+ // exist — a child can end without ever answering.
764
+ const returned = lane.pid === undefined ? undefined : returnMarkerByPid.get(lane.pid);
765
+ if (returned) {
766
+ lane.returnedTo = returned;
489
767
  }
490
768
  }
491
769
 
492
770
  // A session working exactly one task is that task: the two lanes would carry the same label,
493
771
  // so the task lane folds into the session's (which keeps its run span and totals, and takes the
494
772
  // task's id and status so it still reads as the work). Only a lone in-session task, with nothing
495
- // depending on it and no spawned child, folds — a checklist of several stays a tree.
773
+ // depending on it, no sub-tasks and no spawned child, folds — a checklist of several stays a tree.
496
774
  for (const session of lanes.filter((lane) => lane.kind === 'session' && lane.parentId === undefined)) {
497
775
  const children = lanes.filter((lane) => lane.parentId === session.id);
498
776
  const [task] = children;
499
- if (children.length !== 1 || task.kind !== 'task' || lanes.some((lane) => lane.blockedOn?.includes(task.id))) {
777
+ if (
778
+ children.length !== 1 ||
779
+ task.kind !== 'task' ||
780
+ lanes.some((lane) => lane.parentId === task.id || lane.blockedOn?.includes(task.id))
781
+ ) {
500
782
  continue;
501
783
  }
502
784
  lanes.splice(lanes.indexOf(task), 1);
503
785
  session.taskId = task.taskId;
786
+ // The task was started (delegated) before the session's first request, so the folded lane begins
787
+ // with the task; otherwise its first node would sit alone ahead of the bar.
788
+ if (task.start !== undefined) {
789
+ session.start = session.start === undefined ? task.start : Math.min(session.start, task.start);
790
+ }
504
791
  if (task.status === 'blocked' || task.status === 'review' || task.status === 'pending') {
505
792
  session.status = task.status;
506
793
  }
@@ -511,6 +798,80 @@ export const buildSessionTimeline = ({
511
798
  });
512
799
  }
513
800
 
801
+ // A parent task's lane carries each of its sub-tasks' starts and finishes as nodes of its own, so it reads as the
802
+ // span of the work it contains rather than one unbroken bar, and its first node is where that work began. Read from
803
+ // the task tree rather than the lanes' `parentId`, which joins a sub-task to its parent only when both sit on one
804
+ // checklist, and carried to every ancestor, since a grandchild's work is inside its grandparent too.
805
+ const laneById = new Map(lanes.map((lane) => [lane.id, lane]));
806
+ const laneByTaskId = new Map(lanes.flatMap((lane) => (lane.taskId === undefined ? [] : [[lane.taskId, lane]])));
807
+ const ancestorLanesOf = (lane: Lane): Lane[] => {
808
+ const task = lane.taskId === undefined ? undefined : taskById.get(lane.taskId);
809
+ const ancestors: Lane[] = [];
810
+ const seen = new Set<string>(task ? [task.id] : []);
811
+ for (
812
+ let parent = task && Task.getParentTask(task);
813
+ parent && !seen.has(parent.id);
814
+ parent = Task.getParentTask(parent)
815
+ ) {
816
+ seen.add(parent.id);
817
+ const parentLane = laneByTaskId.get(parent.id);
818
+ if (parentLane?.kind === 'task') {
819
+ ancestors.push(parentLane);
820
+ }
821
+ }
822
+ return ancestors;
823
+ };
824
+ for (const marker of [...markers]) {
825
+ const lane = laneById.get(marker.laneId);
826
+ if (lane?.kind !== 'task' || !isStatusChange(marker.detail)) {
827
+ continue;
828
+ }
829
+ const { status } = marker.detail;
830
+ if (status !== 'started' && !CLOSED_STATUSES.has(status)) {
831
+ continue;
832
+ }
833
+ for (const ancestor of ancestorLanesOf(lane)) {
834
+ markers.push({
835
+ ...marker,
836
+ id: `${ancestor.id}:sub:${marker.id}`,
837
+ laneId: ancestor.id,
838
+ label: `${lane.label}: ${status}`,
839
+ });
840
+ }
841
+ }
842
+
843
+ // A parent task encloses its sub-tasks, so its bar begins no later than the earliest of theirs. Read
844
+ // from the task tree rather than the lanes' `parentId`, which joins a sub-task to its parent only when
845
+ // both sit on one checklist: a parent drawn from its edit history alone, or a sub-task worked by
846
+ // another session, would otherwise begin at its own `started` move, after work it contains.
847
+ for (const lane of lanes) {
848
+ const task = lane.taskId === undefined ? undefined : taskById.get(lane.taskId);
849
+ if (!task || lane.start === undefined) {
850
+ continue;
851
+ }
852
+ const seen = new Set<string>([task.id]);
853
+ for (let parent = Task.getParentTask(task); parent && !seen.has(parent.id); parent = Task.getParentTask(parent)) {
854
+ seen.add(parent.id);
855
+ const parentLane = laneByTaskId.get(parent.id);
856
+ if (parentLane?.start !== undefined && lane.start < parentLane.start) {
857
+ parentLane.start = lane.start;
858
+ }
859
+ }
860
+ }
861
+
862
+ // Hashed from the mnemonic, as the task's mnemonic chip is, so a lane and its chip share a hue.
863
+ for (const lane of lanes) {
864
+ const task = lane.taskId === undefined ? undefined : taskById.get(lane.taskId);
865
+ if (task) {
866
+ lane.hue = getHashHue(Obj.getMnemonic(task));
867
+ // After the fold, so a session standing for its one task breaks where the task was put down too.
868
+ const gaps = workGaps(taskStatusChanges?.get(task.id) ?? []);
869
+ if (gaps.length > 0) {
870
+ lane.gaps = gaps;
871
+ }
872
+ }
873
+ }
874
+
514
875
  const times = [
515
876
  ...lanes.flatMap((lane) => [lane.start, lane.end]).filter((time): time is number => time !== undefined),
516
877
  ...markers.map((marker) => marker.timestamp),
@@ -535,7 +896,7 @@ const taskLaneStatus = (task: Task.Task, tasks: readonly Task.Task[]): LaneStatu
535
896
  return (task.status && TASK_STATUS[task.status]) ?? 'pending';
536
897
  };
537
898
 
538
- const subSessionStatus = (endEvent: Trace.FlatEvent | undefined, process: Process.Info | undefined): LaneStatus => {
899
+ const subSessionStatus = (endEvent: Trace.FlatEvent | undefined, process: Process.Process | undefined): LaneStatus => {
539
900
  if (endEvent) {
540
901
  return decode(Trace.OperationEnd.schema, endEvent.data)?.outcome === 'failure' ? 'failed' : 'done';
541
902
  }
@@ -574,6 +935,16 @@ const toMarker = (event: Trace.FlatEvent, id: string, laneId: string): Marker |
574
935
  const data = decode(Trace.DelegationSpawned.schema, event.data);
575
936
  return { ...base, kind: 'delegation', label: 'Delegated', detail: data };
576
937
  }
938
+ case Trace.DelegationCompleted.key: {
939
+ const data = decode(Trace.DelegationCompleted.schema, event.data);
940
+ return {
941
+ ...base,
942
+ kind: 'delegation',
943
+ label: data?.status === 'failure' ? 'Sub-agent failed' : `Returned${data?.result ? `: ${data.result}` : ''}`,
944
+ level: data?.status === 'failure' ? 'error' : undefined,
945
+ detail: data,
946
+ };
947
+ }
577
948
  case Trace.OperationStart.key: {
578
949
  const data = decode(Trace.OperationStart.schema, event.data);
579
950
  return { ...base, kind: 'operation', label: data?.name ?? data?.key ?? 'Operation' };