@qiaolei81/copilot-session-viewer 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qiaolei81/copilot-session-viewer",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Web UI for viewing GitHub Copilot CLI session logs",
5
5
  "author": "Lei Qiao <qiaolei81@gmail.com>",
6
6
  "license": "MIT",
@@ -70,14 +70,29 @@ class SessionService {
70
70
  try {
71
71
  const content = await fs.promises.readFile(eventsFile, 'utf-8');
72
72
  const lines = content.trim().split('\n').filter(line => line.trim());
73
- return lines.map((line, index) => {
73
+ const events = lines.map((line, index) => {
74
74
  try {
75
- return JSON.parse(line);
75
+ const event = JSON.parse(line);
76
+ // Preserve original file order as _fileIndex for stable sorting
77
+ event._fileIndex = index;
78
+ return event;
76
79
  } catch (err) {
77
80
  console.error(`Error parsing line ${index + 1}:`, err.message);
78
81
  return null;
79
82
  }
80
83
  }).filter(event => event !== null);
84
+
85
+ // Sort by timestamp with stable tiebreaker on original file order.
86
+ // This ensures events with identical timestamps (e.g. an assistant.message
87
+ // followed by its tool.execution_start events) keep their logical order.
88
+ events.sort((a, b) => {
89
+ const timeA = a.timestamp ? new Date(a.timestamp).getTime() : 0;
90
+ const timeB = b.timestamp ? new Date(b.timestamp).getTime() : 0;
91
+ if (timeA !== timeB) return timeA - timeB;
92
+ return a._fileIndex - b._fileIndex;
93
+ });
94
+
95
+ return events;
81
96
  } catch (err) {
82
97
  console.error('Error reading events:', err);
83
98
  return [];
@@ -104,6 +119,22 @@ class SessionService {
104
119
  metadata.model = modelChangeEvent.data.newModel || modelChangeEvent.data.model;
105
120
  }
106
121
 
122
+ // Derive "updated" from last event timestamp (more accurate than filesystem mtime)
123
+ if (events.length) {
124
+ const lastEvent = events[events.length - 1];
125
+ if (lastEvent?.timestamp) {
126
+ metadata.updated = lastEvent.timestamp;
127
+ }
128
+ }
129
+
130
+ // Derive "created" from first event timestamp if available
131
+ if (events.length) {
132
+ const firstEvent = events[0];
133
+ if (firstEvent?.timestamp) {
134
+ metadata.created = firstEvent.timestamp;
135
+ }
136
+ }
137
+
107
138
  return { session, events, metadata };
108
139
  }
109
140
  }
@@ -867,7 +867,7 @@
867
867
  content: '';
868
868
  flex: 1;
869
869
  height: 1px;
870
- background: #58a6ff;
870
+ background: var(--sa-color, #58a6ff);
871
871
  }
872
872
  .subagent-divider-text {
873
873
  font-size: 12px;
@@ -886,6 +886,8 @@
886
886
  .subagent-divider-line-right {
887
887
  display: none;
888
888
  }
889
+ .event.event-in-subagent { border-left-color: var(--subagent-border-color, #58a6ff); }
890
+ .subagent-owner-tag { font-size: 11px; padding: 1px 6px; border-radius: 8px; border: 1px solid; white-space: nowrap; opacity: 0.85; }
889
891
  </style>
890
892
  </head>
891
893
  <body>
@@ -949,14 +951,19 @@
949
951
  const eventsLoading = ref(true);
950
952
  const eventsError = ref(null);
951
953
 
952
- // Flatten and sort events
954
+ // Flatten and sort events (stable sort using _fileIndex tiebreaker)
953
955
  const flatEvents = computed(() => {
954
956
  const events = loadedEvents.value
955
- .filter(e =>
956
- e.type !== 'assistant.turn_end' &&
957
+ .filter(e =>
958
+ e.type !== 'assistant.turn_end' &&
957
959
  e.type !== 'assistant.turn_complete'
958
960
  )
959
- .sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp))
961
+ .sort((a, b) => {
962
+ const timeA = a.timestamp ? new Date(a.timestamp).getTime() : 0;
963
+ const timeB = b.timestamp ? new Date(b.timestamp).getTime() : 0;
964
+ if (timeA !== timeB) return timeA - timeB;
965
+ return (a._fileIndex ?? 0) - (b._fileIndex ?? 0);
966
+ })
960
967
  .map((e, index) => ({
961
968
  ...e,
962
969
  virtualIndex: index,
@@ -1004,12 +1011,12 @@
1004
1011
  // Final filtered events (search + type filter)
1005
1012
  const filteredEvents = computed(() => {
1006
1013
  let events = searchFilteredEvents.value;
1007
-
1014
+
1008
1015
  // Apply type filter
1009
1016
  if (currentFilter.value !== 'all') {
1010
1017
  events = events.filter(e => e.type === currentFilter.value);
1011
1018
  }
1012
-
1019
+
1013
1020
  // Divider types (no separator before these)
1014
1021
  const dividerTypes = ['assistant.turn_start', 'subagent.started', 'subagent.completed', 'subagent.failed'];
1015
1022
 
@@ -1197,7 +1204,99 @@
1197
1204
 
1198
1205
  return map;
1199
1206
  });
1200
-
1207
+
1208
+ // Subagent ownership: attribute events to their owning subagent
1209
+ const subagentOwnership = computed(() => {
1210
+ const sorted = flatEvents.value;
1211
+ const ownerMap = new Map(); // stableId → toolCallId
1212
+ const subagentInfo = new Map(); // toolCallId → { name, colorIndex }
1213
+
1214
+ // 1. Collect all subagent.started toolCallIds + assign colorIndex
1215
+ let colorIdx = 0;
1216
+ for (const ev of sorted) {
1217
+ if (ev.type === 'subagent.started') {
1218
+ const tcid = ev.data?.toolCallId;
1219
+ if (tcid) {
1220
+ subagentInfo.set(tcid, {
1221
+ name: ev.data?.agentDisplayName || ev.data?.agentName || 'SubAgent',
1222
+ colorIndex: colorIdx++
1223
+ });
1224
+ }
1225
+ }
1226
+ }
1227
+
1228
+ if (subagentInfo.size === 0) return { ownerMap, subagentInfo };
1229
+
1230
+ // 2. Build id → event lookup for parentId chain walking
1231
+ const idMap = new Map();
1232
+ for (const ev of sorted) {
1233
+ if (ev.id) idMap.set(ev.id, ev);
1234
+ }
1235
+
1236
+ // 3. Attribute assistant.message events via data.parentToolCallId
1237
+ for (const ev of sorted) {
1238
+ if (ev.type === 'assistant.message') {
1239
+ const ptcid = ev.data?.parentToolCallId;
1240
+ if (ptcid && subagentInfo.has(ptcid)) {
1241
+ ownerMap.set(ev.stableId, ptcid);
1242
+ }
1243
+ }
1244
+ }
1245
+
1246
+ // 4. Attribute reasoning events by walking parentId → assistant.message
1247
+ for (const ev of sorted) {
1248
+ if (ev.type !== 'reasoning') continue;
1249
+ let current = ev.parentId;
1250
+ let depth = 0;
1251
+ while (current && depth < 10) {
1252
+ const parent = idMap.get(current);
1253
+ if (!parent) break;
1254
+ if (parent.type === 'assistant.message') {
1255
+ const ptcid = parent.data?.parentToolCallId;
1256
+ if (ptcid && subagentInfo.has(ptcid)) {
1257
+ ownerMap.set(ev.stableId, ptcid);
1258
+ }
1259
+ break;
1260
+ }
1261
+ current = parent.parentId;
1262
+ depth++;
1263
+ }
1264
+ }
1265
+
1266
+ // 5. Attribute tool.execution_start/complete by walking parentId chain
1267
+ const startIdByToolCallId = new Map();
1268
+ for (const ev of sorted) {
1269
+ if (ev.type !== 'tool.execution_start') continue;
1270
+ let current = ev.parentId;
1271
+ let depth = 0;
1272
+ while (current && depth < 10) {
1273
+ const parent = idMap.get(current);
1274
+ if (!parent) break;
1275
+ if (parent.type === 'assistant.message') {
1276
+ const ptcid = parent.data?.parentToolCallId;
1277
+ if (ptcid && subagentInfo.has(ptcid)) {
1278
+ ownerMap.set(ev.stableId, ptcid);
1279
+ const tcid = ev.data?.toolCallId;
1280
+ if (tcid) startIdByToolCallId.set(tcid, ptcid);
1281
+ }
1282
+ break;
1283
+ }
1284
+ current = parent.parentId;
1285
+ depth++;
1286
+ }
1287
+ }
1288
+
1289
+ for (const ev of sorted) {
1290
+ if (ev.type !== 'tool.execution_complete') continue;
1291
+ const tcid = ev.data?.toolCallId;
1292
+ if (tcid && startIdByToolCallId.has(tcid)) {
1293
+ ownerMap.set(ev.stableId, startIdByToolCallId.get(tcid));
1294
+ }
1295
+ }
1296
+
1297
+ return { ownerMap, subagentInfo };
1298
+ });
1299
+
1201
1300
  // Methods
1202
1301
  const formatTime = (timestamp) => {
1203
1302
  if (!timestamp) return '';
@@ -1442,7 +1541,44 @@
1442
1541
  const getToolGroups = (event) => {
1443
1542
  return toolCallMap.value.get(event.id || event.virtualIndex) || [];
1444
1543
  };
1445
-
1544
+
1545
+ // Subagent color palette for parallel subagent distinction
1546
+ const SUBAGENT_COLORS = [
1547
+ '#58a6ff', // blue
1548
+ '#f0883e', // orange
1549
+ '#a371f7', // purple
1550
+ '#3fb950', // green
1551
+ '#f778ba', // pink
1552
+ '#79c0ff', // light blue
1553
+ '#d29922', // amber
1554
+ '#56d4dd' // teal
1555
+ ];
1556
+
1557
+ const getSubagentInfo = (event) => {
1558
+ const { ownerMap, subagentInfo } = subagentOwnership.value;
1559
+ // For subagent dividers, use their own toolCallId
1560
+ if (event.type === 'subagent.started' || event.type === 'subagent.completed' || event.type === 'subagent.failed') {
1561
+ const tcid = event.data?.toolCallId;
1562
+ if (tcid && subagentInfo.has(tcid)) {
1563
+ const info = subagentInfo.get(tcid);
1564
+ return { name: info.name, toolCallId: tcid, colorIndex: info.colorIndex };
1565
+ }
1566
+ return null;
1567
+ }
1568
+ // For regular events, look up ownership
1569
+ const tcid = ownerMap.get(event.stableId);
1570
+ if (!tcid) return null;
1571
+ const info = subagentInfo.get(tcid);
1572
+ if (!info) return null;
1573
+ return { name: info.name, toolCallId: tcid, colorIndex: info.colorIndex };
1574
+ };
1575
+
1576
+ const getSubagentColor = (event) => {
1577
+ const info = getSubagentInfo(event);
1578
+ if (!info) return null;
1579
+ return SUBAGENT_COLORS[info.colorIndex % SUBAGENT_COLORS.length];
1580
+ };
1581
+
1446
1582
  const setFilter = (type) => {
1447
1583
  currentFilter.value = type;
1448
1584
  };
@@ -1744,6 +1880,8 @@
1744
1880
  getToolCommand,
1745
1881
  hasTools,
1746
1882
  getToolGroups,
1883
+ getSubagentInfo,
1884
+ getSubagentColor,
1747
1885
  setFilter,
1748
1886
  scrollToTurn,
1749
1887
  jumpToTurn,
@@ -1913,32 +2051,41 @@
1913
2051
  </div>
1914
2052
 
1915
2053
  <!-- Subagent Divider -->
1916
- <div
2054
+ <div
1917
2055
  v-else-if="item.type === 'subagent.started' || item.type === 'subagent.completed' || item.type === 'subagent.failed'"
1918
2056
  :data-type="item.type"
1919
2057
  :data-index="item.virtualIndex"
1920
2058
  :class="['subagent-divider', item.type.split('.')[1]]"
2059
+ :style="{
2060
+ '--sa-color': getSubagentColor(item) || '#58a6ff'
2061
+ }"
1921
2062
  >
1922
- <div class="subagent-divider-line-left"></div>
1923
- <span class="subagent-divider-text">
2063
+ <div class="subagent-divider-line-left" :style="{ background: getSubagentColor(item) || '#58a6ff' }"></div>
2064
+ <span class="subagent-divider-text" :style="{ color: getSubagentColor(item) || '#58a6ff', borderColor: getSubagentColor(item) || '#58a6ff', background: (getSubagentColor(item) || '#58a6ff') + '1a' }">
1924
2065
  🤖 {{ item.data?.agentDisplayName || item.data?.agentName || 'SubAgent' }}
1925
2066
  {{ item.type === 'subagent.started' ? 'Start ▶' : item.type === 'subagent.completed' ? 'Complete ✓' : 'Failed ✗' }}
1926
2067
  </span>
1927
- <div class="subagent-divider-line-right"></div>
2068
+ <div class="subagent-divider-line-right" :style="{ background: getSubagentColor(item) || '#58a6ff' }"></div>
1928
2069
  <div class="divider-separator"></div>
1929
2070
  </div>
1930
2071
 
1931
2072
  <!-- Regular Event -->
1932
- <div
2073
+ <div
1933
2074
  v-else
1934
- :class="['event']"
2075
+ :class="['event', getSubagentInfo(item) ? 'event-in-subagent' : '']"
1935
2076
  :data-type="item.type"
1936
2077
  :data-index="item.virtualIndex"
2078
+ :style="getSubagentColor(item) ? { '--subagent-border-color': getSubagentColor(item) } : {}"
1937
2079
  >
1938
2080
  <div class="event-header">
1939
2081
  <span :class="['event-badge', getBadgeInfo(item.type).class]">
1940
2082
  {{ getBadgeInfo(item.type).label }}
1941
2083
  </span>
2084
+ <span
2085
+ v-if="getSubagentInfo(item)"
2086
+ class="subagent-owner-tag"
2087
+ :style="{ color: getSubagentColor(item), borderColor: getSubagentColor(item) }"
2088
+ >🤖 {{ getSubagentInfo(item).name }}</span>
1942
2089
  <span class="event-timestamp">{{ formatTime(item.timestamp) }}</span>
1943
2090
  </div>
1944
2091
 
@@ -61,10 +61,9 @@
61
61
  /* Summary cards */
62
62
  .summary-grid {
63
63
  display: grid;
64
- grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
64
+ grid-template-columns: repeat(6, 1fr);
65
65
  gap: 12px;
66
66
  margin: 20px auto;
67
- max-width: 1200px;
68
67
  }
69
68
  .summary-card {
70
69
  background: #161b22;
@@ -359,7 +358,32 @@
359
358
  /* Gantt-like chart for subagents */
360
359
  .gantt-container {
361
360
  overflow-x: auto;
361
+ overflow-y: visible;
362
362
  padding-bottom: 8px;
363
+ padding-top: 22px;
364
+ position: relative;
365
+ }
366
+ .gantt-crosshair {
367
+ position: absolute;
368
+ top: 0;
369
+ bottom: 0;
370
+ width: 1px;
371
+ background: rgba(139, 148, 158, 0.5);
372
+ pointer-events: none;
373
+ z-index: 10;
374
+ }
375
+ .gantt-crosshair-label {
376
+ position: absolute;
377
+ top: 4px;
378
+ left: 50%;
379
+ transform: translateX(-50%);
380
+ background: #30363d;
381
+ color: #e6edf3;
382
+ font-size: 10px;
383
+ padding: 2px 6px;
384
+ border-radius: 3px;
385
+ white-space: nowrap;
386
+ pointer-events: none;
363
387
  }
364
388
  .gantt-row {
365
389
  display: flex;
@@ -740,6 +764,38 @@
740
764
  const showMarkerLegend = ref(false);
741
765
  const copyLabel = ref('📊 Copy as Mermaid Gantt');
742
766
 
767
+ // Gantt crosshair
768
+ const ganttCrosshairX = ref(null); // px from left of gantt-container
769
+ const ganttCrosshairTime = ref('');
770
+ const onGanttMouseMove = (e) => {
771
+ const container = e.currentTarget;
772
+ // Find the first gantt-bar-area to get the bar column bounds
773
+ const barArea = container.querySelector('.gantt-bar-area');
774
+ if (!barArea) return;
775
+ const barRect = barArea.getBoundingClientRect();
776
+ const containerRect = container.getBoundingClientRect();
777
+ const barLeft = barRect.left - containerRect.left;
778
+ const barRight = barLeft + barRect.width;
779
+ const mouseX = e.clientX - containerRect.left;
780
+
781
+ if (mouseX >= barLeft && mouseX <= barRight) {
782
+ ganttCrosshairX.value = mouseX;
783
+ // Compute timestamp from position
784
+ const pct = (mouseX - barLeft) / barRect.width;
785
+ const ts = sessionStart.value + pct * totalDuration.value;
786
+ const d = new Date(ts);
787
+ const h = String(d.getHours()).padStart(2, '0');
788
+ const m = String(d.getMinutes()).padStart(2, '0');
789
+ const s = String(d.getSeconds()).padStart(2, '0');
790
+ ganttCrosshairTime.value = h + ':' + m + ':' + s;
791
+ } else {
792
+ ganttCrosshairX.value = null;
793
+ }
794
+ };
795
+ const onGanttMouseLeave = () => {
796
+ ganttCrosshairX.value = null;
797
+ };
798
+
743
799
  const copyTimelineMarkdown = async () => {
744
800
  const items = unifiedTimelineItems.value;
745
801
  if (!items.length) return;
@@ -883,8 +939,70 @@
883
939
  });
884
940
 
885
941
  // ── Shared sorted events (computed once, reused everywhere) ──
942
+ // Stable sort: use _fileIndex (set by backend) as tiebreaker for identical timestamps
886
943
  const sortedEvents = computed(() => {
887
- return [...events.value].sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
944
+ return [...events.value].sort((a, b) => {
945
+ const timeA = a.timestamp ? new Date(a.timestamp).getTime() : 0;
946
+ const timeB = b.timestamp ? new Date(b.timestamp).getTime() : 0;
947
+ if (timeA !== timeB) return timeA - timeB;
948
+ return (a._fileIndex ?? 0) - (b._fileIndex ?? 0);
949
+ });
950
+ });
951
+
952
+ // ── Map tool.execution_start events to owning subagent via parentToolCallId ──
953
+ const subagentToolMap = computed(() => {
954
+ const sorted = sortedEvents.value;
955
+
956
+ // 1. Collect all subagent toolCallIds
957
+ const subagentToolCallIds = new Set();
958
+ for (const ev of sorted) {
959
+ if (ev.type === 'subagent.started') {
960
+ const tcid = ev.data?.toolCallId;
961
+ if (tcid) subagentToolCallIds.add(tcid);
962
+ }
963
+ }
964
+
965
+ // 2. Build id → event lookup
966
+ const idMap = new Map();
967
+ for (const ev of sorted) {
968
+ if (ev.id) idMap.set(ev.id, ev);
969
+ }
970
+
971
+ // 3. For each tool.execution_start, walk parentId to find assistant.message,
972
+ // then read data.parentToolCallId
973
+ const toolToSubagent = new Map(); // tool event id → subagent toolCallId
974
+ const startIdByToolCallId = new Map(); // data.toolCallId → subagent toolCallId (for matching complete events)
975
+ for (const ev of sorted) {
976
+ if (ev.type !== 'tool.execution_start') continue;
977
+ let current = ev.parentId;
978
+ let depth = 0;
979
+ while (current && depth < 10) {
980
+ const parent = idMap.get(current);
981
+ if (!parent) break;
982
+ if (parent.type === 'assistant.message') {
983
+ const ptcid = parent.data?.parentToolCallId;
984
+ if (ptcid && subagentToolCallIds.has(ptcid)) {
985
+ toolToSubagent.set(ev.id, ptcid);
986
+ const tcid = ev.data?.toolCallId;
987
+ if (tcid) startIdByToolCallId.set(tcid, ptcid);
988
+ }
989
+ break;
990
+ }
991
+ current = parent.parentId;
992
+ depth++;
993
+ }
994
+ }
995
+
996
+ // 4. Map tool.execution_complete events via their toolCallId
997
+ for (const ev of sorted) {
998
+ if (ev.type !== 'tool.execution_complete') continue;
999
+ const tcid = ev.data?.toolCallId;
1000
+ if (tcid && startIdByToolCallId.has(tcid)) {
1001
+ toolToSubagent.set(ev.id, startIdByToolCallId.get(tcid));
1002
+ }
1003
+ }
1004
+
1005
+ return toolToSubagent;
888
1006
  });
889
1007
 
890
1008
  // ── Sub-agent analysis ──
@@ -898,13 +1016,25 @@
898
1016
  startStack.push(ev);
899
1017
  } else if (ev.type === 'subagent.completed' || ev.type === 'subagent.failed') {
900
1018
  const name = ev.data?.agentDisplayName || ev.data?.agentName || 'SubAgent';
901
- // Find matching start (by name, LIFO)
1019
+ // Find matching start by toolCallId, with name-based LIFO fallback
1020
+ const tcid = ev.data?.toolCallId;
902
1021
  let startIdx = -1;
903
- for (let i = startStack.length - 1; i >= 0; i--) {
904
- const sName = startStack[i].data?.agentDisplayName || startStack[i].data?.agentName || 'SubAgent';
905
- if (sName === name) {
906
- startIdx = i;
907
- break;
1022
+ if (tcid) {
1023
+ for (let i = startStack.length - 1; i >= 0; i--) {
1024
+ if (startStack[i].data?.toolCallId === tcid) {
1025
+ startIdx = i;
1026
+ break;
1027
+ }
1028
+ }
1029
+ }
1030
+ // Fallback to name-based LIFO if no toolCallId match
1031
+ if (startIdx < 0) {
1032
+ for (let i = startStack.length - 1; i >= 0; i--) {
1033
+ const sName = startStack[i].data?.agentDisplayName || startStack[i].data?.agentName || 'SubAgent';
1034
+ if (sName === name) {
1035
+ startIdx = i;
1036
+ break;
1037
+ }
908
1038
  }
909
1039
  }
910
1040
  const startEv = startIdx >= 0 ? startStack.splice(startIdx, 1)[0] : null;
@@ -912,16 +1042,28 @@
912
1042
  const endTime = new Date(ev.timestamp).getTime();
913
1043
  const duration = startTime ? endTime - startTime : null;
914
1044
 
915
- // Count tool calls between start and end
1045
+ // Count tool calls using parentToolCallId attribution (not time-window)
1046
+ const subagentTcid = startEv?.data?.toolCallId;
916
1047
  let toolCalls = 0;
917
1048
  const innerEvents = [];
918
1049
  if (startEv) {
919
1050
  for (const e of sorted) {
1051
+ if (e.type === 'tool.execution_start') {
1052
+ if (subagentTcid && subagentToolMap.value.get(e.id) === subagentTcid) {
1053
+ toolCalls++;
1054
+ }
1055
+ }
1056
+ // Keep time-window for innerEvents/markers (visual only)
920
1057
  const t = new Date(e.timestamp).getTime();
921
1058
  if (t >= startTime && t <= endTime) {
922
- if (e.type === 'tool.execution_start') toolCalls++;
923
1059
  if (TRACKABLE_EVENT_TYPES.has(e.type)) {
924
- innerEvents.push({ type: e.type, timestamp: t, data: e.data });
1060
+ if (e.type !== 'tool.execution_start' && e.type !== 'tool.execution_complete') {
1061
+ // Non-tool trackable events: use time-window
1062
+ innerEvents.push({ type: e.type, timestamp: t, data: e.data });
1063
+ } else if (subagentTcid && subagentToolMap.value.get(e.id) === subagentTcid) {
1064
+ // Tool events: only include if attributed to this subagent
1065
+ innerEvents.push({ type: e.type, timestamp: t, data: e.data });
1066
+ }
925
1067
  }
926
1068
  }
927
1069
  }
@@ -949,15 +1091,25 @@
949
1091
  const startTime = new Date(startEv.timestamp).getTime();
950
1092
  const duration = sessionEndTime - startTime;
951
1093
 
952
- // Count tool calls after start
1094
+ // Count tool calls using parentToolCallId attribution (not time-window)
1095
+ const subagentTcid = startEv.data?.toolCallId;
953
1096
  let toolCalls = 0;
954
1097
  const innerEvents = [];
955
1098
  for (const e of sorted) {
1099
+ if (e.type === 'tool.execution_start') {
1100
+ if (subagentTcid && subagentToolMap.value.get(e.id) === subagentTcid) {
1101
+ toolCalls++;
1102
+ }
1103
+ }
1104
+ // Keep time-window for innerEvents/markers (visual only)
956
1105
  const t = new Date(e.timestamp).getTime();
957
1106
  if (t >= startTime && t <= sessionEndTime) {
958
- if (e.type === 'tool.execution_start') toolCalls++;
959
1107
  if (TRACKABLE_EVENT_TYPES.has(e.type)) {
960
- innerEvents.push({ type: e.type, timestamp: t, data: e.data });
1108
+ if (e.type !== 'tool.execution_start' && e.type !== 'tool.execution_complete') {
1109
+ innerEvents.push({ type: e.type, timestamp: t, data: e.data });
1110
+ } else if (subagentTcid && subagentToolMap.value.get(e.id) === subagentTcid) {
1111
+ innerEvents.push({ type: e.type, timestamp: t, data: e.data });
1112
+ }
961
1113
  }
962
1114
  }
963
1115
  }
@@ -994,7 +1146,6 @@
994
1146
  const successRate = agents.length ? ((completed / agents.length) * 100).toFixed(0) : 100;
995
1147
 
996
1148
  // Merge overlapping intervals to get actual wall-clock time in subagents
997
- // and build merged interval list for tool counting (single pass)
998
1149
  const intervals = agents
999
1150
  .filter(a => a.startTime && a.endTime)
1000
1151
  .map(a => [new Date(a.startTime).getTime(), new Date(a.endTime).getTime()])
@@ -1009,19 +1160,8 @@
1009
1160
  }
1010
1161
  const totalTime = mergedIntervals.reduce((sum, iv) => sum + (iv.e - iv.s), 0);
1011
1162
 
1012
- // Count tool calls using merged intervals with binary search (O(n log k))
1013
- let totalTools = 0;
1014
- for (const ev of sortedEvents.value) {
1015
- if (ev.type !== 'tool.execution_start') continue;
1016
- const t = new Date(ev.timestamp).getTime();
1017
- let lo = 0, hi = mergedIntervals.length - 1;
1018
- while (lo <= hi) {
1019
- const mid = (lo + hi) >> 1;
1020
- if (t < mergedIntervals[mid].s) hi = mid - 1;
1021
- else if (t > mergedIntervals[mid].e) lo = mid + 1;
1022
- else { totalTools++; break; }
1023
- }
1024
- }
1163
+ // Sum per-subagent tool counts (already correctly attributed via parentToolCallId)
1164
+ const totalTools = agents.reduce((sum, a) => sum + (a.toolCalls || 0), 0);
1025
1165
 
1026
1166
  return { completed, failed, incomplete, totalTime, totalTools, successRate };
1027
1167
  });
@@ -1074,21 +1214,40 @@
1074
1214
  buckets.get(bucketIdx).push(ev);
1075
1215
  }
1076
1216
 
1217
+ // Helper: interpolate color from tool yellow to error red based on error ratio
1218
+ const toolErrorColor = (errorRatio) => {
1219
+ // 0 = #d29922 (yellow), 1 = #f85149 (red)
1220
+ const r = Math.round(210 + (248 - 210) * errorRatio);
1221
+ const g = Math.round(153 + (81 - 153) * errorRatio);
1222
+ const b = Math.round(34 + (73 - 34) * errorRatio);
1223
+ return 'rgb(' + r + ',' + g + ',' + b + ')';
1224
+ };
1225
+
1226
+ const isToolError = (ev) => {
1227
+ return ev.type === 'tool.execution_complete' && (ev.data?.isError || !!ev.data?.error);
1228
+ };
1229
+
1077
1230
  const toolMarkers = [];
1078
1231
  for (const [bucketIdx, group] of buckets) {
1079
1232
  const bucketMid = startTime + (bucketIdx + 0.5) * bucketSize;
1080
1233
  const relPos = ((bucketMid - startTime) / duration) * 100;
1081
1234
  const pos = Math.max(0, Math.min(100, relPos));
1082
1235
 
1236
+ // Count errors in this bucket
1237
+ const errorCount = group.filter(isToolError).length;
1238
+ const completeCount = group.filter(ev => ev.type === 'tool.execution_complete').length;
1239
+ const errorRatio = completeCount > 0 ? errorCount / completeCount : 0;
1240
+
1083
1241
  if (group.length === 1) {
1084
1242
  const ev = group[0];
1085
1243
  const cat = EVENT_MARKER_CATEGORIES[ev.type] || {};
1244
+ const color = isToolError(ev) ? '#f85149' : cat.color || '#8b949e';
1086
1245
  toolMarkers.push({
1087
1246
  type: ev.type,
1088
1247
  position: pos,
1089
- color: cat.color || '#8b949e',
1248
+ color,
1090
1249
  shape: cat.shape || 'circle',
1091
- label: cat.label || ev.type,
1250
+ label: isToolError(ev) ? (cat.label || ev.type) + ' (error)' : (cat.label || ev.type),
1092
1251
  timestamp: ev.timestamp,
1093
1252
  toolName: ev.data?.toolName || null,
1094
1253
  });
@@ -1100,17 +1259,20 @@
1100
1259
  const lbl = cat.label || ev.type;
1101
1260
  typeCounts[lbl] = (typeCounts[lbl] || 0) + 1;
1102
1261
  });
1262
+ if (errorCount > 0) typeCounts['Errors'] = errorCount;
1103
1263
  const summaryParts = Object.entries(typeCounts).map(([l, c]) => c + ' ' + l);
1104
- const dominantType = group.reduce((best, ev) => {
1105
- const cnt = group.filter(e => e.type === ev.type).length;
1106
- return cnt > best.cnt ? { type: ev.type, cnt } : best;
1107
- }, { type: group[0].type, cnt: 0 }).type;
1108
- const dominantCat = EVENT_MARKER_CATEGORIES[dominantType] || {};
1264
+ const clusterColor = errorRatio > 0 ? toolErrorColor(errorRatio) : ((() => {
1265
+ const dominantType = group.reduce((best, ev) => {
1266
+ const cnt = group.filter(e => e.type === ev.type).length;
1267
+ return cnt > best.cnt ? { type: ev.type, cnt } : best;
1268
+ }, { type: group[0].type, cnt: 0 }).type;
1269
+ return (EVENT_MARKER_CATEGORIES[dominantType] || {}).color || '#8b949e';
1270
+ })());
1109
1271
 
1110
1272
  toolMarkers.push({
1111
1273
  type: 'cluster',
1112
1274
  position: pos,
1113
- color: dominantCat.color || '#8b949e',
1275
+ color: clusterColor,
1114
1276
  shape: 'cluster',
1115
1277
  label: summaryParts.join(', '),
1116
1278
  count: group.length,
@@ -1128,25 +1290,38 @@
1128
1290
  const duration = gapEnd - gapStart;
1129
1291
  const gapEvents = [];
1130
1292
  const eventCounts = {};
1293
+ let toolCalls = 0;
1131
1294
  for (const e of sorted) {
1132
1295
  const t = new Date(e.timestamp).getTime();
1133
1296
  if (t >= gapStart && t <= gapEnd) {
1134
- if (TRACKABLE_EVENT_TYPES.has(e.type)) {
1135
- gapEvents.push({ type: e.type, timestamp: t, data: e.data });
1297
+ // For tool events, only include those NOT attributed to a subagent
1298
+ if (e.type.startsWith('tool.')) {
1299
+ const isSubagentTool = e.id && subagentToolMap.value.has(e.id);
1300
+ if (!isSubagentTool) {
1301
+ if (TRACKABLE_EVENT_TYPES.has(e.type)) {
1302
+ gapEvents.push({ type: e.type, timestamp: t, data: e.data });
1303
+ }
1304
+ if (e.type === 'tool.execution_start') {
1305
+ toolCalls++;
1306
+ }
1307
+ eventCounts.tool = (eventCounts.tool || 0) + 1;
1308
+ }
1309
+ } else {
1310
+ if (TRACKABLE_EVENT_TYPES.has(e.type)) {
1311
+ gapEvents.push({ type: e.type, timestamp: t, data: e.data });
1312
+ }
1313
+ let cat = 'other';
1314
+ if (e.type.startsWith('assistant.')) cat = 'message';
1315
+ else if (e.type.startsWith('user.')) cat = 'user';
1316
+ else if (e.type.startsWith('session.')) cat = 'session';
1317
+ eventCounts[cat] = (eventCounts[cat] || 0) + 1;
1136
1318
  }
1137
- // Count by category for summary
1138
- let cat = 'other';
1139
- if (e.type.startsWith('tool.')) cat = 'tool';
1140
- else if (e.type.startsWith('assistant.')) cat = 'message';
1141
- else if (e.type.startsWith('user.')) cat = 'user';
1142
- else if (e.type.startsWith('session.')) cat = 'session';
1143
- eventCounts[cat] = (eventCounts[cat] || 0) + 1;
1144
1319
  }
1145
1320
  }
1146
1321
 
1147
1322
  // Build summary string
1148
1323
  const parts = [];
1149
- if (eventCounts.tool) parts.push(eventCounts.tool + ' tool event' + (eventCounts.tool > 1 ? 's' : ''));
1324
+ if (toolCalls) parts.push(toolCalls + ' tool' + (toolCalls > 1 ? 's' : ''));
1150
1325
  if (eventCounts.message) parts.push(eventCounts.message + ' message' + (eventCounts.message > 1 ? 's' : ''));
1151
1326
  if (eventCounts.user) parts.push(eventCounts.user + ' user msg');
1152
1327
  if (eventCounts.session) parts.push(eventCounts.session + ' session event' + (eventCounts.session > 1 ? 's' : ''));
@@ -1159,6 +1334,7 @@
1159
1334
  itemType: 'agent-op',
1160
1335
  name: 'Main Agent',
1161
1336
  summary,
1337
+ toolCalls,
1162
1338
  startTime: new Date(gapStart).toISOString(),
1163
1339
  endTime: new Date(gapEnd).toISOString(),
1164
1340
  duration,
@@ -1650,6 +1826,8 @@
1650
1826
  llmTime,
1651
1827
  userThinkingPct: total > 0 ? (userThinkingTime / total * 100).toFixed(0) : 0,
1652
1828
  agentWorkingPct: total > 0 ? (agentWorkingTime / total * 100).toFixed(0) : 0,
1829
+ llmPct: total > 0 ? (llmTime / total * 100).toFixed(0) : 0,
1830
+ toolPct: total > 0 ? (totalToolTime.value / total * 100).toFixed(0) : 0,
1653
1831
  };
1654
1832
  });
1655
1833
 
@@ -1755,25 +1933,6 @@
1755
1933
  }
1756
1934
  }
1757
1935
 
1758
- // Tool summary section
1759
- const cats = toolTimeByCategory.value;
1760
- if (cats.length) {
1761
- items.push({ rowType: 'divider' });
1762
-
1763
- // Compute maxTime for proportional bars
1764
- const maxTime = Math.max(...cats.map(c => c.totalTime), 1);
1765
- for (const cat of cats) {
1766
- items.push({
1767
- rowType: 'tool-summary',
1768
- category: cat.category,
1769
- count: cat.count,
1770
- totalTime: cat.totalTime,
1771
- errors: cat.errors,
1772
- barWidthPct: (cat.totalTime / maxTime) * 100,
1773
- });
1774
- }
1775
- }
1776
-
1777
1936
  return items;
1778
1937
  });
1779
1938
 
@@ -1833,7 +1992,12 @@
1833
1992
  const resp = await fetch('/api/sessions/' + sessionId.value + '/events');
1834
1993
  if (!resp.ok) throw new Error('Failed to load events: ' + resp.statusText);
1835
1994
  const data = await resp.json();
1836
- events.value = data.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
1995
+ events.value = data.sort((a, b) => {
1996
+ const timeA = a.timestamp ? new Date(a.timestamp).getTime() : 0;
1997
+ const timeB = b.timestamp ? new Date(b.timestamp).getTime() : 0;
1998
+ if (timeA !== timeB) return timeA - timeB;
1999
+ return (a._fileIndex ?? 0) - (b._fileIndex ?? 0);
2000
+ });
1837
2001
  } catch (err) {
1838
2002
  error.value = err.message;
1839
2003
  } finally {
@@ -1960,6 +2124,7 @@
1960
2124
  subagentAnalysis, maxSubagentDuration, subagentTimelineItems, subagentStats,
1961
2125
  EVENT_MARKER_CATEGORIES, showMarkerLegend,
1962
2126
  copyLabel, copyTimelineMarkdown,
2127
+ ganttCrosshairX, ganttCrosshairTime, onGanttMouseMove, onGanttMouseLeave,
1963
2128
  turnAnalysis, maxTurnDuration, groupedTurns,
1964
2129
  unifiedTimelineItems,
1965
2130
  toolAnalysis, sortedToolAnalysis, maxToolDuration,
@@ -1985,21 +2150,19 @@
1985
2150
  <div v-else>
1986
2151
  <!-- Summary Cards -->
1987
2152
  <div class="summary-grid">
1988
- <div class="summary-card">
2153
+ <div class="summary-card" title="Wall-clock time from first event to last event in this session.">
1989
2154
  <div class="summary-card-label">Total Duration</div>
1990
2155
  <div class="summary-card-value">{{ formatDuration(totalDuration) }}</div>
1991
- <div class="summary-card-sub">
1992
- LLM {{ formatDuration(timeBreakdown.llmTime) }}
1993
- · Tools {{ formatDuration(totalToolTime) }}
1994
- <span v-if="timeBreakdown.userThinkingTime > 1000"> · User {{ formatDuration(timeBreakdown.userThinkingTime) }}</span>
2156
+ <div class="summary-card-sub" v-if="sessionStart" style="margin-top: 2px; font-size: 10px; opacity: 0.7;">
2157
+ {{ formatDateTime(sessionStart) }} → {{ formatDateTime(sessionEnd) }}
1995
2158
  </div>
1996
2159
  </div>
1997
- <div class="summary-card">
2160
+ <div class="summary-card" title="Number of user messages that triggered agent work. Each request may involve multiple LLM turns.">
1998
2161
  <div class="summary-card-label">User Requests</div>
1999
2162
  <div class="summary-card-value">{{ groupedTurns.length }}</div>
2000
2163
  <div class="summary-card-sub">{{ turnAnalysis.length }} turn{{ turnAnalysis.length !== 1 ? 's' : '' }}</div>
2001
2164
  </div>
2002
- <div class="summary-card">
2165
+ <div class="summary-card" title="Total tool executions (Read, Write, Edit, Bash, Grep, etc.) across the entire session, including subagent tools.">
2003
2166
  <div class="summary-card-label">Tool Calls</div>
2004
2167
  <div class="summary-card-value">{{ totalToolCount }}</div>
2005
2168
  <div class="summary-card-sub">
@@ -2007,7 +2170,7 @@
2007
2170
  <span v-if="errorCount > 0" style="color: #f85149;"> · {{ errorCount }} error{{ errorCount !== 1 ? 's' : '' }}</span>
2008
2171
  </div>
2009
2172
  </div>
2010
- <div class="summary-card">
2173
+ <div class="summary-card" title="Spawned subagents (via Task tool). Shows completed/failed/incomplete counts, total wall-clock time, and tool calls attributed to subagents.">
2011
2174
  <div class="summary-card-label">Sub-Agents</div>
2012
2175
  <div class="summary-card-value">{{ subagentAnalysis.length }}</div>
2013
2176
  <div class="summary-card-sub">
@@ -2018,7 +2181,15 @@
2018
2181
  · {{ subagentStats.totalTools }} tools
2019
2182
  </div>
2020
2183
  </div>
2021
- <div class="summary-card">
2184
+ <div class="summary-card" title="Estimated LLM reasoning time (total duration minus tool execution and user thinking time). Breakdown shows LLM percentage, tool wall-clock time, and user idle time.">
2185
+ <div class="summary-card-label">Time Breakdown</div>
2186
+ <div class="summary-card-value">{{ formatDuration(timeBreakdown.llmTime) }} <span style="font-size: 14px; opacity: 0.6;">({{ timeBreakdown.llmPct }}% LLM Reasoning)</span></div>
2187
+ <div class="summary-card-sub">
2188
+ Tools {{ formatDuration(totalToolTime) }} ({{ timeBreakdown.toolPct }}%)
2189
+ <span v-if="timeBreakdown.userThinkingTime > 1000"> · User {{ formatDuration(timeBreakdown.userThinkingTime) }} ({{ timeBreakdown.userThinkingPct }}%)</span>
2190
+ </div>
2191
+ </div>
2192
+ <div class="summary-card" title="File system operations: reads (Read/Glob), edits (Edit), writes (Write), and searches (Grep).">
2022
2193
  <div class="summary-card-label">File Operations</div>
2023
2194
  <div class="summary-card-value">{{ fileStats.totalOps }}</div>
2024
2195
  <div class="summary-card-sub">
@@ -2069,16 +2240,30 @@
2069
2240
  <span>Main Agent</span>
2070
2241
  </div>
2071
2242
  <div class="event-legend-item">
2072
- <span class="event-legend-swatch" style="background: rgba(158, 106, 3, 0.6);"></span>
2073
- <span>Tool Category</span>
2243
+ <span class="event-legend-swatch" style="background: #d29922;"></span>
2244
+ <span>Tool (no errors)</span>
2074
2245
  </div>
2075
- <div v-for="(cat, type) in EVENT_MARKER_CATEGORIES" :key="type" class="event-legend-item">
2076
- <span class="event-legend-swatch" :style="{ background: cat.color, borderRadius: cat.shape === 'circle' ? '50%' : cat.shape === 'diamond' ? '1px' : '2px', transform: cat.shape === 'diamond' ? 'rotate(45deg)' : 'none' }"></span>
2077
- <span>{{ cat.label }}</span>
2246
+ <div class="event-legend-item">
2247
+ <span class="event-legend-swatch" style="background: linear-gradient(to right, #d29922, #f85149);"></span>
2248
+ <span>Tool (error gradient)</span>
2078
2249
  </div>
2250
+ <div class="event-legend-item">
2251
+ <span class="event-legend-swatch" style="background: #f85149;"></span>
2252
+ <span>Tool Error (100%)</span>
2253
+ </div>
2254
+ <template v-for="(cat, type) in EVENT_MARKER_CATEGORIES" :key="type">
2255
+ <div v-if="type && !type.startsWith('tool.')" class="event-legend-item">
2256
+ <span class="event-legend-swatch" :style="{ background: cat.color, borderRadius: cat.shape === 'circle' ? '50%' : cat.shape === 'diamond' ? '1px' : '2px', transform: cat.shape === 'diamond' ? 'rotate(45deg)' : 'none' }"></span>
2257
+ <span>{{ cat.label }}</span>
2258
+ </div>
2259
+ </template>
2079
2260
  </div>
2080
2261
 
2081
- <div class="gantt-container">
2262
+ <div class="gantt-container" @mousemove="onGanttMouseMove" @mouseleave="onGanttMouseLeave">
2263
+ <!-- Crosshair -->
2264
+ <div v-if="ganttCrosshairX !== null" class="gantt-crosshair" :style="{ left: ganttCrosshairX + 'px' }">
2265
+ <div class="gantt-crosshair-label">{{ ganttCrosshairTime }}</div>
2266
+ </div>
2082
2267
  <template v-for="(item, idx) in unifiedTimelineItems" :key="'utl-' + idx">
2083
2268
 
2084
2269
  <!-- Divider row -->
@@ -2206,24 +2391,6 @@
2206
2391
  </div>
2207
2392
  </div>
2208
2393
 
2209
- <!-- Tool Summary row -->
2210
- <div v-else-if="item.rowType === 'tool-summary'" class="gantt-row">
2211
- <div class="gantt-label tool-summary" :title="item.category + ' (' + item.count + ' calls)'">
2212
- <span class="tool-cat-icon">🔧</span>
2213
- {{ item.category }}
2214
- <span class="tool-cat-count">({{ item.count }})</span>
2215
- </div>
2216
- <div class="gantt-bar-area">
2217
- <div
2218
- class="gantt-bar tool-summary"
2219
- :style="{ left: '0%', width: item.barWidthPct + '%' }"
2220
- :title="item.category + ' — ' + formatDuration(item.totalTime) + ' (' + item.count + ' calls)'"
2221
- >
2222
- {{ formatDuration(item.totalTime) }}
2223
- </div>
2224
- </div>
2225
- </div>
2226
-
2227
2394
  </template>
2228
2395
 
2229
2396
  <div class="gantt-time-axis">
@@ -2232,6 +2399,29 @@
2232
2399
  </div>
2233
2400
  </div>
2234
2401
 
2402
+ <!-- Tool Summary -->
2403
+ <div v-if="toolTimeByCategory.length" style="margin-top: 24px;">
2404
+ <h3 style="color: #e6edf3; font-size: 14px; margin-bottom: 12px;">🔧 Tool Summary</h3>
2405
+ <div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 8px;">
2406
+ <div
2407
+ v-for="cat in toolTimeByCategory"
2408
+ :key="cat.category"
2409
+ style="background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 10px 12px; display: flex; align-items: center; gap: 10px;"
2410
+ >
2411
+ <div style="flex: 1; min-width: 0;">
2412
+ <div style="display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 4px;">
2413
+ <span style="color: #d29922; font-weight: 500; font-size: 13px;">{{ cat.category }}</span>
2414
+ <span style="color: #7d8590; font-size: 11px;">{{ cat.count }} call{{ cat.count !== 1 ? 's' : '' }}<span v-if="cat.errors" style="color: #f85149;"> · {{ cat.errors }} err</span></span>
2415
+ </div>
2416
+ <div style="background: #21262d; border-radius: 3px; height: 6px; overflow: hidden;">
2417
+ <div :style="{ width: (cat.totalTime / maxCategoryTime * 100) + '%', height: '100%', background: 'rgba(158, 106, 3, 0.7)', borderRadius: '3px' }"></div>
2418
+ </div>
2419
+ <div style="color: #7d8590; font-size: 11px; margin-top: 3px;">{{ formatDuration(cat.totalTime) }}</div>
2420
+ </div>
2421
+ </div>
2422
+ </div>
2423
+ </div>
2424
+
2235
2425
  </div>
2236
2426
  </div>
2237
2427