@co0ontty/wand 1.30.0 → 1.30.3

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.
@@ -117,6 +117,50 @@ function tagSubagentBlocks(blocks, parentToolUseId, registry) {
117
117
  };
118
118
  return blocks.map((block) => ({ ...block, __subagent: stamp }));
119
119
  }
120
+ /**
121
+ * 给已被 captureTaskMeta 识别为 Task/Agent 的 tool_use block 本身也盖 __subagent 章。
122
+ * taskId 用自己的 block.id —— 与子消息的 parent_tool_use_id(也等于这个 id)保持一致,
123
+ * 前端 splitTurnBySubagent 按 taskId 分组时父 Task tool_use 和 SDK 转发的子消息能合并到同一段。
124
+ */
125
+ function stampSelfTask(blocks, registry) {
126
+ return blocks.map((b) => {
127
+ if (b.type !== "tool_use")
128
+ return b;
129
+ if (b.__subagent)
130
+ return b; // 已盖章不重复(防止幂等问题)
131
+ const meta = registry.get(b.id);
132
+ if (!meta && b.name !== "Task" && b.name !== "Agent")
133
+ return b;
134
+ const stamp = {
135
+ taskId: b.id,
136
+ ...(meta?.agentType ? { agentType: meta.agentType } : {}),
137
+ ...(meta?.description ? { taskDescription: meta.description } : {}),
138
+ };
139
+ return { ...b, __subagent: stamp };
140
+ });
141
+ }
142
+ /**
143
+ * 当父 assistant 在 parentToolUseId === null 的 user turn 里收到 Task 工具的 tool_result 时,
144
+ * tagSubagentBlocks 不会被调用(它只在 parentToolUseId 非空时盖章)。这里按 tool_use_id
145
+ * 反查 registry,给这条 tool_result 单独盖章,让前端能把它归到同一个 subagent 段。
146
+ */
147
+ function stampParentTaskResults(blocks, registry) {
148
+ return blocks.map((b) => {
149
+ if (b.type !== "tool_result")
150
+ return b;
151
+ if (b.__subagent)
152
+ return b;
153
+ const meta = registry.get(b.tool_use_id);
154
+ if (!meta)
155
+ return b;
156
+ const stamp = {
157
+ taskId: b.tool_use_id,
158
+ ...(meta.agentType ? { agentType: meta.agentType } : {}),
159
+ ...(meta.description ? { taskDescription: meta.description } : {}),
160
+ };
161
+ return { ...b, __subagent: stamp };
162
+ });
163
+ }
120
164
  const STREAM_EMIT_DEBOUNCE_MS = 16;
121
165
  /** Min interval between full saveSession() calls for an in-progress streaming turn.
122
166
  * saveSession serializes the entire messages array, so doing it on every NDJSON
@@ -1555,7 +1599,7 @@ export class StructuredSessionManager {
1555
1599
  captureTaskMeta(extracted.content, taskMetaRegistry);
1556
1600
  }
1557
1601
  const stamped = parentToolUseId === null
1558
- ? extracted.content
1602
+ ? stampSelfTask(extracted.content, taskMetaRegistry)
1559
1603
  : tagSubagentBlocks(extracted.content, parentToolUseId, taskMetaRegistry);
1560
1604
  if (stamped.length > 0) {
1561
1605
  upsertBlocks(msgId, stamped);
@@ -1599,7 +1643,7 @@ export class StructuredSessionManager {
1599
1643
  ? parsed.parent_tool_use_id
1600
1644
  : null;
1601
1645
  const stamped = parentToolUseId === null
1602
- ? collected
1646
+ ? stampParentTaskResults(collected, taskMetaRegistry)
1603
1647
  : tagSubagentBlocks(collected, parentToolUseId, taskMetaRegistry);
1604
1648
  if (stamped.length > 0) {
1605
1649
  upsertBlocks(`tool_result:${toolResultSeq++}`, stamped);
@@ -1983,7 +2027,13 @@ export class StructuredSessionManager {
1983
2027
  streaming.push(block);
1984
2028
  }
1985
2029
  }
1986
- return [...finalizedBlocks, ...streaming];
2030
+ // 流式阶段就给 Task/Agent tool_use 本身盖章,防止"先显示工具卡片几秒再跳为
2031
+ // handoff 行"的闪烁。content_block_start 阶段就有 name=Task/Agent,
2032
+ // stampSelfTask 据此即可命中;agentType 字段藏在 input 里,delta 累计后再由
2033
+ // 后续 captureTaskMeta 回填 registry,下次 rebuild 自动补上更完整的 stamp。
2034
+ captureTaskMeta(streaming, taskMetaRegistry);
2035
+ const stampedStreaming = stampSelfTask(streaming, taskMetaRegistry);
2036
+ return [...finalizedBlocks, ...stampedStreaming];
1987
2037
  };
1988
2038
  const syncSnapshot = () => {
1989
2039
  const current = this.sessions.get(sessionId);
@@ -2095,7 +2145,7 @@ export class StructuredSessionManager {
2095
2145
  const parentToolUseId = assistantMsg.parent_tool_use_id ?? null;
2096
2146
  if (parentToolUseId === null) {
2097
2147
  captureTaskMeta(extracted.content, taskMetaRegistry);
2098
- finalizedBlocks.push(...extracted.content);
2148
+ finalizedBlocks.push(...stampSelfTask(extracted.content, taskMetaRegistry));
2099
2149
  }
2100
2150
  else {
2101
2151
  finalizedBlocks.push(...tagSubagentBlocks(extracted.content, parentToolUseId, taskMetaRegistry));
@@ -2150,7 +2200,7 @@ export class StructuredSessionManager {
2150
2200
  }
2151
2201
  }
2152
2202
  if (parentToolUseId === null) {
2153
- finalizedBlocks.push(...collected);
2203
+ finalizedBlocks.push(...stampParentTaskResults(collected, taskMetaRegistry));
2154
2204
  }
2155
2205
  else {
2156
2206
  finalizedBlocks.push(...tagSubagentBlocks(collected, parentToolUseId, taskMetaRegistry));
@@ -238,10 +238,19 @@
238
238
  chatUnreadCount: 0,
239
239
  // state.currentMessages 中第一条未读消息的 index,-1 表示没有未读。
240
240
  chatUnreadStartIndex: -1,
241
- chatScrollThreshold: 120,
241
+ // 业界共识 150-180px:120px 在触控板/移动端惯性下边界来回弹。
242
+ chatScrollThreshold: 160,
242
243
  chatIsProgrammaticScroll: false,
243
244
  chatScrollElement: null,
244
245
  chatScrollHandler: null,
246
+ chatScrollWheelHandler: null,
247
+ chatScrollTouchStartHandler: null,
248
+ chatScrollTouchMoveHandler: null,
249
+ chatTouchStartY: 0,
250
+ // 仅在"首次渲染当前会话视图"时才允许 fullRenderChat 强制贴底。
251
+ // resetChatRenderCache 会把它设回 false;fullRenderChat 第一次跑完就置 true。
252
+ // page-refresh / ws 重连不重置此标记,避免把用户拽到底部。
253
+ chatInitialRenderDone: false,
245
254
  lastForegroundSyncAt: 0,
246
255
  foregroundSyncTimer: null,
247
256
  wsReconnectAttempts: 0,
@@ -597,8 +606,19 @@
597
606
  updateChatUnreadBubble();
598
607
  return;
599
608
  }
600
- if (state.chatScrollElement && state.chatScrollHandler) {
601
- state.chatScrollElement.removeEventListener("scroll", state.chatScrollHandler);
609
+ if (state.chatScrollElement) {
610
+ if (state.chatScrollHandler) {
611
+ state.chatScrollElement.removeEventListener("scroll", state.chatScrollHandler);
612
+ }
613
+ if (state.chatScrollWheelHandler) {
614
+ state.chatScrollElement.removeEventListener("wheel", state.chatScrollWheelHandler);
615
+ }
616
+ if (state.chatScrollTouchStartHandler) {
617
+ state.chatScrollElement.removeEventListener("touchstart", state.chatScrollTouchStartHandler);
618
+ }
619
+ if (state.chatScrollTouchMoveHandler) {
620
+ state.chatScrollElement.removeEventListener("touchmove", state.chatScrollTouchMoveHandler);
621
+ }
602
622
  }
603
623
  state.chatScrollElement = chatMsgs;
604
624
  state.chatScrollHandler = function() {
@@ -619,7 +639,35 @@
619
639
  }
620
640
  updateChatUnreadBubble();
621
641
  };
642
+ // wheel/touch 提前下台:浏览器要等惯性产生位移才触发 scroll 事件,
643
+ // 这一帧空窗里如果有 streaming chunk 进来,会在 sticky=true 状态下
644
+ // 被强制贴底。监听用户开始上滚的瞬间立刻把 sticky 翻成 false,
645
+ // 避免那一帧的拽回。column-reverse 下 deltaY<0(滚轮上推)= 看历史。
646
+ state.chatScrollWheelHandler = function(e) {
647
+ if (state.chatIsProgrammaticScroll) return;
648
+ if (e.deltaY < 0) {
649
+ state.chatStickToBottom = false;
650
+ updateChatUnreadBubble();
651
+ }
652
+ };
653
+ state.chatScrollTouchStartHandler = function(e) {
654
+ if (!e.touches || e.touches.length === 0) return;
655
+ state.chatTouchStartY = e.touches[0].clientY;
656
+ };
657
+ state.chatScrollTouchMoveHandler = function(e) {
658
+ if (state.chatIsProgrammaticScroll) return;
659
+ if (!e.touches || e.touches.length === 0) return;
660
+ // column-reverse 下:手指向下拖(clientY 变大)= 内容向下走 = 看历史。
661
+ var deltaY = e.touches[0].clientY - state.chatTouchStartY;
662
+ if (deltaY > 4) {
663
+ state.chatStickToBottom = false;
664
+ updateChatUnreadBubble();
665
+ }
666
+ };
622
667
  chatMsgs.addEventListener("scroll", state.chatScrollHandler, { passive: true });
668
+ chatMsgs.addEventListener("wheel", state.chatScrollWheelHandler, { passive: true });
669
+ chatMsgs.addEventListener("touchstart", state.chatScrollTouchStartHandler, { passive: true });
670
+ chatMsgs.addEventListener("touchmove", state.chatScrollTouchMoveHandler, { passive: true });
623
671
  updateChatUnreadBubble();
624
672
  }
625
673
 
@@ -951,23 +999,46 @@
951
999
  });
952
1000
  }
953
1001
 
954
- function resetChatRenderCache() {
1002
+ // options.preserveStickState=true:仅清渲染缓存,不动 sticky/未读
1003
+ // 状态。用于 page-refresh、ws 重连等"用户停留在当前会话,只是想刷新
1004
+ // DOM"的场景——不能把用户从历史位置拽回底部。
1005
+ // 默认(false):切会话 / 新建 / home 等真正"换上下文"路径用,全清。
1006
+ function resetChatRenderCache(options) {
1007
+ var opts = options || {};
955
1008
  state.lastRenderedHash = 0;
956
1009
  state.lastRenderedMsgCount = 0;
957
1010
  state.lastRenderedEmpty = null;
958
1011
  state.renderPending = false;
959
1012
  state.chatRenderedCount = state.chatPageSize;
960
1013
  state.askUserSelections = {};
961
- if (state.chatScrollElement && state.chatScrollHandler) {
962
- state.chatScrollElement.removeEventListener("scroll", state.chatScrollHandler);
1014
+ if (state.chatScrollElement) {
1015
+ if (state.chatScrollHandler) {
1016
+ state.chatScrollElement.removeEventListener("scroll", state.chatScrollHandler);
1017
+ }
1018
+ if (state.chatScrollWheelHandler) {
1019
+ state.chatScrollElement.removeEventListener("wheel", state.chatScrollWheelHandler);
1020
+ }
1021
+ if (state.chatScrollTouchStartHandler) {
1022
+ state.chatScrollElement.removeEventListener("touchstart", state.chatScrollTouchStartHandler);
1023
+ }
1024
+ if (state.chatScrollTouchMoveHandler) {
1025
+ state.chatScrollElement.removeEventListener("touchmove", state.chatScrollTouchMoveHandler);
1026
+ }
963
1027
  }
964
1028
  state.chatScrollElement = null;
965
1029
  state.chatScrollHandler = null;
1030
+ state.chatScrollWheelHandler = null;
1031
+ state.chatScrollTouchStartHandler = null;
1032
+ state.chatScrollTouchMoveHandler = null;
966
1033
  state.chatIsProgrammaticScroll = false;
967
- // 切会话时未读状态归零、贴底重置——避免上一个会话残留的"未读气泡"。
968
- state.chatStickToBottom = true;
969
- state.chatUnreadCount = 0;
970
- state.chatUnreadStartIndex = -1;
1034
+ if (!opts.preserveStickState) {
1035
+ // 切会话时未读状态归零、贴底重置——避免上一个会话残留的"未读气泡"。
1036
+ state.chatStickToBottom = true;
1037
+ state.chatUnreadCount = 0;
1038
+ state.chatUnreadStartIndex = -1;
1039
+ // 真正换会话时才允许首帧贴底;preserve 路径下保留旧 initial 状态。
1040
+ state.chatInitialRenderDone = false;
1041
+ }
971
1042
  }
972
1043
 
973
1044
  function getEffectiveCwd() {
@@ -1464,7 +1535,6 @@
1464
1535
  ? '<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
1536
  : '<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
1537
  '</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
1538
  '<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
1539
  '</div>' +
1470
1540
  '</div>' +
@@ -1676,6 +1746,11 @@
1676
1746
  '<button id="stop-button" class="btn-circle btn-circle-stop' + (state.selectedId ? "" : " hidden") + '" title="停止">' +
1677
1747
  '<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
1748
  '</button>' +
1749
+ // 结构化模式且正在出 token 时显示:中断当前回复、立刻发送新输入。
1750
+ // 默认走 #send-input-button → 排队;想插队的人显式按这颗。
1751
+ '<button id="interrupt-send-button" class="btn-circle btn-circle-interrupt hidden" type="button" title="中断当前回复并立即发送" aria-label="立即发送">' +
1752
+ '<svg 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>' +
1753
+ '</button>' +
1679
1754
  '<button id="send-input-button" class="btn-circle btn-circle-send" title="发送">' +
1680
1755
  '<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
1756
  '</button>' +
@@ -3094,29 +3169,44 @@
3094
3169
  '</section>';
3095
3170
  }
3096
3171
 
3172
+ // 「最近」分组的统一数据源:未归档 active sessions + 24h 内 Claude 历史,
3173
+ // 一起按"创建时间"倒序排(session 用 startedAt,history 用 timestamp)。
3174
+ // 展开侧栏的「最近」和折叠侧栏的窄条都基于这份列表渲染,序号严格对应。
3175
+ function getRecentEntries() {
3176
+ var cutoff = Date.now() - 24 * 60 * 60 * 1000;
3177
+ var entries = [];
3178
+ state.sessions.forEach(function(s) {
3179
+ if (s.archived) return;
3180
+ var t = s.startedAt ? new Date(s.startedAt).getTime() : 0;
3181
+ entries.push({ kind: "session", ref: s, t: isFinite(t) ? t : 0 });
3182
+ });
3183
+ if (state.claudeHistoryLoaded) {
3184
+ getVisibleClaudeHistorySessions().forEach(function(h) {
3185
+ if (!h.timestamp) return;
3186
+ var t = new Date(h.timestamp).getTime();
3187
+ if (!isFinite(t) || t <= cutoff) return;
3188
+ entries.push({ kind: "history", ref: h, t: t });
3189
+ });
3190
+ }
3191
+ entries.sort(function(a, b) { return b.t - a.t; });
3192
+ return entries;
3193
+ }
3194
+
3097
3195
  function renderSessions() {
3098
- var activeSessions = state.sessions.filter(function(session) { return !session.archived; });
3099
3196
  var archivedSessions = state.sessions.filter(function(session) { return session.archived; });
3100
3197
  var groups = [];
3101
3198
  groups.push(renderSessionManageBar());
3102
3199
 
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
- }
3200
+ var recentEntries = getRecentEntries();
3111
3201
 
3112
- if (activeSessions.length > 0 || recentHistorySessions.length > 0) {
3113
- groups.push(renderRecentGroup(activeSessions, recentHistorySessions));
3202
+ if (recentEntries.length > 0) {
3203
+ groups.push(renderRecentGroup(recentEntries));
3114
3204
  }
3115
3205
  if (archivedSessions.length > 0) {
3116
3206
  groups.push(renderArchivedGroup(archivedSessions));
3117
3207
  }
3118
3208
  groups.push(renderClaudeHistorySection());
3119
- if (activeSessions.length === 0 && archivedSessions.length === 0 && recentHistorySessions.length === 0) {
3209
+ if (recentEntries.length === 0 && archivedSessions.length === 0) {
3120
3210
  return renderSessionManageBar() + '<div class="empty-state"><strong>还没有会话记录</strong><br>点击上方「新对话」开始你的第一次对话。</div>' + renderClaudeHistorySection();
3121
3211
  }
3122
3212
  return groups.join("");
@@ -3127,36 +3217,23 @@
3127
3217
  }
3128
3218
 
3129
3219
  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) {
3220
+ var entries = getRecentEntries();
3221
+ if (entries.length === 0) {
3144
3222
  return '<div class="sidebar-collapsed-empty" title="无会话">—</div>';
3145
3223
  }
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
- });
3224
+ var tiles = entries.map(function(e, i) {
3225
+ var idx = i + 1;
3226
+ if (e.kind === "session") {
3227
+ var s = e.ref;
3228
+ var activeCls = s.id === state.selectedId ? " active" : "";
3229
+ var title = s.summary || s.command || ("会话 " + idx);
3230
+ return '<button class="sidebar-collapsed-tile' + activeCls + '" type="button" data-collapsed-session-id="' + escapeHtml(s.id) + '" title="' + escapeHtml(title) + '">' + idx + '</button>';
3231
+ }
3232
+ var h = e.ref;
3233
+ var preview = h.firstUserMessage || "(空会话)";
3234
+ var hTitle = preview + " · " + formatHistoryTime(h.timestamp);
3235
+ 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>';
3236
+ }).join("");
3160
3237
  return '<div class="sidebar-collapsed-tiles">' + tiles + '</div>';
3161
3238
  }
3162
3239
 
@@ -3223,11 +3300,14 @@
3223
3300
  return '<section class="session-group">' + header + items + '</section>';
3224
3301
  }
3225
3302
 
3226
- function renderRecentGroup(activeSessions, recentHistorySessions) {
3303
+ function renderRecentGroup(entries) {
3227
3304
  var html = '<section class="session-group">' +
3228
3305
  '<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("");
3306
+ html += entries.map(function(e) {
3307
+ return e.kind === "session"
3308
+ ? renderSessionItem(e.ref, "sessions")
3309
+ : renderClaudeHistoryItem(e.ref, "history");
3310
+ }).join("");
3231
3311
  html += '</section>';
3232
3312
  return html;
3233
3313
  }
@@ -5627,8 +5707,6 @@
5627
5707
  if (pinBtn) pinBtn.addEventListener("click", toggleSidebarPin);
5628
5708
  var collapseBtn = document.getElementById("sidebar-collapse-btn");
5629
5709
  if (collapseBtn) collapseBtn.addEventListener("click", toggleSidebarCollapsed);
5630
- var closeFullyBtn = document.getElementById("sidebar-close-fully-btn");
5631
- if (closeFullyBtn) closeFullyBtn.addEventListener("click", closeSidebarCompletely);
5632
5710
  var sidebarMoreBtn = document.getElementById("sidebar-more-btn");
5633
5711
  var sidebarOverflow = document.getElementById("sidebar-overflow-menu");
5634
5712
  if (sidebarMoreBtn && sidebarOverflow) {
@@ -5894,6 +5972,11 @@
5894
5972
  closeSessionsDrawer();
5895
5973
  sendOrStart();
5896
5974
  });
5975
+ var interruptSendBtn = document.getElementById("interrupt-send-button");
5976
+ if (interruptSendBtn) interruptSendBtn.addEventListener("click", function() {
5977
+ closeSessionsDrawer();
5978
+ sendOrStart({ interrupt: true });
5979
+ });
5897
5980
  var stopBtn = document.getElementById("stop-button");
5898
5981
  if (stopBtn) stopBtn.addEventListener("click", stopSession);
5899
5982
  var modeSelect = document.getElementById("chat-mode-select");
@@ -6142,7 +6225,8 @@
6142
6225
  return;
6143
6226
  }
6144
6227
  softResyncTerminal();
6145
- resetChatRenderCache();
6228
+ // 用户停留在当前会话,只是想刷一下 DOM——保留其阅读位置和 sticky 状态。
6229
+ resetChatRenderCache({ preserveStickState: true });
6146
6230
  scheduleChatRender(true);
6147
6231
  });
6148
6232
  var jumpBottomBtn = document.getElementById("terminal-jump-bottom");
@@ -7909,6 +7993,11 @@
7909
7993
  if (canAutoResumeSession(session)) return "";
7910
7994
  return "会话已结束";
7911
7995
  }
7996
+ // 结构化会话在出 token 时,输入框仍然可用——告诉用户默认行为是排队,
7997
+ // 想插队请按右侧的 » 按钮。短语保持单行不换行。
7998
+ if (isStructuredSession(session) && session.structuredState && session.structuredState.inFlight) {
7999
+ return "回复中…继续输入将排队(» 立即发送)";
8000
+ }
7912
8001
  return "";
7913
8002
  }
7914
8003
 
@@ -8322,6 +8411,7 @@
8322
8411
  var hasSession = !!state.selectedId;
8323
8412
  var terminalContainer = document.getElementById("output");
8324
8413
  var chatContainer = document.getElementById("chat-output");
8414
+ var blankChat = document.getElementById("blank-chat");
8325
8415
  var selectedSession = state.sessions.find(function(s) { return s.id === state.selectedId; });
8326
8416
  var structured = isStructuredSession(selectedSession);
8327
8417
  var showTerminal = hasSession && !structured && state.currentView === "terminal";
@@ -8340,6 +8430,14 @@
8340
8430
  chatContainer.classList.toggle("active", showChat);
8341
8431
  chatContainer.classList.toggle("hidden", !showChat);
8342
8432
  }
8433
+ // blank-chat 的可见性由 applyCurrentView 收口:updateShellChrome 在
8434
+ // selectedSession 缺失(启动期 selectedId 已恢复但 /api/sessions 未回 /
8435
+ // activateSession 在 updateSessionSnapshot 之前调 switchToSessionView 等
8436
+ // 瞬态)时会走 else 分支把 blank-chat 显示出来,但紧接着调到这里,应
8437
+ // 以 hasSession 为准重新隐藏,避免与 terminal/chat 同屏并存。
8438
+ if (blankChat) {
8439
+ blankChat.classList.toggle("hidden", hasSession);
8440
+ }
8343
8441
  if (chatContainer && showChat) {
8344
8442
  ensureChatMessagesContainer(chatContainer);
8345
8443
  }
@@ -8604,7 +8702,8 @@
8604
8702
  } else if (state.selectedId) {
8605
8703
  var sel = state.sessions.find(function(s) { return s.id === state.selectedId; });
8606
8704
  if (isStructuredSession(sel)) {
8607
- resetChatRenderCache();
8705
+ // ws 重连后的同会话刷新——保留用户阅读位置。
8706
+ resetChatRenderCache({ preserveStickState: true });
8608
8707
  scheduleChatRender(true);
8609
8708
  }
8610
8709
  }
@@ -8974,29 +9073,6 @@
8974
9073
  hideCollapsedTileBubble();
8975
9074
  }
8976
9075
 
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
9076
  function toggleSidebarCollapsed() {
9001
9077
  if (isMobileLayout()) return;
9002
9078
  // 在 drawer 模式(未 pin)下点 collapse 视为「先固定、再收起为窄条」——
@@ -10931,7 +11007,10 @@
10931
11007
  return;
10932
11008
  }
10933
11009
  event.preventDefault();
10934
- sendInputFromBox();
11010
+ // Cmd/Ctrl+Enter → 立即发送(中断当前回复)。仅对正在 inFlight 的
11011
+ // 结构化会话生效;其它情况下退化为普通发送,避免无谓的中断信号。
11012
+ var interruptShortcut = !!(event.metaKey || event.ctrlKey);
11013
+ sendInputFromBox(interruptShortcut ? { interrupt: true } : undefined);
10935
11014
  return;
10936
11015
  }
10937
11016
 
@@ -11640,7 +11719,8 @@
11640
11719
  });
11641
11720
  }
11642
11721
 
11643
- function sendOrStart() {
11722
+ function sendOrStart(opts) {
11723
+ opts = opts || {};
11644
11724
  // Support welcome input as well as the main input box
11645
11725
  var welcomeInput = document.getElementById("welcome-input");
11646
11726
  var inputBox = document.getElementById("input-box");
@@ -11651,7 +11731,7 @@
11651
11731
  // If we have a selected ID, try to send input to it
11652
11732
  if (state.selectedId) {
11653
11733
  if (value) {
11654
- sendInputFromBox();
11734
+ sendInputFromBox(opts);
11655
11735
  }
11656
11736
  return;
11657
11737
  }
@@ -11750,7 +11830,9 @@
11750
11830
  }
11751
11831
 
11752
11832
 
11753
- function sendInputFromBox() {
11833
+ function sendInputFromBox(opts) {
11834
+ opts = opts || {};
11835
+ var interruptFlag = !!opts.interrupt;
11754
11836
  if (state.terminalInteractive) {
11755
11837
  showToast("终端交互模式开启时,请直接在终端中输入。", "info");
11756
11838
  return Promise.resolve();
@@ -11789,7 +11871,7 @@
11789
11871
  if (todoEl) todoEl.classList.add("hidden");
11790
11872
 
11791
11873
  if (isStructuredSession(selectedSession)) {
11792
- return postStructuredInput(finalValue, inputBox, selectedSession);
11874
+ return postStructuredInput(finalValue, inputBox, selectedSession, { interrupt: interruptFlag });
11793
11875
  }
11794
11876
 
11795
11877
  var submitChunks = getTerminalSubmitChunks(selectedSession, finalValue);
@@ -11837,8 +11919,13 @@
11837
11919
  // 防止同一会话并发提交(快速双击 / 重复触发)
11838
11920
  var _structuredSubmittingSessions = {};
11839
11921
 
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 });
11922
+ function postStructuredInput(input, inputBox, session, opts) {
11923
+ opts = opts || {};
11924
+ // 用户显式点击"立即发送"才会传 interrupt:true。普通 Enter / 点发送
11925
+ // 在上一条还在流式时默认走 queue —— 后端 sendMessage(...) 会把它
11926
+ // 追加到 queuedMessages,等当前 turn 结束自动 flush。
11927
+ var requestedInterrupt = !!opts.interrupt;
11928
+ 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
11929
  if (!state.selectedId || !input) return Promise.resolve();
11843
11930
  if (!session) {
11844
11931
  showToast("会话不存在,请重新选择或新建会话。", "error");
@@ -11850,25 +11937,48 @@
11850
11937
  return Promise.resolve();
11851
11938
  }
11852
11939
 
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 }] };
11940
+ var sessionInFlight = !!(session.structuredState && session.structuredState.inFlight && session.status === "running");
11941
+ var isInterrupting = sessionInFlight && requestedInterrupt;
11942
+ var isQueueing = sessionInFlight && !requestedInterrupt;
11943
+
11856
11944
  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);
11945
+ var optimisticPatch;
11946
+
11947
+ if (isQueueing) {
11948
+ // Queue 模式:不要乐观 push user turn —— buildMessagesForRender 会把
11949
+ // queuedMessages 渲成 __queued 占位(带"排队中"徽章),再 push 一份
11950
+ // 真 user turn 会被去重逻辑遮蔽掉,徽章就丢了。inFlight / status 维持。
11951
+ var nextQueue = Array.isArray(session.queuedMessages) ? session.queuedMessages.slice() : [];
11952
+ nextQueue.push(input);
11953
+ optimisticPatch = {
11954
+ id: session.id,
11955
+ queuedMessages: nextQueue,
11956
+ };
11957
+ updateSessionSnapshot(optimisticPatch);
11958
+ var queueRefreshed = state.sessions.find(function(s) { return s.id === session.id; }) || session;
11959
+ state.currentMessages = buildMessagesForRender(queueRefreshed, getPreferredMessages(queueRefreshed, queueRefreshed.output, false));
11960
+ updateInputHint("已加入排队,等待当前回复完成…");
11961
+ renderChat(true);
11962
+ updateStructuredQueueCounter();
11963
+ } else {
11964
+ // 普通发送 / interrupt 发送:照旧乐观推 user turn + inFlight=true
11965
+ var userTurn = { role: "user", content: [{ type: "text", text: input }] };
11966
+ userMsgs.push(userTurn);
11967
+ var optimisticStructuredState = Object.assign({}, session.structuredState || {}, { inFlight: true });
11968
+ updateSessionSnapshot({
11969
+ id: session.id,
11970
+ status: "running",
11971
+ messages: userMsgs,
11972
+ structuredState: optimisticStructuredState,
11973
+ });
11974
+ state.currentMessages = buildMessagesForRender(Object.assign({}, session, {
11975
+ status: "running",
11976
+ messages: userMsgs,
11977
+ structuredState: optimisticStructuredState,
11978
+ }), userMsgs);
11979
+ updateInputHint(isInterrupting ? "已中断,正在处理新消息…" : "思考中…");
11980
+ renderChat(true);
11981
+ }
11872
11982
 
11873
11983
  if (inputBox) {
11874
11984
  inputBox.value = "";
@@ -11924,8 +12034,12 @@
11924
12034
  var refreshedSession = state.sessions.find(function(s) { return s.id === snapshot.id; }) || snapshot;
11925
12035
  state.currentMessages = buildMessagesForRender(refreshedSession, getPreferredMessages(refreshedSession, snapshot.output, false));
11926
12036
  renderChat(true);
12037
+ updateStructuredQueueCounter();
11927
12038
  if (isInterrupting) {
11928
12039
  showToast("已中断上一条回复,正在处理新消息…", "info");
12040
+ } else if (isQueueing) {
12041
+ var qLen = Array.isArray(refreshedSession.queuedMessages) ? refreshedSession.queuedMessages.length : 0;
12042
+ showToast(qLen > 1 ? ("已加入排队(共 " + qLen + " 条等待)") : "已加入排队,等待当前回复完成。", "info");
11929
12043
  }
11930
12044
  }
11931
12045
  }
@@ -11943,20 +12057,36 @@
11943
12057
  return;
11944
12058
  }
11945
12059
 
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);
12060
+ if (isQueueing) {
12061
+ // Queue 模式回滚:把刚 push 的那条 queuedMessages 撤掉。inFlight / messages
12062
+ // 都没动过,不必复位,否则会把后端真实的 inFlight=true 误改成 false。
12063
+ var prevQueue = Array.isArray(session.queuedMessages) ? session.queuedMessages.slice() : [];
12064
+ updateSessionSnapshot({
12065
+ id: session.id,
12066
+ queuedMessages: prevQueue,
12067
+ });
12068
+ if (session.id === state.selectedId) {
12069
+ var rolledQueueSession = state.sessions.find(function(s) { return s.id === session.id; }) || session;
12070
+ state.currentMessages = buildMessagesForRender(rolledQueueSession, getPreferredMessages(rolledQueueSession, rolledQueueSession.output, false));
12071
+ renderChat(true);
12072
+ updateStructuredQueueCounter();
12073
+ }
12074
+ } else {
12075
+ // 回滚乐观更新:恢复发送前的 messages(去掉刚加的 userTurn)和 inFlight 状态
12076
+ var rollbackMsgs = userMsgs.slice(0, -1);
12077
+ updateSessionSnapshot({
12078
+ id: session.id,
12079
+ status: session.status,
12080
+ messages: rollbackMsgs,
12081
+ structuredState: Object.assign({}, session.structuredState || {}, { inFlight: false }),
12082
+ });
12083
+ if (session.id === state.selectedId) {
12084
+ state.currentMessages = buildMessagesForRender(
12085
+ Object.assign({}, session, { messages: rollbackMsgs, structuredState: Object.assign({}, session.structuredState || {}, { inFlight: false }) }),
12086
+ rollbackMsgs
12087
+ );
12088
+ renderChat(true);
12089
+ }
11960
12090
  }
11961
12091
  var message = (error && error.message) || "";
11962
12092
  var isTransientAbort =
@@ -12564,11 +12694,19 @@
12564
12694
  composer.classList.toggle("is-terminal-passthrough", !!state.terminalInteractive);
12565
12695
  }
12566
12696
  var sendBtn = document.getElementById("send-input-button");
12697
+ var structuredInFlight = structured && isRunning;
12567
12698
  if (sendBtn) {
12568
12699
  sendBtn.disabled = !structured && !!selectedSession && !isRunning && !canResumeOnSend;
12569
12700
  sendBtn.setAttribute("title", structured
12570
- ? "发送"
12701
+ ? (structuredInFlight ? "排队发送(当前回复结束后处理)" : "发送")
12571
12702
  : (isCodex ? (isRunning ? "发送给 Codex" : "Codex 会话已结束") : (!selectedSession || isRunning || canResumeOnSend ? "发送" : "会话已结束")));
12703
+ sendBtn.classList.toggle("queue-mode", structuredInFlight);
12704
+ }
12705
+ var interruptBtn = document.getElementById("interrupt-send-button");
12706
+ if (interruptBtn) {
12707
+ // 仅结构化 + inFlight 时显示。pty 会话有自己的 Ctrl+C / stop 按钮,
12708
+ // 用不上这套语义。
12709
+ interruptBtn.classList.toggle("hidden", !structuredInFlight);
12572
12710
  }
12573
12711
  var container = document.getElementById("output");
12574
12712
  if (container) container.classList.toggle("interactive", !structured && state.terminalInteractive);
@@ -13287,7 +13425,10 @@
13287
13425
  // fill the expanded space, and the scroll position needs resetting.
13288
13426
  if (isTouchDevice()) {
13289
13427
  ensureTerminalFit("keyboard-blur", { forceReplay: true });
13290
- maybeScrollTerminalToBottom("force");
13428
+ // "keyboard" 而非 "force":用户原本在终端历史里翻看时,键盘
13429
+ // 收起不该把视图拽走。maybeScrollTerminalToBottom 会按
13430
+ // terminalAutoFollow 决定——贴底者继续贴底,离底者保位。
13431
+ maybeScrollTerminalToBottom("keyboard");
13291
13432
  }
13292
13433
  }, 100);
13293
13434
  }
@@ -14081,7 +14222,9 @@
14081
14222
  syncAppViewportHeight();
14082
14223
  }
14083
14224
  ensureTerminalFit("keyboard-close", { forceReplay: true });
14084
- maybeScrollTerminalToBottom("force");
14225
+ // 同 handleInputBoxBlur:尊重 terminalAutoFollow,避免把上滚
14226
+ // 阅读历史的用户在键盘关闭瞬间拽回底部。
14227
+ maybeScrollTerminalToBottom("keyboard");
14085
14228
  }, 200);
14086
14229
  }
14087
14230
 
@@ -15497,6 +15640,9 @@
15497
15640
  }
15498
15641
 
15499
15642
  var allMessages = state.currentMessages;
15643
+ // 预扫一遍全量 messages,构建 task id → subagent meta 的 map,
15644
+ // 供老消息 tool_result(没有 __subagent 盖章)按 tool_use_id 反查兜底。
15645
+ var legacyTaskMap = collectLegacyTaskIdMap(allMessages);
15500
15646
 
15501
15647
  if (allMessages.length === 0) {
15502
15648
  if (state.lastRenderedEmpty !== "empty") {
@@ -15573,10 +15719,11 @@
15573
15719
 
15574
15720
  // 在动 DOM 之前先看用户是不是贴在底部——这决定后面我们要不要让视图
15575
15721
  // "继续粘在底部"。column-reverse 下 scrollTop 接近 0 = 视觉底部。
15576
- // 同时把 state.chatStickToBottom 同步到当前真实状态,避免长时间不滚动后
15577
- // 的状态漂移(比如新会话 init 的瞬间)。
15722
+ // 注意:state.chatStickToBottom 的维护**完全交给 scroll handler**
15723
+ // (bindChatScrollListener + wheel/touch 提前下台),这里不再做
15724
+ // "近底即锁回 true"的自愈,避免 resize / 键盘动画 / 锚点回填瞬间
15725
+ // 把已经上滚阅读的用户误判回贴底状态。
15578
15726
  var renderWasAtBottom = isChatNearBottom(chatMessages);
15579
- if (renderWasAtBottom) state.chatStickToBottom = true;
15580
15727
 
15581
15728
  // 把 .system-info 卡片从计数里剔除——它由 extractPtySystemInfo 在
15582
15729
  // fullRenderChat 里穿插注入,不存在于 messages 数组中,混进 existingCount
@@ -15620,7 +15767,7 @@
15620
15767
  }
15621
15768
 
15622
15769
  // Render message
15623
- html += renderChatMessage(msg, roundUsageByIndex[originalIndex] || null, originalIndex);
15770
+ html += renderChatMessage(msg, roundUsageByIndex[originalIndex] || null, originalIndex, legacyTaskMap);
15624
15771
  }
15625
15772
 
15626
15773
  // Add sentinel for loading older messages (DOM end = visual top in column-reverse)
@@ -15634,9 +15781,12 @@
15634
15781
  // 的 data-msg-index 和它到容器顶部的偏移。重写完成后找到同一 data-msg-index
15635
15782
  // 的新节点,把它放回原来的偏移——这是 column-reverse 下保住用户视线的
15636
15783
  // 标准锚点法。没有锚点时(首次渲染、空 → 非空)才走 scrollTop=0 兜底。
15784
+ // 改用 existingCount 而非 prevMsgCount:page-refresh 等 preserveStickState
15785
+ // 路径下 prevMsgCount 被重置为 0,但 DOM 里仍有节点可作锚点,必须保住
15786
+ // 用户的阅读位置。
15637
15787
  var anchorMsgIndex = -1;
15638
15788
  var anchorOffset = 0;
15639
- if (prevMsgCount > 0 && !renderWasAtBottom) {
15789
+ if (existingCount > 0 && !renderWasAtBottom) {
15640
15790
  var containerTop = chatMessages.getBoundingClientRect().top;
15641
15791
  var preEls = chatMessages.querySelectorAll(".chat-message:not(.system-info)");
15642
15792
  for (var pi = 0; pi < preEls.length; pi++) {
@@ -15666,13 +15816,19 @@
15666
15816
  })();
15667
15817
  // 会话切换 / 首次渲染后,浏览器会把旧的 scrollTop 钳制到新内容
15668
15818
  // 的最大值——column-reverse 下这意味着视觉上跳到最上面(最旧消息),
15669
- // 也就是用户反馈的"退出再回来时被重定向到最上面"。这里在
15670
- // prevMsgCount === 0(resetChatRenderCache 后或刚从空状态进入)
15671
- // 强制 scrollTop=0(视觉底部 = 最新消息),避免错位。
15672
- if (prevMsgCount === 0) {
15819
+ // 也就是用户反馈的"退出再回来时被重定向到最上面"
15820
+ // 关键:只在该会话视图的**首次**渲染(chatInitialRenderDone=false)
15821
+ // 才执行这个强制贴底;之后即便 prevMsgCount===0(page-refresh /
15822
+ // ws 重连等保留 sticky 的 reset 路径),也尊重 chatStickToBottom,
15823
+ // 不再把上滚的用户拽回去。
15824
+ if (prevMsgCount === 0 && !state.chatInitialRenderDone) {
15673
15825
  chatMessages.scrollTop = 0;
15674
15826
  state.chatStickToBottom = true;
15675
15827
  clearChatUnread({ removeDivider: true });
15828
+ state.chatInitialRenderDone = true;
15829
+ } else if (prevMsgCount === 0 && state.chatStickToBottom) {
15830
+ // 非首次但缓存重置后的 re-render——仅在用户原本贴底时回贴。
15831
+ chatMessages.scrollTop = 0;
15676
15832
  } else if (renderWasAtBottom) {
15677
15833
  // 同一会话内的全量重渲染:用户原本贴底就保持贴底,浏览器在 innerHTML
15678
15834
  // 重置后可能把 scrollTop 钳到一个奇怪的值,这里显式拉回 0。
@@ -15805,7 +15961,7 @@
15805
15961
  for (var i = 0; i < newMessages.length; i++) {
15806
15962
  var div = document.createElement("div");
15807
15963
  var nmOrigIdx = visibleOffset + existingCount + (newMessages.length - 1 - i);
15808
- div.innerHTML = renderChatMessage(newMessages[i], roundUsageByIndex[nmOrigIdx] || null, nmOrigIdx);
15964
+ div.innerHTML = renderChatMessage(newMessages[i], roundUsageByIndex[nmOrigIdx] || null, nmOrigIdx, legacyTaskMap);
15809
15965
  var el = div.firstElementChild;
15810
15966
  if (el) {
15811
15967
  el.classList.add("animate-in");
@@ -15863,7 +16019,7 @@
15863
16019
  var currentEl = existingEls[mi];
15864
16020
  var tmpWrap = document.createElement("div");
15865
16021
  var srOrigIdx = visibleOffset + reversedMessages.length - 1 - mi;
15866
- tmpWrap.innerHTML = renderChatMessage(reversedMessages[mi], roundUsageByIndex[srOrigIdx] || null, srOrigIdx);
16022
+ tmpWrap.innerHTML = renderChatMessage(reversedMessages[mi], roundUsageByIndex[srOrigIdx] || null, srOrigIdx, legacyTaskMap);
15867
16023
  var replacementEl = tmpWrap.firstElementChild;
15868
16024
  if (!replacementEl) continue;
15869
16025
  if (currentEl.innerHTML !== replacementEl.innerHTML || currentEl.className !== replacementEl.className) {
@@ -16844,6 +17000,27 @@
16844
17000
  '<span class="avatar-name">' + escapeHtml(name) + '</span>' +
16845
17001
  '</div>';
16846
17002
  }
17003
+
17004
+ // subagent tool_result → 独立 reply 气泡(markdown 渲染)。出错时显示红色错误体,
17005
+ // 没文本时显示打字指示器(subagent 还在跑)。
17006
+ function renderSubagentReplyBubble(block, role) {
17007
+ if (!block || block.type !== "tool_result") return "";
17008
+ var text = extractToolResultText(block.content);
17009
+ var isError = block.is_error === true;
17010
+
17011
+ if (isError) {
17012
+ return '<div class="subagent-reply error">' +
17013
+ '<span class="subagent-reply-icon">✗</span>' +
17014
+ '<div class="subagent-reply-body">' + (text ? renderMarkdown(text) : escapeHtml("(无输出)")) + '</div>' +
17015
+ '</div>';
17016
+ }
17017
+
17018
+ if (!text || !String(text).trim()) {
17019
+ return '<div class="subagent-reply pending"><span class="typing-indicator"><span></span><span></span><span></span></span></div>';
17020
+ }
17021
+
17022
+ return '<div class="subagent-reply">' + renderMarkdown(text) + '</div>';
17023
+ }
16847
17024
  var PIXEL_AVATAR = {
16848
17025
  assistant: buildPixelSvg(buildCatGrid(GARFIELD_PALETTE)),
16849
17026
  user: buildPixelSvg(buildCatGrid(SHORTHAIR_PALETTE)),
@@ -16897,16 +17074,20 @@
16897
17074
  '</div>';
16898
17075
  }
16899
17076
 
16900
- function renderChatMessage(msg, roundUsage, messageIndex) {
17077
+ function renderChatMessage(msg, roundUsage, messageIndex, legacyTaskMap) {
16901
17078
  // Thinking card (deep thought) — from PTY parsing
16902
17079
  if (msg.role === "thinking") {
17080
+ // 空 / 全空白的 thinking 没有任何信息量,渲染出来只是一条带"展开"的紫色窄条,
17081
+ // 展开了也看不到内容——直接跳过。
17082
+ var ptyThinkingText = typeof msg.content === "string" ? msg.content : "";
17083
+ if (!ptyThinkingText.trim()) return "";
16903
17084
  var thinkingKey = buildExpandKey("thinking", [getMessageKey(msg, messageIndex), "pty"]);
16904
17085
  var thinkingPersisted = getPersistedExpandState(thinkingKey);
16905
17086
  var thinkingExpanded = thinkingPersisted === null ? getCardDefault("thinking") : thinkingPersisted;
16906
17087
  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)">' +
17088
+ '<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
17089
  '<span class="thinking-inline-icon">⦿</span>' +
16909
- '<span class="thinking-inline-preview">' + escapeHtml(msg.content) + '</span>' +
17090
+ '<span class="thinking-inline-preview">' + escapeHtml(ptyThinkingText) + '</span>' +
16910
17091
  '<span class="thinking-inline-action">' + (thinkingExpanded ? '收起' : '展开') + '</span>' +
16911
17092
  '</div>' +
16912
17093
  '</div>';
@@ -16924,7 +17105,7 @@
16924
17105
 
16925
17106
  // Structured content blocks (from JSON chat mode)
16926
17107
  if (Array.isArray(msg.content)) {
16927
- return renderStructuredMessage(msg, roundUsage, messageIndex);
17108
+ return renderStructuredMessage(msg, roundUsage, messageIndex, legacyTaskMap);
16928
17109
  }
16929
17110
 
16930
17111
  // Legacy string content (from PTY parsing)
@@ -16980,6 +17161,8 @@
16980
17161
  }
16981
17162
 
16982
17163
  // ── 连续同类工具调用分组 ──
17164
+ // 注意:禁止把 Task/Agent 加入 GROUPABLE_TOOLS——它们由 renderContentBlock 入口屏蔽返空,
17165
+ // 加入分组会导致空 group 包裹一堆空字符串,留下视觉空盒子。
16983
17166
  var GROUPABLE_TOOLS = { Read: 1, Glob: 1, Grep: 1, WebFetch: 1, WebSearch: 1, TodoRead: 1 };
16984
17167
 
16985
17168
  function groupConsecutiveTools(content) {
@@ -17076,16 +17259,61 @@
17076
17259
  persistElementExpandState(el, "tool-group");
17077
17260
  };
17078
17261
 
17262
+ // 老消息(SQLite 历史 turn)后端还没盖章。前端给 name === "Task"/"Agent"
17263
+ // 或 input.subagent_type 非空的 tool_use 虚拟盖章,让现有 multi-agent 渲染路径吃到。
17264
+ function deriveLegacySubagent(block) {
17265
+ if (!block || block.type !== "tool_use") return null;
17266
+ var input = block.input || {};
17267
+ var agentType = typeof input.subagent_type === "string" ? input.subagent_type : null;
17268
+ if (!agentType && block.name !== "Task" && block.name !== "Agent") return null;
17269
+ return {
17270
+ taskId: block.id,
17271
+ agentType: agentType || undefined,
17272
+ taskDescription: typeof input.description === "string" ? input.description : undefined,
17273
+ };
17274
+ }
17275
+
17276
+ // tool_result 老消息靠 tool_use_id 关联 task。由外层预扫一遍 messages,构建 task id → meta 的 map。
17277
+ function collectLegacyTaskIdMap(allMessages) {
17278
+ var map = new Map();
17279
+ if (!Array.isArray(allMessages)) return map;
17280
+ for (var i = 0; i < allMessages.length; i++) {
17281
+ var m = allMessages[i];
17282
+ if (!m || m.role !== "assistant" || !Array.isArray(m.content)) continue;
17283
+ for (var j = 0; j < m.content.length; j++) {
17284
+ var b = m.content[j];
17285
+ if (!b || b.type !== "tool_use") continue;
17286
+ var derived = b.__subagent || deriveLegacySubagent(b);
17287
+ if (derived) map.set(b.id, derived);
17288
+ }
17289
+ }
17290
+ return map;
17291
+ }
17292
+
17079
17293
  // 把一条 assistant turn 按相邻 block 的 __subagent.taskId 切成段。
17080
17294
  // 输出每段附带原数组中的 firstIndex,方便渲染时 expand key 用全局 index
17081
17295
  // 避免不同段冲突。
17082
- function splitTurnBySubagent(blocks) {
17296
+ // legacyTaskMap:老消息没有 __subagent 盖章时,按 tool_use_id 反查兜底。
17297
+ function splitTurnBySubagent(blocks, legacyTaskMap) {
17083
17298
  var segs = [];
17084
17299
  if (!Array.isArray(blocks) || !blocks.length) return segs;
17085
17300
  var current = null;
17086
17301
  for (var i = 0; i < blocks.length; i++) {
17087
17302
  var b = blocks[i];
17303
+ // __processing 占位 block(流式中的 typing indicator)没有 __subagent 盖章,
17304
+ // 强制延续上一段(若已有),避免"父-Task-占位-子内容"反复切段导致 DOM 抖动。
17305
+ // 边界:若占位是第一个 block(current 仍为 null),走正常路径开 parent 段。
17306
+ var isPlaceholder = b && b.type === "text" && b.__processing === true;
17307
+ if (isPlaceholder && current) {
17308
+ current.blocks.push(b);
17309
+ continue;
17310
+ }
17088
17311
  var sub = b && b.__subagent ? b.__subagent : null;
17312
+ if (!sub) sub = deriveLegacySubagent(b);
17313
+ // 老消息 tool_result 兜底:用 tool_use_id 反查 map
17314
+ if (!sub && b && b.type === "tool_result" && legacyTaskMap && legacyTaskMap.has(b.tool_use_id)) {
17315
+ sub = legacyTaskMap.get(b.tool_use_id);
17316
+ }
17089
17317
  var key = sub ? sub.taskId : null;
17090
17318
  if (!current || current.key !== key) {
17091
17319
  current = { key: key, subagent: sub, blocks: [], firstIndex: i };
@@ -17124,11 +17352,55 @@
17124
17352
  return html;
17125
17353
  }
17126
17354
 
17127
- function renderStructuredMessage(msg, roundUsage, messageIndex) {
17355
+ // 抽出 multi-agent 渲染共用块。assistant turn 与 user turn 都可能含 subagent 段
17356
+ // (user turn 的 Task tool_result 由后端反查盖章),但 user turn 不再输出 handoff
17357
+ // 行,避免与 assistant turn 的 handoff 重复。
17358
+ // TODO:嵌套 subagent(子 subagent 在外层 subagent 段内再切)时,
17359
+ // parentPersonaName 应是外层 subagent.name 而不是固定父 persona;目前先不处理。
17360
+ function buildMultiAgentHtml(segments, role, parentPersonaName, toolResults, messageKey, options) {
17361
+ var opts = options || {};
17362
+ var showHandoff = opts.showHandoff !== false; // 默认 true;user turn 传 false
17363
+ var html = "";
17364
+ var lastSubId = null;
17365
+ for (var si = 0; si < segments.length; si++) {
17366
+ var seg = segments[si];
17367
+ var segHtml = buildSegmentBlocksHtml(seg.blocks, seg.firstIndex, role, toolResults, messageKey);
17368
+ // 段内所有 block 都被短路返空(典型场景:父段只剩一个空 thinking)时,
17369
+ // 跳过整段。否则会渲染出"只有头像没内容"的空气泡。
17370
+ if (!segHtml || !segHtml.trim()) continue;
17371
+ if (seg.subagent) {
17372
+ var subPalette = getSubagentPalette(seg.subagent);
17373
+ if (showHandoff && lastSubId !== seg.subagent.taskId) {
17374
+ var subName = getSubagentDisplayName(seg.subagent);
17375
+ var desc = seg.subagent.taskDescription
17376
+ ? ':<span class="chat-handoff-desc">' + escapeHtml(seg.subagent.taskDescription) + '</span>'
17377
+ : '';
17378
+ html += '<div class="chat-handoff" style="--agent-color:' + subPalette.primary + '">' +
17379
+ '<span class="chat-handoff-arrow">↳</span> ' +
17380
+ escapeHtml(parentPersonaName) + ' 让 <strong>' + escapeHtml(subName) + '</strong> 帮忙' + desc +
17381
+ '</div>';
17382
+ }
17383
+ html += '<div class="chat-message-segment subagent" data-agent-id="' + escapeHtml(seg.subagent.taskId) + '" style="--agent-color:' + subPalette.primary + '">' +
17384
+ subagentAvatarHtml(seg.subagent) +
17385
+ '<div class="chat-message-content">' + segHtml + '</div>' +
17386
+ '</div>';
17387
+ lastSubId = seg.subagent.taskId;
17388
+ } else {
17389
+ html += '<div class="chat-message-segment parent">' +
17390
+ chatAvatar(role) +
17391
+ '<div class="chat-message-content">' + segHtml + '</div>' +
17392
+ '</div>';
17393
+ lastSubId = null;
17394
+ }
17395
+ }
17396
+ return html;
17397
+ }
17398
+
17399
+ function renderStructuredMessage(msg, roundUsage, messageIndex, legacyTaskMap) {
17128
17400
  var role = msg.role;
17129
17401
  var messageKey = getMessageKey(msg, messageIndex);
17130
17402
 
17131
- // 排队中的用户消息标记(subagent 不会出现在 user role
17403
+ // 排队中的用户消息标记(subagent 不会出现在 user role 的 user input 中)
17132
17404
  var isQueued = role === "user" && msg.content && msg.content.some(function(b) { return b.__queued; });
17133
17405
 
17134
17406
  if (!msg.content || msg.content.length === 0) {
@@ -17147,12 +17419,22 @@
17147
17419
  }
17148
17420
 
17149
17421
  var toolResults = buildToolResultMap(msg.content);
17422
+ var parentPersona = getStructuredChatPersona("assistant");
17150
17423
 
17151
- // user role 不会有 subagent,保持旧路径
17424
+ // user role:可能含 Task tool_result(subagent 反查盖章过的)。检测一下,
17425
+ // 有 subagent 段就走 multi-agent 渲染(不输出 handoff,避免重复)。
17152
17426
  if (role !== "assistant") {
17153
- var userHtml = buildSegmentBlocksHtml(msg.content, 0, role, toolResults, messageKey);
17427
+ var userSegments = splitTurnBySubagent(msg.content, legacyTaskMap);
17428
+ var userHasSub = userSegments.some(function(s) { return s.subagent; });
17154
17429
  var queuedClass = isQueued ? " queued" : "";
17155
17430
  var queuedBadge = isQueued ? '<span class="queued-badge">排队中</span>' : "";
17431
+ if (userHasSub) {
17432
+ var userMultiHtml = buildMultiAgentHtml(userSegments, role, parentPersona.name, toolResults, messageKey, { showHandoff: false });
17433
+ return '<div class="chat-message ' + role + queuedClass + ' multi-agent" data-message-key="' + escapeHtml(messageKey) + '">' +
17434
+ userMultiHtml + queuedBadge +
17435
+ '</div>';
17436
+ }
17437
+ var userHtml = buildSegmentBlocksHtml(msg.content, 0, role, toolResults, messageKey);
17156
17438
  return '<div class="chat-message ' + role + queuedClass + '" data-message-key="' + escapeHtml(messageKey) + '">' +
17157
17439
  chatAvatar(role) +
17158
17440
  '<div class="chat-message-content">' + userHtml + queuedBadge + '</div>' +
@@ -17160,7 +17442,7 @@
17160
17442
  }
17161
17443
 
17162
17444
  // assistant:检测是否有 subagent 段,没有就走单段渲染(兼容老消息 / 无 subagent 的 turn)
17163
- var segments = splitTurnBySubagent(msg.content);
17445
+ var segments = splitTurnBySubagent(msg.content, legacyTaskMap);
17164
17446
  var hasSubagent = segments.some(function(s) { return s.subagent; });
17165
17447
 
17166
17448
  if (!hasSubagent) {
@@ -17174,43 +17456,26 @@
17174
17456
  // 多段:父 assistant 段 + 各 subagent 段。同一根 .chat-message 容器,
17175
17457
  // 内部多个 .chat-message-segment 子段,每段自带头像;切到新 subagent 时
17176
17458
  // 插入一行 handoff 提示("勤劳初二 ↳ 让 侦探猫 帮忙")。
17177
- var parentPersona = getStructuredChatPersona("assistant");
17178
17459
  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
- }
17460
+ multiHtml += buildMultiAgentHtml(segments, role, parentPersona.name, toolResults, messageKey, { showHandoff: true });
17208
17461
  multiHtml += '</div>';
17209
17462
  return multiHtml;
17210
17463
  }
17211
17464
  function renderContentBlock(block, role, toolResults, index, messageKey) {
17212
17465
  if (!block || !block.type) return "";
17213
17466
 
17467
+ // Task/Agent tool_use 自带 __subagent.taskId === block.id(后端自盖章):
17468
+ // 不画工具卡片,由 handoff 行表达"派遣"语义。
17469
+ if (block.type === "tool_use" && block.__subagent && block.__subagent.taskId === block.id) {
17470
+ return "";
17471
+ }
17472
+ // 只有父 Task 的 tool_result(taskId === tool_use_id)走 reply bubble;
17473
+ // 子 agent 内部工具的 tool_result(taskId === parent_tool_use_id ≠ tool_use_id)
17474
+ // 走普通工具卡片,不能误判为 reply bubble。
17475
+ if (block.type === "tool_result" && block.__subagent && block.__subagent.taskId === block.tool_use_id) {
17476
+ return renderSubagentReplyBubble(block, role);
17477
+ }
17478
+
17214
17479
  switch (block.type) {
17215
17480
  case "text":
17216
17481
  if (role === "assistant" && block.__processing) {
@@ -17220,7 +17485,6 @@
17220
17485
 
17221
17486
  case "thinking":
17222
17487
  var thinkingText = block.thinking || "";
17223
- var preview = thinkingText.length > 60 ? thinkingText.slice(0, 57) + "…" : thinkingText;
17224
17488
  var isStreaming = block.thinking === undefined && block.type === "thinking";
17225
17489
  if (isStreaming) {
17226
17490
  return '<div class="thinking-inline thinking-streaming" data-thinking="">' +
@@ -17230,6 +17494,10 @@
17230
17494
  '</div>' +
17231
17495
  '</div>';
17232
17496
  }
17497
+ // 非流式分支:thinking 字段是空字符串时,UI 上只会出现一条带"展开"
17498
+ // 的紫色窄条,展开了也是空——直接不渲染,避免视觉噪音。
17499
+ if (!thinkingText.trim()) return "";
17500
+ var preview = thinkingText.length > 60 ? thinkingText.slice(0, 57) + "…" : thinkingText;
17233
17501
  var thinkingKey = buildExpandKey("thinking", [messageKey, index]);
17234
17502
  var thinkingPersisted = getPersistedExpandState(thinkingKey);
17235
17503
  var thinkingExpanded = thinkingPersisted === null ? getCardDefault("thinking") : thinkingPersisted;
@@ -515,7 +515,30 @@
515
515
  transition: width 0.3s var(--ease-out-expo), transform 0.35s var(--ease-out-expo), box-shadow 0.35s ease, opacity 0.25s ease;
516
516
  }
517
517
 
518
- .sidebar.pinned .sidebar-close {
518
+ /* 多塞一层 .sidebar-header-actions 把 specificity 抬到 0,4,0,
519
+ 压过文件后段 .sidebar-header-actions .btn-ghost.btn-sm
520
+ 与 .drawer-close-btn 的 display: inline-flex 重置规则。 */
521
+ .sidebar.pinned .sidebar-header-actions .sidebar-close {
522
+ display: none;
523
+ }
524
+
525
+ /* 窄条(collapsed)状态:明确不显示 X 关闭按钮。
526
+ 虽然 collapsed 当前必然蕴含 pinned(见 isSidebarNarrow),
527
+ 上一条 .sidebar.pinned 规则已经能把它藏住,但单独写一条更直观,
528
+ 也防止以后允许 drawer 模式下 collapse 时漏掉。
529
+ 展开(drawer 模式)下才显示关闭按钮供用户收起侧栏。 */
530
+ .sidebar.collapsed .sidebar-header-actions .sidebar-close {
531
+ display: none;
532
+ }
533
+
534
+ /* 与上方对称:drawer 模式(未 pin)下,X 关闭按钮已能把侧栏关掉,
535
+ 再放一个「收起为窄条」按钮会和 X 视觉上重复,且它的行为
536
+ (自动 pin + collapse)也容易让人误以为是关闭。
537
+ 只在 pinned 模式保留这颗,drawer 模式下隐藏。
538
+ 注意:必须用 .sidebar-header-actions 多嵌一层把 specificity 提到 0,4,0,
539
+ 否则会和文件后段 .sidebar-header-actions .btn-ghost.btn-sm (0,3,0) 打平
540
+ 而因后定义胜出,导致 drawer 模式下窄条按钮和 X 同时显示。 */
541
+ .sidebar:not(.pinned) .sidebar-header-actions .sidebar-collapse-toggle {
519
542
  display: none;
520
543
  }
521
544
 
@@ -677,15 +700,6 @@
677
700
  .sidebar-collapse-toggle.collapsed {
678
701
  color: var(--primary);
679
702
  }
680
- /* 完全关闭侧栏 — 仅在 pinned 展开态可见(drawer 自带 X;窄条态保持极简) */
681
- .sidebar .sidebar-header-actions .btn.sidebar-close-fully {
682
- display: none;
683
- flex-shrink: 0;
684
- }
685
- .sidebar.pinned:not(.collapsed) .sidebar-header-actions .btn.sidebar-close-fully {
686
- display: inline-flex;
687
- }
688
-
689
703
  /* ===== 图钉按钮 ===== */
690
704
  .sidebar-pin-toggle {
691
705
  flex-shrink: 0;
@@ -4117,12 +4131,19 @@
4117
4131
  gap: 6px;
4118
4132
  max-width: 95%;
4119
4133
  }
4134
+ /* user turn 默认 align-self: flex-end(右对齐);多段渲染时强制
4135
+ 容器内左对齐,否则 subagent 段会被推到右边,border-left 色条断裂。 */
4136
+ .chat-message.user.multi-agent {
4137
+ align-self: flex-start;
4138
+ align-items: flex-start;
4139
+ }
4120
4140
  .chat-message.multi-agent .chat-message-segment {
4121
4141
  display: flex;
4122
4142
  flex-direction: column;
4143
+ align-self: flex-start;
4123
4144
  }
4124
4145
  .chat-message.multi-agent .chat-message-segment.subagent {
4125
- padding-left: 14px;
4146
+ padding-left: 10px;
4126
4147
  margin-left: 6px;
4127
4148
  border-left: 2px solid var(--agent-color, var(--border-subtle));
4128
4149
  border-radius: 0 4px 4px 0;
@@ -4165,6 +4186,56 @@
4165
4186
  font-style: italic;
4166
4187
  }
4167
4188
 
4189
+ /* subagent 回复气泡(群聊角色完成任务后的发言) */
4190
+ .subagent-reply {
4191
+ background: color-mix(in srgb, var(--agent-color, var(--accent)) 6%, var(--bg-surface));
4192
+ border: 1px solid color-mix(in srgb, var(--agent-color, var(--accent)) 20%, transparent);
4193
+ border-radius: 10px;
4194
+ padding: 10px 14px;
4195
+ margin: 4px 0;
4196
+ line-height: 1.55;
4197
+ font-size: 0.875rem;
4198
+ color: var(--text-primary);
4199
+ word-break: break-word;
4200
+ }
4201
+
4202
+ .subagent-reply > :first-child { margin-top: 0; }
4203
+ .subagent-reply > :last-child { margin-bottom: 0; }
4204
+
4205
+ .subagent-reply.error {
4206
+ background: color-mix(in srgb, var(--danger, #e2574c) 8%, var(--bg-surface));
4207
+ border-color: color-mix(in srgb, var(--danger, #e2574c) 35%, transparent);
4208
+ }
4209
+
4210
+ .subagent-reply.pending {
4211
+ opacity: 0.7;
4212
+ padding: 14px 16px;
4213
+ }
4214
+
4215
+ .subagent-reply-icon {
4216
+ display: inline-block;
4217
+ color: var(--danger, #e2574c);
4218
+ margin-right: 6px;
4219
+ font-weight: 700;
4220
+ }
4221
+
4222
+ .subagent-reply-body {
4223
+ display: inline;
4224
+ }
4225
+
4226
+ .subagent-reply pre,
4227
+ .subagent-reply code {
4228
+ font-size: 0.82rem;
4229
+ }
4230
+
4231
+ .subagent-reply pre {
4232
+ background: rgba(0, 0, 0, 0.04);
4233
+ padding: 10px 12px;
4234
+ border-radius: 6px;
4235
+ overflow-x: auto;
4236
+ margin: 8px 0;
4237
+ }
4238
+
4168
4239
  /* ===== 消息使用量信息 ===== */
4169
4240
  .message-usage {
4170
4241
  margin-top: 8px;
@@ -6845,6 +6916,38 @@
6845
6916
  transform: scale(0.95);
6846
6917
  }
6847
6918
 
6919
+ /* "立即发送":结构化会话流式输出时显示,按下会中断当前回复立即处理新输入。
6920
+ 视觉上比 send 弱、比 stop 弱,避免被误点;和 send 之间留一点空气。 */
6921
+ .btn-circle-interrupt {
6922
+ background: rgba(160, 110, 60, 0.10);
6923
+ color: var(--accent);
6924
+ margin-right: 2px;
6925
+ border: 1px dashed rgba(197, 101, 61, 0.35);
6926
+ }
6927
+ .btn-circle-interrupt:hover {
6928
+ background: var(--accent);
6929
+ color: white;
6930
+ border-style: solid;
6931
+ transform: scale(1.08);
6932
+ }
6933
+ .btn-circle-interrupt:active {
6934
+ transform: scale(0.95);
6935
+ }
6936
+ .btn-circle-interrupt.hidden {
6937
+ display: none;
6938
+ }
6939
+
6940
+ /* send 按钮在「排队模式」下退到次要色,让相邻的"立即发送"成为视觉重点。
6941
+ 不动尺寸 / 圆形,避免 layout shift。 */
6942
+ .btn-circle-send.queue-mode {
6943
+ background: linear-gradient(180deg, rgba(197, 101, 61, 0.55) 0%, rgba(168, 82, 47, 0.55) 100%);
6944
+ box-shadow: 0 1px 4px rgba(197, 101, 61, 0.15);
6945
+ }
6946
+ .btn-circle-send.queue-mode:hover {
6947
+ background: linear-gradient(180deg, var(--accent) 0%, #a8522f 100%);
6948
+ box-shadow: 0 3px 10px rgba(197, 101, 61, 0.35);
6949
+ }
6950
+
6848
6951
  /* Prompt optimize button — top-right of the input composer */
6849
6952
  .prompt-optimize-btn {
6850
6953
  position: absolute;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@co0ontty/wand",
3
- "version": "1.30.0",
3
+ "version": "1.30.3",
4
4
  "description": "A web terminal for local CLI tools like Claude.",
5
5
  "type": "module",
6
6
  "bin": {