@co0ontty/wand 1.32.0 → 1.32.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.
@@ -80,6 +80,33 @@
80
80
  var configPath = "${escapeHtml(configPath)}";
81
81
  var CHAT_EXPAND_STATE_STORAGE_KEY = "wand-chat-expand-state-v1";
82
82
 
83
+ // ===== 一次性 localStorage 迁移 =====
84
+ // 用 schema 版本号确保每个 migration 只跑一次。每加一项就 ++LS_SCHEMA_VERSION
85
+ // 并在 LS_MIGRATIONS append 一个函数。已升级用户的 wand-ls-schema 大于等于
86
+ // 当前长度时整段跳过;新用户首次加载会一口气把所有 migration 都跑完再写
87
+ // schema 号 —— 因此每个 migration 函数对「key 不存在」的输入也必须是无害的。
88
+ var LS_MIGRATIONS = [
89
+ // v1(2026-05)取消独立的「图钉」按钮,呼出侧栏即常驻。旧版残留的
90
+ // wand-sidebar-pinned=false 会让老用户继续走 drawer 模式看不到新行为,
91
+ // 这里直接清掉,让 state 初始化回退到默认 true。
92
+ function migrateSidebarPinDefault() {
93
+ try { localStorage.removeItem("wand-sidebar-pinned"); } catch (e) {}
94
+ }
95
+ ];
96
+ (function runLocalStorageMigrations() {
97
+ try {
98
+ var raw = localStorage.getItem("wand-ls-schema");
99
+ var applied = raw == null ? 0 : parseInt(raw, 10);
100
+ if (!(applied >= 0)) applied = 0;
101
+ for (var i = applied; i < LS_MIGRATIONS.length; i++) {
102
+ try { LS_MIGRATIONS[i](); } catch (e) {}
103
+ }
104
+ if (applied < LS_MIGRATIONS.length) {
105
+ localStorage.setItem("wand-ls-schema", String(LS_MIGRATIONS.length));
106
+ }
107
+ } catch (e) { /* localStorage 不可用就跳过,按默认行为运行 */ }
108
+ })();
109
+
83
110
  var state = {
84
111
  selectedId: (function() {
85
112
  try { return localStorage.getItem("wand-selected-session") || null; } catch (e) { return null; }
@@ -154,7 +181,12 @@
154
181
  bootstrapping: true,
155
182
  sessionsDrawerOpen: false,
156
183
  sidebarPinned: (function() {
157
- try { return localStorage.getItem("wand-sidebar-pinned") === "true"; } catch (e) { return false; }
184
+ // 新交互:桌面默认呼出即常驻;只有用户主动 X 关闭过才记 "false"
185
+ // 老用户的旧值("true"/"false")继续生效,没存过 key 时回退到 true。
186
+ try {
187
+ var v = localStorage.getItem("wand-sidebar-pinned");
188
+ return v === null ? true : v !== "false";
189
+ } catch (e) { return true; }
158
190
  })(),
159
191
  sidebarCollapsed: (function() {
160
192
  try { return localStorage.getItem("wand-sidebar-collapsed") === "true"; } catch (e) { return false; }
@@ -1380,6 +1412,36 @@
1380
1412
  });
1381
1413
  }
1382
1414
 
1415
+ // ===== 桌面:点 sidebar 外的空白处自动收起 =====
1416
+ // 旧版 drawer 模式下点 backdrop 关闭的便利性,在「呼出即常驻」之后用
1417
+ // document 级捕获 handler 续上。
1418
+ // - 仅 desktop + 全尺寸(非窄条)+ 已打开 时生效
1419
+ // - 窄条态不触发(窄条本来就是稳定常驻形态)
1420
+ // - 手机端由 .drawer-backdrop 元素自己接住点击,不在这里重复处理
1421
+ // - 各类弹层(modal / topbar-more / overflow 菜单 / 文件夹下拉等)不算
1422
+ // 「sidebar 外的空白」,否则点弹层会顺带把 sidebar 关掉
1423
+ // 用 capture 阶段是为了绕过下游按钮自己的 stopPropagation。
1424
+ document.addEventListener("click", function(e) {
1425
+ if (isMobileLayout()) return;
1426
+ if (!state.sidebarPinned) return;
1427
+ if (state.sidebarCollapsed) return;
1428
+ if (!state.sessionsDrawerOpen) return;
1429
+ var target = e.target;
1430
+ if (!target || !(target instanceof Element)) return;
1431
+ if (target.closest("#sessions-drawer")) return;
1432
+ if (target.closest("#sessions-toggle-button")) return;
1433
+ if (target.closest(".floating-sidebar-toggle")) return;
1434
+ if (target.closest(".sidebar-tile-bubble")) return;
1435
+ if (target.closest(
1436
+ ".modal-backdrop, .modal-overlay, .modal-container, " +
1437
+ "[role='dialog'], [role='menu'], " +
1438
+ ".topbar-more-menu, .sidebar-header-overflow, " +
1439
+ ".folder-picker-dropdown, .path-suggestions, " +
1440
+ ".permission-prompt-overlay, .restart-overlay"
1441
+ )) return;
1442
+ closeSessionsDrawer();
1443
+ }, true);
1444
+
1383
1445
  renderBootLoading();
1384
1446
  restoreLoginSession();
1385
1447
 
@@ -1604,9 +1666,6 @@
1604
1666
  '</button>' +
1605
1667
  '</div>' +
1606
1668
  '</div>' +
1607
- '<button id="sidebar-pin-btn" class="btn btn-ghost btn-sm sidebar-pin-toggle' + (state.sidebarPinned ? ' pinned' : '') + '" type="button" title="' + (state.sidebarPinned ? '取消固定侧栏' : '固定侧栏') + '">' +
1608
- '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="17" x2="12" y2="22"/><path d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24z"/></svg>' +
1609
- '</button>' +
1610
1669
  '<button id="sidebar-collapse-btn" class="btn btn-ghost btn-sm sidebar-collapse-toggle' + (isCollapsed ? ' collapsed' : '') + '" type="button" title="' + (isCollapsed ? '展开侧栏' : '收起为窄条') + '" aria-label="' + (isCollapsed ? '展开侧栏' : '收起为窄条') + '">' +
1611
1670
  (isCollapsed
1612
1671
  ? '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="10 6 16 12 10 18"/><line x1="20" y1="5" x2="20" y2="19"/></svg>'
@@ -1727,6 +1786,9 @@
1727
1786
  '<span class="chat-unread-bubble-icon"><svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 3.5v9M3.5 8l4.5 4.5L12.5 8"/></svg></span>' +
1728
1787
  '<span class="chat-unread-bubble-count" aria-hidden="true"></span>' +
1729
1788
  '</button>' +
1789
+ // 排队气泡宿主:贴在对话显示区域的右下角(在"回复中"状态线上方),
1790
+ // 不进输入框 panel。updateQueueBar() 仅在 queuedMessages 非空时显形。
1791
+ '<div id="queue-bar-host" class="queue-bar-host" hidden></div>' +
1730
1792
  '</div>' +
1731
1793
  '<div id="blank-chat" class="blank-chat' + (state.selectedId ? " hidden" : "") + '">' +
1732
1794
  '<div class="blank-chat-inner">' +
@@ -1755,10 +1817,7 @@
1755
1817
  '</div>' +
1756
1818
  '</div>' +
1757
1819
  '<div class="input-panel' + (state.selectedId ? "" : " hidden") + '">' +
1758
- // 排队气泡宿主:默认 display:none,updateQueueBar() queuedMessages 非空时
1759
- // 显形。位置在 composer-top-row(含 "回复中" 状态条)之上,对话框右下角,
1760
- // 不进入输入框内部。所有内容由 updater 注入;这里只保留稳定的外层骨架。
1761
- '<div id="queue-bar-host" class="queue-bar-host" hidden></div>' +
1820
+ // #queue-bar-host 已搬到 #chat-output 内部(对话区右下角),不在这里了。
1762
1821
  '<div class="composer-top-row">' +
1763
1822
  '<div id="todo-progress" class="todo-progress hidden">' +
1764
1823
  '<div class="todo-progress-header" id="todo-progress-toggle">' +
@@ -1841,13 +1900,8 @@
1841
1900
  '<button id="stop-button" class="btn-circle btn-circle-stop' + (state.selectedId ? "" : " hidden") + '" title="停止">' +
1842
1901
  '<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><rect x="3" y="3" width="10" height="10" rx="2"/></svg>' +
1843
1902
  '</button>' +
1844
- // 结构化模式且正在出 token 时显示:中断当前回复、立刻发送新输入。
1845
- // 默认走 #send-input-button → 排队;想插队的人显式按这颗。
1846
- // 用 pill 形态 + 文字 + 脉动,让用户一眼就看到「立即发送」这条快捷路径。
1847
- '<button id="interrupt-send-button" class="btn-pill btn-pill-interrupt hidden" type="button" title="中断当前回复并立即发送新输入(Cmd/Ctrl+Enter)" aria-label="立即发送">' +
1848
- '<svg class="btn-pill-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="13 17 18 12 13 7"/><polyline points="6 17 11 12 6 7"/></svg>' +
1849
- '<span class="btn-pill-label">立即</span>' +
1850
- '</button>' +
1903
+ // 「立即发送」按钮已下线 —— 默认行为永远是排队(气泡),想插队
1904
+ // 请点输入框上方那条气泡(chip)。Cmd/Ctrl+Enter 快捷键仍保留。
1851
1905
  '<button id="send-input-button" class="btn-circle btn-circle-send" title="发送">' +
1852
1906
  '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>' +
1853
1907
  '</button>' +
@@ -5823,8 +5877,6 @@
5823
5877
  if (drawerBackdrop) drawerBackdrop.addEventListener("click", closeSessionsDrawer);
5824
5878
  var closeDrawerBtn = document.getElementById("close-drawer-button");
5825
5879
  if (closeDrawerBtn) closeDrawerBtn.addEventListener("click", closeSessionsDrawer);
5826
- var pinBtn = document.getElementById("sidebar-pin-btn");
5827
- if (pinBtn) pinBtn.addEventListener("click", toggleSidebarPin);
5828
5880
  var collapseBtn = document.getElementById("sidebar-collapse-btn");
5829
5881
  if (collapseBtn) collapseBtn.addEventListener("click", toggleSidebarCollapsed);
5830
5882
  var sidebarMoreBtn = document.getElementById("sidebar-more-btn");
@@ -6092,11 +6144,6 @@
6092
6144
  closeSessionsDrawer();
6093
6145
  sendOrStart();
6094
6146
  });
6095
- var interruptSendBtn = document.getElementById("interrupt-send-button");
6096
- if (interruptSendBtn) interruptSendBtn.addEventListener("click", function() {
6097
- closeSessionsDrawer();
6098
- sendOrStart({ interrupt: true });
6099
- });
6100
6147
  var stopBtn = document.getElementById("stop-button");
6101
6148
  if (stopBtn) stopBtn.addEventListener("click", stopSession);
6102
6149
  var modeSelect = document.getElementById("chat-mode-select");
@@ -8159,14 +8206,17 @@
8159
8206
  // Keep placeholders short so they don't wrap on portrait mobile screens.
8160
8207
  // Only show informative state hints; drop the redundant "send to X" labels.
8161
8208
  if (terminalInteractive) return "终端交互中";
8162
- if (session && session.status !== "running") {
8209
+ // 只有真正进入终止态(exited / failed / stopped)才提示"会话已结束"
8210
+ // 结构化会话刚创建或一次回复结束后会回到 "idle"——那是等待下一条输入的
8211
+ // 正常状态,不应该被当成结束。
8212
+ if (session && (session.status === "exited" || session.status === "failed" || session.status === "stopped")) {
8163
8213
  if (canAutoResumeSession(session)) return "";
8164
8214
  return "会话已结束";
8165
8215
  }
8166
8216
  // 结构化会话在出 token 时,输入框仍然可用——告诉用户默认行为是排队,
8167
- // 想插队请按右侧的 » 按钮。短语保持单行不换行。
8217
+ // 想插队请按气泡上的 按钮。短语尽量短,避免在窄屏手机上换行。
8168
8218
  if (isStructuredSession(session) && session.structuredState && session.structuredState.inFlight) {
8169
- return "回复中…Enter 排队 · 旁边的「» 立即」按钮中断并立即发送";
8219
+ return "回复中…Enter 排队 · 立即发送";
8170
8220
  }
8171
8221
  return "";
8172
8222
  }
@@ -9266,7 +9316,6 @@
9266
9316
  function updatePinState() {
9267
9317
  var drawer = document.getElementById("sessions-drawer");
9268
9318
  var mainLayout = document.querySelector(".main-layout");
9269
- var pinBtn = document.getElementById("sidebar-pin-btn");
9270
9319
  // 与 renderAppShell 保持一致:手机端只允许窄条形态 anchored。
9271
9320
  var isMobile = isMobileLayout();
9272
9321
  var isCollapsed = !!state.sidebarPinned && !!state.sidebarCollapsed;
@@ -9279,10 +9328,6 @@
9279
9328
  mainLayout.classList.toggle("sidebar-pinned", isAnchored);
9280
9329
  mainLayout.classList.toggle("sidebar-collapsed", isCollapsed);
9281
9330
  }
9282
- if (pinBtn) {
9283
- pinBtn.classList.toggle("pinned", state.sidebarPinned);
9284
- pinBtn.title = state.sidebarPinned ? "取消固定侧栏" : "固定侧栏";
9285
- }
9286
9331
  }
9287
9332
 
9288
9333
  function updateDrawerState() {
@@ -9306,9 +9351,26 @@
9306
9351
  }
9307
9352
 
9308
9353
  function toggleSessionsDrawer() {
9309
- if (state.sidebarPinned && !isMobileLayout()) return;
9354
+ var isMobile = isMobileLayout();
9355
+ if (!isMobile) {
9356
+ // 桌面:呼出 = 常驻全尺寸;再次点击 = 完全收起(floating-toggle 重新出现)。
9357
+ // 取消了独立的「图钉」概念,sidebarPinned 现在由呼出/关闭自动管理。
9358
+ var willOpen = !state.sidebarPinned;
9359
+ state.sidebarPinned = willOpen;
9360
+ state.sessionsDrawerOpen = willOpen;
9361
+ if (willOpen) {
9362
+ // 桌面重新呼出默认回到全尺寸;窄条形态需用户主动点 collapse 按钮切换。
9363
+ state.sidebarCollapsed = false;
9364
+ try { localStorage.setItem("wand-sidebar-collapsed", "false"); } catch (e) {}
9365
+ }
9366
+ try { localStorage.setItem("wand-sidebar-pinned", String(willOpen)); } catch (e) {}
9367
+ updateLayoutState();
9368
+ scheduleTerminalRefitAfterPaddingTransition();
9369
+ return;
9370
+ }
9371
+ // 手机端:保持原 drawer 行为。
9310
9372
  state.sessionsDrawerOpen = !state.sessionsDrawerOpen;
9311
- if (state.sessionsDrawerOpen && isMobileLayout()) {
9373
+ if (state.sessionsDrawerOpen) {
9312
9374
  state.filePanelOpen = false;
9313
9375
  try {
9314
9376
  localStorage.setItem("wand-file-panel-open", "false");
@@ -9318,13 +9380,42 @@
9318
9380
  }
9319
9381
 
9320
9382
  function closeSessionsDrawer() {
9321
- if (state.sidebarPinned && !isMobileLayout()) return;
9383
+ var isMobile = isMobileLayout();
9384
+ if (!isMobile) {
9385
+ // 桌面:X 按钮 / backdrop 点击 = 完全收起,撤掉常驻状态,floating-toggle 重新出现。
9386
+ // 窄条状态下没有 X 按钮(CSS 隐藏),不会走到这里,因此无需特判 collapsed。
9387
+ if (!state.sidebarPinned && !state.sessionsDrawerOpen) return;
9388
+ closeSwipedItem();
9389
+ state.sidebarPinned = false;
9390
+ state.sessionsDrawerOpen = false;
9391
+ try { localStorage.setItem("wand-sidebar-pinned", "false"); } catch (e) {}
9392
+ updateLayoutState();
9393
+ scheduleTerminalRefitAfterPaddingTransition();
9394
+ return;
9395
+ }
9396
+ // 手机端:保持原 drawer 关闭行为。
9322
9397
  if (!state.sessionsDrawerOpen) return;
9323
9398
  closeSwipedItem();
9324
9399
  state.sessionsDrawerOpen = false;
9325
9400
  updateLayoutState();
9326
9401
  }
9327
9402
 
9403
+ // 桌面 padding-left transition 结束后重新拟合终端尺寸。
9404
+ // 抽出来给 toggleSessionsDrawer / closeSessionsDrawer / toggleSidebarCollapsed 复用。
9405
+ function scheduleTerminalRefitAfterPaddingTransition() {
9406
+ var mainLayout = document.querySelector(".main-layout");
9407
+ if (mainLayout) {
9408
+ var onEnd = function(e) {
9409
+ if (e.propertyName === "padding-left") {
9410
+ mainLayout.removeEventListener("transitionend", onEnd);
9411
+ scheduleTerminalResize(true);
9412
+ }
9413
+ };
9414
+ mainLayout.addEventListener("transitionend", onEnd);
9415
+ }
9416
+ setTimeout(function() { scheduleTerminalResize(true); }, 350);
9417
+ }
9418
+
9328
9419
  var collapsedTileBubbleEl = null;
9329
9420
  function ensureCollapsedTileBubble() {
9330
9421
  if (collapsedTileBubbleEl && document.body.contains(collapsedTileBubbleEl)) {
@@ -9390,8 +9481,7 @@
9390
9481
 
9391
9482
  function toggleSidebarCollapsed() {
9392
9483
  var isMobile = isMobileLayout();
9393
- // drawer 模式(未 pin)下点 collapse 视为「先固定、再收起为窄条」——
9394
- // 用户直觉是「点了就该看到窄条」,过去这里 early return 让按钮看上去没反应。
9484
+ // 任何形态下点窄条按钮都意味着「我要常驻」,确保 pinned 写上。
9395
9485
  if (!state.sidebarPinned) {
9396
9486
  state.sidebarPinned = true;
9397
9487
  try {
@@ -9415,47 +9505,13 @@
9415
9505
  localStorage.setItem("wand-sidebar-pinned", "false");
9416
9506
  } catch (e) {}
9417
9507
  } else {
9418
- // 桌面端展开窄条 → 300px 全栏固定,自动打开。
9508
+ // 桌面端展开窄条 → 300px 全栏常驻。
9419
9509
  state.sessionsDrawerOpen = true;
9420
9510
  }
9421
9511
  render();
9422
- var mainLayout = document.querySelector(".main-layout");
9423
- if (mainLayout) {
9424
- var onEnd = function(e) {
9425
- if (e.propertyName === "padding-left") {
9426
- mainLayout.removeEventListener("transitionend", onEnd);
9427
- scheduleTerminalResize(true);
9428
- }
9429
- };
9430
- mainLayout.addEventListener("transitionend", onEnd);
9431
- }
9432
- setTimeout(function() { scheduleTerminalResize(true); }, 350);
9512
+ scheduleTerminalRefitAfterPaddingTransition();
9433
9513
  }
9434
9514
 
9435
- function toggleSidebarPin() {
9436
- if (isMobileLayout()) return;
9437
- state.sidebarPinned = !state.sidebarPinned;
9438
- try {
9439
- localStorage.setItem("wand-sidebar-pinned", String(state.sidebarPinned));
9440
- } catch (e) {}
9441
- if (state.sidebarPinned) {
9442
- state.sessionsDrawerOpen = true;
9443
- }
9444
- updateLayoutState();
9445
- // Refit terminal after padding-left transition completes
9446
- var mainLayout = document.querySelector(".main-layout");
9447
- if (mainLayout) {
9448
- var onEnd = function(e) {
9449
- if (e.propertyName === "padding-left") {
9450
- mainLayout.removeEventListener("transitionend", onEnd);
9451
- scheduleTerminalResize(true);
9452
- }
9453
- };
9454
- mainLayout.addEventListener("transitionend", onEnd);
9455
- }
9456
- // Fallback refit in case transition doesn't fire
9457
- setTimeout(function() { scheduleTerminalResize(true); }, 350);
9458
- }
9459
9515
 
9460
9516
  // Store last focused element for focus trap
9461
9517
  var lastFocusedElement = null;
@@ -12257,9 +12313,10 @@
12257
12313
 
12258
12314
  function postStructuredInput(input, inputBox, session, opts) {
12259
12315
  opts = opts || {};
12260
- // 用户显式点击"立即发送"才会传 interrupt:true。普通 Enter / 点发送
12261
- // 在上一条还在流式时默认走 queue —— 后端 sendMessage(...) 会把它
12262
- // 追加到 queuedMessages,等当前 turn 结束自动 flush。
12316
+ // interrupt:true 现在只来自 Cmd/Ctrl+Enter 快捷键,或点队列气泡触发的
12317
+ // queueBarPromoteIndex()。普通 Enter / 点发送在上一条还在流式时默认走
12318
+ // queue —— 后端 sendMessage(...) 会把它追加到 queuedMessages,等当前 turn
12319
+ // 结束自动 flush;想插队就点输入框上方那条气泡。
12263
12320
  var requestedInterrupt = !!opts.interrupt;
12264
12321
  console.log("[WAND] postStructuredInput selectedId:", state.selectedId, "input:", input && input.substring(0, 50), "requestedInterrupt:", requestedInterrupt, "session:", session && { id: session.id, sessionKind: session.sessionKind, runner: session.runner, status: session.status, inFlight: session.structuredState && session.structuredState.inFlight });
12265
12322
  if (!state.selectedId || !input) return Promise.resolve();
@@ -12297,7 +12354,7 @@
12297
12354
  updateSessionSnapshot(optimisticPatch);
12298
12355
  var queueRefreshed = state.sessions.find(function(s) { return s.id === session.id; }) || session;
12299
12356
  state.currentMessages = buildMessagesForRender(queueRefreshed, getPreferredMessages(queueRefreshed, queueRefreshed.output, false));
12300
- updateInputHint("已加入排队,等待当前回复完成…");
12357
+ updateInputHint("已加入排队…");
12301
12358
  renderChat(true);
12302
12359
  updateStructuredQueueCounter();
12303
12360
  // 乐观 toast:原本只在 POST 完成后才提示,Claude 流式拖太久时用户根本
@@ -12458,8 +12515,9 @@
12458
12515
  // · 默认只展开队首(即下一个要发的那条),显示编号 + 文本 + × 删除
12459
12516
  // · 其他消息收起成一根小横杠(指示存在但不占空间)
12460
12517
  // · 鼠标悬到任意小横杠 → 该条展开、原本展开的那条收回小横杠
12461
- // · 悬停期间可以按住展开的那条向上 / 向下拖拽 换序
12462
- // 末尾跟一个 "立即" 按钮:中断当前回复、把队首作为新输入插队发出去。
12518
+ // · 点一下任意气泡中断当前回复、把这条作为新输入插队发出去
12519
+ // · 按住任意气泡向上 / 向下拖拽 → 换序
12520
+ // 末尾跟一个 ⚡ 按钮:等价于点队首气泡(保留作为快速插队的视觉提示)。
12463
12521
  // 数据源:session.queuedMessages(后端 WS + postStructuredInput 乐观更新)。
12464
12522
  // ──────────────────────────────────────────────────────────────────────────
12465
12523
 
@@ -12486,12 +12544,15 @@
12486
12544
  return 0;
12487
12545
  }
12488
12546
 
12489
- function renderQueueBarHtml(items, inFlight, atCapacity, immediateLabel) {
12547
+ function renderQueueBarHtml(items, inFlight, atCapacity) {
12548
+ // 底部独立 ⚡ 按钮已下线,每条 chip 内部自带 ⚡ "立即"按钮 ——
12549
+ // 这样用户一眼就能看出"是把哪一条插队"。
12490
12550
  var single = items.length <= 1;
12491
12551
  var barClass = "queue-bar";
12492
12552
  if (atCapacity) barClass += " queue-bar-capacity";
12493
12553
  if (inFlight) barClass += " queue-bar-inflight";
12494
12554
  var expandedIdx = queueBarExpandedIndex(items.length);
12555
+ var promoteTip = inFlight ? "中断当前回复,立即发送这条" : "立即发送这条";
12495
12556
  var chips = "";
12496
12557
  for (var i = 0; i < items.length; i++) {
12497
12558
  var raw = items[i] == null ? "" : String(items[i]);
@@ -12499,13 +12560,21 @@
12499
12560
  var itemClass = "queue-bar-item";
12500
12561
  if (isExpanded) itemClass += " expanded";
12501
12562
  if (single) itemClass += " queue-bar-item-single";
12502
- // 拖拽起手区是整个 chip,但 delete 按钮要独占点击。
12503
- var titleAttr = isExpanded ? raw + "(按住可拖动调整顺序)" : raw;
12563
+ // chip 本体是"拖拽起手区";内部 ⚡ 按钮独占 click 用于立即发送、× 用于删除。
12564
+ var titleAttr = isExpanded ? raw + "(按住可拖动调序)" : raw;
12504
12565
  chips +=
12505
12566
  '<li class="' + itemClass + '" data-index="' + i + '" data-action="drag"' +
12506
12567
  ' title="' + escapeHtml(titleAttr) + '">' +
12507
12568
  '<span class="queue-bar-item-index" aria-hidden="true">' + (i + 1) + '</span>' +
12508
12569
  '<span class="queue-bar-item-text">' + escapeHtml(queueChipTruncate(raw)) + '</span>' +
12570
+ '<button type="button" class="queue-bar-item-promote" data-action="promote-item"' +
12571
+ ' title="' + escapeHtml(promoteTip) + '" aria-label="立即发送这条"' +
12572
+ ' tabindex="' + (isExpanded ? "0" : "-1") + '">' +
12573
+ '<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">' +
12574
+ '<path d="M13 2 L4 14 L11 14 L10 22 L20 9 L13 9 Z"/>' +
12575
+ '</svg>' +
12576
+ '<span class="queue-bar-item-promote-label">立即</span>' +
12577
+ '</button>' +
12509
12578
  '<button type="button" class="queue-bar-item-delete" data-action="delete"' +
12510
12579
  ' aria-label="删除这条排队消息" title="删除" tabindex="' + (isExpanded ? "0" : "-1") + '">' +
12511
12580
  '<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor"' +
@@ -12517,13 +12586,6 @@
12517
12586
  return (
12518
12587
  '<div class="' + barClass + '" data-queue-bar="1">' +
12519
12588
  '<ol class="queue-bar-list" data-queue-list="1">' + chips + '</ol>' +
12520
- '<button type="button" class="queue-bar-promote" data-action="promote"' +
12521
- ' title="中断当前回复,立刻发送队首这条"' +
12522
- ' aria-label="' + escapeHtml(immediateLabel) + '队首">' +
12523
- '<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">' +
12524
- '<path d="M13 2 L4 14 L11 14 L10 22 L20 9 L13 9 Z"/>' +
12525
- '</svg>' +
12526
- '</button>' +
12527
12589
  '</div>'
12528
12590
  );
12529
12591
  }
@@ -12549,9 +12611,8 @@
12549
12611
  host.hidden = false;
12550
12612
  var inFlight = !!(session.structuredState && session.structuredState.inFlight && session.status === "running");
12551
12613
  var atCapacity = queue.length >= QUEUE_BAR_MAX;
12552
- var immediateLabel = inFlight ? "立即" : "发送";
12553
12614
 
12554
- host.innerHTML = renderQueueBarHtml(queue, inFlight, atCapacity, immediateLabel);
12615
+ host.innerHTML = renderQueueBarHtml(queue, inFlight, atCapacity);
12555
12616
  }
12556
12617
 
12557
12618
  // 只切换 .expanded class,不重建 DOM —— 避免鼠标移过去触发的重建
@@ -12653,20 +12714,24 @@
12653
12714
  });
12654
12715
  }
12655
12716
 
12656
- function queueBarPromoteHead() {
12717
+ // 把队列里第 index 条剥下来,作为新的输入立刻发送出去。
12718
+ // - inFlight:interrupt + preserveQueue(中断当前回复,保留其它排队)
12719
+ // - 非 inFlight:当作普通新消息发出去
12720
+ // 用户路径:点输入框上方的气泡(chip)→ 这里。
12721
+ function queueBarPromoteIndex(index) {
12657
12722
  var session = state.sessions.find(function(s) { return s.id === state.selectedId; });
12658
12723
  if (!session) return;
12659
12724
  var queue = Array.isArray(session.queuedMessages) ? session.queuedMessages.slice() : [];
12660
- if (queue.length === 0) return;
12661
- var head = queue[0];
12662
- var rest = queue.slice(1);
12725
+ if (index < 0 || index >= queue.length) return;
12726
+ var picked = queue[index];
12727
+ var rest = queue.slice(0, index).concat(queue.slice(index + 1));
12663
12728
  var prev = queue.slice();
12664
12729
  var inFlight = !!(session.structuredState && session.structuredState.inFlight && session.status === "running");
12665
12730
 
12666
- // 乐观:剥掉队首;hover 下标随之收缩
12731
+ // 乐观:剥掉这一条;hover 下标随之收缩
12667
12732
  if (typeof state.queueBarHoverIndex === "number") {
12668
- if (state.queueBarHoverIndex === 0) state.queueBarHoverIndex = null;
12669
- else state.queueBarHoverIndex -= 1;
12733
+ if (state.queueBarHoverIndex === index) state.queueBarHoverIndex = null;
12734
+ else if (state.queueBarHoverIndex > index) state.queueBarHoverIndex -= 1;
12670
12735
  }
12671
12736
  updateSessionSnapshot({ id: session.id, queuedMessages: rest });
12672
12737
 
@@ -12674,14 +12739,14 @@
12674
12739
  ? crypto.randomUUID()
12675
12740
  : (Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 10));
12676
12741
 
12677
- var body = { input: head, idempotencyKey: idempotencyKey };
12742
+ var body = { input: picked, idempotencyKey: idempotencyKey };
12678
12743
  if (inFlight) {
12679
12744
  // 中断 + 保留剩余队列
12680
12745
  body.interrupt = true;
12681
12746
  body.preserveQueue = true;
12682
12747
  }
12683
12748
  // 给一个乐观 toast,让用户瞬间知道点击生效了
12684
- showToast(inFlight ? "已请求中断当前回复,立即发送队首。" : "已立即发送队首消息。", "info");
12749
+ showToast(inFlight ? "已请求中断当前回复,立即发送这条。" : "已立即发送这条消息。", "info");
12685
12750
 
12686
12751
  fetch("/api/structured-sessions/" + session.id + "/messages", {
12687
12752
  method: "POST",
@@ -12715,6 +12780,9 @@
12715
12780
  }
12716
12781
 
12717
12782
  // ── 拖拽排序(Pointer Events + 真实高度的 sort/animate)──
12783
+ // 单条气泡的 pointerdown 也会进这里,但 queue.length <= 1 时直接返回,让
12784
+ // 系统 click 事件穿透到 #queue-bar-host 的 click delegate(那里再判断"点击
12785
+ // 气泡 → 立即发送")。
12718
12786
  function queueBarDragStart(ev, chipEl) {
12719
12787
  var session = state.sessions.find(function(s) { return s.id === state.selectedId; });
12720
12788
  if (!session) return;
@@ -12838,7 +12906,8 @@
12838
12906
  state.queueBarDrag = null;
12839
12907
 
12840
12908
  if (origIndex === targetIndex) {
12841
- // 没动,光擦一下重渲就行
12909
+ // 没动 → 单纯刷新一下。立即发送由 chip 内部的 ⚡ 按钮触发,
12910
+ // 不在 chip 本体上做隐式 tap-to-promote(容易误触)。
12842
12911
  updateQueueBar();
12843
12912
  return;
12844
12913
  }
@@ -12886,10 +12955,18 @@
12886
12955
  var actionEl = ev.target && ev.target.closest ? ev.target.closest("[data-action]") : null;
12887
12956
  if (!actionEl || !host.contains(actionEl)) return;
12888
12957
  var action = actionEl.getAttribute("data-action");
12889
- if (action === "drag") return; // 拖拽由 pointerdown 处理,吞掉 click
12958
+ // chip 本体(data-action="drag")由 pointerdown drag-or-tap 流程;
12959
+ // click 阶段不处理,否则会和拖拽收尾冲突。
12960
+ if (action === "drag") return;
12890
12961
  ev.preventDefault();
12891
12962
  ev.stopPropagation();
12892
- if (action === "promote") { queueBarPromoteHead(); return; }
12963
+ if (action === "promote-item") {
12964
+ // chip 内部的 ⚡ "立即"按钮:把这一条剥下来插队发送,让用户一眼看到
12965
+ // 自己点的就是哪一条。
12966
+ var pItem = actionEl.closest(".queue-bar-item");
12967
+ if (pItem) queueBarPromoteIndex(Number(pItem.getAttribute("data-index")));
12968
+ return;
12969
+ }
12893
12970
  if (action === "delete") {
12894
12971
  var itemEl = actionEl.closest(".queue-bar-item");
12895
12972
  if (itemEl) queueBarDeleteItem(Number(itemEl.getAttribute("data-index")));
@@ -12907,10 +12984,11 @@
12907
12984
  if (state.queueBarDrag) return;
12908
12985
  setQueueBarHoverIndex(null);
12909
12986
  });
12910
- // 整个气泡都是拖拽起手区。delete / promote 按钮通过 closest 检查跳过
12987
+ // 整个气泡都是拖拽起手区。chip 内部的 ⚡ / × 按钮通过 closest 跳过,
12988
+ // 让 click 阶段去处理它们。
12911
12989
  host.addEventListener("pointerdown", function(ev) {
12912
12990
  if (ev.button !== undefined && ev.button !== 0) return;
12913
- if (ev.target && ev.target.closest && ev.target.closest('[data-action="delete"], [data-action="promote"]')) return;
12991
+ if (ev.target && ev.target.closest && ev.target.closest('[data-action="delete"], [data-action="promote-item"]')) return;
12914
12992
  var chip = ev.target && ev.target.closest ? ev.target.closest('.queue-bar-item') : null;
12915
12993
  if (!chip) return;
12916
12994
  // 拖拽前先把这条切到 expanded(鼠标按下时通常已经 hovered,但触屏没 hover)
@@ -12961,38 +13039,15 @@
12961
13039
  });
12962
13040
  }
12963
13041
 
12964
- // Append queued user message placeholders to currentMessages so they
12965
- // remain visible across WS updates and re-renders.
13042
+ // 结构化会话的"对话视图"现在只渲染真实的 user/assistant turn。排队消息(还没
13043
+ // flush 出去那批)由 .queue-bar 在对话区右下角统一展示,不再在 chat 流里贴一份
13044
+ // 半透明 "排队中" 用户气泡——避免同一条消息在 UI 上出现两次。
12966
13045
  function buildMessagesForRender(session, messages) {
12967
13046
  var sanitized = Array.isArray(messages) ? stripRenderOnlyStructuredMessages(messages) : [];
12968
13047
  var base = Array.isArray(sanitized) ? sanitized.slice() : [];
12969
13048
  if (!session || session.sessionKind !== "structured") {
12970
13049
  return base;
12971
13050
  }
12972
- var queued = getStructuredQueuedInputs(session);
12973
- if (queued && queued.length > 0) {
12974
- // Collect recent user message texts to deduplicate against queued items.
12975
- // A queued message that already appears as a real user turn should not
12976
- // be rendered a second time with the "排队中" badge.
12977
- var existingUserTexts = {};
12978
- for (var ei = base.length - 1; ei >= 0 && Object.keys(existingUserTexts).length < queued.length + 5; ei--) {
12979
- var em = base[ei];
12980
- if (em && em.role === "user" && Array.isArray(em.content)) {
12981
- for (var ej = 0; ej < em.content.length; ej++) {
12982
- if (em.content[ej] && em.content[ej].type === "text" && em.content[ej].text) {
12983
- existingUserTexts[em.content[ej].text] = (existingUserTexts[em.content[ej].text] || 0) + 1;
12984
- }
12985
- }
12986
- }
12987
- }
12988
- for (var qi = 0; qi < queued.length; qi++) {
12989
- if (existingUserTexts[queued[qi]]) {
12990
- existingUserTexts[queued[qi]]--;
12991
- continue; // Skip — this queued text is already shown as a real message
12992
- }
12993
- base.push({ role: "user", content: [{ type: "text", text: queued[qi], __queued: true }] });
12994
- }
12995
- }
12996
13051
  if (session.structuredState && session.structuredState.inFlight) {
12997
13052
  var last = base[base.length - 1];
12998
13053
  if (!last || last.role !== "assistant") {
@@ -13502,12 +13557,6 @@
13502
13557
  : (isCodex ? (isRunning ? "发送给 Codex" : "Codex 会话已结束") : (!selectedSession || isRunning || canResumeOnSend ? "发送" : "会话已结束")));
13503
13558
  sendBtn.classList.toggle("queue-mode", structuredInFlight);
13504
13559
  }
13505
- var interruptBtn = document.getElementById("interrupt-send-button");
13506
- if (interruptBtn) {
13507
- // 仅结构化 + inFlight 时显示。pty 会话有自己的 Ctrl+C / stop 按钮,
13508
- // 用不上这套语义。
13509
- interruptBtn.classList.toggle("hidden", !structuredInFlight);
13510
- }
13511
13560
  var container = document.getElementById("output");
13512
13561
  if (container) container.classList.toggle("interactive", !structured && state.terminalInteractive);
13513
13562
  }
@@ -16993,24 +17042,13 @@
16993
17042
  return;
16994
17043
  }
16995
17044
 
16996
- // 当前 turn 已结束(结构化 inFlight=false 或 PTY 非 running)就把进度条
16997
- // 收起来——模型经常忘了发最后一条"全 completed" TodoWrite,让用户
16998
- // 对着 "5/6" 干瞪眼很别扭。allDone 那条分支保留,提前命中更快返回。
16999
- var sel = state.sessions.find(function(s) { return s.id === state.selectedId; });
17000
- var turnDone = false;
17001
- if (sel) {
17002
- if (isStructuredSession(sel)) {
17003
- turnDone = !(sel.structuredState && sel.structuredState.inFlight);
17004
- } else {
17005
- turnDone = sel.status !== "running";
17006
- }
17007
- }
17008
- if (turnDone) {
17009
- container.classList.add("hidden");
17010
- if (bodyEl) bodyEl.classList.add("hidden");
17011
- return;
17012
- }
17013
-
17045
+ // 之前这里在 turn 结束(结构化 inFlight=false 或 PTY 非 running)时
17046
+ // 把进度条收起来,理由是「模型经常忘了发最后一条全 completed
17047
+ // TodoWrite,让用户对着 5/6 干瞪眼很别扭」。但反馈是:在结构化模式下
17048
+ // inFlight 在流间隙会短暂置假,进度条因此跟着闪没;而且 turn 刚结束
17049
+ // 时用户其实想再看一眼最终进度。改回「只要当前 turn 里有 todos 就显
17050
+ // 示,allDone 时再隐藏」,跨 turn 残留交给开头那段「最后一条 user
17051
+ // 消息后才扫 TodoWrite」的 scoping 兜住。
17014
17052
  container.classList.remove("hidden");
17015
17053
  if (bodyEl) bodyEl.classList.remove("hidden");
17016
17054