fix: sync context panel iframe themes

Keeps embedded sessions aligned with parent theme
Prevents iframe reloads on theme changes
Supports theme hotkeys from focused iframes
This commit is contained in:
Bohdan Triapitsyn
2026-06-16 23:33:29 +03:00
parent 91e8e94961
commit 0fef61e25f
12 changed files with 484 additions and 118 deletions
@@ -26,6 +26,7 @@ import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
import { Icon } from "@/components/icon/Icon";
import { OpenChamberLogo } from "@/components/ui/OpenChamberLogo";
import { invokeDesktopCommand } from '@/lib/desktopNative';
import { getOrCreateEmbeddedSessionChatURL, type EmbeddedSessionChatURLCacheEntry } from './contextPanelEmbeddedChat';
import {
type PreviewElementMetadata,
isPreviewElementMetadata,
@@ -401,29 +402,6 @@ const runIframeScript = async <T,>(iframe: HTMLIFrameElement, script: string): P
};
const buildEmbeddedSessionChatURL = (sessionID: string, directory: string | null, readOnly: boolean): string => {
if (typeof window === 'undefined') {
return '';
}
const url = new URL(window.location.pathname, window.location.origin);
url.searchParams.set('ocPanel', 'session-chat');
url.searchParams.set('sessionId', sessionID);
if (readOnly) {
url.searchParams.set('readOnly', '1');
} else {
url.searchParams.delete('readOnly');
}
if (directory && directory.trim().length > 0) {
url.searchParams.set('directory', directory);
} else {
url.searchParams.delete('directory');
}
url.hash = '';
return url.toString();
};
const truncateTabLabel = (value: string, maxChars: number): string => {
if (value.length <= maxChars) {
return value;
@@ -2004,7 +1982,7 @@ export const ContextPanel: React.FC = () => {
const reorderContextPanelTabs = useUIStore((state) => state.reorderContextPanelTabs);
const setSelectedFilePath = useFilesViewTabsStore((state) => state.setSelectedPath);
const openContextPreview = useUIStore((state) => state.openContextPreview);
const { themeMode, lightThemeId, darkThemeId, currentTheme } = useThemeSystem();
const { themeMode, setThemeMode, lightThemeId, darkThemeId, currentTheme } = useThemeSystem();
const tabs = React.useMemo(() => panelState?.tabs ?? [], [panelState?.tabs]);
const activeTab = tabs.find((tab) => tab.id === panelState?.activeTabId) ?? tabs[tabs.length - 1] ?? null;
@@ -2020,6 +1998,7 @@ export const ContextPanel: React.FC = () => {
const activeResizePointerIDRef = React.useRef<number | null>(null);
const panelRef = React.useRef<HTMLElement | null>(null);
const chatFrameRefs = React.useRef<Map<string, HTMLIFrameElement>>(new Map());
const chatFrameSrcByTabIDRef = React.useRef<Map<string, EmbeddedSessionChatURLCacheEntry>>(new Map());
const wasOpenRef = React.useRef(false);
const previousIsOpenRef = React.useRef(isOpen);
const suppressWidthTransitionFrameRef = React.useRef<number | null>(null);
@@ -2179,6 +2158,24 @@ export const ContextPanel: React.FC = () => {
const activeChatTabID = activeTab?.mode === 'chat' ? activeTab.id : null;
const getEmbeddedChatSrc = React.useCallback((tabID: string, sessionID: string, readOnly: boolean): string => {
return getOrCreateEmbeddedSessionChatURL(chatFrameSrcByTabIDRef.current, tabID, sessionID, directoryKey || null, readOnly, {
mode: themeMode,
lightThemeId,
darkThemeId,
currentTheme,
});
}, [currentTheme, darkThemeId, directoryKey, lightThemeId, themeMode]);
React.useEffect(() => {
const liveTabIDs = new Set(tabs.map((tab) => tab.id));
for (const tabID of chatFrameSrcByTabIDRef.current.keys()) {
if (!liveTabIDs.has(tabID)) {
chatFrameSrcByTabIDRef.current.delete(tabID);
}
}
}, [tabs]);
const handleDiffScopeChange = React.useCallback((nextScope: 'working' | 'staged') => {
if (!directoryKey || activeTab?.mode !== 'diff') {
return;
@@ -2267,6 +2264,37 @@ export const ContextPanel: React.FC = () => {
}
}, [activeChatTabID]);
React.useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const handleMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) {
return;
}
const isKnownChatFrame = Array.from(chatFrameRefs.current.values())
.some((frame) => frame.contentWindow === event.source);
if (!isKnownChatFrame) {
return;
}
const data = event.data as { type?: unknown };
if (data?.type !== 'openchamber:cycle-theme-request') {
return;
}
const modes: Array<'light' | 'dark' | 'system'> = ['light', 'dark', 'system'];
const currentIndex = modes.indexOf(themeMode);
const nextIndex = (currentIndex + 1) % modes.length;
setThemeMode(modes[nextIndex]);
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [setThemeMode, themeMode]);
React.useLayoutEffect(() => {
const hasAnyChatTab = tabs.some((tab) => tab.mode === 'chat');
if (!hasAnyChatTab) {
@@ -2447,7 +2475,7 @@ export const ContextPanel: React.FC = () => {
return null;
}
const src = buildEmbeddedSessionChatURL(sessionID, directoryKey || null, tab.readOnly);
const src = getEmbeddedChatSrc(tab.id, sessionID, tab.readOnly);
if (!src) {
return null;
}
@@ -0,0 +1,100 @@
import { afterAll, beforeEach, describe, expect, test } from 'bun:test';
import { getDefaultTheme } from '@/lib/theme/themes';
import type { Theme } from '@/types/theme';
import { buildEmbeddedSessionChatURL, getOrCreateEmbeddedSessionChatURL, type EmbeddedSessionChatURLCacheEntry } from './contextPanelEmbeddedChat';
const originalWindow = globalThis.window;
const installWindowLocation = (href = 'http://127.0.0.1:5173/app') => {
const url = new URL(href);
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
location: {
href: url.toString(),
origin: url.origin,
pathname: url.pathname,
search: url.search,
},
},
});
};
const makeTheme = (id: string, variant: 'light' | 'dark'): Theme => ({
...getDefaultTheme(variant === 'dark'),
metadata: {
...getDefaultTheme(variant === 'dark').metadata,
id,
name: id,
variant,
},
});
beforeEach(() => {
installWindowLocation();
});
afterAll(() => {
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: originalWindow,
});
});
describe('embedded session chat URL', () => {
test('includes parent effective system theme bootstrap data', () => {
const currentTheme = makeTheme('custom-dark', 'dark');
const src = buildEmbeddedSessionChatURL('ses_1', '/repo', false, {
mode: 'system',
lightThemeId: 'custom-light',
darkThemeId: 'custom-dark',
currentTheme,
});
const url = new URL(src);
expect(url.searchParams.get('ocPanel')).toBe('session-chat');
expect(url.searchParams.get('themeMode')).toBe('system');
expect(url.searchParams.get('themeVariant')).toBe('dark');
expect(url.searchParams.get('lightThemeId')).toBe('custom-light');
expect(url.searchParams.get('darkThemeId')).toBe('custom-dark');
expect(JSON.parse(url.searchParams.get('currentTheme') || '{}').metadata.id).toBe('custom-dark');
});
test('freezes bootstrap src per tab so live theme changes do not reload iframe', () => {
const cache = new Map<string, EmbeddedSessionChatURLCacheEntry>();
const first = getOrCreateEmbeddedSessionChatURL(cache, 'tab-1', 'ses_1', '/repo', false, {
mode: 'system',
lightThemeId: 'light-a',
darkThemeId: 'dark-a',
currentTheme: makeTheme('dark-a', 'dark'),
});
const second = getOrCreateEmbeddedSessionChatURL(cache, 'tab-1', 'ses_1', '/repo', false, {
mode: 'light',
lightThemeId: 'light-b',
darkThemeId: 'dark-b',
currentTheme: makeTheme('light-b', 'light'),
});
expect(second).toBe(first);
expect(new URL(second).searchParams.get('themeVariant')).toBe('dark');
});
test('rebuilds cached src when readOnly changes for an existing tab', () => {
const cache = new Map<string, EmbeddedSessionChatURLCacheEntry>();
const theme = {
mode: 'system' as const,
lightThemeId: 'light-a',
darkThemeId: 'dark-a',
currentTheme: makeTheme('dark-a', 'dark'),
};
const writable = getOrCreateEmbeddedSessionChatURL(cache, 'tab-1', 'ses_1', '/repo', false, theme);
const readOnly = getOrCreateEmbeddedSessionChatURL(cache, 'tab-1', 'ses_1', '/repo', true, theme);
expect(readOnly).not.toBe(writable);
expect(new URL(writable).searchParams.get('readOnly')).toBeNull();
expect(new URL(readOnly).searchParams.get('readOnly')).toBe('1');
});
});
@@ -0,0 +1,71 @@
import type { Theme } from '@/types/theme';
export type EmbeddedSessionChatThemeBootstrap = {
mode: 'light' | 'dark' | 'system';
lightThemeId: string;
darkThemeId: string;
currentTheme: Theme;
};
export type EmbeddedSessionChatURLCacheEntry = {
signature: string;
src: string;
};
const buildEmbeddedSessionChatURLSignature = (
sessionID: string,
directory: string | null,
readOnly: boolean,
): string => JSON.stringify({ sessionID, directory: directory || '', readOnly: readOnly === true });
export const buildEmbeddedSessionChatURL = (
sessionID: string,
directory: string | null,
readOnly: boolean,
theme: EmbeddedSessionChatThemeBootstrap,
): string => {
if (typeof window === 'undefined') {
return '';
}
const url = new URL(window.location.pathname, window.location.origin);
url.searchParams.set('ocPanel', 'session-chat');
url.searchParams.set('sessionId', sessionID);
if (readOnly) {
url.searchParams.set('readOnly', '1');
} else {
url.searchParams.delete('readOnly');
}
if (directory && directory.trim().length > 0) {
url.searchParams.set('directory', directory);
} else {
url.searchParams.delete('directory');
}
url.searchParams.set('themeMode', theme.mode);
url.searchParams.set('lightThemeId', theme.lightThemeId);
url.searchParams.set('darkThemeId', theme.darkThemeId);
url.searchParams.set('themeVariant', theme.currentTheme.metadata.variant === 'dark' ? 'dark' : 'light');
url.searchParams.set('currentTheme', JSON.stringify(theme.currentTheme));
url.hash = '';
return url.toString();
};
export const getOrCreateEmbeddedSessionChatURL = (
cache: Map<string, EmbeddedSessionChatURLCacheEntry>,
tabID: string,
sessionID: string,
directory: string | null,
readOnly: boolean,
theme: EmbeddedSessionChatThemeBootstrap,
): string => {
const signature = buildEmbeddedSessionChatURLSignature(sessionID, directory, readOnly);
const existing = cache.get(tabID);
if (existing?.signature === signature) {
return existing.src;
}
const src = buildEmbeddedSessionChatURL(sessionID, directory, readOnly, theme);
cache.set(tabID, { signature, src });
return src;
};