@dxos/react-ui-trace 0.11.1 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/README.md +1 -1
  2. package/dist/lib/index.mjs +1722 -561
  3. package/dist/lib/index.mjs.map +1 -1
  4. package/dist/lib/testing.mjs +9 -8
  5. package/dist/lib/testing.mjs.map +1 -1
  6. package/dist/lib/translations.mjs +5 -0
  7. package/dist/lib/translations.mjs.map +1 -1
  8. package/dist/types/src/components/Gantt/Gantt.d.ts +109 -24
  9. package/dist/types/src/components/Gantt/Gantt.d.ts.map +1 -1
  10. package/dist/types/src/components/Gantt/Gantt.stories.d.ts +73 -3
  11. package/dist/types/src/components/Gantt/Gantt.stories.d.ts.map +1 -1
  12. package/dist/types/src/components/Gantt/gantt-rows.d.ts +26 -0
  13. package/dist/types/src/components/Gantt/gantt-rows.d.ts.map +1 -0
  14. package/dist/types/src/components/Gantt/gantt-rows.test.d.ts +2 -0
  15. package/dist/types/src/components/Gantt/gantt-rows.test.d.ts.map +1 -0
  16. package/dist/types/src/components/Gantt/gantt-scale.d.ts +57 -0
  17. package/dist/types/src/components/Gantt/gantt-scale.d.ts.map +1 -0
  18. package/dist/types/src/components/Gantt/gantt-scale.test.d.ts +2 -0
  19. package/dist/types/src/components/Gantt/gantt-scale.test.d.ts.map +1 -0
  20. package/dist/types/src/components/Gantt/index.d.ts +2 -0
  21. package/dist/types/src/components/Gantt/index.d.ts.map +1 -1
  22. package/dist/types/src/components/Gantt/useEnter.d.ts +13 -0
  23. package/dist/types/src/components/Gantt/useEnter.d.ts.map +1 -0
  24. package/dist/types/src/components/Gantt/useRangeTween.d.ts +12 -0
  25. package/dist/types/src/components/Gantt/useRangeTween.d.ts.map +1 -0
  26. package/dist/types/src/components/ProcessTree/ProcessTree.d.ts +3 -3
  27. package/dist/types/src/components/ProcessTree/ProcessTree.d.ts.map +1 -1
  28. package/dist/types/src/components/ProcessTree/ProcessTree.stories.d.ts.map +1 -1
  29. package/dist/types/src/components/Timeline/Timeline.d.ts +10 -2
  30. package/dist/types/src/components/Timeline/Timeline.d.ts.map +1 -1
  31. package/dist/types/src/components/Timeline/Timeline.stories.d.ts +8 -4
  32. package/dist/types/src/components/Timeline/Timeline.stories.d.ts.map +1 -1
  33. package/dist/types/src/components/Timeline/timeline-layout.d.ts +35 -0
  34. package/dist/types/src/components/Timeline/timeline-layout.d.ts.map +1 -0
  35. package/dist/types/src/components/Timeline/timeline-layout.test.d.ts +2 -0
  36. package/dist/types/src/components/Timeline/timeline-layout.test.d.ts.map +1 -0
  37. package/dist/types/src/components/TracePanel/SpanTreeView.d.ts +15 -0
  38. package/dist/types/src/components/TracePanel/SpanTreeView.d.ts.map +1 -0
  39. package/dist/types/src/components/TracePanel/TracePanel.d.ts +3 -3
  40. package/dist/types/src/components/TracePanel/TracePanel.d.ts.map +1 -1
  41. package/dist/types/src/components/TracePanel/TracePanel.stories.d.ts +46 -0
  42. package/dist/types/src/components/TracePanel/TracePanel.stories.d.ts.map +1 -0
  43. package/dist/types/src/components/TracePanel/trace-filter.d.ts +7 -2
  44. package/dist/types/src/components/TracePanel/trace-filter.d.ts.map +1 -1
  45. package/dist/types/src/components/TracePanel/useTraceMenu.d.ts.map +1 -1
  46. package/dist/types/src/execution-graph/execution-graph.d.ts +1 -1
  47. package/dist/types/src/execution-graph/execution-graph.d.ts.map +1 -1
  48. package/dist/types/src/hooks/useExecutionGraph.d.ts +2 -2
  49. package/dist/types/src/hooks/useExecutionGraph.d.ts.map +1 -1
  50. package/dist/types/src/hooks/useSessionTimeline.d.ts +1 -1
  51. package/dist/types/src/hooks/useSessionTimeline.d.ts.map +1 -1
  52. package/dist/types/src/hooks/useTraceMessages.d.ts +1 -1
  53. package/dist/types/src/hooks/useTraceMessages.d.ts.map +1 -1
  54. package/dist/types/src/session-timeline/TaskHistory.stories.d.ts +12 -0
  55. package/dist/types/src/session-timeline/TaskHistory.stories.d.ts.map +1 -0
  56. package/dist/types/src/session-timeline/gantt-mapping.d.ts +14 -0
  57. package/dist/types/src/session-timeline/gantt-mapping.d.ts.map +1 -0
  58. package/dist/types/src/session-timeline/gantt-mapping.test.d.ts +2 -0
  59. package/dist/types/src/session-timeline/gantt-mapping.test.d.ts.map +1 -0
  60. package/dist/types/src/session-timeline/index.d.ts +1 -0
  61. package/dist/types/src/session-timeline/index.d.ts.map +1 -1
  62. package/dist/types/src/session-timeline/session-timeline.d.ts +17 -2
  63. package/dist/types/src/session-timeline/session-timeline.d.ts.map +1 -1
  64. package/dist/types/src/session-timeline/types.d.ts +32 -0
  65. package/dist/types/src/session-timeline/types.d.ts.map +1 -1
  66. package/dist/types/src/testing/simulated-agent.d.ts +5 -4
  67. package/dist/types/src/testing/simulated-agent.d.ts.map +1 -1
  68. package/dist/types/src/testing/test-trace.d.ts +2 -2
  69. package/dist/types/src/testing/test-trace.d.ts.map +1 -1
  70. package/dist/types/src/translations.d.ts +5 -0
  71. package/dist/types/src/translations.d.ts.map +1 -1
  72. package/dist/types/tsconfig.tsbuildinfo +1 -1
  73. package/package.json +30 -26
  74. package/src/components/Gantt/Gantt.stories.tsx +914 -96
  75. package/src/components/Gantt/Gantt.tsx +964 -261
  76. package/src/components/Gantt/gantt-rows.test.ts +123 -0
  77. package/src/components/Gantt/gantt-rows.ts +71 -0
  78. package/src/components/Gantt/gantt-scale.test.ts +105 -0
  79. package/src/components/Gantt/gantt-scale.ts +143 -0
  80. package/src/components/Gantt/index.ts +2 -0
  81. package/src/components/Gantt/useEnter.ts +33 -0
  82. package/src/components/Gantt/useRangeTween.ts +61 -0
  83. package/src/components/ProcessTree/ProcessTree.stories.tsx +2 -3
  84. package/src/components/ProcessTree/ProcessTree.tsx +99 -90
  85. package/src/components/Timeline/Timeline.stories.tsx +110 -22
  86. package/src/components/Timeline/Timeline.tsx +319 -271
  87. package/src/components/Timeline/timeline-layout.test.ts +177 -0
  88. package/src/components/Timeline/timeline-layout.ts +153 -0
  89. package/src/components/TracePanel/SpanTreeView.tsx +39 -0
  90. package/src/components/TracePanel/TracePanel.stories.tsx +106 -0
  91. package/src/components/TracePanel/TracePanel.tsx +113 -106
  92. package/src/components/TracePanel/trace-filter.test.ts +31 -2
  93. package/src/components/TracePanel/trace-filter.ts +28 -3
  94. package/src/components/TracePanel/useTraceMenu.ts +0 -1
  95. package/src/execution-graph/SPEC.md +10 -10
  96. package/src/execution-graph/execution-graph.test.ts +16 -15
  97. package/src/execution-graph/execution-graph.ts +108 -60
  98. package/src/execution-graph/pending-block-status.ts +1 -1
  99. package/src/execution-graph/sub-agent-delegation.test.ts +1 -1
  100. package/src/hooks/useExecutionGraph.ts +14 -11
  101. package/src/hooks/useSessionTimeline.ts +35 -9
  102. package/src/hooks/useTraceMessages.ts +1 -1
  103. package/src/session-timeline/TaskHistory.stories.tsx +142 -0
  104. package/src/session-timeline/gantt-mapping.test.ts +118 -0
  105. package/src/session-timeline/gantt-mapping.ts +158 -0
  106. package/src/session-timeline/index.ts +1 -0
  107. package/src/session-timeline/session-timeline.test.ts +585 -35
  108. package/src/session-timeline/session-timeline.ts +433 -62
  109. package/src/session-timeline/types.ts +14 -0
  110. package/src/testing/simulated-agent.ts +17 -16
  111. package/src/testing/test-trace.ts +14 -12
  112. package/src/translations.ts +8 -2
  113. package/dist/types/src/execution-graph/remote-snapshot.test.d.ts +0 -2
  114. package/dist/types/src/execution-graph/remote-snapshot.test.d.ts.map +0 -1
  115. package/src/execution-graph/remote-snapshot.test.ts +0 -116
@@ -9,15 +9,14 @@ import * as Pipeable from 'effect/Pipeable';
9
9
  import * as Schema from 'effect/Schema';
10
10
  import * as Struct from 'effect/Struct';
11
11
 
12
- import { RUN_AGAIN_ERROR_CODE, RUN_AGAIN_MESSAGE } from '@dxos/compute';
13
12
  import * as Process from '@dxos/compute/Process';
14
13
  import * as Trace from '@dxos/compute/Trace';
15
14
  import { Annotation } from '@dxos/echo';
16
15
  import { EID } from '@dxos/keys';
17
16
  import { LogLevel, log } from '@dxos/log';
18
- import { type Commit } from '../components/Timeline/index.ts';
19
17
  import { type ContentBlock } from '@dxos/types';
20
18
 
19
+ import { type Commit } from '../components/Timeline/index.ts';
21
20
  import { ROOT_SPAN_ID, type Span, buildSpanTree, isSpanBeginEvent, isSpanEndEvent, walkSpanTree } from './span-tree.ts';
22
21
 
23
22
  /**
@@ -93,7 +92,7 @@ const ICONS = {
93
92
 
94
93
  export interface BuildExecutionGraphParams {
95
94
  traceMessages: readonly Trace.Message[];
96
- activeProcesses?: readonly Process.Info[];
95
+ activeProcesses?: readonly Process.Process[];
97
96
  collapseCompletedSpans?: boolean;
98
97
  eventLimit?: number;
99
98
  /**
@@ -336,8 +335,8 @@ const presentEvent = (event: Trace.FlatEvent, toolCallContext: ToolCallContext):
336
335
  // the legacy message for events persisted before `errorCode` existed.
337
336
  const incomplete =
338
337
  event.data.outcome === 'failure' &&
339
- (event.data.errorCode === RUN_AGAIN_ERROR_CODE ||
340
- (event.data.errorCode === undefined && event.data.error === RUN_AGAIN_MESSAGE));
338
+ (event.data.errorCode === Process.RUN_AGAIN_ERROR_CODE ||
339
+ (event.data.errorCode === undefined && event.data.error === Process.RUN_AGAIN_MESSAGE));
341
340
  const presentation =
342
341
  event.data.outcome === 'success'
343
342
  ? { icon: ICONS.operationEndSuccess.icon, level: ICONS.operationEndSuccess.level, suffix: '' }
@@ -478,16 +477,28 @@ export const deriveInFlightActivityLine = (
478
477
  };
479
478
 
480
479
  const collectDescendantPids = (messages: readonly Trace.Message[], rootPid: string): Set<string> => {
480
+ // Indexed first so the walk costs one pass over the messages rather than one per tree level.
481
+ const childPidsByParent = new Map<string, Set<string>>();
482
+ for (const message of messages) {
483
+ const { pid, parentPid } = message.meta;
484
+ if (!pid || !parentPid) {
485
+ continue;
486
+ }
487
+ let children = childPidsByParent.get(parentPid);
488
+ if (!children) {
489
+ children = new Set();
490
+ childPidsByParent.set(parentPid, children);
491
+ }
492
+ children.add(pid);
493
+ }
494
+
481
495
  const pids = new Set([rootPid]);
482
- let expanded = true;
483
- while (expanded) {
484
- expanded = false;
485
- for (const message of messages) {
486
- const pid = message.meta.pid;
487
- const parentPid = message.meta.parentPid;
488
- if (pid && parentPid && pids.has(parentPid) && !pids.has(pid)) {
489
- pids.add(pid);
490
- expanded = true;
496
+ const pending = [rootPid];
497
+ while (pending.length > 0) {
498
+ for (const childPid of childPidsByParent.get(pending.pop()!) ?? []) {
499
+ if (!pids.has(childPid)) {
500
+ pids.add(childPid);
501
+ pending.push(childPid);
491
502
  }
492
503
  }
493
504
  }
@@ -567,9 +578,13 @@ const isCollapsibleSpan = (span: Span, parent: Span | null, collapseCompletedSpa
567
578
  * subtrees (depth-first), then its remaining events. Active-process running rows are inserted
568
579
  * at the end of each matching span block — not appended after the entire tree.
569
580
  */
581
+ /** Branch tips as a commit's parent list; a branch with no commit yet contributes nothing. */
582
+ const parentIds = (...tips: (Commit | undefined)[]): string[] =>
583
+ Array.dedupe(tips.filter((tip) => tip !== undefined).map((tip) => tip.id));
584
+
570
585
  const spanTreeToCommits = (
571
586
  root: Span,
572
- activeProcesses: readonly Process.Info[],
587
+ activeProcesses: readonly Process.Process[],
573
588
  toolCallContext: ToolCallContext,
574
589
  collapseCompletedSpans: boolean,
575
590
  ): { branches: string[]; commits: Commit[]; details: ExecutionGraphDetailsMap } => {
@@ -621,7 +636,7 @@ const spanTreeToCommits = (
621
636
  // Track the id of the begin commit for each non-collapsible span so the end commit can merge into it.
622
637
  const beginCommitIdBySpan = new Map<string, string>();
623
638
 
624
- // Selector that returns "the most recent commit in this span's structural context".
639
+ // The parents for "the most recent commit in this span's structural context".
625
640
  //
626
641
  // The walk is structural (pid/parentPid), not topological: at each ancestor, we prefer
627
642
  // 1) the last commit on that span's own branch, then
@@ -633,17 +648,19 @@ const spanTreeToCommits = (
633
648
  // to A.begin — not to whatever unrelated commit happens to be the latest on main.
634
649
  // This keeps fork edges visually attached to the correct span even when sibling top-level
635
650
  // spans (different pids) overlap in time on main.
636
- const lastInSpanContext = (span: Span | null): CommitSelector => {
651
+ const lastInSpanContext = (span: Span | null): string[] => {
637
652
  if (!span || span.id === ROOT_SPAN_ID) {
638
- return CommitSelector.branch(MAIN_BRANCH).pipe(CommitSelector.compose(CommitSelector.last()));
653
+ return parentIds(builder.tipOf(MAIN_BRANCH));
654
+ }
655
+ const ownTip = builder.tipOf(branchOf(span));
656
+ if (ownTip) {
657
+ return parentIds(ownTip);
639
658
  }
640
- const ownBranch = branchOf(span);
641
659
  const beginId = beginCommitIdBySpan.get(span.id);
642
- return CommitSelector.firstOf(
643
- CommitSelector.branch(ownBranch).pipe(CommitSelector.compose(CommitSelector.last())),
644
- beginId ? CommitSelector.id(beginId) : CommitSelector.filter(() => false),
645
- lastInSpanContext(findParentSpan(span)),
646
- );
660
+ if (beginId && builder.hasCommit(beginId)) {
661
+ return [beginId];
662
+ }
663
+ return lastInSpanContext(findParentSpan(span));
647
664
  };
648
665
 
649
666
  const lastCommitIdBySpan = new Map<string, string>();
@@ -671,12 +688,7 @@ const spanTreeToCommits = (
671
688
  branch: process.pid,
672
689
  // Falls back to the tail of main: a completed request collapses onto main, leaving the
673
690
  // agent's own branch empty, and a parentless spinner draws as a second, disconnected root.
674
- parents: builder.computeParents(
675
- CommitSelector.firstOf(
676
- CommitSelector.branch(process.pid).pipe(CommitSelector.compose(CommitSelector.last())),
677
- CommitSelector.branch(MAIN_BRANCH).pipe(CommitSelector.compose(CommitSelector.last())),
678
- ),
679
- ),
691
+ parents: parentIds(builder.firstTipOf(process.pid, MAIN_BRANCH)),
680
692
  icon: ICONS.agentRequestRunning.icon,
681
693
  level: ICONS.agentRequestRunning.level,
682
694
  message: 'Generating...',
@@ -688,9 +700,7 @@ const spanTreeToCommits = (
688
700
  builder.addCommit({
689
701
  id: `running:${process.pid}`,
690
702
  branch: process.pid,
691
- parents: builder.computeParents(
692
- CommitSelector.branch(process.pid).pipe(CommitSelector.compose(CommitSelector.last())),
693
- ),
703
+ parents: parentIds(builder.tipOf(process.pid)),
694
704
  icon: ICONS.processRunning.icon,
695
705
  level: ICONS.processRunning.level,
696
706
  message: 'Running...',
@@ -746,19 +756,19 @@ const spanTreeToCommits = (
746
756
  if (span.id === ROOT_SPAN_ID) {
747
757
  // Root-level events (no pid) attach sequentially to main.
748
758
  branch = MAIN_BRANCH;
749
- parents = builder.computeParents(lastInSpanContext(null));
759
+ parents = lastInSpanContext(null);
750
760
  } else if (collapsible) {
751
761
  // Only the end event reaches here for collapsible spans.
752
762
  // Anchor to the parent span's structural context so the fork attaches to the parent
753
763
  // — not to a sibling span's commit that happens to be the latest on the shared ancestor.
754
764
  branch = parentBranch;
755
- parents = builder.computeParents(lastInSpanContext(parentSpan));
765
+ parents = lastInSpanContext(parentSpan);
756
766
  } else if (isBeginEvent) {
757
767
  // Begin commit, anchored to the parent's structural context. A nested sub-span stays on the
758
768
  // parent lane (only its middle events fork). A concurrent child process forks onto its own
759
769
  // branch from this first event so it gets its own lane immediately.
760
770
  branch = isProcessBoundary ? ownBranch : parentBranch;
761
- parents = builder.computeParents(lastInSpanContext(parentSpan));
771
+ parents = lastInSpanContext(parentSpan);
762
772
  } else if (isEndEvent) {
763
773
  // End commit: continues the parent branch chronologically and merges in own-branch work.
764
774
  //
@@ -773,18 +783,13 @@ const spanTreeToCommits = (
773
783
  // to main) would otherwise gain commits after its own merge and never re-collapse — leaving a
774
784
  // dangling lane after the child finishes.
775
785
  branch = isProcessBoundary ? MAIN_BRANCH : parentBranch;
776
- parents = builder.computeParents(
777
- CommitSelector.unionAll(
778
- CommitSelector.branch(branch).pipe(CommitSelector.compose(CommitSelector.last())),
779
- CommitSelector.branch(ownBranch).pipe(CommitSelector.compose(CommitSelector.last())),
780
- ),
781
- );
786
+ parents = parentIds(builder.tipOf(branch), builder.tipOf(ownBranch));
782
787
  } else {
783
788
  // Middle event: continue the span's own branch; fall back to span's own context so the
784
789
  // first middle event of a span forks from the span's begin commit, not from a sibling's
785
790
  // commit on an ancestor branch.
786
791
  branch = ownBranch;
787
- parents = builder.computeParents(lastInSpanContext(span));
792
+ parents = lastInSpanContext(span);
788
793
  }
789
794
 
790
795
  const commitIndex = globalIndex++;
@@ -1010,26 +1015,58 @@ export const CommitSelector = {
1010
1015
  }),
1011
1016
  };
1012
1017
 
1018
+ /**
1019
+ * Accumulates the commit list. Every lookup the graph makes while building — the tip of a branch,
1020
+ * a commit by id — is served from an index, because each is asked once per commit and a scan would
1021
+ * make the whole build quadratic in the history.
1022
+ */
1013
1023
  class GraphBuilder {
1014
1024
  #commits: Commit[] = [];
1015
1025
  #branches = new Set<string>();
1016
-
1017
- findCommits(selector: CommitSelector): Commit[] {
1018
- return selector.select(this.#commits);
1019
- }
1026
+ #indexById = new Map<string, number>();
1027
+ #tipIndexByBranch = new Map<string, number>();
1020
1028
 
1021
1029
  hasBranch(branch: string): boolean {
1022
1030
  return this.#branches.has(branch);
1023
1031
  }
1024
1032
 
1033
+ hasCommit(commitId: string): boolean {
1034
+ return this.#indexById.has(commitId);
1035
+ }
1036
+
1037
+ /** The most recent commit on a branch, or undefined while the branch has none. */
1038
+ tipOf(branch: string | Falsy): Commit | undefined {
1039
+ if (!branch) {
1040
+ return undefined;
1041
+ }
1042
+ const index = this.#tipIndexByBranch.get(branch);
1043
+ return index === undefined ? undefined : this.#commits[index];
1044
+ }
1045
+
1046
+ /** The most recent commit on the first of `branches` that has one. */
1047
+ firstTipOf(...branches: (string | Falsy)[]): Commit | undefined {
1048
+ for (const branch of branches) {
1049
+ const tip = this.tipOf(branch);
1050
+ if (tip) {
1051
+ return tip;
1052
+ }
1053
+ }
1054
+ return undefined;
1055
+ }
1056
+
1025
1057
  addCommit(commit: Commit) {
1026
1058
  this.addBranch(commit.branch);
1027
- this.#commits.push(commit);
1059
+ const index = this.#commits.push(commit) - 1;
1060
+ // Duplicate ids survive until `doctor` drops them, and it keeps the first — so does this index.
1061
+ if (!this.#indexById.has(commit.id)) {
1062
+ this.#indexById.set(commit.id, index);
1063
+ }
1064
+ this.#tipIndexByBranch.set(commit.branch, index);
1028
1065
  }
1029
1066
 
1030
1067
  addTag(commitId: string, tag: string) {
1031
- const index = this.#commits.findIndex((commit) => commit.id === commitId);
1032
- if (index < 0) {
1068
+ const index = this.#indexById.get(commitId);
1069
+ if (index === undefined) {
1033
1070
  return;
1034
1071
  }
1035
1072
  const commit = this.#commits[index]!;
@@ -1039,13 +1076,6 @@ class GraphBuilder {
1039
1076
  this.#commits[index] = { ...commit, tags: [...(commit.tags ?? []), tag] };
1040
1077
  }
1041
1078
 
1042
- /**
1043
- * Computes parents — picks first matching commit's id.
1044
- */
1045
- computeParents(selector: CommitSelector): string[] {
1046
- return Array.dedupe(selector.select(this.#commits).map((commit) => commit.id));
1047
- }
1048
-
1049
1079
  addBranch(branch: string) {
1050
1080
  this.#branches.add(branch);
1051
1081
  }
@@ -1054,16 +1084,23 @@ class GraphBuilder {
1054
1084
  * Removes duplicate commits and dangling parents.
1055
1085
  */
1056
1086
  doctor() {
1087
+ const seen = new Set<string>();
1057
1088
  this.#commits = pipe(
1058
1089
  this.#commits,
1059
- Array.dedupeWith((a, b) => a.id === b.id),
1090
+ Array.filter((commit) => {
1091
+ if (seen.has(commit.id)) {
1092
+ return false;
1093
+ }
1094
+ seen.add(commit.id);
1095
+ return true;
1096
+ }),
1060
1097
  Array.map(
1061
1098
  Struct.evolve({
1062
- parents: (parents) =>
1063
- parents ? Array.filter(parents, (id) => this.#commits.some((commit) => commit.id === id)) : undefined,
1099
+ parents: (parents) => (parents ? Array.filter(parents, (id) => seen.has(id)) : undefined),
1064
1100
  }),
1065
1101
  ),
1066
1102
  );
1103
+ this.#reindex();
1067
1104
  }
1068
1105
 
1069
1106
  build() {
@@ -1073,4 +1110,15 @@ class GraphBuilder {
1073
1110
  branches: [...this.#branches],
1074
1111
  };
1075
1112
  }
1113
+
1114
+ #reindex() {
1115
+ this.#indexById.clear();
1116
+ this.#tipIndexByBranch.clear();
1117
+ this.#commits.forEach((commit, index) => {
1118
+ if (!this.#indexById.has(commit.id)) {
1119
+ this.#indexById.set(commit.id, index);
1120
+ }
1121
+ this.#tipIndexByBranch.set(commit.branch, index);
1122
+ });
1123
+ }
1076
1124
  }
@@ -34,7 +34,7 @@ export const formatPendingBlockStatus = (block: ContentBlock.Any): string | unde
34
34
 
35
35
  if (block._tag === 'toolCall') {
36
36
  const bytes = new TextEncoder().encode(block.input).length;
37
- const name = block.operationName ?? block.name;
37
+ const name = block.displayName ?? block.operationName ?? block.name;
38
38
  return `Calling ${name} (${bytes} bytes)...`;
39
39
  }
40
40
 
@@ -5,8 +5,8 @@
5
5
  import { describe, test } from 'vitest';
6
6
 
7
7
  import * as Trace from '@dxos/compute/Trace';
8
- import { renderTimelineAscii } from '../components/Timeline/index.ts';
9
8
 
9
+ import { renderTimelineAscii } from '../components/Timeline/index.ts';
10
10
  import { buildExecutionGraph, collectProcessActivityLines, deriveInFlightActivityLine } from './execution-graph.ts';
11
11
  import subAgentFixture from './testing/sub-agent-delegation.json';
12
12
 
@@ -5,15 +5,14 @@
5
5
  import { useAtomValue } from '@effect/atom-react/Hooks';
6
6
  import * as Duration from 'effect/Duration';
7
7
  import { pipe } from 'effect/Function';
8
- import * as Atom from 'effect/unstable/reactivity/Atom';
8
+ import * as Atom from 'effect/reactivity/Atom';
9
9
  import { useEffect, useMemo, useState } from 'react';
10
10
 
11
11
  import * as Process from '@dxos/compute/Process';
12
12
  import { type Space } from '@dxos/react-client/echo';
13
13
 
14
- import { filterTraceMessages } from '../components/TracePanel/trace-filter.ts';
14
+ import { filterProcessesBySelection, filterTraceMessages } from '../components/TracePanel/trace-filter.ts';
15
15
  import { type ExecutionGraph, buildExecutionGraph } from '../execution-graph/index.ts';
16
-
17
16
  import { getTraceMessagesAtom } from './useTraceMessages.ts';
18
17
 
19
18
  // Stable refs.
@@ -25,6 +24,11 @@ const NO_PIDS: readonly string[] = [];
25
24
  // benefit to re-deriving the graph more often than this just to catch the timeout crossing.
26
25
  const SPAN_TIMEOUT_CHECK_INTERVAL_MS = 60_000;
27
26
 
27
+ // The trace feed emits per message while anything runs, and the process monitor re-polls on its own
28
+ // clock; every emission rebuilds the graph from the whole history, so the rebuild rate is capped
29
+ // rather than either input's, as `useSessionTimeline` already does.
30
+ const REBUILD_DEBOUNCE = Duration.millis(500);
31
+
28
32
  export type UseExecutionGraphOptions = {
29
33
  collapseCompletedSpans?: boolean;
30
34
  eventLimit?: number;
@@ -39,7 +43,7 @@ export type UseExecutionGraphOptions = {
39
43
  */
40
44
  export const useExecutionGraph = (
41
45
  space: Space,
42
- processesAtom: Atom.Atom<readonly Process.Info[]> | undefined,
46
+ processesAtom: Atom.Atom<readonly Process.Process[]> | undefined,
43
47
  { collapseCompletedSpans, eventLimit, selectedPids = NO_PIDS }: UseExecutionGraphOptions = {},
44
48
  ): ExecutionGraph => {
45
49
  // Ticks periodically so spans that are still open purely because no new trace event has
@@ -62,13 +66,13 @@ export const useExecutionGraph = (
62
66
  };
63
67
 
64
68
  /** Identity for the graph: only a process appearing, disappearing or changing state redraws it. */
65
- const sameProcesses = (left: readonly Process.Info[], right: readonly Process.Info[]): boolean =>
69
+ const sameProcesses = (left: readonly Process.Process[], right: readonly Process.Process[]): boolean =>
66
70
  left.length === right.length &&
67
71
  left.every((process, index) => process.pid === right[index].pid && process.state === right[index].state);
68
72
 
69
73
  const getExecutionGraph = (
70
74
  space: Space,
71
- processesAtom: Atom.Atom<readonly Process.Info[]>,
75
+ processesAtom: Atom.Atom<readonly Process.Process[]>,
72
76
  {
73
77
  collapseCompletedSpans = true,
74
78
  eventLimit = 100,
@@ -77,17 +81,16 @@ const getExecutionGraph = (
77
81
  }: UseExecutionGraphOptions & { now: number },
78
82
  ): Atom.Atom<ExecutionGraph> => {
79
83
  const traceMessages = getTraceMessagesAtom(space).pipe(
84
+ Atom.debounce(REBUILD_DEBOUNCE),
80
85
  Atom.map((messages) => filterTraceMessages(messages, selectedPids)),
81
86
  );
82
87
 
83
88
  const activeProcesses = pipe(
84
89
  processesAtom,
85
- Atom.debounce(Duration.millis(500)),
90
+ Atom.debounce(REBUILD_DEBOUNCE),
86
91
  Atom.map((processes) =>
87
- processes.filter(
88
- (process) =>
89
- (process.state === Process.State.RUNNING || process.state === Process.State.HYBERNATING) &&
90
- (selectedPids.length === 0 || selectedPids.includes(process.pid)),
92
+ filterProcessesBySelection(processes, selectedPids).filter(
93
+ (process) => process.state === Process.State.RUNNING || process.state === Process.State.HYBERNATING,
91
94
  ),
92
95
  ),
93
96
  // The monitor rebuilds the process list on every poll, so without a structural comparison the
@@ -3,14 +3,20 @@
3
3
  //
4
4
 
5
5
  import * as Duration from 'effect/Duration';
6
- import { useEffect, useMemo, useState } from 'react';
6
+ import { useEffect, useMemo, useRef, useState } from 'react';
7
7
 
8
8
  import type * as Process from '@dxos/compute/Process';
9
+ import { Obj } from '@dxos/echo';
9
10
  import { type Space } from '@dxos/react-client/echo';
10
11
  import { type Task } from '@dxos/types';
11
12
 
12
- import { type Session, type SessionTimeline, buildSessionTimeline } from '../session-timeline/index.ts';
13
-
13
+ import {
14
+ type Session,
15
+ type SessionTimeline,
16
+ type TaskStatusChange,
17
+ buildSessionTimeline,
18
+ readTaskStatusChanges,
19
+ } from '../session-timeline/index.ts';
14
20
  import { useTraceMessages } from './useTraceMessages.ts';
15
21
 
16
22
  /** How often `now` advances while a session is open, so a running lane's range keeps growing. */
@@ -20,14 +26,31 @@ const TICK_MS = 5_000;
20
26
  // timeline from the full message history — so the rebuild rate is capped rather than the feed's.
21
27
  const TRACE_DEBOUNCE = Duration.millis(500);
22
28
 
23
- const NO_PROCESSES: readonly Process.Info[] = [];
29
+ const NO_PROCESSES: readonly Process.Process[] = [];
30
+
31
+ type StatusChangeCache = Map<string, { heads: string; changes: TaskStatusChange[] }>;
32
+
33
+ /**
34
+ * A task's status moves, re-read only when its document has moved on: reading them diffs every change
35
+ * in the document, and the timeline rebuilds on every trace emission and tick.
36
+ */
37
+ const cachedStatusChanges = (cache: StatusChangeCache, task: Task.Task): TaskStatusChange[] => {
38
+ const heads = (Obj.version(task).automergeHeads ?? []).join(',');
39
+ const cached = cache.get(task.id);
40
+ if (cached?.heads === heads) {
41
+ return cached.changes;
42
+ }
43
+ const changes = readTaskStatusChanges(task);
44
+ cache.set(task.id, { heads, changes });
45
+ return changes;
46
+ };
24
47
 
25
48
  export type UseSessionTimelineOptions = {
26
49
  /** The sessions shown; each contributes a session lane and its checklist's task lanes. */
27
50
  sessions: readonly Session[];
28
51
  tasks?: readonly Task.Task[];
29
52
  /** The live process tree, from whichever monitor the host has; without it a session's liveness is read from the trace alone. */
30
- processes?: readonly Process.Info[];
53
+ processes?: readonly Process.Process[];
31
54
  };
32
55
 
33
56
  /**
@@ -46,8 +69,11 @@ export const useSessionTimeline = (
46
69
  return () => clearInterval(interval);
47
70
  }, []);
48
71
 
49
- return useMemo(
50
- () => buildSessionTimeline({ traceMessages, processes, sessions, tasks, now }),
51
- [traceMessages, processes, sessions, tasks, now],
52
- );
72
+ const statusChangeCache = useRef<StatusChangeCache>(new Map());
73
+ return useMemo(() => {
74
+ const taskStatusChanges = new Map(
75
+ (tasks ?? []).map((task) => [task.id, cachedStatusChanges(statusChangeCache.current, task)]),
76
+ );
77
+ return buildSessionTimeline({ traceMessages, processes, sessions, tasks, taskStatusChanges, now });
78
+ }, [traceMessages, processes, sessions, tasks, now]);
53
79
  };
@@ -5,7 +5,7 @@
5
5
  import { useAtomValue } from '@effect/atom-react/Hooks';
6
6
  import * as Duration from 'effect/Duration';
7
7
  import { pipe } from 'effect/Function';
8
- import * as Atom from 'effect/unstable/reactivity/Atom';
8
+ import * as Atom from 'effect/reactivity/Atom';
9
9
  import { useMemo } from 'react';
10
10
 
11
11
  import { FeedTraceSink } from '@dxos/compute-runtime';
@@ -0,0 +1,142 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { type Meta, type StoryObj } from '@storybook/react-vite';
6
+ import React, { useMemo } from 'react';
7
+
8
+ import * as Trace from '@dxos/compute/Trace';
9
+ import { Feed, Obj, Ref } from '@dxos/echo';
10
+ import { EID, EntityId } from '@dxos/keys';
11
+ import { withLayout, withTheme } from '@dxos/react-ui/testing';
12
+ import { Task } from '@dxos/types';
13
+
14
+ import { Gantt } from '../components/index.ts';
15
+ import { sessionTimelineToGantt } from './gantt-mapping.ts';
16
+ import { type TaskStatusChange, buildSessionTimeline } from './session-timeline.ts';
17
+ import { type Session } from './types.ts';
18
+
19
+ const T0 = Date.UTC(2026, 8, 25, 12, 5, 22);
20
+ const MINUTE = 60_000;
21
+ const minute = (minutes: number): number => T0 + minutes * MINUTE;
22
+ const at = (minutes: number): string => new Date(minute(minutes)).toISOString();
23
+
24
+ /**
25
+ * One session's checklist, and the status moves each task's edit history records — the story's tasks
26
+ * live in no database, so their moves are given rather than read. With `history` off, the chart sees
27
+ * only the trace.
28
+ */
29
+ const makeTasks = (history: boolean) => {
30
+ const audit = Task.make({ title: 'Audit current artifact storage', status: 'done' });
31
+ const design = Task.make({ title: 'Design history entry schema', status: 'done' });
32
+ const migrate = Task.make({ title: 'Write the migration', status: 'done' });
33
+ const backfill = Task.make({
34
+ title: 'Backfill existing tasks',
35
+ status: 'todo',
36
+ dependsOn: [Ref.make(migrate), Ref.make(design)],
37
+ });
38
+ const worked = (start: number, end: number): TaskStatusChange[] => [
39
+ { timestamp: minute(start), status: 'started', previousStatus: 'todo' },
40
+ { timestamp: minute(end), status: 'done', previousStatus: 'started' },
41
+ ];
42
+ if (history) {
43
+ const question = Task.ask(design, {
44
+ text: 'Keep artifacts as refs or fold them into history?',
45
+ actor: { name: 'Scout', role: 'assistant' },
46
+ date: at(36),
47
+ });
48
+ Task.answer(design, question.id, 'Keep refs', { date: at(47) });
49
+ }
50
+ const taskStatusChanges = new Map<string, TaskStatusChange[]>(
51
+ history
52
+ ? [
53
+ [audit.id, worked(1, 26)],
54
+ [design.id, worked(28, 55)],
55
+ [migrate.id, worked(57, 111)],
56
+ ]
57
+ : [],
58
+ );
59
+ return { tasks: [audit, design, migrate, backfill], taskStatusChanges };
60
+ };
61
+
62
+ /** The session's own trace: two requests and the tool calls between them, with no task status events. */
63
+ const makeTrace = (feed: Ref.Ref<Feed.Feed>): Trace.Message[] => {
64
+ const event = (minutes: number, type: string, data: unknown) => ({ timestamp: T0 + minutes * MINUTE, type, data });
65
+ const toolCall = (minutes: number, name: string) =>
66
+ event(minutes, Trace.CompleteBlock.key, {
67
+ messageId: EntityId.random(),
68
+ role: 'assistant',
69
+ block: { _tag: 'toolCall', toolCallId: name, name, input: '{}', providerExecuted: false },
70
+ });
71
+ return [
72
+ Obj.make(Trace.Message, {
73
+ meta: { pid: 'agent', conversation: feed },
74
+ isEphemeral: false,
75
+ events: [
76
+ event(0, Trace.AgentRequestBegin.key, {}),
77
+ toolCall(4, 'Search code'),
78
+ toolCall(12, 'Read file'),
79
+ toolCall(20, 'Read file'),
80
+ toolCall(33, 'Write document'),
81
+ toolCall(42, 'Ask question'),
82
+ event(58, Trace.AgentRequestEnd.key, { status: 'success' }),
83
+ event(104, Trace.AgentRequestBegin.key, {}),
84
+ toolCall(106, 'Edit file'),
85
+ toolCall(109, 'Run tests'),
86
+ event(113, Trace.AgentRequestEnd.key, { status: 'success' }),
87
+ ],
88
+ }),
89
+ ];
90
+ };
91
+
92
+ const Timeline = ({ title, history }: { title: string; history: boolean }) => {
93
+ const timeline = useMemo(() => {
94
+ const feed = Feed.make();
95
+ const { tasks, taskStatusChanges } = makeTasks(history);
96
+ const id = EntityId.random();
97
+ const session: Session = {
98
+ id,
99
+ label: 'Schema Migration: Task Artifacts to History',
100
+ uri: EID.make({ entityId: id }).toString(),
101
+ feedId: feed.id,
102
+ taskIds: tasks.map((task) => task.id),
103
+ };
104
+ return buildSessionTimeline({
105
+ traceMessages: makeTrace(Ref.make(feed)),
106
+ sessions: [session],
107
+ tasks,
108
+ taskStatusChanges,
109
+ });
110
+ }, [history]);
111
+
112
+ return (
113
+ <div className='flex flex-col gap-1'>
114
+ <h2 className='px-2 text-sm text-fg-subtle'>{title}</h2>
115
+ <Gantt.Root {...sessionTimelineToGantt(timeline)} range={timeline.range} classNames='p-2'>
116
+ <Gantt.Legend>
117
+ <Gantt.LegendToggle />
118
+ </Gantt.Legend>
119
+ <Gantt.Chart />
120
+ </Gantt.Root>
121
+ </div>
122
+ );
123
+ };
124
+
125
+ const DefaultStory = () => (
126
+ <div className='flex flex-col gap-6 p-4'>
127
+ <Timeline title='Trace only — task lanes have no span' history={false} />
128
+ <Timeline title='With edit history — begin, end, status moves and questions as nodes' history={true} />
129
+ </div>
130
+ );
131
+
132
+ const meta = {
133
+ title: 'ui/react-ui-trace/SessionTimeline',
134
+ render: DefaultStory,
135
+ decorators: [withTheme(), withLayout({ layout: 'fullscreen' })],
136
+ } satisfies Meta<typeof DefaultStory>;
137
+
138
+ export default meta;
139
+
140
+ type Story = StoryObj<typeof meta>;
141
+
142
+ export const TaskHistory: Story = {};