From ac93a52e21ed0cada6cf8e2d3564ee2a7c95ef99 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 8 Jul 2026 21:52:18 +0300 Subject: [PATCH] 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 --- packages/ui/src/apps/MobileApp.tsx | 500 ++++++++++++++++-- packages/ui/src/apps/MobileSessionsSheet.tsx | 42 +- packages/ui/src/components/chat/ChatInput.tsx | 15 + .../src/components/chat/MobileModelButton.tsx | 10 + .../ui/src/components/chat/ModelControls.tsx | 10 +- packages/ui/src/lib/device.ts | 31 ++ packages/ui/src/lib/platform.ts | 15 + 7 files changed, 568 insertions(+), 55 deletions(-) diff --git a/packages/ui/src/apps/MobileApp.tsx b/packages/ui/src/apps/MobileApp.tsx index 9ac0f421..e4a07fcd 100644 --- a/packages/ui/src/apps/MobileApp.tsx +++ b/packages/ui/src/apps/MobileApp.tsx @@ -27,7 +27,9 @@ import { useUpdatePolling } from '@/hooks/useUpdatePolling'; import { useWindowTitle } from '@/hooks/useWindowTitle'; import { opencodeClient } from '@/lib/opencode/client'; import type { ProjectEntry, RuntimeAPIs } from '@/lib/api/types'; +import { useOrientation } from '@/lib/device'; import { useI18n } from '@/lib/i18n'; +import { isIPadApp } from '@/lib/platform'; import { resolveProjectForDirectory, resolveProjectForSessionDirectory } from '@/lib/projectResolution'; import { clampPercent, formatQuotaResetLabel, formatQuotaValueLabel, formatWindowLabel, QUOTA_PROVIDERS, resolveUsageTone } from '@/lib/quota'; import { getDisplayModelName } from '@/lib/quota/model-families'; @@ -86,6 +88,120 @@ type MobileAppProps = { apis: RuntimeAPIs; }; +const IPAD_LEFT_SIDEBAR_WIDTH = 320; +const IPAD_RIGHT_SIDEBAR_WIDTH = 380; +const IPAD_SIDEBAR_MIN_WIDTH = 280; +const IPAD_SIDEBAR_MAX_WIDTH = 560; +const IPAD_METADATA_POPOVER_WIDTH = 380; + +/** Drag-resize for the iPad sidebars: same live-width mechanics as the desktop + Sidebar (imperative styles during the drag, committed to state at the end), + but with a finger-sized grab strip instead of a 3px hover handle. */ +function useIpadSidebarResize(side: 'left' | 'right', storageKey: string, defaultWidth: number) { + const asideRef = React.useRef(null); + const [width, setWidth] = React.useState(() => { + if (typeof window === 'undefined') return defaultWidth; + const stored = Number.parseInt(window.localStorage.getItem(storageKey) ?? '', 10); + if (!Number.isFinite(stored)) return defaultWidth; + return Math.min(IPAD_SIDEBAR_MAX_WIDTH, Math.max(IPAD_SIDEBAR_MIN_WIDTH, stored)); + }); + const [isResizing, setIsResizing] = React.useState(false); + const startXRef = React.useRef(0); + const startWidthRef = React.useRef(width); + const liveWidthRef = React.useRef(null); + const pointerIdRef = React.useRef(null); + + const clampWidth = React.useCallback((value: number) => ( + Math.min(IPAD_SIDEBAR_MAX_WIDTH, Math.max(IPAD_SIDEBAR_MIN_WIDTH, Math.round(value))) + ), []); + + const applyLiveWidth = React.useCallback((nextWidth: number) => { + const aside = asideRef.current; + if (!aside) return; + aside.style.width = `${nextWidth}px`; + aside.style.minWidth = `${nextWidth}px`; + aside.style.maxWidth = `${nextWidth}px`; + aside.style.setProperty('--oc-ipad-sidebar-width', `${nextWidth}px`); + }, []); + + const handlePointerDown = React.useCallback((event: React.PointerEvent) => { + try { + event.currentTarget.setPointerCapture(event.pointerId); + } catch { + // ignore + } + pointerIdRef.current = event.pointerId; + startXRef.current = event.clientX; + startWidthRef.current = width; + liveWidthRef.current = width; + setIsResizing(true); + event.preventDefault(); + }, [width]); + + const handlePointerMove = React.useCallback((event: React.PointerEvent) => { + if (pointerIdRef.current !== event.pointerId) return; + const delta = event.clientX - startXRef.current; + const next = clampWidth(startWidthRef.current + (side === 'left' ? delta : -delta)); + if (liveWidthRef.current === next) return; + liveWidthRef.current = next; + applyLiveWidth(next); + }, [applyLiveWidth, clampWidth, side]); + + const handlePointerEnd = React.useCallback((event: React.PointerEvent) => { + if (pointerIdRef.current !== event.pointerId) return; + try { + event.currentTarget.releasePointerCapture(event.pointerId); + } catch { + // ignore + } + const finalWidth = clampWidth(liveWidthRef.current ?? startWidthRef.current); + pointerIdRef.current = null; + liveWidthRef.current = null; + setIsResizing(false); + setWidth(finalWidth); + try { + window.localStorage.setItem(storageKey, String(finalWidth)); + } catch { + // ignore + } + }, [clampWidth, storageKey]); + + const handleProps = React.useMemo(() => ({ + onPointerDown: handlePointerDown, + onPointerMove: handlePointerMove, + onPointerUp: handlePointerEnd, + onPointerCancel: handlePointerEnd, + }), [handlePointerDown, handlePointerEnd, handlePointerMove]); + + return { asideRef, width, isResizing, handleProps }; +} + +const IpadSidebarResizeHandle: React.FC<{ + side: 'left' | 'right'; + isResizing: boolean; + ariaLabel: string; + handleProps: React.HTMLAttributes; +}> = ({ side, isResizing, ariaLabel, handleProps }) => ( +
+
+
+); + const isCapacitorMobileApp = (): boolean => { if (typeof window === 'undefined') return false; const maybeCapacitor = (window as typeof window & { @@ -1197,6 +1313,43 @@ const SessionMetadataOverlay: React.FC<{ const panelRef = React.useRef(null); const [shouldRender, setShouldRender] = React.useState(open); const [isExiting, setIsExiting] = React.useState(false); + // iPad: a phone-width sheet stretched across the whole chat column looks + // broken — render a popover anchored to the metadata button instead. + const isIPad = React.useMemo(() => isIPadApp(), []); + const wrapperRef = React.useRef(null); + const [ipadAnchorLeft, setIpadAnchorLeft] = React.useState(null); + + // The shell has transformed ancestors, so the fixed wrapper's containing + // block is the chat column, NOT the viewport. Anchor the popover in the + // wrapper's own coordinate space — viewport-based lefts would double-count + // the sidebar offset. + React.useLayoutEffect(() => { + if (!open || !isIPad || !shouldRender) return; + const compute = () => { + const anchorRect = anchorRef.current?.getBoundingClientRect(); + const wrapperRect = wrapperRef.current?.getBoundingClientRect(); + if (!anchorRect || !wrapperRect) { + setIpadAnchorLeft(null); + return; + } + const relativeLeft = anchorRect.left - wrapperRect.left; + const left = Math.min( + Math.max(relativeLeft, 8), + Math.max(8, wrapperRect.width - IPAD_METADATA_POPOVER_WIDTH - 8), + ); + setIpadAnchorLeft(left); + }; + compute(); + // Re-anchor if the chat column shifts while the popover is open (sidebar + // toggle/resize, orientation change) — the header buttons move with it. + const wrapper = wrapperRef.current; + if (typeof ResizeObserver === 'undefined' || !wrapper) return; + const observer = new ResizeObserver(compute); + observer.observe(wrapper); + return () => observer.disconnect(); + }, [anchorRef, isIPad, open, shouldRender]); + + const ipadPopover = isIPad && ipadAnchorLeft !== null; React.useEffect(() => { if (open) { @@ -1247,18 +1400,26 @@ const SessionMetadataOverlay: React.FC<{ if (!shouldRender) return null; return ( -
+
@@ -1380,7 +1541,10 @@ const MobileOverflowMenu: React.FC<{ open: boolean; onClose: () => void; items: OverflowItem[]; -}> = ({ open, onClose, items }) => { + /** Extra viewport-right inset so the dropdown stays anchored to the + three-dots button when the iPad right sidebar shifts the header. */ + rightOffset?: number; +}> = ({ open, onClose, items, rightOffset = 0 }) => { const { t } = useI18n(); React.useEffect(() => { if (!open) return; @@ -1402,9 +1566,12 @@ const MobileOverflowMenu: React.FC<{ onClick={onClose} />
{items.map((item, index) => ( + + + ) : null} +