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:
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user