fix(chat): add grace period for Gateway phase completion events

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) <noreply@anthropic.com>
This commit is contained in:
Haze
2026-04-20 18:30:46 +08:00
parent 7d955fc607
commit ef51a8bbbf

View File

@@ -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<typeof setTimeout> | 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<string, unknown> = {
...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(() => {});