@co0ontty/wand 1.39.0 → 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">' +
@@ -1912,7 +1921,7 @@
1912
1921
  '</div>' +
1913
1922
  '<div class="file-search-box">' +
1914
1923
  '<span class="file-search-icon">' + wandFileIcon("search", { size: 14 }) + '</span>' +
1915
- '<input type="text" id="file-search-input" class="file-search-input" placeholder="搜索当前目录…" autocomplete="off" />' +
1924
+ '<input type="text" id="file-search-input" class="file-search-input" placeholder="搜索当前目录…" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" />' +
1916
1925
  '<button class="file-search-clear" id="file-search-clear" type="button" aria-label="清除搜索" title="清除">' +
1917
1926
  wandFileIcon("x", { size: 13 }) +
1918
1927
  '</button>' +
@@ -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" 误导。
@@ -2095,7 +2105,7 @@
2095
2105
  '<div id="folder-breadcrumb" class="folder-breadcrumb"></div>' +
2096
2106
  '<div class="folder-picker">' +
2097
2107
  '<span class="folder-picker-icon">' + iconSvg("folder", { size: 15, strokeWidth: 1.7 }) + '</span>' +
2098
- '<input type="text" id="folder-picker-input" class="folder-picker-input" value="" placeholder="输入或选择工作目录..." autocomplete="off" />' +
2108
+ '<input type="text" id="folder-picker-input" class="folder-picker-input" value="" placeholder="输入或选择工作目录..." autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" />' +
2099
2109
  '</div>' +
2100
2110
  '<div id="folder-picker-dropdown" class="folder-picker-dropdown hidden"></div>' +
2101
2111
  '<div id="folder-picker-validation" class="folder-picker-validation"></div>' +
@@ -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
 
@@ -3538,9 +3618,6 @@
3538
3618
 
3539
3619
  function renderCollapsedSessionTiles() {
3540
3620
  var entries = getRecentEntries();
3541
- if (entries.length === 0) {
3542
- return '<div class="sidebar-collapsed-empty" title="无会话">—</div>';
3543
- }
3544
3621
  var tiles = entries.map(function(e, i) {
3545
3622
  var idx = i + 1;
3546
3623
  if (e.kind === "session") {
@@ -3554,7 +3631,11 @@
3554
3631
  var hTitle = preview + " · " + formatHistoryTime(h.timestamp);
3555
3632
  return '<button class="sidebar-collapsed-tile history" type="button" data-collapsed-history-id="' + escapeHtml(h.claudeSessionId) + '" data-cwd="' + escapeHtml(h.cwd || "") + '" title="' + escapeHtml(hTitle) + '">' + idx + '</button>';
3556
3633
  }).join("");
3557
- return '<div class="sidebar-collapsed-tiles">' + tiles + '</div>';
3634
+ // 窄条底部固定一个「+」快速新建会话方块,替代被隐藏的 footer 新会话入口。
3635
+ var addTile = '<button class="sidebar-collapsed-tile add" type="button" data-collapsed-new-session="1" title="新建会话" aria-label="新建会话">' +
3636
+ '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>' +
3637
+ '</button>';
3638
+ return '<div class="sidebar-collapsed-tiles">' + tiles + addTile + '</div>';
3558
3639
  }
3559
3640
 
3560
3641
  function renderSessionsListContent() {
@@ -3582,19 +3663,21 @@
3582
3663
  var selectAllAction = allSelected ? "clear-selection" : "select-all-visible";
3583
3664
  var selectAllDisabled = selectable === 0 ? ' disabled' : '';
3584
3665
 
3585
- // Linear-style toolbar:
3586
- // [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>';
3587
3675
  return '<div class="session-manage-bar active">' +
3588
- '<div class="session-manage-summary">' +
3589
- '<span class="session-manage-count">' + totalCount + '</span>' +
3590
- '<span class="session-manage-summary-label">已选择</span>' +
3591
- '</div>' +
3676
+ exitBtn +
3677
+ '<div class="session-manage-summary">' + summary + '</div>' +
3592
3678
  '<div class="session-manage-actions">' +
3593
3679
  '<button class="btn btn-ghost btn-xs" data-action="' + selectAllAction + '" type="button"' + selectAllDisabled + '>' + selectAllLabel + '</button>' +
3594
- '<button class="btn btn-ghost btn-xs" data-action="clear-selection" type="button"' + (hasAny ? '' : ' disabled') + '>清空</button>' +
3595
- '<span class="session-manage-divider"></span>' +
3596
- '<button class="btn btn-danger btn-xs" data-action="delete-selected" type="button"' + (hasAny ? '' : ' disabled') + '>删除</button>' +
3597
- '<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>' +
3598
3681
  '</div>' +
3599
3682
  '</div>';
3600
3683
  }
@@ -3621,8 +3704,9 @@
3621
3704
  }
3622
3705
 
3623
3706
  function renderRecentGroup(entries) {
3624
- var html = '<section class="session-group">' +
3625
- '<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">';
3626
3710
  html += entries.map(function(e) {
3627
3711
  return e.kind === "session"
3628
3712
  ? renderSessionItem(e.ref, "sessions")
@@ -3641,46 +3725,30 @@
3641
3725
  });
3642
3726
  }
3643
3727
 
3644
- // Render the docked Claude-history region that lives between
3645
- // `.sidebar-body` and `.sidebar-footer`. Collapsed by default only
3646
- // shows a slim header ("历史消息" + count bubble). Expanded reveals the
3647
- // grouped-by-cwd list inside a scroll cap.
3648
- 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() {
3649
3733
  var visibleHistory = getClaudeHistoryRegionItems();
3650
- var expanded = !!state.claudeHistoryExpanded;
3651
3734
  var loaded = !!state.claudeHistoryLoaded;
3652
3735
  var codexVisible = getVisibleCodexHistorySessions();
3653
3736
  var codexLoaded = !!state.codexHistoryLoaded;
3737
+ var fullyLoaded = loaded && codexLoaded;
3654
3738
  var count = (loaded ? visibleHistory.length : 0) + (codexLoaded ? codexVisible.length : 0);
3739
+ if (fullyLoaded && count === 0) return '';
3655
3740
 
3656
- var badgeCls = "history-bubble";
3657
- var badgeContent;
3658
- if (!loaded) {
3659
- badgeCls += " loading";
3660
- badgeContent = "···";
3661
- } else if (count === 0) {
3662
- badgeCls += " empty";
3663
- badgeContent = "0";
3664
- } else {
3665
- badgeContent = count > 999 ? "999+" : String(count);
3666
- }
3667
- var badge = '<span class="' + badgeCls + '">' + badgeContent + '</span>';
3668
-
3669
- // Chevron rotates: collapsed → up (▲, suggests "expand upward"),
3670
- // expanded → down (▼, suggests "collapse downward").
3671
- 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>';
3672
-
3673
- var headerCls = "sidebar-history-header" + (expanded ? " expanded" : "");
3674
- var header = '<button type="button" class="' + headerCls + '" id="claude-history-toggle" aria-expanded="' + expanded + '" aria-controls="sidebar-history-body" title="' + (expanded ? "收起历史消息" : "展开历史消息") + '">' +
3675
- '<span class="sidebar-history-label">历史消息</span>' +
3676
- '<span class="sidebar-history-right">' + badge + chevronSvg + '</span>' +
3677
- '</button>';
3678
-
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>';
3679
3748
  var body = expanded
3680
- ? '<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>'
3681
3750
  : '';
3682
-
3683
- return header + body;
3751
+ return '<section class="session-group session-group--history">' + header + body + '</section>';
3684
3752
  }
3685
3753
 
3686
3754
  function renderClaudeHistoryBodyContent(visibleHistory) {
@@ -3688,7 +3756,9 @@
3688
3756
  return '<div class="claude-history-loading">扫描历史会话中…</div>';
3689
3757
  }
3690
3758
  if (visibleHistory.length === 0) {
3691
- 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 '';
3692
3762
  }
3693
3763
  var groups = {};
3694
3764
  var groupOrder = [];
@@ -3714,14 +3784,6 @@
3714
3784
  return toolbar + '<div class="sidebar-history-scroll">' + listHtml + '</div>';
3715
3785
  }
3716
3786
 
3717
- // Re-render only the docked history region in place. Called by
3718
- // updateSessionsList() so existing callers (load complete, delete, etc.)
3719
- // keep working without changes.
3720
- function updateClaudeHistoryRegion() {
3721
- var region = document.getElementById("sidebar-history-region");
3722
- if (region) region.innerHTML = renderClaudeHistoryRegion();
3723
- }
3724
-
3725
3787
  function getVisibleClaudeHistorySessions() {
3726
3788
  var managedIds = new Set();
3727
3789
  state.sessions.forEach(function(s) {
@@ -5535,9 +5597,10 @@
5535
5597
  var resumeButton = "";
5536
5598
  var checkbox = renderManageCheckbox("sessions", session.id, "选择会话 " + session.command);
5537
5599
 
5538
- if (session.provider === "claude" && session.claudeSessionId) {
5600
+ if ((session.provider === "claude" || session.provider === "codex") && session.claudeSessionId) {
5539
5601
  if (session.status !== "running" && !state.sessionsManageMode && !isStructuredSession(session)) {
5540
- 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>';
5541
5604
  }
5542
5605
  }
5543
5606
 
@@ -5556,7 +5619,7 @@
5556
5619
  // Title: summary or command
5557
5620
  var titleHtml = session.summary
5558
5621
  ? '<div class="session-title">' + escapeHtml(session.summary) + '</div>'
5559
- : '<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>';
5560
5623
 
5561
5624
  // Activity description for running sessions
5562
5625
  var activityDesc = getSessionActivityDesc(session);
@@ -6120,15 +6183,9 @@
6120
6183
  sessionsList.addEventListener("mouseout", handleCollapsedTileLeave);
6121
6184
  initSwipeToDelete(sessionsList);
6122
6185
  }
6123
- // The docked history region lives outside #sessions-list now, but it
6124
- // still wants the same delegated handlers (toggle button, directory
6125
- // expand/collapse, history-item clicks, clear-all, etc.). Reuse the
6126
- // same callbacks so behavior stays identical.
6127
- var historyRegion = document.getElementById("sidebar-history-region");
6128
- if (historyRegion) {
6129
- historyRegion.addEventListener("click", handleSessionItemClick);
6130
- historyRegion.addEventListener("keydown", handleSessionItemKeydown);
6131
- }
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.
6132
6189
  window.addEventListener("scroll", hideCollapsedTileBubble, true);
6133
6190
  window.addEventListener("resize", hideCollapsedTileBubble);
6134
6191
 
@@ -6191,6 +6248,8 @@
6191
6248
  if (closeDrawerBtn) closeDrawerBtn.addEventListener("click", closeSessionsDrawer);
6192
6249
  var collapseBtn = document.getElementById("sidebar-collapse-btn");
6193
6250
  if (collapseBtn) collapseBtn.addEventListener("click", toggleSidebarCollapsed);
6251
+ var pinBtn = document.getElementById("sidebar-pin-btn");
6252
+ if (pinBtn) pinBtn.addEventListener("click", toggleSidebarPin);
6194
6253
  var sidebarMoreBtn = document.getElementById("sidebar-more-btn");
6195
6254
  var sidebarOverflow = document.getElementById("sidebar-overflow-menu");
6196
6255
  if (sidebarMoreBtn && sidebarOverflow) {
@@ -6672,7 +6731,7 @@
6672
6731
  topbarMoreBtn.setAttribute("aria-expanded", "false");
6673
6732
  switch (action) {
6674
6733
  case "copy-claude-session-id":
6675
- copySelectedSessionField("claudeSessionId", "Claude 会话 ID 已复制");
6734
+ copySelectedSessionField("claudeSessionId", getSelectedSession() && getSelectedSession().provider === "codex" ? "Codex thread ID 已复制" : "Claude 会话 ID 已复制");
6676
6735
  break;
6677
6736
  case "copy-cwd":
6678
6737
  copySelectedSessionField("cwd", "工作目录已复制");
@@ -7248,6 +7307,12 @@
7248
7307
 
7249
7308
  var collapsedTile = target.closest(".sidebar-collapsed-tile");
7250
7309
  if (collapsedTile && collapsedTile instanceof HTMLElement) {
7310
+ if (collapsedTile.dataset.collapsedNewSession) {
7311
+ event.preventDefault();
7312
+ event.stopPropagation();
7313
+ openSessionModal();
7314
+ return;
7315
+ }
7251
7316
  if (collapsedTile.dataset.collapsedSessionId) {
7252
7317
  event.preventDefault();
7253
7318
  event.stopPropagation();
@@ -8576,7 +8641,7 @@
8576
8641
  state.sessions = [];
8577
8642
  state.claudeHistory = [];
8578
8643
  state.claudeHistoryLoaded = false;
8579
- state.claudeHistoryExpanded = true;
8644
+ state.claudeHistoryExpanded = false;
8580
8645
  state.claudeHistoryExpandedDirs = {};
8581
8646
  state.sessionsDrawerOpen = false;
8582
8647
  render();
@@ -9513,10 +9578,8 @@
9513
9578
  var countEl = document.getElementById("session-count");
9514
9579
  if (listEl) listEl.innerHTML = renderSessionsListContent();
9515
9580
  if (countEl) countEl.textContent = String(state.sessions.length);
9516
- // The docked history region lives outside #sessions-list refresh it
9517
- // too so callers that mutate state.claudeHistory (load complete,
9518
- // delete, clear) don't need to know about it.
9519
- updateClaudeHistoryRegion();
9581
+ // History renders inline inside #sessions-list now, so the line above
9582
+ // already refreshed it no separate docked region to update.
9520
9583
  if (typeof hideCollapsedTileBubble === "function") hideCollapsedTileBubble();
9521
9584
  updateShellChrome();
9522
9585
  // Re-render cross-session queue (container may have been destroyed by DOM rebuild)
@@ -9730,7 +9793,7 @@
9730
9793
  // 与 renderAppShell 保持一致:手机端只允许窄条形态 anchored。
9731
9794
  var isMobile = isMobileLayout();
9732
9795
  var isCollapsed = !!state.sidebarPinned && !!state.sidebarCollapsed;
9733
- var isAnchored = isCollapsed || (!!state.sidebarPinned && !isMobile);
9796
+ var isAnchored = isCollapsed || (!isMobile && (!!state.sidebarPinned || !!state.sessionsDrawerOpen));
9734
9797
  if (drawer) {
9735
9798
  drawer.classList.toggle("pinned", isAnchored);
9736
9799
  drawer.classList.toggle("collapsed", isCollapsed);
@@ -9739,6 +9802,13 @@
9739
9802
  mainLayout.classList.toggle("sidebar-pinned", isAnchored);
9740
9803
  mainLayout.classList.toggle("sidebar-collapsed", isCollapsed);
9741
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
+ }
9742
9812
  }
9743
9813
 
9744
9814
  function updateDrawerState() {
@@ -9943,6 +10013,26 @@
9943
10013
  scheduleTerminalRefitAfterPaddingTransition();
9944
10014
  }
9945
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
+
9946
10036
  // 收窄按钮的图标/title/状态随 collapsed 切换。抽出来给轻量更新路径用,
9947
10037
  // 避免为了换一个箭头方向就走全量 render()。
9948
10038
  function updateSidebarCollapseButton() {
@@ -12805,7 +12895,7 @@
12805
12895
 
12806
12896
  return ensureSessionReadyForInput(selectedSession).then(function(readySession) {
12807
12897
  if (!readySession) {
12808
- // ensureSessionReadyForInput / resumeClaudeSessionById 已经在失败路径里
12898
+ // ensureSessionReadyForInput / resumeSession 已经在失败路径里
12809
12899
  // 自行 toast,这里不再重复提示,避免叠两条消息。
12810
12900
  return null;
12811
12901
  }
@@ -13599,14 +13689,14 @@
13599
13689
  var isCodex = selectedSession && selectedSession.provider === "codex";
13600
13690
  if (error && (error.errorCode === "SESSION_NOT_RUNNING" || error.errorCode === "SESSION_NO_PTY")) {
13601
13691
  return isCodex
13602
- ? "Codex 会话已结束,请新建会话后继续。"
13692
+ ? "Codex 会话已结束;若存在 Codex 历史会话,将在你下次发送消息时自动恢复。"
13603
13693
  : "会话已结束;若存在 Claude 历史会话,将在你下次发送消息时自动恢复。";
13604
13694
  }
13605
13695
  if (error && error.errorCode === "SESSION_NOT_FOUND") {
13606
13696
  return "会话不存在,请重新选择或新建会话。";
13607
13697
  }
13608
13698
  return (error && error.message) || (isCodex
13609
- ? "Codex 会话暂不可用,请检查终端视图或新建会话。"
13699
+ ? "Codex 会话暂不可用;若存在 Codex 历史会话,将自动尝试恢复。"
13610
13700
  : "会话暂不可用;若存在 Claude 历史会话,将自动尝试恢复。");
13611
13701
  }
13612
13702
 
@@ -13647,10 +13737,10 @@
13647
13737
  }
13648
13738
 
13649
13739
  function canAutoResumeSession(session) {
13650
- // 只要是 Claude provider + 非运行中 + claudeSessionId
13740
+ // 只要是 Claude/Codex PTY provider + 非运行中 + 有可恢复历史 id
13651
13741
  // 就允许在用户发送时静默触发恢复。不再要求 messages 里同时
13652
13742
  // 有 user + assistant 文本(slim 列表/截断历史会让该判断失真)。
13653
- 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);
13654
13744
  }
13655
13745
 
13656
13746
  function ensureSessionReadyForInput(session, errorEl) {
@@ -13663,12 +13753,13 @@
13663
13753
  return Promise.resolve(session);
13664
13754
  }
13665
13755
  if (!canAutoResumeSession(session)) {
13666
- showToast("该会话没有可恢复的 Claude 历史上下文,请新建会话。", "error");
13756
+ var providerLabel = session && session.provider === "codex" ? "Codex" : "Claude";
13757
+ showToast("该会话没有可恢复的 " + providerLabel + " 历史上下文,请新建会话。", "error");
13667
13758
  return Promise.resolve(null);
13668
13759
  }
13669
13760
 
13670
13761
  // 静默恢复:不再弹 "正在恢复历史会话…" 提示,让用户发送动作看起来无缝。
13671
- return resumeClaudeSessionById(session.claudeSessionId, errorEl).then(function(data) {
13762
+ return resumeSession(session.id, errorEl).then(function(data) {
13672
13763
  if (!data) return null;
13673
13764
  updateSessionSnapshot(data);
13674
13765
  updateSessionsList();
@@ -13941,24 +14032,24 @@
13941
14032
  { key: "down", label: "↓" },
13942
14033
  { key: "left", label: "←" }
13943
14034
  ];
13944
- // 外圈 8 功能:i=0 正上方起顺时针,与八分扇区 idx 对应
14035
+ // 外圈功能键:N 个均分扇区,i=0 正上方起顺时针。
14036
+ // 数组长度即扇区数 —— buildJoystickRingSvg / joystickHitTest 都是按
14037
+ // OUTER_KEYS.length 动态算角度,所以这里随便加减都不需要改几何。
14038
+ // 当前 4 键: 上=Enter 右=Ctrl+C 下=Esc 左=Shift+Tab。
14039
+ // 选这 4 个的原因: Claude / Codex 交互里只有方向键 + Enter + Esc +
14040
+ // Shift+Tab (back-tab) + Ctrl+C (abort) 有真实用途, 之前的 Tab /
14041
+ // Ctrl+Z/D/L 在结构化 / chat / PTY claude 里都拿不到效果, 留着只是
14042
+ // 占位 + 误点。
13945
14043
  var JOYSTICK_OUTER_KEYS = [
13946
14044
  { key: "enter", label: "Enter" },
13947
- { key: "escape", label: "Esc" },
13948
- { key: "tab", label: "Tab" },
13949
- { key: "shift_tab", label: "Shift+Tab" },
13950
14045
  { key: "ctrl_c", label: "Ctrl+C" },
13951
- { key: "ctrl_z", label: "Ctrl+Z" },
13952
- { key: "ctrl_d", label: "Ctrl+D" },
13953
- { key: "ctrl_l", label: "Ctrl+L" }
13954
- ];
13955
- // 钉住面板四角翻页键
13956
- var JOYSTICK_CORNER_KEYS = [
13957
- { key: "pageup", label: "PgUp" },
13958
- { key: "home", label: "Home" },
13959
- { key: "pagedown", label: "PgDn" },
13960
- { key: "end", label: "End" }
14046
+ { key: "escape", label: "Esc" },
14047
+ { key: "shift_tab", label: "Shift+Tab" }
13961
14048
  ];
14049
+ // 钉住面板四角翻页键 —— 已弃用 (PgUp/Home/PgDn/End 在 Claude TUI 里
14050
+ // 不是常用导航, 也跟终端历史回滚冲突)。留空数组让面板渲染逻辑自然
14051
+ // 跳过这一排, 不删数组以保 ringSvg 之外别处 reference 安全。
14052
+ var JOYSTICK_CORNER_KEYS = [];
13962
14053
 
13963
14054
  var ignoredInteractiveTargetIds = new Set([
13964
14055
  "mini-keyboard-fab",
@@ -14107,7 +14198,7 @@
14107
14198
  : "Enter 发送 · Shift+Enter 换行";
14108
14199
  }
14109
14200
  }
14110
- // 历史会话只要可自动恢复(Claude provider + claudeSessionId),输入框/发送按钮
14201
+ // 历史会话只要可自动恢复(Claude/Codex PTY + 有历史 id),输入框/发送按钮
14111
14202
  // 就保持可用——发送时由 ensureSessionReadyForInput 透明完成恢复。
14112
14203
  var canResumeOnSend = !structured && !isRunning && canAutoResumeSession(selectedSession);
14113
14204
  if (composer) {
@@ -14642,9 +14733,15 @@
14642
14733
  return resumeSession(sessionId).then(function(data) {
14643
14734
  if (!data) return null;
14644
14735
  if (data.claudeSessionId) {
14645
- state.claudeHistory = state.claudeHistory.filter(function(s) {
14646
- return s.claudeSessionId !== data.claudeSessionId;
14647
- });
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
+ }
14648
14745
  }
14649
14746
  return activateSession(data).then(function() {
14650
14747
  return data;
@@ -14947,7 +15044,8 @@
14947
15044
  function handleInputBoxBlur() {
14948
15045
  resetInputPanelViewportSpacing();
14949
15046
  setTimeout(function() {
14950
- window.scrollTo(0, 0);
15047
+ resetRootViewportScroll();
15048
+ syncAppViewportHeight(false);
14951
15049
  // On mobile, force terminal refit + scroll after keyboard dismissal.
14952
15050
  // The container height restores but terminal needs time to
14953
15051
  // fill the expanded space, and the scroll position needs resetting.
@@ -14959,6 +15057,10 @@
14959
15057
  maybeScrollTerminalToBottom("keyboard");
14960
15058
  }
14961
15059
  }, 100);
15060
+ setTimeout(function() {
15061
+ resetRootViewportScroll();
15062
+ syncAppViewportHeight(false);
15063
+ }, 360);
14962
15064
  }
14963
15065
 
14964
15066
  function adjustInputBoxSelection(inputBox) {
@@ -15669,18 +15771,74 @@
15669
15771
  // adjustResize 不再自动 resize WebView 内容;同时仅给 input-panel
15670
15772
  // 加 padding-bottom 只是把 panel 内部底部撑空,并不会让 panel 自身
15671
15773
  // 上移。这里通过 CSS 变量驱动整层高度,是跨 WebView/Chrome/PWA 的
15672
- // 统一兜底。仅在视口比窗口明显变小时(典型 = 软键盘弹起)覆盖,
15673
- // 桌面与无键盘场景维持 100dvh 不抖。
15674
- function syncAppViewportHeight() {
15774
+ // 统一兜底。iOS 100dvh 在键盘动画后会短暂滞后,所以这里持续用
15775
+ // visualViewport 的实测高度驱动布局,桌面场景下该值基本等于窗口高度。
15776
+ function getRootViewportScrollTop(vv) {
15777
+ var values = [
15778
+ window.scrollY || window.pageYOffset || 0,
15779
+ document.documentElement ? document.documentElement.scrollTop || 0 : 0,
15780
+ document.body ? document.body.scrollTop || 0 : 0
15781
+ ];
15782
+ if (vv) {
15783
+ // pageTop is the visual viewport's top edge in layout coordinates.
15784
+ // On iOS it captures the focus pan that can survive keyboard close.
15785
+ if (typeof vv.pageTop === "number") {
15786
+ values.push(vv.pageTop);
15787
+ } else if (typeof vv.offsetTop === "number") {
15788
+ values.push(vv.offsetTop);
15789
+ }
15790
+ }
15791
+ return Math.max.apply(Math, values);
15792
+ }
15793
+
15794
+ function resetRootViewportScroll() {
15795
+ try { window.scrollTo(0, 0); } catch (e) {}
15796
+ if (document.scrollingElement) document.scrollingElement.scrollTop = 0;
15797
+ if (document.documentElement) document.documentElement.scrollTop = 0;
15798
+ if (document.body) document.body.scrollTop = 0;
15799
+ }
15800
+
15801
+ function syncAppViewportHeight(isKeyboardOpen) {
15675
15802
  var vv = window.visualViewport;
15676
15803
  if (!vv) return;
15677
- var diff = window.innerHeight - vv.height - vv.offsetTop;
15678
15804
  var root = document.documentElement;
15679
- if (diff > 50) {
15680
- root.style.setProperty('--app-viewport-height', vv.height + 'px');
15681
- } else {
15682
- root.style.removeProperty('--app-viewport-height');
15805
+ var visualTop = window.__wandImeNative ? 0 : getRootViewportScrollTop(vv);
15806
+ // iOS Safari 上 100dvh 在键盘 / 地址栏切换后有更新延迟, 经常"卡"在
15807
+ // 上一刻的小值 -> body 比真实可见区还短一截, 输入框下方留出一大段
15808
+ // 奶油色 html 背景。改成直接拿 visualViewport.height 当 body 高度的
15809
+ // 权威值, 每帧实时跟随 (vv.resize/scroll 触发), 不再依赖 dvh。
15810
+ // 桌面浏览器上 vv.height ≈ window.innerHeight, 同样无副作用。
15811
+ // 之前的 diff > 50 阈值现在只用来判断"是不是真键盘上来了"以做
15812
+ // iOS html 滚动复位 (offsetTop hack), 不再控制 body 高度。
15813
+ //
15814
+ // 但 iOS 还有一个更隐蔽的状态: 键盘收起后 visual viewport 已经变高,
15815
+ // 根页面却仍停在键盘弹起时的 pageTop/scrollY。此时如果只写 vv.height,
15816
+ // .app-container 的底边落在 visualViewport 顶点之前, input-panel 会悬在
15817
+ // 屏幕底部上方。把 visualTop 临时加回高度, 再滚回 0; 后续 settle timer
15818
+ // 会用新的 visualTop=0 覆盖回来。
15819
+ root.style.setProperty('--app-viewport-height', Math.ceil(vv.height + Math.max(0, visualTop)) + 'px');
15820
+ // iOS Safari: 当 textarea 获得焦点 / 键盘弹起时, 浏览器会主动把
15821
+ // <html> 向上滚一段, 让焦点元素进可见区 —— 体现为 vv.offsetTop > 0。
15822
+ // 但 body 已经被收缩到 vv.height, 这一段 offsetTop 就变成 body 底部
15823
+ // (= .input-panel) 与键盘上沿之间的"空洞", 用户看到的就是
15824
+ // "输入框离键盘还有很远一截"。这里强行把 html 滚回 0, 让 body 底部
15825
+ // 重新贴回键盘上沿。Wand APK 内 (window.__wandImeNative=true) 走
15826
+ // 原生 IME 回调精确 resize, 这里跳过避免双重补偿。
15827
+ if (!window.__wandImeNative && (isKeyboardOpen || visualTop > 1)) {
15828
+ resetRootViewportScroll();
15829
+ }
15830
+ }
15831
+
15832
+ function isEditableFocusTarget(el) {
15833
+ if (!el) return false;
15834
+ var tag = el.tagName;
15835
+ if (tag === "TEXTAREA") return true;
15836
+ if (tag === "SELECT") return true;
15837
+ if (tag === "INPUT") {
15838
+ var type = (el.getAttribute("type") || "text").toLowerCase();
15839
+ return !/^(button|checkbox|color|file|hidden|image|radio|range|reset|submit)$/i.test(type);
15683
15840
  }
15841
+ return !!el.isContentEditable;
15684
15842
  }
15685
15843
 
15686
15844
  // Visual viewport handling for better mobile keyboard support
@@ -15690,17 +15848,66 @@
15690
15848
  var vv = window.visualViewport;
15691
15849
  var lastHeight = vv.height;
15692
15850
  var keyboardOpen = false;
15851
+ var lastViewportWidth = Math.max(window.innerWidth || 0, vv.width || 0);
15852
+ var largestViewportHeight = Math.max(window.innerHeight || 0, vv.height || 0);
15853
+ var viewportSettleTimers = [];
15854
+
15855
+ function getCurrentViewportHeightBaseline() {
15856
+ return Math.max(window.innerHeight || 0, vv.height || 0);
15857
+ }
15858
+
15859
+ function refreshViewportBaseline() {
15860
+ var width = Math.max(window.innerWidth || 0, vv.width || 0);
15861
+ var height = getCurrentViewportHeightBaseline();
15862
+ if (Math.abs(width - lastViewportWidth) > 8) {
15863
+ lastViewportWidth = width;
15864
+ largestViewportHeight = height;
15865
+ return;
15866
+ }
15867
+ if (height > largestViewportHeight) {
15868
+ largestViewportHeight = height;
15869
+ }
15870
+ }
15871
+
15872
+ function detectKeyboardOpen(inputBox, offsetBottom) {
15873
+ var activeEl = document.activeElement;
15874
+ var hasEditableFocus = activeEl === inputBox || isEditableFocusTarget(activeEl);
15875
+ var shrinkFromLargest = largestViewportHeight - vv.height;
15876
+ var innerShrinkFromLargest = largestViewportHeight - (window.innerHeight || vv.height || 0);
15877
+ if (offsetBottom > 80) return true;
15878
+ // iOS/Chrome iOS sometimes resize window.innerHeight together with
15879
+ // visualViewport.height, so offsetBottom stays near zero. The
15880
+ // focused-editable + baseline shrink path catches that case.
15881
+ if (hasEditableFocus && (shrinkFromLargest > 120 || innerShrinkFromLargest > 120)) return true;
15882
+ // During close animation focus can disappear before viewport height
15883
+ // is fully restored. Keep the "open" state until the shrink is small.
15884
+ if (keyboardOpen && (shrinkFromLargest > 80 || offsetBottom > 32)) return true;
15885
+ return false;
15886
+ }
15887
+
15888
+ function scheduleViewportSettle() {
15889
+ viewportSettleTimers.forEach(function(timer) { clearTimeout(timer); });
15890
+ viewportSettleTimers = [60, 180, 360, 620].map(function(delay) {
15891
+ return setTimeout(function() {
15892
+ if (!window.__wandImeNative) {
15893
+ resetRootViewportScroll();
15894
+ }
15895
+ syncAppViewportHeight(keyboardOpen);
15896
+ }, delay);
15897
+ });
15898
+ }
15693
15899
 
15694
15900
  function updateViewport() {
15695
15901
  if (!vv) return;
15696
15902
  var inputBox = document.getElementById('input-box');
15697
15903
  var offsetBottom = window.innerHeight - vv.height - vv.offsetTop;
15698
- var isKeyboardOpen = offsetBottom > 50;
15904
+ refreshViewportBaseline();
15905
+ var isKeyboardOpen = detectKeyboardOpen(inputBox, offsetBottom);
15699
15906
  var heightChanged = Math.abs(vv.height - lastHeight) > 8;
15700
15907
 
15701
15908
  // 键盘开/关与视口尺寸变化时同步 --app-viewport-height,
15702
15909
  // 让 body 高度跟随可见区域,input-panel 自然贴键盘上沿。
15703
- syncAppViewportHeight();
15910
+ syncAppViewportHeight(isKeyboardOpen);
15704
15911
 
15705
15912
  if (isKeyboardOpen && (!keyboardOpen || heightChanged) && shouldAdjustForKeyboard(vv, inputBox)) {
15706
15913
  syncInputBoxScroll(inputBox);
@@ -15720,6 +15927,21 @@
15720
15927
  // final scroll lands AFTER the animation settles.
15721
15928
  var wasStickToBottom = state.terminalAutoFollow || isTerminalNearBottom();
15722
15929
  ensureTerminalFit("keyboard-open", { forceReplay: true });
15930
+ // iOS Safari 二次复位: 第一次 syncAppViewportHeight 在键盘动画
15931
+ // 起始帧把 html 滚回 0, 但 iOS 在键盘动画收尾时还会再尝试一次
15932
+ // "把焦点元素拽进可见区", 把 html 重新推上去 —— 留下用户报的
15933
+ // "输入框距离键盘还有很长距离"。镜像 keyboard-close 的 200ms 兜底,
15934
+ // 等键盘动画完整跑完后再清一次 scrollTop + 重算 viewport 高度,
15935
+ // 让 input-panel 最终稳定贴在键盘上沿。
15936
+ // Wand APK (__wandImeNative=true) 跳过, 原生 IME callback 已经在
15937
+ // WebView 层精确 resize, 这里再 scroll 反而抖。
15938
+ if (!window.__wandImeNative) {
15939
+ setTimeout(function() {
15940
+ resetRootViewportScroll();
15941
+ syncAppViewportHeight(true);
15942
+ }, 220);
15943
+ }
15944
+ scheduleViewportSettle();
15723
15945
  // Mirror the keyboard-close 200ms delay: by then the iOS / Android
15724
15946
  // keyboard slide-in animation is done, vv.height is final, and
15725
15947
  // scrollHeight reflects the post-replay grid. One more force
@@ -15742,8 +15964,8 @@
15742
15964
  // window.scrollTo(0,0) 不跑,页面停在键盘抬起时被 iOS 推上去的
15743
15965
  // 偏移位置,input-panel 看起来"没回到底"。
15744
15966
  // 这里在 visualViewport 检测到键盘收起的瞬间直接强制复位一次,
15745
- // 并把 --app-viewport-height 兜底清掉,确保 .app-container 回到
15746
- // 100dvh、input-panel 重新贴屏幕底部。
15967
+ // 并把 --app-viewport-height 同步到键盘收起后的实测高度,确保
15968
+ // input-panel 重新贴屏幕底部。
15747
15969
  //
15748
15970
  // Android APK (window.__wandImeNative=true) 跳过这段 iOS hack —
15749
15971
  // MainActivity 已经在 IME 动画 callback 里逐帧把 root setPadding,
@@ -15753,21 +15975,22 @@
15753
15975
  var rootEl = document.documentElement;
15754
15976
  var imeIsNative = !!window.__wandImeNative;
15755
15977
  if (!imeIsNative) {
15756
- rootEl.style.removeProperty('--app-viewport-height');
15757
- window.scrollTo(0, 0);
15758
- if (document.scrollingElement) document.scrollingElement.scrollTop = 0;
15759
- rootEl.scrollTop = 0;
15760
- if (document.body) document.body.scrollTop = 0;
15978
+ // 不要 removeProperty('--app-viewport-height') —— 那样会让 body 退回
15979
+ // 到 100dvh, 而 iOS Safari 的 100dvh 在键盘动画跑完前经常还停留
15980
+ // 在小值, body 立刻短一截 -> 输入框下方露出大段奶油色 html 背景。
15981
+ // 直接让 syncAppViewportHeight 把它更新为新的 vv.height (键盘已收
15982
+ // 起所以是全可见高度), body 平滑过渡, 不出现空洞。
15983
+ syncAppViewportHeight(false);
15984
+ resetRootViewportScroll();
15761
15985
  }
15986
+ scheduleViewportSettle();
15762
15987
  setTimeout(function() {
15763
15988
  if (!imeIsNative) {
15764
15989
  // 二次复位:等键盘收起动画 + iOS 自身的回滚跑完后再清一次,
15765
15990
  // 防止 iOS 在动画过程中又把 scrollTop 推上去。
15766
- window.scrollTo(0, 0);
15767
- if (document.scrollingElement) document.scrollingElement.scrollTop = 0;
15768
- rootEl.scrollTop = 0;
15769
- if (document.body) document.body.scrollTop = 0;
15770
- syncAppViewportHeight();
15991
+ resetRootViewportScroll();
15992
+ if (rootEl) rootEl.scrollTop = 0;
15993
+ syncAppViewportHeight(false);
15771
15994
  }
15772
15995
  ensureTerminalFit("keyboard-close", { forceReplay: true });
15773
15996
  // 同 handleInputBoxBlur:尊重 terminalAutoFollow,避免把上滚
@@ -15883,11 +16106,18 @@
15883
16106
  // 不改动终端背景的 touch/scroll/wheel —— 单指空白处仍是原生滚动看历史。
15884
16107
 
15885
16108
  function isJoystickAvailable() {
15886
- // 触屏与桌面网页端都显示(球球用 Pointer Events,鼠标拖拽同样可用)
15887
- if (state.currentView !== "terminal") return false;
16109
+ // 触屏与桌面网页端都显示(球球用 Pointer Events,鼠标拖拽同样可用)。
16110
+ // 不再用 currentView/isStructuredSession 关掉:
16111
+ // - chat 视图 (含 PTY Claude 的对话视图): 用户偶尔要给底层 PTY 发
16112
+ // 方向键 / Esc / Shift+Tab 选权限菜单, 但只能切到 terminal 视图才点
16113
+ // 摇杆 —— 现在 chat 视图直接可用。sendJoystickKey 已经走 /input
16114
+ // 接口, 服务端不挑视图。
16115
+ // - 结构化会话: 大多数键 (方向 / Tab) 在 SDK runner 里没真实 effect,
16116
+ // 但 Ctrl+C / Esc 都映射到 query.interrupt() 中断当前回复, 用户
16117
+ // 场景是"等不及当前回答, 想停掉重发"。sendJoystickKey 里按 session
16118
+ // 类型分支处理: PTY 走原本 sequence, 结构化只接受中断意图键。
15888
16119
  var session = getSelectedSession();
15889
16120
  if (!session) return false;
15890
- if (isStructuredSession(session)) return false;
15891
16121
  return true;
15892
16122
  }
15893
16123
 
@@ -15934,16 +16164,21 @@
15934
16164
  for (i = 0; i < JOYSTICK_OUTER_KEYS.length; i++) {
15935
16165
  fnRow += keyBtn(JOYSTICK_OUTER_KEYS[i].key, JOYSTICK_OUTER_KEYS[i].label, "");
15936
16166
  }
15937
- var cornerRow = "";
15938
- for (i = 0; i < JOYSTICK_CORNER_KEYS.length; i++) {
15939
- cornerRow += keyBtn(JOYSTICK_CORNER_KEYS[i].key, JOYSTICK_CORNER_KEYS[i].label, "");
15940
- }
15941
- var modRow = keyBtn("ctrl", "Ctrl", "wjp-mod") + keyBtn("alt", "Alt", "wjp-mod");
15942
- return '<div class="wjp-title">遥控面板</div>' +
16167
+ // 角键 (PgUp/Home/PgDn/End) 与修饰键 (Ctrl/Alt) 排已被裁剪 ——
16168
+ // 当前外圈 4 键全是独立功能键 (Enter / Ctrl+C / Esc / Shift+Tab),
16169
+ // 没有"先按 Ctrl 再按字母"的复合组合, 所以修饰键 toggle 没意义。
16170
+ // CORNER_KEYS 为空时, 对应的 grid 不渲染, 面板高度自动收缩。
16171
+ var html = '<div class="wjp-title">遥控面板</div>' +
15943
16172
  dpad +
15944
- '<div class="wjp-grid wjp-fnkeys">' + fnRow + "</div>" +
15945
- '<div class="wjp-grid wjp-corners">' + cornerRow + "</div>" +
15946
- '<div class="wjp-grid wjp-mods">' + modRow + "</div>";
16173
+ '<div class="wjp-grid wjp-fnkeys">' + fnRow + "</div>";
16174
+ if (JOYSTICK_CORNER_KEYS.length > 0) {
16175
+ var cornerRow = "";
16176
+ for (i = 0; i < JOYSTICK_CORNER_KEYS.length; i++) {
16177
+ cornerRow += keyBtn(JOYSTICK_CORNER_KEYS[i].key, JOYSTICK_CORNER_KEYS[i].label, "");
16178
+ }
16179
+ html += '<div class="wjp-grid wjp-corners">' + cornerRow + "</div>";
16180
+ }
16181
+ return html;
15947
16182
  }
15948
16183
 
15949
16184
  function joystickPolar(r, deg) {
@@ -15993,12 +16228,17 @@
15993
16228
  '<path d="' + joystickSectorPath(JOYSTICK_R0, JOYSTICK_R1, center - 45 + gap, center + 45 - gap) + '"/>' +
15994
16229
  joystickLabelMarkup(k.label, lp.x, lp.y) + "</g>";
15995
16230
  }
15996
- for (i = 0; i < JOYSTICK_OUTER_KEYS.length; i++) {
16231
+ // 外圈扇区宽度跟随 OUTER_KEYS.length 动态计算: 4 90° 每片,
16232
+ // 8 键 → 45° 每片。half 是单片半宽 (扇区中心两侧各延半个 step)。
16233
+ var outerCount = JOYSTICK_OUTER_KEYS.length;
16234
+ var outerStep = outerCount > 0 ? 360 / outerCount : 360;
16235
+ var outerHalf = outerStep / 2;
16236
+ for (i = 0; i < outerCount; i++) {
15997
16237
  k = JOYSTICK_OUTER_KEYS[i];
15998
- center = -90 + i * 45;
16238
+ center = -90 + i * outerStep;
15999
16239
  lp = joystickPolar((JOYSTICK_R1 + JOYSTICK_R2) / 2, center);
16000
16240
  svg += '<g class="wjr-sector wjr-outer" data-key="' + k.key + '">' +
16001
- '<path d="' + joystickSectorPath(JOYSTICK_R1, JOYSTICK_R2, center - 22.5 + gap, center + 22.5 - gap) + '"/>' +
16241
+ '<path d="' + joystickSectorPath(JOYSTICK_R1, JOYSTICK_R2, center - outerHalf + gap, center + outerHalf - gap) + '"/>' +
16002
16242
  joystickLabelMarkup(k.label, lp.x, lp.y) + "</g>";
16003
16243
  }
16004
16244
  svg += '<circle class="wjr-hub" cx="0" cy="0" r="' + (JOYSTICK_R0 - 1) + '"/>';
@@ -16248,10 +16488,14 @@
16248
16488
  if (Math.abs(dy) >= Math.abs(dx)) return { zone: "inner", key: dy < 0 ? "up" : "down" };
16249
16489
  return { zone: "inner", key: dx < 0 ? "left" : "right" };
16250
16490
  }
16251
- // 外圈:8 等分扇区,正上方为 0,顺时针递增;+π/8 让扇区中心对准按钮
16491
+ // 外圈:OUTER_KEYS.length 等分扇区,正上方为 0,顺时针递增;
16492
+ // +halfStep 让扇区中心对准按钮 (原本 N=8 时是 +π/8)。
16252
16493
  var ang = Math.atan2(dx, -dy);
16253
16494
  if (ang < 0) ang += Math.PI * 2;
16254
- var idx = Math.floor((ang + Math.PI / 8) / (Math.PI / 4)) % 8;
16495
+ var outerCount = JOYSTICK_OUTER_KEYS.length;
16496
+ if (outerCount === 0) return { zone: "dead", key: null };
16497
+ var outerStepRad = (Math.PI * 2) / outerCount;
16498
+ var idx = Math.floor((ang + outerStepRad / 2) / outerStepRad) % outerCount;
16255
16499
  return { zone: "outer", key: JOYSTICK_OUTER_KEYS[idx].key };
16256
16500
  }
16257
16501
 
@@ -16356,6 +16600,24 @@
16356
16600
  updateJoystickPanelUI();
16357
16601
  return;
16358
16602
  }
16603
+ var session = getSelectedSession();
16604
+ // ── 结构化会话分支 ──
16605
+ // SDK / claude -p 通道没有 PTY 可写, 把原始 escape 序列丢给
16606
+ // /api/sessions/:id/input 会被结构化 sendMessage 当成对话文本 (例如
16607
+ // 把 "\x1b[A" 作为 prompt 发出去), 既无效又污染上下文。
16608
+ // 这里按"中断意图"白名单转发: Ctrl+C / Esc → query.interrupt()。
16609
+ // 其他键 (方向 / Enter / Shift+Tab) 在结构化里没有合理 mapping, 静默
16610
+ // no-op, 同时震一下做反馈。
16611
+ if (session && isStructuredSession(session)) {
16612
+ if (key === "ctrl_c" || key === "escape") {
16613
+ interruptStructuredSessionFromJoystick(session, key);
16614
+ }
16615
+ // 不论是否真发出去, 都消化掉修饰键 + 更新 UI, 避免下次发送残留状态
16616
+ clearModifiers();
16617
+ updateJoystickPanelUI();
16618
+ return;
16619
+ }
16620
+ // ── PTY 会话原路径 ──
16359
16621
  var seq = buildPtySequence(key, {
16360
16622
  ctrl: state.modifiers.ctrl,
16361
16623
  alt: state.modifiers.alt,
@@ -16367,6 +16629,44 @@
16367
16629
  scheduleShortcutResync();
16368
16630
  }
16369
16631
 
16632
+ // 摇杆触发的结构化会话中断: 复用 /api/structured-sessions/:id/messages
16633
+ // 的 interrupt=true 路径 (sendMessage 内部走 query.interrupt 优雅停止,
16634
+ // 失败 fallback 到 abortController.abort)。空 input + interrupt=true =
16635
+ // "停掉当前回复但不发新消息", 跟用户从摇杆按 Ctrl+C/Esc 的预期一致。
16636
+ function interruptStructuredSessionFromJoystick(session, key) {
16637
+ if (!session || !session.id) return;
16638
+ fetch("/api/structured-sessions/" + session.id + "/messages", {
16639
+ method: "POST",
16640
+ headers: { "Content-Type": "application/json" },
16641
+ credentials: "same-origin",
16642
+ body: JSON.stringify({ input: "", interrupt: true, preserveQueue: true }),
16643
+ })
16644
+ .then(function(res) {
16645
+ if (!res.ok) return res.json().catch(function() { return {}; }).then(function(p) {
16646
+ throw new Error((p && p.error) || ("中断失败 (key=" + key + ")"));
16647
+ });
16648
+ return res.json();
16649
+ })
16650
+ .then(function(snapshot) {
16651
+ if (snapshot && snapshot.id) {
16652
+ updateSessionSnapshot(snapshot);
16653
+ if (snapshot.id === state.selectedId) {
16654
+ var refreshed = state.sessions.find(function(s) { return s.id === snapshot.id; }) || snapshot;
16655
+ state.currentMessages = buildMessagesForRender(refreshed, getPreferredMessages(refreshed, snapshot.output, false));
16656
+ renderChat(true);
16657
+ if (typeof updateQueueBar === "function") updateQueueBar();
16658
+ }
16659
+ }
16660
+ })
16661
+ .catch(function(err) {
16662
+ // 已经在 SDK 内部完成 / 没有 pending query 时, 服务端会返回 400,
16663
+ // 这里静默吃掉, 避免给用户冒出"中断失败"toast (按了也是想停, 没东西可停就当成功)。
16664
+ if (window && window.console && err && err.message) {
16665
+ console.debug("[wand] joystick interrupt no-op:", err.message);
16666
+ }
16667
+ });
16668
+ }
16669
+
16370
16670
  function toggleJoystickPanel() {
16371
16671
  if (state.joystickPinnedOpen) closeJoystickPanel();
16372
16672
  else openJoystickPanel();