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) <noreply@anthropic.com>
This commit is contained in:
Haze
2026-04-20 18:20:39 +08:00
parent 94f5ae2799
commit 7d955fc607
3 changed files with 34 additions and 36 deletions

View File

@@ -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 (

View File

@@ -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 (
<ExecutionGraphCard
key={`graph-${runKey}`}
key={`graph-${currentSessionKey}:${card.triggerIndex}`}
agentLabel={card.agentLabel}
steps={card.steps}
active={card.active}
suppressThinking={card.streamingReplyText != null}
expanded={expanded}
onExpandedChange={(next) =>
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) && (
<ChatMessage
message={(() => {
const base = streamMsg

View File

@@ -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;
};