From f8c6643b380bd9f00c9ea93d8d2f78af40ba7442 Mon Sep 17 00:00:00 2001 From: Haze <709547807@qq.com> Date: Mon, 20 Apr 2026 15:52:26 +0800 Subject: [PATCH] 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) --- src/pages/Chat/index.tsx | 60 +++++++++++++++++++++++++++------------- 1 file changed, 41 insertions(+), 19 deletions(-) diff --git a/src/pages/Chat/index.tsx b/src/pages/Chat/index.tsx index 68a1da7..4ce94bd 100644 --- a/src/pages/Chat/index.tsx +++ b/src/pages/Chat/index.tsx @@ -378,12 +378,12 @@ export function Chat() { const autoCollapsedRunKeys = useMemo(() => { const keys = new Set(); for (const card of userRunCards) { - // 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; + // Only auto-collapse after the run is fully complete — not while + // the reply is still streaming, otherwise the graph jumps to a + // collapsed summary mid-stream. + const isStillStreaming = card.streamingReplyText != null; + const shouldCollapse = !isStillStreaming + && (card.replyIndex != null && replyTextOverrides.has(card.replyIndex)); if (!shouldCollapse) continue; const triggerMsg = messages[card.triggerIndex]; const runKey = triggerMsg?.id @@ -492,11 +492,18 @@ export function Chat() { ? `msg-${triggerMsg.id}` : `${currentSessionKey}:trigger-${card.triggerIndex}`; 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 ? userOverride : autoCollapsedRunKeys.has(runKey) ? false - : undefined; + : isStreamingReply + ? true + : undefined; return ( ), - role: (typeof streamMsg.role === 'string' ? streamMsg.role : 'assistant') as RawMessage['role'], - content: streamMsg.content ?? streamText, - timestamp: streamMsg.timestamp ?? streamingTimestamp, - } - : { - role: 'assistant', - content: streamText, - timestamp: streamingTimestamp, - }) as RawMessage} + message={(() => { + const base = streamMsg + ? { + ...(streamMsg as Record), + role: (typeof streamMsg.role === 'string' ? streamMsg.role : 'assistant') as RawMessage['role'], + content: streamMsg.content ?? streamText, + timestamp: streamMsg.timestamp ?? streamingTimestamp, + } + : { + role: 'assistant' as const, + content: streamText, + 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} isStreaming streamingTools={streamingReplyText != null ? [] : streamingTools}