fix(ui): compact embedded chat URLs (#2431)

* fix(ui): keep embedded session chat URLs compact

* test(ui): guard embedded session chat URL length

* fix(ui): sync embedded custom themes

Bootstrap custom themes from parent window so iframe URLs stay compact.

Refs #2423

* docs(ui): clarify theme sync precedence
This commit is contained in:
Kai Yang
2026-07-26 15:33:04 +03:00
committed by GitHub
parent ae40f0fe7d
commit 023ec2362e
6 changed files with 135 additions and 22 deletions
@@ -2457,6 +2457,10 @@ export const ContextPanel: React.FC = () => {
} }
const data = event.data as { type?: unknown }; const data = event.data as { type?: unknown };
if (data?.type === 'openchamber:theme-sync-request') {
postThemeSyncToEmbeddedChat();
return;
}
if (data?.type === 'openchamber:chat-settings-request') { if (data?.type === 'openchamber:chat-settings-request') {
postChatSettingsSyncToEmbeddedChat(); postChatSettingsSyncToEmbeddedChat();
return; return;
@@ -2473,7 +2477,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, setThemeMode, themeMode]); }, [postChatSettingsSyncToEmbeddedChat, postThemeSyncToEmbeddedChat, setThemeMode, themeMode]);
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
const hasAnyChatTab = tabs.some((tab) => tab.mode === 'chat'); const hasAnyChatTab = tabs.some((tab) => tab.mode === 'chat');
@@ -50,7 +50,7 @@ afterAll(() => {
}); });
describe('embedded session chat URL', () => { describe('embedded session chat URL', () => {
test('includes parent effective system theme bootstrap data', () => { test('includes compact parent theme bootstrap data', () => {
const currentTheme = makeTheme('custom-dark', 'dark'); const currentTheme = makeTheme('custom-dark', 'dark');
const src = buildEmbeddedSessionChatURL('ses_1', '/repo', false, { const src = buildEmbeddedSessionChatURL('ses_1', '/repo', false, {
@@ -67,7 +67,41 @@ describe('embedded session chat URL', () => {
expect(url.searchParams.get('themeVariant')).toBe('dark'); expect(url.searchParams.get('themeVariant')).toBe('dark');
expect(url.searchParams.get('lightThemeId')).toBe('custom-light'); expect(url.searchParams.get('lightThemeId')).toBe('custom-light');
expect(url.searchParams.get('darkThemeId')).toBe('custom-dark'); expect(url.searchParams.get('darkThemeId')).toBe('custom-dark');
expect(JSON.parse(url.searchParams.get('currentTheme') || '{}').metadata.id).toBe('custom-dark'); expect(url.searchParams.get('currentTheme')).toBeNull();
});
test('does not encode syntax tokens in the URL', () => {
const currentTheme = makeTheme('token-rich-dark', 'dark');
currentTheme.colors.syntax.tokens = Object.fromEntries(
Array.from({ length: 500 }, (_, index) => [`token-${index}`, `#${index.toString(16).padStart(6, '0')}`]),
);
const src = buildEmbeddedSessionChatURL(
'ses_abcdefghijklmnopqrstuvwxyz0123456789',
'/workspace/projects/openchamber',
true,
{
mode: 'system',
lightThemeId: 'token-rich-light',
darkThemeId: 'token-rich-dark',
currentTheme,
},
);
const srcWithoutTokens = buildEmbeddedSessionChatURL(
'ses_abcdefghijklmnopqrstuvwxyz0123456789',
'/workspace/projects/openchamber',
true,
{
mode: 'system',
lightThemeId: 'token-rich-light',
darkThemeId: 'token-rich-dark',
currentTheme: makeTheme('token-rich-dark', 'dark'),
},
);
expect(new URL(src).searchParams.get('currentTheme')).toBeNull();
expect(src).toBe(srcWithoutTokens);
}); });
test('freezes bootstrap src per tab so live theme changes do not reload iframe', () => { test('freezes bootstrap src per tab so live theme changes do not reload iframe', () => {
@@ -46,7 +46,6 @@ export const buildEmbeddedSessionChatURL = (
url.searchParams.set('lightThemeId', theme.lightThemeId); url.searchParams.set('lightThemeId', theme.lightThemeId);
url.searchParams.set('darkThemeId', theme.darkThemeId); url.searchParams.set('darkThemeId', theme.darkThemeId);
url.searchParams.set('themeVariant', theme.currentTheme.metadata.variant === 'dark' ? 'dark' : 'light'); url.searchParams.set('themeVariant', theme.currentTheme.metadata.variant === 'dark' ? 'dark' : 'light');
url.searchParams.set('currentTheme', JSON.stringify(theme.currentTheme));
url.hash = ''; url.hash = '';
return url.toString(); return url.toString();
@@ -1,9 +1,15 @@
import { afterAll, beforeEach, describe, expect, test } from 'bun:test'; import { afterAll, beforeEach, describe, expect, test } from 'bun:test';
import { getInitialSystemPreference } from './theme-embedded-bootstrap'; import { getDefaultTheme } from '@/lib/theme/themes';
import { resetEmbeddedSessionChatCache } from '@/components/layout/contextPanelEmbeddedChat';
import {
getInitialSystemPreference,
publishEmbeddedThemeBootstrap,
readEmbeddedThemeBootstrap,
} from './theme-embedded-bootstrap';
const originalWindow = globalThis.window; const originalWindow = globalThis.window;
const installWindow = (search: string, matchMediaDark: boolean) => { const installWindow = (search: string, matchMediaDark: boolean, parentTheme?: unknown) => {
Object.defineProperty(globalThis, 'window', { Object.defineProperty(globalThis, 'window', {
configurable: true, configurable: true,
value: { value: {
@@ -11,12 +17,14 @@ const installWindow = (search: string, matchMediaDark: boolean) => {
search, search,
}, },
matchMedia: () => ({ matches: matchMediaDark }), matchMedia: () => ({ matches: matchMediaDark }),
parent: parentTheme === undefined ? null : { __openchamberEmbeddedThemeBootstrap: parentTheme },
}, },
}); });
}; };
beforeEach(() => { beforeEach(() => {
installWindow('', false); installWindow('', false);
resetEmbeddedSessionChatCache();
}); });
afterAll(() => { afterAll(() => {
@@ -29,7 +37,40 @@ afterAll(() => {
describe('ThemeSystemProvider embedded bootstrap', () => { describe('ThemeSystemProvider embedded bootstrap', () => {
test('uses parent effective variant for embedded system theme before iframe matchMedia', () => { test('uses parent effective variant for embedded system theme before iframe matchMedia', () => {
installWindow('?ocPanel=session-chat&themeMode=system&themeVariant=dark', false); installWindow('?ocPanel=session-chat&themeMode=system&themeVariant=dark', false);
resetEmbeddedSessionChatCache();
expect(getInitialSystemPreference()).toBe(true); expect(getInitialSystemPreference()).toBe(true);
}); });
test('uses a validated parent custom theme before first render', () => {
const customTheme = {
...getDefaultTheme(true),
metadata: {
...getDefaultTheme(true).metadata,
id: 'custom-dark',
name: 'Custom dark',
variant: 'dark' as const,
},
};
installWindow('?ocPanel=session-chat', false, customTheme);
resetEmbeddedSessionChatCache();
expect(readEmbeddedThemeBootstrap()).toBe(customTheme);
});
test('rejects an invalid parent theme bootstrap', () => {
installWindow('?ocPanel=session-chat', false, { metadata: { id: 'invalid' } });
resetEmbeddedSessionChatCache();
expect(readEmbeddedThemeBootstrap()).toBeNull();
});
test('publishes the current parent theme without using the URL', () => {
const currentTheme = getDefaultTheme(true);
publishEmbeddedThemeBootstrap(currentTheme);
expect((globalThis.window as unknown as { __openchamberEmbeddedThemeBootstrap?: unknown })
.__openchamberEmbeddedThemeBootstrap).toBe(currentTheme);
});
}); });
+18 -15
View File
@@ -22,7 +22,12 @@ import {
import { ThemeSystemContext, type ThemeContextValue } from './theme-system-context'; import { ThemeSystemContext, type ThemeContextValue } from './theme-system-context';
import type { VSCodeThemePayload } from '@/lib/theme/vscode/adapter'; import type { VSCodeThemePayload } from '@/lib/theme/vscode/adapter';
import { runtimeFetch } from '@/lib/runtime-fetch'; import { runtimeFetch } from '@/lib/runtime-fetch';
import { getInitialSystemPreference, readEmbeddedThemeSearchParams } from './theme-embedded-bootstrap'; import {
getInitialSystemPreference,
publishEmbeddedThemeBootstrap,
readEmbeddedThemeBootstrap,
readEmbeddedThemeSearchParams,
} from './theme-embedded-bootstrap';
import { isValidTheme } from './theme-validation'; import { isValidTheme } from './theme-validation';
import { getSyncedThemeFromPayload, getSyncedThemeVariant } from './theme-sync-payload'; import { getSyncedThemeFromPayload, getSyncedThemeVariant } from './theme-sync-payload';
import { getRuntimeKey, subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch'; import { getRuntimeKey, subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
@@ -44,17 +49,7 @@ const DEFAULT_LIGHT_ID = DEFAULT_LIGHT_THEME_ID;
const DEFAULT_DARK_ID = DEFAULT_DARK_THEME_ID; const DEFAULT_DARK_ID = DEFAULT_DARK_THEME_ID;
const readEmbeddedCurrentTheme = (): Theme | null => { const readEmbeddedCurrentTheme = (): Theme | null => {
const raw = readEmbeddedThemeSearchParams()?.get('currentTheme'); return readEmbeddedThemeBootstrap();
if (!raw) {
return null;
}
try {
const parsed = JSON.parse(raw);
return isValidTheme(parsed) ? parsed : null;
} catch {
return null;
}
}; };
const fallbackThemeForVariant = (variant: 'light' | 'dark'): Theme => const fallbackThemeForVariant = (variant: 'light' | 'dark'): Theme =>
@@ -198,6 +193,7 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
add(vscodeTheme); add(vscodeTheme);
} }
// Live-synced theme wins over bootstrap theme when IDs match (add is first-wins).
if (embeddedSyncedTheme) { if (embeddedSyncedTheme) {
add(embeddedSyncedTheme); add(embeddedSyncedTheme);
} }
@@ -370,6 +366,9 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
} }
const restoreTransitions = suppressTransitionsForThemeSwitch(); const restoreTransitions = suppressTransitionsForThemeSwitch();
cssGenerator.apply(currentTheme); cssGenerator.apply(currentTheme);
if (!receivesParentThemeSync) {
publishEmbeddedThemeBootstrap(currentTheme);
}
applyVSCodeRuntimeClass(isVSCode); applyVSCodeRuntimeClass(isVSCode);
updateBrowserChrome(currentTheme); updateBrowserChrome(currentTheme);
@@ -528,12 +527,16 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
scopedWindow.__openchamberApplyThemeSync = applyIncomingThemeSync; scopedWindow.__openchamberApplyThemeSync = applyIncomingThemeSync;
if (receivesParentThemeSync && window.parent !== window) {
window.parent.postMessage({ type: 'openchamber:theme-sync-request' }, window.location.origin);
}
return () => { return () => {
if (scopedWindow.__openchamberApplyThemeSync === applyIncomingThemeSync) { if (scopedWindow.__openchamberApplyThemeSync === applyIncomingThemeSync) {
delete scopedWindow.__openchamberApplyThemeSync; delete scopedWindow.__openchamberApplyThemeSync;
} }
}; };
}, [applyIncomingThemeSync]); }, [applyIncomingThemeSync, receivesParentThemeSync]);
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
@@ -593,7 +596,7 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
}, [currentTheme.metadata.variant, isDesktopShell, preferences.themeMode, receivesParentThemeSync]); }, [currentTheme.metadata.variant, isDesktopShell, preferences.themeMode, receivesParentThemeSync]);
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') { if (typeof window === 'undefined' || receivesParentThemeSync) {
return; return;
} }
const handleSettingsSynced = (event: Event) => { const handleSettingsSynced = (event: Event) => {
@@ -641,7 +644,7 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
window.addEventListener('openchamber:settings-synced', handleSettingsSynced); window.addEventListener('openchamber:settings-synced', handleSettingsSynced);
return () => window.removeEventListener('openchamber:settings-synced', handleSettingsSynced); return () => window.removeEventListener('openchamber:settings-synced', handleSettingsSynced);
}, []); }, [receivesParentThemeSync]);
const setTheme = useCallback( const setTheme = useCallback(
(themeId: string) => { (themeId: string) => {
@@ -1,4 +1,10 @@
import { isEmbeddedSessionChat } from '@/components/layout/contextPanelEmbeddedChat'; import { isEmbeddedSessionChat } from '@/components/layout/contextPanelEmbeddedChat';
import type { Theme } from '@/types/theme';
import { isValidTheme } from './theme-validation';
type ThemeBootstrapWindow = Window & {
__openchamberEmbeddedThemeBootstrap?: unknown;
};
export const readEmbeddedThemeSearchParams = (): URLSearchParams | null => { export const readEmbeddedThemeSearchParams = (): URLSearchParams | null => {
if (!isEmbeddedSessionChat()) { if (!isEmbeddedSessionChat()) {
@@ -7,6 +13,32 @@ export const readEmbeddedThemeSearchParams = (): URLSearchParams | null => {
return new URLSearchParams(window.location.search); return new URLSearchParams(window.location.search);
}; };
export const publishEmbeddedThemeBootstrap = (theme: Theme): void => {
if (typeof window === 'undefined') {
return;
}
(window as ThemeBootstrapWindow).__openchamberEmbeddedThemeBootstrap = theme;
};
export const readEmbeddedThemeBootstrap = (): Theme | null => {
if (!isEmbeddedSessionChat()) {
return null;
}
try {
const parent = window.parent as ThemeBootstrapWindow;
if (parent === window) {
return null;
}
return isValidTheme(parent.__openchamberEmbeddedThemeBootstrap)
? parent.__openchamberEmbeddedThemeBootstrap
: null;
} catch {
return null;
}
};
const getSystemPreference = (): boolean => { const getSystemPreference = (): boolean => {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return true; return true;