feat(surface): switch app shells when the viewport crosses the phone threshold

The mobile-vs-desktop surface is stamped once at boot, so a browser
window narrowed past the phone threshold kept the desktop shell (and
its legacy squeezed layout) until a manual reload. A viewport watcher
now reloads into the other shell once the resize settles — the same
mechanism the old Settings toggle used. Fixed shells (Capacitor,
desktop, VS Code) and ?surface= overrides never switch.

With the new mobile app reachable this way, the old/new mobile layout
preference is gone: phones always get the mobile app.
This commit is contained in:
Bohdan Triapitsyn
2026-08-24 16:08:04 +03:00
parent 98c2a616c3
commit 841eca5720
16 changed files with 67 additions and 123 deletions
+4 -4
View File
@@ -97,7 +97,7 @@
}, },
"packages/electron": { "packages/electron": {
"name": "@openchamber/electron", "name": "@openchamber/electron",
"version": "1.19.0", "version": "1.20.0",
"dependencies": { "dependencies": {
"@openchamber/web": "workspace:*", "@openchamber/web": "workspace:*",
"electron-context-menu": "^4.1.2", "electron-context-menu": "^4.1.2",
@@ -134,7 +134,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@openchamber/ui", "name": "@openchamber/ui",
"version": "1.19.0", "version": "1.20.0",
"dependencies": { "dependencies": {
"@aparajita/capacitor-secure-storage": "^8.0.0", "@aparajita/capacitor-secure-storage": "^8.0.0",
"@base-ui/react": "^1.4.0", "@base-ui/react": "^1.4.0",
@@ -238,7 +238,7 @@
}, },
"packages/vscode": { "packages/vscode": {
"name": "openchamber", "name": "openchamber",
"version": "1.19.0", "version": "1.20.0",
"dependencies": { "dependencies": {
"@openchamber/ui": "workspace:*", "@openchamber/ui": "workspace:*",
"@opencode-ai/sdk": "1.18.21", "@opencode-ai/sdk": "1.18.21",
@@ -261,7 +261,7 @@
}, },
"packages/web": { "packages/web": {
"name": "@openchamber/web", "name": "@openchamber/web",
"version": "1.19.0", "version": "1.20.0",
"bin": { "bin": {
"openchamber": "./bin/cli.js", "openchamber": "./bin/cli.js",
}, },
@@ -32,7 +32,6 @@ import { CODE_FONT_OPTIONS, DEFAULT_MONO_FONT, DEFAULT_UI_FONT, UI_FONT_OPTIONS,
import { useI18n, type Locale } from '@/lib/i18n'; import { useI18n, type Locale } from '@/lib/i18n';
import { useConfigStore } from '@/stores/useConfigStore'; import { useConfigStore } from '@/stores/useConfigStore';
import { normalizeMobileKeyboardMode, supportsMobileKeyboardResizeContent, type MobileKeyboardMode } from '@/lib/mobileKeyboardMode'; import { normalizeMobileKeyboardMode, supportsMobileKeyboardResizeContent, type MobileKeyboardMode } from '@/lib/mobileKeyboardMode';
import { getStoredMobileLayoutPreference, setStoredMobileLayoutPreference, type MobileLayoutPreference } from '@/lib/mobileLayoutPreference';
import { import {
setDirectoryShowHidden, setDirectoryShowHidden,
useDirectoryShowHidden, useDirectoryShowHidden,
@@ -151,17 +150,6 @@ const MOBILE_KEYBOARD_MODE_OPTIONS: Option<MobileKeyboardMode>[] = [
}, },
]; ];
const MOBILE_LAYOUT_OPTIONS: Array<{ value: MobileLayoutPreference; labelKey: string }> = [
{
value: 'default',
labelKey: 'settings.openchamber.visual.option.mobileLayout.default',
},
{
value: 'new',
labelKey: 'settings.openchamber.visual.option.mobileLayout.new',
},
];
type PwaInstallNameWindow = Window & { type PwaInstallNameWindow = Window & {
__OPENCHAMBER_SET_PWA_INSTALL_NAME__?: (value: string) => string; __OPENCHAMBER_SET_PWA_INSTALL_NAME__?: (value: string) => string;
__OPENCHAMBER_SET_PWA_ORIENTATION__?: (value: 'system' | 'portrait' | 'landscape') => 'system' | 'portrait' | 'landscape'; __OPENCHAMBER_SET_PWA_ORIENTATION__?: (value: 'system' | 'portrait' | 'landscape') => 'system' | 'portrait' | 'landscape';
@@ -629,10 +617,9 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const hasThemeSettings = shouldShow('theme') && !isVSCode; const hasThemeSettings = shouldShow('theme') && !isVSCode;
const showWindowControlsPositionSetting = shouldShow('windowControlsPosition') && showWindowControlsPosition; const showWindowControlsPositionSetting = shouldShow('windowControlsPosition') && showWindowControlsPosition;
const hasLocalizationSettings = shouldShow('theme') || shouldShow('timeFormat') || shouldShow('weekStart'); const hasLocalizationSettings = shouldShow('theme') || shouldShow('timeFormat') || shouldShow('weekStart');
const showMobileLayoutSetting = isMobile && isWebRuntime() && !isDesktopShell() && !isVSCode;
const hasAppearanceSettings = isVSCode const hasAppearanceSettings = isVSCode
? hasLocalizationSettings ? hasLocalizationSettings
: (shouldShow('theme') || showWindowControlsPositionSetting || showMobileLayoutSetting || shouldShow('pwaInstallName') || shouldShow('pwaOrientation') || shouldShow('timeFormat') || shouldShow('weekStart')); : (shouldShow('theme') || showWindowControlsPositionSetting || shouldShow('pwaInstallName') || shouldShow('pwaOrientation') || shouldShow('timeFormat') || shouldShow('weekStart'));
const hasLayoutSettings = shouldShow('fontSize') || shouldShow('terminalFontSize') || shouldShow('editorFontSize') || shouldShow('spacing') || (shouldShow('inputBarOffset') && isMobile); const hasLayoutSettings = shouldShow('fontSize') || shouldShow('terminalFontSize') || shouldShow('editorFontSize') || shouldShow('spacing') || (shouldShow('inputBarOffset') && isMobile);
const hasNavigationSettings = (shouldShow('terminalQuickKeys') && !isMobile) || ((shouldShow('terminalShell') || shouldShow('terminalLoginShell')) && !isVSCode) || shouldShow('fileEditorKeymap') || shouldShow('autoSaveEnabled') || (shouldShow('expandedEditorToolbar') && !isVSCode); const hasNavigationSettings = (shouldShow('terminalQuickKeys') && !isMobile) || ((shouldShow('terminalShell') || shouldShow('terminalLoginShell')) && !isVSCode) || shouldShow('fileEditorKeymap') || shouldShow('autoSaveEnabled') || (shouldShow('expandedEditorToolbar') && !isVSCode);
const hasBehaviorSettings = shouldShow('mermaidRendering') const hasBehaviorSettings = shouldShow('mermaidRendering')
@@ -723,7 +710,6 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
? [...terminalLoginShells.filter((shell) => shell !== terminalShell), terminalShell] ? [...terminalLoginShells.filter((shell) => shell !== terminalShell), terminalShell]
: terminalLoginShells.filter((shell) => shell !== terminalShell)); : terminalLoginShells.filter((shell) => shell !== terminalShell));
}; };
const [mobileLayoutPreference, setMobileLayoutPreference] = React.useState<MobileLayoutPreference>(() => getStoredMobileLayoutPreference());
const [pwaInstallName, setPwaInstallName] = React.useState(''); const [pwaInstallName, setPwaInstallName] = React.useState('');
const [pwaOrientation, setPwaOrientation] = React.useState<'system' | 'portrait' | 'landscape'>('system'); const [pwaOrientation, setPwaOrientation] = React.useState<'system' | 'portrait' | 'landscape'>('system');
const selectedTimeFormatLabel = React.useMemo(() => { const selectedTimeFormatLabel = React.useMemo(() => {
@@ -743,16 +729,6 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
return option ? tUnsafe(option.labelKey) : undefined; return option ? tUnsafe(option.labelKey) : undefined;
}, [mobileKeyboardMode, tUnsafe]); }, [mobileKeyboardMode, tUnsafe]);
const handleMobileLayoutPreferenceChange = React.useCallback((value: MobileLayoutPreference) => {
if (value === mobileLayoutPreference) {
return;
}
setMobileLayoutPreference(value);
setStoredMobileLayoutPreference(value);
window.location.reload();
}, [mobileLayoutPreference]);
const applyPwaInstallName = React.useCallback(async (value: string) => { const applyPwaInstallName = React.useCallback(async (value: string) => {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return; return;
@@ -877,21 +853,6 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
))} ))}
</SettingsRadioGroup> </SettingsRadioGroup>
{showMobileLayoutSetting && (
<SettingsInset>
<SettingsStackedField label={t('settings.openchamber.visual.section.mobileLayout')}>
<SettingsChipGroup
value={mobileLayoutPreference}
options={MOBILE_LAYOUT_OPTIONS.map((option) => ({
value: option.value,
label: tUnsafe(option.labelKey),
}))}
onChange={handleMobileLayoutPreferenceChange}
aria-label={t('settings.openchamber.visual.section.mobileLayout')}
/>
</SettingsStackedField>
</SettingsInset>
)}
</div> </div>
<div className={SETTINGS_FIELDS_STACK_CLASS}> <div className={SETTINGS_FIELDS_STACK_CLASS}>
@@ -1845,9 +1845,6 @@ export const settingsDict = {
'settings.voice.page.field.ttsInputModeRaw': 'Rohes Markdown', 'settings.voice.page.field.ttsInputModeRaw': 'Rohes Markdown',
'settings.voice.page.field.ttsInputModeSummarized': 'zusammengefasst', 'settings.voice.page.field.ttsInputModeSummarized': 'zusammengefasst',
'settings.openchamber.visual.section.colorMode': 'Farbmodus', 'settings.openchamber.visual.section.colorMode': 'Farbmodus',
'settings.openchamber.visual.section.mobileLayout': 'Mobiles Layout',
'settings.openchamber.visual.option.mobileLayout.default': 'Alt',
'settings.openchamber.visual.option.mobileLayout.new': 'Neu',
'settings.openchamber.visual.section.localization': 'Lokalisierung', 'settings.openchamber.visual.section.localization': 'Lokalisierung',
'settings.openchamber.visual.section.spacingAndLayout': 'Abstand & Layout', 'settings.openchamber.visual.section.spacingAndLayout': 'Abstand & Layout',
'settings.openchamber.visual.section.navigation': 'Navigation', 'settings.openchamber.visual.section.navigation': 'Navigation',
@@ -1913,9 +1913,6 @@ export const settingsDict = {
'settings.voice.page.field.ttsInputModeSummarized': 'summarized', 'settings.voice.page.field.ttsInputModeSummarized': 'summarized',
'settings.openchamber.visual.section.colorMode': 'Color Mode', 'settings.openchamber.visual.section.colorMode': 'Color Mode',
'settings.openchamber.visual.section.colorModeAndTheme': 'Color mode & Theme', 'settings.openchamber.visual.section.colorModeAndTheme': 'Color mode & Theme',
'settings.openchamber.visual.section.mobileLayout': 'Mobile Layout',
'settings.openchamber.visual.option.mobileLayout.default': 'Old',
'settings.openchamber.visual.option.mobileLayout.new': 'New',
'settings.openchamber.visual.section.localization': 'Localization', 'settings.openchamber.visual.section.localization': 'Localization',
'settings.openchamber.visual.section.spacingAndLayout': 'Spacing & Layout', 'settings.openchamber.visual.section.spacingAndLayout': 'Spacing & Layout',
'settings.openchamber.visual.section.densityAndType': 'Density & type', 'settings.openchamber.visual.section.densityAndType': 'Density & type',
@@ -1890,9 +1890,6 @@ export const settingsDict = {
"settings.voice.page.field.ttsInputModeSummarized": "resumido", "settings.voice.page.field.ttsInputModeSummarized": "resumido",
"settings.openchamber.visual.section.colorMode": "Modo de color", "settings.openchamber.visual.section.colorMode": "Modo de color",
"settings.openchamber.visual.section.colorModeAndTheme": "Modo de color y tema", "settings.openchamber.visual.section.colorModeAndTheme": "Modo de color y tema",
"settings.openchamber.visual.section.mobileLayout": "Diseño móvil",
"settings.openchamber.visual.option.mobileLayout.default": "Anterior",
"settings.openchamber.visual.option.mobileLayout.new": "Nuevo",
"settings.openchamber.visual.section.localization": "Localización", "settings.openchamber.visual.section.localization": "Localización",
"settings.openchamber.visual.section.spacingAndLayout": "Espaciado y diseño", "settings.openchamber.visual.section.spacingAndLayout": "Espaciado y diseño",
"settings.openchamber.visual.section.densityAndType": "Densidad y tipografía", "settings.openchamber.visual.section.densityAndType": "Densidad y tipografía",
@@ -2147,9 +2147,6 @@ export const settingsDict = {
'settings.voice.page.field.ttsInputModeSanitized': 'Nettoyé', 'settings.voice.page.field.ttsInputModeSanitized': 'Nettoyé',
'settings.voice.page.field.ttsInputModeRaw': 'Markdown brut', 'settings.voice.page.field.ttsInputModeRaw': 'Markdown brut',
'settings.voice.page.field.ttsInputModeSummarized': 'résumé', 'settings.voice.page.field.ttsInputModeSummarized': 'résumé',
'settings.openchamber.visual.section.mobileLayout': 'Mise en page mobile',
'settings.openchamber.visual.option.mobileLayout.default': 'Ancienne',
'settings.openchamber.visual.option.mobileLayout.new': 'Nouvelle',
'settings.openchamber.visual.field.dockBadge': 'Badge du Dock', 'settings.openchamber.visual.field.dockBadge': 'Badge du Dock',
'settings.openchamber.visual.field.dockBadgeHint': 'Afficher sur l’icône du Dock de macOS le nombre de discussions avec une activité non vue.', 'settings.openchamber.visual.field.dockBadgeHint': 'Afficher sur l’icône du Dock de macOS le nombre de discussions avec une activité non vue.',
'settings.openchamber.visual.actions.saveAndRestart': 'Enregistrer et redémarrer', 'settings.openchamber.visual.actions.saveAndRestart': 'Enregistrer et redémarrer',
@@ -1923,9 +1923,6 @@ export const settingsDict = {
'settings.voice.page.field.ttsInputModeSummarized': '要約', 'settings.voice.page.field.ttsInputModeSummarized': '要約',
'settings.openchamber.visual.section.colorMode': 'カラーモード', 'settings.openchamber.visual.section.colorMode': 'カラーモード',
'settings.openchamber.visual.section.colorModeAndTheme': 'カラーモードとテーマ', 'settings.openchamber.visual.section.colorModeAndTheme': 'カラーモードとテーマ',
'settings.openchamber.visual.section.mobileLayout': 'モバイルレイアウト',
'settings.openchamber.visual.option.mobileLayout.default': '旧',
'settings.openchamber.visual.option.mobileLayout.new': '新',
'settings.openchamber.visual.section.localization': 'ローカライゼーション', 'settings.openchamber.visual.section.localization': 'ローカライゼーション',
'settings.openchamber.visual.section.spacingAndLayout': '間隔とレイアウト', 'settings.openchamber.visual.section.spacingAndLayout': '間隔とレイアウト',
'settings.openchamber.visual.section.densityAndType': '密度と書体', 'settings.openchamber.visual.section.densityAndType': '密度と書体',
@@ -1890,9 +1890,6 @@ export const settingsDict = {
'settings.voice.page.field.ttsInputModeSummarized': '요약', 'settings.voice.page.field.ttsInputModeSummarized': '요약',
'settings.openchamber.visual.section.colorMode': '색상 모드', 'settings.openchamber.visual.section.colorMode': '색상 모드',
'settings.openchamber.visual.section.colorModeAndTheme': '색상 모드 및 테마', 'settings.openchamber.visual.section.colorModeAndTheme': '색상 모드 및 테마',
'settings.openchamber.visual.section.mobileLayout': '모바일 레이아웃',
'settings.openchamber.visual.option.mobileLayout.default': '이전',
'settings.openchamber.visual.option.mobileLayout.new': '새로움',
'settings.openchamber.visual.section.localization': '지역화', 'settings.openchamber.visual.section.localization': '지역화',
'settings.openchamber.visual.section.spacingAndLayout': '간격 및 레이아웃', 'settings.openchamber.visual.section.spacingAndLayout': '간격 및 레이아웃',
'settings.openchamber.visual.section.densityAndType': '밀도 및 서체', 'settings.openchamber.visual.section.densityAndType': '밀도 및 서체',
@@ -1196,9 +1196,6 @@ export const settingsDict = {
'settings.openchamber.visual.section.chatFeatures': 'Funkcje', 'settings.openchamber.visual.section.chatFeatures': 'Funkcje',
'settings.openchamber.visual.section.colorMode': 'Tryb kolorów', 'settings.openchamber.visual.section.colorMode': 'Tryb kolorów',
'settings.openchamber.visual.section.colorModeAndTheme': 'Tryb kolorów i motyw', 'settings.openchamber.visual.section.colorModeAndTheme': 'Tryb kolorów i motyw',
'settings.openchamber.visual.section.mobileLayout': 'Układ mobilny',
'settings.openchamber.visual.option.mobileLayout.default': 'Poprzedni',
'settings.openchamber.visual.option.mobileLayout.new': 'Nowy',
'settings.openchamber.visual.section.diffLayout': 'Układ diffa', 'settings.openchamber.visual.section.diffLayout': 'Układ diffa',
'settings.openchamber.visual.section.diffLayoutAria': 'Układ diffa', 'settings.openchamber.visual.section.diffLayoutAria': 'Układ diffa',
'settings.openchamber.visual.section.localization': 'Lokalizacja', 'settings.openchamber.visual.section.localization': 'Lokalizacja',
@@ -1890,9 +1890,6 @@ export const settingsDict = {
"settings.voice.page.field.ttsInputModeSummarized": "resumido", "settings.voice.page.field.ttsInputModeSummarized": "resumido",
"settings.openchamber.visual.section.colorMode": "Modo de cor", "settings.openchamber.visual.section.colorMode": "Modo de cor",
"settings.openchamber.visual.section.colorModeAndTheme": "Modo de cor e tema", "settings.openchamber.visual.section.colorModeAndTheme": "Modo de cor e tema",
"settings.openchamber.visual.section.mobileLayout": "Layout móvel",
"settings.openchamber.visual.option.mobileLayout.default": "Anterior",
"settings.openchamber.visual.option.mobileLayout.new": "Novo",
"settings.openchamber.visual.section.localization": "Localização", "settings.openchamber.visual.section.localization": "Localização",
"settings.openchamber.visual.section.spacingAndLayout": "Espaçamento e layout", "settings.openchamber.visual.section.spacingAndLayout": "Espaçamento e layout",
"settings.openchamber.visual.section.densityAndType": "Densidade e tipografia", "settings.openchamber.visual.section.densityAndType": "Densidade e tipografia",
@@ -1890,9 +1890,6 @@ export const settingsDict = {
"settings.voice.page.field.ttsInputModeSummarized": "скорочений", "settings.voice.page.field.ttsInputModeSummarized": "скорочений",
"settings.openchamber.visual.section.colorMode": "Режим теми", "settings.openchamber.visual.section.colorMode": "Режим теми",
"settings.openchamber.visual.section.colorModeAndTheme": "Режим кольору та тема", "settings.openchamber.visual.section.colorModeAndTheme": "Режим кольору та тема",
"settings.openchamber.visual.section.mobileLayout": "Мобільний макет",
"settings.openchamber.visual.option.mobileLayout.default": "Попередній",
"settings.openchamber.visual.option.mobileLayout.new": "Новий",
"settings.openchamber.visual.section.localization": "Локалізація", "settings.openchamber.visual.section.localization": "Локалізація",
"settings.openchamber.visual.section.spacingAndLayout": "Відступи й компонування", "settings.openchamber.visual.section.spacingAndLayout": "Відступи й компонування",
"settings.openchamber.visual.section.densityAndType": "Щільність і шрифти", "settings.openchamber.visual.section.densityAndType": "Щільність і шрифти",
@@ -1890,9 +1890,6 @@ export const settingsDict = {
'settings.voice.page.field.ttsInputModeSummarized': '摘要', 'settings.voice.page.field.ttsInputModeSummarized': '摘要',
'settings.openchamber.visual.section.colorMode': '颜色模式', 'settings.openchamber.visual.section.colorMode': '颜色模式',
'settings.openchamber.visual.section.colorModeAndTheme': '颜色模式与主题', 'settings.openchamber.visual.section.colorModeAndTheme': '颜色模式与主题',
'settings.openchamber.visual.section.mobileLayout': '移动端布局',
'settings.openchamber.visual.option.mobileLayout.default': '旧版',
'settings.openchamber.visual.option.mobileLayout.new': '新版',
'settings.openchamber.visual.section.localization': '本地化', 'settings.openchamber.visual.section.localization': '本地化',
'settings.openchamber.visual.section.spacingAndLayout': '间距与布局', 'settings.openchamber.visual.section.spacingAndLayout': '间距与布局',
'settings.openchamber.visual.section.densityAndType': '密度与字体', 'settings.openchamber.visual.section.densityAndType': '密度与字体',
@@ -1801,7 +1801,6 @@ export const settingsDict = {
'settings.openchamber.visual.section.spacingAndLayout': '間距與佈局', 'settings.openchamber.visual.section.spacingAndLayout': '間距與佈局',
'settings.openchamber.visual.section.densityAndType': '密度與字型', 'settings.openchamber.visual.section.densityAndType': '密度與字型',
'settings.openchamber.visual.section.appInstall': '應用程式安裝', 'settings.openchamber.visual.section.appInstall': '應用程式安裝',
'settings.openchamber.visual.section.mobileLayout': '行動版版面',
'settings.openchamber.visual.section.navigation': '導覽', 'settings.openchamber.visual.section.navigation': '導覽',
'settings.openchamber.visual.section.chatRenderMode': '聊天渲染模式', 'settings.openchamber.visual.section.chatRenderMode': '聊天渲染模式',
'settings.openchamber.visual.section.chatRenderModeAria': '聊天渲染模式', 'settings.openchamber.visual.section.chatRenderModeAria': '聊天渲染模式',
@@ -1855,8 +1854,6 @@ export const settingsDict = {
'settings.openchamber.visual.field.mobileKeyboardModeAria': '行動裝置鍵盤行為', 'settings.openchamber.visual.field.mobileKeyboardModeAria': '行動裝置鍵盤行為',
'settings.openchamber.visual.field.selectMobileKeyboardModePlaceholder': '選擇鍵盤行為', 'settings.openchamber.visual.field.selectMobileKeyboardModePlaceholder': '選擇鍵盤行為',
'settings.openchamber.visual.actions.resetMobileKeyboardModeAria': '重設行動裝置鍵盤行為', 'settings.openchamber.visual.actions.resetMobileKeyboardModeAria': '重設行動裝置鍵盤行為',
'settings.openchamber.visual.option.mobileLayout.default': '舊版',
'settings.openchamber.visual.option.mobileLayout.new': '新版',
'settings.openchamber.visual.field.interfaceFontSize': '介面字體大小', 'settings.openchamber.visual.field.interfaceFontSize': '介面字體大小',
'settings.openchamber.visual.field.interfaceFont': '介面字體', 'settings.openchamber.visual.field.interfaceFont': '介面字體',
'settings.openchamber.visual.field.selectInterfaceFontAria': '選擇介面字體', 'settings.openchamber.visual.field.selectInterfaceFontAria': '選擇介面字體',
@@ -1,34 +0,0 @@
export type MobileLayoutPreference = 'default' | 'new';
const MOBILE_LAYOUT_PREFERENCE_KEY = 'openchamber-mobile-layout';
const normalizeMobileLayoutPreference = (value: unknown): MobileLayoutPreference => {
// 'new' is the default; only an explicit 'default' (the legacy/"Old" layout)
// opts out of it.
return value === 'default' ? 'default' : 'new';
};
export const getStoredMobileLayoutPreference = (): MobileLayoutPreference => {
if (typeof window === 'undefined') {
return 'new';
}
try {
return normalizeMobileLayoutPreference(window.localStorage.getItem(MOBILE_LAYOUT_PREFERENCE_KEY));
} catch {
return 'new';
}
};
export const setStoredMobileLayoutPreference = (value: MobileLayoutPreference): boolean => {
if (typeof window === 'undefined') {
return false;
}
try {
window.localStorage.setItem(MOBILE_LAYOUT_PREFERENCE_KEY, value);
return true;
} catch {
return false;
}
};
+57 -11
View File
@@ -1,6 +1,5 @@
import { isDesktopShell, isVSCodeRuntime } from '@/lib/desktop'; import { isDesktopShell, isVSCodeRuntime } from '@/lib/desktop';
import { isCapacitorApp } from '@/lib/platform'; import { isCapacitorApp } from '@/lib/platform';
import { getStoredMobileLayoutPreference } from '@/lib/mobileLayoutPreference';
export type HostedSurface = 'desktop' | 'mobile'; export type HostedSurface = 'desktop' | 'mobile';
@@ -11,6 +10,7 @@ declare global {
} }
const MOBILE_SURFACE_MAX_WIDTH = 768; const MOBILE_SURFACE_MAX_WIDTH = 768;
const SURFACE_SWITCH_DEBOUNCE_MS = 800;
const isTouchOrCoarsePointer = (): boolean => { const isTouchOrCoarsePointer = (): boolean => {
if (typeof window === 'undefined') return false; if (typeof window === 'undefined') return false;
@@ -22,12 +22,29 @@ const isTouchOrCoarsePointer = (): boolean => {
return coarsePointer || touchPoints > 0; return coarsePointer || touchPoints > 0;
}; };
const hasSurfaceUrlOverride = (): boolean => {
if (typeof window === 'undefined') return false;
const override = new URLSearchParams(window.location.search).get('surface');
return override === 'mobile' || override === 'desktop';
};
/** Viewport half of the surface decision; re-evaluated on resize by the watcher. */
const isPhoneViewport = (): boolean => {
if (typeof window === 'undefined') return false;
const width = Math.min(
window.innerWidth || Number.POSITIVE_INFINITY,
window.screen?.width || Number.POSITIVE_INFINITY,
);
return Number.isFinite(width)
&& width <= MOBILE_SURFACE_MAX_WIDTH
&& isTouchOrCoarsePointer();
};
/** /**
* Single authority for the mobile-vs-desktop surface decision. * Single authority for the mobile-vs-desktop surface decision.
* *
* Priority: explicit stamp (set once at boot) → URL override → Capacitor * Priority: explicit stamp (set once at boot) → URL override → Capacitor
* shell (always the mobile surface) → desktop shells → phone heuristic * shell (always the mobile surface) → desktop shells → phone heuristic.
* gated by the stored mobile layout preference.
*/ */
const detectHostedSurface = (): HostedSurface => { const detectHostedSurface = (): HostedSurface => {
if (typeof window === 'undefined') return 'desktop'; if (typeof window === 'undefined') return 'desktop';
@@ -45,14 +62,7 @@ const detectHostedSurface = (): HostedSurface => {
if (isCapacitorApp()) return 'mobile'; if (isCapacitorApp()) return 'mobile';
if (isDesktopShell() || isVSCodeRuntime()) return 'desktop'; if (isDesktopShell() || isVSCodeRuntime()) return 'desktop';
const width = Math.min( return isPhoneViewport() ? 'mobile' : 'desktop';
window.innerWidth || Number.POSITIVE_INFINITY,
window.screen?.width || Number.POSITIVE_INFINITY,
);
const likelyPhone = Number.isFinite(width)
&& width <= MOBILE_SURFACE_MAX_WIDTH
&& isTouchOrCoarsePointer();
return likelyPhone && getStoredMobileLayoutPreference() === 'new' ? 'mobile' : 'desktop';
}; };
/** /**
@@ -69,3 +79,39 @@ export const resolveHostedSurface = (): HostedSurface => {
}; };
export const isMobileSurfaceRuntime = (): boolean => detectHostedSurface() === 'mobile'; export const isMobileSurfaceRuntime = (): boolean => detectHostedSurface() === 'mobile';
/**
* The surface is stamped once at boot, so a browser window that crosses the
* phone threshold after load would otherwise keep the wrong app shell (the
* app trees, stores, and sync bootstrap differ, so an in-place switch is not
* safe). Watch for the viewport heuristic disagreeing with the stamp and
* reload — the same mechanism a surface change has always used — once the
* resize settles. Fixed shells never switch: Capacitor is always mobile,
* desktop/VS Code shells are always desktop, and an explicit ?surface=
* override wins over the heuristic.
*/
export const watchHostedSurfaceViewport = (): (() => void) => {
if (typeof window === 'undefined') return () => {};
if (isCapacitorApp() || isDesktopShell() || isVSCodeRuntime()) return () => {};
if (hasSurfaceUrlOverride()) return () => {};
let timeout: ReturnType<typeof setTimeout> | null = null;
const handleResize = () => {
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => {
timeout = null;
const stamped = window.__OPENCHAMBER_SURFACE__;
const desired: HostedSurface = isPhoneViewport() ? 'mobile' : 'desktop';
if (stamped && stamped !== desired) {
window.__OPENCHAMBER_SURFACE__ = undefined;
window.location.reload();
}
}, SURFACE_SWITCH_DEBOUNCE_MS);
};
window.addEventListener('resize', handleResize);
return () => {
if (timeout) clearTimeout(timeout);
window.removeEventListener('resize', handleResize);
};
};
+5 -1
View File
@@ -2,7 +2,7 @@ import { createConfiguredWebAPIs, getDesktopRelayRestoreReady } from './runtimeC
import { registerSW } from 'virtual:pwa-register'; import { registerSW } from 'virtual:pwa-register';
import type { RuntimeAPIs } from '@openchamber/ui/lib/api/types'; import type { RuntimeAPIs } from '@openchamber/ui/lib/api/types';
import { resolveHostedSurface, type HostedSurface } from '@openchamber/ui/lib/runtimeSurface'; import { resolveHostedSurface, watchHostedSurfaceViewport, type HostedSurface } from '@openchamber/ui/lib/runtimeSurface';
import { import {
isEmbeddedSessionChat, isEmbeddedSessionChat,
requestEmbeddedSessionRuntimeBootstrap, requestEmbeddedSessionRuntimeBootstrap,
@@ -90,6 +90,10 @@ const start = async (): Promise<void> => {
: null; : null;
window.__OPENCHAMBER_RUNTIME_APIS__ = createConfiguredWebAPIs(embeddedBootstrap); window.__OPENCHAMBER_RUNTIME_APIS__ = createConfiguredWebAPIs(embeddedBootstrap);
// Reload into the other app shell when the viewport crosses the phone
// threshold after boot (no-op in fixed shells and with ?surface= overrides).
watchHostedSurfaceViewport();
if (hostedSurface === 'mobile') { if (hostedSurface === 'mobile') {
const { renderMobileApp } = await import('@openchamber/ui/apps/renderMobileApp'); const { renderMobileApp } = await import('@openchamber/ui/apps/renderMobileApp');
renderMobileApp(window.__OPENCHAMBER_RUNTIME_APIS__); renderMobileApp(window.__OPENCHAMBER_RUNTIME_APIS__);