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;
};
// 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
&& pendingFinal
&& (pendingFinal || allToolsCompleted || hasCompletedToolPhase)
&& (hasStreamText || hasStreamImages)
&& streamTools.length === 0
&& !hasRunningStreamToolStatus;
@@ -365,8 +378,12 @@ export function Chat() {
const autoCollapsedRunKeys = useMemo(() => {
const keys = new Set<string>();
for (const card of userRunCards) {
const shouldCollapse = card.streamingReplyText != null
|| (card.replyIndex != null && replyTextOverrides.has(card.replyIndex));
// Auto-collapse once the reply is visible — either the streaming
// 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;
const triggerMsg = messages[card.triggerIndex];
const runKey = triggerMsg?.id

View File

@@ -283,6 +283,10 @@ export function deriveTaskSteps({
}
if (streamMessage) {
// When the reply is being rendered as a separate bubble
// (omitLastStreamingMessageSegment), thinking that accompanies
// the reply belongs to the bubble — omit it from the graph.
if (!omitLastStreamingMessageSegment) {
appendDetailSegments(extractThinkingSegments(streamMessage), {
idPrefix: 'stream-thinking',
label: 'Thinking',
@@ -290,6 +294,7 @@ export function deriveTaskSteps({
running: true,
upsertStep,
});
}
// Stream-time narration should also appear in the execution graph so that
// 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.
// 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) {
const recentAssistant = [...filteredMessages].reverse().find((msg) => {
if (msg.role !== 'assistant') return false;
if (!hasNonToolAssistantContent(msg)) return false;
return isAfterUserMsg(msg);
});
if (recentAssistant) {
const lastMsg = filteredMessages[filteredMessages.length - 1];
if (recentAssistant && lastMsg === recentAssistant) {
clearHistoryPoll();
set({ sending: false, activeRunId: null, pendingFinal: false });
}