diff --git a/packages/ui/src/components/layout/ContextPanel.tsx b/packages/ui/src/components/layout/ContextPanel.tsx index ac98db6a..ca1767cb 100644 --- a/packages/ui/src/components/layout/ContextPanel.tsx +++ b/packages/ui/src/components/layout/ContextPanel.tsx @@ -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 (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(null); const panelRef = React.useRef(null); const chatFrameRefs = React.useRef>(new Map()); + const chatFrameSrcByTabIDRef = React.useRef>(new Map()); const wasOpenRef = React.useRef(false); const previousIsOpenRef = React.useRef(isOpen); const suppressWidthTransitionFrameRef = React.useRef(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; } diff --git a/packages/ui/src/components/layout/contextPanelEmbeddedChat.test.ts b/packages/ui/src/components/layout/contextPanelEmbeddedChat.test.ts new file mode 100644 index 00000000..ab609ed5 --- /dev/null +++ b/packages/ui/src/components/layout/contextPanelEmbeddedChat.test.ts @@ -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(); + 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(); + 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'); + }); +}); diff --git a/packages/ui/src/components/layout/contextPanelEmbeddedChat.ts b/packages/ui/src/components/layout/contextPanelEmbeddedChat.ts new file mode 100644 index 00000000..baf4d5be --- /dev/null +++ b/packages/ui/src/components/layout/contextPanelEmbeddedChat.ts @@ -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, + 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; +}; diff --git a/packages/ui/src/contexts/ThemeSystemContext.test.ts b/packages/ui/src/contexts/ThemeSystemContext.test.ts new file mode 100644 index 00000000..2736055a --- /dev/null +++ b/packages/ui/src/contexts/ThemeSystemContext.test.ts @@ -0,0 +1,35 @@ +import { afterAll, beforeEach, describe, expect, test } from 'bun:test'; +import { getInitialSystemPreference } from './theme-embedded-bootstrap'; + +const originalWindow = globalThis.window; + +const installWindow = (search: string, matchMediaDark: boolean) => { + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + location: { + search, + }, + matchMedia: () => ({ matches: matchMediaDark }), + }, + }); +}; + +beforeEach(() => { + installWindow('', false); +}); + +afterAll(() => { + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: originalWindow, + }); +}); + +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); + + expect(getInitialSystemPreference()).toBe(true); + }); +}); diff --git a/packages/ui/src/contexts/ThemeSystemContext.tsx b/packages/ui/src/contexts/ThemeSystemContext.tsx index 7ef1119b..4b3eb6a1 100644 --- a/packages/ui/src/contexts/ThemeSystemContext.tsx +++ b/packages/ui/src/contexts/ThemeSystemContext.tsx @@ -21,6 +21,9 @@ 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 { isValidTheme } from './theme-validation'; +import { getSyncedThemeFromPayload, getSyncedThemeVariant } from './theme-sync-payload'; type ThemePreferences = { themeMode: ThemeMode; @@ -38,11 +41,18 @@ type ThemeSyncPayload = { const DEFAULT_LIGHT_ID = DEFAULT_LIGHT_THEME_ID; const DEFAULT_DARK_ID = DEFAULT_DARK_THEME_ID; -const getSystemPreference = (): boolean => { - if (typeof window === 'undefined') { - return true; +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 window.matchMedia('(prefers-color-scheme: dark)').matches; }; const fallbackThemeForVariant = (variant: 'light' | 'dark'): Theme => @@ -70,87 +80,16 @@ const suppressTransitionsForThemeSwitch = () => { }; }; -const isNonEmptyString = (value: unknown): value is string => - typeof value === 'string' && value.trim().length > 0; - -const getNested = (value: unknown, path: string[]): unknown => - path.reduce((acc, key) => (acc && typeof acc === 'object' ? (acc as Record)[key] : undefined), value); - -const isValidCustomTheme = (value: unknown): value is Theme => { - if (!value || typeof value !== 'object') { - return false; - } - - const requiredPaths = [ - ['metadata', 'id'], - ['metadata', 'name'], - ['metadata', 'variant'], - ['colors', 'primary', 'base'], - ['colors', 'primary', 'foreground'], - ['colors', 'surface', 'background'], - ['colors', 'surface', 'foreground'], - ['colors', 'surface', 'muted'], - ['colors', 'surface', 'mutedForeground'], - ['colors', 'surface', 'elevated'], - ['colors', 'surface', 'elevatedForeground'], - ['colors', 'surface', 'subtle'], - ['colors', 'interactive', 'border'], - ['colors', 'interactive', 'selection'], - ['colors', 'interactive', 'selectionForeground'], - ['colors', 'interactive', 'focusRing'], - ['colors', 'interactive', 'hover'], - ['colors', 'status', 'error'], - ['colors', 'status', 'errorForeground'], - ['colors', 'status', 'errorBackground'], - ['colors', 'status', 'errorBorder'], - ['colors', 'status', 'warning'], - ['colors', 'status', 'warningForeground'], - ['colors', 'status', 'warningBackground'], - ['colors', 'status', 'warningBorder'], - ['colors', 'status', 'success'], - ['colors', 'status', 'successForeground'], - ['colors', 'status', 'successBackground'], - ['colors', 'status', 'successBorder'], - ['colors', 'status', 'info'], - ['colors', 'status', 'infoForeground'], - ['colors', 'status', 'infoBackground'], - ['colors', 'status', 'infoBorder'], - ['colors', 'syntax', 'base', 'background'], - ['colors', 'syntax', 'base', 'foreground'], - ['colors', 'syntax', 'base', 'keyword'], - ['colors', 'syntax', 'base', 'string'], - ['colors', 'syntax', 'base', 'number'], - ['colors', 'syntax', 'base', 'function'], - ['colors', 'syntax', 'base', 'variable'], - ['colors', 'syntax', 'base', 'type'], - ['colors', 'syntax', 'base', 'comment'], - ['colors', 'syntax', 'base', 'operator'], - ['colors', 'syntax', 'highlights', 'diffAdded'], - ['colors', 'syntax', 'highlights', 'diffRemoved'], - ['colors', 'syntax', 'highlights', 'lineNumber'], - ]; - - for (const path of requiredPaths) { - if (!isNonEmptyString(getNested(value, path))) { - return false; - } - } - - const variant = getNested(value, ['metadata', 'variant']); - return variant === 'light' || variant === 'dark'; -}; - -const getSyncedThemeVariant = (payload: ThemeSyncPayload): 'light' | 'dark' | null => { - const variant = getNested(payload.currentTheme, ['metadata', 'variant']); - return variant === 'light' || variant === 'dark' ? variant : null; -}; - const buildInitialPreferences = (defaultThemeId?: string): ThemePreferences => { let lightThemeId: string = DEFAULT_LIGHT_ID; let darkThemeId: string = DEFAULT_DARK_ID; let themeMode: ThemeMode = 'system'; if (typeof window !== 'undefined') { + const embeddedParams = readEmbeddedThemeSearchParams(); + const embeddedMode = embeddedParams?.get('themeMode'); + const embeddedLightId = embeddedParams?.get('lightThemeId'); + const embeddedDarkId = embeddedParams?.get('darkThemeId'); const storedMode = localStorage.getItem('themeMode'); const storedLightId = localStorage.getItem('lightThemeId'); const storedDarkId = localStorage.getItem('darkThemeId'); @@ -158,7 +97,9 @@ const buildInitialPreferences = (defaultThemeId?: string): ThemePreferences => { const legacyThemeId = localStorage.getItem('selectedThemeId'); const legacyVariant = localStorage.getItem('selectedThemeVariant'); - if (storedMode === 'light' || storedMode === 'dark' || storedMode === 'system') { + if (embeddedMode === 'light' || embeddedMode === 'dark' || embeddedMode === 'system') { + themeMode = embeddedMode; + } else if (storedMode === 'light' || storedMode === 'dark' || storedMode === 'system') { themeMode = storedMode; } else if (legacyUseSystem !== null) { const useSystem = legacyUseSystem === 'true'; @@ -179,11 +120,15 @@ const buildInitialPreferences = (defaultThemeId?: string): ThemePreferences => { themeMode = legacyVariant; } - if (typeof storedLightId === 'string' && storedLightId.trim().length > 0) { + if (typeof embeddedLightId === 'string' && embeddedLightId.trim().length > 0) { + lightThemeId = embeddedLightId.trim(); + } else if (typeof storedLightId === 'string' && storedLightId.trim().length > 0) { lightThemeId = storedLightId.trim(); } - if (typeof storedDarkId === 'string' && storedDarkId.trim().length > 0) { + if (typeof embeddedDarkId === 'string' && embeddedDarkId.trim().length > 0) { + darkThemeId = embeddedDarkId.trim(); + } else if (typeof storedDarkId === 'string' && storedDarkId.trim().length > 0) { darkThemeId = storedDarkId.trim(); } } @@ -214,8 +159,10 @@ interface ThemeSystemProviderProps { export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemProviderProps) { const cssGenerator = useMemo(() => new CSSVariableGenerator(), []); const [preferences, setPreferences] = useState(() => buildInitialPreferences(defaultThemeId)); - const [systemPrefersDark, setSystemPrefersDark] = useState(() => getSystemPreference()); + const [systemPrefersDark, setSystemPrefersDark] = useState(() => getInitialSystemPreference()); const [customThemes, setCustomThemes] = useState([]); + const [embeddedBootstrapTheme] = useState(() => readEmbeddedCurrentTheme()); + const [embeddedSyncedTheme, setEmbeddedSyncedTheme] = useState(null); const [customThemesLoading, setCustomThemesLoading] = useState(false); const [vscodeTheme, setVSCodeTheme] = useState(() => { if (typeof window === 'undefined' || !isVSCodeRuntime()) { @@ -231,7 +178,7 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro if (typeof window === 'undefined') { return false; } - return new URLSearchParams(window.location.search).get('ocPanel') === 'session-chat'; + return readEmbeddedThemeSearchParams() !== null; }, []); const availableThemes = useMemo(() => { @@ -249,12 +196,20 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro add(vscodeTheme); } + if (embeddedSyncedTheme) { + add(embeddedSyncedTheme); + } + + if (embeddedBootstrapTheme) { + add(embeddedBootstrapTheme); + } + // Custom themes first so they can override built-ins with the same id. customThemes.forEach(add); themes.forEach(add); return merged; - }, [customThemes, isVSCode, vscodeTheme]); + }, [customThemes, embeddedBootstrapTheme, embeddedSyncedTheme, isVSCode, vscodeTheme]); const getThemeByIdFromAvailable = useCallback( (themeId: string): Theme | undefined => availableThemes.find((theme) => theme.metadata.id === themeId), @@ -315,7 +270,7 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro const payload = await res.json(); const incoming = Array.isArray(payload?.themes) ? payload.themes : []; - const normalized = incoming.filter(isValidCustomTheme); + const normalized = incoming.filter(isValidTheme); setCustomThemes(normalized); } catch { // ignore @@ -430,7 +385,7 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro }, [preferences.themeMode, receivesParentThemeSync]); useEffect(() => { - if (typeof window === 'undefined') { + if (receivesParentThemeSync || typeof window === 'undefined') { return; } @@ -453,13 +408,17 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro localStorage.setItem('splashFgLight', lightTheme.colors.surface.foreground); localStorage.setItem('splashBgDark', darkTheme.colors.surface.background); localStorage.setItem('splashFgDark', darkTheme.colors.surface.foreground); - }, [preferences, currentTheme, ensureThemeById]); + }, [preferences, currentTheme, ensureThemeById, receivesParentThemeSync]); useEffect(() => { if (typeof window === 'undefined') { return; } + if (receivesParentThemeSync) { + return; + } + const handleStorage = (event: StorageEvent) => { if (event.storageArea !== window.localStorage) { return; @@ -500,13 +459,14 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro window.addEventListener('storage', handleStorage); return () => window.removeEventListener('storage', handleStorage); - }, []); + }, [receivesParentThemeSync]); const applyIncomingThemeSync = useCallback((payload: ThemeSyncPayload) => { const mode = payload.themeMode; const light = payload.lightThemeId; const dark = payload.darkThemeId; const syncedVariant = getSyncedThemeVariant(payload); + const syncedTheme = getSyncedThemeFromPayload(payload); if ((mode !== 'light' && mode !== 'dark' && mode !== 'system') || typeof light !== 'string' || typeof dark !== 'string') { return; @@ -520,6 +480,10 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro suppressTransitionsForThemeSwitch(); flushSync(() => { + if (receivesParentThemeSync && syncedTheme) { + setEmbeddedSyncedTheme(syncedTheme); + } + if (mode === 'system' && syncedVariant) { setSystemPrefersDark(syncedVariant === 'dark'); } @@ -536,7 +500,7 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro }; }); }); - }, []); + }, [receivesParentThemeSync]); useEffect(() => { if (typeof window === 'undefined') { @@ -583,6 +547,10 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro }, [applyIncomingThemeSync]); useEffect(() => { + if (receivesParentThemeSync) { + return; + } + const lightTheme = ensureThemeById(preferences.lightThemeId, 'light'); const darkTheme = ensureThemeById(preferences.darkThemeId, 'dark'); @@ -597,17 +565,17 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro splashBgDark: darkTheme.colors.surface.background, splashFgDark: darkTheme.colors.surface.foreground, }); - }, [currentTheme.metadata.id, currentTheme.metadata.variant, ensureThemeById, preferences.themeMode, preferences.lightThemeId, preferences.darkThemeId]); + }, [currentTheme.metadata.id, currentTheme.metadata.variant, ensureThemeById, preferences.themeMode, preferences.lightThemeId, preferences.darkThemeId, receivesParentThemeSync]); useEffect(() => { - if (!isDesktopShell) { + if (receivesParentThemeSync || !isDesktopShell) { return; } void (async () => { await setDesktopWindowTheme(preferences.themeMode, currentTheme.metadata.variant); })(); - }, [currentTheme.metadata.variant, isDesktopShell, preferences.themeMode]); + }, [currentTheme.metadata.variant, isDesktopShell, preferences.themeMode, receivesParentThemeSync]); useEffect(() => { if (typeof window === 'undefined') { diff --git a/packages/ui/src/contexts/theme-embedded-bootstrap.ts b/packages/ui/src/contexts/theme-embedded-bootstrap.ts new file mode 100644 index 00000000..430dbe5c --- /dev/null +++ b/packages/ui/src/contexts/theme-embedded-bootstrap.ts @@ -0,0 +1,23 @@ +export const readEmbeddedThemeSearchParams = (): URLSearchParams | null => { + if (typeof window === 'undefined') { + return null; + } + const params = new URLSearchParams(window.location.search); + return params.get('ocPanel') === 'session-chat' ? params : null; +}; + +const getSystemPreference = (): boolean => { + if (typeof window === 'undefined') { + return true; + } + return window.matchMedia('(prefers-color-scheme: dark)').matches; +}; + +export const getInitialSystemPreference = (): boolean => { + const embeddedParams = readEmbeddedThemeSearchParams(); + const embeddedVariant = embeddedParams?.get('themeVariant'); + if (embeddedVariant === 'dark' || embeddedVariant === 'light') { + return embeddedVariant === 'dark'; + } + return getSystemPreference(); +}; diff --git a/packages/ui/src/contexts/theme-sync-payload.test.ts b/packages/ui/src/contexts/theme-sync-payload.test.ts new file mode 100644 index 00000000..2ae9ba6a --- /dev/null +++ b/packages/ui/src/contexts/theme-sync-payload.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from 'bun:test'; +import { getDefaultTheme } from '@/lib/theme/themes'; +import { getSyncedThemeFromPayload, getSyncedThemeVariant } from './theme-sync-payload'; + +describe('theme sync payload', () => { + test('accepts full custom theme payloads for embedded live sync', () => { + const customTheme = { + ...getDefaultTheme(true), + metadata: { + ...getDefaultTheme(true).metadata, + id: 'live-custom-dark', + name: 'Live custom dark', + variant: 'dark' as const, + }, + }; + + expect(getSyncedThemeFromPayload({ currentTheme: customTheme })?.metadata.id).toBe('live-custom-dark'); + expect(getSyncedThemeVariant({ currentTheme: customTheme })).toBe('dark'); + }); +}); diff --git a/packages/ui/src/contexts/theme-sync-payload.ts b/packages/ui/src/contexts/theme-sync-payload.ts new file mode 100644 index 00000000..ef58ae1f --- /dev/null +++ b/packages/ui/src/contexts/theme-sync-payload.ts @@ -0,0 +1,14 @@ +import type { Theme } from '@/types/theme'; +import { isValidTheme } from './theme-validation'; + +export type ThemeSyncPayloadShape = { + currentTheme?: unknown; +}; + +export const getSyncedThemeFromPayload = (payload: ThemeSyncPayloadShape): Theme | null => ( + isValidTheme(payload.currentTheme) ? payload.currentTheme : null +); + +export const getSyncedThemeVariant = (payload: ThemeSyncPayloadShape): 'light' | 'dark' | null => ( + getSyncedThemeFromPayload(payload)?.metadata.variant ?? null +); diff --git a/packages/ui/src/contexts/theme-validation.ts b/packages/ui/src/contexts/theme-validation.ts new file mode 100644 index 00000000..cb31d683 --- /dev/null +++ b/packages/ui/src/contexts/theme-validation.ts @@ -0,0 +1,71 @@ +import type { Theme } from '@/types/theme'; + +const getNested = (value: unknown, path: string[]): unknown => + path.reduce((acc, key) => (acc && typeof acc === 'object' ? (acc as Record)[key] : undefined), value); + +const isNonEmptyString = (value: unknown): value is string => + typeof value === 'string' && value.trim().length > 0; + +export const isValidTheme = (value: unknown): value is Theme => { + if (!value || typeof value !== 'object') { + return false; + } + + const requiredPaths = [ + ['metadata', 'id'], + ['metadata', 'name'], + ['metadata', 'variant'], + ['colors', 'primary', 'base'], + ['colors', 'primary', 'foreground'], + ['colors', 'surface', 'background'], + ['colors', 'surface', 'foreground'], + ['colors', 'surface', 'muted'], + ['colors', 'surface', 'mutedForeground'], + ['colors', 'surface', 'elevated'], + ['colors', 'surface', 'elevatedForeground'], + ['colors', 'surface', 'subtle'], + ['colors', 'interactive', 'border'], + ['colors', 'interactive', 'selection'], + ['colors', 'interactive', 'selectionForeground'], + ['colors', 'interactive', 'focusRing'], + ['colors', 'interactive', 'hover'], + ['colors', 'status', 'error'], + ['colors', 'status', 'errorForeground'], + ['colors', 'status', 'errorBackground'], + ['colors', 'status', 'errorBorder'], + ['colors', 'status', 'warning'], + ['colors', 'status', 'warningForeground'], + ['colors', 'status', 'warningBackground'], + ['colors', 'status', 'warningBorder'], + ['colors', 'status', 'success'], + ['colors', 'status', 'successForeground'], + ['colors', 'status', 'successBackground'], + ['colors', 'status', 'successBorder'], + ['colors', 'status', 'info'], + ['colors', 'status', 'infoForeground'], + ['colors', 'status', 'infoBackground'], + ['colors', 'status', 'infoBorder'], + ['colors', 'syntax', 'base', 'background'], + ['colors', 'syntax', 'base', 'foreground'], + ['colors', 'syntax', 'base', 'keyword'], + ['colors', 'syntax', 'base', 'string'], + ['colors', 'syntax', 'base', 'number'], + ['colors', 'syntax', 'base', 'function'], + ['colors', 'syntax', 'base', 'variable'], + ['colors', 'syntax', 'base', 'type'], + ['colors', 'syntax', 'base', 'comment'], + ['colors', 'syntax', 'base', 'operator'], + ['colors', 'syntax', 'highlights', 'diffAdded'], + ['colors', 'syntax', 'highlights', 'diffRemoved'], + ['colors', 'syntax', 'highlights', 'lineNumber'], + ]; + + for (const path of requiredPaths) { + if (!isNonEmptyString(getNested(value, path))) { + return false; + } + } + + const variant = getNested(value, ['metadata', 'variant']); + return variant === 'light' || variant === 'dark'; +}; diff --git a/packages/ui/src/hooks/useKeyboardShortcuts.ts b/packages/ui/src/hooks/useKeyboardShortcuts.ts index 22ce72e3..2ee1033b 100644 --- a/packages/ui/src/hooks/useKeyboardShortcuts.ts +++ b/packages/ui/src/hooks/useKeyboardShortcuts.ts @@ -10,6 +10,7 @@ import { useConfigStore } from '@/stores/useConfigStore'; import { canUseElectronDesktopIPC, invokeDesktop, isVSCodeRuntime } from '@/lib/desktop'; import { showOpenCodeStatus } from '@/lib/openCodeStatus'; import { eventMatchesShortcut, getEffectiveShortcutCombo, normalizeCombo } from '@/lib/shortcuts'; +import { readEmbeddedThemeSearchParams } from '@/contexts/theme-embedded-bootstrap'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { getCycledPrimaryAgentName } from '@/components/chat/mobileControlsUtils'; @@ -184,6 +185,10 @@ export const useKeyboardShortcuts = () => { if (eventMatchesShortcut(e, combo('cycle_theme'))) { e.preventDefault(); + if (readEmbeddedThemeSearchParams() !== null && window.parent && window.parent !== window) { + window.parent.postMessage({ type: 'openchamber:cycle-theme-request' }, window.location.origin); + return; + } const modes: Array<'light' | 'dark' | 'system'> = ['light', 'dark', 'system']; const activeElement = document.activeElement as HTMLElement | null; const currentIndex = modes.indexOf(themeModeRef.current); diff --git a/packages/ui/src/stores/useUIStore.contextPanel.test.ts b/packages/ui/src/stores/useUIStore.contextPanel.test.ts new file mode 100644 index 00000000..df7475b1 --- /dev/null +++ b/packages/ui/src/stores/useUIStore.contextPanel.test.ts @@ -0,0 +1,30 @@ +import { beforeEach, describe, expect, test } from 'bun:test'; +import { useUIStore } from './useUIStore'; + +beforeEach(() => { + useUIStore.setState({ contextPanelByDirectory: {} }); +}); + +describe('useUIStore context panel tabs', () => { + test('updates readOnly when an existing chat tab is reopened', () => { + const directory = '/repo'; + + useUIStore.getState().openContextPanelTab(directory, { + mode: 'chat', + dedupeKey: 'session:ses_1', + label: 'Session', + readOnly: true, + }); + + useUIStore.getState().openContextPanelTab(directory, { + mode: 'chat', + dedupeKey: 'session:ses_1', + label: 'Session', + readOnly: false, + }); + + const tabs = useUIStore.getState().contextPanelByDirectory[directory]?.tabs ?? []; + expect(tabs).toHaveLength(1); + expect(tabs[0]?.readOnly).toBe(false); + }); +}); diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 556ffa82..dc8addea 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -351,6 +351,7 @@ const upsertContextPanelTab = ( dedupeKey: nextTab.dedupeKey, label: nextTab.label, stagedDiff: nextTab.stagedDiff, + readOnly: nextTab.readOnly, touchedAt: Date.now(), } : tab));