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
@@ -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