From 24c40b4930478b8c2c80b738860df8e16f1a1ecf Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 7 Jul 2026 01:25:28 +0300 Subject: [PATCH] fix: iOS PWA safe areas, viewport stability, and keyboard/overlay choreography - Add standalone-only safe-area padding for the composer (bottom floor + fullscreen top inset) and top toast offset; env() reports 0 on iOS 26 standalone so a fixed floor is required - Pin the mobile shell to 100lvh: WebKit leaves 100dvh stuck at the keyboard-shrunk value after dismissal - Clamp visual-viewport pinning to documentElement.clientHeight to guard against stale visualViewport metrics - Defer the composer blur flip (120ms) so taps on composer controls survive the keyboard-resize reflow; transition the bottom padding so the late flip reads as a slide, not a dip - Restore the keyboard after mobile overlays close: MobileOverlayPanel dispatches synchronous open/close events, ChatInput refocuses within the same gesture, holds focus through iOS's tap-settle dismissal, guards the pill collapse via DOM focus, and reveals the composer form above the keyboard (programmatic focus skips iOS's native reveal) --- packages/ui/src/components/chat/ChatInput.tsx | 167 +++++++++++++++++- .../src/components/ui/MobileOverlayPanel.tsx | 13 ++ packages/ui/src/styles/mobile.css | 49 +++++ 3 files changed, 225 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index df4dabdb..5fad14e5 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -1016,6 +1016,19 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo // so the chat compensates keyboard + composer height in a single motion. const [mobileComposerExpanded, setMobileComposerExpanded] = React.useState(false); const [mobileTextareaFocused, setMobileTextareaFocused] = React.useState(false); + // Installed PWA (standalone): tapping a composer control while the keyboard + // is up blurs the textarea first, and the keyboard-resize reflow moves the + // control out from under the finger BEFORE iOS synthesizes the click — the + // tap dismisses the keyboard but the control's onClick never fires. Defer + // the blur-driven state flip so the pinned composer holds still through the + // tap; a refocus cancels it. Browser (pan mode) and Capacitor keep the + // immediate flip. + const mobileBlurTimerRef = React.useRef(null); + React.useEffect(() => () => { + if (mobileBlurTimerRef.current !== null) { + window.clearTimeout(mobileBlurTimerRef.current); + } + }, []); const [mobileDictationActive, setMobileDictationActive] = React.useState(false); const [mobileAttachMenuOpen, setMobileAttachMenuOpen] = React.useState(false); const [mobileDraftPicker, setMobileDraftPicker] = React.useState<'project' | 'branch' | null>(null); @@ -4131,6 +4144,74 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo || mobileAttachMenuOpen || issuePickerOpen || prPickerOpen; + // Installed PWA (standalone): a focus() from a bare timeout is outside the + // user gesture and iOS refuses to raise the keyboard for it (Safari + // in-browser is lenient). MobileOverlayPanel dispatches + // 'oc:mobile-overlay-closed' synchronously from the same React flush as + // the click that closed it — refocus right there, while the gesture is + // still live. Chained flows (attach menu → GitHub picker) set the skip ref + // so the keyboard doesn't flash open under the next overlay. + const mobilePickerDialogsOpenRef = React.useRef(false); + mobilePickerDialogsOpenRef.current = issuePickerOpen || prPickerOpen; + const skipNextOverlayCloseRestoreRef = React.useRef(false); + const openSheetCountRef = React.useRef(0); + const holdComposerFocusUntilRef = React.useRef(0); + React.useEffect(() => { + if (!isMobile || isCapacitorApp() || typeof window === 'undefined') return; + if (!window.matchMedia?.('(display-mode: standalone)')?.matches) return; + const handleOverlayOpened = () => { + openSheetCountRef.current += 1; + }; + const handleOverlayClosed = () => { + // Counter instead of a DOM check: the close event fires from a + // layout-effect cleanup, when the closing sheet's portal nodes may + // still be attached — the DOM can't tell "this sheet going away" + // from "another sheet still up". + openSheetCountRef.current = Math.max(0, openSheetCountRef.current - 1); + if (skipNextOverlayCloseRestoreRef.current) { + skipNextOverlayCloseRestoreRef.current = false; + return; + } + if (!restoreKeyboardAfterOverlayRef.current) return; + if (mobilePickerDialogsOpenRef.current) return; + if (openSheetCountRef.current > 0) return; + restoreKeyboardAfterOverlayRef.current = false; + // iOS can still dismiss the freshly-raised keyboard when the tap + // that closed the overlay finishes over non-input content — hold + // focus through that window (see the onBlur guard). + holdComposerFocusUntilRef.current = Date.now() + 600; + textareaRef.current?.focus(); + // The native focus lands mid-commit; React's delegated onFocus may + // not make it into this flush, leaving mobileComposerBusy false for + // a beat — enough for the pill-collapse timer to unmount the + // focused textarea and kill the rising keyboard. Set the state + // explicitly instead of relying on the synthetic event. + if (document.activeElement === textareaRef.current) { + setMobileTextareaFocused(true); + } + // iOS reveals a field above the keyboard only for user-initiated + // focus; a programmatic one leaves the composer parked behind it + // (the chat screen has no viewport pin of its own — the draft + // screen's pinned form ignores these no-op scrolls). Reveal once + // the keyboard has mostly risen, and again after it settles. + const reveal = () => { + const ta = textareaRef.current; + if (!ta || document.activeElement !== ta) return; + // Align the BOTTOM of the whole composer form with the visible + // bottom: 'nearest' on the textarea alone leaves the footer + // icon row parked behind the keyboard accessory bar. + (composerFormRef.current ?? ta).scrollIntoView({ block: 'end' }); + }; + window.setTimeout(reveal, 300); + window.setTimeout(reveal, 650); + }; + window.addEventListener('oc:mobile-overlay-opened', handleOverlayOpened); + window.addEventListener('oc:mobile-overlay-closed', handleOverlayClosed); + return () => { + window.removeEventListener('oc:mobile-overlay-opened', handleOverlayOpened); + window.removeEventListener('oc:mobile-overlay-closed', handleOverlayClosed); + }; + }, [isMobile]); React.useEffect(() => { if (!isMobile) return; if (mobileOverlayOpen) { @@ -4168,6 +4249,10 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo React.useEffect(() => { if (!isMobile || !mobileComposerExpanded || mobileComposerBusy) return; const timer = window.setTimeout(() => { + // Authoritative DOM check: the React focus state can lag a + // programmatic refocus (overlay-close keyboard restore). Collapsing + // would unmount the focused textarea and kill the keyboard. + if (document.activeElement === textareaRef.current) return; mobileExpandIntentRef.current = null; setMobileComposerExpanded(false); setExpandedInput(false); @@ -4189,6 +4274,19 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo root.classList.add('oc-browser-keyboard-open'); } else { root.classList.remove('oc-browser-keyboard-open'); + // Installed PWA (standalone): after the keyboard dismisses, WebKit + // can leave the layout viewport stuck smaller / panned (content + // shifted up with a dead strip at the bottom) until something + // forces it to recompute. A zero scroll after the keyboard's exit + // animation settles snaps it back; harmless when nothing is stuck. + if (window.matchMedia?.('(display-mode: standalone)')?.matches) { + window.setTimeout(() => { + if (root.classList.contains('oc-browser-keyboard-open')) return; + window.scrollTo(0, 0); + document.body.scrollTop = 0; + root.scrollTop = 0; + }, 350); + } } return () => root.classList.remove('oc-browser-keyboard-open'); }, [isMobile, mobileTextareaFocused]); @@ -4272,21 +4370,28 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo // alone — hide the header for the duration via a root class instead. document.documentElement.classList.add('oc-browser-kb-fullscreen'); const apply = () => { + const top = Math.max(0, Math.floor(vv.offsetTop)); + // Same stale-visualViewport guard as the draft pin below: when the + // layout viewport is keyboard-resized (interactive-widget), its + // clientHeight is the authoritative above-keyboard height. + const layoutHeight = document.documentElement.clientHeight; form.style.position = 'fixed'; form.style.left = '0'; form.style.right = '0'; - form.style.top = `${Math.max(0, Math.floor(vv.offsetTop))}px`; - form.style.height = `${Math.floor(vv.height)}px`; + form.style.top = `${top}px`; + form.style.height = `${Math.floor(Math.min(vv.height, layoutHeight - top))}px`; form.style.zIndex = '40'; form.style.background = 'var(--background)'; }; apply(); vv.addEventListener('resize', apply); vv.addEventListener('scroll', apply); + window.addEventListener('resize', apply); window.addEventListener('scroll', apply, true); return () => { vv.removeEventListener('resize', apply); vv.removeEventListener('scroll', apply); + window.removeEventListener('resize', apply); window.removeEventListener('scroll', apply, true); document.documentElement.classList.remove('oc-browser-kb-fullscreen'); form.style.position = ''; @@ -4332,7 +4437,16 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo let lastTop = Number.NaN; let frame = 0; const track = () => { - const top = Math.max(0, Math.floor(vv.offsetTop + vv.height - form.offsetHeight)); + // iOS standalone (PWA) can serve stale visualViewport metrics after + // the keyboard rises (full pre-keyboard height, intermittently), + // parking the form behind the keyboard. When interactive-widget + // resizes the layout viewport, documentElement.clientHeight is the + // true above-keyboard bottom — anchor to whichever is smaller. In + // pan-mode browsers clientHeight stays full height, so the min + // keeps the visual-viewport anchor there. + const layoutBottom = document.documentElement.clientHeight; + const vvBottom = vv.offsetTop + vv.height; + const top = Math.max(0, Math.floor(Math.min(vvBottom, layoutBottom) - form.offsetHeight)); if (top !== lastTop) { lastTop = top; form.style.top = `${top}px`; @@ -5046,13 +5160,54 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo }} onFocus={() => { if (!isMobile) return; + if (mobileBlurTimerRef.current !== null) { + window.clearTimeout(mobileBlurTimerRef.current); + mobileBlurTimerRef.current = null; + } mobileExpandIntentRef.current = null; setMobileTextareaFocused(true); }} onBlur={() => { if (!isMobile) return; + // Focus hold after an overlay-close restore: + // iOS may retract the rising keyboard as the + // closing tap settles — take the focus right + // back instead of accepting the blur. + if (Date.now() < holdComposerFocusUntilRef.current) { + const ta = textareaRef.current; + if (ta) { + ta.focus(); + window.setTimeout(() => { + if (Date.now() < holdComposerFocusUntilRef.current + && document.activeElement !== ta) { + ta.focus(); + } + }, 50); + return; + } + } lastMobileBlurAtRef.current = Date.now(); - setMobileTextareaFocused(false); + const standalone = !isCapacitorApp() + && window.matchMedia?.('(display-mode: standalone)')?.matches; + if (!standalone) { + setMobileTextareaFocused(false); + return; + } + // See mobileBlurTimerRef: hold the pinned + // composer still until the tap's click has + // been delivered. + if (mobileBlurTimerRef.current !== null) { + window.clearTimeout(mobileBlurTimerRef.current); + } + // 120ms is enough to outlive the tap's + // synthesized click (which lands within a + // few ms of the blur) while keeping the + // padding's return visually tied to the + // keyboard dismissal. + mobileBlurTimerRef.current = window.setTimeout(() => { + mobileBlurTimerRef.current = null; + setMobileTextareaFocused(false); + }, 120); }} placeholder={currentSessionId || newSessionDraftOpen ? inputMode === 'shell' @@ -5330,6 +5485,9 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo type="button" className="flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2 py-3 text-left typography-ui-label hover:bg-[var(--interactive-hover)]" onClick={() => { + // Hand-off to the picker: don't sync-restore the + // keyboard under the overlay that opens next frame. + skipNextOverlayCloseRestoreRef.current = true; setMobileAttachMenuOpen(false); requestAnimationFrame(openIssuePicker); }} @@ -5341,6 +5499,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo type="button" className="flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2 py-3 text-left typography-ui-label hover:bg-[var(--interactive-hover)]" onClick={() => { + skipNextOverlayCloseRestoreRef.current = true; setMobileAttachMenuOpen(false); requestAnimationFrame(openPrPicker); }} diff --git a/packages/ui/src/components/ui/MobileOverlayPanel.tsx b/packages/ui/src/components/ui/MobileOverlayPanel.tsx index 373bc6ae..8151f2d9 100644 --- a/packages/ui/src/components/ui/MobileOverlayPanel.tsx +++ b/packages/ui/src/components/ui/MobileOverlayPanel.tsx @@ -72,6 +72,19 @@ export const MobileOverlayPanel: React.FC = ({ }; }, [open]); + // Synchronous close signal: this layout-effect cleanup runs inside the same + // React flush as the user click that closed the panel, so listeners (e.g. + // the keyboard-restore in ChatInput) can refocus an input while iOS still + // considers the gesture active — a deferred focus() would not raise the + // keyboard in an installed PWA. + React.useLayoutEffect(() => { + if (!open) return; + window.dispatchEvent(new Event('oc:mobile-overlay-opened')); + return () => { + window.dispatchEvent(new Event('oc:mobile-overlay-closed')); + }; + }, [open]); + React.useEffect(() => { if (!open) { return; diff --git a/packages/ui/src/styles/mobile.css b/packages/ui/src/styles/mobile.css index c84d6668..ffc4454d 100644 --- a/packages/ui/src/styles/mobile.css +++ b/packages/ui/src/styles/mobile.css @@ -680,6 +680,55 @@ visibility: hidden; } +/* Installed PWA (standalone) safe areas. In the in-browser page Safari's own + chrome covers the home indicator and status bar, and the Capacitor shell has + its own choreography (--oc-app-bottom-safe + keyboard FLIP) — neither matches + display-mode: standalone, so this block is PWA-only and cannot regress them. + Standalone runs the page edge-to-edge (viewport-fit=cover), so the composer + needs real top/bottom safe-area room of its own. */ +@media (display-mode: standalone) { + /* Bottom: reserve home-indicator room under the composer while the keyboard + is down. The generic .bottom-safe-area token is intentionally capped at + 4px (visual tuck under the gradient overlay), which is not enough to keep + controls clear of the indicator / rounded corners. A fixed floor is + required: iOS 26 standalone reports env(safe-area-inset-bottom) as 0. + Cancel the room while the keyboard is up (the indicator area is behind the + keyboard, and the pinned form anchors by its offsetHeight). The class flip + is deliberately delayed (tap race, see ChatInput onBlur) — the transition + turns that late flip into a slide instead of a dip-then-pop. */ + :root:not(.oc-capacitor-app) .oc-mobile-composer { + transition: padding-bottom 220ms cubic-bezier(0.33, 1, 0.68, 1); + } + + :root:not(.oc-capacitor-app):not(.oc-browser-keyboard-open) .oc-mobile-composer { + padding-bottom: calc(0.75rem + max(16px, var(--oc-safe-area-bottom, env(safe-area-inset-bottom, 0px)))) !important; + } + + /* Fullscreen composer: ChatInput pins the form to the visual viewport from + y = visualViewport.offsetTop, which is 0 in standalone (no URL bar), so the + form's first row lands under the status bar / Dynamic Island. Pad the + content down into the safe region instead. */ + :root.oc-browser-kb-fullscreen:not(.oc-capacitor-app) .oc-mobile-composer { + padding-top: calc(var(--oc-safe-area-top, env(safe-area-inset-top, 0px)) + 0.5rem) !important; + } + + /* iOS standalone can leave 100dvh stuck at the keyboard-shrunk value after + the keyboard hides (WebKit doesn't reliably restore the dynamic viewport), + which shifted the whole shell up by that leftover. Standalone has no + collapsing browser chrome, so the dynamic and large viewports are the same + thing — pin the shell to 100lvh, which always reports full screen height. + Initial geometry is identical (lvh == dvh on load). */ + :root:not(.oc-capacitor-app) .h-\[100dvh\] { + height: 100lvh; + } + + /* Top toasts: same offset the Capacitor shell already applies — without it + they render under the clock / Dynamic Island. */ + :root:not(.oc-capacitor-app) [data-sonner-toaster][data-y-position='top'] { + top: calc(var(--oc-safe-area-top, env(safe-area-inset-top, 0px)) + 16px) !important; + } +} + @keyframes oc-composer-morph-fade { from { opacity: 0; } to { opacity: 1; }