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
@@ -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',
@@ -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',
@@ -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",
@@ -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 licône du Dock de macOS le nombre de discussions avec une activité non vue.',
'settings.openchamber.visual.actions.saveAndRestart': 'Enregistrer et redémarrer',
@@ -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': '密度と書体',
@@ -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': '밀도 및 서체',
@@ -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',
@@ -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",
@@ -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": "Щільність і шрифти",
@@ -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': '密度与字体',
@@ -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': '選擇介面字體',
@@ -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 { 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<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);
};
};