@co0ontty/wand 1.39.1 → 1.40.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.
@@ -277,10 +277,9 @@
277
277
  quickCommitSubmitting: false,
278
278
  quickCommitGenerating: false,
279
279
  quickCommitError: "",
280
- // primaryAction: "commit" (commit only, no push) or "commit-push" (commit then push).
281
- // Whether the commit is also tagged is controlled by `makeTag` independently
282
- // that keeps "commit + tag, push later" possible.
283
- quickCommitForm: { customMessage: "", makeTag: false, tag: "", primaryAction: "commit" },
280
+ // commitMode: "commit-tag" (commit + version tag) | "commit" (commit only).
281
+ // Pushing is a separate, standalone action never bundled into the commit button.
282
+ quickCommitForm: { customMessage: "", tag: "", tagEdited: false, commitMode: "commit-tag" },
284
283
  // Which inline panel/dropdown is open. Only one can be open at a time, so a
285
284
  // single field beats juggling three sibling booleans with mutual-exclusion code.
286
285
  // Values: null | "action" | "push" | "tag-head".
@@ -1531,8 +1530,14 @@
1531
1530
  if (_macAppVersion) {
1532
1531
  checkDmgAutoUpdate();
1533
1532
  }
1534
- if (state.claudeHistoryExpanded && !state.claudeHistoryLoaded) {
1535
- ensureClaudeHistoryLoaded();
1533
+ // Warm up history in the background a beat after first paint so
1534
+ // the inline 历史会话 count is real (not "···") and recent CLI
1535
+ // sessions merge into the list without a manual expand. Deferred
1536
+ // to avoid competing with the initial session/output load.
1537
+ if (!state.claudeHistoryLoaded) {
1538
+ setTimeout(function() {
1539
+ if (!state.claudeHistoryLoaded) ensureClaudeHistoryLoaded();
1540
+ }, 600);
1536
1541
  }
1537
1542
  });
1538
1543
  })
@@ -1785,7 +1790,9 @@
1785
1790
  // isAnchored = 边栏占据布局空间(推开主内容)。桌面 pin 或 任意端窄条都算 anchored。
1786
1791
  var isMobile = isMobileLayout();
1787
1792
  var isCollapsed = !!state.sidebarPinned && !!state.sidebarCollapsed;
1788
- var isAnchored = isCollapsed || (!!state.sidebarPinned && !isMobile);
1793
+ // 桌面端任何「可见」的侧栏都停靠(推开内容),绝不悬浮遮挡——避免主区被压到
1794
+ // 侧栏下面。pinned 只表示「锁定常驻」,open 则是临时可见,两者都算停靠。
1795
+ var isAnchored = isCollapsed || (!isMobile && (!!state.sidebarPinned || !!state.sessionsDrawerOpen));
1789
1796
  var collapsedCls = isCollapsed ? ' sidebar-collapsed' : '';
1790
1797
  var sidebarCollapsedCls = isCollapsed ? ' collapsed' : '';
1791
1798
  return '<div class="app-container">' +
@@ -1814,7 +1821,10 @@
1814
1821
  '</button>' +
1815
1822
  '</div>' +
1816
1823
  '</div>' +
1817
- '<button id="sidebar-collapse-btn" class="btn btn-ghost btn-sm sidebar-collapse-toggle' + (isCollapsed ? ' collapsed' : '') + '" type="button" title="' + (isCollapsed ? '展开侧栏' : '收起为窄条') + '" aria-label="' + (isCollapsed ? '展开侧栏' : '收起为窄条') + '">' +
1824
+ '<button id="sidebar-pin-btn" class="btn btn-ghost btn-sm sidebar-pin-toggle' + (state.sidebarPinned ? ' pinned' : '') + '" type="button" title="' + (state.sidebarPinned ? '已固定常驻(点击解除锁定)' : '固定侧栏常驻') + '" aria-label="' + (state.sidebarPinned ? '解除固定常驻' : '固定侧栏常驻') + '" aria-pressed="' + (state.sidebarPinned ? 'true' : 'false') + '">' +
1825
+ '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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>' +
1826
+ '</button>' +
1827
+ '<button id="sidebar-collapse-btn" class="btn btn-ghost btn-sm sidebar-collapse-toggle' + (isCollapsed ? ' collapsed' : '') + '" type="button" title="' + (isCollapsed ? '展开为全尺寸' : '收起为窄条') + '" aria-label="' + (isCollapsed ? '展开为全尺寸' : '收起为窄条') + '">' +
1818
1828
  (isCollapsed
1819
1829
  ? '<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="10 6 16 12 10 18"/><line x1="20" y1="5" x2="20" y2="19"/></svg>'
1820
1830
  : '<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>') +
@@ -1827,7 +1837,6 @@
1827
1837
  '<div class="sessions-list" id="sessions-list">' + renderSessionsListContent() + '</div>' +
1828
1838
  '</div>' +
1829
1839
  '</div>' +
1830
- '<div id="sidebar-history-region" class="sidebar-history-region">' + renderClaudeHistoryRegion() + '</div>' +
1831
1840
  '<div class="sidebar-footer">' +
1832
1841
  '<button id="drawer-new-session-button" class="btn btn-primary btn-block"><span>+</span> 新会话</button>' +
1833
1842
  '<div class="sidebar-footer-actions">' +
@@ -2057,13 +2066,14 @@
2057
2066
  '</div>' +
2058
2067
  '</div>' +
2059
2068
  renderExpandedShortcutsRow() +
2060
- // Session info bar at bottom — 仅保留信息类徽章(Claude session id / exit code)。
2069
+ // Session info bar at bottom — 仅保留信息类徽章(历史会话 id / exit code)。
2061
2070
  // 自动批准已从这里移到主 pill 行(renderAutoApproveChip)。
2062
2071
  (selectedSession
2063
2072
  ? (function() {
2064
2073
  var bits = "";
2065
- if (selectedSession.provider === "claude" && selectedSession.claudeSessionId) {
2066
- bits += '<span id="claude-session-id-badge" class="claude-session-id-badge" data-claude-id="' + escapeHtml(selectedSession.claudeSessionId) + '" title="点击复制 Claude 会话 ID">' + iconSvg("cloud", { size: 11, strokeWidth: 1.7, cls: "claude-session-id-icon" }) + '<span class="claude-session-id-text">' + escapeHtml(selectedSession.claudeSessionId.slice(0, 8)) + '</span></span>';
2074
+ if ((selectedSession.provider === "claude" || selectedSession.provider === "codex") && selectedSession.claudeSessionId) {
2075
+ var historyIdTitle = selectedSession.provider === "codex" ? "点击复制 Codex thread ID" : "点击复制 Claude 会话 ID";
2076
+ bits += '<span id="claude-session-id-badge" class="claude-session-id-badge" data-claude-id="' + escapeHtml(selectedSession.claudeSessionId) + '" title="' + historyIdTitle + '">' + iconSvg("cloud", { size: 11, strokeWidth: 1.7, cls: "claude-session-id-icon" }) + '<span class="claude-session-id-text">' + escapeHtml(selectedSession.claudeSessionId.slice(0, 8)) + '</span></span>';
2067
2077
  }
2068
2078
  // 非结构化会话:进程退出后展示退出码(哪怕 0,告诉用户已正常结束)。
2069
2079
  // 结构化会话:只在退出码非 0(即真有失败)时展示,避免成功的多轮对话也挂个 "退出码=0" 误导。
@@ -2162,7 +2172,8 @@
2162
2172
 
2163
2173
  var infoItems = "";
2164
2174
  if (hasClaudeId) {
2165
- infoItems += '<button class="topbar-more-item" data-action="copy-claude-session-id" type="button" role="menuitem">' + cloudIconSvg + '<span>复制 Claude 会话 ID</span></button>';
2175
+ var historyIdLabel = session.provider === "codex" ? "复制 Codex thread ID" : "复制 Claude 会话 ID";
2176
+ infoItems += '<button class="topbar-more-item" data-action="copy-claude-session-id" type="button" role="menuitem">' + cloudIconSvg + '<span>' + historyIdLabel + '</span></button>';
2166
2177
  }
2167
2178
  if (hasCwd) {
2168
2179
  infoItems += '<button class="topbar-more-item" data-action="copy-cwd" type="button" role="menuitem">' + folderIconSvg + '<span>复制工作目录</span></button>';
@@ -2232,16 +2243,17 @@
2232
2243
  var quickCommitEscHandler = null;
2233
2244
  var quickCommitDocClickHandler = null;
2234
2245
 
2235
- // Restore the user's last "primary action" choice so the split button feels sticky.
2236
- function readSavedPrimaryAction() {
2246
+ // Restore the user's last commit-mode choice so the split button feels sticky.
2247
+ // Modes: "commit-tag" (commit + version tag) | "commit" (commit only).
2248
+ function readSavedCommitMode() {
2237
2249
  try {
2238
- var v = localStorage.getItem("wand.quickCommit.primaryAction");
2239
- if (v === "commit" || v === "commit-push") return v;
2250
+ var v = localStorage.getItem("wand.quickCommit.commitMode");
2251
+ if (v === "commit" || v === "commit-tag") return v;
2240
2252
  } catch (e) { /* localStorage may be blocked */ }
2241
- return "commit";
2253
+ return "commit-tag"; // default: commit + tag
2242
2254
  }
2243
- function savePrimaryAction(value) {
2244
- try { localStorage.setItem("wand.quickCommit.primaryAction", value); } catch (e) { /* no-op */ }
2255
+ function saveCommitMode(value) {
2256
+ try { localStorage.setItem("wand.quickCommit.commitMode", value); } catch (e) { /* no-op */ }
2245
2257
  }
2246
2258
 
2247
2259
  function openQuickCommitModal() {
@@ -2251,9 +2263,11 @@
2251
2263
  state.quickCommitError = "";
2252
2264
  state.quickCommitForm = {
2253
2265
  customMessage: "",
2254
- makeTag: false,
2255
2266
  tag: "",
2256
- primaryAction: readSavedPrimaryAction(),
2267
+ // Whether the user has manually edited the tag (so we stop auto-overwriting it).
2268
+ tagEdited: false,
2269
+ // "commit-tag" → commit + version tag; "commit" → commit only.
2270
+ commitMode: readSavedCommitMode(),
2257
2271
  };
2258
2272
  state.quickCommitOpenMenu = null;
2259
2273
  state.quickCommitTagHeadForm = { tag: "", push: false };
@@ -2303,6 +2317,12 @@
2303
2317
  document.addEventListener("click", quickCommitDocClickHandler, true);
2304
2318
  loadGitStatus(state.selectedId, { force: true }).then(function() {
2305
2319
  if (!state.quickCommitOpen) return;
2320
+ // Seed the tag field with the locally-derived suggestion so a tag is
2321
+ // always shown by default (greyed until the toggle is turned on).
2322
+ var st = state.gitStatus || {};
2323
+ if (!state.quickCommitForm.tagEdited && st.suggestedTag) {
2324
+ state.quickCommitForm.tag = st.suggestedTag;
2325
+ }
2306
2326
  rerenderQuickCommitModal();
2307
2327
  });
2308
2328
  }
@@ -2354,23 +2374,31 @@
2354
2374
  var aiBtn = document.getElementById("quick-commit-ai-btn");
2355
2375
  if (aiBtn) aiBtn.addEventListener("click", generateCommitMessageAI);
2356
2376
  var msgEl = document.getElementById("quick-commit-message");
2357
- if (msgEl) msgEl.addEventListener("input", function() {
2358
- state.quickCommitForm.customMessage = msgEl.value;
2359
- });
2360
- var tagCb = document.getElementById("quick-commit-make-tag");
2361
- if (tagCb) tagCb.addEventListener("change", function() {
2362
- state.quickCommitForm.makeTag = tagCb.checked;
2363
- var row = document.getElementById("quick-commit-tag-row");
2364
- if (row) row.classList.toggle("hidden", !tagCb.checked);
2365
- if (tagCb.checked) {
2366
- var input = document.getElementById("quick-commit-tag");
2367
- if (input) setTimeout(function() { input.focus(); }, 0);
2368
- }
2369
- });
2377
+ if (msgEl) {
2378
+ msgEl.addEventListener("input", function() {
2379
+ state.quickCommitForm.customMessage = msgEl.value;
2380
+ });
2381
+ // Cmd/Ctrl+Enter submits, matching the common editor shortcut.
2382
+ msgEl.addEventListener("keydown", function(e) {
2383
+ if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
2384
+ e.preventDefault();
2385
+ submitQuickCommit();
2386
+ }
2387
+ });
2388
+ }
2370
2389
  var tagInput = document.getElementById("quick-commit-tag");
2371
- if (tagInput) tagInput.addEventListener("input", function() {
2372
- state.quickCommitForm.tag = tagInput.value;
2373
- });
2390
+ if (tagInput) {
2391
+ tagInput.addEventListener("input", function() {
2392
+ state.quickCommitForm.tag = tagInput.value;
2393
+ state.quickCommitForm.tagEdited = true;
2394
+ });
2395
+ tagInput.addEventListener("keydown", function(e) {
2396
+ if (e.key === "Enter") {
2397
+ e.preventDefault();
2398
+ submitQuickCommit();
2399
+ }
2400
+ });
2401
+ }
2374
2402
 
2375
2403
  var actionCaret = document.getElementById("quick-commit-action-caret");
2376
2404
  if (actionCaret) actionCaret.addEventListener("click", function(e) {
@@ -2380,17 +2408,24 @@
2380
2408
  });
2381
2409
  var actionMenu = document.getElementById("quick-commit-action-menu");
2382
2410
  if (actionMenu) {
2383
- var actionItems = actionMenu.querySelectorAll("[data-qc-action]");
2411
+ var actionItems = actionMenu.querySelectorAll("[data-qc-commit-mode]");
2384
2412
  for (var i = 0; i < actionItems.length; i++) {
2385
2413
  (function(btn) {
2386
2414
  btn.addEventListener("click", function() {
2387
- var value = btn.getAttribute("data-qc-action");
2388
- if (value === "commit" || value === "commit-push") {
2389
- state.quickCommitForm.primaryAction = value;
2390
- savePrimaryAction(value);
2415
+ // Keep what the user typed before the re-render.
2416
+ var liveTag = document.getElementById("quick-commit-tag");
2417
+ if (liveTag && !liveTag.disabled) state.quickCommitForm.tag = liveTag.value;
2418
+ var value = btn.getAttribute("data-qc-commit-mode");
2419
+ if (value === "commit" || value === "commit-tag") {
2420
+ state.quickCommitForm.commitMode = value;
2421
+ saveCommitMode(value);
2391
2422
  }
2392
2423
  state.quickCommitOpenMenu = null;
2393
2424
  rerenderQuickCommitModal();
2425
+ if (value === "commit-tag") {
2426
+ var inp = document.getElementById("quick-commit-tag");
2427
+ if (inp) setTimeout(function() { inp.focus(); var v = inp.value; inp.value = ""; inp.value = v; }, 0);
2428
+ }
2394
2429
  });
2395
2430
  })(actionItems[i]);
2396
2431
  }
@@ -2400,7 +2435,14 @@
2400
2435
  if (tagHeadToggle) tagHeadToggle.addEventListener("click", function() {
2401
2436
  var willOpen = state.quickCommitOpenMenu !== "tag-head";
2402
2437
  state.quickCommitOpenMenu = willOpen ? "tag-head" : null;
2403
- if (willOpen) state.quickCommitTagHeadError = "";
2438
+ if (willOpen) {
2439
+ state.quickCommitTagHeadError = "";
2440
+ // Pre-fill with the locally-derived suggestion (unless already set).
2441
+ var sug = (state.gitStatus || {}).suggestedTag;
2442
+ if (sug && !(state.quickCommitTagHeadForm.tag || "").trim()) {
2443
+ state.quickCommitTagHeadForm.tag = sug;
2444
+ }
2445
+ }
2404
2446
  rerenderQuickCommitModal();
2405
2447
  if (willOpen) {
2406
2448
  var inp = document.getElementById("quick-commit-tag-head-input");
@@ -2487,14 +2529,19 @@
2487
2529
  var data = result.data || {};
2488
2530
  var aiMessage = typeof data.message === "string" ? data.message : "";
2489
2531
  var aiTag = typeof data.suggestedTag === "string" ? data.suggestedTag.trim() : "";
2490
- // Only fill fields that are currently empty; never overwrite user input.
2532
+ // "AI 生成" recommends BOTH a commit message and a version tag.
2533
+ // Fill the message only when empty (never clobber what the user typed).
2491
2534
  var currentMessage = (state.quickCommitForm.customMessage || "").trim();
2492
2535
  if (!currentMessage && aiMessage) {
2493
2536
  state.quickCommitForm.customMessage = aiMessage;
2494
2537
  }
2495
- var currentTag = (state.quickCommitForm.tag || "").trim();
2496
- if (state.quickCommitForm.makeTag && !currentTag && aiTag) {
2497
- state.quickCommitForm.tag = aiTag;
2538
+ // Adopt the AI tag (smarter than the local patch-bump default) unless the
2539
+ // user has manually edited it, and switch to "commit + tag" so the
2540
+ // recommendation is actually applied on commit.
2541
+ if (aiTag) {
2542
+ if (!state.quickCommitForm.tagEdited) state.quickCommitForm.tag = aiTag;
2543
+ state.quickCommitForm.commitMode = "commit-tag";
2544
+ saveCommitMode("commit-tag");
2498
2545
  }
2499
2546
  })
2500
2547
  .catch(function(error) {
@@ -2513,21 +2560,22 @@
2513
2560
  var tagEl = document.getElementById("quick-commit-tag");
2514
2561
  if (tagEl) state.quickCommitForm.tag = tagEl.value;
2515
2562
  var form = state.quickCommitForm || {};
2516
- var userTag = form.makeTag ? (form.tag || "").trim() : "";
2563
+ var withTag = form.commitMode === "commit-tag";
2564
+ var userTag = withTag ? (form.tag || "").trim() : "";
2517
2565
  var message = (form.customMessage || "").trim();
2518
2566
  if (!message) {
2519
- state.quickCommitError = "请填写 commit message,或点击 AI 生成。";
2567
+ state.quickCommitError = "请填写提交信息,或点击「AI 生成」。";
2520
2568
  rerenderQuickCommitModal();
2521
2569
  return;
2522
2570
  }
2523
- var willPush = form.primaryAction === "commit-push";
2524
- // 开了 tag 开关但没填 → 由后端在提交时调 AI 生成
2571
+ // Commit no longer pushes — pushing is a separate, standalone action.
2572
+ // 选了「提交并打 Tag」但 tag 留空 → 由后端在提交时调 AI 生成。
2525
2573
  var payload = {
2526
2574
  autoMessage: false,
2527
2575
  customMessage: message,
2528
2576
  tag: userTag,
2529
- autoTag: !!(form.makeTag && !userTag),
2530
- push: willPush
2577
+ autoTag: !!(withTag && !userTag),
2578
+ push: false
2531
2579
  };
2532
2580
  state.quickCommitSubmitting = true;
2533
2581
  state.quickCommitError = "";
@@ -2550,14 +2598,8 @@
2550
2598
  var subPrefix = subCommits.length > 0
2551
2599
  ? "已先提交 " + subCommits.length + " 个 submodule(" + subCommits.map(function(c) { return c.path; }).join("、") + "),"
2552
2600
  : "";
2553
- var base = subPrefix + "已提交" + (hash ? " " + hash : "") + (tagName ? ",已打 tag " + tagName : "");
2554
- if (willPush && data.pushError) {
2555
- var msg = base + ";push 失败:" + data.pushError;
2556
- if (typeof showToast === "function") showToast(msg, "error");
2557
- } else {
2558
- var okMsg = base + (data.pushed ? ",已 push" : "");
2559
- if (typeof showToast === "function") showToast(okMsg, "success");
2560
- }
2601
+ var base = subPrefix + "已提交" + (hash ? " " + hash : "") + (tagName ? ",已打 Tag " + tagName : "");
2602
+ if (typeof showToast === "function") showToast(base + "。可在「同步」区推送到远端。", "success");
2561
2603
  closeQuickCommitModal();
2562
2604
  if (state.selectedId) loadGitStatus(state.selectedId, { force: true });
2563
2605
  })
@@ -2697,16 +2739,37 @@
2697
2739
  });
2698
2740
  }
2699
2741
 
2742
+ // Map a porcelain status (XY two-char, e.g. " M", "A.", "??") to a single
2743
+ // VS-Code-style letter badge: pick the first meaningful char, color by kind.
2744
+ function qcStatusBadge(status) {
2745
+ var raw = (status || "").trim();
2746
+ if (raw === "??") return { letter: "U", cls: "untracked", title: "未跟踪" };
2747
+ if (raw === "!!") return { letter: "I", cls: "ignored", title: "已忽略" };
2748
+ var c = "";
2749
+ for (var i = 0; i < status.length; i++) {
2750
+ if (status[i] && status[i] !== "." && status[i] !== " ") { c = status[i]; break; }
2751
+ }
2752
+ c = (c || raw[0] || "?").toUpperCase();
2753
+ var map = {
2754
+ A: { cls: "add", title: "新增" },
2755
+ M: { cls: "mod", title: "修改" },
2756
+ D: { cls: "del", title: "删除" },
2757
+ R: { cls: "ren", title: "重命名" },
2758
+ C: { cls: "ren", title: "复制" },
2759
+ T: { cls: "mod", title: "类型变更" },
2760
+ U: { cls: "del", title: "冲突" }
2761
+ };
2762
+ var hit = map[c] || { cls: "other", title: "已更改" };
2763
+ return { letter: c, cls: hit.cls, title: hit.title };
2764
+ }
2765
+
2700
2766
  function renderQuickCommitFileRows(files) {
2701
2767
  var rows = files.map(function(item) {
2702
- var status = (item.status || " ").substring(0, 2);
2703
- var flag = status.trim() || "?";
2704
- var cls = "qc-flag";
2705
- if (flag === "A" || status[0] === "A") cls += " qc-flag-add";
2706
- else if (flag === "D" || status[0] === "D") cls += " qc-flag-del";
2707
- else if (flag === "M" || status[0] === "M") cls += " qc-flag-mod";
2708
- else if (flag === "??" || status === "??") cls += " qc-flag-untracked";
2709
- else if (flag === "R") cls += " qc-flag-ren";
2768
+ var badge = qcStatusBadge(item.status || "");
2769
+ var fullPath = item.path || "";
2770
+ var slash = fullPath.lastIndexOf("/");
2771
+ var dir = slash >= 0 ? fullPath.slice(0, slash + 1) : "";
2772
+ var base = slash >= 0 ? fullPath.slice(slash + 1) : fullPath;
2710
2773
  var subBadge = "";
2711
2774
  if (item.isSubmodule) {
2712
2775
  var st = item.submoduleState || {};
@@ -2717,7 +2780,13 @@
2717
2780
  var label = parts.length ? "submodule · " + parts.join(" / ") : "submodule";
2718
2781
  subBadge = '<span class="qc-submodule-badge">' + escapeHtml(label) + '</span>';
2719
2782
  }
2720
- return '<div class="qc-file-row"><span class="' + cls + '">' + escapeHtml(status) + '</span><span class="qc-file-path">' + escapeHtml(item.path || "") + '</span>' + subBadge + '</div>';
2783
+ return '<div class="qc-file-row" title="' + escapeHtml(fullPath) + '">' +
2784
+ '<span class="qc-file-badge qc-badge-' + badge.cls + '" title="' + escapeHtml(badge.title) + '">' + escapeHtml(badge.letter) + '</span>' +
2785
+ '<span class="qc-file-path">' +
2786
+ (dir ? '<span class="qc-file-dir">' + escapeHtml(dir) + '</span>' : '') +
2787
+ '<span class="qc-file-name">' + escapeHtml(base) + '</span>' +
2788
+ '</span>' + subBadge +
2789
+ '</div>';
2721
2790
  }).join("");
2722
2791
  return rows || '<div class="qc-empty">没有可提交的改动。</div>';
2723
2792
  }
@@ -2726,23 +2795,25 @@
2726
2795
  return state.quickCommitSubmitting || state.quickCommitTagHeadSubmitting || state.quickCommitPushing;
2727
2796
  }
2728
2797
 
2729
- function renderQuickCommitPrimary(hasChanges) {
2798
+ function renderQuickCommitCommitButton(hasChanges) {
2730
2799
  var f = state.quickCommitForm;
2800
+ var withTag = f.commitMode === "commit-tag";
2731
2801
  var label;
2732
2802
  if (state.quickCommitSubmitting) label = "提交中…";
2733
- else if (f.primaryAction === "commit-push") label = "提交并 push";
2734
- else label = "仅提交";
2803
+ else label = withTag ? "提交并打 Tag" : "提交";
2735
2804
  var disabled = !hasChanges || isQuickCommitOpInFlight();
2736
2805
  var menuOpen = state.quickCommitOpenMenu === "action";
2737
2806
  var caretActive = menuOpen ? " is-active" : "";
2738
2807
  var menuItems = [
2739
- { value: "commit", label: "仅提交", desc: " commit,不 push" },
2740
- { value: "commit-push", label: "提交并 push", desc: "commit 后立即 push 到远端" }
2808
+ { value: "commit-tag", label: "提交并打 Tag", desc: "创建 commit,并为它打一个版本 Tag" },
2809
+ { value: "commit", label: "仅提交", desc: "只创建 commit,不打 Tag" }
2741
2810
  ];
2742
2811
  var menuHtml = menuItems.map(function(item) {
2743
- var sel = f.primaryAction === item.value ? " is-selected" : "";
2744
- return '<button type="button" class="qc-dropdown-item' + sel + '" data-qc-action="' + item.value + '">' +
2745
- '<span class="qc-dropdown-item-title">' + escapeHtml(item.label) + '</span>' +
2812
+ var sel = f.commitMode === item.value ? " is-selected" : "";
2813
+ return '<button type="button" class="qc-dropdown-item' + sel + '" data-qc-commit-mode="' + item.value + '" role="menuitemradio" aria-checked="' + (f.commitMode === item.value ? 'true' : 'false') + '">' +
2814
+ '<span class="qc-dropdown-item-main"><span class="qc-dropdown-check" aria-hidden="true">' +
2815
+ (f.commitMode === item.value ? '<svg viewBox="0 0 16 16" width="13" height="13"><path d="M13 4.5l-6 6L3 7" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>' : '') +
2816
+ '</span><span class="qc-dropdown-item-title">' + escapeHtml(item.label) + '</span></span>' +
2746
2817
  '<span class="qc-dropdown-item-desc">' + escapeHtml(item.desc) + '</span>' +
2747
2818
  '</button>';
2748
2819
  }).join("");
@@ -2750,7 +2821,7 @@
2750
2821
  '<button id="quick-commit-submit-btn" class="btn btn-primary qc-split-main" type="button"' + (disabled ? ' disabled' : '') + '>' +
2751
2822
  escapeHtml(label) +
2752
2823
  '</button>' +
2753
- '<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="更多提交方式">' +
2824
+ '<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="切换提交方式">' +
2754
2825
  '<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>' +
2755
2826
  '</button>' +
2756
2827
  (menuOpen ?
@@ -2781,18 +2852,19 @@
2781
2852
  var submitting = state.quickCommitTagHeadSubmitting;
2782
2853
  var generating = state.quickCommitTagHeadGenerating;
2783
2854
  return '<div class="qc-tag-head-panel">' +
2855
+ '<div class="qc-tag-head-hint">为当前最新提交(HEAD)打 Tag,不创建新提交。</div>' +
2784
2856
  '<div class="qc-tag-head-row">' +
2785
- '<input type="text" id="quick-commit-tag-head-input" class="field-input" placeholder="输入 tag 名(例如 v1.2.0),或点 AI 建议" value="' + escapeHtml(thf.tag || "") + '"' + (submitting ? ' disabled' : '') + '>' +
2857
+ '<input type="text" id="quick-commit-tag-head-input" class="field-input" placeholder="版本号,如 v1.2.0" value="' + escapeHtml(thf.tag || "") + '"' + (submitting ? ' disabled' : '') + '>' +
2786
2858
  '<button type="button" id="quick-commit-tag-head-ai" class="btn btn-ghost btn-sm"' + (generating || submitting ? ' disabled' : '') + '>' + (generating ? '建议中…' : 'AI 建议') + '</button>' +
2787
2859
  '</div>' +
2788
2860
  '<label class="qc-tag-head-push">' +
2789
2861
  '<input type="checkbox" id="quick-commit-tag-head-push"' + (thf.push ? ' checked' : '') + (submitting ? ' disabled' : '') + '>' +
2790
- '<span>打完 tag 立即推送这个 tag</span>' +
2862
+ '<span>打完后立即推送这个 Tag 到远端</span>' +
2791
2863
  '</label>' +
2792
2864
  (state.quickCommitTagHeadError ? '<p class="error-message">' + escapeHtml(state.quickCommitTagHeadError) + '</p>' : '') +
2793
2865
  '<div class="qc-tag-head-actions">' +
2794
2866
  '<button type="button" id="quick-commit-tag-head-cancel" class="btn btn-ghost btn-sm"' + (submitting ? ' disabled' : '') + '>收起</button>' +
2795
- '<button type="button" id="quick-commit-tag-head-submit" class="btn btn-secondary btn-sm"' + (submitting ? ' disabled' : '') + '>' + (submitting ? '打 tag 中…' : '为 HEAD tag') + '</button>' +
2867
+ '<button type="button" id="quick-commit-tag-head-submit" class="btn btn-secondary btn-sm"' + (submitting ? ' disabled' : '') + '>' + (submitting ? '打 Tag 中…' : '打 Tag') + '</button>' +
2796
2868
  '</div>' +
2797
2869
  '</div>';
2798
2870
  }
@@ -2835,56 +2907,53 @@
2835
2907
 
2836
2908
  function renderQuickCommitModal() {
2837
2909
  var s = state.gitStatus || {};
2838
- var f = state.quickCommitForm || { customMessage: "", makeTag: false, tag: "", primaryAction: "commit" };
2910
+ var f = state.quickCommitForm || { customMessage: "", tag: "", tagEdited: false, commitMode: "commit-tag" };
2839
2911
  var hasChanges = (s.modifiedCount || 0) > 0;
2840
2912
  var files = Array.isArray(s.files) ? s.files : [];
2841
2913
  var fileRows = renderQuickCommitFileRows(files);
2842
2914
 
2843
- // Subtitle: branch · N 改动 · ↑X ↓Y
2915
+ // Subtitle: branch · N 改动 · ↑X ↓Y (clean repos show a small ✓ badge instead of "0 个改动")
2844
2916
  var subParts = [];
2845
2917
  subParts.push(s.branch || "(no branch)");
2846
- subParts.push((s.modifiedCount || 0) + " 个改动");
2918
+ if (hasChanges) subParts.push((s.modifiedCount || 0) + " 个改动");
2847
2919
  if (typeof s.ahead === "number" && s.ahead > 0) subParts.push("↑" + s.ahead);
2848
2920
  if (typeof s.behind === "number" && s.behind > 0) subParts.push("↓" + s.behind);
2849
2921
 
2850
2922
  // Section 1: changes + commit form (only when there are changes)
2851
2923
  var section1 = "";
2852
2924
  if (hasChanges) {
2925
+ var genBusy = state.quickCommitGenerating;
2926
+ var withTag = f.commitMode === "commit-tag";
2853
2927
  section1 = '<section class="qc-section qc-section--changes">' +
2854
- '<div class="qc-section-head"><span class="qc-section-title">暂存改动</span><span class="qc-section-meta">' + escapeHtml(s.modifiedCount + " 个文件") + '</span></div>' +
2928
+ '<div class="qc-section-head"><span class="qc-section-title">更改</span><span class="qc-section-meta">' + escapeHtml(s.modifiedCount + " 个文件") + '</span></div>' +
2855
2929
  '<div class="qc-files-wrap">' + fileRows + '</div>' +
2856
2930
  '<div class="qc-message-row" id="quick-commit-message-row">' +
2857
- '<div class="qc-message-header"><label class="field-label" for="quick-commit-message">commit message</label>' +
2858
- '<div class="qc-ai-controls">' +
2859
- '<button type="button" id="quick-commit-ai-btn" class="btn btn-ghost btn-sm"' + (state.quickCommitGenerating ? ' disabled' : '') + '>' + (state.quickCommitGenerating ? '生成中…' : 'AI 生成') + '</button>' +
2860
- '<label class="qc-ai-tag-toggle" title="开启后,AI 会一并建议 tag,提交时会为本次 commit tag">' +
2861
- '<span class="qc-ai-tag-label">含 tag</span>' +
2862
- '<span class="qc-switch qc-switch--compact">' +
2863
- '<input type="checkbox" id="quick-commit-make-tag" class="switch-toggle" aria-label="同时为本次 commit 打 tag"' + (f.makeTag ? ' checked' : '') + ((state.quickCommitSubmitting || state.quickCommitGenerating) ? ' disabled' : '') + '>' +
2864
- '<span class="switch-slider"></span>' +
2865
- '</span>' +
2866
- '</label>' +
2867
- '</div>' +
2931
+ '<div class="qc-message-header">' +
2932
+ '<label class="field-label qc-message-label" for="quick-commit-message">提交信息</label>' +
2933
+ '<button type="button" id="quick-commit-ai-btn" class="btn btn-ghost btn-sm qc-ai-btn"' + (genBusy ? ' disabled' : '') + ' title="读取改动,用 AI 生成提交信息与版本 Tag">' +
2934
+ '<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true"><path d="M8 1.5l1.4 3.6L13 6.5 9.4 7.9 8 11.5 6.6 7.9 3 6.5l3.6-1.4L8 1.5zM12.5 10.5l.7 1.8 1.8.7-1.8.7-.7 1.8-.7-1.8-1.8-.7 1.8-.7.7-1.8z" fill="currentColor"/></svg>' +
2935
+ '<span>' + (genBusy ? '生成中…' : 'AI 生成') + '</span>' +
2936
+ '</button>' +
2868
2937
  '</div>' +
2869
- '<textarea id="quick-commit-message" class="field-input" rows="2" placeholder="输入 commit message 或点击 AI 生成"' + (state.quickCommitSubmitting ? ' disabled' : '') + '>' + escapeHtml(f.customMessage || "") + '</textarea>' +
2938
+ '<textarea id="quick-commit-message" class="field-input qc-message-input" rows="3" placeholder="描述这次改动;或点「AI 生成」自动填写" ' + (state.quickCommitSubmitting ? 'disabled' : '') + '>' + escapeHtml(f.customMessage || "") + '</textarea>' +
2870
2939
  '</div>' +
2871
- '<div class="qc-tag-row' + (f.makeTag ? '' : ' hidden') + '" id="quick-commit-tag-row">' +
2872
- '<input type="text" id="quick-commit-tag" class="field-input" placeholder="输入 tag 名称;留空则提交时由 AI 自动生成" value="' + escapeHtml(f.tag || "") + '"' + (state.quickCommitSubmitting ? ' disabled' : '') + '>' +
2940
+ '<div class="qc-tag-field' + (withTag ? '' : ' is-off') + '">' +
2941
+ '<span class="qc-tag-field-label" title="' + (withTag ? '这次提交会打上这个版本 Tag' : '当前为「仅提交」,不会打 Tag') + '">Tag</span>' +
2942
+ '<input type="text" id="quick-commit-tag" class="field-input qc-tag-field-input" placeholder="版本号,如 v1.2.0" value="' + escapeHtml(f.tag || "") + '"' + ((!withTag || state.quickCommitSubmitting) ? ' disabled' : '') + '>' +
2943
+ (withTag ? '' : '<span class="qc-tag-field-note">仅提交</span>') +
2873
2944
  '</div>' +
2874
2945
  (state.quickCommitError ? '<p class="error-message">' + escapeHtml(state.quickCommitError) + '</p>' : '') +
2875
2946
  '<div class="qc-section-actions">' +
2876
2947
  '<button id="quick-commit-cancel-btn" class="btn btn-ghost btn-sm" type="button">取消</button>' +
2877
- renderQuickCommitPrimary(hasChanges) +
2878
- '</div>' +
2879
- '</section>';
2880
- } else {
2881
- section1 = '<section class="qc-section qc-section--empty">' +
2882
- '<div class="qc-empty-state">' +
2883
- '<span class="qc-empty-icon">✓</span>' +
2884
- '<div><div class="qc-empty-title">工作区干净</div><div class="qc-empty-sub">没有暂存改动可提交。仍可在下方为 HEAD 打 tag 或推送。</div></div>' +
2948
+ '<div class="qc-action-group">' +
2949
+ renderQuickCommitCommitButton(hasChanges) +
2950
+ renderQuickCommitPushButton(s) +
2951
+ '</div>' +
2885
2952
  '</div>' +
2886
2953
  '</section>';
2887
2954
  }
2955
+ // When clean, we skip the big "changes" card entirely — a small green
2956
+ // indicator in the header subtitle is enough of a signal (see below).
2888
2957
 
2889
2958
  // Section 2: repo status + secondary actions (always show when there's at least one commit)
2890
2959
  var section2 = "";
@@ -2892,32 +2961,38 @@
2892
2961
  var lc = s.lastCommit || {};
2893
2962
  var headLine = lc.shortHash ? lc.shortHash + " · " + (lc.subject || "") : (s.head ? s.head.substring(0, 7) : "(no commit)");
2894
2963
  var upstreamLine = s.upstream ? escapeHtml(s.branch || "") + " → " + escapeHtml(s.upstream) : escapeHtml(s.branch || "(no branch)") + " · 无 upstream";
2964
+ var tagHeadOpen = state.quickCommitOpenMenu === "tag-head";
2895
2965
  section2 = '<section class="qc-section qc-section--repo">' +
2896
- '<div class="qc-section-head"><span class="qc-section-title">仓库状态</span><span class="qc-section-meta">' + upstreamLine + '</span></div>' +
2966
+ '<div class="qc-section-head"><span class="qc-section-title">仓库 · 同步</span><span class="qc-section-meta">' + upstreamLine + '</span></div>' +
2897
2967
  '<div class="qc-head-card">' +
2898
2968
  '<span class="qc-head-label">HEAD</span>' +
2899
2969
  '<code class="qc-head-text">' + escapeHtml(headLine) + '</code>' +
2900
2970
  '</div>' +
2901
2971
  renderQuickCommitStatusChips(s) +
2902
- (state.quickCommitOpenMenu === "tag-head" ? renderQuickCommitTagHeadPanel() : '') +
2972
+ (tagHeadOpen ? renderQuickCommitTagHeadPanel() : '') +
2903
2973
  '<div class="qc-section-actions qc-section-actions--secondary">' +
2904
- '<button id="quick-commit-tag-head-toggle" class="btn btn-ghost btn-sm" type="button"' + (state.quickCommitPushing ? ' disabled' : '') + '>' +
2905
- '<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>' +
2906
- (state.quickCommitOpenMenu === "tag-head" ? '收起' : ' HEAD tag') +
2974
+ '<button id="quick-commit-tag-head-toggle" class="btn btn-secondary btn-sm qc-tag-head-btn' + (tagHeadOpen ? ' is-open' : '') + '" type="button"' + (state.quickCommitPushing ? ' disabled' : '') + ' title="给当前最新提交(HEAD)打 Tag,不会创建新提交">' +
2975
+ '<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><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>' +
2976
+ '<span>' + (tagHeadOpen ? '收起' : '为当前提交打 Tag') + '</span>' +
2907
2977
  '</button>' +
2908
- renderQuickCommitPushButton(s) +
2978
+ // Push lives in the commit footer when there are changes; show it here otherwise.
2979
+ (hasChanges ? '' : renderQuickCommitPushButton(s)) +
2909
2980
  '</div>' +
2910
2981
  '</section>';
2911
2982
  }
2912
2983
 
2913
2984
  var subtitleHtml = subParts.map(escapeHtml).join(" · ");
2985
+ // Small "clean" badge shown inline in the header subtitle (replaces the old empty-state card).
2986
+ var cleanBadge = (!hasChanges && s.isGit !== false)
2987
+ ? '<span class="qc-clean-badge" title="工作区干净,没有待提交的改动"><svg viewBox="0 0 16 16" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M13 4.5l-6 6L3 7"/></svg>干净</span>'
2988
+ : '';
2914
2989
 
2915
2990
  return '<section id="quick-commit-modal" class="modal-backdrop' + (state.quickCommitOpen ? '' : ' hidden') + '">' +
2916
2991
  '<div class="modal quick-commit-modal" role="dialog" aria-labelledby="quick-commit-title">' +
2917
2992
  '<div class="modal-header">' +
2918
2993
  '<div>' +
2919
2994
  '<h2 id="quick-commit-title" class="modal-title">快捷提交</h2>' +
2920
- '<p class="modal-subtitle">' + subtitleHtml + '</p>' +
2995
+ '<p class="modal-subtitle">' + subtitleHtml + cleanBadge + '</p>' +
2921
2996
  '</div>' +
2922
2997
  '<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>' +
2923
2998
  '</div>' +
@@ -3509,14 +3584,16 @@
3509
3584
  }
3510
3585
 
3511
3586
  function renderSessions() {
3512
- // Claude history is no longer inlined here it lives in its own
3513
- // collapsible region between .sidebar-body and .sidebar-footer, so
3514
- // the scrolling sessions list focuses on recent / archived sessions.
3587
+ // Claude/Codex history is rendered INLINE as the final collapsible
3588
+ // group of this same scrolling list (see renderClaudeHistoryGroup),
3589
+ // styled like 已归档. There is no separate docked region anymore —
3590
+ // that previously stranded an empty void above the footer.
3515
3591
  var archivedSessions = state.sessions.filter(function(session) { return session.archived; });
3516
3592
  var groups = [];
3517
3593
  groups.push(renderSessionManageBar());
3518
3594
 
3519
3595
  var recentEntries = getRecentEntries();
3596
+ var historyGroup = renderClaudeHistoryGroup();
3520
3597
 
3521
3598
  if (recentEntries.length > 0) {
3522
3599
  groups.push(renderRecentGroup(recentEntries));
@@ -3524,9 +3601,12 @@
3524
3601
  if (archivedSessions.length > 0) {
3525
3602
  groups.push(renderArchivedGroup(archivedSessions));
3526
3603
  }
3527
- if (recentEntries.length === 0 && archivedSessions.length === 0) {
3604
+ if (recentEntries.length === 0 && archivedSessions.length === 0 && !historyGroup) {
3528
3605
  return renderSessionManageBar() + '<div class="empty-state"><strong>还没有会话记录</strong><br>点击上方「新对话」开始你的第一次对话。</div>';
3529
3606
  }
3607
+ if (historyGroup) {
3608
+ groups.push(historyGroup);
3609
+ }
3530
3610
  return groups.join("");
3531
3611
  }
3532
3612
 
@@ -3583,19 +3663,21 @@
3583
3663
  var selectAllAction = allSelected ? "clear-selection" : "select-all-visible";
3584
3664
  var selectAllDisabled = selectable === 0 ? ' disabled' : '';
3585
3665
 
3586
- // Linear-style toolbar:
3587
- // [N selected] ───────── [全选] [清空] [delete (danger)] [完成 (primary)]
3666
+ // Flat in-place toolbar (NOT a popped card): the same sub-header row
3667
+ // morphs to [] N 已选 ........ 全选/取消全选 删除. Sticky to the top
3668
+ // of the scroll so the count + delete stay reachable while selecting.
3669
+ var exitBtn = '<button class="session-manage-exit" data-action="toggle-manage-mode" type="button" aria-label="退出管理模式" title="退出管理模式">' +
3670
+ '<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>' +
3671
+ '</button>';
3672
+ var summary = hasAny
3673
+ ? '<span class="session-manage-count">' + totalCount + '</span><span class="session-manage-summary-label">已选择</span>'
3674
+ : '<span class="session-manage-summary-label muted">选择要管理的项目</span>';
3588
3675
  return '<div class="session-manage-bar active">' +
3589
- '<div class="session-manage-summary">' +
3590
- '<span class="session-manage-count">' + totalCount + '</span>' +
3591
- '<span class="session-manage-summary-label">已选择</span>' +
3592
- '</div>' +
3676
+ exitBtn +
3677
+ '<div class="session-manage-summary">' + summary + '</div>' +
3593
3678
  '<div class="session-manage-actions">' +
3594
3679
  '<button class="btn btn-ghost btn-xs" data-action="' + selectAllAction + '" type="button"' + selectAllDisabled + '>' + selectAllLabel + '</button>' +
3595
- '<button class="btn btn-ghost btn-xs" data-action="clear-selection" type="button"' + (hasAny ? '' : ' disabled') + '>清空</button>' +
3596
- '<span class="session-manage-divider"></span>' +
3597
- '<button class="btn btn-danger btn-xs" data-action="delete-selected" type="button"' + (hasAny ? '' : ' disabled') + '>删除</button>' +
3598
- '<button class="btn btn-primary btn-xs" data-action="toggle-manage-mode" type="button">完成</button>' +
3680
+ '<button class="btn btn-danger btn-xs" data-action="delete-selected" type="button"' + (hasAny ? '' : ' disabled') + '>删除' + (hasAny ? ' ' + totalCount : '') + '</button>' +
3599
3681
  '</div>' +
3600
3682
  '</div>';
3601
3683
  }
@@ -3622,8 +3704,9 @@
3622
3704
  }
3623
3705
 
3624
3706
  function renderRecentGroup(entries) {
3625
- var html = '<section class="session-group">' +
3626
- '<div class="session-group-title">最近</div>';
3707
+ // No "最近" group title here — the section intro ("最近的会话记录") above
3708
+ // already labels this group, so a second label would be redundant.
3709
+ var html = '<section class="session-group session-group--recent">';
3627
3710
  html += entries.map(function(e) {
3628
3711
  return e.kind === "session"
3629
3712
  ? renderSessionItem(e.ref, "sessions")
@@ -3642,46 +3725,30 @@
3642
3725
  });
3643
3726
  }
3644
3727
 
3645
- // Render the docked Claude-history region that lives between
3646
- // `.sidebar-body` and `.sidebar-footer`. Collapsed by default only
3647
- // shows a slim header ("历史消息" + count bubble). Expanded reveals the
3648
- // grouped-by-cwd list inside a scroll cap.
3649
- function renderClaudeHistoryRegion() {
3728
+ // Render history as the final INLINE collapsible group of #sessions-list,
3729
+ // styled like the 已归档 group. Returns '' when history is fully loaded
3730
+ // and empty, so a workspace with no older CLI history shows no stray
3731
+ // "历史会话 0" row (and no stranded bar above the footer).
3732
+ function renderClaudeHistoryGroup() {
3650
3733
  var visibleHistory = getClaudeHistoryRegionItems();
3651
- var expanded = !!state.claudeHistoryExpanded;
3652
3734
  var loaded = !!state.claudeHistoryLoaded;
3653
3735
  var codexVisible = getVisibleCodexHistorySessions();
3654
3736
  var codexLoaded = !!state.codexHistoryLoaded;
3737
+ var fullyLoaded = loaded && codexLoaded;
3655
3738
  var count = (loaded ? visibleHistory.length : 0) + (codexLoaded ? codexVisible.length : 0);
3739
+ if (fullyLoaded && count === 0) return '';
3656
3740
 
3657
- var badgeCls = "history-bubble";
3658
- var badgeContent;
3659
- if (!loaded) {
3660
- badgeCls += " loading";
3661
- badgeContent = "···";
3662
- } else if (count === 0) {
3663
- badgeCls += " empty";
3664
- badgeContent = "0";
3665
- } else {
3666
- badgeContent = count > 999 ? "999+" : String(count);
3667
- }
3668
- var badge = '<span class="' + badgeCls + '">' + badgeContent + '</span>';
3669
-
3670
- // Chevron rotates: collapsed → up (▲, suggests "expand upward"),
3671
- // expanded → down (▼, suggests "collapse downward").
3672
- var chevronSvg = '<svg class="sidebar-history-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 15 12 9 18 15"/></svg>';
3673
-
3674
- var headerCls = "sidebar-history-header" + (expanded ? " expanded" : "");
3675
- var header = '<button type="button" class="' + headerCls + '" id="claude-history-toggle" aria-expanded="' + expanded + '" aria-controls="sidebar-history-body" title="' + (expanded ? "收起历史消息" : "展开历史消息") + '">' +
3676
- '<span class="sidebar-history-label">历史消息</span>' +
3677
- '<span class="sidebar-history-right">' + badge + chevronSvg + '</span>' +
3678
- '</button>';
3679
-
3741
+ var expanded = !!state.claudeHistoryExpanded;
3742
+ var chevron = expanded ? "&#9662;" : "&#9656;";
3743
+ var countContent = fullyLoaded ? (count > 999 ? "999+" : String(count)) : "···";
3744
+ var header = '<div class="session-group-title claude-history-toggle session-history-toggle" id="claude-history-toggle" role="button" tabindex="0" aria-expanded="' + expanded + '" title="' + (expanded ? "收起历史会话" : "展开历史会话") + '">' +
3745
+ '<span class="chevron">' + chevron + '</span> 历史会话 ' +
3746
+ '<span class="history-count' + (fullyLoaded ? '' : ' loading') + '">' + countContent + '</span>' +
3747
+ '</div>';
3680
3748
  var body = expanded
3681
- ? '<div class="sidebar-history-body" id="sidebar-history-body">' + renderClaudeHistoryBodyContent(visibleHistory) + renderCodexHistoryBodyContent(codexVisible) + '</div>'
3749
+ ? '<div class="session-history-body">' + renderClaudeHistoryBodyContent(visibleHistory) + renderCodexHistoryBodyContent(codexVisible) + '</div>'
3682
3750
  : '';
3683
-
3684
- return header + body;
3751
+ return '<section class="session-group session-group--history">' + header + body + '</section>';
3685
3752
  }
3686
3753
 
3687
3754
  function renderClaudeHistoryBodyContent(visibleHistory) {
@@ -3689,7 +3756,9 @@
3689
3756
  return '<div class="claude-history-loading">扫描历史会话中…</div>';
3690
3757
  }
3691
3758
  if (visibleHistory.length === 0) {
3692
- return '<div class="claude-history-loading">没有更早的 Claude 历史会话</div>';
3759
+ // Group is only rendered when there is content somewhere (Claude or
3760
+ // Codex); a Claude-empty/Codex-present case shows just the Codex list.
3761
+ return '';
3693
3762
  }
3694
3763
  var groups = {};
3695
3764
  var groupOrder = [];
@@ -3715,14 +3784,6 @@
3715
3784
  return toolbar + '<div class="sidebar-history-scroll">' + listHtml + '</div>';
3716
3785
  }
3717
3786
 
3718
- // Re-render only the docked history region in place. Called by
3719
- // updateSessionsList() so existing callers (load complete, delete, etc.)
3720
- // keep working without changes.
3721
- function updateClaudeHistoryRegion() {
3722
- var region = document.getElementById("sidebar-history-region");
3723
- if (region) region.innerHTML = renderClaudeHistoryRegion();
3724
- }
3725
-
3726
3787
  function getVisibleClaudeHistorySessions() {
3727
3788
  var managedIds = new Set();
3728
3789
  state.sessions.forEach(function(s) {
@@ -5536,9 +5597,10 @@
5536
5597
  var resumeButton = "";
5537
5598
  var checkbox = renderManageCheckbox("sessions", session.id, "选择会话 " + session.command);
5538
5599
 
5539
- if (session.provider === "claude" && session.claudeSessionId) {
5600
+ if ((session.provider === "claude" || session.provider === "codex") && session.claudeSessionId) {
5540
5601
  if (session.status !== "running" && !state.sessionsManageMode && !isStructuredSession(session)) {
5541
- resumeButton = '<button class="session-action-btn" data-action="resume" data-session-id="' + session.id + '" type="button" aria-label="恢复会话" title="恢复 Claude 会话"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 4v6h6"/><path d="M3.51 15a9 9 0 105.64-11.36L3 10"/></svg></button>';
5602
+ var resumeTitle = session.provider === "codex" ? "恢复 Codex 会话" : "恢复 Claude 会话";
5603
+ resumeButton = '<button class="session-action-btn" data-action="resume" data-session-id="' + session.id + '" type="button" aria-label="' + resumeTitle + '" title="' + resumeTitle + '"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 4v6h6"/><path d="M3.51 15a9 9 0 105.64-11.36L3 10"/></svg></button>';
5542
5604
  }
5543
5605
  }
5544
5606
 
@@ -5557,7 +5619,7 @@
5557
5619
  // Title: summary or command
5558
5620
  var titleHtml = session.summary
5559
5621
  ? '<div class="session-title">' + escapeHtml(session.summary) + '</div>'
5560
- : '<div class="session-command">' + escapeHtml(session.resumedFromSessionId ? session.command.replace(/\s+--resume\s+\S+/, '') : session.command) + '</div>';
5622
+ : '<div class="session-command">' + escapeHtml(session.resumedFromSessionId ? session.command.replace(/\s+--resume\s+\S+/, '').replace(/\s+resume\s+[0-9a-f-]+/, '') : session.command) + '</div>';
5561
5623
 
5562
5624
  // Activity description for running sessions
5563
5625
  var activityDesc = getSessionActivityDesc(session);
@@ -6121,15 +6183,9 @@
6121
6183
  sessionsList.addEventListener("mouseout", handleCollapsedTileLeave);
6122
6184
  initSwipeToDelete(sessionsList);
6123
6185
  }
6124
- // The docked history region lives outside #sessions-list now, but it
6125
- // still wants the same delegated handlers (toggle button, directory
6126
- // expand/collapse, history-item clicks, clear-all, etc.). Reuse the
6127
- // same callbacks so behavior stays identical.
6128
- var historyRegion = document.getElementById("sidebar-history-region");
6129
- if (historyRegion) {
6130
- historyRegion.addEventListener("click", handleSessionItemClick);
6131
- historyRegion.addEventListener("keydown", handleSessionItemKeydown);
6132
- }
6186
+ // History now renders inline as the final group inside #sessions-list,
6187
+ // so the delegated handlers above already cover its toggle / directory
6188
+ // expand-collapse / item clicks / clear-all no separate region wiring.
6133
6189
  window.addEventListener("scroll", hideCollapsedTileBubble, true);
6134
6190
  window.addEventListener("resize", hideCollapsedTileBubble);
6135
6191
 
@@ -6192,6 +6248,8 @@
6192
6248
  if (closeDrawerBtn) closeDrawerBtn.addEventListener("click", closeSessionsDrawer);
6193
6249
  var collapseBtn = document.getElementById("sidebar-collapse-btn");
6194
6250
  if (collapseBtn) collapseBtn.addEventListener("click", toggleSidebarCollapsed);
6251
+ var pinBtn = document.getElementById("sidebar-pin-btn");
6252
+ if (pinBtn) pinBtn.addEventListener("click", toggleSidebarPin);
6195
6253
  var sidebarMoreBtn = document.getElementById("sidebar-more-btn");
6196
6254
  var sidebarOverflow = document.getElementById("sidebar-overflow-menu");
6197
6255
  if (sidebarMoreBtn && sidebarOverflow) {
@@ -6673,7 +6731,7 @@
6673
6731
  topbarMoreBtn.setAttribute("aria-expanded", "false");
6674
6732
  switch (action) {
6675
6733
  case "copy-claude-session-id":
6676
- copySelectedSessionField("claudeSessionId", "Claude 会话 ID 已复制");
6734
+ copySelectedSessionField("claudeSessionId", getSelectedSession() && getSelectedSession().provider === "codex" ? "Codex thread ID 已复制" : "Claude 会话 ID 已复制");
6677
6735
  break;
6678
6736
  case "copy-cwd":
6679
6737
  copySelectedSessionField("cwd", "工作目录已复制");
@@ -8583,7 +8641,7 @@
8583
8641
  state.sessions = [];
8584
8642
  state.claudeHistory = [];
8585
8643
  state.claudeHistoryLoaded = false;
8586
- state.claudeHistoryExpanded = true;
8644
+ state.claudeHistoryExpanded = false;
8587
8645
  state.claudeHistoryExpandedDirs = {};
8588
8646
  state.sessionsDrawerOpen = false;
8589
8647
  render();
@@ -9520,10 +9578,8 @@
9520
9578
  var countEl = document.getElementById("session-count");
9521
9579
  if (listEl) listEl.innerHTML = renderSessionsListContent();
9522
9580
  if (countEl) countEl.textContent = String(state.sessions.length);
9523
- // The docked history region lives outside #sessions-list refresh it
9524
- // too so callers that mutate state.claudeHistory (load complete,
9525
- // delete, clear) don't need to know about it.
9526
- updateClaudeHistoryRegion();
9581
+ // History renders inline inside #sessions-list now, so the line above
9582
+ // already refreshed it no separate docked region to update.
9527
9583
  if (typeof hideCollapsedTileBubble === "function") hideCollapsedTileBubble();
9528
9584
  updateShellChrome();
9529
9585
  // Re-render cross-session queue (container may have been destroyed by DOM rebuild)
@@ -9737,7 +9793,7 @@
9737
9793
  // 与 renderAppShell 保持一致:手机端只允许窄条形态 anchored。
9738
9794
  var isMobile = isMobileLayout();
9739
9795
  var isCollapsed = !!state.sidebarPinned && !!state.sidebarCollapsed;
9740
- var isAnchored = isCollapsed || (!!state.sidebarPinned && !isMobile);
9796
+ var isAnchored = isCollapsed || (!isMobile && (!!state.sidebarPinned || !!state.sessionsDrawerOpen));
9741
9797
  if (drawer) {
9742
9798
  drawer.classList.toggle("pinned", isAnchored);
9743
9799
  drawer.classList.toggle("collapsed", isCollapsed);
@@ -9746,6 +9802,13 @@
9746
9802
  mainLayout.classList.toggle("sidebar-pinned", isAnchored);
9747
9803
  mainLayout.classList.toggle("sidebar-collapsed", isCollapsed);
9748
9804
  }
9805
+ var pinBtn = document.getElementById("sidebar-pin-btn");
9806
+ if (pinBtn) {
9807
+ pinBtn.classList.toggle("pinned", !!state.sidebarPinned);
9808
+ pinBtn.title = state.sidebarPinned ? "已固定常驻(点击解除锁定)" : "固定侧栏常驻";
9809
+ pinBtn.setAttribute("aria-label", state.sidebarPinned ? "解除固定常驻" : "固定侧栏常驻");
9810
+ pinBtn.setAttribute("aria-pressed", state.sidebarPinned ? "true" : "false");
9811
+ }
9749
9812
  }
9750
9813
 
9751
9814
  function updateDrawerState() {
@@ -9950,6 +10013,26 @@
9950
10013
  scheduleTerminalRefitAfterPaddingTransition();
9951
10014
  }
9952
10015
 
10016
+ // 常驻(图钉)开关:把侧栏「留在原地」并排停靠 ⟷ 悬浮抽屉。
10017
+ // - 常驻 ON:停靠并推开内容;配合「收起为窄条」可在全尺寸/窄条间切换。
10018
+ // - 常驻 OFF:变为悬浮抽屉,交互时自动收起(非常驻)。
10019
+ // 手机端不支持常驻(屏幕太窄),保持抽屉行为。
10020
+ // 「图钉」只切换「是否锁定常驻」,绝不收起 / 隐藏侧栏——这是用户两次反馈的核心:
10021
+ // 点图钉不该让侧栏消失,也不该把主区压到侧栏下面。
10022
+ // - 锁定(pinned):常驻停靠,不会被「关闭(X)」一键收走的语义区分开。
10023
+ // - 解除锁定(unpinned):仍然停靠可见(因为 isAnchored 把 open 也算停靠),
10024
+ // 只是变成「可被 X 关闭」的临时态。
10025
+ // 全尺寸 / 窄条由「收起为窄条」按钮控制,与图钉正交。
10026
+ function toggleSidebarPin() {
10027
+ if (isMobileLayout()) return;
10028
+ state.sidebarPinned = !state.sidebarPinned;
10029
+ // 关键:保持侧栏可见停靠,无论锁定与否,点图钉都不让它消失。
10030
+ state.sessionsDrawerOpen = true;
10031
+ try { localStorage.setItem("wand-sidebar-pinned", String(state.sidebarPinned)); } catch (e) {}
10032
+ updateLayoutState();
10033
+ scheduleTerminalRefitAfterPaddingTransition();
10034
+ }
10035
+
9953
10036
  // 收窄按钮的图标/title/状态随 collapsed 切换。抽出来给轻量更新路径用,
9954
10037
  // 避免为了换一个箭头方向就走全量 render()。
9955
10038
  function updateSidebarCollapseButton() {
@@ -12812,7 +12895,7 @@
12812
12895
 
12813
12896
  return ensureSessionReadyForInput(selectedSession).then(function(readySession) {
12814
12897
  if (!readySession) {
12815
- // ensureSessionReadyForInput / resumeClaudeSessionById 已经在失败路径里
12898
+ // ensureSessionReadyForInput / resumeSession 已经在失败路径里
12816
12899
  // 自行 toast,这里不再重复提示,避免叠两条消息。
12817
12900
  return null;
12818
12901
  }
@@ -13606,14 +13689,14 @@
13606
13689
  var isCodex = selectedSession && selectedSession.provider === "codex";
13607
13690
  if (error && (error.errorCode === "SESSION_NOT_RUNNING" || error.errorCode === "SESSION_NO_PTY")) {
13608
13691
  return isCodex
13609
- ? "Codex 会话已结束,请新建会话后继续。"
13692
+ ? "Codex 会话已结束;若存在 Codex 历史会话,将在你下次发送消息时自动恢复。"
13610
13693
  : "会话已结束;若存在 Claude 历史会话,将在你下次发送消息时自动恢复。";
13611
13694
  }
13612
13695
  if (error && error.errorCode === "SESSION_NOT_FOUND") {
13613
13696
  return "会话不存在,请重新选择或新建会话。";
13614
13697
  }
13615
13698
  return (error && error.message) || (isCodex
13616
- ? "Codex 会话暂不可用,请检查终端视图或新建会话。"
13699
+ ? "Codex 会话暂不可用;若存在 Codex 历史会话,将自动尝试恢复。"
13617
13700
  : "会话暂不可用;若存在 Claude 历史会话,将自动尝试恢复。");
13618
13701
  }
13619
13702
 
@@ -13654,10 +13737,10 @@
13654
13737
  }
13655
13738
 
13656
13739
  function canAutoResumeSession(session) {
13657
- // 只要是 Claude provider + 非运行中 + claudeSessionId
13740
+ // 只要是 Claude/Codex PTY provider + 非运行中 + 有可恢复历史 id
13658
13741
  // 就允许在用户发送时静默触发恢复。不再要求 messages 里同时
13659
13742
  // 有 user + assistant 文本(slim 列表/截断历史会让该判断失真)。
13660
- return !!(session && session.provider === "claude" && session.status !== "running" && session.claudeSessionId);
13743
+ return !!(session && !isStructuredSession(session) && (session.provider === "claude" || session.provider === "codex") && session.status !== "running" && session.claudeSessionId);
13661
13744
  }
13662
13745
 
13663
13746
  function ensureSessionReadyForInput(session, errorEl) {
@@ -13670,12 +13753,13 @@
13670
13753
  return Promise.resolve(session);
13671
13754
  }
13672
13755
  if (!canAutoResumeSession(session)) {
13673
- showToast("该会话没有可恢复的 Claude 历史上下文,请新建会话。", "error");
13756
+ var providerLabel = session && session.provider === "codex" ? "Codex" : "Claude";
13757
+ showToast("该会话没有可恢复的 " + providerLabel + " 历史上下文,请新建会话。", "error");
13674
13758
  return Promise.resolve(null);
13675
13759
  }
13676
13760
 
13677
13761
  // 静默恢复:不再弹 "正在恢复历史会话…" 提示,让用户发送动作看起来无缝。
13678
- return resumeClaudeSessionById(session.claudeSessionId, errorEl).then(function(data) {
13762
+ return resumeSession(session.id, errorEl).then(function(data) {
13679
13763
  if (!data) return null;
13680
13764
  updateSessionSnapshot(data);
13681
13765
  updateSessionsList();
@@ -14114,7 +14198,7 @@
14114
14198
  : "Enter 发送 · Shift+Enter 换行";
14115
14199
  }
14116
14200
  }
14117
- // 历史会话只要可自动恢复(Claude provider + claudeSessionId),输入框/发送按钮
14201
+ // 历史会话只要可自动恢复(Claude/Codex PTY + 有历史 id),输入框/发送按钮
14118
14202
  // 就保持可用——发送时由 ensureSessionReadyForInput 透明完成恢复。
14119
14203
  var canResumeOnSend = !structured && !isRunning && canAutoResumeSession(selectedSession);
14120
14204
  if (composer) {
@@ -14649,9 +14733,15 @@
14649
14733
  return resumeSession(sessionId).then(function(data) {
14650
14734
  if (!data) return null;
14651
14735
  if (data.claudeSessionId) {
14652
- state.claudeHistory = state.claudeHistory.filter(function(s) {
14653
- return s.claudeSessionId !== data.claudeSessionId;
14654
- });
14736
+ if (data.provider === "codex") {
14737
+ state.codexHistory = state.codexHistory.filter(function(s) {
14738
+ return s.claudeSessionId !== data.claudeSessionId;
14739
+ });
14740
+ } else {
14741
+ state.claudeHistory = state.claudeHistory.filter(function(s) {
14742
+ return s.claudeSessionId !== data.claudeSessionId;
14743
+ });
14744
+ }
14655
14745
  }
14656
14746
  return activateSession(data).then(function() {
14657
14747
  return data;