feat(mobile): tablet layout pass and foldable-ready size class (#2569)

The tablet ran the phone layout with a half-finished iPad draft on top: two
custom sidebars, a leftover overflow menu, split Files/Changes header buttons,
and phone-width sheets stretched across a 13" screen. This brings it onto the
phone's navigation model and keeps only the differences a large screen earns.

- Sessions are a persistent resizable left sidebar; the overflow menu is gone
  and its destinations moved into that sidebar's footer (connected instance,
  settings, pending web update) and into the workspace drawer.
- The workspace (Changes / Files / Terminal / Notes / MCP) is the phone's
  drawer everywhere: a resizable right sidebar where the screen can host one
  (up to 900px) and the full-cover drawer otherwise, with its mounted panes —
  an open diff, an edited file, an attached terminal — surviving rotation.
- Header dropdowns are anchored popovers: the recents switcher mirrors the
  usage overlay on the left, and its trigger is sized to the title rather than
  to the free width.
- App-level pages (settings, instances, update, an opened plan) render as
  centered dialogs instead of covering the screen.
- Overlays center on the chat column through published insets, so the model
  and directory pickers no longer sit off-centre; the directory picker also
  stops overriding the shared width clamp.
- Wide chat layout applies to mobile surfaces, where a tablet chat column is
  finally wide enough for the setting to mean anything.

The layout gate is a live size class rather than a device check, so Android
tablets and foldables are covered by the same code:

- `enabled` when the shortest viewport side is at le
  sw600dp). The short side is what makes this a size question instead of a
  device question — a phone reports ~360-430 whichev
  unfolded book foldable ~600+, and folding shut drops back under it. iPads
  also answer on identity, since iPadOS hands out od
- `roomyForPanels` when landscape and at least 1000px wide, which is what it
  takes to host the sidebar, the panel and a readabl
  foldables miss it in BOTH orientations — their long side is barely wider
  than a tablet's short one — so they keep the portr

Every consumer re-decides instead of remembering wha
open sidebar closes if the device folds shut under it. iPad behaviour is
unchanged: its landscape widths all clear the panel
ones do not, exactly as the previous orientation check did.

Hardware keyboards are read natively. iOS reports them through GCKeyboard,
published to the web layer at document start and kep
disconnect and foregrounding; the layer stops inferring once that answers. A
single early publish was not enough — the connect no
already-attached keyboard fires before the page exists, and GameController can
populate late — so the state is re-published across
resume. With a keyboard attached the draft screen keeps its starter chips and
the composer never collapses; tablets skip the colla
Runtimes with no native answer fall back to inferring it from the keyboard
bridge, and only ever conclude "hardware" from silen

Also: sidebar rows no longer sit on a differently ti
footer is no longer clipped by an over-tall content box, the resize handles
moved above the panes' own overlays so they can actu
now-unreachable overflow menu, fullscreen terminal/MCP/notes surfaces and their
locale key are deleted.

Device behaviour is unverified — the tablet layout,
keyboard bridge and the foldable size class have not been exercised on
hardware, and the 600/1000 thresholds are derived fr
rather than measured on a foldable.
This commit is contained in:
Bohdan Triapitsyn
2026-08-02 16:25:16 +03:00
committed by GitHub
parent 34d0ff7383
commit 96c011a8ef
32 changed files with 863 additions and 592 deletions
+83 -1
View File
@@ -1,5 +1,6 @@
import React from 'react';
import { isDesktopShell, isVSCodeRuntime } from '@/lib/desktop';
import { isIPadApp } from '@/lib/platform';
import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface';
type DeviceType = 'desktop' | 'mobile' | 'tablet';
@@ -121,7 +122,7 @@ export function getDeviceInfo(): DeviceInfo {
// UI: every component in that tree is built mobile-first, so wide devices
// (iPad, Android tablets, rotated phones) must not fall into
// tablet/desktop branches scattered across shared components.
// iPad-specific layout upgrades gate on isIPadApp()/orientation instead.
// Tablet layout upgrades gate on useTabletLayout() (a size class) instead.
isMobile = true;
isTablet = false;
isDesktop = false;
@@ -364,6 +365,87 @@ export function useOrientation(): Orientation {
return orientation;
}
/**
* Smallest viewport side that earns the tablet layout, following Android's
* long-standing `sw600dp` size class. The SHORT side is what makes this a size
* question rather than a device question: a phone reports ~360-430 whichever
* way it is held, a 7"+ tablet or an unfolded book foldable reports ~600+, and
* a foldable folded shut drops back under it. So a fold is just a resize, and
* nothing here has to know what a foldable is.
*/
const TABLET_LAYOUT_MIN_SHORT_SIDE_PX = 600;
/**
* Width below which the workspace cannot become a side panel: the sessions
* sidebar (~320) plus the panel (~380) plus a chat column that is still worth
* reading. Unfolded book foldables land under this even "landscape", so they
* keep the full-cover drawer in both orientations — which is the whole point,
* their wide side is barely wider than a tablet's narrow one.
*/
const WORKSPACE_PANEL_MIN_WIDTH_PX = 1000;
export interface TabletLayout {
/** Sessions become a persistent sidebar, dropdowns become anchored popovers. */
enabled: boolean;
/** There is room for the workspace beside the chat instead of over it. */
roomyForPanels: boolean;
}
export const readTabletLayout = (): TabletLayout => {
if (typeof window === 'undefined') return { enabled: false, roomyForPanels: false };
const width = window.innerWidth;
const height = window.innerHeight;
// iPads answer this on identity too: iPadOS reports odd viewports in Slide
// Over / Split View, and a device we KNOW is a tablet should not flip to the
// phone layout because it was given a narrow slice.
const enabled = isIPadApp() || Math.min(width, height) >= TABLET_LAYOUT_MIN_SHORT_SIDE_PX;
return {
enabled,
roomyForPanels: enabled && width > height && width >= WORKSPACE_PANEL_MIN_WIDTH_PX,
};
};
/**
* The tablet layout decision, live.
*
* Deliberately a hook over a one-shot check: foldables change size class while
* the app runs, and the Android shell keeps the WebView alive across the fold
* (`configChanges` covers screenSize), so every consumer has to re-decide
* rather than remember what it saw at mount.
*/
export function useTabletLayout(): TabletLayout {
const [layout, setLayout] = React.useState<TabletLayout>(readTabletLayout);
React.useEffect(() => {
if (typeof window === 'undefined') return;
let frame: number | undefined;
const update = () => {
frame = undefined;
const next = readTabletLayout();
setLayout((current) => (
current.enabled === next.enabled && current.roomyForPanels === next.roomyForPanels
? current
: next
));
};
const schedule = () => {
if (frame !== undefined) return;
frame = window.requestAnimationFrame(update);
};
update();
window.addEventListener('resize', schedule);
const orientationQuery = window.matchMedia?.('(orientation: landscape)') ?? null;
const detachOrientation = attachMediaQueryListener(orientationQuery, schedule);
return () => {
window.removeEventListener('resize', schedule);
detachOrientation();
if (frame !== undefined) window.cancelAnimationFrame(frame);
};
}, []);
return layout;
}
export function useDeviceInfo(): DeviceInfo {
return React.useSyncExternalStore(
subscribeDeviceInfo,
+157
View File
@@ -0,0 +1,157 @@
/**
* "Is a hardware keyboard attached?" — the input the mobile layout uses to
* decide whether a soft keyboard will ever eat the screen.
*
* Two sources, in priority order:
*
* 1. The native answer. On iOS the shell reads `GCKeyboard` and stamps
* `window.__OPENCHAMBER_HARDWARE_KEYBOARD__` at document start, then keeps it
* live via `oc:hardware-keyboard` (see BridgeViewController). This is
* authoritative and — crucially — known BEFORE the user focuses anything, so
* the draft screen and composer start in the right shape instead of
* re-laying-out after the first focus.
* 2. Inference, for runtimes with no native answer (Android, hosted mobile).
* A `keyboardWillShow` with a real height means there IS a soft keyboard; a
* tiny height means only iOS' shortcut strip; focus with no event at all
* within a short window means nothing was presented. Inference is ignored
* entirely once the native source has spoken.
*
* Everything else stays `false`, which is the safe default: the layout then
* behaves exactly as it does on a phone.
*
* In memory only — a keyboard can be attached and detached while the app runs,
* and both sources re-answer the question continuously.
*/
import React from 'react';
/** Below this the "keyboard" is only iOS' shortcut bar, not a real keyboard. */
const SOFTWARE_KEYBOARD_MIN_HEIGHT_PX = 120;
/** iOS starts its keyboard animation well inside this window after focus. */
const KEYBOARD_EVENT_GRACE_MS = 600;
declare global {
interface Window {
__OPENCHAMBER_HARDWARE_KEYBOARD__?: boolean;
}
}
// Read at module init, not just from the bridge effect: the stamp exists from
// document start, and the very first render of the draft screen / composer must
// already see it — otherwise the layout still settles one frame late.
const initialNativeAnswer = typeof window !== 'undefined'
&& typeof window.__OPENCHAMBER_HARDWARE_KEYBOARD__ === 'boolean'
? window.__OPENCHAMBER_HARDWARE_KEYBOARD__
: null;
let hardwareKeyboardAttached = initialNativeAnswer === true;
let hasNativeAnswer = initialNativeAnswer !== null;
let focusProbeTimer: number | null = null;
let bridgeStarted = false;
const subscribers = new Set<() => void>();
if (hardwareKeyboardAttached && typeof document !== 'undefined') {
document.documentElement.classList.add('oc-hardware-keyboard');
}
const clearFocusProbe = (): void => {
if (focusProbeTimer === null) return;
window.clearTimeout(focusProbeTimer);
focusProbeTimer = null;
};
const setHardwareKeyboardAttached = (value: boolean): void => {
if (hardwareKeyboardAttached === value) return;
hardwareKeyboardAttached = value;
if (typeof document !== 'undefined') {
document.documentElement.classList.toggle('oc-hardware-keyboard', value);
}
for (const listener of subscribers) listener();
};
/**
* Adopt the native shell's answer and stop inferring. Idempotent; safe to call
* before the shell has stamped anything (then it is a no-op and inference
* stays in charge).
*/
export const startHardwareKeyboardBridge = (): (() => void) => {
if (typeof window === 'undefined') return () => {};
const adopt = (value: boolean) => {
hasNativeAnswer = true;
clearFocusProbe();
setHardwareKeyboardAttached(value);
};
if (typeof window.__OPENCHAMBER_HARDWARE_KEYBOARD__ === 'boolean') {
adopt(window.__OPENCHAMBER_HARDWARE_KEYBOARD__);
}
if (bridgeStarted) return () => {};
bridgeStarted = true;
const handleNativeChange = (event: Event) => {
const detail = (event as CustomEvent<{ attached?: boolean }>).detail;
adopt(detail?.attached === true);
};
window.addEventListener('oc:hardware-keyboard', handleNativeChange);
return () => {
window.removeEventListener('oc:hardware-keyboard', handleNativeChange);
bridgeStarted = false;
};
};
/**
* Feed a native `keyboardWillShow` height in. Called by the Capacitor keyboard
* bridge (see `mobileNativeChrome`) on both platforms. An arriving event always
* settles the question, so it cancels any pending focus probe.
*/
export const observeNativeKeyboardHeight = (heightPx: number): void => {
if (hasNativeAnswer || !Number.isFinite(heightPx)) return;
clearFocusProbe();
setHardwareKeyboardAttached(heightPx > 0 && heightPx < SOFTWARE_KEYBOARD_MIN_HEIGHT_PX);
};
/**
* Report that an editor just took focus. If no keyboard event follows, nothing
* was presented — which means a hardware keyboard is attached.
*
* Deliberately one-directional within the window: only the SILENCE concludes
* "hardware". A real `keyboardWillShow` cancels the probe above, so a slow
* keyboard can never be misread.
*/
export const observeEditorFocus = (): void => {
if (hasNativeAnswer || typeof window === 'undefined' || typeof document === 'undefined') return;
// A soft keyboard already up sends no second `keyboardWillShow` — a refocus
// through it (the overlay-close keyboard restore) would look like silence.
if (document.documentElement.classList.contains('oc-keyboard-open')) return;
clearFocusProbe();
focusProbeTimer = window.setTimeout(() => {
focusProbeTimer = null;
setHardwareKeyboardAttached(true);
}, KEYBOARD_EVENT_GRACE_MS);
};
/** Drop the inferred state when the native bridge tears down. */
export const resetHardwareKeyboardDetection = (): void => {
clearFocusProbe();
if (hasNativeAnswer) return;
setHardwareKeyboardAttached(false);
};
export const isHardwareKeyboardAttached = (): boolean => hardwareKeyboardAttached;
export const subscribeHardwareKeyboard = (listener: () => void): (() => void) => {
subscribers.add(listener);
return () => {
subscribers.delete(listener);
};
};
export function useHardwareKeyboard(): boolean {
return React.useSyncExternalStore(
subscribeHardwareKeyboard,
isHardwareKeyboardAttached,
() => false,
);
}
-1
View File
@@ -90,7 +90,6 @@ export const dict = {
'mobile.nav.changes': 'Changes',
'mobile.nav.settings': 'Settings',
'mobile.surface.closeAria': 'Close',
'mobile.header.openMenuAria': 'Open menu',
'mobile.header.openWorkspaceAria': 'Open workspace panel',
'mobile.header.openMetadataAria': 'Open session metadata',
'mobile.header.metadata.context': 'Context',
-1
View File
@@ -91,7 +91,6 @@ export const dict: Record<I18nKey, string> = {
"mobile.nav.changes": "Cambios",
"mobile.nav.settings": "Ajustes",
"mobile.surface.closeAria": "Cerrar",
"mobile.header.openMenuAria": "Abrir menú",
"mobile.header.openWorkspaceAria": "Abrir panel de trabajo",
"mobile.header.openMetadataAria": "Abrir metadatos de la sesión",
"mobile.header.metadata.context": "Contexto",
-1
View File
@@ -2699,7 +2699,6 @@ export const dict = {
'mobile.nav.changes': 'Modifications',
'mobile.nav.settings': 'Paramètres',
'mobile.surface.closeAria': 'Fermer',
'mobile.header.openMenuAria': 'Ouvrir le menu',
'mobile.header.openWorkspaceAria': 'Ouvrir le panneau de travail',
'mobile.header.openMetadataAria': 'Ouvrir les métadonnées de session',
'mobile.header.metadata.context': 'Contexte',
-1
View File
@@ -92,7 +92,6 @@ export const dict: Record<I18nKey, string> = {
'mobile.instances.confirmDeleteAria': '{label} の削除を確定',
'mobile.instances.cancelDeleteAria': '{label} を残す',
'mobile.surface.closeAria': '閉じる',
'mobile.header.openMenuAria': 'メニューを開く',
'mobile.header.openWorkspaceAria': 'ワークスペースパネルを開く',
'mobile.header.openMetadataAria': 'セッションメタデータを開く',
'mobile.header.metadata.context': 'コンテキスト',
-1
View File
@@ -91,7 +91,6 @@ export const dict: Record<I18nKey, string> = {
'mobile.nav.changes': '변경사항',
'mobile.nav.settings': '설정',
'mobile.surface.closeAria': '닫기',
'mobile.header.openMenuAria': '메뉴 열기',
'mobile.header.openWorkspaceAria': '작업 공간 패널 열기',
'mobile.header.openMetadataAria': '세션 메타데이터 열기',
'mobile.header.metadata.context': '컨텍스트',
-1
View File
@@ -92,7 +92,6 @@ export const dict: Record<I18nKey, string> = {
'mobile.nav.changes': 'Zmiany',
'mobile.nav.settings': 'Ustawienia',
'mobile.surface.closeAria': 'Zamknij',
'mobile.header.openMenuAria': 'Otwórz menu',
'mobile.header.openWorkspaceAria': 'Otwórz panel roboczy',
'mobile.header.openMetadataAria': 'Otwórz metadane sesji',
'mobile.header.metadata.context': 'Kontekst',
@@ -91,7 +91,6 @@ export const dict: Record<I18nKey, string> = {
"mobile.nav.changes": "Alterações",
"mobile.nav.settings": "Configurações",
"mobile.surface.closeAria": "Fechar",
"mobile.header.openMenuAria": "Abrir menu",
"mobile.header.openWorkspaceAria": "Abrir painel de trabalho",
"mobile.header.openMetadataAria": "Abrir metadados da sessão",
"mobile.header.metadata.context": "Contexto",
-1
View File
@@ -91,7 +91,6 @@ export const dict: Record<I18nKey, string> = {
"mobile.nav.changes": "Зміни",
"mobile.nav.settings": "Налаштування",
"mobile.surface.closeAria": "Закрити",
"mobile.header.openMenuAria": "Відкрити меню",
"mobile.header.openWorkspaceAria": "Відкрити робочу панель",
"mobile.header.openMetadataAria": "Відкрити метадані сесії",
"mobile.header.metadata.context": "Контекст",
@@ -91,7 +91,6 @@ export const dict: Record<I18nKey, string> = {
'mobile.nav.changes': '更改',
'mobile.nav.settings': '设置',
'mobile.surface.closeAria': '关闭',
'mobile.header.openMenuAria': '打开菜单',
'mobile.header.openWorkspaceAria': '打开工作区面板',
'mobile.header.openMetadataAria': '打开会话元数据',
'mobile.header.metadata.context': '上下文',
@@ -91,7 +91,6 @@ export const dict: Record<I18nKey, string> = {
'mobile.nav.changes': '變更',
'mobile.nav.settings': '設定',
'mobile.surface.closeAria': '關閉',
'mobile.header.openMenuAria': '開啟選單',
'mobile.header.openWorkspaceAria': '開啟工作區面板',
'mobile.header.openMetadataAria': '開啟工作階段中繼資料',
'mobile.header.metadata.context': '上下文',
+57
View File
@@ -0,0 +1,57 @@
import { afterEach, describe, expect, test } from 'bun:test';
import { readTabletLayout, type TabletLayout } from './device';
// No module mocking here on purpose: mock.module is process-global and would
// leak into every other test file. Outside a Capacitor shell isIPadApp() is
// already false, so a bare viewport stub isolates the geometry rules.
const originalWindow = globalThis.window;
const setViewport = (width: number, height: number) => {
(globalThis as { window?: unknown }).window = {
innerWidth: width,
innerHeight: height,
// isIPadApp() reaches for the Capacitor markers; a plain web location
// keeps it on its `false` path without mocking the module.
location: { protocol: 'https:', search: '' },
};
};
const withViewport = (width: number, height: number): TabletLayout => {
setViewport(width, height);
return readTabletLayout();
};
afterEach(() => {
(globalThis as { window?: unknown }).window = originalWindow;
});
describe('readTabletLayout', () => {
test('a phone stays a phone in both orientations', () => {
expect(withViewport(390, 844).enabled).toBe(false);
// The long side alone must never qualify — this is the case a plain
// width threshold gets wrong.
expect(withViewport(844, 390).enabled).toBe(false);
});
test('a tablet qualifies in both orientations', () => {
expect(withViewport(834, 1194).enabled).toBe(true);
expect(withViewport(1194, 834).enabled).toBe(true);
});
test('side panels need real width, so a tablet in portrait keeps the drawer', () => {
expect(withViewport(834, 1194).roomyForPanels).toBe(false);
expect(withViewport(1194, 834).roomyForPanels).toBe(true);
});
test('an unfolded foldable is a tablet but never roomy enough for panels', () => {
// Book foldables are near-square: the long side is barely wider than a
// tablet's short one, so both orientations keep the portrait layout.
expect(withViewport(690, 840)).toEqual({ enabled: true, roomyForPanels: false });
expect(withViewport(840, 690)).toEqual({ enabled: true, roomyForPanels: false });
});
test('folding shut drops back to the phone layout', () => {
expect(withViewport(370, 900).enabled).toBe(false);
});
});