diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 7c4d3d07..1929683e 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -54,7 +54,11 @@ import { MCP_OAUTH_CALLBACK_PATH } from '@/components/sections/mcp/mcpOAuth'; import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery'; import { useI18n } from '@/lib/i18n'; import { applyMobileKeyboardMode } from '@/lib/mobileKeyboardMode'; -import { isEmbeddedSessionChat } from '@/components/layout/contextPanelEmbeddedChat'; +import { + EMBEDDED_VISIBILITY_UPDATE, + isEmbeddedSessionChat, + requestEmbeddedSessionVisibility, +} from '@/components/layout/contextPanelEmbeddedChat'; import { SyncAppEffects } from '@/apps/AppEffects'; import { resetAppForRuntimeEndpointChange } from '@/apps/runtimeEndpointReset'; import { useAppFontEffects } from '@/apps/useAppFontEffects'; @@ -538,17 +542,16 @@ function App({ apis }: AppProps) { } const applyVisibility = (payload?: EmbeddedVisibilityPayload) => { - const nextVisible = payload?.visible === true; - setIsEmbeddedVisible(nextVisible); + setIsEmbeddedVisible(payload?.visible === true); }; const handleMessage = (event: MessageEvent) => { - if (event.origin !== window.location.origin) { + if (event.origin !== window.location.origin || event.source !== window.parent) { return; } const data = event.data as { type?: unknown; payload?: EmbeddedVisibilityPayload }; - if (data?.type !== 'openchamber:embedded-visibility') { + if (data?.type !== EMBEDDED_VISIBILITY_UPDATE) { return; } @@ -561,6 +564,7 @@ function App({ apis }: AppProps) { scopedWindow.__openchamberSetEmbeddedVisibility = applyVisibility; window.addEventListener('message', handleMessage); + requestEmbeddedSessionVisibility(); return () => { window.removeEventListener('message', handleMessage); diff --git a/packages/ui/src/components/layout/ContextPanel.tsx b/packages/ui/src/components/layout/ContextPanel.tsx index 5abd20be..efdb91eb 100644 --- a/packages/ui/src/components/layout/ContextPanel.tsx +++ b/packages/ui/src/components/layout/ContextPanel.tsx @@ -38,6 +38,8 @@ import { Icon } from "@/components/icon/Icon"; import { EMBEDDED_RUNTIME_BOOTSTRAP_REQUEST, EMBEDDED_RUNTIME_BOOTSTRAP_RESPONSE, + EMBEDDED_VISIBILITY_REQUEST, + EMBEDDED_VISIBILITY_UPDATE, getActiveEmbeddedSessionChatTab, getOrCreateEmbeddedSessionChatURL, type EmbeddedSessionChatURLCacheEntry, @@ -803,27 +805,34 @@ export const ContextPanel: React.FC = () => { } }, [allowPromptingSubagentSessions]); + const postEmbeddedVisibilityToChat = React.useCallback(( + tabID: string, + frame: HTMLIFrameElement, + targetOrigin: string, + ) => { + const frameWindow = frame.contentWindow; + if (!frameWindow) { + return; + } + + frameWindow.postMessage( + { + type: EMBEDDED_VISIBILITY_UPDATE, + payload: { visible: activeChatTabID === tabID }, + }, + targetOrigin, + ); + }, [activeChatTabID]); + const postEmbeddedVisibilityToChats = React.useCallback(() => { if (typeof window === 'undefined') { return; } for (const [tabID, frame] of chatFrameRefs.current.entries()) { - const frameWindow = frame.contentWindow; - if (!frameWindow) { - continue; - } - - const payload = { visible: activeChatTabID === tabID }; - frameWindow.postMessage( - { - type: 'openchamber:embedded-visibility', - payload, - }, - window.location.origin, - ); + postEmbeddedVisibilityToChat(tabID, frame, window.location.origin); } - }, [activeChatTabID]); + }, [postEmbeddedVisibilityToChat]); React.useEffect(() => { if (typeof window === 'undefined') { @@ -835,13 +844,18 @@ export const ContextPanel: React.FC = () => { return; } - const isKnownChatFrame = Array.from(chatFrameRefs.current.values()) - .some((frame) => frame.contentWindow === event.source); - if (!isKnownChatFrame) { + const sourceChatFrame = Array.from(chatFrameRefs.current.entries()) + .find(([, frame]) => frame.contentWindow === event.source); + if (!sourceChatFrame) { return; } const data = event.data as { type?: unknown; requestId?: unknown }; + if (data?.type === EMBEDDED_VISIBILITY_REQUEST) { + const [tabID, frame] = sourceChatFrame; + postEmbeddedVisibilityToChat(tabID, frame, event.origin); + return; + } if (data?.type === EMBEDDED_RUNTIME_BOOTSTRAP_REQUEST) { if (typeof data.requestId !== 'string' || !data.requestId) return; const runtimeKey = getRuntimeKey(); @@ -882,7 +896,7 @@ export const ContextPanel: React.FC = () => { window.addEventListener('message', handleMessage); return () => window.removeEventListener('message', handleMessage); - }, [postChatSettingsSyncToEmbeddedChat, postThemeSyncToEmbeddedChat, setThemeMode, themeMode]); + }, [postChatSettingsSyncToEmbeddedChat, postEmbeddedVisibilityToChat, postThemeSyncToEmbeddedChat, setThemeMode, themeMode]); React.useLayoutEffect(() => { const hasAnyChatTab = tabs.some((tab) => tab.mode === 'chat'); diff --git a/packages/ui/src/components/layout/__tests__/issue-2815-sessionChatIframesMountAllTabs.test.ts b/packages/ui/src/components/layout/__tests__/issue-2815-sessionChatIframesMountAllTabs.test.ts index d540f359..ccb2fea0 100644 --- a/packages/ui/src/components/layout/__tests__/issue-2815-sessionChatIframesMountAllTabs.test.ts +++ b/packages/ui/src/components/layout/__tests__/issue-2815-sessionChatIframesMountAllTabs.test.ts @@ -19,6 +19,7 @@ import { } from '../contextPanelEmbeddedChat'; const __dirname = dirname(fileURLToPath(import.meta.url)); +const appSource = readFileSync(join(__dirname, '..', '..', '..', 'App.tsx'), 'utf-8'); const contextPanelSource = readFileSync(join(__dirname, '..', 'ContextPanel.tsx'), 'utf-8'); type FixtureTab = { @@ -117,6 +118,7 @@ describe('issue #2815 active-only chat iframe source guard', () => { expect(block).toContain(' { "const activeChatSessionID = isOpen && activeTab?.mode === 'chat'", ); }); + + test('answers the mounted iframe visibility handshake from the active tab', () => { + expect(contextPanelSource).toContain('data?.type === EMBEDDED_VISIBILITY_REQUEST'); + expect(contextPanelSource).toContain('frame.contentWindow === event.source'); + expect(contextPanelSource).toContain('payload: { visible: activeChatTabID === tabID }'); + }); + + test('requests authoritative visibility after installing the iframe listener', () => { + const effectStart = appSource.indexOf('const applyVisibility = (payload?: EmbeddedVisibilityPayload) => {'); + const listenerIndex = appSource.indexOf("window.addEventListener('message', handleMessage);", effectStart); + const requestIndex = appSource.indexOf('requestEmbeddedSessionVisibility();', effectStart); + + expect(effectStart).toBeGreaterThan(-1); + expect(listenerIndex).toBeGreaterThan(effectStart); + expect(requestIndex).toBeGreaterThan(listenerIndex); + }); + + test('gates embedded chat subscriptions and background work on visibility', () => { + expect(appSource).toContain( + 'const embeddedBackgroundWorkEnabled = !embeddedSessionChat || isEmbeddedVisible;', + ); + expect(appSource).toContain('active={embeddedBackgroundWorkEnabled}'); + expect(appSource).toContain( + 'useWebNotificationStream({ enabled: embeddedBackgroundWorkEnabled });', + ); + }); }); describe('issue #2815 persisted scenario', () => { diff --git a/packages/ui/src/components/layout/contextPanelEmbeddedChat.test.ts b/packages/ui/src/components/layout/contextPanelEmbeddedChat.test.ts index 1c2cb866..26bdfea1 100644 --- a/packages/ui/src/components/layout/contextPanelEmbeddedChat.test.ts +++ b/packages/ui/src/components/layout/contextPanelEmbeddedChat.test.ts @@ -5,11 +5,13 @@ import { buildEmbeddedSessionChatURL, EMBEDDED_RUNTIME_BOOTSTRAP_REQUEST, EMBEDDED_RUNTIME_BOOTSTRAP_RESPONSE, + EMBEDDED_VISIBILITY_REQUEST, getOrCreateEmbeddedSessionChatURL, getActiveEmbeddedSessionChatTab, getEmbeddedSessionChatOriginSessionId, isEmbeddedSessionChat, requestEmbeddedSessionRuntimeBootstrap, + requestEmbeddedSessionVisibility, resetEmbeddedSessionChatCache, type EmbeddedSessionChatURLCacheEntry, } from './contextPanelEmbeddedChat'; @@ -171,6 +173,22 @@ describe('active embedded session chat', () => { expect(getActiveEmbeddedSessionChatTab(tabs, null)).toBeNull(); expect(getActiveEmbeddedSessionChatTab(tabs, 'missing-chat')).toBeNull(); }); + + test('requests authoritative visibility from the same-origin parent', () => { + installWindowLocation('http://127.0.0.1:5173/app?ocPanel=session-chat&sessionId=ses_1'); + resetEmbeddedSessionChatCache(); + const calls: Array<{ message: unknown; origin: string }> = []; + (window as unknown as { parent: { postMessage: (message: unknown, origin: string) => void } }).parent = { + postMessage: (message, origin) => calls.push({ message, origin }), + }; + + requestEmbeddedSessionVisibility(); + + expect(calls).toEqual([{ + message: { type: EMBEDDED_VISIBILITY_REQUEST }, + origin: 'http://127.0.0.1:5173', + }]); + }); }); describe('isEmbeddedSessionChat', () => { diff --git a/packages/ui/src/components/layout/contextPanelEmbeddedChat.ts b/packages/ui/src/components/layout/contextPanelEmbeddedChat.ts index 05068ca6..ec1ad921 100644 --- a/packages/ui/src/components/layout/contextPanelEmbeddedChat.ts +++ b/packages/ui/src/components/layout/contextPanelEmbeddedChat.ts @@ -28,6 +28,8 @@ export type EmbeddedSessionRuntimeBootstrap = { export const EMBEDDED_RUNTIME_BOOTSTRAP_REQUEST = 'openchamber:embedded-runtime-bootstrap-request'; export const EMBEDDED_RUNTIME_BOOTSTRAP_RESPONSE = 'openchamber:embedded-runtime-bootstrap-response'; +export const EMBEDDED_VISIBILITY_REQUEST = 'openchamber:embedded-visibility-request'; +export const EMBEDDED_VISIBILITY_UPDATE = 'openchamber:embedded-visibility'; const EMBEDDED_RUNTIME_BOOTSTRAP_TIMEOUT_MS = 5_000; const EMBEDDED_RUNTIME_BOOTSTRAP_RETRY_MS = 100; @@ -104,6 +106,13 @@ export const requestEmbeddedSessionRuntimeBootstrap = (): Promise { + if (!isEmbeddedSessionChat() || typeof window === 'undefined' || !window.parent || window.parent === window) { + return; + } + window.parent.postMessage({ type: EMBEDDED_VISIBILITY_REQUEST }, window.location.origin); +}; + const buildEmbeddedSessionChatURLSignature = ( sessionID: string, directory: string | null, diff --git a/packages/ui/src/stores/DOCUMENTATION.md b/packages/ui/src/stores/DOCUMENTATION.md index a650e008..14df83a7 100644 --- a/packages/ui/src/stores/DOCUMENTATION.md +++ b/packages/ui/src/stores/DOCUMENTATION.md @@ -40,6 +40,13 @@ Examples: These stores coordinate visible app state, navigation, selected tabs, dialogs, and lightweight feature flags. +Context-panel session chats mount only the active chat iframe. After installing +its message listener, the iframe requests its authoritative visibility from the +parent. The parent accepts requests only from a currently mounted chat frame and +answers from the current active tab. Do not rely only on a parent `onLoad` +notification: it can arrive before the iframe listener exists and leave a +visible chat with background work and message subscriptions disabled. + ### Session / project coordination stores Examples: