@co0ontty/wand 1.26.0 → 1.29.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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>' +
@@ -1816,13 +1835,11 @@
1816
1835
  tag: "",
1817
1836
  primaryAction: readSavedPrimaryAction(),
1818
1837
  };
1819
- state.quickCommitActionMenuOpen = false;
1820
- state.quickCommitTagHeadOpen = false;
1838
+ state.quickCommitOpenMenu = null;
1821
1839
  state.quickCommitTagHeadForm = { tag: "", push: false };
1822
1840
  state.quickCommitTagHeadSubmitting = false;
1823
1841
  state.quickCommitTagHeadGenerating = false;
1824
1842
  state.quickCommitTagHeadError = "";
1825
- state.quickCommitPushMenuOpen = false;
1826
1843
  state.quickCommitPushing = false;
1827
1844
  state.quickCommitPushError = "";
1828
1845
  closeWorktreeMergeModal();
@@ -1839,10 +1856,8 @@
1839
1856
  quickCommitEscHandler = function(e) {
1840
1857
  if (e.key === "Escape" && state.quickCommitOpen && !state.quickCommitSubmitting && !state.quickCommitTagHeadSubmitting && !state.quickCommitPushing) {
1841
1858
  // 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;
1859
+ if (state.quickCommitOpenMenu) {
1860
+ state.quickCommitOpenMenu = null;
1846
1861
  rerenderQuickCommitModal();
1847
1862
  return;
1848
1863
  }
@@ -1853,18 +1868,16 @@
1853
1868
  if (quickCommitDocClickHandler) document.removeEventListener("click", quickCommitDocClickHandler, true);
1854
1869
  quickCommitDocClickHandler = function(e) {
1855
1870
  if (!state.quickCommitOpen) return;
1871
+ // tag-head is an inline drawer, not a dropdown — clicking outside shouldn't close it.
1872
+ if (state.quickCommitOpenMenu !== "action" && state.quickCommitOpenMenu !== "push") return;
1856
1873
  var modalEl = document.getElementById("quick-commit-modal");
1857
1874
  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
1875
  var t = e.target;
1862
1876
  while (t && t !== modalEl) {
1863
1877
  if (t.dataset && (t.dataset.qcDropdownToggle || t.dataset.qcDropdownMenu)) return;
1864
1878
  t = t.parentNode;
1865
1879
  }
1866
- state.quickCommitActionMenuOpen = false;
1867
- state.quickCommitPushMenuOpen = false;
1880
+ state.quickCommitOpenMenu = null;
1868
1881
  rerenderQuickCommitModal();
1869
1882
  };
1870
1883
  document.addEventListener("click", quickCommitDocClickHandler, true);
@@ -1878,9 +1891,7 @@
1878
1891
  state.quickCommitOpen = false;
1879
1892
  state.quickCommitSubmitting = false;
1880
1893
  state.quickCommitError = "";
1881
- state.quickCommitActionMenuOpen = false;
1882
- state.quickCommitPushMenuOpen = false;
1883
- state.quickCommitTagHeadOpen = false;
1894
+ state.quickCommitOpenMenu = null;
1884
1895
  var modal = document.getElementById("quick-commit-modal");
1885
1896
  if (modal) modal.classList.add("hidden");
1886
1897
  if (focusTrapHandler) {
@@ -1918,7 +1929,6 @@
1918
1929
  var cancelBtn = document.getElementById("quick-commit-cancel-btn");
1919
1930
  if (cancelBtn) cancelBtn.addEventListener("click", closeQuickCommitModal);
1920
1931
 
1921
- // ── Section 1: commit form ──
1922
1932
  var submitBtn = document.getElementById("quick-commit-submit-btn");
1923
1933
  if (submitBtn) submitBtn.addEventListener("click", submitQuickCommit);
1924
1934
  var aiBtn = document.getElementById("quick-commit-ai-btn");
@@ -1938,12 +1948,10 @@
1938
1948
  state.quickCommitForm.tag = tagInput.value;
1939
1949
  });
1940
1950
 
1941
- // ── Primary action split-button (caret + dropdown) ──
1942
1951
  var actionCaret = document.getElementById("quick-commit-action-caret");
1943
1952
  if (actionCaret) actionCaret.addEventListener("click", function(e) {
1944
1953
  e.stopPropagation();
1945
- state.quickCommitActionMenuOpen = !state.quickCommitActionMenuOpen;
1946
- state.quickCommitPushMenuOpen = false;
1954
+ state.quickCommitOpenMenu = state.quickCommitOpenMenu === "action" ? null : "action";
1947
1955
  rerenderQuickCommitModal();
1948
1956
  });
1949
1957
  var actionMenu = document.getElementById("quick-commit-action-menu");
@@ -1957,29 +1965,27 @@
1957
1965
  state.quickCommitForm.primaryAction = value;
1958
1966
  savePrimaryAction(value);
1959
1967
  }
1960
- state.quickCommitActionMenuOpen = false;
1968
+ state.quickCommitOpenMenu = null;
1961
1969
  rerenderQuickCommitModal();
1962
1970
  });
1963
1971
  })(actionItems[i]);
1964
1972
  }
1965
1973
  }
1966
1974
 
1967
- // ── Section 2: Tag HEAD inline panel ──
1968
1975
  var tagHeadToggle = document.getElementById("quick-commit-tag-head-toggle");
1969
1976
  if (tagHeadToggle) tagHeadToggle.addEventListener("click", function() {
1970
- state.quickCommitTagHeadOpen = !state.quickCommitTagHeadOpen;
1971
- if (state.quickCommitTagHeadOpen) {
1972
- state.quickCommitTagHeadError = "";
1973
- }
1977
+ var willOpen = state.quickCommitOpenMenu !== "tag-head";
1978
+ state.quickCommitOpenMenu = willOpen ? "tag-head" : null;
1979
+ if (willOpen) state.quickCommitTagHeadError = "";
1974
1980
  rerenderQuickCommitModal();
1975
- if (state.quickCommitTagHeadOpen) {
1981
+ if (willOpen) {
1976
1982
  var inp = document.getElementById("quick-commit-tag-head-input");
1977
1983
  if (inp) inp.focus();
1978
1984
  }
1979
1985
  });
1980
1986
  var tagHeadCancel = document.getElementById("quick-commit-tag-head-cancel");
1981
1987
  if (tagHeadCancel) tagHeadCancel.addEventListener("click", function() {
1982
- state.quickCommitTagHeadOpen = false;
1988
+ if (state.quickCommitOpenMenu === "tag-head") state.quickCommitOpenMenu = null;
1983
1989
  state.quickCommitTagHeadError = "";
1984
1990
  rerenderQuickCommitModal();
1985
1991
  });
@@ -2006,7 +2012,6 @@
2006
2012
  submitTagHead(false);
2007
2013
  });
2008
2014
 
2009
- // ── Section 2: Push split-button ──
2010
2015
  var pushBtn = document.getElementById("quick-commit-push-btn");
2011
2016
  if (pushBtn) pushBtn.addEventListener("click", function() {
2012
2017
  submitPushOnly({ pushCommits: true, pushTags: false });
@@ -2014,8 +2019,7 @@
2014
2019
  var pushCaret = document.getElementById("quick-commit-push-caret");
2015
2020
  if (pushCaret) pushCaret.addEventListener("click", function(e) {
2016
2021
  e.stopPropagation();
2017
- state.quickCommitPushMenuOpen = !state.quickCommitPushMenuOpen;
2018
- state.quickCommitActionMenuOpen = false;
2022
+ state.quickCommitOpenMenu = state.quickCommitOpenMenu === "push" ? null : "push";
2019
2023
  rerenderQuickCommitModal();
2020
2024
  });
2021
2025
  var pushMenu = document.getElementById("quick-commit-push-menu");
@@ -2025,7 +2029,7 @@
2025
2029
  (function(btn) {
2026
2030
  btn.addEventListener("click", function() {
2027
2031
  var value = btn.getAttribute("data-qc-push");
2028
- state.quickCommitPushMenuOpen = false;
2032
+ state.quickCommitOpenMenu = null;
2029
2033
  if (value === "commits") submitPushOnly({ pushCommits: true, pushTags: false });
2030
2034
  else if (value === "tags") submitPushOnly({ pushCommits: false, pushTags: true });
2031
2035
  else if (value === "both") submitPushOnly({ pushCommits: true, pushTags: true });
@@ -2119,7 +2123,11 @@
2119
2123
  var data = result.data || {};
2120
2124
  var hash = data.commit && data.commit.hash ? data.commit.hash.substring(0, 7) : "";
2121
2125
  var tagName = data.tag && data.tag.name ? data.tag.name : "";
2122
- var base = "已提交" + (hash ? " " + hash : "") + (tagName ? ",已打 tag " + tagName : "");
2126
+ var subCommits = Array.isArray(data.submoduleCommits) ? data.submoduleCommits : [];
2127
+ var subPrefix = subCommits.length > 0
2128
+ ? "已先提交 " + subCommits.length + " 个 submodule(" + subCommits.map(function(c) { return c.path; }).join("、") + "),"
2129
+ : "";
2130
+ var base = subPrefix + "已提交" + (hash ? " " + hash : "") + (tagName ? ",已打 tag " + tagName : "");
2123
2131
  if (willPush && data.pushError) {
2124
2132
  var msg = base + ";push 失败:" + data.pushError;
2125
2133
  if (typeof showToast === "function") showToast(msg, "error");
@@ -2208,7 +2216,7 @@
2208
2216
  } else {
2209
2217
  if (typeof showToast === "function") showToast(base + (pushed ? ",已 push" : ""), "success");
2210
2218
  }
2211
- state.quickCommitTagHeadOpen = false;
2219
+ if (state.quickCommitOpenMenu === "tag-head") state.quickCommitOpenMenu = null;
2212
2220
  state.quickCommitTagHeadForm = { tag: "", push: false };
2213
2221
  if (state.selectedId) loadGitStatus(state.selectedId, { force: true }).then(function() {
2214
2222
  if (state.quickCommitOpen) rerenderQuickCommitModal();
@@ -2223,7 +2231,6 @@
2223
2231
  });
2224
2232
  }
2225
2233
 
2226
- // Push without committing.
2227
2234
  function submitPushOnly(opts) {
2228
2235
  if (!state.selectedId || state.quickCommitPushing) return;
2229
2236
  var pushCommits = !!(opts && opts.pushCommits);
@@ -2267,7 +2274,6 @@
2267
2274
  });
2268
2275
  }
2269
2276
 
2270
- // Build the file list portion of the modal.
2271
2277
  function renderQuickCommitFileRows(files) {
2272
2278
  var rows = files.map(function(item) {
2273
2279
  var status = (item.status || " ").substring(0, 2);
@@ -2293,15 +2299,19 @@
2293
2299
  return rows || '<div class="qc-empty">没有可提交的改动。</div>';
2294
2300
  }
2295
2301
 
2296
- // Primary action button label / icon.
2302
+ function isQuickCommitOpInFlight() {
2303
+ return state.quickCommitSubmitting || state.quickCommitTagHeadSubmitting || state.quickCommitPushing;
2304
+ }
2305
+
2297
2306
  function renderQuickCommitPrimary(hasChanges) {
2298
2307
  var f = state.quickCommitForm;
2299
2308
  var label;
2300
2309
  if (state.quickCommitSubmitting) label = "提交中…";
2301
2310
  else if (f.primaryAction === "commit-push") label = "提交并 push";
2302
2311
  else label = "仅提交";
2303
- var disabled = !hasChanges || state.quickCommitSubmitting || state.quickCommitTagHeadSubmitting || state.quickCommitPushing;
2304
- var caretActive = state.quickCommitActionMenuOpen ? " is-active" : "";
2312
+ var disabled = !hasChanges || isQuickCommitOpInFlight();
2313
+ var menuOpen = state.quickCommitOpenMenu === "action";
2314
+ var caretActive = menuOpen ? " is-active" : "";
2305
2315
  var menuItems = [
2306
2316
  { value: "commit", label: "仅提交", desc: "只 commit,不 push" },
2307
2317
  { value: "commit-push", label: "提交并 push", desc: "commit 后立即 push 到远端" }
@@ -2317,36 +2327,32 @@
2317
2327
  '<button id="quick-commit-submit-btn" class="btn btn-primary qc-split-main" type="button"' + (disabled ? ' disabled' : '') + '>' +
2318
2328
  escapeHtml(label) +
2319
2329
  '</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="更多提交方式">' +
2330
+ '<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
2331
  '<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
2332
  '</button>' +
2323
- (state.quickCommitActionMenuOpen ?
2333
+ (menuOpen ?
2324
2334
  '<div id="quick-commit-action-menu" class="qc-dropdown-menu" data-qc-dropdown-menu="action" role="menu">' + menuHtml + '</div>' : '') +
2325
2335
  '</div>';
2326
2336
  }
2327
2337
 
2328
- // Status chips (ahead/behind/unpushed tags) under the HEAD card.
2329
2338
  function renderQuickCommitStatusChips(s) {
2330
2339
  var chips = [];
2340
+ var hasUpstream = !!s.upstream;
2331
2341
  if (typeof s.ahead === "number" && s.ahead > 0) {
2332
2342
  chips.push('<span class="qc-chip qc-chip--ahead" title="本地领先 ' + s.ahead + ' 个 commit">↑ ' + s.ahead + ' 待推送</span>');
2333
2343
  }
2334
2344
  if (typeof s.behind === "number" && s.behind > 0) {
2335
2345
  chips.push('<span class="qc-chip qc-chip--behind" title="远端领先 ' + s.behind + ' 个 commit">↓ ' + s.behind + ' 待拉取</span>');
2336
2346
  }
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) {
2347
+ if (!hasUpstream) {
2341
2348
  chips.push('<span class="qc-chip qc-chip--warn" title="当前分支没有 upstream,将首次推送时自动设置">无 upstream</span>');
2342
- }
2343
- if (!chips.length && s.hasUpstream !== false) {
2349
+ } else if (!chips.length) {
2344
2350
  chips.push('<span class="qc-chip qc-chip--clean">与远端同步</span>');
2345
2351
  }
2346
2352
  return '<div class="qc-status-chips">' + chips.join("") + '</div>';
2347
2353
  }
2348
2354
 
2349
- // Inline "tag HEAD" drawer rendering.
2355
+ // Inline drawer used by the " HEAD 打 tag" toggle.
2350
2356
  function renderQuickCommitTagHeadPanel() {
2351
2357
  var thf = state.quickCommitTagHeadForm || { tag: "", push: false };
2352
2358
  var submitting = state.quickCommitTagHeadSubmitting;
@@ -2368,22 +2374,23 @@
2368
2374
  '</div>';
2369
2375
  }
2370
2376
 
2371
- // Push split-button on the secondary row.
2372
2377
  function renderQuickCommitPushButton(s) {
2373
2378
  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);
2379
+ var hasUpstream = !!s.upstream;
2380
+ // Allow pushing commits if we're ahead, if upstream is missing (first push will set it up),
2381
+ // or if ahead is simply unknown — backend will surface real errors.
2382
+ var canPushCommits = ahead > 0 || !hasUpstream || typeof s.ahead !== "number";
2383
+ // Tag count isn't computed locally (would require a network probe). Let the user try.
2384
+ var canPushTags = true;
2385
+ var disabled = isQuickCommitOpInFlight() || (!canPushCommits && !canPushTags);
2381
2386
  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) });
2387
+ var menuOpen = state.quickCommitOpenMenu === "push";
2388
+ var caretActive = menuOpen ? " is-active" : "";
2389
+ var items = [
2390
+ { value: "commits", label: "推送 commits", desc: ahead ? "推送 " + ahead + " commit" : "推送当前分支", disabled: !canPushCommits },
2391
+ { value: "tags", label: "推送 tags", desc: "推送所有本地 tag", disabled: !canPushTags },
2392
+ { value: "both", label: "推送 commits 和 tags", desc: "二合一", disabled: !(canPushCommits || canPushTags) }
2393
+ ];
2387
2394
  var menuHtml = items.map(function(it) {
2388
2395
  var dis = it.disabled ? " is-disabled" : "";
2389
2396
  return '<button type="button" class="qc-dropdown-item' + dis + '" data-qc-push="' + it.value + '"' + (it.disabled ? ' disabled' : '') + '>' +
@@ -2395,10 +2402,10 @@
2395
2402
  '<button id="quick-commit-push-btn" class="btn btn-secondary qc-split-main" type="button"' + (disabled ? ' disabled' : '') + '>' +
2396
2403
  escapeHtml(mainLabel) +
2397
2404
  '</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="更多推送方式">' +
2405
+ '<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
2406
  '<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
2407
  '</button>' +
2401
- (state.quickCommitPushMenuOpen ?
2408
+ (menuOpen ?
2402
2409
  '<div id="quick-commit-push-menu" class="qc-dropdown-menu qc-dropdown-menu--right" data-qc-dropdown-menu="push" role="menu">' + menuHtml + '</div>' : '') +
2403
2410
  '</div>';
2404
2411
  }
@@ -2467,11 +2474,11 @@
2467
2474
  '<code class="qc-head-text">' + escapeHtml(headLine) + '</code>' +
2468
2475
  '</div>' +
2469
2476
  renderQuickCommitStatusChips(s) +
2470
- (state.quickCommitTagHeadOpen ? renderQuickCommitTagHeadPanel() : '') +
2477
+ (state.quickCommitOpenMenu === "tag-head" ? renderQuickCommitTagHeadPanel() : '') +
2471
2478
  '<div class="qc-section-actions qc-section-actions--secondary">' +
2472
2479
  '<button id="quick-commit-tag-head-toggle" class="btn btn-ghost btn-sm" type="button"' + (state.quickCommitPushing ? ' disabled' : '') + '>' +
2473
2480
  '<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') +
2481
+ (state.quickCommitOpenMenu === "tag-head" ? '收起' : '为 HEAD 打 tag') +
2475
2482
  '</button>' +
2476
2483
  renderQuickCommitPushButton(s) +
2477
2484
  '</div>' +
@@ -2640,6 +2647,40 @@
2640
2647
  '</div>' +
2641
2648
  '<p id="android-apk-message" class="hint hidden"></p>' +
2642
2649
  '</div>' +
2650
+ '<div class="settings-update-section hidden" id="macos-dmg-section">' +
2651
+ '<div class="settings-section-head">' +
2652
+ '<span class="settings-section-icon">🖥️</span>' +
2653
+ '<div class="settings-section-head-text">' +
2654
+ '<h4 class="settings-section-heading">macOS App</h4>' +
2655
+ '<p class="settings-section-sub">原生客户端版本与 DMG 下载</p>' +
2656
+ '</div>' +
2657
+ '</div>' +
2658
+ '<div id="macos-dmg-current-row" class="settings-about-row hidden">' +
2659
+ '<span class="settings-label">当前版本</span>' +
2660
+ '<span class="settings-value" id="settings-macos-dmg-current">-</span>' +
2661
+ '</div>' +
2662
+ '<div id="macos-dmg-github-row" class="settings-about-row settings-about-row-action hidden">' +
2663
+ '<span class="settings-label">线上版本</span>' +
2664
+ '<span class="settings-value settings-value-flex" id="settings-macos-dmg-github">-</span>' +
2665
+ '<button id="download-github-dmg-btn" class="btn btn-secondary btn-sm hidden" type="button">下载</button>' +
2666
+ '</div>' +
2667
+ '<div id="macos-dmg-local-row" class="settings-about-row settings-about-row-action hidden">' +
2668
+ '<span class="settings-label">本地版本</span>' +
2669
+ '<span class="settings-value settings-value-flex" id="settings-macos-dmg-local">-</span>' +
2670
+ '<button id="download-local-dmg-btn" class="btn btn-secondary btn-sm hidden" type="button">下载</button>' +
2671
+ '</div>' +
2672
+ '<div id="macos-auto-update-row" class="settings-toggle-row hidden">' +
2673
+ '<div class="settings-toggle-text">' +
2674
+ '<span class="settings-toggle-title">自动更新</span>' +
2675
+ '<span class="settings-toggle-desc" id="macos-auto-update-hint">检测到新版 DMG 将自动下载并挂载。</span>' +
2676
+ '</div>' +
2677
+ '<label class="settings-switch">' +
2678
+ '<input type="checkbox" id="auto-update-dmg-toggle" class="switch-toggle">' +
2679
+ '<span class="switch-slider"></span>' +
2680
+ '</label>' +
2681
+ '</div>' +
2682
+ '<p id="macos-dmg-message" class="hint hidden"></p>' +
2683
+ '</div>' +
2643
2684
  '<div class="settings-update-section" id="android-connect-section">' +
2644
2685
  '<div class="settings-section-head">' +
2645
2686
  '<span class="settings-section-icon">🔗</span>' +
@@ -5580,6 +5621,10 @@
5580
5621
  if (autoUpdateApkToggle) autoUpdateApkToggle.addEventListener("change", function() {
5581
5622
  toggleAutoUpdate("apk", autoUpdateApkToggle.checked);
5582
5623
  });
5624
+ var autoUpdateDmgToggle = document.getElementById("auto-update-dmg-toggle");
5625
+ if (autoUpdateDmgToggle) autoUpdateDmgToggle.addEventListener("change", function() {
5626
+ toggleAutoUpdate("dmg", autoUpdateDmgToggle.checked);
5627
+ });
5583
5628
  var copyConnectCodeBtn = document.getElementById("copy-connect-code-button");
5584
5629
  if (copyConnectCodeBtn) copyConnectCodeBtn.addEventListener("click", function() {
5585
5630
  var text = document.getElementById("android-connect-code");
@@ -7279,7 +7324,13 @@
7279
7324
  var delta = normalizedOutput.slice(currentOutput.length);
7280
7325
  if (delta) {
7281
7326
  wandTerminalWrite(state.terminal, delta);
7282
- maybeScheduleResyncForChunk(delta);
7327
+ // 不在流式 chunk 路径触发 softResyncTerminal —— resync 会
7328
+ // resetTerminal() + 完整重放整段 buffer,重放期间所有 cursor-home
7329
+ // + 重画序列把"被覆盖的中间帧"反复塞进 main-screen scrollback,
7330
+ // 表现为 PTY 视图里同一段回答被画 N 遍(PC 端列宽大、Claude TUI
7331
+ // 重画序列密集时最严重)。响应结束的 thinking→idle 边界已经做了
7332
+ // 一次 softResync 兜底,足以清掉流式残留的错位光标 DOM;流式
7333
+ // 过程中持续重放只是纯粹的重复制造器。
7283
7334
  wrote = true;
7284
7335
  }
7285
7336
  } else if (currentOutput && currentOutput.startsWith(normalizedOutput)) {
@@ -9222,6 +9273,8 @@
9222
9273
  if (autoUpdateWebToggle) autoUpdateWebToggle.checked = !!autoUpdate.web;
9223
9274
  var autoUpdateApkToggle = document.getElementById("auto-update-apk-toggle");
9224
9275
  if (autoUpdateApkToggle) autoUpdateApkToggle.checked = !!autoUpdate.apk;
9276
+ var autoUpdateDmgToggle = document.getElementById("auto-update-dmg-toggle");
9277
+ if (autoUpdateDmgToggle) autoUpdateDmgToggle.checked = !!autoUpdate.dmg;
9225
9278
 
9226
9279
  // ── Android APK version display ──
9227
9280
  var apkSection = document.getElementById("android-apk-section");
@@ -9341,6 +9394,119 @@
9341
9394
  }
9342
9395
  }
9343
9396
 
9397
+ // ── macOS DMG version display ──
9398
+ var dmgSection = document.getElementById("macos-dmg-section");
9399
+ var dmgCurrentRow = document.getElementById("macos-dmg-current-row");
9400
+ var dmgCurrentEl = document.getElementById("settings-macos-dmg-current");
9401
+ var dmgGithubRow = document.getElementById("macos-dmg-github-row");
9402
+ var dmgGithubEl = document.getElementById("settings-macos-dmg-github");
9403
+ var dmgGithubBtn = document.getElementById("download-github-dmg-btn");
9404
+ var dmgLocalRow = document.getElementById("macos-dmg-local-row");
9405
+ var dmgLocalEl = document.getElementById("settings-macos-dmg-local");
9406
+ var dmgLocalBtn = document.getElementById("download-local-dmg-btn");
9407
+ var dmgMessageEl = document.getElementById("macos-dmg-message");
9408
+ var macosDmg = data.macosDmg || {};
9409
+ var isInMacApp = !!_macAppVersion;
9410
+ var hasDmgInfo = isInMacApp || !!macosDmg.github || !!macosDmg.local;
9411
+ if (dmgSection) {
9412
+ if (hasDmgInfo) dmgSection.classList.remove("hidden");
9413
+ else dmgSection.classList.add("hidden");
9414
+ }
9415
+
9416
+ if (isInMacApp) {
9417
+ // ── macOS 壳内:显示当前版本 + 线上 + 本地 + 下载安装按钮 ──
9418
+ if (dmgCurrentRow && dmgCurrentEl) {
9419
+ dmgCurrentEl.textContent = "v" + _macAppVersion;
9420
+ dmgCurrentRow.classList.remove("hidden");
9421
+ }
9422
+ if (macosDmg.github && dmgGithubRow && dmgGithubEl) {
9423
+ var dghLabel = macosDmg.github.version ? ("v" + macosDmg.github.version) : macosDmg.github.fileName;
9424
+ if (typeof macosDmg.github.size === "number") dghLabel += " · " + formatBytes(macosDmg.github.size);
9425
+ dmgGithubEl.textContent = dghLabel;
9426
+ dmgGithubRow.classList.remove("hidden");
9427
+ if (dmgGithubBtn) {
9428
+ dmgGithubBtn.textContent = "下载安装";
9429
+ dmgGithubBtn.classList.remove("hidden");
9430
+ dmgGithubBtn.onclick = function() {
9431
+ try {
9432
+ WandNative.downloadUpdate(macosDmg.github.downloadUrl, macosDmg.github.fileName || "wand-update.dmg", "github");
9433
+ } catch (e) {
9434
+ if (typeof window.wandAlert === "function") {
9435
+ window.wandAlert("调用下载失败: " + e.message, { type: "danger", title: "下载失败" });
9436
+ } else if (typeof showToast === "function") {
9437
+ showToast("调用下载失败: " + e.message, "error");
9438
+ } else {
9439
+ alert("调用下载失败: " + e.message);
9440
+ }
9441
+ }
9442
+ };
9443
+ }
9444
+ }
9445
+ if (macosDmg.local && dmgLocalRow && dmgLocalEl) {
9446
+ var dlcLabel = macosDmg.local.version ? ("v" + macosDmg.local.version) : macosDmg.local.fileName;
9447
+ if (typeof macosDmg.local.size === "number") dlcLabel += " · " + formatBytes(macosDmg.local.size);
9448
+ dmgLocalEl.textContent = dlcLabel;
9449
+ dmgLocalRow.classList.remove("hidden");
9450
+ if (dmgLocalBtn) {
9451
+ dmgLocalBtn.textContent = "下载安装";
9452
+ dmgLocalBtn.classList.remove("hidden");
9453
+ dmgLocalBtn.onclick = function() {
9454
+ try {
9455
+ WandNative.downloadUpdate(macosDmg.local.downloadUrl, macosDmg.local.fileName || "wand-update.dmg", "local");
9456
+ } catch (e) {
9457
+ if (typeof window.wandAlert === "function") {
9458
+ window.wandAlert("调用下载失败: " + e.message, { type: "danger", title: "下载失败" });
9459
+ } else if (typeof showToast === "function") {
9460
+ showToast("调用下载失败: " + e.message, "error");
9461
+ } else {
9462
+ alert("调用下载失败: " + e.message);
9463
+ }
9464
+ }
9465
+ };
9466
+ }
9467
+ }
9468
+ if (!macosDmg.github && !macosDmg.local && dmgMessageEl) {
9469
+ dmgMessageEl.textContent = "暂无可用更新";
9470
+ dmgMessageEl.classList.remove("hidden");
9471
+ }
9472
+ var dmgAutoRow = document.getElementById("macos-auto-update-row");
9473
+ var dmgAutoHint = document.getElementById("macos-auto-update-hint");
9474
+ if (dmgAutoRow) dmgAutoRow.classList.remove("hidden");
9475
+ if (dmgAutoHint) dmgAutoHint.classList.remove("hidden");
9476
+ } else {
9477
+ // ── 浏览器模式:仅展示下载入口 ──
9478
+ if (macosDmg.github && dmgGithubRow && dmgGithubEl) {
9479
+ var dghLabel2 = macosDmg.github.version ? ("v" + macosDmg.github.version) : macosDmg.github.fileName;
9480
+ if (typeof macosDmg.github.size === "number") dghLabel2 += " · " + formatBytes(macosDmg.github.size);
9481
+ dmgGithubEl.textContent = dghLabel2;
9482
+ dmgGithubRow.classList.remove("hidden");
9483
+ if (dmgGithubBtn) {
9484
+ dmgGithubBtn.textContent = "下载";
9485
+ dmgGithubBtn.classList.remove("hidden");
9486
+ dmgGithubBtn.onclick = function() {
9487
+ window.open(macosDmg.github.downloadUrl, "_blank");
9488
+ };
9489
+ }
9490
+ }
9491
+ if (macosDmg.local && dmgLocalRow && dmgLocalEl) {
9492
+ var dlcLabel2 = macosDmg.local.version ? ("v" + macosDmg.local.version) : macosDmg.local.fileName;
9493
+ if (typeof macosDmg.local.size === "number") dlcLabel2 += " · " + formatBytes(macosDmg.local.size);
9494
+ dmgLocalEl.textContent = dlcLabel2;
9495
+ dmgLocalRow.classList.remove("hidden");
9496
+ if (dmgLocalBtn) {
9497
+ dmgLocalBtn.textContent = "下载";
9498
+ dmgLocalBtn.classList.remove("hidden");
9499
+ dmgLocalBtn.onclick = function() {
9500
+ window.open(macosDmg.local.downloadUrl, "_self");
9501
+ };
9502
+ }
9503
+ }
9504
+ if (!macosDmg.github && !macosDmg.local && dmgMessageEl) {
9505
+ dmgMessageEl.textContent = "暂未提供";
9506
+ dmgMessageEl.classList.remove("hidden");
9507
+ }
9508
+ }
9509
+
9344
9510
  // App connect code (encrypted)
9345
9511
  var connectCodeEl = document.getElementById("android-connect-code");
9346
9512
  var connectQrCanvas = document.getElementById("android-connect-qr");
@@ -9710,6 +9876,24 @@
9710
9876
  .catch(function() {});
9711
9877
  }
9712
9878
 
9879
+ function checkDmgAutoUpdate() {
9880
+ if (!_macAppVersion) return;
9881
+ fetch("/api/auto-update", { credentials: "same-origin" })
9882
+ .then(function(res) { return res.json(); })
9883
+ .then(function(autoData) {
9884
+ if (!autoData.dmg) return;
9885
+ return fetch("/api/macos-dmg-update?currentVersion=" + encodeURIComponent(_macAppVersion), { credentials: "same-origin" })
9886
+ .then(function(res) { return res.json(); })
9887
+ .then(function(data) {
9888
+ if (!data.updateAvailable || !data.downloadUrl) return;
9889
+ try {
9890
+ WandNative.downloadUpdate(data.downloadUrl, data.fileName || "wand-update.dmg", data.source || "local");
9891
+ } catch (_e) {}
9892
+ });
9893
+ })
9894
+ .catch(function() {});
9895
+ }
9896
+
9713
9897
  function toggleAutoUpdate(type, enabled) {
9714
9898
  var body = {};
9715
9899
  body[type] = enabled;
@@ -9724,8 +9908,10 @@
9724
9908
  // Sync toggle state with server response
9725
9909
  var webToggle = document.getElementById("auto-update-web-toggle");
9726
9910
  var apkToggle = document.getElementById("auto-update-apk-toggle");
9911
+ var dmgToggle = document.getElementById("auto-update-dmg-toggle");
9727
9912
  if (webToggle) webToggle.checked = !!data.web;
9728
9913
  if (apkToggle) apkToggle.checked = !!data.apk;
9914
+ if (dmgToggle) dmgToggle.checked = !!data.dmg;
9729
9915
  })
9730
9916
  .catch(function() {
9731
9917
  // Revert toggle on failure
@@ -13870,6 +14056,35 @@
13870
14056
 
13871
14057
  // Drop any in-flight socket and start a new one *now* — used by the
13872
14058
  // Android resume bridge to recover from zombie connections (socket
14059
+ // 客户端 WS 心跳检测:每 10s 跑一次,看 lastWsMessageAt 距今多久。
14060
+ // 服务端每 20s 主动下推 {type:"ping"},所以 40s 没消息就明确是半开。
14061
+ // 浏览器在 background 时 setInterval 会被节流到 ~1Hz 或更慢,但
14062
+ // 我们也在 visibilitychange→visible 里做了一次主动评估,所以
14063
+ // 切回前台时不会拖很久才发现 stale。
14064
+ var WS_HEARTBEAT_CHECK_MS = 10_000;
14065
+ var WS_HEARTBEAT_STALE_MS = 40_000;
14066
+ function startWsHeartbeatCheck() {
14067
+ stopWsHeartbeatCheck();
14068
+ state.wsHeartbeatCheckTimer = setInterval(evaluateWsHeartbeatStale, WS_HEARTBEAT_CHECK_MS);
14069
+ }
14070
+ function stopWsHeartbeatCheck() {
14071
+ if (state.wsHeartbeatCheckTimer) {
14072
+ clearInterval(state.wsHeartbeatCheckTimer);
14073
+ state.wsHeartbeatCheckTimer = null;
14074
+ }
14075
+ }
14076
+ function evaluateWsHeartbeatStale() {
14077
+ if (!state.ws || state.ws.readyState !== WebSocket.OPEN) return;
14078
+ // 第一帧(包括 onopen)会刷新 lastWsMessageAt;如果还是 0 说明刚连上
14079
+ // 但服务端没下发任何东西(连 init 都没发出来)——交给下一轮检查。
14080
+ if (!state.lastWsMessageAt) return;
14081
+ var idle = Date.now() - state.lastWsMessageAt;
14082
+ if (idle > WS_HEARTBEAT_STALE_MS) {
14083
+ forceReconnectWebSocket("heartbeat-stale-" + Math.round(idle / 1000) + "s");
14084
+ }
14085
+ }
14086
+
14087
+ // Force a fresh WebSocket connection even if the existing one
13873
14088
  // still says OPEN, but the TCP path was torn down by Doze). Skips
13874
14089
  // the backoff timer; the caller has already decided this is urgent.
13875
14090
  function forceReconnectWebSocket(reason) {
@@ -13880,6 +14095,13 @@
13880
14095
  // reconnect path while we're already starting a fresh one.
13881
14096
  try { stale.onclose = null; } catch (e) { /* ignore */ }
13882
14097
  try { stale.onerror = null; } catch (e) { /* ignore */ }
14098
+ // 也清掉 onmessage:close() 是异步的,TCP RST/Close 帧到达之前,浏览器
14099
+ // socket 缓冲区里可能还有几条 in-flight 帧没派发。一旦它们在新 ws 已
14100
+ // open + init 之后再触发,老 ws 的 output 会被 handleWebSocketMessage
14101
+ // 当成"新增量"写进 wterm,造成"刚才那段又被画了一遍"。stale 心跳触发
14102
+ // 的 force reconnect 比 onclose 触发更早,老 ws 仍处于 OPEN,这个窗口
14103
+ // 更宽,必须显式 detach。
14104
+ try { stale.onmessage = null; } catch (e) { /* ignore */ }
13883
14105
  try { stale.close(); } catch (e) { /* ignore */ }
13884
14106
  state.ws = null;
13885
14107
  }
@@ -13926,6 +14148,7 @@
13926
14148
  ws.onopen = function() {
13927
14149
  state.ws = ws;
13928
14150
  state.wsConnected = true;
14151
+ state.lastWsMessageAt = Date.now();
13929
14152
  // Reset backoff on a successful connect so the next disconnect
13930
14153
  // starts the ladder from 500ms again.
13931
14154
  state.wsReconnectAttempts = 0;
@@ -13933,6 +14156,9 @@
13933
14156
  // Server's per-client output sequence counter restarts on every
13934
14157
  // new socket; clear ours so the first init isn't treated as a gap.
13935
14158
  state.lastSeqBySession = {};
14159
+ // 启动客户端心跳检测:每 10s 检查一次 lastWsMessageAt,超过 40s
14160
+ // 没收到任何消息(包括服务端 20s 一次的 ping)就视为半开连接。
14161
+ startWsHeartbeatCheck();
13936
14162
  // Subscribe to current session if any
13937
14163
  subscribeToSession(state.selectedId);
13938
14164
  // Flush pending messages after reconnection
@@ -13947,8 +14173,20 @@
13947
14173
  };
13948
14174
 
13949
14175
  ws.onmessage = function(event) {
14176
+ // 任意服务端消息都说明连接活着,先刷新心跳计时。
14177
+ state.lastWsMessageAt = Date.now();
13950
14178
  try {
13951
14179
  var msg = JSON.parse(event.data);
14180
+ // 应用层 ping:立刻回 pong。同时也让服务端能算 RTT。
14181
+ // 这条消息处理完就 return,不进入下面的 sessionId 分发流程。
14182
+ if (msg && msg.type === "ping") {
14183
+ if (state.ws && state.ws.readyState === WebSocket.OPEN) {
14184
+ try {
14185
+ state.ws.send(JSON.stringify({ type: "pong", t: msg.t }));
14186
+ } catch (sendErr) { /* ignore */ }
14187
+ }
14188
+ return;
14189
+ }
13952
14190
  if (msg && msg.type === "resync_required" && msg.sessionId) {
13953
14191
  // Server dropped some output events under backpressure and
13954
14192
  // is asking us for a fresh snapshot. Send a resync so the
@@ -13995,6 +14233,7 @@
13995
14233
  ws.onclose = function() {
13996
14234
  state.ws = null;
13997
14235
  state.wsConnected = false;
14236
+ stopWsHeartbeatCheck();
13998
14237
  scheduleWsReconnect();
13999
14238
  };
14000
14239
 
@@ -14128,7 +14367,9 @@
14128
14367
  // 变化的视觉错位无法被自愈,直到用户手动改窗口才修。现在让
14129
14368
  // wterm 内部 ResizeObserver 独占 cols 跟踪职责。
14130
14369
  wandTerminalWrite(state.terminal, msg.data.chunk);
14131
- maybeScheduleResyncForChunk(msg.data.chunk);
14370
+ // 同 syncTerminalBuffer 的 delta 分支:流式 chunk 不再触发
14371
+ // softResyncTerminal,避免完整重放把 cursor-home + 重画的
14372
+ // "被覆盖中间帧"反复塞进 scrollback。thinking→idle 兜底就够了。
14132
14373
  state.terminalSessionId = msg.sessionId;
14133
14374
  if (msg.data.output) {
14134
14375
  state.terminalOutput = clampClientTerminalOutput(normalizeTerminalOutput(msg.data.output));
@@ -14264,10 +14505,24 @@
14264
14505
  renderChat(true);
14265
14506
  updateTaskDisplay();
14266
14507
  updateApprovalStats();
14267
- // 订阅返回的是服务端 ring buffer 最新窗口,与客户端 terminalOutput
14268
- // 可能不连续。强制 replace(reset + 按当前 cols 重写)是订阅时唯一
14269
- // 可信的全量基线,避免 append prefix 检查走错分支。
14270
- updateTerminalOutput(msg.data.output || "", msg.sessionId, "replace");
14508
+ // 服务端 ring buffer 在多数场景下是当前已渲染输出的严格 superset
14509
+ // (同一 PTY,buffer 只增不减)。这种情况下走 append delta 就够了,
14510
+ // 不应该强制 replace。replace 会触发 resetTerminal + 全量重放整段
14511
+ // output,wterm ANSI cursor-home 重画的"中间帧"全部塞进
14512
+ // scrollback,造成"同一段回答在 PTY 视图里被画 N 遍"——尤其是
14513
+ // 锁屏 / 切回前台 / 心跳 stale 触发 reconnect 时,每次 init 都重放
14514
+ // 一次,累积重复非常显著。
14515
+ //
14516
+ // 只有真的不连续(会话切换、ring buffer 截断了头部、output 不是
14517
+ // currentOutput 的严格前缀延伸)才回退到 replace 的全量基线。
14518
+ var initOutput = msg.data.output || "";
14519
+ var sameTerminalSession = state.terminalSessionId === msg.sessionId;
14520
+ var currTerminalOutput = state.terminalOutput || "";
14521
+ var canAppendDelta = sameTerminalSession
14522
+ && currTerminalOutput.length > 0
14523
+ && initOutput.length >= currTerminalOutput.length
14524
+ && initOutput.startsWith(currTerminalOutput);
14525
+ updateTerminalOutput(initOutput, msg.sessionId, canAppendDelta ? "append" : "replace");
14271
14526
  // wterm 启动 cols=120,replace 写入可能落在错的列宽上;ResizeObserver
14272
14527
  // 回调异步,用 fit-with-retry 兜一次确保按真实宽度重排。
14273
14528
  ensureTerminalFitWithRetry("init");
@@ -17762,11 +18017,43 @@
17762
18017
 
17763
18018
  // ── Browser Notification API ──
17764
18019
 
18020
+ // macOS WKWebView shim: Android shell injects a global WandNative via
18021
+ // addJavascriptInterface, but WKWebView only exposes
18022
+ // webkit.messageHandlers.<name>.postMessage(...). To keep call-sites
18023
+ // identical across platforms, we synthesize a WandNative-shaped object
18024
+ // when running inside the macOS shell.
18025
+ try {
18026
+ var _macUaMatch = navigator.userAgent.match(/WandPlatform\/macOS/);
18027
+ var _macHandler = (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.wandNative) || null;
18028
+ if (_macUaMatch && _macHandler && typeof window.WandNative === "undefined") {
18029
+ window.WandNative = {
18030
+ // Only downloadUpdate is wired for now; other Android-specific
18031
+ // methods (notifications, haptics, screen wake) intentionally
18032
+ // omitted so feature detection falls back to web APIs on macOS.
18033
+ downloadUpdate: function(url, fileName, source) {
18034
+ try {
18035
+ _macHandler.postMessage({
18036
+ type: "downloadUpdate",
18037
+ url: String(url || ""),
18038
+ fileName: String(fileName || "wand-update.dmg"),
18039
+ source: String(source || "local"),
18040
+ });
18041
+ } catch (_e) {}
18042
+ },
18043
+ };
18044
+ }
18045
+ } catch (_e) {}
18046
+
17765
18047
  // Detect Android APK native bridge
17766
18048
  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;
18049
+ // Extract WandApp/<version> from User-Agent (set by both Android and macOS shells).
18050
+ // We distinguish platforms by the additional WandPlatform/<name> token —
18051
+ // macOS UA ends with "WandApp/X WandPlatform/macOS".
18052
+ var _wandAppMatch = navigator.userAgent.match(/WandApp\/([^\s]+)/);
18053
+ var _isMacApp = /WandPlatform\/macOS/.test(navigator.userAgent);
18054
+ var _isAndroidApp = !!_wandAppMatch && !_isMacApp;
18055
+ var _apkVersion = (_wandAppMatch && _isAndroidApp) ? _wandAppMatch[1] : null;
18056
+ var _macAppVersion = (_wandAppMatch && _isMacApp) ? _wandAppMatch[1] : null;
17770
18057
 
17771
18058
  function _vibrate(pattern) {
17772
18059
  if (!_hasNativeBridge || typeof WandNative.vibrate !== "function") return;