Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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",
);
});
12 changes: 10 additions & 2 deletions webview/shared/src/chat/ChatShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}
Expand Down
23 changes: 19 additions & 4 deletions webview/shared/src/chat/PanelComponents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2182,26 +2182,41 @@ 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,
),
];
}
}

return filterDismissedInteractiveEvents(
events,
dismissedInteractiveEventKeys,
);
}, [messages, streaming, interactiveEvents, dismissedInteractiveEventKeys]);
}, [messages, streaming, interactiveEvents, dismissedInteractiveEventKeys, currentSessionId]);

const filteredCommands = useMemo(() => {
if (!slashTrigger) {
Expand Down
42 changes: 33 additions & 9 deletions webview/shared/src/chat/ToastOverlay.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<CentralizedToastNotification | null>(null);
const [toastNow, setToastNow] = useState(() => Date.now());
const toastQueueRef = useRef<CentralizedToastNotification[]>([]);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -217,6 +235,15 @@ export function LiveEventBanner({
Retrying in {formatRetryCountdown(activeToast.next, toastNow)}
</div>
) : null}
<button
type="button"
className="oc-quick-input-icon-btn shrink-0 rounded p-1"
title="Dismiss notification"
aria-label="Dismiss notification"
onClick={dismissActiveToast}
>
<X className="h-3.5 w-3.5" />
</button>
</div>
{activeToast.message ? (
<div className={cn(
Expand All @@ -231,6 +258,3 @@ export function LiveEventBanner({
</div>
);
})()}
</div>
);
}
Loading