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

@@ -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 };
}