@co0ontty/wand 1.25.3 → 1.26.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.
@@ -195,7 +195,21 @@
195
195
  quickCommitSubmitting: false,
196
196
  quickCommitGenerating: false,
197
197
  quickCommitError: "",
198
- quickCommitForm: { autoMessage: false, customMessage: "", makeTag: false, tag: "", push: false },
198
+ // primaryAction: "commit" (commit only, no push) or "commit-push" (commit then push).
199
+ // Whether the commit is also tagged is controlled by `makeTag` independently —
200
+ // that keeps "commit + tag, push later" possible.
201
+ quickCommitForm: { customMessage: "", makeTag: false, tag: "", primaryAction: "commit" },
202
+ quickCommitActionMenuOpen: false,
203
+ // Secondary "tag the existing HEAD" panel (inline drawer).
204
+ quickCommitTagHeadOpen: false,
205
+ quickCommitTagHeadForm: { tag: "", push: false },
206
+ quickCommitTagHeadSubmitting: false,
207
+ quickCommitTagHeadGenerating: false,
208
+ quickCommitTagHeadError: "",
209
+ // Secondary "push only" controls.
210
+ quickCommitPushMenuOpen: false,
211
+ quickCommitPushing: false,
212
+ quickCommitPushError: "",
199
213
  // Telegram 风格的"贴底"状态:true = 用户当前贴在底部,新消息会自然出现;
200
214
  // false = 用户向上滚了,未读会累积到气泡里,不会自动滚他们的视图。
201
215
  chatStickToBottom: true,
@@ -1777,13 +1791,40 @@
1777
1791
  }
1778
1792
 
1779
1793
  var quickCommitEscHandler = null;
1794
+ var quickCommitDocClickHandler = null;
1795
+
1796
+ // Restore the user's last "primary action" choice so the split button feels sticky.
1797
+ function readSavedPrimaryAction() {
1798
+ try {
1799
+ var v = localStorage.getItem("wand.quickCommit.primaryAction");
1800
+ if (v === "commit" || v === "commit-push") return v;
1801
+ } catch (e) { /* localStorage may be blocked */ }
1802
+ return "commit";
1803
+ }
1804
+ function savePrimaryAction(value) {
1805
+ try { localStorage.setItem("wand.quickCommit.primaryAction", value); } catch (e) { /* no-op */ }
1806
+ }
1780
1807
 
1781
1808
  function openQuickCommitModal() {
1782
1809
  if (!state.selectedId) return;
1783
1810
  state.quickCommitOpen = true;
1784
1811
  state.quickCommitSubmitting = false;
1785
1812
  state.quickCommitError = "";
1786
- state.quickCommitForm = { autoMessage: false, customMessage: "", makeTag: false, tag: "", push: false };
1813
+ state.quickCommitForm = {
1814
+ customMessage: "",
1815
+ makeTag: false,
1816
+ tag: "",
1817
+ primaryAction: readSavedPrimaryAction(),
1818
+ };
1819
+ state.quickCommitActionMenuOpen = false;
1820
+ state.quickCommitTagHeadOpen = false;
1821
+ state.quickCommitTagHeadForm = { tag: "", push: false };
1822
+ state.quickCommitTagHeadSubmitting = false;
1823
+ state.quickCommitTagHeadGenerating = false;
1824
+ state.quickCommitTagHeadError = "";
1825
+ state.quickCommitPushMenuOpen = false;
1826
+ state.quickCommitPushing = false;
1827
+ state.quickCommitPushError = "";
1787
1828
  closeWorktreeMergeModal();
1788
1829
  closeSessionModal();
1789
1830
  closeSettingsModal();
@@ -1796,11 +1837,37 @@
1796
1837
  }
1797
1838
  if (quickCommitEscHandler) document.removeEventListener("keydown", quickCommitEscHandler);
1798
1839
  quickCommitEscHandler = function(e) {
1799
- if (e.key === "Escape" && state.quickCommitOpen && !state.quickCommitSubmitting) {
1840
+ if (e.key === "Escape" && state.quickCommitOpen && !state.quickCommitSubmitting && !state.quickCommitTagHeadSubmitting && !state.quickCommitPushing) {
1841
+ // 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;
1846
+ rerenderQuickCommitModal();
1847
+ return;
1848
+ }
1800
1849
  closeQuickCommitModal();
1801
1850
  }
1802
1851
  };
1803
1852
  document.addEventListener("keydown", quickCommitEscHandler);
1853
+ if (quickCommitDocClickHandler) document.removeEventListener("click", quickCommitDocClickHandler, true);
1854
+ quickCommitDocClickHandler = function(e) {
1855
+ if (!state.quickCommitOpen) return;
1856
+ var modalEl = document.getElementById("quick-commit-modal");
1857
+ 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
+ var t = e.target;
1862
+ while (t && t !== modalEl) {
1863
+ if (t.dataset && (t.dataset.qcDropdownToggle || t.dataset.qcDropdownMenu)) return;
1864
+ t = t.parentNode;
1865
+ }
1866
+ state.quickCommitActionMenuOpen = false;
1867
+ state.quickCommitPushMenuOpen = false;
1868
+ rerenderQuickCommitModal();
1869
+ };
1870
+ document.addEventListener("click", quickCommitDocClickHandler, true);
1804
1871
  loadGitStatus(state.selectedId, { force: true }).then(function() {
1805
1872
  if (!state.quickCommitOpen) return;
1806
1873
  rerenderQuickCommitModal();
@@ -1811,6 +1878,9 @@
1811
1878
  state.quickCommitOpen = false;
1812
1879
  state.quickCommitSubmitting = false;
1813
1880
  state.quickCommitError = "";
1881
+ state.quickCommitActionMenuOpen = false;
1882
+ state.quickCommitPushMenuOpen = false;
1883
+ state.quickCommitTagHeadOpen = false;
1814
1884
  var modal = document.getElementById("quick-commit-modal");
1815
1885
  if (modal) modal.classList.add("hidden");
1816
1886
  if (focusTrapHandler) {
@@ -1821,6 +1891,10 @@
1821
1891
  document.removeEventListener("keydown", quickCommitEscHandler);
1822
1892
  quickCommitEscHandler = null;
1823
1893
  }
1894
+ if (quickCommitDocClickHandler) {
1895
+ document.removeEventListener("click", quickCommitDocClickHandler, true);
1896
+ quickCommitDocClickHandler = null;
1897
+ }
1824
1898
  if (lastFocusedElement && typeof lastFocusedElement.focus === "function") {
1825
1899
  lastFocusedElement.focus();
1826
1900
  }
@@ -1843,6 +1917,8 @@
1843
1917
  if (closeBtn) closeBtn.addEventListener("click", closeQuickCommitModal);
1844
1918
  var cancelBtn = document.getElementById("quick-commit-cancel-btn");
1845
1919
  if (cancelBtn) cancelBtn.addEventListener("click", closeQuickCommitModal);
1920
+
1921
+ // ── Section 1: commit form ──
1846
1922
  var submitBtn = document.getElementById("quick-commit-submit-btn");
1847
1923
  if (submitBtn) submitBtn.addEventListener("click", submitQuickCommit);
1848
1924
  var aiBtn = document.getElementById("quick-commit-ai-btn");
@@ -1861,10 +1937,102 @@
1861
1937
  if (tagInput) tagInput.addEventListener("input", function() {
1862
1938
  state.quickCommitForm.tag = tagInput.value;
1863
1939
  });
1864
- var pushCb = document.getElementById("quick-commit-push");
1865
- if (pushCb) pushCb.addEventListener("change", function() {
1866
- state.quickCommitForm.push = pushCb.checked;
1940
+
1941
+ // ── Primary action split-button (caret + dropdown) ──
1942
+ var actionCaret = document.getElementById("quick-commit-action-caret");
1943
+ if (actionCaret) actionCaret.addEventListener("click", function(e) {
1944
+ e.stopPropagation();
1945
+ state.quickCommitActionMenuOpen = !state.quickCommitActionMenuOpen;
1946
+ state.quickCommitPushMenuOpen = false;
1947
+ rerenderQuickCommitModal();
1948
+ });
1949
+ var actionMenu = document.getElementById("quick-commit-action-menu");
1950
+ if (actionMenu) {
1951
+ var actionItems = actionMenu.querySelectorAll("[data-qc-action]");
1952
+ for (var i = 0; i < actionItems.length; i++) {
1953
+ (function(btn) {
1954
+ btn.addEventListener("click", function() {
1955
+ var value = btn.getAttribute("data-qc-action");
1956
+ if (value === "commit" || value === "commit-push") {
1957
+ state.quickCommitForm.primaryAction = value;
1958
+ savePrimaryAction(value);
1959
+ }
1960
+ state.quickCommitActionMenuOpen = false;
1961
+ rerenderQuickCommitModal();
1962
+ });
1963
+ })(actionItems[i]);
1964
+ }
1965
+ }
1966
+
1967
+ // ── Section 2: Tag HEAD inline panel ──
1968
+ var tagHeadToggle = document.getElementById("quick-commit-tag-head-toggle");
1969
+ if (tagHeadToggle) tagHeadToggle.addEventListener("click", function() {
1970
+ state.quickCommitTagHeadOpen = !state.quickCommitTagHeadOpen;
1971
+ if (state.quickCommitTagHeadOpen) {
1972
+ state.quickCommitTagHeadError = "";
1973
+ }
1974
+ rerenderQuickCommitModal();
1975
+ if (state.quickCommitTagHeadOpen) {
1976
+ var inp = document.getElementById("quick-commit-tag-head-input");
1977
+ if (inp) inp.focus();
1978
+ }
1867
1979
  });
1980
+ var tagHeadCancel = document.getElementById("quick-commit-tag-head-cancel");
1981
+ if (tagHeadCancel) tagHeadCancel.addEventListener("click", function() {
1982
+ state.quickCommitTagHeadOpen = false;
1983
+ state.quickCommitTagHeadError = "";
1984
+ rerenderQuickCommitModal();
1985
+ });
1986
+ var tagHeadInput = document.getElementById("quick-commit-tag-head-input");
1987
+ if (tagHeadInput) {
1988
+ tagHeadInput.addEventListener("input", function() {
1989
+ state.quickCommitTagHeadForm.tag = tagHeadInput.value;
1990
+ });
1991
+ tagHeadInput.addEventListener("keydown", function(e) {
1992
+ if (e.key === "Enter") {
1993
+ e.preventDefault();
1994
+ submitTagHead(false);
1995
+ }
1996
+ });
1997
+ }
1998
+ var tagHeadAi = document.getElementById("quick-commit-tag-head-ai");
1999
+ if (tagHeadAi) tagHeadAi.addEventListener("click", generateTagHeadAI);
2000
+ var tagHeadPushCb = document.getElementById("quick-commit-tag-head-push");
2001
+ if (tagHeadPushCb) tagHeadPushCb.addEventListener("change", function() {
2002
+ state.quickCommitTagHeadForm.push = tagHeadPushCb.checked;
2003
+ });
2004
+ var tagHeadSubmit = document.getElementById("quick-commit-tag-head-submit");
2005
+ if (tagHeadSubmit) tagHeadSubmit.addEventListener("click", function() {
2006
+ submitTagHead(false);
2007
+ });
2008
+
2009
+ // ── Section 2: Push split-button ──
2010
+ var pushBtn = document.getElementById("quick-commit-push-btn");
2011
+ if (pushBtn) pushBtn.addEventListener("click", function() {
2012
+ submitPushOnly({ pushCommits: true, pushTags: false });
2013
+ });
2014
+ var pushCaret = document.getElementById("quick-commit-push-caret");
2015
+ if (pushCaret) pushCaret.addEventListener("click", function(e) {
2016
+ e.stopPropagation();
2017
+ state.quickCommitPushMenuOpen = !state.quickCommitPushMenuOpen;
2018
+ state.quickCommitActionMenuOpen = false;
2019
+ rerenderQuickCommitModal();
2020
+ });
2021
+ var pushMenu = document.getElementById("quick-commit-push-menu");
2022
+ if (pushMenu) {
2023
+ var pushItems = pushMenu.querySelectorAll("[data-qc-push]");
2024
+ for (var j = 0; j < pushItems.length; j++) {
2025
+ (function(btn) {
2026
+ btn.addEventListener("click", function() {
2027
+ var value = btn.getAttribute("data-qc-push");
2028
+ state.quickCommitPushMenuOpen = false;
2029
+ if (value === "commits") submitPushOnly({ pushCommits: true, pushTags: false });
2030
+ else if (value === "tags") submitPushOnly({ pushCommits: false, pushTags: true });
2031
+ else if (value === "both") submitPushOnly({ pushCommits: true, pushTags: true });
2032
+ });
2033
+ })(pushItems[j]);
2034
+ }
2035
+ }
1868
2036
  }
1869
2037
 
1870
2038
  function generateCommitMessageAI() {
@@ -1925,13 +2093,14 @@
1925
2093
  rerenderQuickCommitModal();
1926
2094
  return;
1927
2095
  }
2096
+ var willPush = form.primaryAction === "commit-push";
1928
2097
  // 开了 tag 开关但没填 → 由后端在提交时调 AI 生成
1929
2098
  var payload = {
1930
2099
  autoMessage: false,
1931
2100
  customMessage: message,
1932
2101
  tag: userTag,
1933
2102
  autoTag: !!(form.makeTag && !userTag),
1934
- push: !!form.push
2103
+ push: willPush
1935
2104
  };
1936
2105
  state.quickCommitSubmitting = true;
1937
2106
  state.quickCommitError = "";
@@ -1951,8 +2120,7 @@
1951
2120
  var hash = data.commit && data.commit.hash ? data.commit.hash.substring(0, 7) : "";
1952
2121
  var tagName = data.tag && data.tag.name ? data.tag.name : "";
1953
2122
  var base = "已提交" + (hash ? " " + hash : "") + (tagName ? ",已打 tag " + tagName : "");
1954
- var pushRequested = !!payload.push;
1955
- if (pushRequested && data.pushError) {
2123
+ if (willPush && data.pushError) {
1956
2124
  var msg = base + ";push 失败:" + data.pushError;
1957
2125
  if (typeof showToast === "function") showToast(msg, "error");
1958
2126
  } else {
@@ -1971,13 +2139,137 @@
1971
2139
  });
1972
2140
  }
1973
2141
 
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) {
2142
+ // ── AI generate a tag for the existing HEAD (no commit involved) ──
2143
+ function generateTagHeadAI() {
2144
+ if (!state.selectedId || state.quickCommitTagHeadGenerating) return;
2145
+ var inp = document.getElementById("quick-commit-tag-head-input");
2146
+ if (inp) state.quickCommitTagHeadForm.tag = inp.value;
2147
+ state.quickCommitTagHeadGenerating = true;
2148
+ state.quickCommitTagHeadError = "";
2149
+ rerenderQuickCommitModal();
2150
+ // Reuse the existing generator — it stages and asks for {message, tag}.
2151
+ // We only consume `suggestedTag` here.
2152
+ fetch("/api/sessions/" + encodeURIComponent(state.selectedId) + "/generate-commit-message", {
2153
+ method: "POST",
2154
+ credentials: "same-origin",
2155
+ headers: { "Content-Type": "application/json" },
2156
+ body: JSON.stringify({})
2157
+ })
2158
+ .then(function(res) { return res.json().then(function(data) { return { ok: res.ok, data: data }; }); })
2159
+ .then(function(result) {
2160
+ if (!result.ok) throw new Error((result.data && result.data.error) || "AI 生成失败。");
2161
+ var aiTag = (result.data && typeof result.data.suggestedTag === "string") ? result.data.suggestedTag.trim() : "";
2162
+ var currentTag = (state.quickCommitTagHeadForm.tag || "").trim();
2163
+ if (!currentTag && aiTag) {
2164
+ state.quickCommitTagHeadForm.tag = aiTag;
2165
+ } else if (!aiTag) {
2166
+ throw new Error("AI 没有给出 tag 建议。");
2167
+ }
2168
+ })
2169
+ .catch(function(error) {
2170
+ state.quickCommitTagHeadError = (error && error.message) || "AI 生成失败。";
2171
+ })
2172
+ .finally(function() {
2173
+ state.quickCommitTagHeadGenerating = false;
2174
+ if (state.quickCommitOpen) rerenderQuickCommitModal();
2175
+ });
2176
+ }
2177
+
2178
+ // Tag the existing HEAD without making a new commit.
2179
+ function submitTagHead(silent) {
2180
+ if (!state.selectedId || state.quickCommitTagHeadSubmitting) return;
2181
+ var inp = document.getElementById("quick-commit-tag-head-input");
2182
+ if (inp) state.quickCommitTagHeadForm.tag = inp.value;
2183
+ var tag = (state.quickCommitTagHeadForm.tag || "").trim();
2184
+ if (!tag) {
2185
+ state.quickCommitTagHeadError = "请填写 tag 名称,或点击 AI 建议。";
2186
+ rerenderQuickCommitModal();
2187
+ return;
2188
+ }
2189
+ state.quickCommitTagHeadSubmitting = true;
2190
+ state.quickCommitTagHeadError = "";
2191
+ rerenderQuickCommitModal();
2192
+ fetch("/api/sessions/" + encodeURIComponent(state.selectedId) + "/git/tag-head", {
2193
+ method: "POST",
2194
+ credentials: "same-origin",
2195
+ headers: { "Content-Type": "application/json" },
2196
+ body: JSON.stringify({ tag: tag, push: !!state.quickCommitTagHeadForm.push })
2197
+ })
2198
+ .then(function(res) { return res.json().then(function(data) { return { ok: res.ok, data: data }; }); })
2199
+ .then(function(result) {
2200
+ if (!result.ok) throw new Error((result.data && result.data.error) || "打 tag 失败。");
2201
+ var data = result.data || {};
2202
+ var name = data.tag && data.tag.name ? data.tag.name : tag;
2203
+ var pushed = !!data.pushed;
2204
+ var pushErr = data.pushError;
2205
+ var base = "已为 HEAD 打 tag " + name;
2206
+ if (state.quickCommitTagHeadForm.push && pushErr) {
2207
+ if (typeof showToast === "function") showToast(base + ";push tag 失败:" + pushErr, "error");
2208
+ } else {
2209
+ if (typeof showToast === "function") showToast(base + (pushed ? ",已 push" : ""), "success");
2210
+ }
2211
+ state.quickCommitTagHeadOpen = false;
2212
+ state.quickCommitTagHeadForm = { tag: "", push: false };
2213
+ if (state.selectedId) loadGitStatus(state.selectedId, { force: true }).then(function() {
2214
+ if (state.quickCommitOpen) rerenderQuickCommitModal();
2215
+ });
2216
+ })
2217
+ .catch(function(error) {
2218
+ state.quickCommitTagHeadError = (error && error.message) || "打 tag 失败。";
2219
+ })
2220
+ .finally(function() {
2221
+ state.quickCommitTagHeadSubmitting = false;
2222
+ if (state.quickCommitOpen) rerenderQuickCommitModal();
2223
+ });
2224
+ }
2225
+
2226
+ // Push without committing.
2227
+ function submitPushOnly(opts) {
2228
+ if (!state.selectedId || state.quickCommitPushing) return;
2229
+ var pushCommits = !!(opts && opts.pushCommits);
2230
+ var pushTags = !!(opts && opts.pushTags);
2231
+ if (!pushCommits && !pushTags) return;
2232
+ state.quickCommitPushing = true;
2233
+ state.quickCommitPushError = "";
2234
+ rerenderQuickCommitModal();
2235
+ fetch("/api/sessions/" + encodeURIComponent(state.selectedId) + "/git/push", {
2236
+ method: "POST",
2237
+ credentials: "same-origin",
2238
+ headers: { "Content-Type": "application/json" },
2239
+ body: JSON.stringify({ pushCommits: pushCommits, pushTags: pushTags })
2240
+ })
2241
+ .then(function(res) { return res.json().then(function(data) { return { ok: res.ok, data: data }; }); })
2242
+ .then(function(result) {
2243
+ var data = result.data || {};
2244
+ if (!result.ok) throw new Error((data && data.error) || "推送失败。");
2245
+ // Backend marks `ok: false` on partial failure but still returns 200 — surface error toast.
2246
+ if (data.error) {
2247
+ state.quickCommitPushError = data.error;
2248
+ if (typeof showToast === "function") showToast("推送失败:" + data.error, "error");
2249
+ return;
2250
+ }
2251
+ var parts = [];
2252
+ if (data.pushedCommits) parts.push("commits");
2253
+ if (data.pushedTags) parts.push("tags");
2254
+ var label = parts.length ? parts.join(" 和 ") : "(无内容)";
2255
+ if (typeof showToast === "function") showToast("已推送 " + label, "success");
2256
+ if (state.selectedId) loadGitStatus(state.selectedId, { force: true }).then(function() {
2257
+ if (state.quickCommitOpen) rerenderQuickCommitModal();
2258
+ });
2259
+ })
2260
+ .catch(function(error) {
2261
+ state.quickCommitPushError = (error && error.message) || "推送失败。";
2262
+ if (typeof showToast === "function") showToast(state.quickCommitPushError, "error");
2263
+ })
2264
+ .finally(function() {
2265
+ state.quickCommitPushing = false;
2266
+ if (state.quickCommitOpen) rerenderQuickCommitModal();
2267
+ });
2268
+ }
2269
+
2270
+ // Build the file list portion of the modal.
2271
+ function renderQuickCommitFileRows(files) {
2272
+ var rows = files.map(function(item) {
1981
2273
  var status = (item.status || " ").substring(0, 2);
1982
2274
  var flag = status.trim() || "?";
1983
2275
  var cls = "qc-flag";
@@ -1998,48 +2290,208 @@
1998
2290
  }
1999
2291
  return '<div class="qc-file-row"><span class="' + cls + '">' + escapeHtml(status) + '</span><span class="qc-file-path">' + escapeHtml(item.path || "") + '</span>' + subBadge + '</div>';
2000
2292
  }).join("");
2001
- if (!fileRows) fileRows = '<div class="qc-empty">工作区干净,没有可提交的改动。</div>';
2293
+ return rows || '<div class="qc-empty">没有可提交的改动。</div>';
2294
+ }
2295
+
2296
+ // Primary action button label / icon.
2297
+ function renderQuickCommitPrimary(hasChanges) {
2298
+ var f = state.quickCommitForm;
2299
+ var label;
2300
+ if (state.quickCommitSubmitting) label = "提交中…";
2301
+ else if (f.primaryAction === "commit-push") label = "提交并 push";
2302
+ else label = "仅提交";
2303
+ var disabled = !hasChanges || state.quickCommitSubmitting || state.quickCommitTagHeadSubmitting || state.quickCommitPushing;
2304
+ var caretActive = state.quickCommitActionMenuOpen ? " is-active" : "";
2305
+ var menuItems = [
2306
+ { value: "commit", label: "仅提交", desc: "只 commit,不 push" },
2307
+ { value: "commit-push", label: "提交并 push", desc: "commit 后立即 push 到远端" }
2308
+ ];
2309
+ var menuHtml = menuItems.map(function(item) {
2310
+ var sel = f.primaryAction === item.value ? " is-selected" : "";
2311
+ return '<button type="button" class="qc-dropdown-item' + sel + '" data-qc-action="' + item.value + '">' +
2312
+ '<span class="qc-dropdown-item-title">' + escapeHtml(item.label) + '</span>' +
2313
+ '<span class="qc-dropdown-item-desc">' + escapeHtml(item.desc) + '</span>' +
2314
+ '</button>';
2315
+ }).join("");
2316
+ return '<div class="qc-split-button">' +
2317
+ '<button id="quick-commit-submit-btn" class="btn btn-primary qc-split-main" type="button"' + (disabled ? ' disabled' : '') + '>' +
2318
+ escapeHtml(label) +
2319
+ '</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="更多提交方式">' +
2321
+ '<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
+ '</button>' +
2323
+ (state.quickCommitActionMenuOpen ?
2324
+ '<div id="quick-commit-action-menu" class="qc-dropdown-menu" data-qc-dropdown-menu="action" role="menu">' + menuHtml + '</div>' : '') +
2325
+ '</div>';
2326
+ }
2327
+
2328
+ // Status chips (ahead/behind/unpushed tags) under the HEAD card.
2329
+ function renderQuickCommitStatusChips(s) {
2330
+ var chips = [];
2331
+ if (typeof s.ahead === "number" && s.ahead > 0) {
2332
+ chips.push('<span class="qc-chip qc-chip--ahead" title="本地领先 ' + s.ahead + ' 个 commit">↑ ' + s.ahead + ' 待推送</span>');
2333
+ }
2334
+ if (typeof s.behind === "number" && s.behind > 0) {
2335
+ chips.push('<span class="qc-chip qc-chip--behind" title="远端领先 ' + s.behind + ' 个 commit">↓ ' + s.behind + ' 待拉取</span>');
2336
+ }
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) {
2341
+ chips.push('<span class="qc-chip qc-chip--warn" title="当前分支没有 upstream,将首次推送时自动设置">无 upstream</span>');
2342
+ }
2343
+ if (!chips.length && s.hasUpstream !== false) {
2344
+ chips.push('<span class="qc-chip qc-chip--clean">与远端同步</span>');
2345
+ }
2346
+ return '<div class="qc-status-chips">' + chips.join("") + '</div>';
2347
+ }
2348
+
2349
+ // Inline "tag HEAD" drawer rendering.
2350
+ function renderQuickCommitTagHeadPanel() {
2351
+ var thf = state.quickCommitTagHeadForm || { tag: "", push: false };
2352
+ var submitting = state.quickCommitTagHeadSubmitting;
2353
+ var generating = state.quickCommitTagHeadGenerating;
2354
+ return '<div class="qc-tag-head-panel">' +
2355
+ '<div class="qc-tag-head-row">' +
2356
+ '<input type="text" id="quick-commit-tag-head-input" class="field-input" placeholder="输入 tag 名(例如 v1.2.0),或点 AI 建议" value="' + escapeHtml(thf.tag || "") + '"' + (submitting ? ' disabled' : '') + '>' +
2357
+ '<button type="button" id="quick-commit-tag-head-ai" class="btn btn-ghost btn-sm"' + (generating || submitting ? ' disabled' : '') + '>' + (generating ? '建议中…' : 'AI 建议') + '</button>' +
2358
+ '</div>' +
2359
+ '<label class="qc-tag-head-push">' +
2360
+ '<input type="checkbox" id="quick-commit-tag-head-push"' + (thf.push ? ' checked' : '') + (submitting ? ' disabled' : '') + '>' +
2361
+ '<span>打完 tag 立即推送这个 tag</span>' +
2362
+ '</label>' +
2363
+ (state.quickCommitTagHeadError ? '<p class="error-message">' + escapeHtml(state.quickCommitTagHeadError) + '</p>' : '') +
2364
+ '<div class="qc-tag-head-actions">' +
2365
+ '<button type="button" id="quick-commit-tag-head-cancel" class="btn btn-ghost btn-sm"' + (submitting ? ' disabled' : '') + '>收起</button>' +
2366
+ '<button type="button" id="quick-commit-tag-head-submit" class="btn btn-secondary btn-sm"' + (submitting ? ' disabled' : '') + '>' + (submitting ? '打 tag 中…' : '为 HEAD 打 tag') + '</button>' +
2367
+ '</div>' +
2368
+ '</div>';
2369
+ }
2370
+
2371
+ // Push split-button on the secondary row.
2372
+ function renderQuickCommitPushButton(s) {
2373
+ 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);
2381
+ 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 menuHtml = items.map(function(it) {
2388
+ var dis = it.disabled ? " is-disabled" : "";
2389
+ return '<button type="button" class="qc-dropdown-item' + dis + '" data-qc-push="' + it.value + '"' + (it.disabled ? ' disabled' : '') + '>' +
2390
+ '<span class="qc-dropdown-item-title">' + escapeHtml(it.label) + '</span>' +
2391
+ '<span class="qc-dropdown-item-desc">' + escapeHtml(it.desc) + '</span>' +
2392
+ '</button>';
2393
+ }).join("");
2394
+ return '<div class="qc-split-button qc-split-button--secondary">' +
2395
+ '<button id="quick-commit-push-btn" class="btn btn-secondary qc-split-main" type="button"' + (disabled ? ' disabled' : '') + '>' +
2396
+ escapeHtml(mainLabel) +
2397
+ '</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="更多推送方式">' +
2399
+ '<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
+ '</button>' +
2401
+ (state.quickCommitPushMenuOpen ?
2402
+ '<div id="quick-commit-push-menu" class="qc-dropdown-menu qc-dropdown-menu--right" data-qc-dropdown-menu="push" role="menu">' + menuHtml + '</div>' : '') +
2403
+ '</div>';
2404
+ }
2405
+
2406
+ function renderQuickCommitModal() {
2407
+ var s = state.gitStatus || {};
2408
+ var f = state.quickCommitForm || { customMessage: "", makeTag: false, tag: "", primaryAction: "commit" };
2002
2409
  var hasChanges = (s.modifiedCount || 0) > 0;
2410
+ var files = Array.isArray(s.files) ? s.files : [];
2411
+ var fileRows = renderQuickCommitFileRows(files);
2412
+
2413
+ // Subtitle: branch · N 改动 · ↑X ↓Y
2414
+ var subParts = [];
2415
+ subParts.push(s.branch || "(no branch)");
2416
+ subParts.push((s.modifiedCount || 0) + " 个改动");
2417
+ if (typeof s.ahead === "number" && s.ahead > 0) subParts.push("↑" + s.ahead);
2418
+ if (typeof s.behind === "number" && s.behind > 0) subParts.push("↓" + s.behind);
2419
+
2420
+ // Section 1: changes + commit form (only when there are changes)
2421
+ var section1 = "";
2422
+ if (hasChanges) {
2423
+ section1 = '<section class="qc-section qc-section--changes">' +
2424
+ '<div class="qc-section-head"><span class="qc-section-title">暂存改动</span><span class="qc-section-meta">' + escapeHtml(s.modifiedCount + " 个文件") + '</span></div>' +
2425
+ '<div class="qc-files-wrap">' + fileRows + '</div>' +
2426
+ '<div class="qc-message-row" id="quick-commit-message-row">' +
2427
+ '<div class="qc-message-header"><label class="field-label" for="quick-commit-message">commit message</label>' +
2428
+ '<button type="button" id="quick-commit-ai-btn" class="btn btn-ghost btn-sm"' + (state.quickCommitGenerating ? ' disabled' : '') + '>' + (state.quickCommitGenerating ? '生成中…' : 'AI 生成') + '</button>' +
2429
+ '</div>' +
2430
+ '<textarea id="quick-commit-message" class="field-input" rows="2" placeholder="输入 commit message 或点击 AI 生成"' + (state.quickCommitSubmitting ? ' disabled' : '') + '>' + escapeHtml(f.customMessage || "") + '</textarea>' +
2431
+ '</div>' +
2432
+ '<div class="qc-checkbox-row">' +
2433
+ '<label class="qc-checkbox-label" for="quick-commit-make-tag">同时为本次 commit 打 tag</label>' +
2434
+ '<label class="qc-switch">' +
2435
+ '<input type="checkbox" id="quick-commit-make-tag" class="switch-toggle"' + (f.makeTag ? ' checked' : '') + '>' +
2436
+ '<span class="switch-slider"></span>' +
2437
+ '</label>' +
2438
+ '</div>' +
2439
+ '<div class="qc-tag-row' + (f.makeTag ? '' : ' hidden') + '" id="quick-commit-tag-row">' +
2440
+ '<input type="text" id="quick-commit-tag" class="field-input" placeholder="输入 tag 名称;留空将由 AI 在提交时自动生成" value="' + escapeHtml(f.tag || "") + '"' + (state.quickCommitSubmitting ? ' disabled' : '') + '>' +
2441
+ '</div>' +
2442
+ (state.quickCommitError ? '<p class="error-message">' + escapeHtml(state.quickCommitError) + '</p>' : '') +
2443
+ '<div class="qc-section-actions">' +
2444
+ '<button id="quick-commit-cancel-btn" class="btn btn-ghost btn-sm" type="button">取消</button>' +
2445
+ renderQuickCommitPrimary(hasChanges) +
2446
+ '</div>' +
2447
+ '</section>';
2448
+ } else {
2449
+ section1 = '<section class="qc-section qc-section--empty">' +
2450
+ '<div class="qc-empty-state">' +
2451
+ '<span class="qc-empty-icon">✓</span>' +
2452
+ '<div><div class="qc-empty-title">工作区干净</div><div class="qc-empty-sub">没有暂存改动可提交。仍可在下方为 HEAD 打 tag 或推送。</div></div>' +
2453
+ '</div>' +
2454
+ '</section>';
2455
+ }
2456
+
2457
+ // Section 2: repo status + secondary actions (always show when there's at least one commit)
2458
+ var section2 = "";
2459
+ if (!s.initialCommit && s.isGit !== false) {
2460
+ var lc = s.lastCommit || {};
2461
+ var headLine = lc.shortHash ? lc.shortHash + " · " + (lc.subject || "") : (s.head ? s.head.substring(0, 7) : "(no commit)");
2462
+ var upstreamLine = s.upstream ? escapeHtml(s.branch || "") + " → " + escapeHtml(s.upstream) : escapeHtml(s.branch || "(no branch)") + " · 无 upstream";
2463
+ section2 = '<section class="qc-section qc-section--repo">' +
2464
+ '<div class="qc-section-head"><span class="qc-section-title">仓库状态</span><span class="qc-section-meta">' + upstreamLine + '</span></div>' +
2465
+ '<div class="qc-head-card">' +
2466
+ '<span class="qc-head-label">HEAD</span>' +
2467
+ '<code class="qc-head-text">' + escapeHtml(headLine) + '</code>' +
2468
+ '</div>' +
2469
+ renderQuickCommitStatusChips(s) +
2470
+ (state.quickCommitTagHeadOpen ? renderQuickCommitTagHeadPanel() : '') +
2471
+ '<div class="qc-section-actions qc-section-actions--secondary">' +
2472
+ '<button id="quick-commit-tag-head-toggle" class="btn btn-ghost btn-sm" type="button"' + (state.quickCommitPushing ? ' disabled' : '') + '>' +
2473
+ '<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') +
2475
+ '</button>' +
2476
+ renderQuickCommitPushButton(s) +
2477
+ '</div>' +
2478
+ '</section>';
2479
+ }
2480
+
2481
+ var subtitleHtml = subParts.map(escapeHtml).join(" · ");
2003
2482
 
2004
2483
  return '<section id="quick-commit-modal" class="modal-backdrop' + (state.quickCommitOpen ? '' : ' hidden') + '">' +
2005
2484
  '<div class="modal quick-commit-modal" role="dialog" aria-labelledby="quick-commit-title">' +
2006
2485
  '<div class="modal-header">' +
2007
2486
  '<div>' +
2008
2487
  '<h2 id="quick-commit-title" class="modal-title">快捷提交</h2>' +
2009
- '<p class="modal-subtitle">' + escapeHtml((s.branch || "(no branch)") + ' · ' + (s.modifiedCount || 0) + ' 个改动') + '</p>' +
2488
+ '<p class="modal-subtitle">' + subtitleHtml + '</p>' +
2010
2489
  '</div>' +
2011
2490
  '<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
2491
  '</div>' +
2013
2492
  '<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>' +
2493
+ section1 +
2494
+ section2 +
2043
2495
  '</div>' +
2044
2496
  '</div>' +
2045
2497
  '</section>';
@@ -9199,16 +9651,27 @@
9199
9651
  })
9200
9652
  .then(function(res) { return res.json(); })
9201
9653
  .then(function(data) {
9654
+ if (data.error) {
9655
+ if (msgEl) {
9656
+ msgEl.textContent = data.error;
9657
+ msgEl.style.color = "var(--error)";
9658
+ msgEl.classList.remove("hidden");
9659
+ }
9660
+ updateBtn.disabled = false;
9661
+ return;
9662
+ }
9663
+ // \u5b89\u88c5\u6210\u529f\uff1a\u81ea\u52a8\u8c03\u7528 /api/restart\uff0c\u8ba9\u670d\u52a1\u91cd\u542f\u751f\u6548\uff0c
9664
+ // \u9875\u9762\u4f1a\u88ab restart overlay \u63a5\u624b\uff0c\u7b49\u540e\u7aef\u56de\u6765\u540e\u81ea\u52a8\u5237\u65b0\u3002
9202
9665
  if (msgEl) {
9203
- msgEl.textContent = data.message || data.error || "\u66f4\u65b0\u5b8c\u6210\u3002";
9204
- msgEl.style.color = data.error ? "var(--error)" : "var(--success)";
9666
+ msgEl.textContent = (data.message || "\u66f4\u65b0\u5b8c\u6210") + "\uff0c\u6b63\u5728\u91cd\u542f\u670d\u52a1\u2026";
9667
+ msgEl.style.color = "var(--success)";
9205
9668
  msgEl.classList.remove("hidden");
9206
9669
  }
9207
- if (data.error) {
9208
- updateBtn.disabled = false;
9670
+ updateBtn.classList.add("hidden");
9671
+ if (data.restartRequired !== false) {
9672
+ performRestart(null, msgEl);
9209
9673
  } else {
9210
- updateBtn.classList.add("hidden");
9211
- // Show restart button
9674
+ // \u670d\u52a1\u7aef\u660e\u786e\u8868\u793a\u4e0d\u9700\u8981\u91cd\u542f\uff0c\u4fdd\u7559\u624b\u52a8\u91cd\u542f\u6309\u94ae
9212
9675
  var restartBtn = document.getElementById("do-restart-button");
9213
9676
  if (restartBtn) restartBtn.classList.remove("hidden");
9214
9677
  }
@@ -10910,6 +11373,48 @@
10910
11373
  }
10911
11374
  }
10912
11375
 
11376
+ // 计算一条 ConversationTurn 里所有 content block 的"信息体积"——文字 / 思考 /
11377
+ // tool_result 内容长度之和。用于在 lastMessage 增量更新里判断 incoming 是否
11378
+ // 至少和 localLast 一样完整,防止服务端偶发吐出更短的同 message.id 导致
11379
+ // 已显示的文字段被回退覆盖("显示了又消失"的根因之一)。
11380
+ function turnContentVolume(turn) {
11381
+ if (!turn || !Array.isArray(turn.content)) return 0;
11382
+ var total = 0;
11383
+ for (var i = 0; i < turn.content.length; i++) {
11384
+ var b = turn.content[i];
11385
+ if (!b) continue;
11386
+ if (typeof b.text === "string") total += b.text.length;
11387
+ if (typeof b.thinking === "string") total += b.thinking.length;
11388
+ if (typeof b.content === "string") total += b.content.length;
11389
+ else if (Array.isArray(b.content)) {
11390
+ for (var k = 0; k < b.content.length; k++) {
11391
+ var sub = b.content[k];
11392
+ if (sub && typeof sub.text === "string") total += sub.text.length;
11393
+ }
11394
+ }
11395
+ if (b.input) {
11396
+ try { total += JSON.stringify(b.input).length; } catch (_e) {}
11397
+ }
11398
+ }
11399
+ return total;
11400
+ }
11401
+
11402
+ // 合并同 role 的 last assistant turn:incoming 通常是权威更新(包含完整 usage、
11403
+ // 完整 block 序列),但偶发的服务端回退会让 incoming 比 local 更短。此时
11404
+ // 保留本地内容——下一次正常 emit 会校正。usage 字段以 incoming 优先(因为
11405
+ // 那是 result event 给的最终值)。
11406
+ function mergeAssistantTurn(localLast, incoming) {
11407
+ if (!localLast) return incoming;
11408
+ if (!incoming) return localLast;
11409
+ var localVol = turnContentVolume(localLast);
11410
+ var incVol = turnContentVolume(incoming);
11411
+ if (incVol >= localVol) return incoming;
11412
+ // incoming 更短:保留 local 的 content,但允许 incoming 更新 usage / 元字段。
11413
+ return Object.assign({}, localLast, {
11414
+ usage: incoming.usage || localLast.usage,
11415
+ });
11416
+ }
11417
+
10913
11418
  // Append queued user message placeholders to currentMessages so they
10914
11419
  // remain visible across WS updates and re-renders.
10915
11420
  function buildMessagesForRender(session, messages) {
@@ -13554,23 +14059,30 @@
13554
14059
  snapshot.sessionKind = msg.data.sessionKind;
13555
14060
  }
13556
14061
 
13557
- if (isIncremental && msg.data.lastMessage) {
14062
+ // 优先级修正:若同一事件里同时带 messages(全量)和 lastMessage(增量),
14063
+ // 让全量赢。WS 端的 debounce 已经会在跨形状时 flush,但保留这层
14064
+ // 客户端兜底,避免任何上游再合并出双载体事件时再次丢消息。
14065
+ if (msg.data.messages) {
14066
+ // Full mode (authoritative)
14067
+ snapshot.messages = msg.data.messages;
14068
+ } else if (isIncremental && msg.data.lastMessage) {
13558
14069
  // Incremental mode: merge lastMessage into existing session messages
13559
14070
  var existingSession = state.sessions.find(function(s) { return s.id === msg.sessionId; });
13560
14071
  if (existingSession) {
13561
14072
  var msgs = Array.isArray(existingSession.messages) ? existingSession.messages.slice() : [];
13562
14073
  var expectedCount = msg.data.messageCount || 0;
13563
- // Replace last turn if same role, or append if new turn
13564
- if (msgs.length > 0 && msg.data.lastMessage.role && msgs[msgs.length - 1].role === msg.data.lastMessage.role) {
13565
- msgs[msgs.length - 1] = msg.data.lastMessage;
14074
+ // 防御性合并:lastMessage 应当至少和本地最后一条一样长。如果服务端
14075
+ // 因为上游 bug(如 upsertBlocks 整段覆盖)回退发来一条更短的同 role
14076
+ // 消息,保留本地版本——文字会被刷新或下一次 emit 修正。
14077
+ var localLast = msgs.length > 0 ? msgs[msgs.length - 1] : null;
14078
+ var incoming = msg.data.lastMessage;
14079
+ if (localLast && incoming.role && localLast.role === incoming.role) {
14080
+ msgs[msgs.length - 1] = mergeAssistantTurn(localLast, incoming);
13566
14081
  } else if (msgs.length < expectedCount) {
13567
- msgs.push(msg.data.lastMessage);
14082
+ msgs.push(incoming);
13568
14083
  }
13569
14084
  snapshot.messages = msgs;
13570
14085
  }
13571
- } else if (!isIncremental && msg.data.messages) {
13572
- // Full mode (backward compatible)
13573
- snapshot.messages = msg.data.messages;
13574
14086
  }
13575
14087
 
13576
14088
  // Fast path: chunk-only incremental events skip expensive chat update
@@ -14353,7 +14865,11 @@
14353
14865
  var renderWasAtBottom = isChatNearBottom(chatMessages);
14354
14866
  if (renderWasAtBottom) state.chatStickToBottom = true;
14355
14867
 
14356
- var existingCount = chatMessages.querySelectorAll(".chat-message").length;
14868
+ // .system-info 卡片从计数里剔除——它由 extractPtySystemInfo 在
14869
+ // fullRenderChat 里穿插注入,不存在于 messages 数组中,混进 existingCount
14870
+ // 会让 msgCount !== existingCount 永远为真,每帧都走 fullRenderChat,从而
14871
+ // 不断 wipe innerHTML,触发"莫名其妙跳到最上面"的视觉错位。
14872
+ var existingCount = chatMessages.querySelectorAll(".chat-message:not(.system-info)").length;
14357
14873
  // Full render when: forced, no existing messages, or message count decreased/changed
14358
14874
  var needsFullRender = forceRender || existingCount === 0 || msgCount !== existingCount;
14359
14875
 
@@ -14401,6 +14917,29 @@
14401
14917
  '</div>';
14402
14918
  }
14403
14919
 
14920
+ // 在 innerHTML 整段重写前,先记下当前视口里"最靠近顶部边缘"的那条消息
14921
+ // 的 data-msg-index 和它到容器顶部的偏移。重写完成后找到同一 data-msg-index
14922
+ // 的新节点,把它放回原来的偏移——这是 column-reverse 下保住用户视线的
14923
+ // 标准锚点法。没有锚点时(首次渲染、空 → 非空)才走 scrollTop=0 兜底。
14924
+ var anchorMsgIndex = -1;
14925
+ var anchorOffset = 0;
14926
+ if (prevMsgCount > 0 && !renderWasAtBottom) {
14927
+ var containerTop = chatMessages.getBoundingClientRect().top;
14928
+ var preEls = chatMessages.querySelectorAll(".chat-message:not(.system-info)");
14929
+ for (var pi = 0; pi < preEls.length; pi++) {
14930
+ var rect = preEls[pi].getBoundingClientRect();
14931
+ // 第一条 top >= containerTop 的就是视口内最靠上的可见消息
14932
+ if (rect.bottom >= containerTop) {
14933
+ var idxAttr = preEls[pi].getAttribute("data-msg-index");
14934
+ if (idxAttr != null) {
14935
+ anchorMsgIndex = parseInt(idxAttr, 10);
14936
+ anchorOffset = rect.top - containerTop;
14937
+ }
14938
+ break;
14939
+ }
14940
+ }
14941
+ }
14942
+
14404
14943
  chatMessages.innerHTML = html;
14405
14944
  // 给每条消息打 data-msg-index(用 state.currentMessages 的全局索引),
14406
14945
  // 后面 refreshChatUnreadDivider 用它找未读分割线的位置。
@@ -14425,6 +14964,21 @@
14425
14964
  // 同一会话内的全量重渲染:用户原本贴底就保持贴底,浏览器在 innerHTML
14426
14965
  // 重置后可能把 scrollTop 钳到一个奇怪的值,这里显式拉回 0。
14427
14966
  chatMessages.scrollTop = 0;
14967
+ } else if (anchorMsgIndex >= 0) {
14968
+ // 用户当前不在底部——根据保存的锚点恢复视图位置,避免被"踢到最上面"。
14969
+ var newAnchor = chatMessages.querySelector(
14970
+ '.chat-message[data-msg-index="' + anchorMsgIndex + '"]'
14971
+ );
14972
+ if (newAnchor) {
14973
+ var newContainerTop = chatMessages.getBoundingClientRect().top;
14974
+ var newRect = newAnchor.getBoundingClientRect();
14975
+ var delta = (newRect.top - newContainerTop) - anchorOffset;
14976
+ if (Math.abs(delta) > 0.5) {
14977
+ state.chatIsProgrammaticScroll = true;
14978
+ chatMessages.scrollTop += delta;
14979
+ requestAnimationFrame(function() { state.chatIsProgrammaticScroll = false; });
14980
+ }
14981
+ }
14428
14982
  }
14429
14983
  attachAllCopyHandlers(chatMessages);
14430
14984
  bindChatScrollListener();
@@ -14584,7 +15138,10 @@
14584
15138
  // Optimization: only re-render the newest N messages (column-reverse: first children)
14585
15139
  // that actually differ, starting from the top (newest). Most streaming updates only
14586
15140
  // touch the latest assistant turn, so we can skip scanning all older messages.
14587
- var existingEls = Array.from(chatMessages.querySelectorAll(".chat-message"));
15141
+ // 同样剔除 system-info 卡片,否则 existingEls 长度对不上 reversedMessages,
15142
+ // top-N 对照会拿 system-info 卡片去比真消息的 HTML,永远 replacedAny=false,
15143
+ // 触发 fullRenderChat 兜底分支——这是滚动跳顶的另一条触发路径。
15144
+ var existingEls = Array.from(chatMessages.querySelectorAll(".chat-message:not(.system-info)"));
14588
15145
  var reversedMessages = messages.slice().reverse();
14589
15146
  var replacedAny = false;
14590
15147
  // Scan from newest (index 0 in reversed) up to MAX_STREAMING_SCAN messages
@@ -17703,29 +18260,29 @@
17703
18260
  })
17704
18261
  .then(function(res) { return res.json(); })
17705
18262
  .then(function(data) {
17706
- setProgress(false);
17707
- card.classList.remove("is-busy");
17708
18263
  if (data.error) {
17709
18264
  // Update failed
18265
+ setProgress(false);
18266
+ card.classList.remove("is-busy");
17710
18267
  setSubtitle("\u66f4\u65b0\u672a\u5b8c\u6210");
17711
18268
  setStatus(data.error, "error");
17712
18269
  actionBtn.disabled = false;
17713
18270
  if (actionLabel) actionLabel.textContent = "\u91cd\u8bd5";
17714
18271
  return;
17715
18272
  }
17716
- // Phase 2: Update succeeded, show restart button
17717
- setSubtitle(data.message || "\u66f4\u65b0\u5b8c\u6210\uff0c\u91cd\u542f\u540e\u751f\u6548");
17718
- setStatus("");
18273
+ // Phase 2: \u5b89\u88c5\u6210\u529f\uff0c\u81ea\u52a8\u8c03\u7528 /api/restart\uff0c\u7531 restart overlay \u63a5\u7ba1 UX\u3002
17719
18274
  card.classList.add("is-success");
17720
- if (actionLabel) actionLabel.textContent = "\u91cd\u542f\u751f\u6548";
17721
- actionBtn.disabled = false;
17722
- actionBtn.onclick = function() {
17723
- actionBtn.disabled = true;
17724
- if (actionLabel) actionLabel.textContent = "\u6b63\u5728\u91cd\u542f\u2026";
17725
- setSubtitle("\u670d\u52a1\u6b63\u5728\u91cd\u542f\u2026");
17726
- setProgress(true);
17727
- performRestartCard(actionBtn, actionLabel, subtitleEl, statusEl, progressEl);
17728
- };
18275
+ setSubtitle((data.message || "\u66f4\u65b0\u5b8c\u6210") + "\uff0c\u6b63\u5728\u91cd\u542f\u670d\u52a1\u2026");
18276
+ setStatus("");
18277
+ if (actionLabel) actionLabel.textContent = "\u6b63\u5728\u91cd\u542f\u2026";
18278
+ if (data.restartRequired === false) {
18279
+ setProgress(false);
18280
+ card.classList.remove("is-busy");
18281
+ actionBtn.disabled = false;
18282
+ if (actionLabel) actionLabel.textContent = "\u5df2\u5b8c\u6210";
18283
+ return;
18284
+ }
18285
+ performRestartCard(actionBtn, actionLabel, subtitleEl, statusEl, progressEl);
17729
18286
  })
17730
18287
  .catch(function() {
17731
18288
  setProgress(false);