fix(ui): restore context panel session chats

This commit is contained in:
Bohdan Triapitsyn
2026-08-14 19:49:17 +03:00
parent 5fbd1c0962
commit 37e12e272b
6 changed files with 103 additions and 23 deletions
+9 -5
View File
@@ -54,7 +54,11 @@ import { MCP_OAUTH_CALLBACK_PATH } from '@/components/sections/mcp/mcpOAuth';
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery'; import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { applyMobileKeyboardMode } from '@/lib/mobileKeyboardMode'; 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 { SyncAppEffects } from '@/apps/AppEffects';
import { resetAppForRuntimeEndpointChange } from '@/apps/runtimeEndpointReset'; import { resetAppForRuntimeEndpointChange } from '@/apps/runtimeEndpointReset';
import { useAppFontEffects } from '@/apps/useAppFontEffects'; import { useAppFontEffects } from '@/apps/useAppFontEffects';
@@ -538,17 +542,16 @@ function App({ apis }: AppProps) {
} }
const applyVisibility = (payload?: EmbeddedVisibilityPayload) => { const applyVisibility = (payload?: EmbeddedVisibilityPayload) => {
const nextVisible = payload?.visible === true; setIsEmbeddedVisible(payload?.visible === true);
setIsEmbeddedVisible(nextVisible);
}; };
const handleMessage = (event: MessageEvent) => { const handleMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) { if (event.origin !== window.location.origin || event.source !== window.parent) {
return; return;
} }
const data = event.data as { type?: unknown; payload?: EmbeddedVisibilityPayload }; const data = event.data as { type?: unknown; payload?: EmbeddedVisibilityPayload };
if (data?.type !== 'openchamber:embedded-visibility') { if (data?.type !== EMBEDDED_VISIBILITY_UPDATE) {
return; return;
} }
@@ -561,6 +564,7 @@ function App({ apis }: AppProps) {
scopedWindow.__openchamberSetEmbeddedVisibility = applyVisibility; scopedWindow.__openchamberSetEmbeddedVisibility = applyVisibility;
window.addEventListener('message', handleMessage); window.addEventListener('message', handleMessage);
requestEmbeddedSessionVisibility();
return () => { return () => {
window.removeEventListener('message', handleMessage); window.removeEventListener('message', handleMessage);
@@ -38,6 +38,8 @@ import { Icon } from "@/components/icon/Icon";
import { import {
EMBEDDED_RUNTIME_BOOTSTRAP_REQUEST, EMBEDDED_RUNTIME_BOOTSTRAP_REQUEST,
EMBEDDED_RUNTIME_BOOTSTRAP_RESPONSE, EMBEDDED_RUNTIME_BOOTSTRAP_RESPONSE,
EMBEDDED_VISIBILITY_REQUEST,
EMBEDDED_VISIBILITY_UPDATE,
getActiveEmbeddedSessionChatTab, getActiveEmbeddedSessionChatTab,
getOrCreateEmbeddedSessionChatURL, getOrCreateEmbeddedSessionChatURL,
type EmbeddedSessionChatURLCacheEntry, type EmbeddedSessionChatURLCacheEntry,
@@ -803,27 +805,34 @@ export const ContextPanel: React.FC = () => {
} }
}, [allowPromptingSubagentSessions]); }, [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(() => { const postEmbeddedVisibilityToChats = React.useCallback(() => {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return; return;
} }
for (const [tabID, frame] of chatFrameRefs.current.entries()) { for (const [tabID, frame] of chatFrameRefs.current.entries()) {
const frameWindow = frame.contentWindow; postEmbeddedVisibilityToChat(tabID, frame, window.location.origin);
if (!frameWindow) {
continue;
}
const payload = { visible: activeChatTabID === tabID };
frameWindow.postMessage(
{
type: 'openchamber:embedded-visibility',
payload,
},
window.location.origin,
);
} }
}, [activeChatTabID]); }, [postEmbeddedVisibilityToChat]);
React.useEffect(() => { React.useEffect(() => {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
@@ -835,13 +844,18 @@ export const ContextPanel: React.FC = () => {
return; return;
} }
const isKnownChatFrame = Array.from(chatFrameRefs.current.values()) const sourceChatFrame = Array.from(chatFrameRefs.current.entries())
.some((frame) => frame.contentWindow === event.source); .find(([, frame]) => frame.contentWindow === event.source);
if (!isKnownChatFrame) { if (!sourceChatFrame) {
return; return;
} }
const data = event.data as { type?: unknown; requestId?: unknown }; 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 (data?.type === EMBEDDED_RUNTIME_BOOTSTRAP_REQUEST) {
if (typeof data.requestId !== 'string' || !data.requestId) return; if (typeof data.requestId !== 'string' || !data.requestId) return;
const runtimeKey = getRuntimeKey(); const runtimeKey = getRuntimeKey();
@@ -882,7 +896,7 @@ export const ContextPanel: React.FC = () => {
window.addEventListener('message', handleMessage); window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage); return () => window.removeEventListener('message', handleMessage);
}, [postChatSettingsSyncToEmbeddedChat, postThemeSyncToEmbeddedChat, setThemeMode, themeMode]); }, [postChatSettingsSyncToEmbeddedChat, postEmbeddedVisibilityToChat, postThemeSyncToEmbeddedChat, setThemeMode, themeMode]);
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
const hasAnyChatTab = tabs.some((tab) => tab.mode === 'chat'); const hasAnyChatTab = tabs.some((tab) => tab.mode === 'chat');
@@ -19,6 +19,7 @@ import {
} from '../contextPanelEmbeddedChat'; } from '../contextPanelEmbeddedChat';
const __dirname = dirname(fileURLToPath(import.meta.url)); 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'); const contextPanelSource = readFileSync(join(__dirname, '..', 'ContextPanel.tsx'), 'utf-8');
type FixtureTab = { type FixtureTab = {
@@ -117,6 +118,7 @@ describe('issue #2815 active-only chat iframe source guard', () => {
expect(block).toContain('<iframe'); expect(block).toContain('<iframe');
expect(block).toContain('key={activeChatTab.id}'); expect(block).toContain('key={activeChatTab.id}');
expect(block).toContain('src={activeChatSrc}'); expect(block).toContain('src={activeChatSrc}');
expect(block).toContain('postEmbeddedVisibilityToChats();');
expect(block).not.toContain("'block' : 'hidden'"); expect(block).not.toContain("'block' : 'hidden'");
}); });
@@ -128,6 +130,32 @@ describe('issue #2815 active-only chat iframe source guard', () => {
"const activeChatSessionID = isOpen && activeTab?.mode === 'chat'", "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', () => { describe('issue #2815 persisted scenario', () => {
@@ -5,11 +5,13 @@ import {
buildEmbeddedSessionChatURL, buildEmbeddedSessionChatURL,
EMBEDDED_RUNTIME_BOOTSTRAP_REQUEST, EMBEDDED_RUNTIME_BOOTSTRAP_REQUEST,
EMBEDDED_RUNTIME_BOOTSTRAP_RESPONSE, EMBEDDED_RUNTIME_BOOTSTRAP_RESPONSE,
EMBEDDED_VISIBILITY_REQUEST,
getOrCreateEmbeddedSessionChatURL, getOrCreateEmbeddedSessionChatURL,
getActiveEmbeddedSessionChatTab, getActiveEmbeddedSessionChatTab,
getEmbeddedSessionChatOriginSessionId, getEmbeddedSessionChatOriginSessionId,
isEmbeddedSessionChat, isEmbeddedSessionChat,
requestEmbeddedSessionRuntimeBootstrap, requestEmbeddedSessionRuntimeBootstrap,
requestEmbeddedSessionVisibility,
resetEmbeddedSessionChatCache, resetEmbeddedSessionChatCache,
type EmbeddedSessionChatURLCacheEntry, type EmbeddedSessionChatURLCacheEntry,
} from './contextPanelEmbeddedChat'; } from './contextPanelEmbeddedChat';
@@ -171,6 +173,22 @@ describe('active embedded session chat', () => {
expect(getActiveEmbeddedSessionChatTab(tabs, null)).toBeNull(); expect(getActiveEmbeddedSessionChatTab(tabs, null)).toBeNull();
expect(getActiveEmbeddedSessionChatTab(tabs, 'missing-chat')).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', () => { describe('isEmbeddedSessionChat', () => {
@@ -28,6 +28,8 @@ export type EmbeddedSessionRuntimeBootstrap = {
export const EMBEDDED_RUNTIME_BOOTSTRAP_REQUEST = 'openchamber:embedded-runtime-bootstrap-request'; 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_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_TIMEOUT_MS = 5_000;
const EMBEDDED_RUNTIME_BOOTSTRAP_RETRY_MS = 100; const EMBEDDED_RUNTIME_BOOTSTRAP_RETRY_MS = 100;
@@ -104,6 +106,13 @@ export const requestEmbeddedSessionRuntimeBootstrap = (): Promise<EmbeddedSessio
}); });
}; };
export const requestEmbeddedSessionVisibility = (): void => {
if (!isEmbeddedSessionChat() || typeof window === 'undefined' || !window.parent || window.parent === window) {
return;
}
window.parent.postMessage({ type: EMBEDDED_VISIBILITY_REQUEST }, window.location.origin);
};
const buildEmbeddedSessionChatURLSignature = ( const buildEmbeddedSessionChatURLSignature = (
sessionID: string, sessionID: string,
directory: string | null, directory: string | null,
+7
View File
@@ -40,6 +40,13 @@ Examples:
These stores coordinate visible app state, navigation, selected tabs, dialogs, and lightweight feature flags. 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 ### Session / project coordination stores
Examples: Examples: