fix(chat): prevent graph collapse during streaming and strip thinking from reply bubble

- Prevent execution graph from auto-collapsing while reply is still
  streaming by excluding from autoCollapsedRunKeys and keeping
  expanded=true via controlled prop
- Strip thinking blocks from the streaming ChatMessage when the reply
  renders as a separate bubble, so thinking content doesn't duplicate
  alongside the response text

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Haze
2026-04-20 15:52:26 +08:00
parent 7fa4852c1d
commit f8c6643b38

View File

@@ -378,12 +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) {
// Auto-collapse once the reply is visible — either the streaming // Only auto-collapse after the run is fully complete — not while
// reply bubble is already rendering (streamingReplyText != null) // the reply is still streaming, otherwise the graph jumps to a
// or the run finished and we have a reply text override. // collapsed summary mid-stream.
const hasStreamingReply = card.streamingReplyText != null; const isStillStreaming = card.streamingReplyText != null;
const hasHistoricalReply = card.replyIndex != null && replyTextOverrides.has(card.replyIndex); const shouldCollapse = !isStillStreaming
const shouldCollapse = hasStreamingReply || hasHistoricalReply; && (card.replyIndex != null && replyTextOverrides.has(card.replyIndex));
if (!shouldCollapse) continue; if (!shouldCollapse) continue;
const triggerMsg = messages[card.triggerIndex]; const triggerMsg = messages[card.triggerIndex];
const runKey = triggerMsg?.id const runKey = triggerMsg?.id
@@ -492,11 +492,18 @@ export function Chat() {
? `msg-${triggerMsg.id}` ? `msg-${triggerMsg.id}`
: `${currentSessionKey}:trigger-${card.triggerIndex}`; : `${currentSessionKey}:trigger-${card.triggerIndex}`;
const userOverride = graphExpandedOverrides[runKey]; const userOverride = graphExpandedOverrides[runKey];
// Keep the graph expanded while the streaming reply
// renders — `active` flips to false once the reply
// bubble appears, which would trigger auto-collapse
// inside ExecutionGraphCard's uncontrolled path.
const isStreamingReply = card.streamingReplyText != null;
const expanded = userOverride != null const expanded = userOverride != null
? userOverride ? userOverride
: autoCollapsedRunKeys.has(runKey) : autoCollapsedRunKeys.has(runKey)
? false ? false
: undefined; : isStreamingReply
? true
: undefined;
return ( return (
<ExecutionGraphCard <ExecutionGraphCard
key={`graph-${runKey}`} key={`graph-${runKey}`}
@@ -517,18 +524,33 @@ export function Chat() {
{/* Streaming message */} {/* Streaming message */}
{shouldRenderStreaming && !hasActiveExecutionGraph && ( {shouldRenderStreaming && !hasActiveExecutionGraph && (
<ChatMessage <ChatMessage
message={(streamMsg message={(() => {
? { const base = streamMsg
...(streamMsg as Record<string, unknown>), ? {
role: (typeof streamMsg.role === 'string' ? streamMsg.role : 'assistant') as RawMessage['role'], ...(streamMsg as Record<string, unknown>),
content: streamMsg.content ?? streamText, role: (typeof streamMsg.role === 'string' ? streamMsg.role : 'assistant') as RawMessage['role'],
timestamp: streamMsg.timestamp ?? streamingTimestamp, content: streamMsg.content ?? streamText,
} timestamp: streamMsg.timestamp ?? streamingTimestamp,
: { }
role: 'assistant', : {
content: streamText, role: 'assistant' as const,
timestamp: streamingTimestamp, content: streamText,
}) as RawMessage} timestamp: streamingTimestamp,
};
// When the reply renders as a separate bubble, strip
// thinking blocks from the message — they belong to
// the execution phase and are already omitted from
// the graph via omitLastStreamingMessageSegment.
if (streamingReplyText != null && Array.isArray(base.content)) {
return {
...base,
content: (base.content as Array<{ type?: string }>).filter(
(block) => block.type !== 'thinking',
),
} as RawMessage;
}
return base as RawMessage;
})()}
textOverride={streamingReplyText ?? undefined} textOverride={streamingReplyText ?? undefined}
isStreaming isStreaming
streamingTools={streamingReplyText != null ? [] : streamingTools} streamingTools={streamingReplyText != null ? [] : streamingTools}