@co0ontty/wand 1.18.0 → 1.18.12

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.
@@ -167,6 +167,7 @@
167
167
  return false;
168
168
  }
169
169
  })(),
170
+ topbarMoreOpen: false,
170
171
  chatAutoFollow: (function() {
171
172
  try {
172
173
  var saved = localStorage.getItem(CHAT_AUTO_FOLLOW_STORAGE_KEY);
@@ -182,6 +183,8 @@
182
183
  chatScrollHandler: null,
183
184
  lastForegroundSyncAt: 0,
184
185
  foregroundSyncTimer: null,
186
+ wsReconnectAttempts: 0,
187
+ wsReconnectTimer: null,
185
188
  currentMessages: [],
186
189
  lastRenderedHash: 0,
187
190
  lastRenderedMsgCount: 0,
@@ -337,8 +340,11 @@
337
340
  var enabled = !!state.chatAutoFollow;
338
341
  button.classList.toggle("active", enabled);
339
342
  button.setAttribute("aria-pressed", enabled ? "true" : "false");
340
- button.setAttribute("title", enabled ? "追踪底部:开启" : "追踪底部:已暂停");
341
- button.textContent = enabled ? "追底" : "暂停";
343
+ button.setAttribute("title", enabled ? "追踪底部:开启(点击暂停)" : "追踪底部:已暂停(点击开启)");
344
+ button.setAttribute("aria-label", enabled ? "追踪底部:开启" : "追踪底部:已暂停");
345
+ button.innerHTML = enabled
346
+ ? '<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3.5 2.5l4.5 4.5 4.5-4.5"/><path d="M3.5 8.5l4.5 4.5 4.5-4.5"/></svg>'
347
+ : '<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5.5 3v10"/><path d="M10.5 3v10"/></svg>';
342
348
  }
343
349
 
344
350
  function updateChatJumpToBottomButton() {
@@ -799,14 +805,11 @@
799
805
 
800
806
  function updateInstallPrompt() {
801
807
  // 显示或隐藏菜单栏中的安装按钮
808
+ var visible = !!(state.showInstallPrompt && state.deferredPrompt);
802
809
  var installBtn = document.getElementById('pwa-install-button');
803
- if (installBtn) {
804
- if (state.showInstallPrompt && state.deferredPrompt) {
805
- installBtn.classList.remove('hidden');
806
- } else {
807
- installBtn.classList.add('hidden');
808
- }
809
- }
810
+ if (installBtn) installBtn.classList.toggle('hidden', !visible);
811
+ var topbarInstallItem = document.getElementById('topbar-install-item');
812
+ if (topbarInstallItem) topbarInstallItem.classList.toggle('hidden', !visible);
810
813
  }
811
814
 
812
815
  function renderBootLoading() {
@@ -821,36 +824,48 @@
821
824
  '</div>';
822
825
  }
823
826
 
824
- function scheduleForegroundSync(reason) {
827
+ function scheduleForegroundSync(reason, opts) {
825
828
  if (!state.config) return;
826
829
  if (document.hidden) return;
830
+ var immediate = opts && opts.immediate === true;
827
831
  var now = Date.now();
828
- if (now - state.lastForegroundSyncAt < 1500) return;
832
+ // 节流只是为了防止 visibilitychange/focus/pageshow 在前台切换时
833
+ // 连珠炮式触发同一份重连工作,不再借此延迟实际同步——之前用
834
+ // 80ms 兜延迟的版本会在前台事件后再去 loadOutput 全量重写
835
+ // terminal,但 wterm cols 那时还没被 ResizeObserver 自适应到,
836
+ // 写进去的全是按错列宽排版的内容,结果"切回前台/刷新页面 →
837
+ // 中间一大段都看不到"反而成了常态。
838
+ if (!immediate && now - state.lastForegroundSyncAt < 1500) return;
829
839
  state.lastForegroundSyncAt = now;
830
840
  if (state.foregroundSyncTimer) {
831
841
  clearTimeout(state.foregroundSyncTimer);
832
- }
833
- state.foregroundSyncTimer = setTimeout(function() {
834
842
  state.foregroundSyncTimer = null;
835
- syncOnForeground(reason);
836
- }, 80);
843
+ }
844
+ syncOnForeground(reason, immediate);
837
845
  }
838
846
 
839
- function syncOnForeground(reason) {
847
+ function syncOnForeground(reason, force) {
840
848
  if (!state.config) return Promise.resolve();
841
849
  if (document.hidden) return Promise.resolve();
842
- if (!state.ws || (state.ws.readyState !== WebSocket.OPEN && state.ws.readyState !== WebSocket.CONNECTING)) {
850
+ // On Android resume the previous WS may still report OPEN/CONNECTING
851
+ // for a few seconds because the close frame hasn't been delivered
852
+ // yet (TCP keepalive / Doze suspended the network stack). Force a
853
+ // fresh socket so we don't sit on a zombie connection.
854
+ if (force) {
855
+ forceReconnectWebSocket("resume-force");
856
+ } else if (!state.ws || (state.ws.readyState !== WebSocket.OPEN && state.ws.readyState !== WebSocket.CONNECTING)) {
843
857
  initWebSocket();
844
858
  }
845
859
  if (state.claudeHistoryLoaded) {
846
860
  loadClaudeHistory();
847
861
  }
848
- return loadSessions({ skipSelectedOutputReload: true }).then(function() {
849
- if (state.selectedId) {
850
- return loadOutput(state.selectedId);
851
- }
852
- scheduleChatRender(true);
853
- }).catch(function(e) {
862
+ // 不再 loadOutput 当前会话——WS 重连后服务端会主动推一条 init
863
+ // 消息,那条路径已经走 ensureTerminalFitWithRetry 强制按真实
864
+ // cols 重排 history,足够覆盖前台恢复时的同步需求。这里多加
865
+ // 一次 fetch + syncTerminalBuffer 反而会在 ws/http 两路的 output
866
+ // 之间来回 reset,导致 alt-screen 中正在绘制的 Claude TUI 被
867
+ // 中途清掉。只把会话列表刷一下,保证状态条/会话名等元数据是新的。
868
+ return loadSessions({ skipSelectedOutputReload: true }).catch(function(e) {
854
869
  console.error("[wand] foreground sync failed:", reason, e);
855
870
  });
856
871
  }
@@ -860,8 +875,15 @@
860
875
  window.__wandForegroundSyncBound = true;
861
876
 
862
877
  document.addEventListener("visibilitychange", function() {
863
- if (!document.hidden) {
878
+ if (document.hidden) {
879
+ // Stop the reconnect backoff while hidden — the OS may freeze
880
+ // timers and then deliver them in a burst when we resume,
881
+ // creating a thundering-herd of connect attempts. The resume
882
+ // event will trigger one decisive reconnect instead.
883
+ cancelWsReconnect();
884
+ } else {
864
885
  scheduleForegroundSync("visibility");
886
+ ensureTerminalFitWithRetry("visibility");
865
887
  }
866
888
  });
867
889
 
@@ -876,6 +898,18 @@
876
898
  window.addEventListener("resume", function() {
877
899
  scheduleForegroundSync("resume");
878
900
  });
901
+
902
+ // Bridge from Android WebView host: MainActivity.onResume() calls
903
+ // evaluateJavascript to dispatch this event, which is the only
904
+ // reliable foreground signal once Doze/process-suspension has
905
+ // frozen page-level events (visibilitychange/focus/pageshow may
906
+ // fire late or not at all after a long suspend). Force-reconnect
907
+ // and force-refit immediately rather than waiting for the
908
+ // throttled scheduleForegroundSync path.
909
+ window.addEventListener("wand-android-resume", function() {
910
+ scheduleForegroundSync("android-resume", { immediate: true });
911
+ ensureTerminalFitWithRetry("android-resume");
912
+ });
879
913
  }
880
914
 
881
915
  function restoreLoginSession() {
@@ -1169,12 +1203,38 @@
1169
1203
  '</aside>' +
1170
1204
  '<main class="main-content">' +
1171
1205
  '<div class="main-header-row">' +
1172
- '<button id="sessions-toggle-button" class="floating-sidebar-toggle' + (state.sessionsDrawerOpen ? ' active' : '') + '" aria-label="切换会话侧栏" type="button">' +
1173
- '<span class="hamburger-icon">' +
1174
- '<span></span><span></span><span></span>' +
1175
- '</span>' +
1176
- '</button>' +
1177
- '<span class="current-task hidden" id="current-task"></span>' +
1206
+ '<div class="topbar-left">' +
1207
+ '<button id="sessions-toggle-button" class="floating-sidebar-toggle' + (state.sessionsDrawerOpen ? ' active' : '') + '" aria-label="切换会话侧栏" type="button">' +
1208
+ '<span class="hamburger-icon">' +
1209
+ '<span></span><span></span><span></span>' +
1210
+ '</span>' +
1211
+ '</button>' +
1212
+ '<span class="topbar-brand" aria-hidden="true">W</span>' +
1213
+ '</div>' +
1214
+ '<div class="topbar-center">' +
1215
+ (selectedSession
1216
+ ? (
1217
+ '<span class="topbar-session-title" title="' + escapeHtml(selectedSession.command || "") + '">' + escapeHtml(shortCommand(selectedSession.command)) + '</span>' +
1218
+ '<span class="session-status-pill ' + getSessionStatusClass(selectedSession) + '" title="' + escapeHtml(getSessionStatusLabel(selectedSession)) + '"><span class="session-status-dot"></span><span class="session-status-text">' + escapeHtml(getSessionStatusLabel(selectedSession)) + '</span></span>' +
1219
+ '<span class="current-task hidden" id="current-task"></span>' +
1220
+ (selectedSession.cwd ? '<span class="topbar-cwd" id="topbar-cwd" title="' + escapeHtml(selectedSession.cwd) + '" role="button" tabindex="0">' + escapeHtml(selectedSession.cwd) + '</span>' : '')
1221
+ )
1222
+ : '<span class="topbar-tagline">Wand 控制台</span>' +
1223
+ '<span class="current-task hidden" id="current-task"></span>'
1224
+ ) +
1225
+ '</div>' +
1226
+ '<div class="topbar-right">' +
1227
+ (selectedSession && selectedSession.cwd ? '<button id="topbar-file-button" class="topbar-btn square' + (state.filePanelOpen ? ' active' : '') + '" type="button" aria-label="文件" title="文件"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>' : '') +
1228
+ '<div class="topbar-more-wrap">' +
1229
+ '<button id="topbar-more-button" class="topbar-btn square' + (state.topbarMoreOpen ? ' active' : '') + '" type="button" aria-label="更多" aria-haspopup="menu" aria-expanded="' + (state.topbarMoreOpen ? 'true' : 'false') + '" title="更多"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg></button>' +
1230
+ '<div id="topbar-more-menu" class="topbar-more-menu' + (state.topbarMoreOpen ? '' : ' hidden') + '" role="menu">' +
1231
+ '<button class="topbar-more-item" data-action="settings" type="button" role="menuitem"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg><span>设置</span></button>' +
1232
+ '<button class="topbar-more-item" data-action="refresh" type="button" role="menuitem"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg><span>刷新</span></button>' +
1233
+ '<button class="topbar-more-item' + (state.showInstallPrompt && state.deferredPrompt ? '' : ' hidden') + '" id="topbar-install-item" data-action="install" type="button" role="menuitem"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg><span>安装应用</span></button>' +
1234
+ '<button class="topbar-more-item topbar-more-item-danger" data-action="logout" type="button" role="menuitem"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg><span>退出</span></button>' +
1235
+ '</div>' +
1236
+ '</div>' +
1237
+ '</div>' +
1178
1238
  '</div>' +
1179
1239
  // File panel backdrop (mobile)
1180
1240
  '<div id="file-panel-backdrop" class="file-panel-backdrop' + (state.filePanelOpen ? " open" : "") + '"></div>' +
@@ -1208,7 +1268,7 @@
1208
1268
  '</div>' +
1209
1269
  '<div id="chat-output" class="chat-container hidden">' +
1210
1270
  '<div class="chat-overlay-controls">' +
1211
- '<button id="chat-follow-toggle" class="chat-follow-toggle topbar-btn' + (state.chatAutoFollow ? ' active' : '') + '" type="button" aria-pressed="' + (state.chatAutoFollow ? 'true' : 'false') + '" title="' + (state.chatAutoFollow ? '追踪底部:开启' : '追踪底部:已暂停') + '">' + (state.chatAutoFollow ? '追底' : '暂停') + '</button>' +
1271
+ '<button id="chat-follow-toggle" class="chat-follow-toggle topbar-btn' + (state.chatAutoFollow ? ' active' : '') + '" type="button" aria-pressed="' + (state.chatAutoFollow ? 'true' : 'false') + '" aria-label="' + (state.chatAutoFollow ? '追踪底部:开启' : '追踪底部:已暂停') + '" title="' + (state.chatAutoFollow ? '追踪底部:开启(点击暂停)' : '追踪底部:已暂停(点击开启)') + '">' + (state.chatAutoFollow ? '<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3.5 2.5l4.5 4.5 4.5-4.5"/><path d="M3.5 8.5l4.5 4.5 4.5-4.5"/></svg>' : '<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5.5 3v10"/><path d="M10.5 3v10"/></svg>') + '</button>' +
1212
1272
  '</div>' +
1213
1273
  '<button id="chat-jump-bottom" class="chat-jump-bottom' + (state.showChatJumpToBottom ? ' visible' : '') + '" type="button" title="回到底部并继续追底" aria-label="回到底部"><svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 3.5v9M3.5 8l4.5 4.5L12.5 8"/></svg></button>' +
1214
1274
  '</div>' +
@@ -1412,58 +1472,81 @@
1412
1472
  '<div class="settings-about-row"><span class="settings-label">Node.js 要求</span><span class="settings-value" id="settings-node-req">-</span></div>' +
1413
1473
  '<div class="settings-about-row"><span class="settings-label">仓库地址</span><span class="settings-value" id="settings-repo-url"><a href="#" target="_blank" rel="noopener">-</a></span></div>' +
1414
1474
  '</div>' +
1415
- '<div class="settings-update-section">' +
1475
+ '<div class="settings-update-section" id="web-update-section">' +
1476
+ '<div class="settings-section-head">' +
1477
+ '<span class="settings-section-icon">🌐</span>' +
1478
+ '<div class="settings-section-head-text">' +
1479
+ '<h4 class="settings-section-heading">Web 端</h4>' +
1480
+ '<p class="settings-section-sub">浏览器访问的服务版本</p>' +
1481
+ '</div>' +
1482
+ '</div>' +
1416
1483
  '<div class="settings-about-row">' +
1417
1484
  '<span class="settings-label">最新版本</span>' +
1418
1485
  '<span class="settings-value" id="settings-latest-version">-</span>' +
1419
1486
  '</div>' +
1420
1487
  '<div class="settings-update-actions">' +
1421
- '<button id="check-update-button" class="btn btn-ghost btn-sm">\u68c0\u67e5\u66f4\u65b0</button>' +
1488
+ '<button id="check-update-button" class="btn btn-secondary btn-sm">\u68c0\u67e5\u66f4\u65b0</button>' +
1422
1489
  '<button id="do-update-button" class="btn btn-primary btn-sm hidden">\u66f4\u65b0\u5230\u6700\u65b0\u7248</button>' +
1423
1490
  '<button id="do-restart-button" class="btn btn-success btn-sm hidden">\u91cd\u542f\u751f\u6548</button>' +
1424
1491
  '</div>' +
1425
1492
  '<p id="update-message" class="hint hidden"></p>' +
1426
- '<div class="settings-auto-update-row" style="display:flex;align-items:center;justify-content:space-between;margin-top:10px">' +
1427
- '<span class="settings-label">自动更新</span>' +
1428
- '<label style="position:relative;cursor:pointer">' +
1493
+ '<div class="settings-toggle-row">' +
1494
+ '<div class="settings-toggle-text">' +
1495
+ '<span class="settings-toggle-title">自动更新</span>' +
1496
+ '<span class="settings-toggle-desc">检测到新版本将自动下载安装并重启服务。</span>' +
1497
+ '</div>' +
1498
+ '<label class="settings-switch">' +
1429
1499
  '<input type="checkbox" id="auto-update-web-toggle" class="switch-toggle">' +
1430
1500
  '<span class="switch-slider"></span>' +
1431
1501
  '</label>' +
1432
1502
  '</div>' +
1433
- '<p class="hint" style="margin-top:2px">开启后,检测到新版本将自动下载安装并重启服务。</p>' +
1434
1503
  '</div>' +
1435
- '<div class="settings-update-section" id="android-apk-section">' +
1504
+ '<div class="settings-update-section hidden" id="android-apk-section">' +
1505
+ '<div class="settings-section-head">' +
1506
+ '<span class="settings-section-icon">📱</span>' +
1507
+ '<div class="settings-section-head-text">' +
1508
+ '<h4 class="settings-section-heading">Android App</h4>' +
1509
+ '<p class="settings-section-sub">原生客户端版本与 APK 下载</p>' +
1510
+ '</div>' +
1511
+ '</div>' +
1436
1512
  '<div id="android-apk-current-row" class="settings-about-row hidden">' +
1437
1513
  '<span class="settings-label">当前版本</span>' +
1438
1514
  '<span class="settings-value" id="settings-android-apk-current">-</span>' +
1439
1515
  '</div>' +
1440
- '<div id="android-apk-github-row" class="settings-about-row hidden">' +
1516
+ '<div id="android-apk-github-row" class="settings-about-row settings-about-row-action hidden">' +
1441
1517
  '<span class="settings-label">线上版本</span>' +
1442
- '<span class="settings-value" id="settings-android-apk-github" style="flex:1">-</span>' +
1443
- '<button id="download-github-apk-btn" class="btn btn-ghost btn-sm hidden" type="button" style="margin-left:8px;flex-shrink:0">下载</button>' +
1518
+ '<span class="settings-value settings-value-flex" id="settings-android-apk-github">-</span>' +
1519
+ '<button id="download-github-apk-btn" class="btn btn-secondary btn-sm hidden" type="button">下载</button>' +
1444
1520
  '</div>' +
1445
- '<div id="android-apk-local-row" class="settings-about-row hidden">' +
1521
+ '<div id="android-apk-local-row" class="settings-about-row settings-about-row-action hidden">' +
1446
1522
  '<span class="settings-label">本地版本</span>' +
1447
- '<span class="settings-value" id="settings-android-apk-local" style="flex:1">-</span>' +
1448
- '<button id="download-local-apk-btn" class="btn btn-ghost btn-sm hidden" type="button" style="margin-left:8px;flex-shrink:0">下载</button>' +
1523
+ '<span class="settings-value settings-value-flex" id="settings-android-apk-local">-</span>' +
1524
+ '<button id="download-local-apk-btn" class="btn btn-secondary btn-sm hidden" type="button">下载</button>' +
1449
1525
  '</div>' +
1450
- '<div id="android-auto-update-row" class="hidden" style="display:flex;align-items:center;justify-content:space-between;margin-top:10px">' +
1451
- '<span class="settings-label">自动更新</span>' +
1452
- '<label style="position:relative;cursor:pointer">' +
1526
+ '<div id="android-auto-update-row" class="settings-toggle-row hidden">' +
1527
+ '<div class="settings-toggle-text">' +
1528
+ '<span class="settings-toggle-title">自动更新</span>' +
1529
+ '<span class="settings-toggle-desc" id="android-auto-update-hint">检测到新版 APK 将自动下载安装。</span>' +
1530
+ '</div>' +
1531
+ '<label class="settings-switch">' +
1453
1532
  '<input type="checkbox" id="auto-update-apk-toggle" class="switch-toggle">' +
1454
1533
  '<span class="switch-slider"></span>' +
1455
1534
  '</label>' +
1456
1535
  '</div>' +
1457
- '<p id="android-auto-update-hint" class="hint hidden" style="margin-top:2px">开启后,检测到新版 APK 将自动下载安装。</p>' +
1458
1536
  '<p id="android-apk-message" class="hint hidden"></p>' +
1459
1537
  '</div>' +
1460
1538
  '<div class="settings-update-section" id="android-connect-section">' +
1461
- '<div class="settings-section-title" style="margin-bottom:8px">App 连接码</div>' +
1539
+ '<div class="settings-section-head">' +
1540
+ '<span class="settings-section-icon">🔗</span>' +
1541
+ '<div class="settings-section-head-text">' +
1542
+ '<h4 class="settings-section-heading">App 连接码</h4>' +
1543
+ '<p class="settings-section-sub">粘贴到 Android App 即可自动连接,无需密码;改密码后失效。</p>' +
1544
+ '</div>' +
1545
+ '</div>' +
1462
1546
  '<div class="settings-connect-url-box">' +
1463
- '<code id="android-connect-code" class="settings-connect-url-text" style="font-size:12px;word-break:break-all">-</code>' +
1464
- '<button id="copy-connect-code-button" class="btn btn-ghost btn-sm" type="button" title="复制连接码">复制</button>' +
1547
+ '<code id="android-connect-code" class="settings-connect-url-text">-</code>' +
1548
+ '<button id="copy-connect-code-button" class="btn btn-secondary btn-sm" type="button" title="复制连接码">复制</button>' +
1465
1549
  '</div>' +
1466
- '<p class="hint">复制此连接码粘贴到 Android App 即可自动连接,无需输入密码。修改密码后连接码自动失效。</p>' +
1467
1550
  '</div>' +
1468
1551
  '</div>' +
1469
1552
 
@@ -1473,54 +1556,88 @@
1473
1556
  '<h3 class="settings-panel-title">通知</h3>' +
1474
1557
  '<p class="settings-panel-desc">设置提示音、系统通知和浏览器通知的行为。</p>' +
1475
1558
  '</div>' +
1476
- '<div class="settings-section-title">\u901a\u77e5\u504f\u597d</div>' +
1477
- '<div class="field field-inline">' +
1478
- '<input id="cfg-notif-sound" type="checkbox" class="field-checkbox" />' +
1479
- '<label class="field-label" for="cfg-notif-sound">\u64ad\u653e\u63d0\u793a\u97f3</label>' +
1480
- '</div>' +
1481
- '<p class="hint" style="margin-top:0;margin-bottom:10px">\u91cd\u8981\u901a\u77e5\uff08\u7248\u672c\u66f4\u65b0\u3001\u6743\u9650\u7b49\u5f85\u7b49\uff09\u65f6\u64ad\u653e\u67d4\u548c\u7684\u63d0\u793a\u97f3</p>' +
1482
- '<div class="field" id="notif-volume-field" style="margin-bottom:10px">' +
1483
- '<label class="field-label" style="margin-bottom:4px">\u97f3\u91cf</label>' +
1484
- '<div style="display:flex;align-items:center;gap:8px">' +
1485
- '<input id="cfg-notif-volume" type="range" min="0" max="100" step="5" style="flex:1;accent-color:var(--accent)" />' +
1486
- '<span id="cfg-notif-volume-val" style="min-width:32px;text-align:right;font-size:12px;color:var(--text-secondary)">80%</span>' +
1559
+ '<div class="settings-notification-section">' +
1560
+ '<div class="settings-section-head">' +
1561
+ '<span class="settings-section-icon">🔔</span>' +
1562
+ '<div class="settings-section-head-text">' +
1563
+ '<h4 class="settings-section-heading">通知偏好</h4>' +
1564
+ '<p class="settings-section-sub">提示音与应用内通知气泡</p>' +
1565
+ '</div>' +
1566
+ '</div>' +
1567
+ '<div class="settings-toggle-row">' +
1568
+ '<div class="settings-toggle-text">' +
1569
+ '<label class="settings-toggle-title" for="cfg-notif-sound">播放提示音</label>' +
1570
+ '<span class="settings-toggle-desc">重要通知(版本更新、权限等待等)时播放柔和提示音。</span>' +
1571
+ '</div>' +
1572
+ '<label class="settings-switch">' +
1573
+ '<input id="cfg-notif-sound" type="checkbox" class="switch-toggle" />' +
1574
+ '<span class="switch-slider"></span>' +
1575
+ '</label>' +
1576
+ '</div>' +
1577
+ '<div class="settings-range-row" id="notif-volume-field">' +
1578
+ '<label class="settings-range-label" for="cfg-notif-volume">音量</label>' +
1579
+ '<input id="cfg-notif-volume" type="range" min="0" max="100" step="5" class="settings-range" />' +
1580
+ '<span id="cfg-notif-volume-val" class="settings-range-value">80%</span>' +
1581
+ '</div>' +
1582
+ '<div class="settings-toggle-row">' +
1583
+ '<div class="settings-toggle-text">' +
1584
+ '<label class="settings-toggle-title" for="cfg-notif-bubble">应用内通知气泡</label>' +
1585
+ '<span class="settings-toggle-desc">在页面顶部弹出浮动通知气泡。</span>' +
1586
+ '</div>' +
1587
+ '<label class="settings-switch">' +
1588
+ '<input id="cfg-notif-bubble" type="checkbox" class="switch-toggle" />' +
1589
+ '<span class="switch-slider"></span>' +
1590
+ '</label>' +
1487
1591
  '</div>' +
1488
1592
  '</div>' +
1489
- '<div class="field field-inline">' +
1490
- '<input id="cfg-notif-bubble" type="checkbox" class="field-checkbox" />' +
1491
- '<label class="field-label" for="cfg-notif-bubble">\u5e94\u7528\u5185\u901a\u77e5\u6c14\u6ce1</label>' +
1492
- '</div>' +
1493
- '<p class="hint" style="margin-top:0;margin-bottom:10px">\u5728\u9875\u9762\u9876\u90e8\u5f39\u51fa\u6d6e\u52a8\u901a\u77e5\u6c14\u6ce1</p>' +
1494
- '<div id="native-sound-section" class="settings-notification-section hidden" style="margin-top:6px">' +
1495
- '<div class="settings-section-title">\u7cfb\u7edf\u901a\u77e5\u94c3\u58f0</div>' +
1496
- '<div class="settings-about-row">' +
1497
- '<span class="settings-label">\u94c3\u58f0</span>' +
1498
- '<div style="display:flex;align-items:center;gap:6px">' +
1499
- '<select id="native-sound-select" class="field-select" style="min-width:100px"></select>' +
1500
- '<button id="native-sound-preview" class="btn btn-ghost btn-sm">\u25b6 \u8bd5\u542c</button>' +
1593
+ '<div id="native-sound-section" class="settings-notification-section hidden">' +
1594
+ '<div class="settings-section-head">' +
1595
+ '<span class="settings-section-icon">🎵</span>' +
1596
+ '<div class="settings-section-head-text">' +
1597
+ '<h4 class="settings-section-heading">系统通知铃声</h4>' +
1598
+ '<p class="settings-section-sub">选择 Android 系统通知使用的铃声</p>' +
1501
1599
  '</div>' +
1502
1600
  '</div>' +
1503
- '<p class="hint" style="margin-top:0">\u9009\u62e9 Android \u7cfb\u7edf\u901a\u77e5\u4f7f\u7528\u7684\u94c3\u58f0</p>' +
1601
+ '<div class="settings-row-with-action">' +
1602
+ '<select id="native-sound-select" class="field-input field-select"></select>' +
1603
+ '<button id="native-sound-preview" class="btn btn-secondary btn-sm" type="button">▶ 试听</button>' +
1604
+ '</div>' +
1504
1605
  '</div>' +
1505
- '<div id="native-haptic-section" class="settings-notification-section hidden" style="margin-top:6px">' +
1506
- '<div class="settings-section-title">\u89e6\u611f\u53cd\u9988</div>' +
1507
- '<div class="field field-inline" style="margin:4px 0">' +
1508
- '<input id="cfg-haptic-enabled" type="checkbox" class="field-checkbox" />' +
1509
- '<label class="field-label" for="cfg-haptic-enabled">\u542f\u7528\u89e6\u611f\u53cd\u9988</label>' +
1606
+ '<div id="native-haptic-section" class="settings-notification-section hidden">' +
1607
+ '<div class="settings-section-head">' +
1608
+ '<span class="settings-section-icon">📳</span>' +
1609
+ '<div class="settings-section-head-text">' +
1610
+ '<h4 class="settings-section-heading">触感反馈</h4>' +
1611
+ '<p class="settings-section-sub">按钮操作和任务完成时提供振动反馈</p>' +
1612
+ '</div>' +
1613
+ '</div>' +
1614
+ '<div class="settings-toggle-row">' +
1615
+ '<div class="settings-toggle-text">' +
1616
+ '<label class="settings-toggle-title" for="cfg-haptic-enabled">启用触感反馈</label>' +
1617
+ '</div>' +
1618
+ '<label class="settings-switch">' +
1619
+ '<input id="cfg-haptic-enabled" type="checkbox" class="switch-toggle" />' +
1620
+ '<span class="switch-slider"></span>' +
1621
+ '</label>' +
1510
1622
  '</div>' +
1511
- '<p class="hint" style="margin-top:0">\u6309\u94ae\u64cd\u4f5c\u548c\u4efb\u52a1\u5b8c\u6210\u65f6\u63d0\u4f9b\u632f\u52a8\u53cd\u9988</p>' +
1512
1623
  '</div>' +
1513
- '<div class="settings-notification-section" style="margin-top:6px">' +
1514
- '<div class="settings-section-title">\u6d4f\u89c8\u5668\u901a\u77e5</div>' +
1624
+ '<div class="settings-notification-section">' +
1625
+ '<div class="settings-section-head">' +
1626
+ '<span class="settings-section-icon">🌐</span>' +
1627
+ '<div class="settings-section-head-text">' +
1628
+ '<h4 class="settings-section-heading">浏览器通知</h4>' +
1629
+ '<p class="settings-section-sub">来自系统通知中心的弹窗</p>' +
1630
+ '</div>' +
1631
+ '</div>' +
1515
1632
  '<div class="settings-about-row">' +
1516
- '<span class="settings-label">\u6388\u6743\u72b6\u6001</span>' +
1633
+ '<span class="settings-label">授权状态</span>' +
1517
1634
  '<span class="settings-value" id="notification-permission-status">-</span>' +
1518
1635
  '</div>' +
1519
1636
  '<div class="settings-update-actions">' +
1520
- '<button id="notification-request-btn" class="btn btn-ghost btn-sm hidden">\u6388\u6743\u901a\u77e5</button>' +
1521
- '<button id="notification-reset-btn" class="btn btn-ghost btn-sm hidden">\u91cd\u65b0\u6388\u6743</button>' +
1522
- '<button id="notification-test-btn" class="btn btn-ghost btn-sm">\u53d1\u9001\u6d4b\u8bd5\u901a\u77e5</button>' +
1523
- '<button id="notification-test-delay-btn" class="btn btn-ghost btn-sm">10 \u79d2\u540e\u53d1\u9001</button>' +
1637
+ '<button id="notification-request-btn" class="btn btn-secondary btn-sm hidden" type="button">授权通知</button>' +
1638
+ '<button id="notification-reset-btn" class="btn btn-secondary btn-sm hidden" type="button">重新授权</button>' +
1639
+ '<button id="notification-test-btn" class="btn btn-secondary btn-sm" type="button">发送测试通知</button>' +
1640
+ '<button id="notification-test-delay-btn" class="btn btn-secondary btn-sm" type="button">10 秒后发送</button>' +
1524
1641
  '</div>' +
1525
1642
  '<p id="notification-test-message" class="hint hidden"></p>' +
1526
1643
  '</div>' +
@@ -1578,13 +1695,13 @@
1578
1695
  '<p class="field-hint" style="margin-top:-4px;">设置回复语言后,Claude 将尽量使用指定语言回复。</p>' +
1579
1696
  '<div class="field">' +
1580
1697
  '<label class="field-label" for="cfg-default-model">默认模型</label>' +
1581
- '<div style="display:flex;gap:8px;align-items:center;">' +
1582
- '<select id="cfg-default-model" class="field-input" style="flex:1;">' +
1698
+ '<div class="settings-row-with-action">' +
1699
+ '<select id="cfg-default-model" class="field-input field-select">' +
1583
1700
  '<option value="">跟随 Claude Code 默认</option>' +
1584
1701
  '</select>' +
1585
- '<button type="button" id="cfg-default-model-refresh" class="btn btn-ghost btn-sm" title="刷新模型列表">刷新</button>' +
1702
+ '<button type="button" id="cfg-default-model-refresh" class="btn btn-secondary btn-sm" title="刷新模型列表">刷新</button>' +
1586
1703
  '</div>' +
1587
- '<p class="field-hint" id="cfg-default-model-version" style="margin-top:4px;">新建会话时默认使用该模型;运行中的会话可在输入框切换。</p>' +
1704
+ '<p class="field-hint" id="cfg-default-model-version">新建会话时默认使用该模型;运行中的会话可在输入框切换。</p>' +
1588
1705
  '</div>' +
1589
1706
  '<div class="field">' +
1590
1707
  '<label class="field-label" for="cfg-cwd">默认工作目录</label>' +
@@ -1595,24 +1712,29 @@
1595
1712
  '<input id="cfg-shell" type="text" class="field-input" placeholder="/bin/bash" />' +
1596
1713
  '</div>' +
1597
1714
  (typeof WandNative !== "undefined" && typeof WandNative.getAppIcon === "function" ?
1598
- '<div style="margin-bottom:16px">' +
1599
- '<div class="settings-section-title">应用图标</div>' +
1600
- '<p class="hint" style="margin-top:-4px;margin-bottom:10px">选择 App 启动器图标,返回桌面后生效</p>' +
1601
- '<div id="app-icon-picker" style="display:flex;gap:16px">' +
1602
- '<div class="app-icon-option" data-icon="shorthair" style="cursor:pointer;text-align:center">' +
1603
- '<div class="app-icon-preview" style="width:56px;height:56px;border-radius:12px;overflow:hidden;border:3px solid transparent;background:var(--bg-tertiary);display:flex;align-items:center;justify-content:center;margin-bottom:4px">' +
1604
- PIXEL_AVATAR.user +
1605
- '</div>' +
1606
- '<span style="font-size:0.72rem;color:var(--text-secondary)">赛博虎妞</span>' +
1715
+ '<div class="settings-app-icon-block">' +
1716
+ '<div class="settings-section-head">' +
1717
+ '<span class="settings-section-icon">🎨</span>' +
1718
+ '<div class="settings-section-head-text">' +
1719
+ '<h4 class="settings-section-heading">应用图标</h4>' +
1720
+ '<p class="settings-section-sub">选择 App 启动器图标,返回桌面后生效</p>' +
1607
1721
  '</div>' +
1608
- '<div class="app-icon-option" data-icon="garfield" style="cursor:pointer;text-align:center">' +
1609
- '<div class="app-icon-preview" style="width:56px;height:56px;border-radius:12px;overflow:hidden;border:3px solid transparent;background:var(--bg-tertiary);display:flex;align-items:center;justify-content:center;margin-bottom:4px">' +
1722
+ '</div>' +
1723
+ '<div id="app-icon-picker" class="settings-app-icon-picker">' +
1724
+ '<button type="button" class="settings-app-icon-option" data-icon="shorthair">' +
1725
+ '<span class="settings-app-icon-preview">' +
1726
+ PIXEL_AVATAR.user +
1727
+ '</span>' +
1728
+ '<span class="settings-app-icon-label">赛博虎妞</span>' +
1729
+ '</button>' +
1730
+ '<button type="button" class="settings-app-icon-option" data-icon="garfield">' +
1731
+ '<span class="settings-app-icon-preview">' +
1610
1732
  PIXEL_AVATAR.assistant +
1611
- '</div>' +
1612
- '<span style="font-size:0.72rem;color:var(--text-secondary)">勤劳初二</span>' +
1613
- '</div>' +
1733
+ '</span>' +
1734
+ '<span class="settings-app-icon-label">勤劳初二</span>' +
1735
+ '</button>' +
1614
1736
  '</div>' +
1615
- '<p id="app-icon-message" class="hint hidden" style="margin-top:8px"></p>' +
1737
+ '<p id="app-icon-message" class="hint hidden"></p>' +
1616
1738
  '</div>'
1617
1739
  : '') +
1618
1740
  '<div class="settings-actions settings-actions-sticky">' +
@@ -1628,7 +1750,13 @@
1628
1750
  '<p class="settings-panel-desc">管理登录密码与 SSL 证书,敏感变更请确认后再保存。</p>' +
1629
1751
  '</div>' +
1630
1752
  '<div class="settings-card">' +
1631
- '<h3 class="settings-section-title">\ud83d\udd12 修改密码</h3>' +
1753
+ '<div class="settings-card-head">' +
1754
+ '<span class="settings-card-icon">\ud83d\udd12</span>' +
1755
+ '<div class="settings-card-head-text">' +
1756
+ '<h3 class="settings-card-title">修改密码</h3>' +
1757
+ '<p class="settings-card-desc">至少 6 个字符;保存后下次登录生效。</p>' +
1758
+ '</div>' +
1759
+ '</div>' +
1632
1760
  '<div class="field">' +
1633
1761
  '<label class="field-label" for="new-password">新密码</label>' +
1634
1762
  '<input id="new-password" type="password" class="field-input" placeholder="输入新密码(至少 6 个字符)" autocomplete="new-password" />' +
@@ -1637,22 +1765,45 @@
1637
1765
  '<label class="field-label" for="confirm-password">确认密码</label>' +
1638
1766
  '<input id="confirm-password" type="password" class="field-input" placeholder="再次输入新密码" autocomplete="new-password" />' +
1639
1767
  '</div>' +
1640
- '<button id="save-password-button" class="btn btn-primary btn-block">保存密码</button>' +
1768
+ '<div class="settings-card-actions">' +
1769
+ '<button id="save-password-button" class="btn btn-primary">保存密码</button>' +
1770
+ '</div>' +
1641
1771
  '<p id="settings-error" class="error-message hidden"></p>' +
1642
- '<p id="settings-success" class="hint hidden" style="color: var(--success);"></p>' +
1772
+ '<p id="settings-success" class="hint settings-success-message hidden"></p>' +
1643
1773
  '</div>' +
1644
1774
  '<div class="settings-card">' +
1645
- '<h3 class="settings-section-title">\ud83d\udd10 SSL 证书</h3>' +
1646
- '<p class="settings-hint" id="cert-status">加载中...</p>' +
1775
+ '<div class="settings-card-head">' +
1776
+ '<span class="settings-card-icon">\ud83d\udd10</span>' +
1777
+ '<div class="settings-card-head-text">' +
1778
+ '<h3 class="settings-card-title">SSL 证书</h3>' +
1779
+ '<p class="settings-card-desc" id="cert-status">加载中...</p>' +
1780
+ '</div>' +
1781
+ '</div>' +
1647
1782
  '<div class="field">' +
1648
1783
  '<label class="field-label" for="cert-key-file">私钥文件 (.key)</label>' +
1649
- '<input id="cert-key-file" type="file" class="field-input field-file" accept=".key,.pem" />' +
1784
+ '<div class="file-picker">' +
1785
+ '<input id="cert-key-file" type="file" class="file-picker-input" accept=".key,.pem" />' +
1786
+ '<label for="cert-key-file" class="file-picker-trigger">' +
1787
+ '<svg class="file-picker-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"></path></svg>' +
1788
+ '<span class="file-picker-label">选择私钥</span>' +
1789
+ '</label>' +
1790
+ '<span class="file-picker-name" data-default="未选择文件">未选择文件</span>' +
1791
+ '</div>' +
1650
1792
  '</div>' +
1651
1793
  '<div class="field">' +
1652
1794
  '<label class="field-label" for="cert-cert-file">证书文件 (.crt/.pem)</label>' +
1653
- '<input id="cert-cert-file" type="file" class="field-input field-file" accept=".crt,.pem,.cert" />' +
1795
+ '<div class="file-picker">' +
1796
+ '<input id="cert-cert-file" type="file" class="file-picker-input" accept=".crt,.pem,.cert" />' +
1797
+ '<label for="cert-cert-file" class="file-picker-trigger">' +
1798
+ '<svg class="file-picker-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline></svg>' +
1799
+ '<span class="file-picker-label">选择证书</span>' +
1800
+ '</label>' +
1801
+ '<span class="file-picker-name" data-default="未选择文件">未选择文件</span>' +
1802
+ '</div>' +
1803
+ '</div>' +
1804
+ '<div class="settings-card-actions">' +
1805
+ '<button id="upload-cert-button" class="btn btn-primary">上传证书</button>' +
1654
1806
  '</div>' +
1655
- '<button id="upload-cert-button" class="btn btn-primary btn-block">上传证书</button>' +
1656
1807
  '<p id="cert-message" class="hint hidden"></p>' +
1657
1808
  '</div>' +
1658
1809
  '</div>' +
@@ -1727,7 +1878,7 @@
1727
1878
  }
1728
1879
 
1729
1880
  function renderSessions() {
1730
- var activeSessions = state.sessions.filter(function(session) { return !session.archived && !session.resumedToSessionId; });
1881
+ var activeSessions = state.sessions.filter(function(session) { return !session.archived; });
1731
1882
  var archivedSessions = state.sessions.filter(function(session) { return session.archived; });
1732
1883
  var groups = [];
1733
1884
  groups.push(renderSessionManageBar());
@@ -1900,9 +2051,7 @@
1900
2051
  }
1901
2052
 
1902
2053
  function getSelectableSessions() {
1903
- return state.sessions.filter(function(session) {
1904
- return session.archived || !session.resumedToSessionId;
1905
- });
2054
+ return state.sessions.slice();
1906
2055
  }
1907
2056
 
1908
2057
  function countSelectableItems() {
@@ -2649,10 +2798,8 @@
2649
2798
  var statusMap = {
2650
2799
  "stopped": "已停止",
2651
2800
  "running": "运行中",
2652
- "idle": "空闲",
2653
- "thinking": "思考中",
2654
- "waiting-input": "等待输入",
2655
- "initializing": "启动中"
2801
+ "exited": "已退出",
2802
+ "failed": "已失败"
2656
2803
  };
2657
2804
  return statusMap[session.status] || session.status;
2658
2805
  }
@@ -3363,7 +3510,7 @@
3363
3510
  // App icon picker (APK only)
3364
3511
  var appIconPicker = document.getElementById("app-icon-picker");
3365
3512
  if (appIconPicker) {
3366
- var appIconOpts = appIconPicker.querySelectorAll(".app-icon-option");
3513
+ var appIconOpts = appIconPicker.querySelectorAll(".settings-app-icon-option");
3367
3514
  for (var ai = 0; ai < appIconOpts.length; ai++) {
3368
3515
  appIconOpts[ai].addEventListener("click", function() {
3369
3516
  var iconName = this.getAttribute("data-icon");
@@ -3384,6 +3531,24 @@
3384
3531
  }
3385
3532
  var uploadCertBtn = document.getElementById("upload-cert-button");
3386
3533
  if (uploadCertBtn) uploadCertBtn.addEventListener("click", uploadCertificates);
3534
+ var filePickerInputs = document.querySelectorAll(".file-picker-input");
3535
+ for (var fpi = 0; fpi < filePickerInputs.length; fpi++) {
3536
+ (function(input) {
3537
+ input.addEventListener("change", function() {
3538
+ var picker = input.closest(".file-picker");
3539
+ if (!picker) return;
3540
+ var nameEl = picker.querySelector(".file-picker-name");
3541
+ if (!nameEl) return;
3542
+ if (input.files && input.files[0]) {
3543
+ nameEl.textContent = input.files[0].name;
3544
+ picker.classList.add("file-picker-has-file");
3545
+ } else {
3546
+ nameEl.textContent = nameEl.getAttribute("data-default") || "未选择文件";
3547
+ picker.classList.remove("file-picker-has-file");
3548
+ }
3549
+ });
3550
+ })(filePickerInputs[fpi]);
3551
+ }
3387
3552
  var checkUpdateBtn = document.getElementById("check-update-button");
3388
3553
  if (checkUpdateBtn) checkUpdateBtn.addEventListener("click", checkForUpdate);
3389
3554
  var doUpdateBtn = document.getElementById("do-update-button");
@@ -3678,6 +3843,88 @@
3678
3843
  var filePanelBackdrop = document.getElementById("file-panel-backdrop");
3679
3844
  if (filePanelBackdrop) filePanelBackdrop.addEventListener("click", closeFilePanel);
3680
3845
 
3846
+ // Topbar: file button (mirrors toggleFilePanel)
3847
+ var topbarFileBtn = document.getElementById("topbar-file-button");
3848
+ if (topbarFileBtn) topbarFileBtn.addEventListener("click", toggleFilePanel);
3849
+
3850
+ // Topbar: cwd click → open file panel
3851
+ var topbarCwdEl = document.getElementById("topbar-cwd");
3852
+ if (topbarCwdEl) {
3853
+ topbarCwdEl.addEventListener("click", function() {
3854
+ if (!state.filePanelOpen) toggleFilePanel();
3855
+ });
3856
+ topbarCwdEl.addEventListener("keydown", function(e) {
3857
+ if (e.key === "Enter" || e.key === " ") {
3858
+ e.preventDefault();
3859
+ if (!state.filePanelOpen) toggleFilePanel();
3860
+ }
3861
+ });
3862
+ }
3863
+
3864
+ // Topbar: more menu
3865
+ var topbarMoreBtn = document.getElementById("topbar-more-button");
3866
+ var topbarMoreMenu = document.getElementById("topbar-more-menu");
3867
+ if (topbarMoreBtn && topbarMoreMenu) {
3868
+ topbarMoreBtn.addEventListener("click", function(e) {
3869
+ e.stopPropagation();
3870
+ state.topbarMoreOpen = !state.topbarMoreOpen;
3871
+ topbarMoreMenu.classList.toggle("hidden", !state.topbarMoreOpen);
3872
+ topbarMoreBtn.classList.toggle("active", state.topbarMoreOpen);
3873
+ topbarMoreBtn.setAttribute("aria-expanded", state.topbarMoreOpen ? "true" : "false");
3874
+ });
3875
+ topbarMoreMenu.addEventListener("click", function(e) {
3876
+ var btn = e.target && e.target.closest ? e.target.closest(".topbar-more-item") : null;
3877
+ if (!btn) return;
3878
+ var action = btn.getAttribute("data-action");
3879
+ // Close menu first regardless of action
3880
+ state.topbarMoreOpen = false;
3881
+ topbarMoreMenu.classList.add("hidden");
3882
+ topbarMoreBtn.classList.remove("active");
3883
+ topbarMoreBtn.setAttribute("aria-expanded", "false");
3884
+ switch (action) {
3885
+ case "settings":
3886
+ openSettingsModal();
3887
+ break;
3888
+ case "refresh":
3889
+ window.location.reload();
3890
+ break;
3891
+ case "install":
3892
+ if (state.deferredPrompt) {
3893
+ state.deferredPrompt.prompt();
3894
+ state.deferredPrompt.userChoice.then(function() {
3895
+ state.deferredPrompt = null;
3896
+ state.showInstallPrompt = false;
3897
+ updateInstallPrompt();
3898
+ });
3899
+ }
3900
+ break;
3901
+ case "logout":
3902
+ logout();
3903
+ break;
3904
+ }
3905
+ });
3906
+ // Close on outside click
3907
+ document.addEventListener("click", function(e) {
3908
+ if (!state.topbarMoreOpen) return;
3909
+ var wrap = topbarMoreMenu.parentElement;
3910
+ if (wrap && !wrap.contains(e.target)) {
3911
+ state.topbarMoreOpen = false;
3912
+ topbarMoreMenu.classList.add("hidden");
3913
+ topbarMoreBtn.classList.remove("active");
3914
+ topbarMoreBtn.setAttribute("aria-expanded", "false");
3915
+ }
3916
+ });
3917
+ // Close on ESC
3918
+ document.addEventListener("keydown", function(e) {
3919
+ if (e.key === "Escape" && state.topbarMoreOpen) {
3920
+ state.topbarMoreOpen = false;
3921
+ topbarMoreMenu.classList.add("hidden");
3922
+ topbarMoreBtn.classList.remove("active");
3923
+ topbarMoreBtn.setAttribute("aria-expanded", "false");
3924
+ }
3925
+ });
3926
+ }
3927
+
3681
3928
  // Terminal scale controls (topbar)
3682
3929
  var scaleDownBtn = document.getElementById("terminal-scale-down-top");
3683
3930
  var scaleUpBtn = document.getElementById("terminal-scale-up-top");
@@ -4547,9 +4794,148 @@
4547
4794
  requestSyncScrollbar();
4548
4795
  }
4549
4796
 
4797
+ // ──────── East-Asian-Wide padding for wterm WASM ────────
4798
+ //
4799
+ // wterm's WASM grid (as of @wterm/core 0.1.8/0.1.9) treats every
4800
+ // codepoint as occupying exactly 1 cell, while node-pty's backend
4801
+ // and Claude Code's TUI emit cursor-positioning sequences that
4802
+ // assume CJK / fullwidth / emoji codepoints occupy 2 columns
4803
+ // (Unicode TR11 East-Asian-Width = W or F). The mismatch makes
4804
+ // every CSI cursor move after CJK output drift by N/2 columns,
4805
+ // causing in-place rewrites (thinking spinner, todo list,
4806
+ // permission menus) to leave torn residue like "替替换换".
4807
+ //
4808
+ // Fix: insert U+2060 (Word Joiner — zero-width, unbreakable) after
4809
+ // each wide codepoint before handing the byte stream to the WASM
4810
+ // grid. The WJ takes one cell, so wide chars now occupy 2 cells —
4811
+ // matching the backend's column accounting exactly. The browser
4812
+ // renders WJ at zero width, so the visual layout stays correct.
4813
+ //
4814
+ // The scanner is ANSI-aware: it tracks ESC / CSI / OSC / DCS
4815
+ // / PM / APC state across chunk boundaries so wide codepoints
4816
+ // inside escape sequences (e.g. OSC window-title payloads) are
4817
+ // not padded — that would break sequence parsing.
4818
+ function isEastAsianWide(cp) {
4819
+ if (cp < 0x1100) return false;
4820
+ return (
4821
+ (cp >= 0x1100 && cp <= 0x115f) ||
4822
+ (cp >= 0x2329 && cp <= 0x232a) ||
4823
+ (cp >= 0x2e80 && cp <= 0x303e) ||
4824
+ (cp >= 0x3041 && cp <= 0x33ff) ||
4825
+ (cp >= 0x3400 && cp <= 0x4dbf) ||
4826
+ (cp >= 0x4e00 && cp <= 0x9fff) ||
4827
+ (cp >= 0xa000 && cp <= 0xa4cf) ||
4828
+ (cp >= 0xac00 && cp <= 0xd7a3) ||
4829
+ (cp >= 0xf900 && cp <= 0xfaff) ||
4830
+ (cp >= 0xfe30 && cp <= 0xfe4f) ||
4831
+ (cp >= 0xff00 && cp <= 0xff60) ||
4832
+ (cp >= 0xffe0 && cp <= 0xffe6) ||
4833
+ (cp >= 0x1f000 && cp <= 0x1f9ff) ||
4834
+ (cp >= 0x20000 && cp <= 0x2fffd) ||
4835
+ (cp >= 0x30000 && cp <= 0x3fffd)
4836
+ );
4837
+ }
4838
+
4839
+ var WAND_WIDE_FILLER = "\u2060";
4840
+
4841
+ function createWideParserState() { return { mode: "normal" }; }
4842
+
4843
+ function widePadAnsi(data, st) {
4844
+ if (!data) return "";
4845
+ var s = String(data);
4846
+ var out = "";
4847
+ for (var i = 0; i < s.length; i++) {
4848
+ var code = s.charCodeAt(i);
4849
+ var cp = code;
4850
+ var consumed = 1;
4851
+ if (code >= 0xd800 && code <= 0xdbff && i + 1 < s.length) {
4852
+ var lo = s.charCodeAt(i + 1);
4853
+ if (lo >= 0xdc00 && lo <= 0xdfff) {
4854
+ cp = (code - 0xd800) * 0x400 + (lo - 0xdc00) + 0x10000;
4855
+ consumed = 2;
4856
+ }
4857
+ }
4858
+ var ch = consumed === 2 ? s.substr(i, 2) : s.charAt(i);
4859
+ switch (st.mode) {
4860
+ case "normal":
4861
+ if (cp === 0x1b) { st.mode = "esc"; out += ch; }
4862
+ else if (cp === 0x9b) { st.mode = "csi"; out += ch; }
4863
+ else if (cp === 0x9d || cp === 0x90 || cp === 0x9e || cp === 0x9f) {
4864
+ st.mode = "string"; out += ch;
4865
+ } else {
4866
+ out += ch;
4867
+ if (isEastAsianWide(cp)) out += WAND_WIDE_FILLER;
4868
+ }
4869
+ break;
4870
+ case "esc":
4871
+ out += ch;
4872
+ if (cp === 0x5b) st.mode = "csi";
4873
+ else if (cp === 0x5d || cp === 0x50 || cp === 0x58 ||
4874
+ cp === 0x5e || cp === 0x5f) st.mode = "string";
4875
+ else st.mode = "normal";
4876
+ break;
4877
+ case "csi":
4878
+ out += ch;
4879
+ if (cp >= 0x40 && cp <= 0x7e) st.mode = "normal";
4880
+ break;
4881
+ case "string":
4882
+ out += ch;
4883
+ if (cp === 0x07 || cp === 0x9c) st.mode = "normal";
4884
+ else if (cp === 0x1b) st.mode = "string-esc";
4885
+ break;
4886
+ case "string-esc":
4887
+ out += ch;
4888
+ if (cp === 0x5c) st.mode = "normal";
4889
+ else st.mode = "string";
4890
+ break;
4891
+ }
4892
+ i += consumed - 1;
4893
+ }
4894
+ return out;
4895
+ }
4896
+
4897
+ function wandTerminalWrite(terminal, data) {
4898
+ if (!terminal || data == null) return;
4899
+ if (!state.wideParserState) state.wideParserState = createWideParserState();
4900
+ terminal.write(widePadAnsi(data, state.wideParserState));
4901
+ }
4902
+
4903
+ function resetWideParserState() {
4904
+ state.wideParserState = createWideParserState();
4905
+ }
4906
+
4907
+ // Strip the wide-pad filler from copied text so users pasting
4908
+ // selected terminal output don't get hidden U+2060 sprinkled
4909
+ // through every CJK string.
4910
+ function stripWideFillerForCopy() {
4911
+ if (typeof document === "undefined") return;
4912
+ document.addEventListener("copy", function(e) {
4913
+ var sel = window.getSelection && window.getSelection();
4914
+ if (!sel || sel.isCollapsed) return;
4915
+ var anchor = sel.anchorNode;
4916
+ var node = anchor && anchor.nodeType === 3 ? anchor.parentNode : anchor;
4917
+ var output = document.getElementById("output");
4918
+ if (!output || !node || !output.contains(node)) return;
4919
+ var text = sel.toString();
4920
+ if (text.indexOf(WAND_WIDE_FILLER) === -1) return;
4921
+ if (e.clipboardData) {
4922
+ e.clipboardData.setData("text/plain", text.split(WAND_WIDE_FILLER).join(""));
4923
+ e.preventDefault();
4924
+ }
4925
+ });
4926
+ }
4927
+ stripWideFillerForCopy();
4928
+
4550
4929
  function resetTerminal() {
4551
- if (!state.terminal || typeof state.terminal.reset !== "function") return;
4552
- state.terminal.reset();
4930
+ if (!state.terminal || typeof state.terminal.write !== "function") return;
4931
+ // @wterm/dom 的 WTerm 类没有暴露 reset() 方法(grep 全包零匹配),
4932
+ // 所以早期的 state.terminal.reset() 调用是 no-op——softResyncTerminal
4933
+ // 实际只做了"再写一份 terminalOutput 追加",旧 grid 不会被清空,
4934
+ // 这就是"点刷新按钮没用、只有窗口尺寸变化才修"的根因。
4935
+ // 改用 ANSI RIS (Reset to Initial State, ESC c) 让 WASM 状态机自己
4936
+ // 重置 grid / 光标 / 属性 / scrollback,所有 VT 实现都支持这个序列。
4937
+ state.terminal.write("\x1bc");
4938
+ resetWideParserState();
4553
4939
  }
4554
4940
 
4555
4941
  // Soft resync terminal: reset WASM grid and replay full output buffer.
@@ -4558,12 +4944,15 @@
4558
4944
  function softResyncTerminal() {
4559
4945
  if (!state.terminal || !state.terminalOutput) return false;
4560
4946
  resetTerminal();
4561
- state.terminal.write(state.terminalOutput);
4947
+ wandTerminalWrite(state.terminal, state.terminalOutput);
4562
4948
  state.lastTerminalResyncAt = Date.now();
4563
4949
  maybeScrollTerminalToBottom("output");
4564
4950
  // Remeasure against real container: the refresh button used to only
4565
4951
  // reset+write, so a stale cols/rows (set at mount time with hidden
4566
4952
  // container) would survive the refresh and keep wrapping output wrong.
4953
+ // Suppress the auto-replay branch in ensureTerminalFit — we just
4954
+ // replayed, no point doing it again on the next rAF tick.
4955
+ state.suppressFitReplay = true;
4567
4956
  ensureTerminalFit("refresh");
4568
4957
  return true;
4569
4958
  }
@@ -4619,7 +5008,7 @@
4619
5008
  if (normalizedOutput !== currentOutput) {
4620
5009
  resetTerminal();
4621
5010
  if (normalizedOutput) {
4622
- state.terminal.write(normalizedOutput);
5011
+ wandTerminalWrite(state.terminal, normalizedOutput);
4623
5012
  }
4624
5013
  wrote = true;
4625
5014
  }
@@ -4630,7 +5019,7 @@
4630
5019
  } else if (normalizedOutput.startsWith(currentOutput)) {
4631
5020
  var delta = normalizedOutput.slice(currentOutput.length);
4632
5021
  if (delta) {
4633
- state.terminal.write(delta);
5022
+ wandTerminalWrite(state.terminal, delta);
4634
5023
  wrote = true;
4635
5024
  }
4636
5025
  } else if (currentOutput && currentOutput.startsWith(normalizedOutput)) {
@@ -4638,7 +5027,7 @@
4638
5027
  } else {
4639
5028
  resetTerminal();
4640
5029
  if (normalizedOutput) {
4641
- state.terminal.write(normalizedOutput);
5030
+ wandTerminalWrite(state.terminal, normalizedOutput);
4642
5031
  }
4643
5032
  wrote = true;
4644
5033
  }
@@ -4685,10 +5074,32 @@
4685
5074
  },
4686
5075
  onResize: function(cols, rows) {
4687
5076
  sendTerminalResize(cols, rows);
5077
+ // wterm 自身 ResizeObserver 在容器尺寸变化时主动调 resize(),
5078
+ // bridge.resize() 把 grid 按新 cols 重排,但 scrollback 仍是
5079
+ // 按旧 cols 写入 WASM 的、新 grid 又被清空到干净状态。wand
5080
+ // 这层缓存的 terminalOutput 才是完整原始字节流,必须按新
5081
+ // cols 重放一次,grid + scrollback 才会和实际历史对齐。
5082
+ // 同步立即重放——不要走 setTimeout(0):移动端 WebView 在
5083
+ // 前后台切换或键盘动画期间,macrotask 经常被推迟到 wterm
5084
+ // 的下一帧 render 之后,结果用户先看到一帧空 grid 才看到
5085
+ // replay 完的内容,体感上就是"刷新都没用、动一下窗口才好"。
5086
+ if (state.terminal && state.terminalOutput) {
5087
+ state.suppressFitReplay = true;
5088
+ softResyncTerminal();
5089
+ }
4688
5090
  }
4689
5091
  });
4690
5092
 
4691
- term.init().then(function() {
5093
+ // Wait for the monospace webfont (if any) before init so the very first
5094
+ // _measureCharSize() inside wterm uses the final glyph metrics. Otherwise
5095
+ // the fallback font's narrower glyphs make wterm calculate too many cols,
5096
+ // and subsequent chunks render with broken wrapping until the user
5097
+ // triggers a resize. Cap the wait so a missing font never blocks startup.
5098
+ var fontsReady = (document.fonts && typeof document.fonts.ready === "object")
5099
+ ? Promise.race([document.fonts.ready, new Promise(function(r) { setTimeout(r, 800); })])
5100
+ : Promise.resolve();
5101
+
5102
+ fontsReady.then(function() { return term.init(); }).then(function() {
4692
5103
  state.terminal = term;
4693
5104
  state.terminalInitializing = false;
4694
5105
  applyTerminalScale();
@@ -4729,7 +5140,7 @@
4729
5140
  syncTerminalBuffer(session.id, session.output || "", { mode: "append", scroll: false });
4730
5141
  }
4731
5142
  } else {
4732
- term.write("点击上方「新对话」开始你的第一次对话。\r\n");
5143
+ wandTerminalWrite(term, "点击上方「新对话」开始你的第一次对话。\r\n");
4733
5144
  }
4734
5145
 
4735
5146
  state.terminalClickHandler = focusInputBox;
@@ -4870,7 +5281,7 @@
4870
5281
  return "会话已结束,无法继续发送";
4871
5282
  }
4872
5283
  return session && isStructuredSession(session) && session.structuredState && session.structuredState.inFlight
4873
- ? "思考中,可继续发送,消息会自动排队"
5284
+ ? "思考中 · 发送新消息将中断当前回复"
4874
5285
  : "输入消息...";
4875
5286
  }
4876
5287
 
@@ -5499,12 +5910,17 @@
5499
5910
  initTerminal();
5500
5911
  }
5501
5912
 
5502
- if (selectedSession && state.terminal) {
5503
- syncTerminalBuffer(selectedSession.id, selectedSession.output || "", { mode: "append", scroll: false });
5504
- } else if (!selectedSession) {
5913
+ if (!selectedSession) {
5505
5914
  state.terminalSessionId = null;
5506
5915
  state.terminalOutput = "";
5507
5916
  }
5917
+ // 之前这里会用 selectedSession.output 再 syncTerminalBuffer 一次。
5918
+ // 但 updateShellChrome 在 updateSessionsList、status 推送、init
5919
+ // 等多个高频路径都会被调,每次都拿"可能不带 output 的 slim 快照"
5920
+ // 兜回来 sync 一遍:要么早返回浪费判断,要么 prefix 不匹配触发
5921
+ // reset+全量重写、把 alt-screen 中正在绘制的 Claude TUI 切走。
5922
+ // terminal 写入应当只走 chunk hot-path 与 ws init 这两条权威路径,
5923
+ // 这里不再插手,避免引入二次覆盖。
5508
5924
 
5509
5925
  if (state.terminal && selectedSession && state.currentView === "terminal") {
5510
5926
  maybeScrollTerminalToBottom("view");
@@ -6220,6 +6636,11 @@
6220
6636
  var apkMessageEl = document.getElementById("android-apk-message");
6221
6637
  var androidApk = data.androidApk || {};
6222
6638
  var isInApk = !!_apkVersion;
6639
+ var hasApkInfo = isInApk || !!androidApk.github || !!androidApk.local;
6640
+ if (apkSection) {
6641
+ if (hasApkInfo) apkSection.classList.remove("hidden");
6642
+ else apkSection.classList.add("hidden");
6643
+ }
6223
6644
 
6224
6645
  if (isInApk) {
6225
6646
  // ── APK 内模式:显示当前版本 + 线上版本 + 本地版本 ──
@@ -6663,12 +7084,11 @@
6663
7084
  // ── Notification Settings Helpers ──
6664
7085
 
6665
7086
  function _updateAppIconSelection(activeIcon) {
6666
- var opts = document.querySelectorAll(".app-icon-option");
7087
+ var opts = document.querySelectorAll(".settings-app-icon-option");
6667
7088
  for (var i = 0; i < opts.length; i++) {
6668
- var preview = opts[i].querySelector(".app-icon-preview");
6669
- if (preview) {
6670
- preview.style.borderColor = opts[i].getAttribute("data-icon") === activeIcon ? "var(--accent)" : "transparent";
6671
- }
7089
+ var isActive = opts[i].getAttribute("data-icon") === activeIcon;
7090
+ opts[i].classList.toggle("selected", isActive);
7091
+ opts[i].setAttribute("aria-pressed", isActive ? "true" : "false");
6672
7092
  }
6673
7093
  }
6674
7094
 
@@ -8027,29 +8447,25 @@
8027
8447
  return Promise.resolve();
8028
8448
  }
8029
8449
 
8030
- var isQueueing = !!(session.structuredState && session.structuredState.inFlight && session.status === "running");
8031
- if (!isQueueing) {
8032
- // Immediately render user message with thinking indicator
8033
- var userTurn = { role: "user", content: [{ type: "text", text: input }] };
8034
- var userMsgs = stripRenderOnlyStructuredMessages(Array.isArray(session.messages) ? session.messages.slice() : []);
8035
- userMsgs.push(userTurn);
8036
- var optimisticStructuredState = Object.assign({}, session.structuredState || {}, { inFlight: true });
8037
- // Write optimistic user turn into session.messages so WS updates
8038
- // that arrive before the HTTP response don't erase it.
8039
- updateSessionSnapshot({
8040
- id: session.id,
8041
- status: "running",
8042
- messages: userMsgs,
8043
- structuredState: optimisticStructuredState,
8044
- });
8045
- state.currentMessages = buildMessagesForRender(Object.assign({}, session, {
8046
- status: "running",
8047
- messages: userMsgs,
8048
- structuredState: optimisticStructuredState,
8049
- }), userMsgs);
8050
- updateInputHint("思考中…");
8051
- renderChat(true);
8052
- }
8450
+ var isInterrupting = !!(session.structuredState && session.structuredState.inFlight && session.status === "running");
8451
+ // Immediately render user message with thinking indicator
8452
+ var userTurn = { role: "user", content: [{ type: "text", text: input }] };
8453
+ var userMsgs = stripRenderOnlyStructuredMessages(Array.isArray(session.messages) ? session.messages.slice() : []);
8454
+ userMsgs.push(userTurn);
8455
+ var optimisticStructuredState = Object.assign({}, session.structuredState || {}, { inFlight: true });
8456
+ updateSessionSnapshot({
8457
+ id: session.id,
8458
+ status: "running",
8459
+ messages: userMsgs,
8460
+ structuredState: optimisticStructuredState,
8461
+ });
8462
+ state.currentMessages = buildMessagesForRender(Object.assign({}, session, {
8463
+ status: "running",
8464
+ messages: userMsgs,
8465
+ structuredState: optimisticStructuredState,
8466
+ }), userMsgs);
8467
+ updateInputHint("思考中…");
8468
+ renderChat(true);
8053
8469
 
8054
8470
  if (inputBox) {
8055
8471
  inputBox.value = "";
@@ -8066,17 +8482,21 @@
8066
8482
  method: "POST",
8067
8483
  headers: { "Content-Type": "application/json" },
8068
8484
  credentials: "same-origin",
8069
- body: JSON.stringify({ input: input })
8485
+ body: JSON.stringify({ input: input, interrupt: isInterrupting || undefined })
8486
+ })
8487
+ .then(function(res) {
8488
+ if (!res.ok) {
8489
+ return res.json().catch(function() { return { error: "请求失败" }; }).then(function(payload) {
8490
+ throw new Error((payload && payload.error) || "无法发送结构化消息。");
8491
+ });
8492
+ }
8493
+ return res.json();
8070
8494
  })
8071
- .then(function(res) { return res.json(); })
8072
8495
  .then(function(snapshot) {
8073
8496
  if (snapshot && snapshot.error) {
8074
8497
  throw new Error(snapshot.error);
8075
8498
  }
8076
8499
  if (snapshot && snapshot.id) {
8077
- // If a WS update has already bumped the queue epoch, the HTTP
8078
- // response's queuedMessages is stale — drop it to avoid
8079
- // re-introducing already-dequeued items.
8080
8500
  if (state.queueEpoch > epochBeforePost && snapshot.queuedMessages) {
8081
8501
  delete snapshot.queuedMessages;
8082
8502
  }
@@ -8084,13 +8504,8 @@
8084
8504
  var refreshedSession = state.sessions.find(function(s) { return s.id === snapshot.id; }) || snapshot;
8085
8505
  state.currentMessages = buildMessagesForRender(refreshedSession, getPreferredMessages(refreshedSession, snapshot.output, false));
8086
8506
  renderChat(true);
8087
- if (isQueueing) {
8088
- var queuedCount = getStructuredQueuedInputs(refreshedSession).length;
8089
- if (queuedCount > 0) {
8090
- showToast("已排队(第 " + queuedCount + " 条),将在当前消息处理完成后自动发送。", "info");
8091
- }
8092
- } else {
8093
- updateInputHint("Enter 发送 · Shift+Enter 换行");
8507
+ if (isInterrupting) {
8508
+ showToast("已中断上一条回复,正在处理新消息…", "info");
8094
8509
  }
8095
8510
  }
8096
8511
  })
@@ -8774,6 +9189,12 @@
8774
9189
  function flushPendingMessages() {
8775
9190
  if (state.pendingMessages.length === 0) return;
8776
9191
 
9192
+ var selectedSession = getSelectedSession();
9193
+ if (isStructuredSession(selectedSession)) {
9194
+ state.pendingMessages = [];
9195
+ return;
9196
+ }
9197
+
8777
9198
  // Send queued messages in order, bypassing the session-running check
8778
9199
  // since our local state may be stale right after reconnect
8779
9200
  var queue = state.pendingMessages.slice();
@@ -10019,15 +10440,30 @@
10019
10440
  syncInputBoxScroll(inputBox);
10020
10441
  }
10021
10442
 
10443
+ // Keyboard just opened — terminal viewport now shares space with
10444
+ // the keyboard; visible rows shrink even if cols stayed the same.
10445
+ // Without an immediate refit, any chunk arriving while the keyboard
10446
+ // animates in renders against the old grid and tears the screen.
10447
+ if (!keyboardOpen && isKeyboardOpen) {
10448
+ ensureTerminalFit("keyboard-open");
10449
+ }
10450
+
10022
10451
  // Keyboard just closed — force terminal refit and scroll to bottom
10023
10452
  // after a delay so the keyboard dismiss animation and layout settle.
10024
10453
  if (keyboardOpen && !isKeyboardOpen) {
10025
10454
  setTimeout(function() {
10026
- ensureTerminalFit();
10455
+ ensureTerminalFit("keyboard-close");
10027
10456
  maybeScrollTerminalToBottom("force");
10028
10457
  }, 200);
10029
10458
  }
10030
10459
 
10460
+ // visualViewport height changed without a keyboard transition —
10461
+ // covers iOS address-bar collapse/expand and split-screen drag.
10462
+ // Cheap to call: ensureTerminalFit early-exits if cols/rows stable.
10463
+ if (heightChanged && keyboardOpen === isKeyboardOpen) {
10464
+ ensureTerminalFit("viewport");
10465
+ }
10466
+
10031
10467
  keyboardOpen = isKeyboardOpen;
10032
10468
  lastHeight = vv.height;
10033
10469
  }
@@ -10280,10 +10716,68 @@
10280
10716
  // new output renders with broken wrapping (content visually piles at
10281
10717
  // the top). Call this after any layout change that might have altered
10282
10718
  // container geometry (mount, session switch, view switch, refresh).
10719
+ // Same as ensureTerminalFit, but if the container is currently 0×0
10720
+ // (typical right after Android WebView.onResume — the page hasn't
10721
+ // re-laid-out yet), keep retrying through requestAnimationFrame /
10722
+ // setTimeout up to ~5 frames. Each attempt forces a layout read
10723
+ // (offsetHeight) so the browser has to flush styles.
10724
+ // Without this, the very first ensureTerminalFit silently fails,
10725
+ // cols/rows stay at the pre-suspend values, and freshly arriving
10726
+ // PTY chunks wrap against the wrong width — that's exactly the
10727
+ // "content piles at the top after resuming the app" bug.
10728
+ function ensureTerminalFitWithRetry(reason) {
10729
+ if (!state.terminal) return;
10730
+ var attempts = 0;
10731
+ var maxAttempts = 8;
10732
+ function tryFit() {
10733
+ if (!state.terminal) return;
10734
+ var el = document.getElementById("output");
10735
+ if (el) {
10736
+ // Force a layout flush so offsetWidth reflects the post-resume
10737
+ // container size, not a stale 0 from the suspended frame.
10738
+ void el.offsetHeight;
10739
+ }
10740
+ if (el && el.offsetWidth > 0 && el.offsetHeight > 0) {
10741
+ ensureTerminalFit(reason);
10742
+ // After fit, force a buffer replay: even when cols didn't
10743
+ // change, the WASM grid state may be inconsistent after a
10744
+ // long suspend (DOM rows clipped, scroll position lost).
10745
+ // softResyncTerminal is cheap because terminalOutput is
10746
+ // already in memory.
10747
+ if (state.terminalOutput) {
10748
+ state.suppressFitReplay = true;
10749
+ softResyncTerminal();
10750
+ }
10751
+ return;
10752
+ }
10753
+ if (++attempts >= maxAttempts) return;
10754
+ // Mix rAF and timeout: some Android WebView versions skip rAF
10755
+ // during the first frame after resume, so falling back to a
10756
+ // 16ms timer guarantees forward progress.
10757
+ if (attempts <= 4) {
10758
+ requestAnimationFrame(tryFit);
10759
+ } else {
10760
+ setTimeout(tryFit, 32);
10761
+ }
10762
+ }
10763
+ tryFit();
10764
+ }
10765
+
10283
10766
  function ensureTerminalFit(reason) {
10284
10767
  if (!state.terminal) return false;
10285
10768
  var el = document.getElementById("output");
10286
- if (!el || el.offsetWidth === 0 || el.offsetHeight === 0) return false;
10769
+ if (!el || el.offsetWidth === 0 || el.offsetHeight === 0) {
10770
+ // 容器暂时没有可见尺寸(hidden、动画过渡、键盘弹起前的 layout
10771
+ // 中间帧、Android WebView resume 头几帧),不要静默放弃——
10772
+ // 改成丢给 ensureTerminalFitWithRetry 兜底,等容器有了真实
10773
+ // 尺寸再 fit + replay。否则一旦错过这一次,只能等下一次外部
10774
+ // 触发(旋转屏幕、开关键盘等),中间的输出就会一直按错误
10775
+ // 宽度堆在视图上方,看起来像"中间一大段都没有显示"。
10776
+ ensureTerminalFitWithRetry(reason || "fit-retry");
10777
+ return false;
10778
+ }
10779
+ var prevCols = state.terminal.cols;
10780
+ var prevRows = state.terminal.rows;
10287
10781
  requestAnimationFrame(function() {
10288
10782
  requestAnimationFrame(function() {
10289
10783
  if (!state.terminal) return;
@@ -10291,6 +10785,21 @@
10291
10785
  state.terminal.remeasure();
10292
10786
  }
10293
10787
  sendTerminalResize(state.terminal.cols, state.terminal.rows);
10788
+ // Cache the container width that produced this cols/rows so the
10789
+ // hot-path chunk writer can detect drift cheaply (avoids running
10790
+ // a full remeasure on every WebSocket message).
10791
+ state.lastFitContainerWidth = el.offsetWidth;
10792
+ state.lastFitContainerHeight = el.offsetHeight;
10793
+ // If cols actually changed, the previously written buffer was
10794
+ // wrapped to the old width. Replay the full buffer so historical
10795
+ // lines and any in-flight CSI cursor sequences re-render against
10796
+ // the new grid — this is what fixes the "torn" screens users see
10797
+ // after rotating, opening the keyboard, or resizing the panel.
10798
+ var skipReplay = state.suppressFitReplay === true;
10799
+ state.suppressFitReplay = false;
10800
+ if (!skipReplay && (state.terminal.cols !== prevCols || state.terminal.rows !== prevRows)) {
10801
+ if (state.terminalOutput) softResyncTerminal();
10802
+ }
10294
10803
  if (state.terminalAutoFollow || isTerminalNearBottom()) {
10295
10804
  maybeScrollTerminalToBottom("resize");
10296
10805
  }
@@ -10338,7 +10847,54 @@
10338
10847
  if (timeEls.length > 0) scheduleSessionListUpdate();
10339
10848
  }, 30000);
10340
10849
 
10341
- function initWebSocket() {
10850
+ function cancelWsReconnect() {
10851
+ if (state.wsReconnectTimer) {
10852
+ clearTimeout(state.wsReconnectTimer);
10853
+ state.wsReconnectTimer = null;
10854
+ }
10855
+ }
10856
+
10857
+ // Drop any in-flight socket and start a new one *now* — used by the
10858
+ // Android resume bridge to recover from zombie connections (socket
10859
+ // still says OPEN, but the TCP path was torn down by Doze). Skips
10860
+ // the backoff timer; the caller has already decided this is urgent.
10861
+ function forceReconnectWebSocket(reason) {
10862
+ cancelWsReconnect();
10863
+ if (state.ws) {
10864
+ var stale = state.ws;
10865
+ // Detach handlers so the imminent close doesn't trigger another
10866
+ // reconnect path while we're already starting a fresh one.
10867
+ try { stale.onclose = null; } catch (e) { /* ignore */ }
10868
+ try { stale.onerror = null; } catch (e) { /* ignore */ }
10869
+ try { stale.close(); } catch (e) { /* ignore */ }
10870
+ state.ws = null;
10871
+ }
10872
+ state.wsConnected = false;
10873
+ state.wsReconnectAttempts = 0;
10874
+ initWebSocket(reason);
10875
+ }
10876
+
10877
+ function scheduleWsReconnect() {
10878
+ if (state.wsReconnectTimer) return;
10879
+ // Don't burn battery reconnecting while hidden — the resume
10880
+ // listener will kick a fresh connect when we're foreground.
10881
+ if (document.hidden) return;
10882
+ var attempt = state.wsReconnectAttempts || 0;
10883
+ // 0.5s, 1s, 2s, 4s, then capped at 8s. Faster than the old
10884
+ // fixed 2s on the first retry (matters for transient blips)
10885
+ // and bounded so a flapping server doesn't get hammered.
10886
+ var delays = [500, 1000, 2000, 4000, 8000];
10887
+ var delay = delays[attempt < delays.length ? attempt : delays.length - 1];
10888
+ state.wsReconnectAttempts = attempt + 1;
10889
+ state.wsReconnectTimer = setTimeout(function() {
10890
+ state.wsReconnectTimer = null;
10891
+ if (state.config && !state.ws && !document.hidden) {
10892
+ initWebSocket("backoff");
10893
+ }
10894
+ }, delay);
10895
+ }
10896
+
10897
+ function initWebSocket(reason) {
10342
10898
  if (!window.WebSocket) return false;
10343
10899
 
10344
10900
  // Prevent duplicate connections
@@ -10356,6 +10912,10 @@
10356
10912
  ws.onopen = function() {
10357
10913
  state.ws = ws;
10358
10914
  state.wsConnected = true;
10915
+ // Reset backoff on a successful connect so the next disconnect
10916
+ // starts the ladder from 500ms again.
10917
+ state.wsReconnectAttempts = 0;
10918
+ cancelWsReconnect();
10359
10919
  // Subscribe to current session if any
10360
10920
  subscribeToSession(state.selectedId);
10361
10921
  // Flush pending messages after reconnection
@@ -10363,7 +10923,10 @@
10363
10923
  // Re-fit terminal on reconnect — the viewport may have changed
10364
10924
  // while disconnected, so remeasure against real container size
10365
10925
  // rather than sending stale cols/rows from before the disconnect.
10366
- ensureTerminalFit("ws-reconnect");
10926
+ // Use the retry variant: when the reconnect is triggered by
10927
+ // Android resume, the WebView container may still be 0×0 for
10928
+ // the first 1–2 frames while layout settles.
10929
+ ensureTerminalFitWithRetry("ws-reconnect");
10367
10930
  };
10368
10931
 
10369
10932
  ws.onmessage = function(event) {
@@ -10378,12 +10941,7 @@
10378
10941
  ws.onclose = function() {
10379
10942
  state.ws = null;
10380
10943
  state.wsConnected = false;
10381
- // Reconnect after 2 seconds
10382
- setTimeout(function() {
10383
- if (state.config && !state.ws) {
10384
- initWebSocket();
10385
- }
10386
- }, 2000);
10944
+ scheduleWsReconnect();
10387
10945
  };
10388
10946
 
10389
10947
  ws.onerror = function() {
@@ -10392,6 +10950,9 @@
10392
10950
 
10393
10951
  return true;
10394
10952
  } catch (e) {
10953
+ // Constructor threw (rare — bad URL, blocked scheme). Try again
10954
+ // through the backoff path so we don't get stuck.
10955
+ scheduleWsReconnect();
10395
10956
  return false;
10396
10957
  }
10397
10958
  }
@@ -10478,7 +11039,13 @@
10478
11039
  // Fast path: write chunk directly to avoid full-output comparison.
10479
11040
  state.lastChunkAt = Date.now();
10480
11041
  state.terminalLiveStreamSessions[msg.sessionId] = true;
10481
- state.terminal.write(msg.data.chunk);
11042
+ // 不再在 hot-path 调 maybeRefitTerminal/remeasure。它会偷偷把
11043
+ // wterm 的 this.cols 改成新值,让 wterm 自己的 ResizeObserver
11044
+ // 误判 newCols === this.cols 而跳过 wterm.resize() —— 那条路径
11045
+ // 才会真正调 Renderer.setup() 重建 DOM 行。绕过它就让容器尺寸
11046
+ // 变化的视觉错位无法被自愈,直到用户手动改窗口才修。现在让
11047
+ // wterm 内部 ResizeObserver 独占 cols 跟踪职责。
11048
+ wandTerminalWrite(state.terminal, msg.data.chunk);
10482
11049
  state.terminalSessionId = msg.sessionId;
10483
11050
  if (msg.data.output) {
10484
11051
  state.terminalOutput = normalizeTerminalOutput(msg.data.output);
@@ -10612,14 +11179,19 @@
10612
11179
  renderChat(true);
10613
11180
  updateTaskDisplay();
10614
11181
  updateApprovalStats();
10615
- updateTerminalOutput(msg.data.output || "", msg.sessionId, "append");
10616
- // Ensure terminal is properly fitted after receiving initial data
10617
- scheduleTerminalResize(true);
10618
- if (state.terminal && state.terminal.remeasure) {
10619
- requestAnimationFrame(function() {
10620
- if (state.terminal) state.terminal.remeasure();
10621
- });
10622
- }
11182
+ // ws 重新订阅时拿到的是服务端 ring buffer 的最新窗口(最多
11183
+ // 120KB);客户端缓存的 terminalOutput 可能早于服务端窗口
11184
+ // 的起点。append 模式有 prefix 检查,prefix 不匹配就 reset+
11185
+ // 全量重写、全等就直接 return false——前者会把 alt-screen
11186
+ // 中的 Claude TUI 切走,后者会把"应该按真实 cols 重写"的
11187
+ // 机会跳过。改用 replace 强制 reset+按当前 cols 重写一次,
11188
+ // 这是订阅时唯一可信的全量基线。
11189
+ updateTerminalOutput(msg.data.output || "", msg.sessionId, "replace");
11190
+ // 紧接着等容器有真实尺寸再 fit + softResync:wterm 启动
11191
+ // 硬编码 cols=120,replace 写入也可能落在错的列宽上,
11192
+ // ResizeObserver 的回调是异步的,得用 fit-with-retry 兜
11193
+ // 一次,确保最终一定按真实宽度重排。
11194
+ ensureTerminalFitWithRetry("init");
10623
11195
  }
10624
11196
  break;
10625
11197
  case 'usage':