From ef51a8bbbf807a0f8f2ebeb34f4f6752e71c723c Mon Sep 17 00:00:00 2001 From: Haze <709547807@qq.com> Date: Mon, 20 Apr 2026 18:30:46 +0800 Subject: [PATCH] fix(chat): add grace period for Gateway phase completion events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Gateway sends phase "end" after each tool-execution round (sub-run), not just when the entire conversation finishes. This caused sending=false between tool rounds, breaking the thinking indicator and input state. Add a 5-second grace timer: on phase "end", delay sending=false. If a new streaming event, "started" phase, or chat data arrives within the window, the timer is cancelled and sending stays true. Only if the grace period expires with no new activity does the run finalize. Also: remove loadHistory finalize logic entirely — run completion is now handled exclusively by Gateway phase events (with grace) and streaming final events. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/stores/gateway.ts | 46 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 39 insertions(+), 7 deletions(-) diff --git a/src/stores/gateway.ts b/src/stores/gateway.ts index 910f336..38c2f4a 100644 --- a/src/stores/gateway.ts +++ b/src/stores/gateway.ts @@ -19,6 +19,20 @@ let lastLoadSessionsAt = 0; let lastLoadHistoryAt = 0; let cronRepairTriggeredThisSession = false; +// Grace timer for agent phase completion. The Gateway sends phase "end" +// after each tool-execution round, not just at the end of the entire run. +// We delay `sending=false` to give the next sub-run's "started" event +// time to arrive. If a new streaming event or started phase cancels the +// timer, sending stays true seamlessly. +let _phaseCompletionTimer: ReturnType | null = null; +const PHASE_COMPLETION_GRACE_MS = 5_000; +function clearPhaseCompletionTimer(): void { + if (_phaseCompletionTimer) { + clearTimeout(_phaseCompletionTimer); + _phaseCompletionTimer = null; + } +} + interface GatewayHealth { ok: boolean; error?: string; @@ -128,6 +142,9 @@ function handleGatewayNotification(notification: { method?: string; params?: Rec const hasChatData = (p.state ?? data.state) || (p.message ?? data.message); if (hasChatData) { + // Any streaming data cancels the phase-completion grace timer — the + // run is still producing output (or a new sub-run has started). + clearPhaseCompletionTimer(); const normalizedEvent: Record = { ...data, runId: p.runId ?? data.runId, @@ -149,6 +166,7 @@ function handleGatewayNotification(notification: { method?: string; params?: Rec const runId = p.runId ?? data.runId; const sessionKey = p.sessionKey ?? data.sessionKey; if (phase === 'started' && runId != null && sessionKey != null) { + clearPhaseCompletionTimer(); import('./chat') .then(({ useChatStore }) => { const state = useChatStore.getState(); @@ -189,13 +207,27 @@ function handleGatewayNotification(notification: { method?: string; params?: Rec maybeLoadHistory(state); } if ((matchesCurrentSession || matchesActiveRun) && state.sending) { - useChatStore.setState({ - sending: false, - activeRunId: null, - pendingFinal: false, - lastUserMessageAt: null, - error: null, - }); + // The Gateway sends phase "end" after each tool-execution round, + // not only when the entire conversation finishes. Delay the + // sending=false so a subsequent sub-run's "started" event or + // streaming delta can cancel it and keep the UI in the active state. + clearPhaseCompletionTimer(); + _phaseCompletionTimer = setTimeout(() => { + _phaseCompletionTimer = null; + const current = useChatStore.getState(); + // Only finalize if still in the same run and no new streaming data arrived. + if (current.sending && !current.streamingMessage) { + useChatStore.setState({ + sending: false, + activeRunId: null, + pendingFinal: false, + lastUserMessageAt: null, + error: null, + }); + // Reload history to get the final state. + maybeLoadHistory(current); + } + }, PHASE_COMPLETION_GRACE_MS); } }) .catch(() => {});