fix(dashboard): mobile quick-chat panel layout under iOS keyboard

The mobile QuickChat panel was unusable on iOS Safari: the input bar
rendered off-screen, the soft keyboard often did not open on FAB tap,
the panel slid up out of view on the second input focus, and the
header pill overflowed when the model name was long.

- The FAB JSX no longer emits inline right/bottom styles on mobile,
  and the mobile media query pins every edge with !important so the
  panel cannot be dragged off-screen by the desktop draggable hook.
- Body scroll is locked while the panel is open via overflow:hidden
  on <html> and <body>, and the document is reset to scroll 0 on
  open. This prevents iOS from leaking residual scroll into the
  fixed-positioned panel between opens.
- An always-mounted, offscreen stealth input claims the iOS soft
  keyboard inside the FAB click gesture (the real composer input is
  initially `disabled` waiting for the chat session, and iOS will
  not open the keyboard for a disabled input). Focus is transferred
  synchronously to the real input once it becomes enabled.
- The composer input intercepts touchstart on mobile and re-focuses
  with `preventScroll: true`, suppressing iOS's default focus-and-
  scroll behavior that was shifting visualViewport.offsetTop and
  sliding the panel up off-screen on a refocus.
- A useLayoutEffect mirrors visualViewport.height onto the panel as
  --vv-height directly via the DOM, bypassing React state to avoid
  the per-event reconciliation lag that read as visual jank during
  the keyboard slide-in. On blur, the variable is cleared and a
  short suppress window (~450ms) prevents iOS's mid-dismiss reports
  from clobbering the regrowth, so the panel snaps back to full
  height immediately while the keyboard finishes sliding down on
  top of it.
- The model-tag pill in the header collapses to the provider icon
  when the tag would otherwise exceed ~12 characters on mobile.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-30 21:27:10 -07:00
parent 40620a9a50
commit 13ed470f9f
3 changed files with 81 additions and 28 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix the mobile QuickChat panel layout when the iOS keyboard opens. The panel now stays anchored to the visible viewport (no off-screen drift on a refocus after the keyboard was dismissed), the soft keyboard reliably comes up the moment the FAB is tapped (a stealth input claims focus inside the user gesture so iOS opens the keyboard even before the real composer is enabled), the panel snaps back to full height immediately on blur instead of trailing the keyboard slide-down, and the model name in the header pill collapses to a provider icon when it would otherwise overflow.

View File

@@ -230,6 +230,17 @@
text-overflow: ellipsis;
}
@media (min-width: 769px) {
.quick-chat-model-tag {
max-width: 14ch;
font-size: 0.6875rem;
line-height: 1.2;
white-space: normal;
overflow-wrap: anywhere;
text-overflow: clip;
}
}
/* Icon-only fallback when the model name is too long for the header. */
.quick-chat-model-tag--icon {
max-width: none;
@@ -637,21 +648,17 @@
/* === Quick Chat Mobile (FN: full-screen) =================================== */
@media (max-width: 768px) {
.quick-chat-panel {
/* Full-screen sheet that ignores drag position on mobile. `top`
follows visualViewport.offsetTop because on the *second* input
focus iOS Safari shifts the visual viewport (offsetTop > 0)
instead of repositioning the keyboard, so a fixed panel pinned
at layout top:0 ends up rendering at visual -offsetTop and slides
off-screen. Translating the panel by offsetTop puts the header
back at visual y=0 — i.e. the user still sees the top of the
chat box. The CSS variable is written directly to the DOM via
a layout effect, so it tracks the keyboard 1:1 with no React
state lag. */
/* Full-screen sheet that ignores drag position on mobile. We pin
every edge with !important so any stray inline value cannot pull
the panel off-screen. iOS's would-be visual-viewport shift on
input focus is suppressed at the source by the input's
onTouchStart handler in QuickChatFAB.tsx, so we don't need to
compensate with translateY here. */
position: fixed !important;
inset: 0 !important;
left: 0 !important;
right: 0 !important;
top: var(--vv-offset-top, 0px) !important;
top: 0 !important;
bottom: 0 !important;
width: 100vw !important;
height: 100vh !important;

View File

@@ -900,6 +900,12 @@ export function QuickChatFAB({
// session). Focus is transferred to the real input once it is enabled —
// iOS keeps the keyboard up across that transfer.
const stealthInputRef = useRef<HTMLInputElement | null>(null);
// Set true briefly while the keyboard is dismissing. While set, the
// visualViewport apply() ignores incoming vv.height values so iOS's
// mid-dismiss reports cannot shrink the panel back down — the panel
// visually grows to full height immediately on blur and the keyboard
// slides down on top of it.
const suppressVvShrinkRef = useRef(false);
// Pin the document at the top while the panel is open on mobile.
// Otherwise iOS can leave window.scrollY > 0 (e.g. after the keyboard
@@ -939,12 +945,16 @@ export function QuickChatFAB({
// Mirror visualViewport metrics onto the panel as CSS variables
// directly, bypassing React state. --vv-height shrinks the panel to
// the visible area; --vv-offset-top translates it back into view when
// iOS shifts the visual viewport on input focus (which would otherwise
// slide the position:fixed panel off-screen, especially on the second
// focus after the keyboard has been dismissed once). Going through
// setState here introduced per-event React reconciliation lag that
// showed up as visible jank during the keyboard animation.
// the visible area; --vv-offset-top compensates for iOS shifting the
// visual viewport on input focus (without it the position:fixed panel
// slides off-screen on the second focus after the keyboard has been
// dismissed once).
//
// We deliberately do NOT throttle via requestAnimationFrame here.
// iOS fires visualViewport resize/scroll events on the same frame as
// its own keyboard animation; deferring our write to the next frame
// makes the panel lag iOS by one paint, which is visible as a slide.
// Synchronous writes keep the panel locked to the visual viewport.
useLayoutEffect(() => {
if (!isOpen) return;
if (typeof window === "undefined" || !window.visualViewport) return;
@@ -952,24 +962,18 @@ export function QuickChatFAB({
if (!panel) return;
const vv = window.visualViewport;
let frame = 0;
const apply = () => {
frame = 0;
if (suppressVvShrinkRef.current) return;
panel.style.setProperty("--vv-height", `${vv.height}px`);
panel.style.setProperty("--vv-offset-top", `${vv.offsetTop || 0}px`);
};
const schedule = () => {
if (frame) return;
frame = requestAnimationFrame(apply);
};
apply();
vv.addEventListener("resize", schedule);
vv.addEventListener("scroll", schedule);
vv.addEventListener("resize", apply);
vv.addEventListener("scroll", apply);
return () => {
if (frame) cancelAnimationFrame(frame);
vv.removeEventListener("resize", schedule);
vv.removeEventListener("scroll", schedule);
vv.removeEventListener("resize", apply);
vv.removeEventListener("scroll", apply);
};
}, [isOpen]);
@@ -1454,6 +1458,24 @@ export function QuickChatFAB({
);
const handleInputBlur = useCallback(() => {
// Pre-grow the panel ahead of iOS's keyboard dismiss animation so the
// user sees the panel snap to full height immediately instead of
// following the keyboard slide-down. The suppress flag prevents the
// visualViewport listener from clobbering this with mid-dismiss
// reports while iOS is still animating the keyboard out.
if (
typeof window !== "undefined"
&& window.innerWidth <= QUICK_CHAT_DESKTOP_BREAKPOINT
&& panelRef.current
) {
suppressVvShrinkRef.current = true;
panelRef.current.style.removeProperty("--vv-height");
panelRef.current.style.removeProperty("--vv-offset-top");
window.setTimeout(() => {
suppressVvShrinkRef.current = false;
}, 450);
}
if (hideMentionPopupTimeoutRef.current !== null) {
window.clearTimeout(hideMentionPopupTimeoutRef.current);
}
@@ -1469,6 +1491,10 @@ export function QuickChatFAB({
}, [fileMention]);
const handleInputFocus = useCallback(() => {
// Re-enable visualViewport tracking — the suppress flag set on blur
// would otherwise still be in effect if the user re-focused inside
// the suppress window.
suppressVvShrinkRef.current = false;
if (hideMentionPopupTimeoutRef.current !== null) {
window.clearTimeout(hideMentionPopupTimeoutRef.current);
hideMentionPopupTimeoutRef.current = null;
@@ -1986,6 +2012,21 @@ export function QuickChatFAB({
onBlur={handleInputBlur}
onFocus={handleInputFocus}
onPaste={handlePaste}
// Intercept the touch *before* iOS's default focus-and-
// scroll handler runs. Without this, on the second focus
// (after a keyboard dismiss) iOS shifts the visual
// viewport to "scroll" the input into view, which yanks
// the position:fixed panel up off-screen for ~1s before
// settling back. preventDefault on touchstart suppresses
// that auto-scroll; we then focus programmatically with
// preventScroll so the keyboard still comes up.
onTouchStart={(event) => {
if (typeof window === "undefined") return;
if (window.innerWidth > QUICK_CHAT_DESKTOP_BREAKPOINT) return;
if (document.activeElement === event.currentTarget) return;
event.preventDefault();
event.currentTarget.focus({ preventScroll: true });
}}
placeholder={inputPlaceholder}
disabled={inputDisabled}
data-testid="quick-chat-input"