diff --git a/src/pages/Chat/index.tsx b/src/pages/Chat/index.tsx index 1325de5..61f1c78 100644 --- a/src/pages/Chat/index.tsx +++ b/src/pages/Chat/index.tsx @@ -82,6 +82,7 @@ export function Chat() { const streamingMessage = useChatStore((s) => s.streamingMessage); const streamingTools = useChatStore((s) => s.streamingTools); const pendingFinal = useChatStore((s) => s.pendingFinal); + const activeRunId = useChatStore((s) => s.activeRunId); const sendMessage = useChatStore((s) => s.sendMessage); const abortRun = useChatStore((s) => s.abortRun); const clearError = useChatStore((s) => s.clearError); @@ -280,8 +281,12 @@ export function Chat() { ); }); const runStillExecutingTools = hasToolActivity && !hasFinalReply; + // runStillExecutingTools bridges the brief gap between tool rounds when + // Gateway temporarily clears sending. However, after an explicit abort + // (which clears activeRunId), we must NOT keep the run "open" — so we + // gate it on activeRunId being present. const isLatestOpenRun = nextUserIndex === -1 - && (sending || pendingFinal || hasAnyStreamContent || runStillExecutingTools); + && (sending || pendingFinal || hasAnyStreamContent || (runStillExecutingTools && !!activeRunId)); const replyIndexOffset = findReplyMessageIndex(segmentMessages, isLatestOpenRun); const replyIndex = replyIndexOffset === -1 ? null : idx + 1 + replyIndexOffset; diff --git a/src/stores/chat/helpers.ts b/src/stores/chat/helpers.ts index c3068f6..737fa88 100644 --- a/src/stores/chat/helpers.ts +++ b/src/stores/chat/helpers.ts @@ -23,6 +23,12 @@ let _historyPollTimer: ReturnType | null = null; // before committing the error to give the recovery path a chance. let _errorRecoveryTimer: ReturnType | null = null; +// Track the last run ID that was explicitly aborted by the user. +// Prevents lingering Gateway events from the aborted run from re-arming +// the sending state after abortRun clears it. +let _lastAbortedRunId: string | null = null; +const _blockedRunEvents = new Map[]>(); + function clearErrorRecoveryTimer(): void { if (_errorRecoveryTimer) { clearTimeout(_errorRecoveryTimer); @@ -1020,6 +1026,27 @@ function getLastChatEventAt(): number { return _lastChatEventAt; } +function setLastAbortedRunId(id: string | null): void { + _lastAbortedRunId = id; +} + +function getLastAbortedRunId(): string | null { + return _lastAbortedRunId; +} + +function queueBlockedRunEvent(runId: string, event: Record): void { + const events = _blockedRunEvents.get(runId) ?? []; + events.push({ ...event }); + if (events.length > 100) events.shift(); + _blockedRunEvents.set(runId, events); +} + +function takeBlockedRunEvents(runId: string): Record[] { + const events = _blockedRunEvents.get(runId) ?? []; + _blockedRunEvents.delete(runId); + return events; +} + export { toMs, clearErrorRecoveryTimer, @@ -1050,4 +1077,8 @@ export { setErrorRecoveryTimer, setLastChatEventAt, getLastChatEventAt, + setLastAbortedRunId, + getLastAbortedRunId, + queueBlockedRunEvent, + takeBlockedRunEvents, }; diff --git a/src/stores/chat/runtime-event-actions.ts b/src/stores/chat/runtime-event-actions.ts index beec597..b22bb93 100644 --- a/src/stores/chat/runtime-event-actions.ts +++ b/src/stores/chat/runtime-event-actions.ts @@ -1,4 +1,4 @@ -import { clearHistoryPoll, setLastChatEventAt } from './helpers'; +import { clearHistoryPoll, getLastAbortedRunId, queueBlockedRunEvent, setLastAbortedRunId, setLastChatEventAt } from './helpers'; import type { ChatGet, ChatSet, RuntimeActions } from './store-api'; import { handleRuntimeEventState } from './runtime-event-handlers'; @@ -16,6 +16,28 @@ export function createRuntimeEventActions(set: ChatSet, get: ChatGet): Pick { return { sendMessage: async ( @@ -48,6 +53,7 @@ export function createRuntimeSendActions(set: ChatSet, get: ChatGet): Pick { const trimmed = text.trim(); if (!trimmed && (!attachments || attachments.length === 0)) return; + const currentSendGeneration = ++sendGeneration; const targetSessionKey = resolveMainSessionKeyForAgent(targetAgentId) ?? get().currentSessionKey; if (targetSessionKey !== get().currentSessionKey) { @@ -220,13 +226,43 @@ export function createRuntimeSendActions(set: ChatSet, get: ChatGet): Pick 0) { + queueMicrotask(() => { + for (const blockedEvent of blockedEvents) { + get().handleChatEvent(blockedEvent); + } + }); + } } } catch (err) { + if (currentSendGeneration !== sendGeneration) return; clearHistoryPoll(); set({ error: String(err), sending: false }); } @@ -235,10 +271,16 @@ export function createRuntimeSendActions(set: ChatSet, get: ChatGet): Pick { + sendGeneration += 1; clearHistoryPoll(); clearErrorRecoveryTimer(); - const { currentSessionKey } = get(); - set({ sending: false, streamingText: '', streamingMessage: null, pendingFinal: false, lastUserMessageAt: null, pendingToolImages: [] }); + const { currentSessionKey, activeRunId } = get(); + // Mark the run as aborted BEFORE clearing state, so the event handler + // rejects any lingering Gateway events from this run. Use wildcard '*' + // when activeRunId is not yet known (user stopped before chat.send + // returned a runId) to block ALL run events from re-arming sending. + setLastAbortedRunId(activeRunId || '*'); + set({ sending: false, activeRunId: null, streamingText: '', streamingMessage: null, pendingFinal: false, lastUserMessageAt: null, pendingToolImages: [] }); set({ streamingTools: [] }); try { @@ -250,6 +292,9 @@ export function createRuntimeSendActions(set: ChatSet, get: ChatGet): Pick