diff --git a/packages/ui/src/apps/MobileApp.tsx b/packages/ui/src/apps/MobileApp.tsx index bce07ebe..24f82b31 100644 --- a/packages/ui/src/apps/MobileApp.tsx +++ b/packages/ui/src/apps/MobileApp.tsx @@ -210,7 +210,7 @@ const useNativeMobileChrome = (): void => { // Keyboard slide choreography (see the "Native (Capacitor) keyboard handling" // block in mobile.css for the full picture). `keyboardWillShow` fires at the // START of the iOS keyboard animation and carries the final height; the - // visible motion is transform-only (--oc-kb-shift), and the shell's layout + // visible motion is transform-only (inline styles on the kb-movers), and the shell's layout // height (--oc-kb-layout) snaps exactly once per open/close at the moment the // resize is invisible. visualViewport tracking was tried but doesn't shrink // under WKWebView's `resize: 'none'`, so these events are the reliable signal. @@ -238,6 +238,26 @@ const useNativeMobileChrome = (): void => { const dispatchKb = (type: 'oc:keyboard-intent' | 'oc:keyboard-anim' | 'oc:keyboard-settled', detail: Record) => { window.dispatchEvent(new CustomEvent(type, { detail })); }; + // Elements that ride the keyboard slide, with their travel factor. Driven + // by INLINE styles from here: WebKit does not reliably start a transition + // when the transform's value changes via a CSS custom property, which + // left the composer parked until the keyboard finished. + const getKbMovers = (): Array<{ el: HTMLElement; factor: number }> => { + const movers: Array<{ el: HTMLElement; factor: number }> = []; + const composer = document.querySelector('.oc-mobile-composer'); + if (composer) movers.push({ el: composer, factor: 1 }); + // The centered draft title moves half the shift — exactly where the + // center lands after the shell snap (see mobile.css notes). + const draftCenter = document.querySelector('.oc-draft-center'); + if (draftCenter) movers.push({ el: draftCenter, factor: 0.5 }); + return movers; + }; + const clearKbMovers = () => { + for (const { el } of getKbMovers()) { + el.style.transition = ''; + el.style.transform = ''; + } + }; const showHandle = await Keyboard.addListener('keyboardWillShow', (info) => { clearSettle(); @@ -262,7 +282,18 @@ const useNativeMobileChrome = (): void => { } root.classList.add('oc-keyboard-open', 'oc-kb-animating', 'oc-kb-caret-hold'); setInset(keyboardHeight); - setVar('--oc-kb-shift', slide); + for (const { el, factor } of getKbMovers()) { + el.style.transition = `transform ${KB_ANIM_MS}ms ${KB_ANIM_EASING}`; + el.style.transform = `translateY(${-slide * factor}px)`; + } + // Reserve the keyboard strip inside the chat scroller NOW and re-pin + // immediately (settled = one cheap scrollTop write over already-mounted + // rows), so the chat bottom moves as the keyboard STARTS rising instead + // of waiting for it to finish. `slide` (keyboard minus the safe inset + // the shell gives up) is exactly the strip the scroller loses at + // settle, so pin position and settle stay geometry-neutral. + setVar('--oc-kb-scroll-inset', slide); + dispatchKb('oc:keyboard-settled', { open: true }); dispatchKb('oc:keyboard-anim', { phase: 'show', slide, durationMs: KB_ANIM_MS, easing: KB_ANIM_EASING }); settleTimer = window.setTimeout(() => { settleTimer = null; @@ -271,7 +302,7 @@ const useNativeMobileChrome = (): void => { root.classList.remove('oc-kb-animating'); setVar('--oc-kb-layout', keyboardHeight); layoutApplied = true; - setVar('--oc-kb-shift', 0); + clearKbMovers(); dispatchKb('oc:keyboard-settled', { open: true }); // Reveal the caret only after UIKit's own caret reposition window. caretTimer = window.setTimeout(() => { @@ -304,26 +335,34 @@ const useNativeMobileChrome = (): void => { const slide = Math.max(0, keyboardHeight - safeBottomPx); root.classList.remove('oc-keyboard-open'); setInset(0); + setVar('--oc-kb-scroll-inset', 0); if (layoutApplied) { // Settled-open → restore the full-height layout NOW (still hidden behind - // the keyboard) and FLIP the composer to its raised position without + // the keyboard) and FLIP the movers to their raised position without // transitioning, so the next frame looks unchanged. root.classList.remove('oc-kb-animating'); setVar('--oc-kb-layout', 0); layoutApplied = false; - setVar('--oc-kb-shift', slide); + for (const { el, factor } of getKbMovers()) { + el.style.transition = 'none'; + el.style.transform = `translateY(${-slide * factor}px)`; + } // Force the style/layout flush so the transition below starts from the // FLIP position instead of coalescing both writes into one frame. void (document.querySelector('.oc-mobile-app-shell') as HTMLElement | null)?.offsetHeight; } // If the hide interrupted a show mid-animation (layout not applied yet), - // the shift transitions back down from wherever it currently is. + // the movers transition back down from wherever they currently are. dispatchKb('oc:keyboard-anim', { phase: 'hide', slide, durationMs: KB_HIDE_MS, easing: KB_ANIM_EASING }); root.classList.add('oc-kb-animating', 'oc-kb-hide'); - setVar('--oc-kb-shift', 0); + for (const { el } of getKbMovers()) { + el.style.transition = `transform ${KB_HIDE_MS}ms ${KB_ANIM_EASING}`; + el.style.transform = 'translateY(0px)'; + } settleTimer = window.setTimeout(() => { settleTimer = null; root.classList.remove('oc-kb-animating', 'oc-kb-hide'); + clearKbMovers(); dispatchKb('oc:keyboard-settled', { open: false }); }, KB_HIDE_MS + 20); }; @@ -378,6 +417,7 @@ const useNativeMobileChrome = (): void => { root.style.removeProperty('--oc-keyboard-inset'); root.style.removeProperty('--oc-kb-shift'); root.style.removeProperty('--oc-kb-layout'); + root.style.removeProperty('--oc-kb-scroll-inset'); }; }, []); }; diff --git a/packages/ui/src/hooks/useChatAutoFollow.ts b/packages/ui/src/hooks/useChatAutoFollow.ts index 2c450d3c..a12d634f 100644 --- a/packages/ui/src/hooks/useChatAutoFollow.ts +++ b/packages/ui/src/hooks/useChatAutoFollow.ts @@ -716,24 +716,22 @@ export const useChatAutoFollow = ({ return () => observer.disconnect(); }, [armEntryStickQuiet, containerEl, isActive, scrollToBottom, setStateValue, updateOverflowAndButton]); - // ── native keyboard slide (Capacitor iOS choreography) ────────────────── - // useNativeMobileChrome (MobileApp) drives the keyboard open/close as a - // transform-only slide: during the animation the shell layout does NOT change - // (show) or changes exactly once up-front (hide). Our job here: - // show: if pinned, slide the inner content up in sync with the composer. - // hide: the shell snaps back to full height first, which makes the browser - // clamp scrollTop — measure that clamp and FLIP the inner content so - // the frame looks unchanged, then let it slide down with the keyboard. - // settled: drop the transforms and do ONE instant re-pin in the same frame - // (invisible — this runs before the post-reflow paint). - // These events never fire outside the Capacitor iOS app, so the listeners are - // inert everywhere else. + // ── native keyboard transitions (Capacitor choreography) ──────────────── + // The chat scroller gets NO transforms during the keyboard transition: + // transforming the scroll container (or its content) forces WebKit to + // rebuild the composited scrolling layers, which stalls for seconds on + // long chats. Instead the chat repositions with instant snaps that hide + // behind the keyboard itself: + // show: content stays put while the keyboard/composer slide over it; the + // settled event (shell layout snap) does ONE instant re-pin. + // hide: the shell layout is restored up-front — the scrollTop clamp + // happens while the keyboard still covers that region — and the + // settled event re-pins once at the end. + // During the window we only guard the scroll heuristics and the observer + // chase. These events never fire outside the Capacitor app. React.useEffect(() => { if (typeof window === 'undefined') return; - const innerOf = (el: HTMLElement): HTMLElement | null => - el.firstElementChild instanceof HTMLElement ? el.firstElementChild : null; - const handleKeyboardAnim = (event: Event) => { const detail = (event as CustomEvent<{ phase: 'show' | 'hide'; slide: number; durationMs: number; easing: string }>).detail; if (!detail) return; @@ -742,39 +740,6 @@ export const useChatAutoFollow = ({ // that land away from the auto marker — never read those as a user // scroll-away. animationGuardUntilRef.current = now() + detail.durationMs + ANIMATION_GUARD_MS; - const el = scrollRef.current; - if (!el) return; - const inner = innerOf(el); - if (!inner) return; - const transition = `transform ${detail.durationMs}ms ${detail.easing}`; - - if (detail.phase === 'show') { - // Only pinned content rides the keyboard; a user reading history - // stays put (the composer slides over the bottom, like native apps). - if (stateRef.current !== 'following' || !canScroll(el)) return; - // Fold any pending re-pin distance into the same slide. The pill - // composer swaps to the full composer right before focusing, which - // shrinks the viewport without moving scrollTop — compensating it - // here makes keyboard + composer growth one motion instead of two. - const pending = Math.max(0, el.scrollHeight - el.scrollTop - el.clientHeight); - inner.style.transition = transition; - inner.style.transform = `translateY(${-(detail.slide + pending)}px)`; - return; - } - - // hide: the layout var was restored just before this event — force the - // reflow now and measure how far the scrollTop clamp moved the content, - // then FLIP it back and slide to 0. - const before = el.scrollTop; - void el.clientHeight; - const clamped = before - el.scrollTop; - if (clamped > 0.5) { - inner.style.transition = 'none'; - inner.style.transform = `translateY(${-clamped}px)`; - void inner.offsetHeight; - } - inner.style.transition = transition; - inner.style.transform = 'translateY(0px)'; }; const handleKeyboardSettled = () => { @@ -784,11 +749,6 @@ export const useChatAutoFollow = ({ updateOverflowAndButton(); return; } - const inner = innerOf(el); - if (inner) { - inner.style.transition = ''; - inner.style.transform = ''; - } // Single deterministic re-pin, same task as the layout swap → lands // before paint. (scrollToBottomNow, not scrollToBottom: this must not // be gated on working/settling — the keyboard resize is a viewport diff --git a/packages/ui/src/styles/mobile.css b/packages/ui/src/styles/mobile.css index 9c127b0f..75a3139e 100644 --- a/packages/ui/src/styles/mobile.css +++ b/packages/ui/src/styles/mobile.css @@ -552,8 +552,8 @@ choreography driven by useNativeMobileChrome: show: shell keeps its full height for the whole 0.25s; the composer (and, when - pinned, the chat content — see useChatAutoFollow) slides up via - --oc-kb-shift in sync with the keyboard; at the end the shell snaps to + pinned, the chat scroll container — see useChatAutoFollow) slides up via + inline transforms in sync with the keyboard; at the end the shell snaps to its final height (--oc-kb-layout, one reflow) and the shift is removed in the same frame — visually identical, so the swap is invisible. hide: the shell snaps back to full height immediately (still hidden behind the @@ -601,25 +601,41 @@ transition: height 0.25s cubic-bezier(0.38, 0.7, 0.125, 1); } -/* Keyboard slide choreography (see the shell comment above). --oc-kb-shift is the - transform-only travel distance (keyboard height minus the bottom safe padding the - shell gives up when the keyboard is open). The transition is gated on - .oc-kb-animating so the end-of-animation swap (shift back to 0 while the layout - height takes over) is instant and invisible. */ -:root.oc-capacitor-app .oc-mobile-composer { - transform: translateY(calc(-1 * var(--oc-kb-shift, 0px))); -} +/* Keyboard slide choreography: the composer (and the draft title, see + .oc-draft-center note below) are moved with INLINE transform/transition set + by useNativeMobileChrome — WebKit does not reliably start transitions when a + transform changes via a CSS custom property, which parked the composer until + the keyboard finished. Only the sliding backdrop lives here. */ :root.oc-capacitor-app.oc-kb-animating .oc-mobile-composer { - transition: transform 0.25s cubic-bezier(0.38, 0.7, 0.125, 1); /* While sliding, the composer passes over chat content that hasn't moved (user scrolled up) — give it an opaque backdrop so text doesn't show through the form's transparent padding. */ background: var(--background); } -/* Keyboard dismissal reads faster than the rise — a full 0.25s down-slide feels - laggy, so the hide leg runs shorter. Must match KB_HIDE_MS in useNativeMobileChrome. */ -:root.oc-capacitor-app.oc-kb-animating.oc-kb-hide .oc-mobile-composer { - transition-duration: 0.2s; + +/* Constant-viewport trick for the chat scroller: when the keyboard settles the + shell shrinks by --oc-kb-layout, but the scroller keeps its full height by + extending that far below its (shrunken) region — behind the composer and the + keyboard — and converts the covered strip into its own bottom padding. For + the virtualizer NOTHING resizes across keyboard transitions: clientHeight is + constant, every row that can become visible is already mounted, and the + open/close reposition is a single cheap scrollTop change (the settle re-pin / + the clamp) instead of a viewport resize that mounts rows with a visible + pause on long chats. */ +:root.oc-capacitor-app .oc-mobile-app-shell .chat-scroll { + /* The shell shrinks by the keyboard height BUT also gives up its bottom + safe-area padding at the same moment (see the shell's padding-bottom + formula) — so the scroller's parent only loses (keyboard − safe). Extend + by exactly that, or the settle grows clientHeight by the safe inset and + the pinned chat clamps one row down a beat after the keyboard opens. */ + bottom: calc(-1 * max(var(--oc-kb-layout, 0px) - var(--oc-app-bottom-safe, 0px), 0px)); + /* The padding leg is driven by its own variable so it can be applied at the + very START of the keyboard rise (with an immediate re-pin — the bottom of + the chat moves up as the keyboard begins, not after it finishes), while + the bottom-extension leg still tracks the shell's settle-time shrink. Both + legs equal the keyboard height once settled, so the settle snap is + geometry-neutral for the scroller. */ + padding-bottom: var(--oc-kb-scroll-inset, var(--oc-kb-layout, 0px)); } /* WKWebView draws the text caret as a native layer that ignores CSS transforms: @@ -649,23 +665,12 @@ display: none; } -/* The draft title is vertically centered, so it has no scroll-pinning to - compensate the keyboard like the chat does: during the slide the shell keeps - its full height and only snaps shorter at settle, which made the centered - title glide down (starters collapsing) and then JUMP up (shell snap). Ride - the same choreography as the composer: translate the centered block up by - half the keyboard shift (exactly how far the center moves after the snap), - with the transition gated on .oc-kb-animating so the settle swap — shift - back to 0 in the same frame the layout shrinks — is invisible. */ -:root.oc-capacitor-app .oc-draft-center { - transform: translateY(calc(-0.5 * var(--oc-kb-shift, 0px))); -} -:root.oc-capacitor-app.oc-kb-animating .oc-draft-center { - transition: transform 0.25s cubic-bezier(0.38, 0.7, 0.125, 1); -} -:root.oc-capacitor-app.oc-kb-animating.oc-kb-hide .oc-draft-center { - transition-duration: 0.2s; -} +/* The draft title (.oc-draft-center) is vertically centered, so it has no + scroll-pinning to compensate the keyboard like the chat does. It rides the + keyboard choreography with an INLINE transform of half the keyboard shift + (exactly how far the center moves after the shell snap) — see the kb-movers + list in useNativeMobileChrome. No CSS rules needed here; the class only + marks the element for the mover query. */ @keyframes oc-composer-morph-fade { from { opacity: 0; }