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)
This commit is contained in:
Bohdan Triapitsyn
2026-07-07 01:25:28 +03:00
parent 182fc89b8d
commit 24c40b4930
3 changed files with 225 additions and 4 deletions
+163 -4
View File
@@ -1016,6 +1016,19 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ 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<number | null>(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<ChatInputProps> = ({ 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<ChatInputProps> = ({ 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<ChatInputProps> = ({ 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<ChatInputProps> = ({ 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<ChatInputProps> = ({ 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<ChatInputProps> = ({ 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<ChatInputProps> = ({ 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<ChatInputProps> = ({ 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);
}}
@@ -72,6 +72,19 @@ export const MobileOverlayPanel: React.FC<MobileOverlayPanelProps> = ({
};
}, [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;
+49
View File
@@ -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; }