From 841eca572091d28fcbe077ca5b17d418734cba98 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 24 Aug 2026 16:08:04 +0300 Subject: [PATCH] feat(surface): switch app shells when the viewport crosses the phone threshold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- bun.lock | 8 +-- .../openchamber/OpenChamberVisualSettings.tsx | 41 +---------- .../ui/src/lib/i18n/messages/de.settings.ts | 3 - .../ui/src/lib/i18n/messages/en.settings.ts | 3 - .../ui/src/lib/i18n/messages/es.settings.ts | 3 - .../ui/src/lib/i18n/messages/fr.settings.ts | 3 - .../ui/src/lib/i18n/messages/ja.settings.ts | 3 - .../ui/src/lib/i18n/messages/ko.settings.ts | 3 - .../ui/src/lib/i18n/messages/pl.settings.ts | 3 - .../src/lib/i18n/messages/pt-BR.settings.ts | 3 - .../ui/src/lib/i18n/messages/uk.settings.ts | 3 - .../src/lib/i18n/messages/zh-CN.settings.ts | 3 - .../src/lib/i18n/messages/zh-TW.settings.ts | 3 - packages/ui/src/lib/mobileLayoutPreference.ts | 34 ---------- packages/ui/src/lib/runtimeSurface.ts | 68 ++++++++++++++++--- packages/web/src/main.tsx | 6 +- 16 files changed, 67 insertions(+), 123 deletions(-) delete mode 100644 packages/ui/src/lib/mobileLayoutPreference.ts diff --git a/bun.lock b/bun.lock index 2b7a26c2..98e47d8f 100644 --- a/bun.lock +++ b/bun.lock @@ -97,7 +97,7 @@ }, "packages/electron": { "name": "@openchamber/electron", - "version": "1.19.0", + "version": "1.20.0", "dependencies": { "@openchamber/web": "workspace:*", "electron-context-menu": "^4.1.2", @@ -134,7 +134,7 @@ }, "packages/ui": { "name": "@openchamber/ui", - "version": "1.19.0", + "version": "1.20.0", "dependencies": { "@aparajita/capacitor-secure-storage": "^8.0.0", "@base-ui/react": "^1.4.0", @@ -238,7 +238,7 @@ }, "packages/vscode": { "name": "openchamber", - "version": "1.19.0", + "version": "1.20.0", "dependencies": { "@openchamber/ui": "workspace:*", "@opencode-ai/sdk": "1.18.21", @@ -261,7 +261,7 @@ }, "packages/web": { "name": "@openchamber/web", - "version": "1.19.0", + "version": "1.20.0", "bin": { "openchamber": "./bin/cli.js", }, diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx index 8de1244c..589e98d2 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx @@ -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 { useConfigStore } from '@/stores/useConfigStore'; import { normalizeMobileKeyboardMode, supportsMobileKeyboardResizeContent, type MobileKeyboardMode } from '@/lib/mobileKeyboardMode'; -import { getStoredMobileLayoutPreference, setStoredMobileLayoutPreference, type MobileLayoutPreference } from '@/lib/mobileLayoutPreference'; import { setDirectoryShowHidden, useDirectoryShowHidden, @@ -151,17 +150,6 @@ const MOBILE_KEYBOARD_MODE_OPTIONS: Option[] = [ }, ]; -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 & { __OPENCHAMBER_SET_PWA_INSTALL_NAME__?: (value: string) => string; __OPENCHAMBER_SET_PWA_ORIENTATION__?: (value: 'system' | 'portrait' | 'landscape') => 'system' | 'portrait' | 'landscape'; @@ -629,10 +617,9 @@ export const OpenChamberVisualSettings: React.FC const hasThemeSettings = shouldShow('theme') && !isVSCode; const showWindowControlsPositionSetting = shouldShow('windowControlsPosition') && showWindowControlsPosition; const hasLocalizationSettings = shouldShow('theme') || shouldShow('timeFormat') || shouldShow('weekStart'); - const showMobileLayoutSetting = isMobile && isWebRuntime() && !isDesktopShell() && !isVSCode; const hasAppearanceSettings = isVSCode ? 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 hasNavigationSettings = (shouldShow('terminalQuickKeys') && !isMobile) || ((shouldShow('terminalShell') || shouldShow('terminalLoginShell')) && !isVSCode) || shouldShow('fileEditorKeymap') || shouldShow('autoSaveEnabled') || (shouldShow('expandedEditorToolbar') && !isVSCode); const hasBehaviorSettings = shouldShow('mermaidRendering') @@ -723,7 +710,6 @@ export const OpenChamberVisualSettings: React.FC ? [...terminalLoginShells.filter((shell) => shell !== terminalShell), terminalShell] : terminalLoginShells.filter((shell) => shell !== terminalShell)); }; - const [mobileLayoutPreference, setMobileLayoutPreference] = React.useState(() => getStoredMobileLayoutPreference()); const [pwaInstallName, setPwaInstallName] = React.useState(''); const [pwaOrientation, setPwaOrientation] = React.useState<'system' | 'portrait' | 'landscape'>('system'); const selectedTimeFormatLabel = React.useMemo(() => { @@ -743,16 +729,6 @@ export const OpenChamberVisualSettings: React.FC return option ? tUnsafe(option.labelKey) : undefined; }, [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) => { if (typeof window === 'undefined') { return; @@ -877,21 +853,6 @@ export const OpenChamberVisualSettings: React.FC ))} - {showMobileLayoutSetting && ( - - - ({ - value: option.value, - label: tUnsafe(option.labelKey), - }))} - onChange={handleMobileLayoutPreferenceChange} - aria-label={t('settings.openchamber.visual.section.mobileLayout')} - /> - - - )}
diff --git a/packages/ui/src/lib/i18n/messages/de.settings.ts b/packages/ui/src/lib/i18n/messages/de.settings.ts index 6a3410d0..28f6c5ef 100644 --- a/packages/ui/src/lib/i18n/messages/de.settings.ts +++ b/packages/ui/src/lib/i18n/messages/de.settings.ts @@ -1845,9 +1845,6 @@ export const settingsDict = { 'settings.voice.page.field.ttsInputModeRaw': 'Rohes Markdown', 'settings.voice.page.field.ttsInputModeSummarized': 'zusammengefasst', '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.spacingAndLayout': 'Abstand & Layout', 'settings.openchamber.visual.section.navigation': 'Navigation', diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 525d76f3..fe98e79c 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -1913,9 +1913,6 @@ export const settingsDict = { 'settings.voice.page.field.ttsInputModeSummarized': 'summarized', 'settings.openchamber.visual.section.colorMode': 'Color Mode', '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.spacingAndLayout': 'Spacing & Layout', 'settings.openchamber.visual.section.densityAndType': 'Density & type', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index b841e8da..ee7e3163 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -1890,9 +1890,6 @@ export const settingsDict = { "settings.voice.page.field.ttsInputModeSummarized": "resumido", "settings.openchamber.visual.section.colorMode": "Modo de color", "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.spacingAndLayout": "Espaciado y diseño", "settings.openchamber.visual.section.densityAndType": "Densidad y tipografía", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index 88d46f33..9ad25052 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -2147,9 +2147,6 @@ export const settingsDict = { 'settings.voice.page.field.ttsInputModeSanitized': 'Nettoyé', 'settings.voice.page.field.ttsInputModeRaw': 'Markdown brut', '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.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', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index f4070831..20454d38 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -1923,9 +1923,6 @@ export const settingsDict = { 'settings.voice.page.field.ttsInputModeSummarized': '要約', 'settings.openchamber.visual.section.colorMode': 'カラーモード', '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.spacingAndLayout': '間隔とレイアウト', 'settings.openchamber.visual.section.densityAndType': '密度と書体', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index e4e2ef9e..d97dc80a 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -1890,9 +1890,6 @@ export const settingsDict = { 'settings.voice.page.field.ttsInputModeSummarized': '요약', 'settings.openchamber.visual.section.colorMode': '색상 모드', '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.spacingAndLayout': '간격 및 레이아웃', 'settings.openchamber.visual.section.densityAndType': '밀도 및 서체', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index 7bebca44..a84587d8 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -1196,9 +1196,6 @@ export const settingsDict = { 'settings.openchamber.visual.section.chatFeatures': 'Funkcje', 'settings.openchamber.visual.section.colorMode': 'Tryb kolorów', '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.diffLayoutAria': 'Układ diffa', 'settings.openchamber.visual.section.localization': 'Lokalizacja', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index ce24a8d0..3a515bfd 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -1890,9 +1890,6 @@ export const settingsDict = { "settings.voice.page.field.ttsInputModeSummarized": "resumido", "settings.openchamber.visual.section.colorMode": "Modo de cor", "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.spacingAndLayout": "Espaçamento e layout", "settings.openchamber.visual.section.densityAndType": "Densidade e tipografia", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index 2d7dcb16..c7ac4bbc 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -1890,9 +1890,6 @@ export const settingsDict = { "settings.voice.page.field.ttsInputModeSummarized": "скорочений", "settings.openchamber.visual.section.colorMode": "Режим теми", "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.spacingAndLayout": "Відступи й компонування", "settings.openchamber.visual.section.densityAndType": "Щільність і шрифти", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index d648d30e..6b0f45cb 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -1890,9 +1890,6 @@ export const settingsDict = { 'settings.voice.page.field.ttsInputModeSummarized': '摘要', 'settings.openchamber.visual.section.colorMode': '颜色模式', '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.spacingAndLayout': '间距与布局', 'settings.openchamber.visual.section.densityAndType': '密度与字体', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index 28e71162..fcf9dae2 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -1801,7 +1801,6 @@ export const settingsDict = { 'settings.openchamber.visual.section.spacingAndLayout': '間距與佈局', 'settings.openchamber.visual.section.densityAndType': '密度與字型', 'settings.openchamber.visual.section.appInstall': '應用程式安裝', - 'settings.openchamber.visual.section.mobileLayout': '行動版版面', 'settings.openchamber.visual.section.navigation': '導覽', 'settings.openchamber.visual.section.chatRenderMode': '聊天渲染模式', 'settings.openchamber.visual.section.chatRenderModeAria': '聊天渲染模式', @@ -1855,8 +1854,6 @@ export const settingsDict = { 'settings.openchamber.visual.field.mobileKeyboardModeAria': '行動裝置鍵盤行為', 'settings.openchamber.visual.field.selectMobileKeyboardModePlaceholder': '選擇鍵盤行為', '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.interfaceFont': '介面字體', 'settings.openchamber.visual.field.selectInterfaceFontAria': '選擇介面字體', diff --git a/packages/ui/src/lib/mobileLayoutPreference.ts b/packages/ui/src/lib/mobileLayoutPreference.ts deleted file mode 100644 index 10050bf2..00000000 --- a/packages/ui/src/lib/mobileLayoutPreference.ts +++ /dev/null @@ -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; - } -}; diff --git a/packages/ui/src/lib/runtimeSurface.ts b/packages/ui/src/lib/runtimeSurface.ts index dddeaa1d..786a1090 100644 --- a/packages/ui/src/lib/runtimeSurface.ts +++ b/packages/ui/src/lib/runtimeSurface.ts @@ -1,6 +1,5 @@ import { isDesktopShell, isVSCodeRuntime } from '@/lib/desktop'; import { isCapacitorApp } from '@/lib/platform'; -import { getStoredMobileLayoutPreference } from '@/lib/mobileLayoutPreference'; export type HostedSurface = 'desktop' | 'mobile'; @@ -11,6 +10,7 @@ declare global { } const MOBILE_SURFACE_MAX_WIDTH = 768; +const SURFACE_SWITCH_DEBOUNCE_MS = 800; const isTouchOrCoarsePointer = (): boolean => { if (typeof window === 'undefined') return false; @@ -22,12 +22,29 @@ const isTouchOrCoarsePointer = (): boolean => { 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. * * Priority: explicit stamp (set once at boot) → URL override → Capacitor - * shell (always the mobile surface) → desktop shells → phone heuristic - * gated by the stored mobile layout preference. + * shell (always the mobile surface) → desktop shells → phone heuristic. */ const detectHostedSurface = (): HostedSurface => { if (typeof window === 'undefined') return 'desktop'; @@ -45,14 +62,7 @@ const detectHostedSurface = (): HostedSurface => { if (isCapacitorApp()) return 'mobile'; if (isDesktopShell() || isVSCodeRuntime()) return 'desktop'; - const width = Math.min( - 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'; + return isPhoneViewport() ? 'mobile' : 'desktop'; }; /** @@ -69,3 +79,39 @@ export const resolveHostedSurface = (): HostedSurface => { }; 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 | 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); + }; +}; diff --git a/packages/web/src/main.tsx b/packages/web/src/main.tsx index 83e06823..4dd0d7a9 100644 --- a/packages/web/src/main.tsx +++ b/packages/web/src/main.tsx @@ -2,7 +2,7 @@ import { createConfiguredWebAPIs, getDesktopRelayRestoreReady } from './runtimeC import { registerSW } from 'virtual:pwa-register'; 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 { isEmbeddedSessionChat, requestEmbeddedSessionRuntimeBootstrap, @@ -90,6 +90,10 @@ const start = async (): Promise => { : null; 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') { const { renderMobileApp } = await import('@openchamber/ui/apps/renderMobileApp'); renderMobileApp(window.__OPENCHAMBER_RUNTIME_APIS__);