@co0ontty/wand 1.30.0 → 1.31.0

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.
@@ -149,6 +149,12 @@
149
149
  chatModel: (function() {
150
150
  try { return localStorage.getItem("wand-chat-model") || ""; } catch (e) { return ""; }
151
151
  })(),
152
+ chatThinking: (function() {
153
+ try {
154
+ var v = localStorage.getItem("wand-thinking-effort") || "off";
155
+ return (v === "off" || v === "standard" || v === "deep" || v === "max") ? v : "off";
156
+ } catch (e) { return "off"; }
157
+ })(),
152
158
  availableModels: [],
153
159
  availableCodexModels: [],
154
160
  modelsRefreshing: false,
@@ -238,10 +244,19 @@
238
244
  chatUnreadCount: 0,
239
245
  // state.currentMessages 中第一条未读消息的 index,-1 表示没有未读。
240
246
  chatUnreadStartIndex: -1,
241
- chatScrollThreshold: 120,
247
+ // 业界共识 150-180px:120px 在触控板/移动端惯性下边界来回弹。
248
+ chatScrollThreshold: 160,
242
249
  chatIsProgrammaticScroll: false,
243
250
  chatScrollElement: null,
244
251
  chatScrollHandler: null,
252
+ chatScrollWheelHandler: null,
253
+ chatScrollTouchStartHandler: null,
254
+ chatScrollTouchMoveHandler: null,
255
+ chatTouchStartY: 0,
256
+ // 仅在"首次渲染当前会话视图"时才允许 fullRenderChat 强制贴底。
257
+ // resetChatRenderCache 会把它设回 false;fullRenderChat 第一次跑完就置 true。
258
+ // page-refresh / ws 重连不重置此标记,避免把用户拽到底部。
259
+ chatInitialRenderDone: false,
245
260
  lastForegroundSyncAt: 0,
246
261
  foregroundSyncTimer: null,
247
262
  wsReconnectAttempts: 0,
@@ -597,8 +612,19 @@
597
612
  updateChatUnreadBubble();
598
613
  return;
599
614
  }
600
- if (state.chatScrollElement && state.chatScrollHandler) {
601
- state.chatScrollElement.removeEventListener("scroll", state.chatScrollHandler);
615
+ if (state.chatScrollElement) {
616
+ if (state.chatScrollHandler) {
617
+ state.chatScrollElement.removeEventListener("scroll", state.chatScrollHandler);
618
+ }
619
+ if (state.chatScrollWheelHandler) {
620
+ state.chatScrollElement.removeEventListener("wheel", state.chatScrollWheelHandler);
621
+ }
622
+ if (state.chatScrollTouchStartHandler) {
623
+ state.chatScrollElement.removeEventListener("touchstart", state.chatScrollTouchStartHandler);
624
+ }
625
+ if (state.chatScrollTouchMoveHandler) {
626
+ state.chatScrollElement.removeEventListener("touchmove", state.chatScrollTouchMoveHandler);
627
+ }
602
628
  }
603
629
  state.chatScrollElement = chatMsgs;
604
630
  state.chatScrollHandler = function() {
@@ -619,7 +645,35 @@
619
645
  }
620
646
  updateChatUnreadBubble();
621
647
  };
648
+ // wheel/touch 提前下台:浏览器要等惯性产生位移才触发 scroll 事件,
649
+ // 这一帧空窗里如果有 streaming chunk 进来,会在 sticky=true 状态下
650
+ // 被强制贴底。监听用户开始上滚的瞬间立刻把 sticky 翻成 false,
651
+ // 避免那一帧的拽回。column-reverse 下 deltaY<0(滚轮上推)= 看历史。
652
+ state.chatScrollWheelHandler = function(e) {
653
+ if (state.chatIsProgrammaticScroll) return;
654
+ if (e.deltaY < 0) {
655
+ state.chatStickToBottom = false;
656
+ updateChatUnreadBubble();
657
+ }
658
+ };
659
+ state.chatScrollTouchStartHandler = function(e) {
660
+ if (!e.touches || e.touches.length === 0) return;
661
+ state.chatTouchStartY = e.touches[0].clientY;
662
+ };
663
+ state.chatScrollTouchMoveHandler = function(e) {
664
+ if (state.chatIsProgrammaticScroll) return;
665
+ if (!e.touches || e.touches.length === 0) return;
666
+ // column-reverse 下:手指向下拖(clientY 变大)= 内容向下走 = 看历史。
667
+ var deltaY = e.touches[0].clientY - state.chatTouchStartY;
668
+ if (deltaY > 4) {
669
+ state.chatStickToBottom = false;
670
+ updateChatUnreadBubble();
671
+ }
672
+ };
622
673
  chatMsgs.addEventListener("scroll", state.chatScrollHandler, { passive: true });
674
+ chatMsgs.addEventListener("wheel", state.chatScrollWheelHandler, { passive: true });
675
+ chatMsgs.addEventListener("touchstart", state.chatScrollTouchStartHandler, { passive: true });
676
+ chatMsgs.addEventListener("touchmove", state.chatScrollTouchMoveHandler, { passive: true });
623
677
  updateChatUnreadBubble();
624
678
  }
625
679
 
@@ -951,23 +1005,46 @@
951
1005
  });
952
1006
  }
953
1007
 
954
- function resetChatRenderCache() {
1008
+ // options.preserveStickState=true:仅清渲染缓存,不动 sticky/未读
1009
+ // 状态。用于 page-refresh、ws 重连等"用户停留在当前会话,只是想刷新
1010
+ // DOM"的场景——不能把用户从历史位置拽回底部。
1011
+ // 默认(false):切会话 / 新建 / home 等真正"换上下文"路径用,全清。
1012
+ function resetChatRenderCache(options) {
1013
+ var opts = options || {};
955
1014
  state.lastRenderedHash = 0;
956
1015
  state.lastRenderedMsgCount = 0;
957
1016
  state.lastRenderedEmpty = null;
958
1017
  state.renderPending = false;
959
1018
  state.chatRenderedCount = state.chatPageSize;
960
1019
  state.askUserSelections = {};
961
- if (state.chatScrollElement && state.chatScrollHandler) {
962
- state.chatScrollElement.removeEventListener("scroll", state.chatScrollHandler);
1020
+ if (state.chatScrollElement) {
1021
+ if (state.chatScrollHandler) {
1022
+ state.chatScrollElement.removeEventListener("scroll", state.chatScrollHandler);
1023
+ }
1024
+ if (state.chatScrollWheelHandler) {
1025
+ state.chatScrollElement.removeEventListener("wheel", state.chatScrollWheelHandler);
1026
+ }
1027
+ if (state.chatScrollTouchStartHandler) {
1028
+ state.chatScrollElement.removeEventListener("touchstart", state.chatScrollTouchStartHandler);
1029
+ }
1030
+ if (state.chatScrollTouchMoveHandler) {
1031
+ state.chatScrollElement.removeEventListener("touchmove", state.chatScrollTouchMoveHandler);
1032
+ }
963
1033
  }
964
1034
  state.chatScrollElement = null;
965
1035
  state.chatScrollHandler = null;
1036
+ state.chatScrollWheelHandler = null;
1037
+ state.chatScrollTouchStartHandler = null;
1038
+ state.chatScrollTouchMoveHandler = null;
966
1039
  state.chatIsProgrammaticScroll = false;
967
- // 切会话时未读状态归零、贴底重置——避免上一个会话残留的"未读气泡"。
968
- state.chatStickToBottom = true;
969
- state.chatUnreadCount = 0;
970
- state.chatUnreadStartIndex = -1;
1040
+ if (!opts.preserveStickState) {
1041
+ // 切会话时未读状态归零、贴底重置——避免上一个会话残留的"未读气泡"。
1042
+ state.chatStickToBottom = true;
1043
+ state.chatUnreadCount = 0;
1044
+ state.chatUnreadStartIndex = -1;
1045
+ // 真正换会话时才允许首帧贴底;preserve 路径下保留旧 initial 状态。
1046
+ state.chatInitialRenderDone = false;
1047
+ }
971
1048
  }
972
1049
 
973
1050
  function getEffectiveCwd() {
@@ -1464,7 +1541,6 @@
1464
1541
  ? '<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>'
1465
1542
  : '<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="14 6 8 12 14 18"/><line x1="4" y1="5" x2="4" y2="19"/></svg>') +
1466
1543
  '</button>' +
1467
- '<button id="sidebar-close-fully-btn" class="btn btn-ghost btn-sm sidebar-close-fully" type="button" title="完全关闭侧栏" aria-label="完全关闭侧栏"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg></button>' +
1468
1544
  '<button id="close-drawer-button" class="btn btn-ghost btn-icon sidebar-close drawer-close-btn" type="button" aria-label="关闭菜单"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg></button>' +
1469
1545
  '</div>' +
1470
1546
  '</div>' +
@@ -1654,13 +1730,26 @@
1654
1730
  // tabindex="-1": 把这些控件移出 iOS Safari 的表单导航链,
1655
1731
  // 这样 textarea 聚焦时键盘上方就不会出现 ⌃ ⌄ ✓ 表单辅助栏。
1656
1732
  '<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">' +
1657
- '<select id="chat-mode-select" class="chat-mode-select" tabindex="-1" title="仅对新建会话生效">' +
1658
- renderModeOptions(preferredTool, composerMode) +
1659
- '</select>' +
1660
- '<select id="chat-model-select" class="chat-mode-select chat-model-select" tabindex="-1" title="切换模型(对运行中会话发送 /model,对新会话作为 --model 启动)">' +
1661
- renderChatModelOptions(getEffectiveModel(selectedSession), selectedSession) +
1662
- '</select>' +
1663
- '<button id="terminal-interactive-toggle-top" class="composer-interactive-toggle' + (state.terminalInteractive ? " active" : "") + '" type="button" title="切换终端交互模式">⌨</button>' +
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
+ '</select>' +
1749
+ '</span>' +
1750
+ '</span>' +
1751
+ renderAutoApproveChip(selectedSession) +
1752
+ '<button id="terminal-interactive-toggle-top" class="composer-pill composer-pill-chip composer-interactive-toggle' + (state.terminalInteractive ? " active" : "") + '" type="button" title="切换终端交互模式">⌨</button>' +
1664
1753
  '<span class="permission-actions hidden" id="permission-actions">' +
1665
1754
  '<span class="permission-actions-divider"></span>' +
1666
1755
  '<span class="permission-actions-label" id="permission-actions-label">等待授权</span>' +
@@ -1670,26 +1759,41 @@
1670
1759
  renderApprovalStatsBadge() +
1671
1760
  '</div>' +
1672
1761
  '<div class="input-composer-right">' +
1673
- '<span id="queue-counter" class="queue-counter hidden">队列: 0</span>' +
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>' +
1674
1765
  '<span class="input-hint' + (state.terminalInteractive ? ' terminal-interactive-hint' : state.currentView === "terminal" ? " hidden" : "") + '">' + (state.terminalInteractive ? '终端交互中 · Ctrl+C 中断 · Ctrl+L 清屏' : 'Enter 发送 · Shift+Enter 换行') + '</span>' +
1675
1766
  renderInlineKeyboard() +
1676
1767
  '<button id="stop-button" class="btn-circle btn-circle-stop' + (state.selectedId ? "" : " hidden") + '" title="停止">' +
1677
1768
  '<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><rect x="3" y="3" width="10" height="10" rx="2"/></svg>' +
1678
1769
  '</button>' +
1770
+ // 结构化模式且正在出 token 时显示:中断当前回复、立刻发送新输入。
1771
+ // 默认走 #send-input-button → 排队;想插队的人显式按这颗。
1772
+ // 用 pill 形态 + 文字 + 脉动,让用户一眼就看到「立即发送」这条快捷路径。
1773
+ '<button id="interrupt-send-button" class="btn-pill btn-pill-interrupt hidden" type="button" title="中断当前回复并立即发送新输入(Cmd/Ctrl+Enter)" aria-label="立即发送">' +
1774
+ '<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>' +
1775
+ '<span class="btn-pill-label">立即</span>' +
1776
+ '</button>' +
1679
1777
  '<button id="send-input-button" class="btn-circle btn-circle-send" title="发送">' +
1680
1778
  '<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>' +
1681
1779
  '</button>' +
1682
1780
  '</div>' +
1683
1781
  '</div>' +
1684
1782
  renderExpandedShortcutsRow() +
1685
- // Session info bar at bottom — only keeps unique controls/info
1686
- // (cwd / mode / status / kind are already shown in topbar or composer dropdown)
1783
+ // Session info bar at bottom — 仅保留信息类徽章(Claude session id / exit code)。
1784
+ // 自动批准已从这里移到主 pill 行(renderAutoApproveChip)。
1687
1785
  (selectedSession
1688
- ? '<div class="input-session-info-bar">' +
1689
- (selectedSession.autoApprovePermissions ? '<span id="auto-approve-toggle" class="auto-approve-indicator active" title="自动批准已启用 — 点击关闭">🛡 自动批准</span>' : '<span id="auto-approve-toggle" class="auto-approve-indicator" title="自动批准已关闭 — 点击开启">🛡 手动</span>') +
1690
- (selectedSession.provider === "claude" && selectedSession.claudeSessionId ? '<span class="session-info-separator">|</span><span id="claude-session-id-badge" class="claude-session-id-badge" data-claude-id="' + escapeHtml(selectedSession.claudeSessionId) + '" title="点击复制 Claude 会话 ID">☁ ' + escapeHtml(selectedSession.claudeSessionId.slice(0, 8)) + '</span>' : '') +
1691
- (!isStructuredSession(selectedSession) && selectedSession.exitCode !== undefined && selectedSession.exitCode !== null ? '<span class="session-info-separator">|</span><span id="session-exit-display" class="session-exit-display">退出码=' + selectedSession.exitCode + '</span>' : '') +
1692
- '</div>'
1786
+ ? (function() {
1787
+ var bits = "";
1788
+ if (selectedSession.provider === "claude" && selectedSession.claudeSessionId) {
1789
+ bits += '<span id="claude-session-id-badge" class="claude-session-id-badge" data-claude-id="' + escapeHtml(selectedSession.claudeSessionId) + '" title="点击复制 Claude 会话 ID">☁ ' + escapeHtml(selectedSession.claudeSessionId.slice(0, 8)) + '</span>';
1790
+ }
1791
+ if (!isStructuredSession(selectedSession) && selectedSession.exitCode !== undefined && selectedSession.exitCode !== null) {
1792
+ if (bits) bits += '<span class="session-info-separator">|</span>';
1793
+ bits += '<span id="session-exit-display" class="session-exit-display">退出码=' + selectedSession.exitCode + '</span>';
1794
+ }
1795
+ return bits ? '<div class="input-session-info-bar">' + bits + '</div>' : '';
1796
+ })()
1693
1797
  : '') +
1694
1798
  '</div>' +
1695
1799
  '<p id="action-error" class="error-message hidden"></p>' +
@@ -3094,29 +3198,44 @@
3094
3198
  '</section>';
3095
3199
  }
3096
3200
 
3201
+ // 「最近」分组的统一数据源:未归档 active sessions + 24h 内 Claude 历史,
3202
+ // 一起按"创建时间"倒序排(session 用 startedAt,history 用 timestamp)。
3203
+ // 展开侧栏的「最近」和折叠侧栏的窄条都基于这份列表渲染,序号严格对应。
3204
+ function getRecentEntries() {
3205
+ var cutoff = Date.now() - 24 * 60 * 60 * 1000;
3206
+ var entries = [];
3207
+ state.sessions.forEach(function(s) {
3208
+ if (s.archived) return;
3209
+ var t = s.startedAt ? new Date(s.startedAt).getTime() : 0;
3210
+ entries.push({ kind: "session", ref: s, t: isFinite(t) ? t : 0 });
3211
+ });
3212
+ if (state.claudeHistoryLoaded) {
3213
+ getVisibleClaudeHistorySessions().forEach(function(h) {
3214
+ if (!h.timestamp) return;
3215
+ var t = new Date(h.timestamp).getTime();
3216
+ if (!isFinite(t) || t <= cutoff) return;
3217
+ entries.push({ kind: "history", ref: h, t: t });
3218
+ });
3219
+ }
3220
+ entries.sort(function(a, b) { return b.t - a.t; });
3221
+ return entries;
3222
+ }
3223
+
3097
3224
  function renderSessions() {
3098
- var activeSessions = state.sessions.filter(function(session) { return !session.archived; });
3099
3225
  var archivedSessions = state.sessions.filter(function(session) { return session.archived; });
3100
3226
  var groups = [];
3101
3227
  groups.push(renderSessionManageBar());
3102
3228
 
3103
- // Split claude history into recent (24h) and older
3104
- var recentHistorySessions = [];
3105
- if (state.claudeHistoryLoaded) {
3106
- var cutoff = Date.now() - 24 * 60 * 60 * 1000;
3107
- recentHistorySessions = getVisibleClaudeHistorySessions().filter(function(s) {
3108
- return s.timestamp && new Date(s.timestamp).getTime() > cutoff;
3109
- });
3110
- }
3229
+ var recentEntries = getRecentEntries();
3111
3230
 
3112
- if (activeSessions.length > 0 || recentHistorySessions.length > 0) {
3113
- groups.push(renderRecentGroup(activeSessions, recentHistorySessions));
3231
+ if (recentEntries.length > 0) {
3232
+ groups.push(renderRecentGroup(recentEntries));
3114
3233
  }
3115
3234
  if (archivedSessions.length > 0) {
3116
3235
  groups.push(renderArchivedGroup(archivedSessions));
3117
3236
  }
3118
3237
  groups.push(renderClaudeHistorySection());
3119
- if (activeSessions.length === 0 && archivedSessions.length === 0 && recentHistorySessions.length === 0) {
3238
+ if (recentEntries.length === 0 && archivedSessions.length === 0) {
3120
3239
  return renderSessionManageBar() + '<div class="empty-state"><strong>还没有会话记录</strong><br>点击上方「新对话」开始你的第一次对话。</div>' + renderClaudeHistorySection();
3121
3240
  }
3122
3241
  return groups.join("");
@@ -3127,36 +3246,23 @@
3127
3246
  }
3128
3247
 
3129
3248
  function renderCollapsedSessionTiles() {
3130
- var activeSessions = state.sessions.filter(function(s) { return !s.archived; });
3131
- activeSessions.sort(function(a, b) {
3132
- var ta = a.startedAt ? new Date(a.startedAt).getTime() : 0;
3133
- var tb = b.startedAt ? new Date(b.startedAt).getTime() : 0;
3134
- return ta - tb;
3135
- });
3136
- var recentHistorySessions = [];
3137
- if (state.claudeHistoryLoaded) {
3138
- var cutoff = Date.now() - 24 * 60 * 60 * 1000;
3139
- recentHistorySessions = getVisibleClaudeHistorySessions().filter(function(s) {
3140
- return s.timestamp && new Date(s.timestamp).getTime() > cutoff;
3141
- });
3142
- }
3143
- if (activeSessions.length === 0 && recentHistorySessions.length === 0) {
3249
+ var entries = getRecentEntries();
3250
+ if (entries.length === 0) {
3144
3251
  return '<div class="sidebar-collapsed-empty" title="无会话">—</div>';
3145
3252
  }
3146
- var tiles = "";
3147
- var idx = 0;
3148
- activeSessions.forEach(function(s) {
3149
- idx += 1;
3150
- var activeCls = s.id === state.selectedId ? " active" : "";
3151
- var title = s.summary || s.command || ("会话 " + idx);
3152
- tiles += '<button class="sidebar-collapsed-tile' + activeCls + '" type="button" data-collapsed-session-id="' + escapeHtml(s.id) + '" title="' + escapeHtml(title) + '">' + idx + '</button>';
3153
- });
3154
- recentHistorySessions.forEach(function(s) {
3155
- idx += 1;
3156
- var preview = s.firstUserMessage || "(空会话)";
3157
- var title = preview + " · " + formatHistoryTime(s.timestamp);
3158
- tiles += '<button class="sidebar-collapsed-tile history" type="button" data-collapsed-history-id="' + escapeHtml(s.claudeSessionId) + '" data-cwd="' + escapeHtml(s.cwd || "") + '" title="' + escapeHtml(title) + '">' + idx + '</button>';
3159
- });
3253
+ var tiles = entries.map(function(e, i) {
3254
+ var idx = i + 1;
3255
+ if (e.kind === "session") {
3256
+ var s = e.ref;
3257
+ var activeCls = s.id === state.selectedId ? " active" : "";
3258
+ var title = s.summary || s.command || ("会话 " + idx);
3259
+ return '<button class="sidebar-collapsed-tile' + activeCls + '" type="button" data-collapsed-session-id="' + escapeHtml(s.id) + '" title="' + escapeHtml(title) + '">' + idx + '</button>';
3260
+ }
3261
+ var h = e.ref;
3262
+ var preview = h.firstUserMessage || "(空会话)";
3263
+ var hTitle = preview + " · " + formatHistoryTime(h.timestamp);
3264
+ return '<button class="sidebar-collapsed-tile history" type="button" data-collapsed-history-id="' + escapeHtml(h.claudeSessionId) + '" data-cwd="' + escapeHtml(h.cwd || "") + '" title="' + escapeHtml(hTitle) + '">' + idx + '</button>';
3265
+ }).join("");
3160
3266
  return '<div class="sidebar-collapsed-tiles">' + tiles + '</div>';
3161
3267
  }
3162
3268
 
@@ -3223,11 +3329,14 @@
3223
3329
  return '<section class="session-group">' + header + items + '</section>';
3224
3330
  }
3225
3331
 
3226
- function renderRecentGroup(activeSessions, recentHistorySessions) {
3332
+ function renderRecentGroup(entries) {
3227
3333
  var html = '<section class="session-group">' +
3228
3334
  '<div class="session-group-title">最近</div>';
3229
- html += activeSessions.map(function(session) { return renderSessionItem(session, "sessions"); }).join("");
3230
- html += recentHistorySessions.map(function(session) { return renderClaudeHistoryItem(session, "history"); }).join("");
3335
+ html += entries.map(function(e) {
3336
+ return e.kind === "session"
3337
+ ? renderSessionItem(e.ref, "sessions")
3338
+ : renderClaudeHistoryItem(e.ref, "history");
3339
+ }).join("");
3231
3340
  html += '</section>';
3232
3341
  return html;
3233
3342
  }
@@ -5627,8 +5736,6 @@
5627
5736
  if (pinBtn) pinBtn.addEventListener("click", toggleSidebarPin);
5628
5737
  var collapseBtn = document.getElementById("sidebar-collapse-btn");
5629
5738
  if (collapseBtn) collapseBtn.addEventListener("click", toggleSidebarCollapsed);
5630
- var closeFullyBtn = document.getElementById("sidebar-close-fully-btn");
5631
- if (closeFullyBtn) closeFullyBtn.addEventListener("click", closeSidebarCompletely);
5632
5739
  var sidebarMoreBtn = document.getElementById("sidebar-more-btn");
5633
5740
  var sidebarOverflow = document.getElementById("sidebar-overflow-menu");
5634
5741
  if (sidebarMoreBtn && sidebarOverflow) {
@@ -5894,6 +6001,11 @@
5894
6001
  closeSessionsDrawer();
5895
6002
  sendOrStart();
5896
6003
  });
6004
+ var interruptSendBtn = document.getElementById("interrupt-send-button");
6005
+ if (interruptSendBtn) interruptSendBtn.addEventListener("click", function() {
6006
+ closeSessionsDrawer();
6007
+ sendOrStart({ interrupt: true });
6008
+ });
5897
6009
  var stopBtn = document.getElementById("stop-button");
5898
6010
  if (stopBtn) stopBtn.addEventListener("click", stopSession);
5899
6011
  var modeSelect = document.getElementById("chat-mode-select");
@@ -5905,6 +6017,10 @@
5905
6017
  if (modelSelect) modelSelect.addEventListener("change", function() {
5906
6018
  onChatModelChange(this.value);
5907
6019
  });
6020
+ var thinkingSelect = document.getElementById("chat-thinking-select");
6021
+ if (thinkingSelect) thinkingSelect.addEventListener("change", function() {
6022
+ onChatThinkingChange(this.value);
6023
+ });
5908
6024
 
5909
6025
  var sessionModal = document.getElementById("session-modal");
5910
6026
  if (sessionModal) sessionModal.addEventListener("click", function(e) {
@@ -6142,7 +6258,8 @@
6142
6258
  return;
6143
6259
  }
6144
6260
  softResyncTerminal();
6145
- resetChatRenderCache();
6261
+ // 用户停留在当前会话,只是想刷一下 DOM——保留其阅读位置和 sticky 状态。
6262
+ resetChatRenderCache({ preserveStickState: true });
6146
6263
  scheduleChatRender(true);
6147
6264
  });
6148
6265
  var jumpBottomBtn = document.getElementById("terminal-jump-bottom");
@@ -7909,6 +8026,11 @@
7909
8026
  if (canAutoResumeSession(session)) return "";
7910
8027
  return "会话已结束";
7911
8028
  }
8029
+ // 结构化会话在出 token 时,输入框仍然可用——告诉用户默认行为是排队,
8030
+ // 想插队请按右侧的 » 按钮。短语保持单行不换行。
8031
+ if (isStructuredSession(session) && session.structuredState && session.structuredState.inFlight) {
8032
+ return "回复中…Enter 排队 · 旁边的「» 立即」按钮中断并立即发送";
8033
+ }
7912
8034
  return "";
7913
8035
  }
7914
8036
 
@@ -8040,10 +8162,109 @@
8040
8162
 
8041
8163
  function syncComposerModelSelect(session) {
8042
8164
  var select = document.getElementById("chat-model-select");
8165
+ if (select) {
8166
+ var effective = getEffectiveModel(session);
8167
+ select.innerHTML = renderChatModelOptions(effective, session);
8168
+ select.value = effective;
8169
+ }
8170
+ // thinking 选择器与 model 选择器属于同一组"会话级设置",
8171
+ // 任何刷新 model 的时机也应该同步刷新 thinking,避免漂移。
8172
+ syncComposerThinkingSelect(session);
8173
+ }
8174
+
8175
+ // ── 思考深度 (thinkingEffort) —— 与 model 选择三件套对称 ──
8176
+
8177
+ // 标签直接用 Claude CLI 原生 magic word:think / think hard / ultrathink。
8178
+ // 这样用户一眼能对上官方文档里的思考强度档位,PTY 模式下也是这几个词被注入到 prompt 前缀。
8179
+ var THINKING_LEVELS = [
8180
+ { id: "off", label: "off", hint: "不启用思考(CLI 无前缀;SDK 关闭 thinking;Codex --reasoning-effort minimal)" },
8181
+ { id: "standard", label: "think", hint: "Claude CLI: think · SDK budget 4096 · Codex low" },
8182
+ { id: "deep", label: "think hard", hint: "Claude CLI: think hard · SDK budget 16000 · Codex medium" },
8183
+ { id: "max", label: "ultrathink", hint: "Claude CLI: ultrathink · SDK budget 31999 · Codex high" }
8184
+ ];
8185
+
8186
+ function getThinkingLabel(id) {
8187
+ for (var i = 0; i < THINKING_LEVELS.length; i++) {
8188
+ if (THINKING_LEVELS[i].id === id) return THINKING_LEVELS[i].label;
8189
+ }
8190
+ return THINKING_LEVELS[0].label;
8191
+ }
8192
+
8193
+ function getEffectiveThinking(session) {
8194
+ if (session && session.thinkingEffort) return session.thinkingEffort;
8195
+ if (state.chatThinking) return state.chatThinking;
8196
+ return "off";
8197
+ }
8198
+
8199
+ function renderChatThinkingOptions(selected) {
8200
+ var v = selected || "off";
8201
+ var html = "";
8202
+ for (var i = 0; i < THINKING_LEVELS.length; i++) {
8203
+ var lvl = THINKING_LEVELS[i];
8204
+ html += '<option value="' + escapeHtml(lvl.id) + '"' + (lvl.id === v ? ' selected' : '') + ' title="' + escapeHtml(lvl.hint) + '">' + escapeHtml(lvl.label) + '</option>';
8205
+ }
8206
+ return html;
8207
+ }
8208
+
8209
+ function syncComposerThinkingSelect(session) {
8210
+ var select = document.getElementById("chat-thinking-select");
8043
8211
  if (!select) return;
8044
- var effective = getEffectiveModel(session);
8045
- select.innerHTML = renderChatModelOptions(effective, session);
8212
+ var effective = getEffectiveThinking(session);
8213
+ select.innerHTML = renderChatThinkingOptions(effective);
8046
8214
  select.value = effective;
8215
+ var labelEl = document.getElementById("chat-thinking-label");
8216
+ if (labelEl) labelEl.textContent = getThinkingLabel(effective);
8217
+ }
8218
+
8219
+ function onChatThinkingChange(value) {
8220
+ var normalized = (value || "off").trim();
8221
+ if (normalized !== "off" && normalized !== "standard" && normalized !== "deep" && normalized !== "max") {
8222
+ normalized = "off";
8223
+ }
8224
+ state.chatThinking = normalized;
8225
+ try { localStorage.setItem("wand-thinking-effort", normalized); } catch (e) {}
8226
+ var labelEl = document.getElementById("chat-thinking-label");
8227
+ if (labelEl) labelEl.textContent = getThinkingLabel(normalized);
8228
+ var session = getSelectedSession();
8229
+ if (!session) return;
8230
+ fetch("/api/sessions/" + encodeURIComponent(session.id) + "/thinking-effort", {
8231
+ method: "POST",
8232
+ headers: { "Content-Type": "application/json" },
8233
+ credentials: "same-origin",
8234
+ body: JSON.stringify({ thinkingEffort: normalized })
8235
+ })
8236
+ .then(function(res) { return res.json(); })
8237
+ .then(function(data) {
8238
+ if (data && data.error) {
8239
+ showToast(data.error, "error");
8240
+ return;
8241
+ }
8242
+ if (data && data.id) {
8243
+ updateSessionSnapshot(data);
8244
+ if (typeof showToast === "function") {
8245
+ showToast("已切换思考深度 → " + getThinkingLabel(normalized), "success");
8246
+ }
8247
+ }
8248
+ })
8249
+ .catch(function() { showToast("切换思考深度失败", "error"); });
8250
+ }
8251
+
8252
+ // 自动批准 chip:与原 .auto-approve-indicator 等价,但用统一的 .composer-pill 风格放主行。
8253
+ // Codex 会话固定全权限不可切;结构化 Claude 会话后端 toggle-auto-approve 路由会拒绝。
8254
+ // 当会话已经处于 managed / full-access 模式时,"自动批准"语义已经由模式表达,
8255
+ // 重复显示一个独立 chip 只会占用空间又制造歧义 —— 此时直接折叠掉。
8256
+ function isAutoApproveImpliedByMode(session) {
8257
+ if (!session) return false;
8258
+ var m = session.mode;
8259
+ return m === "managed" || m === "full-access";
8260
+ }
8261
+ function renderAutoApproveChip(session) {
8262
+ if (!session) return "";
8263
+ if (isAutoApproveImpliedByMode(session)) return "";
8264
+ var enabled = !!session.autoApprovePermissions;
8265
+ return enabled
8266
+ ? '<span id="auto-approve-toggle" class="composer-pill composer-pill-chip auto-approve-indicator active" title="自动批准已启用 — 点击关闭">🛡 自动</span>'
8267
+ : '<span id="auto-approve-toggle" class="composer-pill composer-pill-chip auto-approve-indicator" title="自动批准已关闭 — 点击开启">🛡 手动</span>';
8047
8268
  }
8048
8269
 
8049
8270
  function fetchAvailableModels() {
@@ -8281,6 +8502,7 @@
8281
8502
  function createStructuredSession(prompt, cwdOverride, modeOverride, worktreeEnabled) {
8282
8503
  var provider = state.sessionTool === "codex" ? "codex" : "claude";
8283
8504
  var modelPref = state.chatModel || (state.config && state.config.defaultModel) || "";
8505
+ var thinkingPref = state.chatThinking || "off";
8284
8506
  var payload = {
8285
8507
  cwd: cwdOverride || getEffectiveCwd(),
8286
8508
  mode: modeOverride || state.chatMode || (state.config && state.config.defaultMode) || "default",
@@ -8288,7 +8510,8 @@
8288
8510
  runner: provider === "codex" ? "codex-cli-exec" : ((state.config && state.config.structuredRunner === "sdk") ? "claude-sdk" : (state.structuredRunner || "claude-cli-print")),
8289
8511
  prompt: prompt || undefined,
8290
8512
  worktreeEnabled: worktreeEnabled === true,
8291
- model: modelPref || undefined
8513
+ model: modelPref || undefined,
8514
+ thinkingEffort: thinkingPref
8292
8515
  };
8293
8516
  console.log("[WAND] createStructuredSession payload:", JSON.stringify(payload));
8294
8517
  return fetch("/api/structured-sessions", {
@@ -8322,6 +8545,7 @@
8322
8545
  var hasSession = !!state.selectedId;
8323
8546
  var terminalContainer = document.getElementById("output");
8324
8547
  var chatContainer = document.getElementById("chat-output");
8548
+ var blankChat = document.getElementById("blank-chat");
8325
8549
  var selectedSession = state.sessions.find(function(s) { return s.id === state.selectedId; });
8326
8550
  var structured = isStructuredSession(selectedSession);
8327
8551
  var showTerminal = hasSession && !structured && state.currentView === "terminal";
@@ -8340,6 +8564,14 @@
8340
8564
  chatContainer.classList.toggle("active", showChat);
8341
8565
  chatContainer.classList.toggle("hidden", !showChat);
8342
8566
  }
8567
+ // blank-chat 的可见性由 applyCurrentView 收口:updateShellChrome 在
8568
+ // selectedSession 缺失(启动期 selectedId 已恢复但 /api/sessions 未回 /
8569
+ // activateSession 在 updateSessionSnapshot 之前调 switchToSessionView 等
8570
+ // 瞬态)时会走 else 分支把 blank-chat 显示出来,但紧接着调到这里,应
8571
+ // 以 hasSession 为准重新隐藏,避免与 terminal/chat 同屏并存。
8572
+ if (blankChat) {
8573
+ blankChat.classList.toggle("hidden", hasSession);
8574
+ }
8343
8575
  if (chatContainer && showChat) {
8344
8576
  ensureChatMessagesContainer(chatContainer);
8345
8577
  }
@@ -8604,7 +8836,8 @@
8604
8836
  } else if (state.selectedId) {
8605
8837
  var sel = state.sessions.find(function(s) { return s.id === state.selectedId; });
8606
8838
  if (isStructuredSession(sel)) {
8607
- resetChatRenderCache();
8839
+ // ws 重连后的同会话刷新——保留用户阅读位置。
8840
+ resetChatRenderCache({ preserveStickState: true });
8608
8841
  scheduleChatRender(true);
8609
8842
  }
8610
8843
  }
@@ -8974,29 +9207,6 @@
8974
9207
  hideCollapsedTileBubble();
8975
9208
  }
8976
9209
 
8977
- function closeSidebarCompletely() {
8978
- if (isMobileLayout()) return;
8979
- state.sidebarPinned = false;
8980
- state.sidebarCollapsed = false;
8981
- state.sessionsDrawerOpen = false;
8982
- try {
8983
- localStorage.setItem("wand-sidebar-pinned", "false");
8984
- localStorage.setItem("wand-sidebar-collapsed", "false");
8985
- } catch (e) {}
8986
- render();
8987
- var mainLayout = document.querySelector(".main-layout");
8988
- if (mainLayout) {
8989
- var onEnd = function(e) {
8990
- if (e.propertyName === "padding-left") {
8991
- mainLayout.removeEventListener("transitionend", onEnd);
8992
- scheduleTerminalResize(true);
8993
- }
8994
- };
8995
- mainLayout.addEventListener("transitionend", onEnd);
8996
- }
8997
- setTimeout(function() { scheduleTerminalResize(true); }, 350);
8998
- }
8999
-
9000
9210
  function toggleSidebarCollapsed() {
9001
9211
  if (isMobileLayout()) return;
9002
9212
  // 在 drawer 模式(未 pin)下点 collapse 视为「先固定、再收起为窄条」——
@@ -10931,7 +11141,10 @@
10931
11141
  return;
10932
11142
  }
10933
11143
  event.preventDefault();
10934
- sendInputFromBox();
11144
+ // Cmd/Ctrl+Enter → 立即发送(中断当前回复)。仅对正在 inFlight 的
11145
+ // 结构化会话生效;其它情况下退化为普通发送,避免无谓的中断信号。
11146
+ var interruptShortcut = !!(event.metaKey || event.ctrlKey);
11147
+ sendInputFromBox(interruptShortcut ? { interrupt: true } : undefined);
10935
11148
  return;
10936
11149
  }
10937
11150
 
@@ -11640,7 +11853,8 @@
11640
11853
  });
11641
11854
  }
11642
11855
 
11643
- function sendOrStart() {
11856
+ function sendOrStart(opts) {
11857
+ opts = opts || {};
11644
11858
  // Support welcome input as well as the main input box
11645
11859
  var welcomeInput = document.getElementById("welcome-input");
11646
11860
  var inputBox = document.getElementById("input-box");
@@ -11651,7 +11865,7 @@
11651
11865
  // If we have a selected ID, try to send input to it
11652
11866
  if (state.selectedId) {
11653
11867
  if (value) {
11654
- sendInputFromBox();
11868
+ sendInputFromBox(opts);
11655
11869
  }
11656
11870
  return;
11657
11871
  }
@@ -11750,7 +11964,9 @@
11750
11964
  }
11751
11965
 
11752
11966
 
11753
- function sendInputFromBox() {
11967
+ function sendInputFromBox(opts) {
11968
+ opts = opts || {};
11969
+ var interruptFlag = !!opts.interrupt;
11754
11970
  if (state.terminalInteractive) {
11755
11971
  showToast("终端交互模式开启时,请直接在终端中输入。", "info");
11756
11972
  return Promise.resolve();
@@ -11789,7 +12005,7 @@
11789
12005
  if (todoEl) todoEl.classList.add("hidden");
11790
12006
 
11791
12007
  if (isStructuredSession(selectedSession)) {
11792
- return postStructuredInput(finalValue, inputBox, selectedSession);
12008
+ return postStructuredInput(finalValue, inputBox, selectedSession, { interrupt: interruptFlag });
11793
12009
  }
11794
12010
 
11795
12011
  var submitChunks = getTerminalSubmitChunks(selectedSession, finalValue);
@@ -11834,41 +12050,87 @@
11834
12050
  return Promise.resolve();
11835
12051
  }
11836
12052
 
11837
- // 防止同一会话并发提交(快速双击 / 重复触发)
11838
- var _structuredSubmittingSessions = {};
12053
+ // 防止同一会话「快速双击 / 重复触发」。原来这是个布尔 flag,绑在 fetch 的
12054
+ // promise —— 但 structured-sessions/:id/messages 的 POST 对首条消息会 await
12055
+ // 整段流式 streaming,flag 会被卡到回复完才释放。结果:用户点发送 → 服务端
12056
+ // 流式 30s 不响应 → 这 30s 里再点发送全被这里静默 drop,看起来"排队 / 立即发送
12057
+ // 都没效果"。改成时间戳 + 短窗口(350ms)只挡真正的连击。idempotencyKey 已经
12058
+ // 在后端兜底防 webview 网络层重发,这里的 hot-path 守门只需要应付 UI 双触发。
12059
+ var _structuredLastSubmitAt = {};
12060
+ var DUPLICATE_SUBMIT_WINDOW_MS = 350;
11839
12061
 
11840
- function postStructuredInput(input, inputBox, session) {
11841
- console.log("[WAND] postStructuredInput selectedId:", state.selectedId, "input:", input && input.substring(0, 50), "session:", session && { id: session.id, sessionKind: session.sessionKind, runner: session.runner, status: session.status, inFlight: session.structuredState && session.structuredState.inFlight });
12062
+ function postStructuredInput(input, inputBox, session, opts) {
12063
+ opts = opts || {};
12064
+ // 用户显式点击"立即发送"才会传 interrupt:true。普通 Enter / 点发送
12065
+ // 在上一条还在流式时默认走 queue —— 后端 sendMessage(...) 会把它
12066
+ // 追加到 queuedMessages,等当前 turn 结束自动 flush。
12067
+ var requestedInterrupt = !!opts.interrupt;
12068
+ 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 });
11842
12069
  if (!state.selectedId || !input) return Promise.resolve();
11843
12070
  if (!session) {
11844
12071
  showToast("会话不存在,请重新选择或新建会话。", "error");
11845
12072
  return Promise.resolve();
11846
12073
  }
11847
- // 同一会话的上一次提交尚未落地,直接忽略防止重复发送
11848
- if (_structuredSubmittingSessions[session.id]) {
11849
- console.log("[wand] postStructuredInput: duplicate submit ignored for session", session.id);
12074
+ // 短窗口内的连击当作重复点击丢掉;正常间隔的两次提交(哪怕第一次还在流式)
12075
+ // 都放行,让 queue / interrupt 真正生效。
12076
+ var nowTs = Date.now();
12077
+ var lastTs = _structuredLastSubmitAt[session.id] || 0;
12078
+ if (nowTs - lastTs < DUPLICATE_SUBMIT_WINDOW_MS) {
12079
+ console.log("[wand] postStructuredInput: duplicate submit (within " + DUPLICATE_SUBMIT_WINDOW_MS + "ms) ignored for session", session.id);
11850
12080
  return Promise.resolve();
11851
12081
  }
12082
+ _structuredLastSubmitAt[session.id] = nowTs;
12083
+
12084
+ var sessionInFlight = !!(session.structuredState && session.structuredState.inFlight && session.status === "running");
12085
+ var isInterrupting = sessionInFlight && requestedInterrupt;
12086
+ var isQueueing = sessionInFlight && !requestedInterrupt;
11852
12087
 
11853
- var isInterrupting = !!(session.structuredState && session.structuredState.inFlight && session.status === "running");
11854
- // Immediately render user message with thinking indicator
11855
- var userTurn = { role: "user", content: [{ type: "text", text: input }] };
11856
12088
  var userMsgs = stripRenderOnlyStructuredMessages(Array.isArray(session.messages) ? session.messages.slice() : []);
11857
- userMsgs.push(userTurn);
11858
- var optimisticStructuredState = Object.assign({}, session.structuredState || {}, { inFlight: true });
11859
- updateSessionSnapshot({
11860
- id: session.id,
11861
- status: "running",
11862
- messages: userMsgs,
11863
- structuredState: optimisticStructuredState,
11864
- });
11865
- state.currentMessages = buildMessagesForRender(Object.assign({}, session, {
11866
- status: "running",
11867
- messages: userMsgs,
11868
- structuredState: optimisticStructuredState,
11869
- }), userMsgs);
11870
- updateInputHint("思考中…");
11871
- renderChat(true);
12089
+ var optimisticPatch;
12090
+
12091
+ if (isQueueing) {
12092
+ // Queue 模式:不要乐观 push user turn —— buildMessagesForRender 会把
12093
+ // queuedMessages 渲成 __queued 占位(带"排队中"徽章),再 push 一份
12094
+ // 真 user turn 会被去重逻辑遮蔽掉,徽章就丢了。inFlight / status 维持。
12095
+ var nextQueue = Array.isArray(session.queuedMessages) ? session.queuedMessages.slice() : [];
12096
+ nextQueue.push(input);
12097
+ optimisticPatch = {
12098
+ id: session.id,
12099
+ queuedMessages: nextQueue,
12100
+ };
12101
+ updateSessionSnapshot(optimisticPatch);
12102
+ var queueRefreshed = state.sessions.find(function(s) { return s.id === session.id; }) || session;
12103
+ state.currentMessages = buildMessagesForRender(queueRefreshed, getPreferredMessages(queueRefreshed, queueRefreshed.output, false));
12104
+ updateInputHint("已加入排队,等待当前回复完成…");
12105
+ renderChat(true);
12106
+ updateStructuredQueueCounter();
12107
+ // 乐观 toast:原本只在 POST 完成后才提示,Claude 流式拖太久时用户根本
12108
+ // 看不到反馈,会误判"点了没反应"。点击瞬间就给一条短提示。
12109
+ showToast(nextQueue.length > 1 ? ("已加入排队(共 " + nextQueue.length + " 条等待)") : "已加入排队,等当前回复完成会自动发送。", "info");
12110
+ } else {
12111
+ // 普通发送 / interrupt 发送:照旧乐观推 user turn + inFlight=true
12112
+ var userTurn = { role: "user", content: [{ type: "text", text: input }] };
12113
+ userMsgs.push(userTurn);
12114
+ var optimisticStructuredState = Object.assign({}, session.structuredState || {}, { inFlight: true });
12115
+ updateSessionSnapshot({
12116
+ id: session.id,
12117
+ status: "running",
12118
+ messages: userMsgs,
12119
+ structuredState: optimisticStructuredState,
12120
+ });
12121
+ state.currentMessages = buildMessagesForRender(Object.assign({}, session, {
12122
+ status: "running",
12123
+ messages: userMsgs,
12124
+ structuredState: optimisticStructuredState,
12125
+ }), userMsgs);
12126
+ updateInputHint(isInterrupting ? "已中断,正在处理新消息…" : "思考中…");
12127
+ renderChat(true);
12128
+ // 中断模式:乐观给一条提示,让用户立刻知道"中断成功了",否则跟 queue 一样会
12129
+ // 觉得"点了没反应"。原 toast 在 then() 里,等 SIGTERM/HTTP roundtrip 完才出。
12130
+ if (isInterrupting) {
12131
+ showToast("已中断上一条回复,正在处理新消息…", "info");
12132
+ }
12133
+ }
11872
12134
 
11873
12135
  if (inputBox) {
11874
12136
  inputBox.value = "";
@@ -11891,7 +12153,6 @@
11891
12153
 
11892
12154
  // 用 session.id(参数绑定,in-flight 期间不变)而不是 state.selectedId
11893
12155
  // 拼 URL,避免用户切到别的会话后 fetch 落到错误 sessionId。
11894
- _structuredSubmittingSessions[session.id] = true;
11895
12156
  return fetch("/api/structured-sessions/" + session.id + "/messages", {
11896
12157
  method: "POST",
11897
12158
  headers: { "Content-Type": "application/json" },
@@ -11910,7 +12171,6 @@
11910
12171
  return res.json();
11911
12172
  })
11912
12173
  .then(function(snapshot) {
11913
- _structuredSubmittingSessions[session.id] = false;
11914
12174
  if (snapshot && snapshot.error) {
11915
12175
  throw new Error(snapshot.error);
11916
12176
  }
@@ -11924,15 +12184,13 @@
11924
12184
  var refreshedSession = state.sessions.find(function(s) { return s.id === snapshot.id; }) || snapshot;
11925
12185
  state.currentMessages = buildMessagesForRender(refreshedSession, getPreferredMessages(refreshedSession, snapshot.output, false));
11926
12186
  renderChat(true);
11927
- if (isInterrupting) {
11928
- showToast("已中断上一条回复,正在处理新消息…", "info");
11929
- }
12187
+ updateStructuredQueueCounter();
12188
+ // toast 已在 click 时乐观 fire(见 isQueueing / isInterrupting 分支),
12189
+ // 这里不再重复推送,避免同一动作出两条一样的 toast。
11930
12190
  }
11931
12191
  }
11932
12192
  })
11933
12193
  .catch(function(error) {
11934
- _structuredSubmittingSessions[session.id] = false;
11935
-
11936
12194
  // duplicate_idempotency_key:服务端识别出 WebView 底层重发的副本,
11937
12195
  // 直接拦截不处理。这里**不**回滚乐观更新——第一次的请求实际上已经
11938
12196
  // 被服务端接收并处理(或正在处理),ws 推送会带回真实状态;如果在
@@ -11943,20 +12201,36 @@
11943
12201
  return;
11944
12202
  }
11945
12203
 
11946
- // 回滚乐观更新:恢复发送前的 messages(去掉刚加的 userTurn)和 inFlight 状态
11947
- var rollbackMsgs = userMsgs.slice(0, -1);
11948
- updateSessionSnapshot({
11949
- id: session.id,
11950
- status: session.status,
11951
- messages: rollbackMsgs,
11952
- structuredState: Object.assign({}, session.structuredState || {}, { inFlight: false }),
11953
- });
11954
- if (session.id === state.selectedId) {
11955
- state.currentMessages = buildMessagesForRender(
11956
- Object.assign({}, session, { messages: rollbackMsgs, structuredState: Object.assign({}, session.structuredState || {}, { inFlight: false }) }),
11957
- rollbackMsgs
11958
- );
11959
- renderChat(true);
12204
+ if (isQueueing) {
12205
+ // Queue 模式回滚:把刚 push 的那条 queuedMessages 撤掉。inFlight / messages
12206
+ // 都没动过,不必复位,否则会把后端真实的 inFlight=true 误改成 false。
12207
+ var prevQueue = Array.isArray(session.queuedMessages) ? session.queuedMessages.slice() : [];
12208
+ updateSessionSnapshot({
12209
+ id: session.id,
12210
+ queuedMessages: prevQueue,
12211
+ });
12212
+ if (session.id === state.selectedId) {
12213
+ var rolledQueueSession = state.sessions.find(function(s) { return s.id === session.id; }) || session;
12214
+ state.currentMessages = buildMessagesForRender(rolledQueueSession, getPreferredMessages(rolledQueueSession, rolledQueueSession.output, false));
12215
+ renderChat(true);
12216
+ updateStructuredQueueCounter();
12217
+ }
12218
+ } else {
12219
+ // 回滚乐观更新:恢复发送前的 messages(去掉刚加的 userTurn)和 inFlight 状态
12220
+ var rollbackMsgs = userMsgs.slice(0, -1);
12221
+ updateSessionSnapshot({
12222
+ id: session.id,
12223
+ status: session.status,
12224
+ messages: rollbackMsgs,
12225
+ structuredState: Object.assign({}, session.structuredState || {}, { inFlight: false }),
12226
+ });
12227
+ if (session.id === state.selectedId) {
12228
+ state.currentMessages = buildMessagesForRender(
12229
+ Object.assign({}, session, { messages: rollbackMsgs, structuredState: Object.assign({}, session.structuredState || {}, { inFlight: false }) }),
12230
+ rollbackMsgs
12231
+ );
12232
+ renderChat(true);
12233
+ }
11960
12234
  }
11961
12235
  var message = (error && error.message) || "";
11962
12236
  var isTransientAbort =
@@ -11980,7 +12254,15 @@
11980
12254
  var counter = document.getElementById("queue-counter");
11981
12255
  var count = getSelectedStructuredQueuedInputs().length;
11982
12256
  if (counter) {
11983
- counter.textContent = "队列: " + count;
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;
12265
+ }
11984
12266
  if (count > 0) {
11985
12267
  counter.classList.remove("hidden");
11986
12268
  } else {
@@ -12564,11 +12846,19 @@
12564
12846
  composer.classList.toggle("is-terminal-passthrough", !!state.terminalInteractive);
12565
12847
  }
12566
12848
  var sendBtn = document.getElementById("send-input-button");
12849
+ var structuredInFlight = structured && isRunning;
12567
12850
  if (sendBtn) {
12568
12851
  sendBtn.disabled = !structured && !!selectedSession && !isRunning && !canResumeOnSend;
12569
12852
  sendBtn.setAttribute("title", structured
12570
- ? "发送"
12853
+ ? (structuredInFlight ? "排队发送(当前回复结束后处理)" : "发送")
12571
12854
  : (isCodex ? (isRunning ? "发送给 Codex" : "Codex 会话已结束") : (!selectedSession || isRunning || canResumeOnSend ? "发送" : "会话已结束")));
12855
+ sendBtn.classList.toggle("queue-mode", structuredInFlight);
12856
+ }
12857
+ var interruptBtn = document.getElementById("interrupt-send-button");
12858
+ if (interruptBtn) {
12859
+ // 仅结构化 + inFlight 时显示。pty 会话有自己的 Ctrl+C / stop 按钮,
12860
+ // 用不上这套语义。
12861
+ interruptBtn.classList.toggle("hidden", !structuredInFlight);
12572
12862
  }
12573
12863
  var container = document.getElementById("output");
12574
12864
  if (container) container.classList.toggle("interactive", !structured && state.terminalInteractive);
@@ -12949,7 +13239,8 @@
12949
13239
  command: command,
12950
13240
  cwd: cwd || "",
12951
13241
  mode: state.chatMode || state.config.defaultMode || "default",
12952
- model: modelPref || undefined
13242
+ model: modelPref || undefined,
13243
+ thinkingEffort: state.chatThinking || undefined
12953
13244
  }))
12954
13245
  })
12955
13246
  .then(function(res) { return res.json(); })
@@ -13091,7 +13382,8 @@
13091
13382
  cwd: defaultCwd,
13092
13383
  mode: mode,
13093
13384
  initialInput: value,
13094
- model: modelPref || undefined
13385
+ model: modelPref || undefined,
13386
+ thinkingEffort: state.chatThinking || undefined
13095
13387
  }))
13096
13388
  })
13097
13389
  .then(function(res) { return res.json(); })
@@ -13129,7 +13421,8 @@
13129
13421
  cwd: defaultCwd,
13130
13422
  mode: mode,
13131
13423
  initialInput: value || undefined,
13132
- model: modelPref || undefined
13424
+ model: modelPref || undefined,
13425
+ thinkingEffort: state.chatThinking || undefined
13133
13426
  }))
13134
13427
  })
13135
13428
  .then(function(res) { return res.json(); })
@@ -13287,7 +13580,10 @@
13287
13580
  // fill the expanded space, and the scroll position needs resetting.
13288
13581
  if (isTouchDevice()) {
13289
13582
  ensureTerminalFit("keyboard-blur", { forceReplay: true });
13290
- maybeScrollTerminalToBottom("force");
13583
+ // "keyboard" 而非 "force":用户原本在终端历史里翻看时,键盘
13584
+ // 收起不该把视图拽走。maybeScrollTerminalToBottom 会按
13585
+ // terminalAutoFollow 决定——贴底者继续贴底,离底者保位。
13586
+ maybeScrollTerminalToBottom("keyboard");
13291
13587
  }
13292
13588
  }, 100);
13293
13589
  }
@@ -14042,7 +14338,27 @@
14042
14338
  // Without an immediate refit, any chunk arriving while the keyboard
14043
14339
  // animates in renders against the old grid and tears the screen.
14044
14340
  if (!keyboardOpen && isKeyboardOpen) {
14341
+ // Snapshot bottom-pinned intent BEFORE the layout starts shifting.
14342
+ // visualViewport.resize fires while the keyboard is still mid-
14343
+ // animation on iOS; if we wait until ensureTerminalFit's rAF
14344
+ // body executes, clientHeight has already shrunk and the user
14345
+ // who was visibly at the bottom registers as "scrolled up".
14346
+ // We pass the snapshot through to a delayed catch-up so the
14347
+ // final scroll lands AFTER the animation settles.
14348
+ var wasStickToBottom = state.terminalAutoFollow || isTerminalNearBottom();
14045
14349
  ensureTerminalFit("keyboard-open", { forceReplay: true });
14350
+ // Mirror the keyboard-close 200ms delay: by then the iOS / Android
14351
+ // keyboard slide-in animation is done, vv.height is final, and
14352
+ // scrollHeight reflects the post-replay grid. One more force
14353
+ // scroll closes the gap between "we scrolled during animation
14354
+ // when scrollHeight was still in flux" and "user expects to see
14355
+ // the bottom now that the keyboard has fully settled".
14356
+ if (wasStickToBottom) {
14357
+ setTimeout(function() {
14358
+ if (!state.terminal) return;
14359
+ maybeScrollTerminalToBottom("force");
14360
+ }, 220);
14361
+ }
14046
14362
  }
14047
14363
 
14048
14364
  // Keyboard just closed — force terminal refit and scroll to bottom
@@ -14081,7 +14397,9 @@
14081
14397
  syncAppViewportHeight();
14082
14398
  }
14083
14399
  ensureTerminalFit("keyboard-close", { forceReplay: true });
14084
- maybeScrollTerminalToBottom("force");
14400
+ // 同 handleInputBoxBlur:尊重 terminalAutoFollow,避免把上滚
14401
+ // 阅读历史的用户在键盘关闭瞬间拽回底部。
14402
+ maybeScrollTerminalToBottom("keyboard");
14085
14403
  }, 200);
14086
14404
  }
14087
14405
 
@@ -14409,6 +14727,21 @@
14409
14727
  ensureTerminalFitWithRetry(reason || "fit-retry", { forceReplay: forceReplay });
14410
14728
  return false;
14411
14729
  }
14730
+ // Snapshot stick-to-bottom intent NOW, before any layout work.
14731
+ // Two concrete bugs this guards against:
14732
+ // 1. Mobile keyboard opens → visualViewport shrinks → terminal
14733
+ // clientHeight drops while scrollTop stays put → by the time
14734
+ // the rAF body runs, isTerminalNearBottom() reads false even
14735
+ // though the user was visibly pinned to the bottom a frame ago.
14736
+ // 2. Any softResyncTerminal triggered below does resetTerminal()
14737
+ // (scrollTop snaps to 0) then re-writes; the wterm element
14738
+ // can fire intermediate scroll events that flip
14739
+ // terminalAutoFollow to false before we get a chance to
14740
+ // scroll back.
14741
+ // Both failure modes left users mid-buffer after a resize. We
14742
+ // capture the intent up front and use "force" below to bypass
14743
+ // the (now-poisoned) flag check inside maybeScrollTerminalToBottom.
14744
+ var shouldStickToBottom = state.terminalAutoFollow || isTerminalNearBottom();
14412
14745
  var prevCols = state.terminal.cols;
14413
14746
  var prevRows = state.terminal.rows;
14414
14747
  requestAnimationFrame(function() {
@@ -14428,8 +14761,8 @@
14428
14761
  if (!didResize && forceReplay && state.terminalOutput) {
14429
14762
  softResyncTerminal({ skipFit: true });
14430
14763
  }
14431
- if (state.terminalAutoFollow || isTerminalNearBottom()) {
14432
- maybeScrollTerminalToBottom("resize");
14764
+ if (shouldStickToBottom) {
14765
+ maybeScrollTerminalToBottom("force");
14433
14766
  }
14434
14767
  });
14435
14768
  });
@@ -14486,9 +14819,15 @@
14486
14819
 
14487
14820
  function syncTerminalSize() {
14488
14821
  if (!state.terminal) return;
14489
- var shouldFollow = state.terminalAutoFollow || isTerminalNearBottom();
14490
- if (shouldFollow) {
14491
- maybeScrollTerminalToBottom("resize");
14822
+ // Force-scroll (vs the weaker maybeScrollTerminalToBottom("resize"))
14823
+ // for the same reason ensureTerminalFit does: between this entry
14824
+ // and the actual scroll, the wterm DOM may fire scroll events that
14825
+ // poison terminalAutoFollow. Capturing intent now + force-scrolling
14826
+ // keeps a user who was visibly at the bottom pinned there across
14827
+ // window/orientation/viewport resizes.
14828
+ var shouldStickToBottom = state.terminalAutoFollow || isTerminalNearBottom();
14829
+ if (shouldStickToBottom) {
14830
+ maybeScrollTerminalToBottom("force");
14492
14831
  }
14493
14832
  sendTerminalResize(state.terminal.cols, state.terminal.rows);
14494
14833
  }
@@ -15284,21 +15623,22 @@
15284
15623
 
15285
15624
  function updateAutoApproveIndicator() {
15286
15625
  var toggle = document.getElementById("auto-approve-toggle");
15287
- if (!toggle) return;
15288
15626
  var selectedSession = state.sessions.find(function(s) { return s.id === state.selectedId; });
15289
- if (selectedSession && selectedSession.provider === "codex") {
15290
- toggle.className = "auto-approve-indicator active";
15291
- toggle.title = "Codex 固定以 full-access PTY 启动,不支持切换自动批准";
15292
- toggle.textContent = "🛡 Codex 固定全权限";
15627
+ // 当模式(managed / full-access)已隐含自动批准,chip 应该不存在;如果上一次渲染留下来了,
15628
+ // 在这里清理掉,避免视觉上还有冗余 chip。
15629
+ if (isAutoApproveImpliedByMode(selectedSession)) {
15630
+ if (toggle && toggle.parentNode) toggle.parentNode.removeChild(toggle);
15293
15631
  return;
15294
15632
  }
15633
+ if (!toggle) return;
15634
+ var base = "composer-pill composer-pill-chip auto-approve-indicator";
15295
15635
  var enabled = selectedSession && selectedSession.autoApprovePermissions;
15296
15636
  if (enabled) {
15297
- toggle.className = "auto-approve-indicator active";
15637
+ toggle.className = base + " active";
15298
15638
  toggle.title = "自动批准已启用 — 点击关闭";
15299
- toggle.textContent = "🛡 自动批准";
15639
+ toggle.textContent = "🛡 自动";
15300
15640
  } else {
15301
- toggle.className = "auto-approve-indicator";
15641
+ toggle.className = base;
15302
15642
  toggle.title = "自动批准已关闭 — 点击开启";
15303
15643
  toggle.textContent = "🛡 手动";
15304
15644
  }
@@ -15497,6 +15837,9 @@
15497
15837
  }
15498
15838
 
15499
15839
  var allMessages = state.currentMessages;
15840
+ // 预扫一遍全量 messages,构建 task id → subagent meta 的 map,
15841
+ // 供老消息 tool_result(没有 __subagent 盖章)按 tool_use_id 反查兜底。
15842
+ var legacyTaskMap = collectLegacyTaskIdMap(allMessages);
15500
15843
 
15501
15844
  if (allMessages.length === 0) {
15502
15845
  if (state.lastRenderedEmpty !== "empty") {
@@ -15573,10 +15916,11 @@
15573
15916
 
15574
15917
  // 在动 DOM 之前先看用户是不是贴在底部——这决定后面我们要不要让视图
15575
15918
  // "继续粘在底部"。column-reverse 下 scrollTop 接近 0 = 视觉底部。
15576
- // 同时把 state.chatStickToBottom 同步到当前真实状态,避免长时间不滚动后
15577
- // 的状态漂移(比如新会话 init 的瞬间)。
15919
+ // 注意:state.chatStickToBottom 的维护**完全交给 scroll handler**
15920
+ // (bindChatScrollListener + wheel/touch 提前下台),这里不再做
15921
+ // "近底即锁回 true"的自愈,避免 resize / 键盘动画 / 锚点回填瞬间
15922
+ // 把已经上滚阅读的用户误判回贴底状态。
15578
15923
  var renderWasAtBottom = isChatNearBottom(chatMessages);
15579
- if (renderWasAtBottom) state.chatStickToBottom = true;
15580
15924
 
15581
15925
  // 把 .system-info 卡片从计数里剔除——它由 extractPtySystemInfo 在
15582
15926
  // fullRenderChat 里穿插注入,不存在于 messages 数组中,混进 existingCount
@@ -15620,7 +15964,7 @@
15620
15964
  }
15621
15965
 
15622
15966
  // Render message
15623
- html += renderChatMessage(msg, roundUsageByIndex[originalIndex] || null, originalIndex);
15967
+ html += renderChatMessage(msg, roundUsageByIndex[originalIndex] || null, originalIndex, legacyTaskMap);
15624
15968
  }
15625
15969
 
15626
15970
  // Add sentinel for loading older messages (DOM end = visual top in column-reverse)
@@ -15634,9 +15978,12 @@
15634
15978
  // 的 data-msg-index 和它到容器顶部的偏移。重写完成后找到同一 data-msg-index
15635
15979
  // 的新节点,把它放回原来的偏移——这是 column-reverse 下保住用户视线的
15636
15980
  // 标准锚点法。没有锚点时(首次渲染、空 → 非空)才走 scrollTop=0 兜底。
15981
+ // 改用 existingCount 而非 prevMsgCount:page-refresh 等 preserveStickState
15982
+ // 路径下 prevMsgCount 被重置为 0,但 DOM 里仍有节点可作锚点,必须保住
15983
+ // 用户的阅读位置。
15637
15984
  var anchorMsgIndex = -1;
15638
15985
  var anchorOffset = 0;
15639
- if (prevMsgCount > 0 && !renderWasAtBottom) {
15986
+ if (existingCount > 0 && !renderWasAtBottom) {
15640
15987
  var containerTop = chatMessages.getBoundingClientRect().top;
15641
15988
  var preEls = chatMessages.querySelectorAll(".chat-message:not(.system-info)");
15642
15989
  for (var pi = 0; pi < preEls.length; pi++) {
@@ -15666,13 +16013,19 @@
15666
16013
  })();
15667
16014
  // 会话切换 / 首次渲染后,浏览器会把旧的 scrollTop 钳制到新内容
15668
16015
  // 的最大值——column-reverse 下这意味着视觉上跳到最上面(最旧消息),
15669
- // 也就是用户反馈的"退出再回来时被重定向到最上面"。这里在
15670
- // prevMsgCount === 0(resetChatRenderCache 后或刚从空状态进入)
15671
- // 强制 scrollTop=0(视觉底部 = 最新消息),避免错位。
15672
- if (prevMsgCount === 0) {
16016
+ // 也就是用户反馈的"退出再回来时被重定向到最上面"
16017
+ // 关键:只在该会话视图的**首次**渲染(chatInitialRenderDone=false)
16018
+ // 才执行这个强制贴底;之后即便 prevMsgCount===0(page-refresh /
16019
+ // ws 重连等保留 sticky 的 reset 路径),也尊重 chatStickToBottom,
16020
+ // 不再把上滚的用户拽回去。
16021
+ if (prevMsgCount === 0 && !state.chatInitialRenderDone) {
15673
16022
  chatMessages.scrollTop = 0;
15674
16023
  state.chatStickToBottom = true;
15675
16024
  clearChatUnread({ removeDivider: true });
16025
+ state.chatInitialRenderDone = true;
16026
+ } else if (prevMsgCount === 0 && state.chatStickToBottom) {
16027
+ // 非首次但缓存重置后的 re-render——仅在用户原本贴底时回贴。
16028
+ chatMessages.scrollTop = 0;
15676
16029
  } else if (renderWasAtBottom) {
15677
16030
  // 同一会话内的全量重渲染:用户原本贴底就保持贴底,浏览器在 innerHTML
15678
16031
  // 重置后可能把 scrollTop 钳到一个奇怪的值,这里显式拉回 0。
@@ -15805,7 +16158,7 @@
15805
16158
  for (var i = 0; i < newMessages.length; i++) {
15806
16159
  var div = document.createElement("div");
15807
16160
  var nmOrigIdx = visibleOffset + existingCount + (newMessages.length - 1 - i);
15808
- div.innerHTML = renderChatMessage(newMessages[i], roundUsageByIndex[nmOrigIdx] || null, nmOrigIdx);
16161
+ div.innerHTML = renderChatMessage(newMessages[i], roundUsageByIndex[nmOrigIdx] || null, nmOrigIdx, legacyTaskMap);
15809
16162
  var el = div.firstElementChild;
15810
16163
  if (el) {
15811
16164
  el.classList.add("animate-in");
@@ -15863,7 +16216,7 @@
15863
16216
  var currentEl = existingEls[mi];
15864
16217
  var tmpWrap = document.createElement("div");
15865
16218
  var srOrigIdx = visibleOffset + reversedMessages.length - 1 - mi;
15866
- tmpWrap.innerHTML = renderChatMessage(reversedMessages[mi], roundUsageByIndex[srOrigIdx] || null, srOrigIdx);
16219
+ tmpWrap.innerHTML = renderChatMessage(reversedMessages[mi], roundUsageByIndex[srOrigIdx] || null, srOrigIdx, legacyTaskMap);
15867
16220
  var replacementEl = tmpWrap.firstElementChild;
15868
16221
  if (!replacementEl) continue;
15869
16222
  if (currentEl.innerHTML !== replacementEl.innerHTML || currentEl.className !== replacementEl.className) {
@@ -16844,6 +17197,27 @@
16844
17197
  '<span class="avatar-name">' + escapeHtml(name) + '</span>' +
16845
17198
  '</div>';
16846
17199
  }
17200
+
17201
+ // subagent tool_result → 独立 reply 气泡(markdown 渲染)。出错时显示红色错误体,
17202
+ // 没文本时显示打字指示器(subagent 还在跑)。
17203
+ function renderSubagentReplyBubble(block, role) {
17204
+ if (!block || block.type !== "tool_result") return "";
17205
+ var text = extractToolResultText(block.content);
17206
+ var isError = block.is_error === true;
17207
+
17208
+ if (isError) {
17209
+ return '<div class="subagent-reply error">' +
17210
+ '<span class="subagent-reply-icon">✗</span>' +
17211
+ '<div class="subagent-reply-body">' + (text ? renderMarkdown(text) : escapeHtml("(无输出)")) + '</div>' +
17212
+ '</div>';
17213
+ }
17214
+
17215
+ if (!text || !String(text).trim()) {
17216
+ return '<div class="subagent-reply pending"><span class="typing-indicator"><span></span><span></span><span></span></span></div>';
17217
+ }
17218
+
17219
+ return '<div class="subagent-reply">' + renderMarkdown(text) + '</div>';
17220
+ }
16847
17221
  var PIXEL_AVATAR = {
16848
17222
  assistant: buildPixelSvg(buildCatGrid(GARFIELD_PALETTE)),
16849
17223
  user: buildPixelSvg(buildCatGrid(SHORTHAIR_PALETTE)),
@@ -16897,16 +17271,20 @@
16897
17271
  '</div>';
16898
17272
  }
16899
17273
 
16900
- function renderChatMessage(msg, roundUsage, messageIndex) {
17274
+ function renderChatMessage(msg, roundUsage, messageIndex, legacyTaskMap) {
16901
17275
  // Thinking card (deep thought) — from PTY parsing
16902
17276
  if (msg.role === "thinking") {
17277
+ // 空 / 全空白的 thinking 没有任何信息量,渲染出来只是一条带"展开"的紫色窄条,
17278
+ // 展开了也看不到内容——直接跳过。
17279
+ var ptyThinkingText = typeof msg.content === "string" ? msg.content : "";
17280
+ if (!ptyThinkingText.trim()) return "";
16903
17281
  var thinkingKey = buildExpandKey("thinking", [getMessageKey(msg, messageIndex), "pty"]);
16904
17282
  var thinkingPersisted = getPersistedExpandState(thinkingKey);
16905
17283
  var thinkingExpanded = thinkingPersisted === null ? getCardDefault("thinking") : thinkingPersisted;
16906
17284
  return '<div class="chat-message thinking">' +
16907
- '<div class="thinking-inline thinking-pty ' + (thinkingExpanded ? 'expanded' : 'collapsed') + '" data-expand-kind="thinking" data-expand-key="' + escapeHtml(thinkingKey) + '" data-thinking="" onclick="__thinkingToggle(this)">' +
17285
+ '<div class="thinking-inline thinking-pty ' + (thinkingExpanded ? 'expanded' : 'collapsed') + '" data-expand-kind="thinking" data-expand-key="' + escapeHtml(thinkingKey) + '" data-thinking="' + escapeHtml(ptyThinkingText) + '" onclick="__thinkingToggle(this)">' +
16908
17286
  '<span class="thinking-inline-icon">⦿</span>' +
16909
- '<span class="thinking-inline-preview">' + escapeHtml(msg.content) + '</span>' +
17287
+ '<span class="thinking-inline-preview">' + escapeHtml(ptyThinkingText) + '</span>' +
16910
17288
  '<span class="thinking-inline-action">' + (thinkingExpanded ? '收起' : '展开') + '</span>' +
16911
17289
  '</div>' +
16912
17290
  '</div>';
@@ -16924,7 +17302,7 @@
16924
17302
 
16925
17303
  // Structured content blocks (from JSON chat mode)
16926
17304
  if (Array.isArray(msg.content)) {
16927
- return renderStructuredMessage(msg, roundUsage, messageIndex);
17305
+ return renderStructuredMessage(msg, roundUsage, messageIndex, legacyTaskMap);
16928
17306
  }
16929
17307
 
16930
17308
  // Legacy string content (from PTY parsing)
@@ -16980,6 +17358,8 @@
16980
17358
  }
16981
17359
 
16982
17360
  // ── 连续同类工具调用分组 ──
17361
+ // 注意:禁止把 Task/Agent 加入 GROUPABLE_TOOLS——它们由 renderContentBlock 入口屏蔽返空,
17362
+ // 加入分组会导致空 group 包裹一堆空字符串,留下视觉空盒子。
16983
17363
  var GROUPABLE_TOOLS = { Read: 1, Glob: 1, Grep: 1, WebFetch: 1, WebSearch: 1, TodoRead: 1 };
16984
17364
 
16985
17365
  function groupConsecutiveTools(content) {
@@ -17076,16 +17456,61 @@
17076
17456
  persistElementExpandState(el, "tool-group");
17077
17457
  };
17078
17458
 
17459
+ // 老消息(SQLite 历史 turn)后端还没盖章。前端给 name === "Task"/"Agent"
17460
+ // 或 input.subagent_type 非空的 tool_use 虚拟盖章,让现有 multi-agent 渲染路径吃到。
17461
+ function deriveLegacySubagent(block) {
17462
+ if (!block || block.type !== "tool_use") return null;
17463
+ var input = block.input || {};
17464
+ var agentType = typeof input.subagent_type === "string" ? input.subagent_type : null;
17465
+ if (!agentType && block.name !== "Task" && block.name !== "Agent") return null;
17466
+ return {
17467
+ taskId: block.id,
17468
+ agentType: agentType || undefined,
17469
+ taskDescription: typeof input.description === "string" ? input.description : undefined,
17470
+ };
17471
+ }
17472
+
17473
+ // tool_result 老消息靠 tool_use_id 关联 task。由外层预扫一遍 messages,构建 task id → meta 的 map。
17474
+ function collectLegacyTaskIdMap(allMessages) {
17475
+ var map = new Map();
17476
+ if (!Array.isArray(allMessages)) return map;
17477
+ for (var i = 0; i < allMessages.length; i++) {
17478
+ var m = allMessages[i];
17479
+ if (!m || m.role !== "assistant" || !Array.isArray(m.content)) continue;
17480
+ for (var j = 0; j < m.content.length; j++) {
17481
+ var b = m.content[j];
17482
+ if (!b || b.type !== "tool_use") continue;
17483
+ var derived = b.__subagent || deriveLegacySubagent(b);
17484
+ if (derived) map.set(b.id, derived);
17485
+ }
17486
+ }
17487
+ return map;
17488
+ }
17489
+
17079
17490
  // 把一条 assistant turn 按相邻 block 的 __subagent.taskId 切成段。
17080
17491
  // 输出每段附带原数组中的 firstIndex,方便渲染时 expand key 用全局 index
17081
17492
  // 避免不同段冲突。
17082
- function splitTurnBySubagent(blocks) {
17493
+ // legacyTaskMap:老消息没有 __subagent 盖章时,按 tool_use_id 反查兜底。
17494
+ function splitTurnBySubagent(blocks, legacyTaskMap) {
17083
17495
  var segs = [];
17084
17496
  if (!Array.isArray(blocks) || !blocks.length) return segs;
17085
17497
  var current = null;
17086
17498
  for (var i = 0; i < blocks.length; i++) {
17087
17499
  var b = blocks[i];
17500
+ // __processing 占位 block(流式中的 typing indicator)没有 __subagent 盖章,
17501
+ // 强制延续上一段(若已有),避免"父-Task-占位-子内容"反复切段导致 DOM 抖动。
17502
+ // 边界:若占位是第一个 block(current 仍为 null),走正常路径开 parent 段。
17503
+ var isPlaceholder = b && b.type === "text" && b.__processing === true;
17504
+ if (isPlaceholder && current) {
17505
+ current.blocks.push(b);
17506
+ continue;
17507
+ }
17088
17508
  var sub = b && b.__subagent ? b.__subagent : null;
17509
+ if (!sub) sub = deriveLegacySubagent(b);
17510
+ // 老消息 tool_result 兜底:用 tool_use_id 反查 map
17511
+ if (!sub && b && b.type === "tool_result" && legacyTaskMap && legacyTaskMap.has(b.tool_use_id)) {
17512
+ sub = legacyTaskMap.get(b.tool_use_id);
17513
+ }
17089
17514
  var key = sub ? sub.taskId : null;
17090
17515
  if (!current || current.key !== key) {
17091
17516
  current = { key: key, subagent: sub, blocks: [], firstIndex: i };
@@ -17124,11 +17549,55 @@
17124
17549
  return html;
17125
17550
  }
17126
17551
 
17127
- function renderStructuredMessage(msg, roundUsage, messageIndex) {
17552
+ // 抽出 multi-agent 渲染共用块。assistant turn 与 user turn 都可能含 subagent 段
17553
+ // (user turn 的 Task tool_result 由后端反查盖章),但 user turn 不再输出 handoff
17554
+ // 行,避免与 assistant turn 的 handoff 重复。
17555
+ // TODO:嵌套 subagent(子 subagent 在外层 subagent 段内再切)时,
17556
+ // parentPersonaName 应是外层 subagent.name 而不是固定父 persona;目前先不处理。
17557
+ function buildMultiAgentHtml(segments, role, parentPersonaName, toolResults, messageKey, options) {
17558
+ var opts = options || {};
17559
+ var showHandoff = opts.showHandoff !== false; // 默认 true;user turn 传 false
17560
+ var html = "";
17561
+ var lastSubId = null;
17562
+ for (var si = 0; si < segments.length; si++) {
17563
+ var seg = segments[si];
17564
+ var segHtml = buildSegmentBlocksHtml(seg.blocks, seg.firstIndex, role, toolResults, messageKey);
17565
+ // 段内所有 block 都被短路返空(典型场景:父段只剩一个空 thinking)时,
17566
+ // 跳过整段。否则会渲染出"只有头像没内容"的空气泡。
17567
+ if (!segHtml || !segHtml.trim()) continue;
17568
+ if (seg.subagent) {
17569
+ var subPalette = getSubagentPalette(seg.subagent);
17570
+ if (showHandoff && lastSubId !== seg.subagent.taskId) {
17571
+ var subName = getSubagentDisplayName(seg.subagent);
17572
+ var desc = seg.subagent.taskDescription
17573
+ ? ':<span class="chat-handoff-desc">' + escapeHtml(seg.subagent.taskDescription) + '</span>'
17574
+ : '';
17575
+ html += '<div class="chat-handoff" style="--agent-color:' + subPalette.primary + '">' +
17576
+ '<span class="chat-handoff-arrow">↳</span> ' +
17577
+ escapeHtml(parentPersonaName) + ' 让 <strong>' + escapeHtml(subName) + '</strong> 帮忙' + desc +
17578
+ '</div>';
17579
+ }
17580
+ html += '<div class="chat-message-segment subagent" data-agent-id="' + escapeHtml(seg.subagent.taskId) + '" style="--agent-color:' + subPalette.primary + '">' +
17581
+ subagentAvatarHtml(seg.subagent) +
17582
+ '<div class="chat-message-content">' + segHtml + '</div>' +
17583
+ '</div>';
17584
+ lastSubId = seg.subagent.taskId;
17585
+ } else {
17586
+ html += '<div class="chat-message-segment parent">' +
17587
+ chatAvatar(role) +
17588
+ '<div class="chat-message-content">' + segHtml + '</div>' +
17589
+ '</div>';
17590
+ lastSubId = null;
17591
+ }
17592
+ }
17593
+ return html;
17594
+ }
17595
+
17596
+ function renderStructuredMessage(msg, roundUsage, messageIndex, legacyTaskMap) {
17128
17597
  var role = msg.role;
17129
17598
  var messageKey = getMessageKey(msg, messageIndex);
17130
17599
 
17131
- // 排队中的用户消息标记(subagent 不会出现在 user role
17600
+ // 排队中的用户消息标记(subagent 不会出现在 user role 的 user input 中)
17132
17601
  var isQueued = role === "user" && msg.content && msg.content.some(function(b) { return b.__queued; });
17133
17602
 
17134
17603
  if (!msg.content || msg.content.length === 0) {
@@ -17147,12 +17616,22 @@
17147
17616
  }
17148
17617
 
17149
17618
  var toolResults = buildToolResultMap(msg.content);
17619
+ var parentPersona = getStructuredChatPersona("assistant");
17150
17620
 
17151
- // user role 不会有 subagent,保持旧路径
17621
+ // user role:可能含 Task tool_result(subagent 反查盖章过的)。检测一下,
17622
+ // 有 subagent 段就走 multi-agent 渲染(不输出 handoff,避免重复)。
17152
17623
  if (role !== "assistant") {
17153
- var userHtml = buildSegmentBlocksHtml(msg.content, 0, role, toolResults, messageKey);
17624
+ var userSegments = splitTurnBySubagent(msg.content, legacyTaskMap);
17625
+ var userHasSub = userSegments.some(function(s) { return s.subagent; });
17154
17626
  var queuedClass = isQueued ? " queued" : "";
17155
17627
  var queuedBadge = isQueued ? '<span class="queued-badge">排队中</span>' : "";
17628
+ if (userHasSub) {
17629
+ var userMultiHtml = buildMultiAgentHtml(userSegments, role, parentPersona.name, toolResults, messageKey, { showHandoff: false });
17630
+ return '<div class="chat-message ' + role + queuedClass + ' multi-agent" data-message-key="' + escapeHtml(messageKey) + '">' +
17631
+ userMultiHtml + queuedBadge +
17632
+ '</div>';
17633
+ }
17634
+ var userHtml = buildSegmentBlocksHtml(msg.content, 0, role, toolResults, messageKey);
17156
17635
  return '<div class="chat-message ' + role + queuedClass + '" data-message-key="' + escapeHtml(messageKey) + '">' +
17157
17636
  chatAvatar(role) +
17158
17637
  '<div class="chat-message-content">' + userHtml + queuedBadge + '</div>' +
@@ -17160,7 +17639,7 @@
17160
17639
  }
17161
17640
 
17162
17641
  // assistant:检测是否有 subagent 段,没有就走单段渲染(兼容老消息 / 无 subagent 的 turn)
17163
- var segments = splitTurnBySubagent(msg.content);
17642
+ var segments = splitTurnBySubagent(msg.content, legacyTaskMap);
17164
17643
  var hasSubagent = segments.some(function(s) { return s.subagent; });
17165
17644
 
17166
17645
  if (!hasSubagent) {
@@ -17174,43 +17653,26 @@
17174
17653
  // 多段:父 assistant 段 + 各 subagent 段。同一根 .chat-message 容器,
17175
17654
  // 内部多个 .chat-message-segment 子段,每段自带头像;切到新 subagent 时
17176
17655
  // 插入一行 handoff 提示("勤劳初二 ↳ 让 侦探猫 帮忙")。
17177
- var parentPersona = getStructuredChatPersona("assistant");
17178
17656
  var multiHtml = '<div class="chat-message ' + role + ' multi-agent" data-message-key="' + escapeHtml(messageKey) + '">';
17179
- var lastSubId = null;
17180
- for (var si = 0; si < segments.length; si++) {
17181
- var seg = segments[si];
17182
- var segHtml = buildSegmentBlocksHtml(seg.blocks, seg.firstIndex, role, toolResults, messageKey);
17183
- if (seg.subagent) {
17184
- var subPalette = getSubagentPalette(seg.subagent);
17185
- if (lastSubId !== seg.subagent.taskId) {
17186
- var subName = getSubagentDisplayName(seg.subagent);
17187
- var desc = seg.subagent.taskDescription
17188
- ? ':<span class="chat-handoff-desc">' + escapeHtml(seg.subagent.taskDescription) + '</span>'
17189
- : '';
17190
- multiHtml += '<div class="chat-handoff" style="--agent-color:' + subPalette.primary + '">' +
17191
- '<span class="chat-handoff-arrow">↳</span> ' +
17192
- escapeHtml(parentPersona.name) + ' 让 <strong>' + escapeHtml(subName) + '</strong> 帮忙' + desc +
17193
- '</div>';
17194
- }
17195
- multiHtml += '<div class="chat-message-segment subagent" data-agent-id="' + escapeHtml(seg.subagent.taskId) + '" style="--agent-color:' + subPalette.primary + '">' +
17196
- subagentAvatarHtml(seg.subagent) +
17197
- '<div class="chat-message-content">' + segHtml + '</div>' +
17198
- '</div>';
17199
- lastSubId = seg.subagent.taskId;
17200
- } else {
17201
- multiHtml += '<div class="chat-message-segment parent">' +
17202
- chatAvatar("assistant") +
17203
- '<div class="chat-message-content">' + segHtml + '</div>' +
17204
- '</div>';
17205
- lastSubId = null;
17206
- }
17207
- }
17657
+ multiHtml += buildMultiAgentHtml(segments, role, parentPersona.name, toolResults, messageKey, { showHandoff: true });
17208
17658
  multiHtml += '</div>';
17209
17659
  return multiHtml;
17210
17660
  }
17211
17661
  function renderContentBlock(block, role, toolResults, index, messageKey) {
17212
17662
  if (!block || !block.type) return "";
17213
17663
 
17664
+ // Task/Agent tool_use 自带 __subagent.taskId === block.id(后端自盖章):
17665
+ // 不画工具卡片,由 handoff 行表达"派遣"语义。
17666
+ if (block.type === "tool_use" && block.__subagent && block.__subagent.taskId === block.id) {
17667
+ return "";
17668
+ }
17669
+ // 只有父 Task 的 tool_result(taskId === tool_use_id)走 reply bubble;
17670
+ // 子 agent 内部工具的 tool_result(taskId === parent_tool_use_id ≠ tool_use_id)
17671
+ // 走普通工具卡片,不能误判为 reply bubble。
17672
+ if (block.type === "tool_result" && block.__subagent && block.__subagent.taskId === block.tool_use_id) {
17673
+ return renderSubagentReplyBubble(block, role);
17674
+ }
17675
+
17214
17676
  switch (block.type) {
17215
17677
  case "text":
17216
17678
  if (role === "assistant" && block.__processing) {
@@ -17220,7 +17682,6 @@
17220
17682
 
17221
17683
  case "thinking":
17222
17684
  var thinkingText = block.thinking || "";
17223
- var preview = thinkingText.length > 60 ? thinkingText.slice(0, 57) + "…" : thinkingText;
17224
17685
  var isStreaming = block.thinking === undefined && block.type === "thinking";
17225
17686
  if (isStreaming) {
17226
17687
  return '<div class="thinking-inline thinking-streaming" data-thinking="">' +
@@ -17230,6 +17691,10 @@
17230
17691
  '</div>' +
17231
17692
  '</div>';
17232
17693
  }
17694
+ // 非流式分支:thinking 字段是空字符串时,UI 上只会出现一条带"展开"
17695
+ // 的紫色窄条,展开了也是空——直接不渲染,避免视觉噪音。
17696
+ if (!thinkingText.trim()) return "";
17697
+ var preview = thinkingText.length > 60 ? thinkingText.slice(0, 57) + "…" : thinkingText;
17233
17698
  var thinkingKey = buildExpandKey("thinking", [messageKey, index]);
17234
17699
  var thinkingPersisted = getPersistedExpandState(thinkingKey);
17235
17700
  var thinkingExpanded = thinkingPersisted === null ? getCardDefault("thinking") : thinkingPersisted;