feat: iPad split layout for the Capacitor app (#2104)

* fix: open mobile model/agent panels on tablet-width Capacitor shells and keep composer taps from dismissing the keyboard

* feat: add iPadOS-style split layout to the Capacitor app

- classify the Capacitor shell as mobile in device detection so shared
  surfaces (draft starters, panels) stop falling into tablet branches
- add isIPadApp() and useOrientation() helpers
- iPad: persistent full-height sessions sidebar (mobile sessions surface
  inline), Changes/Files in a right sidebar with header shortcut toggles
- animate sidebar open/close like the desktop sidebars and add
  finger-sized drag-resize with persisted widths
- anchor the overflow menu and the usage/metadata popover next to their
  header buttons regardless of open sidebars

* fix: re-anchor metadata popover on layout shifts and untangle sidebar toggle updates

- recompute the iPad metadata popover anchor via a ResizeObserver on its
  wrapper so sidebar toggles/resizes while it is open cannot leave it
  misplaced
- move the portrait right-panel close out of the setIpadSidebarOpen
  updater into plain sequential state updates
This commit is contained in:
Bohdan Triapitsyn
2026-07-08 21:52:18 +03:00
committed by GitHub
parent 3d32ac7989
commit ac93a52e21
7 changed files with 568 additions and 55 deletions
+31
View File
@@ -1,5 +1,6 @@
import React from 'react';
import { isDesktopShell, isVSCodeRuntime } from '@/lib/desktop';
import { isCapacitorApp } from '@/lib/platform';
type DeviceType = 'desktop' | 'mobile' | 'tablet';
@@ -122,6 +123,15 @@ export function getDeviceInfo(): DeviceInfo {
isTablet = false;
isDesktop = true;
deviceType = 'desktop';
} else if (isCapacitorApp()) {
// The Capacitor shell IS the phone UI: every surface in that bundle is
// built mobile-first, so wide devices (iPad, Android tablets) must not
// fall into tablet/desktop branches scattered across shared components.
// iPad-specific layout upgrades gate on isIPadApp()/orientation instead.
isMobile = true;
isTablet = false;
isDesktop = false;
deviceType = 'mobile';
} else if (isMobile) {
deviceType = 'mobile';
} else if (isTablet) {
@@ -348,6 +358,27 @@ export function useTabletStandalonePwaRuntime(): boolean {
return value;
}
export type Orientation = 'portrait' | 'landscape';
const getOrientation = (): Orientation => {
if (typeof window === 'undefined') return 'portrait';
return window.matchMedia?.('(orientation: landscape)')?.matches ? 'landscape' : 'portrait';
};
export function useOrientation(): Orientation {
const [orientation, setOrientation] = React.useState<Orientation>(getOrientation);
React.useEffect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
const query = window.matchMedia('(orientation: landscape)');
const update = () => setOrientation(query.matches ? 'landscape' : 'portrait');
update();
return attachMediaQueryListener(query, update);
}, []);
return orientation;
}
export function useDeviceInfo(): DeviceInfo {
return React.useSyncExternalStore(
subscribeDeviceInfo,
+15
View File
@@ -7,6 +7,21 @@ export const isCapacitorApp = (): boolean => {
return capacitor?.isNativePlatform?.() === true || window.location.protocol === 'capacitor:';
};
/**
* True when running inside the native Capacitor shell on an iPad.
* Capacitor reports 'ios' for both iPhone and iPad; iPadOS WKWebView
* masquerades as macOS Safari, so the only reliable tell is a Mac-like
* platform with real touch points (or a legacy explicit iPad UA).
*/
export const isIPadApp = (): boolean => {
if (typeof window === 'undefined' || !isCapacitorApp()) return false;
if (getClientPlatform() !== 'ios') return false;
const userAgent = navigator.userAgent || '';
const maxTouchPoints = navigator.maxTouchPoints ?? 0;
return /iPad/i.test(userAgent)
|| (/Macintosh|MacIntel/i.test(userAgent) && maxTouchPoints > 1);
};
export type ClientPlatform = 'ios' | 'android' | 'vscode' | 'desktop' | 'web';
/**