fix(chat): separate response and thinking messages (#878)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: paisley <8197966+su8su@users.noreply.github.com>
This commit is contained in:
Haze
2026-04-20 15:22:09 +08:00
committed by GitHub
Unverified
parent 1b2dccee6e
commit 7fa4852c1d
3 changed files with 39 additions and 11 deletions

View File

@@ -258,8 +258,21 @@ export function Chat() {
return builtSteps; return builtSteps;
}; };
// Show the streaming response as a separate bubble (not inside the
// execution graph) once all tool calls have finished.
//
// Three signals indicate "tools finished, now streaming the reply":
// 1. `pendingFinal` — set by tool-result final events
// 2. `allToolsCompleted` — all entries in streamingTools are completed
// 3. `hasCompletedToolPhase` — historical messages (loaded by the poll)
// contain tool_use blocks, meaning the Gateway executed tools
// server-side without sending streaming tool events to the client
const allToolsCompleted = streamingTools.length > 0 && !hasRunningStreamToolStatus;
const hasCompletedToolPhase = segmentMessages.some((msg) =>
msg.role === 'assistant' && extractToolUse(msg).length > 0,
);
const rawStreamingReplyCandidate = isLatestOpenRun const rawStreamingReplyCandidate = isLatestOpenRun
&& pendingFinal && (pendingFinal || allToolsCompleted || hasCompletedToolPhase)
&& (hasStreamText || hasStreamImages) && (hasStreamText || hasStreamImages)
&& streamTools.length === 0 && streamTools.length === 0
&& !hasRunningStreamToolStatus; && !hasRunningStreamToolStatus;
@@ -365,8 +378,12 @@ export function Chat() {
const autoCollapsedRunKeys = useMemo(() => { const autoCollapsedRunKeys = useMemo(() => {
const keys = new Set<string>(); const keys = new Set<string>();
for (const card of userRunCards) { for (const card of userRunCards) {
const shouldCollapse = card.streamingReplyText != null // Auto-collapse once the reply is visible — either the streaming
|| (card.replyIndex != null && replyTextOverrides.has(card.replyIndex)); // reply bubble is already rendering (streamingReplyText != null)
// or the run finished and we have a reply text override.
const hasStreamingReply = card.streamingReplyText != null;
const hasHistoricalReply = card.replyIndex != null && replyTextOverrides.has(card.replyIndex);
const shouldCollapse = hasStreamingReply || hasHistoricalReply;
if (!shouldCollapse) continue; if (!shouldCollapse) continue;
const triggerMsg = messages[card.triggerIndex]; const triggerMsg = messages[card.triggerIndex];
const runKey = triggerMsg?.id const runKey = triggerMsg?.id

View File

@@ -283,13 +283,18 @@ export function deriveTaskSteps({
} }
if (streamMessage) { if (streamMessage) {
appendDetailSegments(extractThinkingSegments(streamMessage), { // When the reply is being rendered as a separate bubble
idPrefix: 'stream-thinking', // (omitLastStreamingMessageSegment), thinking that accompanies
label: 'Thinking', // the reply belongs to the bubble — omit it from the graph.
kind: 'thinking', if (!omitLastStreamingMessageSegment) {
running: true, appendDetailSegments(extractThinkingSegments(streamMessage), {
upsertStep, idPrefix: 'stream-thinking',
}); label: 'Thinking',
kind: 'thinking',
running: true,
upsertStep,
});
}
// Stream-time narration should also appear in the execution graph so that // Stream-time narration should also appear in the execution graph so that
// intermediate process output stays in P1 instead of leaking into the // intermediate process output stays in P1 instead of leaking into the

View File

@@ -171,13 +171,19 @@ export function createHistoryActions(
} }
// If pendingFinal, check whether the AI produced a final text response. // If pendingFinal, check whether the AI produced a final text response.
// Only finalize when the candidate is the very last message in the
// history — intermediate assistant messages (narration + tool_use) are
// followed by tool-result messages and must NOT be treated as the
// completed response, otherwise `pendingFinal` is cleared too early
// and the streaming reply bubble never renders.
if (pendingFinal || get().pendingFinal) { if (pendingFinal || get().pendingFinal) {
const recentAssistant = [...filteredMessages].reverse().find((msg) => { const recentAssistant = [...filteredMessages].reverse().find((msg) => {
if (msg.role !== 'assistant') return false; if (msg.role !== 'assistant') return false;
if (!hasNonToolAssistantContent(msg)) return false; if (!hasNonToolAssistantContent(msg)) return false;
return isAfterUserMsg(msg); return isAfterUserMsg(msg);
}); });
if (recentAssistant) { const lastMsg = filteredMessages[filteredMessages.length - 1];
if (recentAssistant && lastMsg === recentAssistant) {
clearHistoryPoll(); clearHistoryPoll();
set({ sending: false, activeRunId: null, pendingFinal: false }); set({ sending: false, activeRunId: null, pendingFinal: false });
} }