FN-5673: keep mobile nav and footer pinned when keyboard opens

Prevent keyboard viewport shifts from displacing mobile bottom bars while preserving iOS hide safeguards.

- Split executor footer keyboard behavior into separate hide and pinning controls
- Add keyboard-open CSS overrides to keep mobile nav/footer bottom at 0 instead of offsetting upward
- Wire App to pass distinct keyboard props and expand component/CSS contract tests for keyboard-open classes and rule ordering

Files changed:
 packages/dashboard/app/App.tsx                     |  3 ++-
 packages/dashboard/app/__tests__/mobile-bottom-bars-keyboard-layout.test.ts | 30 ++++++++++++++++++++++
 packages/dashboard/app/components/ExecutorStatusBar.css |  6 +++++
 packages/dashboard/app/components/ExecutorStatusBar.tsx | 14 +++++-----
 packages/dashboard/app/components/MobileNavBar.css |  3 ++-
 packages/dashboard/app/components/__tests__/ExecutorStatusBar.test.tsx | 17 ++++++++++++
 packages/dashboard/app/components/__tests__/MobileNavBar.test.tsx |  6 +++--
 7 files changed, 69 insertions(+), 10 deletions(-)

Fusion-Task-Id: FN-5673

Fusion-Task-Lineage: 7c2dc749-214a-48ab-af58-5638ea27ae47
This commit is contained in:
gsxdsm
2026-05-29 09:32:22 -07:00
parent ab16307750
commit 16d20063bf
7 changed files with 69 additions and 10 deletions

View File

@@ -1909,7 +1909,8 @@ function AppInner() {
lastFetchTimeMs={lastFetchTimeMs}
currentProjectPath={currentProject.path}
onOpenProjectDirectory={handleOpenProjectDirectory}
keyboardOpen={mobileKeyboardOpen}
keyboardOpen={mobileNavKeyboardOpen}
hideWhenKeyboardOpen={mobileKeyboardOpen}
/>
)}
<MobileNavBar

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { loadAllAppCss } from "../test/cssFixture";
const css = loadAllAppCss();
describe("mobile bottom bars keyboard-open css contract", () => {
it("mobile nav keyboard-open rule pins bottom to 0", () => {
const match = css.match(/\.mobile-nav-bar\.mobile-nav-bar--keyboard-open,\s*\.mobile-nav-bar\.mobile-nav-bar--with-footer\.mobile-nav-bar--keyboard-open\s*\{([^}]*)\}/m);
expect(match?.[1] ?? "").toContain("bottom: 0");
});
it("mobile nav keyboard-open rule appears after with-footer rule", () => {
const withFooterPos = css.indexOf(".mobile-nav-bar--with-footer");
const keyboardPos = css.indexOf(".mobile-nav-bar.mobile-nav-bar--keyboard-open");
expect(withFooterPos).toBeGreaterThanOrEqual(0);
expect(keyboardPos).toBeGreaterThan(withFooterPos);
});
it("executor status bar keyboard-open rule pins bottom to 0", () => {
const match = css.match(/\.executor-status-bar\.executor-status-bar--keyboard-open\s*\{([^}]*)\}/m);
expect(match?.[1] ?? "").toContain("bottom: 0");
});
it("executor status bar keyboard-open rule appears after mobile base bottom rule", () => {
const mobileBasePos = css.indexOf("bottom: calc(var(--icb-bottom-offset, 0px) + var(--mobile-nav-height)");
const keyboardPos = css.indexOf(".executor-status-bar.executor-status-bar--keyboard-open");
expect(mobileBasePos).toBeGreaterThanOrEqual(0);
expect(keyboardPos).toBeGreaterThan(mobileBasePos);
});
});

View File

@@ -298,6 +298,12 @@
bottom: calc(var(--icb-bottom-offset, 0px) + var(--mobile-nav-height) + max(env(safe-area-inset-bottom, 0px), 12px) + var(--standalone-bottom-gap));
}
/* Keyboard-open override: ignore ICB offset so the keyboard can cover the
footer instead of displacing it upward by keyboard height. */
.executor-status-bar.executor-status-bar--keyboard-open {
bottom: 0;
}
.executor-status-bar__segment {
min-width: 0;
gap: 4px;

View File

@@ -32,10 +32,12 @@ interface ExecutorStatusBarProps {
currentProjectPath?: string;
/** Opens the workspace-aware file browser to the project workspace. */
onOpenProjectDirectory?: () => void;
/** When true on mobile, hide the bar so it doesn't slide over messages
* during visualViewport pans (position:fixed is anchored to layout
* viewport, which iOS leaves below the keyboard). */
/** When true on mobile, force bottom pinning so ICB compensation does not
* push the bar above the keyboard; keyboard may cover it instead. */
keyboardOpen?: boolean;
/** iOS-only hide guard to prevent footer drifting over content while
* visualViewport settles during keyboard transitions. */
hideWhenKeyboardOpen?: boolean;
}
/**
@@ -85,8 +87,8 @@ function getStateDisplay(state: ExecutorState): { label: string; color: string;
* - Executor state badge (idle/running/paused)
* - Last activity timestamp
*/
export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleHighFanoutBlockerAgeThresholdMs, backgroundSessions, backgroundGenerating, backgroundNeedsInput, onOpenBackgroundSession, onDismissBackgroundSession, lastFetchTimeMs, currentProjectPath, onOpenProjectDirectory, keyboardOpen }: ExecutorStatusBarProps) {
if (keyboardOpen) return null;
export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleHighFanoutBlockerAgeThresholdMs, backgroundSessions, backgroundGenerating, backgroundNeedsInput, onOpenBackgroundSession, onDismissBackgroundSession, lastFetchTimeMs, currentProjectPath, onOpenProjectDirectory, keyboardOpen, hideWhenKeyboardOpen }: ExecutorStatusBarProps) {
if (hideWhenKeyboardOpen) return null;
const { stats, loading, error } = useExecutorStats(tasks, projectId, taskStuckTimeoutMs, lastFetchTimeMs);
const [isProjectPathVisible, setIsProjectPathVisible] = useState(false);
@@ -136,7 +138,7 @@ export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleH
return (
<div
className={`executor-status-bar ${stats.executorState === "running" ? "executor-status-bar--running" : ""}`}
className={`executor-status-bar ${stats.executorState === "running" ? "executor-status-bar--running" : ""}${keyboardOpen ? " executor-status-bar--keyboard-open" : ""}`}
role="status"
aria-label="Executor status"
>

View File

@@ -48,7 +48,8 @@
compensation that would otherwise push the bar up by the keyboard
height (iOS shrinks vv.height; bottomOffset becomes ~keyboard height).
Pin the nav to the page bottom so the keyboard simply covers it. */
.mobile-nav-bar--keyboard-open {
.mobile-nav-bar.mobile-nav-bar--keyboard-open,
.mobile-nav-bar.mobile-nav-bar--with-footer.mobile-nav-bar--keyboard-open {
bottom: 0;
}

View File

@@ -495,6 +495,23 @@ describe("ExecutorStatusBar", () => {
});
});
describe("mobile keyboard behavior", () => {
it("hides bar when hideWhenKeyboardOpen is true", () => {
const { container } = render(<ExecutorStatusBar tasks={emptyTasks} hideWhenKeyboardOpen={true} />);
expect(container.firstChild).toBeNull();
});
it("applies keyboard-open class when keyboardOpen is true", () => {
render(<ExecutorStatusBar tasks={emptyTasks} keyboardOpen={true} />);
expect(screen.getByRole("status")).toHaveClass("executor-status-bar--keyboard-open");
});
it("does not apply keyboard-open class when keyboardOpen is false", () => {
render(<ExecutorStatusBar tasks={emptyTasks} keyboardOpen={false} />);
expect(screen.getByRole("status")).not.toHaveClass("executor-status-bar--keyboard-open");
});
});
describe("layout integration", () => {
it("exposes stable executor-status-bar class for external layout hooks", () => {
render(<ExecutorStatusBar tasks={emptyTasks} />);

View File

@@ -635,14 +635,16 @@ describe("MobileNavBar", () => {
expect(container.querySelector(".mobile-nav-bar")).toBeNull();
});
it("renders nav bar when keyboardOpen is true on mobile", () => {
it("renders nav bar with keyboard-open class when keyboardOpen is true on mobile", () => {
const { container } = render(<MobileNavBar {...createDefaultProps()} keyboardOpen={true} />);
expect(container.querySelector(".mobile-nav-bar")).not.toBeNull();
expect(container.querySelector(".mobile-nav-bar--keyboard-open")).not.toBeNull();
});
it("renders nav bar when keyboardOpen is false on mobile", () => {
it("renders nav bar without keyboard-open class when keyboardOpen is false on mobile", () => {
const { container } = render(<MobileNavBar {...createDefaultProps()} keyboardOpen={false} />);
expect(container.querySelector(".mobile-nav-bar")).not.toBeNull();
expect(container.querySelector(".mobile-nav-bar--keyboard-open")).toBeNull();
});
it("applies footer-visible class when footer is shown", () => {