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": {
"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",
},
@@ -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<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 & {
__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<OpenChamberVisualSettingsProps>
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<OpenChamberVisualSettingsProps>
? [...terminalLoginShells.filter((shell) => shell !== terminalShell), terminalShell]
: terminalLoginShells.filter((shell) => shell !== terminalShell));
};
const [mobileLayoutPreference, setMobileLayoutPreference] = React.useState<MobileLayoutPreference>(() => 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<OpenChamberVisualSettingsProps>
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<OpenChamberVisualSettingsProps>
))}
</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 className={SETTINGS_FIELDS_STACK_CLASS}>
@@ -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);
};
};
+5 -1
View File
@@ -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<void> => {
: 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__);