@co0ontty/wand 1.31.0 → 1.31.2

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.
@@ -106,6 +106,13 @@
106
106
  terminalHealthTimer: null,
107
107
  lastTerminalResyncAt: 0,
108
108
  terminalAutoFollow: true,
109
+ // 程序触发的滚动(wand 主动 scrollTo / wterm 内部因 _shouldScrollToBottom=true
110
+ // 拽 scrollTop=scrollHeight)落到 scroll handler 时会被误判为"用户滚回严格
111
+ // 底部",把 autoFollow 反转回 true,把用户刚 wheel 上滚的意图吞掉。
112
+ // 存"窗口截止时间戳"而非"开始时间戳":不同调用方按各自动画长度延长窗口
113
+ // (瞬时 120ms 覆盖一次 rAF + 事件分发;smooth 500ms 覆盖 Chromium smooth
114
+ // scroll 动画),多次调用用 Math.max 合并、不会被短窗口缩短。
115
+ terminalProgrammaticScrollUntil: 0,
109
116
  terminalScrollIdleTimer: null,
110
117
  terminalScrollIdleMs: 1800,
111
118
  terminalScrollThreshold: 12,
@@ -129,6 +136,13 @@
129
136
  }
130
137
  })(), // 跨会话排队消息 [{ id, text, cwd, mode, tool }]
131
138
  structuredInputQueue: [], // 结构化会话同会话排队消息
139
+ // 排队条 UI 局部状态 ——
140
+ // queueBarExpanded: 折叠条点击展开成下拉面板
141
+ // queueBarItemExpanded: 展开面板里被点开看完整内容的 item 下标集合
142
+ // queueBarDrag: 拖拽排序进行中时的临时状态(pointer 捕获、起始坐标、参考 rect)
143
+ queueBarExpanded: false,
144
+ queueBarItemExpanded: {},
145
+ queueBarDrag: null,
132
146
  drafts: {},
133
147
  isSyncingInputBox: false,
134
148
  loginPending: false,
@@ -1503,14 +1517,17 @@
1503
1517
  var preferredTool = getComposerTool();
1504
1518
  var composerMode = getSafeModeForTool(preferredTool, state.chatMode);
1505
1519
 
1506
- var isDesktopPinned = state.sidebarPinned && !isMobileLayout();
1507
- var isCollapsed = isDesktopPinned && state.sidebarCollapsed;
1520
+ // 手机端不允许「pin 但不窄条」(300px 固定边栏太占地),只允许窄条形态。
1521
+ // isAnchored = 边栏占据布局空间(推开主内容)。桌面 pin 或 任意端窄条都算 anchored。
1522
+ var isMobile = isMobileLayout();
1523
+ var isCollapsed = !!state.sidebarPinned && !!state.sidebarCollapsed;
1524
+ var isAnchored = isCollapsed || (!!state.sidebarPinned && !isMobile);
1508
1525
  var collapsedCls = isCollapsed ? ' sidebar-collapsed' : '';
1509
1526
  var sidebarCollapsedCls = isCollapsed ? ' collapsed' : '';
1510
1527
  return '<div class="app-container">' +
1511
1528
  '<div id="sessions-drawer-backdrop" class="drawer-backdrop' + drawerClass + '"></div>' +
1512
- '<div class="main-layout' + (state.sessionsDrawerOpen ? ' sidebar-open' : '') + (isDesktopPinned ? ' sidebar-pinned' : '') + collapsedCls + '">' +
1513
- '<aside id="sessions-drawer" class="sidebar' + drawerClass + (isDesktopPinned ? ' pinned' : '') + sidebarCollapsedCls + '">' +
1529
+ '<div class="main-layout' + (state.sessionsDrawerOpen ? ' sidebar-open' : '') + (isAnchored ? ' sidebar-pinned' : '') + collapsedCls + '">' +
1530
+ '<aside id="sessions-drawer" class="sidebar' + drawerClass + (isAnchored ? ' pinned' : '') + sidebarCollapsedCls + '">' +
1514
1531
  '<div class="sidebar-header">' +
1515
1532
  '<div class="sidebar-header-main">' +
1516
1533
  '<div class="topbar-logo-icon">W</div>' +
@@ -1710,6 +1727,11 @@
1710
1727
  '<ul class="todo-progress-list" id="todo-progress-list"></ul>' +
1711
1728
  '</div>' +
1712
1729
  '</div>' +
1730
+ // 排队条宿主:默认 display:none,updateQueueBar() 在 queuedMessages 非空时
1731
+ // 显形。结构上夹在 composer-top-row(todo 进度)和 input-composer(输入框 +
1732
+ // 工具栏)之间,位置正好"在输入框上方、对话框右下角"。所有内容由 updater
1733
+ // 注入;这里只保留稳定的外层骨架,便于 renderAppShell 全量重建后无缝复位。
1734
+ '<div id="queue-bar-host" class="queue-bar-host" hidden></div>' +
1713
1735
  '<div class="input-composer">' +
1714
1736
  '<button id="prompt-optimize-btn" class="prompt-optimize-btn" type="button" title="提示词优化(AI)" aria-label="提示词优化">' +
1715
1737
  '<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">' +
@@ -1730,21 +1752,28 @@
1730
1752
  // tabindex="-1": 把这些控件移出 iOS Safari 的表单导航链,
1731
1753
  // 这样 textarea 聚焦时键盘上方就不会出现 ⌃ ⌄ ✓ 表单辅助栏。
1732
1754
  '<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">' +
1733
- // 三件套 (Mode / Model / Thinking) 同属"会话设置"层:视觉上统一为同一种 pill 风格,
1734
- // CSS .composer-pill-group 内的轻量分隔感传达"归类"。
1735
- '<span class="composer-pill-group" role="group" aria-label="会话设置">' +
1736
- '<select id="chat-mode-select" class="composer-pill composer-pill-select chat-mode-select" tabindex="-1" title="新会话模式 · 托管 / 全权限 自动启用批准">' +
1737
- renderModeOptions(preferredTool, composerMode) +
1738
- '</select>' +
1739
- '<select id="chat-model-select" class="composer-pill composer-pill-select chat-mode-select chat-model-select" tabindex="-1" title="切换模型(对运行中会话发送 /model,对新会话作为 --model 启动)">' +
1740
- renderChatModelOptions(getEffectiveModel(selectedSession), selectedSession) +
1741
- '</select>' +
1742
- // 思考深度 trigger:与 mode / model 同形(border + chevron),保证视觉一致。
1743
- // 可见层只有「档位文字」,原生 <select> 透明叠在上面,依然能调起 iOS 滚轮选择。
1744
- '<span class="composer-pill composer-pill-select chat-thinking-trigger" title="思考深度(structured 立即生效;PTY 仅作用于通过 chat 视图发送的消息)">' +
1745
- '<span class="chat-thinking-label" id="chat-thinking-label">' + escapeHtml(getThinkingLabel(getEffectiveThinking(selectedSession))) + '</span>' +
1746
- '<select id="chat-thinking-select" class="chat-thinking-hidden-select" tabindex="-1" aria-label="思考深度">' +
1747
- renderChatThinkingOptions(getEffectiveThinking(selectedSession)) +
1755
+ // 三件套 (Mode / Model / Thinking) 同属"会话设置"层:扁平文字 + · 分隔。
1756
+ // 文字下叠一个透明 <select> 承载交互,桌面端弹原生下拉、移动端弹滚轮选择。
1757
+ // 显示文本用 raw id(如 default / claude-sonnet-4-5 / standard),不做翻译。
1758
+ '<span class="composer-text-group" role="group" aria-label="会话设置">' +
1759
+ '<span class="composer-text-pill" title="模式">' +
1760
+ '<span class="composer-text-label" id="chat-mode-label">' + escapeHtml(composerMode) + '</span>' +
1761
+ '<select id="chat-mode-select" class="composer-text-hidden-select" tabindex="-1" aria-label="模式">' +
1762
+ renderChatModeOptionsRaw(preferredTool, composerMode) +
1763
+ '</select>' +
1764
+ '</span>' +
1765
+ '<span class="composer-text-sep" aria-hidden="true">·</span>' +
1766
+ '<span class="composer-text-pill chat-model-text-pill" title="模型">' +
1767
+ '<span class="composer-text-label" id="chat-model-label">' + escapeHtml(getEffectiveModel(selectedSession) || "default") + '</span>' +
1768
+ '<select id="chat-model-select" class="composer-text-hidden-select" tabindex="-1" aria-label="模型">' +
1769
+ renderChatModelOptionsRaw(getEffectiveModel(selectedSession), selectedSession) +
1770
+ '</select>' +
1771
+ '</span>' +
1772
+ '<span class="composer-text-sep" aria-hidden="true">·</span>' +
1773
+ '<span class="composer-text-pill chat-thinking-text-pill" title="思考深度">' +
1774
+ '<span class="composer-text-label" id="chat-thinking-label">' + escapeHtml(getEffectiveThinking(selectedSession)) + '</span>' +
1775
+ '<select id="chat-thinking-select" class="composer-text-hidden-select" tabindex="-1" aria-label="思考深度">' +
1776
+ renderChatThinkingOptionsRaw(getEffectiveThinking(selectedSession)) +
1748
1777
  '</select>' +
1749
1778
  '</span>' +
1750
1779
  '</span>' +
@@ -1759,9 +1788,7 @@
1759
1788
  renderApprovalStatsBadge() +
1760
1789
  '</div>' +
1761
1790
  '<div class="input-composer-right">' +
1762
- // queue-counter:当 queuedMessages 不为空时显示,提示用户后面还堆了几条。
1763
- // 比小角标更显眼一点——加图标 + 强对比色,避免 v1.30.3 那一版用户没看见。
1764
- '<span id="queue-counter" class="queue-counter hidden" title="正在排队的输入条数"><span class="queue-counter-dot"></span><span class="queue-counter-text">队列 0</span></span>' +
1791
+ // 排队提示从这里搬到 .queue-bar(输入框上方独立浮条),原 #queue-counter 已移除。
1765
1792
  '<span class="input-hint' + (state.terminalInteractive ? ' terminal-interactive-hint' : state.currentView === "terminal" ? " hidden" : "") + '">' + (state.terminalInteractive ? '终端交互中 · Ctrl+C 中断 · Ctrl+L 清屏' : 'Enter 发送 · Shift+Enter 换行') + '</span>' +
1766
1793
  renderInlineKeyboard() +
1767
1794
  '<button id="stop-button" class="btn-circle btn-circle-stop' + (state.selectedId ? "" : " hidden") + '" title="停止">' +
@@ -3242,7 +3269,9 @@
3242
3269
  }
3243
3270
 
3244
3271
  function isSidebarNarrow() {
3245
- return !!state.sidebarPinned && !isMobileLayout() && !!state.sidebarCollapsed;
3272
+ // 桌面: pinned + collapsed = 56px 窄条。
3273
+ // 手机: pinned + collapsed 同样允许窄条(pin 单独不在手机生效,但 collapsed 是窄条形态的标志)。
3274
+ return !!state.sidebarPinned && !!state.sidebarCollapsed;
3246
3275
  }
3247
3276
 
3248
3277
  function renderCollapsedSessionTiles() {
@@ -5320,7 +5349,8 @@
5320
5349
  '</div>' +
5321
5350
  '<div class="field-hint session-kind-hint-row">' +
5322
5351
  '<span id="session-kind-description">' + escapeHtml(getSessionKindHint(sessionKind)) + '</span>' +
5323
- renderWorktreeToggle(worktreeEnabled) +
5352
+ // Worktree 模式入口暂时隐藏,保留 renderWorktreeToggle/state.sessionCreateWorktree 以便后续恢复
5353
+ // renderWorktreeToggle(worktreeEnabled) +
5324
5354
  '</div>' +
5325
5355
  '</div>' +
5326
5356
  '<div class="field">' +
@@ -5435,6 +5465,36 @@
5435
5465
  }
5436
5466
  persistElementExpandState(el, "thinking");
5437
5467
  };
5468
+ // Toggle function for subagent reply bubbles — cycles preview → expanded → collapsed.
5469
+ // 三态循环(preview 默认 ~5 行可滚 / expanded 大区可滚 / collapsed 完全收起)。
5470
+ window.__subagentReplyCycle = function(e, btn) {
5471
+ if (e) { e.preventDefault(); e.stopPropagation(); }
5472
+ var bubble = btn.closest(".subagent-reply");
5473
+ if (!bubble) return;
5474
+ var modes = ["preview", "expanded", "collapsed"];
5475
+ var current = bubble.getAttribute("data-collapse-mode") || "preview";
5476
+ var idx = modes.indexOf(current);
5477
+ if (idx < 0) idx = 0;
5478
+ var next = modes[(idx + 1) % modes.length];
5479
+ bubble.setAttribute("data-collapse-mode", next);
5480
+ var label = btn.querySelector(".subagent-reply-cycle-label");
5481
+ var icon = btn.querySelector(".subagent-reply-cycle-icon");
5482
+ if (label) {
5483
+ label.textContent = next === "preview" ? "展开"
5484
+ : next === "expanded" ? "收起"
5485
+ : "预览";
5486
+ }
5487
+ if (icon) {
5488
+ icon.textContent = next === "collapsed" ? "▸"
5489
+ : next === "expanded" ? "▴"
5490
+ : "▾";
5491
+ }
5492
+ btn.setAttribute("aria-label",
5493
+ next === "preview" ? "点击展开全部" :
5494
+ next === "expanded" ? "点击完全收起" :
5495
+ "点击切回预览"
5496
+ );
5497
+ };
5438
5498
  // Toggle function for inline tool rows (Read, Glob, Grep, etc.)
5439
5499
  window.__inlineToolToggle = function(el) {
5440
5500
  var expanded = el.classList.toggle("inline-tool-open");
@@ -6011,7 +6071,9 @@
6011
6071
  var modeSelect = document.getElementById("chat-mode-select");
6012
6072
  if (modeSelect) modeSelect.addEventListener("change", function() {
6013
6073
  state.chatMode = this.value;
6014
- showToast("新会话模式已切换为:" + getModeLabel(this.value), "info");
6074
+ var label = document.getElementById("chat-mode-label");
6075
+ if (label) label.textContent = this.value;
6076
+ showToast("新会话模式:" + this.value, "info");
6015
6077
  });
6016
6078
  var modelSelect = document.getElementById("chat-model-select");
6017
6079
  if (modelSelect) modelSelect.addEventListener("change", function() {
@@ -6723,6 +6785,16 @@
6723
6785
  initTerminal();
6724
6786
  setupMobileKeyboardHandlers();
6725
6787
  setupVisualViewportHandlers();
6788
+
6789
+ // 排队条:每次 shell 重渲后,重新挂事件代理 + 刷新内容。
6790
+ // document-level 的 ESC / 外点击 handler 只挂一次(state.__queueBarGlobalAttached 守门)。
6791
+ attachQueueBarDelegates();
6792
+ updateQueueBar();
6793
+ if (!state.__queueBarGlobalAttached) {
6794
+ state.__queueBarGlobalAttached = true;
6795
+ document.addEventListener("pointerdown", handleQueueBarOutsideClick, true);
6796
+ document.addEventListener("keydown", handleQueueBarKeydown, true);
6797
+ }
6726
6798
  }
6727
6799
 
6728
6800
  function saveWorkingDir(path) {
@@ -7016,6 +7088,16 @@
7016
7088
  if (!state.terminal) return;
7017
7089
  var viewport = getTerminalViewport();
7018
7090
  if (!viewport) return;
7091
+ // 打"程序触发滚动"窗口:紧跟着的 scroll 事件是 wand 自己拽出来的,
7092
+ // scroll handler 在窗口内跳过 autoFollow 修改,避免"程序拽底 →
7093
+ // scroll 事件 → handler 看到在底 → autoFollow=true"的反馈环把
7094
+ // 用户刚 wheel 上滚的意图覆盖掉。smooth 模式 Chromium 滚动动画约
7095
+ // 300-500ms,瞬时滚动只需覆盖一次 rAF + 事件分发延迟。
7096
+ var windowMs = smooth ? 500 : 120;
7097
+ state.terminalProgrammaticScrollUntil = Math.max(
7098
+ state.terminalProgrammaticScrollUntil,
7099
+ Date.now() + windowMs
7100
+ );
7019
7101
  if (smooth) {
7020
7102
  viewport.scrollTo({ top: viewport.scrollHeight, behavior: "smooth" });
7021
7103
  } else {
@@ -7422,7 +7504,36 @@
7422
7504
  if (!state.wideParserState) state.wideParserState = createWideParserState();
7423
7505
  var padded = widePadAnsi(data, state.wideParserState);
7424
7506
  var framed = processSyncOutputFraming(padded);
7507
+ // wterm.write 内部用 5px 阈值判定"在底部",下一帧 _doRender 据此强制
7508
+ // scrollTop = scrollHeight。这与 wand 的 autoFollow("真正到底"才为
7509
+ // true,2px 阈值)独立,会把用户主动向上滚的几像素吞掉。覆写为 wand
7510
+ // 的 autoFollow 状态,让 autoFollow 成为唯一真相。
7511
+ //
7512
+ // 时序关键:必须在 terminal.write() 之前先覆写一次,否则 wterm 在 write
7513
+ // 内部解析 chunk 时可能同步触发 _doRender → 提前完成 scrollTop=scrollHeight,
7514
+ // write 之后再覆写就晚了一帧,用户上滚位置已被吞。write 之后再覆写一次
7515
+ // 兜底:wterm 在解析 newline / cursor move / scroll region 时可能把
7516
+ // _shouldScrollToBottom 改回 true。
7517
+ var follow = state.terminalAutoFollow !== false;
7518
+ if ("_shouldScrollToBottom" in terminal) {
7519
+ terminal._shouldScrollToBottom = follow;
7520
+ }
7425
7521
  if (framed) terminal.write(framed);
7522
+ if ("_shouldScrollToBottom" in terminal) {
7523
+ terminal._shouldScrollToBottom = follow;
7524
+ }
7525
+ // wterm 按 follow=true 真的 scrollTop=scrollHeight 时会触发一次程序性的
7526
+ // scroll 事件 — 打窗口,让 scroll handler 不要误判为"用户滚回底部"。
7527
+ // **只在 follow=true 时打**:follow=false 时 wterm 不会拽底,没有程序事件
7528
+ // 要过滤;如果这里也打标,Claude 流式输出 chunk <120ms 一个会让窗口永
7529
+ // 不过期,scroll handler 永远 early return,用户哪怕滚回严格底部 autoFollow
7530
+ // 也回不到 true,再也走不出"上滚阅读"模式。
7531
+ if (follow) {
7532
+ state.terminalProgrammaticScrollUntil = Math.max(
7533
+ state.terminalProgrammaticScrollUntil,
7534
+ Date.now() + 120
7535
+ );
7536
+ }
7426
7537
  // R6: 在 chunk 热路径上识别原地重绘序列(CSI nA/B/C/D/f/H/J/K),
7427
7538
  // 节流安排一次 softResync 兜底。Claude 用相对光标位移重画菜单时,
7428
7539
  // 如果 NEW-A 的 sync output buffer 因某种原因没拦截到完整帧(比如
@@ -7430,13 +7541,6 @@
7430
7541
  // 错位。此 fallback 仅在真出现错位序列时触发,正常输出零开销。
7431
7542
  // 与 R2 策略 A 配合:移除被动 5 处触发后,这是唯一的主动救场路径。
7432
7543
  maybeScheduleResyncForChunk(data);
7433
- // wterm.write 内部用 5px 阈值判定"在底部",下一帧 _doRender 据此强制
7434
- // scrollTop = scrollHeight。这与 wand 的 autoFollow("真正到底"才为
7435
- // true,2px 阈值)独立,会把用户主动向上滚的几像素吞掉。覆写为 wand
7436
- // 的 autoFollow 状态,让 autoFollow 成为唯一真相。
7437
- if ("_shouldScrollToBottom" in terminal) {
7438
- terminal._shouldScrollToBottom = state.terminalAutoFollow !== false;
7439
- }
7440
7544
  }
7441
7545
 
7442
7546
  function resetWideParserState() {
@@ -7854,6 +7958,16 @@
7854
7958
  var viewport = getTerminalViewport();
7855
7959
  if (viewport) {
7856
7960
  state.terminalViewportScrollHandler = function() {
7961
+ // 程序触发的 scroll(wand 主动 scrollTo / wterm 内部
7962
+ // _doRender 因 _shouldScrollToBottom=true 拽 scrollTop=scrollHeight)
7963
+ // 也会进这里。如果不过滤,handler 会看到 isTerminalAtBottom()=true
7964
+ // 把 autoFollow 反转回 true,把用户刚上滚的意图吞掉,下一帧 chunk
7965
+ // 到达又被拽底,形成"上滚→拽底"反馈环。窗口长度由调用方按
7966
+ // 各自动画长度决定(瞬时 120ms / smooth 500ms)。
7967
+ if (Date.now() < state.terminalProgrammaticScrollUntil) {
7968
+ updateTerminalJumpToBottomButton();
7969
+ return;
7970
+ }
7857
7971
  // 严格"真正到底"才恢复 autoFollow:避免 wheel 设 false 后被
7858
7972
  // 紧接着的 scroll 事件因"接近底部 12px"而反转回 true。
7859
7973
  if (isTerminalAtBottom()) {
@@ -8125,14 +8239,26 @@
8125
8239
 
8126
8240
  function syncComposerModeSelect() {
8127
8241
  var select = document.getElementById("chat-mode-select");
8128
- if (!select) return;
8129
8242
  state.chatMode = getSafeModeForTool("claude", state.chatMode);
8130
- select.innerHTML = renderModeOptions("claude", state.chatMode);
8131
- select.value = state.chatMode;
8243
+ if (select) {
8244
+ select.innerHTML = renderChatModeOptionsRaw("claude", state.chatMode);
8245
+ select.value = state.chatMode;
8246
+ }
8247
+ var labelEl = document.getElementById("chat-mode-label");
8248
+ if (labelEl) labelEl.textContent = state.chatMode;
8132
8249
  var modeHint = document.getElementById("mode-hint");
8133
8250
  if (modeHint) modeHint.textContent = getModeHint(state.chatMode);
8134
8251
  }
8135
8252
 
8253
+ // 三件套 raw 选项渲染:option 文本直接是 id(不带括号注释 / 不本地化)。
8254
+ function renderChatModeOptionsRaw(tool, selectedMode) {
8255
+ return getSupportedModes(tool).map(function(mode) {
8256
+ return '<option value="' + escapeHtml(mode) + '"' + (mode === selectedMode ? " selected" : "") + '>' +
8257
+ escapeHtml(mode) +
8258
+ '</option>';
8259
+ }).join("");
8260
+ }
8261
+
8136
8262
  function getEffectiveModel(session) {
8137
8263
  if (session && session.selectedModel) return session.selectedModel;
8138
8264
  if (state.chatModel) return state.chatModel;
@@ -8160,13 +8286,29 @@
8160
8286
  return html;
8161
8287
  }
8162
8288
 
8289
+ // model 选项 raw 版:空值显示 "default",其它直接用 raw id(不带"(自定义)"等后缀)。
8290
+ function renderChatModelOptionsRaw(selected, session) {
8291
+ var models = getModelsForCurrentProvider(session);
8292
+ var html = '<option value="">default</option>';
8293
+ for (var i = 0; i < models.length; i++) {
8294
+ var m = models[i];
8295
+ html += '<option value="' + escapeHtml(m.id) + '"' + (m.id === selected ? " selected" : "") + '>' + escapeHtml(m.id) + '</option>';
8296
+ }
8297
+ if (selected && !models.some(function(m) { return m.id === selected; })) {
8298
+ html += '<option value="' + escapeHtml(selected) + '" selected>' + escapeHtml(selected) + '</option>';
8299
+ }
8300
+ return html;
8301
+ }
8302
+
8163
8303
  function syncComposerModelSelect(session) {
8164
8304
  var select = document.getElementById("chat-model-select");
8305
+ var effective = getEffectiveModel(session);
8165
8306
  if (select) {
8166
- var effective = getEffectiveModel(session);
8167
- select.innerHTML = renderChatModelOptions(effective, session);
8307
+ select.innerHTML = renderChatModelOptionsRaw(effective, session);
8168
8308
  select.value = effective;
8169
8309
  }
8310
+ var labelEl = document.getElementById("chat-model-label");
8311
+ if (labelEl) labelEl.textContent = effective || "default";
8170
8312
  // thinking 选择器与 model 选择器属于同一组"会话级设置",
8171
8313
  // 任何刷新 model 的时机也应该同步刷新 thinking,避免漂移。
8172
8314
  syncComposerThinkingSelect(session);
@@ -8206,14 +8348,26 @@
8206
8348
  return html;
8207
8349
  }
8208
8350
 
8351
+ // thinking 选项 raw 版:option 文本直接是 id(off / standard / deep / max)。
8352
+ function renderChatThinkingOptionsRaw(selected) {
8353
+ var v = selected || "off";
8354
+ var html = "";
8355
+ for (var i = 0; i < THINKING_LEVELS.length; i++) {
8356
+ var lvl = THINKING_LEVELS[i];
8357
+ html += '<option value="' + escapeHtml(lvl.id) + '"' + (lvl.id === v ? ' selected' : '') + '>' + escapeHtml(lvl.id) + '</option>';
8358
+ }
8359
+ return html;
8360
+ }
8361
+
8209
8362
  function syncComposerThinkingSelect(session) {
8210
8363
  var select = document.getElementById("chat-thinking-select");
8211
- if (!select) return;
8212
8364
  var effective = getEffectiveThinking(session);
8213
- select.innerHTML = renderChatThinkingOptions(effective);
8214
- select.value = effective;
8365
+ if (select) {
8366
+ select.innerHTML = renderChatThinkingOptionsRaw(effective);
8367
+ select.value = effective;
8368
+ }
8215
8369
  var labelEl = document.getElementById("chat-thinking-label");
8216
- if (labelEl) labelEl.textContent = getThinkingLabel(effective);
8370
+ if (labelEl) labelEl.textContent = effective;
8217
8371
  }
8218
8372
 
8219
8373
  function onChatThinkingChange(value) {
@@ -8224,7 +8378,7 @@
8224
8378
  state.chatThinking = normalized;
8225
8379
  try { localStorage.setItem("wand-thinking-effort", normalized); } catch (e) {}
8226
8380
  var labelEl = document.getElementById("chat-thinking-label");
8227
- if (labelEl) labelEl.textContent = getThinkingLabel(normalized);
8381
+ if (labelEl) labelEl.textContent = normalized;
8228
8382
  var session = getSelectedSession();
8229
8383
  if (!session) return;
8230
8384
  fetch("/api/sessions/" + encodeURIComponent(session.id) + "/thinking-effort", {
@@ -8473,6 +8627,8 @@
8473
8627
  var normalized = (value || "").trim();
8474
8628
  state.chatModel = normalized;
8475
8629
  try { localStorage.setItem("wand-chat-model", normalized); } catch (e) {}
8630
+ var labelEl = document.getElementById("chat-model-label");
8631
+ if (labelEl) labelEl.textContent = normalized || "default";
8476
8632
  var session = getSelectedSession();
8477
8633
  if (!session) return;
8478
8634
  fetch("/api/sessions/" + encodeURIComponent(session.id) + "/model", {
@@ -9088,14 +9244,16 @@
9088
9244
  var drawer = document.getElementById("sessions-drawer");
9089
9245
  var mainLayout = document.querySelector(".main-layout");
9090
9246
  var pinBtn = document.getElementById("sidebar-pin-btn");
9091
- var isDesktopPinned = state.sidebarPinned && !isMobileLayout();
9092
- var isCollapsed = isDesktopPinned && state.sidebarCollapsed;
9247
+ // renderAppShell 保持一致:手机端只允许窄条形态 anchored。
9248
+ var isMobile = isMobileLayout();
9249
+ var isCollapsed = !!state.sidebarPinned && !!state.sidebarCollapsed;
9250
+ var isAnchored = isCollapsed || (!!state.sidebarPinned && !isMobile);
9093
9251
  if (drawer) {
9094
- drawer.classList.toggle("pinned", isDesktopPinned);
9252
+ drawer.classList.toggle("pinned", isAnchored);
9095
9253
  drawer.classList.toggle("collapsed", isCollapsed);
9096
9254
  }
9097
9255
  if (mainLayout) {
9098
- mainLayout.classList.toggle("sidebar-pinned", isDesktopPinned);
9256
+ mainLayout.classList.toggle("sidebar-pinned", isAnchored);
9099
9257
  mainLayout.classList.toggle("sidebar-collapsed", isCollapsed);
9100
9258
  }
9101
9259
  if (pinBtn) {
@@ -9208,12 +9366,11 @@
9208
9366
  }
9209
9367
 
9210
9368
  function toggleSidebarCollapsed() {
9211
- if (isMobileLayout()) return;
9369
+ var isMobile = isMobileLayout();
9212
9370
  // 在 drawer 模式(未 pin)下点 collapse 视为「先固定、再收起为窄条」——
9213
9371
  // 用户直觉是「点了就该看到窄条」,过去这里 early return 让按钮看上去没反应。
9214
9372
  if (!state.sidebarPinned) {
9215
9373
  state.sidebarPinned = true;
9216
- state.sessionsDrawerOpen = true;
9217
9374
  try {
9218
9375
  localStorage.setItem("wand-sidebar-pinned", "true");
9219
9376
  } catch (e) {}
@@ -9222,6 +9379,22 @@
9222
9379
  try {
9223
9380
  localStorage.setItem("wand-sidebar-collapsed", String(state.sidebarCollapsed));
9224
9381
  } catch (e) {}
9382
+ if (state.sidebarCollapsed) {
9383
+ // 进入窄条形态:sessionsDrawerOpen 设 false,避免手机上 .drawer-backdrop
9384
+ // 仍带 .open 类导致背景遮罩误显示(窄条已经常驻显示,不需要遮罩)。
9385
+ state.sessionsDrawerOpen = false;
9386
+ } else if (isMobile) {
9387
+ // 手机端展开窄条:不允许「pin 但不窄条」的 300px 全栏(太占地),
9388
+ // 改为回到 drawer 模式并自动打开抽屉,让用户看到完整会话列表。
9389
+ state.sidebarPinned = false;
9390
+ state.sessionsDrawerOpen = true;
9391
+ try {
9392
+ localStorage.setItem("wand-sidebar-pinned", "false");
9393
+ } catch (e) {}
9394
+ } else {
9395
+ // 桌面端展开窄条 → 300px 全栏固定,自动打开。
9396
+ state.sessionsDrawerOpen = true;
9397
+ }
9225
9398
  render();
9226
9399
  var mainLayout = document.querySelector(".main-layout");
9227
9400
  if (mainLayout) {
@@ -12251,24 +12424,507 @@
12251
12424
  }
12252
12425
 
12253
12426
  function updateStructuredQueueCounter() {
12254
- var counter = document.getElementById("queue-counter");
12255
- var count = getSelectedStructuredQueuedInputs().length;
12256
- if (counter) {
12257
- // counter 现在是 dot + text 双节点结构,只更新文字节点;如果用 textContent
12258
- // 直接覆盖会把内嵌的 .queue-counter-dot 也一并干掉,CSS 上做的脉动小红点就没了。
12259
- var textNode = counter.querySelector(".queue-counter-text");
12260
- var label = count > 0 ? ("排队 " + count + " 条") : "队列 0";
12261
- if (textNode) {
12262
- textNode.textContent = label;
12263
- } else {
12264
- counter.textContent = label;
12427
+ // 旧 #queue-counter 已下线,所有"排队"提示由 .queue-bar(输入框上方独立浮条)承担。
12428
+ // 函数名先保留 —— 老的调用点(postStructuredInput / WS 事件等)都还在指向它。
12429
+ updateQueueBar();
12430
+ }
12431
+
12432
+ // ──────────────────────────────────────────────────────────────────────────
12433
+ // 排队条(.queue-bar)—— 输入框上方独立浮条,承担三个事情:
12434
+ // 1) 折叠态:● 排队 N + 队尾预览 + ⌃ chevron + ⚡ 立即 按钮
12435
+ // 2) 展开面板:列出所有排队消息,支持拖拽换序 / 单条删除 / 一键清空
12436
+ // 3) 立即按钮:中断当前回复,把队首作为新消息插队发出去(剩余队列保留)
12437
+ // 数据源:session.queuedMessages(由后端 WS 推送 + postStructuredInput 乐观更新)。
12438
+ // ──────────────────────────────────────────────────────────────────────────
12439
+
12440
+ var QUEUE_BAR_MAX = 10; // 后端硬上限
12441
+
12442
+ function queueBarTruncatePreview(text) {
12443
+ if (typeof text !== "string") return "";
12444
+ var s = text.replace(/\s+/g, " ").trim();
12445
+ if (s.length <= 48) return s;
12446
+ return s.slice(0, 46) + "…";
12447
+ }
12448
+
12449
+ function renderQueueBarSkeleton(count, latestPreview, inFlight, atCapacity, immediateLabel) {
12450
+ // 折叠条 + 展开面板的 HTML 一次性渲染好,靠 .queue-bar.expanded class 切换可见性。
12451
+ // 这样展开/收起不需要拼字符串,纯 class toggle,动画也好做。
12452
+ var dotClass = inFlight ? "queue-bar-dot queue-bar-dot-pulse" : "queue-bar-dot";
12453
+ var barClass = "queue-bar";
12454
+ if (state.queueBarExpanded) barClass += " expanded";
12455
+ if (atCapacity) barClass += " queue-bar-capacity";
12456
+ if (inFlight) barClass += " queue-bar-inflight";
12457
+ var html =
12458
+ '<div class="' + barClass + '" data-queue-bar="1">' +
12459
+ '<button type="button" class="queue-bar-toggle" data-action="toggle"' +
12460
+ ' aria-expanded="' + (state.queueBarExpanded ? "true" : "false") + '"' +
12461
+ ' title="点击查看 / 收起排队消息">' +
12462
+ '<span class="' + dotClass + '" aria-hidden="true"></span>' +
12463
+ '<span class="queue-bar-count">' + (atCapacity ? "队列已满 " : "排队 ") + count + '</span>' +
12464
+ '<span class="queue-bar-sep" aria-hidden="true">·</span>' +
12465
+ '<span class="queue-bar-preview">' + escapeHtml(latestPreview) + '</span>' +
12466
+ '<svg class="queue-bar-chevron" width="11" height="11" viewBox="0 0 24 24"' +
12467
+ ' fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"' +
12468
+ ' stroke-linejoin="round" aria-hidden="true"><polyline points="6 15 12 9 18 15"/></svg>' +
12469
+ '</button>' +
12470
+ '<span class="queue-bar-divider" aria-hidden="true"></span>' +
12471
+ '<button type="button" class="queue-bar-promote" data-action="promote"' +
12472
+ ' title="中断当前回复,立刻发送队首这条" aria-label="立即发送队首">' +
12473
+ '<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">' +
12474
+ '<path d="M13 2 L4 14 L11 14 L10 22 L20 9 L13 9 Z"/>' +
12475
+ '</svg>' +
12476
+ '<span class="queue-bar-promote-label">' + escapeHtml(immediateLabel) + '</span>' +
12477
+ '</button>' +
12478
+ '<div class="queue-bar-panel" data-queue-panel="1" role="region" aria-label="排队消息列表">' +
12479
+ '<div class="queue-bar-panel-header">' +
12480
+ '<span class="queue-bar-panel-title">📥 排队中 (' + count + ')</span>' +
12481
+ '<button type="button" class="queue-bar-clear" data-action="clear"' +
12482
+ (count === 0 ? " disabled" : "") + '>清空</button>' +
12483
+ '<button type="button" class="queue-bar-collapse" data-action="collapse" aria-label="收起">' +
12484
+ '收起' +
12485
+ '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor"' +
12486
+ ' stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
12487
+ '<polyline points="6 9 12 15 18 9"/></svg>' +
12488
+ '</button>' +
12489
+ '</div>' +
12490
+ '<ol class="queue-bar-list" data-queue-list="1"></ol>' +
12491
+ '</div>' +
12492
+ '</div>';
12493
+ return html;
12494
+ }
12495
+
12496
+ function renderQueueBarItems(listEl, items) {
12497
+ // ol 内容单独 render —— 拖拽 / 删除 / 展开会频繁动它,外层骨架不重建避免抖动。
12498
+ var single = items.length <= 1;
12499
+ var html = "";
12500
+ for (var i = 0; i < items.length; i++) {
12501
+ var raw = items[i] == null ? "" : String(items[i]);
12502
+ var expanded = !!state.queueBarItemExpanded[i];
12503
+ var itemClass = "queue-bar-item";
12504
+ if (expanded) itemClass += " expanded";
12505
+ if (single) itemClass += " queue-bar-item-single";
12506
+ html +=
12507
+ '<li class="' + itemClass + '" data-index="' + i + '">' +
12508
+ '<button type="button" class="queue-bar-item-drag" data-action="drag" aria-label="拖动调整顺序"' +
12509
+ ' title="按住拖动调整顺序"' + (single ? " disabled" : "") + '>' +
12510
+ '<svg width="10" height="14" viewBox="0 0 10 14" fill="currentColor" aria-hidden="true">' +
12511
+ '<circle cx="2.2" cy="2.2" r="1.2"/><circle cx="7.8" cy="2.2" r="1.2"/>' +
12512
+ '<circle cx="2.2" cy="7" r="1.2"/><circle cx="7.8" cy="7" r="1.2"/>' +
12513
+ '<circle cx="2.2" cy="11.8" r="1.2"/><circle cx="7.8" cy="11.8" r="1.2"/>' +
12514
+ '</svg>' +
12515
+ '</button>' +
12516
+ '<span class="queue-bar-item-index">#' + (i + 1) + '</span>' +
12517
+ '<button type="button" class="queue-bar-item-text" data-action="expand-text"' +
12518
+ ' aria-expanded="' + (expanded ? "true" : "false") + '"' +
12519
+ ' title="点击展开 / 收起完整内容">' +
12520
+ escapeHtml(raw) +
12521
+ '</button>' +
12522
+ '<button type="button" class="queue-bar-item-delete" data-action="delete"' +
12523
+ ' aria-label="删除这条排队消息" title="删除">' +
12524
+ '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor"' +
12525
+ ' stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
12526
+ '<line x1="6" y1="6" x2="18" y2="18"/><line x1="6" y1="18" x2="18" y2="6"/></svg>' +
12527
+ '</button>' +
12528
+ '</li>';
12529
+ }
12530
+ listEl.innerHTML = html;
12531
+ }
12532
+
12533
+ function updateQueueBar() {
12534
+ var host = document.getElementById("queue-bar-host");
12535
+ if (!host) return;
12536
+ var session = state.sessions.find(function(s) { return s.id === state.selectedId; });
12537
+ var isStructured = session && session.sessionKind === "structured";
12538
+ var queue = isStructured ? getStructuredQueuedInputs(session) : [];
12539
+ queue = Array.isArray(queue) ? queue : [];
12540
+
12541
+ if (!isStructured || queue.length === 0) {
12542
+ // 队列空 / 非结构化会话:整条隐藏,并清掉展开/逐条展开的本地态。
12543
+ host.hidden = true;
12544
+ host.innerHTML = "";
12545
+ state.queueBarExpanded = false;
12546
+ state.queueBarItemExpanded = {};
12547
+ return;
12548
+ }
12549
+
12550
+ host.hidden = false;
12551
+ var inFlight = !!(session.structuredState && session.structuredState.inFlight && session.status === "running");
12552
+ var atCapacity = queue.length >= QUEUE_BAR_MAX;
12553
+ var latest = queueBarTruncatePreview(queue[queue.length - 1]);
12554
+ // inFlight=false 时按钮语义从"插队"退化为"立刻发";文案一并切换让用户不疑惑。
12555
+ var immediateLabel = inFlight ? "立即" : "发送";
12556
+
12557
+ // 拖拽进行中绝不重建骨架,否则 pointer capture 丢失、items 闪屏。
12558
+ // 只更新列表内容(且如果数量不变也跳过整段重排)。
12559
+ var existing = host.querySelector(".queue-bar");
12560
+ if (state.queueBarDrag && existing) {
12561
+ var listInDrag = existing.querySelector('[data-queue-list="1"]');
12562
+ if (listInDrag && listInDrag.children.length !== queue.length) {
12563
+ renderQueueBarItems(listInDrag, queue);
12265
12564
  }
12266
- if (count > 0) {
12267
- counter.classList.remove("hidden");
12268
- } else {
12269
- counter.classList.add("hidden");
12565
+ return;
12566
+ }
12567
+
12568
+ host.innerHTML = renderQueueBarSkeleton(queue.length, latest, inFlight, atCapacity, immediateLabel);
12569
+ var listEl = host.querySelector('[data-queue-list="1"]');
12570
+ if (listEl) renderQueueBarItems(listEl, queue);
12571
+ }
12572
+
12573
+ // ── 折叠 / 展开 ──
12574
+ function setQueueBarExpanded(expanded) {
12575
+ var next = !!expanded;
12576
+ if (state.queueBarExpanded === next) return;
12577
+ state.queueBarExpanded = next;
12578
+ if (!next) state.queueBarItemExpanded = {};
12579
+ updateQueueBar();
12580
+ }
12581
+ function toggleQueueBar() { setQueueBarExpanded(!state.queueBarExpanded); }
12582
+
12583
+ function handleQueueBarOutsideClick(ev) {
12584
+ if (!state.queueBarExpanded) return;
12585
+ var host = document.getElementById("queue-bar-host");
12586
+ if (!host) return;
12587
+ if (host.contains(ev.target)) return;
12588
+ setQueueBarExpanded(false);
12589
+ }
12590
+ function handleQueueBarKeydown(ev) {
12591
+ if (!state.queueBarExpanded) return;
12592
+ if (ev.key === "Escape" || ev.key === "Esc") {
12593
+ setQueueBarExpanded(false);
12594
+ // 焦点回到 toggle 按钮,方便键盘党
12595
+ var toggle = document.querySelector(".queue-bar-toggle");
12596
+ if (toggle) toggle.focus();
12597
+ }
12598
+ }
12599
+
12600
+ // ── 单条删除 / 全部清空 / 队首插队 ──
12601
+ function rollbackQueueOptimistic(session, prevQueue) {
12602
+ updateSessionSnapshot({ id: session.id, queuedMessages: prevQueue });
12603
+ var refreshed = state.sessions.find(function(s) { return s.id === session.id; }) || session;
12604
+ state.currentMessages = buildMessagesForRender(refreshed, getPreferredMessages(refreshed, refreshed.output, false));
12605
+ renderChat(true);
12606
+ updateQueueBar();
12607
+ }
12608
+
12609
+ function queueBarDeleteItem(index) {
12610
+ var session = state.sessions.find(function(s) { return s.id === state.selectedId; });
12611
+ if (!session) return;
12612
+ var queue = Array.isArray(session.queuedMessages) ? session.queuedMessages.slice() : [];
12613
+ if (index < 0 || index >= queue.length) return;
12614
+ var prev = queue.slice();
12615
+ var next = queue.slice(0, index).concat(queue.slice(index + 1));
12616
+ // 调整 queueBarItemExpanded 的下标偏移
12617
+ var nextExpanded = {};
12618
+ Object.keys(state.queueBarItemExpanded).forEach(function(k) {
12619
+ var i = Number(k);
12620
+ if (i === index) return;
12621
+ if (i > index) nextExpanded[i - 1] = state.queueBarItemExpanded[k];
12622
+ else nextExpanded[i] = state.queueBarItemExpanded[k];
12623
+ });
12624
+ state.queueBarItemExpanded = nextExpanded;
12625
+ updateSessionSnapshot({ id: session.id, queuedMessages: next });
12626
+ var refreshed = state.sessions.find(function(s) { return s.id === session.id; }) || session;
12627
+ state.currentMessages = buildMessagesForRender(refreshed, getPreferredMessages(refreshed, refreshed.output, false));
12628
+ renderChat(true);
12629
+ updateQueueBar();
12630
+ fetch("/api/structured-sessions/" + session.id + "/queued/" + index, {
12631
+ method: "DELETE",
12632
+ credentials: "same-origin",
12633
+ })
12634
+ .then(function(res) {
12635
+ if (!res.ok) {
12636
+ return res.json().catch(function() { return {}; }).then(function(p) {
12637
+ throw new Error((p && p.error) || "删除失败");
12638
+ });
12639
+ }
12640
+ })
12641
+ .catch(function(err) {
12642
+ rollbackQueueOptimistic(session, prev);
12643
+ showToast((err && err.message) || "删除排队消息失败。", "error");
12644
+ });
12645
+ }
12646
+
12647
+ function queueBarClearAll() {
12648
+ var session = state.sessions.find(function(s) { return s.id === state.selectedId; });
12649
+ if (!session) return;
12650
+ var prev = Array.isArray(session.queuedMessages) ? session.queuedMessages.slice() : [];
12651
+ if (prev.length === 0) return;
12652
+ state.queueBarItemExpanded = {};
12653
+ updateSessionSnapshot({ id: session.id, queuedMessages: [] });
12654
+ var refreshed = state.sessions.find(function(s) { return s.id === session.id; }) || session;
12655
+ state.currentMessages = buildMessagesForRender(refreshed, getPreferredMessages(refreshed, refreshed.output, false));
12656
+ renderChat(true);
12657
+ updateQueueBar();
12658
+ fetch("/api/structured-sessions/" + session.id + "/queued", {
12659
+ method: "DELETE",
12660
+ credentials: "same-origin",
12661
+ })
12662
+ .then(function(res) {
12663
+ if (!res.ok) {
12664
+ return res.json().catch(function() { return {}; }).then(function(p) {
12665
+ throw new Error((p && p.error) || "清空失败");
12666
+ });
12667
+ }
12668
+ showToast("已清空 " + prev.length + " 条排队消息。", "info");
12669
+ })
12670
+ .catch(function(err) {
12671
+ rollbackQueueOptimistic(session, prev);
12672
+ showToast((err && err.message) || "清空排队消息失败。", "error");
12673
+ });
12674
+ }
12675
+
12676
+ function queueBarPromoteHead() {
12677
+ var session = state.sessions.find(function(s) { return s.id === state.selectedId; });
12678
+ if (!session) return;
12679
+ var queue = Array.isArray(session.queuedMessages) ? session.queuedMessages.slice() : [];
12680
+ if (queue.length === 0) return;
12681
+ var head = queue[0];
12682
+ var rest = queue.slice(1);
12683
+ var prev = queue.slice();
12684
+ var inFlight = !!(session.structuredState && session.structuredState.inFlight && session.status === "running");
12685
+
12686
+ // 乐观:剥掉队首
12687
+ state.queueBarItemExpanded = (function() {
12688
+ var out = {};
12689
+ Object.keys(state.queueBarItemExpanded).forEach(function(k) {
12690
+ var i = Number(k);
12691
+ if (i === 0) return;
12692
+ out[i - 1] = state.queueBarItemExpanded[k];
12693
+ });
12694
+ return out;
12695
+ })();
12696
+ updateSessionSnapshot({ id: session.id, queuedMessages: rest });
12697
+
12698
+ // 收起面板,让用户视线回到 chat(新消息马上要进 user turn)
12699
+ setQueueBarExpanded(false);
12700
+
12701
+ var idempotencyKey = (typeof crypto !== "undefined" && crypto.randomUUID)
12702
+ ? crypto.randomUUID()
12703
+ : (Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 10));
12704
+
12705
+ var body = { input: head, idempotencyKey: idempotencyKey };
12706
+ if (inFlight) {
12707
+ // 中断 + 保留剩余队列
12708
+ body.interrupt = true;
12709
+ body.preserveQueue = true;
12710
+ }
12711
+ // 给一个乐观 toast,让用户瞬间知道点击生效了
12712
+ showToast(inFlight ? "已请求中断当前回复,立即发送队首。" : "已立即发送队首消息。", "info");
12713
+
12714
+ fetch("/api/structured-sessions/" + session.id + "/messages", {
12715
+ method: "POST",
12716
+ headers: { "Content-Type": "application/json" },
12717
+ credentials: "same-origin",
12718
+ body: JSON.stringify(body),
12719
+ })
12720
+ .then(function(res) {
12721
+ if (!res.ok) {
12722
+ return res.json().catch(function() { return {}; }).then(function(p) {
12723
+ throw new Error((p && p.error) || "立即发送失败");
12724
+ });
12725
+ }
12726
+ return res.json();
12727
+ })
12728
+ .then(function(snapshot) {
12729
+ if (snapshot && snapshot.id) {
12730
+ updateSessionSnapshot(snapshot);
12731
+ if (snapshot.id === state.selectedId) {
12732
+ var refreshed = state.sessions.find(function(s) { return s.id === snapshot.id; }) || snapshot;
12733
+ state.currentMessages = buildMessagesForRender(refreshed, getPreferredMessages(refreshed, snapshot.output, false));
12734
+ renderChat(true);
12735
+ updateQueueBar();
12736
+ }
12270
12737
  }
12738
+ })
12739
+ .catch(function(err) {
12740
+ rollbackQueueOptimistic(session, prev);
12741
+ showToast((err && err.message) || "立即发送失败。", "error");
12742
+ });
12743
+ }
12744
+
12745
+ // ── 拖拽排序(Pointer Events + 简化版 sort/animate)──
12746
+ function queueBarDragStart(ev, handleEl) {
12747
+ var session = state.sessions.find(function(s) { return s.id === state.selectedId; });
12748
+ if (!session) return;
12749
+ var queue = Array.isArray(session.queuedMessages) ? session.queuedMessages.slice() : [];
12750
+ if (queue.length <= 1) return;
12751
+ var itemEl = handleEl.closest(".queue-bar-item");
12752
+ if (!itemEl) return;
12753
+ var listEl = itemEl.parentElement;
12754
+ if (!listEl) return;
12755
+ var origIndex = Number(itemEl.getAttribute("data-index"));
12756
+ var siblings = Array.prototype.slice.call(listEl.children);
12757
+ var rects = siblings.map(function(el) { return el.getBoundingClientRect(); });
12758
+ var rect0 = rects[origIndex];
12759
+ var itemHeight = rect0.height;
12760
+ var gap = 6; // 与 CSS .queue-bar-list 的 gap 保持一致
12761
+
12762
+ ev.preventDefault();
12763
+ try { handleEl.setPointerCapture(ev.pointerId); } catch (_e) {}
12764
+ if (navigator && navigator.vibrate) { try { navigator.vibrate(8); } catch (_e2) {} }
12765
+
12766
+ state.queueBarDrag = {
12767
+ pointerId: ev.pointerId,
12768
+ handleEl: handleEl,
12769
+ itemEl: itemEl,
12770
+ listEl: listEl,
12771
+ siblings: siblings,
12772
+ rects: rects,
12773
+ origIndex: origIndex,
12774
+ targetIndex: origIndex,
12775
+ startY: ev.clientY,
12776
+ itemHeight: itemHeight,
12777
+ gap: gap,
12778
+ queueSnapshot: queue,
12779
+ };
12780
+
12781
+ itemEl.classList.add("dragging");
12782
+ // 把所有兄弟先标记为"参与平滑动画"
12783
+ siblings.forEach(function(el) { if (el !== itemEl) el.classList.add("queue-bar-item-sliding"); });
12784
+
12785
+ var move = function(e) { queueBarDragMove(e); };
12786
+ var up = function(e) { queueBarDragEnd(e); };
12787
+ state.queueBarDrag.moveHandler = move;
12788
+ state.queueBarDrag.upHandler = up;
12789
+ handleEl.addEventListener("pointermove", move);
12790
+ handleEl.addEventListener("pointerup", up);
12791
+ handleEl.addEventListener("pointercancel", up);
12792
+ }
12793
+
12794
+ function queueBarDragMove(ev) {
12795
+ var d = state.queueBarDrag;
12796
+ if (!d || ev.pointerId !== d.pointerId) return;
12797
+ ev.preventDefault();
12798
+ var deltaY = ev.clientY - d.startY;
12799
+ d.itemEl.style.transform = "translateY(" + deltaY + "px)";
12800
+
12801
+ // 拖动中心 Y 决定目标插入位置
12802
+ var centerY = d.rects[d.origIndex].top + d.rects[d.origIndex].height / 2 + deltaY;
12803
+ var target = d.origIndex;
12804
+ for (var i = 0; i < d.rects.length; i++) {
12805
+ if (i === d.origIndex) continue;
12806
+ var midY = d.rects[i].top + d.rects[i].height / 2;
12807
+ if (i < d.origIndex && centerY < midY) { target = Math.min(target, i); }
12808
+ else if (i > d.origIndex && centerY > midY) { target = Math.max(target, i); }
12809
+ }
12810
+ if (target !== d.targetIndex) {
12811
+ d.targetIndex = target;
12812
+ // 重排兄弟元素的 translateY
12813
+ var shift = d.itemHeight + d.gap;
12814
+ d.siblings.forEach(function(el, idx) {
12815
+ if (idx === d.origIndex) return;
12816
+ var move = 0;
12817
+ if (d.origIndex < target && idx > d.origIndex && idx <= target) move = -shift;
12818
+ else if (d.origIndex > target && idx < d.origIndex && idx >= target) move = shift;
12819
+ el.style.transform = move ? "translateY(" + move + "px)" : "";
12820
+ });
12821
+ }
12822
+ }
12823
+
12824
+ function queueBarDragEnd(ev) {
12825
+ var d = state.queueBarDrag;
12826
+ if (!d || (ev && ev.pointerId !== d.pointerId)) return;
12827
+ try { d.handleEl.releasePointerCapture(d.pointerId); } catch (_e) {}
12828
+ d.handleEl.removeEventListener("pointermove", d.moveHandler);
12829
+ d.handleEl.removeEventListener("pointerup", d.upHandler);
12830
+ d.handleEl.removeEventListener("pointercancel", d.upHandler);
12831
+
12832
+ var origIndex = d.origIndex;
12833
+ var targetIndex = d.targetIndex;
12834
+ var queueSnapshot = d.queueSnapshot;
12835
+
12836
+ // 清掉 inline transform 让 CSS 自然回位
12837
+ d.siblings.forEach(function(el) {
12838
+ el.style.transform = "";
12839
+ el.classList.remove("queue-bar-item-sliding");
12840
+ });
12841
+ d.itemEl.classList.remove("dragging");
12842
+
12843
+ state.queueBarDrag = null;
12844
+
12845
+ if (origIndex === targetIndex) {
12846
+ // 没动,光擦一下重渲就行
12847
+ updateQueueBar();
12848
+ return;
12271
12849
  }
12850
+
12851
+ // 计算 order: 原下标的新排列
12852
+ var order = [];
12853
+ for (var i = 0; i < queueSnapshot.length; i++) order.push(i);
12854
+ order.splice(origIndex, 1);
12855
+ order.splice(targetIndex, 0, origIndex);
12856
+ var nextQueue = order.map(function(i) { return queueSnapshot[i]; });
12857
+
12858
+ // 同步迁移 queueBarItemExpanded 下标
12859
+ var nextExpanded = {};
12860
+ Object.keys(state.queueBarItemExpanded).forEach(function(k) {
12861
+ var oldI = Number(k);
12862
+ var newI = order.indexOf(oldI);
12863
+ if (newI >= 0) nextExpanded[newI] = state.queueBarItemExpanded[k];
12864
+ });
12865
+ state.queueBarItemExpanded = nextExpanded;
12866
+
12867
+ var session = state.sessions.find(function(s) { return s.id === state.selectedId; });
12868
+ if (!session) { updateQueueBar(); return; }
12869
+ updateSessionSnapshot({ id: session.id, queuedMessages: nextQueue });
12870
+ updateQueueBar();
12871
+
12872
+ fetch("/api/structured-sessions/" + session.id + "/queued", {
12873
+ method: "PATCH",
12874
+ headers: { "Content-Type": "application/json" },
12875
+ credentials: "same-origin",
12876
+ body: JSON.stringify({ order: order }),
12877
+ })
12878
+ .then(function(res) {
12879
+ if (!res.ok) {
12880
+ return res.json().catch(function() { return {}; }).then(function(p) {
12881
+ throw new Error((p && p.error) || "排序失败");
12882
+ });
12883
+ }
12884
+ })
12885
+ .catch(function(err) {
12886
+ rollbackQueueOptimistic(session, queueSnapshot);
12887
+ showToast((err && err.message) || "调整排队顺序失败。", "error");
12888
+ });
12889
+ }
12890
+
12891
+ // ── 事件代理:所有交互入口都从 #queue-bar-host 起手 ──
12892
+ function attachQueueBarDelegates() {
12893
+ var host = document.getElementById("queue-bar-host");
12894
+ if (!host || host.__queueDelegated) return;
12895
+ host.__queueDelegated = true;
12896
+ host.addEventListener("click", function(ev) {
12897
+ var actionEl = ev.target && ev.target.closest ? ev.target.closest("[data-action]") : null;
12898
+ if (!actionEl || !host.contains(actionEl)) return;
12899
+ var action = actionEl.getAttribute("data-action");
12900
+ if (action === "drag") return; // 拖拽由 pointerdown 处理,吞掉点击避免误触发
12901
+ ev.preventDefault();
12902
+ ev.stopPropagation();
12903
+ if (action === "toggle") { toggleQueueBar(); return; }
12904
+ if (action === "collapse") { setQueueBarExpanded(false); return; }
12905
+ if (action === "promote") { queueBarPromoteHead(); return; }
12906
+ if (action === "clear") { queueBarClearAll(); return; }
12907
+ if (action === "delete") {
12908
+ var itemEl = actionEl.closest(".queue-bar-item");
12909
+ if (itemEl) queueBarDeleteItem(Number(itemEl.getAttribute("data-index")));
12910
+ return;
12911
+ }
12912
+ if (action === "expand-text") {
12913
+ var item = actionEl.closest(".queue-bar-item");
12914
+ if (!item) return;
12915
+ var idx = Number(item.getAttribute("data-index"));
12916
+ state.queueBarItemExpanded[idx] = !state.queueBarItemExpanded[idx];
12917
+ item.classList.toggle("expanded", !!state.queueBarItemExpanded[idx]);
12918
+ actionEl.setAttribute("aria-expanded", state.queueBarItemExpanded[idx] ? "true" : "false");
12919
+ return;
12920
+ }
12921
+ });
12922
+ host.addEventListener("pointerdown", function(ev) {
12923
+ if (ev.button !== undefined && ev.button !== 0) return;
12924
+ var handle = ev.target && ev.target.closest ? ev.target.closest('[data-action="drag"]') : null;
12925
+ if (!handle || handle.disabled) return;
12926
+ queueBarDragStart(ev, handle);
12927
+ });
12272
12928
  }
12273
12929
 
12274
12930
  // 计算一条 ConversationTurn 里所有 content block 的"信息体积"——文字 / 思考 /
@@ -15904,6 +16560,9 @@
15904
16560
  // (inFlight state may have changed without new message content)
15905
16561
  var chatMessages = chatOutput.querySelector(".chat-messages");
15906
16562
  if (chatMessages) renderStructuredStatusBar(chatMessages, selectedSession);
16563
+ // 同步刷一次进度条:inFlight 从 true→false 时(turn 结束)没有新消息,
16564
+ // updateTodoProgress 不被调到就会让"5/6"卡在底部一直不消失。
16565
+ updateTodoProgress(allMessages);
15907
16566
  return;
15908
16567
  }
15909
16568
  var prevHash = state.lastRenderedHash;
@@ -16307,9 +16966,19 @@
16307
16966
  });
16308
16967
 
16309
16968
  function updateTodoProgress(messages) {
16969
+ // 只看"当前 turn"里的 TodoWrite——即最后一条 user 消息之后的那段。
16970
+ // 不限制范围的话,上一轮留下的进度条会在新一轮(哪怕新一轮根本没用
16971
+ // TodoWrite)里阴魂不散地重现。
16972
+ var startIdx = 0;
16973
+ for (var ui = messages.length - 1; ui >= 0; ui--) {
16974
+ if (messages[ui] && messages[ui].role === "user") {
16975
+ startIdx = ui + 1;
16976
+ break;
16977
+ }
16978
+ }
16979
+
16310
16980
  var todos = null;
16311
- // Scan all messages for latest TodoWrite tool_use
16312
- for (var i = messages.length - 1; i >= 0; i--) {
16981
+ for (var i = messages.length - 1; i >= startIdx; i--) {
16313
16982
  var msg = messages[i];
16314
16983
  if (!msg.content || !Array.isArray(msg.content)) continue;
16315
16984
  for (var j = msg.content.length - 1; j >= 0; j--) {
@@ -16332,6 +17001,24 @@
16332
17001
  return;
16333
17002
  }
16334
17003
 
17004
+ // 当前 turn 已结束(结构化 inFlight=false 或 PTY 非 running)就把进度条
17005
+ // 收起来——模型经常忘了发最后一条"全 completed"的 TodoWrite,让用户
17006
+ // 对着 "5/6" 干瞪眼很别扭。allDone 那条分支保留,提前命中更快返回。
17007
+ var sel = state.sessions.find(function(s) { return s.id === state.selectedId; });
17008
+ var turnDone = false;
17009
+ if (sel) {
17010
+ if (isStructuredSession(sel)) {
17011
+ turnDone = !(sel.structuredState && sel.structuredState.inFlight);
17012
+ } else {
17013
+ turnDone = sel.status !== "running";
17014
+ }
17015
+ }
17016
+ if (turnDone) {
17017
+ container.classList.add("hidden");
17018
+ if (bodyEl) bodyEl.classList.add("hidden");
17019
+ return;
17020
+ }
17021
+
16335
17022
  container.classList.remove("hidden");
16336
17023
  if (bodyEl) bodyEl.classList.remove("hidden");
16337
17024
 
@@ -17216,7 +17903,16 @@
17216
17903
  return '<div class="subagent-reply pending"><span class="typing-indicator"><span></span><span></span><span></span></span></div>';
17217
17904
  }
17218
17905
 
17219
- return '<div class="subagent-reply">' + renderMarkdown(text) + '</div>';
17906
+ // 三态折叠:preview(默认 ~5 行预览,内部可滚)→ expanded(高一些上限,可滚)→
17907
+ // collapsed(完全收起,只剩工具条)→ preview。按钮一直可见在右下,状态写在
17908
+ // data-collapse-mode 上,配套 CSS 控制 max-height。
17909
+ return '<div class="subagent-reply collapsible" data-collapse-mode="preview">' +
17910
+ '<div class="subagent-reply-scroll">' + renderMarkdown(text) + '</div>' +
17911
+ '<button type="button" class="subagent-reply-cycle" onclick="__subagentReplyCycle(event, this)" title="展开 / 收起">' +
17912
+ '<span class="subagent-reply-cycle-label">展开</span>' +
17913
+ '<span class="subagent-reply-cycle-icon" aria-hidden="true">▾</span>' +
17914
+ '</button>' +
17915
+ '</div>';
17220
17916
  }
17221
17917
  var PIXEL_AVATAR = {
17222
17918
  assistant: buildPixelSvg(buildCatGrid(GARFIELD_PALETTE)),
@@ -17574,7 +18270,9 @@
17574
18270
  : '';
17575
18271
  html += '<div class="chat-handoff" style="--agent-color:' + subPalette.primary + '">' +
17576
18272
  '<span class="chat-handoff-arrow">↳</span> ' +
17577
- escapeHtml(parentPersonaName) + ' 让 <strong>' + escapeHtml(subName) + '</strong> 帮忙' + desc +
18273
+ escapeHtml(parentPersonaName) + ' 让 <strong>' + escapeHtml(subName) + '</strong>' +
18274
+ '<span class="chat-handoff-tag" title="子代理 / subagent">subagent</span>' +
18275
+ '帮忙' + desc +
17578
18276
  '</div>';
17579
18277
  }
17580
18278
  html += '<div class="chat-message-segment subagent" data-agent-id="' + escapeHtml(seg.subagent.taskId) + '" style="--agent-color:' + subPalette.primary + '">' +