@co0ontty/wand 1.31.3 → 1.32.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.
@@ -1,22 +1,28 @@
1
1
  // Register Service Worker for PWA
2
- // For self-signed certificates, we need to handle certificate errors gracefully
2
+ // 自签证书场景下 SW 注册会被浏览器强拒(规范要求 secure context + 证书可信,
3
+ // 即便用户已"高级 → 继续访问"也不行)。这里只能优雅降级,并把解决路径打到 console。
3
4
  if ('serviceWorker' in navigator) {
4
- // First, try to fetch the service worker script with a custom handler for certificate errors
5
5
  fetch('/sw.js', { cache: 'no-cache' })
6
6
  .then(function(response) {
7
7
  if (response.ok) {
8
8
  return navigator.serviceWorker.register('/sw.js');
9
9
  }
10
- // If fetch fails (e.g., certificate error), skip service worker registration
11
10
  console.log('SW fetch failed, skipping service worker registration');
12
11
  return Promise.reject('Service worker script not available');
13
12
  })
14
13
  .catch(function(e) {
15
- // Distinguish between certificate errors and other failures
16
- if (e.name === 'TypeError' || e.message.includes('certificate')) {
17
- console.log('SW registration skipped: likely self-signed certificate issue');
14
+ var msg = (e && e.message) || String(e || '');
15
+ var isCertIssue = (e && e.name === 'TypeError') || /certificate|SSL|ERR_CERT/i.test(msg);
16
+ if (isCertIssue && location.protocol === 'https:') {
17
+ console.warn(
18
+ '[wand] PWA / Service Worker 因 TLS 证书不可信而跳过。\n' +
19
+ '解决办法(任选一种):\n' +
20
+ ' 1) 从 ' + location.origin + '/cert/server.crt 下载本机自签证书,导入到系统/浏览器"受信任根证书颁发机构"\n' +
21
+ ' 2) 在本机用 mkcert 签发受信任证书,并在 ~/.wand/config.json 配置 tls.certPath / tls.keyPath\n' +
22
+ ' 3) 用内网 CA 或 Let\'s Encrypt 给域名签真证书(同上配置 tls)'
23
+ );
18
24
  } else {
19
- console.log('SW registration failed:', e.message || e);
25
+ console.log('SW registration failed:', msg);
20
26
  }
21
27
  });
22
28
 
@@ -74,6 +80,33 @@
74
80
  var configPath = "${escapeHtml(configPath)}";
75
81
  var CHAT_EXPAND_STATE_STORAGE_KEY = "wand-chat-expand-state-v1";
76
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
+
77
110
  var state = {
78
111
  selectedId: (function() {
79
112
  try { return localStorage.getItem("wand-selected-session") || null; } catch (e) { return null; }
@@ -137,11 +170,9 @@
137
170
  })(), // 跨会话排队消息 [{ id, text, cwd, mode, tool }]
138
171
  structuredInputQueue: [], // 结构化会话同会话排队消息
139
172
  // 排队条 UI 局部状态 ——
140
- // queueBarExpanded: 折叠条点击展开成下拉面板
141
- // queueBarItemExpanded: 展开面板里被点开看完整内容的 item 下标集合
173
+ // queueBarHoverIndex: 当前被鼠标悬停的气泡下标(null 时默认展开队首)
142
174
  // queueBarDrag: 拖拽排序进行中时的临时状态(pointer 捕获、起始坐标、参考 rect)
143
- queueBarExpanded: false,
144
- queueBarItemExpanded: {},
175
+ queueBarHoverIndex: null,
145
176
  queueBarDrag: null,
146
177
  drafts: {},
147
178
  isSyncingInputBox: false,
@@ -150,7 +181,12 @@
150
181
  bootstrapping: true,
151
182
  sessionsDrawerOpen: false,
152
183
  sidebarPinned: (function() {
153
- 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; }
154
190
  })(),
155
191
  sidebarCollapsed: (function() {
156
192
  try { return localStorage.getItem("wand-sidebar-collapsed") === "true"; } catch (e) { return false; }
@@ -293,9 +329,6 @@
293
329
  fileExplorerCwd: "",
294
330
  fileExplorerTruncated: false,
295
331
  fileExplorerTotal: 0,
296
- fileExplorerShowHidden: (function() {
297
- try { return localStorage.getItem("wand-file-show-hidden") === "1"; } catch (e) { return false; }
298
- })(),
299
332
  claudeHistory: [],
300
333
  claudeHistoryLoaded: false,
301
334
  claudeHistoryExpanded: true,
@@ -1379,6 +1412,36 @@
1379
1412
  });
1380
1413
  }
1381
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
+
1382
1445
  renderBootLoading();
1383
1446
  restoreLoginSession();
1384
1447
 
@@ -1603,9 +1666,6 @@
1603
1666
  '</button>' +
1604
1667
  '</div>' +
1605
1668
  '</div>' +
1606
- '<button id="sidebar-pin-btn" class="btn btn-ghost btn-sm sidebar-pin-toggle' + (state.sidebarPinned ? ' pinned' : '') + '" type="button" title="' + (state.sidebarPinned ? '取消固定侧栏' : '固定侧栏') + '">' +
1607
- '<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>' +
1608
- '</button>' +
1609
1669
  '<button id="sidebar-collapse-btn" class="btn btn-ghost btn-sm sidebar-collapse-toggle' + (isCollapsed ? ' collapsed' : '') + '" type="button" title="' + (isCollapsed ? '展开侧栏' : '收起为窄条') + '" aria-label="' + (isCollapsed ? '展开侧栏' : '收起为窄条') + '">' +
1610
1670
  (isCollapsed
1611
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>'
@@ -1686,12 +1746,6 @@
1686
1746
  '<span class="file-side-panel-title">文件</span>' +
1687
1747
  '</div>' +
1688
1748
  '<div class="file-side-panel-header-actions">' +
1689
- '<button class="file-side-panel-iconbtn file-explorer-toggle-hidden' +
1690
- (state.fileExplorerShowHidden ? ' active' : '') + '" id="file-explorer-toggle-hidden" type="button" title="' +
1691
- (state.fileExplorerShowHidden ? "隐藏点开头文件" : "显示隐藏文件") + '" aria-pressed="' +
1692
- (state.fileExplorerShowHidden ? "true" : "false") + '" aria-label="切换显示隐藏文件">' +
1693
- wandFileIcon(state.fileExplorerShowHidden ? "eye" : "eye-off", { size: 15 }) +
1694
- '</button>' +
1695
1749
  '<button class="file-side-panel-iconbtn" id="file-explorer-refresh" type="button" title="刷新" aria-label="刷新文件列表">' +
1696
1750
  wandFileIcon("refresh", { size: 15 }) +
1697
1751
  '</button>' +
@@ -1760,6 +1814,10 @@
1760
1814
  '</div>' +
1761
1815
  '</div>' +
1762
1816
  '<div class="input-panel' + (state.selectedId ? "" : " hidden") + '">' +
1817
+ // 排队气泡宿主:默认 display:none,updateQueueBar() 在 queuedMessages 非空时
1818
+ // 显形。位置在 composer-top-row(含 "回复中" 状态条)之上,对话框右下角,
1819
+ // 不进入输入框内部。所有内容由 updater 注入;这里只保留稳定的外层骨架。
1820
+ '<div id="queue-bar-host" class="queue-bar-host" hidden></div>' +
1763
1821
  '<div class="composer-top-row">' +
1764
1822
  '<div id="todo-progress" class="todo-progress hidden">' +
1765
1823
  '<div class="todo-progress-header" id="todo-progress-toggle">' +
@@ -1780,11 +1838,6 @@
1780
1838
  '<ul class="todo-progress-list" id="todo-progress-list"></ul>' +
1781
1839
  '</div>' +
1782
1840
  '</div>' +
1783
- // 排队条宿主:默认 display:none,updateQueueBar() 在 queuedMessages 非空时
1784
- // 显形。结构上夹在 composer-top-row(todo 进度)和 input-composer(输入框 +
1785
- // 工具栏)之间,位置正好"在输入框上方、对话框右下角"。所有内容由 updater
1786
- // 注入;这里只保留稳定的外层骨架,便于 renderAppShell 全量重建后无缝复位。
1787
- '<div id="queue-bar-host" class="queue-bar-host" hidden></div>' +
1788
1841
  '<div class="input-composer">' +
1789
1842
  '<button id="prompt-optimize-btn" class="prompt-optimize-btn" type="button" title="提示词优化(AI)" aria-label="提示词优化">' +
1790
1843
  '<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">' +
@@ -1847,13 +1900,8 @@
1847
1900
  '<button id="stop-button" class="btn-circle btn-circle-stop' + (state.selectedId ? "" : " hidden") + '" title="停止">' +
1848
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>' +
1849
1902
  '</button>' +
1850
- // 结构化模式且正在出 token 时显示:中断当前回复、立刻发送新输入。
1851
- // 默认走 #send-input-button → 排队;想插队的人显式按这颗。
1852
- // 用 pill 形态 + 文字 + 脉动,让用户一眼就看到「立即发送」这条快捷路径。
1853
- '<button id="interrupt-send-button" class="btn-pill btn-pill-interrupt hidden" type="button" title="中断当前回复并立即发送新输入(Cmd/Ctrl+Enter)" aria-label="立即发送">' +
1854
- '<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>' +
1855
- '<span class="btn-pill-label">立即</span>' +
1856
- '</button>' +
1903
+ // 「立即发送」按钮已下线 —— 默认行为永远是排队(气泡),想插队
1904
+ // 请点输入框上方那条气泡(chip)。Cmd/Ctrl+Enter 快捷键仍保留。
1857
1905
  '<button id="send-input-button" class="btn-circle btn-circle-send" title="发送">' +
1858
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>' +
1859
1907
  '</button>' +
@@ -3990,8 +4038,7 @@
3990
4038
  cwdEl.title = cwd;
3991
4039
  }
3992
4040
  var url = "/api/directory?q=" + encodeURIComponent(cwd) +
3993
- "&gitStatus=true" +
3994
- (state.fileExplorerShowHidden ? "&showHidden=true" : "");
4041
+ "&gitStatus=true";
3995
4042
  fetch(url, { credentials: "same-origin" })
3996
4043
  .then(function(res) {
3997
4044
  if (!res.ok) throw new Error("Failed to load directory.");
@@ -4154,8 +4201,7 @@
4154
4201
  var iconEl2 = item.querySelector(".tree-icon");
4155
4202
  if (iconEl2) iconEl2.textContent = "📂";
4156
4203
  var url = "/api/directory?q=" + encodeURIComponent(p) +
4157
- "&gitStatus=true" +
4158
- (state.fileExplorerShowHidden ? "&showHidden=true" : "");
4204
+ "&gitStatus=true";
4159
4205
  fetch(url, { credentials: "same-origin" })
4160
4206
  .then(function(res) { return res.json(); })
4161
4207
  .then(function(payload) {
@@ -4187,20 +4233,6 @@
4187
4233
  refreshFileExplorer({ cwd: parent });
4188
4234
  }
4189
4235
 
4190
- // Toggle the show-hidden flag and persist it.
4191
- function toggleExplorerHidden() {
4192
- state.fileExplorerShowHidden = !state.fileExplorerShowHidden;
4193
- try { localStorage.setItem("wand-file-show-hidden", state.fileExplorerShowHidden ? "1" : "0"); } catch (e) {}
4194
- var btn = document.getElementById("file-explorer-toggle-hidden");
4195
- if (btn) {
4196
- btn.classList.toggle("active", state.fileExplorerShowHidden);
4197
- btn.setAttribute("aria-pressed", state.fileExplorerShowHidden ? "true" : "false");
4198
- btn.innerHTML = wandFileIcon(state.fileExplorerShowHidden ? "eye" : "eye-off", { size: 15 });
4199
- btn.title = state.fileExplorerShowHidden ? "隐藏点开头文件" : "显示隐藏文件";
4200
- }
4201
- refreshFileExplorer();
4202
- }
4203
-
4204
4236
  function appendToComposer(text) {
4205
4237
  var inputBox = document.getElementById("input-box");
4206
4238
  if (!inputBox) return false;
@@ -5845,8 +5877,6 @@
5845
5877
  if (drawerBackdrop) drawerBackdrop.addEventListener("click", closeSessionsDrawer);
5846
5878
  var closeDrawerBtn = document.getElementById("close-drawer-button");
5847
5879
  if (closeDrawerBtn) closeDrawerBtn.addEventListener("click", closeSessionsDrawer);
5848
- var pinBtn = document.getElementById("sidebar-pin-btn");
5849
- if (pinBtn) pinBtn.addEventListener("click", toggleSidebarPin);
5850
5880
  var collapseBtn = document.getElementById("sidebar-collapse-btn");
5851
5881
  if (collapseBtn) collapseBtn.addEventListener("click", toggleSidebarCollapsed);
5852
5882
  var sidebarMoreBtn = document.getElementById("sidebar-more-btn");
@@ -6114,11 +6144,6 @@
6114
6144
  closeSessionsDrawer();
6115
6145
  sendOrStart();
6116
6146
  });
6117
- var interruptSendBtn = document.getElementById("interrupt-send-button");
6118
- if (interruptSendBtn) interruptSendBtn.addEventListener("click", function() {
6119
- closeSessionsDrawer();
6120
- sendOrStart({ interrupt: true });
6121
- });
6122
6147
  var stopBtn = document.getElementById("stop-button");
6123
6148
  if (stopBtn) stopBtn.addEventListener("click", stopSession);
6124
6149
  var modeSelect = document.getElementById("chat-mode-select");
@@ -6390,8 +6415,6 @@
6390
6415
  if (fileRefresh) fileRefresh.addEventListener("click", function() { refreshFileExplorer(); });
6391
6416
  var fileUp = document.getElementById("file-explorer-up");
6392
6417
  if (fileUp) fileUp.addEventListener("click", navigateExplorerUp);
6393
- var fileToggleHidden = document.getElementById("file-explorer-toggle-hidden");
6394
- if (fileToggleHidden) fileToggleHidden.addEventListener("click", toggleExplorerHidden);
6395
6418
 
6396
6419
  // 路径输入框:支持点击修改路径,回车跳转,Esc 撤销。
6397
6420
  var fileCwdInput = document.getElementById("file-explorer-cwd");
@@ -6840,14 +6863,8 @@
6840
6863
  setupVisualViewportHandlers();
6841
6864
 
6842
6865
  // 排队条:每次 shell 重渲后,重新挂事件代理 + 刷新内容。
6843
- // document-level 的 ESC / 外点击 handler 只挂一次(state.__queueBarGlobalAttached 守门)。
6844
6866
  attachQueueBarDelegates();
6845
6867
  updateQueueBar();
6846
- if (!state.__queueBarGlobalAttached) {
6847
- state.__queueBarGlobalAttached = true;
6848
- document.addEventListener("pointerdown", handleQueueBarOutsideClick, true);
6849
- document.addEventListener("keydown", handleQueueBarKeydown, true);
6850
- }
6851
6868
  }
6852
6869
 
6853
6870
  function saveWorkingDir(path) {
@@ -8189,14 +8206,17 @@
8189
8206
  // Keep placeholders short so they don't wrap on portrait mobile screens.
8190
8207
  // Only show informative state hints; drop the redundant "send to X" labels.
8191
8208
  if (terminalInteractive) return "终端交互中";
8192
- if (session && session.status !== "running") {
8209
+ // 只有真正进入终止态(exited / failed / stopped)才提示"会话已结束"
8210
+ // 结构化会话刚创建或一次回复结束后会回到 "idle"——那是等待下一条输入的
8211
+ // 正常状态,不应该被当成结束。
8212
+ if (session && (session.status === "exited" || session.status === "failed" || session.status === "stopped")) {
8193
8213
  if (canAutoResumeSession(session)) return "";
8194
8214
  return "会话已结束";
8195
8215
  }
8196
8216
  // 结构化会话在出 token 时,输入框仍然可用——告诉用户默认行为是排队,
8197
- // 想插队请按右侧的 » 按钮。短语保持单行不换行。
8217
+ // 想插队请直接点上面的气泡。短语尽量短,避免在窄屏手机上换行。
8198
8218
  if (isStructuredSession(session) && session.structuredState && session.structuredState.inFlight) {
8199
- return "回复中…Enter 排队 · 旁边的「» 立即」按钮中断并立即发送";
8219
+ return "回复中…Enter 排队 · 点气泡插队";
8200
8220
  }
8201
8221
  return "";
8202
8222
  }
@@ -9296,7 +9316,6 @@
9296
9316
  function updatePinState() {
9297
9317
  var drawer = document.getElementById("sessions-drawer");
9298
9318
  var mainLayout = document.querySelector(".main-layout");
9299
- var pinBtn = document.getElementById("sidebar-pin-btn");
9300
9319
  // 与 renderAppShell 保持一致:手机端只允许窄条形态 anchored。
9301
9320
  var isMobile = isMobileLayout();
9302
9321
  var isCollapsed = !!state.sidebarPinned && !!state.sidebarCollapsed;
@@ -9309,10 +9328,6 @@
9309
9328
  mainLayout.classList.toggle("sidebar-pinned", isAnchored);
9310
9329
  mainLayout.classList.toggle("sidebar-collapsed", isCollapsed);
9311
9330
  }
9312
- if (pinBtn) {
9313
- pinBtn.classList.toggle("pinned", state.sidebarPinned);
9314
- pinBtn.title = state.sidebarPinned ? "取消固定侧栏" : "固定侧栏";
9315
- }
9316
9331
  }
9317
9332
 
9318
9333
  function updateDrawerState() {
@@ -9336,9 +9351,26 @@
9336
9351
  }
9337
9352
 
9338
9353
  function toggleSessionsDrawer() {
9339
- 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 行为。
9340
9372
  state.sessionsDrawerOpen = !state.sessionsDrawerOpen;
9341
- if (state.sessionsDrawerOpen && isMobileLayout()) {
9373
+ if (state.sessionsDrawerOpen) {
9342
9374
  state.filePanelOpen = false;
9343
9375
  try {
9344
9376
  localStorage.setItem("wand-file-panel-open", "false");
@@ -9348,13 +9380,42 @@
9348
9380
  }
9349
9381
 
9350
9382
  function closeSessionsDrawer() {
9351
- 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 关闭行为。
9352
9397
  if (!state.sessionsDrawerOpen) return;
9353
9398
  closeSwipedItem();
9354
9399
  state.sessionsDrawerOpen = false;
9355
9400
  updateLayoutState();
9356
9401
  }
9357
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
+
9358
9419
  var collapsedTileBubbleEl = null;
9359
9420
  function ensureCollapsedTileBubble() {
9360
9421
  if (collapsedTileBubbleEl && document.body.contains(collapsedTileBubbleEl)) {
@@ -9420,8 +9481,7 @@
9420
9481
 
9421
9482
  function toggleSidebarCollapsed() {
9422
9483
  var isMobile = isMobileLayout();
9423
- // drawer 模式(未 pin)下点 collapse 视为「先固定、再收起为窄条」——
9424
- // 用户直觉是「点了就该看到窄条」,过去这里 early return 让按钮看上去没反应。
9484
+ // 任何形态下点窄条按钮都意味着「我要常驻」,确保 pinned 写上。
9425
9485
  if (!state.sidebarPinned) {
9426
9486
  state.sidebarPinned = true;
9427
9487
  try {
@@ -9445,47 +9505,13 @@
9445
9505
  localStorage.setItem("wand-sidebar-pinned", "false");
9446
9506
  } catch (e) {}
9447
9507
  } else {
9448
- // 桌面端展开窄条 → 300px 全栏固定,自动打开。
9508
+ // 桌面端展开窄条 → 300px 全栏常驻。
9449
9509
  state.sessionsDrawerOpen = true;
9450
9510
  }
9451
9511
  render();
9452
- var mainLayout = document.querySelector(".main-layout");
9453
- if (mainLayout) {
9454
- var onEnd = function(e) {
9455
- if (e.propertyName === "padding-left") {
9456
- mainLayout.removeEventListener("transitionend", onEnd);
9457
- scheduleTerminalResize(true);
9458
- }
9459
- };
9460
- mainLayout.addEventListener("transitionend", onEnd);
9461
- }
9462
- setTimeout(function() { scheduleTerminalResize(true); }, 350);
9512
+ scheduleTerminalRefitAfterPaddingTransition();
9463
9513
  }
9464
9514
 
9465
- function toggleSidebarPin() {
9466
- if (isMobileLayout()) return;
9467
- state.sidebarPinned = !state.sidebarPinned;
9468
- try {
9469
- localStorage.setItem("wand-sidebar-pinned", String(state.sidebarPinned));
9470
- } catch (e) {}
9471
- if (state.sidebarPinned) {
9472
- state.sessionsDrawerOpen = true;
9473
- }
9474
- updateLayoutState();
9475
- // Refit terminal after padding-left transition completes
9476
- var mainLayout = document.querySelector(".main-layout");
9477
- if (mainLayout) {
9478
- var onEnd = function(e) {
9479
- if (e.propertyName === "padding-left") {
9480
- mainLayout.removeEventListener("transitionend", onEnd);
9481
- scheduleTerminalResize(true);
9482
- }
9483
- };
9484
- mainLayout.addEventListener("transitionend", onEnd);
9485
- }
9486
- // Fallback refit in case transition doesn't fire
9487
- setTimeout(function() { scheduleTerminalResize(true); }, 350);
9488
- }
9489
9515
 
9490
9516
  // Store last focused element for focus trap
9491
9517
  var lastFocusedElement = null;
@@ -12287,9 +12313,10 @@
12287
12313
 
12288
12314
  function postStructuredInput(input, inputBox, session, opts) {
12289
12315
  opts = opts || {};
12290
- // 用户显式点击"立即发送"才会传 interrupt:true。普通 Enter / 点发送
12291
- // 在上一条还在流式时默认走 queue —— 后端 sendMessage(...) 会把它
12292
- // 追加到 queuedMessages,等当前 turn 结束自动 flush。
12316
+ // interrupt:true 现在只来自 Cmd/Ctrl+Enter 快捷键,或点队列气泡触发的
12317
+ // queueBarPromoteIndex()。普通 Enter / 点发送在上一条还在流式时默认走
12318
+ // queue —— 后端 sendMessage(...) 会把它追加到 queuedMessages,等当前 turn
12319
+ // 结束自动 flush;想插队就点输入框上方那条气泡。
12293
12320
  var requestedInterrupt = !!opts.interrupt;
12294
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 });
12295
12322
  if (!state.selectedId || !input) return Promise.resolve();
@@ -12327,7 +12354,7 @@
12327
12354
  updateSessionSnapshot(optimisticPatch);
12328
12355
  var queueRefreshed = state.sessions.find(function(s) { return s.id === session.id; }) || session;
12329
12356
  state.currentMessages = buildMessagesForRender(queueRefreshed, getPreferredMessages(queueRefreshed, queueRefreshed.output, false));
12330
- updateInputHint("已加入排队,等待当前回复完成…");
12357
+ updateInputHint("已加入排队…");
12331
12358
  renderChat(true);
12332
12359
  updateStructuredQueueCounter();
12333
12360
  // 乐观 toast:原本只在 POST 完成后才提示,Claude 流式拖太久时用户根本
@@ -12483,104 +12510,80 @@
12483
12510
  }
12484
12511
 
12485
12512
  // ──────────────────────────────────────────────────────────────────────────
12486
- // 排队条(.queue-bar)—— 输入框上方独立浮条,承担三个事情:
12487
- // 1) 折叠态:● 排队 N + 队尾预览 + ⌃ chevron + ⚡ 立即 按钮
12488
- // 2) 展开面板:列出所有排队消息,支持拖拽换序 / 单条删除 / 一键清空
12489
- // 3) 立即按钮:中断当前回复,把队首作为新消息插队发出去(剩余队列保留)
12490
- // 数据源:session.queuedMessages(由后端 WS 推送 + postStructuredInput 乐观更新)。
12513
+ // 排队气泡条(.queue-bar)—— 垂直堆叠,浮在 "回复中" 状态条上方。
12514
+ // 交互参考 iOS 通讯录右侧的字母选择条:
12515
+ // · 默认只展开队首(即下一个要发的那条),显示编号 + 文本 + × 删除
12516
+ // · 其他消息收起成一根小横杠(指示存在但不占空间)
12517
+ // · 鼠标悬到任意小横杠 该条展开、原本展开的那条收回小横杠
12518
+ // · 点一下任意气泡 → 中断当前回复、把这条作为新输入插队发出去
12519
+ // · 按住任意气泡向上 / 向下拖拽 → 换序
12520
+ // 末尾跟一个 ⚡ 按钮:等价于点队首气泡(保留作为快速插队的视觉提示)。
12521
+ // 数据源:session.queuedMessages(后端 WS + postStructuredInput 乐观更新)。
12491
12522
  // ──────────────────────────────────────────────────────────────────────────
12492
12523
 
12493
- var QUEUE_BAR_MAX = 10; // 后端硬上限
12524
+ var QUEUE_BAR_MAX = 10; // 后端硬上限
12525
+ var QUEUE_CHIP_MAX_TEXT = 24; // 单个气泡展开时显示的字数上限
12494
12526
 
12495
- function queueBarTruncatePreview(text) {
12527
+ function queueChipTruncate(text) {
12496
12528
  if (typeof text !== "string") return "";
12497
12529
  var s = text.replace(/\s+/g, " ").trim();
12498
- if (s.length <= 48) return s;
12499
- return s.slice(0, 46) + "…";
12530
+ if (s.length <= QUEUE_CHIP_MAX_TEXT) return s;
12531
+ return s.slice(0, QUEUE_CHIP_MAX_TEXT) + "…";
12500
12532
  }
12501
12533
 
12502
- function renderQueueBarSkeleton(count, latestPreview, inFlight, atCapacity, immediateLabel) {
12503
- // 折叠条 + 展开面板的 HTML 一次性渲染好,靠 .queue-bar.expanded class 切换可见性。
12504
- // 这样展开/收起不需要拼字符串,纯 class toggle,动画也好做。
12505
- var dotClass = inFlight ? "queue-bar-dot queue-bar-dot-pulse" : "queue-bar-dot";
12506
- var barClass = "queue-bar";
12507
- if (state.queueBarExpanded) barClass += " expanded";
12508
- if (atCapacity) barClass += " queue-bar-capacity";
12509
- if (inFlight) barClass += " queue-bar-inflight";
12510
- var html =
12511
- '<div class="' + barClass + '" data-queue-bar="1">' +
12512
- '<button type="button" class="queue-bar-toggle" data-action="toggle"' +
12513
- ' aria-expanded="' + (state.queueBarExpanded ? "true" : "false") + '"' +
12514
- ' title="点击查看 / 收起排队消息">' +
12515
- '<span class="' + dotClass + '" aria-hidden="true"></span>' +
12516
- '<span class="queue-bar-count">' + (atCapacity ? "队列已满 " : "排队 ") + count + '</span>' +
12517
- '<span class="queue-bar-sep" aria-hidden="true">·</span>' +
12518
- '<span class="queue-bar-preview">' + escapeHtml(latestPreview) + '</span>' +
12519
- '<svg class="queue-bar-chevron" width="11" height="11" viewBox="0 0 24 24"' +
12520
- ' fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"' +
12521
- ' stroke-linejoin="round" aria-hidden="true"><polyline points="6 15 12 9 18 15"/></svg>' +
12522
- '</button>' +
12523
- '<span class="queue-bar-divider" aria-hidden="true"></span>' +
12524
- '<button type="button" class="queue-bar-promote" data-action="promote"' +
12525
- ' title="中断当前回复,立刻发送队首这条" aria-label="立即发送队首">' +
12526
- '<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">' +
12527
- '<path d="M13 2 L4 14 L11 14 L10 22 L20 9 L13 9 Z"/>' +
12528
- '</svg>' +
12529
- '<span class="queue-bar-promote-label">' + escapeHtml(immediateLabel) + '</span>' +
12530
- '</button>' +
12531
- '<div class="queue-bar-panel" data-queue-panel="1" role="region" aria-label="排队消息列表">' +
12532
- '<div class="queue-bar-panel-header">' +
12533
- '<span class="queue-bar-panel-title">' + iconSvg("inbox", { size: 13, strokeWidth: 1.7, cls: "queue-bar-panel-title-icon" }) + '<span>排队中 (' + count + ')</span></span>' +
12534
- '<button type="button" class="queue-bar-clear" data-action="clear"' +
12535
- (count === 0 ? " disabled" : "") + '>清空</button>' +
12536
- '<button type="button" class="queue-bar-collapse" data-action="collapse" aria-label="收起">' +
12537
- '收起' +
12538
- '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor"' +
12539
- ' stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
12540
- '<polyline points="6 9 12 15 18 9"/></svg>' +
12541
- '</button>' +
12542
- '</div>' +
12543
- '<ol class="queue-bar-list" data-queue-list="1"></ol>' +
12544
- '</div>' +
12545
- '</div>';
12546
- return html;
12534
+ // 当前应该展开的下标:拖拽中 被拖的那条(data-index 不变);hover 被 hover 的;否则 → 第 0 项
12535
+ function queueBarExpandedIndex(itemsLength) {
12536
+ if (state.queueBarDrag && typeof state.queueBarDrag.origIndex === "number") {
12537
+ return state.queueBarDrag.origIndex;
12538
+ }
12539
+ if (typeof state.queueBarHoverIndex === "number"
12540
+ && state.queueBarHoverIndex >= 0
12541
+ && state.queueBarHoverIndex < itemsLength) {
12542
+ return state.queueBarHoverIndex;
12543
+ }
12544
+ return 0;
12547
12545
  }
12548
12546
 
12549
- function renderQueueBarItems(listEl, items) {
12550
- // ol 内容单独 render —— 拖拽 / 删除 / 展开会频繁动它,外层骨架不重建避免抖动。
12547
+ function renderQueueBarHtml(items, inFlight, atCapacity, immediateLabel) {
12551
12548
  var single = items.length <= 1;
12552
- var html = "";
12549
+ var barClass = "queue-bar";
12550
+ if (atCapacity) barClass += " queue-bar-capacity";
12551
+ if (inFlight) barClass += " queue-bar-inflight";
12552
+ var expandedIdx = queueBarExpandedIndex(items.length);
12553
+ var chips = "";
12553
12554
  for (var i = 0; i < items.length; i++) {
12554
12555
  var raw = items[i] == null ? "" : String(items[i]);
12555
- var expanded = !!state.queueBarItemExpanded[i];
12556
+ var isExpanded = i === expandedIdx;
12556
12557
  var itemClass = "queue-bar-item";
12557
- if (expanded) itemClass += " expanded";
12558
+ if (isExpanded) itemClass += " expanded";
12558
12559
  if (single) itemClass += " queue-bar-item-single";
12559
- html +=
12560
- '<li class="' + itemClass + '" data-index="' + i + '">' +
12561
- '<button type="button" class="queue-bar-item-drag" data-action="drag" aria-label="拖动调整顺序"' +
12562
- ' title="按住拖动调整顺序"' + (single ? " disabled" : "") + '>' +
12563
- '<svg width="10" height="14" viewBox="0 0 10 14" fill="currentColor" aria-hidden="true">' +
12564
- '<circle cx="2.2" cy="2.2" r="1.2"/><circle cx="7.8" cy="2.2" r="1.2"/>' +
12565
- '<circle cx="2.2" cy="7" r="1.2"/><circle cx="7.8" cy="7" r="1.2"/>' +
12566
- '<circle cx="2.2" cy="11.8" r="1.2"/><circle cx="7.8" cy="11.8" r="1.2"/>' +
12567
- '</svg>' +
12568
- '</button>' +
12569
- '<span class="queue-bar-item-index">#' + (i + 1) + '</span>' +
12570
- '<button type="button" class="queue-bar-item-text" data-action="expand-text"' +
12571
- ' aria-expanded="' + (expanded ? "true" : "false") + '"' +
12572
- ' title="点击展开 / 收起完整内容">' +
12573
- escapeHtml(raw) +
12574
- '</button>' +
12560
+ // 整个 chip 既是拖拽起手区,也是"点一下立即发送"的触发点;delete 按钮单独占点击。
12561
+ var titleAttr = isExpanded ? raw + "(点一下立即发送 · 按住可拖动调序)" : raw + "(点一下立即发送)";
12562
+ chips +=
12563
+ '<li class="' + itemClass + '" data-index="' + i + '" data-action="drag"' +
12564
+ ' title="' + escapeHtml(titleAttr) + '">' +
12565
+ '<span class="queue-bar-item-index" aria-hidden="true">' + (i + 1) + '</span>' +
12566
+ '<span class="queue-bar-item-text">' + escapeHtml(queueChipTruncate(raw)) + '</span>' +
12575
12567
  '<button type="button" class="queue-bar-item-delete" data-action="delete"' +
12576
- ' aria-label="删除这条排队消息" title="删除">' +
12577
- '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor"' +
12578
- ' stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
12568
+ ' aria-label="删除这条排队消息" title="删除" tabindex="' + (isExpanded ? "0" : "-1") + '">' +
12569
+ '<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor"' +
12570
+ ' stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
12579
12571
  '<line x1="6" y1="6" x2="18" y2="18"/><line x1="6" y1="18" x2="18" y2="6"/></svg>' +
12580
12572
  '</button>' +
12581
12573
  '</li>';
12582
12574
  }
12583
- listEl.innerHTML = html;
12575
+ return (
12576
+ '<div class="' + barClass + '" data-queue-bar="1">' +
12577
+ '<ol class="queue-bar-list" data-queue-list="1">' + chips + '</ol>' +
12578
+ '<button type="button" class="queue-bar-promote" data-action="promote"' +
12579
+ ' title="中断当前回复,立刻发送队首这条"' +
12580
+ ' aria-label="' + escapeHtml(immediateLabel) + '队首">' +
12581
+ '<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">' +
12582
+ '<path d="M13 2 L4 14 L11 14 L10 22 L20 9 L13 9 Z"/>' +
12583
+ '</svg>' +
12584
+ '</button>' +
12585
+ '</div>'
12586
+ );
12584
12587
  }
12585
12588
 
12586
12589
  function updateQueueBar() {
@@ -12592,64 +12595,50 @@
12592
12595
  queue = Array.isArray(queue) ? queue : [];
12593
12596
 
12594
12597
  if (!isStructured || queue.length === 0) {
12595
- // 队列空 / 非结构化会话:整条隐藏,并清掉展开/逐条展开的本地态。
12596
12598
  host.hidden = true;
12597
12599
  host.innerHTML = "";
12598
- state.queueBarExpanded = false;
12599
- state.queueBarItemExpanded = {};
12600
+ state.queueBarHoverIndex = null;
12600
12601
  return;
12601
12602
  }
12602
12603
 
12604
+ // 拖拽进行中绝不重建 DOM,否则 pointer capture 丢失、气泡闪屏。
12605
+ if (state.queueBarDrag) return;
12606
+
12603
12607
  host.hidden = false;
12604
12608
  var inFlight = !!(session.structuredState && session.structuredState.inFlight && session.status === "running");
12605
12609
  var atCapacity = queue.length >= QUEUE_BAR_MAX;
12606
- var latest = queueBarTruncatePreview(queue[queue.length - 1]);
12607
- // inFlight=false 时按钮语义从"插队"退化为"立刻发";文案一并切换让用户不疑惑。
12608
12610
  var immediateLabel = inFlight ? "立即" : "发送";
12609
12611
 
12610
- // 拖拽进行中绝不重建骨架,否则 pointer capture 丢失、items 闪屏。
12611
- // 只更新列表内容(且如果数量不变也跳过整段重排)。
12612
- var existing = host.querySelector(".queue-bar");
12613
- if (state.queueBarDrag && existing) {
12614
- var listInDrag = existing.querySelector('[data-queue-list="1"]');
12615
- if (listInDrag && listInDrag.children.length !== queue.length) {
12616
- renderQueueBarItems(listInDrag, queue);
12617
- }
12618
- return;
12619
- }
12620
-
12621
- host.innerHTML = renderQueueBarSkeleton(queue.length, latest, inFlight, atCapacity, immediateLabel);
12622
- var listEl = host.querySelector('[data-queue-list="1"]');
12623
- if (listEl) renderQueueBarItems(listEl, queue);
12612
+ host.innerHTML = renderQueueBarHtml(queue, inFlight, atCapacity, immediateLabel);
12624
12613
  }
12625
12614
 
12626
- // ── 折叠 / 展开 ──
12627
- function setQueueBarExpanded(expanded) {
12628
- var next = !!expanded;
12629
- if (state.queueBarExpanded === next) return;
12630
- state.queueBarExpanded = next;
12631
- if (!next) state.queueBarItemExpanded = {};
12632
- updateQueueBar();
12633
- }
12634
- function toggleQueueBar() { setQueueBarExpanded(!state.queueBarExpanded); }
12635
-
12636
- function handleQueueBarOutsideClick(ev) {
12637
- if (!state.queueBarExpanded) return;
12615
+ // 只切换 .expanded class,不重建 DOM —— 避免鼠标移过去触发的重建
12616
+ // 让拖拽/输入框焦点等丢失。所有同步状态(hoverIndex / drag)的改变都通过这里反映到 DOM。
12617
+ function reflectQueueBarExpansion() {
12638
12618
  var host = document.getElementById("queue-bar-host");
12639
- if (!host) return;
12640
- if (host.contains(ev.target)) return;
12641
- setQueueBarExpanded(false);
12642
- }
12643
- function handleQueueBarKeydown(ev) {
12644
- if (!state.queueBarExpanded) return;
12645
- if (ev.key === "Escape" || ev.key === "Esc") {
12646
- setQueueBarExpanded(false);
12647
- // 焦点回到 toggle 按钮,方便键盘党
12648
- var toggle = document.querySelector(".queue-bar-toggle");
12649
- if (toggle) toggle.focus();
12619
+ if (!host || host.hidden) return;
12620
+ var list = host.querySelector('[data-queue-list="1"]');
12621
+ if (!list) return;
12622
+ var children = list.children;
12623
+ var expandedIdx = queueBarExpandedIndex(children.length);
12624
+ for (var i = 0; i < children.length; i++) {
12625
+ var el = children[i];
12626
+ var should = i === expandedIdx;
12627
+ if (el.classList.contains("expanded") !== should) {
12628
+ el.classList.toggle("expanded", should);
12629
+ var del = el.querySelector('.queue-bar-item-delete');
12630
+ if (del) del.tabIndex = should ? 0 : -1;
12631
+ }
12650
12632
  }
12651
12633
  }
12652
12634
 
12635
+ function setQueueBarHoverIndex(idx) {
12636
+ var next = (idx == null ? null : Number(idx));
12637
+ if (state.queueBarHoverIndex === next) return;
12638
+ state.queueBarHoverIndex = next;
12639
+ reflectQueueBarExpansion();
12640
+ }
12641
+
12653
12642
  // ── 单条删除 / 全部清空 / 队首插队 ──
12654
12643
  function rollbackQueueOptimistic(session, prevQueue) {
12655
12644
  updateSessionSnapshot({ id: session.id, queuedMessages: prevQueue });
@@ -12666,15 +12655,11 @@
12666
12655
  if (index < 0 || index >= queue.length) return;
12667
12656
  var prev = queue.slice();
12668
12657
  var next = queue.slice(0, index).concat(queue.slice(index + 1));
12669
- // 调整 queueBarItemExpanded 的下标偏移
12670
- var nextExpanded = {};
12671
- Object.keys(state.queueBarItemExpanded).forEach(function(k) {
12672
- var i = Number(k);
12673
- if (i === index) return;
12674
- if (i > index) nextExpanded[i - 1] = state.queueBarItemExpanded[k];
12675
- else nextExpanded[i] = state.queueBarItemExpanded[k];
12676
- });
12677
- state.queueBarItemExpanded = nextExpanded;
12658
+ // hover 下标也要随之收缩,否则删完后展开的是错位的那条
12659
+ if (typeof state.queueBarHoverIndex === "number") {
12660
+ if (state.queueBarHoverIndex === index) state.queueBarHoverIndex = null;
12661
+ else if (state.queueBarHoverIndex > index) state.queueBarHoverIndex -= 1;
12662
+ }
12678
12663
  updateSessionSnapshot({ id: session.id, queuedMessages: next });
12679
12664
  var refreshed = state.sessions.find(function(s) { return s.id === session.id; }) || session;
12680
12665
  state.currentMessages = buildMessagesForRender(refreshed, getPreferredMessages(refreshed, refreshed.output, false));
@@ -12702,7 +12687,7 @@
12702
12687
  if (!session) return;
12703
12688
  var prev = Array.isArray(session.queuedMessages) ? session.queuedMessages.slice() : [];
12704
12689
  if (prev.length === 0) return;
12705
- state.queueBarItemExpanded = {};
12690
+ state.queueBarHoverIndex = null;
12706
12691
  updateSessionSnapshot({ id: session.id, queuedMessages: [] });
12707
12692
  var refreshed = state.sessions.find(function(s) { return s.id === session.id; }) || session;
12708
12693
  state.currentMessages = buildMessagesForRender(refreshed, getPreferredMessages(refreshed, refreshed.output, false));
@@ -12727,42 +12712,42 @@
12727
12712
  }
12728
12713
 
12729
12714
  function queueBarPromoteHead() {
12715
+ queueBarPromoteIndex(0);
12716
+ }
12717
+
12718
+ // 把队列里第 index 条剥下来,作为新的输入立刻发送出去。
12719
+ // - inFlight:interrupt + preserveQueue(中断当前回复,保留其它排队)
12720
+ // - 非 inFlight:当作普通新消息发出去
12721
+ // 用户路径:点输入框上方的气泡(chip)→ 这里。
12722
+ function queueBarPromoteIndex(index) {
12730
12723
  var session = state.sessions.find(function(s) { return s.id === state.selectedId; });
12731
12724
  if (!session) return;
12732
12725
  var queue = Array.isArray(session.queuedMessages) ? session.queuedMessages.slice() : [];
12733
- if (queue.length === 0) return;
12734
- var head = queue[0];
12735
- var rest = queue.slice(1);
12726
+ if (index < 0 || index >= queue.length) return;
12727
+ var picked = queue[index];
12728
+ var rest = queue.slice(0, index).concat(queue.slice(index + 1));
12736
12729
  var prev = queue.slice();
12737
12730
  var inFlight = !!(session.structuredState && session.structuredState.inFlight && session.status === "running");
12738
12731
 
12739
- // 乐观:剥掉队首
12740
- state.queueBarItemExpanded = (function() {
12741
- var out = {};
12742
- Object.keys(state.queueBarItemExpanded).forEach(function(k) {
12743
- var i = Number(k);
12744
- if (i === 0) return;
12745
- out[i - 1] = state.queueBarItemExpanded[k];
12746
- });
12747
- return out;
12748
- })();
12732
+ // 乐观:剥掉这一条;hover 下标随之收缩
12733
+ if (typeof state.queueBarHoverIndex === "number") {
12734
+ if (state.queueBarHoverIndex === index) state.queueBarHoverIndex = null;
12735
+ else if (state.queueBarHoverIndex > index) state.queueBarHoverIndex -= 1;
12736
+ }
12749
12737
  updateSessionSnapshot({ id: session.id, queuedMessages: rest });
12750
12738
 
12751
- // 收起面板,让用户视线回到 chat(新消息马上要进 user turn)
12752
- setQueueBarExpanded(false);
12753
-
12754
12739
  var idempotencyKey = (typeof crypto !== "undefined" && crypto.randomUUID)
12755
12740
  ? crypto.randomUUID()
12756
12741
  : (Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 10));
12757
12742
 
12758
- var body = { input: head, idempotencyKey: idempotencyKey };
12743
+ var body = { input: picked, idempotencyKey: idempotencyKey };
12759
12744
  if (inFlight) {
12760
12745
  // 中断 + 保留剩余队列
12761
12746
  body.interrupt = true;
12762
12747
  body.preserveQueue = true;
12763
12748
  }
12764
12749
  // 给一个乐观 toast,让用户瞬间知道点击生效了
12765
- showToast(inFlight ? "已请求中断当前回复,立即发送队首。" : "已立即发送队首消息。", "info");
12750
+ showToast(inFlight ? "已请求中断当前回复,立即发送这条。" : "已立即发送这条消息。", "info");
12766
12751
 
12767
12752
  fetch("/api/structured-sessions/" + session.id + "/messages", {
12768
12753
  method: "POST",
@@ -12795,53 +12780,82 @@
12795
12780
  });
12796
12781
  }
12797
12782
 
12798
- // ── 拖拽排序(Pointer Events + 简化版 sort/animate)──
12799
- function queueBarDragStart(ev, handleEl) {
12783
+ // ── 拖拽排序(Pointer Events + 真实高度的 sort/animate)──
12784
+ // 单条气泡的 pointerdown 也会进这里,但 queue.length <= 1 时直接返回,让
12785
+ // 系统 click 事件穿透到 #queue-bar-host 的 click delegate(那里再判断"点击
12786
+ // 气泡 → 立即发送")。
12787
+ function queueBarDragStart(ev, chipEl) {
12800
12788
  var session = state.sessions.find(function(s) { return s.id === state.selectedId; });
12801
12789
  if (!session) return;
12802
12790
  var queue = Array.isArray(session.queuedMessages) ? session.queuedMessages.slice() : [];
12803
12791
  if (queue.length <= 1) return;
12804
- var itemEl = handleEl.closest(".queue-bar-item");
12805
- if (!itemEl) return;
12806
- var listEl = itemEl.parentElement;
12792
+ if (!chipEl) return;
12793
+ var listEl = chipEl.parentElement;
12807
12794
  if (!listEl) return;
12808
- var origIndex = Number(itemEl.getAttribute("data-index"));
12795
+ var origIndex = Number(chipEl.getAttribute("data-index"));
12809
12796
  var siblings = Array.prototype.slice.call(listEl.children);
12810
12797
  var rects = siblings.map(function(el) { return el.getBoundingClientRect(); });
12811
- var rect0 = rects[origIndex];
12812
- var itemHeight = rect0.height;
12813
- var gap = 6; // CSS .queue-bar-list gap 保持一致
12798
+ // 真实间距:相邻两个 chip 的 top 差减去前一个高度(容错 hover 状态变化后的高度切换)
12799
+ var gap = 3;
12800
+ if (rects.length >= 2) gap = Math.max(0, rects[1].top - rects[0].top - rects[0].height);
12814
12801
 
12815
12802
  ev.preventDefault();
12816
- try { handleEl.setPointerCapture(ev.pointerId); } catch (_e) {}
12803
+ try { chipEl.setPointerCapture(ev.pointerId); } catch (_e) {}
12817
12804
  if (navigator && navigator.vibrate) { try { navigator.vibrate(8); } catch (_e2) {} }
12818
12805
 
12819
12806
  state.queueBarDrag = {
12820
12807
  pointerId: ev.pointerId,
12821
- handleEl: handleEl,
12822
- itemEl: itemEl,
12808
+ handleEl: chipEl,
12809
+ itemEl: chipEl,
12823
12810
  listEl: listEl,
12824
12811
  siblings: siblings,
12825
12812
  rects: rects,
12826
12813
  origIndex: origIndex,
12827
12814
  targetIndex: origIndex,
12828
12815
  startY: ev.clientY,
12829
- itemHeight: itemHeight,
12830
12816
  gap: gap,
12831
12817
  queueSnapshot: queue,
12818
+ moved: false, // 没真正拖动过 → 抬手时按 tap 处理:promote 这条
12832
12819
  };
12833
12820
 
12834
- itemEl.classList.add("dragging");
12821
+ chipEl.classList.add("dragging");
12822
+ // 让被拖元素保持 expanded(即便鼠标已经离开它)
12823
+ reflectQueueBarExpansion();
12835
12824
  // 把所有兄弟先标记为"参与平滑动画"
12836
- siblings.forEach(function(el) { if (el !== itemEl) el.classList.add("queue-bar-item-sliding"); });
12825
+ siblings.forEach(function(el) { if (el !== chipEl) el.classList.add("queue-bar-item-sliding"); });
12837
12826
 
12838
12827
  var move = function(e) { queueBarDragMove(e); };
12839
12828
  var up = function(e) { queueBarDragEnd(e); };
12840
12829
  state.queueBarDrag.moveHandler = move;
12841
12830
  state.queueBarDrag.upHandler = up;
12842
- handleEl.addEventListener("pointermove", move);
12843
- handleEl.addEventListener("pointerup", up);
12844
- handleEl.addEventListener("pointercancel", up);
12831
+ chipEl.addEventListener("pointermove", move);
12832
+ chipEl.addEventListener("pointerup", up);
12833
+ chipEl.addEventListener("pointercancel", up);
12834
+ }
12835
+
12836
+ // 给定 origIndex / target / 真实 rects,算出新排列下每个 sibling 的目标 top。
12837
+ // 用真实高度而不是固定 shift,因为 expanded chip 比 collapsed 高很多。
12838
+ function queueBarComputeNewTops(origIndex, target, rects, gap) {
12839
+ var n = rects.length;
12840
+ var order = [];
12841
+ for (var i = 0; i < n; i++) order.push(i);
12842
+ order.splice(origIndex, 1);
12843
+ order.splice(target, 0, origIndex);
12844
+ var top = rects[0].top;
12845
+ // list 是右对齐 column flex,所有元素相对 list 左边对齐 — 我们只关心 top
12846
+ // 用第一个 rect 的 top 作为锚点累加。
12847
+ // 但 list 起始位置不一定是 rects[0].top(rects[0] 现在变到 order[0] 的位置)
12848
+ // 这里需要找原本的 list top —— 取 rects 里最小 top 即可。
12849
+ var listTop = rects[0].top;
12850
+ for (var k = 1; k < n; k++) if (rects[k].top < listTop) listTop = rects[k].top;
12851
+ var newTops = {};
12852
+ var cursor = listTop;
12853
+ for (var newPos = 0; newPos < n; newPos++) {
12854
+ var oldIdx = order[newPos];
12855
+ newTops[oldIdx] = cursor;
12856
+ cursor += rects[oldIdx].height + gap;
12857
+ }
12858
+ return newTops;
12845
12859
  }
12846
12860
 
12847
12861
  function queueBarDragMove(ev) {
@@ -12849,6 +12863,8 @@
12849
12863
  if (!d || ev.pointerId !== d.pointerId) return;
12850
12864
  ev.preventDefault();
12851
12865
  var deltaY = ev.clientY - d.startY;
12866
+ // 4px 阈值过滤抖动 / 触屏轻微滑动;超过才算"真的在拖",否则抬手当 tap。
12867
+ if (Math.abs(deltaY) > 4) d.moved = true;
12852
12868
  d.itemEl.style.transform = "translateY(" + deltaY + "px)";
12853
12869
 
12854
12870
  // 拖动中心 Y 决定目标插入位置
@@ -12862,13 +12878,11 @@
12862
12878
  }
12863
12879
  if (target !== d.targetIndex) {
12864
12880
  d.targetIndex = target;
12865
- // 重排兄弟元素的 translateY
12866
- var shift = d.itemHeight + d.gap;
12881
+ // 按真实高度精确算每个 sibling 的新 top
12882
+ var newTops = queueBarComputeNewTops(d.origIndex, target, d.rects, d.gap);
12867
12883
  d.siblings.forEach(function(el, idx) {
12868
12884
  if (idx === d.origIndex) return;
12869
- var move = 0;
12870
- if (d.origIndex < target && idx > d.origIndex && idx <= target) move = -shift;
12871
- else if (d.origIndex > target && idx < d.origIndex && idx >= target) move = shift;
12885
+ var move = newTops[idx] - d.rects[idx].top;
12872
12886
  el.style.transform = move ? "translateY(" + move + "px)" : "";
12873
12887
  });
12874
12888
  }
@@ -12885,6 +12899,7 @@
12885
12899
  var origIndex = d.origIndex;
12886
12900
  var targetIndex = d.targetIndex;
12887
12901
  var queueSnapshot = d.queueSnapshot;
12902
+ var wasTap = !d.moved;
12888
12903
 
12889
12904
  // 清掉 inline transform 让 CSS 自然回位
12890
12905
  d.siblings.forEach(function(el) {
@@ -12896,8 +12911,9 @@
12896
12911
  state.queueBarDrag = null;
12897
12912
 
12898
12913
  if (origIndex === targetIndex) {
12899
- // 没动,光擦一下重渲就行
12914
+ // 没真的移动过 → 按 tap 处理:把这条剥下来插队发送。
12900
12915
  updateQueueBar();
12916
+ if (wasTap) queueBarPromoteIndex(origIndex);
12901
12917
  return;
12902
12918
  }
12903
12919
 
@@ -12908,14 +12924,8 @@
12908
12924
  order.splice(targetIndex, 0, origIndex);
12909
12925
  var nextQueue = order.map(function(i) { return queueSnapshot[i]; });
12910
12926
 
12911
- // 同步迁移 queueBarItemExpanded 下标
12912
- var nextExpanded = {};
12913
- Object.keys(state.queueBarItemExpanded).forEach(function(k) {
12914
- var oldI = Number(k);
12915
- var newI = order.indexOf(oldI);
12916
- if (newI >= 0) nextExpanded[newI] = state.queueBarItemExpanded[k];
12917
- });
12918
- state.queueBarItemExpanded = nextExpanded;
12927
+ // hover 下标迁移到新位置(拖拽放手时鼠标停在 targetIndex 上)
12928
+ state.queueBarHoverIndex = targetIndex;
12919
12929
 
12920
12930
  var session = state.sessions.find(function(s) { return s.id === state.selectedId; });
12921
12931
  if (!session) { updateQueueBar(); return; }
@@ -12950,33 +12960,45 @@
12950
12960
  var actionEl = ev.target && ev.target.closest ? ev.target.closest("[data-action]") : null;
12951
12961
  if (!actionEl || !host.contains(actionEl)) return;
12952
12962
  var action = actionEl.getAttribute("data-action");
12953
- if (action === "drag") return; // 拖拽由 pointerdown 处理,吞掉点击避免误触发
12963
+ if (action === "drag") {
12964
+ // queue.length > 1 时 pointerdown 已经 preventDefault → click 不会到这;
12965
+ // queue.length === 1 时 drag-start 早退、click 会落到这里:当成 tap,
12966
+ // 把这条直接 promote 出去。
12967
+ ev.preventDefault();
12968
+ ev.stopPropagation();
12969
+ var idx = Number(actionEl.getAttribute("data-index"));
12970
+ queueBarPromoteIndex(idx);
12971
+ return;
12972
+ }
12954
12973
  ev.preventDefault();
12955
12974
  ev.stopPropagation();
12956
- if (action === "toggle") { toggleQueueBar(); return; }
12957
- if (action === "collapse") { setQueueBarExpanded(false); return; }
12958
12975
  if (action === "promote") { queueBarPromoteHead(); return; }
12959
- if (action === "clear") { queueBarClearAll(); return; }
12960
12976
  if (action === "delete") {
12961
12977
  var itemEl = actionEl.closest(".queue-bar-item");
12962
12978
  if (itemEl) queueBarDeleteItem(Number(itemEl.getAttribute("data-index")));
12963
12979
  return;
12964
12980
  }
12965
- if (action === "expand-text") {
12966
- var item = actionEl.closest(".queue-bar-item");
12967
- if (!item) return;
12968
- var idx = Number(item.getAttribute("data-index"));
12969
- state.queueBarItemExpanded[idx] = !state.queueBarItemExpanded[idx];
12970
- item.classList.toggle("expanded", !!state.queueBarItemExpanded[idx]);
12971
- actionEl.setAttribute("aria-expanded", state.queueBarItemExpanded[idx] ? "true" : "false");
12972
- return;
12973
- }
12974
12981
  });
12982
+ // hover 跟随:鼠标移到哪一条,哪一条就展开(拖拽进行中不响应,免得抢拖拽)
12983
+ host.addEventListener("mouseover", function(ev) {
12984
+ if (state.queueBarDrag) return;
12985
+ var chip = ev.target && ev.target.closest ? ev.target.closest(".queue-bar-item") : null;
12986
+ if (!chip || !host.contains(chip)) return;
12987
+ setQueueBarHoverIndex(Number(chip.getAttribute("data-index")));
12988
+ });
12989
+ host.addEventListener("mouseleave", function() {
12990
+ if (state.queueBarDrag) return;
12991
+ setQueueBarHoverIndex(null);
12992
+ });
12993
+ // 整个气泡都是拖拽起手区。delete / promote 按钮通过 closest 检查跳过
12975
12994
  host.addEventListener("pointerdown", function(ev) {
12976
12995
  if (ev.button !== undefined && ev.button !== 0) return;
12977
- var handle = ev.target && ev.target.closest ? ev.target.closest('[data-action="drag"]') : null;
12978
- if (!handle || handle.disabled) return;
12979
- queueBarDragStart(ev, handle);
12996
+ if (ev.target && ev.target.closest && ev.target.closest('[data-action="delete"], [data-action="promote"]')) return;
12997
+ var chip = ev.target && ev.target.closest ? ev.target.closest('.queue-bar-item') : null;
12998
+ if (!chip) return;
12999
+ // 拖拽前先把这条切到 expanded(鼠标按下时通常已经 hovered,但触屏没 hover)
13000
+ setQueueBarHoverIndex(Number(chip.getAttribute("data-index")));
13001
+ queueBarDragStart(ev, chip);
12980
13002
  });
12981
13003
  }
12982
13004
 
@@ -13563,12 +13585,6 @@
13563
13585
  : (isCodex ? (isRunning ? "发送给 Codex" : "Codex 会话已结束") : (!selectedSession || isRunning || canResumeOnSend ? "发送" : "会话已结束")));
13564
13586
  sendBtn.classList.toggle("queue-mode", structuredInFlight);
13565
13587
  }
13566
- var interruptBtn = document.getElementById("interrupt-send-button");
13567
- if (interruptBtn) {
13568
- // 仅结构化 + inFlight 时显示。pty 会话有自己的 Ctrl+C / stop 按钮,
13569
- // 用不上这套语义。
13570
- interruptBtn.classList.toggle("hidden", !structuredInFlight);
13571
- }
13572
13588
  var container = document.getElementById("output");
13573
13589
  if (container) container.classList.toggle("interactive", !structured && state.terminalInteractive);
13574
13590
  }
@@ -17054,24 +17070,13 @@
17054
17070
  return;
17055
17071
  }
17056
17072
 
17057
- // 当前 turn 已结束(结构化 inFlight=false 或 PTY 非 running)就把进度条
17058
- // 收起来——模型经常忘了发最后一条"全 completed" TodoWrite,让用户
17059
- // 对着 "5/6" 干瞪眼很别扭。allDone 那条分支保留,提前命中更快返回。
17060
- var sel = state.sessions.find(function(s) { return s.id === state.selectedId; });
17061
- var turnDone = false;
17062
- if (sel) {
17063
- if (isStructuredSession(sel)) {
17064
- turnDone = !(sel.structuredState && sel.structuredState.inFlight);
17065
- } else {
17066
- turnDone = sel.status !== "running";
17067
- }
17068
- }
17069
- if (turnDone) {
17070
- container.classList.add("hidden");
17071
- if (bodyEl) bodyEl.classList.add("hidden");
17072
- return;
17073
- }
17074
-
17073
+ // 之前这里在 turn 结束(结构化 inFlight=false 或 PTY 非 running)时
17074
+ // 把进度条收起来,理由是「模型经常忘了发最后一条全 completed
17075
+ // TodoWrite,让用户对着 5/6 干瞪眼很别扭」。但反馈是:在结构化模式下
17076
+ // inFlight 在流间隙会短暂置假,进度条因此跟着闪没;而且 turn 刚结束
17077
+ // 时用户其实想再看一眼最终进度。改回「只要当前 turn 里有 todos 就显
17078
+ // 示,allDone 时再隐藏」,跨 turn 残留交给开头那段「最后一条 user
17079
+ // 消息后才扫 TodoWrite」的 scoping 兜住。
17075
17080
  container.classList.remove("hidden");
17076
17081
  if (bodyEl) bodyEl.classList.remove("hidden");
17077
17082