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;