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:
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user