diff --git a/tests/regression/interactive-toast-and-terminal-handoff-regression.test.mjs b/tests/regression/interactive-toast-and-terminal-handoff-regression.test.mjs new file mode 100644 index 0000000..466bd32 --- /dev/null +++ b/tests/regression/interactive-toast-and-terminal-handoff-regression.test.mjs @@ -0,0 +1,59 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const read = (relativePath) => + fs.readFileSync(path.join(repoRoot, relativePath), "utf8"); + +test("toast overlay scopes notifications to the active session and exposes dismissal", () => { + const source = read("webview/shared/src/chat/ToastOverlay.tsx"); + + assert.match( + source, + /notification\.sessionId\s*&&\s*sessionId[\s\S]*notification\.sessionId\s*!==\s*sessionId/, + "toast rendering must reject notifications belonging to another session", + ); + assert.match( + source, + /aria-label=\"Dismiss notification\"[\s\S]*onClick=\{dismissActiveToast\}/, + "toast rendering must provide an explicit dismiss action", + ); + assert.match( + source, + /const dismissActiveToast = \(\) => \{[\s\S]*clearActiveTimer\(\)[\s\S]*showNextToast\(\)/, + "dismissing a toast must release its timer and advance the queue", + ); +}); + +test("interactive composer ignores events from another session", () => { + const source = read("webview/shared/src/chat/PanelComponents.tsx"); + + const sessionFilterCount = (source.match(/event\.sessionID === currentSessionId/g) ?? []).length; + assert.ok( + sessionFilterCount >= 3, + "top-level, streaming, and hydrated interactive events must share session filtering", + ); + assert.match( + source, + /dismissedInteractiveEventKeys, currentSessionId\]/, + "the composer must recompute when the active session changes", + ); +}); + +test("terminal transcript handoff does not use a stale deferred snapshot", () => { + const source = read("webview/shared/src/chat/ChatShell.tsx"); + + assert.match( + source, + /const transcriptSnapshotForRender = state\.streaming\?\.isActive\s*\n\s*\? deferredTranscriptSnapshot\s*\n\s*: transcriptSnapshot/, + "completed turns must switch to the current transcript snapshot immediately", + ); + assert.match( + source, + /renderMessages=\{transcriptSnapshotForRender\.renderMessages\}/, + "the transcript must consume the same atomic snapshot as its visible entries", + ); +}); diff --git a/webview/shared/src/chat/ChatShell.tsx b/webview/shared/src/chat/ChatShell.tsx index 8064ffb..c20b61c 100644 --- a/webview/shared/src/chat/ChatShell.tsx +++ b/webview/shared/src/chat/ChatShell.tsx @@ -3720,8 +3720,16 @@ function ChatContent() { [renderMessages, visibleConversationEntries], ); const deferredTranscriptSnapshot = useDeferredValue(transcriptSnapshot); + // The deferred snapshot is useful while tokens are arriving, but retaining + // it after the stream becomes inactive leaves the live card unmounted before + // the completed assistant transcript is painted. Use the current atomic + // snapshot for the terminal handoff so the response body and prompt appear + // in the same commit. + const transcriptSnapshotForRender = state.streaming?.isActive + ? deferredTranscriptSnapshot + : transcriptSnapshot; const deferredVisibleConversationEntries = - deferredTranscriptSnapshot.visibleConversationEntries; + transcriptSnapshotForRender.visibleConversationEntries; const transcriptScrollViewport = deferredVisibleConversationEntries.length >= VIRTUALIZED_TRANSCRIPT_MIN_ENTRIES ? scrollRenderViewport @@ -4111,7 +4119,7 @@ function ChatContent() { isProcessing={state.isProcessing} lastCompactedAt={state.lastCompactedAt} onSetBlockExpanded={handleSetBlockExpanded} - renderMessages={deferredTranscriptSnapshot.renderMessages} + renderMessages={transcriptSnapshotForRender.renderMessages} resolveAgentColor={resolveAgentColor} selectedAgent={state.selectedAgent} streamingAgent={deferredStreamingAgent} diff --git a/webview/shared/src/chat/PanelComponents.tsx b/webview/shared/src/chat/PanelComponents.tsx index 6c42d1a..62667c1 100644 --- a/webview/shared/src/chat/PanelComponents.tsx +++ b/webview/shared/src/chat/PanelComponents.tsx @@ -2182,18 +2182,33 @@ export const InputWrapper = memo(function InputWrapper() { // 1. Top-level interactive events (out-of-band questions, etc.) if (Array.isArray(interactiveEvents)) { - events = [...events, ...interactiveEvents]; + events = [ + ...events, + ...interactiveEvents.filter( + (event) => !event.sessionID || !currentSessionId || event.sessionID === currentSessionId, + ), + ]; } // 2. Interactive events from currently streaming response if (streaming?.isActive && Array.isArray(streaming.interactiveEvents)) { - events = [...events, ...streaming.interactiveEvents]; + events = [ + ...events, + ...streaming.interactiveEvents.filter( + (event) => !event.sessionID || !currentSessionId || event.sessionID === currentSessionId, + ), + ]; } else { // 3. If not streaming, fallback to interactive events from the latest assistant message, // but ONLY if the assistant is the absolute last speaker in the conversation. const lastMsg = messages.length > 0 ? messages[messages.length - 1] : null; if (lastMsg?.role === "assistant" && Array.isArray(lastMsg.interactiveEvents)) { - events = [...events, ...lastMsg.interactiveEvents]; + events = [ + ...events, + ...lastMsg.interactiveEvents.filter( + (event) => !event.sessionID || !currentSessionId || event.sessionID === currentSessionId, + ), + ]; } } @@ -2201,7 +2216,7 @@ export const InputWrapper = memo(function InputWrapper() { events, dismissedInteractiveEventKeys, ); - }, [messages, streaming, interactiveEvents, dismissedInteractiveEventKeys]); + }, [messages, streaming, interactiveEvents, dismissedInteractiveEventKeys, currentSessionId]); const filteredCommands = useMemo(() => { if (!slashTrigger) { diff --git a/webview/shared/src/chat/ToastOverlay.tsx b/webview/shared/src/chat/ToastOverlay.tsx index fcc8ed6..93aadec 100644 --- a/webview/shared/src/chat/ToastOverlay.tsx +++ b/webview/shared/src/chat/ToastOverlay.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import { AlertTriangle, CheckCircle2, Info, XCircle } from "lucide-react"; +import { AlertTriangle, CheckCircle2, Info, X, XCircle } from "lucide-react"; import { cn } from "../utils"; import logger from "./lib/logger"; @@ -60,13 +60,24 @@ export function LiveEventBanner({ const notifications = useMemo(() => { const persisted = extractCentralizedToastNotifications(rawSdkEventPayloads); const allNotifications = [...persisted, ...(liveNotifications ?? [])]; - const placedNotifications = allNotifications.filter((notification) => - placement === "composer" + const placedNotifications = allNotifications.filter((notification) => { + // A late SSE/debug payload from another session must never become a + // visible toast in the active session. Untagged notifications are kept + // for compatibility with older SDK payloads and are scoped by the + // reducer/live-event route already. + if ( + notification.sessionId && + sessionId && + notification.sessionId !== sessionId + ) { + return false; + } + return placement === "composer" ? notification.type === "session.status" - : notification.type !== "session.status", - ); + : notification.type !== "session.status"; + }); return placedNotifications; - }, [liveNotifications, placement, rawSdkEventPayloads]); + }, [liveNotifications, placement, rawSdkEventPayloads, sessionId]); const [activeToast, setActiveToast] = useState(null); const [toastNow, setToastNow] = useState(() => Date.now()); const toastQueueRef = useRef([]); @@ -127,6 +138,13 @@ export function LiveEventBanner({ } }; + const dismissActiveToast = () => { + clearActiveTimer(); + activeToastRef.current = null; + setActiveToast(null); + showNextToast(); + }; + useEffect(() => { const currentSessionKey = sessionId ?? null; if (initializedSessionRef.current !== currentSessionKey) { @@ -217,6 +235,15 @@ export function LiveEventBanner({ Retrying in {formatRetryCountdown(activeToast.next, toastNow)} ) : null} + {activeToast.message ? (
); })()} -
- ); -}