@qiaolei81/copilot-session-viewer 0.1.8 → 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.8",
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 [];
@@ -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
 
@@ -939,8 +939,14 @@
939
939
  });
940
940
 
941
941
  // ── Shared sorted events (computed once, reused everywhere) ──
942
+ // Stable sort: use _fileIndex (set by backend) as tiebreaker for identical timestamps
942
943
  const sortedEvents = computed(() => {
943
- 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
+ });
944
950
  });
945
951
 
946
952
  // ── Map tool.execution_start events to owning subagent via parentToolCallId ──
@@ -1986,7 +1992,12 @@
1986
1992
  const resp = await fetch('/api/sessions/' + sessionId.value + '/events');
1987
1993
  if (!resp.ok) throw new Error('Failed to load events: ' + resp.statusText);
1988
1994
  const data = await resp.json();
1989
- 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
+ });
1990
2001
  } catch (err) {
1991
2002
  error.value = err.message;
1992
2003
  } finally {