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
+455 -45
View File
@@ -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<HTMLElement | null>(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<number | null>(null);
const pointerIdRef = React.useRef<number | null>(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<HTMLDivElement>;
}> = ({ side, isResizing, ariaLabel, handleProps }) => (
<div
className={cn(
'absolute inset-y-0 z-30 w-6 cursor-col-resize touch-none',
side === 'left' ? 'right-0' : 'left-0',
)}
role="separator"
aria-orientation="vertical"
aria-label={ariaLabel}
{...handleProps}
>
<div
className={cn(
'absolute inset-y-0 w-[3px] transition-colors',
side === 'left' ? 'right-0' : 'left-0',
isResizing && 'bg-[var(--interactive-border)]',
)}
/>
</div>
);
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<HTMLDivElement>(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<HTMLDivElement>(null);
const [ipadAnchorLeft, setIpadAnchorLeft] = React.useState<number | null>(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 (
<div className="fixed inset-x-0 bottom-0 top-[calc(var(--oc-safe-area-top,0px)+var(--oc-header-height,56px))] z-20 pointer-events-none">
<div ref={wrapperRef} className="fixed inset-x-0 bottom-0 top-[calc(var(--oc-safe-area-top,0px)+var(--oc-header-height,56px))] z-20 pointer-events-none">
<div
ref={panelRef}
role="dialog"
aria-label={t('mobile.header.openMetadataAria')}
className={cn(
'mx-3 mt-2 overflow-y-auto overscroll-contain rounded-[20px] border border-border/40 bg-[var(--surface-elevated)] p-2 shadow-[0_12px_32px_rgb(0_0_0_/_0.2)] will-change-transform',
'overflow-y-auto overscroll-contain rounded-[20px] border border-border/40 bg-[var(--surface-elevated)] p-2 shadow-[0_12px_32px_rgb(0_0_0_/_0.2)] will-change-transform',
ipadPopover ? 'absolute origin-top-left' : 'mx-3 mt-2',
isExiting ? 'pointer-events-none' : 'pointer-events-auto',
)}
style={{
animation: `${isExiting ? 'session-metadata-out' : 'session-metadata-in'} ${isExiting ? 140 : 170}ms cubic-bezier(0.32, 0.72, 0, 1) forwards`,
maxHeight: 'min(72dvh, calc(100dvh - var(--oc-safe-area-top, 0px) - var(--oc-header-height, 56px) - 1rem))',
...(ipadPopover
? {
top: 8,
left: ipadAnchorLeft ?? 8,
width: `min(${IPAD_METADATA_POPOVER_WIDTH}px, calc(100% - 16px))`,
}
: null),
}}
>
<div className="space-y-1">
@@ -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}
/>
<div
className="absolute right-2 top-[calc(var(--oc-safe-area-top,0px)+56px+4px)] w-[min(220px,calc(100vw-1rem))] origin-top-right overflow-hidden rounded-2xl border border-border/40 bg-background shadow-[0_18px_60px_rgb(0_0_0_/_0.35)]"
className="absolute top-[calc(var(--oc-safe-area-top,0px)+56px+4px)] w-[min(220px,calc(100vw-1rem))] origin-top-right overflow-hidden rounded-2xl border border-border/40 bg-background shadow-[0_18px_60px_rgb(0_0_0_/_0.35)]"
role="menu"
style={{ animation: 'mobile-menu-in 160ms cubic-bezier(0.32, 0.72, 0, 1)' }}
style={{
right: `${8 + rightOffset}px`,
animation: 'mobile-menu-in 160ms cubic-bezier(0.32, 0.72, 0, 1)',
}}
>
{items.map((item, index) => (
<button
@@ -1673,10 +1840,19 @@ const MobileSessionMetadataButton = React.memo(function MobileSessionMetadataBut
);
});
type MobileHeaderSurfaceShortcuts = {
activePanel: 'files' | 'changes' | null;
changesDirty: boolean;
onToggleFiles: () => void;
onToggleChanges: () => void;
};
const MobileHeader: React.FC<{
onOpenSessions: () => void;
onOpenMenu: () => void;
}> = ({ onOpenSessions, onOpenMenu }) => {
/** iPad only: Files/Changes header shortcuts that toggle the right sidebar. */
surfaceShortcuts?: MobileHeaderSurfaceShortcuts;
}> = ({ onOpenSessions, onOpenMenu, surfaceShortcuts }) => {
const { t } = useI18n();
const [metadataOpen, setMetadataOpen] = React.useState(false);
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
@@ -1750,6 +1926,44 @@ const MobileHeader: React.FC<{
secondaryLabel={secondaryLabel}
/>
{surfaceShortcuts ? (
<>
<button
type="button"
className={cn(
'flex size-10 shrink-0 items-center justify-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
surfaceShortcuts.activePanel === 'files'
? 'bg-[var(--interactive-selection)] text-[var(--interactive-selectionForeground)]'
: 'text-muted-foreground hover:bg-interactive-hover hover:text-foreground',
)}
aria-label={t('mobile.menu.files')}
aria-pressed={surfaceShortcuts.activePanel === 'files'}
onClick={surfaceShortcuts.onToggleFiles}
style={{ touchAction: 'manipulation' }}
>
<Icon name="file-text" className="size-5" />
</button>
<button
type="button"
className={cn(
'relative flex size-10 shrink-0 items-center justify-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
surfaceShortcuts.activePanel === 'changes'
? 'bg-[var(--interactive-selection)] text-[var(--interactive-selectionForeground)]'
: 'text-muted-foreground hover:bg-interactive-hover hover:text-foreground',
)}
aria-label={t('mobile.menu.changes')}
aria-pressed={surfaceShortcuts.activePanel === 'changes'}
onClick={surfaceShortcuts.onToggleChanges}
style={{ touchAction: 'manipulation' }}
>
<Icon name="git-branch" className="size-5" />
{surfaceShortcuts.changesDirty ? (
<span className="absolute right-2 top-2 inline-flex size-2 rounded-full bg-primary" aria-hidden />
) : null}
</button>
</>
) : null}
<button
type="button"
className="flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
@@ -1792,19 +2006,87 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc
const gitStatus = useGitStatus(normalizePath(currentDirectory) || null);
const dirtyChangeCount = gitStatus?.files?.length ?? 0;
// iPad (Capacitor): sessions live in a persistent full-height left sidebar
// and Changes/Files in a right sidebar, instead of phone sheets/surfaces.
const isIPad = React.useMemo(() => isIPadApp(), []);
const orientation = useOrientation();
const isPortrait = orientation === 'portrait';
const [ipadSidebarOpen, setIpadSidebarOpen] = React.useState(isIPad && !isPortrait);
const [ipadRightPanel, setIpadRightPanel] = React.useState<'files' | 'changes' | null>(null);
const toggleIpadSidebar = React.useCallback(() => {
const willOpen = !ipadSidebarOpen;
// Portrait doesn't fit both side panels next to a usable chat column:
// opening one closes the other (iPadOS behaves the same way).
if (willOpen && isPortrait) setIpadRightPanel(null);
setIpadSidebarOpen(willOpen);
}, [ipadSidebarOpen, isPortrait]);
const openFilesSurface = React.useCallback(() => {
if (isIPad) {
setPendingChangesDiff(null);
setIpadRightPanel('files');
if (isPortrait) setIpadSidebarOpen(false);
return;
}
setFilesOpen(true);
}, [isIPad, isPortrait]);
const openChangesSurface = React.useCallback((diff: { path: string; staged: boolean } | null = null) => {
setPendingChangesDiff(diff);
if (isIPad) {
setIpadRightPanel('changes');
if (isPortrait) setIpadSidebarOpen(false);
return;
}
setChangesOpen(true);
}, [isIPad, isPortrait]);
const closeIpadRightPanel = React.useCallback(() => {
setIpadRightPanel(null);
setPendingChangesDiff(null);
}, []);
const toggleIpadRightPanel = React.useCallback((panel: 'files' | 'changes') => {
if (ipadRightPanel === panel) {
closeIpadRightPanel();
return;
}
if (panel === 'files') openFilesSurface();
else openChangesSurface();
}, [closeIpadRightPanel, ipadRightPanel, openChangesSurface, openFilesSurface]);
// Keep the right panel's content mounted through the width-collapse
// animation; drop it once the panel is fully closed.
const lastIpadRightPanelRef = React.useRef<'files' | 'changes'>('changes');
if (ipadRightPanel) lastIpadRightPanelRef.current = ipadRightPanel;
const [ipadRightContentMounted, setIpadRightContentMounted] = React.useState(false);
React.useEffect(() => {
if (!isIPad) return;
if (ipadRightPanel) {
setIpadRightContentMounted(true);
return;
}
const id = window.setTimeout(() => setIpadRightContentMounted(false), 240);
return () => window.clearTimeout(id);
}, [ipadRightPanel, isIPad]);
const renderedIpadRightPanel = ipadRightPanel ?? lastIpadRightPanelRef.current;
const leftResize = useIpadSidebarResize('left', 'openchamber.ipad.leftSidebarWidth', IPAD_LEFT_SIDEBAR_WIDTH);
const rightResize = useIpadSidebarResize('right', 'openchamber.ipad.rightSidebarWidth', IPAD_RIGHT_SIDEBAR_WIDTH);
const mobileActions = React.useMemo<MobileAppActions>(
() => ({
openChanges: ({ diffPath, staged } = {}) => {
setPendingChangesDiff(diffPath ? { path: diffPath, staged: staged === true } : null);
setChangesOpen(true);
openChangesSurface(diffPath ? { path: diffPath, staged: staged === true } : null);
},
openFiles: () => setFilesOpen(true),
openFiles: () => openFilesSurface(),
openSettings: () => {
setSettingsInitialMobileStage('nav');
setSettingsOpen(true);
},
}),
[],
[openChangesSurface, openFilesSurface],
);
const closeChanges = React.useCallback(() => {
@@ -1817,16 +2099,18 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc
// new-session intents resolve directly against the store, so they aren't wired here.
const deepLinkHandlers = React.useMemo(
() => ({
openSessions: () => setSessionsSheetOpen(true),
openSessions: () => {
if (isIPad) setIpadSidebarOpen(true);
else setSessionsSheetOpen(true);
},
openView: (target: 'files' | 'mcp' | 'instances' | 'update') => {
if (target === 'files') setFilesOpen(true);
if (target === 'files') openFilesSurface();
else if (target === 'mcp') setMcpOpen(true);
else if (target === 'instances') setInstancesOpen(true);
else if (target === 'update') setUpdateOpen(true);
},
openChanges: ({ path, staged }: { path?: string; staged?: boolean } = {}) => {
setPendingChangesDiff(path ? { path, staged: staged === true } : null);
setChangesOpen(true);
openChangesSurface(path ? { path, staged: staged === true } : null);
},
openSettings: (section?: string) => {
if (section) setSettingsPage(section as Parameters<typeof setSettingsPage>[0]);
@@ -1834,7 +2118,7 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc
setSettingsOpen(true);
},
}),
[setSettingsPage],
[isIPad, openChangesSurface, openFilesSurface, setSettingsPage],
);
useDeepLinkHandlers(deepLinkHandlers);
@@ -1957,27 +2241,31 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc
const overflowItems: OverflowItem[] = React.useMemo(
() => {
const items: OverflowItem[] = [
{
key: 'files',
icon: 'file-text',
label: t('mobile.menu.files'),
onSelect: () => setFilesOpen(true),
},
{
key: 'changes',
icon: 'git-branch',
label: t('mobile.menu.changes'),
badge: dirtyChangeCount,
onSelect: () => setChangesOpen(true),
},
{
const items: OverflowItem[] = [];
// iPad exposes Files/Changes as header shortcuts instead of menu items.
if (!isIPad) {
items.push(
{
key: 'files',
icon: 'file-text',
label: t('mobile.menu.files'),
onSelect: () => openFilesSurface(),
},
{
key: 'changes',
icon: 'git-branch',
label: t('mobile.menu.changes'),
badge: dirtyChangeCount,
onSelect: () => openChangesSurface(),
},
);
}
items.push({
key: 'mcp',
iconNode: <McpIcon className="size-5 shrink-0 text-muted-foreground" />,
label: t('mobile.menu.mcp'),
onSelect: () => setMcpOpen(true),
},
];
});
if (showCapacitorOnlyFeatures) {
items.push({
key: 'instances',
@@ -2005,31 +2293,153 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc
});
return items;
},
[dirtyChangeCount, showCapacitorOnlyFeatures, showUpdateItem, t],
[dirtyChangeCount, isIPad, openChangesSurface, openFilesSurface, showCapacitorOnlyFeatures, showUpdateItem, t],
);
return (
<DedicatedMobileAppProvider actions={mobileActions}>
<div
className="oc-mobile-app-shell main-content-safe-area flex h-[100dvh] flex-col bg-background text-foreground"
className="oc-mobile-app-shell main-content-safe-area flex h-[100dvh] flex-row bg-background text-foreground"
data-page-scroll-lock="true"
>
<MobileHeader
onOpenSessions={() => setSessionsSheetOpen(true)}
onOpenMenu={() => setOverflowOpen(true)}
/>
<main ref={chatMainRef} className="relative min-h-0 flex-1 overflow-hidden" data-page-scroll-lock="true">
<div ref={chatAnimRef} className="h-full w-full">
<ErrorBoundary>
<ChatView />
</ErrorBoundary>
</div>
</main>
{/* iPad: persistent full-height sessions sidebar; the chat column and
its header butt against it (iPadOS-style split layout). Always
mounted so open/close animates width, same as the desktop Sidebar. */}
{isIPad ? (
<aside
ref={leftResize.asideRef}
className={cn(
'relative flex h-full shrink-0 flex-col overflow-hidden border-r border-border/50 bg-sidebar will-change-[width] motion-reduce:transition-none',
!ipadSidebarOpen && 'border-r-0',
)}
style={{
width: ipadSidebarOpen ? leftResize.width : 0,
minWidth: ipadSidebarOpen ? leftResize.width : 0,
maxWidth: ipadSidebarOpen ? leftResize.width : 0,
['--oc-ipad-sidebar-width' as string]: `${leftResize.width}px`,
overflowX: 'clip',
paddingTop: 'var(--oc-safe-area-top, 0px)',
transitionProperty: leftResize.isResizing ? 'none' : 'width, min-width, max-width',
transitionDuration: '200ms',
transitionTimingFunction: 'cubic-bezier(0.22, 1, 0.36, 1)',
}}
aria-hidden={!ipadSidebarOpen}
data-page-scroll-lock="true"
>
{ipadSidebarOpen ? (
<IpadSidebarResizeHandle
side="left"
isResizing={leftResize.isResizing}
ariaLabel={t('sidebar.resize.leftPanelAria')}
handleProps={leftResize.handleProps}
/>
) : null}
<div
className={cn(
'flex h-full shrink-0 flex-col transition-opacity duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none',
leftResize.isResizing && 'pointer-events-none',
!ipadSidebarOpen && 'pointer-events-none select-none opacity-0',
)}
style={{ width: 'var(--oc-ipad-sidebar-width)', overflowX: 'hidden' }}
>
<ErrorBoundary>
<MobileSessionsSheet
open
variant="sidebar"
// The surface asks to close after picking a session/project or
// creating a worktree; the persistent landscape sidebar stays
// put, portrait gives the space back to the chat.
onOpenChange={(value) => {
if (!value && isPortrait) setIpadSidebarOpen(false);
}}
/>
</ErrorBoundary>
</div>
</aside>
) : null}
<div className="flex h-full min-w-0 flex-1 flex-col" data-page-scroll-lock="true">
<MobileHeader
onOpenSessions={() => (isIPad ? toggleIpadSidebar() : setSessionsSheetOpen(true))}
onOpenMenu={() => setOverflowOpen(true)}
surfaceShortcuts={isIPad ? {
activePanel: ipadRightPanel,
changesDirty: dirtyChangeCount > 0,
onToggleFiles: () => toggleIpadRightPanel('files'),
onToggleChanges: () => toggleIpadRightPanel('changes'),
} : undefined}
/>
<main ref={chatMainRef} className="relative min-h-0 flex-1 overflow-hidden" data-page-scroll-lock="true">
<div ref={chatAnimRef} className="h-full w-full">
<ErrorBoundary>
<ChatView />
</ErrorBoundary>
</div>
</main>
</div>
{/* iPad: Changes/Files live in a full-height right sidebar instead of
the phone's fullscreen surfaces. Width animates like the desktop
RightSidebar; content stays mounted through the collapse. */}
{isIPad ? (
<aside
ref={rightResize.asideRef}
className={cn(
'relative flex h-full shrink-0 flex-col overflow-hidden border-l border-border/50 bg-background will-change-[width] motion-reduce:transition-none',
!ipadRightPanel && 'border-l-0',
)}
style={{
width: ipadRightPanel ? rightResize.width : 0,
minWidth: ipadRightPanel ? rightResize.width : 0,
maxWidth: ipadRightPanel ? rightResize.width : 0,
['--oc-ipad-sidebar-width' as string]: `${rightResize.width}px`,
overflowX: 'clip',
paddingTop: 'var(--oc-safe-area-top, 0px)',
transitionProperty: rightResize.isResizing ? 'none' : 'width, min-width, max-width',
transitionDuration: '200ms',
transitionTimingFunction: 'cubic-bezier(0.22, 1, 0.36, 1)',
}}
aria-hidden={!ipadRightPanel}
data-page-scroll-lock="true"
>
{ipadRightPanel ? (
<IpadSidebarResizeHandle
side="right"
isResizing={rightResize.isResizing}
ariaLabel={t('sidebar.resize.rightPanelAria')}
handleProps={rightResize.handleProps}
/>
) : null}
<div
className={cn(
'flex h-full shrink-0 flex-col transition-opacity duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none',
rightResize.isResizing && 'pointer-events-none',
!ipadRightPanel && 'pointer-events-none select-none opacity-0',
)}
style={{ width: 'var(--oc-ipad-sidebar-width)', overflowX: 'hidden' }}
>
{ipadRightContentMounted ? (
<ErrorBoundary>
{renderedIpadRightPanel === 'files' ? (
<MobileFilesSurface onClose={closeIpadRightPanel} />
) : (
<MobileChangesSurface
onClose={closeIpadRightPanel}
initialDiffPath={pendingChangesDiff?.path ?? null}
initialDiffStaged={pendingChangesDiff?.staged === true}
/>
)}
</ErrorBoundary>
) : null}
</div>
</aside>
) : null}
<MobileOverflowMenu
open={overflowOpen}
onClose={() => setOverflowOpen(false)}
items={overflowItems}
rightOffset={isIPad && ipadRightPanel ? rightResize.width : 0}
/>
{sessionsSheetOpen ? (
+33 -9
View File
@@ -60,6 +60,9 @@ import { MobileSurfaceShell } from './MobileSurfaceShell';
type MobileSessionsSheetProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
/** 'sheet' (default) wraps the content in the swipe-dismiss MobileSurfaceShell;
'sidebar' renders the same content inline for the iPad persistent sidebar. */
variant?: 'sheet' | 'sidebar';
};
type ProjectMeta = {
@@ -511,7 +514,7 @@ const SortableProjectRow: React.FC<{
);
};
export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open, onOpenChange }) => {
export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open, onOpenChange, variant = 'sheet' }) => {
const { t } = useI18n();
const { git } = useRuntimeAPIs();
const liveSessions = useAllLiveSessions();
@@ -999,14 +1002,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
</>
) : null;
return (
<MobileSurfaceShell
open={open}
onClose={() => onOpenChange(false)}
ariaLabel={t('mobile.sessions.sheet.title')}
title={t('mobile.sessions.sheet.title')}
trailing={trailingActions}
>
const surfaceContent = (
<div className="flex h-full flex-col">
<div className={cn('shrink-0 px-4 pb-2 pt-1', editingOrder && 'hidden')}>
<div className="relative">
@@ -1294,6 +1290,34 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
onWorktreesChanged={() => setWorktreeRefreshKey((value) => value + 1)}
/>
</div>
);
if (variant === 'sidebar') {
if (!open) return null;
return (
<div className="flex h-full min-h-0 flex-col">
<div className="flex h-[var(--oc-header-height,56px)] shrink-0 items-center justify-between gap-2 border-b border-border/30 px-4">
<h2 className="truncate typography-ui-label font-semibold text-foreground">
{t('mobile.sessions.sheet.title')}
</h2>
{trailingActions ? (
<div className="flex shrink-0 items-center gap-2">{trailingActions}</div>
) : null}
</div>
{surfaceContent}
</div>
);
}
return (
<MobileSurfaceShell
open={open}
onClose={() => onOpenChange(false)}
ariaLabel={t('mobile.sessions.sheet.title')}
title={t('mobile.sessions.sheet.title')}
trailing={trailingActions}
>
{surfaceContent}
</MobileSurfaceShell>
);
};
@@ -556,6 +556,16 @@ const ComposerAttachmentControls = React.memo(function ComposerAttachmentControl
type="button"
className={footerIconButtonClass}
onClick={props.onOpenMobileSheet}
// Same guard as PermissionAutoAcceptButton: keep the tap
// from dismissing the keyboard. On Android's
// resizes-content viewport the keyboard-close relayout
// moves this button mid-tap and the click never lands.
onMouseDown={(event) => event.preventDefault()}
onPointerDownCapture={(event) => {
if (event.pointerType === 'touch') {
event.preventDefault();
}
}}
title={t('chat.chatInput.actions.addAttachment')}
aria-label={t('chat.chatInput.actions.addAttachment')}
>
@@ -4087,7 +4097,12 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
}, [newSessionDraftOpen, openNewSessionDraft, currentDirectory]);
const openMobileAttachSheet = React.useCallback(() => {
// Same order as handleOpenMobilePanel: mark the sheet open BEFORE the
// blur so the collapse watcher sees an overlay when the keyboard-close
// lands. The trigger button blocks the tap's own focus transfer, so
// the keyboard must be dismissed explicitly here.
setMobileAttachMenuOpen(true);
textareaRef.current?.blur();
}, []);
const mobileComposerExpandedRef = React.useRef(mobileComposerExpanded);
@@ -22,6 +22,16 @@ export const MobileModelButton: React.FC<MobileModelButtonProps> = ({ onOpenMode
<button
type="button"
onClick={onOpenModel}
// Same guard as PermissionAutoAcceptButton/MobileAgentButton: block
// the focus transfer so the tap doesn't dismiss the keyboard. With
// interactive-widget=resizes-content (Android), the keyboard-close
// relayout moves this button mid-tap and the click never lands.
onMouseDown={(event) => event.preventDefault()}
onPointerDownCapture={(event) => {
if (event.pointerType === 'touch') {
event.preventDefault();
}
}}
className={cn(
'inline-flex min-w-0 items-stretch',
'rounded-lg',
@@ -386,7 +386,15 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
const [isAgentSelectorOpen, setIsAgentSelectorOpen] = React.useState(false);
const { favoriteModelsList, recentModelsList } = useModelLists();
const { isMobile } = useDeviceInfo();
const { isMobile: deviceIsMobile } = useDeviceInfo();
// The composer decides whether it renders the mobile layout from the UI
// store (the Capacitor shell forces it true even on tablets/iPad, where
// useDeviceInfo classifies the wide screen as non-mobile). The bottom-sheet
// panels must follow the SAME source: with the device flag alone, tapping
// the model/agent chip on an iPad set the panel state while the sheet
// itself rendered null.
const uiIsMobile = useUIStore((state) => state.isMobile);
const isMobile = deviceIsMobile || uiIsMobile;
const isDesktop = React.useMemo(() => isDesktopShell(), []);
const isVSCodeRuntime = useIsVSCodeRuntime();
// Only use mobile panels on actual mobile devices, VSCode uses desktop dropdowns
+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';
/**