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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user