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:
@@ -26,6 +26,7 @@ import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
|
|||||||
import { Icon } from "@/components/icon/Icon";
|
import { Icon } from "@/components/icon/Icon";
|
||||||
import { OpenChamberLogo } from "@/components/ui/OpenChamberLogo";
|
import { OpenChamberLogo } from "@/components/ui/OpenChamberLogo";
|
||||||
import { invokeDesktopCommand } from '@/lib/desktopNative';
|
import { invokeDesktopCommand } from '@/lib/desktopNative';
|
||||||
|
import { getOrCreateEmbeddedSessionChatURL, type EmbeddedSessionChatURLCacheEntry } from './contextPanelEmbeddedChat';
|
||||||
import {
|
import {
|
||||||
type PreviewElementMetadata,
|
type PreviewElementMetadata,
|
||||||
isPreviewElementMetadata,
|
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 => {
|
const truncateTabLabel = (value: string, maxChars: number): string => {
|
||||||
if (value.length <= maxChars) {
|
if (value.length <= maxChars) {
|
||||||
return value;
|
return value;
|
||||||
@@ -2004,7 +1982,7 @@ export const ContextPanel: React.FC = () => {
|
|||||||
const reorderContextPanelTabs = useUIStore((state) => state.reorderContextPanelTabs);
|
const reorderContextPanelTabs = useUIStore((state) => state.reorderContextPanelTabs);
|
||||||
const setSelectedFilePath = useFilesViewTabsStore((state) => state.setSelectedPath);
|
const setSelectedFilePath = useFilesViewTabsStore((state) => state.setSelectedPath);
|
||||||
const openContextPreview = useUIStore((state) => state.openContextPreview);
|
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 tabs = React.useMemo(() => panelState?.tabs ?? [], [panelState?.tabs]);
|
||||||
const activeTab = tabs.find((tab) => tab.id === panelState?.activeTabId) ?? tabs[tabs.length - 1] ?? null;
|
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 activeResizePointerIDRef = React.useRef<number | null>(null);
|
||||||
const panelRef = React.useRef<HTMLElement | null>(null);
|
const panelRef = React.useRef<HTMLElement | null>(null);
|
||||||
const chatFrameRefs = React.useRef<Map<string, HTMLIFrameElement>>(new Map());
|
const chatFrameRefs = React.useRef<Map<string, HTMLIFrameElement>>(new Map());
|
||||||
|
const chatFrameSrcByTabIDRef = React.useRef<Map<string, EmbeddedSessionChatURLCacheEntry>>(new Map());
|
||||||
const wasOpenRef = React.useRef(false);
|
const wasOpenRef = React.useRef(false);
|
||||||
const previousIsOpenRef = React.useRef(isOpen);
|
const previousIsOpenRef = React.useRef(isOpen);
|
||||||
const suppressWidthTransitionFrameRef = React.useRef<number | null>(null);
|
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 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') => {
|
const handleDiffScopeChange = React.useCallback((nextScope: 'working' | 'staged') => {
|
||||||
if (!directoryKey || activeTab?.mode !== 'diff') {
|
if (!directoryKey || activeTab?.mode !== 'diff') {
|
||||||
return;
|
return;
|
||||||
@@ -2267,6 +2264,37 @@ export const ContextPanel: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [activeChatTabID]);
|
}, [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(() => {
|
React.useLayoutEffect(() => {
|
||||||
const hasAnyChatTab = tabs.some((tab) => tab.mode === 'chat');
|
const hasAnyChatTab = tabs.some((tab) => tab.mode === 'chat');
|
||||||
if (!hasAnyChatTab) {
|
if (!hasAnyChatTab) {
|
||||||
@@ -2447,7 +2475,7 @@ export const ContextPanel: React.FC = () => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const src = buildEmbeddedSessionChatURL(sessionID, directoryKey || null, tab.readOnly);
|
const src = getEmbeddedChatSrc(tab.id, sessionID, tab.readOnly);
|
||||||
if (!src) {
|
if (!src) {
|
||||||
return null;
|
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;
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,6 +21,9 @@ 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 { isValidTheme } from './theme-validation';
|
||||||
|
import { getSyncedThemeFromPayload, getSyncedThemeVariant } from './theme-sync-payload';
|
||||||
|
|
||||||
type ThemePreferences = {
|
type ThemePreferences = {
|
||||||
themeMode: ThemeMode;
|
themeMode: ThemeMode;
|
||||||
@@ -38,11 +41,18 @@ type ThemeSyncPayload = {
|
|||||||
const DEFAULT_LIGHT_ID = DEFAULT_LIGHT_THEME_ID;
|
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 getSystemPreference = (): boolean => {
|
const readEmbeddedCurrentTheme = (): Theme | null => {
|
||||||
if (typeof window === 'undefined') {
|
const raw = readEmbeddedThemeSearchParams()?.get('currentTheme');
|
||||||
return true;
|
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 =>
|
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<unknown>((acc, key) => (acc && typeof acc === 'object' ? (acc as Record<string, unknown>)[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 => {
|
const buildInitialPreferences = (defaultThemeId?: string): ThemePreferences => {
|
||||||
let lightThemeId: string = DEFAULT_LIGHT_ID;
|
let lightThemeId: string = DEFAULT_LIGHT_ID;
|
||||||
let darkThemeId: string = DEFAULT_DARK_ID;
|
let darkThemeId: string = DEFAULT_DARK_ID;
|
||||||
let themeMode: ThemeMode = 'system';
|
let themeMode: ThemeMode = 'system';
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
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 storedMode = localStorage.getItem('themeMode');
|
||||||
const storedLightId = localStorage.getItem('lightThemeId');
|
const storedLightId = localStorage.getItem('lightThemeId');
|
||||||
const storedDarkId = localStorage.getItem('darkThemeId');
|
const storedDarkId = localStorage.getItem('darkThemeId');
|
||||||
@@ -158,7 +97,9 @@ const buildInitialPreferences = (defaultThemeId?: string): ThemePreferences => {
|
|||||||
const legacyThemeId = localStorage.getItem('selectedThemeId');
|
const legacyThemeId = localStorage.getItem('selectedThemeId');
|
||||||
const legacyVariant = localStorage.getItem('selectedThemeVariant');
|
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;
|
themeMode = storedMode;
|
||||||
} else if (legacyUseSystem !== null) {
|
} else if (legacyUseSystem !== null) {
|
||||||
const useSystem = legacyUseSystem === 'true';
|
const useSystem = legacyUseSystem === 'true';
|
||||||
@@ -179,11 +120,15 @@ const buildInitialPreferences = (defaultThemeId?: string): ThemePreferences => {
|
|||||||
themeMode = legacyVariant;
|
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();
|
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();
|
darkThemeId = storedDarkId.trim();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -214,8 +159,10 @@ interface ThemeSystemProviderProps {
|
|||||||
export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemProviderProps) {
|
export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemProviderProps) {
|
||||||
const cssGenerator = useMemo(() => new CSSVariableGenerator(), []);
|
const cssGenerator = useMemo(() => new CSSVariableGenerator(), []);
|
||||||
const [preferences, setPreferences] = useState<ThemePreferences>(() => buildInitialPreferences(defaultThemeId));
|
const [preferences, setPreferences] = useState<ThemePreferences>(() => buildInitialPreferences(defaultThemeId));
|
||||||
const [systemPrefersDark, setSystemPrefersDark] = useState<boolean>(() => getSystemPreference());
|
const [systemPrefersDark, setSystemPrefersDark] = useState<boolean>(() => getInitialSystemPreference());
|
||||||
const [customThemes, setCustomThemes] = useState<Theme[]>([]);
|
const [customThemes, setCustomThemes] = useState<Theme[]>([]);
|
||||||
|
const [embeddedBootstrapTheme] = useState<Theme | null>(() => readEmbeddedCurrentTheme());
|
||||||
|
const [embeddedSyncedTheme, setEmbeddedSyncedTheme] = useState<Theme | null>(null);
|
||||||
const [customThemesLoading, setCustomThemesLoading] = useState(false);
|
const [customThemesLoading, setCustomThemesLoading] = useState(false);
|
||||||
const [vscodeTheme, setVSCodeTheme] = useState<Theme | null>(() => {
|
const [vscodeTheme, setVSCodeTheme] = useState<Theme | null>(() => {
|
||||||
if (typeof window === 'undefined' || !isVSCodeRuntime()) {
|
if (typeof window === 'undefined' || !isVSCodeRuntime()) {
|
||||||
@@ -231,7 +178,7 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
|
|||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return new URLSearchParams(window.location.search).get('ocPanel') === 'session-chat';
|
return readEmbeddedThemeSearchParams() !== null;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const availableThemes = useMemo(() => {
|
const availableThemes = useMemo(() => {
|
||||||
@@ -249,12 +196,20 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
|
|||||||
add(vscodeTheme);
|
add(vscodeTheme);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (embeddedSyncedTheme) {
|
||||||
|
add(embeddedSyncedTheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (embeddedBootstrapTheme) {
|
||||||
|
add(embeddedBootstrapTheme);
|
||||||
|
}
|
||||||
|
|
||||||
// Custom themes first so they can override built-ins with the same id.
|
// Custom themes first so they can override built-ins with the same id.
|
||||||
customThemes.forEach(add);
|
customThemes.forEach(add);
|
||||||
themes.forEach(add);
|
themes.forEach(add);
|
||||||
|
|
||||||
return merged;
|
return merged;
|
||||||
}, [customThemes, isVSCode, vscodeTheme]);
|
}, [customThemes, embeddedBootstrapTheme, embeddedSyncedTheme, isVSCode, vscodeTheme]);
|
||||||
|
|
||||||
const getThemeByIdFromAvailable = useCallback(
|
const getThemeByIdFromAvailable = useCallback(
|
||||||
(themeId: string): Theme | undefined => availableThemes.find((theme) => theme.metadata.id === themeId),
|
(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 payload = await res.json();
|
||||||
const incoming = Array.isArray(payload?.themes) ? payload.themes : [];
|
const incoming = Array.isArray(payload?.themes) ? payload.themes : [];
|
||||||
const normalized = incoming.filter(isValidCustomTheme);
|
const normalized = incoming.filter(isValidTheme);
|
||||||
setCustomThemes(normalized);
|
setCustomThemes(normalized);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
@@ -430,7 +385,7 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
|
|||||||
}, [preferences.themeMode, receivesParentThemeSync]);
|
}, [preferences.themeMode, receivesParentThemeSync]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') {
|
if (receivesParentThemeSync || typeof window === 'undefined') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,13 +408,17 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
|
|||||||
localStorage.setItem('splashFgLight', lightTheme.colors.surface.foreground);
|
localStorage.setItem('splashFgLight', lightTheme.colors.surface.foreground);
|
||||||
localStorage.setItem('splashBgDark', darkTheme.colors.surface.background);
|
localStorage.setItem('splashBgDark', darkTheme.colors.surface.background);
|
||||||
localStorage.setItem('splashFgDark', darkTheme.colors.surface.foreground);
|
localStorage.setItem('splashFgDark', darkTheme.colors.surface.foreground);
|
||||||
}, [preferences, currentTheme, ensureThemeById]);
|
}, [preferences, currentTheme, ensureThemeById, receivesParentThemeSync]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (receivesParentThemeSync) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const handleStorage = (event: StorageEvent) => {
|
const handleStorage = (event: StorageEvent) => {
|
||||||
if (event.storageArea !== window.localStorage) {
|
if (event.storageArea !== window.localStorage) {
|
||||||
return;
|
return;
|
||||||
@@ -500,13 +459,14 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
|
|||||||
|
|
||||||
window.addEventListener('storage', handleStorage);
|
window.addEventListener('storage', handleStorage);
|
||||||
return () => window.removeEventListener('storage', handleStorage);
|
return () => window.removeEventListener('storage', handleStorage);
|
||||||
}, []);
|
}, [receivesParentThemeSync]);
|
||||||
|
|
||||||
const applyIncomingThemeSync = useCallback((payload: ThemeSyncPayload) => {
|
const applyIncomingThemeSync = useCallback((payload: ThemeSyncPayload) => {
|
||||||
const mode = payload.themeMode;
|
const mode = payload.themeMode;
|
||||||
const light = payload.lightThemeId;
|
const light = payload.lightThemeId;
|
||||||
const dark = payload.darkThemeId;
|
const dark = payload.darkThemeId;
|
||||||
const syncedVariant = getSyncedThemeVariant(payload);
|
const syncedVariant = getSyncedThemeVariant(payload);
|
||||||
|
const syncedTheme = getSyncedThemeFromPayload(payload);
|
||||||
|
|
||||||
if ((mode !== 'light' && mode !== 'dark' && mode !== 'system') || typeof light !== 'string' || typeof dark !== 'string') {
|
if ((mode !== 'light' && mode !== 'dark' && mode !== 'system') || typeof light !== 'string' || typeof dark !== 'string') {
|
||||||
return;
|
return;
|
||||||
@@ -520,6 +480,10 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
|
|||||||
|
|
||||||
suppressTransitionsForThemeSwitch();
|
suppressTransitionsForThemeSwitch();
|
||||||
flushSync(() => {
|
flushSync(() => {
|
||||||
|
if (receivesParentThemeSync && syncedTheme) {
|
||||||
|
setEmbeddedSyncedTheme(syncedTheme);
|
||||||
|
}
|
||||||
|
|
||||||
if (mode === 'system' && syncedVariant) {
|
if (mode === 'system' && syncedVariant) {
|
||||||
setSystemPrefersDark(syncedVariant === 'dark');
|
setSystemPrefersDark(syncedVariant === 'dark');
|
||||||
}
|
}
|
||||||
@@ -536,7 +500,7 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}, []);
|
}, [receivesParentThemeSync]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
@@ -583,6 +547,10 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
|
|||||||
}, [applyIncomingThemeSync]);
|
}, [applyIncomingThemeSync]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (receivesParentThemeSync) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const lightTheme = ensureThemeById(preferences.lightThemeId, 'light');
|
const lightTheme = ensureThemeById(preferences.lightThemeId, 'light');
|
||||||
const darkTheme = ensureThemeById(preferences.darkThemeId, 'dark');
|
const darkTheme = ensureThemeById(preferences.darkThemeId, 'dark');
|
||||||
|
|
||||||
@@ -597,17 +565,17 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
|
|||||||
splashBgDark: darkTheme.colors.surface.background,
|
splashBgDark: darkTheme.colors.surface.background,
|
||||||
splashFgDark: darkTheme.colors.surface.foreground,
|
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(() => {
|
useEffect(() => {
|
||||||
if (!isDesktopShell) {
|
if (receivesParentThemeSync || !isDesktopShell) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
await setDesktopWindowTheme(preferences.themeMode, currentTheme.metadata.variant);
|
await setDesktopWindowTheme(preferences.themeMode, currentTheme.metadata.variant);
|
||||||
})();
|
})();
|
||||||
}, [currentTheme.metadata.variant, isDesktopShell, preferences.themeMode]);
|
}, [currentTheme.metadata.variant, isDesktopShell, preferences.themeMode, receivesParentThemeSync]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import type { Theme } from '@/types/theme';
|
||||||
|
|
||||||
|
const getNested = (value: unknown, path: string[]): unknown =>
|
||||||
|
path.reduce<unknown>((acc, key) => (acc && typeof acc === 'object' ? (acc as Record<string, unknown>)[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';
|
||||||
|
};
|
||||||
@@ -10,6 +10,7 @@ import { useConfigStore } from '@/stores/useConfigStore';
|
|||||||
import { canUseElectronDesktopIPC, invokeDesktop, isVSCodeRuntime } from '@/lib/desktop';
|
import { canUseElectronDesktopIPC, invokeDesktop, isVSCodeRuntime } from '@/lib/desktop';
|
||||||
import { showOpenCodeStatus } from '@/lib/openCodeStatus';
|
import { showOpenCodeStatus } from '@/lib/openCodeStatus';
|
||||||
import { eventMatchesShortcut, getEffectiveShortcutCombo, normalizeCombo } from '@/lib/shortcuts';
|
import { eventMatchesShortcut, getEffectiveShortcutCombo, normalizeCombo } from '@/lib/shortcuts';
|
||||||
|
import { readEmbeddedThemeSearchParams } from '@/contexts/theme-embedded-bootstrap';
|
||||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||||
import { getCycledPrimaryAgentName } from '@/components/chat/mobileControlsUtils';
|
import { getCycledPrimaryAgentName } from '@/components/chat/mobileControlsUtils';
|
||||||
@@ -184,6 +185,10 @@ export const useKeyboardShortcuts = () => {
|
|||||||
|
|
||||||
if (eventMatchesShortcut(e, combo('cycle_theme'))) {
|
if (eventMatchesShortcut(e, combo('cycle_theme'))) {
|
||||||
e.preventDefault();
|
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 modes: Array<'light' | 'dark' | 'system'> = ['light', 'dark', 'system'];
|
||||||
const activeElement = document.activeElement as HTMLElement | null;
|
const activeElement = document.activeElement as HTMLElement | null;
|
||||||
const currentIndex = modes.indexOf(themeModeRef.current);
|
const currentIndex = modes.indexOf(themeModeRef.current);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -351,6 +351,7 @@ const upsertContextPanelTab = (
|
|||||||
dedupeKey: nextTab.dedupeKey,
|
dedupeKey: nextTab.dedupeKey,
|
||||||
label: nextTab.label,
|
label: nextTab.label,
|
||||||
stagedDiff: nextTab.stagedDiff,
|
stagedDiff: nextTab.stagedDiff,
|
||||||
|
readOnly: nextTab.readOnly,
|
||||||
touchedAt: Date.now(),
|
touchedAt: Date.now(),
|
||||||
}
|
}
|
||||||
: tab));
|
: tab));
|
||||||
|
|||||||
Reference in New Issue
Block a user