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:
@@ -8,6 +8,8 @@ interface ExecutionGraphCardProps {
|
|||||||
agentLabel: string;
|
agentLabel: string;
|
||||||
steps: TaskStep[];
|
steps: TaskStep[];
|
||||||
active: boolean;
|
active: boolean;
|
||||||
|
/** Hide the trailing "Thinking ..." indicator even when active. */
|
||||||
|
suppressThinking?: boolean;
|
||||||
/**
|
/**
|
||||||
* When provided, the card becomes fully controlled: the parent owns the
|
* When provided, the card becomes fully controlled: the parent owns the
|
||||||
* expand state (e.g. to persist across remounts) and toggling goes through
|
* expand state (e.g. to persist across remounts) and toggling goes through
|
||||||
@@ -149,6 +151,7 @@ export function ExecutionGraphCard({
|
|||||||
agentLabel,
|
agentLabel,
|
||||||
steps,
|
steps,
|
||||||
active,
|
active,
|
||||||
|
suppressThinking = false,
|
||||||
expanded: controlledExpanded,
|
expanded: controlledExpanded,
|
||||||
onExpandedChange,
|
onExpandedChange,
|
||||||
}: ExecutionGraphCardProps) {
|
}: ExecutionGraphCardProps) {
|
||||||
@@ -175,7 +178,7 @@ export function ExecutionGraphCard({
|
|||||||
|
|
||||||
const toolCount = steps.filter((step) => step.kind === 'tool').length;
|
const toolCount = steps.filter((step) => step.kind === 'tool').length;
|
||||||
const processCount = steps.length - toolCount;
|
const processCount = steps.length - toolCount;
|
||||||
const shouldShowTrailingThinking = active;
|
const shouldShowTrailingThinking = active && !suppressThinking;
|
||||||
|
|
||||||
if (!expanded) {
|
if (!expanded) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -368,7 +368,12 @@ export function Chat() {
|
|||||||
foldedNarrationIndices.add(idx + 1 + offset);
|
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 [{
|
return [{
|
||||||
triggerIndex: idx,
|
triggerIndex: idx,
|
||||||
@@ -517,24 +522,22 @@ 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
|
// Always use the controlled expanded prop instead of
|
||||||
// renders — `active` flips to false once the reply
|
// relying on ExecutionGraphCard's uncontrolled state.
|
||||||
// bubble appears, which would trigger auto-collapse
|
// Uncontrolled state is lost on remount (key changes
|
||||||
// inside ExecutionGraphCard's uncontrolled path.
|
// when loadHistory replaces message ids), causing
|
||||||
const isStreamingReply = card.streamingReplyText != null;
|
// spurious collapse. The controlled prop survives
|
||||||
|
// remounts because it's computed fresh each render.
|
||||||
const expanded = userOverride != null
|
const expanded = userOverride != null
|
||||||
? userOverride
|
? userOverride
|
||||||
: autoCollapsedRunKeys.has(runKey)
|
: !autoCollapsedRunKeys.has(runKey);
|
||||||
? false
|
|
||||||
: isStreamingReply
|
|
||||||
? true
|
|
||||||
: undefined;
|
|
||||||
return (
|
return (
|
||||||
<ExecutionGraphCard
|
<ExecutionGraphCard
|
||||||
key={`graph-${runKey}`}
|
key={`graph-${currentSessionKey}:${card.triggerIndex}`}
|
||||||
agentLabel={card.agentLabel}
|
agentLabel={card.agentLabel}
|
||||||
steps={card.steps}
|
steps={card.steps}
|
||||||
active={card.active}
|
active={card.active}
|
||||||
|
suppressThinking={card.streamingReplyText != null}
|
||||||
expanded={expanded}
|
expanded={expanded}
|
||||||
onExpandedChange={(next) =>
|
onExpandedChange={(next) =>
|
||||||
setGraphExpandedOverrides((prev) => ({ ...prev, [runKey]: next }))
|
setGraphExpandedOverrides((prev) => ({ ...prev, [runKey]: next }))
|
||||||
@@ -546,8 +549,9 @@ export function Chat() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Streaming message */}
|
{/* Streaming message — render when reply text is separated from graph,
|
||||||
{shouldRenderStreaming && !hasActiveExecutionGraph && (
|
OR when there's streaming content without an active graph */}
|
||||||
|
{shouldRenderStreaming && (streamingReplyText != null || !hasActiveExecutionGraph) && (
|
||||||
<ChatMessage
|
<ChatMessage
|
||||||
message={(() => {
|
message={(() => {
|
||||||
const base = streamMsg
|
const base = streamMsg
|
||||||
|
|||||||
@@ -2,12 +2,10 @@ import { invokeIpc } from '@/lib/api-client';
|
|||||||
import { hostApiFetch } from '@/lib/host-api';
|
import { hostApiFetch } from '@/lib/host-api';
|
||||||
import { useGatewayStore } from '@/stores/gateway';
|
import { useGatewayStore } from '@/stores/gateway';
|
||||||
import {
|
import {
|
||||||
clearHistoryPoll,
|
|
||||||
enrichWithCachedImages,
|
enrichWithCachedImages,
|
||||||
enrichWithToolResultFiles,
|
enrichWithToolResultFiles,
|
||||||
getLatestOptimisticUserMessage,
|
getLatestOptimisticUserMessage,
|
||||||
getMessageText,
|
getMessageText,
|
||||||
hasNonToolAssistantContent,
|
|
||||||
isInternalMessage,
|
isInternalMessage,
|
||||||
isToolResultRole,
|
isToolResultRole,
|
||||||
loadMissingPreviews,
|
loadMissingPreviews,
|
||||||
@@ -160,6 +158,18 @@ export function createHistoryActions(
|
|||||||
return toMs(msg.timestamp) >= userMsTs;
|
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) {
|
if (isSendingNow && !pendingFinal) {
|
||||||
const hasRecentAssistantActivity = [...filteredMessages].reverse().some((msg) => {
|
const hasRecentAssistantActivity = [...filteredMessages].reverse().some((msg) => {
|
||||||
if (msg.role !== 'assistant') return false;
|
if (msg.role !== 'assistant') return false;
|
||||||
@@ -169,25 +179,6 @@ export function createHistoryActions(
|
|||||||
set({ pendingFinal: true });
|
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;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user