feat(FN-5155): stabilize mobile keyboard viewport metrics
Stabilizes mobile keyboard viewport metrics in the `useMobileKeyboard` hook, with tests covering the hook and `ChatView` integration; a minor dashboard command adjustment is included. Fusion-Task-Id: FN-5155
This commit is contained in:
committed by
gsxdsm
parent
41d18b3f6d
commit
587e9b416c
@@ -652,35 +652,52 @@ vi.mock("@fusion/engine", async (importOriginal) => {
|
||||
ProjectEngine,
|
||||
ProjectEngineManager: vi.fn().mockImplementation((centralCore: any, options: any) => {
|
||||
const engines = new Map<string, any>();
|
||||
return {
|
||||
startAll: vi.fn(async () => {
|
||||
// Grab the most recently created TaskStore mock — this is the one
|
||||
// the dashboard created at startup. By passing it as externalTaskStore,
|
||||
// the engine shares the same store, so settings listeners and events
|
||||
// in tests work as expected.
|
||||
const starting = new Map<string, Promise<any>>();
|
||||
const startEngine = async (id: string, pathHint?: string) => {
|
||||
const existing = engines.get(id);
|
||||
if (existing) return existing;
|
||||
const pending = starting.get(id);
|
||||
if (pending) return pending;
|
||||
|
||||
const promise = (async () => {
|
||||
const { TaskStore: TSMock } = await import("@fusion/core");
|
||||
const lastStore = (TSMock as any).mock?.results?.at(-1)?.value;
|
||||
const project = pathHint ? { path: pathHint } : await centralCore.getProject(id);
|
||||
const engine = new ProjectEngine(
|
||||
{ workingDirectory: project?.path ?? process.cwd() },
|
||||
centralCore,
|
||||
{ ...options, externalTaskStore: lastStore, projectId: id },
|
||||
);
|
||||
await engine.start();
|
||||
engines.set(id, engine);
|
||||
starting.delete(id);
|
||||
return engine;
|
||||
})();
|
||||
|
||||
starting.set(id, promise);
|
||||
return promise;
|
||||
};
|
||||
|
||||
return {
|
||||
startAll: vi.fn(async () => {
|
||||
const projects = await centralCore.listProjects();
|
||||
for (const project of projects) {
|
||||
const engine = new ProjectEngine(
|
||||
{ workingDirectory: project.path },
|
||||
centralCore,
|
||||
{ ...options, externalTaskStore: lastStore, projectId: project.id },
|
||||
);
|
||||
await engine.start();
|
||||
engines.set(project.id, engine);
|
||||
await startEngine(project.id, project.path);
|
||||
}
|
||||
}),
|
||||
getEngine: vi.fn((id: string) => engines.get(id)),
|
||||
getAllEngines: vi.fn(() => engines),
|
||||
getStore: vi.fn((id: string) => engines.get(id)?.getTaskStore()),
|
||||
has: vi.fn((id: string) => engines.has(id)),
|
||||
ensureEngine: vi.fn(async (id: string) => engines.get(id)),
|
||||
has: vi.fn((id: string) => engines.has(id) || starting.has(id)),
|
||||
ensureEngine: vi.fn(async (id: string) => startEngine(id)),
|
||||
stopAll: vi.fn(async () => {
|
||||
for (const engine of engines.values()) await engine.stop();
|
||||
engines.clear();
|
||||
starting.clear();
|
||||
}),
|
||||
onProjectAccessed: vi.fn((id: string) => {
|
||||
void startEngine(id);
|
||||
}),
|
||||
onProjectAccessed: vi.fn(),
|
||||
startReconciliation: vi.fn(),
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -1585,7 +1585,9 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
try {
|
||||
const registered = await centralCoreForEngine.getProjectByPath(cwd).catch(() => null);
|
||||
if (registered) {
|
||||
cwdEngine = engineManager.getEngine(registered.id);
|
||||
// Ensure the cwd project's engine exists before handing HTTP defaults to
|
||||
// createServer; background startAll may still be warming other projects.
|
||||
cwdEngine = await engineManager.ensureEngine(registered.id);
|
||||
}
|
||||
} catch {
|
||||
// cwd not registered — no engine defaults for HTTP layer
|
||||
|
||||
@@ -1149,17 +1149,17 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
enabled: isMobile && (!!activeSession || roomThreadActive),
|
||||
});
|
||||
|
||||
// Only opt into visual-viewport sizing when we have concrete keyboard
|
||||
// displacement (overlap or offset). The shared hook can report `keyboardOpen`
|
||||
// during iOS settle/shrink phases with zero overlap; forcing vv-height in that
|
||||
// transient state shrinks the thread and pushes the composer upward.
|
||||
const hasKeyboardViewportDisplacement = keyboardOverlap > 0 || viewportOffsetTop > 0;
|
||||
// FN-5155: only publish keyboard-active viewport vars once the hook has a
|
||||
// self-consistent open sample. A transient offsetTop without vvHeight causes
|
||||
// mobile ChatView to shrink/translate the thread before the keyboard settles.
|
||||
const hasKeyboardViewportMetrics = viewportHeight !== null;
|
||||
const hasKeyboardViewportDisplacement = hasKeyboardViewportMetrics && (keyboardOverlap > 0 || viewportOffsetTop > 0);
|
||||
const threadKeyboardStyle: CSSProperties =
|
||||
keyboardOpen && hasKeyboardViewportDisplacement
|
||||
? ({
|
||||
"--keyboard-overlap": `${keyboardOverlap}px`,
|
||||
"--vv-offset-top": `${viewportOffsetTop}px`,
|
||||
...(viewportHeight !== null ? { "--vv-height": `${viewportHeight}px` } : {}),
|
||||
"--vv-height": `${viewportHeight}px`,
|
||||
} as CSSProperties)
|
||||
: {};
|
||||
const threadClassName = `chat-thread${keyboardOpen && hasKeyboardViewportDisplacement ? " chat-thread--keyboard-active" : ""}`;
|
||||
|
||||
@@ -3683,6 +3683,57 @@ describe("ChatView mobile behavior", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("FN-5155: mobile mode does not apply keyboard-active styles for impossible mid-transition viewport samples", async () => {
|
||||
const restoreMatchMedia = mockMobileViewport();
|
||||
const { mockVV } = mockMobileVisualViewport({
|
||||
innerHeight: 844,
|
||||
vvHeight: 844,
|
||||
});
|
||||
|
||||
try {
|
||||
setupMockChat({
|
||||
activeSession: activeSessionFixture,
|
||||
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }],
|
||||
});
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
const thread = document.querySelector(".chat-thread") as HTMLDivElement;
|
||||
expect(thread).toBeInTheDocument();
|
||||
|
||||
const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement;
|
||||
await act(async () => {
|
||||
textarea.focus();
|
||||
});
|
||||
|
||||
Object.defineProperty(mockVV, "offsetTop", {
|
||||
value: 180,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(mockVV, "height", {
|
||||
value: 820,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event("focusin"));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// FN-5155 fix keeps transient impossible metrics out of ChatView until
|
||||
// the viewport settles, so the composer never floats above the keyboard.
|
||||
expect(thread.classList.contains("chat-thread--keyboard-active")).toBe(false);
|
||||
expect(thread.style.getPropertyValue("--vv-height")).toBe("");
|
||||
expect(thread.style.getPropertyValue("--vv-offset-top")).toBe("");
|
||||
expect(thread.style.getPropertyValue("--keyboard-overlap")).toBe("");
|
||||
});
|
||||
} finally {
|
||||
restoreMatchMedia.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("mobile mode: removes keyboard-active class immediately on blur even before visualViewport settles", async () => {
|
||||
const restoreMatchMedia = mockMobileViewport();
|
||||
const { listeners, mockVV } = mockMobileVisualViewport({
|
||||
|
||||
@@ -451,6 +451,147 @@ describe("useMobileKeyboard", () => {
|
||||
input.remove();
|
||||
});
|
||||
|
||||
it("FN-5155: ignores impossible focusin samples until the visualViewport settles", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { mockVV } = setupMobileVisualViewport({
|
||||
innerHeight: 844,
|
||||
vvHeight: 844,
|
||||
});
|
||||
|
||||
const input = document.createElement("textarea");
|
||||
document.body.appendChild(input);
|
||||
|
||||
const { result } = renderHook(() => useMobileKeyboard());
|
||||
|
||||
expect(result.current.keyboardOpen).toBe(false);
|
||||
|
||||
input.focus();
|
||||
Object.defineProperty(mockVV, "offsetTop", {
|
||||
value: 180,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(mockVV, "height", {
|
||||
value: 820,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event("focusin"));
|
||||
});
|
||||
|
||||
// FN-5155: current main incorrectly treats this as open via the
|
||||
// viewport-shrink fallback even though offsetTop + height exceeds the
|
||||
// window height, so the impossible sample must be ignored.
|
||||
expect(result.current.keyboardOpen).toBe(false);
|
||||
expect(result.current.viewportHeight).toBeNull();
|
||||
expect(result.current.viewportOffsetTop).toBe(0);
|
||||
|
||||
Object.defineProperty(mockVV, "offsetTop", {
|
||||
value: 0,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(mockVV, "height", {
|
||||
value: 520,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
value: 520,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(60);
|
||||
});
|
||||
|
||||
expect(result.current.keyboardOpen).toBe(true);
|
||||
expect(result.current.keyboardOverlap).toBe(324);
|
||||
expect(result.current.viewportHeight).toBe(520);
|
||||
expect(result.current.viewportOffsetTop).toBe(0);
|
||||
|
||||
input.remove();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("FN-5155: converges from stale visibility-restore metrics to the settled keyboard-open viewport", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { listeners, mockVV } = setupMobileVisualViewport({
|
||||
innerHeight: 844,
|
||||
vvHeight: 844,
|
||||
});
|
||||
|
||||
const input = document.createElement("textarea");
|
||||
document.body.appendChild(input);
|
||||
|
||||
const { result } = renderHook(() => useMobileKeyboard());
|
||||
|
||||
expect(result.current.keyboardOpen).toBe(false);
|
||||
|
||||
input.focus();
|
||||
Object.defineProperty(mockVV, "offsetTop", {
|
||||
value: 160,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(mockVV, "height", {
|
||||
value: 820,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
|
||||
// FN-5155: page-restore can surface the same impossible transient sample
|
||||
// before resize settles; keep the hook closed until metrics agree.
|
||||
expect(result.current.keyboardOpen).toBe(false);
|
||||
expect(result.current.viewportHeight).toBeNull();
|
||||
expect(result.current.viewportOffsetTop).toBe(0);
|
||||
|
||||
Object.defineProperty(mockVV, "offsetTop", {
|
||||
value: 0,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(mockVV, "height", {
|
||||
value: 520,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
value: 520,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
for (const cb of listeners.resize) cb();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(60);
|
||||
});
|
||||
|
||||
expect(result.current.keyboardOpen).toBe(true);
|
||||
expect(result.current.keyboardOverlap).toBe(324);
|
||||
expect(result.current.viewportHeight).toBe(520);
|
||||
expect(result.current.viewportOffsetTop).toBe(0);
|
||||
|
||||
input.remove();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
// 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 () => {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
const IOS_FALLBACK_MIN_GAP_PX = 30;
|
||||
const IOS_FALLBACK_MIN_FOCUSED_GAP_PX = 16;
|
||||
const IOS_VIEWPORT_SHRINK_MIN_PX = 16;
|
||||
const IMPOSSIBLE_VIEWPORT_EPSILON_PX = 2;
|
||||
|
||||
/** Whether the current device is likely mobile (touch-primary, small viewport). */
|
||||
function isMobileDevice(): boolean {
|
||||
@@ -50,9 +51,24 @@ interface KeyboardMetrics {
|
||||
vvOffsetTop: number;
|
||||
}
|
||||
|
||||
function getKeyboardMetrics(): KeyboardMetrics {
|
||||
const CLOSED_KEYBOARD_METRICS: KeyboardMetrics = {
|
||||
overlap: 0,
|
||||
open: false,
|
||||
vvHeight: null,
|
||||
vvOffsetTop: 0,
|
||||
};
|
||||
|
||||
function hasImpossibleViewportSample(): boolean {
|
||||
if (typeof window === "undefined" || !window.visualViewport) {
|
||||
return { overlap: 0, open: false, vvHeight: null, vvOffsetTop: 0 };
|
||||
return false;
|
||||
}
|
||||
|
||||
return window.visualViewport.offsetTop + window.visualViewport.height > window.innerHeight + IMPOSSIBLE_VIEWPORT_EPSILON_PX;
|
||||
}
|
||||
|
||||
function getKeyboardMetrics(previousMetrics: KeyboardMetrics = CLOSED_KEYBOARD_METRICS): KeyboardMetrics {
|
||||
if (typeof window === "undefined" || !window.visualViewport) {
|
||||
return CLOSED_KEYBOARD_METRICS;
|
||||
}
|
||||
|
||||
const vv = window.visualViewport;
|
||||
@@ -64,6 +80,13 @@ function getKeyboardMetrics(): KeyboardMetrics {
|
||||
updateBaselineViewportHeight(vv.height);
|
||||
}
|
||||
|
||||
// FN-5155: iOS focus/restore can briefly report offsetTop from the keyboard
|
||||
// transition while height is still near the pre-keyboard baseline. Reject
|
||||
// that impossible snapshot and keep the last stable metrics until settle.
|
||||
if (focused && hasImpossibleViewportSample()) {
|
||||
return previousMetrics;
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -95,7 +118,7 @@ function getKeyboardMetrics(): KeyboardMetrics {
|
||||
return { overlap: 0, open: true, vvHeight: vv.height, vvOffsetTop: offsetTop };
|
||||
}
|
||||
|
||||
return { overlap: 0, open: false, vvHeight: null, vvOffsetTop: 0 };
|
||||
return CLOSED_KEYBOARD_METRICS;
|
||||
}
|
||||
|
||||
/** Reset cached viewport baseline. Exported for tests only. */
|
||||
@@ -114,6 +137,7 @@ export function useMobileKeyboard(
|
||||
const [viewportHeight, setViewportHeight] = useState<number | null>(null);
|
||||
const [viewportOffsetTop, setViewportOffsetTop] = useState(0);
|
||||
const [keyboardOpen, setKeyboardOpen] = useState(false);
|
||||
const stableMetricsRef = useRef<KeyboardMetrics>(CLOSED_KEYBOARD_METRICS);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !isMobileDevice()) {
|
||||
@@ -130,18 +154,23 @@ export function useMobileKeyboard(
|
||||
setViewportHeight(null);
|
||||
setViewportOffsetTop(0);
|
||||
setKeyboardOpen(false);
|
||||
stableMetricsRef.current = CLOSED_KEYBOARD_METRICS;
|
||||
return;
|
||||
}
|
||||
|
||||
const commitMetrics = (metrics: KeyboardMetrics) => {
|
||||
stableMetricsRef.current = metrics;
|
||||
setKeyboardOverlap(metrics.overlap);
|
||||
setViewportHeight(metrics.vvHeight);
|
||||
setViewportOffsetTop(metrics.vvOffsetTop);
|
||||
setKeyboardOpen(metrics.open);
|
||||
};
|
||||
|
||||
// Full update — used on resize and focus transitions. These are the
|
||||
// events that signal an actual keyboard open/close, so we want to
|
||||
// re-snapshot offsetTop/height/overlap.
|
||||
const update = () => {
|
||||
const metrics = getKeyboardMetrics();
|
||||
setKeyboardOverlap(metrics.overlap);
|
||||
setViewportHeight(metrics.vvHeight);
|
||||
setViewportOffsetTop(metrics.vvOffsetTop);
|
||||
setKeyboardOpen(metrics.open);
|
||||
commitMetrics(getKeyboardMetrics(stableMetricsRef.current));
|
||||
};
|
||||
|
||||
// Scroll-only update — fires on every visualViewport pan (60fps on
|
||||
@@ -152,7 +181,8 @@ export function useMobileKeyboard(
|
||||
// update height/keyboardOpen if those changed; offsetTop stays
|
||||
// pinned to whatever resize/focus last set it.
|
||||
const updateScrollOnly = () => {
|
||||
const metrics = getKeyboardMetrics();
|
||||
const metrics = getKeyboardMetrics(stableMetricsRef.current);
|
||||
stableMetricsRef.current = metrics;
|
||||
setKeyboardOverlap(metrics.overlap);
|
||||
setViewportHeight(metrics.vvHeight);
|
||||
setKeyboardOpen(metrics.open);
|
||||
@@ -180,9 +210,16 @@ export function useMobileKeyboard(
|
||||
// (e.g. switching tabs back with the keyboard up) where the
|
||||
// timed reads miss the right window.
|
||||
let rafId: number | null = null;
|
||||
let headRafId: number | null = null;
|
||||
let pollDeadline = 0;
|
||||
let lastOffsetTop = -1;
|
||||
let stableFrames = 0;
|
||||
const cancelHeadUpdate = () => {
|
||||
if (headRafId !== null && typeof window !== "undefined") {
|
||||
window.cancelAnimationFrame(headRafId);
|
||||
headRafId = null;
|
||||
}
|
||||
};
|
||||
const cancelPoll = () => {
|
||||
if (rafId !== null && typeof window !== "undefined") {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
@@ -214,7 +251,18 @@ export function useMobileKeyboard(
|
||||
rafId = window.requestAnimationFrame(pollFrame);
|
||||
};
|
||||
const updateWithTail = () => {
|
||||
update();
|
||||
cancelHeadUpdate();
|
||||
if (isKeyboardFocusableElement(document.activeElement) && hasImpossibleViewportSample()) {
|
||||
// FN-5155: focusin/page-restore can arrive before visualViewport height
|
||||
// catches up to the keyboard transition. Defer the head commit one frame
|
||||
// so the tail/poll can converge instead of publishing the stale sample.
|
||||
headRafId = window.requestAnimationFrame(() => {
|
||||
headRafId = null;
|
||||
update();
|
||||
});
|
||||
} else {
|
||||
update();
|
||||
}
|
||||
scheduleUpdate(50);
|
||||
scheduleUpdate(200);
|
||||
scheduleUpdate(500);
|
||||
@@ -244,7 +292,9 @@ export function useMobileKeyboard(
|
||||
for (const timeoutId of timeoutIds) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
cancelHeadUpdate();
|
||||
cancelPoll();
|
||||
stableMetricsRef.current = CLOSED_KEYBOARD_METRICS;
|
||||
setKeyboardOverlap(0);
|
||||
setViewportHeight(null);
|
||||
setViewportOffsetTop(0);
|
||||
|
||||
Reference in New Issue
Block a user