claude-opencode-viewer 2.6.38 → 2.6.40

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 +120 -47
  2. package/index.html +296 -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,16 @@
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
+ // 预加载已移除,改为按需查询:首次打开面板时查询并缓存,刷新按钮才重新查
2224
+ }
2225
+
2186
2226
  var STATUS_COLORS = {
2187
2227
  'M': '#e2c08d', 'A': '#73c991', 'D': '#f14c4c',
2188
2228
  'R': '#73c991', 'C': '#73c991', 'U': '#e2c08d',
@@ -2194,27 +2234,42 @@
2194
2234
  var bar = document.getElementById('git-diff-bar');
2195
2235
  if (diffBarVisible) {
2196
2236
  bar.classList.add('visible');
2197
- loadGitStatus();
2237
+ loadGitStatus(false);
2198
2238
  } else {
2199
2239
  bar.classList.remove('visible');
2200
2240
  diffSelectedFile = null;
2201
2241
  }
2202
2242
  }
2203
2243
 
2204
- function loadGitStatus() {
2244
+ function loadGitStatus(forceRefresh) {
2205
2245
  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';
2246
+
2247
+ if (cachedGitStatus && !forceRefresh) {
2248
+ diffChanges = cachedGitStatus.changes || [];
2249
+ document.getElementById('git-diff-count').textContent = diffChanges.length;
2250
+ renderDiffFileList();
2251
+ return;
2252
+ }
2253
+
2254
+ fileList.innerHTML = '<div class="git-diff-loading">' + (forceRefresh ? '正在刷新...' : '正在查询 git status...') + '</div>';
2255
+ document.getElementById('git-diff-count').textContent = '...';
2256
+
2257
+ if (gitStatusLoading && !forceRefresh) return;
2258
+ gitStatusLoading = true;
2259
+ if (forceRefresh) cachedGitStatus = null;
2208
2260
 
2209
2261
  fetch(basePath + '/api/git-status')
2210
2262
  .then(function(r) { return r.json(); })
2211
2263
  .then(function(data) {
2264
+ cachedGitStatus = data;
2265
+ gitStatusLoading = false;
2212
2266
  diffChanges = data.changes || [];
2213
2267
  document.getElementById('git-diff-count').textContent = diffChanges.length;
2214
- renderDiffFileList();
2268
+ if (diffBarVisible) renderDiffFileList();
2215
2269
  })
2216
2270
  .catch(function() {
2217
- fileList.innerHTML = '<div class="git-diff-error">无法加载 git status</div>';
2271
+ gitStatusLoading = false;
2272
+ if (diffBarVisible) fileList.innerHTML = '<div class="git-diff-error">无法加载 git status</div>';
2218
2273
  });
2219
2274
  }
2220
2275
 
@@ -2343,7 +2398,7 @@
2343
2398
  document.getElementById('close-diff').addEventListener('click', toggleDiffBar);
2344
2399
  document.getElementById('refresh-diff').addEventListener('click', function(e) {
2345
2400
  e.stopPropagation();
2346
- loadGitStatus();
2401
+ loadGitStatus(true);
2347
2402
  // 重置 diff 内容区
2348
2403
  diffSelectedFile = null;
2349
2404
  document.getElementById('git-diff-content-area').innerHTML =
@@ -2365,49 +2420,67 @@
2365
2420
  var bar = document.getElementById('docs-bar');
2366
2421
  if (docsBarVisible) {
2367
2422
  bar.classList.add('visible');
2368
- loadDocs();
2423
+ loadDocs(false);
2369
2424
  } else {
2370
2425
  bar.classList.remove('visible');
2371
2426
  docsSelectedFile = null;
2372
2427
  }
2373
2428
  }
2374
2429
 
2375
- function loadDocs() {
2430
+ function renderDocsList(data) {
2431
+ var fileList = document.getElementById('docs-file-list');
2432
+ var docs = data.docs || [];
2433
+ document.getElementById('docs-count').textContent = docs.length;
2434
+ if (!docs.length) {
2435
+ fileList.innerHTML = '<div class="docs-loading" style="color:#666;">无文档</div>';
2436
+ return;
2437
+ }
2438
+ var html = '<div style="padding:4px 12px;font-size:11px;color:#666;border-bottom:1px solid #2a2a2a;">📁 ' + escapeHtml(data.cwd || 'PROJECT_DIR') + '</div>';
2439
+ docs.forEach(function(doc) {
2440
+ var activeClass = docsSelectedFile === doc.name ? ' active' : '';
2441
+ var time = new Date(doc.mtime).toLocaleString('zh-CN', { month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit' });
2442
+ html += '<div class="docs-file-item' + activeClass + '" data-file="' + escapeHtml(doc.name) + '">';
2443
+ html += '<span class="docs-file-name">' + escapeHtml(doc.name) + '</span>';
2444
+ html += '<span class="docs-file-time">' + time + '</span>';
2445
+ html += '</div>';
2446
+ });
2447
+ fileList.innerHTML = html;
2448
+ fileList.querySelectorAll('.docs-file-item').forEach(function(item) {
2449
+ item.addEventListener('click', function() {
2450
+ var file = this.getAttribute('data-file');
2451
+ docsSelectedFile = file;
2452
+ fileList.querySelectorAll('.docs-file-item').forEach(function(el) { el.classList.remove('active'); });
2453
+ this.classList.add('active');
2454
+ loadDocContent(file);
2455
+ });
2456
+ });
2457
+ }
2458
+
2459
+ function loadDocs(forceRefresh) {
2376
2460
  var fileList = document.getElementById('docs-file-list');
2377
- fileList.innerHTML = '<div class="docs-loading">加载中...</div>';
2378
- document.getElementById('docs-count').textContent = '0';
2461
+
2462
+ if (cachedDocs && !forceRefresh) {
2463
+ renderDocsList(cachedDocs);
2464
+ return;
2465
+ }
2466
+
2467
+ fileList.innerHTML = '<div class="docs-loading">' + (forceRefresh ? '正在刷新...' : '正在查询文档...') + '</div>';
2468
+ document.getElementById('docs-count').textContent = '...';
2469
+
2470
+ if (docsLoading && !forceRefresh) return;
2471
+ docsLoading = true;
2472
+ if (forceRefresh) cachedDocs = null;
2379
2473
 
2380
2474
  fetch(basePath + '/api/docs')
2381
2475
  .then(function(r) { return r.json(); })
2382
2476
  .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
- });
2477
+ cachedDocs = data;
2478
+ docsLoading = false;
2479
+ if (docsBarVisible) renderDocsList(data);
2408
2480
  })
2409
2481
  .catch(function() {
2410
- fileList.innerHTML = '<div class="docs-loading" style="color:#ff6b6b;">加载失败</div>';
2482
+ docsLoading = false;
2483
+ if (docsBarVisible) fileList.innerHTML = '<div class="docs-loading" style="color:#ff6b6b;">加载失败</div>';
2411
2484
  });
2412
2485
  }
2413
2486
 
@@ -2432,7 +2505,7 @@
2432
2505
  document.getElementById('close-docs').addEventListener('click', toggleDocsBar);
2433
2506
  document.getElementById('refresh-docs').addEventListener('click', function(e) {
2434
2507
  e.stopPropagation();
2435
- loadDocs();
2508
+ loadDocs(true);
2436
2509
  docsSelectedFile = null;
2437
2510
  document.getElementById('docs-content-area').innerHTML =
2438
2511
  '<div class="docs-placeholder">' +
package/index.html CHANGED
@@ -357,7 +357,20 @@
357
357
  display: flex;
358
358
  flex-direction: column;
359
359
  background: #0a0a0a;
360
+ position: relative;
361
+ }
362
+ #loading-overlay {
363
+ position: fixed;
364
+ top: 0; left: 0; right: 0; bottom: 0;
365
+ background: #0a0a0a;
366
+ color: #888;
367
+ display: flex;
368
+ align-items: center;
369
+ justify-content: center;
370
+ font-size: 15px;
371
+ z-index: 9999;
360
372
  }
373
+ #loading-overlay.hidden { display: none; }
361
374
 
362
375
  #terminal {
363
376
  flex: 1;
@@ -489,6 +502,18 @@
489
502
  -webkit-user-select: text;
490
503
  user-select: text;
491
504
  }
505
+ .msg-text pre {
506
+ background: #0d0d0d;
507
+ border: 1px solid #333;
508
+ border-radius: 4px;
509
+ padding: 8px;
510
+ overflow-x: auto;
511
+ font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
512
+ font-size: 12px;
513
+ line-height: 1.4;
514
+ white-space: pre;
515
+ -webkit-overflow-scrolling: touch;
516
+ }
492
517
  .msg-tool {
493
518
  margin-top: 6px;
494
519
  padding: 6px 8px;
@@ -832,6 +857,7 @@
832
857
  </head>
833
858
  <body>
834
859
  <!-- 参考 cc-viewer 的 App.jsx 行 1315-1607: 完整的移动端布局结构 -->
860
+ <div id="loading-overlay">正在初始化...</div>
835
861
  <div id="layout">
836
862
  <div id="header">
837
863
  <div style="display: flex; gap: 4px; align-items: center; overflow-x: auto; flex: 1; min-width: 0;">
@@ -1044,6 +1070,7 @@
1044
1070
 
1045
1071
  <div id="copy-toast">已复制</div>
1046
1072
  <script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
1073
+ <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-webgl@0.18.0/lib/addon-webgl.min.js"></script>
1047
1074
  <script>
1048
1075
  (function() {
1049
1076
  var isMobile = /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
@@ -1052,6 +1079,7 @@
1052
1079
  var fontSize = isMobile ? 11 : 13;
1053
1080
  var currentMode = 'claude';
1054
1081
  var isTransitioning = false;
1082
+ var mobileInitSent = false;
1055
1083
 
1056
1084
  var term = new Terminal({
1057
1085
  cursorBlink: !isMobile,
@@ -1071,6 +1099,19 @@
1071
1099
 
1072
1100
  term.open(document.getElementById('terminal'));
1073
1101
 
1102
+ // WebGL 渲染器:GPU 加速绘制,非 iOS 设备启用(iOS WebGL 性能差)
1103
+ if (!isIOS && window.WebglAddon) {
1104
+ try {
1105
+ var webglAddon = new WebglAddon.WebglAddon();
1106
+ webglAddon.onContextLoss(function() {
1107
+ webglAddon.dispose();
1108
+ });
1109
+ term.loadAddon(webglAddon);
1110
+ } catch(e) {
1111
+ // WebGL 不可用时回退到 Canvas 渲染
1112
+ }
1113
+ }
1114
+
1074
1115
  // PC端复制:用 xterm.js selection API 获取纯文本,避免复制出乱码
1075
1116
  document.getElementById('terminal').addEventListener('copy', function(e) {
1076
1117
  var sel = term.getSelection();
@@ -1132,6 +1173,8 @@
1132
1173
  // 会话历史相关
1133
1174
  var sessions = [];
1134
1175
  var currentSessionId = null;
1176
+ var claudeSessionId = null;
1177
+ var claudeProject = null;
1135
1178
  var currentSessionData = null;
1136
1179
  var historyBarVisible = false;
1137
1180
 
@@ -1258,15 +1301,10 @@
1258
1301
  }
1259
1302
  }
1260
1303
 
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; // 滚动灵敏度,越小每次滑动行数越少
1304
+ // 触摸滚动实现
1305
+ // Claude 模式:与 cc-viewer 一致,直接 scrollLines,1:1 像素映射,无 WheelEvent
1306
+ // OpenCode 模式:alternate buffer WheelEvent(vim/less 等需要),灵敏度 0.7
1307
+ var SCROLL_SENSITIVITY = 0.7;
1270
1308
  var touchScreen = null;
1271
1309
  var touchEventsBound = false;
1272
1310
 
@@ -1300,6 +1338,12 @@
1300
1338
 
1301
1339
  function doScroll(lines) {
1302
1340
  if (lines === 0) return;
1341
+ // Claude 模式:始终直接 scrollLines(与 cc-viewer 一致)
1342
+ if (currentMode === 'claude') {
1343
+ term.scrollLines(lines);
1344
+ return;
1345
+ }
1346
+ // OpenCode 模式:alternate buffer 发 WheelEvent,normal buffer 用 scrollLines
1303
1347
  if (isAlternateBuffer()) {
1304
1348
  altScrollAccum += lines;
1305
1349
  if (Math.abs(altScrollAccum) >= ALT_SCROLL_THRESHOLD) {
@@ -1335,7 +1379,8 @@
1335
1379
  scrollRaf = null;
1336
1380
  if (pendingDy === 0) return;
1337
1381
 
1338
- pixelAccum += pendingDy * SCROLL_SENSITIVITY;
1382
+ // Claude 模式 1:1 像素映射(cc-viewer 同款),OpenCode 降低灵敏度
1383
+ pixelAccum += pendingDy * (currentMode === 'claude' ? 1 : SCROLL_SENSITIVITY);
1339
1384
  pendingDy = 0;
1340
1385
 
1341
1386
  var lh = getLineHeight();
@@ -1372,22 +1417,19 @@
1372
1417
  }
1373
1418
 
1374
1419
  pendingDy += dy;
1375
- console.log('[scroll] touchmove dy:', dy, 'pendingDy:', pendingDy);
1376
-
1377
1420
  if (!scrollRaf) scrollRaf = requestAnimationFrame(flushScroll);
1378
1421
  lastY = y;
1379
1422
  lastTime = now;
1380
1423
  }
1381
1424
 
1382
1425
  function handleTouchEnd() {
1383
-
1384
1426
  if (scrollRaf) {
1385
1427
  cancelAnimationFrame(scrollRaf);
1386
1428
  scrollRaf = null;
1387
1429
  }
1388
1430
 
1389
1431
  if (pendingDy !== 0) {
1390
- pixelAccum += pendingDy * SCROLL_SENSITIVITY;
1432
+ pixelAccum += pendingDy * (currentMode === 'claude' ? 1 : SCROLL_SENSITIVITY);
1391
1433
  pendingDy = 0;
1392
1434
  var lh = getLineHeight();
1393
1435
  var lines = Math.trunc(pixelAccum / lh);
@@ -1412,9 +1454,8 @@
1412
1454
  }
1413
1455
  velocitySamples = [];
1414
1456
 
1415
- velocity *= SCROLL_SENSITIVITY;
1416
- console.log('[scroll] velocity:', velocity);
1417
- if (Math.abs(velocity) < 0.3) return;
1457
+ velocity *= (currentMode === 'claude' ? 1 : SCROLL_SENSITIVITY);
1458
+ if (Math.abs(velocity) < 0.5) return;
1418
1459
 
1419
1460
  var friction = 0.95;
1420
1461
  var mAccum = 0;
@@ -1443,7 +1484,6 @@
1443
1484
 
1444
1485
  function unbindTouchScroll() {
1445
1486
  if (touchScreen && touchEventsBound) {
1446
- console.log('[scroll] unbinding touch events');
1447
1487
  touchScreen.removeEventListener('touchstart', handleTouchStart);
1448
1488
  touchScreen.removeEventListener('touchmove', handleTouchMove);
1449
1489
  touchScreen.removeEventListener('touchend', handleTouchEnd);
@@ -1553,15 +1593,24 @@
1553
1593
 
1554
1594
  ws.onclose = function() {
1555
1595
  ws = null;
1556
- term.reset(); // 清除终端状态,避免 buffer 回放叠加导致状态混乱
1596
+ term.reset();
1597
+ term.write('\r\n \x1b[33m连接断开,正在重连...\x1b[0m\r\n');
1557
1598
  setTimeout(connect, 2000);
1558
1599
  };
1559
1600
 
1601
+ var loadingOverlay = document.getElementById('loading-overlay');
1602
+ function hideLoading() {
1603
+ if (loadingOverlay && !loadingOverlay.classList.contains('hidden')) {
1604
+ loadingOverlay.classList.add('hidden');
1605
+ }
1606
+ }
1607
+
1560
1608
  ws.onmessage = function(e) {
1561
1609
  try {
1562
1610
  var msg = JSON.parse(e.data);
1563
1611
  if (msg.type === 'data') {
1564
- if (!isCreatingNewSession) {
1612
+ hideLoading();
1613
+ if (!isCreatingNewSession && !isTransitioning) {
1565
1614
  throttledWrite(msg.data);
1566
1615
  }
1567
1616
  }
@@ -1571,15 +1620,45 @@
1571
1620
  throttledWrite('按 Enter 键重新启动 ' + currentMode + '...\r\n');
1572
1621
  }
1573
1622
  }
1623
+ else if (msg.type === 'state') {
1624
+ // 服务端还没启动进程时,查最近会话并自动恢复
1625
+ if (!msg.running && !mobileInitSent) {
1626
+ mobileInitSent = true;
1627
+ fetch('/api/last-sessions')
1628
+ .then(function(r) { return r.json(); })
1629
+ .then(function(data) {
1630
+ var oc = data.opencode;
1631
+ var cl = data.claude;
1632
+ // 取 mtime 最新的那个
1633
+ var useOc = oc && (!cl || oc.mtime > cl.mtime);
1634
+ var mode = useOc ? 'opencode' : 'claude';
1635
+ var sessionId = useOc ? (oc && oc.id) : (cl && cl.id);
1636
+ if (cl) {
1637
+ claudeSessionId = cl.id;
1638
+ claudeProject = cl.project;
1639
+ }
1640
+ currentMode = mode;
1641
+ ws.send(JSON.stringify({ type: 'init', mode: mode, sessionId: sessionId || null }));
1642
+ })
1643
+ .catch(function() {
1644
+ ws.send(JSON.stringify({ type: 'init', mode: 'claude' }));
1645
+ });
1646
+ }
1647
+ }
1574
1648
  else if (msg.type === 'mode') {
1649
+ if (writeTimer) { cancelAnimationFrame(writeTimer); writeTimer = null; }
1650
+ writeBuffer = '';
1651
+ term.reset();
1575
1652
  endTransition(msg.mode);
1576
- // 模式切换完成后,重新绑定触摸事件
1653
+ if (msg.buffer) {
1654
+ term.write(msg.buffer);
1655
+ }
1577
1656
  rebindTouchScroll();
1578
1657
  }
1579
1658
  else if (msg.type === 'switching') {
1580
- // 服务端开始切换,完全重置终端(清除残留输出和状态)
1581
- term.reset();
1659
+ if (writeTimer) { cancelAnimationFrame(writeTimer); writeTimer = null; }
1582
1660
  writeBuffer = '';
1661
+ term.clear();
1583
1662
  }
1584
1663
  else if (msg.type === 'state') {
1585
1664
  if (msg.mode) {
@@ -1589,22 +1668,37 @@
1589
1668
  }
1590
1669
  }
1591
1670
  else if (msg.type === 'restored') {
1592
- // 会话恢复成功,重置终端清除残留ANSI状态
1671
+ // 会话恢复成功,清除所有残留
1672
+ if (writeTimer) { cancelAnimationFrame(writeTimer); writeTimer = null; }
1673
+ writeBuffer = '';
1593
1674
  term.reset();
1675
+ if (msg.buffer) {
1676
+ term.write(msg.buffer);
1677
+ }
1678
+ isTransitioning = false;
1594
1679
  }
1595
1680
  else if (msg.type === 'restore-error') {
1596
- // 恢复失败
1681
+ isTransitioning = false;
1597
1682
  term.write('恢复失败: ' + msg.error + '\r\n');
1598
1683
  }
1599
1684
  else if (msg.type === 'started') {
1685
+ hideLoading();
1600
1686
  rebindTouchScroll();
1687
+ preloadData();
1601
1688
  }
1602
1689
  else if (msg.type === 'new-session-ok') {
1603
- isCreatingNewSession = false;
1690
+ if (writeTimer) { cancelAnimationFrame(writeTimer); writeTimer = null; }
1691
+ writeBuffer = '';
1604
1692
  term.reset();
1693
+ if (msg.buffer) {
1694
+ term.write(msg.buffer);
1695
+ }
1696
+ isCreatingNewSession = false;
1697
+ isTransitioning = false;
1605
1698
  }
1606
1699
  else if (msg.type === 'new-session-error') {
1607
1700
  isCreatingNewSession = false;
1701
+ isTransitioning = false;
1608
1702
  term.write('新会话启动失败: ' + msg.error + '\r\n');
1609
1703
  }
1610
1704
  } catch(err) {}
@@ -1923,11 +2017,15 @@
1923
2017
  // 关闭历史栏
1924
2018
  toggleHistoryBar();
1925
2019
 
1926
- // 重置终端(清除残留ANSI解析状态)
2020
+ // 阻止旧数据写入
2021
+ isTransitioning = true;
2022
+ if (writeTimer) { cancelAnimationFrame(writeTimer); writeTimer = null; }
2023
+ writeBuffer = '';
1927
2024
  term.reset();
1928
2025
 
1929
2026
  if (ws && ws.readyState === 1) {
1930
2027
  if (currentMode !== 'opencode') {
2028
+ isTransitioning = false;
1931
2029
  term.write('错误: 请先切换到 OpenCode 模式\r\n');
1932
2030
  return;
1933
2031
  }
@@ -2022,7 +2120,7 @@
2022
2120
  item.appendChild(deleteBtn);
2023
2121
 
2024
2122
  item.addEventListener('click', function() {
2025
- restoreClaudeSession(s.id);
2123
+ restoreClaudeSession(s.id, s.project);
2026
2124
  });
2027
2125
 
2028
2126
  sessionList.appendChild(item);
@@ -2113,12 +2211,18 @@
2113
2211
  });
2114
2212
  }
2115
2213
 
2116
- function restoreClaudeSession(sessionId) {
2214
+ function restoreClaudeSession(sessionId, project) {
2117
2215
  console.log('[restore] 恢复 Claude 会话:', sessionId);
2216
+ claudeSessionId = sessionId;
2217
+ if (project) claudeProject = project;
2118
2218
  toggleHistoryBar();
2219
+ isTransitioning = true;
2220
+ if (writeTimer) { cancelAnimationFrame(writeTimer); writeTimer = null; }
2221
+ writeBuffer = '';
2119
2222
  term.reset();
2120
2223
  if (ws && ws.readyState === 1) {
2121
2224
  if (currentMode !== 'claude') {
2225
+ isTransitioning = false;
2122
2226
  term.write('错误: 请先切换到 Claude 模式\r\n');
2123
2227
  return;
2124
2228
  }
@@ -2140,9 +2244,12 @@
2140
2244
  // 新会话按钮
2141
2245
  var isCreatingNewSession = false;
2142
2246
  document.getElementById('new-session-btn').addEventListener('click', function() {
2143
- if (!ws || ws.readyState !== 1) return;
2247
+ if (!ws || ws.readyState !== 1 || isTransitioning) return;
2144
2248
  isCreatingNewSession = true;
2145
- term.clear();
2249
+ isTransitioning = true;
2250
+ if (writeTimer) { cancelAnimationFrame(writeTimer); writeTimer = null; }
2251
+ writeBuffer = '';
2252
+ term.reset();
2146
2253
  ws.send(JSON.stringify({ type: 'new-session' }));
2147
2254
  });
2148
2255
 
@@ -2227,23 +2334,81 @@
2227
2334
  msgViewerContent.innerHTML = '<div class="msg-empty">加载中...</div>';
2228
2335
  unbindTouchScroll();
2229
2336
 
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)
2337
+ if (currentMode === 'claude') {
2338
+ // Claude 模式:从 JSONL 文件读取消息
2339
+ var loadClaudeMessages = function(sid, proj) {
2340
+ fetch(basePath + '/api/claude-session-messages?id=' + encodeURIComponent(sid) + '&project=' + encodeURIComponent(proj))
2239
2341
  .then(function(r) { return r.json(); })
2240
- .then(function(messages) {
2342
+ .then(function(data) {
2343
+ if (data.error) {
2344
+ msgViewerContent.innerHTML = '<div class="msg-empty">' + escapeHtml(data.error) + '</div>';
2345
+ return;
2346
+ }
2347
+ var messages = data.messages || [];
2241
2348
  renderMessages(messages);
2349
+ })
2350
+ .catch(function(e) {
2351
+ msgViewerContent.innerHTML = '<div class="msg-empty">加载失败: ' + e.message + '</div>';
2242
2352
  });
2243
- })
2244
- .catch(function(e) {
2245
- msgViewerContent.innerHTML = '<div class="msg-empty">加载失败: ' + e.message + '</div>';
2246
- });
2353
+ };
2354
+ if (claudeSessionId && claudeProject) {
2355
+ loadClaudeMessages(claudeSessionId, claudeProject);
2356
+ } else {
2357
+ // 尚未获取到 session 信息,先查
2358
+ fetch(basePath + '/api/last-sessions')
2359
+ .then(function(r) { return r.json(); })
2360
+ .then(function(data) {
2361
+ var cl = data.claude;
2362
+ if (cl && cl.id && cl.project) {
2363
+ claudeSessionId = cl.id;
2364
+ claudeProject = cl.project;
2365
+ loadClaudeMessages(cl.id, cl.project);
2366
+ } else {
2367
+ msgViewerContent.innerHTML = '<div class="msg-empty">暂无活跃会话</div>';
2368
+ }
2369
+ })
2370
+ .catch(function(e) {
2371
+ msgViewerContent.innerHTML = '<div class="msg-empty">加载失败: ' + e.message + '</div>';
2372
+ });
2373
+ }
2374
+ } else {
2375
+ // OpenCode 模式:从 SQLite 读取消息
2376
+ fetch(basePath + '/api/current-session')
2377
+ .then(function(r) { return r.json(); })
2378
+ .then(function(data) {
2379
+ if (!data.sessionId) {
2380
+ msgViewerContent.innerHTML = '<div class="msg-empty">暂无活跃会话</div>';
2381
+ return;
2382
+ }
2383
+ return fetch(basePath + '/api/session/' + data.sessionId)
2384
+ .then(function(r) { return r.json(); })
2385
+ .then(function(messages) {
2386
+ renderMessages(messages);
2387
+ });
2388
+ })
2389
+ .catch(function(e) {
2390
+ msgViewerContent.innerHTML = '<div class="msg-empty">加载失败: ' + e.message + '</div>';
2391
+ });
2392
+ }
2393
+ }
2394
+
2395
+ function formatMsgText(text) {
2396
+ // 将 markdown 代码块转为 <pre>,其余部分转义
2397
+ var parts = text.split(/(```[\s\S]*?```)/g);
2398
+ var result = '';
2399
+ for (var i = 0; i < parts.length; i++) {
2400
+ var p = parts[i];
2401
+ if (p.startsWith('```') && p.endsWith('```')) {
2402
+ // 去掉首尾 ```(可能带语言标记)
2403
+ var inner = p.slice(3, -3);
2404
+ var nlIdx = inner.indexOf('\n');
2405
+ if (nlIdx !== -1) inner = inner.slice(nlIdx + 1);
2406
+ result += '<pre>' + escapeHtml(inner) + '</pre>';
2407
+ } else {
2408
+ result += escapeHtml(p);
2409
+ }
2410
+ }
2411
+ return result;
2247
2412
  }
2248
2413
 
2249
2414
  function renderMessages(messages) {
@@ -2259,7 +2424,7 @@
2259
2424
  html += '<div class="msg-item ' + cls + '">';
2260
2425
  html += '<div class="msg-role">' + roleLabel + '</div>';
2261
2426
  if (msg.text) {
2262
- html += '<div class="msg-text">' + escapeHtml(msg.text) + '</div>';
2427
+ html += '<div class="msg-text">' + formatMsgText(msg.text) + '</div>';
2263
2428
  }
2264
2429
  if (msg.toolCalls && msg.toolCalls.length > 0) {
2265
2430
  msg.toolCalls.forEach(function(tc) {
@@ -2269,6 +2434,7 @@
2269
2434
  html += '</div>';
2270
2435
  });
2271
2436
  msgViewerContent.innerHTML = html;
2437
+ msgViewerContent.scrollTop = msgViewerContent.scrollHeight;
2272
2438
  }
2273
2439
 
2274
2440
  function escapeHtml(str) {
@@ -2302,6 +2468,16 @@
2302
2468
  var diffChanges = [];
2303
2469
  var diffSelectedFile = null;
2304
2470
 
2471
+ // 预加载缓存
2472
+ var cachedGitStatus = null;
2473
+ var cachedDocs = null;
2474
+ var gitStatusLoading = false;
2475
+ var docsLoading = false;
2476
+
2477
+ function preloadData() {
2478
+ // 预加载已移除,改为按需查询:首次打开面板时查询并缓存,刷新按钮才重新查
2479
+ }
2480
+
2305
2481
  var STATUS_COLORS = {
2306
2482
  'M': '#e2c08d', 'A': '#73c991', 'D': '#f14c4c',
2307
2483
  'R': '#73c991', 'C': '#73c991', 'U': '#e2c08d',
@@ -2313,27 +2489,44 @@
2313
2489
  var bar = document.getElementById('git-diff-bar');
2314
2490
  if (diffBarVisible) {
2315
2491
  bar.classList.add('visible');
2316
- loadGitStatus();
2492
+ loadGitStatus(false);
2317
2493
  } else {
2318
2494
  bar.classList.remove('visible');
2319
2495
  diffSelectedFile = null;
2320
2496
  }
2321
2497
  }
2322
2498
 
2323
- function loadGitStatus() {
2499
+ function loadGitStatus(forceRefresh) {
2324
2500
  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';
2501
+
2502
+ // 有缓存且非强制刷新,直接用缓存
2503
+ if (cachedGitStatus && !forceRefresh) {
2504
+ diffChanges = cachedGitStatus.changes || [];
2505
+ document.getElementById('git-diff-count').textContent = diffChanges.length;
2506
+ renderDiffFileList();
2507
+ return;
2508
+ }
2509
+
2510
+ // 正在加载中或需要发起请求
2511
+ fileList.innerHTML = '<div class="git-diff-loading">' + (forceRefresh ? '正在刷新...' : '正在查询 git status...') + '</div>';
2512
+ document.getElementById('git-diff-count').textContent = '...';
2513
+
2514
+ if (gitStatusLoading && !forceRefresh) return; // 预加载进行中,等它完成
2515
+ gitStatusLoading = true;
2516
+ if (forceRefresh) cachedGitStatus = null;
2327
2517
 
2328
2518
  fetch(basePath + '/api/git-status')
2329
2519
  .then(function(r) { return r.json(); })
2330
2520
  .then(function(data) {
2521
+ cachedGitStatus = data;
2522
+ gitStatusLoading = false;
2331
2523
  diffChanges = data.changes || [];
2332
2524
  document.getElementById('git-diff-count').textContent = diffChanges.length;
2333
- renderDiffFileList();
2525
+ if (diffBarVisible) renderDiffFileList();
2334
2526
  })
2335
2527
  .catch(function() {
2336
- fileList.innerHTML = '<div class="git-diff-error">无法加载 git status</div>';
2528
+ gitStatusLoading = false;
2529
+ if (diffBarVisible) fileList.innerHTML = '<div class="git-diff-error">无法加载 git status</div>';
2337
2530
  });
2338
2531
  }
2339
2532
 
@@ -2462,7 +2655,7 @@
2462
2655
  document.getElementById('close-diff').addEventListener('click', toggleDiffBar);
2463
2656
  document.getElementById('refresh-diff').addEventListener('click', function(e) {
2464
2657
  e.stopPropagation();
2465
- loadGitStatus();
2658
+ loadGitStatus(true);
2466
2659
  // 重置 diff 内容区
2467
2660
  diffSelectedFile = null;
2468
2661
  document.getElementById('git-diff-content-area').innerHTML =
@@ -2484,49 +2677,68 @@
2484
2677
  var bar = document.getElementById('docs-bar');
2485
2678
  if (docsBarVisible) {
2486
2679
  bar.classList.add('visible');
2487
- loadDocs();
2680
+ loadDocs(false);
2488
2681
  } else {
2489
2682
  bar.classList.remove('visible');
2490
2683
  docsSelectedFile = null;
2491
2684
  }
2492
2685
  }
2493
2686
 
2494
- function loadDocs() {
2687
+ function renderDocsList(data) {
2495
2688
  var fileList = document.getElementById('docs-file-list');
2496
- fileList.innerHTML = '<div class="docs-loading">加载中...</div>';
2497
- document.getElementById('docs-count').textContent = '0';
2689
+ var docs = data.docs || [];
2690
+ document.getElementById('docs-count').textContent = docs.length;
2691
+ if (!docs.length) {
2692
+ fileList.innerHTML = '<div class="docs-loading" style="color:#666;">无文档</div>';
2693
+ return;
2694
+ }
2695
+ var html = '<div style="padding:4px 12px;font-size:11px;color:#666;border-bottom:1px solid #2a2a2a;">📁 ' + escapeHtml(data.cwd || 'PROJECT_DIR') + '</div>';
2696
+ docs.forEach(function(doc) {
2697
+ var activeClass = docsSelectedFile === doc.name ? ' active' : '';
2698
+ var time = new Date(doc.mtime).toLocaleString('zh-CN', { month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit' });
2699
+ html += '<div class="docs-file-item' + activeClass + '" data-file="' + escapeHtml(doc.name) + '">';
2700
+ html += '<span class="docs-file-name">' + escapeHtml(doc.name) + '</span>';
2701
+ html += '<span class="docs-file-time">' + time + '</span>';
2702
+ html += '</div>';
2703
+ });
2704
+ fileList.innerHTML = html;
2705
+ fileList.querySelectorAll('.docs-file-item').forEach(function(item) {
2706
+ item.addEventListener('click', function() {
2707
+ var file = this.getAttribute('data-file');
2708
+ docsSelectedFile = file;
2709
+ fileList.querySelectorAll('.docs-file-item').forEach(function(el) { el.classList.remove('active'); });
2710
+ this.classList.add('active');
2711
+ loadDocContent(file);
2712
+ });
2713
+ });
2714
+ }
2715
+
2716
+ function loadDocs(forceRefresh) {
2717
+ var fileList = document.getElementById('docs-file-list');
2718
+
2719
+ // 有缓存且非强制刷新,直接用缓存
2720
+ if (cachedDocs && !forceRefresh) {
2721
+ renderDocsList(cachedDocs);
2722
+ return;
2723
+ }
2724
+
2725
+ fileList.innerHTML = '<div class="docs-loading">' + (forceRefresh ? '正在刷新...' : '正在查询文档...') + '</div>';
2726
+ document.getElementById('docs-count').textContent = '...';
2727
+
2728
+ if (docsLoading && !forceRefresh) return;
2729
+ docsLoading = true;
2730
+ if (forceRefresh) cachedDocs = null;
2498
2731
 
2499
2732
  fetch(basePath + '/api/docs')
2500
2733
  .then(function(r) { return r.json(); })
2501
2734
  .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
- });
2735
+ cachedDocs = data;
2736
+ docsLoading = false;
2737
+ if (docsBarVisible) renderDocsList(data);
2527
2738
  })
2528
2739
  .catch(function() {
2529
- fileList.innerHTML = '<div class="docs-loading" style="color:#ff6b6b;">加载失败</div>';
2740
+ docsLoading = false;
2741
+ if (docsBarVisible) fileList.innerHTML = '<div class="docs-loading" style="color:#ff6b6b;">加载失败</div>';
2530
2742
  });
2531
2743
  }
2532
2744
 
@@ -2551,7 +2763,7 @@
2551
2763
  document.getElementById('close-docs').addEventListener('click', toggleDocsBar);
2552
2764
  document.getElementById('refresh-docs').addEventListener('click', function(e) {
2553
2765
  e.stopPropagation();
2554
- loadDocs();
2766
+ loadDocs(true);
2555
2767
  docsSelectedFile = null;
2556
2768
  document.getElementById('docs-content-area').innerHTML =
2557
2769
  '<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.40",
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) => {