@qiaolei81/copilot-session-viewer 0.3.2 → 0.3.4
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 +34 -5
- package/public/js/homepage.min.js +35 -0
- package/public/js/session-detail.min.js +461 -0
- package/public/js/telemetry-browser.min.js +1 -0
- package/public/js/time-analyze.min.js +518 -0
- package/server.js +3 -0
- package/src/app.js +2 -1
- package/src/controllers/insightController.js +31 -0
- package/src/controllers/sessionController.js +56 -0
- package/src/controllers/tagController.js +8 -0
- package/src/controllers/uploadController.js +32 -1
- package/src/middleware/common.js +20 -1
- package/src/telemetry.js +152 -0
- package/views/index.ejs +9 -494
- package/views/session-vue.ejs +166 -1869
- package/views/telemetry-snippet.ejs +26 -0
- package/views/time-analyze.ejs +2 -2217
- package/.env.example +0 -14
- package/.nycrc +0 -29
- package/CHANGELOG.md +0 -290
- package/examples/parser-usage.js +0 -114
package/views/session-vue.ejs
CHANGED
|
@@ -17,7 +17,9 @@
|
|
|
17
17
|
|
|
18
18
|
<!-- DOMPurify for XSS protection -->
|
|
19
19
|
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.8/dist/purify.min.js" crossorigin="anonymous"></script>
|
|
20
|
-
|
|
20
|
+
|
|
21
|
+
<%- include('telemetry-snippet') %>
|
|
22
|
+
|
|
21
23
|
<style>
|
|
22
24
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
23
25
|
body {
|
|
@@ -509,6 +511,39 @@
|
|
|
509
511
|
padding: 0 16px;
|
|
510
512
|
}
|
|
511
513
|
|
|
514
|
+
.scroll-float-btns {
|
|
515
|
+
position: fixed;
|
|
516
|
+
bottom: 24px;
|
|
517
|
+
right: 24px;
|
|
518
|
+
display: flex;
|
|
519
|
+
flex-direction: column;
|
|
520
|
+
gap: 8px;
|
|
521
|
+
z-index: 9999;
|
|
522
|
+
}
|
|
523
|
+
.scroll-edge-btn {
|
|
524
|
+
background: #21262d;
|
|
525
|
+
color: #c9d1d9;
|
|
526
|
+
border: 1px solid #30363d;
|
|
527
|
+
border-radius: 50%;
|
|
528
|
+
width: 32px;
|
|
529
|
+
height: 32px;
|
|
530
|
+
font-size: 13px;
|
|
531
|
+
cursor: pointer;
|
|
532
|
+
display: flex;
|
|
533
|
+
align-items: center;
|
|
534
|
+
justify-content: center;
|
|
535
|
+
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
|
|
536
|
+
transition: background 0.15s, transform 0.1s, opacity 0.15s;
|
|
537
|
+
padding: 0;
|
|
538
|
+
opacity: 0.3;
|
|
539
|
+
}
|
|
540
|
+
.scroll-edge-btn:hover {
|
|
541
|
+
background: #388bfd;
|
|
542
|
+
border-color: #388bfd;
|
|
543
|
+
color: #fff;
|
|
544
|
+
transform: scale(1.1);
|
|
545
|
+
opacity: 1;
|
|
546
|
+
}
|
|
512
547
|
.search-input {
|
|
513
548
|
width: 300px;
|
|
514
549
|
padding: 6px 12px;
|
|
@@ -704,6 +739,8 @@
|
|
|
704
739
|
/* Virtual Scroller */
|
|
705
740
|
.vue-recycle-scroller {
|
|
706
741
|
flex: 1;
|
|
742
|
+
overflow-x: hidden !important;
|
|
743
|
+
padding-bottom: env(safe-area-inset-bottom, 0px);
|
|
707
744
|
}
|
|
708
745
|
.vue-recycle-scroller__item-wrapper {
|
|
709
746
|
overflow: visible !important;
|
|
@@ -895,8 +932,7 @@
|
|
|
895
932
|
/* Tool calls */
|
|
896
933
|
.tool-list {
|
|
897
934
|
margin-top: 6px;
|
|
898
|
-
padding-left:
|
|
899
|
-
border-left: 2px solid rgba(110, 118, 129, 0.3);
|
|
935
|
+
padding-left: 0;
|
|
900
936
|
}
|
|
901
937
|
.tool-item {
|
|
902
938
|
padding: 2px 0;
|
|
@@ -1062,1882 +1098,143 @@
|
|
|
1062
1098
|
.event.event-in-subagent { border-left-color: var(--subagent-border-color, #58a6ff); }
|
|
1063
1099
|
.subagent-owner-tag { font-size: 11px; padding: 1px 6px; border-radius: 8px; border: 1px solid; white-space: nowrap; opacity: 0.85; }
|
|
1064
1100
|
.subagent-name-badge { font-size: 11px; padding: 2px 8px; border-radius: 4px; border: 1px solid; white-space: nowrap; font-weight: 600; max-width: 280px; overflow: hidden; text-overflow: ellipsis; }
|
|
1065
|
-
</style>
|
|
1066
|
-
</head>
|
|
1067
|
-
<body>
|
|
1068
|
-
<div id="app"></div>
|
|
1069
|
-
|
|
1070
|
-
<!-- Session data -->
|
|
1071
|
-
<script>
|
|
1072
|
-
window.sessionData = {
|
|
1073
|
-
sessionId: '<%= sessionId %>',
|
|
1074
|
-
metadata: <%- JSON.stringify(metadata).replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029') %>,
|
|
1075
|
-
events: [] // Will be loaded asynchronously
|
|
1076
|
-
};
|
|
1077
|
-
</script>
|
|
1078
|
-
|
|
1079
|
-
<!-- Vue App -->
|
|
1080
|
-
<script>
|
|
1081
|
-
// Immediate initialization (script is at bottom, DOM is ready)
|
|
1082
|
-
(function() {
|
|
1083
|
-
// Verify Vue is loaded
|
|
1084
|
-
if (typeof Vue === 'undefined') {
|
|
1085
|
-
console.error('Vue is not loaded');
|
|
1086
|
-
return;
|
|
1087
|
-
}
|
|
1088
|
-
|
|
1089
|
-
// Verify VueVirtualScroller is loaded
|
|
1090
|
-
if (typeof window.VueVirtualScroller === 'undefined') {
|
|
1091
|
-
console.error('VueVirtualScroller is not loaded');
|
|
1092
|
-
return;
|
|
1093
|
-
}
|
|
1094
|
-
|
|
1095
|
-
console.log('Initializing Vue app...');
|
|
1096
|
-
|
|
1097
|
-
const { createApp, ref, computed, onMounted, onBeforeUnmount, reactive, watch } = Vue;
|
|
1098
|
-
const { DynamicScroller, DynamicScrollerItem } = window.VueVirtualScroller;
|
|
1099
|
-
|
|
1100
|
-
const app = createApp({
|
|
1101
|
-
components: {
|
|
1102
|
-
DynamicScroller,
|
|
1103
|
-
DynamicScrollerItem
|
|
1104
|
-
},
|
|
1105
|
-
|
|
1106
|
-
setup() {
|
|
1107
|
-
const sessionId = ref(window.sessionData.sessionId);
|
|
1108
|
-
const metadata = ref(window.sessionData.metadata);
|
|
1109
|
-
const exporting = ref(false);
|
|
1110
|
-
|
|
1111
|
-
// Load sidebar state from localStorage
|
|
1112
|
-
const sidebarCollapsed = ref(
|
|
1113
|
-
localStorage.getItem('sidebarCollapsed') === 'true'
|
|
1114
|
-
);
|
|
1115
|
-
|
|
1116
|
-
// Persist sidebar state to localStorage
|
|
1117
|
-
watch(sidebarCollapsed, (newValue) => {
|
|
1118
|
-
localStorage.setItem('sidebarCollapsed', newValue.toString());
|
|
1119
|
-
});
|
|
1120
|
-
|
|
1121
|
-
const expandedTools = ref({});
|
|
1122
|
-
const expandedContent = ref({});
|
|
1123
|
-
const MAX_EXPANDED_ITEMS = 50; // Memory leak fix: Limit expanded items
|
|
1124
|
-
|
|
1125
|
-
// Clean up old expansion state to prevent memory leak
|
|
1126
|
-
const cleanupExpansionState = () => {
|
|
1127
|
-
const toolKeys = Object.keys(expandedTools.value);
|
|
1128
|
-
if (toolKeys.length > MAX_EXPANDED_ITEMS) {
|
|
1129
|
-
// Keep only recent 50 expanded items
|
|
1130
|
-
const toRemove = toolKeys.slice(0, toolKeys.length - MAX_EXPANDED_ITEMS);
|
|
1131
|
-
toRemove.forEach(key => delete expandedTools.value[key]);
|
|
1132
|
-
}
|
|
1133
|
-
|
|
1134
|
-
const contentKeys = Object.keys(expandedContent.value);
|
|
1135
|
-
if (contentKeys.length > MAX_EXPANDED_ITEMS) {
|
|
1136
|
-
const toRemove = contentKeys.slice(0, contentKeys.length - MAX_EXPANDED_ITEMS);
|
|
1137
|
-
toRemove.forEach(key => delete expandedContent.value[key]);
|
|
1138
|
-
}
|
|
1139
|
-
};
|
|
1140
|
-
|
|
1141
|
-
const currentFilter = ref('all');
|
|
1142
|
-
const searchText = ref('');
|
|
1143
|
-
const debouncedSearchText = ref('');
|
|
1144
|
-
const currentTurnIndex = ref(0); // Current selected turn
|
|
1145
|
-
const scrollerRef = ref(null);
|
|
1146
|
-
const visibleRange = ref({ start: 0, end: 0 });
|
|
1147
|
-
|
|
1148
|
-
// Debounce search input
|
|
1149
|
-
let searchTimeout = null;
|
|
1150
|
-
watch(searchText, (newValue) => {
|
|
1151
|
-
clearTimeout(searchTimeout);
|
|
1152
|
-
searchTimeout = setTimeout(() => {
|
|
1153
|
-
debouncedSearchText.value = newValue;
|
|
1154
|
-
}, 300);
|
|
1155
|
-
});
|
|
1156
|
-
|
|
1157
|
-
// Memory leak fix: Clean up expansion state when filter/search changes
|
|
1158
|
-
watch(currentFilter, () => {
|
|
1159
|
-
cleanupExpansionState();
|
|
1160
|
-
});
|
|
1161
|
-
|
|
1162
|
-
watch(debouncedSearchText, () => {
|
|
1163
|
-
cleanupExpansionState();
|
|
1164
|
-
});
|
|
1165
|
-
|
|
1166
|
-
// Async loading state
|
|
1167
|
-
const loadedEvents = ref([]);
|
|
1168
|
-
const eventsLoading = ref(true);
|
|
1169
|
-
const eventsError = ref(null);
|
|
1170
|
-
|
|
1171
|
-
// Flatten and sort events (stable sort using _fileIndex tiebreaker)
|
|
1172
|
-
const flatEvents = computed(() => {
|
|
1173
|
-
const events = loadedEvents.value
|
|
1174
|
-
.filter(e =>
|
|
1175
|
-
e.type !== 'assistant.turn_end' &&
|
|
1176
|
-
e.type !== 'assistant.turn_complete'
|
|
1177
|
-
)
|
|
1178
|
-
.sort((a, b) => {
|
|
1179
|
-
const timeA = a.timestamp ? new Date(a.timestamp).getTime() : 0;
|
|
1180
|
-
const timeB = b.timestamp ? new Date(b.timestamp).getTime() : 0;
|
|
1181
|
-
if (timeA !== timeB) return timeA - timeB;
|
|
1182
|
-
return (a._fileIndex ?? 0) - (b._fileIndex ?? 0);
|
|
1183
|
-
})
|
|
1184
|
-
.map((e, index) => ({
|
|
1185
|
-
...e,
|
|
1186
|
-
virtualIndex: index,
|
|
1187
|
-
stableId: e.id || `${e.timestamp}-${e.type}-${index}` // Stable ID for toggle state
|
|
1188
|
-
}));
|
|
1189
|
-
return events;
|
|
1190
|
-
});
|
|
1191
|
-
|
|
1192
|
-
// Helper: check if event matches search
|
|
1193
|
-
const matchesSearch = (e) => {
|
|
1194
|
-
if (!debouncedSearchText.value.trim()) return true;
|
|
1195
|
-
|
|
1196
|
-
const search = debouncedSearchText.value.toLowerCase();
|
|
1197
|
-
// Only search in event.data fields, not type
|
|
1198
|
-
const content = [
|
|
1199
|
-
e.data?.message,
|
|
1200
|
-
e.data?.text,
|
|
1201
|
-
e.data?.content,
|
|
1202
|
-
e.data?.reason,
|
|
1203
|
-
e.data?.errorType,
|
|
1204
|
-
e.data?.previousModel,
|
|
1205
|
-
e.data?.newModel
|
|
1206
|
-
].filter(Boolean).join(' ').toLowerCase();
|
|
1207
|
-
|
|
1208
|
-
return content.includes(search);
|
|
1209
|
-
};
|
|
1210
|
-
|
|
1211
|
-
// Events after search (before type filter) - used for filter counts
|
|
1212
|
-
const searchFilteredEvents = computed(() => {
|
|
1213
|
-
const excludeToolCalls = (e) => {
|
|
1214
|
-
const eventType = e.type || '';
|
|
1215
|
-
return eventType !== 'tool.execution_start' && eventType !== 'tool.execution_complete';
|
|
1216
|
-
};
|
|
1217
|
-
|
|
1218
|
-
let events = flatEvents.value.filter(excludeToolCalls);
|
|
1219
|
-
|
|
1220
|
-
// Apply search only (use debouncedSearchText for consistency)
|
|
1221
|
-
if (debouncedSearchText.value.trim()) {
|
|
1222
|
-
events = events.filter(matchesSearch);
|
|
1223
|
-
}
|
|
1224
|
-
|
|
1225
|
-
return events;
|
|
1226
|
-
});
|
|
1227
|
-
|
|
1228
|
-
// Final filtered events (search + type filter)
|
|
1229
|
-
const filteredEvents = computed(() => {
|
|
1230
|
-
let events = searchFilteredEvents.value;
|
|
1231
|
-
|
|
1232
|
-
// Apply type filter
|
|
1233
|
-
if (currentFilter.value !== 'all') {
|
|
1234
|
-
events = events.filter(e => e.type === currentFilter.value);
|
|
1235
|
-
}
|
|
1236
|
-
|
|
1237
|
-
// Divider types (no separator before these)
|
|
1238
|
-
const dividerTypes = ['assistant.turn_start', 'subagent.started', 'subagent.completed', 'subagent.failed'];
|
|
1239
|
-
|
|
1240
|
-
// Mark events that shouldn't have separator
|
|
1241
|
-
const totalCount = events.length;
|
|
1242
|
-
return events.map((e, index) => {
|
|
1243
|
-
const nextItem = events[index + 1];
|
|
1244
|
-
const isLast = index === totalCount - 1;
|
|
1245
|
-
const nextIsDivider = nextItem && dividerTypes.includes(nextItem.type);
|
|
1246
|
-
|
|
1247
|
-
return {
|
|
1248
|
-
...e,
|
|
1249
|
-
filteredIndex: index,
|
|
1250
|
-
filteredTotal: totalCount,
|
|
1251
|
-
isLastEvent: isLast || nextIsDivider // Hide separator if last OR next is divider
|
|
1252
|
-
};
|
|
1253
|
-
});
|
|
1254
|
-
});
|
|
1255
|
-
|
|
1256
|
-
// Event type counts (based on search results)
|
|
1257
|
-
const eventCounts = computed(() => {
|
|
1258
|
-
const counts = {};
|
|
1259
|
-
searchFilteredEvents.value.forEach(e => {
|
|
1260
|
-
if (e.type) {
|
|
1261
|
-
counts[e.type] = (counts[e.type] || 0) + 1;
|
|
1262
|
-
}
|
|
1263
|
-
});
|
|
1264
|
-
return counts;
|
|
1265
|
-
});
|
|
1266
|
-
|
|
1267
|
-
// Search result count for display
|
|
1268
|
-
const searchResultCount = computed(() => {
|
|
1269
|
-
if (!debouncedSearchText.value.trim()) return null;
|
|
1270
|
-
const count = searchFilteredEvents.value.length;
|
|
1271
|
-
return count > 0 ? `${count} result${count !== 1 ? 's' : ''}` : 'No matches';
|
|
1272
|
-
});
|
|
1273
|
-
|
|
1274
|
-
// Track expansion state changes for size-dependencies
|
|
1275
|
-
const expansionCount = computed(() => {
|
|
1276
|
-
const toolsExpanded = Object.keys(expandedTools.value).filter(k => expandedTools.value[k]).length;
|
|
1277
|
-
const contentExpanded = Object.keys(expandedContent.value).filter(k => expandedContent.value[k]).length;
|
|
1278
|
-
return toolsExpanded + contentExpanded;
|
|
1279
|
-
});
|
|
1280
|
-
|
|
1281
|
-
// Available filters (with counts based on search results)
|
|
1282
|
-
const filters = computed(() => {
|
|
1283
|
-
const totalEvents = searchFilteredEvents.value.length;
|
|
1284
|
-
|
|
1285
|
-
// Start with "All" filter
|
|
1286
|
-
const result = [{ type: 'all', label: `All (${totalEvents})`, count: totalEvents }];
|
|
1287
|
-
|
|
1288
|
-
// Dynamically extract all event types from actual events
|
|
1289
|
-
const typeCounts = {};
|
|
1290
|
-
searchFilteredEvents.value.forEach(e => {
|
|
1291
|
-
if (e.type) {
|
|
1292
|
-
typeCounts[e.type] = (typeCounts[e.type] || 0) + 1;
|
|
1293
|
-
}
|
|
1294
|
-
});
|
|
1295
|
-
|
|
1296
|
-
// Convert to array and sort by count (descending)
|
|
1297
|
-
const sortedTypes = Object.entries(typeCounts)
|
|
1298
|
-
.sort((a, b) => b[1] - a[1]) // Sort by count descending
|
|
1299
|
-
.map(([type, count]) => ({
|
|
1300
|
-
type,
|
|
1301
|
-
label: `${type} (${count})`,
|
|
1302
|
-
count,
|
|
1303
|
-
disabled: false
|
|
1304
|
-
}));
|
|
1305
|
-
|
|
1306
|
-
return [...result, ...sortedTypes];
|
|
1307
|
-
});
|
|
1308
|
-
|
|
1309
|
-
// Turns
|
|
1310
|
-
const turns = computed(() => {
|
|
1311
|
-
const turnStarts = flatEvents.value.filter(e => e.type === 'assistant.turn_start');
|
|
1312
|
-
const allUserMessages = flatEvents.value.filter(e => e.type === 'user.message');
|
|
1313
|
-
|
|
1314
|
-
return turnStarts.map((turn, idx) => {
|
|
1315
|
-
// Use idx as the display turn number (sequential, no duplicates)
|
|
1316
|
-
const turnId = idx;
|
|
1317
|
-
const startTime = new Date(turn.timestamp).getTime();
|
|
1318
|
-
|
|
1319
|
-
// Find turn end
|
|
1320
|
-
let endTime;
|
|
1321
|
-
const nextTurnIndex = turnStarts.indexOf(turn) + 1;
|
|
1322
|
-
if (nextTurnIndex < turnStarts.length) {
|
|
1323
|
-
endTime = new Date(turnStarts[nextTurnIndex].timestamp).getTime();
|
|
1324
|
-
} else {
|
|
1325
|
-
endTime = Date.now();
|
|
1326
|
-
}
|
|
1327
|
-
|
|
1328
|
-
// Calculate duration
|
|
1329
|
-
const durationMs = endTime - startTime;
|
|
1330
|
-
const totalSeconds = Math.floor(durationMs / 1000);
|
|
1331
|
-
const minutes = Math.floor(totalSeconds / 60);
|
|
1332
|
-
const seconds = totalSeconds % 60;
|
|
1333
|
-
const durationText = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
|
|
1334
|
-
|
|
1335
|
-
// Find user message before this turn
|
|
1336
|
-
const userMessage = flatEvents.value
|
|
1337
|
-
.slice(0, flatEvents.value.indexOf(turn))
|
|
1338
|
-
.reverse()
|
|
1339
|
-
.find(e => e.type === 'user.message');
|
|
1340
|
-
|
|
1341
|
-
// Calculate UserReq number (1-indexed)
|
|
1342
|
-
const userReqNumber = userMessage
|
|
1343
|
-
? allUserMessages.indexOf(userMessage) + 1
|
|
1344
|
-
: 0;
|
|
1345
|
-
|
|
1346
|
-
return {
|
|
1347
|
-
id: turnId,
|
|
1348
|
-
index: turn.virtualIndex,
|
|
1349
|
-
originalTurnId: turn.data?.turnId, // Keep original for reference
|
|
1350
|
-
timestamp: turn.timestamp,
|
|
1351
|
-
duration: durationText,
|
|
1352
|
-
message: userMessage?.data?.content || userMessage?.data?.transformedContent || '',
|
|
1353
|
-
userReqNumber: userReqNumber
|
|
1354
|
-
};
|
|
1355
|
-
});
|
|
1356
|
-
});
|
|
1357
|
-
|
|
1358
|
-
// Group turns by UserReq for optgroup navigation
|
|
1359
|
-
const userReqs = computed(() => {
|
|
1360
|
-
const groups = [];
|
|
1361
|
-
const reqMap = new Map();
|
|
1362
|
-
|
|
1363
|
-
turns.value.forEach(turn => {
|
|
1364
|
-
const reqNum = turn.userReqNumber || 0;
|
|
1365
|
-
if (!reqMap.has(reqNum)) {
|
|
1366
|
-
const group = {
|
|
1367
|
-
reqNumber: reqNum,
|
|
1368
|
-
message: turn.message,
|
|
1369
|
-
turns: []
|
|
1370
|
-
};
|
|
1371
|
-
reqMap.set(reqNum, group);
|
|
1372
|
-
groups.push(group);
|
|
1373
|
-
}
|
|
1374
|
-
reqMap.get(reqNum).turns.push(turn);
|
|
1375
|
-
});
|
|
1376
|
-
|
|
1377
|
-
return groups;
|
|
1378
|
-
});
|
|
1379
|
-
|
|
1380
|
-
// Truncate text helper for optgroup labels
|
|
1381
|
-
const truncateText = (text, maxLen) => {
|
|
1382
|
-
if (!text) return '';
|
|
1383
|
-
if (text.length <= maxLen) return text;
|
|
1384
|
-
return text.substring(0, maxLen) + '…';
|
|
1385
|
-
};
|
|
1386
|
-
|
|
1387
|
-
// Tool call map
|
|
1388
|
-
// Subagent ownership: attribute events to their owning subagent
|
|
1389
|
-
const subagentOwnership = computed(() => {
|
|
1390
|
-
const sorted = flatEvents.value;
|
|
1391
|
-
const ownerMap = new Map(); // stableId → toolCallId
|
|
1392
|
-
const subagentInfo = new Map(); // toolCallId → { name, colorIndex }
|
|
1393
|
-
|
|
1394
|
-
// 1. Collect all subagent.started toolCallIds + assign colorIndex
|
|
1395
|
-
let colorIdx = 0;
|
|
1396
|
-
for (const ev of sorted) {
|
|
1397
|
-
if (ev.type === 'subagent.started') {
|
|
1398
|
-
const tcid = ev.data?.toolCallId;
|
|
1399
|
-
if (tcid) {
|
|
1400
|
-
subagentInfo.set(tcid, {
|
|
1401
|
-
name: ev.data?.agentDisplayName || ev.data?.agentName || 'SubAgent',
|
|
1402
|
-
colorIndex: colorIdx++
|
|
1403
|
-
});
|
|
1404
|
-
}
|
|
1405
|
-
}
|
|
1406
|
-
}
|
|
1407
|
-
|
|
1408
|
-
// 1b. VS Code source: collect subagent names from assistant.message data.subAgentName
|
|
1409
|
-
// (VS Code does not emit subagent.started events; subagent identity is on the message)
|
|
1410
|
-
for (const ev of sorted) {
|
|
1411
|
-
if (ev.type === 'assistant.message' && ev.data?.subAgentName && ev.data?.subAgentId) {
|
|
1412
|
-
const sid = ev.data.subAgentId;
|
|
1413
|
-
if (!subagentInfo.has(sid)) {
|
|
1414
|
-
subagentInfo.set(sid, {
|
|
1415
|
-
name: ev.data.subAgentName,
|
|
1416
|
-
colorIndex: colorIdx++
|
|
1417
|
-
});
|
|
1418
|
-
}
|
|
1419
|
-
// Directly map this event to its subagent (vscode has no parentToolCallId)
|
|
1420
|
-
ownerMap.set(ev.stableId, sid);
|
|
1421
|
-
}
|
|
1422
|
-
}
|
|
1423
|
-
|
|
1424
|
-
if (subagentInfo.size === 0) return { ownerMap, subagentInfo };
|
|
1425
|
-
|
|
1426
|
-
// 2. Build id → event lookup for parentId chain walking
|
|
1427
|
-
const idMap = new Map();
|
|
1428
|
-
for (const ev of sorted) {
|
|
1429
|
-
if (ev.id) idMap.set(ev.id, ev);
|
|
1430
|
-
}
|
|
1431
|
-
|
|
1432
|
-
// 3. Attribute assistant.message events via data.parentToolCallId
|
|
1433
|
-
for (const ev of sorted) {
|
|
1434
|
-
if (ev.type === 'assistant.message') {
|
|
1435
|
-
const ptcid = ev.data?.parentToolCallId;
|
|
1436
|
-
if (ptcid && subagentInfo.has(ptcid)) {
|
|
1437
|
-
ownerMap.set(ev.stableId, ptcid);
|
|
1438
|
-
}
|
|
1439
|
-
}
|
|
1440
|
-
}
|
|
1441
|
-
|
|
1442
|
-
// 4. Attribute reasoning events by walking parentId → assistant.message
|
|
1443
|
-
for (const ev of sorted) {
|
|
1444
|
-
if (ev.type !== 'reasoning') continue;
|
|
1445
|
-
let current = ev.parentId;
|
|
1446
|
-
let depth = 0;
|
|
1447
|
-
while (current && depth < 10) {
|
|
1448
|
-
const parent = idMap.get(current);
|
|
1449
|
-
if (!parent) break;
|
|
1450
|
-
if (parent.type === 'assistant.message') {
|
|
1451
|
-
const ptcid = parent.data?.parentToolCallId;
|
|
1452
|
-
if (ptcid && subagentInfo.has(ptcid)) {
|
|
1453
|
-
ownerMap.set(ev.stableId, ptcid);
|
|
1454
|
-
}
|
|
1455
|
-
break;
|
|
1456
|
-
}
|
|
1457
|
-
current = parent.parentId;
|
|
1458
|
-
depth++;
|
|
1459
|
-
}
|
|
1460
|
-
}
|
|
1461
|
-
|
|
1462
|
-
// 5. Attribute tool.execution_start/complete by walking parentId chain
|
|
1463
|
-
const startIdByToolCallId = new Map();
|
|
1464
|
-
for (const ev of sorted) {
|
|
1465
|
-
if (ev.type !== 'tool.execution_start') continue;
|
|
1466
|
-
let current = ev.parentId;
|
|
1467
|
-
let depth = 0;
|
|
1468
|
-
while (current && depth < 10) {
|
|
1469
|
-
const parent = idMap.get(current);
|
|
1470
|
-
if (!parent) break;
|
|
1471
|
-
if (parent.type === 'assistant.message') {
|
|
1472
|
-
const ptcid = parent.data?.parentToolCallId;
|
|
1473
|
-
if (ptcid && subagentInfo.has(ptcid)) {
|
|
1474
|
-
ownerMap.set(ev.stableId, ptcid);
|
|
1475
|
-
const tcid = ev.data?.toolCallId;
|
|
1476
|
-
if (tcid) startIdByToolCallId.set(tcid, ptcid);
|
|
1477
|
-
}
|
|
1478
|
-
break;
|
|
1479
|
-
}
|
|
1480
|
-
current = parent.parentId;
|
|
1481
|
-
depth++;
|
|
1482
|
-
}
|
|
1483
|
-
}
|
|
1484
|
-
|
|
1485
|
-
for (const ev of sorted) {
|
|
1486
|
-
if (ev.type !== 'tool.execution_complete') continue;
|
|
1487
|
-
const tcid = ev.data?.toolCallId;
|
|
1488
|
-
if (tcid && startIdByToolCallId.has(tcid)) {
|
|
1489
|
-
ownerMap.set(ev.stableId, startIdByToolCallId.get(tcid));
|
|
1490
|
-
}
|
|
1491
|
-
}
|
|
1492
|
-
|
|
1493
|
-
// 6. Attribute tool.invocation events (VS Code format) via parentToolCallId
|
|
1494
|
-
for (const ev of sorted) {
|
|
1495
|
-
if (ev.type !== 'tool.invocation') continue;
|
|
1496
|
-
const ptcid = ev.data?.parentToolCallId;
|
|
1497
|
-
if (ptcid && subagentInfo.has(ptcid)) {
|
|
1498
|
-
ownerMap.set(ev.stableId, ptcid);
|
|
1499
|
-
}
|
|
1500
|
-
}
|
|
1501
|
-
|
|
1502
|
-
return { ownerMap, subagentInfo };
|
|
1503
|
-
});
|
|
1504
|
-
|
|
1505
|
-
// Methods
|
|
1506
|
-
const formatTime = (timestamp) => {
|
|
1507
|
-
if (!timestamp) return '';
|
|
1508
|
-
const date = new Date(timestamp);
|
|
1509
|
-
const hours = String(date.getHours()).padStart(2, '0');
|
|
1510
|
-
const minutes = String(date.getMinutes()).padStart(2, '0');
|
|
1511
|
-
const seconds = String(date.getSeconds()).padStart(2, '0');
|
|
1512
|
-
return `${hours}:${minutes}:${seconds}`;
|
|
1513
|
-
};
|
|
1514
|
-
|
|
1515
|
-
// Performance fix: Cache markdown rendering results
|
|
1516
|
-
const markdownCache = new Map();
|
|
1517
|
-
const MAX_CACHE_SIZE = 200;
|
|
1518
|
-
|
|
1519
|
-
const renderMarkdown = (text) => {
|
|
1520
|
-
if (!text) return '';
|
|
1521
|
-
|
|
1522
|
-
// Check cache first
|
|
1523
|
-
if (markdownCache.has(text)) {
|
|
1524
|
-
return markdownCache.get(text);
|
|
1525
|
-
}
|
|
1526
|
-
|
|
1527
|
-
try {
|
|
1528
|
-
// 处理转义序列:将 \r\n、\n、\t 等转换为实际字符
|
|
1529
|
-
let processedText = text
|
|
1530
|
-
.replace(/\\r\\n/g, '\n') // \r\n → 换行
|
|
1531
|
-
.replace(/\\n/g, '\n') // \n → 换行
|
|
1532
|
-
.replace(/\\t/g, '\t') // \t → 制表符
|
|
1533
|
-
.replace(/\\"/g, '"') // \" → 引号
|
|
1534
|
-
.replace(/\\\\/g, '\\'); // \\ → 反斜杠
|
|
1535
|
-
|
|
1536
|
-
// DOMPurify configuration for markdown content
|
|
1537
|
-
const purifyConfig = {
|
|
1538
|
-
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'],
|
|
1539
|
-
ALLOWED_ATTR: ['href', 'style', 'class'],
|
|
1540
|
-
ALLOW_DATA_ATTR: false,
|
|
1541
|
-
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i
|
|
1542
|
-
};
|
|
1543
|
-
|
|
1544
|
-
// Parse YAML frontmatter
|
|
1545
|
-
const frontmatterMatch = processedText.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|
1546
|
-
if (frontmatterMatch) {
|
|
1547
|
-
const frontmatter = frontmatterMatch[1];
|
|
1548
|
-
const content = frontmatterMatch[2];
|
|
1549
|
-
|
|
1550
|
-
// Parse frontmatter into key-value pairs
|
|
1551
|
-
const pairs = frontmatter.split('\n').filter(line => line.trim() && line.includes(':')).map(line => {
|
|
1552
|
-
const colonIndex = line.indexOf(':');
|
|
1553
|
-
const key = line.substring(0, colonIndex).trim();
|
|
1554
|
-
const value = line.substring(colonIndex + 1).trim();
|
|
1555
|
-
return { key, value };
|
|
1556
|
-
});
|
|
1557
|
-
|
|
1558
|
-
// Render frontmatter as table (sanitize key/value)
|
|
1559
|
-
let tableHTML = '<table style="margin-bottom: 16px; border-collapse: collapse; width: 100%;"><tbody>';
|
|
1560
|
-
pairs.forEach(pair => {
|
|
1561
|
-
const sanitizedKey = DOMPurify.sanitize(pair.key, { ALLOWED_TAGS: [] });
|
|
1562
|
-
const sanitizedValue = DOMPurify.sanitize(pair.value, { ALLOWED_TAGS: [] });
|
|
1563
|
-
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>`;
|
|
1564
|
-
});
|
|
1565
|
-
tableHTML += '</tbody></table>';
|
|
1566
|
-
|
|
1567
|
-
// Render remaining content with sanitization
|
|
1568
|
-
const markdownHTML = marked.parse(content);
|
|
1569
|
-
const sanitizedMarkdown = DOMPurify.sanitize(markdownHTML, purifyConfig);
|
|
1570
|
-
const result = tableHTML + sanitizedMarkdown;
|
|
1571
|
-
|
|
1572
|
-
// Cache the result
|
|
1573
|
-
if (markdownCache.size >= MAX_CACHE_SIZE) {
|
|
1574
|
-
const firstKey = markdownCache.keys().next().value;
|
|
1575
|
-
markdownCache.delete(firstKey);
|
|
1576
|
-
}
|
|
1577
|
-
markdownCache.set(text, result);
|
|
1578
|
-
|
|
1579
|
-
return result;
|
|
1580
|
-
}
|
|
1581
|
-
|
|
1582
|
-
// Regular markdown rendering with sanitization
|
|
1583
|
-
const markdownHTML = marked.parse(processedText);
|
|
1584
|
-
const result = DOMPurify.sanitize(markdownHTML, purifyConfig);
|
|
1585
|
-
|
|
1586
|
-
// Cache the result (with size limit to prevent memory leak)
|
|
1587
|
-
if (markdownCache.size >= MAX_CACHE_SIZE) {
|
|
1588
|
-
const firstKey = markdownCache.keys().next().value;
|
|
1589
|
-
markdownCache.delete(firstKey);
|
|
1590
|
-
}
|
|
1591
|
-
markdownCache.set(text, result);
|
|
1592
|
-
|
|
1593
|
-
return result;
|
|
1594
|
-
} catch (e) {
|
|
1595
|
-
return text;
|
|
1596
|
-
}
|
|
1597
|
-
};
|
|
1598
|
-
|
|
1599
|
-
const toggleTool = (toolId) => {
|
|
1600
|
-
const newState = { ...expandedTools.value };
|
|
1601
|
-
if (newState[toolId]) {
|
|
1602
|
-
delete newState[toolId];
|
|
1603
|
-
} else {
|
|
1604
|
-
newState[toolId] = true;
|
|
1605
|
-
}
|
|
1606
|
-
expandedTools.value = newState;
|
|
1607
|
-
};
|
|
1608
|
-
|
|
1609
|
-
const highlightSearchText = (html, searchTerm) => {
|
|
1610
|
-
if (!searchTerm || !searchTerm.trim() || !html) return html;
|
|
1611
|
-
|
|
1612
|
-
const term = searchTerm.trim();
|
|
1613
|
-
// Escape HTML in search term to prevent XSS
|
|
1614
|
-
const escapedTerm = escapeHtml(term)
|
|
1615
|
-
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // Also escape regex special chars
|
|
1616
|
-
|
|
1617
|
-
// Create a temporary element to parse HTML
|
|
1618
|
-
const temp = document.createElement('div');
|
|
1619
|
-
temp.innerHTML = html;
|
|
1620
|
-
|
|
1621
|
-
// Function to highlight text in text nodes
|
|
1622
|
-
const highlightTextNode = (node) => {
|
|
1623
|
-
if (node.nodeType === Node.TEXT_NODE) {
|
|
1624
|
-
const text = node.textContent;
|
|
1625
|
-
const regex = new RegExp(`(${escapedTerm})`, 'gi');
|
|
1626
|
-
if (regex.test(text)) {
|
|
1627
|
-
const highlighted = text.replace(regex, '<mark class="search-highlight">$1</mark>');
|
|
1628
|
-
const span = document.createElement('span');
|
|
1629
|
-
span.innerHTML = highlighted;
|
|
1630
|
-
node.parentNode.replaceChild(span, node);
|
|
1631
|
-
}
|
|
1632
|
-
} else if (node.nodeType === Node.ELEMENT_NODE && node.tagName !== 'SCRIPT' && node.tagName !== 'STYLE') {
|
|
1633
|
-
Array.from(node.childNodes).forEach(highlightTextNode);
|
|
1634
|
-
}
|
|
1635
|
-
};
|
|
1636
|
-
|
|
1637
|
-
Array.from(temp.childNodes).forEach(highlightTextNode);
|
|
1638
|
-
return temp.innerHTML;
|
|
1639
|
-
};
|
|
1640
|
-
|
|
1641
|
-
const toggleContent = (contentId) => {
|
|
1642
|
-
// Create new object to trigger Vue reactivity
|
|
1643
|
-
const newState = { ...expandedContent.value };
|
|
1644
|
-
if (newState[contentId]) {
|
|
1645
|
-
delete newState[contentId];
|
|
1646
|
-
} else {
|
|
1647
|
-
newState[contentId] = true;
|
|
1648
|
-
}
|
|
1649
|
-
expandedContent.value = newState;
|
|
1650
|
-
};
|
|
1651
|
-
|
|
1652
|
-
const isContentTooLong = (text) => {
|
|
1653
|
-
if (!text) return false;
|
|
1654
|
-
const lineCount = text.split('\n').length;
|
|
1655
|
-
return lineCount > 20 || text.length > 2000;
|
|
1656
|
-
};
|
|
1657
|
-
|
|
1658
|
-
const truncateContent = (text) => {
|
|
1659
|
-
const lines = text.split('\n');
|
|
1660
|
-
if (lines.length <= 20) return text;
|
|
1661
|
-
return lines.slice(0, 20).join('\n') + '\n\n...';
|
|
1662
|
-
};
|
|
1663
|
-
|
|
1664
|
-
const getBadgeInfo = (type, item) => {
|
|
1665
|
-
// Prefer backend-generated badge info (Violation #4 fix)
|
|
1666
|
-
if (item?.data?.badgeLabel && item?.data?.badgeClass) {
|
|
1667
|
-
return { label: item.data.badgeLabel, class: item.data.badgeClass };
|
|
1668
|
-
}
|
|
1669
|
-
|
|
1670
|
-
// Fallback: frontend logic for backward compatibility
|
|
1671
|
-
// Pi-Mono toolResult events: still use type='message' with role='toolResult'
|
|
1672
|
-
if (type === 'message' && item?.data?.role === 'toolResult') {
|
|
1673
|
-
return { label: 'TOOL RESULT', class: 'badge-tool' };
|
|
1674
|
-
}
|
|
1675
|
-
|
|
1676
|
-
// Special case for specific event types
|
|
1677
|
-
if (type === 'session.model_change') {
|
|
1678
|
-
return { label: 'MODEL CHANGE', class: 'badge-session' };
|
|
1679
|
-
}
|
|
1680
|
-
if (type === 'session.truncation') {
|
|
1681
|
-
return { label: 'TRUNCATION', class: 'badge-truncation' };
|
|
1682
|
-
}
|
|
1683
|
-
if (type === 'session.compaction_start' || type === 'session.compaction_complete') {
|
|
1684
|
-
return { label: 'COMPACTION', class: 'badge-compaction' };
|
|
1685
|
-
}
|
|
1686
|
-
if (type === 'system.notification') {
|
|
1687
|
-
return { label: 'SYSTEM', class: 'badge-system' };
|
|
1688
|
-
}
|
|
1689
|
-
|
|
1690
|
-
const parts = (type || '').split('.');
|
|
1691
|
-
const category = parts[0] || 'unknown';
|
|
1692
|
-
|
|
1693
|
-
const badges = {
|
|
1694
|
-
user: { label: 'USER', class: 'badge-user' },
|
|
1695
|
-
assistant: { label: 'ASSISTANT', class: 'badge-assistant' },
|
|
1696
|
-
reasoning: { label: 'REASONING', class: 'badge-reasoning' },
|
|
1697
|
-
turn: { label: 'TURN', class: 'badge-turn' },
|
|
1698
|
-
tool: { label: 'TOOL', class: 'badge-tool' },
|
|
1699
|
-
subagent: { label: 'SUBAGENT', class: 'badge-subagent' },
|
|
1700
|
-
skill: { label: 'SKILL', class: 'badge-skill' },
|
|
1701
|
-
session: { label: 'SESSION', class: 'badge-session' },
|
|
1702
|
-
error: { label: 'ERROR', class: 'badge-error' },
|
|
1703
|
-
abort: { label: 'ABORT', class: 'badge-error' }
|
|
1704
|
-
};
|
|
1705
|
-
|
|
1706
|
-
return badges[category] || { label: category.toUpperCase(), class: 'badge-info' };
|
|
1707
|
-
};
|
|
1708
|
-
|
|
1709
|
-
const getToolStatus = (group) => {
|
|
1710
|
-
if (!group.complete) {
|
|
1711
|
-
return { icon: '⏳', color: 'tool-status-running', text: '' };
|
|
1712
|
-
}
|
|
1713
|
-
|
|
1714
|
-
const completeData = group.complete.data || {};
|
|
1715
|
-
if (completeData.error || completeData.isError) {
|
|
1716
|
-
return { icon: '❌', color: 'tool-status-error', text: '' };
|
|
1717
|
-
}
|
|
1718
|
-
|
|
1719
|
-
return { icon: '✓', color: 'tool-status-success', text: '' };
|
|
1720
|
-
};
|
|
1721
|
-
|
|
1722
|
-
const getToolErrorMessage = (group) => {
|
|
1723
|
-
if (!group.complete?.data?.error) return '';
|
|
1724
|
-
|
|
1725
|
-
const error = group.complete.data.error;
|
|
1726
|
-
|
|
1727
|
-
// If error is an object with message property
|
|
1728
|
-
if (typeof error === 'object' && error.message) {
|
|
1729
|
-
return error.message;
|
|
1730
|
-
}
|
|
1731
|
-
|
|
1732
|
-
// If error is a string, try to parse as JSON
|
|
1733
|
-
if (typeof error === 'string') {
|
|
1734
|
-
try {
|
|
1735
|
-
const parsed = JSON.parse(error);
|
|
1736
|
-
if (parsed.message) return parsed.message;
|
|
1737
|
-
} catch (e) {
|
|
1738
|
-
// Not JSON, return as-is
|
|
1739
|
-
}
|
|
1740
|
-
return error;
|
|
1741
|
-
}
|
|
1742
|
-
|
|
1743
|
-
// Fallback to stringified error
|
|
1744
|
-
return String(error);
|
|
1745
|
-
};
|
|
1746
|
-
|
|
1747
|
-
const getToolDuration = (group) => {
|
|
1748
|
-
if (!group.complete) return '';
|
|
1749
|
-
|
|
1750
|
-
const startTime = new Date(group.start.timestamp).getTime();
|
|
1751
|
-
const endTime = new Date(group.complete.timestamp).getTime();
|
|
1752
|
-
const durationMs = endTime - startTime;
|
|
1753
|
-
|
|
1754
|
-
if (durationMs >= 100) {
|
|
1755
|
-
return `${(durationMs / 1000).toFixed(1)}s`;
|
|
1756
|
-
}
|
|
1757
|
-
return '';
|
|
1758
|
-
};
|
|
1759
|
-
|
|
1760
|
-
const getToolCommand = (group) => {
|
|
1761
|
-
if (!group.start) return '';
|
|
1762
|
-
const args = group.start.data?.arguments || {};
|
|
1763
|
-
const toolName = group.start.data?.toolName || group.tool || '';
|
|
1764
|
-
|
|
1765
|
-
let command = '';
|
|
1766
|
-
if (toolName === 'bash' || toolName === 'exec') {
|
|
1767
|
-
command = args.command || args.description || '';
|
|
1768
|
-
} else if (toolName === 'ask_user') {
|
|
1769
|
-
command = args.question || args.message || '';
|
|
1770
|
-
} else if (toolName === 'read' || toolName === 'write' || toolName === 'edit') {
|
|
1771
|
-
command = args.file_path || args.path || '';
|
|
1772
|
-
} else if (toolName === 'view') {
|
|
1773
|
-
command = args.path || args.file || '';
|
|
1774
|
-
} else if (toolName === 'create') {
|
|
1775
|
-
command = args.path || args.name || '';
|
|
1776
|
-
} else if (toolName === 'report_intent') {
|
|
1777
|
-
command = args.intent || args.message || '';
|
|
1778
|
-
} else if (toolName === 'web_search') {
|
|
1779
|
-
command = args.query || '';
|
|
1780
|
-
} else if (toolName === 'web_fetch') {
|
|
1781
|
-
command = args.url || '';
|
|
1782
|
-
} else if (toolName === 'browser') {
|
|
1783
|
-
const action = args.action || '';
|
|
1784
|
-
const url = args.targetUrl || args.url || '';
|
|
1785
|
-
command = url ? `${action} ${url}` : action;
|
|
1786
|
-
} else {
|
|
1787
|
-
command = args.description || args.command || args.message ||
|
|
1788
|
-
args.path || args.file_path || args.query || '';
|
|
1789
|
-
}
|
|
1790
|
-
|
|
1791
|
-
if (command && command.length > 200) {
|
|
1792
|
-
command = command.substring(0, 200) + '...';
|
|
1793
|
-
}
|
|
1794
|
-
|
|
1795
|
-
return command;
|
|
1796
|
-
};
|
|
1797
|
-
|
|
1798
|
-
const hasTools = (event) => {
|
|
1799
|
-
// Unified format: check data.tools (works for both Copilot and Claude)
|
|
1800
|
-
return event.data?.tools && event.data.tools.length > 0;
|
|
1801
|
-
};
|
|
1802
|
-
|
|
1803
|
-
const getToolGroups = (event) => {
|
|
1804
|
-
// Unified format from server (both Copilot and Claude normalized to data.tools)
|
|
1805
|
-
if (event.data?.tools && Array.isArray(event.data.tools)) {
|
|
1806
|
-
return event.data.tools
|
|
1807
|
-
.filter(tool => tool && typeof tool === 'object' && tool.name) // Any tool object with a name
|
|
1808
|
-
.map(tool => {
|
|
1809
|
-
// Check if tool has result (works for all formats)
|
|
1810
|
-
const hasResult = tool.result !== undefined || tool.status === 'completed' || tool.status === 'error';
|
|
1811
|
-
return {
|
|
1812
|
-
tool: tool.name,
|
|
1813
|
-
start: {
|
|
1814
|
-
data: {
|
|
1815
|
-
toolName: tool.name,
|
|
1816
|
-
arguments: tool.input || tool.arguments || {}
|
|
1817
|
-
}
|
|
1818
|
-
},
|
|
1819
|
-
complete: hasResult ? {
|
|
1820
|
-
data: {
|
|
1821
|
-
result: tool.result,
|
|
1822
|
-
error: tool.status === 'error' ? tool.error : null
|
|
1823
|
-
}
|
|
1824
|
-
} : null
|
|
1825
|
-
};
|
|
1826
|
-
});
|
|
1827
|
-
}
|
|
1828
|
-
|
|
1829
|
-
return [];
|
|
1830
|
-
};
|
|
1831
|
-
|
|
1832
|
-
// Subagent color palette for parallel subagent distinction
|
|
1833
|
-
const SUBAGENT_COLORS = [
|
|
1834
|
-
'#58a6ff', // blue
|
|
1835
|
-
'#f0883e', // orange
|
|
1836
|
-
'#a371f7', // purple
|
|
1837
|
-
'#3fb950', // green
|
|
1838
|
-
'#f778ba', // pink
|
|
1839
|
-
'#79c0ff', // light blue
|
|
1840
|
-
'#d29922', // amber
|
|
1841
|
-
'#56d4dd' // teal
|
|
1842
|
-
];
|
|
1843
|
-
|
|
1844
|
-
// Hash function for generating consistent color indices
|
|
1845
|
-
const hashCode = (str) => {
|
|
1846
|
-
let hash = 0;
|
|
1847
|
-
for (let i = 0; i < str.length; i++) {
|
|
1848
|
-
const char = str.charCodeAt(i);
|
|
1849
|
-
hash = ((hash << 5) - hash) + char;
|
|
1850
|
-
hash = hash & hash; // Convert to 32bit integer
|
|
1851
|
-
}
|
|
1852
|
-
return hash;
|
|
1853
|
-
};
|
|
1854
|
-
|
|
1855
|
-
const getSubagentInfo = (event) => {
|
|
1856
|
-
const { ownerMap, subagentInfo } = subagentOwnership.value;
|
|
1857
|
-
// For subagent dividers, use their own toolCallId
|
|
1858
|
-
if (event.type === 'subagent.started' || event.type === 'subagent.completed' || event.type === 'subagent.failed') {
|
|
1859
|
-
const tcid = event.data?.toolCallId;
|
|
1860
|
-
if (tcid && subagentInfo.has(tcid)) {
|
|
1861
|
-
const info = subagentInfo.get(tcid);
|
|
1862
|
-
return { name: info.name, toolCallId: tcid, colorIndex: info.colorIndex };
|
|
1863
|
-
}
|
|
1864
|
-
return null;
|
|
1865
|
-
}
|
|
1866
|
-
// For regular events, first check _subagent metadata (Claude format)
|
|
1867
|
-
if (event._subagent) {
|
|
1868
|
-
const subagentId = event._subagent.id;
|
|
1869
|
-
const subagentName = event._subagent.name;
|
|
1870
|
-
// Use subagentId as toolCallId for consistency
|
|
1871
|
-
if (subagentInfo.has(subagentId)) {
|
|
1872
|
-
const info = subagentInfo.get(subagentId);
|
|
1873
|
-
return { name: info.name, toolCallId: subagentId, colorIndex: info.colorIndex };
|
|
1874
|
-
}
|
|
1875
|
-
// If not in subagentInfo, create a default entry
|
|
1876
|
-
return { name: subagentName, toolCallId: subagentId, colorIndex: Math.abs(hashCode(subagentId)) };
|
|
1877
|
-
}
|
|
1878
|
-
// VS Code format: subAgentId directly on the event data
|
|
1879
|
-
if (event.data?.subAgentId) {
|
|
1880
|
-
const sid = event.data.subAgentId;
|
|
1881
|
-
const info = subagentInfo.get(sid);
|
|
1882
|
-
if (info) return { name: info.name, toolCallId: sid, colorIndex: info.colorIndex };
|
|
1883
|
-
}
|
|
1884
|
-
// For regular events, look up ownership (Copilot format)
|
|
1885
|
-
const tcid = ownerMap.get(event.stableId);
|
|
1886
|
-
if (!tcid) return null;
|
|
1887
|
-
const info = subagentInfo.get(tcid);
|
|
1888
|
-
if (!info) return null;
|
|
1889
|
-
return { name: info.name, toolCallId: tcid, colorIndex: info.colorIndex };
|
|
1890
|
-
};
|
|
1891
1101
|
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
const setFilter = (type) => {
|
|
1899
|
-
currentFilter.value = type;
|
|
1900
|
-
};
|
|
1901
|
-
|
|
1902
|
-
const scrollToTurn = (turn) => {
|
|
1903
|
-
// Clear search and filter when jumping to a turn
|
|
1904
|
-
searchText.value = '';
|
|
1905
|
-
currentFilter.value = 'all';
|
|
1906
|
-
|
|
1907
|
-
currentTurnIndex.value = turn.id;
|
|
1908
|
-
|
|
1909
|
-
// Wait for DOM to update and virtual scroller to re-calculate
|
|
1910
|
-
Vue.nextTick(() => {
|
|
1911
|
-
if (scrollerRef.value) {
|
|
1912
|
-
// Use turn.index (virtualIndex) to find the exact turn_start event
|
|
1913
|
-
const targetIndex = filteredEvents.value.findIndex(e =>
|
|
1914
|
-
e.virtualIndex === turn.index
|
|
1915
|
-
);
|
|
1916
|
-
|
|
1917
|
-
if (targetIndex >= 0) {
|
|
1918
|
-
// DynamicScroller with variable heights needs multiple scroll passes
|
|
1919
|
-
// to converge on the correct position as it measures real item sizes
|
|
1920
|
-
const doScroll = (attempts) => {
|
|
1921
|
-
if (attempts <= 0 || !scrollerRef.value) return;
|
|
1922
|
-
scrollerRef.value.scrollToItem(targetIndex);
|
|
1923
|
-
setTimeout(() => doScroll(attempts - 1), 100);
|
|
1924
|
-
};
|
|
1925
|
-
setTimeout(() => doScroll(3), 50);
|
|
1926
|
-
}
|
|
1927
|
-
}
|
|
1928
|
-
});
|
|
1929
|
-
};
|
|
1930
|
-
|
|
1931
|
-
const jumpToTurn = (turnId) => {
|
|
1932
|
-
const turn = turns.value.find(t => t.id === turnId);
|
|
1933
|
-
if (turn) {
|
|
1934
|
-
// Update URL with eventType + eventName
|
|
1935
|
-
const eventName = `UserReq${turn.userReqNumber}_Turn${turn.id}`;
|
|
1936
|
-
const newUrl = `${window.location.pathname}?eventType=assistant.turn_start&eventName=${eventName}`;
|
|
1937
|
-
window.history.pushState({}, '', newUrl);
|
|
1938
|
-
|
|
1939
|
-
// Scroll to turn
|
|
1940
|
-
scrollToTurn(turn);
|
|
1941
|
-
}
|
|
1942
|
-
};
|
|
1943
|
-
|
|
1944
|
-
const repoBasename = (cwd) => {
|
|
1945
|
-
if (!cwd) return '';
|
|
1946
|
-
const parts = cwd.replace(/\/$/, '').split('/');
|
|
1947
|
-
return parts[parts.length - 1] || cwd;
|
|
1948
|
-
};
|
|
1949
|
-
|
|
1950
|
-
const getTurnNumber = (virtualIndex) => { // Find the turn with matching virtualIndex
|
|
1951
|
-
const turn = turns.value.find(t => t.index === virtualIndex);
|
|
1952
|
-
if (!turn) return '?';
|
|
1953
|
-
|
|
1954
|
-
const turnLabel = turn.originalTurnId != null ? turn.originalTurnId : turn.id;
|
|
1955
|
-
// Format: "UserReq N - Turn M" or just "Turn M" if no UserReq
|
|
1956
|
-
if (turn.userReqNumber > 0) {
|
|
1957
|
-
return `${turn.userReqNumber} - Turn ${turnLabel}`;
|
|
1958
|
-
}
|
|
1959
|
-
return `Turn ${turnLabel}`;
|
|
1960
|
-
};
|
|
1961
|
-
|
|
1962
|
-
const getTurnDuration = (virtualIndex) => {
|
|
1963
|
-
const turn = turns.value.find(t => t.index === virtualIndex);
|
|
1964
|
-
return turn?.duration || null;
|
|
1965
|
-
};
|
|
1966
|
-
const escapeHtml = (text) => {
|
|
1967
|
-
const div = document.createElement('div');
|
|
1968
|
-
div.textContent = text;
|
|
1969
|
-
return div.innerHTML;
|
|
1970
|
-
};
|
|
1971
|
-
|
|
1972
|
-
const formatDateTime = (timestamp) => {
|
|
1973
|
-
if (!timestamp) return 'N/A';
|
|
1974
|
-
return new Date(timestamp).toLocaleString();
|
|
1975
|
-
};
|
|
1976
|
-
|
|
1977
|
-
const exportSession = async () => {
|
|
1978
|
-
console.log('[Export] exportSession called');
|
|
1979
|
-
exporting.value = true;
|
|
1980
|
-
try {
|
|
1981
|
-
console.log('[Export] Fetching:', `/session/${sessionId.value}/export`);
|
|
1982
|
-
const response = await fetch(`/session/${sessionId.value}/export`);
|
|
1983
|
-
console.log('[Export] Response received:', response.status, response.ok);
|
|
1984
|
-
console.log('[Export] Response received:', response.status, response.ok);
|
|
1985
|
-
if (!response.ok) {
|
|
1986
|
-
throw new Error('Share failed');
|
|
1987
|
-
}
|
|
1988
|
-
|
|
1989
|
-
// Download the file
|
|
1990
|
-
console.log('[Export] Creating blob...');
|
|
1991
|
-
const blob = await response.blob();
|
|
1992
|
-
console.log('[Export] Blob size:', blob.size, 'type:', blob.type);
|
|
1993
|
-
const url = window.URL.createObjectURL(blob);
|
|
1994
|
-
console.log('[Export] Creating download link...');
|
|
1995
|
-
const a = document.createElement('a');
|
|
1996
|
-
a.href = url;
|
|
1997
|
-
a.download = `session-${sessionId.value}.zip`;
|
|
1998
|
-
document.body.appendChild(a);
|
|
1999
|
-
a.click();
|
|
2000
|
-
console.log('[Export] Download triggered');
|
|
2001
|
-
window.URL.revokeObjectURL(url);
|
|
2002
|
-
document.body.removeChild(a);
|
|
2003
|
-
|
|
2004
|
-
// Show success feedback
|
|
2005
|
-
console.log('[Export] Showing success feedback...');
|
|
2006
|
-
const originalText = '📤 Share Session';
|
|
2007
|
-
const successText = '✓ Downloaded!';
|
|
2008
|
-
const btn = document.querySelector('.export-btn');
|
|
2009
|
-
if (btn) {
|
|
2010
|
-
btn.textContent = successText;
|
|
2011
|
-
btn.style.background = '#238636';
|
|
2012
|
-
console.log('[Export] Button text updated to:', btn.textContent);
|
|
2013
|
-
setTimeout(() => {
|
|
2014
|
-
btn.textContent = originalText;
|
|
2015
|
-
btn.style.background = '';
|
|
2016
|
-
console.log('[Export] Button text restored');
|
|
2017
|
-
}, 2000);
|
|
2018
|
-
}
|
|
2019
|
-
} catch (err) {
|
|
2020
|
-
console.error('[Export] Share session error:', err);
|
|
2021
|
-
alert('Failed to share session: ' + err.message);
|
|
2022
|
-
} finally {
|
|
2023
|
-
exporting.value = false;
|
|
2024
|
-
console.log('[Export] Export complete');
|
|
2025
|
-
}
|
|
2026
|
-
};
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
// Lifecycle
|
|
2030
|
-
onMounted(async () => {
|
|
2031
|
-
// Load events asynchronously
|
|
2032
|
-
try {
|
|
2033
|
-
console.log('[Navigation] Starting event loading...');
|
|
2034
|
-
const response = await fetch(`/api/sessions/${sessionId.value}/events`);
|
|
2035
|
-
if (!response.ok) {
|
|
2036
|
-
throw new Error(`Failed to load events: ${response.statusText}`);
|
|
2037
|
-
}
|
|
2038
|
-
const data = await response.json();
|
|
2039
|
-
|
|
2040
|
-
// Handle both old (array) and new (object with pagination) response formats
|
|
2041
|
-
if (Array.isArray(data)) {
|
|
2042
|
-
// Old format: direct array
|
|
2043
|
-
loadedEvents.value = data;
|
|
2044
|
-
} else if (data.events && Array.isArray(data.events)) {
|
|
2045
|
-
// New format: { events, pagination }
|
|
2046
|
-
loadedEvents.value = data.events;
|
|
2047
|
-
console.log('[Navigation] Pagination:', data.pagination);
|
|
2048
|
-
} else {
|
|
2049
|
-
throw new Error('Invalid response format');
|
|
2050
|
-
}
|
|
2051
|
-
|
|
2052
|
-
console.log('[Navigation] Events loaded:', loadedEvents.value.length);
|
|
2053
|
-
|
|
2054
|
-
// Update 'Updated' time from last event timestamp (more accurate than file mtime)
|
|
2055
|
-
if (loadedEvents.value.length > 0) {
|
|
2056
|
-
const lastEvent = loadedEvents.value[loadedEvents.value.length - 1];
|
|
2057
|
-
const lastTime = lastEvent.timestamp || lastEvent.time || lastEvent.data?.timestamp;
|
|
2058
|
-
if (lastTime) {
|
|
2059
|
-
metadata.value.updated = new Date(lastTime);
|
|
2060
|
-
}
|
|
2061
|
-
}
|
|
2062
|
-
|
|
2063
|
-
// Check for URL query parameters and jump to event AFTER events are loaded
|
|
2064
|
-
const urlParams = new URLSearchParams(window.location.search);
|
|
2065
|
-
const eventTypeParam = urlParams.get('eventType');
|
|
2066
|
-
const eventNameParam = urlParams.get('eventName');
|
|
2067
|
-
const eventTimestampParam = urlParams.get('eventTimestamp');
|
|
2068
|
-
console.log('[Navigation] URL params:', eventTypeParam, eventNameParam, eventTimestampParam);
|
|
2069
|
-
|
|
2070
|
-
if (eventTypeParam && eventNameParam) {
|
|
2071
|
-
console.log('[Navigation] Waiting for Vue to render...');
|
|
2072
|
-
// Wait for Vue to process the events and render
|
|
2073
|
-
Vue.nextTick(() => {
|
|
2074
|
-
console.log('[Navigation] nextTick - flatEvents count:', flatEvents.value?.length);
|
|
2075
|
-
let targetEvent = null;
|
|
2076
|
-
|
|
2077
|
-
if (eventTypeParam === 'assistant.turn_start') {
|
|
2078
|
-
// Parse "UserReq1_Turn0" format
|
|
2079
|
-
const match = eventNameParam.match(/UserReq(\d+)_Turn(\d+)/);
|
|
2080
|
-
if (match) {
|
|
2081
|
-
const turnId = parseInt(match[2], 10);
|
|
2082
|
-
if (!isNaN(turnId)) {
|
|
2083
|
-
console.log('[Navigation] Jumping to turn:', turnId);
|
|
2084
|
-
jumpToTurn(turnId);
|
|
2085
|
-
return;
|
|
2086
|
-
}
|
|
2087
|
-
}
|
|
2088
|
-
} else if (eventTypeParam === 'subagent.started') {
|
|
2089
|
-
console.log('[Navigation] Searching for subagent:', eventNameParam, 'timestamp:', eventTimestampParam);
|
|
2090
|
-
// Find subagent by name + timestamp (handles duplicate subagent names)
|
|
2091
|
-
if (eventTimestampParam) {
|
|
2092
|
-
targetEvent = flatEvents.value.find(event =>
|
|
2093
|
-
event.type === 'subagent.started' &&
|
|
2094
|
-
event.timestamp === eventTimestampParam
|
|
2095
|
-
);
|
|
2096
|
-
}
|
|
2097
|
-
// Fallback: match by name only (for links without timestamp)
|
|
2098
|
-
if (!targetEvent) {
|
|
2099
|
-
targetEvent = flatEvents.value.find(event =>
|
|
2100
|
-
event.type === 'subagent.started' &&
|
|
2101
|
-
(event.data?.agentDisplayName === eventNameParam ||
|
|
2102
|
-
event.data?.agentName === eventNameParam ||
|
|
2103
|
-
event.data?.label === eventNameParam)
|
|
2104
|
-
);
|
|
2105
|
-
}
|
|
2106
|
-
console.log('[Navigation] Target event found:', targetEvent ? 'YES' : 'NO', 'virtualIndex:', targetEvent?.virtualIndex);
|
|
2107
|
-
} else {
|
|
2108
|
-
// Generic: match by type only
|
|
2109
|
-
targetEvent = flatEvents.value.find(event => event.type === eventTypeParam);
|
|
2110
|
-
}
|
|
2111
|
-
|
|
2112
|
-
if (targetEvent) {
|
|
2113
|
-
// Find target in filteredEvents (which may be different from flatEvents due to filters)
|
|
2114
|
-
const targetIndex = filteredEvents.value.findIndex(e =>
|
|
2115
|
-
e.virtualIndex === targetEvent.virtualIndex
|
|
2116
|
-
);
|
|
2117
|
-
console.log('[Navigation] Target in filteredEvents at index:', targetIndex);
|
|
2118
|
-
|
|
2119
|
-
if (targetIndex >= 0 && scrollerRef.value) {
|
|
2120
|
-
console.log('[Navigation] Scrolling to index:', targetIndex);
|
|
2121
|
-
// Use retry mechanism like scrollToTurn
|
|
2122
|
-
const doScroll = (attempts) => {
|
|
2123
|
-
if (attempts <= 0 || !scrollerRef.value) return;
|
|
2124
|
-
scrollerRef.value.scrollToItem(targetIndex);
|
|
2125
|
-
setTimeout(() => doScroll(attempts - 1), 100);
|
|
2126
|
-
};
|
|
2127
|
-
setTimeout(() => doScroll(3), 50);
|
|
2128
|
-
} else {
|
|
2129
|
-
console.log('[Navigation] Failed - targetIndex:', targetIndex, 'scrollerRef:', !!scrollerRef.value);
|
|
2130
|
-
}
|
|
2131
|
-
} else {
|
|
2132
|
-
console.log('[Navigation] Target event not found');
|
|
2133
|
-
}
|
|
2134
|
-
});
|
|
2135
|
-
}
|
|
2136
|
-
} catch (error) {
|
|
2137
|
-
console.error('Error loading events:', error);
|
|
2138
|
-
eventsError.value = error.message;
|
|
2139
|
-
} finally {
|
|
2140
|
-
eventsLoading.value = false;
|
|
2141
|
-
}
|
|
2142
|
-
|
|
2143
|
-
window.addEventListener('keydown', (e) => {
|
|
2144
|
-
if (e.ctrlKey && e.key === 'b') {
|
|
2145
|
-
e.preventDefault();
|
|
2146
|
-
sidebarCollapsed.value = !sidebarCollapsed.value;
|
|
2147
|
-
}
|
|
2148
|
-
});
|
|
2149
|
-
|
|
2150
|
-
if (window.marked) {
|
|
2151
|
-
marked.setOptions({
|
|
2152
|
-
breaks: true,
|
|
2153
|
-
gfm: true
|
|
2154
|
-
});
|
|
2155
|
-
}
|
|
2156
|
-
|
|
2157
|
-
// 监听滚动事件来更新 visibleRange
|
|
2158
|
-
const updateVisibleRange = () => {
|
|
2159
|
-
if (!scrollerRef.value) return;
|
|
2160
|
-
|
|
2161
|
-
// 尝试多种方式访问 scroller 元素
|
|
2162
|
-
let scroller = null;
|
|
2163
|
-
if (scrollerRef.value.$el && typeof scrollerRef.value.$el.querySelector === 'function') {
|
|
2164
|
-
scroller = scrollerRef.value.$el.querySelector('.vue-recycle-scroller');
|
|
2165
|
-
} else if (scrollerRef.value.querySelector && typeof scrollerRef.value.querySelector === 'function') {
|
|
2166
|
-
scroller = scrollerRef.value.querySelector('.vue-recycle-scroller');
|
|
2167
|
-
}
|
|
2168
|
-
|
|
2169
|
-
if (!scroller) {
|
|
2170
|
-
// 如果还找不到,直接查询 DOM
|
|
2171
|
-
scroller = document.querySelector('.vue-recycle-scroller');
|
|
2172
|
-
}
|
|
2173
|
-
|
|
2174
|
-
if (scroller) {
|
|
2175
|
-
const scrollTop = scroller.scrollTop;
|
|
2176
|
-
const clientHeight = scroller.clientHeight;
|
|
2177
|
-
|
|
2178
|
-
// 估算可见范围
|
|
2179
|
-
const avgItemHeight = 80;
|
|
2180
|
-
const startIndex = Math.floor(scrollTop / avgItemHeight);
|
|
2181
|
-
const visibleCount = Math.ceil(clientHeight / avgItemHeight);
|
|
2182
|
-
const endIndex = Math.min(startIndex + visibleCount, filteredEvents.value.length);
|
|
2183
|
-
|
|
2184
|
-
const startPos = Math.max(1, startIndex + 1);
|
|
2185
|
-
const endPos = Math.max(1, endIndex);
|
|
2186
|
-
|
|
2187
|
-
visibleRange.value = {
|
|
2188
|
-
start: Math.min(startPos, endPos), // Ensure start <= end
|
|
2189
|
-
end: endPos
|
|
2190
|
-
};
|
|
2191
|
-
}
|
|
2192
|
-
};
|
|
2193
|
-
|
|
2194
|
-
// 初始更新和添加滚动监听
|
|
2195
|
-
let scrollCleanup = null;
|
|
2196
|
-
setTimeout(() => {
|
|
2197
|
-
updateVisibleRange();
|
|
2198
|
-
|
|
2199
|
-
const scroller = document.querySelector('.vue-recycle-scroller');
|
|
2200
|
-
if (scroller) {
|
|
2201
|
-
scroller.addEventListener('scroll', updateVisibleRange);
|
|
2202
|
-
// Store cleanup function
|
|
2203
|
-
scrollCleanup = () => {
|
|
2204
|
-
scroller.removeEventListener('scroll', updateVisibleRange);
|
|
2205
|
-
};
|
|
2206
|
-
}
|
|
2207
|
-
}, 500);
|
|
2208
|
-
|
|
2209
|
-
// Cleanup on unmount
|
|
2210
|
-
onBeforeUnmount(() => {
|
|
2211
|
-
// Clear search timeout (memory leak fix)
|
|
2212
|
-
if (searchTimeout) {
|
|
2213
|
-
clearTimeout(searchTimeout);
|
|
2214
|
-
searchTimeout = null;
|
|
2215
|
-
}
|
|
2216
|
-
|
|
2217
|
-
// Clean scroll listeners
|
|
2218
|
-
if (scrollCleanup) {
|
|
2219
|
-
scrollCleanup();
|
|
2220
|
-
}
|
|
2221
|
-
|
|
2222
|
-
// Clear expansion state (memory leak fix)
|
|
2223
|
-
expandedTools.value = {};
|
|
2224
|
-
expandedContent.value = {};
|
|
2225
|
-
|
|
2226
|
-
// Clear markdown cache (memory leak fix)
|
|
2227
|
-
markdownCache.clear();
|
|
2228
|
-
});
|
|
2229
|
-
});
|
|
2230
|
-
|
|
2231
|
-
// Session Tags
|
|
2232
|
-
const sessionTags = ref([]);
|
|
2233
|
-
const allTags = ref([]);
|
|
2234
|
-
const tagsEditing = ref(false);
|
|
2235
|
-
const editingTags = ref([]);
|
|
2236
|
-
const tagInputValue = ref('');
|
|
2237
|
-
const tagInputRef = ref(null);
|
|
2238
|
-
const tagsError = ref('');
|
|
2239
|
-
const showAutocomplete = ref(false);
|
|
2240
|
-
const autocompleteOptions = ref([]);
|
|
2241
|
-
const autocompleteSelectedIndex = ref(0);
|
|
2242
|
-
|
|
2243
|
-
// Tag colors (6 colors cycling based on hash)
|
|
2244
|
-
const tagColors = [
|
|
2245
|
-
'#3b82f6', // blue
|
|
2246
|
-
'#10b981', // green
|
|
2247
|
-
'#f59e0b', // amber
|
|
2248
|
-
'#ef4444', // red
|
|
2249
|
-
'#8b5cf6', // purple
|
|
2250
|
-
'#ec4899', // pink
|
|
2251
|
-
'#06b6d4', // cyan
|
|
2252
|
-
'#f97316' // orange
|
|
2253
|
-
];
|
|
2254
|
-
|
|
2255
|
-
const getTagColor = (tag) => {
|
|
2256
|
-
let hash = 0;
|
|
2257
|
-
for (let i = 0; i < tag.length; i++) {
|
|
2258
|
-
hash = tag.charCodeAt(i) + ((hash << 5) - hash);
|
|
2259
|
-
}
|
|
2260
|
-
return tagColors[Math.abs(hash) % tagColors.length];
|
|
2261
|
-
};
|
|
2262
|
-
|
|
2263
|
-
const loadTags = async () => {
|
|
2264
|
-
try {
|
|
2265
|
-
const response = await fetch(`/api/sessions/${sessionId.value}/tags`);
|
|
2266
|
-
if (response.ok) {
|
|
2267
|
-
const data = await response.json();
|
|
2268
|
-
sessionTags.value = data.tags || [];
|
|
2269
|
-
}
|
|
2270
|
-
} catch (err) {
|
|
2271
|
-
console.error('Error loading tags:', err);
|
|
2272
|
-
}
|
|
2273
|
-
};
|
|
2274
|
-
|
|
2275
|
-
const loadAllTags = async () => {
|
|
2276
|
-
try {
|
|
2277
|
-
const response = await fetch('/api/tags');
|
|
2278
|
-
if (response.ok) {
|
|
2279
|
-
const data = await response.json();
|
|
2280
|
-
allTags.value = data.tags || [];
|
|
2281
|
-
}
|
|
2282
|
-
} catch (err) {
|
|
2283
|
-
console.error('Error loading all tags:', err);
|
|
2284
|
-
}
|
|
2285
|
-
};
|
|
2286
|
-
|
|
2287
|
-
const saveTags = async (tags) => {
|
|
2288
|
-
try {
|
|
2289
|
-
const response = await fetch(`/api/sessions/${sessionId.value}/tags`, {
|
|
2290
|
-
method: 'PUT',
|
|
2291
|
-
headers: { 'Content-Type': 'application/json' },
|
|
2292
|
-
body: JSON.stringify({ tags })
|
|
2293
|
-
});
|
|
2294
|
-
if (response.ok) {
|
|
2295
|
-
const data = await response.json();
|
|
2296
|
-
sessionTags.value = data.tags || [];
|
|
2297
|
-
tagsError.value = '';
|
|
2298
|
-
return true;
|
|
2299
|
-
} else {
|
|
2300
|
-
const error = await response.json();
|
|
2301
|
-
tagsError.value = error.error || 'Failed to save tags';
|
|
2302
|
-
return false;
|
|
2303
|
-
}
|
|
2304
|
-
} catch (err) {
|
|
2305
|
-
console.error('Error saving tags:', err);
|
|
2306
|
-
tagsError.value = 'Network error';
|
|
2307
|
-
return false;
|
|
2308
|
-
}
|
|
2309
|
-
};
|
|
2310
|
-
|
|
2311
|
-
const startEditTags = () => {
|
|
2312
|
-
editingTags.value = [...sessionTags.value];
|
|
2313
|
-
tagsEditing.value = true;
|
|
2314
|
-
tagsError.value = '';
|
|
2315
|
-
setTimeout(() => {
|
|
2316
|
-
if (tagInputRef.value) {
|
|
2317
|
-
tagInputRef.value.focus();
|
|
2318
|
-
}
|
|
2319
|
-
}, 10);
|
|
2320
|
-
};
|
|
2321
|
-
|
|
2322
|
-
const cancelEditTags = () => {
|
|
2323
|
-
tagsEditing.value = false;
|
|
2324
|
-
editingTags.value = [];
|
|
2325
|
-
tagInputValue.value = '';
|
|
2326
|
-
showAutocomplete.value = false;
|
|
2327
|
-
tagsError.value = '';
|
|
2328
|
-
};
|
|
2329
|
-
|
|
2330
|
-
const addTag = () => {
|
|
2331
|
-
const tag = tagInputValue.value.trim().toLowerCase();
|
|
2332
|
-
if (!tag) return;
|
|
2333
|
-
|
|
2334
|
-
if (tag.length > 30) {
|
|
2335
|
-
tagsError.value = 'Tag must be 30 characters or less';
|
|
2336
|
-
return;
|
|
2337
|
-
}
|
|
2338
|
-
|
|
2339
|
-
if (editingTags.value.length >= 10) {
|
|
2340
|
-
tagsError.value = 'Maximum 10 tags per session';
|
|
2341
|
-
return;
|
|
2342
|
-
}
|
|
2343
|
-
|
|
2344
|
-
if (editingTags.value.includes(tag)) {
|
|
2345
|
-
tagsError.value = 'Tag already added';
|
|
2346
|
-
tagInputValue.value = '';
|
|
2347
|
-
return;
|
|
2348
|
-
}
|
|
2349
|
-
|
|
2350
|
-
editingTags.value.push(tag);
|
|
2351
|
-
tagInputValue.value = '';
|
|
2352
|
-
showAutocomplete.value = false;
|
|
2353
|
-
tagsError.value = '';
|
|
2354
|
-
};
|
|
2355
|
-
|
|
2356
|
-
const removeTagFromEdit = (tag) => {
|
|
2357
|
-
editingTags.value = editingTags.value.filter(t => t !== tag);
|
|
2358
|
-
tagsError.value = '';
|
|
2359
|
-
};
|
|
2360
|
-
|
|
2361
|
-
const updateAutocomplete = () => {
|
|
2362
|
-
const input = tagInputValue.value.trim().toLowerCase();
|
|
2363
|
-
if (!input) {
|
|
2364
|
-
showAutocomplete.value = false;
|
|
2365
|
-
autocompleteOptions.value = [];
|
|
2366
|
-
return;
|
|
2367
|
-
}
|
|
2368
|
-
|
|
2369
|
-
const filtered = allTags.value
|
|
2370
|
-
.filter(tag =>
|
|
2371
|
-
tag.toLowerCase().includes(input) &&
|
|
2372
|
-
!editingTags.value.includes(tag)
|
|
2373
|
-
)
|
|
2374
|
-
.slice(0, 5);
|
|
2375
|
-
|
|
2376
|
-
if (filtered.length > 0) {
|
|
2377
|
-
showAutocomplete.value = true;
|
|
2378
|
-
autocompleteOptions.value = filtered;
|
|
2379
|
-
autocompleteSelectedIndex.value = 0;
|
|
2380
|
-
} else {
|
|
2381
|
-
showAutocomplete.value = false;
|
|
2382
|
-
autocompleteOptions.value = [];
|
|
2383
|
-
}
|
|
2384
|
-
};
|
|
2385
|
-
|
|
2386
|
-
const selectAutocompleteOption = (option) => {
|
|
2387
|
-
tagInputValue.value = option;
|
|
2388
|
-
addTag();
|
|
2389
|
-
};
|
|
2390
|
-
|
|
2391
|
-
const saveTagsOnBlur = async () => {
|
|
2392
|
-
// Small delay to allow click events on autocomplete
|
|
2393
|
-
setTimeout(async () => {
|
|
2394
|
-
if (!tagsEditing.value) return;
|
|
2395
|
-
|
|
2396
|
-
const success = await saveTags(editingTags.value);
|
|
2397
|
-
if (success) {
|
|
2398
|
-
tagsEditing.value = false;
|
|
2399
|
-
editingTags.value = [];
|
|
2400
|
-
tagInputValue.value = '';
|
|
2401
|
-
showAutocomplete.value = false;
|
|
2402
|
-
// Reload all tags for autocomplete
|
|
2403
|
-
await loadAllTags();
|
|
2404
|
-
}
|
|
2405
|
-
}, 200);
|
|
2406
|
-
};
|
|
2407
|
-
|
|
2408
|
-
// Load tags on mount
|
|
2409
|
-
onMounted(async () => {
|
|
2410
|
-
await loadTags();
|
|
2411
|
-
await loadAllTags();
|
|
2412
|
-
});
|
|
1102
|
+
/* Bottom spacer for last event visibility */
|
|
1103
|
+
.scroller-bottom-spacer {
|
|
1104
|
+
height: max(env(safe-area-inset-bottom, 0px), 80px);
|
|
1105
|
+
flex-shrink: 0;
|
|
1106
|
+
}
|
|
2413
1107
|
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
sidebarCollapsed,
|
|
2419
|
-
expandedTools,
|
|
2420
|
-
expandedContent,
|
|
2421
|
-
expansionCount,
|
|
2422
|
-
currentFilter,
|
|
2423
|
-
searchText,
|
|
2424
|
-
currentTurnIndex,
|
|
2425
|
-
scrollerRef,
|
|
2426
|
-
visibleRange,
|
|
2427
|
-
loadedEvents,
|
|
2428
|
-
eventsLoading,
|
|
2429
|
-
eventsError,
|
|
2430
|
-
flatEvents,
|
|
2431
|
-
filteredEvents,
|
|
2432
|
-
eventCounts,
|
|
2433
|
-
filters,
|
|
2434
|
-
turns,
|
|
2435
|
-
userReqs,
|
|
2436
|
-
truncateText,
|
|
2437
|
-
formatTime,
|
|
2438
|
-
formatDateTime,
|
|
2439
|
-
renderMarkdown,
|
|
2440
|
-
highlightSearchText,
|
|
2441
|
-
toggleTool,
|
|
2442
|
-
toggleContent,
|
|
2443
|
-
isContentTooLong,
|
|
2444
|
-
truncateContent,
|
|
2445
|
-
getBadgeInfo,
|
|
2446
|
-
getToolStatus,
|
|
2447
|
-
getToolErrorMessage,
|
|
2448
|
-
getToolDuration,
|
|
2449
|
-
getToolCommand,
|
|
2450
|
-
hasTools,
|
|
2451
|
-
getToolGroups,
|
|
2452
|
-
getSubagentInfo,
|
|
2453
|
-
getSubagentColor,
|
|
2454
|
-
setFilter,
|
|
2455
|
-
scrollToTurn,
|
|
2456
|
-
jumpToTurn,
|
|
2457
|
-
getTurnNumber,
|
|
2458
|
-
getTurnDuration,
|
|
2459
|
-
repoBasename,
|
|
2460
|
-
escapeHtml,
|
|
2461
|
-
exportSession,
|
|
2462
|
-
searchResultCount,
|
|
2463
|
-
// Tags
|
|
2464
|
-
sessionTags,
|
|
2465
|
-
allTags,
|
|
2466
|
-
tagsEditing,
|
|
2467
|
-
editingTags,
|
|
2468
|
-
tagInputValue,
|
|
2469
|
-
tagInputRef,
|
|
2470
|
-
tagsError,
|
|
2471
|
-
showAutocomplete,
|
|
2472
|
-
autocompleteOptions,
|
|
2473
|
-
autocompleteSelectedIndex,
|
|
2474
|
-
getTagColor,
|
|
2475
|
-
startEditTags,
|
|
2476
|
-
cancelEditTags,
|
|
2477
|
-
addTag,
|
|
2478
|
-
removeTagFromEdit,
|
|
2479
|
-
updateAutocomplete,
|
|
2480
|
-
selectAutocompleteOption,
|
|
2481
|
-
saveTagsOnBlur
|
|
2482
|
-
};
|
|
2483
|
-
},
|
|
2484
|
-
|
|
2485
|
-
template: `
|
|
2486
|
-
<div class="container">
|
|
2487
|
-
<div class="header">
|
|
2488
|
-
<a href="/" class="home-btn">← Back to Home</a>
|
|
2489
|
-
<h1>📋 Session: {{ sessionId }}
|
|
2490
|
-
<span v-if="metadata.sessionStatus === 'wip'" style="font-size: 12px; padding: 2px 8px; border-radius: 3px; background: rgba(210, 153, 34, 0.2); color: #d29922; border: 1px solid rgba(210, 153, 34, 0.4); vertical-align: middle; margin-left: 8px;">🔄 WIP</span>
|
|
2491
|
-
</h1>
|
|
2492
|
-
<div style="display: flex; gap: 10px;">
|
|
2493
|
-
<a :href="'/session/' + sessionId + '/time-analyze'" class="time-analyze-btn">⏱ Analysis</a>
|
|
2494
|
-
<button @click="exportSession" class="export-btn" :disabled="exporting">
|
|
2495
|
-
{{ exporting ? '⏳ Sharing...' : '📤 Share Session' }}
|
|
2496
|
-
</button>
|
|
2497
|
-
</div>
|
|
2498
|
-
</div>
|
|
2499
|
-
|
|
2500
|
-
<div class="main-layout">
|
|
2501
|
-
<div :class="['sidebar', { collapsed: sidebarCollapsed }]">
|
|
2502
|
-
<div class="sidebar-section">
|
|
2503
|
-
<div class="sidebar-section-title">Session Info</div>
|
|
2504
|
-
<div class="session-info">
|
|
2505
|
-
<div v-if="metadata.summary" class="session-summary-block">{{ metadata.summary }}</div>
|
|
2506
|
-
<table class="session-info-table">
|
|
2507
|
-
<tbody>
|
|
2508
|
-
<tr v-if="metadata.source">
|
|
2509
|
-
<td>Source</td>
|
|
2510
|
-
<td>
|
|
2511
|
-
<!-- Use backend-provided source metadata (Violation #3 fix) -->
|
|
2512
|
-
<span :class="['source-badge', metadata.sourceBadgeClass || 'source-copilot']">
|
|
2513
|
-
{{ metadata.sourceName || 'GitHub Copilot' }}
|
|
2514
|
-
</span>
|
|
2515
|
-
</td>
|
|
2516
|
-
</tr>
|
|
2517
|
-
<tr v-if="metadata.copilotVersion">
|
|
2518
|
-
<td>Version</td>
|
|
2519
|
-
<td>{{ metadata.copilotVersion }}</td>
|
|
2520
|
-
</tr>
|
|
2521
|
-
<tr v-if="metadata.model">
|
|
2522
|
-
<td>Model</td>
|
|
2523
|
-
<td>{{ metadata.model }}</td>
|
|
2524
|
-
</tr>
|
|
2525
|
-
<tr v-if="metadata.repo">
|
|
2526
|
-
<td>Repo</td>
|
|
2527
|
-
<td>{{ metadata.repo }}</td>
|
|
2528
|
-
</tr>
|
|
2529
|
-
<tr v-if="metadata.branch">
|
|
2530
|
-
<td>Branch</td>
|
|
2531
|
-
<td>{{ metadata.branch }}</td>
|
|
2532
|
-
</tr>
|
|
2533
|
-
<tr v-if="metadata.cwd && !metadata.repo">
|
|
2534
|
-
<td>Repo</td>
|
|
2535
|
-
<td>{{ metadata.cwd }}</td>
|
|
2536
|
-
</tr>
|
|
2537
|
-
<tr v-if="metadata.created">
|
|
2538
|
-
<td>Created</td>
|
|
2539
|
-
<td>{{ formatDateTime(metadata.created) }}</td>
|
|
2540
|
-
</tr>
|
|
2541
|
-
<tr v-if="metadata.updated">
|
|
2542
|
-
<td>Updated</td>
|
|
2543
|
-
<td>{{ formatDateTime(metadata.updated) }}</td>
|
|
2544
|
-
</tr>
|
|
2545
|
-
</tbody>
|
|
2546
|
-
</table>
|
|
2547
|
-
</div>
|
|
2548
|
-
</div>
|
|
2549
|
-
|
|
2550
|
-
<div class="sidebar-section">
|
|
2551
|
-
<div class="sidebar-section-title">Event Filters</div>
|
|
2552
|
-
<div class="event-filters">
|
|
2553
|
-
<button
|
|
2554
|
-
v-for="filter in filters"
|
|
2555
|
-
:key="filter.type"
|
|
2556
|
-
:class="['filter-btn', { active: currentFilter === filter.type }]"
|
|
2557
|
-
:disabled="filter.disabled"
|
|
2558
|
-
@click="setFilter(filter.type)"
|
|
2559
|
-
>
|
|
2560
|
-
{{ filter.label }}
|
|
2561
|
-
</button>
|
|
2562
|
-
</div>
|
|
2563
|
-
</div>
|
|
1108
|
+
/* Sidebar backdrop — hidden by default, shown via mobile media query */
|
|
1109
|
+
.sidebar-backdrop {
|
|
1110
|
+
display: none;
|
|
1111
|
+
}
|
|
2564
1112
|
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
:key="tag"
|
|
2586
|
-
class="tag-input-chip"
|
|
2587
|
-
:style="{ backgroundColor: getTagColor(tag) }"
|
|
2588
|
-
>
|
|
2589
|
-
{{ tag }}
|
|
2590
|
-
<button @click="removeTagFromEdit(tag)" title="Remove tag">×</button>
|
|
2591
|
-
</span>
|
|
2592
|
-
<input
|
|
2593
|
-
ref="tagInputRef"
|
|
2594
|
-
v-model="tagInputValue"
|
|
2595
|
-
@keydown.enter.prevent="addTag"
|
|
2596
|
-
@keydown.escape="cancelEditTags"
|
|
2597
|
-
@blur="saveTagsOnBlur"
|
|
2598
|
-
@input="updateAutocomplete"
|
|
2599
|
-
class="tags-text-input"
|
|
2600
|
-
placeholder="Type tag name..."
|
|
2601
|
-
maxlength="30"
|
|
2602
|
-
/>
|
|
2603
|
-
</div>
|
|
2604
|
-
<div v-if="showAutocomplete && autocompleteOptions.length > 0" class="tags-autocomplete">
|
|
2605
|
-
<div
|
|
2606
|
-
v-for="(option, index) in autocompleteOptions"
|
|
2607
|
-
:key="option"
|
|
2608
|
-
:class="['tags-autocomplete-item', { selected: index === autocompleteSelectedIndex }]"
|
|
2609
|
-
@click="selectAutocompleteOption(option)"
|
|
2610
|
-
@mouseenter="autocompleteSelectedIndex = index"
|
|
2611
|
-
>
|
|
2612
|
-
{{ option }}
|
|
2613
|
-
</div>
|
|
2614
|
-
</div>
|
|
2615
|
-
<div v-if="tagsError" class="tags-error">{{ tagsError }}</div>
|
|
2616
|
-
</div>
|
|
2617
|
-
</div>
|
|
2618
|
-
</div>
|
|
1113
|
+
/* ── Mobile responsive ────────────────────────────────────── */
|
|
1114
|
+
@media (max-width: 640px) {
|
|
1115
|
+
/* Header: smaller, wrap if needed */
|
|
1116
|
+
.header {
|
|
1117
|
+
padding: 8px 12px;
|
|
1118
|
+
flex-wrap: wrap;
|
|
1119
|
+
gap: 8px;
|
|
1120
|
+
}
|
|
1121
|
+
.header-title {
|
|
1122
|
+
font-size: 13px;
|
|
1123
|
+
overflow: hidden;
|
|
1124
|
+
text-overflow: ellipsis;
|
|
1125
|
+
white-space: nowrap;
|
|
1126
|
+
max-width: calc(100vw - 80px);
|
|
1127
|
+
}
|
|
1128
|
+
.time-analyze-btn,
|
|
1129
|
+
.share-btn {
|
|
1130
|
+
padding: 5px 8px;
|
|
1131
|
+
font-size: 12px;
|
|
1132
|
+
}
|
|
2619
1133
|
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
☰
|
|
2629
|
-
</button>
|
|
2630
|
-
|
|
2631
|
-
<!-- Turn dropdown with optgroup -->
|
|
2632
|
-
<select
|
|
2633
|
-
v-if="turns.length > 0"
|
|
2634
|
-
v-model="currentTurnIndex"
|
|
2635
|
-
@change="jumpToTurn(currentTurnIndex)"
|
|
2636
|
-
class="turn-dropdown"
|
|
2637
|
-
>
|
|
2638
|
-
<optgroup
|
|
2639
|
-
v-for="req in userReqs"
|
|
2640
|
-
:key="req.reqNumber"
|
|
2641
|
-
:label="req.reqNumber > 0 ? 'UserReq ' + req.reqNumber + ': ' + truncateText(req.message, 40) : 'Setup'"
|
|
2642
|
-
>
|
|
2643
|
-
<option v-for="turn in req.turns" :key="turn.id" :value="turn.id">
|
|
2644
|
-
Turn {{ turn.originalTurnId != null ? turn.originalTurnId : turn.id }} ({{ turn.duration }})
|
|
2645
|
-
</option>
|
|
2646
|
-
</optgroup>
|
|
2647
|
-
</select>
|
|
2648
|
-
</div>
|
|
2649
|
-
<div class="content-toolbar-center">
|
|
2650
|
-
</div>
|
|
2651
|
-
<div class="content-toolbar-right">
|
|
2652
|
-
<input
|
|
2653
|
-
v-model="searchText"
|
|
2654
|
-
type="text"
|
|
2655
|
-
placeholder="🔍 Search events..."
|
|
2656
|
-
class="search-input"
|
|
2657
|
-
/>
|
|
2658
|
-
<span v-if="searchResultCount" class="search-result-count">
|
|
2659
|
-
{{ searchResultCount }}
|
|
2660
|
-
</span>
|
|
2661
|
-
</div>
|
|
2662
|
-
</div>
|
|
2663
|
-
|
|
2664
|
-
<!-- Loading state -->
|
|
2665
|
-
<div v-if="eventsLoading" class="loading-message">
|
|
2666
|
-
<div style="text-align: center; padding: 40px; color: #c9d1d9;">
|
|
2667
|
-
⏳ Loading events...
|
|
2668
|
-
</div>
|
|
2669
|
-
</div>
|
|
2670
|
-
|
|
2671
|
-
<!-- Error state -->
|
|
2672
|
-
<div v-else-if="eventsError" class="error-message">
|
|
2673
|
-
<div style="text-align: center; padding: 40px; color: #f85149;">
|
|
2674
|
-
❌ Error loading events: {{ eventsError }}
|
|
2675
|
-
</div>
|
|
2676
|
-
</div>
|
|
2677
|
-
|
|
2678
|
-
<!-- Events list -->
|
|
2679
|
-
<DynamicScroller
|
|
2680
|
-
v-else
|
|
2681
|
-
ref="scrollerRef"
|
|
2682
|
-
:items="filteredEvents"
|
|
2683
|
-
:min-item-size="80"
|
|
2684
|
-
key-field="stableId"
|
|
2685
|
-
class="scroller"
|
|
2686
|
-
>
|
|
2687
|
-
<template #default="{ item, index, active }">
|
|
2688
|
-
<DynamicScrollerItem
|
|
2689
|
-
:item="item"
|
|
2690
|
-
:active="active"
|
|
2691
|
-
:size-dependencies="[expansionCount]"
|
|
2692
|
-
:data-index="index"
|
|
2693
|
-
>
|
|
2694
|
-
<!-- Turn Start Divider -->
|
|
2695
|
-
<div
|
|
2696
|
-
v-if="item.type === 'assistant.turn_start'"
|
|
2697
|
-
:data-type="item.type"
|
|
2698
|
-
:data-index="item.virtualIndex"
|
|
2699
|
-
class="turn-divider"
|
|
2700
|
-
>
|
|
2701
|
-
<div class="turn-divider-line-left"></div>
|
|
2702
|
-
<span class="turn-divider-text">
|
|
2703
|
-
UserReq {{ getTurnNumber(item.virtualIndex) }}
|
|
2704
|
-
<template v-if="metadata.source === 'vscode'">
|
|
2705
|
-
<span class="turn-time">{{ formatTime(item.timestamp) }}</span>
|
|
2706
|
-
<span v-if="getTurnDuration(item.virtualIndex)" class="turn-duration">{{ getTurnDuration(item.virtualIndex) }}</span>
|
|
2707
|
-
</template>
|
|
2708
|
-
<template v-else>Start</template>
|
|
2709
|
-
</span>
|
|
2710
|
-
<div class="turn-divider-line-right"></div>
|
|
2711
|
-
<div class="divider-separator"></div>
|
|
2712
|
-
</div>
|
|
2713
|
-
|
|
2714
|
-
<!-- Subagent Divider -->
|
|
2715
|
-
<div
|
|
2716
|
-
v-else-if="item.type === 'subagent.started' || item.type === 'subagent.completed' || item.type === 'subagent.failed'"
|
|
2717
|
-
:data-type="item.type"
|
|
2718
|
-
:data-index="item.virtualIndex"
|
|
2719
|
-
:class="['subagent-divider', item.type.split('.')[1]]"
|
|
2720
|
-
:style="{
|
|
2721
|
-
'--sa-color': getSubagentColor(item) || '#58a6ff'
|
|
2722
|
-
}"
|
|
2723
|
-
>
|
|
2724
|
-
<div class="subagent-divider-line-left" :style="{ background: getSubagentColor(item) || '#58a6ff' }"></div>
|
|
2725
|
-
<span class="subagent-divider-text" :style="{ color: getSubagentColor(item) || '#58a6ff', borderColor: getSubagentColor(item) || '#58a6ff', background: (getSubagentColor(item) || '#58a6ff') + '1a' }">
|
|
2726
|
-
🤖 {{ item.data?.agentDisplayName || item.data?.agentName || 'SubAgent' }}
|
|
2727
|
-
{{ item.type === 'subagent.started' ? 'Start ▶' : item.type === 'subagent.completed' ? 'Complete ✓' : 'Failed ✗' }}
|
|
2728
|
-
</span>
|
|
2729
|
-
<div class="subagent-divider-line-right" :style="{ background: getSubagentColor(item) || '#58a6ff' }"></div>
|
|
2730
|
-
<div class="divider-separator"></div>
|
|
2731
|
-
</div>
|
|
2732
|
-
|
|
2733
|
-
<!-- Regular Event -->
|
|
2734
|
-
<div
|
|
2735
|
-
v-else
|
|
2736
|
-
:class="['event', getSubagentInfo(item) ? 'event-in-subagent' : '']"
|
|
2737
|
-
:data-type="item.type"
|
|
2738
|
-
:data-index="item.virtualIndex"
|
|
2739
|
-
:style="getSubagentColor(item) ? { '--subagent-border-color': getSubagentColor(item) } : {}"
|
|
2740
|
-
>
|
|
2741
|
-
<div class="event-header">
|
|
2742
|
-
<span :class="['event-badge', getBadgeInfo(item.type, item).class]">
|
|
2743
|
-
{{ getBadgeInfo(item.type, item).label }}
|
|
2744
|
-
</span>
|
|
2745
|
-
<span
|
|
2746
|
-
v-if="getSubagentInfo(item)"
|
|
2747
|
-
class="subagent-owner-tag"
|
|
2748
|
-
:style="{ color: getSubagentColor(item), borderColor: getSubagentColor(item) }"
|
|
2749
|
-
>{{ getSubagentInfo(item).name }}</span>
|
|
2750
|
-
<span v-if="metadata.source !== 'vscode'" class="event-timestamp">{{ formatTime(item.timestamp) }}</span>
|
|
2751
|
-
</div>
|
|
2752
|
-
|
|
2753
|
-
<!-- Abort event: show reason -->
|
|
2754
|
-
<div v-if="item.type === 'abort' && item.data?.reason" class="event-content">
|
|
2755
|
-
<strong>Reason:</strong> {{ item.data.reason }}
|
|
2756
|
-
</div>
|
|
2757
|
-
|
|
2758
|
-
<!-- Session start: show type and selectedModel -->
|
|
2759
|
-
<div v-else-if="item.type === 'session.start'" class="event-content">
|
|
2760
|
-
<div v-if="item.data?.type"><strong>Type:</strong> {{ item.data.type }}</div>
|
|
2761
|
-
<div v-if="item.data?.selectedModel"><strong>Model:</strong> {{ item.data.selectedModel }}</div>
|
|
2762
|
-
<div v-if="item.data?.producer"><strong>Producer:</strong> {{ item.data.producer }}</div>
|
|
2763
|
-
</div>
|
|
2764
|
-
|
|
2765
|
-
<!-- Session resume: show resumeTime, eventCount, context -->
|
|
2766
|
-
<div v-else-if="item.type === 'session.resume'" class="event-content">
|
|
2767
|
-
<div v-if="item.data?.resumeTime"><strong>Resume Time:</strong> {{ formatDateTime(item.data.resumeTime) }}</div>
|
|
2768
|
-
<div v-if="item.data?.eventCount"><strong>Event Count:</strong> {{ item.data.eventCount }}</div>
|
|
2769
|
-
<div v-if="item.data?.context?.branch"><strong>Branch:</strong> {{ item.data.context.branch }}</div>
|
|
2770
|
-
<div v-if="item.data?.context?.repository"><strong>Repository:</strong> {{ item.data.context.repository }}</div>
|
|
2771
|
-
<div v-if="item.data?.context?.cwd"><strong>Working Directory:</strong> {{ item.data.context.cwd }}</div>
|
|
2772
|
-
</div>
|
|
2773
|
-
|
|
2774
|
-
<!-- Session error: show errorType + message -->
|
|
2775
|
-
<div v-else-if="item.type === 'session.error' && (item.data?.errorType || item.data?.message)" class="event-content">
|
|
2776
|
-
<div v-if="item.data?.errorType"><strong>Error Type:</strong> {{ item.data.errorType }}</div>
|
|
2777
|
-
<div v-if="item.data?.message"><strong>Message:</strong> {{ item.data.message }}</div>
|
|
2778
|
-
</div>
|
|
2779
|
-
|
|
2780
|
-
<!-- Model change: show previousModel → newModel -->
|
|
2781
|
-
<div v-else-if="item.type === 'session.model_change'" class="event-content model-change-content">
|
|
2782
|
-
<div v-if="item.data?.previousModel && item.data?.newModel" class="model-change-text">
|
|
2783
|
-
<span class="model-name">{{ item.data.previousModel }}</span>
|
|
2784
|
-
<span class="model-arrow">→</span>
|
|
2785
|
-
<span class="model-name">{{ item.data.newModel }}</span>
|
|
2786
|
-
</div>
|
|
2787
|
-
<div v-else-if="item.data?.newModel" class="model-change-text">
|
|
2788
|
-
Switched to <span class="model-name">{{ item.data.newModel }}</span>
|
|
2789
|
-
</div>
|
|
2790
|
-
<div v-else-if="item.data?.model" class="model-change-text">
|
|
2791
|
-
Switched to <span class="model-name">{{ item.data.model }}</span>
|
|
2792
|
-
</div>
|
|
2793
|
-
<div v-else class="model-change-text">
|
|
2794
|
-
Model changed
|
|
2795
|
-
</div>
|
|
2796
|
-
</div>
|
|
1134
|
+
/* Sidebar backdrop (mobile only) */
|
|
1135
|
+
.sidebar-backdrop {
|
|
1136
|
+
display: block;
|
|
1137
|
+
position: fixed;
|
|
1138
|
+
inset: 0;
|
|
1139
|
+
background: rgba(0,0,0,0.5);
|
|
1140
|
+
z-index: 999;
|
|
1141
|
+
}
|
|
2797
1142
|
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
1143
|
+
/* Sidebar: hidden by default on mobile, overlay when open */
|
|
1144
|
+
.sidebar {
|
|
1145
|
+
position: fixed;
|
|
1146
|
+
top: 0;
|
|
1147
|
+
left: 0;
|
|
1148
|
+
height: 100%;
|
|
1149
|
+
width: 280px !important;
|
|
1150
|
+
z-index: 1000;
|
|
1151
|
+
box-shadow: 4px 0 16px rgba(0,0,0,0.6);
|
|
1152
|
+
transform: translateX(-100%);
|
|
1153
|
+
transition: transform 0.3s ease;
|
|
1154
|
+
}
|
|
1155
|
+
.sidebar:not(.collapsed) {
|
|
1156
|
+
transform: translateX(0);
|
|
1157
|
+
}
|
|
1158
|
+
.sidebar.collapsed {
|
|
1159
|
+
transform: translateX(-100%);
|
|
1160
|
+
width: 280px !important;
|
|
1161
|
+
padding: 16px !important;
|
|
1162
|
+
border-right: 1px solid #30363d !important;
|
|
1163
|
+
overflow-y: auto !important;
|
|
1164
|
+
}
|
|
2802
1165
|
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
<div v-if="item.data?.postTruncationMessagesLength"><strong>Post-truncation messages:</strong> {{ item.data.postTruncationMessagesLength }}</div>
|
|
2808
|
-
<div v-if="item.data?.performedBy"><strong>Performed by:</strong> {{ item.data.performedBy }}</div>
|
|
2809
|
-
</div>
|
|
1166
|
+
/* Content: full width */
|
|
1167
|
+
.content {
|
|
1168
|
+
width: 100%;
|
|
1169
|
+
}
|
|
2810
1170
|
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
1171
|
+
/* Toolbar: stack if narrow */
|
|
1172
|
+
.scroll-indicator {
|
|
1173
|
+
flex-wrap: wrap;
|
|
1174
|
+
gap: 4px;
|
|
1175
|
+
padding: 6px 8px;
|
|
1176
|
+
}
|
|
1177
|
+
.content-toolbar-left,
|
|
1178
|
+
.content-toolbar-right {
|
|
1179
|
+
gap: 4px;
|
|
1180
|
+
}
|
|
1181
|
+
.search-input {
|
|
1182
|
+
flex: 1;
|
|
1183
|
+
min-width: 0;
|
|
1184
|
+
font-size: 12px;
|
|
1185
|
+
}
|
|
1186
|
+
.turn-dropdown {
|
|
1187
|
+
max-width: 140px;
|
|
1188
|
+
font-size: 12px;
|
|
1189
|
+
}
|
|
2815
1190
|
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
input {{ item.data.compactionTokensUsed.input?.toLocaleString() || 0 }},
|
|
2822
|
-
output {{ item.data.compactionTokensUsed.output?.toLocaleString() || 0 }}
|
|
2823
|
-
<span v-if="item.data.compactionTokensUsed.cachedInput">, cached {{ item.data.compactionTokensUsed.cachedInput.toLocaleString() }}</span>
|
|
2824
|
-
</div>
|
|
2825
|
-
<div v-if="item.data?.preCompactionMessagesLength"><strong>Pre-compaction messages:</strong> {{ item.data.preCompactionMessagesLength }}</div>
|
|
2826
|
-
<div v-if="item.data?.preCompactionTokens"><strong>Pre-compaction tokens:</strong> {{ item.data.preCompactionTokens.toLocaleString() }}</div>
|
|
2827
|
-
<div v-if="item.data?.summaryContent" style="margin-top: 8px;">
|
|
2828
|
-
<button
|
|
2829
|
-
@click="toggleContent('compaction-' + item.stableId)"
|
|
2830
|
-
style="background: none; border: 1px solid #30363d; color: #58a6ff; padding: 4px 12px; border-radius: 4px; cursor: pointer; font-size: 13px;"
|
|
2831
|
-
>
|
|
2832
|
-
{{ expandedContent['compaction-' + item.stableId] ? 'Hide summary ▲' : 'Show summary ▼' }}
|
|
2833
|
-
</button>
|
|
2834
|
-
<div v-if="expandedContent['compaction-' + item.stableId]" class="event-content" style="margin-top: 8px;" v-html="renderMarkdown(item.data.summaryContent)"></div>
|
|
2835
|
-
</div>
|
|
2836
|
-
</div>
|
|
1191
|
+
/* Float buttons: slightly smaller, keep fixed */
|
|
1192
|
+
.scroll-float-btns {
|
|
1193
|
+
bottom: 16px;
|
|
1194
|
+
right: 12px;
|
|
1195
|
+
}
|
|
2837
1196
|
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
<div v-if="hasTools(item)" class="tool-list">
|
|
2872
|
-
<div
|
|
2873
|
-
v-for="(group, idx) in getToolGroups(item)"
|
|
2874
|
-
:key="idx"
|
|
2875
|
-
class="tool-item"
|
|
2876
|
-
>
|
|
2877
|
-
<div
|
|
2878
|
-
class="tool-header-line"
|
|
2879
|
-
@click="toggleTool(item.stableId + '-' + idx)"
|
|
2880
|
-
>
|
|
2881
|
-
<span class="tool-connector">{{ idx === getToolGroups(item).length - 1 ? '└─' : '├─' }}</span>
|
|
2882
|
-
<span class="tool-expand-icon">{{ expandedTools[item.stableId + '-' + idx] ? '▼' : '▶' }}</span>
|
|
2883
|
-
<span class="tool-name">🔧 {{ group.start?.data?.toolName || group.tool || 'Tool' }}</span>
|
|
2884
|
-
<span :class="getToolStatus(group).color" style="margin-left: 4px;">({{ getToolStatus(group).icon }}{{ getToolDuration(group) ? ' ' + getToolDuration(group) : '' }})</span>
|
|
2885
|
-
<span v-if="getToolCommand(group)" style="color: #7d8590; margin-left: 8px;">{{ getToolCommand(group) }}</span>
|
|
2886
|
-
<span v-if="getToolErrorMessage(group)" style="color: #ff7b72; margin-left: 8px;">{{ getToolErrorMessage(group).length > 80 ? getToolErrorMessage(group).substring(0, 80) + '...' : getToolErrorMessage(group) }}</span>
|
|
2887
|
-
</div>
|
|
2888
|
-
|
|
2889
|
-
<div v-if="expandedTools[item.stableId + '-' + idx]" class="tool-detail">
|
|
2890
|
-
<div v-if="group.start?.data?.arguments" class="tool-detail-section">
|
|
2891
|
-
<div class="tool-detail-title">Arguments:</div>
|
|
2892
|
-
<div class="tool-detail-content">
|
|
2893
|
-
<pre>{{ JSON.stringify(group.start.data.arguments, null, 2) }}</pre>
|
|
2894
|
-
</div>
|
|
2895
|
-
</div>
|
|
2896
|
-
<div v-if="group.complete?.data?.result" class="tool-detail-section">
|
|
2897
|
-
<div class="tool-detail-title">Result:</div>
|
|
2898
|
-
<div class="tool-detail-content">
|
|
2899
|
-
<pre>{{ JSON.stringify(group.complete.data.result, null, 2) }}</pre>
|
|
2900
|
-
</div>
|
|
2901
|
-
</div>
|
|
2902
|
-
<div v-if="getToolErrorMessage(group)" class="tool-detail-section">
|
|
2903
|
-
<div class="tool-detail-title">Error:</div>
|
|
2904
|
-
<div class="tool-detail-content" style="color: #ff7b72;">
|
|
2905
|
-
{{ getToolErrorMessage(group) }}
|
|
2906
|
-
</div>
|
|
2907
|
-
</div>
|
|
2908
|
-
</div>
|
|
2909
|
-
</div>
|
|
2910
|
-
</div>
|
|
2911
|
-
|
|
2912
|
-
<!-- Separator (inside event for proper height calculation) -->
|
|
2913
|
-
<div v-if="!item.isLastEvent" class="event-separator"></div>
|
|
2914
|
-
</div>
|
|
2915
|
-
</DynamicScrollerItem>
|
|
2916
|
-
</template>
|
|
2917
|
-
</DynamicScroller>
|
|
2918
|
-
</div>
|
|
2919
|
-
</div>
|
|
2920
|
-
|
|
2921
|
-
</div>
|
|
2922
|
-
`
|
|
2923
|
-
});
|
|
1197
|
+
/* Prevent content from stretching wider than viewport */
|
|
1198
|
+
.event-card {
|
|
1199
|
+
max-width: 100%;
|
|
1200
|
+
overflow-x: hidden;
|
|
1201
|
+
}
|
|
1202
|
+
.event-content pre {
|
|
1203
|
+
max-width: calc(100vw - 32px);
|
|
1204
|
+
}
|
|
1205
|
+
.tool-command {
|
|
1206
|
+
word-break: break-all;
|
|
1207
|
+
}
|
|
1208
|
+
/* Tool command text wraps on mobile */
|
|
1209
|
+
.tool-header-line {
|
|
1210
|
+
overflow-wrap: anywhere;
|
|
1211
|
+
word-break: break-all;
|
|
1212
|
+
}
|
|
1213
|
+
/* Extra bottom padding for mobile browser nav bar */
|
|
1214
|
+
.vue-recycle-scroller {
|
|
1215
|
+
padding-bottom: max(env(safe-area-inset-bottom, 0px), 80px);
|
|
1216
|
+
}
|
|
1217
|
+
.scroller-bottom-spacer {
|
|
1218
|
+
height: max(env(safe-area-inset-bottom, 0px), 100px);
|
|
1219
|
+
}
|
|
1220
|
+
.scroll-edge-btn {
|
|
1221
|
+
width: 28px;
|
|
1222
|
+
height: 28px;
|
|
1223
|
+
font-size: 12px;
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
</style>
|
|
1227
|
+
</head>
|
|
1228
|
+
<body>
|
|
1229
|
+
<div id="app"></div>
|
|
2924
1230
|
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
console.log('Vue app mounted successfully!', vm ? 'Instance created' : 'No instance');
|
|
2932
|
-
console.log('VM type:', typeof vm, 'Has exportSession:', typeof vm?.exportSession);
|
|
2933
|
-
console.log('VM keys:', vm ? Object.keys(vm).slice(0, 10) : 'NO_VM');
|
|
2934
|
-
console.log('#app innerHTML length:', document.getElementById('app').innerHTML.length);
|
|
2935
|
-
console.log('#app first 100 chars:', document.getElementById('app').innerHTML.substring(0, 100));
|
|
2936
|
-
} catch (error) {
|
|
2937
|
-
console.error('Mount failed:', error);
|
|
2938
|
-
console.error('Error stack:', error.stack);
|
|
2939
|
-
}
|
|
2940
|
-
})(); // End IIFE
|
|
1231
|
+
<script>
|
|
1232
|
+
window.__PAGE_DATA = {
|
|
1233
|
+
sessionId: "<%= sessionId %>",
|
|
1234
|
+
events: <%- JSON.stringify(events) %>,
|
|
1235
|
+
metadata: <%- JSON.stringify(metadata) %>
|
|
1236
|
+
};
|
|
2941
1237
|
</script>
|
|
1238
|
+
<script src="/public/js/session-detail.min.js"></script>
|
|
2942
1239
|
</body>
|
|
2943
1240
|
</html>
|