@qiaolei81/copilot-session-viewer 0.1.9 → 0.2.1

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.
@@ -8,13 +8,16 @@
8
8
  <!-- Vue 3 -->
9
9
  <script src="https://cdn.jsdelivr.net/npm/vue@3.5.28/dist/vue.global.prod.js" integrity="sha384-EyKhbIJoP1t1fKIFRNEfYKy4uy8qxs7UNS4Cab53xyXqCTUB1PCoxeFsD0G/NX9W" crossorigin="anonymous"></script>
10
10
 
11
- <!-- vue-virtual-scroller -->
12
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/vue-virtual-scroller@2.0.0-beta.8/dist/vue-virtual-scroller.css" integrity="sha384-PIWxQLH13FE3yKn8YrBWCcBRMkCKr2xW0XcqY9sNVhmihrqyXrdsWrcjZdqlnaWg" crossorigin="anonymous">
13
- <script src="https://cdn.jsdelivr.net/npm/vue-virtual-scroller@2.0.0-beta.8/dist/vue-virtual-scroller.min.js" integrity="sha384-asaCxaI3GxogQlKm59ynOPR3hwB7hNOTYipFrlNUHdRM0BOQs5BRO8ix8clxlj6p" crossorigin="anonymous"></script>
11
+ <!-- vue-virtual-scroller Vue 3 -->
12
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/vue-virtual-scroller@next/dist/vue-virtual-scroller.css" crossorigin="anonymous">
13
+ <script src="https://cdn.jsdelivr.net/npm/vue-virtual-scroller@next/dist/vue-virtual-scroller.min.js" crossorigin="anonymous"></script>
14
14
 
15
15
  <!-- Markdown rendering -->
16
16
  <script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js" integrity="sha384-zbcZAIxlvJtNE3Dp5nxLXdXtXyxwOdnILY1TDPVmKFhl4r4nSUG1r8bcFXGVa4Te" crossorigin="anonymous"></script>
17
17
 
18
+ <!-- DOMPurify for XSS protection -->
19
+ <script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.8/dist/purify.min.js" crossorigin="anonymous"></script>
20
+
18
21
  <style>
19
22
  * { margin: 0; padding: 0; box-sizing: border-box; }
20
23
  body {
@@ -224,6 +227,21 @@
224
227
  color: #58a6ff;
225
228
  border: 1px solid rgba(31, 111, 235, 0.4);
226
229
  }
230
+ .source-copilot {
231
+ background: rgba(88, 166, 255, 0.2);
232
+ color: #58a6ff;
233
+ border: 1px solid rgba(88, 166, 255, 0.4);
234
+ }
235
+ .source-claude {
236
+ background: rgba(210, 153, 34, 0.2);
237
+ color: #d29922;
238
+ border: 1px solid rgba(210, 153, 34, 0.4);
239
+ }
240
+ .source-pi-mono {
241
+ background: rgba(138, 102, 204, 0.2);
242
+ color: #a78bdb;
243
+ border: 1px solid rgba(138, 102, 204, 0.4);
244
+ }
227
245
 
228
246
  /* Turn buttons */
229
247
  .turn-buttons {
@@ -904,14 +922,30 @@
904
922
 
905
923
  <!-- Vue App -->
906
924
  <script>
907
- const { createApp, ref, computed, onMounted, onBeforeUnmount, reactive, watch } = Vue;
908
- const { DynamicScroller, DynamicScrollerItem } = window.VueVirtualScroller;
909
-
910
- const app = createApp({
911
- components: {
912
- DynamicScroller,
913
- DynamicScrollerItem
914
- },
925
+ // Immediate initialization (script is at bottom, DOM is ready)
926
+ (function() {
927
+ // Verify Vue is loaded
928
+ if (typeof Vue === 'undefined') {
929
+ console.error('Vue is not loaded');
930
+ return;
931
+ }
932
+
933
+ // Verify VueVirtualScroller is loaded
934
+ if (typeof window.VueVirtualScroller === 'undefined') {
935
+ console.error('VueVirtualScroller is not loaded');
936
+ return;
937
+ }
938
+
939
+ console.log('Initializing Vue app...');
940
+
941
+ const { createApp, ref, computed, onMounted, onBeforeUnmount, reactive, watch } = Vue;
942
+ const { DynamicScroller, DynamicScrollerItem } = window.VueVirtualScroller;
943
+
944
+ const app = createApp({
945
+ components: {
946
+ DynamicScroller,
947
+ DynamicScrollerItem
948
+ },
915
949
 
916
950
  setup() {
917
951
  const sessionId = ref(window.sessionData.sessionId);
@@ -930,6 +964,24 @@
930
964
 
931
965
  const expandedTools = ref({});
932
966
  const expandedContent = ref({});
967
+ const MAX_EXPANDED_ITEMS = 50; // Memory leak fix: Limit expanded items
968
+
969
+ // Clean up old expansion state to prevent memory leak
970
+ const cleanupExpansionState = () => {
971
+ const toolKeys = Object.keys(expandedTools.value);
972
+ if (toolKeys.length > MAX_EXPANDED_ITEMS) {
973
+ // Keep only recent 50 expanded items
974
+ const toRemove = toolKeys.slice(0, toolKeys.length - MAX_EXPANDED_ITEMS);
975
+ toRemove.forEach(key => delete expandedTools.value[key]);
976
+ }
977
+
978
+ const contentKeys = Object.keys(expandedContent.value);
979
+ if (contentKeys.length > MAX_EXPANDED_ITEMS) {
980
+ const toRemove = contentKeys.slice(0, contentKeys.length - MAX_EXPANDED_ITEMS);
981
+ toRemove.forEach(key => delete expandedContent.value[key]);
982
+ }
983
+ };
984
+
933
985
  const currentFilter = ref('all');
934
986
  const searchText = ref('');
935
987
  const debouncedSearchText = ref('');
@@ -946,6 +998,15 @@
946
998
  }, 300);
947
999
  });
948
1000
 
1001
+ // Memory leak fix: Clean up expansion state when filter/search changes
1002
+ watch(currentFilter, () => {
1003
+ cleanupExpansionState();
1004
+ });
1005
+
1006
+ watch(debouncedSearchText, () => {
1007
+ cleanupExpansionState();
1008
+ });
1009
+
949
1010
  // Async loading state
950
1011
  const loadedEvents = ref([]);
951
1012
  const eventsLoading = ref(true);
@@ -1168,43 +1229,6 @@
1168
1229
  };
1169
1230
 
1170
1231
  // Tool call map
1171
- const toolCallMap = computed(() => {
1172
- const map = new Map();
1173
- const toolGroups = new Map();
1174
-
1175
- flatEvents.value.forEach(event => {
1176
- if (event.type === 'tool.execution_start') {
1177
- const toolId = event.data?.toolCallId;
1178
- if (toolId) {
1179
- if (!toolGroups.has(toolId)) {
1180
- toolGroups.set(toolId, { tool: event.data.tool, start: event });
1181
- }
1182
- }
1183
- } else if (event.type === 'tool.execution_complete') {
1184
- const toolId = event.data?.toolCallId;
1185
- if (toolId && toolGroups.has(toolId)) {
1186
- toolGroups.get(toolId).complete = event;
1187
- }
1188
- }
1189
- });
1190
-
1191
- flatEvents.value.forEach(event => {
1192
- if (event.type === 'assistant.message') {
1193
- const groups = [];
1194
- toolGroups.forEach((group, toolId) => {
1195
- if (group.start?.parentId === event.id) {
1196
- groups.push(group);
1197
- }
1198
- });
1199
- if (groups.length > 0) {
1200
- map.set(event.id || event.virtualIndex, groups);
1201
- }
1202
- }
1203
- });
1204
-
1205
- return map;
1206
- });
1207
-
1208
1232
  // Subagent ownership: attribute events to their owning subagent
1209
1233
  const subagentOwnership = computed(() => {
1210
1234
  const sorted = flatEvents.value;
@@ -1307,8 +1331,18 @@
1307
1331
  return `${hours}:${minutes}:${seconds}`;
1308
1332
  };
1309
1333
 
1334
+ // Performance fix: Cache markdown rendering results
1335
+ const markdownCache = new Map();
1336
+ const MAX_CACHE_SIZE = 200;
1337
+
1310
1338
  const renderMarkdown = (text) => {
1311
1339
  if (!text) return '';
1340
+
1341
+ // Check cache first
1342
+ if (markdownCache.has(text)) {
1343
+ return markdownCache.get(text);
1344
+ }
1345
+
1312
1346
  try {
1313
1347
  // 处理转义序列:将 \r\n、\n、\t 等转换为实际字符
1314
1348
  let processedText = text
@@ -1318,6 +1352,14 @@
1318
1352
  .replace(/\\"/g, '"') // \" → 引号
1319
1353
  .replace(/\\\\/g, '\\'); // \\ → 反斜杠
1320
1354
 
1355
+ // DOMPurify configuration for markdown content
1356
+ const purifyConfig = {
1357
+ ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'code', 'pre', 'a', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr', 'del', 'span', 'div', 'mark'],
1358
+ ALLOWED_ATTR: ['href', 'style', 'class'],
1359
+ ALLOW_DATA_ATTR: false,
1360
+ ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i
1361
+ };
1362
+
1321
1363
  // Parse YAML frontmatter
1322
1364
  const frontmatterMatch = processedText.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
1323
1365
  if (frontmatterMatch) {
@@ -1332,18 +1374,42 @@
1332
1374
  return { key, value };
1333
1375
  });
1334
1376
 
1335
- // Render frontmatter as table
1377
+ // Render frontmatter as table (sanitize key/value)
1336
1378
  let tableHTML = '<table style="margin-bottom: 16px; border-collapse: collapse;"><tbody>';
1337
1379
  pairs.forEach(pair => {
1338
- tableHTML += `<tr><td style="padding: 4px 12px; border: 1px solid #30363d; font-weight: 600; color: #7d8590;">${pair.key}</td><td style="padding: 4px 12px; border: 1px solid #30363d;">${pair.value}</td></tr>`;
1380
+ const sanitizedKey = DOMPurify.sanitize(pair.key, { ALLOWED_TAGS: [] });
1381
+ const sanitizedValue = DOMPurify.sanitize(pair.value, { ALLOWED_TAGS: [] });
1382
+ tableHTML += `<tr><td style="padding: 4px 12px; border: 1px solid #30363d; font-weight: 600; color: #7d8590;">${sanitizedKey}</td><td style="padding: 4px 12px; border: 1px solid #30363d;">${sanitizedValue}</td></tr>`;
1339
1383
  });
1340
1384
  tableHTML += '</tbody></table>';
1341
1385
 
1342
- // Render remaining content
1343
- return tableHTML + marked.parse(content);
1386
+ // Render remaining content with sanitization
1387
+ const markdownHTML = marked.parse(content);
1388
+ const sanitizedMarkdown = DOMPurify.sanitize(markdownHTML, purifyConfig);
1389
+ const result = tableHTML + sanitizedMarkdown;
1390
+
1391
+ // Cache the result
1392
+ if (markdownCache.size >= MAX_CACHE_SIZE) {
1393
+ const firstKey = markdownCache.keys().next().value;
1394
+ markdownCache.delete(firstKey);
1395
+ }
1396
+ markdownCache.set(text, result);
1397
+
1398
+ return result;
1344
1399
  }
1345
1400
 
1346
- return marked.parse(processedText);
1401
+ // Regular markdown rendering with sanitization
1402
+ const markdownHTML = marked.parse(processedText);
1403
+ const result = DOMPurify.sanitize(markdownHTML, purifyConfig);
1404
+
1405
+ // Cache the result (with size limit to prevent memory leak)
1406
+ if (markdownCache.size >= MAX_CACHE_SIZE) {
1407
+ const firstKey = markdownCache.keys().next().value;
1408
+ markdownCache.delete(firstKey);
1409
+ }
1410
+ markdownCache.set(text, result);
1411
+
1412
+ return result;
1347
1413
  } catch (e) {
1348
1414
  return text;
1349
1415
  }
@@ -1414,7 +1480,18 @@
1414
1480
  return lines.slice(0, 20).join('\n') + '\n\n...';
1415
1481
  };
1416
1482
 
1417
- const getBadgeInfo = (type) => {
1483
+ const getBadgeInfo = (type, item) => {
1484
+ // Prefer backend-generated badge info (Violation #4 fix)
1485
+ if (item?.data?.badgeLabel && item?.data?.badgeClass) {
1486
+ return { label: item.data.badgeLabel, class: item.data.badgeClass };
1487
+ }
1488
+
1489
+ // Fallback: frontend logic for backward compatibility
1490
+ // Pi-Mono toolResult events: still use type='message' with role='toolResult'
1491
+ if (type === 'message' && item?.data?.role === 'toolResult') {
1492
+ return { label: 'TOOL RESULT', class: 'badge-tool' };
1493
+ }
1494
+
1418
1495
  // Special case for specific event types
1419
1496
  if (type === 'session.model_change') {
1420
1497
  return { label: 'MODEL CHANGE', class: 'badge-session' };
@@ -1535,11 +1612,37 @@
1535
1612
  };
1536
1613
 
1537
1614
  const hasTools = (event) => {
1538
- return event.type === 'assistant.message' && toolCallMap.value.has(event.id || event.virtualIndex);
1615
+ // Unified format: check data.tools (works for both Copilot and Claude)
1616
+ return event.data?.tools && event.data.tools.length > 0;
1539
1617
  };
1540
1618
 
1541
1619
  const getToolGroups = (event) => {
1542
- return toolCallMap.value.get(event.id || event.virtualIndex) || [];
1620
+ // Unified format from server (both Copilot and Claude normalized to data.tools)
1621
+ if (event.data?.tools && Array.isArray(event.data.tools)) {
1622
+ return event.data.tools
1623
+ .filter(tool => tool && typeof tool === 'object' && tool.name) // Any tool object with a name
1624
+ .map(tool => {
1625
+ // Check if tool has result (works for all formats)
1626
+ const hasResult = tool.result !== undefined || tool.status === 'completed' || tool.status === 'error';
1627
+ return {
1628
+ tool: tool.name,
1629
+ start: {
1630
+ data: {
1631
+ toolName: tool.name,
1632
+ arguments: tool.input || tool.arguments || {}
1633
+ }
1634
+ },
1635
+ complete: hasResult ? {
1636
+ data: {
1637
+ result: tool.result,
1638
+ error: tool.status === 'error' ? tool.error : null
1639
+ }
1640
+ } : null
1641
+ };
1642
+ });
1643
+ }
1644
+
1645
+ return [];
1543
1646
  };
1544
1647
 
1545
1648
  // Subagent color palette for parallel subagent distinction
@@ -1554,6 +1657,17 @@
1554
1657
  '#56d4dd' // teal
1555
1658
  ];
1556
1659
 
1660
+ // Hash function for generating consistent color indices
1661
+ const hashCode = (str) => {
1662
+ let hash = 0;
1663
+ for (let i = 0; i < str.length; i++) {
1664
+ const char = str.charCodeAt(i);
1665
+ hash = ((hash << 5) - hash) + char;
1666
+ hash = hash & hash; // Convert to 32bit integer
1667
+ }
1668
+ return hash;
1669
+ };
1670
+
1557
1671
  const getSubagentInfo = (event) => {
1558
1672
  const { ownerMap, subagentInfo } = subagentOwnership.value;
1559
1673
  // For subagent dividers, use their own toolCallId
@@ -1565,7 +1679,19 @@
1565
1679
  }
1566
1680
  return null;
1567
1681
  }
1568
- // For regular events, look up ownership
1682
+ // For regular events, first check _subagent metadata (Claude format)
1683
+ if (event._subagent) {
1684
+ const subagentId = event._subagent.id;
1685
+ const subagentName = event._subagent.name;
1686
+ // Use subagentId as toolCallId for consistency
1687
+ if (subagentInfo.has(subagentId)) {
1688
+ const info = subagentInfo.get(subagentId);
1689
+ return { name: info.name, toolCallId: subagentId, colorIndex: info.colorIndex };
1690
+ }
1691
+ // If not in subagentInfo, create a default entry
1692
+ return { name: subagentName, toolCallId: subagentId, colorIndex: Math.abs(hashCode(subagentId)) };
1693
+ }
1694
+ // For regular events, look up ownership (Copilot format)
1569
1695
  const tcid = ownerMap.get(event.stableId);
1570
1696
  if (!tcid) return null;
1571
1697
  const info = subagentInfo.get(tcid);
@@ -1649,28 +1775,53 @@
1649
1775
  };
1650
1776
 
1651
1777
  const exportSession = async () => {
1778
+ console.log('[Export] exportSession called');
1652
1779
  exporting.value = true;
1653
1780
  try {
1781
+ console.log('[Export] Fetching:', `/session/${sessionId.value}/export`);
1654
1782
  const response = await fetch(`/session/${sessionId.value}/export`);
1783
+ console.log('[Export] Response received:', response.status, response.ok);
1784
+ console.log('[Export] Response received:', response.status, response.ok);
1655
1785
  if (!response.ok) {
1656
1786
  throw new Error('Share failed');
1657
1787
  }
1658
1788
 
1659
1789
  // Download the file
1790
+ console.log('[Export] Creating blob...');
1660
1791
  const blob = await response.blob();
1792
+ console.log('[Export] Blob size:', blob.size, 'type:', blob.type);
1661
1793
  const url = window.URL.createObjectURL(blob);
1794
+ console.log('[Export] Creating download link...');
1662
1795
  const a = document.createElement('a');
1663
1796
  a.href = url;
1664
1797
  a.download = `session-${sessionId.value}.zip`;
1665
1798
  document.body.appendChild(a);
1666
1799
  a.click();
1800
+ console.log('[Export] Download triggered');
1667
1801
  window.URL.revokeObjectURL(url);
1668
1802
  document.body.removeChild(a);
1803
+
1804
+ // Show success feedback
1805
+ console.log('[Export] Showing success feedback...');
1806
+ const originalText = '📤 Share Session';
1807
+ const successText = '✓ Downloaded!';
1808
+ const btn = document.querySelector('.export-btn');
1809
+ if (btn) {
1810
+ btn.textContent = successText;
1811
+ btn.style.background = '#238636';
1812
+ console.log('[Export] Button text updated to:', btn.textContent);
1813
+ setTimeout(() => {
1814
+ btn.textContent = originalText;
1815
+ btn.style.background = '';
1816
+ console.log('[Export] Button text restored');
1817
+ }, 2000);
1818
+ }
1669
1819
  } catch (err) {
1670
- console.error('Share session error:', err);
1820
+ console.error('[Export] Share session error:', err);
1671
1821
  alert('Failed to share session: ' + err.message);
1672
1822
  } finally {
1673
1823
  exporting.value = false;
1824
+ console.log('[Export] Export complete');
1674
1825
  }
1675
1826
  };
1676
1827
 
@@ -1684,7 +1835,20 @@
1684
1835
  if (!response.ok) {
1685
1836
  throw new Error(`Failed to load events: ${response.statusText}`);
1686
1837
  }
1687
- loadedEvents.value = await response.json();
1838
+ const data = await response.json();
1839
+
1840
+ // Handle both old (array) and new (object with pagination) response formats
1841
+ if (Array.isArray(data)) {
1842
+ // Old format: direct array
1843
+ loadedEvents.value = data;
1844
+ } else if (data.events && Array.isArray(data.events)) {
1845
+ // New format: { events, pagination }
1846
+ loadedEvents.value = data.events;
1847
+ console.log('[Navigation] Pagination:', data.pagination);
1848
+ } else {
1849
+ throw new Error('Invalid response format');
1850
+ }
1851
+
1688
1852
  console.log('[Navigation] Events loaded:', loadedEvents.value.length);
1689
1853
 
1690
1854
  // Check for URL query parameters and jump to event AFTER events are loaded
@@ -1835,9 +1999,23 @@
1835
1999
 
1836
2000
  // Cleanup on unmount
1837
2001
  onBeforeUnmount(() => {
2002
+ // Clear search timeout (memory leak fix)
2003
+ if (searchTimeout) {
2004
+ clearTimeout(searchTimeout);
2005
+ searchTimeout = null;
2006
+ }
2007
+
2008
+ // Clean scroll listeners
1838
2009
  if (scrollCleanup) {
1839
2010
  scrollCleanup();
1840
2011
  }
2012
+
2013
+ // Clear expansion state (memory leak fix)
2014
+ expandedTools.value = {};
2015
+ expandedContent.value = {};
2016
+
2017
+ // Clear markdown cache (memory leak fix)
2018
+ markdownCache.clear();
1841
2019
  });
1842
2020
  });
1843
2021
 
@@ -1864,7 +2042,6 @@
1864
2042
  turns,
1865
2043
  userReqs,
1866
2044
  truncateText,
1867
- toolCallMap,
1868
2045
  formatTime,
1869
2046
  formatDateTime,
1870
2047
  renderMarkdown,
@@ -1914,6 +2091,15 @@
1914
2091
  <div v-if="metadata.summary" class="session-summary-block">{{ metadata.summary }}</div>
1915
2092
  <table class="session-info-table">
1916
2093
  <tbody>
2094
+ <tr v-if="metadata.source">
2095
+ <td>Source</td>
2096
+ <td>
2097
+ <!-- Use backend-provided source metadata (Violation #3 fix) -->
2098
+ <span :class="['source-badge', metadata.sourceBadgeClass || 'source-copilot']">
2099
+ {{ metadata.sourceName || 'GitHub Copilot' }}
2100
+ </span>
2101
+ </td>
2102
+ </tr>
1917
2103
  <tr v-if="metadata.copilotVersion">
1918
2104
  <td>CLI Version</td>
1919
2105
  <td>{{ metadata.copilotVersion }}</td>
@@ -2078,8 +2264,8 @@
2078
2264
  :style="getSubagentColor(item) ? { '--subagent-border-color': getSubagentColor(item) } : {}"
2079
2265
  >
2080
2266
  <div class="event-header">
2081
- <span :class="['event-badge', getBadgeInfo(item.type).class]">
2082
- {{ getBadgeInfo(item.type).label }}
2267
+ <span :class="['event-badge', getBadgeInfo(item.type, item).class]">
2268
+ {{ getBadgeInfo(item.type, item).label }}
2083
2269
  </span>
2084
2270
  <span
2085
2271
  v-if="getSubagentInfo(item)"
@@ -2170,7 +2356,7 @@
2170
2356
  </div>
2171
2357
  </div>
2172
2358
 
2173
- <!-- Regular content -->
2359
+ <!-- Regular content (unified format from server) -->
2174
2360
  <div v-else-if="item.data?.message || item.data?.text || item.data?.content || item.data?.transformedContent">
2175
2361
  <div
2176
2362
  class="event-content"
@@ -2197,6 +2383,12 @@
2197
2383
  </div>
2198
2384
  </div>
2199
2385
 
2386
+ <!-- No content at all (no message and no tools) -->
2387
+ <div v-else-if="!hasTools(item)" class="event-content" style="color: #7d8590; font-style: italic;">
2388
+ No available message
2389
+ </div>
2390
+
2391
+ <!-- Tool calls section (independent of message content, but don't need "No available message" if tools exist) -->
2200
2392
  <div v-if="hasTools(item)" class="tool-list">
2201
2393
  <div
2202
2394
  v-for="(group, idx) in getToolGroups(item)"
@@ -2250,8 +2442,23 @@
2250
2442
  </div>
2251
2443
  `
2252
2444
  });
2253
-
2254
- app.mount('#app');
2445
+
2446
+ // Mount the app
2447
+ console.log('Mounting Vue app to #app...');
2448
+ console.log('App config:', app.config);
2449
+ console.log('Target element:', document.getElementById('app'));
2450
+ try {
2451
+ const vm = app.mount('#app');
2452
+ console.log('Vue app mounted successfully!', vm ? 'Instance created' : 'No instance');
2453
+ console.log('VM type:', typeof vm, 'Has exportSession:', typeof vm?.exportSession);
2454
+ console.log('VM keys:', vm ? Object.keys(vm).slice(0, 10) : 'NO_VM');
2455
+ console.log('#app innerHTML length:', document.getElementById('app').innerHTML.length);
2456
+ console.log('#app first 100 chars:', document.getElementById('app').innerHTML.substring(0, 100));
2457
+ } catch (error) {
2458
+ console.error('Mount failed:', error);
2459
+ console.error('Error stack:', error.stack);
2460
+ }
2461
+ })(); // End IIFE
2255
2462
  </script>
2256
2463
  </body>
2257
2464
  </html>