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:
5
.changeset/quick-chat-mobile-keyboard.md
Normal file
5
.changeset/quick-chat-mobile-keyboard.md
Normal 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.
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user