@co0ontty/wand 1.29.6 → 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.
@@ -86,7 +86,22 @@
86
86
  terminalFitInProgress: false,
87
87
  terminalSessionId: null,
88
88
  terminalOutput: "",
89
+ // R8: /clear marker。Claude 的 /clear 不发任何 ANSI 清屏序列,它只
90
+ // 就地把对话框重画成空、把旧对话推进 scrollback。但 wand 的
91
+ // state.terminalOutput 是 append-only buffer,softResync 一触发就
92
+ // 把 /clear 之前的历史全部重放回 wterm(用户看到"/clear 后短暂闪
93
+ // 回旧内容")。marker 表示 buffer 里"用户上次 /clear 时刻的位置",
94
+ // softResync 只重放 slice(marker),从根上避免历史被重放。
95
+ terminalOutputMarker: 0,
89
96
  terminalLiveStreamSessions: {},
97
+ // CSI ?2026h..l 同步输出缓冲:begin 时拿到 "\x1b[?2026h" 后开始缓冲,
98
+ // end 时拿到 "\x1b[?2026l" 一次性 flush 给 wterm。null 表示当前不在
99
+ // sync 包帧内。@wterm/core 0.1.8 不实现 sync output,begin/end 之间
100
+ // 每个 write 立即落到 grid + mark dirty —— 跨 server-debounce 窗口
101
+ // 时浏览器看到中间帧 + 触发 softResync 时状态机被打断,正是
102
+ // askuserquestion 菜单多份叠加的最强候选根因。
103
+ syncOutputBuffer: null,
104
+ syncOutputDeadline: 0,
90
105
  lastChunkAt: 0,
91
106
  terminalHealthTimer: null,
92
107
  lastTerminalResyncAt: 0,
@@ -223,10 +238,19 @@
223
238
  chatUnreadCount: 0,
224
239
  // state.currentMessages 中第一条未读消息的 index,-1 表示没有未读。
225
240
  chatUnreadStartIndex: -1,
226
- chatScrollThreshold: 120,
241
+ // 业界共识 150-180px:120px 在触控板/移动端惯性下边界来回弹。
242
+ chatScrollThreshold: 160,
227
243
  chatIsProgrammaticScroll: false,
228
244
  chatScrollElement: null,
229
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,
230
254
  lastForegroundSyncAt: 0,
231
255
  foregroundSyncTimer: null,
232
256
  wsReconnectAttempts: 0,
@@ -582,8 +606,19 @@
582
606
  updateChatUnreadBubble();
583
607
  return;
584
608
  }
585
- if (state.chatScrollElement && state.chatScrollHandler) {
586
- 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
+ }
587
622
  }
588
623
  state.chatScrollElement = chatMsgs;
589
624
  state.chatScrollHandler = function() {
@@ -604,7 +639,35 @@
604
639
  }
605
640
  updateChatUnreadBubble();
606
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
+ };
607
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 });
608
671
  updateChatUnreadBubble();
609
672
  }
610
673
 
@@ -936,23 +999,46 @@
936
999
  });
937
1000
  }
938
1001
 
939
- function resetChatRenderCache() {
1002
+ // options.preserveStickState=true:仅清渲染缓存,不动 sticky/未读
1003
+ // 状态。用于 page-refresh、ws 重连等"用户停留在当前会话,只是想刷新
1004
+ // DOM"的场景——不能把用户从历史位置拽回底部。
1005
+ // 默认(false):切会话 / 新建 / home 等真正"换上下文"路径用,全清。
1006
+ function resetChatRenderCache(options) {
1007
+ var opts = options || {};
940
1008
  state.lastRenderedHash = 0;
941
1009
  state.lastRenderedMsgCount = 0;
942
1010
  state.lastRenderedEmpty = null;
943
1011
  state.renderPending = false;
944
1012
  state.chatRenderedCount = state.chatPageSize;
945
1013
  state.askUserSelections = {};
946
- if (state.chatScrollElement && state.chatScrollHandler) {
947
- 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
+ }
948
1027
  }
949
1028
  state.chatScrollElement = null;
950
1029
  state.chatScrollHandler = null;
1030
+ state.chatScrollWheelHandler = null;
1031
+ state.chatScrollTouchStartHandler = null;
1032
+ state.chatScrollTouchMoveHandler = null;
951
1033
  state.chatIsProgrammaticScroll = false;
952
- // 切会话时未读状态归零、贴底重置——避免上一个会话残留的"未读气泡"。
953
- state.chatStickToBottom = true;
954
- state.chatUnreadCount = 0;
955
- 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
+ }
956
1042
  }
957
1043
 
958
1044
  function getEffectiveCwd() {
@@ -1660,6 +1746,11 @@
1660
1746
  '<button id="stop-button" class="btn-circle btn-circle-stop' + (state.selectedId ? "" : " hidden") + '" title="停止">' +
1661
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>' +
1662
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>' +
1663
1754
  '<button id="send-input-button" class="btn-circle btn-circle-send" title="发送">' +
1664
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>' +
1665
1756
  '</button>' +
@@ -3078,29 +3169,44 @@
3078
3169
  '</section>';
3079
3170
  }
3080
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
+
3081
3195
  function renderSessions() {
3082
- var activeSessions = state.sessions.filter(function(session) { return !session.archived; });
3083
3196
  var archivedSessions = state.sessions.filter(function(session) { return session.archived; });
3084
3197
  var groups = [];
3085
3198
  groups.push(renderSessionManageBar());
3086
3199
 
3087
- // Split claude history into recent (24h) and older
3088
- var recentHistorySessions = [];
3089
- if (state.claudeHistoryLoaded) {
3090
- var cutoff = Date.now() - 24 * 60 * 60 * 1000;
3091
- recentHistorySessions = getVisibleClaudeHistorySessions().filter(function(s) {
3092
- return s.timestamp && new Date(s.timestamp).getTime() > cutoff;
3093
- });
3094
- }
3200
+ var recentEntries = getRecentEntries();
3095
3201
 
3096
- if (activeSessions.length > 0 || recentHistorySessions.length > 0) {
3097
- groups.push(renderRecentGroup(activeSessions, recentHistorySessions));
3202
+ if (recentEntries.length > 0) {
3203
+ groups.push(renderRecentGroup(recentEntries));
3098
3204
  }
3099
3205
  if (archivedSessions.length > 0) {
3100
3206
  groups.push(renderArchivedGroup(archivedSessions));
3101
3207
  }
3102
3208
  groups.push(renderClaudeHistorySection());
3103
- if (activeSessions.length === 0 && archivedSessions.length === 0 && recentHistorySessions.length === 0) {
3209
+ if (recentEntries.length === 0 && archivedSessions.length === 0) {
3104
3210
  return renderSessionManageBar() + '<div class="empty-state"><strong>还没有会话记录</strong><br>点击上方「新对话」开始你的第一次对话。</div>' + renderClaudeHistorySection();
3105
3211
  }
3106
3212
  return groups.join("");
@@ -3111,24 +3217,24 @@
3111
3217
  }
3112
3218
 
3113
3219
  function renderCollapsedSessionTiles() {
3114
- var running = state.sessions.filter(function(s) {
3115
- return !s.archived && s.status === "running";
3116
- });
3117
- running.sort(function(a, b) {
3118
- var ta = a.startedAt ? new Date(a.startedAt).getTime() : 0;
3119
- var tb = b.startedAt ? new Date(b.startedAt).getTime() : 0;
3120
- return ta - tb;
3121
- });
3122
- if (running.length === 0) {
3123
- return '<div class="sidebar-collapsed-empty" title="无运行中的会话">—</div>';
3124
- }
3125
- return '<div class="sidebar-collapsed-tiles">' +
3126
- running.map(function(s, i) {
3220
+ var entries = getRecentEntries();
3221
+ if (entries.length === 0) {
3222
+ return '<div class="sidebar-collapsed-empty" title="无会话">—</div>';
3223
+ }
3224
+ var tiles = entries.map(function(e, i) {
3225
+ var idx = i + 1;
3226
+ if (e.kind === "session") {
3227
+ var s = e.ref;
3127
3228
  var activeCls = s.id === state.selectedId ? " active" : "";
3128
- var title = s.summary || s.command || ("会话 " + (i + 1));
3129
- return '<button class="sidebar-collapsed-tile' + activeCls + '" type="button" data-collapsed-session-id="' + escapeHtml(s.id) + '" title="' + escapeHtml(title) + '">' + (i + 1) + '</button>';
3130
- }).join("") +
3131
- '</div>';
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("");
3237
+ return '<div class="sidebar-collapsed-tiles">' + tiles + '</div>';
3132
3238
  }
3133
3239
 
3134
3240
  function renderSessionsListContent() {
@@ -3194,11 +3300,14 @@
3194
3300
  return '<section class="session-group">' + header + items + '</section>';
3195
3301
  }
3196
3302
 
3197
- function renderRecentGroup(activeSessions, recentHistorySessions) {
3303
+ function renderRecentGroup(entries) {
3198
3304
  var html = '<section class="session-group">' +
3199
3305
  '<div class="session-group-title">最近</div>';
3200
- html += activeSessions.map(function(session) { return renderSessionItem(session, "sessions"); }).join("");
3201
- 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("");
3202
3311
  html += '</section>';
3203
3312
  return html;
3204
3313
  }
@@ -4918,6 +5027,32 @@
4918
5027
  return "";
4919
5028
  }
4920
5029
 
5030
+ /** Get the most recent user-sent text from messages (for narrow-strip hover bubble). */
5031
+ function getSessionLatestUserText(session) {
5032
+ var msgs = session && session.messages;
5033
+ if (!msgs || msgs.length === 0) return "";
5034
+ for (var i = msgs.length - 1; i >= 0; i--) {
5035
+ var msg = msgs[i];
5036
+ if (!msg || msg.role !== "user") continue;
5037
+ var content = msg.content;
5038
+ if (typeof content === "string") {
5039
+ var t = content.trim();
5040
+ if (t) return t;
5041
+ continue;
5042
+ }
5043
+ if (Array.isArray(content)) {
5044
+ for (var j = 0; j < content.length; j++) {
5045
+ var block = content[j];
5046
+ if (!block || block.type !== "text" || !block.text) continue;
5047
+ if (block.__queued) continue;
5048
+ var bt = String(block.text).trim();
5049
+ if (bt) return bt;
5050
+ }
5051
+ }
5052
+ }
5053
+ return "";
5054
+ }
5055
+
4921
5056
  /** Get the last meaningful assistant text from messages for notification/display */
4922
5057
  function getLastAssistantSummary(session) {
4923
5058
  var msgs = session && session.messages;
@@ -5504,8 +5639,12 @@
5504
5639
  if (sessionsList) {
5505
5640
  sessionsList.addEventListener("click", handleSessionItemClick);
5506
5641
  sessionsList.addEventListener("keydown", handleSessionItemKeydown);
5642
+ sessionsList.addEventListener("mouseover", handleCollapsedTileHover);
5643
+ sessionsList.addEventListener("mouseout", handleCollapsedTileLeave);
5507
5644
  initSwipeToDelete(sessionsList);
5508
5645
  }
5646
+ window.addEventListener("scroll", hideCollapsedTileBubble, true);
5647
+ window.addEventListener("resize", hideCollapsedTileBubble);
5509
5648
 
5510
5649
  // Claude session ID badge click-to-copy (event delegation on document)
5511
5650
  document.addEventListener("click", handleClaudeIdCopy);
@@ -5833,6 +5972,11 @@
5833
5972
  closeSessionsDrawer();
5834
5973
  sendOrStart();
5835
5974
  });
5975
+ var interruptSendBtn = document.getElementById("interrupt-send-button");
5976
+ if (interruptSendBtn) interruptSendBtn.addEventListener("click", function() {
5977
+ closeSessionsDrawer();
5978
+ sendOrStart({ interrupt: true });
5979
+ });
5836
5980
  var stopBtn = document.getElementById("stop-button");
5837
5981
  if (stopBtn) stopBtn.addEventListener("click", stopSession);
5838
5982
  var modeSelect = document.getElementById("chat-mode-select");
@@ -6081,7 +6225,8 @@
6081
6225
  return;
6082
6226
  }
6083
6227
  softResyncTerminal();
6084
- resetChatRenderCache();
6228
+ // 用户停留在当前会话,只是想刷一下 DOM——保留其阅读位置和 sticky 状态。
6229
+ resetChatRenderCache({ preserveStickState: true });
6085
6230
  scheduleChatRender(true);
6086
6231
  });
6087
6232
  var jumpBottomBtn = document.getElementById("terminal-jump-bottom");
@@ -6590,11 +6735,31 @@
6590
6735
  if (!target || !(target instanceof Element)) return;
6591
6736
 
6592
6737
  var collapsedTile = target.closest(".sidebar-collapsed-tile");
6593
- if (collapsedTile && collapsedTile instanceof HTMLElement && collapsedTile.dataset.collapsedSessionId) {
6594
- event.preventDefault();
6595
- event.stopPropagation();
6596
- activateSessionItem(collapsedTile.dataset.collapsedSessionId);
6597
- return;
6738
+ if (collapsedTile && collapsedTile instanceof HTMLElement) {
6739
+ if (collapsedTile.dataset.collapsedSessionId) {
6740
+ event.preventDefault();
6741
+ event.stopPropagation();
6742
+ activateSessionItem(collapsedTile.dataset.collapsedSessionId);
6743
+ return;
6744
+ }
6745
+ if (collapsedTile.dataset.collapsedHistoryId) {
6746
+ event.preventDefault();
6747
+ event.stopPropagation();
6748
+ var historyCid = collapsedTile.dataset.collapsedHistoryId;
6749
+ var historyCwd = collapsedTile.dataset.cwd || "";
6750
+ resumeClaudeHistorySession(historyCid, historyCwd)
6751
+ .then(function(data) {
6752
+ if (data && data.id) {
6753
+ state.selectedId = data.id;
6754
+ persistSelectedId();
6755
+ state.drafts[data.id] = "";
6756
+ loadSessions().then(function() {
6757
+ selectSession(data.id);
6758
+ });
6759
+ }
6760
+ });
6761
+ return;
6762
+ }
6598
6763
  }
6599
6764
 
6600
6765
  var historyToggle = target.closest("#claude-history-toggle");
@@ -7145,10 +7310,93 @@
7145
7310
  return out;
7146
7311
  }
7147
7312
 
7313
+ // CSI ?2026 同步输出(DEC mode 2026)的 wand 软实现。Claude Code 用
7314
+ // \x1b[?2026h ... 重画 ... \x1b[?2026l
7315
+ // 包裹每一帧 askuserquestion / model / 任意菜单的原地重绘,期望终端
7316
+ // 在 end 之前不渲染中间态。@wterm/core 0.1.8 未实现 sync output,于是
7317
+ // 我们在 JS 层先 buffer,遇到 end 时一次性下发。这条修复直接解决
7318
+ // 菜单逐帧叠加(image 2 的主因)。
7319
+ //
7320
+ // 安全护栏:
7321
+ // - 单帧字节 > SYNC_OUTPUT_MAX_BYTES → 强制 flush(防 buffer 爆)
7322
+ // - 单帧滞留 > SYNC_OUTPUT_MAX_BUFFER_MS → 强制 flush(防 begin 没 end)
7323
+ // - 没有 ?2026 字节流时透传,零开销
7324
+ var SYNC_OUTPUT_BEGIN = "\x1b[?2026h";
7325
+ var SYNC_OUTPUT_END = "\x1b[?2026l";
7326
+ var SYNC_OUTPUT_MAX_BUFFER_MS = 200;
7327
+ var SYNC_OUTPUT_MAX_BYTES = 256 * 1024;
7328
+
7329
+ function processSyncOutputFraming(data) {
7330
+ if (!data) return data;
7331
+ // 快路径:当前不在 sync 内、本批数据也不含 begin → 直接透传
7332
+ if (state.syncOutputBuffer === null && data.indexOf(SYNC_OUTPUT_BEGIN) === -1) {
7333
+ return data;
7334
+ }
7335
+ var out = "";
7336
+ var i = 0;
7337
+ while (i < data.length) {
7338
+ if (state.syncOutputBuffer !== null) {
7339
+ // 在 sync 内:扫到 end 才 flush
7340
+ var endIdx = data.indexOf(SYNC_OUTPUT_END, i);
7341
+ if (endIdx === -1) {
7342
+ state.syncOutputBuffer += data.slice(i);
7343
+ if (state.syncOutputBuffer.length > SYNC_OUTPUT_MAX_BYTES
7344
+ || Date.now() > state.syncOutputDeadline) {
7345
+ // 护栏:超长/超时强制 flush,避免永久卡死
7346
+ out += state.syncOutputBuffer;
7347
+ state.syncOutputBuffer = null;
7348
+ }
7349
+ return out;
7350
+ }
7351
+ state.syncOutputBuffer += data.slice(i, endIdx + SYNC_OUTPUT_END.length);
7352
+ out += state.syncOutputBuffer;
7353
+ state.syncOutputBuffer = null;
7354
+ i = endIdx + SYNC_OUTPUT_END.length;
7355
+ } else {
7356
+ // 不在 sync 内:扫 begin
7357
+ var beginIdx = data.indexOf(SYNC_OUTPUT_BEGIN, i);
7358
+ if (beginIdx === -1) {
7359
+ out += data.slice(i);
7360
+ return out;
7361
+ }
7362
+ // begin 之前的字节立即透传给 wterm
7363
+ out += data.slice(i, beginIdx);
7364
+ state.syncOutputBuffer = SYNC_OUTPUT_BEGIN;
7365
+ state.syncOutputDeadline = Date.now() + SYNC_OUTPUT_MAX_BUFFER_MS;
7366
+ i = beginIdx + SYNC_OUTPUT_BEGIN.length;
7367
+ }
7368
+ }
7369
+ return out;
7370
+ }
7371
+
7372
+ function flushSyncOutputBuffer() {
7373
+ if (state.syncOutputBuffer !== null) {
7374
+ var buffered = state.syncOutputBuffer;
7375
+ state.syncOutputBuffer = null;
7376
+ return buffered;
7377
+ }
7378
+ return "";
7379
+ }
7380
+
7381
+ // NEW-B (DA1/XTVERSION 应答) 已暂缓:实测在 PTY ECHO 阶段(claude
7382
+ // 启动早期 / claude 不在 raw mode 的窗口)回灌的字节会被 PTY 自动
7383
+ // echo 到 stdout 并显示成 ^[[?6c^[P>|wterm-wand^[\ 字面字符,污染
7384
+ // 终端。需要先在服务端 ProcessManager 写到 PTY master 时识别 ECHO
7385
+ // 状态再决定是否回包,挪到 Phase 2 重新设计。
7386
+
7148
7387
  function wandTerminalWrite(terminal, data) {
7149
7388
  if (!terminal || data == null) return;
7150
7389
  if (!state.wideParserState) state.wideParserState = createWideParserState();
7151
- terminal.write(widePadAnsi(data, state.wideParserState));
7390
+ var padded = widePadAnsi(data, state.wideParserState);
7391
+ var framed = processSyncOutputFraming(padded);
7392
+ if (framed) terminal.write(framed);
7393
+ // R6: 在 chunk 热路径上识别原地重绘序列(CSI nA/B/C/D/f/H/J/K),
7394
+ // 节流安排一次 softResync 兜底。Claude 用相对光标位移重画菜单时,
7395
+ // 如果 NEW-A 的 sync output buffer 因某种原因没拦截到完整帧(比如
7396
+ // ?2026 begin 之后跨 200ms 超时强制 flush),CSI 序列残留会让 wterm
7397
+ // 错位。此 fallback 仅在真出现错位序列时触发,正常输出零开销。
7398
+ // 与 R2 策略 A 配合:移除被动 5 处触发后,这是唯一的主动救场路径。
7399
+ maybeScheduleResyncForChunk(data);
7152
7400
  // wterm.write 内部用 5px 阈值判定"在底部",下一帧 _doRender 据此强制
7153
7401
  // scrollTop = scrollHeight。这与 wand 的 autoFollow("真正到底"才为
7154
7402
  // true,2px 阈值)独立,会把用户主动向上滚的几像素吞掉。覆写为 wand
@@ -7195,10 +7443,27 @@
7195
7443
  // scrollback),所以必须限长,否则长跑会话每次 resync 都喂几 MB 给 wterm。
7196
7444
  // 裁切优先在行边界(ANSI 状态机此时一定 idle,重放等价),找不到再按字节切
7197
7445
  // 并避开 UTF-16 半截 / ANSI 半截。
7198
- var CLIENT_OUTPUT_MAX = 256 * 1024;
7199
- var CLIENT_OUTPUT_TRIM_AT = 320 * 1024;
7446
+ //
7447
+ // R10: 客户端 buffer 必须 < 服务端 PTY_OUTPUT_MAX_SIZE=200KB,否则长跑会话
7448
+ // 服务端先于客户端裁头,发 init 时携带的 output 是字节 ~56KB 起的尾段,
7449
+ // 与客户端本地 0..256KB 的完整 buffer 做 prefix 检查必然失败 → fall back
7450
+ // 到 replace 全量重写 → 每次 ws-reconnect / 切 tab 都踩一次 softResync 灾难。
7451
+ // 让 client < server 保证客户端永远是服务端的子集,prefix 永远成立。
7452
+ var CLIENT_OUTPUT_MAX = 160 * 1024;
7453
+ var CLIENT_OUTPUT_TRIM_AT = 192 * 1024;
7200
7454
  function clampClientTerminalOutput(buf) {
7201
7455
  if (!buf || buf.length <= CLIENT_OUTPUT_TRIM_AT) return buf;
7456
+ var preTrimLen = buf.length;
7457
+ // 内部 helper:根据裁掉的字节数同步缩减 marker,保证 marker 始终指向
7458
+ // "/clear 之后的字节"。如果 marker 落到了被裁掉的区间里,clamp 到 0
7459
+ // (/clear 之前的历史本来就要丢,marker=0 等于 fall back 重放全部)。
7460
+ var _adjustMarker = function(trimmedLen) {
7461
+ if (typeof state === "undefined" || !state) return;
7462
+ var mk = state.terminalOutputMarker | 0;
7463
+ if (mk <= 0) return;
7464
+ var dropped = preTrimLen - trimmedLen;
7465
+ state.terminalOutputMarker = mk > dropped ? mk - dropped : 0;
7466
+ };
7202
7467
  var start = buf.length - CLIENT_OUTPUT_MAX;
7203
7468
  // UTF-16 low surrogate
7204
7469
  if (start > 0 && start < buf.length) {
@@ -7209,7 +7474,11 @@
7209
7474
  var LOOKAHEAD = 4096;
7210
7475
  var upper = Math.min(start + LOOKAHEAD, buf.length);
7211
7476
  for (var i = start; i < upper; i++) {
7212
- if (buf.charCodeAt(i) === 0x0a) return buf.slice(i + 1);
7477
+ if (buf.charCodeAt(i) === 0x0a) {
7478
+ var trimmed1 = buf.slice(i + 1);
7479
+ _adjustMarker(trimmed1.length);
7480
+ return trimmed1;
7481
+ }
7213
7482
  }
7214
7483
  // 没换行 → 检查 start 是否落在未结束的 ESC 序列里
7215
7484
  var lookback = Math.max(0, start - 256);
@@ -7232,12 +7501,16 @@
7232
7501
  for (var m = start; m < ahead; m++) {
7233
7502
  var cm = buf.charCodeAt(m);
7234
7503
  if (cm === 0x07 || (cm >= 0x40 && cm <= 0x7e)) {
7235
- return buf.slice(m + 1);
7504
+ var trimmed2 = buf.slice(m + 1);
7505
+ _adjustMarker(trimmed2.length);
7506
+ return trimmed2;
7236
7507
  }
7237
7508
  }
7238
7509
  }
7239
7510
  }
7240
- return buf.slice(start);
7511
+ var trimmed3 = buf.slice(start);
7512
+ _adjustMarker(trimmed3.length);
7513
+ return trimmed3;
7241
7514
  }
7242
7515
 
7243
7516
  function resetTerminal() {
@@ -7250,12 +7523,16 @@
7250
7523
  if (typeof state.terminal.reset === "function") {
7251
7524
  state.terminal.reset();
7252
7525
  resetWideParserState();
7526
+ state.syncOutputBuffer = null;
7527
+ state.syncOutputDeadline = 0;
7253
7528
  return;
7254
7529
  }
7255
7530
  if (typeof state.terminal.write === "function") {
7256
7531
  state.terminal.write("\x1bc");
7257
7532
  }
7258
7533
  resetWideParserState();
7534
+ state.syncOutputBuffer = null;
7535
+ state.syncOutputDeadline = 0;
7259
7536
  }
7260
7537
 
7261
7538
  // Reset wterm WASM grid and replay the full output buffer to clear stale
@@ -7274,10 +7551,17 @@
7274
7551
  function softResyncTerminal(options) {
7275
7552
  if (!state.terminal || !state.terminalOutput) return false;
7276
7553
  var opts = options || {};
7277
- var bufLen = state.terminalOutput.length;
7554
+ // R8: 只重放 marker 之后的字节。marker = 0 时等同于"重放整段"(与旧
7555
+ // 行为一致);用户输入过 /clear 后 marker 标到当时 buffer 长度,重放
7556
+ // 跳过 /clear 之前的历史,杜绝"/clear 后短暂闪回旧内容"。
7557
+ var marker = state.terminalOutputMarker | 0;
7558
+ if (marker < 0) marker = 0;
7559
+ if (marker > state.terminalOutput.length) marker = state.terminalOutput.length;
7560
+ var replaySource = marker > 0 ? state.terminalOutput.slice(marker) : state.terminalOutput;
7561
+ var bufLen = replaySource.length;
7278
7562
  var startedAt = (typeof performance !== "undefined" && performance.now) ? performance.now() : Date.now();
7279
7563
  resetTerminal();
7280
- wandTerminalWrite(state.terminal, state.terminalOutput);
7564
+ wandTerminalWrite(state.terminal, replaySource);
7281
7565
  state.lastTerminalResyncAt = Date.now();
7282
7566
  maybeScrollTerminalToBottom("output");
7283
7567
  if (!opts.skipFit) ensureTerminalFit("refresh");
@@ -7317,9 +7601,14 @@
7317
7601
  // 触发用 leading + tail 节流而非 debounce:用户持续按键时每次 chunk 都会
7318
7602
  // reset debounce timer,永远等不到静默期。leading 立即 resync、窗口内
7319
7603
  // 用尾巴 timer 收尾,不依赖按键停顿。
7604
+ // R6 chunk 热路径救场 throttle:原值 400/350 让 Claude thinking 期间
7605
+ // 大量 CSI A/B/K 重绘(spinner、状态行)每秒触发 ~2.5 次 softResync,
7606
+ // 13 次/5s 直接撞警戒线。NEW-A 已经把 askuserquestion 这种 ?2026 包帧
7607
+ // 重绘原子化,R6 退化为"NEW-A 失手时的弱兜底",频率拉到 1.5s/0.8s
7608
+ // 即可。代价:错位状态最长滞留 1.5s 才修,可接受。
7320
7609
  var IN_PLACE_REDRAW_RE = /\x1b\[\d*(?:;\d*)?[ABCDfHJK]/;
7321
- var RESYNC_THROTTLE_MS = 400;
7322
- var RESYNC_TAIL_MS = 350;
7610
+ var RESYNC_THROTTLE_MS = 1500;
7611
+ var RESYNC_TAIL_MS = 800;
7323
7612
  var _resyncChunkLastAt = 0;
7324
7613
  var _resyncChunkTailTimer = null;
7325
7614
  function maybeScheduleResyncForChunk(chunk) {
@@ -7368,6 +7657,7 @@
7368
7657
  resetTerminal();
7369
7658
  currentOutput = "";
7370
7659
  state.terminalOutput = "";
7660
+ state.terminalOutputMarker = 0; // R8: 切会话重置 /clear marker
7371
7661
  state.terminalAutoFollow = true;
7372
7662
  clearTerminalScrollIdleTimer();
7373
7663
  updateTerminalJumpToBottomButton();
@@ -7410,6 +7700,11 @@
7410
7700
 
7411
7701
  state.terminalSessionId = nextSessionId;
7412
7702
  state.terminalOutput = normalizedOutput;
7703
+ // R8: syncTerminalBuffer 是整段 replace / sessionChanged 路径,旧
7704
+ // marker 已不属于新 buffer,重置为 0。append-delta 子路径(startsWith
7705
+ // 命中那条)虽然在 buffer 末尾延伸,但 normalizedOutput 也是延续值,
7706
+ // 把 marker 截到不超过新长度即可;为简单起见统一 reset 0。
7707
+ state.terminalOutputMarker = 0;
7413
7708
  if (shouldScroll && (wrote || sessionChanged || mode === "replace")) {
7414
7709
  maybeScrollTerminalToBottom(sessionChanged || mode === "replace" ? "force" : "output");
7415
7710
  } else {
@@ -7698,6 +7993,11 @@
7698
7993
  if (canAutoResumeSession(session)) return "";
7699
7994
  return "会话已结束";
7700
7995
  }
7996
+ // 结构化会话在出 token 时,输入框仍然可用——告诉用户默认行为是排队,
7997
+ // 想插队请按右侧的 » 按钮。短语保持单行不换行。
7998
+ if (isStructuredSession(session) && session.structuredState && session.structuredState.inFlight) {
7999
+ return "回复中…继续输入将排队(» 立即发送)";
8000
+ }
7701
8001
  return "";
7702
8002
  }
7703
8003
 
@@ -8111,6 +8411,7 @@
8111
8411
  var hasSession = !!state.selectedId;
8112
8412
  var terminalContainer = document.getElementById("output");
8113
8413
  var chatContainer = document.getElementById("chat-output");
8414
+ var blankChat = document.getElementById("blank-chat");
8114
8415
  var selectedSession = state.sessions.find(function(s) { return s.id === state.selectedId; });
8115
8416
  var structured = isStructuredSession(selectedSession);
8116
8417
  var showTerminal = hasSession && !structured && state.currentView === "terminal";
@@ -8129,6 +8430,14 @@
8129
8430
  chatContainer.classList.toggle("active", showChat);
8130
8431
  chatContainer.classList.toggle("hidden", !showChat);
8131
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
+ }
8132
8441
  if (chatContainer && showChat) {
8133
8442
  ensureChatMessagesContainer(chatContainer);
8134
8443
  }
@@ -8204,13 +8513,19 @@
8204
8513
  // redraw sequences from Claude CLI. When they appear or dismiss, schedule a
8205
8514
  // debounced terminal resync so residual DOM rows get cleaned up automatically
8206
8515
  // — same fix the user used to have to reach for via the refresh button.
8207
- var prevEsc = prevSession && prevSession.pendingEscalation ? 1 : 0;
8208
- var nextEsc = updatedSession && updatedSession.pendingEscalation ? 1 : 0;
8209
- var prevBlocked = prevSession && prevSession.permissionBlocked ? 1 : 0;
8210
- var nextBlocked = updatedSession && updatedSession.permissionBlocked ? 1 : 0;
8211
- if (prevEsc !== nextEsc || prevBlocked !== nextBlocked) {
8212
- scheduleSoftResyncTerminal(200);
8213
- }
8516
+ // R2 策略 A:移除 permissionBlocked / pendingEscalation 翻转触发的
8517
+ // softResync。原本是为了"权限菜单消失后清掉残留 DOM 行",但 softResync
8518
+ // 全量重放在 fresh buffer 上会把 Claude 用相对位移画的菜单帧顺序堆叠
8519
+ // (截图 2 的根因之一)。NEW-A(CSI ?2026 同步输出缓冲)已经把菜单帧
8520
+ // 渲染原子化,R6(wandTerminalWrite 内的 maybeScheduleResyncForChunk)
8521
+ // 在出现原地重绘序列时兜底。这条翻转触发现在是多余且有害的。
8522
+ // var prevEsc = prevSession && prevSession.pendingEscalation ? 1 : 0;
8523
+ // var nextEsc = updatedSession && updatedSession.pendingEscalation ? 1 : 0;
8524
+ // var prevBlocked = prevSession && prevSession.permissionBlocked ? 1 : 0;
8525
+ // var nextBlocked = updatedSession && updatedSession.permissionBlocked ? 1 : 0;
8526
+ // if (prevEsc !== nextEsc || prevBlocked !== nextBlocked) {
8527
+ // scheduleSoftResyncTerminal(200);
8528
+ // }
8214
8529
  }
8215
8530
  // When a session transitions to a non-running state, try flushing cross-session queue
8216
8531
  if (normalizedSnapshot.status && normalizedSnapshot.status !== "running" && state.crossSessionQueue.length > 0) {
@@ -8387,7 +8702,8 @@
8387
8702
  } else if (state.selectedId) {
8388
8703
  var sel = state.sessions.find(function(s) { return s.id === state.selectedId; });
8389
8704
  if (isStructuredSession(sel)) {
8390
- resetChatRenderCache();
8705
+ // ws 重连后的同会话刷新——保留用户阅读位置。
8706
+ resetChatRenderCache({ preserveStickState: true });
8391
8707
  scheduleChatRender(true);
8392
8708
  }
8393
8709
  }
@@ -8427,6 +8743,7 @@
8427
8743
  var countEl = document.getElementById("session-count");
8428
8744
  if (listEl) listEl.innerHTML = renderSessionsListContent();
8429
8745
  if (countEl) countEl.textContent = String(state.sessions.length);
8746
+ if (typeof hideCollapsedTileBubble === "function") hideCollapsedTileBubble();
8430
8747
  updateShellChrome();
8431
8748
  // Re-render cross-session queue (container may have been destroyed by DOM rebuild)
8432
8749
  if (state.crossSessionQueue.length > 0) renderCrossSessionQueue();
@@ -8481,6 +8798,7 @@
8481
8798
  if (!selectedSession) {
8482
8799
  state.terminalSessionId = null;
8483
8800
  state.terminalOutput = "";
8801
+ state.terminalOutputMarker = 0; // R8: 取消选中会话时重置 /clear marker
8484
8802
  }
8485
8803
  // 之前这里会用 selectedSession.output 再 syncTerminalBuffer 一次。
8486
8804
  // 但 updateShellChrome 在 updateSessionsList、status 推送、init
@@ -8692,6 +9010,69 @@
8692
9010
  updateLayoutState();
8693
9011
  }
8694
9012
 
9013
+ var collapsedTileBubbleEl = null;
9014
+ function ensureCollapsedTileBubble() {
9015
+ if (collapsedTileBubbleEl && document.body.contains(collapsedTileBubbleEl)) {
9016
+ return collapsedTileBubbleEl;
9017
+ }
9018
+ collapsedTileBubbleEl = document.createElement("div");
9019
+ collapsedTileBubbleEl.className = "sidebar-tile-bubble";
9020
+ collapsedTileBubbleEl.setAttribute("role", "tooltip");
9021
+ document.body.appendChild(collapsedTileBubbleEl);
9022
+ return collapsedTileBubbleEl;
9023
+ }
9024
+ function hideCollapsedTileBubble() {
9025
+ if (collapsedTileBubbleEl) collapsedTileBubbleEl.classList.remove("visible");
9026
+ }
9027
+ function showCollapsedTileBubble(tile, text) {
9028
+ if (!text) { hideCollapsedTileBubble(); return; }
9029
+ var bubble = ensureCollapsedTileBubble();
9030
+ bubble.textContent = text.length > 400 ? text.slice(0, 400) + "…" : text;
9031
+ var rect = tile.getBoundingClientRect();
9032
+ bubble.classList.add("visible");
9033
+ // Measure after content set; clamp vertically to viewport.
9034
+ var bubbleRect = bubble.getBoundingClientRect();
9035
+ var centerY = rect.top + rect.height / 2;
9036
+ var top = centerY - bubbleRect.height / 2;
9037
+ var minTop = 8;
9038
+ var maxTop = window.innerHeight - bubbleRect.height - 8;
9039
+ if (top < minTop) top = minTop;
9040
+ if (top > maxTop) top = Math.max(minTop, maxTop);
9041
+ bubble.style.left = (rect.right + 12) + "px";
9042
+ bubble.style.top = top + "px";
9043
+ bubble.style.setProperty("--bubble-tail-y", (centerY - top) + "px");
9044
+ }
9045
+ function getCollapsedTileBubbleText(tile) {
9046
+ if (tile.dataset.collapsedSessionId) {
9047
+ var session = state.sessions.find(function(s) { return s.id === tile.dataset.collapsedSessionId; });
9048
+ if (!session) return "";
9049
+ var latest = getSessionLatestUserText(session);
9050
+ if (latest) return latest;
9051
+ return session.summary || session.command || "";
9052
+ }
9053
+ if (tile.dataset.collapsedHistoryId) {
9054
+ var hist = state.claudeHistory.find(function(s) { return s.claudeSessionId === tile.dataset.collapsedHistoryId; });
9055
+ if (hist && hist.firstUserMessage) return hist.firstUserMessage;
9056
+ }
9057
+ return "";
9058
+ }
9059
+ function handleCollapsedTileHover(event) {
9060
+ var target = event.target;
9061
+ if (!target || !(target instanceof Element)) return;
9062
+ var tile = target.closest(".sidebar-collapsed-tile");
9063
+ if (!tile) { hideCollapsedTileBubble(); return; }
9064
+ var text = getCollapsedTileBubbleText(tile);
9065
+ if (!text) { hideCollapsedTileBubble(); return; }
9066
+ showCollapsedTileBubble(tile, text);
9067
+ }
9068
+ function handleCollapsedTileLeave(event) {
9069
+ var related = event.relatedTarget;
9070
+ if (related && related instanceof Element && related.closest(".sidebar-collapsed-tile")) {
9071
+ return;
9072
+ }
9073
+ hideCollapsedTileBubble();
9074
+ }
9075
+
8695
9076
  function toggleSidebarCollapsed() {
8696
9077
  if (isMobileLayout()) return;
8697
9078
  // 在 drawer 模式(未 pin)下点 collapse 视为「先固定、再收起为窄条」——
@@ -10287,17 +10668,42 @@
10287
10668
  return body;
10288
10669
  }
10289
10670
 
10671
+ // 会话创建路径:保证 wterm 已经按真实容器尺寸校准,再向服务端 POST
10672
+ // 新会话——否则 withTerminalDimensions 拿不到 cols/rows,body 不带尺寸
10673
+ // → 服务端兜底 120/36 → Claude 按 120 列画 banner/box → wterm 实际渲
10674
+ // 染宽 ≠ 120 → 横线断行(图 1 现象)。带 2s 兜底超时,避免
10675
+ // initTerminal 失败时 UI 永久卡在"创建会话"按钮。
10676
+ function ensureTerminalReady() {
10677
+ if (state.terminal && state.terminal.cols) return Promise.resolve();
10678
+ return new Promise(function(resolve) {
10679
+ var done = false;
10680
+ var settle = function() { if (!done) { done = true; resolve(); } };
10681
+ var hardTimeout = setTimeout(settle, 2000);
10682
+ try { initTerminal(); } catch (e) {}
10683
+ requestAnimationFrame(function() {
10684
+ requestAnimationFrame(function() {
10685
+ if (state.terminal && state.terminal.cols) {
10686
+ clearTimeout(hardTimeout);
10687
+ settle();
10688
+ }
10689
+ });
10690
+ });
10691
+ });
10692
+ }
10693
+
10290
10694
  function quickStartSession() {
10291
10695
  var command = getPreferredTool();
10292
10696
  var defaultCwd = getEffectiveCwd();
10293
10697
  var defaultMode = getSafeModeForTool(command, (state.config && state.config.defaultMode) ? state.config.defaultMode : "default");
10294
10698
  state.preferredCommand = command;
10295
10699
  state.chatMode = getSafeModeForTool(command, state.chatMode);
10296
- fetch("/api/commands", {
10700
+ ensureTerminalReady().then(function() {
10701
+ return fetch("/api/commands", {
10297
10702
  method: "POST",
10298
10703
  headers: { "Content-Type": "application/json" },
10299
10704
  credentials: "same-origin",
10300
10705
  body: JSON.stringify(withTerminalDimensions({ command: command, provider: command, cwd: defaultCwd, mode: defaultMode }))
10706
+ });
10301
10707
  })
10302
10708
  .then(function(res) { return res.json(); })
10303
10709
  .then(function(data) {
@@ -10374,7 +10780,8 @@
10374
10780
  state.preferredCommand = command;
10375
10781
  syncComposerModeSelect();
10376
10782
 
10377
- fetch("/api/commands", {
10783
+ ensureTerminalReady().then(function() {
10784
+ return fetch("/api/commands", {
10378
10785
  method: "POST",
10379
10786
  headers: { "Content-Type": "application/json" },
10380
10787
  credentials: "same-origin",
@@ -10385,6 +10792,7 @@
10385
10792
  mode: mode,
10386
10793
  worktreeEnabled: worktreeEnabled
10387
10794
  }))
10795
+ });
10388
10796
  })
10389
10797
  .then(function(res) { return res.json(); })
10390
10798
  .then(function(data) {
@@ -10599,7 +11007,10 @@
10599
11007
  return;
10600
11008
  }
10601
11009
  event.preventDefault();
10602
- sendInputFromBox();
11010
+ // Cmd/Ctrl+Enter → 立即发送(中断当前回复)。仅对正在 inFlight 的
11011
+ // 结构化会话生效;其它情况下退化为普通发送,避免无谓的中断信号。
11012
+ var interruptShortcut = !!(event.metaKey || event.ctrlKey);
11013
+ sendInputFromBox(interruptShortcut ? { interrupt: true } : undefined);
10603
11014
  return;
10604
11015
  }
10605
11016
 
@@ -11308,7 +11719,8 @@
11308
11719
  });
11309
11720
  }
11310
11721
 
11311
- function sendOrStart() {
11722
+ function sendOrStart(opts) {
11723
+ opts = opts || {};
11312
11724
  // Support welcome input as well as the main input box
11313
11725
  var welcomeInput = document.getElementById("welcome-input");
11314
11726
  var inputBox = document.getElementById("input-box");
@@ -11319,7 +11731,7 @@
11319
11731
  // If we have a selected ID, try to send input to it
11320
11732
  if (state.selectedId) {
11321
11733
  if (value) {
11322
- sendInputFromBox();
11734
+ sendInputFromBox(opts);
11323
11735
  }
11324
11736
  return;
11325
11737
  }
@@ -11418,7 +11830,9 @@
11418
11830
  }
11419
11831
 
11420
11832
 
11421
- function sendInputFromBox() {
11833
+ function sendInputFromBox(opts) {
11834
+ opts = opts || {};
11835
+ var interruptFlag = !!opts.interrupt;
11422
11836
  if (state.terminalInteractive) {
11423
11837
  showToast("终端交互模式开启时,请直接在终端中输入。", "info");
11424
11838
  return Promise.resolve();
@@ -11457,7 +11871,7 @@
11457
11871
  if (todoEl) todoEl.classList.add("hidden");
11458
11872
 
11459
11873
  if (isStructuredSession(selectedSession)) {
11460
- return postStructuredInput(finalValue, inputBox, selectedSession);
11874
+ return postStructuredInput(finalValue, inputBox, selectedSession, { interrupt: interruptFlag });
11461
11875
  }
11462
11876
 
11463
11877
  var submitChunks = getTerminalSubmitChunks(selectedSession, finalValue);
@@ -11505,8 +11919,13 @@
11505
11919
  // 防止同一会话并发提交(快速双击 / 重复触发)
11506
11920
  var _structuredSubmittingSessions = {};
11507
11921
 
11508
- function postStructuredInput(input, inputBox, session) {
11509
- 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 });
11510
11929
  if (!state.selectedId || !input) return Promise.resolve();
11511
11930
  if (!session) {
11512
11931
  showToast("会话不存在,请重新选择或新建会话。", "error");
@@ -11518,25 +11937,48 @@
11518
11937
  return Promise.resolve();
11519
11938
  }
11520
11939
 
11521
- var isInterrupting = !!(session.structuredState && session.structuredState.inFlight && session.status === "running");
11522
- // Immediately render user message with thinking indicator
11523
- 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
+
11524
11944
  var userMsgs = stripRenderOnlyStructuredMessages(Array.isArray(session.messages) ? session.messages.slice() : []);
11525
- userMsgs.push(userTurn);
11526
- var optimisticStructuredState = Object.assign({}, session.structuredState || {}, { inFlight: true });
11527
- updateSessionSnapshot({
11528
- id: session.id,
11529
- status: "running",
11530
- messages: userMsgs,
11531
- structuredState: optimisticStructuredState,
11532
- });
11533
- state.currentMessages = buildMessagesForRender(Object.assign({}, session, {
11534
- status: "running",
11535
- messages: userMsgs,
11536
- structuredState: optimisticStructuredState,
11537
- }), userMsgs);
11538
- updateInputHint("思考中…");
11539
- 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
+ }
11540
11982
 
11541
11983
  if (inputBox) {
11542
11984
  inputBox.value = "";
@@ -11592,8 +12034,12 @@
11592
12034
  var refreshedSession = state.sessions.find(function(s) { return s.id === snapshot.id; }) || snapshot;
11593
12035
  state.currentMessages = buildMessagesForRender(refreshedSession, getPreferredMessages(refreshedSession, snapshot.output, false));
11594
12036
  renderChat(true);
12037
+ updateStructuredQueueCounter();
11595
12038
  if (isInterrupting) {
11596
12039
  showToast("已中断上一条回复,正在处理新消息…", "info");
12040
+ } else if (isQueueing) {
12041
+ var qLen = Array.isArray(refreshedSession.queuedMessages) ? refreshedSession.queuedMessages.length : 0;
12042
+ showToast(qLen > 1 ? ("已加入排队(共 " + qLen + " 条等待)") : "已加入排队,等待当前回复完成。", "info");
11597
12043
  }
11598
12044
  }
11599
12045
  }
@@ -11611,20 +12057,36 @@
11611
12057
  return;
11612
12058
  }
11613
12059
 
11614
- // 回滚乐观更新:恢复发送前的 messages(去掉刚加的 userTurn)和 inFlight 状态
11615
- var rollbackMsgs = userMsgs.slice(0, -1);
11616
- updateSessionSnapshot({
11617
- id: session.id,
11618
- status: session.status,
11619
- messages: rollbackMsgs,
11620
- structuredState: Object.assign({}, session.structuredState || {}, { inFlight: false }),
11621
- });
11622
- if (session.id === state.selectedId) {
11623
- state.currentMessages = buildMessagesForRender(
11624
- Object.assign({}, session, { messages: rollbackMsgs, structuredState: Object.assign({}, session.structuredState || {}, { inFlight: false }) }),
11625
- rollbackMsgs
11626
- );
11627
- 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
+ }
11628
12090
  }
11629
12091
  var message = (error && error.message) || "";
11630
12092
  var isTransientAbort =
@@ -11878,8 +12340,26 @@
11878
12340
  });
11879
12341
  }
11880
12342
 
12343
+ // R8: 检测用户输入是否包含 /clear 命令,命中时把 marker 标到当前 buffer
12344
+ // 长度,下次 softResync 时就不会重放 /clear 之前的历史。
12345
+ // 检测点放在 queueDirectInput 是因为:所有用户 input(chat 框发送、终端
12346
+ // interactive 直写、shortcut 按键、bracketed paste 等)最终都汇到这条
12347
+ // 路径。先 strip bracketed-paste 包络(\x1b[200~ ... \x1b[201~)再做行首
12348
+ // 匹配,覆盖多种粘贴形式。
12349
+ function _detectAndMarkClear(input) {
12350
+ if (typeof input !== "string" || !input) return;
12351
+ var stripped = input.replace(/\x1b\[200~/g, "").replace(/\x1b\[201~/g, "");
12352
+ // 必须 /clear 在某一行起始位置,且后接 \r 或 \n 或行尾
12353
+ if (/(?:^|\n)\s*\/clear\s*(?:\r|\n|$)/.test(stripped)) {
12354
+ if (typeof state !== "undefined" && state) {
12355
+ state.terminalOutputMarker = (state.terminalOutput && state.terminalOutput.length) | 0;
12356
+ }
12357
+ }
12358
+ }
12359
+
11881
12360
  function queueDirectInput(input, shortcutKey, viewOverride) {
11882
12361
  if (!input || !state.selectedId) return Promise.resolve();
12362
+ _detectAndMarkClear(input);
11883
12363
  state.messageQueue.push(input);
11884
12364
  state.inputQueue = state.inputQueue.then(function() {
11885
12365
  return postInput(input, shortcutKey, viewOverride).finally(function() {
@@ -12214,11 +12694,19 @@
12214
12694
  composer.classList.toggle("is-terminal-passthrough", !!state.terminalInteractive);
12215
12695
  }
12216
12696
  var sendBtn = document.getElementById("send-input-button");
12697
+ var structuredInFlight = structured && isRunning;
12217
12698
  if (sendBtn) {
12218
12699
  sendBtn.disabled = !structured && !!selectedSession && !isRunning && !canResumeOnSend;
12219
12700
  sendBtn.setAttribute("title", structured
12220
- ? "发送"
12701
+ ? (structuredInFlight ? "排队发送(当前回复结束后处理)" : "发送")
12221
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);
12222
12710
  }
12223
12711
  var container = document.getElementById("output");
12224
12712
  if (container) container.classList.toggle("interactive", !structured && state.terminalInteractive);
@@ -12937,7 +13425,10 @@
12937
13425
  // fill the expanded space, and the scroll position needs resetting.
12938
13426
  if (isTouchDevice()) {
12939
13427
  ensureTerminalFit("keyboard-blur", { forceReplay: true });
12940
- maybeScrollTerminalToBottom("force");
13428
+ // "keyboard" 而非 "force":用户原本在终端历史里翻看时,键盘
13429
+ // 收起不该把视图拽走。maybeScrollTerminalToBottom 会按
13430
+ // terminalAutoFollow 决定——贴底者继续贴底,离底者保位。
13431
+ maybeScrollTerminalToBottom("keyboard");
12941
13432
  }
12942
13433
  }, 100);
12943
13434
  }
@@ -13731,7 +14222,9 @@
13731
14222
  syncAppViewportHeight();
13732
14223
  }
13733
14224
  ensureTerminalFit("keyboard-close", { forceReplay: true });
13734
- maybeScrollTerminalToBottom("force");
14225
+ // 同 handleInputBoxBlur:尊重 terminalAutoFollow,避免把上滚
14226
+ // 阅读历史的用户在键盘关闭瞬间拽回底部。
14227
+ maybeScrollTerminalToBottom("keyboard");
13735
14228
  }, 200);
13736
14229
  }
13737
14230
 
@@ -13980,8 +14473,13 @@
13980
14473
  // 中间态,下一个 wterm 实例的首批字节会被错误归类(首字符被吃成
13981
14474
  // ANSI 序列尾巴)。重建终端前显式复位,避免状态泄漏到新实例。
13982
14475
  resetWideParserState();
14476
+ // sync output 缓冲跨会话也要清,否则旧会话最后没收完的 ?2026h 帧
14477
+ // 会让新会话的首批 PTY 字节全部被吞进 buffer 等永远不会来的 end。
14478
+ state.syncOutputBuffer = null;
14479
+ state.syncOutputDeadline = 0;
13983
14480
  state.terminalSessionId = null;
13984
14481
  state.terminalOutput = "";
14482
+ state.terminalOutputMarker = 0; // R8: teardown 时重置 /clear marker
13985
14483
  state.terminalAutoFollow = true;
13986
14484
  state.showTerminalJumpToBottom = false;
13987
14485
  updateTerminalJumpToBottomButton();
@@ -14010,6 +14508,9 @@
14010
14508
  // 直接 400/404,console 留一条红色错误;这里提前剪掉,避免噪音。
14011
14509
  if (!selectedSess || selectedSess.status !== "running") return;
14012
14510
  if (isStructuredSession(selectedSess)) return;
14511
+ // wterm WASM grid 的 maxCols 硬编码 256。POST 给服务端的 cols 也同步
14512
+ // clamp,避免服务端 pty.resize 给 Claude 一个 wterm 实际渲不下的列宽。
14513
+ if (cols > 256) cols = 256;
14013
14514
  var nextSize = { cols: cols, rows: rows };
14014
14515
  if (state.lastResize.cols !== nextSize.cols || state.lastResize.rows !== nextSize.rows) {
14015
14516
  state.lastResize = nextSize;
@@ -14366,21 +14867,11 @@
14366
14867
  if (msg.data && msg.sessionId
14367
14868
  && Object.prototype.hasOwnProperty.call(msg.data, 'isResponding')) {
14368
14869
  if (!state._lastIsResponding) state._lastIsResponding = {};
14369
- var _prevResp = !!state._lastIsResponding[msg.sessionId];
14370
- var _nextResp = !!msg.data.isResponding;
14371
- state._lastIsResponding[msg.sessionId] = _nextResp;
14372
- if (_prevResp && !_nextResp
14373
- && msg.sessionId === state.selectedId
14374
- && state.terminal
14375
- && state.terminalOutput) {
14376
- if (state._idleResyncTimer) clearTimeout(state._idleResyncTimer);
14377
- var _idleResyncSid = msg.sessionId;
14378
- state._idleResyncTimer = setTimeout(function() {
14379
- state._idleResyncTimer = null;
14380
- if (state.selectedId !== _idleResyncSid) return;
14381
- try { softResyncTerminal({ skipFit: true }); } catch (e) {}
14382
- }, 120);
14383
- }
14870
+ state._lastIsResponding[msg.sessionId] = !!msg.data.isResponding;
14871
+ // R2 策略 A:移除 isResponding true→false 翻转触发的 softResync。
14872
+ // 原本是想在"流式回答结束"瞬间洗掉错位的 cursor 定位残留,但
14873
+ // softResync 全量重放在 fresh buffer 上会把 askuserquestion 的多
14874
+ // 帧字节顺序堆叠(截图 2 的根因之一)。NEW-A + R6 兜底后不再需要。
14384
14875
  }
14385
14876
  if (msg.data && msg.sessionId) {
14386
14877
  var isIncremental = !!msg.data.incremental;
@@ -14478,8 +14969,12 @@
14478
14969
  // "被覆盖中间帧"反复塞进 scrollback。thinking→idle 兜底就够了。
14479
14970
  state.terminalSessionId = msg.sessionId;
14480
14971
  if (msg.data.output) {
14972
+ // R8: full output replace → marker 失效,重置为 0
14481
14973
  state.terminalOutput = clampClientTerminalOutput(normalizeTerminalOutput(msg.data.output));
14974
+ state.terminalOutputMarker = 0;
14482
14975
  } else {
14976
+ // append-delta:buffer 延续,marker 保持(clampClientTerminalOutput
14977
+ // 内部已经按裁掉字节数同步缩减 marker)
14483
14978
  state.terminalOutput = clampClientTerminalOutput((state.terminalOutput || "") + normalizeTerminalOutput(msg.data.chunk));
14484
14979
  }
14485
14980
  maybeScrollTerminalToBottom("output");
@@ -15145,6 +15640,9 @@
15145
15640
  }
15146
15641
 
15147
15642
  var allMessages = state.currentMessages;
15643
+ // 预扫一遍全量 messages,构建 task id → subagent meta 的 map,
15644
+ // 供老消息 tool_result(没有 __subagent 盖章)按 tool_use_id 反查兜底。
15645
+ var legacyTaskMap = collectLegacyTaskIdMap(allMessages);
15148
15646
 
15149
15647
  if (allMessages.length === 0) {
15150
15648
  if (state.lastRenderedEmpty !== "empty") {
@@ -15221,10 +15719,11 @@
15221
15719
 
15222
15720
  // 在动 DOM 之前先看用户是不是贴在底部——这决定后面我们要不要让视图
15223
15721
  // "继续粘在底部"。column-reverse 下 scrollTop 接近 0 = 视觉底部。
15224
- // 同时把 state.chatStickToBottom 同步到当前真实状态,避免长时间不滚动后
15225
- // 的状态漂移(比如新会话 init 的瞬间)。
15722
+ // 注意:state.chatStickToBottom 的维护**完全交给 scroll handler**
15723
+ // (bindChatScrollListener + wheel/touch 提前下台),这里不再做
15724
+ // "近底即锁回 true"的自愈,避免 resize / 键盘动画 / 锚点回填瞬间
15725
+ // 把已经上滚阅读的用户误判回贴底状态。
15226
15726
  var renderWasAtBottom = isChatNearBottom(chatMessages);
15227
- if (renderWasAtBottom) state.chatStickToBottom = true;
15228
15727
 
15229
15728
  // 把 .system-info 卡片从计数里剔除——它由 extractPtySystemInfo 在
15230
15729
  // fullRenderChat 里穿插注入,不存在于 messages 数组中,混进 existingCount
@@ -15268,7 +15767,7 @@
15268
15767
  }
15269
15768
 
15270
15769
  // Render message
15271
- html += renderChatMessage(msg, roundUsageByIndex[originalIndex] || null, originalIndex);
15770
+ html += renderChatMessage(msg, roundUsageByIndex[originalIndex] || null, originalIndex, legacyTaskMap);
15272
15771
  }
15273
15772
 
15274
15773
  // Add sentinel for loading older messages (DOM end = visual top in column-reverse)
@@ -15282,9 +15781,12 @@
15282
15781
  // 的 data-msg-index 和它到容器顶部的偏移。重写完成后找到同一 data-msg-index
15283
15782
  // 的新节点,把它放回原来的偏移——这是 column-reverse 下保住用户视线的
15284
15783
  // 标准锚点法。没有锚点时(首次渲染、空 → 非空)才走 scrollTop=0 兜底。
15784
+ // 改用 existingCount 而非 prevMsgCount:page-refresh 等 preserveStickState
15785
+ // 路径下 prevMsgCount 被重置为 0,但 DOM 里仍有节点可作锚点,必须保住
15786
+ // 用户的阅读位置。
15285
15787
  var anchorMsgIndex = -1;
15286
15788
  var anchorOffset = 0;
15287
- if (prevMsgCount > 0 && !renderWasAtBottom) {
15789
+ if (existingCount > 0 && !renderWasAtBottom) {
15288
15790
  var containerTop = chatMessages.getBoundingClientRect().top;
15289
15791
  var preEls = chatMessages.querySelectorAll(".chat-message:not(.system-info)");
15290
15792
  for (var pi = 0; pi < preEls.length; pi++) {
@@ -15314,13 +15816,19 @@
15314
15816
  })();
15315
15817
  // 会话切换 / 首次渲染后,浏览器会把旧的 scrollTop 钳制到新内容
15316
15818
  // 的最大值——column-reverse 下这意味着视觉上跳到最上面(最旧消息),
15317
- // 也就是用户反馈的"退出再回来时被重定向到最上面"。这里在
15318
- // prevMsgCount === 0(resetChatRenderCache 后或刚从空状态进入)
15319
- // 强制 scrollTop=0(视觉底部 = 最新消息),避免错位。
15320
- if (prevMsgCount === 0) {
15819
+ // 也就是用户反馈的"退出再回来时被重定向到最上面"
15820
+ // 关键:只在该会话视图的**首次**渲染(chatInitialRenderDone=false)
15821
+ // 才执行这个强制贴底;之后即便 prevMsgCount===0(page-refresh /
15822
+ // ws 重连等保留 sticky 的 reset 路径),也尊重 chatStickToBottom,
15823
+ // 不再把上滚的用户拽回去。
15824
+ if (prevMsgCount === 0 && !state.chatInitialRenderDone) {
15321
15825
  chatMessages.scrollTop = 0;
15322
15826
  state.chatStickToBottom = true;
15323
15827
  clearChatUnread({ removeDivider: true });
15828
+ state.chatInitialRenderDone = true;
15829
+ } else if (prevMsgCount === 0 && state.chatStickToBottom) {
15830
+ // 非首次但缓存重置后的 re-render——仅在用户原本贴底时回贴。
15831
+ chatMessages.scrollTop = 0;
15324
15832
  } else if (renderWasAtBottom) {
15325
15833
  // 同一会话内的全量重渲染:用户原本贴底就保持贴底,浏览器在 innerHTML
15326
15834
  // 重置后可能把 scrollTop 钳到一个奇怪的值,这里显式拉回 0。
@@ -15453,7 +15961,7 @@
15453
15961
  for (var i = 0; i < newMessages.length; i++) {
15454
15962
  var div = document.createElement("div");
15455
15963
  var nmOrigIdx = visibleOffset + existingCount + (newMessages.length - 1 - i);
15456
- div.innerHTML = renderChatMessage(newMessages[i], roundUsageByIndex[nmOrigIdx] || null, nmOrigIdx);
15964
+ div.innerHTML = renderChatMessage(newMessages[i], roundUsageByIndex[nmOrigIdx] || null, nmOrigIdx, legacyTaskMap);
15457
15965
  var el = div.firstElementChild;
15458
15966
  if (el) {
15459
15967
  el.classList.add("animate-in");
@@ -15511,7 +16019,7 @@
15511
16019
  var currentEl = existingEls[mi];
15512
16020
  var tmpWrap = document.createElement("div");
15513
16021
  var srOrigIdx = visibleOffset + reversedMessages.length - 1 - mi;
15514
- tmpWrap.innerHTML = renderChatMessage(reversedMessages[mi], roundUsageByIndex[srOrigIdx] || null, srOrigIdx);
16022
+ tmpWrap.innerHTML = renderChatMessage(reversedMessages[mi], roundUsageByIndex[srOrigIdx] || null, srOrigIdx, legacyTaskMap);
15515
16023
  var replacementEl = tmpWrap.firstElementChild;
15516
16024
  if (!replacementEl) continue;
15517
16025
  if (currentEl.innerHTML !== replacementEl.innerHTML || currentEl.className !== replacementEl.className) {
@@ -16386,55 +16894,137 @@
16386
16894
  }
16387
16895
 
16388
16896
  // ── 像素风猫咪头像 ──
16389
- var PIXEL_AVATAR = (function() {
16390
- var _ = "transparent";
16391
- function buildSvg(grid, size) {
16392
- var s = size || 3;
16393
- var w = grid[0].length * s;
16394
- var h = grid.length * s;
16395
- var rects = "";
16396
- for (var y = 0; y < grid.length; y++) {
16397
- for (var x = 0; x < grid[y].length; x++) {
16398
- if (grid[y][x] !== _) {
16399
- rects += '<rect x="' + (x * s) + '" y="' + (y * s) + '" width="' + s + '" height="' + s + '" fill="' + grid[y][x] + '"/>';
16400
- }
16897
+ // 统一的 10×10 猫咪 grid 模板:父 assistant = 加菲(橙),user = 美短(灰),
16898
+ // subagent = 一组按 taskId/agentType 哈希选色的备选 palette。同一模板让多个
16899
+ // 角色看起来是"同种生物的不同毛色",群聊感更自然。
16900
+ var _AVATAR_T = "transparent";
16901
+ function buildPixelSvg(grid, size) {
16902
+ var s = size || 3;
16903
+ var w = grid[0].length * s;
16904
+ var h = grid.length * s;
16905
+ var rects = "";
16906
+ for (var y = 0; y < grid.length; y++) {
16907
+ for (var x = 0; x < grid[y].length; x++) {
16908
+ if (grid[y][x] !== _AVATAR_T) {
16909
+ rects += '<rect x="' + (x * s) + '" y="' + (y * s) + '" width="' + s + '" height="' + s + '" fill="' + grid[y][x] + '"/>';
16401
16910
  }
16402
16911
  }
16403
- return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + w + ' ' + h + '" class="pixel-avatar-svg">' + rects + '</svg>';
16404
- }
16405
- // 加菲猫 (勤劳初二 / AI) — 橙色系
16406
- var o = "#F0923A", d = "#C46A1A", w = "#FFFFFF", k = "#2D2D2D", p = "#F28B9A", n = "#E87D5A";
16407
- var garfield = [
16408
- [_,d,_,_,_,_,_,_,d,_],
16409
- [d,o,d,_,_,_,_,d,o,d],
16410
- [d,o,o,o,o,o,o,o,o,d],
16411
- [o,o,w,k,o,o,w,k,o,o],
16412
- [o,o,w,w,o,o,w,w,o,o],
16413
- [o,o,o,o,p,p,o,o,o,o],
16414
- [o,d,o,n,o,o,n,o,d,o],
16415
- [_,o,o,o,o,o,o,o,o,_],
16416
- [_,_,o,d,o,o,d,o,_,_],
16417
- [_,_,_,o,_,_,o,_,_,_],
16418
- ];
16419
- // 美短 (赛博虎妞 / 用户) — 灰色系
16420
- var g = "#9EAAB8", dg = "#6B7B8D", lg = "#C5CED8", gn = "#7EC88B";
16421
- var shorthair = [
16422
- [_,dg,_,_,_,_,_,_,dg,_],
16423
- [dg,g,dg,_,_,_,_,dg,g,dg],
16424
- [dg,g,g,g,g,g,g,g,g,dg],
16425
- [g,g,w,gn,g,g,w,gn,g,g],
16426
- [g,g,w,w,g,g,w,w,g,g],
16427
- [g,g,g,g,p,p,g,g,g,g],
16428
- [g,dg,g,lg,g,g,lg,g,dg,g],
16429
- [_,g,g,g,g,g,g,g,g,_],
16430
- [_,_,g,dg,g,g,dg,g,_,_],
16431
- [_,_,_,g,_,_,g,_,_,_],
16912
+ }
16913
+ return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + w + ' ' + h + '" class="pixel-avatar-svg">' + rects + '</svg>';
16914
+ }
16915
+ function buildCatGrid(palette) {
16916
+ // palette: { base, dark, light, accent, eye, mouth, nose }
16917
+ var T = _AVATAR_T;
16918
+ var b = palette.base;
16919
+ var d = palette.dark;
16920
+ var l = palette.light || palette.base;
16921
+ var w = palette.accent || "#FFFFFF";
16922
+ var k = palette.eye || "#2D2D2D";
16923
+ var p = palette.mouth || "#F28B9A";
16924
+ var n = palette.nose || palette.dark;
16925
+ return [
16926
+ [T,d,T,T,T,T,T,T,d,T],
16927
+ [d,b,d,T,T,T,T,d,b,d],
16928
+ [d,b,b,b,b,b,b,b,b,d],
16929
+ [b,b,w,k,b,b,w,k,b,b],
16930
+ [b,b,w,w,b,b,w,w,b,b],
16931
+ [b,b,b,b,p,p,b,b,b,b],
16932
+ [b,n,b,l,b,b,l,b,n,b],
16933
+ [T,b,b,b,b,b,b,b,b,T],
16934
+ [T,T,b,d,b,b,d,b,T,T],
16935
+ [T,T,T,b,T,T,b,T,T,T],
16432
16936
  ];
16433
- return {
16434
- assistant: buildSvg(garfield),
16435
- user: buildSvg(shorthair)
16436
- };
16437
- })();
16937
+ }
16938
+ var GARFIELD_PALETTE = {
16939
+ base: "#F0923A", dark: "#C46A1A", light: "#F0923A",
16940
+ accent: "#FFFFFF", eye: "#2D2D2D", mouth: "#F28B9A", nose: "#E87D5A",
16941
+ };
16942
+ var SHORTHAIR_PALETTE = {
16943
+ base: "#9EAAB8", dark: "#6B7B8D", light: "#C5CED8",
16944
+ accent: "#FFFFFF", eye: "#7EC88B", mouth: "#F28B9A",
16945
+ };
16946
+ // 子 agent palette 池。色相与父/用户都拉开距离,避免群聊里多只猫颜色相近难辨认。
16947
+ // primary 用来暴露成 CSS 变量 --agent-color,给气泡左边框 / handoff 文字着色。
16948
+ var SUBAGENT_PALETTES = [
16949
+ { base: "#5A8FE0", dark: "#2E5BB3", light: "#9CC0F2", accent: "#FFFFFF", eye: "#FFD66E", mouth: "#F28B9A", primary: "#5A8FE0" }, // 蓝猫
16950
+ { base: "#A06FE0", dark: "#6B45A8", light: "#C8A4F2", accent: "#FFFFFF", eye: "#FFE36E", mouth: "#F28B9A", primary: "#A06FE0" }, // 紫猫
16951
+ { base: "#7BB76B", dark: "#4F8A40", light: "#A9D49C", accent: "#FFFFFF", eye: "#2D2D2D", mouth: "#F28B9A", primary: "#7BB76B" }, // 抹茶猫
16952
+ { base: "#D86A88", dark: "#9C3A57", light: "#E8A4B5", accent: "#FFFFFF", eye: "#2D2D2D", mouth: "#FFFFFF", primary: "#D86A88" }, // 樱花猫
16953
+ { base: "#5BB7B0", dark: "#2E7873", light: "#9CD6D2", accent: "#FFFFFF", eye: "#FFD66E", mouth: "#F28B9A", primary: "#5BB7B0" }, // 青苔猫
16954
+ { base: "#4A4A60", dark: "#1F1F2E", light: "#6E6E84", accent: "#F5F5F5", eye: "#FFD66E", mouth: "#F28B9A", primary: "#4A4A60" }, // 黑猫
16955
+ { base: "#D8A85A", dark: "#9C7028", light: "#EBC78A", accent: "#FFFFFF", eye: "#2D2D2D", mouth: "#F28B9A", primary: "#D8A85A" }, // 焦糖猫
16956
+ ];
16957
+ function hashStringToIndex(str, mod) {
16958
+ var s = String(str || "");
16959
+ var h = 0;
16960
+ for (var i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0;
16961
+ return Math.abs(h) % mod;
16962
+ }
16963
+ // agentType → 中文名映射。命中映射就用映射名,否则用 agentType 原文;
16964
+ // 都没有则退化为 "协作猫·<taskId 后 4 位>"。
16965
+ var SUBAGENT_NAME_MAP = {
16966
+ "general-purpose": "万能猫",
16967
+ "Explore": "侦探猫",
16968
+ "code-explorer": "侦探猫",
16969
+ "code-reviewer": "审查猫",
16970
+ "code-architect": "架构猫",
16971
+ "code-simplifier": "简化猫",
16972
+ "code-guide": "向导猫",
16973
+ "Plan": "策划猫",
16974
+ "feature-dev": "开发猫",
16975
+ "pr-test-analyzer": "测试猫",
16976
+ "silent-failure-hunter": "护卫猫",
16977
+ "type-design-analyzer": "类型猫",
16978
+ "comment-analyzer": "注释猫",
16979
+ };
16980
+ function getSubagentDisplayName(sub) {
16981
+ if (!sub) return "";
16982
+ var agentType = sub.agentType || "";
16983
+ if (agentType && SUBAGENT_NAME_MAP[agentType]) return SUBAGENT_NAME_MAP[agentType];
16984
+ if (agentType) return agentType;
16985
+ var tail = (sub.taskId || "").slice(-4) || "未知";
16986
+ return "协作猫·" + tail;
16987
+ }
16988
+ function getSubagentPalette(sub) {
16989
+ // 哈希优先用 agentType,让同类型 agent 跨 turn 颜色稳定;没有 agentType 时
16990
+ // 退化用 taskId,至少同 turn 内同一只猫颜色稳定。
16991
+ var seed = (sub && (sub.agentType || sub.taskId)) || "subagent";
16992
+ return SUBAGENT_PALETTES[hashStringToIndex(seed, SUBAGENT_PALETTES.length)];
16993
+ }
16994
+ function subagentAvatarHtml(sub) {
16995
+ var palette = getSubagentPalette(sub);
16996
+ var name = getSubagentDisplayName(sub);
16997
+ var svg = buildPixelSvg(buildCatGrid(palette));
16998
+ return '<div class="chat-message-avatar assistant subagent" style="--agent-color:' + palette.primary + '">' +
16999
+ '<div class="pixel-avatar">' + svg + '</div>' +
17000
+ '<span class="avatar-name">' + escapeHtml(name) + '</span>' +
17001
+ '</div>';
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
+ }
17024
+ var PIXEL_AVATAR = {
17025
+ assistant: buildPixelSvg(buildCatGrid(GARFIELD_PALETTE)),
17026
+ user: buildPixelSvg(buildCatGrid(SHORTHAIR_PALETTE)),
17027
+ };
16438
17028
 
16439
17029
  var DEFAULT_CHAT_PERSONA = {
16440
17030
  user: {
@@ -16484,16 +17074,20 @@
16484
17074
  '</div>';
16485
17075
  }
16486
17076
 
16487
- function renderChatMessage(msg, roundUsage, messageIndex) {
17077
+ function renderChatMessage(msg, roundUsage, messageIndex, legacyTaskMap) {
16488
17078
  // Thinking card (deep thought) — from PTY parsing
16489
17079
  if (msg.role === "thinking") {
17080
+ // 空 / 全空白的 thinking 没有任何信息量,渲染出来只是一条带"展开"的紫色窄条,
17081
+ // 展开了也看不到内容——直接跳过。
17082
+ var ptyThinkingText = typeof msg.content === "string" ? msg.content : "";
17083
+ if (!ptyThinkingText.trim()) return "";
16490
17084
  var thinkingKey = buildExpandKey("thinking", [getMessageKey(msg, messageIndex), "pty"]);
16491
17085
  var thinkingPersisted = getPersistedExpandState(thinkingKey);
16492
17086
  var thinkingExpanded = thinkingPersisted === null ? getCardDefault("thinking") : thinkingPersisted;
16493
17087
  return '<div class="chat-message thinking">' +
16494
- '<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)">' +
16495
17089
  '<span class="thinking-inline-icon">⦿</span>' +
16496
- '<span class="thinking-inline-preview">' + escapeHtml(msg.content) + '</span>' +
17090
+ '<span class="thinking-inline-preview">' + escapeHtml(ptyThinkingText) + '</span>' +
16497
17091
  '<span class="thinking-inline-action">' + (thinkingExpanded ? '收起' : '展开') + '</span>' +
16498
17092
  '</div>' +
16499
17093
  '</div>';
@@ -16511,7 +17105,7 @@
16511
17105
 
16512
17106
  // Structured content blocks (from JSON chat mode)
16513
17107
  if (Array.isArray(msg.content)) {
16514
- return renderStructuredMessage(msg, roundUsage, messageIndex);
17108
+ return renderStructuredMessage(msg, roundUsage, messageIndex, legacyTaskMap);
16515
17109
  }
16516
17110
 
16517
17111
  // Legacy string content (from PTY parsing)
@@ -16567,6 +17161,8 @@
16567
17161
  }
16568
17162
 
16569
17163
  // ── 连续同类工具调用分组 ──
17164
+ // 注意:禁止把 Task/Agent 加入 GROUPABLE_TOOLS——它们由 renderContentBlock 入口屏蔽返空,
17165
+ // 加入分组会导致空 group 包裹一堆空字符串,留下视觉空盒子。
16570
17166
  var GROUPABLE_TOOLS = { Read: 1, Glob: 1, Grep: 1, WebFetch: 1, WebSearch: 1, TodoRead: 1 };
16571
17167
 
16572
17168
  function groupConsecutiveTools(content) {
@@ -16663,61 +17259,223 @@
16663
17259
  persistElementExpandState(el, "tool-group");
16664
17260
  };
16665
17261
 
16666
- function renderStructuredMessage(msg, roundUsage, messageIndex) {
16667
- var role = msg.role;
16668
- var avatar = chatAvatar(role);
16669
- var messageKey = getMessageKey(msg, messageIndex);
16670
-
16671
- // Check if this is a queued user message
16672
- var isQueued = role === "user" && msg.content && msg.content.some(function(b) { return b.__queued; });
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
+ }
16673
17275
 
16674
- if (!msg.content || msg.content.length === 0) {
16675
- if (role === "assistant") {
16676
- return '<div class="chat-message ' + role + '">' +
16677
- avatar +
16678
- '<div class="chat-message-bubble"><div class="typing-indicator"><span></span><span></span><span></span></div></div>' +
16679
- '</div>';
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
+
17293
+ // 把一条 assistant turn 按相邻 block 的 __subagent.taskId 切成段。
17294
+ // 输出每段附带原数组中的 firstIndex,方便渲染时 expand key 用全局 index
17295
+ // 避免不同段冲突。
17296
+ // legacyTaskMap:老消息没有 __subagent 盖章时,按 tool_use_id 反查兜底。
17297
+ function splitTurnBySubagent(blocks, legacyTaskMap) {
17298
+ var segs = [];
17299
+ if (!Array.isArray(blocks) || !blocks.length) return segs;
17300
+ var current = null;
17301
+ for (var i = 0; i < blocks.length; i++) {
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;
16680
17310
  }
16681
- return "";
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
+ }
17317
+ var key = sub ? sub.taskId : null;
17318
+ if (!current || current.key !== key) {
17319
+ current = { key: key, subagent: sub, blocks: [], firstIndex: i };
17320
+ segs.push(current);
17321
+ }
17322
+ current.blocks.push(b);
16682
17323
  }
17324
+ return segs;
17325
+ }
16683
17326
 
16684
- var toolResults = buildToolResultMap(msg.content);
16685
- var blocksHtml = "";
16686
-
17327
+ // 渲染一段内的 blocks。独立 group consecutive tools,避免父/子 agent 的工具
17328
+ // 调用跨边界被合并;grp.index 偏移到原数组全局位置,保持 expand key 唯一。
17329
+ function buildSegmentBlocksHtml(segmentBlocks, segmentFirstIndex, role, toolResults, messageKey) {
17330
+ var html = "";
16687
17331
  try {
16688
- var groups = groupConsecutiveTools(msg.content);
17332
+ var groups = groupConsecutiveTools(segmentBlocks);
16689
17333
  for (var g = 0; g < groups.length; g++) {
16690
17334
  var grp = groups[g];
16691
17335
  try {
16692
17336
  if (grp.type === "group") {
16693
- blocksHtml += renderToolGroup(grp.items, role, toolResults, messageKey);
17337
+ var shifted = [];
17338
+ for (var k = 0; k < grp.items.length; k++) {
17339
+ shifted.push({ block: grp.items[k].block, index: grp.items[k].index + segmentFirstIndex });
17340
+ }
17341
+ html += renderToolGroup(shifted, role, toolResults, messageKey);
16694
17342
  } else {
16695
- blocksHtml += renderContentBlock(grp.block, role, toolResults, grp.index, messageKey);
17343
+ html += renderContentBlock(grp.block, role, toolResults, grp.index + segmentFirstIndex, messageKey);
16696
17344
  }
16697
17345
  } catch (e) {
16698
- blocksHtml += '<div class="render-error">消息块渲染失败</div>';
17346
+ html += '<div class="render-error">消息块渲染失败</div>';
16699
17347
  }
16700
17348
  }
16701
17349
  } catch (e) {
16702
- return '<div class="chat-message ' + role + '">' +
16703
- avatar +
16704
- '<div class="chat-message-content"><div class="render-error">消息渲染失败</div></div>' +
17350
+ html += '<div class="render-error">消息渲染失败</div>';
17351
+ }
17352
+ return html;
17353
+ }
17354
+
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) {
17400
+ var role = msg.role;
17401
+ var messageKey = getMessageKey(msg, messageIndex);
17402
+
17403
+ // 排队中的用户消息标记(subagent 不会出现在 user role 的 user input 中)
17404
+ var isQueued = role === "user" && msg.content && msg.content.some(function(b) { return b.__queued; });
17405
+
17406
+ if (!msg.content || msg.content.length === 0) {
17407
+ if (role === "assistant") {
17408
+ return '<div class="chat-message ' + role + '">' +
17409
+ chatAvatar(role) +
17410
+ '<div class="chat-message-bubble"><div class="typing-indicator"><span></span><span></span><span></span></div></div>' +
17411
+ '</div>';
17412
+ }
17413
+ // 空 user 消息(极少出现,但快速发送的边界场景会让消息"消失")。
17414
+ // 给个明确占位避免视觉断层。
17415
+ return '<div class="chat-message ' + role + ' empty-message" data-message-key="' + escapeHtml(messageKey) + '">' +
17416
+ chatAvatar(role) +
17417
+ '<div class="chat-message-content"><span class="empty-message-hint">(空消息)</span></div>' +
17418
+ '</div>';
17419
+ }
17420
+
17421
+ var toolResults = buildToolResultMap(msg.content);
17422
+ var parentPersona = getStructuredChatPersona("assistant");
17423
+
17424
+ // user role:可能含 Task tool_result(subagent 反查盖章过的)。检测一下,
17425
+ // 有 subagent 段就走 multi-agent 渲染(不输出 handoff,避免重复)。
17426
+ if (role !== "assistant") {
17427
+ var userSegments = splitTurnBySubagent(msg.content, legacyTaskMap);
17428
+ var userHasSub = userSegments.some(function(s) { return s.subagent; });
17429
+ var queuedClass = isQueued ? " queued" : "";
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);
17438
+ return '<div class="chat-message ' + role + queuedClass + '" data-message-key="' + escapeHtml(messageKey) + '">' +
17439
+ chatAvatar(role) +
17440
+ '<div class="chat-message-content">' + userHtml + queuedBadge + '</div>' +
16705
17441
  '</div>';
16706
17442
  }
16707
17443
 
16708
- var usageHtml = "";
16709
- var queuedClass = isQueued ? " queued" : "";
16710
- var queuedBadge = isQueued ? '<span class="queued-badge">排队中</span>' : "";
17444
+ // assistant:检测是否有 subagent 段,没有就走单段渲染(兼容老消息 / 无 subagent 的 turn)
17445
+ var segments = splitTurnBySubagent(msg.content, legacyTaskMap);
17446
+ var hasSubagent = segments.some(function(s) { return s.subagent; });
16711
17447
 
16712
- return '<div class="chat-message ' + role + queuedClass + '" data-message-key="' + escapeHtml(messageKey) + '">' +
16713
- avatar +
16714
- '<div class="chat-message-content">' + blocksHtml + queuedBadge + '</div>' +
16715
- usageHtml +
16716
- '</div>';
17448
+ if (!hasSubagent) {
17449
+ var html = buildSegmentBlocksHtml(msg.content, 0, role, toolResults, messageKey);
17450
+ return '<div class="chat-message ' + role + '" data-message-key="' + escapeHtml(messageKey) + '">' +
17451
+ chatAvatar(role) +
17452
+ '<div class="chat-message-content">' + html + '</div>' +
17453
+ '</div>';
17454
+ }
17455
+
17456
+ // 多段:父 assistant 段 + 各 subagent 段。同一根 .chat-message 容器,
17457
+ // 内部多个 .chat-message-segment 子段,每段自带头像;切到新 subagent 时
17458
+ // 插入一行 handoff 提示("勤劳初二 ↳ 让 侦探猫 帮忙")。
17459
+ var multiHtml = '<div class="chat-message ' + role + ' multi-agent" data-message-key="' + escapeHtml(messageKey) + '">';
17460
+ multiHtml += buildMultiAgentHtml(segments, role, parentPersona.name, toolResults, messageKey, { showHandoff: true });
17461
+ multiHtml += '</div>';
17462
+ return multiHtml;
16717
17463
  }
16718
17464
  function renderContentBlock(block, role, toolResults, index, messageKey) {
16719
17465
  if (!block || !block.type) return "";
16720
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
+
16721
17479
  switch (block.type) {
16722
17480
  case "text":
16723
17481
  if (role === "assistant" && block.__processing) {
@@ -16727,7 +17485,6 @@
16727
17485
 
16728
17486
  case "thinking":
16729
17487
  var thinkingText = block.thinking || "";
16730
- var preview = thinkingText.length > 60 ? thinkingText.slice(0, 57) + "…" : thinkingText;
16731
17488
  var isStreaming = block.thinking === undefined && block.type === "thinking";
16732
17489
  if (isStreaming) {
16733
17490
  return '<div class="thinking-inline thinking-streaming" data-thinking="">' +
@@ -16737,6 +17494,10 @@
16737
17494
  '</div>' +
16738
17495
  '</div>';
16739
17496
  }
17497
+ // 非流式分支:thinking 字段是空字符串时,UI 上只会出现一条带"展开"
17498
+ // 的紫色窄条,展开了也是空——直接不渲染,避免视觉噪音。
17499
+ if (!thinkingText.trim()) return "";
17500
+ var preview = thinkingText.length > 60 ? thinkingText.slice(0, 57) + "…" : thinkingText;
16740
17501
  var thinkingKey = buildExpandKey("thinking", [messageKey, index]);
16741
17502
  var thinkingPersisted = getPersistedExpandState(thinkingKey);
16742
17503
  var thinkingExpanded = thinkingPersisted === null ? getCardDefault("thinking") : thinkingPersisted;
@@ -16755,10 +17516,26 @@
16755
17516
  return rendered;
16756
17517
 
16757
17518
  case "tool_result":
17519
+ // tool_result 通常被对应的 tool_use 卡片以"结果"区域消化掉,不在主流渲染。
17520
+ // 但如果父 tool_use 在另一条 turn 或被裁剪掉了,结果会变成孤儿——返回空字符串
17521
+ // 会让这条消息看起来"消失"。下面 renderStructuredMessage 在切段前会再做一次
17522
+ // orphan 兜底,这里保持空返回以维持旧行为不变。
16758
17523
  return "";
16759
17524
 
16760
17525
  default:
16761
- return '<div class="unknown-block">' + escapeHtml(JSON.stringify(block)) + '</div>';
17526
+ // 兜底:未来后端新增 block 类型时(image / chart / 文件等)不让 JSON 裸露在
17527
+ // 用户面前。给一个折叠卡片,默认收起,展开后是原始 JSON。
17528
+ var unknownType = block && block.type ? String(block.type) : "未知";
17529
+ var unknownJson = "";
17530
+ try { unknownJson = JSON.stringify(block, null, 2); } catch (_e) { unknownJson = "{}"; }
17531
+ return '<div class="unknown-block collapsed" onclick="this.classList.toggle(\'collapsed\')">' +
17532
+ '<div class="unknown-block-header">' +
17533
+ '<span class="unknown-block-icon">?</span>' +
17534
+ '<span class="unknown-block-label">未识别的内容块:' + escapeHtml(unknownType) + '</span>' +
17535
+ '<span class="unknown-block-toggle">▼</span>' +
17536
+ '</div>' +
17537
+ '<pre class="unknown-block-body">' + escapeHtml(unknownJson) + '</pre>' +
17538
+ '</div>';
16762
17539
  }
16763
17540
  }
16764
17541