@co0ontty/wand 1.26.0 → 1.29.0

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.
@@ -123,6 +123,9 @@
123
123
  sidebarPinned: (function() {
124
124
  try { return localStorage.getItem("wand-sidebar-pinned") === "true"; } catch (e) { return false; }
125
125
  })(),
126
+ sidebarCollapsed: (function() {
127
+ try { return localStorage.getItem("wand-sidebar-collapsed") === "true"; } catch (e) { return false; }
128
+ })(),
126
129
  modalOpen: false,
127
130
  presetValue: "",
128
131
  cwdValue: "",
@@ -150,6 +153,11 @@
150
153
  showInstallPrompt: false,
151
154
  ws: null,
152
155
  wsConnected: false,
156
+ // 上一次从服务器收到任意 WS 消息(包括 ping)的时间戳。心跳 stale 检测
157
+ // 用它来判断半开连接:长时间没消息 → forceReconnect。0 表示尚未连接过。
158
+ lastWsMessageAt: 0,
159
+ // 心跳检查 timer 句柄。每 10s 跑一次 evaluateWsHeartbeatStale()。
160
+ wsHeartbeatCheckTimer: null,
153
161
  _updateBubbleShown: false,
154
162
  notificationHistory: {},
155
163
  delayedNotificationTimer: null,
@@ -199,15 +207,14 @@
199
207
  // Whether the commit is also tagged is controlled by `makeTag` independently —
200
208
  // that keeps "commit + tag, push later" possible.
201
209
  quickCommitForm: { customMessage: "", makeTag: false, tag: "", primaryAction: "commit" },
202
- quickCommitActionMenuOpen: false,
203
- // Secondary "tag the existing HEAD" panel (inline drawer).
204
- quickCommitTagHeadOpen: false,
210
+ // Which inline panel/dropdown is open. Only one can be open at a time, so a
211
+ // single field beats juggling three sibling booleans with mutual-exclusion code.
212
+ // Values: null | "action" | "push" | "tag-head".
213
+ quickCommitOpenMenu: null,
205
214
  quickCommitTagHeadForm: { tag: "", push: false },
206
215
  quickCommitTagHeadSubmitting: false,
207
216
  quickCommitTagHeadGenerating: false,
208
217
  quickCommitTagHeadError: "",
209
- // Secondary "push only" controls.
210
- quickCommitPushMenuOpen: false,
211
218
  quickCommitPushing: false,
212
219
  quickCommitPushError: "",
213
220
  // Telegram 风格的"贴底"状态:true = 用户当前贴在底部,新消息会自然出现;
@@ -1027,6 +1034,11 @@
1027
1034
  function syncOnForeground(reason, force) {
1028
1035
  if (!state.config) return Promise.resolve();
1029
1036
  if (document.hidden) return Promise.resolve();
1037
+ // 切回前台时立刻评估一次心跳 stale。setInterval 在 background 会被
1038
+ // 浏览器节流(最低 1Hz,部分浏览器更慢),所以如果挂了 1 分钟回来,
1039
+ // 不主动跑这一次的话要等到下一个 10s tick 才会发现,前 10s 会继续
1040
+ // 往一条死 socket 上推消息。
1041
+ evaluateWsHeartbeatStale();
1030
1042
  // On Android resume the previous WS may still report OPEN/CONNECTING
1031
1043
  // for a few seconds because the close frame hasn't been delivered
1032
1044
  // yet (TCP keepalive / Doze suspended the network stack). Force a
@@ -1168,6 +1180,10 @@
1168
1180
  if (_apkVersion) {
1169
1181
  checkApkAutoUpdate();
1170
1182
  }
1183
+ // macOS DMG auto-update check on startup
1184
+ if (_macAppVersion) {
1185
+ checkDmgAutoUpdate();
1186
+ }
1171
1187
  if (state.claudeHistoryExpanded && !state.claudeHistoryLoaded) {
1172
1188
  loadClaudeHistory();
1173
1189
  }
@@ -1412,6 +1428,9 @@
1412
1428
  '<button id="sidebar-pin-btn" class="btn btn-ghost btn-sm sidebar-pin-toggle' + (state.sidebarPinned ? ' pinned' : '') + '" type="button" title="' + (state.sidebarPinned ? '取消固定侧栏' : '固定侧栏') + '">' +
1413
1429
  '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="17" x2="12" y2="22"/><path d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24z"/></svg>' +
1414
1430
  '</button>' +
1431
+ '<button id="sidebar-collapse-btn" class="btn btn-ghost btn-sm sidebar-collapse-toggle" type="button" title="收起为窄条" aria-label="收起为窄条">' +
1432
+ '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="14 6 8 12 14 18"/><line x1="4" y1="5" x2="4" y2="19"/></svg>' +
1433
+ '</button>' +
1415
1434
  '<button id="close-drawer-button" class="btn btn-ghost btn-icon sidebar-close drawer-close-btn" type="button" aria-label="关闭菜单"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg></button>' +
1416
1435
  '</div>' +
1417
1436
  '</div>' +
@@ -1448,6 +1467,15 @@
1448
1467
  '</button>' +
1449
1468
  '</div>' +
1450
1469
  '</div>' +
1470
+ '<div class="sidebar-rail" id="sidebar-rail" aria-hidden="true">' +
1471
+ '<button class="sidebar-rail-expand" id="sidebar-rail-expand" type="button" title="展开侧栏" aria-label="展开侧栏">' +
1472
+ '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="10 6 16 12 10 18"/><line x1="20" y1="5" x2="20" y2="19"/></svg>' +
1473
+ '</button>' +
1474
+ '<div class="sidebar-rail-list" id="sidebar-rail-list">' + renderRailSessions() + '</div>' +
1475
+ '<button class="sidebar-rail-new" id="sidebar-rail-new" type="button" title="新会话" aria-label="新会话">' +
1476
+ '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>' +
1477
+ '</button>' +
1478
+ '</div>' +
1451
1479
  '</aside>' +
1452
1480
  '<main class="main-content">' +
1453
1481
  '<div class="main-header-row">' +
@@ -1816,13 +1844,11 @@
1816
1844
  tag: "",
1817
1845
  primaryAction: readSavedPrimaryAction(),
1818
1846
  };
1819
- state.quickCommitActionMenuOpen = false;
1820
- state.quickCommitTagHeadOpen = false;
1847
+ state.quickCommitOpenMenu = null;
1821
1848
  state.quickCommitTagHeadForm = { tag: "", push: false };
1822
1849
  state.quickCommitTagHeadSubmitting = false;
1823
1850
  state.quickCommitTagHeadGenerating = false;
1824
1851
  state.quickCommitTagHeadError = "";
1825
- state.quickCommitPushMenuOpen = false;
1826
1852
  state.quickCommitPushing = false;
1827
1853
  state.quickCommitPushError = "";
1828
1854
  closeWorktreeMergeModal();
@@ -1839,10 +1865,8 @@
1839
1865
  quickCommitEscHandler = function(e) {
1840
1866
  if (e.key === "Escape" && state.quickCommitOpen && !state.quickCommitSubmitting && !state.quickCommitTagHeadSubmitting && !state.quickCommitPushing) {
1841
1867
  // First Esc closes any open dropdown; second closes the modal.
1842
- if (state.quickCommitActionMenuOpen || state.quickCommitPushMenuOpen || state.quickCommitTagHeadOpen) {
1843
- state.quickCommitActionMenuOpen = false;
1844
- state.quickCommitPushMenuOpen = false;
1845
- state.quickCommitTagHeadOpen = false;
1868
+ if (state.quickCommitOpenMenu) {
1869
+ state.quickCommitOpenMenu = null;
1846
1870
  rerenderQuickCommitModal();
1847
1871
  return;
1848
1872
  }
@@ -1853,18 +1877,16 @@
1853
1877
  if (quickCommitDocClickHandler) document.removeEventListener("click", quickCommitDocClickHandler, true);
1854
1878
  quickCommitDocClickHandler = function(e) {
1855
1879
  if (!state.quickCommitOpen) return;
1880
+ // tag-head is an inline drawer, not a dropdown — clicking outside shouldn't close it.
1881
+ if (state.quickCommitOpenMenu !== "action" && state.quickCommitOpenMenu !== "push") return;
1856
1882
  var modalEl = document.getElementById("quick-commit-modal");
1857
1883
  if (!modalEl) return;
1858
- var anyMenuOpen = state.quickCommitActionMenuOpen || state.quickCommitPushMenuOpen;
1859
- if (!anyMenuOpen) return;
1860
- // Click on the toggle itself? leave it to the toggle handler.
1861
1884
  var t = e.target;
1862
1885
  while (t && t !== modalEl) {
1863
1886
  if (t.dataset && (t.dataset.qcDropdownToggle || t.dataset.qcDropdownMenu)) return;
1864
1887
  t = t.parentNode;
1865
1888
  }
1866
- state.quickCommitActionMenuOpen = false;
1867
- state.quickCommitPushMenuOpen = false;
1889
+ state.quickCommitOpenMenu = null;
1868
1890
  rerenderQuickCommitModal();
1869
1891
  };
1870
1892
  document.addEventListener("click", quickCommitDocClickHandler, true);
@@ -1878,9 +1900,7 @@
1878
1900
  state.quickCommitOpen = false;
1879
1901
  state.quickCommitSubmitting = false;
1880
1902
  state.quickCommitError = "";
1881
- state.quickCommitActionMenuOpen = false;
1882
- state.quickCommitPushMenuOpen = false;
1883
- state.quickCommitTagHeadOpen = false;
1903
+ state.quickCommitOpenMenu = null;
1884
1904
  var modal = document.getElementById("quick-commit-modal");
1885
1905
  if (modal) modal.classList.add("hidden");
1886
1906
  if (focusTrapHandler) {
@@ -1918,7 +1938,6 @@
1918
1938
  var cancelBtn = document.getElementById("quick-commit-cancel-btn");
1919
1939
  if (cancelBtn) cancelBtn.addEventListener("click", closeQuickCommitModal);
1920
1940
 
1921
- // ── Section 1: commit form ──
1922
1941
  var submitBtn = document.getElementById("quick-commit-submit-btn");
1923
1942
  if (submitBtn) submitBtn.addEventListener("click", submitQuickCommit);
1924
1943
  var aiBtn = document.getElementById("quick-commit-ai-btn");
@@ -1938,12 +1957,10 @@
1938
1957
  state.quickCommitForm.tag = tagInput.value;
1939
1958
  });
1940
1959
 
1941
- // ── Primary action split-button (caret + dropdown) ──
1942
1960
  var actionCaret = document.getElementById("quick-commit-action-caret");
1943
1961
  if (actionCaret) actionCaret.addEventListener("click", function(e) {
1944
1962
  e.stopPropagation();
1945
- state.quickCommitActionMenuOpen = !state.quickCommitActionMenuOpen;
1946
- state.quickCommitPushMenuOpen = false;
1963
+ state.quickCommitOpenMenu = state.quickCommitOpenMenu === "action" ? null : "action";
1947
1964
  rerenderQuickCommitModal();
1948
1965
  });
1949
1966
  var actionMenu = document.getElementById("quick-commit-action-menu");
@@ -1957,29 +1974,27 @@
1957
1974
  state.quickCommitForm.primaryAction = value;
1958
1975
  savePrimaryAction(value);
1959
1976
  }
1960
- state.quickCommitActionMenuOpen = false;
1977
+ state.quickCommitOpenMenu = null;
1961
1978
  rerenderQuickCommitModal();
1962
1979
  });
1963
1980
  })(actionItems[i]);
1964
1981
  }
1965
1982
  }
1966
1983
 
1967
- // ── Section 2: Tag HEAD inline panel ──
1968
1984
  var tagHeadToggle = document.getElementById("quick-commit-tag-head-toggle");
1969
1985
  if (tagHeadToggle) tagHeadToggle.addEventListener("click", function() {
1970
- state.quickCommitTagHeadOpen = !state.quickCommitTagHeadOpen;
1971
- if (state.quickCommitTagHeadOpen) {
1972
- state.quickCommitTagHeadError = "";
1973
- }
1986
+ var willOpen = state.quickCommitOpenMenu !== "tag-head";
1987
+ state.quickCommitOpenMenu = willOpen ? "tag-head" : null;
1988
+ if (willOpen) state.quickCommitTagHeadError = "";
1974
1989
  rerenderQuickCommitModal();
1975
- if (state.quickCommitTagHeadOpen) {
1990
+ if (willOpen) {
1976
1991
  var inp = document.getElementById("quick-commit-tag-head-input");
1977
1992
  if (inp) inp.focus();
1978
1993
  }
1979
1994
  });
1980
1995
  var tagHeadCancel = document.getElementById("quick-commit-tag-head-cancel");
1981
1996
  if (tagHeadCancel) tagHeadCancel.addEventListener("click", function() {
1982
- state.quickCommitTagHeadOpen = false;
1997
+ if (state.quickCommitOpenMenu === "tag-head") state.quickCommitOpenMenu = null;
1983
1998
  state.quickCommitTagHeadError = "";
1984
1999
  rerenderQuickCommitModal();
1985
2000
  });
@@ -2006,7 +2021,6 @@
2006
2021
  submitTagHead(false);
2007
2022
  });
2008
2023
 
2009
- // ── Section 2: Push split-button ──
2010
2024
  var pushBtn = document.getElementById("quick-commit-push-btn");
2011
2025
  if (pushBtn) pushBtn.addEventListener("click", function() {
2012
2026
  submitPushOnly({ pushCommits: true, pushTags: false });
@@ -2014,8 +2028,7 @@
2014
2028
  var pushCaret = document.getElementById("quick-commit-push-caret");
2015
2029
  if (pushCaret) pushCaret.addEventListener("click", function(e) {
2016
2030
  e.stopPropagation();
2017
- state.quickCommitPushMenuOpen = !state.quickCommitPushMenuOpen;
2018
- state.quickCommitActionMenuOpen = false;
2031
+ state.quickCommitOpenMenu = state.quickCommitOpenMenu === "push" ? null : "push";
2019
2032
  rerenderQuickCommitModal();
2020
2033
  });
2021
2034
  var pushMenu = document.getElementById("quick-commit-push-menu");
@@ -2025,7 +2038,7 @@
2025
2038
  (function(btn) {
2026
2039
  btn.addEventListener("click", function() {
2027
2040
  var value = btn.getAttribute("data-qc-push");
2028
- state.quickCommitPushMenuOpen = false;
2041
+ state.quickCommitOpenMenu = null;
2029
2042
  if (value === "commits") submitPushOnly({ pushCommits: true, pushTags: false });
2030
2043
  else if (value === "tags") submitPushOnly({ pushCommits: false, pushTags: true });
2031
2044
  else if (value === "both") submitPushOnly({ pushCommits: true, pushTags: true });
@@ -2119,7 +2132,11 @@
2119
2132
  var data = result.data || {};
2120
2133
  var hash = data.commit && data.commit.hash ? data.commit.hash.substring(0, 7) : "";
2121
2134
  var tagName = data.tag && data.tag.name ? data.tag.name : "";
2122
- var base = "已提交" + (hash ? " " + hash : "") + (tagName ? ",已打 tag " + tagName : "");
2135
+ var subCommits = Array.isArray(data.submoduleCommits) ? data.submoduleCommits : [];
2136
+ var subPrefix = subCommits.length > 0
2137
+ ? "已先提交 " + subCommits.length + " 个 submodule(" + subCommits.map(function(c) { return c.path; }).join("、") + "),"
2138
+ : "";
2139
+ var base = subPrefix + "已提交" + (hash ? " " + hash : "") + (tagName ? ",已打 tag " + tagName : "");
2123
2140
  if (willPush && data.pushError) {
2124
2141
  var msg = base + ";push 失败:" + data.pushError;
2125
2142
  if (typeof showToast === "function") showToast(msg, "error");
@@ -2208,7 +2225,7 @@
2208
2225
  } else {
2209
2226
  if (typeof showToast === "function") showToast(base + (pushed ? ",已 push" : ""), "success");
2210
2227
  }
2211
- state.quickCommitTagHeadOpen = false;
2228
+ if (state.quickCommitOpenMenu === "tag-head") state.quickCommitOpenMenu = null;
2212
2229
  state.quickCommitTagHeadForm = { tag: "", push: false };
2213
2230
  if (state.selectedId) loadGitStatus(state.selectedId, { force: true }).then(function() {
2214
2231
  if (state.quickCommitOpen) rerenderQuickCommitModal();
@@ -2223,7 +2240,6 @@
2223
2240
  });
2224
2241
  }
2225
2242
 
2226
- // Push without committing.
2227
2243
  function submitPushOnly(opts) {
2228
2244
  if (!state.selectedId || state.quickCommitPushing) return;
2229
2245
  var pushCommits = !!(opts && opts.pushCommits);
@@ -2267,7 +2283,6 @@
2267
2283
  });
2268
2284
  }
2269
2285
 
2270
- // Build the file list portion of the modal.
2271
2286
  function renderQuickCommitFileRows(files) {
2272
2287
  var rows = files.map(function(item) {
2273
2288
  var status = (item.status || " ").substring(0, 2);
@@ -2293,15 +2308,19 @@
2293
2308
  return rows || '<div class="qc-empty">没有可提交的改动。</div>';
2294
2309
  }
2295
2310
 
2296
- // Primary action button label / icon.
2311
+ function isQuickCommitOpInFlight() {
2312
+ return state.quickCommitSubmitting || state.quickCommitTagHeadSubmitting || state.quickCommitPushing;
2313
+ }
2314
+
2297
2315
  function renderQuickCommitPrimary(hasChanges) {
2298
2316
  var f = state.quickCommitForm;
2299
2317
  var label;
2300
2318
  if (state.quickCommitSubmitting) label = "提交中…";
2301
2319
  else if (f.primaryAction === "commit-push") label = "提交并 push";
2302
2320
  else label = "仅提交";
2303
- var disabled = !hasChanges || state.quickCommitSubmitting || state.quickCommitTagHeadSubmitting || state.quickCommitPushing;
2304
- var caretActive = state.quickCommitActionMenuOpen ? " is-active" : "";
2321
+ var disabled = !hasChanges || isQuickCommitOpInFlight();
2322
+ var menuOpen = state.quickCommitOpenMenu === "action";
2323
+ var caretActive = menuOpen ? " is-active" : "";
2305
2324
  var menuItems = [
2306
2325
  { value: "commit", label: "仅提交", desc: "只 commit,不 push" },
2307
2326
  { value: "commit-push", label: "提交并 push", desc: "commit 后立即 push 到远端" }
@@ -2317,36 +2336,32 @@
2317
2336
  '<button id="quick-commit-submit-btn" class="btn btn-primary qc-split-main" type="button"' + (disabled ? ' disabled' : '') + '>' +
2318
2337
  escapeHtml(label) +
2319
2338
  '</button>' +
2320
- '<button id="quick-commit-action-caret" class="btn btn-primary qc-split-caret' + caretActive + '" type="button" data-qc-dropdown-toggle="action"' + (disabled ? ' disabled' : '') + ' aria-haspopup="menu" aria-expanded="' + (state.quickCommitActionMenuOpen ? 'true' : 'false') + '" aria-label="更多提交方式">' +
2339
+ '<button id="quick-commit-action-caret" class="btn btn-primary qc-split-caret' + caretActive + '" type="button" data-qc-dropdown-toggle="action"' + (disabled ? ' disabled' : '') + ' aria-haspopup="menu" aria-expanded="' + (menuOpen ? 'true' : 'false') + '" aria-label="更多提交方式">' +
2321
2340
  '<svg viewBox="0 0 12 12" width="10" height="10" aria-hidden="true"><path d="M2 4l4 4 4-4" stroke="currentColor" stroke-width="1.6" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>' +
2322
2341
  '</button>' +
2323
- (state.quickCommitActionMenuOpen ?
2342
+ (menuOpen ?
2324
2343
  '<div id="quick-commit-action-menu" class="qc-dropdown-menu" data-qc-dropdown-menu="action" role="menu">' + menuHtml + '</div>' : '') +
2325
2344
  '</div>';
2326
2345
  }
2327
2346
 
2328
- // Status chips (ahead/behind/unpushed tags) under the HEAD card.
2329
2347
  function renderQuickCommitStatusChips(s) {
2330
2348
  var chips = [];
2349
+ var hasUpstream = !!s.upstream;
2331
2350
  if (typeof s.ahead === "number" && s.ahead > 0) {
2332
2351
  chips.push('<span class="qc-chip qc-chip--ahead" title="本地领先 ' + s.ahead + ' 个 commit">↑ ' + s.ahead + ' 待推送</span>');
2333
2352
  }
2334
2353
  if (typeof s.behind === "number" && s.behind > 0) {
2335
2354
  chips.push('<span class="qc-chip qc-chip--behind" title="远端领先 ' + s.behind + ' 个 commit">↓ ' + s.behind + ' 待拉取</span>');
2336
2355
  }
2337
- if (typeof s.unpushedTagCount === "number" && s.unpushedTagCount > 0) {
2338
- chips.push('<span class="qc-chip qc-chip--tag" title="' + s.unpushedTagCount + ' 个本地 tag 未推送">🏷 ' + s.unpushedTagCount + ' 待推送</span>');
2339
- }
2340
- if (s.hasUpstream === false) {
2356
+ if (!hasUpstream) {
2341
2357
  chips.push('<span class="qc-chip qc-chip--warn" title="当前分支没有 upstream,将首次推送时自动设置">无 upstream</span>');
2342
- }
2343
- if (!chips.length && s.hasUpstream !== false) {
2358
+ } else if (!chips.length) {
2344
2359
  chips.push('<span class="qc-chip qc-chip--clean">与远端同步</span>');
2345
2360
  }
2346
2361
  return '<div class="qc-status-chips">' + chips.join("") + '</div>';
2347
2362
  }
2348
2363
 
2349
- // Inline "tag HEAD" drawer rendering.
2364
+ // Inline drawer used by the " HEAD 打 tag" toggle.
2350
2365
  function renderQuickCommitTagHeadPanel() {
2351
2366
  var thf = state.quickCommitTagHeadForm || { tag: "", push: false };
2352
2367
  var submitting = state.quickCommitTagHeadSubmitting;
@@ -2368,22 +2383,23 @@
2368
2383
  '</div>';
2369
2384
  }
2370
2385
 
2371
- // Push split-button on the secondary row.
2372
2386
  function renderQuickCommitPushButton(s) {
2373
2387
  var ahead = typeof s.ahead === "number" ? s.ahead : 0;
2374
- var unpushedTags = typeof s.unpushedTagCount === "number" ? s.unpushedTagCount : 0;
2375
- var canPushCommits = ahead > 0 || s.hasUpstream === false; // first-push lets you set upstream
2376
- var canPushTags = unpushedTags > 0;
2377
- // Allow pushing commits even without ahead info (e.g. ls-remote unreachable) backend will report.
2378
- if (typeof s.ahead !== "number" && s.hasUpstream !== false) canPushCommits = true;
2379
- if (typeof s.unpushedTagCount !== "number") canPushTags = true; // unknown — let user try
2380
- var disabled = state.quickCommitPushing || state.quickCommitSubmitting || state.quickCommitTagHeadSubmitting || (!canPushCommits && !canPushTags);
2388
+ var hasUpstream = !!s.upstream;
2389
+ // Allow pushing commits if we're ahead, if upstream is missing (first push will set it up),
2390
+ // or if ahead is simply unknown — backend will surface real errors.
2391
+ var canPushCommits = ahead > 0 || !hasUpstream || typeof s.ahead !== "number";
2392
+ // Tag count isn't computed locally (would require a network probe). Let the user try.
2393
+ var canPushTags = true;
2394
+ var disabled = isQuickCommitOpInFlight() || (!canPushCommits && !canPushTags);
2381
2395
  var mainLabel = state.quickCommitPushing ? "推送中…" : "推送";
2382
- var caretActive = state.quickCommitPushMenuOpen ? " is-active" : "";
2383
- var items = [];
2384
- items.push({ value: "commits", label: "推送 commits", desc: ahead ? "推送 " + ahead + " 个 commit" : "推送当前分支", disabled: !canPushCommits });
2385
- items.push({ value: "tags", label: "推送 tags", desc: unpushedTags ? "推送 " + unpushedTags + " 个本地 tag" : "推送所有本地 tag", disabled: !canPushTags });
2386
- items.push({ value: "both", label: "推送 commits 和 tags", desc: "二合一", disabled: !(canPushCommits || canPushTags) });
2396
+ var menuOpen = state.quickCommitOpenMenu === "push";
2397
+ var caretActive = menuOpen ? " is-active" : "";
2398
+ var items = [
2399
+ { value: "commits", label: "推送 commits", desc: ahead ? "推送 " + ahead + " commit" : "推送当前分支", disabled: !canPushCommits },
2400
+ { value: "tags", label: "推送 tags", desc: "推送所有本地 tag", disabled: !canPushTags },
2401
+ { value: "both", label: "推送 commits 和 tags", desc: "二合一", disabled: !(canPushCommits || canPushTags) }
2402
+ ];
2387
2403
  var menuHtml = items.map(function(it) {
2388
2404
  var dis = it.disabled ? " is-disabled" : "";
2389
2405
  return '<button type="button" class="qc-dropdown-item' + dis + '" data-qc-push="' + it.value + '"' + (it.disabled ? ' disabled' : '') + '>' +
@@ -2395,10 +2411,10 @@
2395
2411
  '<button id="quick-commit-push-btn" class="btn btn-secondary qc-split-main" type="button"' + (disabled ? ' disabled' : '') + '>' +
2396
2412
  escapeHtml(mainLabel) +
2397
2413
  '</button>' +
2398
- '<button id="quick-commit-push-caret" class="btn btn-secondary qc-split-caret' + caretActive + '" type="button" data-qc-dropdown-toggle="push"' + (disabled ? ' disabled' : '') + ' aria-haspopup="menu" aria-expanded="' + (state.quickCommitPushMenuOpen ? 'true' : 'false') + '" aria-label="更多推送方式">' +
2414
+ '<button id="quick-commit-push-caret" class="btn btn-secondary qc-split-caret' + caretActive + '" type="button" data-qc-dropdown-toggle="push"' + (disabled ? ' disabled' : '') + ' aria-haspopup="menu" aria-expanded="' + (menuOpen ? 'true' : 'false') + '" aria-label="更多推送方式">' +
2399
2415
  '<svg viewBox="0 0 12 12" width="10" height="10" aria-hidden="true"><path d="M2 4l4 4 4-4" stroke="currentColor" stroke-width="1.6" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>' +
2400
2416
  '</button>' +
2401
- (state.quickCommitPushMenuOpen ?
2417
+ (menuOpen ?
2402
2418
  '<div id="quick-commit-push-menu" class="qc-dropdown-menu qc-dropdown-menu--right" data-qc-dropdown-menu="push" role="menu">' + menuHtml + '</div>' : '') +
2403
2419
  '</div>';
2404
2420
  }
@@ -2467,11 +2483,11 @@
2467
2483
  '<code class="qc-head-text">' + escapeHtml(headLine) + '</code>' +
2468
2484
  '</div>' +
2469
2485
  renderQuickCommitStatusChips(s) +
2470
- (state.quickCommitTagHeadOpen ? renderQuickCommitTagHeadPanel() : '') +
2486
+ (state.quickCommitOpenMenu === "tag-head" ? renderQuickCommitTagHeadPanel() : '') +
2471
2487
  '<div class="qc-section-actions qc-section-actions--secondary">' +
2472
2488
  '<button id="quick-commit-tag-head-toggle" class="btn btn-ghost btn-sm" type="button"' + (state.quickCommitPushing ? ' disabled' : '') + '>' +
2473
2489
  '<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true" style="vertical-align:-2px;margin-right:4px;"><path d="M2 2h6.5l5 5-5.5 5.5L2 7.5V2z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/><circle cx="5" cy="5" r="1" fill="currentColor"/></svg>' +
2474
- (state.quickCommitTagHeadOpen ? '收起' : '为 HEAD 打 tag') +
2490
+ (state.quickCommitOpenMenu === "tag-head" ? '收起' : '为 HEAD 打 tag') +
2475
2491
  '</button>' +
2476
2492
  renderQuickCommitPushButton(s) +
2477
2493
  '</div>' +
@@ -2640,6 +2656,40 @@
2640
2656
  '</div>' +
2641
2657
  '<p id="android-apk-message" class="hint hidden"></p>' +
2642
2658
  '</div>' +
2659
+ '<div class="settings-update-section hidden" id="macos-dmg-section">' +
2660
+ '<div class="settings-section-head">' +
2661
+ '<span class="settings-section-icon">🖥️</span>' +
2662
+ '<div class="settings-section-head-text">' +
2663
+ '<h4 class="settings-section-heading">macOS App</h4>' +
2664
+ '<p class="settings-section-sub">原生客户端版本与 DMG 下载</p>' +
2665
+ '</div>' +
2666
+ '</div>' +
2667
+ '<div id="macos-dmg-current-row" class="settings-about-row hidden">' +
2668
+ '<span class="settings-label">当前版本</span>' +
2669
+ '<span class="settings-value" id="settings-macos-dmg-current">-</span>' +
2670
+ '</div>' +
2671
+ '<div id="macos-dmg-github-row" class="settings-about-row settings-about-row-action hidden">' +
2672
+ '<span class="settings-label">线上版本</span>' +
2673
+ '<span class="settings-value settings-value-flex" id="settings-macos-dmg-github">-</span>' +
2674
+ '<button id="download-github-dmg-btn" class="btn btn-secondary btn-sm hidden" type="button">下载</button>' +
2675
+ '</div>' +
2676
+ '<div id="macos-dmg-local-row" class="settings-about-row settings-about-row-action hidden">' +
2677
+ '<span class="settings-label">本地版本</span>' +
2678
+ '<span class="settings-value settings-value-flex" id="settings-macos-dmg-local">-</span>' +
2679
+ '<button id="download-local-dmg-btn" class="btn btn-secondary btn-sm hidden" type="button">下载</button>' +
2680
+ '</div>' +
2681
+ '<div id="macos-auto-update-row" class="settings-toggle-row hidden">' +
2682
+ '<div class="settings-toggle-text">' +
2683
+ '<span class="settings-toggle-title">自动更新</span>' +
2684
+ '<span class="settings-toggle-desc" id="macos-auto-update-hint">检测到新版 DMG 将自动下载并挂载。</span>' +
2685
+ '</div>' +
2686
+ '<label class="settings-switch">' +
2687
+ '<input type="checkbox" id="auto-update-dmg-toggle" class="switch-toggle">' +
2688
+ '<span class="switch-slider"></span>' +
2689
+ '</label>' +
2690
+ '</div>' +
2691
+ '<p id="macos-dmg-message" class="hint hidden"></p>' +
2692
+ '</div>' +
2643
2693
  '<div class="settings-update-section" id="android-connect-section">' +
2644
2694
  '<div class="settings-section-head">' +
2645
2695
  '<span class="settings-section-icon">🔗</span>' +
@@ -5580,6 +5630,10 @@
5580
5630
  if (autoUpdateApkToggle) autoUpdateApkToggle.addEventListener("change", function() {
5581
5631
  toggleAutoUpdate("apk", autoUpdateApkToggle.checked);
5582
5632
  });
5633
+ var autoUpdateDmgToggle = document.getElementById("auto-update-dmg-toggle");
5634
+ if (autoUpdateDmgToggle) autoUpdateDmgToggle.addEventListener("change", function() {
5635
+ toggleAutoUpdate("dmg", autoUpdateDmgToggle.checked);
5636
+ });
5583
5637
  var copyConnectCodeBtn = document.getElementById("copy-connect-code-button");
5584
5638
  if (copyConnectCodeBtn) copyConnectCodeBtn.addEventListener("click", function() {
5585
5639
  var text = document.getElementById("android-connect-code");
@@ -7279,7 +7333,13 @@
7279
7333
  var delta = normalizedOutput.slice(currentOutput.length);
7280
7334
  if (delta) {
7281
7335
  wandTerminalWrite(state.terminal, delta);
7282
- maybeScheduleResyncForChunk(delta);
7336
+ // 不在流式 chunk 路径触发 softResyncTerminal —— resync 会
7337
+ // resetTerminal() + 完整重放整段 buffer,重放期间所有 cursor-home
7338
+ // + 重画序列把"被覆盖的中间帧"反复塞进 main-screen scrollback,
7339
+ // 表现为 PTY 视图里同一段回答被画 N 遍(PC 端列宽大、Claude TUI
7340
+ // 重画序列密集时最严重)。响应结束的 thinking→idle 边界已经做了
7341
+ // 一次 softResync 兜底,足以清掉流式残留的错位光标 DOM;流式
7342
+ // 过程中持续重放只是纯粹的重复制造器。
7283
7343
  wrote = true;
7284
7344
  }
7285
7345
  } else if (currentOutput && currentOutput.startsWith(normalizedOutput)) {
@@ -9222,6 +9282,8 @@
9222
9282
  if (autoUpdateWebToggle) autoUpdateWebToggle.checked = !!autoUpdate.web;
9223
9283
  var autoUpdateApkToggle = document.getElementById("auto-update-apk-toggle");
9224
9284
  if (autoUpdateApkToggle) autoUpdateApkToggle.checked = !!autoUpdate.apk;
9285
+ var autoUpdateDmgToggle = document.getElementById("auto-update-dmg-toggle");
9286
+ if (autoUpdateDmgToggle) autoUpdateDmgToggle.checked = !!autoUpdate.dmg;
9225
9287
 
9226
9288
  // ── Android APK version display ──
9227
9289
  var apkSection = document.getElementById("android-apk-section");
@@ -9341,6 +9403,119 @@
9341
9403
  }
9342
9404
  }
9343
9405
 
9406
+ // ── macOS DMG version display ──
9407
+ var dmgSection = document.getElementById("macos-dmg-section");
9408
+ var dmgCurrentRow = document.getElementById("macos-dmg-current-row");
9409
+ var dmgCurrentEl = document.getElementById("settings-macos-dmg-current");
9410
+ var dmgGithubRow = document.getElementById("macos-dmg-github-row");
9411
+ var dmgGithubEl = document.getElementById("settings-macos-dmg-github");
9412
+ var dmgGithubBtn = document.getElementById("download-github-dmg-btn");
9413
+ var dmgLocalRow = document.getElementById("macos-dmg-local-row");
9414
+ var dmgLocalEl = document.getElementById("settings-macos-dmg-local");
9415
+ var dmgLocalBtn = document.getElementById("download-local-dmg-btn");
9416
+ var dmgMessageEl = document.getElementById("macos-dmg-message");
9417
+ var macosDmg = data.macosDmg || {};
9418
+ var isInMacApp = !!_macAppVersion;
9419
+ var hasDmgInfo = isInMacApp || !!macosDmg.github || !!macosDmg.local;
9420
+ if (dmgSection) {
9421
+ if (hasDmgInfo) dmgSection.classList.remove("hidden");
9422
+ else dmgSection.classList.add("hidden");
9423
+ }
9424
+
9425
+ if (isInMacApp) {
9426
+ // ── macOS 壳内:显示当前版本 + 线上 + 本地 + 下载安装按钮 ──
9427
+ if (dmgCurrentRow && dmgCurrentEl) {
9428
+ dmgCurrentEl.textContent = "v" + _macAppVersion;
9429
+ dmgCurrentRow.classList.remove("hidden");
9430
+ }
9431
+ if (macosDmg.github && dmgGithubRow && dmgGithubEl) {
9432
+ var dghLabel = macosDmg.github.version ? ("v" + macosDmg.github.version) : macosDmg.github.fileName;
9433
+ if (typeof macosDmg.github.size === "number") dghLabel += " · " + formatBytes(macosDmg.github.size);
9434
+ dmgGithubEl.textContent = dghLabel;
9435
+ dmgGithubRow.classList.remove("hidden");
9436
+ if (dmgGithubBtn) {
9437
+ dmgGithubBtn.textContent = "下载安装";
9438
+ dmgGithubBtn.classList.remove("hidden");
9439
+ dmgGithubBtn.onclick = function() {
9440
+ try {
9441
+ WandNative.downloadUpdate(macosDmg.github.downloadUrl, macosDmg.github.fileName || "wand-update.dmg", "github");
9442
+ } catch (e) {
9443
+ if (typeof window.wandAlert === "function") {
9444
+ window.wandAlert("调用下载失败: " + e.message, { type: "danger", title: "下载失败" });
9445
+ } else if (typeof showToast === "function") {
9446
+ showToast("调用下载失败: " + e.message, "error");
9447
+ } else {
9448
+ alert("调用下载失败: " + e.message);
9449
+ }
9450
+ }
9451
+ };
9452
+ }
9453
+ }
9454
+ if (macosDmg.local && dmgLocalRow && dmgLocalEl) {
9455
+ var dlcLabel = macosDmg.local.version ? ("v" + macosDmg.local.version) : macosDmg.local.fileName;
9456
+ if (typeof macosDmg.local.size === "number") dlcLabel += " · " + formatBytes(macosDmg.local.size);
9457
+ dmgLocalEl.textContent = dlcLabel;
9458
+ dmgLocalRow.classList.remove("hidden");
9459
+ if (dmgLocalBtn) {
9460
+ dmgLocalBtn.textContent = "下载安装";
9461
+ dmgLocalBtn.classList.remove("hidden");
9462
+ dmgLocalBtn.onclick = function() {
9463
+ try {
9464
+ WandNative.downloadUpdate(macosDmg.local.downloadUrl, macosDmg.local.fileName || "wand-update.dmg", "local");
9465
+ } catch (e) {
9466
+ if (typeof window.wandAlert === "function") {
9467
+ window.wandAlert("调用下载失败: " + e.message, { type: "danger", title: "下载失败" });
9468
+ } else if (typeof showToast === "function") {
9469
+ showToast("调用下载失败: " + e.message, "error");
9470
+ } else {
9471
+ alert("调用下载失败: " + e.message);
9472
+ }
9473
+ }
9474
+ };
9475
+ }
9476
+ }
9477
+ if (!macosDmg.github && !macosDmg.local && dmgMessageEl) {
9478
+ dmgMessageEl.textContent = "暂无可用更新";
9479
+ dmgMessageEl.classList.remove("hidden");
9480
+ }
9481
+ var dmgAutoRow = document.getElementById("macos-auto-update-row");
9482
+ var dmgAutoHint = document.getElementById("macos-auto-update-hint");
9483
+ if (dmgAutoRow) dmgAutoRow.classList.remove("hidden");
9484
+ if (dmgAutoHint) dmgAutoHint.classList.remove("hidden");
9485
+ } else {
9486
+ // ── 浏览器模式:仅展示下载入口 ──
9487
+ if (macosDmg.github && dmgGithubRow && dmgGithubEl) {
9488
+ var dghLabel2 = macosDmg.github.version ? ("v" + macosDmg.github.version) : macosDmg.github.fileName;
9489
+ if (typeof macosDmg.github.size === "number") dghLabel2 += " · " + formatBytes(macosDmg.github.size);
9490
+ dmgGithubEl.textContent = dghLabel2;
9491
+ dmgGithubRow.classList.remove("hidden");
9492
+ if (dmgGithubBtn) {
9493
+ dmgGithubBtn.textContent = "下载";
9494
+ dmgGithubBtn.classList.remove("hidden");
9495
+ dmgGithubBtn.onclick = function() {
9496
+ window.open(macosDmg.github.downloadUrl, "_blank");
9497
+ };
9498
+ }
9499
+ }
9500
+ if (macosDmg.local && dmgLocalRow && dmgLocalEl) {
9501
+ var dlcLabel2 = macosDmg.local.version ? ("v" + macosDmg.local.version) : macosDmg.local.fileName;
9502
+ if (typeof macosDmg.local.size === "number") dlcLabel2 += " · " + formatBytes(macosDmg.local.size);
9503
+ dmgLocalEl.textContent = dlcLabel2;
9504
+ dmgLocalRow.classList.remove("hidden");
9505
+ if (dmgLocalBtn) {
9506
+ dmgLocalBtn.textContent = "下载";
9507
+ dmgLocalBtn.classList.remove("hidden");
9508
+ dmgLocalBtn.onclick = function() {
9509
+ window.open(macosDmg.local.downloadUrl, "_self");
9510
+ };
9511
+ }
9512
+ }
9513
+ if (!macosDmg.github && !macosDmg.local && dmgMessageEl) {
9514
+ dmgMessageEl.textContent = "暂未提供";
9515
+ dmgMessageEl.classList.remove("hidden");
9516
+ }
9517
+ }
9518
+
9344
9519
  // App connect code (encrypted)
9345
9520
  var connectCodeEl = document.getElementById("android-connect-code");
9346
9521
  var connectQrCanvas = document.getElementById("android-connect-qr");
@@ -9710,6 +9885,24 @@
9710
9885
  .catch(function() {});
9711
9886
  }
9712
9887
 
9888
+ function checkDmgAutoUpdate() {
9889
+ if (!_macAppVersion) return;
9890
+ fetch("/api/auto-update", { credentials: "same-origin" })
9891
+ .then(function(res) { return res.json(); })
9892
+ .then(function(autoData) {
9893
+ if (!autoData.dmg) return;
9894
+ return fetch("/api/macos-dmg-update?currentVersion=" + encodeURIComponent(_macAppVersion), { credentials: "same-origin" })
9895
+ .then(function(res) { return res.json(); })
9896
+ .then(function(data) {
9897
+ if (!data.updateAvailable || !data.downloadUrl) return;
9898
+ try {
9899
+ WandNative.downloadUpdate(data.downloadUrl, data.fileName || "wand-update.dmg", data.source || "local");
9900
+ } catch (_e) {}
9901
+ });
9902
+ })
9903
+ .catch(function() {});
9904
+ }
9905
+
9713
9906
  function toggleAutoUpdate(type, enabled) {
9714
9907
  var body = {};
9715
9908
  body[type] = enabled;
@@ -9724,8 +9917,10 @@
9724
9917
  // Sync toggle state with server response
9725
9918
  var webToggle = document.getElementById("auto-update-web-toggle");
9726
9919
  var apkToggle = document.getElementById("auto-update-apk-toggle");
9920
+ var dmgToggle = document.getElementById("auto-update-dmg-toggle");
9727
9921
  if (webToggle) webToggle.checked = !!data.web;
9728
9922
  if (apkToggle) apkToggle.checked = !!data.apk;
9923
+ if (dmgToggle) dmgToggle.checked = !!data.dmg;
9729
9924
  })
9730
9925
  .catch(function() {
9731
9926
  // Revert toggle on failure
@@ -13870,6 +14065,35 @@
13870
14065
 
13871
14066
  // Drop any in-flight socket and start a new one *now* — used by the
13872
14067
  // Android resume bridge to recover from zombie connections (socket
14068
+ // 客户端 WS 心跳检测:每 10s 跑一次,看 lastWsMessageAt 距今多久。
14069
+ // 服务端每 20s 主动下推 {type:"ping"},所以 40s 没消息就明确是半开。
14070
+ // 浏览器在 background 时 setInterval 会被节流到 ~1Hz 或更慢,但
14071
+ // 我们也在 visibilitychange→visible 里做了一次主动评估,所以
14072
+ // 切回前台时不会拖很久才发现 stale。
14073
+ var WS_HEARTBEAT_CHECK_MS = 10_000;
14074
+ var WS_HEARTBEAT_STALE_MS = 40_000;
14075
+ function startWsHeartbeatCheck() {
14076
+ stopWsHeartbeatCheck();
14077
+ state.wsHeartbeatCheckTimer = setInterval(evaluateWsHeartbeatStale, WS_HEARTBEAT_CHECK_MS);
14078
+ }
14079
+ function stopWsHeartbeatCheck() {
14080
+ if (state.wsHeartbeatCheckTimer) {
14081
+ clearInterval(state.wsHeartbeatCheckTimer);
14082
+ state.wsHeartbeatCheckTimer = null;
14083
+ }
14084
+ }
14085
+ function evaluateWsHeartbeatStale() {
14086
+ if (!state.ws || state.ws.readyState !== WebSocket.OPEN) return;
14087
+ // 第一帧(包括 onopen)会刷新 lastWsMessageAt;如果还是 0 说明刚连上
14088
+ // 但服务端没下发任何东西(连 init 都没发出来)——交给下一轮检查。
14089
+ if (!state.lastWsMessageAt) return;
14090
+ var idle = Date.now() - state.lastWsMessageAt;
14091
+ if (idle > WS_HEARTBEAT_STALE_MS) {
14092
+ forceReconnectWebSocket("heartbeat-stale-" + Math.round(idle / 1000) + "s");
14093
+ }
14094
+ }
14095
+
14096
+ // Force a fresh WebSocket connection even if the existing one
13873
14097
  // still says OPEN, but the TCP path was torn down by Doze). Skips
13874
14098
  // the backoff timer; the caller has already decided this is urgent.
13875
14099
  function forceReconnectWebSocket(reason) {
@@ -13880,6 +14104,13 @@
13880
14104
  // reconnect path while we're already starting a fresh one.
13881
14105
  try { stale.onclose = null; } catch (e) { /* ignore */ }
13882
14106
  try { stale.onerror = null; } catch (e) { /* ignore */ }
14107
+ // 也清掉 onmessage:close() 是异步的,TCP RST/Close 帧到达之前,浏览器
14108
+ // socket 缓冲区里可能还有几条 in-flight 帧没派发。一旦它们在新 ws 已
14109
+ // open + init 之后再触发,老 ws 的 output 会被 handleWebSocketMessage
14110
+ // 当成"新增量"写进 wterm,造成"刚才那段又被画了一遍"。stale 心跳触发
14111
+ // 的 force reconnect 比 onclose 触发更早,老 ws 仍处于 OPEN,这个窗口
14112
+ // 更宽,必须显式 detach。
14113
+ try { stale.onmessage = null; } catch (e) { /* ignore */ }
13883
14114
  try { stale.close(); } catch (e) { /* ignore */ }
13884
14115
  state.ws = null;
13885
14116
  }
@@ -13926,6 +14157,7 @@
13926
14157
  ws.onopen = function() {
13927
14158
  state.ws = ws;
13928
14159
  state.wsConnected = true;
14160
+ state.lastWsMessageAt = Date.now();
13929
14161
  // Reset backoff on a successful connect so the next disconnect
13930
14162
  // starts the ladder from 500ms again.
13931
14163
  state.wsReconnectAttempts = 0;
@@ -13933,6 +14165,9 @@
13933
14165
  // Server's per-client output sequence counter restarts on every
13934
14166
  // new socket; clear ours so the first init isn't treated as a gap.
13935
14167
  state.lastSeqBySession = {};
14168
+ // 启动客户端心跳检测:每 10s 检查一次 lastWsMessageAt,超过 40s
14169
+ // 没收到任何消息(包括服务端 20s 一次的 ping)就视为半开连接。
14170
+ startWsHeartbeatCheck();
13936
14171
  // Subscribe to current session if any
13937
14172
  subscribeToSession(state.selectedId);
13938
14173
  // Flush pending messages after reconnection
@@ -13947,8 +14182,20 @@
13947
14182
  };
13948
14183
 
13949
14184
  ws.onmessage = function(event) {
14185
+ // 任意服务端消息都说明连接活着,先刷新心跳计时。
14186
+ state.lastWsMessageAt = Date.now();
13950
14187
  try {
13951
14188
  var msg = JSON.parse(event.data);
14189
+ // 应用层 ping:立刻回 pong。同时也让服务端能算 RTT。
14190
+ // 这条消息处理完就 return,不进入下面的 sessionId 分发流程。
14191
+ if (msg && msg.type === "ping") {
14192
+ if (state.ws && state.ws.readyState === WebSocket.OPEN) {
14193
+ try {
14194
+ state.ws.send(JSON.stringify({ type: "pong", t: msg.t }));
14195
+ } catch (sendErr) { /* ignore */ }
14196
+ }
14197
+ return;
14198
+ }
13952
14199
  if (msg && msg.type === "resync_required" && msg.sessionId) {
13953
14200
  // Server dropped some output events under backpressure and
13954
14201
  // is asking us for a fresh snapshot. Send a resync so the
@@ -13995,6 +14242,7 @@
13995
14242
  ws.onclose = function() {
13996
14243
  state.ws = null;
13997
14244
  state.wsConnected = false;
14245
+ stopWsHeartbeatCheck();
13998
14246
  scheduleWsReconnect();
13999
14247
  };
14000
14248
 
@@ -14128,7 +14376,9 @@
14128
14376
  // 变化的视觉错位无法被自愈,直到用户手动改窗口才修。现在让
14129
14377
  // wterm 内部 ResizeObserver 独占 cols 跟踪职责。
14130
14378
  wandTerminalWrite(state.terminal, msg.data.chunk);
14131
- maybeScheduleResyncForChunk(msg.data.chunk);
14379
+ // 同 syncTerminalBuffer 的 delta 分支:流式 chunk 不再触发
14380
+ // softResyncTerminal,避免完整重放把 cursor-home + 重画的
14381
+ // "被覆盖中间帧"反复塞进 scrollback。thinking→idle 兜底就够了。
14132
14382
  state.terminalSessionId = msg.sessionId;
14133
14383
  if (msg.data.output) {
14134
14384
  state.terminalOutput = clampClientTerminalOutput(normalizeTerminalOutput(msg.data.output));
@@ -14264,10 +14514,24 @@
14264
14514
  renderChat(true);
14265
14515
  updateTaskDisplay();
14266
14516
  updateApprovalStats();
14267
- // 订阅返回的是服务端 ring buffer 最新窗口,与客户端 terminalOutput
14268
- // 可能不连续。强制 replace(reset + 按当前 cols 重写)是订阅时唯一
14269
- // 可信的全量基线,避免 append prefix 检查走错分支。
14270
- updateTerminalOutput(msg.data.output || "", msg.sessionId, "replace");
14517
+ // 服务端 ring buffer 在多数场景下是当前已渲染输出的严格 superset
14518
+ // (同一 PTY,buffer 只增不减)。这种情况下走 append delta 就够了,
14519
+ // 不应该强制 replace。replace 会触发 resetTerminal + 全量重放整段
14520
+ // output,wterm ANSI cursor-home 重画的"中间帧"全部塞进
14521
+ // scrollback,造成"同一段回答在 PTY 视图里被画 N 遍"——尤其是
14522
+ // 锁屏 / 切回前台 / 心跳 stale 触发 reconnect 时,每次 init 都重放
14523
+ // 一次,累积重复非常显著。
14524
+ //
14525
+ // 只有真的不连续(会话切换、ring buffer 截断了头部、output 不是
14526
+ // currentOutput 的严格前缀延伸)才回退到 replace 的全量基线。
14527
+ var initOutput = msg.data.output || "";
14528
+ var sameTerminalSession = state.terminalSessionId === msg.sessionId;
14529
+ var currTerminalOutput = state.terminalOutput || "";
14530
+ var canAppendDelta = sameTerminalSession
14531
+ && currTerminalOutput.length > 0
14532
+ && initOutput.length >= currTerminalOutput.length
14533
+ && initOutput.startsWith(currTerminalOutput);
14534
+ updateTerminalOutput(initOutput, msg.sessionId, canAppendDelta ? "append" : "replace");
14271
14535
  // wterm 启动 cols=120,replace 写入可能落在错的列宽上;ResizeObserver
14272
14536
  // 回调异步,用 fit-with-retry 兜一次确保按真实宽度重排。
14273
14537
  ensureTerminalFitWithRetry("init");
@@ -17762,11 +18026,43 @@
17762
18026
 
17763
18027
  // ── Browser Notification API ──
17764
18028
 
18029
+ // macOS WKWebView shim: Android shell injects a global WandNative via
18030
+ // addJavascriptInterface, but WKWebView only exposes
18031
+ // webkit.messageHandlers.<name>.postMessage(...). To keep call-sites
18032
+ // identical across platforms, we synthesize a WandNative-shaped object
18033
+ // when running inside the macOS shell.
18034
+ try {
18035
+ var _macUaMatch = navigator.userAgent.match(/WandPlatform\/macOS/);
18036
+ var _macHandler = (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.wandNative) || null;
18037
+ if (_macUaMatch && _macHandler && typeof window.WandNative === "undefined") {
18038
+ window.WandNative = {
18039
+ // Only downloadUpdate is wired for now; other Android-specific
18040
+ // methods (notifications, haptics, screen wake) intentionally
18041
+ // omitted so feature detection falls back to web APIs on macOS.
18042
+ downloadUpdate: function(url, fileName, source) {
18043
+ try {
18044
+ _macHandler.postMessage({
18045
+ type: "downloadUpdate",
18046
+ url: String(url || ""),
18047
+ fileName: String(fileName || "wand-update.dmg"),
18048
+ source: String(source || "local"),
18049
+ });
18050
+ } catch (_e) {}
18051
+ },
18052
+ };
18053
+ }
18054
+ } catch (_e) {}
18055
+
17765
18056
  // Detect Android APK native bridge
17766
18057
  var _hasNativeBridge = typeof WandNative !== "undefined" && typeof WandNative.sendNotification === "function";
17767
- // Detect if running inside APK and extract installed version from User-Agent
17768
- var _apkVersionMatch = navigator.userAgent.match(/WandApp\/([^\s]+)/);
17769
- var _apkVersion = _apkVersionMatch ? _apkVersionMatch[1] : null;
18058
+ // Extract WandApp/<version> from User-Agent (set by both Android and macOS shells).
18059
+ // We distinguish platforms by the additional WandPlatform/<name> token —
18060
+ // macOS UA ends with "WandApp/X WandPlatform/macOS".
18061
+ var _wandAppMatch = navigator.userAgent.match(/WandApp\/([^\s]+)/);
18062
+ var _isMacApp = /WandPlatform\/macOS/.test(navigator.userAgent);
18063
+ var _isAndroidApp = !!_wandAppMatch && !_isMacApp;
18064
+ var _apkVersion = (_wandAppMatch && _isAndroidApp) ? _wandAppMatch[1] : null;
18065
+ var _macAppVersion = (_wandAppMatch && _isMacApp) ? _wandAppMatch[1] : null;
17770
18066
 
17771
18067
  function _vibrate(pattern) {
17772
18068
  if (!_hasNativeBridge || typeof WandNative.vibrate !== "function") return;