From 023ec2362e2c58e422d6900463397a24f3f9e211 Mon Sep 17 00:00:00 2001 From: Kai Yang Date: Sun, 26 Jul 2026 20:33:04 +0800 Subject: [PATCH] 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 --- .../ui/src/components/layout/ContextPanel.tsx | 6 ++- .../layout/contextPanelEmbeddedChat.test.ts | 38 +++++++++++++++- .../layout/contextPanelEmbeddedChat.ts | 3 +- .../src/contexts/ThemeSystemContext.test.ts | 45 ++++++++++++++++++- .../ui/src/contexts/ThemeSystemContext.tsx | 33 +++++++------- .../src/contexts/theme-embedded-bootstrap.ts | 32 +++++++++++++ 6 files changed, 135 insertions(+), 22 deletions(-) diff --git a/packages/ui/src/components/layout/ContextPanel.tsx b/packages/ui/src/components/layout/ContextPanel.tsx index 2e057339..90334547 100644 --- a/packages/ui/src/components/layout/ContextPanel.tsx +++ b/packages/ui/src/components/layout/ContextPanel.tsx @@ -2457,6 +2457,10 @@ export const ContextPanel: React.FC = () => { } const data = event.data as { type?: unknown }; + if (data?.type === 'openchamber:theme-sync-request') { + postThemeSyncToEmbeddedChat(); + return; + } if (data?.type === 'openchamber:chat-settings-request') { postChatSettingsSyncToEmbeddedChat(); return; @@ -2473,7 +2477,7 @@ export const ContextPanel: React.FC = () => { window.addEventListener('message', handleMessage); return () => window.removeEventListener('message', handleMessage); - }, [postChatSettingsSyncToEmbeddedChat, setThemeMode, themeMode]); + }, [postChatSettingsSyncToEmbeddedChat, postThemeSyncToEmbeddedChat, setThemeMode, themeMode]); React.useLayoutEffect(() => { const hasAnyChatTab = tabs.some((tab) => tab.mode === 'chat'); diff --git a/packages/ui/src/components/layout/contextPanelEmbeddedChat.test.ts b/packages/ui/src/components/layout/contextPanelEmbeddedChat.test.ts index e8304903..fdbbd0da 100644 --- a/packages/ui/src/components/layout/contextPanelEmbeddedChat.test.ts +++ b/packages/ui/src/components/layout/contextPanelEmbeddedChat.test.ts @@ -50,7 +50,7 @@ afterAll(() => { }); 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 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('lightThemeId')).toBe('custom-light'); 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', () => { diff --git a/packages/ui/src/components/layout/contextPanelEmbeddedChat.ts b/packages/ui/src/components/layout/contextPanelEmbeddedChat.ts index 34210e79..85f53573 100644 --- a/packages/ui/src/components/layout/contextPanelEmbeddedChat.ts +++ b/packages/ui/src/components/layout/contextPanelEmbeddedChat.ts @@ -46,7 +46,6 @@ export const buildEmbeddedSessionChatURL = ( 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(); @@ -126,4 +125,4 @@ export const getEmbeddedSessionChatOriginSessionId = (): string | null => { } catch { return null; } -}; +}; \ No newline at end of file diff --git a/packages/ui/src/contexts/ThemeSystemContext.test.ts b/packages/ui/src/contexts/ThemeSystemContext.test.ts index 2736055a..4e582a48 100644 --- a/packages/ui/src/contexts/ThemeSystemContext.test.ts +++ b/packages/ui/src/contexts/ThemeSystemContext.test.ts @@ -1,9 +1,15 @@ 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 installWindow = (search: string, matchMediaDark: boolean) => { +const installWindow = (search: string, matchMediaDark: boolean, parentTheme?: unknown) => { Object.defineProperty(globalThis, 'window', { configurable: true, value: { @@ -11,12 +17,14 @@ const installWindow = (search: string, matchMediaDark: boolean) => { search, }, matchMedia: () => ({ matches: matchMediaDark }), + parent: parentTheme === undefined ? null : { __openchamberEmbeddedThemeBootstrap: parentTheme }, }, }); }; beforeEach(() => { installWindow('', false); + resetEmbeddedSessionChatCache(); }); afterAll(() => { @@ -29,7 +37,40 @@ afterAll(() => { describe('ThemeSystemProvider embedded bootstrap', () => { test('uses parent effective variant for embedded system theme before iframe matchMedia', () => { installWindow('?ocPanel=session-chat&themeMode=system&themeVariant=dark', false); + resetEmbeddedSessionChatCache(); 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); + }); }); diff --git a/packages/ui/src/contexts/ThemeSystemContext.tsx b/packages/ui/src/contexts/ThemeSystemContext.tsx index cb76b498..7ddf2b3c 100644 --- a/packages/ui/src/contexts/ThemeSystemContext.tsx +++ b/packages/ui/src/contexts/ThemeSystemContext.tsx @@ -22,7 +22,12 @@ import { import { ThemeSystemContext, type ThemeContextValue } from './theme-system-context'; import type { VSCodeThemePayload } from '@/lib/theme/vscode/adapter'; 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 { getSyncedThemeFromPayload, getSyncedThemeVariant } from './theme-sync-payload'; 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 readEmbeddedCurrentTheme = (): Theme | null => { - const raw = readEmbeddedThemeSearchParams()?.get('currentTheme'); - if (!raw) { - return null; - } - - try { - const parsed = JSON.parse(raw); - return isValidTheme(parsed) ? parsed : null; - } catch { - return null; - } + return readEmbeddedThemeBootstrap(); }; const fallbackThemeForVariant = (variant: 'light' | 'dark'): Theme => @@ -198,6 +193,7 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro add(vscodeTheme); } + // Live-synced theme wins over bootstrap theme when IDs match (add is first-wins). if (embeddedSyncedTheme) { add(embeddedSyncedTheme); } @@ -370,6 +366,9 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro } const restoreTransitions = suppressTransitionsForThemeSwitch(); cssGenerator.apply(currentTheme); + if (!receivesParentThemeSync) { + publishEmbeddedThemeBootstrap(currentTheme); + } applyVSCodeRuntimeClass(isVSCode); updateBrowserChrome(currentTheme); @@ -528,12 +527,16 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro scopedWindow.__openchamberApplyThemeSync = applyIncomingThemeSync; + if (receivesParentThemeSync && window.parent !== window) { + window.parent.postMessage({ type: 'openchamber:theme-sync-request' }, window.location.origin); + } + return () => { if (scopedWindow.__openchamberApplyThemeSync === applyIncomingThemeSync) { delete scopedWindow.__openchamberApplyThemeSync; } }; - }, [applyIncomingThemeSync]); + }, [applyIncomingThemeSync, receivesParentThemeSync]); useEffect(() => { if (typeof window === 'undefined') { @@ -593,7 +596,7 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro }, [currentTheme.metadata.variant, isDesktopShell, preferences.themeMode, receivesParentThemeSync]); useEffect(() => { - if (typeof window === 'undefined') { + if (typeof window === 'undefined' || receivesParentThemeSync) { return; } const handleSettingsSynced = (event: Event) => { @@ -641,7 +644,7 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro window.addEventListener('openchamber:settings-synced', handleSettingsSynced); return () => window.removeEventListener('openchamber:settings-synced', handleSettingsSynced); - }, []); + }, [receivesParentThemeSync]); const setTheme = useCallback( (themeId: string) => { diff --git a/packages/ui/src/contexts/theme-embedded-bootstrap.ts b/packages/ui/src/contexts/theme-embedded-bootstrap.ts index b6427fc1..0a2a26f2 100644 --- a/packages/ui/src/contexts/theme-embedded-bootstrap.ts +++ b/packages/ui/src/contexts/theme-embedded-bootstrap.ts @@ -1,4 +1,10 @@ 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 => { if (!isEmbeddedSessionChat()) { @@ -7,6 +13,32 @@ export const readEmbeddedThemeSearchParams = (): URLSearchParams | null => { 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 => { if (typeof window === 'undefined') { return true;