@co0ontty/wand 1.30.3 → 1.31.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -129,6 +129,13 @@
129
129
  }
130
130
  })(), // 跨会话排队消息 [{ id, text, cwd, mode, tool }]
131
131
  structuredInputQueue: [], // 结构化会话同会话排队消息
132
+ // 排队条 UI 局部状态 ——
133
+ // queueBarExpanded: 折叠条点击展开成下拉面板
134
+ // queueBarItemExpanded: 展开面板里被点开看完整内容的 item 下标集合
135
+ // queueBarDrag: 拖拽排序进行中时的临时状态(pointer 捕获、起始坐标、参考 rect)
136
+ queueBarExpanded: false,
137
+ queueBarItemExpanded: {},
138
+ queueBarDrag: null,
132
139
  drafts: {},
133
140
  isSyncingInputBox: false,
134
141
  loginPending: false,
@@ -149,6 +156,12 @@
149
156
  chatModel: (function() {
150
157
  try { return localStorage.getItem("wand-chat-model") || ""; } catch (e) { return ""; }
151
158
  })(),
159
+ chatThinking: (function() {
160
+ try {
161
+ var v = localStorage.getItem("wand-thinking-effort") || "off";
162
+ return (v === "off" || v === "standard" || v === "deep" || v === "max") ? v : "off";
163
+ } catch (e) { return "off"; }
164
+ })(),
152
165
  availableModels: [],
153
166
  availableCodexModels: [],
154
167
  modelsRefreshing: false,
@@ -1497,14 +1510,17 @@
1497
1510
  var preferredTool = getComposerTool();
1498
1511
  var composerMode = getSafeModeForTool(preferredTool, state.chatMode);
1499
1512
 
1500
- var isDesktopPinned = state.sidebarPinned && !isMobileLayout();
1501
- var isCollapsed = isDesktopPinned && state.sidebarCollapsed;
1513
+ // 手机端不允许「pin 但不窄条」(300px 固定边栏太占地),只允许窄条形态。
1514
+ // isAnchored = 边栏占据布局空间(推开主内容)。桌面 pin 或 任意端窄条都算 anchored。
1515
+ var isMobile = isMobileLayout();
1516
+ var isCollapsed = !!state.sidebarPinned && !!state.sidebarCollapsed;
1517
+ var isAnchored = isCollapsed || (!!state.sidebarPinned && !isMobile);
1502
1518
  var collapsedCls = isCollapsed ? ' sidebar-collapsed' : '';
1503
1519
  var sidebarCollapsedCls = isCollapsed ? ' collapsed' : '';
1504
1520
  return '<div class="app-container">' +
1505
1521
  '<div id="sessions-drawer-backdrop" class="drawer-backdrop' + drawerClass + '"></div>' +
1506
- '<div class="main-layout' + (state.sessionsDrawerOpen ? ' sidebar-open' : '') + (isDesktopPinned ? ' sidebar-pinned' : '') + collapsedCls + '">' +
1507
- '<aside id="sessions-drawer" class="sidebar' + drawerClass + (isDesktopPinned ? ' pinned' : '') + sidebarCollapsedCls + '">' +
1522
+ '<div class="main-layout' + (state.sessionsDrawerOpen ? ' sidebar-open' : '') + (isAnchored ? ' sidebar-pinned' : '') + collapsedCls + '">' +
1523
+ '<aside id="sessions-drawer" class="sidebar' + drawerClass + (isAnchored ? ' pinned' : '') + sidebarCollapsedCls + '">' +
1508
1524
  '<div class="sidebar-header">' +
1509
1525
  '<div class="sidebar-header-main">' +
1510
1526
  '<div class="topbar-logo-icon">W</div>' +
@@ -1704,6 +1720,11 @@
1704
1720
  '<ul class="todo-progress-list" id="todo-progress-list"></ul>' +
1705
1721
  '</div>' +
1706
1722
  '</div>' +
1723
+ // 排队条宿主:默认 display:none,updateQueueBar() 在 queuedMessages 非空时
1724
+ // 显形。结构上夹在 composer-top-row(todo 进度)和 input-composer(输入框 +
1725
+ // 工具栏)之间,位置正好"在输入框上方、对话框右下角"。所有内容由 updater
1726
+ // 注入;这里只保留稳定的外层骨架,便于 renderAppShell 全量重建后无缝复位。
1727
+ '<div id="queue-bar-host" class="queue-bar-host" hidden></div>' +
1707
1728
  '<div class="input-composer">' +
1708
1729
  '<button id="prompt-optimize-btn" class="prompt-optimize-btn" type="button" title="提示词优化(AI)" aria-label="提示词优化">' +
1709
1730
  '<svg class="prompt-optimize-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
@@ -1724,13 +1745,33 @@
1724
1745
  // tabindex="-1": 把这些控件移出 iOS Safari 的表单导航链,
1725
1746
  // 这样 textarea 聚焦时键盘上方就不会出现 ⌃ ⌄ ✓ 表单辅助栏。
1726
1747
  '<input type="file" id="file-upload-input" multiple tabindex="-1" style="position:absolute;width:1px;height:1px;opacity:0;overflow:hidden;clip:rect(0,0,0,0);pointer-events:none">' +
1727
- '<select id="chat-mode-select" class="chat-mode-select" tabindex="-1" title="仅对新建会话生效">' +
1728
- renderModeOptions(preferredTool, composerMode) +
1729
- '</select>' +
1730
- '<select id="chat-model-select" class="chat-mode-select chat-model-select" tabindex="-1" title="切换模型(对运行中会话发送 /model,对新会话作为 --model 启动)">' +
1731
- renderChatModelOptions(getEffectiveModel(selectedSession), selectedSession) +
1732
- '</select>' +
1733
- '<button id="terminal-interactive-toggle-top" class="composer-interactive-toggle' + (state.terminalInteractive ? " active" : "") + '" type="button" title="切换终端交互模式">⌨</button>' +
1748
+ // 三件套 (Mode / Model / Thinking) 同属"会话设置"层:扁平文字 + · 分隔。
1749
+ // 文字下叠一个透明 <select> 承载交互,桌面端弹原生下拉、移动端弹滚轮选择。
1750
+ // 显示文本用 raw id(如 default / claude-sonnet-4-5 / standard),不做翻译。
1751
+ '<span class="composer-text-group" role="group" aria-label="会话设置">' +
1752
+ '<span class="composer-text-pill" title="模式">' +
1753
+ '<span class="composer-text-label" id="chat-mode-label">' + escapeHtml(composerMode) + '</span>' +
1754
+ '<select id="chat-mode-select" class="composer-text-hidden-select" tabindex="-1" aria-label="模式">' +
1755
+ renderChatModeOptionsRaw(preferredTool, composerMode) +
1756
+ '</select>' +
1757
+ '</span>' +
1758
+ '<span class="composer-text-sep" aria-hidden="true">·</span>' +
1759
+ '<span class="composer-text-pill chat-model-text-pill" title="模型">' +
1760
+ '<span class="composer-text-label" id="chat-model-label">' + escapeHtml(getEffectiveModel(selectedSession) || "default") + '</span>' +
1761
+ '<select id="chat-model-select" class="composer-text-hidden-select" tabindex="-1" aria-label="模型">' +
1762
+ renderChatModelOptionsRaw(getEffectiveModel(selectedSession), selectedSession) +
1763
+ '</select>' +
1764
+ '</span>' +
1765
+ '<span class="composer-text-sep" aria-hidden="true">·</span>' +
1766
+ '<span class="composer-text-pill chat-thinking-text-pill" title="思考深度">' +
1767
+ '<span class="composer-text-label" id="chat-thinking-label">' + escapeHtml(getEffectiveThinking(selectedSession)) + '</span>' +
1768
+ '<select id="chat-thinking-select" class="composer-text-hidden-select" tabindex="-1" aria-label="思考深度">' +
1769
+ renderChatThinkingOptionsRaw(getEffectiveThinking(selectedSession)) +
1770
+ '</select>' +
1771
+ '</span>' +
1772
+ '</span>' +
1773
+ renderAutoApproveChip(selectedSession) +
1774
+ '<button id="terminal-interactive-toggle-top" class="composer-pill composer-pill-chip composer-interactive-toggle' + (state.terminalInteractive ? " active" : "") + '" type="button" title="切换终端交互模式">⌨</button>' +
1734
1775
  '<span class="permission-actions hidden" id="permission-actions">' +
1735
1776
  '<span class="permission-actions-divider"></span>' +
1736
1777
  '<span class="permission-actions-label" id="permission-actions-label">等待授权</span>' +
@@ -1740,7 +1781,7 @@
1740
1781
  renderApprovalStatsBadge() +
1741
1782
  '</div>' +
1742
1783
  '<div class="input-composer-right">' +
1743
- '<span id="queue-counter" class="queue-counter hidden">队列: 0</span>' +
1784
+ // 排队提示从这里搬到 .queue-bar(输入框上方独立浮条),原 #queue-counter 已移除。
1744
1785
  '<span class="input-hint' + (state.terminalInteractive ? ' terminal-interactive-hint' : state.currentView === "terminal" ? " hidden" : "") + '">' + (state.terminalInteractive ? '终端交互中 · Ctrl+C 中断 · Ctrl+L 清屏' : 'Enter 发送 · Shift+Enter 换行') + '</span>' +
1745
1786
  renderInlineKeyboard() +
1746
1787
  '<button id="stop-button" class="btn-circle btn-circle-stop' + (state.selectedId ? "" : " hidden") + '" title="停止">' +
@@ -1748,8 +1789,10 @@
1748
1789
  '</button>' +
1749
1790
  // 结构化模式且正在出 token 时显示:中断当前回复、立刻发送新输入。
1750
1791
  // 默认走 #send-input-button → 排队;想插队的人显式按这颗。
1751
- '<button id="interrupt-send-button" class="btn-circle btn-circle-interrupt hidden" type="button" title="中断当前回复并立即发送" aria-label="立即发送">' +
1752
- '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="13 17 18 12 13 7"/><polyline points="6 17 11 12 6 7"/></svg>' +
1792
+ // pill 形态 + 文字 + 脉动,让用户一眼就看到「立即发送」这条快捷路径。
1793
+ '<button id="interrupt-send-button" class="btn-pill btn-pill-interrupt hidden" type="button" title="中断当前回复并立即发送新输入(Cmd/Ctrl+Enter)" aria-label="立即发送">' +
1794
+ '<svg class="btn-pill-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="13 17 18 12 13 7"/><polyline points="6 17 11 12 6 7"/></svg>' +
1795
+ '<span class="btn-pill-label">立即</span>' +
1753
1796
  '</button>' +
1754
1797
  '<button id="send-input-button" class="btn-circle btn-circle-send" title="发送">' +
1755
1798
  '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>' +
@@ -1757,14 +1800,20 @@
1757
1800
  '</div>' +
1758
1801
  '</div>' +
1759
1802
  renderExpandedShortcutsRow() +
1760
- // Session info bar at bottom — only keeps unique controls/info
1761
- // (cwd / mode / status / kind are already shown in topbar or composer dropdown)
1803
+ // Session info bar at bottom — 仅保留信息类徽章(Claude session id / exit code)。
1804
+ // 自动批准已从这里移到主 pill 行(renderAutoApproveChip)。
1762
1805
  (selectedSession
1763
- ? '<div class="input-session-info-bar">' +
1764
- (selectedSession.autoApprovePermissions ? '<span id="auto-approve-toggle" class="auto-approve-indicator active" title="自动批准已启用 — 点击关闭">🛡 自动批准</span>' : '<span id="auto-approve-toggle" class="auto-approve-indicator" title="自动批准已关闭 — 点击开启">🛡 手动</span>') +
1765
- (selectedSession.provider === "claude" && selectedSession.claudeSessionId ? '<span class="session-info-separator">|</span><span id="claude-session-id-badge" class="claude-session-id-badge" data-claude-id="' + escapeHtml(selectedSession.claudeSessionId) + '" title="点击复制 Claude 会话 ID">☁ ' + escapeHtml(selectedSession.claudeSessionId.slice(0, 8)) + '</span>' : '') +
1766
- (!isStructuredSession(selectedSession) && selectedSession.exitCode !== undefined && selectedSession.exitCode !== null ? '<span class="session-info-separator">|</span><span id="session-exit-display" class="session-exit-display">退出码=' + selectedSession.exitCode + '</span>' : '') +
1767
- '</div>'
1806
+ ? (function() {
1807
+ var bits = "";
1808
+ if (selectedSession.provider === "claude" && selectedSession.claudeSessionId) {
1809
+ bits += '<span id="claude-session-id-badge" class="claude-session-id-badge" data-claude-id="' + escapeHtml(selectedSession.claudeSessionId) + '" title="点击复制 Claude 会话 ID">☁ ' + escapeHtml(selectedSession.claudeSessionId.slice(0, 8)) + '</span>';
1810
+ }
1811
+ if (!isStructuredSession(selectedSession) && selectedSession.exitCode !== undefined && selectedSession.exitCode !== null) {
1812
+ if (bits) bits += '<span class="session-info-separator">|</span>';
1813
+ bits += '<span id="session-exit-display" class="session-exit-display">退出码=' + selectedSession.exitCode + '</span>';
1814
+ }
1815
+ return bits ? '<div class="input-session-info-bar">' + bits + '</div>' : '';
1816
+ })()
1768
1817
  : '') +
1769
1818
  '</div>' +
1770
1819
  '<p id="action-error" class="error-message hidden"></p>' +
@@ -3213,7 +3262,9 @@
3213
3262
  }
3214
3263
 
3215
3264
  function isSidebarNarrow() {
3216
- return !!state.sidebarPinned && !isMobileLayout() && !!state.sidebarCollapsed;
3265
+ // 桌面: pinned + collapsed = 56px 窄条。
3266
+ // 手机: pinned + collapsed 同样允许窄条(pin 单独不在手机生效,但 collapsed 是窄条形态的标志)。
3267
+ return !!state.sidebarPinned && !!state.sidebarCollapsed;
3217
3268
  }
3218
3269
 
3219
3270
  function renderCollapsedSessionTiles() {
@@ -5291,7 +5342,8 @@
5291
5342
  '</div>' +
5292
5343
  '<div class="field-hint session-kind-hint-row">' +
5293
5344
  '<span id="session-kind-description">' + escapeHtml(getSessionKindHint(sessionKind)) + '</span>' +
5294
- renderWorktreeToggle(worktreeEnabled) +
5345
+ // Worktree 模式入口暂时隐藏,保留 renderWorktreeToggle/state.sessionCreateWorktree 以便后续恢复
5346
+ // renderWorktreeToggle(worktreeEnabled) +
5295
5347
  '</div>' +
5296
5348
  '</div>' +
5297
5349
  '<div class="field">' +
@@ -5406,6 +5458,36 @@
5406
5458
  }
5407
5459
  persistElementExpandState(el, "thinking");
5408
5460
  };
5461
+ // Toggle function for subagent reply bubbles — cycles preview → expanded → collapsed.
5462
+ // 三态循环(preview 默认 ~5 行可滚 / expanded 大区可滚 / collapsed 完全收起)。
5463
+ window.__subagentReplyCycle = function(e, btn) {
5464
+ if (e) { e.preventDefault(); e.stopPropagation(); }
5465
+ var bubble = btn.closest(".subagent-reply");
5466
+ if (!bubble) return;
5467
+ var modes = ["preview", "expanded", "collapsed"];
5468
+ var current = bubble.getAttribute("data-collapse-mode") || "preview";
5469
+ var idx = modes.indexOf(current);
5470
+ if (idx < 0) idx = 0;
5471
+ var next = modes[(idx + 1) % modes.length];
5472
+ bubble.setAttribute("data-collapse-mode", next);
5473
+ var label = btn.querySelector(".subagent-reply-cycle-label");
5474
+ var icon = btn.querySelector(".subagent-reply-cycle-icon");
5475
+ if (label) {
5476
+ label.textContent = next === "preview" ? "展开"
5477
+ : next === "expanded" ? "收起"
5478
+ : "预览";
5479
+ }
5480
+ if (icon) {
5481
+ icon.textContent = next === "collapsed" ? "▸"
5482
+ : next === "expanded" ? "▴"
5483
+ : "▾";
5484
+ }
5485
+ btn.setAttribute("aria-label",
5486
+ next === "preview" ? "点击展开全部" :
5487
+ next === "expanded" ? "点击完全收起" :
5488
+ "点击切回预览"
5489
+ );
5490
+ };
5409
5491
  // Toggle function for inline tool rows (Read, Glob, Grep, etc.)
5410
5492
  window.__inlineToolToggle = function(el) {
5411
5493
  var expanded = el.classList.toggle("inline-tool-open");
@@ -5982,12 +6064,18 @@
5982
6064
  var modeSelect = document.getElementById("chat-mode-select");
5983
6065
  if (modeSelect) modeSelect.addEventListener("change", function() {
5984
6066
  state.chatMode = this.value;
5985
- showToast("新会话模式已切换为:" + getModeLabel(this.value), "info");
6067
+ var label = document.getElementById("chat-mode-label");
6068
+ if (label) label.textContent = this.value;
6069
+ showToast("新会话模式:" + this.value, "info");
5986
6070
  });
5987
6071
  var modelSelect = document.getElementById("chat-model-select");
5988
6072
  if (modelSelect) modelSelect.addEventListener("change", function() {
5989
6073
  onChatModelChange(this.value);
5990
6074
  });
6075
+ var thinkingSelect = document.getElementById("chat-thinking-select");
6076
+ if (thinkingSelect) thinkingSelect.addEventListener("change", function() {
6077
+ onChatThinkingChange(this.value);
6078
+ });
5991
6079
 
5992
6080
  var sessionModal = document.getElementById("session-modal");
5993
6081
  if (sessionModal) sessionModal.addEventListener("click", function(e) {
@@ -6690,6 +6778,16 @@
6690
6778
  initTerminal();
6691
6779
  setupMobileKeyboardHandlers();
6692
6780
  setupVisualViewportHandlers();
6781
+
6782
+ // 排队条:每次 shell 重渲后,重新挂事件代理 + 刷新内容。
6783
+ // document-level 的 ESC / 外点击 handler 只挂一次(state.__queueBarGlobalAttached 守门)。
6784
+ attachQueueBarDelegates();
6785
+ updateQueueBar();
6786
+ if (!state.__queueBarGlobalAttached) {
6787
+ state.__queueBarGlobalAttached = true;
6788
+ document.addEventListener("pointerdown", handleQueueBarOutsideClick, true);
6789
+ document.addEventListener("keydown", handleQueueBarKeydown, true);
6790
+ }
6693
6791
  }
6694
6792
 
6695
6793
  function saveWorkingDir(path) {
@@ -7996,7 +8094,7 @@
7996
8094
  // 结构化会话在出 token 时,输入框仍然可用——告诉用户默认行为是排队,
7997
8095
  // 想插队请按右侧的 » 按钮。短语保持单行不换行。
7998
8096
  if (isStructuredSession(session) && session.structuredState && session.structuredState.inFlight) {
7999
- return "回复中…继续输入将排队(» 立即发送)";
8097
+ return "回复中…Enter 排队 · 旁边的「» 立即」按钮中断并立即发送";
8000
8098
  }
8001
8099
  return "";
8002
8100
  }
@@ -8092,14 +8190,26 @@
8092
8190
 
8093
8191
  function syncComposerModeSelect() {
8094
8192
  var select = document.getElementById("chat-mode-select");
8095
- if (!select) return;
8096
8193
  state.chatMode = getSafeModeForTool("claude", state.chatMode);
8097
- select.innerHTML = renderModeOptions("claude", state.chatMode);
8098
- select.value = state.chatMode;
8194
+ if (select) {
8195
+ select.innerHTML = renderChatModeOptionsRaw("claude", state.chatMode);
8196
+ select.value = state.chatMode;
8197
+ }
8198
+ var labelEl = document.getElementById("chat-mode-label");
8199
+ if (labelEl) labelEl.textContent = state.chatMode;
8099
8200
  var modeHint = document.getElementById("mode-hint");
8100
8201
  if (modeHint) modeHint.textContent = getModeHint(state.chatMode);
8101
8202
  }
8102
8203
 
8204
+ // 三件套 raw 选项渲染:option 文本直接是 id(不带括号注释 / 不本地化)。
8205
+ function renderChatModeOptionsRaw(tool, selectedMode) {
8206
+ return getSupportedModes(tool).map(function(mode) {
8207
+ return '<option value="' + escapeHtml(mode) + '"' + (mode === selectedMode ? " selected" : "") + '>' +
8208
+ escapeHtml(mode) +
8209
+ '</option>';
8210
+ }).join("");
8211
+ }
8212
+
8103
8213
  function getEffectiveModel(session) {
8104
8214
  if (session && session.selectedModel) return session.selectedModel;
8105
8215
  if (state.chatModel) return state.chatModel;
@@ -8127,12 +8237,139 @@
8127
8237
  return html;
8128
8238
  }
8129
8239
 
8240
+ // model 选项 raw 版:空值显示 "default",其它直接用 raw id(不带"(自定义)"等后缀)。
8241
+ function renderChatModelOptionsRaw(selected, session) {
8242
+ var models = getModelsForCurrentProvider(session);
8243
+ var html = '<option value="">default</option>';
8244
+ for (var i = 0; i < models.length; i++) {
8245
+ var m = models[i];
8246
+ html += '<option value="' + escapeHtml(m.id) + '"' + (m.id === selected ? " selected" : "") + '>' + escapeHtml(m.id) + '</option>';
8247
+ }
8248
+ if (selected && !models.some(function(m) { return m.id === selected; })) {
8249
+ html += '<option value="' + escapeHtml(selected) + '" selected>' + escapeHtml(selected) + '</option>';
8250
+ }
8251
+ return html;
8252
+ }
8253
+
8130
8254
  function syncComposerModelSelect(session) {
8131
8255
  var select = document.getElementById("chat-model-select");
8132
- if (!select) return;
8133
8256
  var effective = getEffectiveModel(session);
8134
- select.innerHTML = renderChatModelOptions(effective, session);
8135
- select.value = effective;
8257
+ if (select) {
8258
+ select.innerHTML = renderChatModelOptionsRaw(effective, session);
8259
+ select.value = effective;
8260
+ }
8261
+ var labelEl = document.getElementById("chat-model-label");
8262
+ if (labelEl) labelEl.textContent = effective || "default";
8263
+ // thinking 选择器与 model 选择器属于同一组"会话级设置",
8264
+ // 任何刷新 model 的时机也应该同步刷新 thinking,避免漂移。
8265
+ syncComposerThinkingSelect(session);
8266
+ }
8267
+
8268
+ // ── 思考深度 (thinkingEffort) —— 与 model 选择三件套对称 ──
8269
+
8270
+ // 标签直接用 Claude CLI 原生 magic word:think / think hard / ultrathink。
8271
+ // 这样用户一眼能对上官方文档里的思考强度档位,PTY 模式下也是这几个词被注入到 prompt 前缀。
8272
+ var THINKING_LEVELS = [
8273
+ { id: "off", label: "off", hint: "不启用思考(CLI 无前缀;SDK 关闭 thinking;Codex --reasoning-effort minimal)" },
8274
+ { id: "standard", label: "think", hint: "Claude CLI: think · SDK budget 4096 · Codex low" },
8275
+ { id: "deep", label: "think hard", hint: "Claude CLI: think hard · SDK budget 16000 · Codex medium" },
8276
+ { id: "max", label: "ultrathink", hint: "Claude CLI: ultrathink · SDK budget 31999 · Codex high" }
8277
+ ];
8278
+
8279
+ function getThinkingLabel(id) {
8280
+ for (var i = 0; i < THINKING_LEVELS.length; i++) {
8281
+ if (THINKING_LEVELS[i].id === id) return THINKING_LEVELS[i].label;
8282
+ }
8283
+ return THINKING_LEVELS[0].label;
8284
+ }
8285
+
8286
+ function getEffectiveThinking(session) {
8287
+ if (session && session.thinkingEffort) return session.thinkingEffort;
8288
+ if (state.chatThinking) return state.chatThinking;
8289
+ return "off";
8290
+ }
8291
+
8292
+ function renderChatThinkingOptions(selected) {
8293
+ var v = selected || "off";
8294
+ var html = "";
8295
+ for (var i = 0; i < THINKING_LEVELS.length; i++) {
8296
+ var lvl = THINKING_LEVELS[i];
8297
+ html += '<option value="' + escapeHtml(lvl.id) + '"' + (lvl.id === v ? ' selected' : '') + ' title="' + escapeHtml(lvl.hint) + '">' + escapeHtml(lvl.label) + '</option>';
8298
+ }
8299
+ return html;
8300
+ }
8301
+
8302
+ // thinking 选项 raw 版:option 文本直接是 id(off / standard / deep / max)。
8303
+ function renderChatThinkingOptionsRaw(selected) {
8304
+ var v = selected || "off";
8305
+ var html = "";
8306
+ for (var i = 0; i < THINKING_LEVELS.length; i++) {
8307
+ var lvl = THINKING_LEVELS[i];
8308
+ html += '<option value="' + escapeHtml(lvl.id) + '"' + (lvl.id === v ? ' selected' : '') + '>' + escapeHtml(lvl.id) + '</option>';
8309
+ }
8310
+ return html;
8311
+ }
8312
+
8313
+ function syncComposerThinkingSelect(session) {
8314
+ var select = document.getElementById("chat-thinking-select");
8315
+ var effective = getEffectiveThinking(session);
8316
+ if (select) {
8317
+ select.innerHTML = renderChatThinkingOptionsRaw(effective);
8318
+ select.value = effective;
8319
+ }
8320
+ var labelEl = document.getElementById("chat-thinking-label");
8321
+ if (labelEl) labelEl.textContent = effective;
8322
+ }
8323
+
8324
+ function onChatThinkingChange(value) {
8325
+ var normalized = (value || "off").trim();
8326
+ if (normalized !== "off" && normalized !== "standard" && normalized !== "deep" && normalized !== "max") {
8327
+ normalized = "off";
8328
+ }
8329
+ state.chatThinking = normalized;
8330
+ try { localStorage.setItem("wand-thinking-effort", normalized); } catch (e) {}
8331
+ var labelEl = document.getElementById("chat-thinking-label");
8332
+ if (labelEl) labelEl.textContent = normalized;
8333
+ var session = getSelectedSession();
8334
+ if (!session) return;
8335
+ fetch("/api/sessions/" + encodeURIComponent(session.id) + "/thinking-effort", {
8336
+ method: "POST",
8337
+ headers: { "Content-Type": "application/json" },
8338
+ credentials: "same-origin",
8339
+ body: JSON.stringify({ thinkingEffort: normalized })
8340
+ })
8341
+ .then(function(res) { return res.json(); })
8342
+ .then(function(data) {
8343
+ if (data && data.error) {
8344
+ showToast(data.error, "error");
8345
+ return;
8346
+ }
8347
+ if (data && data.id) {
8348
+ updateSessionSnapshot(data);
8349
+ if (typeof showToast === "function") {
8350
+ showToast("已切换思考深度 → " + getThinkingLabel(normalized), "success");
8351
+ }
8352
+ }
8353
+ })
8354
+ .catch(function() { showToast("切换思考深度失败", "error"); });
8355
+ }
8356
+
8357
+ // 自动批准 chip:与原 .auto-approve-indicator 等价,但用统一的 .composer-pill 风格放主行。
8358
+ // Codex 会话固定全权限不可切;结构化 Claude 会话后端 toggle-auto-approve 路由会拒绝。
8359
+ // 当会话已经处于 managed / full-access 模式时,"自动批准"语义已经由模式表达,
8360
+ // 重复显示一个独立 chip 只会占用空间又制造歧义 —— 此时直接折叠掉。
8361
+ function isAutoApproveImpliedByMode(session) {
8362
+ if (!session) return false;
8363
+ var m = session.mode;
8364
+ return m === "managed" || m === "full-access";
8365
+ }
8366
+ function renderAutoApproveChip(session) {
8367
+ if (!session) return "";
8368
+ if (isAutoApproveImpliedByMode(session)) return "";
8369
+ var enabled = !!session.autoApprovePermissions;
8370
+ return enabled
8371
+ ? '<span id="auto-approve-toggle" class="composer-pill composer-pill-chip auto-approve-indicator active" title="自动批准已启用 — 点击关闭">🛡 自动</span>'
8372
+ : '<span id="auto-approve-toggle" class="composer-pill composer-pill-chip auto-approve-indicator" title="自动批准已关闭 — 点击开启">🛡 手动</span>';
8136
8373
  }
8137
8374
 
8138
8375
  function fetchAvailableModels() {
@@ -8341,6 +8578,8 @@
8341
8578
  var normalized = (value || "").trim();
8342
8579
  state.chatModel = normalized;
8343
8580
  try { localStorage.setItem("wand-chat-model", normalized); } catch (e) {}
8581
+ var labelEl = document.getElementById("chat-model-label");
8582
+ if (labelEl) labelEl.textContent = normalized || "default";
8344
8583
  var session = getSelectedSession();
8345
8584
  if (!session) return;
8346
8585
  fetch("/api/sessions/" + encodeURIComponent(session.id) + "/model", {
@@ -8370,6 +8609,7 @@
8370
8609
  function createStructuredSession(prompt, cwdOverride, modeOverride, worktreeEnabled) {
8371
8610
  var provider = state.sessionTool === "codex" ? "codex" : "claude";
8372
8611
  var modelPref = state.chatModel || (state.config && state.config.defaultModel) || "";
8612
+ var thinkingPref = state.chatThinking || "off";
8373
8613
  var payload = {
8374
8614
  cwd: cwdOverride || getEffectiveCwd(),
8375
8615
  mode: modeOverride || state.chatMode || (state.config && state.config.defaultMode) || "default",
@@ -8377,7 +8617,8 @@
8377
8617
  runner: provider === "codex" ? "codex-cli-exec" : ((state.config && state.config.structuredRunner === "sdk") ? "claude-sdk" : (state.structuredRunner || "claude-cli-print")),
8378
8618
  prompt: prompt || undefined,
8379
8619
  worktreeEnabled: worktreeEnabled === true,
8380
- model: modelPref || undefined
8620
+ model: modelPref || undefined,
8621
+ thinkingEffort: thinkingPref
8381
8622
  };
8382
8623
  console.log("[WAND] createStructuredSession payload:", JSON.stringify(payload));
8383
8624
  return fetch("/api/structured-sessions", {
@@ -8954,14 +9195,16 @@
8954
9195
  var drawer = document.getElementById("sessions-drawer");
8955
9196
  var mainLayout = document.querySelector(".main-layout");
8956
9197
  var pinBtn = document.getElementById("sidebar-pin-btn");
8957
- var isDesktopPinned = state.sidebarPinned && !isMobileLayout();
8958
- var isCollapsed = isDesktopPinned && state.sidebarCollapsed;
9198
+ // renderAppShell 保持一致:手机端只允许窄条形态 anchored。
9199
+ var isMobile = isMobileLayout();
9200
+ var isCollapsed = !!state.sidebarPinned && !!state.sidebarCollapsed;
9201
+ var isAnchored = isCollapsed || (!!state.sidebarPinned && !isMobile);
8959
9202
  if (drawer) {
8960
- drawer.classList.toggle("pinned", isDesktopPinned);
9203
+ drawer.classList.toggle("pinned", isAnchored);
8961
9204
  drawer.classList.toggle("collapsed", isCollapsed);
8962
9205
  }
8963
9206
  if (mainLayout) {
8964
- mainLayout.classList.toggle("sidebar-pinned", isDesktopPinned);
9207
+ mainLayout.classList.toggle("sidebar-pinned", isAnchored);
8965
9208
  mainLayout.classList.toggle("sidebar-collapsed", isCollapsed);
8966
9209
  }
8967
9210
  if (pinBtn) {
@@ -9074,12 +9317,11 @@
9074
9317
  }
9075
9318
 
9076
9319
  function toggleSidebarCollapsed() {
9077
- if (isMobileLayout()) return;
9320
+ var isMobile = isMobileLayout();
9078
9321
  // 在 drawer 模式(未 pin)下点 collapse 视为「先固定、再收起为窄条」——
9079
9322
  // 用户直觉是「点了就该看到窄条」,过去这里 early return 让按钮看上去没反应。
9080
9323
  if (!state.sidebarPinned) {
9081
9324
  state.sidebarPinned = true;
9082
- state.sessionsDrawerOpen = true;
9083
9325
  try {
9084
9326
  localStorage.setItem("wand-sidebar-pinned", "true");
9085
9327
  } catch (e) {}
@@ -9088,6 +9330,22 @@
9088
9330
  try {
9089
9331
  localStorage.setItem("wand-sidebar-collapsed", String(state.sidebarCollapsed));
9090
9332
  } catch (e) {}
9333
+ if (state.sidebarCollapsed) {
9334
+ // 进入窄条形态:sessionsDrawerOpen 设 false,避免手机上 .drawer-backdrop
9335
+ // 仍带 .open 类导致背景遮罩误显示(窄条已经常驻显示,不需要遮罩)。
9336
+ state.sessionsDrawerOpen = false;
9337
+ } else if (isMobile) {
9338
+ // 手机端展开窄条:不允许「pin 但不窄条」的 300px 全栏(太占地),
9339
+ // 改为回到 drawer 模式并自动打开抽屉,让用户看到完整会话列表。
9340
+ state.sidebarPinned = false;
9341
+ state.sessionsDrawerOpen = true;
9342
+ try {
9343
+ localStorage.setItem("wand-sidebar-pinned", "false");
9344
+ } catch (e) {}
9345
+ } else {
9346
+ // 桌面端展开窄条 → 300px 全栏固定,自动打开。
9347
+ state.sessionsDrawerOpen = true;
9348
+ }
9091
9349
  render();
9092
9350
  var mainLayout = document.querySelector(".main-layout");
9093
9351
  if (mainLayout) {
@@ -11916,8 +12174,14 @@
11916
12174
  return Promise.resolve();
11917
12175
  }
11918
12176
 
11919
- // 防止同一会话并发提交(快速双击 / 重复触发)
11920
- var _structuredSubmittingSessions = {};
12177
+ // 防止同一会话「快速双击 / 重复触发」。原来这是个布尔 flag,绑在 fetch 的
12178
+ // promise —— 但 structured-sessions/:id/messages 的 POST 对首条消息会 await
12179
+ // 整段流式 streaming,flag 会被卡到回复完才释放。结果:用户点发送 → 服务端
12180
+ // 流式 30s 不响应 → 这 30s 里再点发送全被这里静默 drop,看起来"排队 / 立即发送
12181
+ // 都没效果"。改成时间戳 + 短窗口(350ms)只挡真正的连击。idempotencyKey 已经
12182
+ // 在后端兜底防 webview 网络层重发,这里的 hot-path 守门只需要应付 UI 双触发。
12183
+ var _structuredLastSubmitAt = {};
12184
+ var DUPLICATE_SUBMIT_WINDOW_MS = 350;
11921
12185
 
11922
12186
  function postStructuredInput(input, inputBox, session, opts) {
11923
12187
  opts = opts || {};
@@ -11931,11 +12195,15 @@
11931
12195
  showToast("会话不存在,请重新选择或新建会话。", "error");
11932
12196
  return Promise.resolve();
11933
12197
  }
11934
- // 同一会话的上一次提交尚未落地,直接忽略防止重复发送
11935
- if (_structuredSubmittingSessions[session.id]) {
11936
- console.log("[wand] postStructuredInput: duplicate submit ignored for session", session.id);
12198
+ // 短窗口内的连击当作重复点击丢掉;正常间隔的两次提交(哪怕第一次还在流式)
12199
+ // 都放行,让 queue / interrupt 真正生效。
12200
+ var nowTs = Date.now();
12201
+ var lastTs = _structuredLastSubmitAt[session.id] || 0;
12202
+ if (nowTs - lastTs < DUPLICATE_SUBMIT_WINDOW_MS) {
12203
+ console.log("[wand] postStructuredInput: duplicate submit (within " + DUPLICATE_SUBMIT_WINDOW_MS + "ms) ignored for session", session.id);
11937
12204
  return Promise.resolve();
11938
12205
  }
12206
+ _structuredLastSubmitAt[session.id] = nowTs;
11939
12207
 
11940
12208
  var sessionInFlight = !!(session.structuredState && session.structuredState.inFlight && session.status === "running");
11941
12209
  var isInterrupting = sessionInFlight && requestedInterrupt;
@@ -11960,6 +12228,9 @@
11960
12228
  updateInputHint("已加入排队,等待当前回复完成…");
11961
12229
  renderChat(true);
11962
12230
  updateStructuredQueueCounter();
12231
+ // 乐观 toast:原本只在 POST 完成后才提示,Claude 流式拖太久时用户根本
12232
+ // 看不到反馈,会误判"点了没反应"。点击瞬间就给一条短提示。
12233
+ showToast(nextQueue.length > 1 ? ("已加入排队(共 " + nextQueue.length + " 条等待)") : "已加入排队,等当前回复完成会自动发送。", "info");
11963
12234
  } else {
11964
12235
  // 普通发送 / interrupt 发送:照旧乐观推 user turn + inFlight=true
11965
12236
  var userTurn = { role: "user", content: [{ type: "text", text: input }] };
@@ -11978,6 +12249,11 @@
11978
12249
  }), userMsgs);
11979
12250
  updateInputHint(isInterrupting ? "已中断,正在处理新消息…" : "思考中…");
11980
12251
  renderChat(true);
12252
+ // 中断模式:乐观给一条提示,让用户立刻知道"中断成功了",否则跟 queue 一样会
12253
+ // 觉得"点了没反应"。原 toast 在 then() 里,等 SIGTERM/HTTP roundtrip 完才出。
12254
+ if (isInterrupting) {
12255
+ showToast("已中断上一条回复,正在处理新消息…", "info");
12256
+ }
11981
12257
  }
11982
12258
 
11983
12259
  if (inputBox) {
@@ -12001,7 +12277,6 @@
12001
12277
 
12002
12278
  // 用 session.id(参数绑定,in-flight 期间不变)而不是 state.selectedId
12003
12279
  // 拼 URL,避免用户切到别的会话后 fetch 落到错误 sessionId。
12004
- _structuredSubmittingSessions[session.id] = true;
12005
12280
  return fetch("/api/structured-sessions/" + session.id + "/messages", {
12006
12281
  method: "POST",
12007
12282
  headers: { "Content-Type": "application/json" },
@@ -12020,7 +12295,6 @@
12020
12295
  return res.json();
12021
12296
  })
12022
12297
  .then(function(snapshot) {
12023
- _structuredSubmittingSessions[session.id] = false;
12024
12298
  if (snapshot && snapshot.error) {
12025
12299
  throw new Error(snapshot.error);
12026
12300
  }
@@ -12035,18 +12309,12 @@
12035
12309
  state.currentMessages = buildMessagesForRender(refreshedSession, getPreferredMessages(refreshedSession, snapshot.output, false));
12036
12310
  renderChat(true);
12037
12311
  updateStructuredQueueCounter();
12038
- if (isInterrupting) {
12039
- showToast("已中断上一条回复,正在处理新消息…", "info");
12040
- } else if (isQueueing) {
12041
- var qLen = Array.isArray(refreshedSession.queuedMessages) ? refreshedSession.queuedMessages.length : 0;
12042
- showToast(qLen > 1 ? ("已加入排队(共 " + qLen + " 条等待)") : "已加入排队,等待当前回复完成。", "info");
12043
- }
12312
+ // toast 已在 click 时乐观 fire(见 isQueueing / isInterrupting 分支),
12313
+ // 这里不再重复推送,避免同一动作出两条一样的 toast。
12044
12314
  }
12045
12315
  }
12046
12316
  })
12047
12317
  .catch(function(error) {
12048
- _structuredSubmittingSessions[session.id] = false;
12049
-
12050
12318
  // duplicate_idempotency_key:服务端识别出 WebView 底层重发的副本,
12051
12319
  // 直接拦截不处理。这里**不**回滚乐观更新——第一次的请求实际上已经
12052
12320
  // 被服务端接收并处理(或正在处理),ws 推送会带回真实状态;如果在
@@ -12107,16 +12375,507 @@
12107
12375
  }
12108
12376
 
12109
12377
  function updateStructuredQueueCounter() {
12110
- var counter = document.getElementById("queue-counter");
12111
- var count = getSelectedStructuredQueuedInputs().length;
12112
- if (counter) {
12113
- counter.textContent = "队列: " + count;
12114
- if (count > 0) {
12115
- counter.classList.remove("hidden");
12116
- } else {
12117
- counter.classList.add("hidden");
12378
+ // 旧 #queue-counter 已下线,所有"排队"提示由 .queue-bar(输入框上方独立浮条)承担。
12379
+ // 函数名先保留 —— 老的调用点(postStructuredInput / WS 事件等)都还在指向它。
12380
+ updateQueueBar();
12381
+ }
12382
+
12383
+ // ──────────────────────────────────────────────────────────────────────────
12384
+ // 排队条(.queue-bar)—— 输入框上方独立浮条,承担三个事情:
12385
+ // 1) 折叠态:● 排队 N + 队尾预览 + ⌃ chevron + ⚡ 立即 按钮
12386
+ // 2) 展开面板:列出所有排队消息,支持拖拽换序 / 单条删除 / 一键清空
12387
+ // 3) 立即按钮:中断当前回复,把队首作为新消息插队发出去(剩余队列保留)
12388
+ // 数据源:session.queuedMessages(由后端 WS 推送 + postStructuredInput 乐观更新)。
12389
+ // ──────────────────────────────────────────────────────────────────────────
12390
+
12391
+ var QUEUE_BAR_MAX = 10; // 后端硬上限
12392
+
12393
+ function queueBarTruncatePreview(text) {
12394
+ if (typeof text !== "string") return "";
12395
+ var s = text.replace(/\s+/g, " ").trim();
12396
+ if (s.length <= 48) return s;
12397
+ return s.slice(0, 46) + "…";
12398
+ }
12399
+
12400
+ function renderQueueBarSkeleton(count, latestPreview, inFlight, atCapacity, immediateLabel) {
12401
+ // 折叠条 + 展开面板的 HTML 一次性渲染好,靠 .queue-bar.expanded class 切换可见性。
12402
+ // 这样展开/收起不需要拼字符串,纯 class toggle,动画也好做。
12403
+ var dotClass = inFlight ? "queue-bar-dot queue-bar-dot-pulse" : "queue-bar-dot";
12404
+ var barClass = "queue-bar";
12405
+ if (state.queueBarExpanded) barClass += " expanded";
12406
+ if (atCapacity) barClass += " queue-bar-capacity";
12407
+ if (inFlight) barClass += " queue-bar-inflight";
12408
+ var html =
12409
+ '<div class="' + barClass + '" data-queue-bar="1">' +
12410
+ '<button type="button" class="queue-bar-toggle" data-action="toggle"' +
12411
+ ' aria-expanded="' + (state.queueBarExpanded ? "true" : "false") + '"' +
12412
+ ' title="点击查看 / 收起排队消息">' +
12413
+ '<span class="' + dotClass + '" aria-hidden="true"></span>' +
12414
+ '<span class="queue-bar-count">' + (atCapacity ? "队列已满 " : "排队 ") + count + '</span>' +
12415
+ '<span class="queue-bar-sep" aria-hidden="true">·</span>' +
12416
+ '<span class="queue-bar-preview">' + escapeHtml(latestPreview) + '</span>' +
12417
+ '<svg class="queue-bar-chevron" width="11" height="11" viewBox="0 0 24 24"' +
12418
+ ' fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"' +
12419
+ ' stroke-linejoin="round" aria-hidden="true"><polyline points="6 15 12 9 18 15"/></svg>' +
12420
+ '</button>' +
12421
+ '<span class="queue-bar-divider" aria-hidden="true"></span>' +
12422
+ '<button type="button" class="queue-bar-promote" data-action="promote"' +
12423
+ ' title="中断当前回复,立刻发送队首这条" aria-label="立即发送队首">' +
12424
+ '<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">' +
12425
+ '<path d="M13 2 L4 14 L11 14 L10 22 L20 9 L13 9 Z"/>' +
12426
+ '</svg>' +
12427
+ '<span class="queue-bar-promote-label">' + escapeHtml(immediateLabel) + '</span>' +
12428
+ '</button>' +
12429
+ '<div class="queue-bar-panel" data-queue-panel="1" role="region" aria-label="排队消息列表">' +
12430
+ '<div class="queue-bar-panel-header">' +
12431
+ '<span class="queue-bar-panel-title">📥 排队中 (' + count + ')</span>' +
12432
+ '<button type="button" class="queue-bar-clear" data-action="clear"' +
12433
+ (count === 0 ? " disabled" : "") + '>清空</button>' +
12434
+ '<button type="button" class="queue-bar-collapse" data-action="collapse" aria-label="收起">' +
12435
+ '收起' +
12436
+ '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor"' +
12437
+ ' stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
12438
+ '<polyline points="6 9 12 15 18 9"/></svg>' +
12439
+ '</button>' +
12440
+ '</div>' +
12441
+ '<ol class="queue-bar-list" data-queue-list="1"></ol>' +
12442
+ '</div>' +
12443
+ '</div>';
12444
+ return html;
12445
+ }
12446
+
12447
+ function renderQueueBarItems(listEl, items) {
12448
+ // ol 内容单独 render —— 拖拽 / 删除 / 展开会频繁动它,外层骨架不重建避免抖动。
12449
+ var single = items.length <= 1;
12450
+ var html = "";
12451
+ for (var i = 0; i < items.length; i++) {
12452
+ var raw = items[i] == null ? "" : String(items[i]);
12453
+ var expanded = !!state.queueBarItemExpanded[i];
12454
+ var itemClass = "queue-bar-item";
12455
+ if (expanded) itemClass += " expanded";
12456
+ if (single) itemClass += " queue-bar-item-single";
12457
+ html +=
12458
+ '<li class="' + itemClass + '" data-index="' + i + '">' +
12459
+ '<button type="button" class="queue-bar-item-drag" data-action="drag" aria-label="拖动调整顺序"' +
12460
+ ' title="按住拖动调整顺序"' + (single ? " disabled" : "") + '>' +
12461
+ '<svg width="10" height="14" viewBox="0 0 10 14" fill="currentColor" aria-hidden="true">' +
12462
+ '<circle cx="2.2" cy="2.2" r="1.2"/><circle cx="7.8" cy="2.2" r="1.2"/>' +
12463
+ '<circle cx="2.2" cy="7" r="1.2"/><circle cx="7.8" cy="7" r="1.2"/>' +
12464
+ '<circle cx="2.2" cy="11.8" r="1.2"/><circle cx="7.8" cy="11.8" r="1.2"/>' +
12465
+ '</svg>' +
12466
+ '</button>' +
12467
+ '<span class="queue-bar-item-index">#' + (i + 1) + '</span>' +
12468
+ '<button type="button" class="queue-bar-item-text" data-action="expand-text"' +
12469
+ ' aria-expanded="' + (expanded ? "true" : "false") + '"' +
12470
+ ' title="点击展开 / 收起完整内容">' +
12471
+ escapeHtml(raw) +
12472
+ '</button>' +
12473
+ '<button type="button" class="queue-bar-item-delete" data-action="delete"' +
12474
+ ' aria-label="删除这条排队消息" title="删除">' +
12475
+ '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor"' +
12476
+ ' stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
12477
+ '<line x1="6" y1="6" x2="18" y2="18"/><line x1="6" y1="18" x2="18" y2="6"/></svg>' +
12478
+ '</button>' +
12479
+ '</li>';
12480
+ }
12481
+ listEl.innerHTML = html;
12482
+ }
12483
+
12484
+ function updateQueueBar() {
12485
+ var host = document.getElementById("queue-bar-host");
12486
+ if (!host) return;
12487
+ var session = state.sessions.find(function(s) { return s.id === state.selectedId; });
12488
+ var isStructured = session && session.sessionKind === "structured";
12489
+ var queue = isStructured ? getStructuredQueuedInputs(session) : [];
12490
+ queue = Array.isArray(queue) ? queue : [];
12491
+
12492
+ if (!isStructured || queue.length === 0) {
12493
+ // 队列空 / 非结构化会话:整条隐藏,并清掉展开/逐条展开的本地态。
12494
+ host.hidden = true;
12495
+ host.innerHTML = "";
12496
+ state.queueBarExpanded = false;
12497
+ state.queueBarItemExpanded = {};
12498
+ return;
12499
+ }
12500
+
12501
+ host.hidden = false;
12502
+ var inFlight = !!(session.structuredState && session.structuredState.inFlight && session.status === "running");
12503
+ var atCapacity = queue.length >= QUEUE_BAR_MAX;
12504
+ var latest = queueBarTruncatePreview(queue[queue.length - 1]);
12505
+ // inFlight=false 时按钮语义从"插队"退化为"立刻发";文案一并切换让用户不疑惑。
12506
+ var immediateLabel = inFlight ? "立即" : "发送";
12507
+
12508
+ // 拖拽进行中绝不重建骨架,否则 pointer capture 丢失、items 闪屏。
12509
+ // 只更新列表内容(且如果数量不变也跳过整段重排)。
12510
+ var existing = host.querySelector(".queue-bar");
12511
+ if (state.queueBarDrag && existing) {
12512
+ var listInDrag = existing.querySelector('[data-queue-list="1"]');
12513
+ if (listInDrag && listInDrag.children.length !== queue.length) {
12514
+ renderQueueBarItems(listInDrag, queue);
12118
12515
  }
12516
+ return;
12119
12517
  }
12518
+
12519
+ host.innerHTML = renderQueueBarSkeleton(queue.length, latest, inFlight, atCapacity, immediateLabel);
12520
+ var listEl = host.querySelector('[data-queue-list="1"]');
12521
+ if (listEl) renderQueueBarItems(listEl, queue);
12522
+ }
12523
+
12524
+ // ── 折叠 / 展开 ──
12525
+ function setQueueBarExpanded(expanded) {
12526
+ var next = !!expanded;
12527
+ if (state.queueBarExpanded === next) return;
12528
+ state.queueBarExpanded = next;
12529
+ if (!next) state.queueBarItemExpanded = {};
12530
+ updateQueueBar();
12531
+ }
12532
+ function toggleQueueBar() { setQueueBarExpanded(!state.queueBarExpanded); }
12533
+
12534
+ function handleQueueBarOutsideClick(ev) {
12535
+ if (!state.queueBarExpanded) return;
12536
+ var host = document.getElementById("queue-bar-host");
12537
+ if (!host) return;
12538
+ if (host.contains(ev.target)) return;
12539
+ setQueueBarExpanded(false);
12540
+ }
12541
+ function handleQueueBarKeydown(ev) {
12542
+ if (!state.queueBarExpanded) return;
12543
+ if (ev.key === "Escape" || ev.key === "Esc") {
12544
+ setQueueBarExpanded(false);
12545
+ // 焦点回到 toggle 按钮,方便键盘党
12546
+ var toggle = document.querySelector(".queue-bar-toggle");
12547
+ if (toggle) toggle.focus();
12548
+ }
12549
+ }
12550
+
12551
+ // ── 单条删除 / 全部清空 / 队首插队 ──
12552
+ function rollbackQueueOptimistic(session, prevQueue) {
12553
+ updateSessionSnapshot({ id: session.id, queuedMessages: prevQueue });
12554
+ var refreshed = state.sessions.find(function(s) { return s.id === session.id; }) || session;
12555
+ state.currentMessages = buildMessagesForRender(refreshed, getPreferredMessages(refreshed, refreshed.output, false));
12556
+ renderChat(true);
12557
+ updateQueueBar();
12558
+ }
12559
+
12560
+ function queueBarDeleteItem(index) {
12561
+ var session = state.sessions.find(function(s) { return s.id === state.selectedId; });
12562
+ if (!session) return;
12563
+ var queue = Array.isArray(session.queuedMessages) ? session.queuedMessages.slice() : [];
12564
+ if (index < 0 || index >= queue.length) return;
12565
+ var prev = queue.slice();
12566
+ var next = queue.slice(0, index).concat(queue.slice(index + 1));
12567
+ // 调整 queueBarItemExpanded 的下标偏移
12568
+ var nextExpanded = {};
12569
+ Object.keys(state.queueBarItemExpanded).forEach(function(k) {
12570
+ var i = Number(k);
12571
+ if (i === index) return;
12572
+ if (i > index) nextExpanded[i - 1] = state.queueBarItemExpanded[k];
12573
+ else nextExpanded[i] = state.queueBarItemExpanded[k];
12574
+ });
12575
+ state.queueBarItemExpanded = nextExpanded;
12576
+ updateSessionSnapshot({ id: session.id, queuedMessages: next });
12577
+ var refreshed = state.sessions.find(function(s) { return s.id === session.id; }) || session;
12578
+ state.currentMessages = buildMessagesForRender(refreshed, getPreferredMessages(refreshed, refreshed.output, false));
12579
+ renderChat(true);
12580
+ updateQueueBar();
12581
+ fetch("/api/structured-sessions/" + session.id + "/queued/" + index, {
12582
+ method: "DELETE",
12583
+ credentials: "same-origin",
12584
+ })
12585
+ .then(function(res) {
12586
+ if (!res.ok) {
12587
+ return res.json().catch(function() { return {}; }).then(function(p) {
12588
+ throw new Error((p && p.error) || "删除失败");
12589
+ });
12590
+ }
12591
+ })
12592
+ .catch(function(err) {
12593
+ rollbackQueueOptimistic(session, prev);
12594
+ showToast((err && err.message) || "删除排队消息失败。", "error");
12595
+ });
12596
+ }
12597
+
12598
+ function queueBarClearAll() {
12599
+ var session = state.sessions.find(function(s) { return s.id === state.selectedId; });
12600
+ if (!session) return;
12601
+ var prev = Array.isArray(session.queuedMessages) ? session.queuedMessages.slice() : [];
12602
+ if (prev.length === 0) return;
12603
+ state.queueBarItemExpanded = {};
12604
+ updateSessionSnapshot({ id: session.id, queuedMessages: [] });
12605
+ var refreshed = state.sessions.find(function(s) { return s.id === session.id; }) || session;
12606
+ state.currentMessages = buildMessagesForRender(refreshed, getPreferredMessages(refreshed, refreshed.output, false));
12607
+ renderChat(true);
12608
+ updateQueueBar();
12609
+ fetch("/api/structured-sessions/" + session.id + "/queued", {
12610
+ method: "DELETE",
12611
+ credentials: "same-origin",
12612
+ })
12613
+ .then(function(res) {
12614
+ if (!res.ok) {
12615
+ return res.json().catch(function() { return {}; }).then(function(p) {
12616
+ throw new Error((p && p.error) || "清空失败");
12617
+ });
12618
+ }
12619
+ showToast("已清空 " + prev.length + " 条排队消息。", "info");
12620
+ })
12621
+ .catch(function(err) {
12622
+ rollbackQueueOptimistic(session, prev);
12623
+ showToast((err && err.message) || "清空排队消息失败。", "error");
12624
+ });
12625
+ }
12626
+
12627
+ function queueBarPromoteHead() {
12628
+ var session = state.sessions.find(function(s) { return s.id === state.selectedId; });
12629
+ if (!session) return;
12630
+ var queue = Array.isArray(session.queuedMessages) ? session.queuedMessages.slice() : [];
12631
+ if (queue.length === 0) return;
12632
+ var head = queue[0];
12633
+ var rest = queue.slice(1);
12634
+ var prev = queue.slice();
12635
+ var inFlight = !!(session.structuredState && session.structuredState.inFlight && session.status === "running");
12636
+
12637
+ // 乐观:剥掉队首
12638
+ state.queueBarItemExpanded = (function() {
12639
+ var out = {};
12640
+ Object.keys(state.queueBarItemExpanded).forEach(function(k) {
12641
+ var i = Number(k);
12642
+ if (i === 0) return;
12643
+ out[i - 1] = state.queueBarItemExpanded[k];
12644
+ });
12645
+ return out;
12646
+ })();
12647
+ updateSessionSnapshot({ id: session.id, queuedMessages: rest });
12648
+
12649
+ // 收起面板,让用户视线回到 chat(新消息马上要进 user turn)
12650
+ setQueueBarExpanded(false);
12651
+
12652
+ var idempotencyKey = (typeof crypto !== "undefined" && crypto.randomUUID)
12653
+ ? crypto.randomUUID()
12654
+ : (Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 10));
12655
+
12656
+ var body = { input: head, idempotencyKey: idempotencyKey };
12657
+ if (inFlight) {
12658
+ // 中断 + 保留剩余队列
12659
+ body.interrupt = true;
12660
+ body.preserveQueue = true;
12661
+ }
12662
+ // 给一个乐观 toast,让用户瞬间知道点击生效了
12663
+ showToast(inFlight ? "已请求中断当前回复,立即发送队首。" : "已立即发送队首消息。", "info");
12664
+
12665
+ fetch("/api/structured-sessions/" + session.id + "/messages", {
12666
+ method: "POST",
12667
+ headers: { "Content-Type": "application/json" },
12668
+ credentials: "same-origin",
12669
+ body: JSON.stringify(body),
12670
+ })
12671
+ .then(function(res) {
12672
+ if (!res.ok) {
12673
+ return res.json().catch(function() { return {}; }).then(function(p) {
12674
+ throw new Error((p && p.error) || "立即发送失败");
12675
+ });
12676
+ }
12677
+ return res.json();
12678
+ })
12679
+ .then(function(snapshot) {
12680
+ if (snapshot && snapshot.id) {
12681
+ updateSessionSnapshot(snapshot);
12682
+ if (snapshot.id === state.selectedId) {
12683
+ var refreshed = state.sessions.find(function(s) { return s.id === snapshot.id; }) || snapshot;
12684
+ state.currentMessages = buildMessagesForRender(refreshed, getPreferredMessages(refreshed, snapshot.output, false));
12685
+ renderChat(true);
12686
+ updateQueueBar();
12687
+ }
12688
+ }
12689
+ })
12690
+ .catch(function(err) {
12691
+ rollbackQueueOptimistic(session, prev);
12692
+ showToast((err && err.message) || "立即发送失败。", "error");
12693
+ });
12694
+ }
12695
+
12696
+ // ── 拖拽排序(Pointer Events + 简化版 sort/animate)──
12697
+ function queueBarDragStart(ev, handleEl) {
12698
+ var session = state.sessions.find(function(s) { return s.id === state.selectedId; });
12699
+ if (!session) return;
12700
+ var queue = Array.isArray(session.queuedMessages) ? session.queuedMessages.slice() : [];
12701
+ if (queue.length <= 1) return;
12702
+ var itemEl = handleEl.closest(".queue-bar-item");
12703
+ if (!itemEl) return;
12704
+ var listEl = itemEl.parentElement;
12705
+ if (!listEl) return;
12706
+ var origIndex = Number(itemEl.getAttribute("data-index"));
12707
+ var siblings = Array.prototype.slice.call(listEl.children);
12708
+ var rects = siblings.map(function(el) { return el.getBoundingClientRect(); });
12709
+ var rect0 = rects[origIndex];
12710
+ var itemHeight = rect0.height;
12711
+ var gap = 6; // 与 CSS .queue-bar-list 的 gap 保持一致
12712
+
12713
+ ev.preventDefault();
12714
+ try { handleEl.setPointerCapture(ev.pointerId); } catch (_e) {}
12715
+ if (navigator && navigator.vibrate) { try { navigator.vibrate(8); } catch (_e2) {} }
12716
+
12717
+ state.queueBarDrag = {
12718
+ pointerId: ev.pointerId,
12719
+ handleEl: handleEl,
12720
+ itemEl: itemEl,
12721
+ listEl: listEl,
12722
+ siblings: siblings,
12723
+ rects: rects,
12724
+ origIndex: origIndex,
12725
+ targetIndex: origIndex,
12726
+ startY: ev.clientY,
12727
+ itemHeight: itemHeight,
12728
+ gap: gap,
12729
+ queueSnapshot: queue,
12730
+ };
12731
+
12732
+ itemEl.classList.add("dragging");
12733
+ // 把所有兄弟先标记为"参与平滑动画"
12734
+ siblings.forEach(function(el) { if (el !== itemEl) el.classList.add("queue-bar-item-sliding"); });
12735
+
12736
+ var move = function(e) { queueBarDragMove(e); };
12737
+ var up = function(e) { queueBarDragEnd(e); };
12738
+ state.queueBarDrag.moveHandler = move;
12739
+ state.queueBarDrag.upHandler = up;
12740
+ handleEl.addEventListener("pointermove", move);
12741
+ handleEl.addEventListener("pointerup", up);
12742
+ handleEl.addEventListener("pointercancel", up);
12743
+ }
12744
+
12745
+ function queueBarDragMove(ev) {
12746
+ var d = state.queueBarDrag;
12747
+ if (!d || ev.pointerId !== d.pointerId) return;
12748
+ ev.preventDefault();
12749
+ var deltaY = ev.clientY - d.startY;
12750
+ d.itemEl.style.transform = "translateY(" + deltaY + "px)";
12751
+
12752
+ // 拖动中心 Y 决定目标插入位置
12753
+ var centerY = d.rects[d.origIndex].top + d.rects[d.origIndex].height / 2 + deltaY;
12754
+ var target = d.origIndex;
12755
+ for (var i = 0; i < d.rects.length; i++) {
12756
+ if (i === d.origIndex) continue;
12757
+ var midY = d.rects[i].top + d.rects[i].height / 2;
12758
+ if (i < d.origIndex && centerY < midY) { target = Math.min(target, i); }
12759
+ else if (i > d.origIndex && centerY > midY) { target = Math.max(target, i); }
12760
+ }
12761
+ if (target !== d.targetIndex) {
12762
+ d.targetIndex = target;
12763
+ // 重排兄弟元素的 translateY
12764
+ var shift = d.itemHeight + d.gap;
12765
+ d.siblings.forEach(function(el, idx) {
12766
+ if (idx === d.origIndex) return;
12767
+ var move = 0;
12768
+ if (d.origIndex < target && idx > d.origIndex && idx <= target) move = -shift;
12769
+ else if (d.origIndex > target && idx < d.origIndex && idx >= target) move = shift;
12770
+ el.style.transform = move ? "translateY(" + move + "px)" : "";
12771
+ });
12772
+ }
12773
+ }
12774
+
12775
+ function queueBarDragEnd(ev) {
12776
+ var d = state.queueBarDrag;
12777
+ if (!d || (ev && ev.pointerId !== d.pointerId)) return;
12778
+ try { d.handleEl.releasePointerCapture(d.pointerId); } catch (_e) {}
12779
+ d.handleEl.removeEventListener("pointermove", d.moveHandler);
12780
+ d.handleEl.removeEventListener("pointerup", d.upHandler);
12781
+ d.handleEl.removeEventListener("pointercancel", d.upHandler);
12782
+
12783
+ var origIndex = d.origIndex;
12784
+ var targetIndex = d.targetIndex;
12785
+ var queueSnapshot = d.queueSnapshot;
12786
+
12787
+ // 清掉 inline transform 让 CSS 自然回位
12788
+ d.siblings.forEach(function(el) {
12789
+ el.style.transform = "";
12790
+ el.classList.remove("queue-bar-item-sliding");
12791
+ });
12792
+ d.itemEl.classList.remove("dragging");
12793
+
12794
+ state.queueBarDrag = null;
12795
+
12796
+ if (origIndex === targetIndex) {
12797
+ // 没动,光擦一下重渲就行
12798
+ updateQueueBar();
12799
+ return;
12800
+ }
12801
+
12802
+ // 计算 order: 原下标的新排列
12803
+ var order = [];
12804
+ for (var i = 0; i < queueSnapshot.length; i++) order.push(i);
12805
+ order.splice(origIndex, 1);
12806
+ order.splice(targetIndex, 0, origIndex);
12807
+ var nextQueue = order.map(function(i) { return queueSnapshot[i]; });
12808
+
12809
+ // 同步迁移 queueBarItemExpanded 下标
12810
+ var nextExpanded = {};
12811
+ Object.keys(state.queueBarItemExpanded).forEach(function(k) {
12812
+ var oldI = Number(k);
12813
+ var newI = order.indexOf(oldI);
12814
+ if (newI >= 0) nextExpanded[newI] = state.queueBarItemExpanded[k];
12815
+ });
12816
+ state.queueBarItemExpanded = nextExpanded;
12817
+
12818
+ var session = state.sessions.find(function(s) { return s.id === state.selectedId; });
12819
+ if (!session) { updateQueueBar(); return; }
12820
+ updateSessionSnapshot({ id: session.id, queuedMessages: nextQueue });
12821
+ updateQueueBar();
12822
+
12823
+ fetch("/api/structured-sessions/" + session.id + "/queued", {
12824
+ method: "PATCH",
12825
+ headers: { "Content-Type": "application/json" },
12826
+ credentials: "same-origin",
12827
+ body: JSON.stringify({ order: order }),
12828
+ })
12829
+ .then(function(res) {
12830
+ if (!res.ok) {
12831
+ return res.json().catch(function() { return {}; }).then(function(p) {
12832
+ throw new Error((p && p.error) || "排序失败");
12833
+ });
12834
+ }
12835
+ })
12836
+ .catch(function(err) {
12837
+ rollbackQueueOptimistic(session, queueSnapshot);
12838
+ showToast((err && err.message) || "调整排队顺序失败。", "error");
12839
+ });
12840
+ }
12841
+
12842
+ // ── 事件代理:所有交互入口都从 #queue-bar-host 起手 ──
12843
+ function attachQueueBarDelegates() {
12844
+ var host = document.getElementById("queue-bar-host");
12845
+ if (!host || host.__queueDelegated) return;
12846
+ host.__queueDelegated = true;
12847
+ host.addEventListener("click", function(ev) {
12848
+ var actionEl = ev.target && ev.target.closest ? ev.target.closest("[data-action]") : null;
12849
+ if (!actionEl || !host.contains(actionEl)) return;
12850
+ var action = actionEl.getAttribute("data-action");
12851
+ if (action === "drag") return; // 拖拽由 pointerdown 处理,吞掉点击避免误触发
12852
+ ev.preventDefault();
12853
+ ev.stopPropagation();
12854
+ if (action === "toggle") { toggleQueueBar(); return; }
12855
+ if (action === "collapse") { setQueueBarExpanded(false); return; }
12856
+ if (action === "promote") { queueBarPromoteHead(); return; }
12857
+ if (action === "clear") { queueBarClearAll(); return; }
12858
+ if (action === "delete") {
12859
+ var itemEl = actionEl.closest(".queue-bar-item");
12860
+ if (itemEl) queueBarDeleteItem(Number(itemEl.getAttribute("data-index")));
12861
+ return;
12862
+ }
12863
+ if (action === "expand-text") {
12864
+ var item = actionEl.closest(".queue-bar-item");
12865
+ if (!item) return;
12866
+ var idx = Number(item.getAttribute("data-index"));
12867
+ state.queueBarItemExpanded[idx] = !state.queueBarItemExpanded[idx];
12868
+ item.classList.toggle("expanded", !!state.queueBarItemExpanded[idx]);
12869
+ actionEl.setAttribute("aria-expanded", state.queueBarItemExpanded[idx] ? "true" : "false");
12870
+ return;
12871
+ }
12872
+ });
12873
+ host.addEventListener("pointerdown", function(ev) {
12874
+ if (ev.button !== undefined && ev.button !== 0) return;
12875
+ var handle = ev.target && ev.target.closest ? ev.target.closest('[data-action="drag"]') : null;
12876
+ if (!handle || handle.disabled) return;
12877
+ queueBarDragStart(ev, handle);
12878
+ });
12120
12879
  }
12121
12880
 
12122
12881
  // 计算一条 ConversationTurn 里所有 content block 的"信息体积"——文字 / 思考 /
@@ -13087,7 +13846,8 @@
13087
13846
  command: command,
13088
13847
  cwd: cwd || "",
13089
13848
  mode: state.chatMode || state.config.defaultMode || "default",
13090
- model: modelPref || undefined
13849
+ model: modelPref || undefined,
13850
+ thinkingEffort: state.chatThinking || undefined
13091
13851
  }))
13092
13852
  })
13093
13853
  .then(function(res) { return res.json(); })
@@ -13229,7 +13989,8 @@
13229
13989
  cwd: defaultCwd,
13230
13990
  mode: mode,
13231
13991
  initialInput: value,
13232
- model: modelPref || undefined
13992
+ model: modelPref || undefined,
13993
+ thinkingEffort: state.chatThinking || undefined
13233
13994
  }))
13234
13995
  })
13235
13996
  .then(function(res) { return res.json(); })
@@ -13267,7 +14028,8 @@
13267
14028
  cwd: defaultCwd,
13268
14029
  mode: mode,
13269
14030
  initialInput: value || undefined,
13270
- model: modelPref || undefined
14031
+ model: modelPref || undefined,
14032
+ thinkingEffort: state.chatThinking || undefined
13271
14033
  }))
13272
14034
  })
13273
14035
  .then(function(res) { return res.json(); })
@@ -14183,7 +14945,27 @@
14183
14945
  // Without an immediate refit, any chunk arriving while the keyboard
14184
14946
  // animates in renders against the old grid and tears the screen.
14185
14947
  if (!keyboardOpen && isKeyboardOpen) {
14948
+ // Snapshot bottom-pinned intent BEFORE the layout starts shifting.
14949
+ // visualViewport.resize fires while the keyboard is still mid-
14950
+ // animation on iOS; if we wait until ensureTerminalFit's rAF
14951
+ // body executes, clientHeight has already shrunk and the user
14952
+ // who was visibly at the bottom registers as "scrolled up".
14953
+ // We pass the snapshot through to a delayed catch-up so the
14954
+ // final scroll lands AFTER the animation settles.
14955
+ var wasStickToBottom = state.terminalAutoFollow || isTerminalNearBottom();
14186
14956
  ensureTerminalFit("keyboard-open", { forceReplay: true });
14957
+ // Mirror the keyboard-close 200ms delay: by then the iOS / Android
14958
+ // keyboard slide-in animation is done, vv.height is final, and
14959
+ // scrollHeight reflects the post-replay grid. One more force
14960
+ // scroll closes the gap between "we scrolled during animation
14961
+ // when scrollHeight was still in flux" and "user expects to see
14962
+ // the bottom now that the keyboard has fully settled".
14963
+ if (wasStickToBottom) {
14964
+ setTimeout(function() {
14965
+ if (!state.terminal) return;
14966
+ maybeScrollTerminalToBottom("force");
14967
+ }, 220);
14968
+ }
14187
14969
  }
14188
14970
 
14189
14971
  // Keyboard just closed — force terminal refit and scroll to bottom
@@ -14552,6 +15334,21 @@
14552
15334
  ensureTerminalFitWithRetry(reason || "fit-retry", { forceReplay: forceReplay });
14553
15335
  return false;
14554
15336
  }
15337
+ // Snapshot stick-to-bottom intent NOW, before any layout work.
15338
+ // Two concrete bugs this guards against:
15339
+ // 1. Mobile keyboard opens → visualViewport shrinks → terminal
15340
+ // clientHeight drops while scrollTop stays put → by the time
15341
+ // the rAF body runs, isTerminalNearBottom() reads false even
15342
+ // though the user was visibly pinned to the bottom a frame ago.
15343
+ // 2. Any softResyncTerminal triggered below does resetTerminal()
15344
+ // (scrollTop snaps to 0) then re-writes; the wterm element
15345
+ // can fire intermediate scroll events that flip
15346
+ // terminalAutoFollow to false before we get a chance to
15347
+ // scroll back.
15348
+ // Both failure modes left users mid-buffer after a resize. We
15349
+ // capture the intent up front and use "force" below to bypass
15350
+ // the (now-poisoned) flag check inside maybeScrollTerminalToBottom.
15351
+ var shouldStickToBottom = state.terminalAutoFollow || isTerminalNearBottom();
14555
15352
  var prevCols = state.terminal.cols;
14556
15353
  var prevRows = state.terminal.rows;
14557
15354
  requestAnimationFrame(function() {
@@ -14571,8 +15368,8 @@
14571
15368
  if (!didResize && forceReplay && state.terminalOutput) {
14572
15369
  softResyncTerminal({ skipFit: true });
14573
15370
  }
14574
- if (state.terminalAutoFollow || isTerminalNearBottom()) {
14575
- maybeScrollTerminalToBottom("resize");
15371
+ if (shouldStickToBottom) {
15372
+ maybeScrollTerminalToBottom("force");
14576
15373
  }
14577
15374
  });
14578
15375
  });
@@ -14629,9 +15426,15 @@
14629
15426
 
14630
15427
  function syncTerminalSize() {
14631
15428
  if (!state.terminal) return;
14632
- var shouldFollow = state.terminalAutoFollow || isTerminalNearBottom();
14633
- if (shouldFollow) {
14634
- maybeScrollTerminalToBottom("resize");
15429
+ // Force-scroll (vs the weaker maybeScrollTerminalToBottom("resize"))
15430
+ // for the same reason ensureTerminalFit does: between this entry
15431
+ // and the actual scroll, the wterm DOM may fire scroll events that
15432
+ // poison terminalAutoFollow. Capturing intent now + force-scrolling
15433
+ // keeps a user who was visibly at the bottom pinned there across
15434
+ // window/orientation/viewport resizes.
15435
+ var shouldStickToBottom = state.terminalAutoFollow || isTerminalNearBottom();
15436
+ if (shouldStickToBottom) {
15437
+ maybeScrollTerminalToBottom("force");
14635
15438
  }
14636
15439
  sendTerminalResize(state.terminal.cols, state.terminal.rows);
14637
15440
  }
@@ -15427,21 +16230,22 @@
15427
16230
 
15428
16231
  function updateAutoApproveIndicator() {
15429
16232
  var toggle = document.getElementById("auto-approve-toggle");
15430
- if (!toggle) return;
15431
16233
  var selectedSession = state.sessions.find(function(s) { return s.id === state.selectedId; });
15432
- if (selectedSession && selectedSession.provider === "codex") {
15433
- toggle.className = "auto-approve-indicator active";
15434
- toggle.title = "Codex 固定以 full-access PTY 启动,不支持切换自动批准";
15435
- toggle.textContent = "🛡 Codex 固定全权限";
16234
+ // 当模式(managed / full-access)已隐含自动批准,chip 应该不存在;如果上一次渲染留下来了,
16235
+ // 在这里清理掉,避免视觉上还有冗余 chip。
16236
+ if (isAutoApproveImpliedByMode(selectedSession)) {
16237
+ if (toggle && toggle.parentNode) toggle.parentNode.removeChild(toggle);
15436
16238
  return;
15437
16239
  }
16240
+ if (!toggle) return;
16241
+ var base = "composer-pill composer-pill-chip auto-approve-indicator";
15438
16242
  var enabled = selectedSession && selectedSession.autoApprovePermissions;
15439
16243
  if (enabled) {
15440
- toggle.className = "auto-approve-indicator active";
16244
+ toggle.className = base + " active";
15441
16245
  toggle.title = "自动批准已启用 — 点击关闭";
15442
- toggle.textContent = "🛡 自动批准";
16246
+ toggle.textContent = "🛡 自动";
15443
16247
  } else {
15444
- toggle.className = "auto-approve-indicator";
16248
+ toggle.className = base;
15445
16249
  toggle.title = "自动批准已关闭 — 点击开启";
15446
16250
  toggle.textContent = "🛡 手动";
15447
16251
  }
@@ -15707,6 +16511,9 @@
15707
16511
  // (inFlight state may have changed without new message content)
15708
16512
  var chatMessages = chatOutput.querySelector(".chat-messages");
15709
16513
  if (chatMessages) renderStructuredStatusBar(chatMessages, selectedSession);
16514
+ // 同步刷一次进度条:inFlight 从 true→false 时(turn 结束)没有新消息,
16515
+ // updateTodoProgress 不被调到就会让"5/6"卡在底部一直不消失。
16516
+ updateTodoProgress(allMessages);
15710
16517
  return;
15711
16518
  }
15712
16519
  var prevHash = state.lastRenderedHash;
@@ -16110,9 +16917,19 @@
16110
16917
  });
16111
16918
 
16112
16919
  function updateTodoProgress(messages) {
16920
+ // 只看"当前 turn"里的 TodoWrite——即最后一条 user 消息之后的那段。
16921
+ // 不限制范围的话,上一轮留下的进度条会在新一轮(哪怕新一轮根本没用
16922
+ // TodoWrite)里阴魂不散地重现。
16923
+ var startIdx = 0;
16924
+ for (var ui = messages.length - 1; ui >= 0; ui--) {
16925
+ if (messages[ui] && messages[ui].role === "user") {
16926
+ startIdx = ui + 1;
16927
+ break;
16928
+ }
16929
+ }
16930
+
16113
16931
  var todos = null;
16114
- // Scan all messages for latest TodoWrite tool_use
16115
- for (var i = messages.length - 1; i >= 0; i--) {
16932
+ for (var i = messages.length - 1; i >= startIdx; i--) {
16116
16933
  var msg = messages[i];
16117
16934
  if (!msg.content || !Array.isArray(msg.content)) continue;
16118
16935
  for (var j = msg.content.length - 1; j >= 0; j--) {
@@ -16135,6 +16952,24 @@
16135
16952
  return;
16136
16953
  }
16137
16954
 
16955
+ // 当前 turn 已结束(结构化 inFlight=false 或 PTY 非 running)就把进度条
16956
+ // 收起来——模型经常忘了发最后一条"全 completed"的 TodoWrite,让用户
16957
+ // 对着 "5/6" 干瞪眼很别扭。allDone 那条分支保留,提前命中更快返回。
16958
+ var sel = state.sessions.find(function(s) { return s.id === state.selectedId; });
16959
+ var turnDone = false;
16960
+ if (sel) {
16961
+ if (isStructuredSession(sel)) {
16962
+ turnDone = !(sel.structuredState && sel.structuredState.inFlight);
16963
+ } else {
16964
+ turnDone = sel.status !== "running";
16965
+ }
16966
+ }
16967
+ if (turnDone) {
16968
+ container.classList.add("hidden");
16969
+ if (bodyEl) bodyEl.classList.add("hidden");
16970
+ return;
16971
+ }
16972
+
16138
16973
  container.classList.remove("hidden");
16139
16974
  if (bodyEl) bodyEl.classList.remove("hidden");
16140
16975
 
@@ -17019,7 +17854,16 @@
17019
17854
  return '<div class="subagent-reply pending"><span class="typing-indicator"><span></span><span></span><span></span></span></div>';
17020
17855
  }
17021
17856
 
17022
- return '<div class="subagent-reply">' + renderMarkdown(text) + '</div>';
17857
+ // 三态折叠:preview(默认 ~5 行预览,内部可滚)→ expanded(高一些上限,可滚)→
17858
+ // collapsed(完全收起,只剩工具条)→ preview。按钮一直可见在右下,状态写在
17859
+ // data-collapse-mode 上,配套 CSS 控制 max-height。
17860
+ return '<div class="subagent-reply collapsible" data-collapse-mode="preview">' +
17861
+ '<div class="subagent-reply-scroll">' + renderMarkdown(text) + '</div>' +
17862
+ '<button type="button" class="subagent-reply-cycle" onclick="__subagentReplyCycle(event, this)" title="展开 / 收起">' +
17863
+ '<span class="subagent-reply-cycle-label">展开</span>' +
17864
+ '<span class="subagent-reply-cycle-icon" aria-hidden="true">▾</span>' +
17865
+ '</button>' +
17866
+ '</div>';
17023
17867
  }
17024
17868
  var PIXEL_AVATAR = {
17025
17869
  assistant: buildPixelSvg(buildCatGrid(GARFIELD_PALETTE)),
@@ -17377,7 +18221,9 @@
17377
18221
  : '';
17378
18222
  html += '<div class="chat-handoff" style="--agent-color:' + subPalette.primary + '">' +
17379
18223
  '<span class="chat-handoff-arrow">↳</span> ' +
17380
- escapeHtml(parentPersonaName) + ' 让 <strong>' + escapeHtml(subName) + '</strong> 帮忙' + desc +
18224
+ escapeHtml(parentPersonaName) + ' 让 <strong>' + escapeHtml(subName) + '</strong>' +
18225
+ '<span class="chat-handoff-tag" title="子代理 / subagent">subagent</span>' +
18226
+ '帮忙' + desc +
17381
18227
  '</div>';
17382
18228
  }
17383
18229
  html += '<div class="chat-message-segment subagent" data-agent-id="' + escapeHtml(seg.subagent.taskId) + '" style="--agent-color:' + subPalette.primary + '">' +