@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.
- package/dist/process-manager.js +5 -2
- package/dist/structured-session-manager.js +183 -25
- package/dist/types.d.ts +19 -0
- package/dist/web-ui/content/scripts.js +997 -220
- package/dist/web-ui/content/styles.css +364 -18
- package/dist/web-ui/content/vendor/wterm/wterm.bundle.js +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
|
586
|
-
|
|
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
|
-
|
|
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
|
|
947
|
-
|
|
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
|
-
|
|
954
|
-
|
|
955
|
-
|
|
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
|
-
|
|
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 (
|
|
3097
|
-
groups.push(renderRecentGroup(
|
|
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 (
|
|
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
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
var
|
|
3120
|
-
|
|
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 || ("会话 " +
|
|
3129
|
-
return '<button class="sidebar-collapsed-tile' + activeCls + '" type="button" data-collapsed-session-id="' + escapeHtml(s.id) + '" title="' + escapeHtml(title) + '">' +
|
|
3130
|
-
}
|
|
3131
|
-
|
|
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(
|
|
3303
|
+
function renderRecentGroup(entries) {
|
|
3198
3304
|
var html = '<section class="session-group">' +
|
|
3199
3305
|
'<div class="session-group-title">最近</div>';
|
|
3200
|
-
html +=
|
|
3201
|
-
|
|
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
|
-
|
|
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
|
|
6594
|
-
|
|
6595
|
-
|
|
6596
|
-
|
|
6597
|
-
|
|
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
|
-
|
|
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
|
-
|
|
7199
|
-
|
|
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)
|
|
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
|
-
|
|
7504
|
+
var trimmed2 = buf.slice(m + 1);
|
|
7505
|
+
_adjustMarker(trimmed2.length);
|
|
7506
|
+
return trimmed2;
|
|
7236
7507
|
}
|
|
7237
7508
|
}
|
|
7238
7509
|
}
|
|
7239
7510
|
}
|
|
7240
|
-
|
|
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
|
-
|
|
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,
|
|
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 =
|
|
7322
|
-
var RESYNC_TAIL_MS =
|
|
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
|
-
|
|
8208
|
-
|
|
8209
|
-
|
|
8210
|
-
|
|
8211
|
-
|
|
8212
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
11522
|
-
|
|
11523
|
-
var
|
|
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
|
-
|
|
11526
|
-
|
|
11527
|
-
|
|
11528
|
-
|
|
11529
|
-
|
|
11530
|
-
|
|
11531
|
-
|
|
11532
|
-
|
|
11533
|
-
|
|
11534
|
-
|
|
11535
|
-
|
|
11536
|
-
|
|
11537
|
-
|
|
11538
|
-
|
|
11539
|
-
|
|
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
|
-
|
|
11615
|
-
|
|
11616
|
-
|
|
11617
|
-
|
|
11618
|
-
|
|
11619
|
-
|
|
11620
|
-
|
|
11621
|
-
|
|
11622
|
-
|
|
11623
|
-
|
|
11624
|
-
|
|
11625
|
-
|
|
11626
|
-
|
|
11627
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
14370
|
-
|
|
14371
|
-
|
|
14372
|
-
|
|
14373
|
-
|
|
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
|
-
//
|
|
15225
|
-
//
|
|
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 (
|
|
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
|
-
//
|
|
15319
|
-
//
|
|
15320
|
-
|
|
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
|
-
|
|
16390
|
-
|
|
16391
|
-
|
|
16392
|
-
|
|
16393
|
-
|
|
16394
|
-
|
|
16395
|
-
|
|
16396
|
-
|
|
16397
|
-
|
|
16398
|
-
|
|
16399
|
-
|
|
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
|
-
|
|
16404
|
-
|
|
16405
|
-
|
|
16406
|
-
|
|
16407
|
-
|
|
16408
|
-
|
|
16409
|
-
|
|
16410
|
-
|
|
16411
|
-
|
|
16412
|
-
|
|
16413
|
-
|
|
16414
|
-
|
|
16415
|
-
|
|
16416
|
-
|
|
16417
|
-
[
|
|
16418
|
-
|
|
16419
|
-
|
|
16420
|
-
|
|
16421
|
-
|
|
16422
|
-
[
|
|
16423
|
-
[
|
|
16424
|
-
[
|
|
16425
|
-
[
|
|
16426
|
-
[
|
|
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
|
-
|
|
16434
|
-
|
|
16435
|
-
|
|
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(
|
|
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
|
-
|
|
16667
|
-
|
|
16668
|
-
|
|
16669
|
-
|
|
16670
|
-
|
|
16671
|
-
|
|
16672
|
-
|
|
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
|
-
|
|
16675
|
-
|
|
16676
|
-
|
|
16677
|
-
|
|
16678
|
-
|
|
16679
|
-
|
|
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
|
-
|
|
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
|
-
|
|
16685
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
17343
|
+
html += renderContentBlock(grp.block, role, toolResults, grp.index + segmentFirstIndex, messageKey);
|
|
16696
17344
|
}
|
|
16697
17345
|
} catch (e) {
|
|
16698
|
-
|
|
17346
|
+
html += '<div class="render-error">消息块渲染失败</div>';
|
|
16699
17347
|
}
|
|
16700
17348
|
}
|
|
16701
17349
|
} catch (e) {
|
|
16702
|
-
|
|
16703
|
-
|
|
16704
|
-
|
|
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
|
-
|
|
16709
|
-
var
|
|
16710
|
-
var
|
|
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
|
-
|
|
16713
|
-
|
|
16714
|
-
'<div class="chat-message
|
|
16715
|
-
|
|
16716
|
-
|
|
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
|
-
|
|
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
|
|