FN-6329: prevent Quick Chat keyboard board shift
Keep the mobile board layout stable while Quick Chat owns the keyboard viewport. - Treat fullscreen mobile overlays as suppressors for App-level footer hiding. - Pass Quick Chat mobile open state into the shared keyboard flag helper. - Cover iOS, Android, modal, and non-mobile keyboard flag behavior. - Document the Quick Chat mobile keyboard board-shift root cause and fix. Files changed: .../quick-chat-mobile-keyboard-board-shift.md | 58 ++++++++++++++++++++++ packages/dashboard/app/App.tsx | 4 +- .../utils/__tests__/mobileBarKeyboardFlags.test.ts | 57 ++++++++++++++++++++- .../dashboard/app/utils/mobileBarKeyboardFlags.ts | 10 +++- 4 files changed, 126 insertions(+), 3 deletions(-) Fusion-Task-Id: FN-6329 Fusion-Task-Lineage: f7c37252-c0ea-44f0-b4e3-5aa2022cf7bc
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
---
|
||||
title: "Quick Chat mobile keyboard board shift"
|
||||
date: 2026-06-12
|
||||
category: ui-bugs
|
||||
module: packages/dashboard/app/utils/mobileBarKeyboardFlags
|
||||
problem_type: ui_bug
|
||||
component: frontend_mobile_layout
|
||||
applies_when: "A fullscreen mobile overlay owns its own soft-keyboard and visual-viewport handling while the dashboard board remains mounted underneath."
|
||||
symptoms:
|
||||
- "Opening Quick Chat on mobile focuses the composer and raises the soft keyboard"
|
||||
- "The board underneath shifts upward because App-level keyboard logic removes footer/mobile-nav padding"
|
||||
- "After dismissing the keyboard or closing Quick Chat, the board can remain shifted with a bottom gap"
|
||||
root_cause: overlay_keyboard_state_leaked_to_board_layout
|
||||
resolution_type: code_fix
|
||||
severity: medium
|
||||
related_components:
|
||||
- packages/dashboard/app/App.tsx
|
||||
- packages/dashboard/app/components/QuickChatFAB.tsx
|
||||
- packages/dashboard/app/hooks/useMobileScrollLock.ts
|
||||
- FN-6329
|
||||
tags:
|
||||
- quick-chat
|
||||
- mobile-keyboard
|
||||
- visualviewport
|
||||
- overlay-layout
|
||||
- footer-padding
|
||||
---
|
||||
|
||||
# Quick Chat mobile keyboard board shift
|
||||
|
||||
## Problem
|
||||
|
||||
Quick Chat's mobile UI is a fullscreen fixed sheet that covers the board and manages its own keyboard viewport with `--vv-height` and `--vv-offset-top`. App-level mobile keyboard logic did not know that sheet was open, so the Quick Chat composer keyboard was treated like an inline board keyboard.
|
||||
|
||||
On iOS, `computeMobileBarKeyboardFlags` returned `footerHidden: true` whenever `isMobile`, `keyboardOpen`, and `!anyModalOpen` were true. `App.tsx` mapped that to `mobileKeyboardOpen`, which removed `project-content--with-footer` / `project-content--with-mobile-nav` and hid the footer. Because Quick Chat is not part of `modalManager.anyModalOpen`, the board behind the sheet shifted up and could remain offset after iOS keyboard dismissal lag.
|
||||
|
||||
## Solution
|
||||
|
||||
Model fullscreen mobile overlays as explicit board-layout suppressors in `computeMobileBarKeyboardFlags`.
|
||||
|
||||
- Keep existing modal suppression intact.
|
||||
- Add an `overlayOpen` input and suppress only `footerHidden` when `anyModalOpen || overlayOpen` is true.
|
||||
- Preserve `navKeyboardOpen` and `footerKeyboardOpen` semantics so mobile nav/footer keyboard classes continue to reflect keyboard state where needed.
|
||||
- In `App.tsx`, pass `overlayOpen: isMobile && quickChatOpen` so only Quick Chat's mobile fullscreen sheet suppresses board layout. Desktop Quick Chat remains unaffected.
|
||||
|
||||
This keeps the board's footer/mobile-nav padding classes present for the entire time Quick Chat is open. The board therefore never shifts in response to the Quick Chat keyboard, leaving nothing to snap back after the overlay closes.
|
||||
|
||||
## Regression coverage
|
||||
|
||||
Cover the invariant at the pure helper seam:
|
||||
|
||||
- iOS + mobile + keyboard + no overlay still hides the footer for inline board keyboards.
|
||||
- iOS + mobile + keyboard + modal keeps the footer visible.
|
||||
- iOS + mobile + keyboard + fullscreen overlay keeps the footer visible.
|
||||
- Android + mobile + keyboard + fullscreen overlay keeps `footerHidden` false while preserving nav keyboard state.
|
||||
- Non-mobile remains all-false.
|
||||
|
||||
Prefer this narrow helper coverage over mock-heavy `App` rendering unless a future regression needs DOM-level evidence. `computeMobileBarKeyboardFlags` has a single production caller in `App.tsx`, making the seam small and reliable.
|
||||
@@ -508,6 +508,8 @@ function AppInner() {
|
||||
}
|
||||
}, [initialLoadComplete]);
|
||||
|
||||
const [quickChatOpen, setQuickChatOpen] = useState(false);
|
||||
|
||||
const { keyboardOpen } = useMobileKeyboard({ enabled: isMobile });
|
||||
// Keyboard visibility controls both MobileNavBar rendering and whether
|
||||
// the project content reserves bottom padding for the mobile nav bar.
|
||||
@@ -532,6 +534,7 @@ function AppInner() {
|
||||
isMobile,
|
||||
keyboardOpen,
|
||||
anyModalOpen: modalManager.anyModalOpen,
|
||||
overlayOpen: isMobile && quickChatOpen,
|
||||
isIOS: isIOS(),
|
||||
});
|
||||
const mobileKeyboardOpen = footerHidden;
|
||||
@@ -787,7 +790,6 @@ function AppInner() {
|
||||
setSelectedPrId(undefined);
|
||||
}
|
||||
}, [selectedPrId, taskView]);
|
||||
const [quickChatOpen, setQuickChatOpen] = useState(false);
|
||||
const [authTokenRecoveryOpen, setAuthTokenRecoveryOpen] = useState(false);
|
||||
const [dashboardHealth, setDashboardHealth] = useState<DashboardHealthResponse | null>(null);
|
||||
const [dbCorruptionRefreshing, setDbCorruptionRefreshing] = useState(false);
|
||||
|
||||
@@ -7,6 +7,7 @@ describe("computeMobileBarKeyboardFlags", () => {
|
||||
isMobile: true,
|
||||
keyboardOpen: true,
|
||||
anyModalOpen: false,
|
||||
overlayOpen: false,
|
||||
isIOS: false,
|
||||
});
|
||||
|
||||
@@ -15,11 +16,12 @@ describe("computeMobileBarKeyboardFlags", () => {
|
||||
expect(flags.navKeyboardOpen).toBe(true);
|
||||
});
|
||||
|
||||
it("hides and collapses footer on iOS when keyboard is open and no modal is open", () => {
|
||||
it("hides and collapses footer on iOS when keyboard is open and no overlay is open", () => {
|
||||
const flags = computeMobileBarKeyboardFlags({
|
||||
isMobile: true,
|
||||
keyboardOpen: true,
|
||||
anyModalOpen: false,
|
||||
overlayOpen: false,
|
||||
isIOS: true,
|
||||
});
|
||||
|
||||
@@ -33,6 +35,7 @@ describe("computeMobileBarKeyboardFlags", () => {
|
||||
isMobile: true,
|
||||
keyboardOpen: true,
|
||||
anyModalOpen: true,
|
||||
overlayOpen: false,
|
||||
isIOS: true,
|
||||
});
|
||||
|
||||
@@ -46,6 +49,7 @@ describe("computeMobileBarKeyboardFlags", () => {
|
||||
isMobile: true,
|
||||
keyboardOpen: false,
|
||||
anyModalOpen: false,
|
||||
overlayOpen: false,
|
||||
isIOS: true,
|
||||
});
|
||||
|
||||
@@ -61,6 +65,7 @@ describe("computeMobileBarKeyboardFlags", () => {
|
||||
isMobile: false,
|
||||
keyboardOpen: true,
|
||||
anyModalOpen: false,
|
||||
overlayOpen: false,
|
||||
isIOS: true,
|
||||
});
|
||||
|
||||
@@ -70,4 +75,54 @@ describe("computeMobileBarKeyboardFlags", () => {
|
||||
footerKeyboardOpen: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the board footer visible on iOS when a fullscreen overlay owns the keyboard", () => {
|
||||
const flags = computeMobileBarKeyboardFlags({
|
||||
isMobile: true,
|
||||
keyboardOpen: true,
|
||||
anyModalOpen: false,
|
||||
overlayOpen: true,
|
||||
isIOS: true,
|
||||
});
|
||||
|
||||
expect(flags.footerHidden).toBe(false);
|
||||
expect(flags.navKeyboardOpen).toBe(true);
|
||||
expect(flags.footerKeyboardOpen).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps Android board-layout behavior unchanged when a fullscreen overlay owns the keyboard", () => {
|
||||
const flags = computeMobileBarKeyboardFlags({
|
||||
isMobile: true,
|
||||
keyboardOpen: true,
|
||||
anyModalOpen: false,
|
||||
overlayOpen: true,
|
||||
isIOS: false,
|
||||
});
|
||||
|
||||
expect(flags.footerHidden).toBe(false);
|
||||
expect(flags.navKeyboardOpen).toBe(true);
|
||||
expect(flags.footerKeyboardOpen).toBe(false);
|
||||
});
|
||||
|
||||
it("suppresses the original iOS board-shift trigger only when the Quick Chat overlay flag is set", () => {
|
||||
const originalBoardShiftTrigger = computeMobileBarKeyboardFlags({
|
||||
isMobile: true,
|
||||
keyboardOpen: true,
|
||||
anyModalOpen: false,
|
||||
overlayOpen: false,
|
||||
isIOS: true,
|
||||
});
|
||||
const quickChatOverlayKeyboard = computeMobileBarKeyboardFlags({
|
||||
isMobile: true,
|
||||
keyboardOpen: true,
|
||||
anyModalOpen: false,
|
||||
overlayOpen: true,
|
||||
isIOS: true,
|
||||
});
|
||||
|
||||
expect(originalBoardShiftTrigger.footerHidden).toBe(true);
|
||||
expect(quickChatOverlayKeyboard.footerHidden).toBe(false);
|
||||
expect(quickChatOverlayKeyboard.navKeyboardOpen).toBe(true);
|
||||
expect(quickChatOverlayKeyboard.footerKeyboardOpen).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,8 @@ export interface MobileBarKeyboardFlagsInput {
|
||||
isMobile: boolean;
|
||||
keyboardOpen: boolean;
|
||||
anyModalOpen: boolean;
|
||||
/** True when a fullscreen mobile overlay owns keyboard/viewport layout. */
|
||||
overlayOpen: boolean;
|
||||
isIOS: boolean;
|
||||
}
|
||||
|
||||
@@ -17,14 +19,20 @@ export interface MobileBarKeyboardFlags {
|
||||
* position remains correct above the mobile nav. Only iOS should apply the
|
||||
* footer keyboard-collapse class (`bottom: 0`) used to let the keyboard cover
|
||||
* bars when visualViewport shifts independently.
|
||||
*
|
||||
* Fullscreen mobile overlays (for example Quick Chat's sheet) own their own
|
||||
* visual viewport handling. Treat them like modals for board-layout padding so
|
||||
* overlay-local keyboards never shift the underlying board.
|
||||
*/
|
||||
export function computeMobileBarKeyboardFlags({
|
||||
isMobile,
|
||||
keyboardOpen,
|
||||
anyModalOpen,
|
||||
overlayOpen,
|
||||
isIOS,
|
||||
}: MobileBarKeyboardFlagsInput): MobileBarKeyboardFlags {
|
||||
const footerHidden = isMobile && keyboardOpen && !anyModalOpen && isIOS;
|
||||
const boardLayoutSuppressed = anyModalOpen || overlayOpen;
|
||||
const footerHidden = isMobile && keyboardOpen && !boardLayoutSuppressed && isIOS;
|
||||
const navKeyboardOpen = isMobile && keyboardOpen;
|
||||
const footerKeyboardOpen = navKeyboardOpen && isIOS;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user