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:
committed by
GitHub
parent
3d32ac7989
commit
ac93a52e21
@@ -27,7 +27,9 @@ import { useUpdatePolling } from '@/hooks/useUpdatePolling';
|
|||||||
import { useWindowTitle } from '@/hooks/useWindowTitle';
|
import { useWindowTitle } from '@/hooks/useWindowTitle';
|
||||||
import { opencodeClient } from '@/lib/opencode/client';
|
import { opencodeClient } from '@/lib/opencode/client';
|
||||||
import type { ProjectEntry, RuntimeAPIs } from '@/lib/api/types';
|
import type { ProjectEntry, RuntimeAPIs } from '@/lib/api/types';
|
||||||
|
import { useOrientation } from '@/lib/device';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { isIPadApp } from '@/lib/platform';
|
||||||
import { resolveProjectForDirectory, resolveProjectForSessionDirectory } from '@/lib/projectResolution';
|
import { resolveProjectForDirectory, resolveProjectForSessionDirectory } from '@/lib/projectResolution';
|
||||||
import { clampPercent, formatQuotaResetLabel, formatQuotaValueLabel, formatWindowLabel, QUOTA_PROVIDERS, resolveUsageTone } from '@/lib/quota';
|
import { clampPercent, formatQuotaResetLabel, formatQuotaValueLabel, formatWindowLabel, QUOTA_PROVIDERS, resolveUsageTone } from '@/lib/quota';
|
||||||
import { getDisplayModelName } from '@/lib/quota/model-families';
|
import { getDisplayModelName } from '@/lib/quota/model-families';
|
||||||
@@ -86,6 +88,120 @@ type MobileAppProps = {
|
|||||||
apis: RuntimeAPIs;
|
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 => {
|
const isCapacitorMobileApp = (): boolean => {
|
||||||
if (typeof window === 'undefined') return false;
|
if (typeof window === 'undefined') return false;
|
||||||
const maybeCapacitor = (window as typeof window & {
|
const maybeCapacitor = (window as typeof window & {
|
||||||
@@ -1197,6 +1313,43 @@ const SessionMetadataOverlay: React.FC<{
|
|||||||
const panelRef = React.useRef<HTMLDivElement>(null);
|
const panelRef = React.useRef<HTMLDivElement>(null);
|
||||||
const [shouldRender, setShouldRender] = React.useState(open);
|
const [shouldRender, setShouldRender] = React.useState(open);
|
||||||
const [isExiting, setIsExiting] = React.useState(false);
|
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(() => {
|
React.useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
@@ -1247,18 +1400,26 @@ const SessionMetadataOverlay: React.FC<{
|
|||||||
if (!shouldRender) return null;
|
if (!shouldRender) return null;
|
||||||
|
|
||||||
return (
|
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
|
<div
|
||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-label={t('mobile.header.openMetadataAria')}
|
aria-label={t('mobile.header.openMetadataAria')}
|
||||||
className={cn(
|
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',
|
isExiting ? 'pointer-events-none' : 'pointer-events-auto',
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
animation: `${isExiting ? 'session-metadata-out' : 'session-metadata-in'} ${isExiting ? 140 : 170}ms cubic-bezier(0.32, 0.72, 0, 1) forwards`,
|
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))',
|
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">
|
<div className="space-y-1">
|
||||||
@@ -1380,7 +1541,10 @@ const MobileOverflowMenu: React.FC<{
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
items: OverflowItem[];
|
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();
|
const { t } = useI18n();
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@@ -1402,9 +1566,12 @@ const MobileOverflowMenu: React.FC<{
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
/>
|
/>
|
||||||
<div
|
<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"
|
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) => (
|
{items.map((item, index) => (
|
||||||
<button
|
<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<{
|
const MobileHeader: React.FC<{
|
||||||
onOpenSessions: () => void;
|
onOpenSessions: () => void;
|
||||||
onOpenMenu: () => 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 { t } = useI18n();
|
||||||
const [metadataOpen, setMetadataOpen] = React.useState(false);
|
const [metadataOpen, setMetadataOpen] = React.useState(false);
|
||||||
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||||
@@ -1750,6 +1926,44 @@ const MobileHeader: React.FC<{
|
|||||||
secondaryLabel={secondaryLabel}
|
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
|
<button
|
||||||
type="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"
|
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 gitStatus = useGitStatus(normalizePath(currentDirectory) || null);
|
||||||
const dirtyChangeCount = gitStatus?.files?.length ?? 0;
|
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>(
|
const mobileActions = React.useMemo<MobileAppActions>(
|
||||||
() => ({
|
() => ({
|
||||||
openChanges: ({ diffPath, staged } = {}) => {
|
openChanges: ({ diffPath, staged } = {}) => {
|
||||||
setPendingChangesDiff(diffPath ? { path: diffPath, staged: staged === true } : null);
|
openChangesSurface(diffPath ? { path: diffPath, staged: staged === true } : null);
|
||||||
setChangesOpen(true);
|
|
||||||
},
|
},
|
||||||
openFiles: () => setFilesOpen(true),
|
openFiles: () => openFilesSurface(),
|
||||||
openSettings: () => {
|
openSettings: () => {
|
||||||
setSettingsInitialMobileStage('nav');
|
setSettingsInitialMobileStage('nav');
|
||||||
setSettingsOpen(true);
|
setSettingsOpen(true);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[],
|
[openChangesSurface, openFilesSurface],
|
||||||
);
|
);
|
||||||
|
|
||||||
const closeChanges = React.useCallback(() => {
|
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.
|
// new-session intents resolve directly against the store, so they aren't wired here.
|
||||||
const deepLinkHandlers = React.useMemo(
|
const deepLinkHandlers = React.useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
openSessions: () => setSessionsSheetOpen(true),
|
openSessions: () => {
|
||||||
|
if (isIPad) setIpadSidebarOpen(true);
|
||||||
|
else setSessionsSheetOpen(true);
|
||||||
|
},
|
||||||
openView: (target: 'files' | 'mcp' | 'instances' | 'update') => {
|
openView: (target: 'files' | 'mcp' | 'instances' | 'update') => {
|
||||||
if (target === 'files') setFilesOpen(true);
|
if (target === 'files') openFilesSurface();
|
||||||
else if (target === 'mcp') setMcpOpen(true);
|
else if (target === 'mcp') setMcpOpen(true);
|
||||||
else if (target === 'instances') setInstancesOpen(true);
|
else if (target === 'instances') setInstancesOpen(true);
|
||||||
else if (target === 'update') setUpdateOpen(true);
|
else if (target === 'update') setUpdateOpen(true);
|
||||||
},
|
},
|
||||||
openChanges: ({ path, staged }: { path?: string; staged?: boolean } = {}) => {
|
openChanges: ({ path, staged }: { path?: string; staged?: boolean } = {}) => {
|
||||||
setPendingChangesDiff(path ? { path, staged: staged === true } : null);
|
openChangesSurface(path ? { path, staged: staged === true } : null);
|
||||||
setChangesOpen(true);
|
|
||||||
},
|
},
|
||||||
openSettings: (section?: string) => {
|
openSettings: (section?: string) => {
|
||||||
if (section) setSettingsPage(section as Parameters<typeof setSettingsPage>[0]);
|
if (section) setSettingsPage(section as Parameters<typeof setSettingsPage>[0]);
|
||||||
@@ -1834,7 +2118,7 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc
|
|||||||
setSettingsOpen(true);
|
setSettingsOpen(true);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[setSettingsPage],
|
[isIPad, openChangesSurface, openFilesSurface, setSettingsPage],
|
||||||
);
|
);
|
||||||
useDeepLinkHandlers(deepLinkHandlers);
|
useDeepLinkHandlers(deepLinkHandlers);
|
||||||
|
|
||||||
@@ -1957,27 +2241,31 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc
|
|||||||
|
|
||||||
const overflowItems: OverflowItem[] = React.useMemo(
|
const overflowItems: OverflowItem[] = React.useMemo(
|
||||||
() => {
|
() => {
|
||||||
const items: OverflowItem[] = [
|
const items: OverflowItem[] = [];
|
||||||
{
|
// iPad exposes Files/Changes as header shortcuts instead of menu items.
|
||||||
key: 'files',
|
if (!isIPad) {
|
||||||
icon: 'file-text',
|
items.push(
|
||||||
label: t('mobile.menu.files'),
|
{
|
||||||
onSelect: () => setFilesOpen(true),
|
key: 'files',
|
||||||
},
|
icon: 'file-text',
|
||||||
{
|
label: t('mobile.menu.files'),
|
||||||
key: 'changes',
|
onSelect: () => openFilesSurface(),
|
||||||
icon: 'git-branch',
|
},
|
||||||
label: t('mobile.menu.changes'),
|
{
|
||||||
badge: dirtyChangeCount,
|
key: 'changes',
|
||||||
onSelect: () => setChangesOpen(true),
|
icon: 'git-branch',
|
||||||
},
|
label: t('mobile.menu.changes'),
|
||||||
{
|
badge: dirtyChangeCount,
|
||||||
|
onSelect: () => openChangesSurface(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
items.push({
|
||||||
key: 'mcp',
|
key: 'mcp',
|
||||||
iconNode: <McpIcon className="size-5 shrink-0 text-muted-foreground" />,
|
iconNode: <McpIcon className="size-5 shrink-0 text-muted-foreground" />,
|
||||||
label: t('mobile.menu.mcp'),
|
label: t('mobile.menu.mcp'),
|
||||||
onSelect: () => setMcpOpen(true),
|
onSelect: () => setMcpOpen(true),
|
||||||
},
|
});
|
||||||
];
|
|
||||||
if (showCapacitorOnlyFeatures) {
|
if (showCapacitorOnlyFeatures) {
|
||||||
items.push({
|
items.push({
|
||||||
key: 'instances',
|
key: 'instances',
|
||||||
@@ -2005,31 +2293,153 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc
|
|||||||
});
|
});
|
||||||
return items;
|
return items;
|
||||||
},
|
},
|
||||||
[dirtyChangeCount, showCapacitorOnlyFeatures, showUpdateItem, t],
|
[dirtyChangeCount, isIPad, openChangesSurface, openFilesSurface, showCapacitorOnlyFeatures, showUpdateItem, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DedicatedMobileAppProvider actions={mobileActions}>
|
<DedicatedMobileAppProvider actions={mobileActions}>
|
||||||
<div
|
<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"
|
data-page-scroll-lock="true"
|
||||||
>
|
>
|
||||||
<MobileHeader
|
{/* iPad: persistent full-height sessions sidebar; the chat column and
|
||||||
onOpenSessions={() => setSessionsSheetOpen(true)}
|
its header butt against it (iPadOS-style split layout). Always
|
||||||
onOpenMenu={() => setOverflowOpen(true)}
|
mounted so open/close animates width, same as the desktop Sidebar. */}
|
||||||
/>
|
{isIPad ? (
|
||||||
<main ref={chatMainRef} className="relative min-h-0 flex-1 overflow-hidden" data-page-scroll-lock="true">
|
<aside
|
||||||
<div ref={chatAnimRef} className="h-full w-full">
|
ref={leftResize.asideRef}
|
||||||
<ErrorBoundary>
|
className={cn(
|
||||||
<ChatView />
|
'relative flex h-full shrink-0 flex-col overflow-hidden border-r border-border/50 bg-sidebar will-change-[width] motion-reduce:transition-none',
|
||||||
</ErrorBoundary>
|
!ipadSidebarOpen && 'border-r-0',
|
||||||
</div>
|
)}
|
||||||
</main>
|
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
|
<MobileOverflowMenu
|
||||||
open={overflowOpen}
|
open={overflowOpen}
|
||||||
onClose={() => setOverflowOpen(false)}
|
onClose={() => setOverflowOpen(false)}
|
||||||
items={overflowItems}
|
items={overflowItems}
|
||||||
|
rightOffset={isIPad && ipadRightPanel ? rightResize.width : 0}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{sessionsSheetOpen ? (
|
{sessionsSheetOpen ? (
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ import { MobileSurfaceShell } from './MobileSurfaceShell';
|
|||||||
type MobileSessionsSheetProps = {
|
type MobileSessionsSheetProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
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 = {
|
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 { t } = useI18n();
|
||||||
const { git } = useRuntimeAPIs();
|
const { git } = useRuntimeAPIs();
|
||||||
const liveSessions = useAllLiveSessions();
|
const liveSessions = useAllLiveSessions();
|
||||||
@@ -999,14 +1002,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
|
|||||||
</>
|
</>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
return (
|
const surfaceContent = (
|
||||||
<MobileSurfaceShell
|
|
||||||
open={open}
|
|
||||||
onClose={() => onOpenChange(false)}
|
|
||||||
ariaLabel={t('mobile.sessions.sheet.title')}
|
|
||||||
title={t('mobile.sessions.sheet.title')}
|
|
||||||
trailing={trailingActions}
|
|
||||||
>
|
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<div className={cn('shrink-0 px-4 pb-2 pt-1', editingOrder && 'hidden')}>
|
<div className={cn('shrink-0 px-4 pb-2 pt-1', editingOrder && 'hidden')}>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -1294,6 +1290,34 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
|
|||||||
onWorktreesChanged={() => setWorktreeRefreshKey((value) => value + 1)}
|
onWorktreesChanged={() => setWorktreeRefreshKey((value) => value + 1)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</MobileSurfaceShell>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -556,6 +556,16 @@ const ComposerAttachmentControls = React.memo(function ComposerAttachmentControl
|
|||||||
type="button"
|
type="button"
|
||||||
className={footerIconButtonClass}
|
className={footerIconButtonClass}
|
||||||
onClick={props.onOpenMobileSheet}
|
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')}
|
title={t('chat.chatInput.actions.addAttachment')}
|
||||||
aria-label={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]);
|
}, [newSessionDraftOpen, openNewSessionDraft, currentDirectory]);
|
||||||
|
|
||||||
const openMobileAttachSheet = React.useCallback(() => {
|
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);
|
setMobileAttachMenuOpen(true);
|
||||||
|
textareaRef.current?.blur();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const mobileComposerExpandedRef = React.useRef(mobileComposerExpanded);
|
const mobileComposerExpandedRef = React.useRef(mobileComposerExpanded);
|
||||||
|
|||||||
@@ -22,6 +22,16 @@ export const MobileModelButton: React.FC<MobileModelButtonProps> = ({ onOpenMode
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onOpenModel}
|
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(
|
className={cn(
|
||||||
'inline-flex min-w-0 items-stretch',
|
'inline-flex min-w-0 items-stretch',
|
||||||
'rounded-lg',
|
'rounded-lg',
|
||||||
|
|||||||
@@ -386,7 +386,15 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
const [isAgentSelectorOpen, setIsAgentSelectorOpen] = React.useState(false);
|
const [isAgentSelectorOpen, setIsAgentSelectorOpen] = React.useState(false);
|
||||||
const { favoriteModelsList, recentModelsList } = useModelLists();
|
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 isDesktop = React.useMemo(() => isDesktopShell(), []);
|
||||||
const isVSCodeRuntime = useIsVSCodeRuntime();
|
const isVSCodeRuntime = useIsVSCodeRuntime();
|
||||||
// Only use mobile panels on actual mobile devices, VSCode uses desktop dropdowns
|
// Only use mobile panels on actual mobile devices, VSCode uses desktop dropdowns
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { isDesktopShell, isVSCodeRuntime } from '@/lib/desktop';
|
import { isDesktopShell, isVSCodeRuntime } from '@/lib/desktop';
|
||||||
|
import { isCapacitorApp } from '@/lib/platform';
|
||||||
|
|
||||||
type DeviceType = 'desktop' | 'mobile' | 'tablet';
|
type DeviceType = 'desktop' | 'mobile' | 'tablet';
|
||||||
|
|
||||||
@@ -122,6 +123,15 @@ export function getDeviceInfo(): DeviceInfo {
|
|||||||
isTablet = false;
|
isTablet = false;
|
||||||
isDesktop = true;
|
isDesktop = true;
|
||||||
deviceType = 'desktop';
|
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) {
|
} else if (isMobile) {
|
||||||
deviceType = 'mobile';
|
deviceType = 'mobile';
|
||||||
} else if (isTablet) {
|
} else if (isTablet) {
|
||||||
@@ -348,6 +358,27 @@ export function useTabletStandalonePwaRuntime(): boolean {
|
|||||||
return value;
|
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 {
|
export function useDeviceInfo(): DeviceInfo {
|
||||||
return React.useSyncExternalStore(
|
return React.useSyncExternalStore(
|
||||||
subscribeDeviceInfo,
|
subscribeDeviceInfo,
|
||||||
|
|||||||
@@ -7,6 +7,21 @@ export const isCapacitorApp = (): boolean => {
|
|||||||
return capacitor?.isNativePlatform?.() === true || window.location.protocol === 'capacitor:';
|
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';
|
export type ClientPlatform = 'ios' | 'android' | 'vscode' | 'desktop' | 'web';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user