FN-5707: fix Android executor footer overlap after keyboard dismiss

Adjust mobile keyboard bar flags so Android keeps the executor footer stacked above the nav bar while iOS retains collapse behavior.

- extract mobile bar keyboard flag computation into a dedicated utility
- keep nav keyboard pinning cross-platform while limiting footer keyboard-collapse to iOS
- add focused unit tests for Android/iOS/modal/desktop keyboard flag scenarios
- update ExecutorStatusBar keyboard-open test assertion and dashboard guide keyboard behavior notes

Files changed:
 docs/dashboard-guide.md                            |  2 +-
 packages/dashboard/app/App.tsx                     | 23 ++++---
 .../__tests__/ExecutorStatusBar.test.tsx           |  6 +-
 .../utils/__tests__/mobileBarKeyboardFlags.test.ts | 73 ++++++++++++++++++++++
 .../dashboard/app/utils/mobileBarKeyboardFlags.ts  | 36 +++++++++++
 5 files changed, 129 insertions(+), 11 deletions(-)

Fusion-Task-Id: FN-5707

Fusion-Task-Lineage: ed01fcb0-d814-4595-9479-5baf97326ae7
This commit is contained in:
gsxdsm
2026-05-29 21:03:12 -07:00
parent ee0b1cd9c8
commit 9b54f52b39
5 changed files with 129 additions and 11 deletions

View File

@@ -59,6 +59,7 @@ import { useFavorites } from "./hooks/useFavorites";
import { useAuthOnboarding } from "./hooks/useAuthOnboarding";
import { useMobileKeyboard } from "./hooks/useMobileKeyboard";
import { isIOS, useMobileScrollLock } from "./hooks/useMobileScrollLock";
import { computeMobileBarKeyboardFlags } from "./utils/mobileBarKeyboardFlags";
import { useSetupReadiness } from "./hooks/useSetupReadiness";
import { useUpdateCheck } from "./hooks/useUpdateCheck";
import { useViewState, type TaskView } from "./hooks/useViewState";
@@ -472,13 +473,19 @@ function AppInner() {
// as the focus target moving and dismisses the keyboard immediately.
// iOS doesn't shrink the layout viewport, so the iOS path keeps the
// hide-nav-on-keyboard behavior intact.
const mobileKeyboardOpen = isMobile && keyboardOpen && !modalManager.anyModalOpen && isIOS();
// Nav-bar-only: pin the bar to the page bottom whenever the keyboard is up on
// mobile, regardless of modal state or platform. The bar's `bottom` defaults
// to `var(--icb-bottom-offset)`, which equals the keyboard height on iOS and
// would otherwise float the bar above the keyboard. We want the keyboard to
// simply cover the bar instead.
const mobileNavKeyboardOpen = isMobile && keyboardOpen;
//
// FN-5707: keep nav pinning cross-platform, but only apply the footer
// keyboard-collapse class on iOS. On Android, collapsing the footer to
// `bottom: 0` overlaps it with the nav bar because the layout viewport
// already shrinks and the stacked footer position is already correct.
const { footerHidden, navKeyboardOpen, footerKeyboardOpen } = computeMobileBarKeyboardFlags({
isMobile,
keyboardOpen,
anyModalOpen: modalManager.anyModalOpen,
isIOS: isIOS(),
});
const mobileKeyboardOpen = footerHidden;
const mobileNavKeyboardOpen = navKeyboardOpen;
// App-level scroll lock for inline editing (TaskCard inline edit, etc.):
// when the keyboard is up outside of any modal, pin the body so iOS can't
// shift the document or visualViewport, and so the dashboard snaps back
@@ -1909,7 +1916,7 @@ function AppInner() {
lastFetchTimeMs={lastFetchTimeMs}
currentProjectPath={currentProject.path}
onOpenProjectDirectory={handleOpenProjectDirectory}
keyboardOpen={mobileNavKeyboardOpen}
keyboardOpen={footerKeyboardOpen}
hideWhenKeyboardOpen={mobileKeyboardOpen}
/>
)}

View File

@@ -506,9 +506,11 @@ describe("ExecutorStatusBar", () => {
expect(screen.getByRole("status")).toHaveClass("executor-status-bar--keyboard-open");
});
it("does not apply keyboard-open class when keyboardOpen is false", () => {
it("does not apply keyboard-open class and remains rendered when keyboardOpen is false", () => {
render(<ExecutorStatusBar tasks={emptyTasks} keyboardOpen={false} />);
expect(screen.getByRole("status")).not.toHaveClass("executor-status-bar--keyboard-open");
const status = screen.getByRole("status");
expect(status).toBeInTheDocument();
expect(status).not.toHaveClass("executor-status-bar--keyboard-open");
});
});

View File

@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import { computeMobileBarKeyboardFlags } from "../mobileBarKeyboardFlags";
describe("computeMobileBarKeyboardFlags", () => {
it("keeps footer rendered and uncollapsed on Android when keyboard is open", () => {
const flags = computeMobileBarKeyboardFlags({
isMobile: true,
keyboardOpen: true,
anyModalOpen: false,
isIOS: false,
});
expect(flags.footerKeyboardOpen).toBe(false);
expect(flags.footerHidden).toBe(false);
expect(flags.navKeyboardOpen).toBe(true);
});
it("hides and collapses footer on iOS when keyboard is open and no modal is open", () => {
const flags = computeMobileBarKeyboardFlags({
isMobile: true,
keyboardOpen: true,
anyModalOpen: false,
isIOS: true,
});
expect(flags.footerHidden).toBe(true);
expect(flags.navKeyboardOpen).toBe(true);
expect(flags.footerKeyboardOpen).toBe(true);
});
it("keeps footer visible when iOS keyboard is open over a modal", () => {
const flags = computeMobileBarKeyboardFlags({
isMobile: true,
keyboardOpen: true,
anyModalOpen: true,
isIOS: true,
});
expect(flags.footerHidden).toBe(false);
expect(flags.footerKeyboardOpen).toBe(true);
expect(flags.navKeyboardOpen).toBe(true);
});
it("returns all false when keyboard is closed", () => {
const flags = computeMobileBarKeyboardFlags({
isMobile: true,
keyboardOpen: false,
anyModalOpen: false,
isIOS: true,
});
expect(flags).toEqual({
footerHidden: false,
navKeyboardOpen: false,
footerKeyboardOpen: false,
});
});
it("returns all false when not mobile", () => {
const flags = computeMobileBarKeyboardFlags({
isMobile: false,
keyboardOpen: true,
anyModalOpen: false,
isIOS: true,
});
expect(flags).toEqual({
footerHidden: false,
navKeyboardOpen: false,
footerKeyboardOpen: false,
});
});
});

View File

@@ -0,0 +1,36 @@
export interface MobileBarKeyboardFlagsInput {
isMobile: boolean;
keyboardOpen: boolean;
anyModalOpen: boolean;
isIOS: boolean;
}
export interface MobileBarKeyboardFlags {
footerHidden: boolean;
navKeyboardOpen: boolean;
footerKeyboardOpen: boolean;
}
/**
* FN-5707: Android uses `interactive-widget=resizes-content`, so the layout
* viewport shrinks with the keyboard and the footer's normal stacked bottom
* 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.
*/
export function computeMobileBarKeyboardFlags({
isMobile,
keyboardOpen,
anyModalOpen,
isIOS,
}: MobileBarKeyboardFlagsInput): MobileBarKeyboardFlags {
const footerHidden = isMobile && keyboardOpen && !anyModalOpen && isIOS;
const navKeyboardOpen = isMobile && keyboardOpen;
const footerKeyboardOpen = navKeyboardOpen && isIOS;
return {
footerHidden,
navKeyboardOpen,
footerKeyboardOpen,
};
}