@qiaolei81/copilot-session-viewer 0.1.8 → 0.2.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 (56) hide show
  1. package/.nyc_output/coverage-core-functionality-spec-js-Core-Functionality-Tests-should-be-responsive-on-mobile-viewport-1771605454041.json +435 -0
  2. package/.nyc_output/coverage-core-functionality-spec-js-Core-Functionality-Tests-should-display-sessions-if-available-1771605462872.json +435 -0
  3. package/.nyc_output/coverage-core-functionality-spec-js-Core-Functionality-Tests-should-handle-JavaScript-errors-gracefully-1771605463381.json +435 -0
  4. package/.nyc_output/coverage-core-functionality-spec-js-Core-Functionality-Tests-should-handle-session-import-dialog-1771605466264.json +435 -0
  5. package/.nyc_output/coverage-core-functionality-spec-js-Core-Functionality-Tests-should-have-working-infinite-scroll-elements-1771605454038.json +435 -0
  6. package/.nyc_output/coverage-core-functionality-spec-js-Core-Functionality-Tests-should-load-homepage-with-basic-elements-1771605454001.json +435 -0
  7. package/.nyc_output/coverage-core-functionality-spec-js-Core-Functionality-Tests-should-load-time-analysis-page-1771605464990.json +1236 -0
  8. package/.nyc_output/coverage-core-functionality-spec-js-Core-Functionality-Tests-should-navigate-to-session-detail-page-1771605472595.json +1177 -0
  9. package/.nyc_output/coverage-e2e-merged.json +1 -0
  10. package/.nyc_output/coverage-homepage-spec-js-Homepage-should-display-session-list-1771605453565.json +435 -0
  11. package/.nyc_output/coverage-homepage-spec-js-Homepage-should-load-homepage-successfully-1771605453552.json +435 -0
  12. package/.nyc_output/coverage-homepage-spec-js-Homepage-should-navigate-to-session-detail-on-click-1771605469317.json +1134 -0
  13. package/.nyc_output/coverage-homepage-spec-js-Homepage-should-show-session-metadata-1771605460581.json +435 -0
  14. package/.nyc_output/coverage-infinite-scroll-spec-js-Infinite-Scroll-should-display-Load-More-Sessions-button-when-there-are-more-sessions-1771605468486.json +435 -0
  15. package/.nyc_output/coverage-infinite-scroll-spec-js-Infinite-Scroll-should-handle-API-errors-gracefully-during-infinite-scroll-1771605482161.json +471 -0
  16. package/.nyc_output/coverage-infinite-scroll-spec-js-Infinite-Scroll-should-hide-Load-More-button-when-no-more-sessions-available-1771605478370.json +471 -0
  17. package/.nyc_output/coverage-infinite-scroll-spec-js-Infinite-Scroll-should-load-additional-sessions-when-Load-More-button-is-clicked-1771605475059.json +471 -0
  18. package/.nyc_output/coverage-infinite-scroll-spec-js-Infinite-Scroll-should-preserve-session-list-state-during-navigation-1771605494575.json +1633 -0
  19. package/.nyc_output/coverage-infinite-scroll-spec-js-Infinite-Scroll-should-show-loading-state-when-Load-More-button-is-clicked-1771605475401.json +471 -0
  20. package/.nyc_output/coverage-infinite-scroll-spec-js-Infinite-Scroll-should-trigger-infinite-scroll-when-scrolling-near-bottom-1771605476949.json +471 -0
  21. package/.nyc_output/coverage-session-detail-spec-js-Session-Detail-Page-should-clear-search-filter-1771605508542.json +1255 -0
  22. package/.nyc_output/coverage-session-detail-spec-js-Session-Detail-Page-should-display-event-list-1771605505572.json +1156 -0
  23. package/.nyc_output/coverage-session-detail-spec-js-Session-Detail-Page-should-display-session-metadata-1771605504552.json +701 -0
  24. package/.nyc_output/coverage-session-detail-spec-js-Session-Detail-Page-should-expand-and-collapse-tool-details-1771605515809.json +1182 -0
  25. package/.nyc_output/coverage-session-detail-spec-js-Session-Detail-Page-should-filter-events-by-search-1771605513421.json +1245 -0
  26. package/.nyc_output/coverage-session-detail-spec-js-Session-Detail-Page-should-load-session-detail-page-1771605494974.json +701 -0
  27. package/.nyc_output/coverage-session-detail-spec-js-Session-Detail-Page-should-toggle-content-visibility-1771605550729.json +1177 -0
  28. package/.nyc_output/coverage-unit.json +21 -0
  29. package/.nycrc +29 -0
  30. package/CHANGELOG.md +36 -0
  31. package/README.md +154 -15
  32. package/examples/parser-usage.js +114 -0
  33. package/lib/parsers/README.md +239 -0
  34. package/lib/parsers/base-parser.js +53 -0
  35. package/lib/parsers/claude-parser.js +181 -0
  36. package/lib/parsers/copilot-parser.js +143 -0
  37. package/lib/parsers/index.js +13 -0
  38. package/lib/parsers/parser-factory.js +77 -0
  39. package/lib/parsers/pi-mono-parser.js +119 -0
  40. package/package.json +12 -4
  41. package/server.js +17 -2
  42. package/src/app.js +45 -20
  43. package/src/controllers/insightController.js +44 -8
  44. package/src/controllers/sessionController.js +217 -3
  45. package/src/controllers/uploadController.js +447 -7
  46. package/src/middleware/rateLimiting.js +7 -1
  47. package/src/models/Session.js +26 -0
  48. package/src/schemas/event.schema.js +73 -0
  49. package/src/services/eventNormalizer.js +291 -0
  50. package/src/services/insightService.js +140 -48
  51. package/src/services/sessionRepository.js +584 -49
  52. package/src/services/sessionService.js +1594 -27
  53. package/src/utils/helpers.js +6 -1
  54. package/views/index.ejs +111 -4
  55. package/views/session-vue.ejs +425 -71
  56. package/views/time-analyze.ejs +140 -57
@@ -8,13 +8,16 @@
8
8
  <!-- Vue 3 -->
9
9
  <script src="https://cdn.jsdelivr.net/npm/vue@3.5.28/dist/vue.global.prod.js" integrity="sha384-EyKhbIJoP1t1fKIFRNEfYKy4uy8qxs7UNS4Cab53xyXqCTUB1PCoxeFsD0G/NX9W" crossorigin="anonymous"></script>
10
10
 
11
- <!-- vue-virtual-scroller -->
12
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/vue-virtual-scroller@2.0.0-beta.8/dist/vue-virtual-scroller.css" integrity="sha384-PIWxQLH13FE3yKn8YrBWCcBRMkCKr2xW0XcqY9sNVhmihrqyXrdsWrcjZdqlnaWg" crossorigin="anonymous">
13
- <script src="https://cdn.jsdelivr.net/npm/vue-virtual-scroller@2.0.0-beta.8/dist/vue-virtual-scroller.min.js" integrity="sha384-asaCxaI3GxogQlKm59ynOPR3hwB7hNOTYipFrlNUHdRM0BOQs5BRO8ix8clxlj6p" crossorigin="anonymous"></script>
11
+ <!-- vue-virtual-scroller Vue 3 -->
12
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/vue-virtual-scroller@next/dist/vue-virtual-scroller.css" crossorigin="anonymous">
13
+ <script src="https://cdn.jsdelivr.net/npm/vue-virtual-scroller@next/dist/vue-virtual-scroller.min.js" crossorigin="anonymous"></script>
14
14
 
15
15
  <!-- Markdown rendering -->
16
16
  <script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js" integrity="sha384-zbcZAIxlvJtNE3Dp5nxLXdXtXyxwOdnILY1TDPVmKFhl4r4nSUG1r8bcFXGVa4Te" crossorigin="anonymous"></script>
17
17
 
18
+ <!-- DOMPurify for XSS protection -->
19
+ <script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.8/dist/purify.min.js" crossorigin="anonymous"></script>
20
+
18
21
  <style>
19
22
  * { margin: 0; padding: 0; box-sizing: border-box; }
20
23
  body {
@@ -224,6 +227,21 @@
224
227
  color: #58a6ff;
225
228
  border: 1px solid rgba(31, 111, 235, 0.4);
226
229
  }
230
+ .source-copilot {
231
+ background: rgba(88, 166, 255, 0.2);
232
+ color: #58a6ff;
233
+ border: 1px solid rgba(88, 166, 255, 0.4);
234
+ }
235
+ .source-claude {
236
+ background: rgba(210, 153, 34, 0.2);
237
+ color: #d29922;
238
+ border: 1px solid rgba(210, 153, 34, 0.4);
239
+ }
240
+ .source-pi-mono {
241
+ background: rgba(138, 102, 204, 0.2);
242
+ color: #a78bdb;
243
+ border: 1px solid rgba(138, 102, 204, 0.4);
244
+ }
227
245
 
228
246
  /* Turn buttons */
229
247
  .turn-buttons {
@@ -867,7 +885,7 @@
867
885
  content: '';
868
886
  flex: 1;
869
887
  height: 1px;
870
- background: #58a6ff;
888
+ background: var(--sa-color, #58a6ff);
871
889
  }
872
890
  .subagent-divider-text {
873
891
  font-size: 12px;
@@ -886,6 +904,8 @@
886
904
  .subagent-divider-line-right {
887
905
  display: none;
888
906
  }
907
+ .event.event-in-subagent { border-left-color: var(--subagent-border-color, #58a6ff); }
908
+ .subagent-owner-tag { font-size: 11px; padding: 1px 6px; border-radius: 8px; border: 1px solid; white-space: nowrap; opacity: 0.85; }
889
909
  </style>
890
910
  </head>
891
911
  <body>
@@ -902,14 +922,30 @@
902
922
 
903
923
  <!-- Vue App -->
904
924
  <script>
905
- const { createApp, ref, computed, onMounted, onBeforeUnmount, reactive, watch } = Vue;
906
- const { DynamicScroller, DynamicScrollerItem } = window.VueVirtualScroller;
907
-
908
- const app = createApp({
909
- components: {
910
- DynamicScroller,
911
- DynamicScrollerItem
912
- },
925
+ // Immediate initialization (script is at bottom, DOM is ready)
926
+ (function() {
927
+ // Verify Vue is loaded
928
+ if (typeof Vue === 'undefined') {
929
+ console.error('Vue is not loaded');
930
+ return;
931
+ }
932
+
933
+ // Verify VueVirtualScroller is loaded
934
+ if (typeof window.VueVirtualScroller === 'undefined') {
935
+ console.error('VueVirtualScroller is not loaded');
936
+ return;
937
+ }
938
+
939
+ console.log('Initializing Vue app...');
940
+
941
+ const { createApp, ref, computed, onMounted, onBeforeUnmount, reactive, watch } = Vue;
942
+ const { DynamicScroller, DynamicScrollerItem } = window.VueVirtualScroller;
943
+
944
+ const app = createApp({
945
+ components: {
946
+ DynamicScroller,
947
+ DynamicScrollerItem
948
+ },
913
949
 
914
950
  setup() {
915
951
  const sessionId = ref(window.sessionData.sessionId);
@@ -928,6 +964,24 @@
928
964
 
929
965
  const expandedTools = ref({});
930
966
  const expandedContent = ref({});
967
+ const MAX_EXPANDED_ITEMS = 50; // Memory leak fix: Limit expanded items
968
+
969
+ // Clean up old expansion state to prevent memory leak
970
+ const cleanupExpansionState = () => {
971
+ const toolKeys = Object.keys(expandedTools.value);
972
+ if (toolKeys.length > MAX_EXPANDED_ITEMS) {
973
+ // Keep only recent 50 expanded items
974
+ const toRemove = toolKeys.slice(0, toolKeys.length - MAX_EXPANDED_ITEMS);
975
+ toRemove.forEach(key => delete expandedTools.value[key]);
976
+ }
977
+
978
+ const contentKeys = Object.keys(expandedContent.value);
979
+ if (contentKeys.length > MAX_EXPANDED_ITEMS) {
980
+ const toRemove = contentKeys.slice(0, contentKeys.length - MAX_EXPANDED_ITEMS);
981
+ toRemove.forEach(key => delete expandedContent.value[key]);
982
+ }
983
+ };
984
+
931
985
  const currentFilter = ref('all');
932
986
  const searchText = ref('');
933
987
  const debouncedSearchText = ref('');
@@ -944,19 +998,33 @@
944
998
  }, 300);
945
999
  });
946
1000
 
1001
+ // Memory leak fix: Clean up expansion state when filter/search changes
1002
+ watch(currentFilter, () => {
1003
+ cleanupExpansionState();
1004
+ });
1005
+
1006
+ watch(debouncedSearchText, () => {
1007
+ cleanupExpansionState();
1008
+ });
1009
+
947
1010
  // Async loading state
948
1011
  const loadedEvents = ref([]);
949
1012
  const eventsLoading = ref(true);
950
1013
  const eventsError = ref(null);
951
1014
 
952
- // Flatten and sort events
1015
+ // Flatten and sort events (stable sort using _fileIndex tiebreaker)
953
1016
  const flatEvents = computed(() => {
954
1017
  const events = loadedEvents.value
955
- .filter(e =>
956
- e.type !== 'assistant.turn_end' &&
1018
+ .filter(e =>
1019
+ e.type !== 'assistant.turn_end' &&
957
1020
  e.type !== 'assistant.turn_complete'
958
1021
  )
959
- .sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp))
1022
+ .sort((a, b) => {
1023
+ const timeA = a.timestamp ? new Date(a.timestamp).getTime() : 0;
1024
+ const timeB = b.timestamp ? new Date(b.timestamp).getTime() : 0;
1025
+ if (timeA !== timeB) return timeA - timeB;
1026
+ return (a._fileIndex ?? 0) - (b._fileIndex ?? 0);
1027
+ })
960
1028
  .map((e, index) => ({
961
1029
  ...e,
962
1030
  virtualIndex: index,
@@ -1004,12 +1072,12 @@
1004
1072
  // Final filtered events (search + type filter)
1005
1073
  const filteredEvents = computed(() => {
1006
1074
  let events = searchFilteredEvents.value;
1007
-
1075
+
1008
1076
  // Apply type filter
1009
1077
  if (currentFilter.value !== 'all') {
1010
1078
  events = events.filter(e => e.type === currentFilter.value);
1011
1079
  }
1012
-
1080
+
1013
1081
  // Divider types (no separator before these)
1014
1082
  const dividerTypes = ['assistant.turn_start', 'subagent.started', 'subagent.completed', 'subagent.failed'];
1015
1083
 
@@ -1161,43 +1229,98 @@
1161
1229
  };
1162
1230
 
1163
1231
  // Tool call map
1164
- const toolCallMap = computed(() => {
1165
- const map = new Map();
1166
- const toolGroups = new Map();
1167
-
1168
- flatEvents.value.forEach(event => {
1169
- if (event.type === 'tool.execution_start') {
1170
- const toolId = event.data?.toolCallId;
1171
- if (toolId) {
1172
- if (!toolGroups.has(toolId)) {
1173
- toolGroups.set(toolId, { tool: event.data.tool, start: event });
1174
- }
1232
+ // Subagent ownership: attribute events to their owning subagent
1233
+ const subagentOwnership = computed(() => {
1234
+ const sorted = flatEvents.value;
1235
+ const ownerMap = new Map(); // stableId → toolCallId
1236
+ const subagentInfo = new Map(); // toolCallId { name, colorIndex }
1237
+
1238
+ // 1. Collect all subagent.started toolCallIds + assign colorIndex
1239
+ let colorIdx = 0;
1240
+ for (const ev of sorted) {
1241
+ if (ev.type === 'subagent.started') {
1242
+ const tcid = ev.data?.toolCallId;
1243
+ if (tcid) {
1244
+ subagentInfo.set(tcid, {
1245
+ name: ev.data?.agentDisplayName || ev.data?.agentName || 'SubAgent',
1246
+ colorIndex: colorIdx++
1247
+ });
1175
1248
  }
1176
- } else if (event.type === 'tool.execution_complete') {
1177
- const toolId = event.data?.toolCallId;
1178
- if (toolId && toolGroups.has(toolId)) {
1179
- toolGroups.get(toolId).complete = event;
1249
+ }
1250
+ }
1251
+
1252
+ if (subagentInfo.size === 0) return { ownerMap, subagentInfo };
1253
+
1254
+ // 2. Build id → event lookup for parentId chain walking
1255
+ const idMap = new Map();
1256
+ for (const ev of sorted) {
1257
+ if (ev.id) idMap.set(ev.id, ev);
1258
+ }
1259
+
1260
+ // 3. Attribute assistant.message events via data.parentToolCallId
1261
+ for (const ev of sorted) {
1262
+ if (ev.type === 'assistant.message') {
1263
+ const ptcid = ev.data?.parentToolCallId;
1264
+ if (ptcid && subagentInfo.has(ptcid)) {
1265
+ ownerMap.set(ev.stableId, ptcid);
1180
1266
  }
1181
1267
  }
1182
- });
1183
-
1184
- flatEvents.value.forEach(event => {
1185
- if (event.type === 'assistant.message') {
1186
- const groups = [];
1187
- toolGroups.forEach((group, toolId) => {
1188
- if (group.start?.parentId === event.id) {
1189
- groups.push(group);
1268
+ }
1269
+
1270
+ // 4. Attribute reasoning events by walking parentId → assistant.message
1271
+ for (const ev of sorted) {
1272
+ if (ev.type !== 'reasoning') continue;
1273
+ let current = ev.parentId;
1274
+ let depth = 0;
1275
+ while (current && depth < 10) {
1276
+ const parent = idMap.get(current);
1277
+ if (!parent) break;
1278
+ if (parent.type === 'assistant.message') {
1279
+ const ptcid = parent.data?.parentToolCallId;
1280
+ if (ptcid && subagentInfo.has(ptcid)) {
1281
+ ownerMap.set(ev.stableId, ptcid);
1190
1282
  }
1191
- });
1192
- if (groups.length > 0) {
1193
- map.set(event.id || event.virtualIndex, groups);
1283
+ break;
1194
1284
  }
1285
+ current = parent.parentId;
1286
+ depth++;
1195
1287
  }
1196
- });
1197
-
1198
- return map;
1288
+ }
1289
+
1290
+ // 5. Attribute tool.execution_start/complete by walking parentId chain
1291
+ const startIdByToolCallId = new Map();
1292
+ for (const ev of sorted) {
1293
+ if (ev.type !== 'tool.execution_start') continue;
1294
+ let current = ev.parentId;
1295
+ let depth = 0;
1296
+ while (current && depth < 10) {
1297
+ const parent = idMap.get(current);
1298
+ if (!parent) break;
1299
+ if (parent.type === 'assistant.message') {
1300
+ const ptcid = parent.data?.parentToolCallId;
1301
+ if (ptcid && subagentInfo.has(ptcid)) {
1302
+ ownerMap.set(ev.stableId, ptcid);
1303
+ const tcid = ev.data?.toolCallId;
1304
+ if (tcid) startIdByToolCallId.set(tcid, ptcid);
1305
+ }
1306
+ break;
1307
+ }
1308
+ current = parent.parentId;
1309
+ depth++;
1310
+ }
1311
+ }
1312
+
1313
+ for (const ev of sorted) {
1314
+ if (ev.type !== 'tool.execution_complete') continue;
1315
+ const tcid = ev.data?.toolCallId;
1316
+ if (tcid && startIdByToolCallId.has(tcid)) {
1317
+ ownerMap.set(ev.stableId, startIdByToolCallId.get(tcid));
1318
+ }
1319
+ }
1320
+
1321
+ return { ownerMap, subagentInfo };
1199
1322
  });
1200
-
1323
+
1201
1324
  // Methods
1202
1325
  const formatTime = (timestamp) => {
1203
1326
  if (!timestamp) return '';
@@ -1208,8 +1331,18 @@
1208
1331
  return `${hours}:${minutes}:${seconds}`;
1209
1332
  };
1210
1333
 
1334
+ // Performance fix: Cache markdown rendering results
1335
+ const markdownCache = new Map();
1336
+ const MAX_CACHE_SIZE = 200;
1337
+
1211
1338
  const renderMarkdown = (text) => {
1212
1339
  if (!text) return '';
1340
+
1341
+ // Check cache first
1342
+ if (markdownCache.has(text)) {
1343
+ return markdownCache.get(text);
1344
+ }
1345
+
1213
1346
  try {
1214
1347
  // 处理转义序列:将 \r\n、\n、\t 等转换为实际字符
1215
1348
  let processedText = text
@@ -1219,6 +1352,14 @@
1219
1352
  .replace(/\\"/g, '"') // \" → 引号
1220
1353
  .replace(/\\\\/g, '\\'); // \\ → 反斜杠
1221
1354
 
1355
+ // DOMPurify configuration for markdown content
1356
+ const purifyConfig = {
1357
+ ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'code', 'pre', 'a', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr', 'del', 'span', 'div', 'mark'],
1358
+ ALLOWED_ATTR: ['href', 'style', 'class'],
1359
+ ALLOW_DATA_ATTR: false,
1360
+ ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i
1361
+ };
1362
+
1222
1363
  // Parse YAML frontmatter
1223
1364
  const frontmatterMatch = processedText.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
1224
1365
  if (frontmatterMatch) {
@@ -1233,18 +1374,42 @@
1233
1374
  return { key, value };
1234
1375
  });
1235
1376
 
1236
- // Render frontmatter as table
1377
+ // Render frontmatter as table (sanitize key/value)
1237
1378
  let tableHTML = '<table style="margin-bottom: 16px; border-collapse: collapse;"><tbody>';
1238
1379
  pairs.forEach(pair => {
1239
- tableHTML += `<tr><td style="padding: 4px 12px; border: 1px solid #30363d; font-weight: 600; color: #7d8590;">${pair.key}</td><td style="padding: 4px 12px; border: 1px solid #30363d;">${pair.value}</td></tr>`;
1380
+ const sanitizedKey = DOMPurify.sanitize(pair.key, { ALLOWED_TAGS: [] });
1381
+ const sanitizedValue = DOMPurify.sanitize(pair.value, { ALLOWED_TAGS: [] });
1382
+ tableHTML += `<tr><td style="padding: 4px 12px; border: 1px solid #30363d; font-weight: 600; color: #7d8590;">${sanitizedKey}</td><td style="padding: 4px 12px; border: 1px solid #30363d;">${sanitizedValue}</td></tr>`;
1240
1383
  });
1241
1384
  tableHTML += '</tbody></table>';
1242
1385
 
1243
- // Render remaining content
1244
- return tableHTML + marked.parse(content);
1386
+ // Render remaining content with sanitization
1387
+ const markdownHTML = marked.parse(content);
1388
+ const sanitizedMarkdown = DOMPurify.sanitize(markdownHTML, purifyConfig);
1389
+ const result = tableHTML + sanitizedMarkdown;
1390
+
1391
+ // Cache the result
1392
+ if (markdownCache.size >= MAX_CACHE_SIZE) {
1393
+ const firstKey = markdownCache.keys().next().value;
1394
+ markdownCache.delete(firstKey);
1395
+ }
1396
+ markdownCache.set(text, result);
1397
+
1398
+ return result;
1245
1399
  }
1246
1400
 
1247
- return marked.parse(processedText);
1401
+ // Regular markdown rendering with sanitization
1402
+ const markdownHTML = marked.parse(processedText);
1403
+ const result = DOMPurify.sanitize(markdownHTML, purifyConfig);
1404
+
1405
+ // Cache the result (with size limit to prevent memory leak)
1406
+ if (markdownCache.size >= MAX_CACHE_SIZE) {
1407
+ const firstKey = markdownCache.keys().next().value;
1408
+ markdownCache.delete(firstKey);
1409
+ }
1410
+ markdownCache.set(text, result);
1411
+
1412
+ return result;
1248
1413
  } catch (e) {
1249
1414
  return text;
1250
1415
  }
@@ -1315,7 +1480,18 @@
1315
1480
  return lines.slice(0, 20).join('\n') + '\n\n...';
1316
1481
  };
1317
1482
 
1318
- const getBadgeInfo = (type) => {
1483
+ const getBadgeInfo = (type, item) => {
1484
+ // Prefer backend-generated badge info (Violation #4 fix)
1485
+ if (item?.data?.badgeLabel && item?.data?.badgeClass) {
1486
+ return { label: item.data.badgeLabel, class: item.data.badgeClass };
1487
+ }
1488
+
1489
+ // Fallback: frontend logic for backward compatibility
1490
+ // Pi-Mono toolResult events: still use type='message' with role='toolResult'
1491
+ if (type === 'message' && item?.data?.role === 'toolResult') {
1492
+ return { label: 'TOOL RESULT', class: 'badge-tool' };
1493
+ }
1494
+
1319
1495
  // Special case for specific event types
1320
1496
  if (type === 'session.model_change') {
1321
1497
  return { label: 'MODEL CHANGE', class: 'badge-session' };
@@ -1436,13 +1612,99 @@
1436
1612
  };
1437
1613
 
1438
1614
  const hasTools = (event) => {
1439
- return event.type === 'assistant.message' && toolCallMap.value.has(event.id || event.virtualIndex);
1615
+ // Unified format: check data.tools (works for both Copilot and Claude)
1616
+ return event.data?.tools && event.data.tools.length > 0;
1440
1617
  };
1441
1618
 
1442
1619
  const getToolGroups = (event) => {
1443
- return toolCallMap.value.get(event.id || event.virtualIndex) || [];
1620
+ // Unified format from server (both Copilot and Claude normalized to data.tools)
1621
+ if (event.data?.tools && Array.isArray(event.data.tools)) {
1622
+ return event.data.tools
1623
+ .filter(tool => tool && typeof tool === 'object' && tool.name) // Any tool object with a name
1624
+ .map(tool => {
1625
+ // Check if tool has result (works for all formats)
1626
+ const hasResult = tool.result !== undefined || tool.status === 'completed' || tool.status === 'error';
1627
+ return {
1628
+ tool: tool.name,
1629
+ start: {
1630
+ data: {
1631
+ toolName: tool.name,
1632
+ arguments: tool.input || tool.arguments || {}
1633
+ }
1634
+ },
1635
+ complete: hasResult ? {
1636
+ data: {
1637
+ result: tool.result,
1638
+ error: tool.status === 'error' ? tool.error : null
1639
+ }
1640
+ } : null
1641
+ };
1642
+ });
1643
+ }
1644
+
1645
+ return [];
1444
1646
  };
1445
-
1647
+
1648
+ // Subagent color palette for parallel subagent distinction
1649
+ const SUBAGENT_COLORS = [
1650
+ '#58a6ff', // blue
1651
+ '#f0883e', // orange
1652
+ '#a371f7', // purple
1653
+ '#3fb950', // green
1654
+ '#f778ba', // pink
1655
+ '#79c0ff', // light blue
1656
+ '#d29922', // amber
1657
+ '#56d4dd' // teal
1658
+ ];
1659
+
1660
+ // Hash function for generating consistent color indices
1661
+ const hashCode = (str) => {
1662
+ let hash = 0;
1663
+ for (let i = 0; i < str.length; i++) {
1664
+ const char = str.charCodeAt(i);
1665
+ hash = ((hash << 5) - hash) + char;
1666
+ hash = hash & hash; // Convert to 32bit integer
1667
+ }
1668
+ return hash;
1669
+ };
1670
+
1671
+ const getSubagentInfo = (event) => {
1672
+ const { ownerMap, subagentInfo } = subagentOwnership.value;
1673
+ // For subagent dividers, use their own toolCallId
1674
+ if (event.type === 'subagent.started' || event.type === 'subagent.completed' || event.type === 'subagent.failed') {
1675
+ const tcid = event.data?.toolCallId;
1676
+ if (tcid && subagentInfo.has(tcid)) {
1677
+ const info = subagentInfo.get(tcid);
1678
+ return { name: info.name, toolCallId: tcid, colorIndex: info.colorIndex };
1679
+ }
1680
+ return null;
1681
+ }
1682
+ // For regular events, first check _subagent metadata (Claude format)
1683
+ if (event._subagent) {
1684
+ const subagentId = event._subagent.id;
1685
+ const subagentName = event._subagent.name;
1686
+ // Use subagentId as toolCallId for consistency
1687
+ if (subagentInfo.has(subagentId)) {
1688
+ const info = subagentInfo.get(subagentId);
1689
+ return { name: info.name, toolCallId: subagentId, colorIndex: info.colorIndex };
1690
+ }
1691
+ // If not in subagentInfo, create a default entry
1692
+ return { name: subagentName, toolCallId: subagentId, colorIndex: Math.abs(hashCode(subagentId)) };
1693
+ }
1694
+ // For regular events, look up ownership (Copilot format)
1695
+ const tcid = ownerMap.get(event.stableId);
1696
+ if (!tcid) return null;
1697
+ const info = subagentInfo.get(tcid);
1698
+ if (!info) return null;
1699
+ return { name: info.name, toolCallId: tcid, colorIndex: info.colorIndex };
1700
+ };
1701
+
1702
+ const getSubagentColor = (event) => {
1703
+ const info = getSubagentInfo(event);
1704
+ if (!info) return null;
1705
+ return SUBAGENT_COLORS[info.colorIndex % SUBAGENT_COLORS.length];
1706
+ };
1707
+
1446
1708
  const setFilter = (type) => {
1447
1709
  currentFilter.value = type;
1448
1710
  };
@@ -1513,28 +1775,53 @@
1513
1775
  };
1514
1776
 
1515
1777
  const exportSession = async () => {
1778
+ console.log('[Export] exportSession called');
1516
1779
  exporting.value = true;
1517
1780
  try {
1781
+ console.log('[Export] Fetching:', `/session/${sessionId.value}/export`);
1518
1782
  const response = await fetch(`/session/${sessionId.value}/export`);
1783
+ console.log('[Export] Response received:', response.status, response.ok);
1784
+ console.log('[Export] Response received:', response.status, response.ok);
1519
1785
  if (!response.ok) {
1520
1786
  throw new Error('Share failed');
1521
1787
  }
1522
1788
 
1523
1789
  // Download the file
1790
+ console.log('[Export] Creating blob...');
1524
1791
  const blob = await response.blob();
1792
+ console.log('[Export] Blob size:', blob.size, 'type:', blob.type);
1525
1793
  const url = window.URL.createObjectURL(blob);
1794
+ console.log('[Export] Creating download link...');
1526
1795
  const a = document.createElement('a');
1527
1796
  a.href = url;
1528
1797
  a.download = `session-${sessionId.value}.zip`;
1529
1798
  document.body.appendChild(a);
1530
1799
  a.click();
1800
+ console.log('[Export] Download triggered');
1531
1801
  window.URL.revokeObjectURL(url);
1532
1802
  document.body.removeChild(a);
1803
+
1804
+ // Show success feedback
1805
+ console.log('[Export] Showing success feedback...');
1806
+ const originalText = '📤 Share Session';
1807
+ const successText = '✓ Downloaded!';
1808
+ const btn = document.querySelector('.export-btn');
1809
+ if (btn) {
1810
+ btn.textContent = successText;
1811
+ btn.style.background = '#238636';
1812
+ console.log('[Export] Button text updated to:', btn.textContent);
1813
+ setTimeout(() => {
1814
+ btn.textContent = originalText;
1815
+ btn.style.background = '';
1816
+ console.log('[Export] Button text restored');
1817
+ }, 2000);
1818
+ }
1533
1819
  } catch (err) {
1534
- console.error('Share session error:', err);
1820
+ console.error('[Export] Share session error:', err);
1535
1821
  alert('Failed to share session: ' + err.message);
1536
1822
  } finally {
1537
1823
  exporting.value = false;
1824
+ console.log('[Export] Export complete');
1538
1825
  }
1539
1826
  };
1540
1827
 
@@ -1548,7 +1835,20 @@
1548
1835
  if (!response.ok) {
1549
1836
  throw new Error(`Failed to load events: ${response.statusText}`);
1550
1837
  }
1551
- loadedEvents.value = await response.json();
1838
+ const data = await response.json();
1839
+
1840
+ // Handle both old (array) and new (object with pagination) response formats
1841
+ if (Array.isArray(data)) {
1842
+ // Old format: direct array
1843
+ loadedEvents.value = data;
1844
+ } else if (data.events && Array.isArray(data.events)) {
1845
+ // New format: { events, pagination }
1846
+ loadedEvents.value = data.events;
1847
+ console.log('[Navigation] Pagination:', data.pagination);
1848
+ } else {
1849
+ throw new Error('Invalid response format');
1850
+ }
1851
+
1552
1852
  console.log('[Navigation] Events loaded:', loadedEvents.value.length);
1553
1853
 
1554
1854
  // Check for URL query parameters and jump to event AFTER events are loaded
@@ -1699,9 +1999,23 @@
1699
1999
 
1700
2000
  // Cleanup on unmount
1701
2001
  onBeforeUnmount(() => {
2002
+ // Clear search timeout (memory leak fix)
2003
+ if (searchTimeout) {
2004
+ clearTimeout(searchTimeout);
2005
+ searchTimeout = null;
2006
+ }
2007
+
2008
+ // Clean scroll listeners
1702
2009
  if (scrollCleanup) {
1703
2010
  scrollCleanup();
1704
2011
  }
2012
+
2013
+ // Clear expansion state (memory leak fix)
2014
+ expandedTools.value = {};
2015
+ expandedContent.value = {};
2016
+
2017
+ // Clear markdown cache (memory leak fix)
2018
+ markdownCache.clear();
1705
2019
  });
1706
2020
  });
1707
2021
 
@@ -1728,7 +2042,6 @@
1728
2042
  turns,
1729
2043
  userReqs,
1730
2044
  truncateText,
1731
- toolCallMap,
1732
2045
  formatTime,
1733
2046
  formatDateTime,
1734
2047
  renderMarkdown,
@@ -1744,6 +2057,8 @@
1744
2057
  getToolCommand,
1745
2058
  hasTools,
1746
2059
  getToolGroups,
2060
+ getSubagentInfo,
2061
+ getSubagentColor,
1747
2062
  setFilter,
1748
2063
  scrollToTurn,
1749
2064
  jumpToTurn,
@@ -1776,6 +2091,15 @@
1776
2091
  <div v-if="metadata.summary" class="session-summary-block">{{ metadata.summary }}</div>
1777
2092
  <table class="session-info-table">
1778
2093
  <tbody>
2094
+ <tr v-if="metadata.source">
2095
+ <td>Source</td>
2096
+ <td>
2097
+ <!-- Use backend-provided source metadata (Violation #3 fix) -->
2098
+ <span :class="['source-badge', metadata.sourceBadgeClass || 'source-copilot']">
2099
+ {{ metadata.sourceName || 'GitHub Copilot' }}
2100
+ </span>
2101
+ </td>
2102
+ </tr>
1779
2103
  <tr v-if="metadata.copilotVersion">
1780
2104
  <td>CLI Version</td>
1781
2105
  <td>{{ metadata.copilotVersion }}</td>
@@ -1913,32 +2237,41 @@
1913
2237
  </div>
1914
2238
 
1915
2239
  <!-- Subagent Divider -->
1916
- <div
2240
+ <div
1917
2241
  v-else-if="item.type === 'subagent.started' || item.type === 'subagent.completed' || item.type === 'subagent.failed'"
1918
2242
  :data-type="item.type"
1919
2243
  :data-index="item.virtualIndex"
1920
2244
  :class="['subagent-divider', item.type.split('.')[1]]"
2245
+ :style="{
2246
+ '--sa-color': getSubagentColor(item) || '#58a6ff'
2247
+ }"
1921
2248
  >
1922
- <div class="subagent-divider-line-left"></div>
1923
- <span class="subagent-divider-text">
2249
+ <div class="subagent-divider-line-left" :style="{ background: getSubagentColor(item) || '#58a6ff' }"></div>
2250
+ <span class="subagent-divider-text" :style="{ color: getSubagentColor(item) || '#58a6ff', borderColor: getSubagentColor(item) || '#58a6ff', background: (getSubagentColor(item) || '#58a6ff') + '1a' }">
1924
2251
  🤖 {{ item.data?.agentDisplayName || item.data?.agentName || 'SubAgent' }}
1925
2252
  {{ item.type === 'subagent.started' ? 'Start ▶' : item.type === 'subagent.completed' ? 'Complete ✓' : 'Failed ✗' }}
1926
2253
  </span>
1927
- <div class="subagent-divider-line-right"></div>
2254
+ <div class="subagent-divider-line-right" :style="{ background: getSubagentColor(item) || '#58a6ff' }"></div>
1928
2255
  <div class="divider-separator"></div>
1929
2256
  </div>
1930
2257
 
1931
2258
  <!-- Regular Event -->
1932
- <div
2259
+ <div
1933
2260
  v-else
1934
- :class="['event']"
2261
+ :class="['event', getSubagentInfo(item) ? 'event-in-subagent' : '']"
1935
2262
  :data-type="item.type"
1936
2263
  :data-index="item.virtualIndex"
2264
+ :style="getSubagentColor(item) ? { '--subagent-border-color': getSubagentColor(item) } : {}"
1937
2265
  >
1938
2266
  <div class="event-header">
1939
- <span :class="['event-badge', getBadgeInfo(item.type).class]">
1940
- {{ getBadgeInfo(item.type).label }}
2267
+ <span :class="['event-badge', getBadgeInfo(item.type, item).class]">
2268
+ {{ getBadgeInfo(item.type, item).label }}
1941
2269
  </span>
2270
+ <span
2271
+ v-if="getSubagentInfo(item)"
2272
+ class="subagent-owner-tag"
2273
+ :style="{ color: getSubagentColor(item), borderColor: getSubagentColor(item) }"
2274
+ >🤖 {{ getSubagentInfo(item).name }}</span>
1942
2275
  <span class="event-timestamp">{{ formatTime(item.timestamp) }}</span>
1943
2276
  </div>
1944
2277
 
@@ -2023,7 +2356,7 @@
2023
2356
  </div>
2024
2357
  </div>
2025
2358
 
2026
- <!-- Regular content -->
2359
+ <!-- Regular content (unified format from server) -->
2027
2360
  <div v-else-if="item.data?.message || item.data?.text || item.data?.content || item.data?.transformedContent">
2028
2361
  <div
2029
2362
  class="event-content"
@@ -2050,6 +2383,12 @@
2050
2383
  </div>
2051
2384
  </div>
2052
2385
 
2386
+ <!-- No content at all (no message and no tools) -->
2387
+ <div v-else-if="!hasTools(item)" class="event-content" style="color: #7d8590; font-style: italic;">
2388
+ No available message
2389
+ </div>
2390
+
2391
+ <!-- Tool calls section (independent of message content, but don't need "No available message" if tools exist) -->
2053
2392
  <div v-if="hasTools(item)" class="tool-list">
2054
2393
  <div
2055
2394
  v-for="(group, idx) in getToolGroups(item)"
@@ -2103,8 +2442,23 @@
2103
2442
  </div>
2104
2443
  `
2105
2444
  });
2106
-
2107
- app.mount('#app');
2445
+
2446
+ // Mount the app
2447
+ console.log('Mounting Vue app to #app...');
2448
+ console.log('App config:', app.config);
2449
+ console.log('Target element:', document.getElementById('app'));
2450
+ try {
2451
+ const vm = app.mount('#app');
2452
+ console.log('Vue app mounted successfully!', vm ? 'Instance created' : 'No instance');
2453
+ console.log('VM type:', typeof vm, 'Has exportSession:', typeof vm?.exportSession);
2454
+ console.log('VM keys:', vm ? Object.keys(vm).slice(0, 10) : 'NO_VM');
2455
+ console.log('#app innerHTML length:', document.getElementById('app').innerHTML.length);
2456
+ console.log('#app first 100 chars:', document.getElementById('app').innerHTML.substring(0, 100));
2457
+ } catch (error) {
2458
+ console.error('Mount failed:', error);
2459
+ console.error('Error stack:', error.stack);
2460
+ }
2461
+ })(); // End IIFE
2108
2462
  </script>
2109
2463
  </body>
2110
2464
  </html>