@co0ontty/wand 1.31.0 → 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,
@@ -1503,14 +1510,17 @@
1503
1510
  var preferredTool = getComposerTool();
1504
1511
  var composerMode = getSafeModeForTool(preferredTool, state.chatMode);
1505
1512
 
1506
- var isDesktopPinned = state.sidebarPinned && !isMobileLayout();
1507
- 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);
1508
1518
  var collapsedCls = isCollapsed ? ' sidebar-collapsed' : '';
1509
1519
  var sidebarCollapsedCls = isCollapsed ? ' collapsed' : '';
1510
1520
  return '<div class="app-container">' +
1511
1521
  '<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 + '">' +
1522
+ '<div class="main-layout' + (state.sessionsDrawerOpen ? ' sidebar-open' : '') + (isAnchored ? ' sidebar-pinned' : '') + collapsedCls + '">' +
1523
+ '<aside id="sessions-drawer" class="sidebar' + drawerClass + (isAnchored ? ' pinned' : '') + sidebarCollapsedCls + '">' +
1514
1524
  '<div class="sidebar-header">' +
1515
1525
  '<div class="sidebar-header-main">' +
1516
1526
  '<div class="topbar-logo-icon">W</div>' +
@@ -1710,6 +1720,11 @@
1710
1720
  '<ul class="todo-progress-list" id="todo-progress-list"></ul>' +
1711
1721
  '</div>' +
1712
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>' +
1713
1728
  '<div class="input-composer">' +
1714
1729
  '<button id="prompt-optimize-btn" class="prompt-optimize-btn" type="button" title="提示词优化(AI)" aria-label="提示词优化">' +
1715
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">' +
@@ -1730,21 +1745,28 @@
1730
1745
  // tabindex="-1": 把这些控件移出 iOS Safari 的表单导航链,
1731
1746
  // 这样 textarea 聚焦时键盘上方就不会出现 ⌃ ⌄ ✓ 表单辅助栏。
1732
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">' +
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)) +
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)) +
1748
1770
  '</select>' +
1749
1771
  '</span>' +
1750
1772
  '</span>' +
@@ -1759,9 +1781,7 @@
1759
1781
  renderApprovalStatsBadge() +
1760
1782
  '</div>' +
1761
1783
  '<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>' +
1784
+ // 排队提示从这里搬到 .queue-bar(输入框上方独立浮条),原 #queue-counter 已移除。
1765
1785
  '<span class="input-hint' + (state.terminalInteractive ? ' terminal-interactive-hint' : state.currentView === "terminal" ? " hidden" : "") + '">' + (state.terminalInteractive ? '终端交互中 · Ctrl+C 中断 · Ctrl+L 清屏' : 'Enter 发送 · Shift+Enter 换行') + '</span>' +
1766
1786
  renderInlineKeyboard() +
1767
1787
  '<button id="stop-button" class="btn-circle btn-circle-stop' + (state.selectedId ? "" : " hidden") + '" title="停止">' +
@@ -3242,7 +3262,9 @@
3242
3262
  }
3243
3263
 
3244
3264
  function isSidebarNarrow() {
3245
- return !!state.sidebarPinned && !isMobileLayout() && !!state.sidebarCollapsed;
3265
+ // 桌面: pinned + collapsed = 56px 窄条。
3266
+ // 手机: pinned + collapsed 同样允许窄条(pin 单独不在手机生效,但 collapsed 是窄条形态的标志)。
3267
+ return !!state.sidebarPinned && !!state.sidebarCollapsed;
3246
3268
  }
3247
3269
 
3248
3270
  function renderCollapsedSessionTiles() {
@@ -5320,7 +5342,8 @@
5320
5342
  '</div>' +
5321
5343
  '<div class="field-hint session-kind-hint-row">' +
5322
5344
  '<span id="session-kind-description">' + escapeHtml(getSessionKindHint(sessionKind)) + '</span>' +
5323
- renderWorktreeToggle(worktreeEnabled) +
5345
+ // Worktree 模式入口暂时隐藏,保留 renderWorktreeToggle/state.sessionCreateWorktree 以便后续恢复
5346
+ // renderWorktreeToggle(worktreeEnabled) +
5324
5347
  '</div>' +
5325
5348
  '</div>' +
5326
5349
  '<div class="field">' +
@@ -5435,6 +5458,36 @@
5435
5458
  }
5436
5459
  persistElementExpandState(el, "thinking");
5437
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
+ };
5438
5491
  // Toggle function for inline tool rows (Read, Glob, Grep, etc.)
5439
5492
  window.__inlineToolToggle = function(el) {
5440
5493
  var expanded = el.classList.toggle("inline-tool-open");
@@ -6011,7 +6064,9 @@
6011
6064
  var modeSelect = document.getElementById("chat-mode-select");
6012
6065
  if (modeSelect) modeSelect.addEventListener("change", function() {
6013
6066
  state.chatMode = this.value;
6014
- 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");
6015
6070
  });
6016
6071
  var modelSelect = document.getElementById("chat-model-select");
6017
6072
  if (modelSelect) modelSelect.addEventListener("change", function() {
@@ -6723,6 +6778,16 @@
6723
6778
  initTerminal();
6724
6779
  setupMobileKeyboardHandlers();
6725
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
+ }
6726
6791
  }
6727
6792
 
6728
6793
  function saveWorkingDir(path) {
@@ -8125,14 +8190,26 @@
8125
8190
 
8126
8191
  function syncComposerModeSelect() {
8127
8192
  var select = document.getElementById("chat-mode-select");
8128
- if (!select) return;
8129
8193
  state.chatMode = getSafeModeForTool("claude", state.chatMode);
8130
- select.innerHTML = renderModeOptions("claude", state.chatMode);
8131
- 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;
8132
8200
  var modeHint = document.getElementById("mode-hint");
8133
8201
  if (modeHint) modeHint.textContent = getModeHint(state.chatMode);
8134
8202
  }
8135
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
+
8136
8213
  function getEffectiveModel(session) {
8137
8214
  if (session && session.selectedModel) return session.selectedModel;
8138
8215
  if (state.chatModel) return state.chatModel;
@@ -8160,13 +8237,29 @@
8160
8237
  return html;
8161
8238
  }
8162
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
+
8163
8254
  function syncComposerModelSelect(session) {
8164
8255
  var select = document.getElementById("chat-model-select");
8256
+ var effective = getEffectiveModel(session);
8165
8257
  if (select) {
8166
- var effective = getEffectiveModel(session);
8167
- select.innerHTML = renderChatModelOptions(effective, session);
8258
+ select.innerHTML = renderChatModelOptionsRaw(effective, session);
8168
8259
  select.value = effective;
8169
8260
  }
8261
+ var labelEl = document.getElementById("chat-model-label");
8262
+ if (labelEl) labelEl.textContent = effective || "default";
8170
8263
  // thinking 选择器与 model 选择器属于同一组"会话级设置",
8171
8264
  // 任何刷新 model 的时机也应该同步刷新 thinking,避免漂移。
8172
8265
  syncComposerThinkingSelect(session);
@@ -8206,14 +8299,26 @@
8206
8299
  return html;
8207
8300
  }
8208
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
+
8209
8313
  function syncComposerThinkingSelect(session) {
8210
8314
  var select = document.getElementById("chat-thinking-select");
8211
- if (!select) return;
8212
8315
  var effective = getEffectiveThinking(session);
8213
- select.innerHTML = renderChatThinkingOptions(effective);
8214
- select.value = effective;
8316
+ if (select) {
8317
+ select.innerHTML = renderChatThinkingOptionsRaw(effective);
8318
+ select.value = effective;
8319
+ }
8215
8320
  var labelEl = document.getElementById("chat-thinking-label");
8216
- if (labelEl) labelEl.textContent = getThinkingLabel(effective);
8321
+ if (labelEl) labelEl.textContent = effective;
8217
8322
  }
8218
8323
 
8219
8324
  function onChatThinkingChange(value) {
@@ -8224,7 +8329,7 @@
8224
8329
  state.chatThinking = normalized;
8225
8330
  try { localStorage.setItem("wand-thinking-effort", normalized); } catch (e) {}
8226
8331
  var labelEl = document.getElementById("chat-thinking-label");
8227
- if (labelEl) labelEl.textContent = getThinkingLabel(normalized);
8332
+ if (labelEl) labelEl.textContent = normalized;
8228
8333
  var session = getSelectedSession();
8229
8334
  if (!session) return;
8230
8335
  fetch("/api/sessions/" + encodeURIComponent(session.id) + "/thinking-effort", {
@@ -8473,6 +8578,8 @@
8473
8578
  var normalized = (value || "").trim();
8474
8579
  state.chatModel = normalized;
8475
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";
8476
8583
  var session = getSelectedSession();
8477
8584
  if (!session) return;
8478
8585
  fetch("/api/sessions/" + encodeURIComponent(session.id) + "/model", {
@@ -9088,14 +9195,16 @@
9088
9195
  var drawer = document.getElementById("sessions-drawer");
9089
9196
  var mainLayout = document.querySelector(".main-layout");
9090
9197
  var pinBtn = document.getElementById("sidebar-pin-btn");
9091
- var isDesktopPinned = state.sidebarPinned && !isMobileLayout();
9092
- 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);
9093
9202
  if (drawer) {
9094
- drawer.classList.toggle("pinned", isDesktopPinned);
9203
+ drawer.classList.toggle("pinned", isAnchored);
9095
9204
  drawer.classList.toggle("collapsed", isCollapsed);
9096
9205
  }
9097
9206
  if (mainLayout) {
9098
- mainLayout.classList.toggle("sidebar-pinned", isDesktopPinned);
9207
+ mainLayout.classList.toggle("sidebar-pinned", isAnchored);
9099
9208
  mainLayout.classList.toggle("sidebar-collapsed", isCollapsed);
9100
9209
  }
9101
9210
  if (pinBtn) {
@@ -9208,12 +9317,11 @@
9208
9317
  }
9209
9318
 
9210
9319
  function toggleSidebarCollapsed() {
9211
- if (isMobileLayout()) return;
9320
+ var isMobile = isMobileLayout();
9212
9321
  // 在 drawer 模式(未 pin)下点 collapse 视为「先固定、再收起为窄条」——
9213
9322
  // 用户直觉是「点了就该看到窄条」,过去这里 early return 让按钮看上去没反应。
9214
9323
  if (!state.sidebarPinned) {
9215
9324
  state.sidebarPinned = true;
9216
- state.sessionsDrawerOpen = true;
9217
9325
  try {
9218
9326
  localStorage.setItem("wand-sidebar-pinned", "true");
9219
9327
  } catch (e) {}
@@ -9222,6 +9330,22 @@
9222
9330
  try {
9223
9331
  localStorage.setItem("wand-sidebar-collapsed", String(state.sidebarCollapsed));
9224
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
+ }
9225
9349
  render();
9226
9350
  var mainLayout = document.querySelector(".main-layout");
9227
9351
  if (mainLayout) {
@@ -12251,26 +12375,509 @@
12251
12375
  }
12252
12376
 
12253
12377
  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;
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);
12265
12515
  }
12266
- if (count > 0) {
12267
- counter.classList.remove("hidden");
12268
- } else {
12269
- counter.classList.add("hidden");
12516
+ return;
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
+ });
12270
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
+ });
12271
12772
  }
12272
12773
  }
12273
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
+ });
12879
+ }
12880
+
12274
12881
  // 计算一条 ConversationTurn 里所有 content block 的"信息体积"——文字 / 思考 /
12275
12882
  // tool_result 内容长度之和。用于在 lastMessage 增量更新里判断 incoming 是否
12276
12883
  // 至少和 localLast 一样完整,防止服务端偶发吐出更短的同 message.id 导致
@@ -15904,6 +16511,9 @@
15904
16511
  // (inFlight state may have changed without new message content)
15905
16512
  var chatMessages = chatOutput.querySelector(".chat-messages");
15906
16513
  if (chatMessages) renderStructuredStatusBar(chatMessages, selectedSession);
16514
+ // 同步刷一次进度条:inFlight 从 true→false 时(turn 结束)没有新消息,
16515
+ // updateTodoProgress 不被调到就会让"5/6"卡在底部一直不消失。
16516
+ updateTodoProgress(allMessages);
15907
16517
  return;
15908
16518
  }
15909
16519
  var prevHash = state.lastRenderedHash;
@@ -16307,9 +16917,19 @@
16307
16917
  });
16308
16918
 
16309
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
+
16310
16931
  var todos = null;
16311
- // Scan all messages for latest TodoWrite tool_use
16312
- for (var i = messages.length - 1; i >= 0; i--) {
16932
+ for (var i = messages.length - 1; i >= startIdx; i--) {
16313
16933
  var msg = messages[i];
16314
16934
  if (!msg.content || !Array.isArray(msg.content)) continue;
16315
16935
  for (var j = msg.content.length - 1; j >= 0; j--) {
@@ -16332,6 +16952,24 @@
16332
16952
  return;
16333
16953
  }
16334
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
+
16335
16973
  container.classList.remove("hidden");
16336
16974
  if (bodyEl) bodyEl.classList.remove("hidden");
16337
16975
 
@@ -17216,7 +17854,16 @@
17216
17854
  return '<div class="subagent-reply pending"><span class="typing-indicator"><span></span><span></span><span></span></span></div>';
17217
17855
  }
17218
17856
 
17219
- 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>';
17220
17867
  }
17221
17868
  var PIXEL_AVATAR = {
17222
17869
  assistant: buildPixelSvg(buildCatGrid(GARFIELD_PALETTE)),
@@ -17574,7 +18221,9 @@
17574
18221
  : '';
17575
18222
  html += '<div class="chat-handoff" style="--agent-color:' + subPalette.primary + '">' +
17576
18223
  '<span class="chat-handoff-arrow">↳</span> ' +
17577
- 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 +
17578
18227
  '</div>';
17579
18228
  }
17580
18229
  html += '<div class="chat-message-segment subagent" data-agent-id="' + escapeHtml(seg.subagent.taskId) + '" style="--agent-color:' + subPalette.primary + '">' +