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} +