claude-opencode-viewer 2.6.38 → 2.6.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/index-pc.html +133 -47
  2. package/index.html +287 -84
  3. package/package.json +1 -1
  4. package/server.js +30 -38
package/index-pc.html CHANGED
@@ -1007,6 +1007,7 @@
1007
1007
 
1008
1008
  <div id="copy-toast">已复制</div>
1009
1009
  <script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
1010
+ <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-webgl@0.18.0/lib/addon-webgl.min.js"></script>
1010
1011
  <script>
1011
1012
  (function() {
1012
1013
  var isMobile = /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
@@ -1036,6 +1037,17 @@
1036
1037
 
1037
1038
  term.open(document.getElementById('terminal'));
1038
1039
 
1040
+ // WebGL 渲染器:GPU 加速绘制
1041
+ if (window.WebglAddon) {
1042
+ try {
1043
+ var webglAddon = new WebglAddon.WebglAddon();
1044
+ webglAddon.onContextLoss(function() {
1045
+ webglAddon.dispose();
1046
+ });
1047
+ term.loadAddon(webglAddon);
1048
+ } catch(e) {}
1049
+ }
1050
+
1039
1051
  // PC端复制:用 xterm.js selection API 获取纯文本,避免复制出乱码
1040
1052
  document.getElementById('terminal').addEventListener('copy', function(e) {
1041
1053
  var sel = term.getSelection();
@@ -1585,7 +1597,8 @@
1585
1597
 
1586
1598
  ws.onclose = function() {
1587
1599
  ws = null;
1588
- term.reset(); // 清除终端状态,避免 buffer 回放叠加导致状态混乱
1600
+ term.reset();
1601
+ term.write('\r\n \x1b[33m连接断开,正在重连...\x1b[0m\r\n');
1589
1602
  setTimeout(connect, 2000);
1590
1603
  };
1591
1604
 
@@ -1593,7 +1606,7 @@
1593
1606
  try {
1594
1607
  var msg = JSON.parse(e.data);
1595
1608
  if (msg.type === 'data') {
1596
- if (!isCreatingNewSession) {
1609
+ if (!isCreatingNewSession && !isTransitioning) {
1597
1610
  throttledWrite(msg.data);
1598
1611
  }
1599
1612
  }
@@ -1604,14 +1617,19 @@
1604
1617
  }
1605
1618
  }
1606
1619
  else if (msg.type === 'mode') {
1620
+ if (writeTimer) { cancelAnimationFrame(writeTimer); writeTimer = null; }
1621
+ writeBuffer = '';
1622
+ term.reset();
1607
1623
  endTransition(msg.mode);
1608
- // 模式切换完成后,重新绑定触摸事件
1624
+ if (msg.buffer) {
1625
+ term.write(msg.buffer);
1626
+ }
1609
1627
  rebindTouchScroll();
1610
1628
  }
1611
1629
  else if (msg.type === 'switching') {
1612
- // 服务端开始切换,完全重置终端(清除残留输出和状态)
1613
- term.reset();
1630
+ if (writeTimer) { cancelAnimationFrame(writeTimer); writeTimer = null; }
1614
1631
  writeBuffer = '';
1632
+ term.clear();
1615
1633
  }
1616
1634
  else if (msg.type === 'state') {
1617
1635
  if (msg.mode) {
@@ -1626,19 +1644,28 @@
1626
1644
  }
1627
1645
  }
1628
1646
  else if (msg.type === 'restored') {
1629
- // 会话恢复成功,重置终端清除残留ANSI状态
1647
+ if (writeTimer) { cancelAnimationFrame(writeTimer); writeTimer = null; }
1648
+ writeBuffer = '';
1630
1649
  term.reset();
1650
+ if (msg.buffer) {
1651
+ term.write(msg.buffer);
1652
+ }
1631
1653
  }
1632
1654
  else if (msg.type === 'restore-error') {
1633
- // 恢复失败
1634
1655
  term.write('恢复失败: ' + msg.error + '\r\n');
1635
1656
  }
1636
1657
  else if (msg.type === 'started') {
1637
1658
  rebindTouchScroll();
1659
+ preloadData();
1638
1660
  }
1639
1661
  else if (msg.type === 'new-session-ok') {
1640
- isCreatingNewSession = false;
1662
+ if (writeTimer) { cancelAnimationFrame(writeTimer); writeTimer = null; }
1663
+ writeBuffer = '';
1641
1664
  term.reset();
1665
+ if (msg.buffer) {
1666
+ term.write(msg.buffer);
1667
+ }
1668
+ isCreatingNewSession = false;
1642
1669
  }
1643
1670
  else if (msg.type === 'new-session-error') {
1644
1671
  isCreatingNewSession = false;
@@ -1664,11 +1691,14 @@
1664
1691
  });
1665
1692
  }
1666
1693
 
1667
- // 页面卸载前保存输入缓存
1694
+ // 页面卸载前保存输入缓存,并通知服务端退出
1668
1695
  window.addEventListener('beforeunload', function() {
1669
1696
  if (currentInputBuffer) {
1670
1697
  saveInputCache();
1671
1698
  }
1699
+ if (ws && ws.readyState === WebSocket.OPEN) {
1700
+ ws.send(JSON.stringify({ type: 'quit' }));
1701
+ }
1672
1702
  });
1673
1703
 
1674
1704
  // 页面可见性变化时保存缓存
@@ -2183,6 +2213,29 @@
2183
2213
  var diffChanges = [];
2184
2214
  var diffSelectedFile = null;
2185
2215
 
2216
+ // 预加载缓存
2217
+ var cachedGitStatus = null;
2218
+ var cachedDocs = null;
2219
+ var gitStatusLoading = false;
2220
+ var docsLoading = false;
2221
+
2222
+ function preloadData() {
2223
+ if (!gitStatusLoading) {
2224
+ gitStatusLoading = true;
2225
+ fetch(basePath + '/api/git-status')
2226
+ .then(function(r) { return r.json(); })
2227
+ .then(function(data) { cachedGitStatus = data; gitStatusLoading = false; })
2228
+ .catch(function() { gitStatusLoading = false; });
2229
+ }
2230
+ if (!docsLoading) {
2231
+ docsLoading = true;
2232
+ fetch(basePath + '/api/docs')
2233
+ .then(function(r) { return r.json(); })
2234
+ .then(function(data) { cachedDocs = data; docsLoading = false; })
2235
+ .catch(function() { docsLoading = false; });
2236
+ }
2237
+ }
2238
+
2186
2239
  var STATUS_COLORS = {
2187
2240
  'M': '#e2c08d', 'A': '#73c991', 'D': '#f14c4c',
2188
2241
  'R': '#73c991', 'C': '#73c991', 'U': '#e2c08d',
@@ -2194,27 +2247,42 @@
2194
2247
  var bar = document.getElementById('git-diff-bar');
2195
2248
  if (diffBarVisible) {
2196
2249
  bar.classList.add('visible');
2197
- loadGitStatus();
2250
+ loadGitStatus(false);
2198
2251
  } else {
2199
2252
  bar.classList.remove('visible');
2200
2253
  diffSelectedFile = null;
2201
2254
  }
2202
2255
  }
2203
2256
 
2204
- function loadGitStatus() {
2257
+ function loadGitStatus(forceRefresh) {
2205
2258
  var fileList = document.getElementById('git-diff-file-list');
2206
- fileList.innerHTML = '<div class="git-diff-loading">加载中...</div>';
2207
- document.getElementById('git-diff-count').textContent = '0';
2259
+
2260
+ if (cachedGitStatus && !forceRefresh) {
2261
+ diffChanges = cachedGitStatus.changes || [];
2262
+ document.getElementById('git-diff-count').textContent = diffChanges.length;
2263
+ renderDiffFileList();
2264
+ return;
2265
+ }
2266
+
2267
+ fileList.innerHTML = '<div class="git-diff-loading">' + (forceRefresh ? '正在刷新...' : '正在查询 git status...') + '</div>';
2268
+ document.getElementById('git-diff-count').textContent = '...';
2269
+
2270
+ if (gitStatusLoading && !forceRefresh) return;
2271
+ gitStatusLoading = true;
2272
+ if (forceRefresh) cachedGitStatus = null;
2208
2273
 
2209
2274
  fetch(basePath + '/api/git-status')
2210
2275
  .then(function(r) { return r.json(); })
2211
2276
  .then(function(data) {
2277
+ cachedGitStatus = data;
2278
+ gitStatusLoading = false;
2212
2279
  diffChanges = data.changes || [];
2213
2280
  document.getElementById('git-diff-count').textContent = diffChanges.length;
2214
- renderDiffFileList();
2281
+ if (diffBarVisible) renderDiffFileList();
2215
2282
  })
2216
2283
  .catch(function() {
2217
- fileList.innerHTML = '<div class="git-diff-error">无法加载 git status</div>';
2284
+ gitStatusLoading = false;
2285
+ if (diffBarVisible) fileList.innerHTML = '<div class="git-diff-error">无法加载 git status</div>';
2218
2286
  });
2219
2287
  }
2220
2288
 
@@ -2343,7 +2411,7 @@
2343
2411
  document.getElementById('close-diff').addEventListener('click', toggleDiffBar);
2344
2412
  document.getElementById('refresh-diff').addEventListener('click', function(e) {
2345
2413
  e.stopPropagation();
2346
- loadGitStatus();
2414
+ loadGitStatus(true);
2347
2415
  // 重置 diff 内容区
2348
2416
  diffSelectedFile = null;
2349
2417
  document.getElementById('git-diff-content-area').innerHTML =
@@ -2365,49 +2433,67 @@
2365
2433
  var bar = document.getElementById('docs-bar');
2366
2434
  if (docsBarVisible) {
2367
2435
  bar.classList.add('visible');
2368
- loadDocs();
2436
+ loadDocs(false);
2369
2437
  } else {
2370
2438
  bar.classList.remove('visible');
2371
2439
  docsSelectedFile = null;
2372
2440
  }
2373
2441
  }
2374
2442
 
2375
- function loadDocs() {
2443
+ function renderDocsList(data) {
2444
+ var fileList = document.getElementById('docs-file-list');
2445
+ var docs = data.docs || [];
2446
+ document.getElementById('docs-count').textContent = docs.length;
2447
+ if (!docs.length) {
2448
+ fileList.innerHTML = '<div class="docs-loading" style="color:#666;">无文档</div>';
2449
+ return;
2450
+ }
2451
+ var html = '<div style="padding:4px 12px;font-size:11px;color:#666;border-bottom:1px solid #2a2a2a;">📁 ' + escapeHtml(data.cwd || 'PROJECT_DIR') + '</div>';
2452
+ docs.forEach(function(doc) {
2453
+ var activeClass = docsSelectedFile === doc.name ? ' active' : '';
2454
+ var time = new Date(doc.mtime).toLocaleString('zh-CN', { month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit' });
2455
+ html += '<div class="docs-file-item' + activeClass + '" data-file="' + escapeHtml(doc.name) + '">';
2456
+ html += '<span class="docs-file-name">' + escapeHtml(doc.name) + '</span>';
2457
+ html += '<span class="docs-file-time">' + time + '</span>';
2458
+ html += '</div>';
2459
+ });
2460
+ fileList.innerHTML = html;
2461
+ fileList.querySelectorAll('.docs-file-item').forEach(function(item) {
2462
+ item.addEventListener('click', function() {
2463
+ var file = this.getAttribute('data-file');
2464
+ docsSelectedFile = file;
2465
+ fileList.querySelectorAll('.docs-file-item').forEach(function(el) { el.classList.remove('active'); });
2466
+ this.classList.add('active');
2467
+ loadDocContent(file);
2468
+ });
2469
+ });
2470
+ }
2471
+
2472
+ function loadDocs(forceRefresh) {
2376
2473
  var fileList = document.getElementById('docs-file-list');
2377
- fileList.innerHTML = '<div class="docs-loading">加载中...</div>';
2378
- document.getElementById('docs-count').textContent = '0';
2474
+
2475
+ if (cachedDocs && !forceRefresh) {
2476
+ renderDocsList(cachedDocs);
2477
+ return;
2478
+ }
2479
+
2480
+ fileList.innerHTML = '<div class="docs-loading">' + (forceRefresh ? '正在刷新...' : '正在查询文档...') + '</div>';
2481
+ document.getElementById('docs-count').textContent = '...';
2482
+
2483
+ if (docsLoading && !forceRefresh) return;
2484
+ docsLoading = true;
2485
+ if (forceRefresh) cachedDocs = null;
2379
2486
 
2380
2487
  fetch(basePath + '/api/docs')
2381
2488
  .then(function(r) { return r.json(); })
2382
2489
  .then(function(data) {
2383
- var docs = data.docs || [];
2384
- document.getElementById('docs-count').textContent = docs.length;
2385
- if (!docs.length) {
2386
- fileList.innerHTML = '<div class="docs-loading" style="color:#666;">无文档</div>';
2387
- return;
2388
- }
2389
- var html = '<div style="padding:4px 12px;font-size:11px;color:#666;border-bottom:1px solid #2a2a2a;">📁 ' + escapeHtml(data.cwd || 'PROJECT_DIR') + '</div>';
2390
- docs.forEach(function(doc) {
2391
- var activeClass = docsSelectedFile === doc.name ? ' active' : '';
2392
- var time = new Date(doc.mtime).toLocaleString('zh-CN', { month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit' });
2393
- html += '<div class="docs-file-item' + activeClass + '" data-file="' + escapeHtml(doc.name) + '">';
2394
- html += '<span class="docs-file-name">' + escapeHtml(doc.name) + '</span>';
2395
- html += '<span class="docs-file-time">' + time + '</span>';
2396
- html += '</div>';
2397
- });
2398
- fileList.innerHTML = html;
2399
- fileList.querySelectorAll('.docs-file-item').forEach(function(item) {
2400
- item.addEventListener('click', function() {
2401
- var file = this.getAttribute('data-file');
2402
- docsSelectedFile = file;
2403
- fileList.querySelectorAll('.docs-file-item').forEach(function(el) { el.classList.remove('active'); });
2404
- this.classList.add('active');
2405
- loadDocContent(file);
2406
- });
2407
- });
2490
+ cachedDocs = data;
2491
+ docsLoading = false;
2492
+ if (docsBarVisible) renderDocsList(data);
2408
2493
  })
2409
2494
  .catch(function() {
2410
- fileList.innerHTML = '<div class="docs-loading" style="color:#ff6b6b;">加载失败</div>';
2495
+ docsLoading = false;
2496
+ if (docsBarVisible) fileList.innerHTML = '<div class="docs-loading" style="color:#ff6b6b;">加载失败</div>';
2411
2497
  });
2412
2498
  }
2413
2499
 
@@ -2432,7 +2518,7 @@
2432
2518
  document.getElementById('close-docs').addEventListener('click', toggleDocsBar);
2433
2519
  document.getElementById('refresh-docs').addEventListener('click', function(e) {
2434
2520
  e.stopPropagation();
2435
- loadDocs();
2521
+ loadDocs(true);
2436
2522
  docsSelectedFile = null;
2437
2523
  document.getElementById('docs-content-area').innerHTML =
2438
2524
  '<div class="docs-placeholder">' +
package/index.html CHANGED
@@ -489,6 +489,18 @@
489
489
  -webkit-user-select: text;
490
490
  user-select: text;
491
491
  }
492
+ .msg-text pre {
493
+ background: #0d0d0d;
494
+ border: 1px solid #333;
495
+ border-radius: 4px;
496
+ padding: 8px;
497
+ overflow-x: auto;
498
+ font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
499
+ font-size: 12px;
500
+ line-height: 1.4;
501
+ white-space: pre;
502
+ -webkit-overflow-scrolling: touch;
503
+ }
492
504
  .msg-tool {
493
505
  margin-top: 6px;
494
506
  padding: 6px 8px;
@@ -1044,6 +1056,7 @@
1044
1056
 
1045
1057
  <div id="copy-toast">已复制</div>
1046
1058
  <script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
1059
+ <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-webgl@0.18.0/lib/addon-webgl.min.js"></script>
1047
1060
  <script>
1048
1061
  (function() {
1049
1062
  var isMobile = /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
@@ -1052,6 +1065,7 @@
1052
1065
  var fontSize = isMobile ? 11 : 13;
1053
1066
  var currentMode = 'claude';
1054
1067
  var isTransitioning = false;
1068
+ var mobileInitSent = false;
1055
1069
 
1056
1070
  var term = new Terminal({
1057
1071
  cursorBlink: !isMobile,
@@ -1071,6 +1085,19 @@
1071
1085
 
1072
1086
  term.open(document.getElementById('terminal'));
1073
1087
 
1088
+ // WebGL 渲染器:GPU 加速绘制,非 iOS 设备启用(iOS WebGL 性能差)
1089
+ if (!isIOS && window.WebglAddon) {
1090
+ try {
1091
+ var webglAddon = new WebglAddon.WebglAddon();
1092
+ webglAddon.onContextLoss(function() {
1093
+ webglAddon.dispose();
1094
+ });
1095
+ term.loadAddon(webglAddon);
1096
+ } catch(e) {
1097
+ // WebGL 不可用时回退到 Canvas 渲染
1098
+ }
1099
+ }
1100
+
1074
1101
  // PC端复制:用 xterm.js selection API 获取纯文本,避免复制出乱码
1075
1102
  document.getElementById('terminal').addEventListener('copy', function(e) {
1076
1103
  var sel = term.getSelection();
@@ -1132,6 +1159,8 @@
1132
1159
  // 会话历史相关
1133
1160
  var sessions = [];
1134
1161
  var currentSessionId = null;
1162
+ var claudeSessionId = null;
1163
+ var claudeProject = null;
1135
1164
  var currentSessionData = null;
1136
1165
  var historyBarVisible = false;
1137
1166
 
@@ -1258,15 +1287,10 @@
1258
1287
  }
1259
1288
  }
1260
1289
 
1261
- function stopMomentum() {
1262
- if (momentumRaf) { cancelAnimationFrame(momentumRaf); momentumRaf = null; }
1263
- if (scrollRaf) { cancelAnimationFrame(scrollRaf); scrollRaf = null; }
1264
- pendingDy = 0;
1265
- pixelAccum = 0;
1266
- }
1267
-
1268
- // 触摸滚动实现 - 混合模式:alternate buffer 发鼠标滚轮,normal buffer 用 scrollLines
1269
- var SCROLL_SENSITIVITY = 0.7; // 滚动灵敏度,越小每次滑动行数越少
1290
+ // 触摸滚动实现
1291
+ // Claude 模式:与 cc-viewer 一致,直接 scrollLines,1:1 像素映射,无 WheelEvent
1292
+ // OpenCode 模式:alternate buffer WheelEvent(vim/less 等需要),灵敏度 0.7
1293
+ var SCROLL_SENSITIVITY = 0.7;
1270
1294
  var touchScreen = null;
1271
1295
  var touchEventsBound = false;
1272
1296
 
@@ -1300,6 +1324,12 @@
1300
1324
 
1301
1325
  function doScroll(lines) {
1302
1326
  if (lines === 0) return;
1327
+ // Claude 模式:始终直接 scrollLines(与 cc-viewer 一致)
1328
+ if (currentMode === 'claude') {
1329
+ term.scrollLines(lines);
1330
+ return;
1331
+ }
1332
+ // OpenCode 模式:alternate buffer 发 WheelEvent,normal buffer 用 scrollLines
1303
1333
  if (isAlternateBuffer()) {
1304
1334
  altScrollAccum += lines;
1305
1335
  if (Math.abs(altScrollAccum) >= ALT_SCROLL_THRESHOLD) {
@@ -1335,7 +1365,8 @@
1335
1365
  scrollRaf = null;
1336
1366
  if (pendingDy === 0) return;
1337
1367
 
1338
- pixelAccum += pendingDy * SCROLL_SENSITIVITY;
1368
+ // Claude 模式 1:1 像素映射(cc-viewer 同款),OpenCode 降低灵敏度
1369
+ pixelAccum += pendingDy * (currentMode === 'claude' ? 1 : SCROLL_SENSITIVITY);
1339
1370
  pendingDy = 0;
1340
1371
 
1341
1372
  var lh = getLineHeight();
@@ -1372,22 +1403,19 @@
1372
1403
  }
1373
1404
 
1374
1405
  pendingDy += dy;
1375
- console.log('[scroll] touchmove dy:', dy, 'pendingDy:', pendingDy);
1376
-
1377
1406
  if (!scrollRaf) scrollRaf = requestAnimationFrame(flushScroll);
1378
1407
  lastY = y;
1379
1408
  lastTime = now;
1380
1409
  }
1381
1410
 
1382
1411
  function handleTouchEnd() {
1383
-
1384
1412
  if (scrollRaf) {
1385
1413
  cancelAnimationFrame(scrollRaf);
1386
1414
  scrollRaf = null;
1387
1415
  }
1388
1416
 
1389
1417
  if (pendingDy !== 0) {
1390
- pixelAccum += pendingDy * SCROLL_SENSITIVITY;
1418
+ pixelAccum += pendingDy * (currentMode === 'claude' ? 1 : SCROLL_SENSITIVITY);
1391
1419
  pendingDy = 0;
1392
1420
  var lh = getLineHeight();
1393
1421
  var lines = Math.trunc(pixelAccum / lh);
@@ -1412,9 +1440,8 @@
1412
1440
  }
1413
1441
  velocitySamples = [];
1414
1442
 
1415
- velocity *= SCROLL_SENSITIVITY;
1416
- console.log('[scroll] velocity:', velocity);
1417
- if (Math.abs(velocity) < 0.3) return;
1443
+ velocity *= (currentMode === 'claude' ? 1 : SCROLL_SENSITIVITY);
1444
+ if (Math.abs(velocity) < 0.5) return;
1418
1445
 
1419
1446
  var friction = 0.95;
1420
1447
  var mAccum = 0;
@@ -1443,7 +1470,6 @@
1443
1470
 
1444
1471
  function unbindTouchScroll() {
1445
1472
  if (touchScreen && touchEventsBound) {
1446
- console.log('[scroll] unbinding touch events');
1447
1473
  touchScreen.removeEventListener('touchstart', handleTouchStart);
1448
1474
  touchScreen.removeEventListener('touchmove', handleTouchMove);
1449
1475
  touchScreen.removeEventListener('touchend', handleTouchEnd);
@@ -1553,7 +1579,8 @@
1553
1579
 
1554
1580
  ws.onclose = function() {
1555
1581
  ws = null;
1556
- term.reset(); // 清除终端状态,避免 buffer 回放叠加导致状态混乱
1582
+ term.reset();
1583
+ term.write('\r\n \x1b[33m连接断开,正在重连...\x1b[0m\r\n');
1557
1584
  setTimeout(connect, 2000);
1558
1585
  };
1559
1586
 
@@ -1561,7 +1588,7 @@
1561
1588
  try {
1562
1589
  var msg = JSON.parse(e.data);
1563
1590
  if (msg.type === 'data') {
1564
- if (!isCreatingNewSession) {
1591
+ if (!isCreatingNewSession && !isTransitioning) {
1565
1592
  throttledWrite(msg.data);
1566
1593
  }
1567
1594
  }
@@ -1571,15 +1598,46 @@
1571
1598
  throttledWrite('按 Enter 键重新启动 ' + currentMode + '...\r\n');
1572
1599
  }
1573
1600
  }
1601
+ else if (msg.type === 'state') {
1602
+ // 服务端还没启动进程时,查最近会话并自动恢复
1603
+ if (!msg.running && !mobileInitSent) {
1604
+ mobileInitSent = true;
1605
+ term.write('\r\n \x1b[36m正在恢复会话...\x1b[0m\r\n');
1606
+ fetch('/api/last-sessions')
1607
+ .then(function(r) { return r.json(); })
1608
+ .then(function(data) {
1609
+ var oc = data.opencode;
1610
+ var cl = data.claude;
1611
+ // 取 mtime 最新的那个
1612
+ var useOc = oc && (!cl || oc.mtime > cl.mtime);
1613
+ var mode = useOc ? 'opencode' : 'claude';
1614
+ var sessionId = useOc ? (oc && oc.id) : (cl && cl.id);
1615
+ if (cl) {
1616
+ claudeSessionId = cl.id;
1617
+ claudeProject = cl.project;
1618
+ }
1619
+ currentMode = mode;
1620
+ ws.send(JSON.stringify({ type: 'init', mode: mode, sessionId: sessionId || null }));
1621
+ })
1622
+ .catch(function() {
1623
+ ws.send(JSON.stringify({ type: 'init', mode: 'claude' }));
1624
+ });
1625
+ }
1626
+ }
1574
1627
  else if (msg.type === 'mode') {
1628
+ if (writeTimer) { cancelAnimationFrame(writeTimer); writeTimer = null; }
1629
+ writeBuffer = '';
1630
+ term.reset();
1575
1631
  endTransition(msg.mode);
1576
- // 模式切换完成后,重新绑定触摸事件
1632
+ if (msg.buffer) {
1633
+ term.write(msg.buffer);
1634
+ }
1577
1635
  rebindTouchScroll();
1578
1636
  }
1579
1637
  else if (msg.type === 'switching') {
1580
- // 服务端开始切换,完全重置终端(清除残留输出和状态)
1581
- term.reset();
1638
+ if (writeTimer) { cancelAnimationFrame(writeTimer); writeTimer = null; }
1582
1639
  writeBuffer = '';
1640
+ term.clear();
1583
1641
  }
1584
1642
  else if (msg.type === 'state') {
1585
1643
  if (msg.mode) {
@@ -1589,22 +1647,36 @@
1589
1647
  }
1590
1648
  }
1591
1649
  else if (msg.type === 'restored') {
1592
- // 会话恢复成功,重置终端清除残留ANSI状态
1650
+ // 会话恢复成功,清除所有残留
1651
+ if (writeTimer) { cancelAnimationFrame(writeTimer); writeTimer = null; }
1652
+ writeBuffer = '';
1593
1653
  term.reset();
1654
+ if (msg.buffer) {
1655
+ term.write(msg.buffer);
1656
+ }
1657
+ isTransitioning = false;
1594
1658
  }
1595
1659
  else if (msg.type === 'restore-error') {
1596
- // 恢复失败
1660
+ isTransitioning = false;
1597
1661
  term.write('恢复失败: ' + msg.error + '\r\n');
1598
1662
  }
1599
1663
  else if (msg.type === 'started') {
1600
1664
  rebindTouchScroll();
1665
+ preloadData();
1601
1666
  }
1602
1667
  else if (msg.type === 'new-session-ok') {
1603
- isCreatingNewSession = false;
1668
+ if (writeTimer) { cancelAnimationFrame(writeTimer); writeTimer = null; }
1669
+ writeBuffer = '';
1604
1670
  term.reset();
1671
+ if (msg.buffer) {
1672
+ term.write(msg.buffer);
1673
+ }
1674
+ isCreatingNewSession = false;
1675
+ isTransitioning = false;
1605
1676
  }
1606
1677
  else if (msg.type === 'new-session-error') {
1607
1678
  isCreatingNewSession = false;
1679
+ isTransitioning = false;
1608
1680
  term.write('新会话启动失败: ' + msg.error + '\r\n');
1609
1681
  }
1610
1682
  } catch(err) {}
@@ -1923,11 +1995,15 @@
1923
1995
  // 关闭历史栏
1924
1996
  toggleHistoryBar();
1925
1997
 
1926
- // 重置终端(清除残留ANSI解析状态)
1998
+ // 阻止旧数据写入
1999
+ isTransitioning = true;
2000
+ if (writeTimer) { cancelAnimationFrame(writeTimer); writeTimer = null; }
2001
+ writeBuffer = '';
1927
2002
  term.reset();
1928
2003
 
1929
2004
  if (ws && ws.readyState === 1) {
1930
2005
  if (currentMode !== 'opencode') {
2006
+ isTransitioning = false;
1931
2007
  term.write('错误: 请先切换到 OpenCode 模式\r\n');
1932
2008
  return;
1933
2009
  }
@@ -2022,7 +2098,7 @@
2022
2098
  item.appendChild(deleteBtn);
2023
2099
 
2024
2100
  item.addEventListener('click', function() {
2025
- restoreClaudeSession(s.id);
2101
+ restoreClaudeSession(s.id, s.project);
2026
2102
  });
2027
2103
 
2028
2104
  sessionList.appendChild(item);
@@ -2113,12 +2189,18 @@
2113
2189
  });
2114
2190
  }
2115
2191
 
2116
- function restoreClaudeSession(sessionId) {
2192
+ function restoreClaudeSession(sessionId, project) {
2117
2193
  console.log('[restore] 恢复 Claude 会话:', sessionId);
2194
+ claudeSessionId = sessionId;
2195
+ if (project) claudeProject = project;
2118
2196
  toggleHistoryBar();
2197
+ isTransitioning = true;
2198
+ if (writeTimer) { cancelAnimationFrame(writeTimer); writeTimer = null; }
2199
+ writeBuffer = '';
2119
2200
  term.reset();
2120
2201
  if (ws && ws.readyState === 1) {
2121
2202
  if (currentMode !== 'claude') {
2203
+ isTransitioning = false;
2122
2204
  term.write('错误: 请先切换到 Claude 模式\r\n');
2123
2205
  return;
2124
2206
  }
@@ -2140,9 +2222,12 @@
2140
2222
  // 新会话按钮
2141
2223
  var isCreatingNewSession = false;
2142
2224
  document.getElementById('new-session-btn').addEventListener('click', function() {
2143
- if (!ws || ws.readyState !== 1) return;
2225
+ if (!ws || ws.readyState !== 1 || isTransitioning) return;
2144
2226
  isCreatingNewSession = true;
2145
- term.clear();
2227
+ isTransitioning = true;
2228
+ if (writeTimer) { cancelAnimationFrame(writeTimer); writeTimer = null; }
2229
+ writeBuffer = '';
2230
+ term.reset();
2146
2231
  ws.send(JSON.stringify({ type: 'new-session' }));
2147
2232
  });
2148
2233
 
@@ -2227,23 +2312,81 @@
2227
2312
  msgViewerContent.innerHTML = '<div class="msg-empty">加载中...</div>';
2228
2313
  unbindTouchScroll();
2229
2314
 
2230
- // 获取当前会话 ID
2231
- fetch(basePath + '/api/current-session')
2232
- .then(function(r) { return r.json(); })
2233
- .then(function(data) {
2234
- if (!data.sessionId) {
2235
- msgViewerContent.innerHTML = '<div class="msg-empty">暂无活跃会话</div>';
2236
- return;
2237
- }
2238
- return fetch(basePath + '/api/session/' + data.sessionId)
2315
+ if (currentMode === 'claude') {
2316
+ // Claude 模式:从 JSONL 文件读取消息
2317
+ var loadClaudeMessages = function(sid, proj) {
2318
+ fetch(basePath + '/api/claude-session-messages?id=' + encodeURIComponent(sid) + '&project=' + encodeURIComponent(proj))
2239
2319
  .then(function(r) { return r.json(); })
2240
- .then(function(messages) {
2320
+ .then(function(data) {
2321
+ if (data.error) {
2322
+ msgViewerContent.innerHTML = '<div class="msg-empty">' + escapeHtml(data.error) + '</div>';
2323
+ return;
2324
+ }
2325
+ var messages = data.messages || [];
2241
2326
  renderMessages(messages);
2327
+ })
2328
+ .catch(function(e) {
2329
+ msgViewerContent.innerHTML = '<div class="msg-empty">加载失败: ' + e.message + '</div>';
2242
2330
  });
2243
- })
2244
- .catch(function(e) {
2245
- msgViewerContent.innerHTML = '<div class="msg-empty">加载失败: ' + e.message + '</div>';
2246
- });
2331
+ };
2332
+ if (claudeSessionId && claudeProject) {
2333
+ loadClaudeMessages(claudeSessionId, claudeProject);
2334
+ } else {
2335
+ // 尚未获取到 session 信息,先查
2336
+ fetch(basePath + '/api/last-sessions')
2337
+ .then(function(r) { return r.json(); })
2338
+ .then(function(data) {
2339
+ var cl = data.claude;
2340
+ if (cl && cl.id && cl.project) {
2341
+ claudeSessionId = cl.id;
2342
+ claudeProject = cl.project;
2343
+ loadClaudeMessages(cl.id, cl.project);
2344
+ } else {
2345
+ msgViewerContent.innerHTML = '<div class="msg-empty">暂无活跃会话</div>';
2346
+ }
2347
+ })
2348
+ .catch(function(e) {
2349
+ msgViewerContent.innerHTML = '<div class="msg-empty">加载失败: ' + e.message + '</div>';
2350
+ });
2351
+ }
2352
+ } else {
2353
+ // OpenCode 模式:从 SQLite 读取消息
2354
+ fetch(basePath + '/api/current-session')
2355
+ .then(function(r) { return r.json(); })
2356
+ .then(function(data) {
2357
+ if (!data.sessionId) {
2358
+ msgViewerContent.innerHTML = '<div class="msg-empty">暂无活跃会话</div>';
2359
+ return;
2360
+ }
2361
+ return fetch(basePath + '/api/session/' + data.sessionId)
2362
+ .then(function(r) { return r.json(); })
2363
+ .then(function(messages) {
2364
+ renderMessages(messages);
2365
+ });
2366
+ })
2367
+ .catch(function(e) {
2368
+ msgViewerContent.innerHTML = '<div class="msg-empty">加载失败: ' + e.message + '</div>';
2369
+ });
2370
+ }
2371
+ }
2372
+
2373
+ function formatMsgText(text) {
2374
+ // 将 markdown 代码块转为 <pre>,其余部分转义
2375
+ var parts = text.split(/(```[\s\S]*?```)/g);
2376
+ var result = '';
2377
+ for (var i = 0; i < parts.length; i++) {
2378
+ var p = parts[i];
2379
+ if (p.startsWith('```') && p.endsWith('```')) {
2380
+ // 去掉首尾 ```(可能带语言标记)
2381
+ var inner = p.slice(3, -3);
2382
+ var nlIdx = inner.indexOf('\n');
2383
+ if (nlIdx !== -1) inner = inner.slice(nlIdx + 1);
2384
+ result += '<pre>' + escapeHtml(inner) + '</pre>';
2385
+ } else {
2386
+ result += escapeHtml(p);
2387
+ }
2388
+ }
2389
+ return result;
2247
2390
  }
2248
2391
 
2249
2392
  function renderMessages(messages) {
@@ -2259,7 +2402,7 @@
2259
2402
  html += '<div class="msg-item ' + cls + '">';
2260
2403
  html += '<div class="msg-role">' + roleLabel + '</div>';
2261
2404
  if (msg.text) {
2262
- html += '<div class="msg-text">' + escapeHtml(msg.text) + '</div>';
2405
+ html += '<div class="msg-text">' + formatMsgText(msg.text) + '</div>';
2263
2406
  }
2264
2407
  if (msg.toolCalls && msg.toolCalls.length > 0) {
2265
2408
  msg.toolCalls.forEach(function(tc) {
@@ -2269,6 +2412,7 @@
2269
2412
  html += '</div>';
2270
2413
  });
2271
2414
  msgViewerContent.innerHTML = html;
2415
+ msgViewerContent.scrollTop = msgViewerContent.scrollHeight;
2272
2416
  }
2273
2417
 
2274
2418
  function escapeHtml(str) {
@@ -2302,6 +2446,29 @@
2302
2446
  var diffChanges = [];
2303
2447
  var diffSelectedFile = null;
2304
2448
 
2449
+ // 预加载缓存
2450
+ var cachedGitStatus = null;
2451
+ var cachedDocs = null;
2452
+ var gitStatusLoading = false;
2453
+ var docsLoading = false;
2454
+
2455
+ function preloadData() {
2456
+ if (!gitStatusLoading) {
2457
+ gitStatusLoading = true;
2458
+ fetch(basePath + '/api/git-status')
2459
+ .then(function(r) { return r.json(); })
2460
+ .then(function(data) { cachedGitStatus = data; gitStatusLoading = false; })
2461
+ .catch(function() { gitStatusLoading = false; });
2462
+ }
2463
+ if (!docsLoading) {
2464
+ docsLoading = true;
2465
+ fetch(basePath + '/api/docs')
2466
+ .then(function(r) { return r.json(); })
2467
+ .then(function(data) { cachedDocs = data; docsLoading = false; })
2468
+ .catch(function() { docsLoading = false; });
2469
+ }
2470
+ }
2471
+
2305
2472
  var STATUS_COLORS = {
2306
2473
  'M': '#e2c08d', 'A': '#73c991', 'D': '#f14c4c',
2307
2474
  'R': '#73c991', 'C': '#73c991', 'U': '#e2c08d',
@@ -2313,27 +2480,44 @@
2313
2480
  var bar = document.getElementById('git-diff-bar');
2314
2481
  if (diffBarVisible) {
2315
2482
  bar.classList.add('visible');
2316
- loadGitStatus();
2483
+ loadGitStatus(false);
2317
2484
  } else {
2318
2485
  bar.classList.remove('visible');
2319
2486
  diffSelectedFile = null;
2320
2487
  }
2321
2488
  }
2322
2489
 
2323
- function loadGitStatus() {
2490
+ function loadGitStatus(forceRefresh) {
2324
2491
  var fileList = document.getElementById('git-diff-file-list');
2325
- fileList.innerHTML = '<div class="git-diff-loading">加载中...</div>';
2326
- document.getElementById('git-diff-count').textContent = '0';
2492
+
2493
+ // 有缓存且非强制刷新,直接用缓存
2494
+ if (cachedGitStatus && !forceRefresh) {
2495
+ diffChanges = cachedGitStatus.changes || [];
2496
+ document.getElementById('git-diff-count').textContent = diffChanges.length;
2497
+ renderDiffFileList();
2498
+ return;
2499
+ }
2500
+
2501
+ // 正在加载中或需要发起请求
2502
+ fileList.innerHTML = '<div class="git-diff-loading">' + (forceRefresh ? '正在刷新...' : '正在查询 git status...') + '</div>';
2503
+ document.getElementById('git-diff-count').textContent = '...';
2504
+
2505
+ if (gitStatusLoading && !forceRefresh) return; // 预加载进行中,等它完成
2506
+ gitStatusLoading = true;
2507
+ if (forceRefresh) cachedGitStatus = null;
2327
2508
 
2328
2509
  fetch(basePath + '/api/git-status')
2329
2510
  .then(function(r) { return r.json(); })
2330
2511
  .then(function(data) {
2512
+ cachedGitStatus = data;
2513
+ gitStatusLoading = false;
2331
2514
  diffChanges = data.changes || [];
2332
2515
  document.getElementById('git-diff-count').textContent = diffChanges.length;
2333
- renderDiffFileList();
2516
+ if (diffBarVisible) renderDiffFileList();
2334
2517
  })
2335
2518
  .catch(function() {
2336
- fileList.innerHTML = '<div class="git-diff-error">无法加载 git status</div>';
2519
+ gitStatusLoading = false;
2520
+ if (diffBarVisible) fileList.innerHTML = '<div class="git-diff-error">无法加载 git status</div>';
2337
2521
  });
2338
2522
  }
2339
2523
 
@@ -2462,7 +2646,7 @@
2462
2646
  document.getElementById('close-diff').addEventListener('click', toggleDiffBar);
2463
2647
  document.getElementById('refresh-diff').addEventListener('click', function(e) {
2464
2648
  e.stopPropagation();
2465
- loadGitStatus();
2649
+ loadGitStatus(true);
2466
2650
  // 重置 diff 内容区
2467
2651
  diffSelectedFile = null;
2468
2652
  document.getElementById('git-diff-content-area').innerHTML =
@@ -2484,49 +2668,68 @@
2484
2668
  var bar = document.getElementById('docs-bar');
2485
2669
  if (docsBarVisible) {
2486
2670
  bar.classList.add('visible');
2487
- loadDocs();
2671
+ loadDocs(false);
2488
2672
  } else {
2489
2673
  bar.classList.remove('visible');
2490
2674
  docsSelectedFile = null;
2491
2675
  }
2492
2676
  }
2493
2677
 
2494
- function loadDocs() {
2678
+ function renderDocsList(data) {
2679
+ var fileList = document.getElementById('docs-file-list');
2680
+ var docs = data.docs || [];
2681
+ document.getElementById('docs-count').textContent = docs.length;
2682
+ if (!docs.length) {
2683
+ fileList.innerHTML = '<div class="docs-loading" style="color:#666;">无文档</div>';
2684
+ return;
2685
+ }
2686
+ var html = '<div style="padding:4px 12px;font-size:11px;color:#666;border-bottom:1px solid #2a2a2a;">📁 ' + escapeHtml(data.cwd || 'PROJECT_DIR') + '</div>';
2687
+ docs.forEach(function(doc) {
2688
+ var activeClass = docsSelectedFile === doc.name ? ' active' : '';
2689
+ var time = new Date(doc.mtime).toLocaleString('zh-CN', { month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit' });
2690
+ html += '<div class="docs-file-item' + activeClass + '" data-file="' + escapeHtml(doc.name) + '">';
2691
+ html += '<span class="docs-file-name">' + escapeHtml(doc.name) + '</span>';
2692
+ html += '<span class="docs-file-time">' + time + '</span>';
2693
+ html += '</div>';
2694
+ });
2695
+ fileList.innerHTML = html;
2696
+ fileList.querySelectorAll('.docs-file-item').forEach(function(item) {
2697
+ item.addEventListener('click', function() {
2698
+ var file = this.getAttribute('data-file');
2699
+ docsSelectedFile = file;
2700
+ fileList.querySelectorAll('.docs-file-item').forEach(function(el) { el.classList.remove('active'); });
2701
+ this.classList.add('active');
2702
+ loadDocContent(file);
2703
+ });
2704
+ });
2705
+ }
2706
+
2707
+ function loadDocs(forceRefresh) {
2495
2708
  var fileList = document.getElementById('docs-file-list');
2496
- fileList.innerHTML = '<div class="docs-loading">加载中...</div>';
2497
- document.getElementById('docs-count').textContent = '0';
2709
+
2710
+ // 有缓存且非强制刷新,直接用缓存
2711
+ if (cachedDocs && !forceRefresh) {
2712
+ renderDocsList(cachedDocs);
2713
+ return;
2714
+ }
2715
+
2716
+ fileList.innerHTML = '<div class="docs-loading">' + (forceRefresh ? '正在刷新...' : '正在查询文档...') + '</div>';
2717
+ document.getElementById('docs-count').textContent = '...';
2718
+
2719
+ if (docsLoading && !forceRefresh) return;
2720
+ docsLoading = true;
2721
+ if (forceRefresh) cachedDocs = null;
2498
2722
 
2499
2723
  fetch(basePath + '/api/docs')
2500
2724
  .then(function(r) { return r.json(); })
2501
2725
  .then(function(data) {
2502
- var docs = data.docs || [];
2503
- document.getElementById('docs-count').textContent = docs.length;
2504
- if (!docs.length) {
2505
- fileList.innerHTML = '<div class="docs-loading" style="color:#666;">无文档</div>';
2506
- return;
2507
- }
2508
- var html = '<div style="padding:4px 12px;font-size:11px;color:#666;border-bottom:1px solid #2a2a2a;">📁 ' + escapeHtml(data.cwd || 'PROJECT_DIR') + '</div>';
2509
- docs.forEach(function(doc) {
2510
- var activeClass = docsSelectedFile === doc.name ? ' active' : '';
2511
- var time = new Date(doc.mtime).toLocaleString('zh-CN', { month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit' });
2512
- html += '<div class="docs-file-item' + activeClass + '" data-file="' + escapeHtml(doc.name) + '">';
2513
- html += '<span class="docs-file-name">' + escapeHtml(doc.name) + '</span>';
2514
- html += '<span class="docs-file-time">' + time + '</span>';
2515
- html += '</div>';
2516
- });
2517
- fileList.innerHTML = html;
2518
- fileList.querySelectorAll('.docs-file-item').forEach(function(item) {
2519
- item.addEventListener('click', function() {
2520
- var file = this.getAttribute('data-file');
2521
- docsSelectedFile = file;
2522
- fileList.querySelectorAll('.docs-file-item').forEach(function(el) { el.classList.remove('active'); });
2523
- this.classList.add('active');
2524
- loadDocContent(file);
2525
- });
2526
- });
2726
+ cachedDocs = data;
2727
+ docsLoading = false;
2728
+ if (docsBarVisible) renderDocsList(data);
2527
2729
  })
2528
2730
  .catch(function() {
2529
- fileList.innerHTML = '<div class="docs-loading" style="color:#ff6b6b;">加载失败</div>';
2731
+ docsLoading = false;
2732
+ if (docsBarVisible) fileList.innerHTML = '<div class="docs-loading" style="color:#ff6b6b;">加载失败</div>';
2530
2733
  });
2531
2734
  }
2532
2735
 
@@ -2551,7 +2754,7 @@
2551
2754
  document.getElementById('close-docs').addEventListener('click', toggleDocsBar);
2552
2755
  document.getElementById('refresh-docs').addEventListener('click', function(e) {
2553
2756
  e.stopPropagation();
2554
- loadDocs();
2757
+ loadDocs(true);
2555
2758
  docsSelectedFile = null;
2556
2759
  document.getElementById('docs-content-area').innerHTML =
2557
2760
  '<div class="docs-placeholder">' +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-opencode-viewer",
3
- "version": "2.6.38",
3
+ "version": "2.6.39",
4
4
  "description": "A unified terminal viewer for Claude Code and OpenCode with seamless switching",
5
5
  "type": "module",
6
6
  "main": "server.js",
package/server.js CHANGED
@@ -404,7 +404,7 @@ async function switchMode(newMode) {
404
404
  } catch (e) {
405
405
  LOG('[switchMode] 启动新进程失败:', e.message);
406
406
  }
407
- isSwitching = false;
407
+ // 注意:isSwitching 由调用方在回放 buffer 后设为 false
408
408
  }
409
409
 
410
410
  function writeToPty(data) {
@@ -1042,28 +1042,7 @@ const requestHandler = async (req, res) => {
1042
1042
  const httpsOpts = USE_HTTPS ? await getOrCreateCert() : null;
1043
1043
  let server, wss;
1044
1044
 
1045
- // 无客户端连接后自动退出,防止进程堆积
1046
- // 首次启动等 3 分钟(给用户时间打开浏览器),连接过之后断开等 30 秒
1047
- let noClientTimer = null;
1048
1045
  let hasEverConnected = false;
1049
- function startNoClientTimer() {
1050
- if (noClientTimer) return;
1051
- if (wss && wss.clients.size > 0) return;
1052
- const timeout = hasEverConnected ? 10000 : 180000;
1053
- noClientTimer = setTimeout(() => {
1054
- if (!wss || wss.clients.size === 0) {
1055
- LOG(`[auto-exit] ${timeout / 1000}秒无客户端连接,自动退出`);
1056
- cleanupAndExit();
1057
- }
1058
- noClientTimer = null;
1059
- }, timeout);
1060
- }
1061
- function cancelNoClientTimer() {
1062
- if (noClientTimer) {
1063
- clearTimeout(noClientTimer);
1064
- noClientTimer = null;
1065
- }
1066
- }
1067
1046
 
1068
1047
  function createServerAndWss() {
1069
1048
  server = USE_HTTPS
@@ -1087,7 +1066,6 @@ function setupWss(wssInst) {
1087
1066
  wssInst.on('connection', (ws, req) => {
1088
1067
  LOG('[WS] 客户端连接 from', req.socket.remoteAddress);
1089
1068
  hasEverConnected = true;
1090
- cancelNoClientTimer();
1091
1069
  ws.isAlive = true;
1092
1070
  ws.on('pong', () => { ws.isAlive = true; });
1093
1071
 
@@ -1178,16 +1156,14 @@ wssInst.on('connection', (ws, req) => {
1178
1156
  }
1179
1157
  } else if (msg.type === 'switch') {
1180
1158
  if (msg.mode !== currentMode) {
1159
+ isSwitching = true;
1181
1160
  ws.send(JSON.stringify({ type: 'switching', mode: msg.mode }));
1182
1161
  await switchMode(msg.mode);
1183
- // 切换完成后:先发 reset 清除残留,再发新进程的 buffer
1184
- ws.send(JSON.stringify({ type: 'data', data: '\x1bc' }));
1185
- ws.send(JSON.stringify({ type: 'mode', mode: currentMode }));
1186
- setTimeout(() => {
1187
- if (outputBuffer) {
1188
- ws.send(JSON.stringify({ type: 'data', data: outputBuffer }));
1189
- }
1190
- }, 100);
1162
+ // 切换完成后统一回放(switchMode 期间 isSwitching=true,listener 不推数据)
1163
+ const buf = outputBuffer;
1164
+ outputBuffer = '';
1165
+ ws.send(JSON.stringify({ type: 'mode', mode: currentMode, buffer: buf || undefined }));
1166
+ isSwitching = false;
1191
1167
  }
1192
1168
  } else if (msg.type === 'restore') {
1193
1169
  // 恢复会话(支持 opencode 和 claude)
@@ -1223,7 +1199,9 @@ wssInst.on('connection', (ws, req) => {
1223
1199
  // 启动进程,传入 session ID
1224
1200
  try {
1225
1201
  await spawnProcess(currentMode, msg.sessionId);
1226
- ws.send(JSON.stringify({ type: 'restored', sessionId: msg.sessionId }));
1202
+ const buf = outputBuffer;
1203
+ outputBuffer = '';
1204
+ ws.send(JSON.stringify({ type: 'restored', sessionId: msg.sessionId, buffer: buf || undefined }));
1227
1205
  } catch (e) {
1228
1206
  LOG('[restore] 启动进程失败:', e.message);
1229
1207
  ws.send(JSON.stringify({ type: 'restore-error', error: e.message }));
@@ -1280,10 +1258,11 @@ wssInst.on('connection', (ws, req) => {
1280
1258
  await new Promise(resolve => setTimeout(resolve, 500));
1281
1259
  cleanupOrphanProcesses();
1282
1260
 
1283
- // 先通知前端准备好,再启动新进程
1284
- ws.send(JSON.stringify({ type: 'new-session-ok', mode }));
1285
1261
  try {
1286
1262
  await spawnProcess(mode);
1263
+ const buf = outputBuffer;
1264
+ outputBuffer = '';
1265
+ ws.send(JSON.stringify({ type: 'new-session-ok', mode, buffer: buf || undefined }));
1287
1266
  } catch (e) {
1288
1267
  LOG('[new-session] 启动失败:', e.message);
1289
1268
  ws.send(JSON.stringify({ type: 'new-session-error', error: e.message }));
@@ -1315,6 +1294,17 @@ wssInst.on('connection', (ws, req) => {
1315
1294
  setTimeout(() => checkNewSession(attempt + 1), 2000);
1316
1295
  };
1317
1296
  setTimeout(() => checkNewSession(0), 3000);
1297
+ } else if (msg.type === 'quit') {
1298
+ // PC 端关闭浏览器时发送,延迟 5 秒退出(防止刷新页面误杀)
1299
+ LOG('[quit] 收到退出请求,5秒后检查是否仍无连接...');
1300
+ setTimeout(() => {
1301
+ if (!wss || wss.clients.size === 0) {
1302
+ LOG('[quit] 无活跃连接,退出进程');
1303
+ cleanupAndExit();
1304
+ } else {
1305
+ LOG('[quit] 仍有活跃连接,取消退出');
1306
+ }
1307
+ }, 5000);
1318
1308
  }
1319
1309
  } catch (err) {
1320
1310
  LOG('[WS] Error:', err.message);
@@ -1343,15 +1333,19 @@ wssInst.on('connection', (ws, req) => {
1343
1333
  }
1344
1334
  }
1345
1335
  }
1346
- // 无客户端连接时,30秒后自动退出
1347
- startNoClientTimer();
1348
1336
  });
1349
1337
  });
1350
1338
 
1351
1339
  // WebSocket 心跳保活,防止中间网络设备断开空闲连接
1340
+ // 移动端跳过心跳检测:锁屏时 JS 暂停无法回 pong,但不需要断开(进程常驻)
1352
1341
  const HEARTBEAT_INTERVAL = 5000;
1353
1342
  const heartbeat = setInterval(() => {
1354
1343
  wssInst.clients.forEach((ws) => {
1344
+ if (mobileClients.has(ws)) {
1345
+ // 移动端不检测心跳,但清理已断开的僵尸连接
1346
+ if (ws.readyState !== 1) mobileClients.delete(ws);
1347
+ return;
1348
+ }
1355
1349
  if (ws.isAlive === false) return ws.terminate();
1356
1350
  ws.isAlive = false;
1357
1351
  ws.ping();
@@ -1397,8 +1391,6 @@ function startServer() {
1397
1391
  // 移动端客户端连接时自动启动 claude(见 WS 连接处理)
1398
1392
  LOG('[startup] 等待客户端连接并选择会话...');
1399
1393
 
1400
- // 启动首次连接超时检测(3分钟无人连接则退出)
1401
- startNoClientTimer();
1402
1394
  });
1403
1395
 
1404
1396
  server.on('error', (err) => {