fix(mobile): browser composer keyboard handling on chat and draft screens
Mobile browsers don't shrink the layout for the keyboard, so the fullscreen composer is now pinned to the visual viewport (fixed at its offset and height, tracked as the browser pans) instead of overflowing underneath it; the draft screen's normal composer gets the same pinning anchored to the visible bottom via a rAF tracker, since Safari's own focused-field reveal proved unreliable there after leaving fullscreen. The draft and empty-session roots drop their transform-gpu (a transform would make them the containing block for the pinned form), the app header hides while the browser fullscreen composer is up (the form can't out-stack it from inside the composer wrapper's stacking context), and leaving fullscreen nudges the still-focused field back into view. Draft starter chips now hide while the keyboard is open in browsers too, via an oc-browser-keyboard-open root class driven by composer focus.
This commit is contained in:
@@ -1705,7 +1705,7 @@ const MobileHeader: React.FC<{
|
||||
return (
|
||||
<>
|
||||
<header
|
||||
className="relative z-30 flex shrink-0 items-center gap-1 border-b border-border/30 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80"
|
||||
className="oc-mobile-header relative z-30 flex shrink-0 items-center gap-1 border-b border-border/30 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80"
|
||||
style={{ paddingTop: 'var(--oc-safe-area-top, 0px)' }}
|
||||
>
|
||||
<div className="flex h-[var(--oc-header-height,56px)] w-full items-center gap-1 px-2">
|
||||
|
||||
@@ -806,7 +806,10 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
|
||||
if (!currentSessionId && draftOpen) {
|
||||
return (
|
||||
<div className="relative flex h-full flex-col bg-background transform-gpu">
|
||||
// No transform on this root: it would become the containing block for
|
||||
// the fullscreen composer's position:fixed visual-viewport pinning in
|
||||
// mobile browsers (see ChatInput's composerFormRef effect).
|
||||
<div className="relative flex h-full flex-col bg-background">
|
||||
{useCompactDraftLayout && !isDesktopExpandedInput ? (
|
||||
<div className="oc-draft-center flex min-h-0 flex-1 flex-col items-center justify-center px-6 text-center">
|
||||
<h1 className="text-balance text-3xl font-normal tracking-tight text-foreground">
|
||||
@@ -901,7 +904,9 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
|
||||
if (sessionMessages.length === 0 && !sessionIsWorking) {
|
||||
return (
|
||||
<div className="relative flex flex-col h-full bg-background transform-gpu">
|
||||
// No transform here either — same fixed-positioning constraint as the
|
||||
// draft branch above.
|
||||
<div className="relative flex flex-col h-full bg-background">
|
||||
{returnToParentButton}
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -4167,6 +4167,21 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
const mobileComposerBusyRef = React.useRef(false);
|
||||
mobileComposerBusyRef.current = mobileComposerBusy;
|
||||
|
||||
// Browser counterpart of Capacitor's oc-keyboard-open root class (which is
|
||||
// driven by native keyboard events): the focused composer textarea is the
|
||||
// best keyboard proxy a browser has. CSS keyed on it hides the draft
|
||||
// starters while typing, mirroring the native app.
|
||||
React.useEffect(() => {
|
||||
if (!isMobile || isCapacitorApp() || typeof document === 'undefined') return;
|
||||
const root = document.documentElement;
|
||||
if (mobileTextareaFocused) {
|
||||
root.classList.add('oc-browser-keyboard-open');
|
||||
} else {
|
||||
root.classList.remove('oc-browser-keyboard-open');
|
||||
}
|
||||
return () => root.classList.remove('oc-browser-keyboard-open');
|
||||
}, [isMobile, mobileTextareaFocused]);
|
||||
|
||||
// Capacitor: collapse in the SAME frame the keyboard starts hiding. The
|
||||
// hide choreography dispatches oc:keyboard-intent BEFORE restoring the
|
||||
// shell layout and measuring the chat compensation; flushSync commits the
|
||||
@@ -4227,6 +4242,104 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
composerHandleTouchRef.current = null;
|
||||
}, []);
|
||||
|
||||
// Fullscreen composer in a mobile BROWSER: the page layout doesn't shrink
|
||||
// for the keyboard there — Safari pans/scrolls instead, so any flow-based
|
||||
// sizing ends up partly off-screen or under the keyboard (the chat page is
|
||||
// usually already panned when fullscreen is entered). Pin the form to the
|
||||
// VISUAL viewport directly: fixed at its offset with its height, updated
|
||||
// as the browser pans. Capacitor is excluded — its shell already resizes
|
||||
// via the keyboard choreography.
|
||||
const composerFormRef = React.useRef<HTMLFormElement | null>(null);
|
||||
React.useLayoutEffect(() => {
|
||||
if (!isMobile || !isMobileExpanded || isCapacitorApp()) return;
|
||||
const vv = window.visualViewport;
|
||||
const form = composerFormRef.current;
|
||||
if (!vv || !form) return;
|
||||
// The form is trapped inside lower stacking contexts (the composer
|
||||
// wrapper's z-10), so it cannot out-stack the app header with z-index
|
||||
// alone — hide the header for the duration via a root class instead.
|
||||
document.documentElement.classList.add('oc-browser-kb-fullscreen');
|
||||
const apply = () => {
|
||||
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.zIndex = '40';
|
||||
form.style.background = 'var(--background)';
|
||||
};
|
||||
apply();
|
||||
vv.addEventListener('resize', apply);
|
||||
vv.addEventListener('scroll', apply);
|
||||
window.addEventListener('scroll', apply, true);
|
||||
return () => {
|
||||
vv.removeEventListener('resize', apply);
|
||||
vv.removeEventListener('scroll', apply);
|
||||
window.removeEventListener('scroll', apply, true);
|
||||
document.documentElement.classList.remove('oc-browser-kb-fullscreen');
|
||||
form.style.position = '';
|
||||
form.style.left = '';
|
||||
form.style.right = '';
|
||||
form.style.top = '';
|
||||
form.style.height = '';
|
||||
form.style.zIndex = '';
|
||||
form.style.background = '';
|
||||
// Back in flow: the browser panned/scrolled for the fullscreen
|
||||
// session and won't re-reveal the (still focused) field on its own,
|
||||
// which left the composer parked behind the keyboard.
|
||||
requestAnimationFrame(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (textarea && document.activeElement === textarea) {
|
||||
textarea.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
});
|
||||
};
|
||||
}, [isMobile, isMobileExpanded]);
|
||||
|
||||
// Draft screen in a mobile BROWSER with the keyboard open: Safari's own
|
||||
// focused-field reveal is unreliable there (leaving the composer behind
|
||||
// the keyboard, e.g. after collapsing from fullscreen), so the NORMAL
|
||||
// composer is pinned to the visual viewport too — anchored to its visible
|
||||
// bottom at its natural height. The chat screen doesn't need this (its
|
||||
// reveal works) and Capacitor has the keyboard choreography.
|
||||
React.useLayoutEffect(() => {
|
||||
if (!isMobile || isCapacitorApp()) return;
|
||||
if (!newSessionDraftOpen || isMobileExpanded || !mobileTextareaFocused) return;
|
||||
const vv = window.visualViewport;
|
||||
const form = composerFormRef.current;
|
||||
if (!vv || !form) return;
|
||||
// Keep the in-flow horizontal geometry (page paddings) while fixed.
|
||||
const rect = form.getBoundingClientRect();
|
||||
form.style.position = 'fixed';
|
||||
form.style.left = `${Math.floor(rect.left)}px`;
|
||||
form.style.width = `${Math.floor(rect.width)}px`;
|
||||
form.style.zIndex = '40';
|
||||
form.style.background = 'var(--background)';
|
||||
// Safari's visualViewport events are unreliable mid keyboard pan (they
|
||||
// can simply not fire), so track the pan with a rAF loop instead —
|
||||
// cheap math per frame, a style write only when the value changes.
|
||||
let lastTop = Number.NaN;
|
||||
let frame = 0;
|
||||
const track = () => {
|
||||
const top = Math.max(0, Math.floor(vv.offsetTop + vv.height - form.offsetHeight));
|
||||
if (top !== lastTop) {
|
||||
lastTop = top;
|
||||
form.style.top = `${top}px`;
|
||||
}
|
||||
frame = requestAnimationFrame(track);
|
||||
};
|
||||
track();
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
form.style.position = '';
|
||||
form.style.left = '';
|
||||
form.style.width = '';
|
||||
form.style.top = '';
|
||||
form.style.zIndex = '';
|
||||
form.style.background = '';
|
||||
};
|
||||
}, [isMobile, isMobileExpanded, newSessionDraftOpen, mobileTextareaFocused]);
|
||||
|
||||
// Shared drag handle: rendered at the top of the full composer AND inside
|
||||
// the dictation overlay, so swipe-expand/collapse works in Listening mode.
|
||||
// Memoized so the always-mounted dictation instance's memo stays effective.
|
||||
@@ -4311,6 +4424,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
return (
|
||||
<>
|
||||
<form
|
||||
ref={composerFormRef}
|
||||
onSubmit={(e) => { e.preventDefault(); handlePrimaryAction(); }}
|
||||
className={cn(
|
||||
"relative w-full pt-0 pb-4",
|
||||
|
||||
@@ -661,7 +661,8 @@
|
||||
with the keyboard up there is only room for the draft title. Instant
|
||||
show/hide (no squish animation); the title's own keyboard compensation
|
||||
(.oc-draft-center below) carries the smooth motion. */
|
||||
:root.oc-capacitor-app.oc-keyboard-open .oc-draft-starters {
|
||||
:root.oc-capacitor-app.oc-keyboard-open .oc-draft-starters,
|
||||
:root.oc-browser-keyboard-open .oc-draft-starters {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -672,6 +673,13 @@
|
||||
list in useNativeMobileChrome. No CSS rules needed here; the class only
|
||||
marks the element for the mover query. */
|
||||
|
||||
/* Mobile-browser fullscreen composer (visual-viewport pinned form in
|
||||
ChatInput): the header would otherwise paint above it — the form can't
|
||||
out-stack it from inside the composer wrapper's z-10 context. */
|
||||
:root.oc-browser-kb-fullscreen .oc-mobile-header {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
@keyframes oc-composer-morph-fade {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
|
||||
Reference in New Issue
Block a user