fix(dashboard): treat keyboard as closed on blur instead of waiting for vv settle

useMobileKeyboard now requires a focused input for keyboardOpen=true (both
the chrome-overlap and iOS-gap paths). The moment an input blurs, the hook
reports keyboardOpen=false instead of waiting hundreds of ms for iOS's
visualViewport dismissal animation to settle.

This makes App-level mobileKeyboardOpen flip false instantly on blur, so
MobileNavBar reappears and project-content regains nav-bar padding in the
same frame. The ChatView composer (and TodoModal/PlanningModeModal) snap
to their post-keyboard layout in one move instead of crawling down with
iOS's keyboard slide.

Replaces the per-component 450ms suppress hack in ChatView (also dropped
in this commit) which couldn't reach the parent layout's nav padding
state and produced "below tab bar then snap up" jitter.

Adds a regression test and updates four existing tests that assumed
"vv shrinks → keyboard up" without focus (focus is now required).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-03 19:34:28 -07:00
parent 4bb3af220e
commit 330541049d
4 changed files with 85 additions and 51 deletions

View File

@@ -6,4 +6,4 @@ Fix mobile keyboard regressions in the dashboard.
- **Dashboard pushed up after closing a modal on mobile.** Adds a shared `useMobileScrollLock` hook that pins `body` with `position: fixed; top: -scrollY; width: 100%` while a fullscreen mobile overlay is open and restores scroll on cleanup — the same pattern Bootstrap, Headless UI, and Stripe Elements use to prevent iOS Safari from scrolling the document (and shifting `visualViewport.offsetTop`) when an input inside a `position: fixed` overlay is focused. Reference-counted so nested overlays don't release each other's locks. Wired into TodoModal, PlanningModeModal, TaskDetailModal, NewTaskModal, SettingsModal, MailboxModal, AddNodeModal, MissionInterviewModal, MilestoneSliceInterviewModal, SubtaskBreakdownModal, GitHubImportModal, AgentGenerationModal, AgentImportModal, ScriptsModal, ResearchTaskActionModal, and ChatView (replacing its inline body-overflow effect).
- **Auto-reload prompt missed rebuilds.** Widens `computeBuildVersion` in `vite.config.ts` to hash the entire `app/` source tree (FN-3333 follow-up). The previous version only hashed `app/main.tsx` and `package.json`, so edits to any other component or stylesheet produced an identical build version and the version-check poll never noticed the rebuild.
- **ChatView composer crawled down with iOS's keyboard-dismiss animation.** On blur, ChatView now suppresses keyboard-aware sizing for ~450ms so the composer snaps back to full height immediately instead of following iOS's slow keyboard slide-out (matches the existing QuickChatFAB behavior).
- **ChatView composer crawled down with iOS's keyboard-dismiss animation.** `useMobileKeyboard` now requires a focused input for `keyboardOpen=true`. The moment any input blurs, `keyboardOpen` flips to `false` instead of waiting for iOS's slow visualViewport animation to settle (hundreds of ms). This propagates to App-level `mobileKeyboardOpen` so the MobileNavBar reappears and `project-content` regains its nav-bar padding immediately — chat-thread, modals, and any other consumer all snap to their post-keyboard layout in one frame.

View File

@@ -785,32 +785,14 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
enabled: isMobile && !!activeSession,
});
// Suppresses keyboard-following styles for ~450ms after blur so the
// composer snaps to full height immediately instead of crawling down with
// iOS's keyboard-dismiss animation. See `handleInputBlur` below for where
// this is set, and QuickChatFAB's `suppressVvShrinkRef` for the same trick
// applied to the FAB panel.
const [suppressKeyboardStyle, setSuppressKeyboardStyle] = useState(false);
const suppressKeyboardStyleTimeoutRef = useRef<number | null>(null);
const threadKeyboardStyle: CSSProperties = keyboardOpen
? suppressKeyboardStyle
? // Suppress window: snap composer to the final post-keyboard height
// immediately, but reserve space for the MobileNavBar that is about
// to reappear once App-level keyboardOpen settles to false. Without
// the reservation the composer briefly extends below the nav bar
// position, then jumps up when the nav reappears.
({
"--keyboard-overlap":
"calc(var(--mobile-nav-height, 44px) + env(safe-area-inset-bottom, 0px))",
"--vv-offset-top": "0px",
} as CSSProperties)
: ({
const threadKeyboardStyle: CSSProperties =
keyboardOpen
? ({
"--keyboard-overlap": `${keyboardOverlap}px`,
"--vv-offset-top": `${viewportOffsetTop}px`,
...(viewportHeight !== null ? { "--vv-height": `${viewportHeight}px` } : {}),
} as CSSProperties)
: {};
: {};
const filteredSkills = useMemo(() => {
const normalizedFilter = skillFilter.trim().toLowerCase();
@@ -1334,22 +1316,6 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
return;
}
// Pre-grow the composer ahead of iOS's keyboard-dismiss animation so it
// snaps to full height immediately instead of crawling down with the
// keyboard. The suppress window covers the iOS slide-out duration
// (~250ms with margin) during which visualViewport keeps reporting
// mid-dismiss heights that would otherwise drag the panel back down.
if (isMobile) {
setSuppressKeyboardStyle(true);
if (suppressKeyboardStyleTimeoutRef.current !== null) {
window.clearTimeout(suppressKeyboardStyleTimeoutRef.current);
}
suppressKeyboardStyleTimeoutRef.current = window.setTimeout(() => {
setSuppressKeyboardStyle(false);
suppressKeyboardStyleTimeoutRef.current = null;
}, 450);
}
if (hideSkillMenuTimeoutRef.current !== null) {
window.clearTimeout(hideSkillMenuTimeoutRef.current);
}
@@ -1363,16 +1329,9 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
fileMention.dismissMention();
hideSkillMenuTimeoutRef.current = null;
}, 120);
}, [fileMention, focusComposerInput, isMobile]);
}, [fileMention, focusComposerInput]);
const handleInputFocus = useCallback(() => {
// If the user re-focuses inside the post-blur suppress window, lift it
// immediately so the keyboard-aware sizing kicks back in.
if (suppressKeyboardStyleTimeoutRef.current !== null) {
window.clearTimeout(suppressKeyboardStyleTimeoutRef.current);
suppressKeyboardStyleTimeoutRef.current = null;
}
setSuppressKeyboardStyle(false);
if (hideSkillMenuTimeoutRef.current !== null) {
window.clearTimeout(hideSkillMenuTimeoutRef.current);
hideSkillMenuTimeoutRef.current = null;

View File

@@ -122,6 +122,10 @@ describe("useMobileKeyboard", () => {
vvHeight: 600,
});
const input = document.createElement("textarea");
document.body.appendChild(input);
input.focus();
const { result } = renderHook(() => useMobileKeyboard());
await waitFor(() => {
@@ -148,6 +152,8 @@ describe("useMobileKeyboard", () => {
expect(result.current.keyboardOverlap).toBe(100);
expect(result.current.viewportHeight).toBe(700);
});
input.remove();
});
it("unsubscribes listeners and resets state when disabled", async () => {
@@ -156,6 +162,10 @@ describe("useMobileKeyboard", () => {
vvHeight: 600,
});
const input = document.createElement("textarea");
document.body.appendChild(input);
input.focus();
const { result, rerender } = renderHook(
({ enabled }) => useMobileKeyboard({ enabled }),
{ initialProps: { enabled: true } },
@@ -178,6 +188,8 @@ describe("useMobileKeyboard", () => {
expect(mockVV.removeEventListener).toHaveBeenCalledWith("resize", resizeListener);
expect(mockVV.removeEventListener).toHaveBeenCalledWith("scroll", scrollListener);
input.remove();
});
it("uses iOS Safari fallback when innerHeight shrinks with visualViewport", async () => {
@@ -186,6 +198,10 @@ describe("useMobileKeyboard", () => {
vvHeight: 844,
});
const input = document.createElement("textarea");
document.body.appendChild(input);
input.focus();
const { result } = renderHook(() => useMobileKeyboard());
await waitFor(() => {
@@ -212,6 +228,8 @@ describe("useMobileKeyboard", () => {
expect(result.current.keyboardOverlap).toBe(324);
expect(result.current.viewportHeight).toBe(520);
});
input.remove();
});
it("reports moderate iOS fallback overlap below 80px", async () => {
@@ -220,6 +238,10 @@ describe("useMobileKeyboard", () => {
vvHeight: 844,
});
const input = document.createElement("textarea");
document.body.appendChild(input);
input.focus();
const { result } = renderHook(() => useMobileKeyboard());
await waitFor(() => {
@@ -245,6 +267,8 @@ describe("useMobileKeyboard", () => {
expect(result.current.keyboardOverlap).toBe(40);
expect(result.current.viewportHeight).toBe(804);
});
input.remove();
});
it("uses focused-input fallback for small viewport gaps", async () => {
@@ -336,6 +360,50 @@ describe("useMobileKeyboard", () => {
input.remove();
});
it("reports keyboardOpen=false the instant focus leaves an input even while visualViewport still reports keyboard-up size", async () => {
// Regression for the ChatView "composer crawls down with the keyboard"
// bug: on iOS the visualViewport keeps reporting the small mid-dismiss
// size for hundreds of ms after the user blurs an input. App-level
// layout (mobile nav bar, project-content padding) must flip back to
// no-keyboard mode immediately on blur, not when vv finally settles.
const { listeners, mockVV } = setupMobileVisualViewport({
innerHeight: 844,
vvHeight: 844,
});
const input = document.createElement("textarea");
document.body.appendChild(input);
const { result } = renderHook(() => useMobileKeyboard());
// Bring up the keyboard: focus the input, then shrink the viewport.
input.focus();
Object.defineProperty(window, "innerHeight", { value: 520, writable: true, configurable: true });
Object.defineProperty(mockVV, "height", { value: 520, writable: true, configurable: true });
act(() => {
for (const cb of listeners.resize) cb();
});
await waitFor(() => {
expect(result.current.keyboardOpen).toBe(true);
});
// Blur, but leave visualViewport still reporting the small mid-dismiss
// size — the dismissal animation takes hundreds of ms on iOS.
input.blur();
act(() => {
for (const cb of listeners.resize) cb();
});
await waitFor(() => {
expect(result.current.keyboardOpen).toBe(false);
});
input.remove();
});
// FN-3290 regression: focusout must reset keyboard state when input blurs
describe("FN-3290: focusout resets keyboard state", () => {
it("resets keyboardOpen to false on focusout when viewport returns to baseline", async () => {

View File

@@ -64,17 +64,24 @@ function getKeyboardMetrics(): KeyboardMetrics {
updateBaselineViewportHeight(vv.height);
}
// Android/Chrome style overlap.
// Android/Chrome style overlap. Only treat as open while an input is
// actually focused — without this, the (often slow) visualViewport
// dismissal animation keeps reporting overlap > 0 for hundreds of ms
// after the user has tapped Done, which leaves App-level layout (mobile
// nav bar visibility, project-content padding) stuck in keyboard-up
// mode and makes downstream components (ChatView) jump on settle.
const chromeOverlap = Math.max(0, window.innerHeight - vv.offsetTop - vv.height);
if (chromeOverlap > 0) {
if (chromeOverlap > 0 && focused) {
return { overlap: chromeOverlap, open: true, vvHeight: vv.height, vvOffsetTop: offsetTop };
}
// iOS fallback (window.innerHeight shrinks with keyboard).
// iOS fallback (window.innerHeight shrinks with keyboard). Same focused
// requirement as above — the dismissal animation otherwise leaves the
// gap > the open-threshold for the duration of the slide.
const baselineHeight = getBaselineViewportHeight();
const gap = Math.max(0, baselineHeight - vv.offsetTop - vv.height);
if (gap >= IOS_FALLBACK_MIN_GAP_PX) {
if (gap >= IOS_FALLBACK_MIN_GAP_PX && focused) {
return { overlap: gap, open: true, vvHeight: vv.height, vvOffsetTop: offsetTop };
}