From 7d955fc607be55379df551f9ff93daf2c600c2b8 Mon Sep 17 00:00:00 2001 From: Haze <709547807@qq.com> Date: Mon, 20 Apr 2026 18:20:39 +0800 Subject: [PATCH] fix(chat): remove loadHistory run completion inference, rely on Gateway events loadHistory repeatedly set sending=false during server-side tool execution by incorrectly inferring run completion from message content. Run completion is now ONLY signalled by: 1. Gateway's phase 'completed' event (gateway.ts) 2. Streaming 'final' event (runtime-event-handlers.ts) 3. Safety timeout after 90s of no events Also: fully controlled graph expanded prop, stable key, card.active decoupled from streamingReplyText, suppressThinking prop. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/pages/Chat/ExecutionGraphCard.tsx | 5 +++- src/pages/Chat/index.tsx | 32 ++++++++++++++------------ src/stores/chat/history-actions.ts | 33 ++++++++++----------------- 3 files changed, 34 insertions(+), 36 deletions(-) diff --git a/src/pages/Chat/ExecutionGraphCard.tsx b/src/pages/Chat/ExecutionGraphCard.tsx index 17e3d4c..911a1a7 100644 --- a/src/pages/Chat/ExecutionGraphCard.tsx +++ b/src/pages/Chat/ExecutionGraphCard.tsx @@ -8,6 +8,8 @@ interface ExecutionGraphCardProps { agentLabel: string; steps: TaskStep[]; active: boolean; + /** Hide the trailing "Thinking ..." indicator even when active. */ + suppressThinking?: boolean; /** * When provided, the card becomes fully controlled: the parent owns the * expand state (e.g. to persist across remounts) and toggling goes through @@ -149,6 +151,7 @@ export function ExecutionGraphCard({ agentLabel, steps, active, + suppressThinking = false, expanded: controlledExpanded, onExpandedChange, }: ExecutionGraphCardProps) { @@ -175,7 +178,7 @@ export function ExecutionGraphCard({ const toolCount = steps.filter((step) => step.kind === 'tool').length; const processCount = steps.length - toolCount; - const shouldShowTrailingThinking = active; + const shouldShowTrailingThinking = active && !suppressThinking; if (!expanded) { return ( diff --git a/src/pages/Chat/index.tsx b/src/pages/Chat/index.tsx index 158205f..60fbad5 100644 --- a/src/pages/Chat/index.tsx +++ b/src/pages/Chat/index.tsx @@ -368,7 +368,12 @@ export function Chat() { foldedNarrationIndices.add(idx + 1 + offset); } - const cardActive = isLatestOpenRun && streamingReplyText == null; + // The graph should stay "active" (expanded, can show trailing thinking) + // for the entire duration of the run — not just until a streaming reply + // appears. Tying active to streamingReplyText caused a flicker: a brief + // active→false→true transition collapsed the graph via ExecutionGraphCard's + // uncontrolled path before the controlled `expanded` override could kick in. + const cardActive = isLatestOpenRun; return [{ triggerIndex: idx, @@ -517,24 +522,22 @@ 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; + // Always use the controlled expanded prop instead of + // relying on ExecutionGraphCard's uncontrolled state. + // Uncontrolled state is lost on remount (key changes + // when loadHistory replaces message ids), causing + // spurious collapse. The controlled prop survives + // remounts because it's computed fresh each render. const expanded = userOverride != null ? userOverride - : autoCollapsedRunKeys.has(runKey) - ? false - : isStreamingReply - ? true - : undefined; + : !autoCollapsedRunKeys.has(runKey); return ( setGraphExpandedOverrides((prev) => ({ ...prev, [runKey]: next })) @@ -546,8 +549,9 @@ export function Chat() { ); })} - {/* Streaming message */} - {shouldRenderStreaming && !hasActiveExecutionGraph && ( + {/* Streaming message — render when reply text is separated from graph, + OR when there's streaming content without an active graph */} + {shouldRenderStreaming && (streamingReplyText != null || !hasActiveExecutionGraph) && ( { const base = streamMsg diff --git a/src/stores/chat/history-actions.ts b/src/stores/chat/history-actions.ts index 13ca78a..83acbd6 100644 --- a/src/stores/chat/history-actions.ts +++ b/src/stores/chat/history-actions.ts @@ -2,12 +2,10 @@ import { invokeIpc } from '@/lib/api-client'; import { hostApiFetch } from '@/lib/host-api'; import { useGatewayStore } from '@/stores/gateway'; import { - clearHistoryPoll, enrichWithCachedImages, enrichWithToolResultFiles, getLatestOptimisticUserMessage, getMessageText, - hasNonToolAssistantContent, isInternalMessage, isToolResultRole, loadMissingPreviews, @@ -160,6 +158,18 @@ export function createHistoryActions( return toMs(msg.timestamp) >= userMsTs; }; + // If we're sending but haven't received streaming events, check + // whether the loaded history reveals assistant activity (tool calls, + // narration, etc.). Setting pendingFinal surfaces the execution + // graph / activity indicator in the UI. + // + // Note: we intentionally do NOT set sending=false here. Run + // completion is exclusively signalled by the Gateway's phase + // 'completed' event (handled in gateway.ts) or by receiving a + // 'final' streaming event (handled in runtime-event-handlers.ts). + // Attempting to infer completion from message history is fragile + // and leads to premature sending=false during server-side tool + // execution. if (isSendingNow && !pendingFinal) { const hasRecentAssistantActivity = [...filteredMessages].reverse().some((msg) => { if (msg.role !== 'assistant') return false; @@ -169,25 +179,6 @@ export function createHistoryActions( set({ pendingFinal: true }); } } - - // 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); - }); - const lastMsg = filteredMessages[filteredMessages.length - 1]; - if (recentAssistant && lastMsg === recentAssistant) { - clearHistoryPoll(); - set({ sending: false, activeRunId: null, pendingFinal: false }); - } - } return true; };