@co0ontty/wand 1.25.5 → 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,
@@ -195,7 +203,20 @@
195
203
  quickCommitSubmitting: false,
196
204
  quickCommitGenerating: false,
197
205
  quickCommitError: "",
198
- quickCommitForm: { autoMessage: false, customMessage: "", makeTag: false, tag: "", push: false },
206
+ // primaryAction: "commit" (commit only, no push) or "commit-push" (commit then push).
207
+ // Whether the commit is also tagged is controlled by `makeTag` independently —
208
+ // that keeps "commit + tag, push later" possible.
209
+ quickCommitForm: { customMessage: "", makeTag: false, tag: "", primaryAction: "commit" },
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,
214
+ quickCommitTagHeadForm: { tag: "", push: false },
215
+ quickCommitTagHeadSubmitting: false,
216
+ quickCommitTagHeadGenerating: false,
217
+ quickCommitTagHeadError: "",
218
+ quickCommitPushing: false,
219
+ quickCommitPushError: "",
199
220
  // Telegram 风格的"贴底"状态:true = 用户当前贴在底部,新消息会自然出现;
200
221
  // false = 用户向上滚了,未读会累积到气泡里,不会自动滚他们的视图。
201
222
  chatStickToBottom: true,
@@ -1013,6 +1034,11 @@
1013
1034
  function syncOnForeground(reason, force) {
1014
1035
  if (!state.config) return Promise.resolve();
1015
1036
  if (document.hidden) return Promise.resolve();
1037
+ // 切回前台时立刻评估一次心跳 stale。setInterval 在 background 会被
1038
+ // 浏览器节流(最低 1Hz,部分浏览器更慢),所以如果挂了 1 分钟回来,
1039
+ // 不主动跑这一次的话要等到下一个 10s tick 才会发现,前 10s 会继续
1040
+ // 往一条死 socket 上推消息。
1041
+ evaluateWsHeartbeatStale();
1016
1042
  // On Android resume the previous WS may still report OPEN/CONNECTING
1017
1043
  // for a few seconds because the close frame hasn't been delivered
1018
1044
  // yet (TCP keepalive / Doze suspended the network stack). Force a
@@ -1154,6 +1180,10 @@
1154
1180
  if (_apkVersion) {
1155
1181
  checkApkAutoUpdate();
1156
1182
  }
1183
+ // macOS DMG auto-update check on startup
1184
+ if (_macAppVersion) {
1185
+ checkDmgAutoUpdate();
1186
+ }
1157
1187
  if (state.claudeHistoryExpanded && !state.claudeHistoryLoaded) {
1158
1188
  loadClaudeHistory();
1159
1189
  }
@@ -1398,6 +1428,9 @@
1398
1428
  '<button id="sidebar-pin-btn" class="btn btn-ghost btn-sm sidebar-pin-toggle' + (state.sidebarPinned ? ' pinned' : '') + '" type="button" title="' + (state.sidebarPinned ? '取消固定侧栏' : '固定侧栏') + '">' +
1399
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>' +
1400
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>' +
1401
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>' +
1402
1435
  '</div>' +
1403
1436
  '</div>' +
@@ -1434,6 +1467,15 @@
1434
1467
  '</button>' +
1435
1468
  '</div>' +
1436
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>' +
1437
1479
  '</aside>' +
1438
1480
  '<main class="main-content">' +
1439
1481
  '<div class="main-header-row">' +
@@ -1777,13 +1819,38 @@
1777
1819
  }
1778
1820
 
1779
1821
  var quickCommitEscHandler = null;
1822
+ var quickCommitDocClickHandler = null;
1823
+
1824
+ // Restore the user's last "primary action" choice so the split button feels sticky.
1825
+ function readSavedPrimaryAction() {
1826
+ try {
1827
+ var v = localStorage.getItem("wand.quickCommit.primaryAction");
1828
+ if (v === "commit" || v === "commit-push") return v;
1829
+ } catch (e) { /* localStorage may be blocked */ }
1830
+ return "commit";
1831
+ }
1832
+ function savePrimaryAction(value) {
1833
+ try { localStorage.setItem("wand.quickCommit.primaryAction", value); } catch (e) { /* no-op */ }
1834
+ }
1780
1835
 
1781
1836
  function openQuickCommitModal() {
1782
1837
  if (!state.selectedId) return;
1783
1838
  state.quickCommitOpen = true;
1784
1839
  state.quickCommitSubmitting = false;
1785
1840
  state.quickCommitError = "";
1786
- state.quickCommitForm = { autoMessage: false, customMessage: "", makeTag: false, tag: "", push: false };
1841
+ state.quickCommitForm = {
1842
+ customMessage: "",
1843
+ makeTag: false,
1844
+ tag: "",
1845
+ primaryAction: readSavedPrimaryAction(),
1846
+ };
1847
+ state.quickCommitOpenMenu = null;
1848
+ state.quickCommitTagHeadForm = { tag: "", push: false };
1849
+ state.quickCommitTagHeadSubmitting = false;
1850
+ state.quickCommitTagHeadGenerating = false;
1851
+ state.quickCommitTagHeadError = "";
1852
+ state.quickCommitPushing = false;
1853
+ state.quickCommitPushError = "";
1787
1854
  closeWorktreeMergeModal();
1788
1855
  closeSessionModal();
1789
1856
  closeSettingsModal();
@@ -1796,11 +1863,33 @@
1796
1863
  }
1797
1864
  if (quickCommitEscHandler) document.removeEventListener("keydown", quickCommitEscHandler);
1798
1865
  quickCommitEscHandler = function(e) {
1799
- if (e.key === "Escape" && state.quickCommitOpen && !state.quickCommitSubmitting) {
1866
+ if (e.key === "Escape" && state.quickCommitOpen && !state.quickCommitSubmitting && !state.quickCommitTagHeadSubmitting && !state.quickCommitPushing) {
1867
+ // First Esc closes any open dropdown; second closes the modal.
1868
+ if (state.quickCommitOpenMenu) {
1869
+ state.quickCommitOpenMenu = null;
1870
+ rerenderQuickCommitModal();
1871
+ return;
1872
+ }
1800
1873
  closeQuickCommitModal();
1801
1874
  }
1802
1875
  };
1803
1876
  document.addEventListener("keydown", quickCommitEscHandler);
1877
+ if (quickCommitDocClickHandler) document.removeEventListener("click", quickCommitDocClickHandler, true);
1878
+ quickCommitDocClickHandler = function(e) {
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;
1882
+ var modalEl = document.getElementById("quick-commit-modal");
1883
+ if (!modalEl) return;
1884
+ var t = e.target;
1885
+ while (t && t !== modalEl) {
1886
+ if (t.dataset && (t.dataset.qcDropdownToggle || t.dataset.qcDropdownMenu)) return;
1887
+ t = t.parentNode;
1888
+ }
1889
+ state.quickCommitOpenMenu = null;
1890
+ rerenderQuickCommitModal();
1891
+ };
1892
+ document.addEventListener("click", quickCommitDocClickHandler, true);
1804
1893
  loadGitStatus(state.selectedId, { force: true }).then(function() {
1805
1894
  if (!state.quickCommitOpen) return;
1806
1895
  rerenderQuickCommitModal();
@@ -1811,6 +1900,7 @@
1811
1900
  state.quickCommitOpen = false;
1812
1901
  state.quickCommitSubmitting = false;
1813
1902
  state.quickCommitError = "";
1903
+ state.quickCommitOpenMenu = null;
1814
1904
  var modal = document.getElementById("quick-commit-modal");
1815
1905
  if (modal) modal.classList.add("hidden");
1816
1906
  if (focusTrapHandler) {
@@ -1821,6 +1911,10 @@
1821
1911
  document.removeEventListener("keydown", quickCommitEscHandler);
1822
1912
  quickCommitEscHandler = null;
1823
1913
  }
1914
+ if (quickCommitDocClickHandler) {
1915
+ document.removeEventListener("click", quickCommitDocClickHandler, true);
1916
+ quickCommitDocClickHandler = null;
1917
+ }
1824
1918
  if (lastFocusedElement && typeof lastFocusedElement.focus === "function") {
1825
1919
  lastFocusedElement.focus();
1826
1920
  }
@@ -1843,6 +1937,7 @@
1843
1937
  if (closeBtn) closeBtn.addEventListener("click", closeQuickCommitModal);
1844
1938
  var cancelBtn = document.getElementById("quick-commit-cancel-btn");
1845
1939
  if (cancelBtn) cancelBtn.addEventListener("click", closeQuickCommitModal);
1940
+
1846
1941
  var submitBtn = document.getElementById("quick-commit-submit-btn");
1847
1942
  if (submitBtn) submitBtn.addEventListener("click", submitQuickCommit);
1848
1943
  var aiBtn = document.getElementById("quick-commit-ai-btn");
@@ -1861,10 +1956,96 @@
1861
1956
  if (tagInput) tagInput.addEventListener("input", function() {
1862
1957
  state.quickCommitForm.tag = tagInput.value;
1863
1958
  });
1864
- var pushCb = document.getElementById("quick-commit-push");
1865
- if (pushCb) pushCb.addEventListener("change", function() {
1866
- state.quickCommitForm.push = pushCb.checked;
1959
+
1960
+ var actionCaret = document.getElementById("quick-commit-action-caret");
1961
+ if (actionCaret) actionCaret.addEventListener("click", function(e) {
1962
+ e.stopPropagation();
1963
+ state.quickCommitOpenMenu = state.quickCommitOpenMenu === "action" ? null : "action";
1964
+ rerenderQuickCommitModal();
1965
+ });
1966
+ var actionMenu = document.getElementById("quick-commit-action-menu");
1967
+ if (actionMenu) {
1968
+ var actionItems = actionMenu.querySelectorAll("[data-qc-action]");
1969
+ for (var i = 0; i < actionItems.length; i++) {
1970
+ (function(btn) {
1971
+ btn.addEventListener("click", function() {
1972
+ var value = btn.getAttribute("data-qc-action");
1973
+ if (value === "commit" || value === "commit-push") {
1974
+ state.quickCommitForm.primaryAction = value;
1975
+ savePrimaryAction(value);
1976
+ }
1977
+ state.quickCommitOpenMenu = null;
1978
+ rerenderQuickCommitModal();
1979
+ });
1980
+ })(actionItems[i]);
1981
+ }
1982
+ }
1983
+
1984
+ var tagHeadToggle = document.getElementById("quick-commit-tag-head-toggle");
1985
+ if (tagHeadToggle) tagHeadToggle.addEventListener("click", function() {
1986
+ var willOpen = state.quickCommitOpenMenu !== "tag-head";
1987
+ state.quickCommitOpenMenu = willOpen ? "tag-head" : null;
1988
+ if (willOpen) state.quickCommitTagHeadError = "";
1989
+ rerenderQuickCommitModal();
1990
+ if (willOpen) {
1991
+ var inp = document.getElementById("quick-commit-tag-head-input");
1992
+ if (inp) inp.focus();
1993
+ }
1994
+ });
1995
+ var tagHeadCancel = document.getElementById("quick-commit-tag-head-cancel");
1996
+ if (tagHeadCancel) tagHeadCancel.addEventListener("click", function() {
1997
+ if (state.quickCommitOpenMenu === "tag-head") state.quickCommitOpenMenu = null;
1998
+ state.quickCommitTagHeadError = "";
1999
+ rerenderQuickCommitModal();
1867
2000
  });
2001
+ var tagHeadInput = document.getElementById("quick-commit-tag-head-input");
2002
+ if (tagHeadInput) {
2003
+ tagHeadInput.addEventListener("input", function() {
2004
+ state.quickCommitTagHeadForm.tag = tagHeadInput.value;
2005
+ });
2006
+ tagHeadInput.addEventListener("keydown", function(e) {
2007
+ if (e.key === "Enter") {
2008
+ e.preventDefault();
2009
+ submitTagHead(false);
2010
+ }
2011
+ });
2012
+ }
2013
+ var tagHeadAi = document.getElementById("quick-commit-tag-head-ai");
2014
+ if (tagHeadAi) tagHeadAi.addEventListener("click", generateTagHeadAI);
2015
+ var tagHeadPushCb = document.getElementById("quick-commit-tag-head-push");
2016
+ if (tagHeadPushCb) tagHeadPushCb.addEventListener("change", function() {
2017
+ state.quickCommitTagHeadForm.push = tagHeadPushCb.checked;
2018
+ });
2019
+ var tagHeadSubmit = document.getElementById("quick-commit-tag-head-submit");
2020
+ if (tagHeadSubmit) tagHeadSubmit.addEventListener("click", function() {
2021
+ submitTagHead(false);
2022
+ });
2023
+
2024
+ var pushBtn = document.getElementById("quick-commit-push-btn");
2025
+ if (pushBtn) pushBtn.addEventListener("click", function() {
2026
+ submitPushOnly({ pushCommits: true, pushTags: false });
2027
+ });
2028
+ var pushCaret = document.getElementById("quick-commit-push-caret");
2029
+ if (pushCaret) pushCaret.addEventListener("click", function(e) {
2030
+ e.stopPropagation();
2031
+ state.quickCommitOpenMenu = state.quickCommitOpenMenu === "push" ? null : "push";
2032
+ rerenderQuickCommitModal();
2033
+ });
2034
+ var pushMenu = document.getElementById("quick-commit-push-menu");
2035
+ if (pushMenu) {
2036
+ var pushItems = pushMenu.querySelectorAll("[data-qc-push]");
2037
+ for (var j = 0; j < pushItems.length; j++) {
2038
+ (function(btn) {
2039
+ btn.addEventListener("click", function() {
2040
+ var value = btn.getAttribute("data-qc-push");
2041
+ state.quickCommitOpenMenu = null;
2042
+ if (value === "commits") submitPushOnly({ pushCommits: true, pushTags: false });
2043
+ else if (value === "tags") submitPushOnly({ pushCommits: false, pushTags: true });
2044
+ else if (value === "both") submitPushOnly({ pushCommits: true, pushTags: true });
2045
+ });
2046
+ })(pushItems[j]);
2047
+ }
2048
+ }
1868
2049
  }
1869
2050
 
1870
2051
  function generateCommitMessageAI() {
@@ -1925,13 +2106,14 @@
1925
2106
  rerenderQuickCommitModal();
1926
2107
  return;
1927
2108
  }
2109
+ var willPush = form.primaryAction === "commit-push";
1928
2110
  // 开了 tag 开关但没填 → 由后端在提交时调 AI 生成
1929
2111
  var payload = {
1930
2112
  autoMessage: false,
1931
2113
  customMessage: message,
1932
2114
  tag: userTag,
1933
2115
  autoTag: !!(form.makeTag && !userTag),
1934
- push: !!form.push
2116
+ push: willPush
1935
2117
  };
1936
2118
  state.quickCommitSubmitting = true;
1937
2119
  state.quickCommitError = "";
@@ -1950,9 +2132,12 @@
1950
2132
  var data = result.data || {};
1951
2133
  var hash = data.commit && data.commit.hash ? data.commit.hash.substring(0, 7) : "";
1952
2134
  var tagName = data.tag && data.tag.name ? data.tag.name : "";
1953
- var base = "已提交" + (hash ? " " + hash : "") + (tagName ? ",已打 tag " + tagName : "");
1954
- var pushRequested = !!payload.push;
1955
- if (pushRequested && data.pushError) {
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 : "");
2140
+ if (willPush && data.pushError) {
1956
2141
  var msg = base + ";push 失败:" + data.pushError;
1957
2142
  if (typeof showToast === "function") showToast(msg, "error");
1958
2143
  } else {
@@ -1971,13 +2156,135 @@
1971
2156
  });
1972
2157
  }
1973
2158
 
1974
- function renderQuickCommitModal() {
1975
- var s = state.gitStatus || {};
1976
- var f = state.quickCommitForm || { autoMessage: false, customMessage: "", makeTag: false, tag: "", push: false };
1977
- var langValue = (state.config && (state.config.language || "")) || "";
1978
- var langLabel = langValue ? langValue : "中文";
1979
- var files = Array.isArray(s.files) ? s.files : [];
1980
- var fileRows = files.map(function(item) {
2159
+ // ── AI generate a tag for the existing HEAD (no commit involved) ──
2160
+ function generateTagHeadAI() {
2161
+ if (!state.selectedId || state.quickCommitTagHeadGenerating) return;
2162
+ var inp = document.getElementById("quick-commit-tag-head-input");
2163
+ if (inp) state.quickCommitTagHeadForm.tag = inp.value;
2164
+ state.quickCommitTagHeadGenerating = true;
2165
+ state.quickCommitTagHeadError = "";
2166
+ rerenderQuickCommitModal();
2167
+ // Reuse the existing generator — it stages and asks for {message, tag}.
2168
+ // We only consume `suggestedTag` here.
2169
+ fetch("/api/sessions/" + encodeURIComponent(state.selectedId) + "/generate-commit-message", {
2170
+ method: "POST",
2171
+ credentials: "same-origin",
2172
+ headers: { "Content-Type": "application/json" },
2173
+ body: JSON.stringify({})
2174
+ })
2175
+ .then(function(res) { return res.json().then(function(data) { return { ok: res.ok, data: data }; }); })
2176
+ .then(function(result) {
2177
+ if (!result.ok) throw new Error((result.data && result.data.error) || "AI 生成失败。");
2178
+ var aiTag = (result.data && typeof result.data.suggestedTag === "string") ? result.data.suggestedTag.trim() : "";
2179
+ var currentTag = (state.quickCommitTagHeadForm.tag || "").trim();
2180
+ if (!currentTag && aiTag) {
2181
+ state.quickCommitTagHeadForm.tag = aiTag;
2182
+ } else if (!aiTag) {
2183
+ throw new Error("AI 没有给出 tag 建议。");
2184
+ }
2185
+ })
2186
+ .catch(function(error) {
2187
+ state.quickCommitTagHeadError = (error && error.message) || "AI 生成失败。";
2188
+ })
2189
+ .finally(function() {
2190
+ state.quickCommitTagHeadGenerating = false;
2191
+ if (state.quickCommitOpen) rerenderQuickCommitModal();
2192
+ });
2193
+ }
2194
+
2195
+ // Tag the existing HEAD without making a new commit.
2196
+ function submitTagHead(silent) {
2197
+ if (!state.selectedId || state.quickCommitTagHeadSubmitting) return;
2198
+ var inp = document.getElementById("quick-commit-tag-head-input");
2199
+ if (inp) state.quickCommitTagHeadForm.tag = inp.value;
2200
+ var tag = (state.quickCommitTagHeadForm.tag || "").trim();
2201
+ if (!tag) {
2202
+ state.quickCommitTagHeadError = "请填写 tag 名称,或点击 AI 建议。";
2203
+ rerenderQuickCommitModal();
2204
+ return;
2205
+ }
2206
+ state.quickCommitTagHeadSubmitting = true;
2207
+ state.quickCommitTagHeadError = "";
2208
+ rerenderQuickCommitModal();
2209
+ fetch("/api/sessions/" + encodeURIComponent(state.selectedId) + "/git/tag-head", {
2210
+ method: "POST",
2211
+ credentials: "same-origin",
2212
+ headers: { "Content-Type": "application/json" },
2213
+ body: JSON.stringify({ tag: tag, push: !!state.quickCommitTagHeadForm.push })
2214
+ })
2215
+ .then(function(res) { return res.json().then(function(data) { return { ok: res.ok, data: data }; }); })
2216
+ .then(function(result) {
2217
+ if (!result.ok) throw new Error((result.data && result.data.error) || "打 tag 失败。");
2218
+ var data = result.data || {};
2219
+ var name = data.tag && data.tag.name ? data.tag.name : tag;
2220
+ var pushed = !!data.pushed;
2221
+ var pushErr = data.pushError;
2222
+ var base = "已为 HEAD 打 tag " + name;
2223
+ if (state.quickCommitTagHeadForm.push && pushErr) {
2224
+ if (typeof showToast === "function") showToast(base + ";push tag 失败:" + pushErr, "error");
2225
+ } else {
2226
+ if (typeof showToast === "function") showToast(base + (pushed ? ",已 push" : ""), "success");
2227
+ }
2228
+ if (state.quickCommitOpenMenu === "tag-head") state.quickCommitOpenMenu = null;
2229
+ state.quickCommitTagHeadForm = { tag: "", push: false };
2230
+ if (state.selectedId) loadGitStatus(state.selectedId, { force: true }).then(function() {
2231
+ if (state.quickCommitOpen) rerenderQuickCommitModal();
2232
+ });
2233
+ })
2234
+ .catch(function(error) {
2235
+ state.quickCommitTagHeadError = (error && error.message) || "打 tag 失败。";
2236
+ })
2237
+ .finally(function() {
2238
+ state.quickCommitTagHeadSubmitting = false;
2239
+ if (state.quickCommitOpen) rerenderQuickCommitModal();
2240
+ });
2241
+ }
2242
+
2243
+ function submitPushOnly(opts) {
2244
+ if (!state.selectedId || state.quickCommitPushing) return;
2245
+ var pushCommits = !!(opts && opts.pushCommits);
2246
+ var pushTags = !!(opts && opts.pushTags);
2247
+ if (!pushCommits && !pushTags) return;
2248
+ state.quickCommitPushing = true;
2249
+ state.quickCommitPushError = "";
2250
+ rerenderQuickCommitModal();
2251
+ fetch("/api/sessions/" + encodeURIComponent(state.selectedId) + "/git/push", {
2252
+ method: "POST",
2253
+ credentials: "same-origin",
2254
+ headers: { "Content-Type": "application/json" },
2255
+ body: JSON.stringify({ pushCommits: pushCommits, pushTags: pushTags })
2256
+ })
2257
+ .then(function(res) { return res.json().then(function(data) { return { ok: res.ok, data: data }; }); })
2258
+ .then(function(result) {
2259
+ var data = result.data || {};
2260
+ if (!result.ok) throw new Error((data && data.error) || "推送失败。");
2261
+ // Backend marks `ok: false` on partial failure but still returns 200 — surface error toast.
2262
+ if (data.error) {
2263
+ state.quickCommitPushError = data.error;
2264
+ if (typeof showToast === "function") showToast("推送失败:" + data.error, "error");
2265
+ return;
2266
+ }
2267
+ var parts = [];
2268
+ if (data.pushedCommits) parts.push("commits");
2269
+ if (data.pushedTags) parts.push("tags");
2270
+ var label = parts.length ? parts.join(" 和 ") : "(无内容)";
2271
+ if (typeof showToast === "function") showToast("已推送 " + label, "success");
2272
+ if (state.selectedId) loadGitStatus(state.selectedId, { force: true }).then(function() {
2273
+ if (state.quickCommitOpen) rerenderQuickCommitModal();
2274
+ });
2275
+ })
2276
+ .catch(function(error) {
2277
+ state.quickCommitPushError = (error && error.message) || "推送失败。";
2278
+ if (typeof showToast === "function") showToast(state.quickCommitPushError, "error");
2279
+ })
2280
+ .finally(function() {
2281
+ state.quickCommitPushing = false;
2282
+ if (state.quickCommitOpen) rerenderQuickCommitModal();
2283
+ });
2284
+ }
2285
+
2286
+ function renderQuickCommitFileRows(files) {
2287
+ var rows = files.map(function(item) {
1981
2288
  var status = (item.status || " ").substring(0, 2);
1982
2289
  var flag = status.trim() || "?";
1983
2290
  var cls = "qc-flag";
@@ -1998,48 +2305,209 @@
1998
2305
  }
1999
2306
  return '<div class="qc-file-row"><span class="' + cls + '">' + escapeHtml(status) + '</span><span class="qc-file-path">' + escapeHtml(item.path || "") + '</span>' + subBadge + '</div>';
2000
2307
  }).join("");
2001
- if (!fileRows) fileRows = '<div class="qc-empty">工作区干净,没有可提交的改动。</div>';
2308
+ return rows || '<div class="qc-empty">没有可提交的改动。</div>';
2309
+ }
2310
+
2311
+ function isQuickCommitOpInFlight() {
2312
+ return state.quickCommitSubmitting || state.quickCommitTagHeadSubmitting || state.quickCommitPushing;
2313
+ }
2314
+
2315
+ function renderQuickCommitPrimary(hasChanges) {
2316
+ var f = state.quickCommitForm;
2317
+ var label;
2318
+ if (state.quickCommitSubmitting) label = "提交中…";
2319
+ else if (f.primaryAction === "commit-push") label = "提交并 push";
2320
+ else label = "仅提交";
2321
+ var disabled = !hasChanges || isQuickCommitOpInFlight();
2322
+ var menuOpen = state.quickCommitOpenMenu === "action";
2323
+ var caretActive = menuOpen ? " is-active" : "";
2324
+ var menuItems = [
2325
+ { value: "commit", label: "仅提交", desc: "只 commit,不 push" },
2326
+ { value: "commit-push", label: "提交并 push", desc: "commit 后立即 push 到远端" }
2327
+ ];
2328
+ var menuHtml = menuItems.map(function(item) {
2329
+ var sel = f.primaryAction === item.value ? " is-selected" : "";
2330
+ return '<button type="button" class="qc-dropdown-item' + sel + '" data-qc-action="' + item.value + '">' +
2331
+ '<span class="qc-dropdown-item-title">' + escapeHtml(item.label) + '</span>' +
2332
+ '<span class="qc-dropdown-item-desc">' + escapeHtml(item.desc) + '</span>' +
2333
+ '</button>';
2334
+ }).join("");
2335
+ return '<div class="qc-split-button">' +
2336
+ '<button id="quick-commit-submit-btn" class="btn btn-primary qc-split-main" type="button"' + (disabled ? ' disabled' : '') + '>' +
2337
+ escapeHtml(label) +
2338
+ '</button>' +
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="更多提交方式">' +
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>' +
2341
+ '</button>' +
2342
+ (menuOpen ?
2343
+ '<div id="quick-commit-action-menu" class="qc-dropdown-menu" data-qc-dropdown-menu="action" role="menu">' + menuHtml + '</div>' : '') +
2344
+ '</div>';
2345
+ }
2346
+
2347
+ function renderQuickCommitStatusChips(s) {
2348
+ var chips = [];
2349
+ var hasUpstream = !!s.upstream;
2350
+ if (typeof s.ahead === "number" && s.ahead > 0) {
2351
+ chips.push('<span class="qc-chip qc-chip--ahead" title="本地领先 ' + s.ahead + ' 个 commit">↑ ' + s.ahead + ' 待推送</span>');
2352
+ }
2353
+ if (typeof s.behind === "number" && s.behind > 0) {
2354
+ chips.push('<span class="qc-chip qc-chip--behind" title="远端领先 ' + s.behind + ' 个 commit">↓ ' + s.behind + ' 待拉取</span>');
2355
+ }
2356
+ if (!hasUpstream) {
2357
+ chips.push('<span class="qc-chip qc-chip--warn" title="当前分支没有 upstream,将首次推送时自动设置">无 upstream</span>');
2358
+ } else if (!chips.length) {
2359
+ chips.push('<span class="qc-chip qc-chip--clean">与远端同步</span>');
2360
+ }
2361
+ return '<div class="qc-status-chips">' + chips.join("") + '</div>';
2362
+ }
2363
+
2364
+ // Inline drawer used by the "为 HEAD 打 tag" toggle.
2365
+ function renderQuickCommitTagHeadPanel() {
2366
+ var thf = state.quickCommitTagHeadForm || { tag: "", push: false };
2367
+ var submitting = state.quickCommitTagHeadSubmitting;
2368
+ var generating = state.quickCommitTagHeadGenerating;
2369
+ return '<div class="qc-tag-head-panel">' +
2370
+ '<div class="qc-tag-head-row">' +
2371
+ '<input type="text" id="quick-commit-tag-head-input" class="field-input" placeholder="输入 tag 名(例如 v1.2.0),或点 AI 建议" value="' + escapeHtml(thf.tag || "") + '"' + (submitting ? ' disabled' : '') + '>' +
2372
+ '<button type="button" id="quick-commit-tag-head-ai" class="btn btn-ghost btn-sm"' + (generating || submitting ? ' disabled' : '') + '>' + (generating ? '建议中…' : 'AI 建议') + '</button>' +
2373
+ '</div>' +
2374
+ '<label class="qc-tag-head-push">' +
2375
+ '<input type="checkbox" id="quick-commit-tag-head-push"' + (thf.push ? ' checked' : '') + (submitting ? ' disabled' : '') + '>' +
2376
+ '<span>打完 tag 立即推送这个 tag</span>' +
2377
+ '</label>' +
2378
+ (state.quickCommitTagHeadError ? '<p class="error-message">' + escapeHtml(state.quickCommitTagHeadError) + '</p>' : '') +
2379
+ '<div class="qc-tag-head-actions">' +
2380
+ '<button type="button" id="quick-commit-tag-head-cancel" class="btn btn-ghost btn-sm"' + (submitting ? ' disabled' : '') + '>收起</button>' +
2381
+ '<button type="button" id="quick-commit-tag-head-submit" class="btn btn-secondary btn-sm"' + (submitting ? ' disabled' : '') + '>' + (submitting ? '打 tag 中…' : '为 HEAD 打 tag') + '</button>' +
2382
+ '</div>' +
2383
+ '</div>';
2384
+ }
2385
+
2386
+ function renderQuickCommitPushButton(s) {
2387
+ var ahead = typeof s.ahead === "number" ? s.ahead : 0;
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);
2395
+ var mainLabel = state.quickCommitPushing ? "推送中…" : "推送";
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
+ ];
2403
+ var menuHtml = items.map(function(it) {
2404
+ var dis = it.disabled ? " is-disabled" : "";
2405
+ return '<button type="button" class="qc-dropdown-item' + dis + '" data-qc-push="' + it.value + '"' + (it.disabled ? ' disabled' : '') + '>' +
2406
+ '<span class="qc-dropdown-item-title">' + escapeHtml(it.label) + '</span>' +
2407
+ '<span class="qc-dropdown-item-desc">' + escapeHtml(it.desc) + '</span>' +
2408
+ '</button>';
2409
+ }).join("");
2410
+ return '<div class="qc-split-button qc-split-button--secondary">' +
2411
+ '<button id="quick-commit-push-btn" class="btn btn-secondary qc-split-main" type="button"' + (disabled ? ' disabled' : '') + '>' +
2412
+ escapeHtml(mainLabel) +
2413
+ '</button>' +
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="更多推送方式">' +
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>' +
2416
+ '</button>' +
2417
+ (menuOpen ?
2418
+ '<div id="quick-commit-push-menu" class="qc-dropdown-menu qc-dropdown-menu--right" data-qc-dropdown-menu="push" role="menu">' + menuHtml + '</div>' : '') +
2419
+ '</div>';
2420
+ }
2421
+
2422
+ function renderQuickCommitModal() {
2423
+ var s = state.gitStatus || {};
2424
+ var f = state.quickCommitForm || { customMessage: "", makeTag: false, tag: "", primaryAction: "commit" };
2002
2425
  var hasChanges = (s.modifiedCount || 0) > 0;
2426
+ var files = Array.isArray(s.files) ? s.files : [];
2427
+ var fileRows = renderQuickCommitFileRows(files);
2428
+
2429
+ // Subtitle: branch · N 改动 · ↑X ↓Y
2430
+ var subParts = [];
2431
+ subParts.push(s.branch || "(no branch)");
2432
+ subParts.push((s.modifiedCount || 0) + " 个改动");
2433
+ if (typeof s.ahead === "number" && s.ahead > 0) subParts.push("↑" + s.ahead);
2434
+ if (typeof s.behind === "number" && s.behind > 0) subParts.push("↓" + s.behind);
2435
+
2436
+ // Section 1: changes + commit form (only when there are changes)
2437
+ var section1 = "";
2438
+ if (hasChanges) {
2439
+ section1 = '<section class="qc-section qc-section--changes">' +
2440
+ '<div class="qc-section-head"><span class="qc-section-title">暂存改动</span><span class="qc-section-meta">' + escapeHtml(s.modifiedCount + " 个文件") + '</span></div>' +
2441
+ '<div class="qc-files-wrap">' + fileRows + '</div>' +
2442
+ '<div class="qc-message-row" id="quick-commit-message-row">' +
2443
+ '<div class="qc-message-header"><label class="field-label" for="quick-commit-message">commit message</label>' +
2444
+ '<button type="button" id="quick-commit-ai-btn" class="btn btn-ghost btn-sm"' + (state.quickCommitGenerating ? ' disabled' : '') + '>' + (state.quickCommitGenerating ? '生成中…' : 'AI 生成') + '</button>' +
2445
+ '</div>' +
2446
+ '<textarea id="quick-commit-message" class="field-input" rows="2" placeholder="输入 commit message 或点击 AI 生成"' + (state.quickCommitSubmitting ? ' disabled' : '') + '>' + escapeHtml(f.customMessage || "") + '</textarea>' +
2447
+ '</div>' +
2448
+ '<div class="qc-checkbox-row">' +
2449
+ '<label class="qc-checkbox-label" for="quick-commit-make-tag">同时为本次 commit 打 tag</label>' +
2450
+ '<label class="qc-switch">' +
2451
+ '<input type="checkbox" id="quick-commit-make-tag" class="switch-toggle"' + (f.makeTag ? ' checked' : '') + '>' +
2452
+ '<span class="switch-slider"></span>' +
2453
+ '</label>' +
2454
+ '</div>' +
2455
+ '<div class="qc-tag-row' + (f.makeTag ? '' : ' hidden') + '" id="quick-commit-tag-row">' +
2456
+ '<input type="text" id="quick-commit-tag" class="field-input" placeholder="输入 tag 名称;留空将由 AI 在提交时自动生成" value="' + escapeHtml(f.tag || "") + '"' + (state.quickCommitSubmitting ? ' disabled' : '') + '>' +
2457
+ '</div>' +
2458
+ (state.quickCommitError ? '<p class="error-message">' + escapeHtml(state.quickCommitError) + '</p>' : '') +
2459
+ '<div class="qc-section-actions">' +
2460
+ '<button id="quick-commit-cancel-btn" class="btn btn-ghost btn-sm" type="button">取消</button>' +
2461
+ renderQuickCommitPrimary(hasChanges) +
2462
+ '</div>' +
2463
+ '</section>';
2464
+ } else {
2465
+ section1 = '<section class="qc-section qc-section--empty">' +
2466
+ '<div class="qc-empty-state">' +
2467
+ '<span class="qc-empty-icon">✓</span>' +
2468
+ '<div><div class="qc-empty-title">工作区干净</div><div class="qc-empty-sub">没有暂存改动可提交。仍可在下方为 HEAD 打 tag 或推送。</div></div>' +
2469
+ '</div>' +
2470
+ '</section>';
2471
+ }
2472
+
2473
+ // Section 2: repo status + secondary actions (always show when there's at least one commit)
2474
+ var section2 = "";
2475
+ if (!s.initialCommit && s.isGit !== false) {
2476
+ var lc = s.lastCommit || {};
2477
+ var headLine = lc.shortHash ? lc.shortHash + " · " + (lc.subject || "") : (s.head ? s.head.substring(0, 7) : "(no commit)");
2478
+ var upstreamLine = s.upstream ? escapeHtml(s.branch || "") + " → " + escapeHtml(s.upstream) : escapeHtml(s.branch || "(no branch)") + " · 无 upstream";
2479
+ section2 = '<section class="qc-section qc-section--repo">' +
2480
+ '<div class="qc-section-head"><span class="qc-section-title">仓库状态</span><span class="qc-section-meta">' + upstreamLine + '</span></div>' +
2481
+ '<div class="qc-head-card">' +
2482
+ '<span class="qc-head-label">HEAD</span>' +
2483
+ '<code class="qc-head-text">' + escapeHtml(headLine) + '</code>' +
2484
+ '</div>' +
2485
+ renderQuickCommitStatusChips(s) +
2486
+ (state.quickCommitOpenMenu === "tag-head" ? renderQuickCommitTagHeadPanel() : '') +
2487
+ '<div class="qc-section-actions qc-section-actions--secondary">' +
2488
+ '<button id="quick-commit-tag-head-toggle" class="btn btn-ghost btn-sm" type="button"' + (state.quickCommitPushing ? ' disabled' : '') + '>' +
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>' +
2490
+ (state.quickCommitOpenMenu === "tag-head" ? '收起' : '为 HEAD 打 tag') +
2491
+ '</button>' +
2492
+ renderQuickCommitPushButton(s) +
2493
+ '</div>' +
2494
+ '</section>';
2495
+ }
2496
+
2497
+ var subtitleHtml = subParts.map(escapeHtml).join(" · ");
2003
2498
 
2004
2499
  return '<section id="quick-commit-modal" class="modal-backdrop' + (state.quickCommitOpen ? '' : ' hidden') + '">' +
2005
2500
  '<div class="modal quick-commit-modal" role="dialog" aria-labelledby="quick-commit-title">' +
2006
2501
  '<div class="modal-header">' +
2007
2502
  '<div>' +
2008
2503
  '<h2 id="quick-commit-title" class="modal-title">快捷提交</h2>' +
2009
- '<p class="modal-subtitle">' + escapeHtml((s.branch || "(no branch)") + ' · ' + (s.modifiedCount || 0) + ' 个改动') + '</p>' +
2504
+ '<p class="modal-subtitle">' + subtitleHtml + '</p>' +
2010
2505
  '</div>' +
2011
2506
  '<button id="quick-commit-close-btn" class="btn btn-ghost btn-icon modal-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>' +
2012
2507
  '</div>' +
2013
2508
  '<div class="modal-body">' +
2014
- '<div class="qc-files-wrap">' + fileRows + '</div>' +
2015
- '<div class="qc-message-row" id="quick-commit-message-row">' +
2016
- '<div class="qc-message-header"><label class="field-label" for="quick-commit-message">commit message</label>' +
2017
- '<button type="button" id="quick-commit-ai-btn" class="btn btn-ghost btn-sm"' + (state.quickCommitGenerating ? ' disabled' : '') + '>' + (state.quickCommitGenerating ? '生成中…' : 'AI 生成') + '</button>' +
2018
- '</div>' +
2019
- '<textarea id="quick-commit-message" class="field-input" rows="2" placeholder="输入 commit message 或点击 AI 生成">' + escapeHtml(f.customMessage || "") + '</textarea>' +
2020
- '</div>' +
2021
- '<div class="qc-checkbox-row">' +
2022
- '<label class="qc-checkbox-label" for="quick-commit-make-tag">提交后打 tag</label>' +
2023
- '<label class="qc-switch">' +
2024
- '<input type="checkbox" id="quick-commit-make-tag" class="switch-toggle"' + (f.makeTag ? ' checked' : '') + '>' +
2025
- '<span class="switch-slider"></span>' +
2026
- '</label>' +
2027
- '</div>' +
2028
- '<div class="qc-tag-row' + (f.makeTag ? '' : ' hidden') + '" id="quick-commit-tag-row">' +
2029
- '<input type="text" id="quick-commit-tag" class="field-input" placeholder="输入 tag 名称;留空将由 AI 在提交时自动生成" value="' + escapeHtml(f.tag || "") + '">' +
2030
- '</div>' +
2031
- '<div class="qc-checkbox-row">' +
2032
- '<label class="qc-checkbox-label" for="quick-commit-push">提交后 push 到远端</label>' +
2033
- '<label class="qc-switch">' +
2034
- '<input type="checkbox" id="quick-commit-push" class="switch-toggle"' + (f.push ? ' checked' : '') + '>' +
2035
- '<span class="switch-slider"></span>' +
2036
- '</label>' +
2037
- '</div>' +
2038
- '<p id="quick-commit-error" class="error-message' + (state.quickCommitError ? '' : ' hidden') + '">' + escapeHtml(state.quickCommitError || "") + '</p>' +
2039
- '<div class="worktree-merge-actions">' +
2040
- '<button id="quick-commit-cancel-btn" class="btn btn-secondary" type="button">取消</button>' +
2041
- '<button id="quick-commit-submit-btn" class="btn btn-primary" type="button"' + (hasChanges && !state.quickCommitSubmitting ? '' : ' disabled') + '>' + (state.quickCommitSubmitting ? '提交中…' : '执行') + '</button>' +
2042
- '</div>' +
2509
+ section1 +
2510
+ section2 +
2043
2511
  '</div>' +
2044
2512
  '</div>' +
2045
2513
  '</section>';
@@ -2188,6 +2656,40 @@
2188
2656
  '</div>' +
2189
2657
  '<p id="android-apk-message" class="hint hidden"></p>' +
2190
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>' +
2191
2693
  '<div class="settings-update-section" id="android-connect-section">' +
2192
2694
  '<div class="settings-section-head">' +
2193
2695
  '<span class="settings-section-icon">🔗</span>' +
@@ -5128,6 +5630,10 @@
5128
5630
  if (autoUpdateApkToggle) autoUpdateApkToggle.addEventListener("change", function() {
5129
5631
  toggleAutoUpdate("apk", autoUpdateApkToggle.checked);
5130
5632
  });
5633
+ var autoUpdateDmgToggle = document.getElementById("auto-update-dmg-toggle");
5634
+ if (autoUpdateDmgToggle) autoUpdateDmgToggle.addEventListener("change", function() {
5635
+ toggleAutoUpdate("dmg", autoUpdateDmgToggle.checked);
5636
+ });
5131
5637
  var copyConnectCodeBtn = document.getElementById("copy-connect-code-button");
5132
5638
  if (copyConnectCodeBtn) copyConnectCodeBtn.addEventListener("click", function() {
5133
5639
  var text = document.getElementById("android-connect-code");
@@ -6827,7 +7333,13 @@
6827
7333
  var delta = normalizedOutput.slice(currentOutput.length);
6828
7334
  if (delta) {
6829
7335
  wandTerminalWrite(state.terminal, delta);
6830
- 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
+ // 过程中持续重放只是纯粹的重复制造器。
6831
7343
  wrote = true;
6832
7344
  }
6833
7345
  } else if (currentOutput && currentOutput.startsWith(normalizedOutput)) {
@@ -8770,6 +9282,8 @@
8770
9282
  if (autoUpdateWebToggle) autoUpdateWebToggle.checked = !!autoUpdate.web;
8771
9283
  var autoUpdateApkToggle = document.getElementById("auto-update-apk-toggle");
8772
9284
  if (autoUpdateApkToggle) autoUpdateApkToggle.checked = !!autoUpdate.apk;
9285
+ var autoUpdateDmgToggle = document.getElementById("auto-update-dmg-toggle");
9286
+ if (autoUpdateDmgToggle) autoUpdateDmgToggle.checked = !!autoUpdate.dmg;
8773
9287
 
8774
9288
  // ── Android APK version display ──
8775
9289
  var apkSection = document.getElementById("android-apk-section");
@@ -8889,6 +9403,119 @@
8889
9403
  }
8890
9404
  }
8891
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
+
8892
9519
  // App connect code (encrypted)
8893
9520
  var connectCodeEl = document.getElementById("android-connect-code");
8894
9521
  var connectQrCanvas = document.getElementById("android-connect-qr");
@@ -9258,6 +9885,24 @@
9258
9885
  .catch(function() {});
9259
9886
  }
9260
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
+
9261
9906
  function toggleAutoUpdate(type, enabled) {
9262
9907
  var body = {};
9263
9908
  body[type] = enabled;
@@ -9272,8 +9917,10 @@
9272
9917
  // Sync toggle state with server response
9273
9918
  var webToggle = document.getElementById("auto-update-web-toggle");
9274
9919
  var apkToggle = document.getElementById("auto-update-apk-toggle");
9920
+ var dmgToggle = document.getElementById("auto-update-dmg-toggle");
9275
9921
  if (webToggle) webToggle.checked = !!data.web;
9276
9922
  if (apkToggle) apkToggle.checked = !!data.apk;
9923
+ if (dmgToggle) dmgToggle.checked = !!data.dmg;
9277
9924
  })
9278
9925
  .catch(function() {
9279
9926
  // Revert toggle on failure
@@ -13418,6 +14065,35 @@
13418
14065
 
13419
14066
  // Drop any in-flight socket and start a new one *now* — used by the
13420
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
13421
14097
  // still says OPEN, but the TCP path was torn down by Doze). Skips
13422
14098
  // the backoff timer; the caller has already decided this is urgent.
13423
14099
  function forceReconnectWebSocket(reason) {
@@ -13428,6 +14104,13 @@
13428
14104
  // reconnect path while we're already starting a fresh one.
13429
14105
  try { stale.onclose = null; } catch (e) { /* ignore */ }
13430
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 */ }
13431
14114
  try { stale.close(); } catch (e) { /* ignore */ }
13432
14115
  state.ws = null;
13433
14116
  }
@@ -13474,6 +14157,7 @@
13474
14157
  ws.onopen = function() {
13475
14158
  state.ws = ws;
13476
14159
  state.wsConnected = true;
14160
+ state.lastWsMessageAt = Date.now();
13477
14161
  // Reset backoff on a successful connect so the next disconnect
13478
14162
  // starts the ladder from 500ms again.
13479
14163
  state.wsReconnectAttempts = 0;
@@ -13481,6 +14165,9 @@
13481
14165
  // Server's per-client output sequence counter restarts on every
13482
14166
  // new socket; clear ours so the first init isn't treated as a gap.
13483
14167
  state.lastSeqBySession = {};
14168
+ // 启动客户端心跳检测:每 10s 检查一次 lastWsMessageAt,超过 40s
14169
+ // 没收到任何消息(包括服务端 20s 一次的 ping)就视为半开连接。
14170
+ startWsHeartbeatCheck();
13484
14171
  // Subscribe to current session if any
13485
14172
  subscribeToSession(state.selectedId);
13486
14173
  // Flush pending messages after reconnection
@@ -13495,8 +14182,20 @@
13495
14182
  };
13496
14183
 
13497
14184
  ws.onmessage = function(event) {
14185
+ // 任意服务端消息都说明连接活着,先刷新心跳计时。
14186
+ state.lastWsMessageAt = Date.now();
13498
14187
  try {
13499
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
+ }
13500
14199
  if (msg && msg.type === "resync_required" && msg.sessionId) {
13501
14200
  // Server dropped some output events under backpressure and
13502
14201
  // is asking us for a fresh snapshot. Send a resync so the
@@ -13543,6 +14242,7 @@
13543
14242
  ws.onclose = function() {
13544
14243
  state.ws = null;
13545
14244
  state.wsConnected = false;
14245
+ stopWsHeartbeatCheck();
13546
14246
  scheduleWsReconnect();
13547
14247
  };
13548
14248
 
@@ -13676,7 +14376,9 @@
13676
14376
  // 变化的视觉错位无法被自愈,直到用户手动改窗口才修。现在让
13677
14377
  // wterm 内部 ResizeObserver 独占 cols 跟踪职责。
13678
14378
  wandTerminalWrite(state.terminal, msg.data.chunk);
13679
- maybeScheduleResyncForChunk(msg.data.chunk);
14379
+ // 同 syncTerminalBuffer 的 delta 分支:流式 chunk 不再触发
14380
+ // softResyncTerminal,避免完整重放把 cursor-home + 重画的
14381
+ // "被覆盖中间帧"反复塞进 scrollback。thinking→idle 兜底就够了。
13680
14382
  state.terminalSessionId = msg.sessionId;
13681
14383
  if (msg.data.output) {
13682
14384
  state.terminalOutput = clampClientTerminalOutput(normalizeTerminalOutput(msg.data.output));
@@ -13812,10 +14514,24 @@
13812
14514
  renderChat(true);
13813
14515
  updateTaskDisplay();
13814
14516
  updateApprovalStats();
13815
- // 订阅返回的是服务端 ring buffer 最新窗口,与客户端 terminalOutput
13816
- // 可能不连续。强制 replace(reset + 按当前 cols 重写)是订阅时唯一
13817
- // 可信的全量基线,避免 append prefix 检查走错分支。
13818
- 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");
13819
14535
  // wterm 启动 cols=120,replace 写入可能落在错的列宽上;ResizeObserver
13820
14536
  // 回调异步,用 fit-with-retry 兜一次确保按真实宽度重排。
13821
14537
  ensureTerminalFitWithRetry("init");
@@ -17310,11 +18026,43 @@
17310
18026
 
17311
18027
  // ── Browser Notification API ──
17312
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
+
17313
18056
  // Detect Android APK native bridge
17314
18057
  var _hasNativeBridge = typeof WandNative !== "undefined" && typeof WandNative.sendNotification === "function";
17315
- // Detect if running inside APK and extract installed version from User-Agent
17316
- var _apkVersionMatch = navigator.userAgent.match(/WandApp\/([^\s]+)/);
17317
- 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;
17318
18066
 
17319
18067
  function _vibrate(pattern) {
17320
18068
  if (!_hasNativeBridge || typeof WandNative.vibrate !== "function") return;