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;
|
||||
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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user