@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.
@@ -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: 8px;
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
- const getSubagentColor = (event) => {
1893
- const info = getSubagentInfo(event);
1894
- if (!info) return null;
1895
- return SUBAGENT_COLORS[info.colorIndex % SUBAGENT_COLORS.length];
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
- return {
2415
- sessionId,
2416
- metadata,
2417
- exporting,
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
- <!-- Session Tags -->
2566
- <div class="sidebar-section session-tags-container">
2567
- <div class="sidebar-section-title">Tags</div>
2568
- <div v-if="!tagsEditing" class="tags-display">
2569
- <span
2570
- v-for="tag in sessionTags"
2571
- :key="tag"
2572
- class="tag-label"
2573
- :style="{ backgroundColor: getTagColor(tag) }"
2574
- >
2575
- {{ tag }}
2576
- </span>
2577
- <button class="tags-edit-btn" @click="startEditTags" title="Edit tags">
2578
- ✏️
2579
- </button>
2580
- </div>
2581
- <div v-else class="tags-dropdown">
2582
- <div class="tags-input-container">
2583
- <span
2584
- v-for="tag in editingTags"
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
- <div class="content">
2621
- <div class="scroll-indicator">
2622
- <div class="content-toolbar-left">
2623
- <button
2624
- class="sidebar-toggle"
2625
- @click="sidebarCollapsed = !sidebarCollapsed"
2626
- :title="sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'"
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
- <!-- Session truncation: show token/message removal info -->
2799
- <div v-else-if="item.type === 'system.notification'" class="event-content" style="opacity:0.7">
2800
- <span>{{ item.data?.message }}</span>
2801
- </div>
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
- <div v-else-if="item.type === 'session.truncation'" class="event-content">
2804
- <div v-if="item.data?.messagesRemovedDuringTruncation"><strong>Messages removed:</strong> {{ item.data.messagesRemovedDuringTruncation }}</div>
2805
- <div v-if="item.data?.tokensRemovedDuringTruncation"><strong>Tokens removed:</strong> {{ item.data.tokensRemovedDuringTruncation.toLocaleString() }}</div>
2806
- <div v-if="item.data?.preTruncationTokensInMessages"><strong>Pre-truncation tokens:</strong> {{ item.data.preTruncationTokensInMessages.toLocaleString() }}</div>
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
- <!-- Session compaction start -->
2812
- <div v-else-if="item.type === 'session.compaction_start'" class="event-content">
2813
- Context compaction started
2814
- </div>
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
- <!-- Session compaction complete: show results -->
2817
- <div v-else-if="item.type === 'session.compaction_complete'" class="event-content">
2818
- <div v-if="item.data?.success != null"><strong>Success:</strong> {{ item.data.success ? '✓' : '✗' }}</div>
2819
- <div v-if="item.data?.compactionTokensUsed">
2820
- <strong>Tokens used:</strong>
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
- <!-- Regular content (unified format from server) -->
2839
- <div v-else-if="item.data?.message || item.data?.text || item.data?.content || item.data?.transformedContent">
2840
- <div
2841
- class="event-content"
2842
- v-html="highlightSearchText(
2843
- renderMarkdown(
2844
- (expandedContent[item.stableId] || !isContentTooLong(item.data?.message || item.data?.text || item.data?.content || item.data?.transformedContent))
2845
- ? (item.data?.message || item.data?.text || item.data?.content || item.data?.transformedContent)
2846
- : truncateContent(item.data?.message || item.data?.text || item.data?.content || item.data?.transformedContent)
2847
- ),
2848
- searchText
2849
- )"
2850
- ></div>
2851
- <div
2852
- v-if="isContentTooLong(item.data?.message || item.data?.text || item.data?.content || item.data?.transformedContent)"
2853
- style="margin-top: 8px;"
2854
- >
2855
- <button
2856
- @click="toggleContent(item.stableId)"
2857
- :data-content-id="item.stableId"
2858
- style="background: none; border: 1px solid #30363d; color: #58a6ff; padding: 4px 12px; border-radius: 4px; cursor: pointer; font-size: 13px;"
2859
- >
2860
- {{ expandedContent[item.stableId] ? 'Show less ▲' : 'Show more ▼' }}
2861
- </button>
2862
- </div>
2863
- </div>
2864
-
2865
- <!-- No content at all (no message and no tools) -->
2866
- <div v-else-if="!hasTools(item)" class="event-content" style="color: #7d8590; font-style: italic;">
2867
- No available message
2868
- </div>
2869
-
2870
- <!-- Tool calls section (independent of message content, but don't need "No available message" if tools exist) -->
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">🔧&nbsp;{{ 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
- // Mount the app
2926
- console.log('Mounting Vue app to #app...');
2927
- console.log('App config:', app.config);
2928
- console.log('Target element:', document.getElementById('app'));
2929
- try {
2930
- const vm = app.mount('#app');
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>