FN-9195: Keep the chat composer visible above iOS keyboards

Keep Chat input reachable across software-keyboard viewport changes.

- Track visual viewport changes across phones, tablets, compact docks, and narrow floating Chat hosts.
- Clamp thread height using the measured host offset while preserving iOS focus behavior.
- Add regression coverage, shared viewport test helpers, operator documentation, and a patch changeset.

Files changed:
 .changeset/fn-9195-chat-ios-keyboard-composer.md   |   7 +
 docs/dashboard-guide.md                            |   2 +-
 packages/dashboard/app/components/ChatView.css     |  44 +--
 packages/dashboard/app/components/ChatView.tsx     |  43 ++-
 .../__tests__/ChatView.ios-keyboard.test.tsx       | 335 +++++++++++++++++++++
 .../components/__tests__/ChatView.test-harness.tsx | 103 ++++++-
 6 files changed, 506 insertions(+), 28 deletions(-)

Fusion-Task-Id: FN-9195

Fusion-Task-Lineage: cdada0cc-af6c-4cf8-8ffd-0940d280b07a

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-23 09:55:53 -07:00
parent ea48af7ab5
commit 52d124bde2
6 changed files with 506 additions and 28 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Keep the Chat message box visible above the software keyboard on tablets and landscape phones.
category: fix
dev: Aligns keyboard tracking hosts, promotes the thread clamp, and measures thread viewport offset.

View File

@@ -754,7 +754,7 @@ The full **New Task** dialog includes a compact **GitHub issue or PR** picker ne
## Chat View
Chat view provides project-scoped conversations with agents. Every host—embedded Chat, Quick Chat, and the dock—starts at the conversation list. Select a conversation to open its thread.
Chat view provides project-scoped conversations with agents. Every host—embedded Chat, Quick Chat, and the dock—starts at the conversation list. Select a conversation to open its thread. When the software keyboard is open, the composer remains visible on phones in portrait and landscape, tablets/iPad, compact right-dock Chat, and narrow floating Quick Chat; desktop Chat is unaffected.
### Docked conversation sidebar

View File

@@ -496,6 +496,31 @@ FN-8191 gives the conversation action menu a token-composed border-box width. Ch
position: relative;
}
/*
FNXC:ChatComposer 2026-08-23-16:07:
This clamp is class-gated rather than width-gated: iPhone landscape, iPad, compact dock, and
narrow floating Chat can all have a visible keyboard outside a phone-width media query. The
measured thread top accounts for host chrome above Chat instead of assuming only the app header.
*/
.chat-thread--keyboard-active {
height: calc(var(--vv-height, calc(100dvh - var(--keyboard-overlap, 0px))) - var(--chat-thread-viewport-top, var(--header-height)));
max-height: calc(var(--vv-height, calc(100dvh - var(--keyboard-overlap, 0px))) - var(--chat-thread-viewport-top, var(--header-height)));
/* NOTE: the translateY drift compensation is applied imperatively in
JS (see ChatView's vv `apply()`), NOT here. Declaring
`transform`/`will-change: transform` in CSS keeps a non-`none`
transform on .chat-thread for the entire keyboard-active window —
and since .chat-thread is an ancestor of the focused composer
textarea, iOS Safari treats establishing that containing block as a
reason to blur the input and collapse the keyboard the instant it
opens. JS only sets a transform when there is real viewport drift
(offsetTop > 0); at the focus moment offsetTop is 0, so the
ancestor stays `transform: none` and the keyboard stays up. */
}
.chat-thread--keyboard-active .chat-input-area {
padding-bottom: calc(var(--space-md) + env(safe-area-inset-bottom, 0px) + var(--chat-keyboard-accessory-clearance, 0px));
}
.chat-thread-header {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
@@ -2224,25 +2249,6 @@ Thinking-section text uses the defined muted text token across all themes. The m
overflow-y: auto;
}
.chat-thread--keyboard-active {
height: calc(var(--vv-height, calc(100dvh - var(--keyboard-overlap, 0px))) - var(--header-height));
max-height: calc(var(--vv-height, calc(100dvh - var(--keyboard-overlap, 0px))) - var(--header-height));
/* NOTE: the translateY drift compensation is applied imperatively in
JS (see ChatView's vv `apply()`), NOT here. Declaring
`transform`/`will-change: transform` in CSS keeps a non-`none`
transform on .chat-thread for the entire keyboard-active window —
and since .chat-thread is an ancestor of the focused composer
textarea, iOS Safari treats establishing that containing block as a
reason to blur the input and collapse the keyboard the instant it
opens. JS only sets a transform when there is real viewport drift
(offsetTop > 0); at the focus moment offsetTop is 0, so the
ancestor stays `transform: none` and the keyboard stays up. */
}
.chat-thread--keyboard-active .chat-input-area {
padding-bottom: calc(var(--space-md) + env(safe-area-inset-bottom, 0px) + var(--chat-keyboard-accessory-clearance, 0px));
}
/* On mobile, the active scope affordance uses a full-width pinned footer. */
.chat-sidebar-footer {
display: block;

View File

@@ -912,6 +912,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
const isMobile = mode === "mobile";
const isTablet = mode === "tablet";
const chatViewRef = useRef<HTMLDivElement>(null);
const appliedThreadTranslateYRef = useRef(0);
const [floatingNarrow, setFloatingNarrow] = useState(false);
/*
FNXC:ChatModal 2026-06-22-14:38:
@@ -938,6 +939,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
return () => observer.disconnect();
}, [floating]);
const isChatMobile = isMobile || floatingNarrow || compactLayout;
const keyboardTrackedHost = isChatMobile || isTablet;
/*
FNXC:ChatNavigation 2026-08-23-03:40:
FN-9193 restores an optional conversation list only for non-floating tablet-or-wider hosts.
@@ -1053,9 +1055,15 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
}, [activeDraftKey, messageInput]);
const roomThreadActive = chatRoomsEnabled && chatScope === "rooms" && !!rooms.activeRoom;
/*
FNXC:ChatComposer 2026-08-23-16:07:
The composer must track the soft keyboard on every Fusion-classified Chat host, not only a
phone-width viewport. Keep enabled and allowNonMobileViewport on keyboardTrackedHost so the
hook's internal width heuristic cannot disagree with Chat's tablet, dock, or floating host.
*/
const { keyboardOverlap, keyboardOpen } = useMobileKeyboard({
enabled: (isChatMobile || isTablet) && (!!activeSession || roomThreadActive),
allowNonMobileViewport: isTablet,
enabled: keyboardTrackedHost && (!!activeSession || roomThreadActive),
allowNonMobileViewport: keyboardTrackedHost,
});
const filteredSkills = useMemo(() => {
@@ -1496,10 +1504,18 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
// window.scrollTo(0, 0) on cleanup to recover from any iOS drift.
useMobileKeyboardViewportLock(isMobile && keyboardOpen);
// FN-5365: mirror QuickChatFAB keyboard handling by writing visualViewport
// metrics directly to .chat-thread, avoiding React commit lag/jitter.
/*
FNXC:ChatComposer 2026-08-23-16:07:
The composer must remain inside the visual viewport whenever Fusion knows a soft keyboard is
up on phone portrait/landscape, tablet, compact dock, or narrow floating Chat. The writer,
hook enabled state, and allowNonMobileViewport deliberately share keyboardTrackedHost so their
host gates cannot drift. Detection remains a layout-height-minus-visual-height gap; the measured
thread top lets CSS account for dock/floating chrome instead of assuming only the app header.
Landscape-phone keyboard state newly reaches the existing touch guard while its body lock keeps
its own phone-width iOS gate, so this does not add body pinning on wide hosts.
*/
useLayoutEffect(() => {
if (!isMobile || (!activeSession && !roomThreadActive)) return;
if (!keyboardTrackedHost || (!activeSession && !roomThreadActive)) return;
if (typeof window === "undefined") return;
const thread = chatThreadRef.current;
@@ -1518,8 +1534,10 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
if (suppressVvShrinkRef.current) {
thread.classList.remove("chat-thread--keyboard-active");
thread.style.setProperty("--chat-keyboard-accessory-clearance", "0px");
thread.style.removeProperty("--chat-thread-viewport-top");
thread.style.transform = "";
thread.style.willChange = "";
appliedThreadTranslateYRef.current = 0;
return;
}
const overlap = Math.max(0, window.innerHeight - vv.offsetTop - vv.height);
@@ -1528,6 +1546,15 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
thread.style.setProperty("--vv-offset-top", `${offsetTop}px`);
thread.style.setProperty("--keyboard-overlap", `${overlap}px`);
const threadRect = thread.getBoundingClientRect();
if (threadRect.height > 0) {
const untransformedTop = Math.max(0, threadRect.top - appliedThreadTranslateYRef.current - offsetTop);
const viewportTop = Math.min(vv.height, untransformedTop);
thread.style.setProperty("--chat-thread-viewport-top", `${viewportTop}px`);
} else {
thread.style.removeProperty("--chat-thread-viewport-top");
}
const keyboardActive = (overlap > 0 || offsetTop > 0) && isKeyboardTrackingFocusable(document.activeElement);
thread.classList.toggle("chat-thread--keyboard-active", keyboardActive);
/*
@@ -1550,9 +1577,11 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
if (keyboardActive && offsetTop > 0) {
thread.style.transform = `translateY(${offsetTop}px)`;
thread.style.willChange = "transform";
appliedThreadTranslateYRef.current = offsetTop;
} else {
thread.style.transform = "";
thread.style.willChange = "";
appliedThreadTranslateYRef.current = 0;
}
};
@@ -1572,10 +1601,12 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
document.removeEventListener("visibilitychange", apply);
thread.classList.remove("chat-thread--keyboard-active");
thread.style.setProperty("--chat-keyboard-accessory-clearance", "0px");
thread.style.removeProperty("--chat-thread-viewport-top");
thread.style.transform = "";
thread.style.willChange = "";
appliedThreadTranslateYRef.current = 0;
};
}, [activeSession, isMobile, roomThreadActive]);
}, [activeSession, keyboardTrackedHost, roomThreadActive]);
// Close context menu on outside click
useEffect(() => {

View File

@@ -0,0 +1,335 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, render, screen } from "@testing-library/react";
import { ChatView } from "../ChatView";
import { loadAllAppCss } from "../../test/cssFixture";
import * as useChatModule from "../../hooks/useChat";
import * as useChatRoomsModule from "../../hooks/useChatRooms";
import {
activeSessionFixture,
createRoomFixture,
defaultChatState,
defaultRoomsState,
installChatViewEnv,
mockDesktopNonTouchViewport,
mockPhoneLandscapeViewport,
mockTabletClassTouchViewport,
mockViewportMode,
mockVisualViewport,
setLayoutViewportHeight,
setVisualViewportHeight,
setVisualViewportOffsetTop,
setupMockChat,
setupMockRooms,
simulateKeyboardOpen,
} from "./ChatView.test-harness";
vi.mock("../../hooks/useChat");
vi.mock("../../hooks/useChatRooms");
vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => ({
...(await importOriginal<typeof import("../../hooks/useNavigationHistory")>()),
useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }),
}));
vi.mock("../../api", async (importOriginal) => ({
...(await importOriginal<typeof import("../../api")>()),
fetchAgents: vi.fn().mockResolvedValue([]),
fetchModels: vi.fn().mockResolvedValue({ models: [] }),
fetchDiscoveredSkills: vi.fn().mockResolvedValue([]),
fetchTasks: vi.fn().mockResolvedValue([]),
searchFiles: vi.fn().mockResolvedValue({ files: [] }),
}));
const css = loadAllAppCss();
const mockUseChat = vi.mocked(useChatModule.useChat);
const mockUseChatRooms = vi.mocked(useChatRoomsModule.useChatRooms);
installChatViewEnv();
afterEach(() => {
cleanup();
document.head.innerHTML = "";
});
async function renderChat(props: Partial<React.ComponentProps<typeof ChatView>> = {}) {
const style = document.createElement("style");
style.textContent = css;
document.head.append(style);
// Start without a selected session so selecting this row changes activeSession, matching the
// production transition that re-runs the viewport writer after the thread ref has mounted.
setupMockChat({ ...defaultChatState, sessions: [activeSessionFixture], filteredSessions: [activeSessionFixture], activeSession: undefined });
setupMockRooms(defaultRoomsState);
const view = render(<ChatView projectId="proj-123" addToast={vi.fn()} {...props} />);
await act(async () => { screen.getByTestId(`chat-session-${activeSessionFixture.id}`).click(); });
setupMockChat({ ...defaultChatState, sessions: [activeSessionFixture], filteredSessions: [activeSessionFixture], activeSession: activeSessionFixture });
view.rerender(<ChatView projectId="proj-123" addToast={vi.fn()} {...props} />);
return screen.getByTestId("chat-input") as HTMLTextAreaElement;
}
function getThread() {
return screen.getByTestId("chat-input").closest(".chat-thread") as HTMLElement;
}
async function openKeyboard(input: HTMLTextAreaElement, vv: VisualViewport, height: number) {
await act(async () => simulateKeyboardOpen({ input, vv, visualHeight: height }));
}
function observeKeyboardScroll() {
const messages = document.querySelector(".chat-messages") as HTMLElement;
let scrollTop = 0;
Object.defineProperties(messages, {
scrollHeight: { value: 1000, configurable: true },
clientHeight: { value: 300, configurable: true },
scrollTop: {
get: () => scrollTop,
set: (value: number) => { scrollTop = value; },
configurable: true,
},
});
return () => scrollTop;
}
function isInsideNarrowMediaRule(source: string, ruleIndex: number) {
const mediaIndex = source.lastIndexOf("@media (max-width: 768px)", ruleIndex);
if (mediaIndex < 0) return false;
const openIndex = source.indexOf("{", mediaIndex);
let depth = 0;
for (let index = openIndex; index < ruleIndex; index += 1) {
if (source[index] === "{") depth += 1;
if (source[index] === "}") depth -= 1;
}
return depth > 0;
}
describe("FN-9195 Chat composer visual viewport", () => {
it("preserves the layout-versus-visual viewport helper contract", () => {
const { vv, restore } = mockVisualViewport({ width: 375, height: 812 });
try {
setVisualViewportHeight(vv, 400);
expect(window.innerHeight).toBe(812);
expect(document.documentElement.clientHeight).toBe(812);
expect(vv.height).toBe(400);
expect(window.innerHeight - vv.offsetTop - vv.height).toBe(412);
setLayoutViewportHeight(vv, 600);
expect(window.innerHeight).toBe(600);
expect(document.documentElement.clientHeight).toBe(600);
expect(vv.height).toBe(600);
expect(window.innerHeight - vv.offsetTop - vv.height).toBe(0);
} finally { restore(); }
});
it("keeps the portrait phone composer in a keyboard-active thread", async () => {
const viewport = mockVisualViewport({ width: 375, height: 812 });
const mode = mockViewportMode("mobile");
try {
const input = await renderChat();
expect(window.visualViewport).toBe(viewport.vv);
await openKeyboard(input, viewport.vv, 400);
expect(getThread()).toHaveClass("chat-thread--keyboard-active");
expect(getThread().style.getPropertyValue("--vv-height")).toBe("400px");
expect(getThread().style.getPropertyValue("--keyboard-overlap")).toBe("412px");
expect(getThread().contains(input)).toBe(true);
} finally { mode.mockRestore(); viewport.restore(); }
});
it("applies the keyboard clamp to a landscape phone outside the narrow media query", async () => {
const restoreHost = mockPhoneLandscapeViewport();
const viewport = mockVisualViewport({ width: 932, height: 430 });
try {
const input = await renderChat();
await openKeyboard(input, viewport.vv, 200);
expect(getThread()).toHaveClass("chat-thread--keyboard-active");
expect(getThread().style.getPropertyValue("--vv-height")).toBe("200px");
expect(getThread().style.getPropertyValue("--keyboard-overlap")).toBe("230px");
const ruleIndex = css.indexOf(".chat-thread--keyboard-active {");
expect(ruleIndex).toBeGreaterThan(-1);
expect(isInsideNarrowMediaRule(css, ruleIndex)).toBe(false);
} finally { viewport.restore(); restoreHost(); }
});
it.each([
["tablet viewport mode", () => mockViewportMode("tablet"), 900, 1180, 700],
["tablet-class touch viewport", mockTabletClassTouchViewport, 768, 1024, 600],
])("tracks the keyboard on %s", async (_name, setup, width, layoutHeight, visualHeight) => {
const restoreHost = setup();
const viewport = mockVisualViewport({ width, height: layoutHeight });
try {
const input = await renderChat();
await openKeyboard(input, viewport.vv, visualHeight);
expect(getThread()).toHaveClass("chat-thread--keyboard-active");
expect(getThread().style.getPropertyValue("--vv-height")).toBe(`${visualHeight}px`);
} finally { viewport.restore(); restoreHost(); }
});
it.each([
["compact dock", { compactLayout: true }],
["narrow floating Chat", { floating: true }],
])("tracks the keyboard in %s", async (_name, props) => {
const restoreHost = mockDesktopNonTouchViewport();
const viewport = mockVisualViewport({ width: 1280, height: 900 });
try {
const input = await renderChat(props);
await openKeyboard(input, viewport.vv, 500);
expect(getThread()).toHaveClass("chat-thread--keyboard-active");
if (props.floating) expect(document.querySelector(".chat-view")).toHaveClass("chat-view--narrow");
} finally { viewport.restore(); restoreHost(); }
});
it("does not fabricate keyboard layout state on desktop", async () => {
const restoreHost = mockDesktopNonTouchViewport();
const viewport = mockVisualViewport({ width: 1280, height: 900 });
try {
const input = await renderChat();
await openKeyboard(input, viewport.vv, 500);
expect(getThread()).not.toHaveClass("chat-thread--keyboard-active");
expect(getThread().style.getPropertyValue("--vv-height")).toBe("");
expect(getThread().style.transform).toBe("");
} finally { viewport.restore(); restoreHost(); }
});
it("makes the landscape keyboard state reachable without pinning the body", async () => {
const restoreHost = mockPhoneLandscapeViewport();
const viewport = mockVisualViewport({ width: 932, height: 430 });
try {
const input = await renderChat();
await openKeyboard(input, viewport.vv, 200);
await act(async () => undefined);
const outsideMove = new Event("touchmove", { bubbles: true, cancelable: true });
document.body.dispatchEvent(outsideMove);
expect(outsideMove.defaultPrevented).toBe(true);
const messages = document.querySelector(".chat-messages") as HTMLElement;
const messagesMove = new Event("touchmove", { bubbles: true, cancelable: true });
messages.dispatchEvent(messagesMove);
expect(messagesMove.defaultPrevented).toBe(false);
expect(document.body.style.position).not.toBe("fixed");
} finally { viewport.restore(); restoreHost(); }
});
it("anchors messages after the keyboard opens on landscape, compact dock, and floating hosts", async () => {
for (const [name, setupHost, props, width, layoutHeight, visualHeight] of [
["landscape", mockPhoneLandscapeViewport, {}, 932, 430, 200],
["compact dock", mockDesktopNonTouchViewport, { compactLayout: true }, 1280, 900, 500],
["narrow floating", mockDesktopNonTouchViewport, { floating: true }, 1280, 900, 500],
] as const) {
const restoreHost = setupHost();
const viewport = mockVisualViewport({ width, height: layoutHeight });
try {
const input = await renderChat(props);
const readScrollTop = observeKeyboardScroll();
await openKeyboard(input, viewport.vv, visualHeight);
expect(readScrollTop(), name).toBe(1000);
} finally { cleanup(); viewport.restore(); restoreHost(); }
}
});
it("keeps the active room-thread render path keyboard-active", async () => {
const restoreHost = mockDesktopNonTouchViewport();
const viewport = mockVisualViewport({ width: 1280, height: 900 });
const room = createRoomFixture("keyboard-room");
try {
localStorage.setItem("fusion:chat-scope", "rooms");
setupMockChat({ sessions: [], filteredSessions: [] });
setupMockRooms({ rooms: [room], activeRoom: null });
const style = document.createElement("style");
style.textContent = css;
document.head.append(style);
const view = render(<ChatView projectId="proj-123" addToast={vi.fn()} compactLayout />);
await act(async () => { screen.getByTestId("chat-room-item-keyboard-room").click(); });
setupMockRooms({ rooms: [room], activeRoom: room });
view.rerender(<ChatView projectId="proj-123" addToast={vi.fn()} compactLayout />);
const input = screen.getByTestId("chat-input") as HTMLTextAreaElement;
expect(getThread().querySelector(".chat-room-thread-header")).toBeTruthy();
await openKeyboard(input, viewport.vv, 500);
expect(getThread()).toHaveClass("chat-thread--keyboard-active");
expect(getThread().querySelector(".chat-room-thread-header")).toBeTruthy();
} finally { viewport.restore(); restoreHost(); }
});
it("does not fabricate keyboard state for an unshrunk compact host", async () => {
const restoreHost = mockDesktopNonTouchViewport();
const viewport = mockVisualViewport({ width: 1280, height: 900 });
try {
const input = await renderChat({ compactLayout: true });
await act(async () => input.focus());
const move = new Event("touchmove", { bubbles: true, cancelable: true });
document.body.dispatchEvent(move);
expect(getThread()).not.toHaveClass("chat-thread--keyboard-active");
expect(move.defaultPrevented).toBe(false);
} finally { viewport.restore(); restoreHost(); }
});
it("only applies drift compensation when visual viewport offset is nonzero", async () => {
const viewport = mockVisualViewport({ width: 375, height: 812 });
const mode = mockViewportMode("mobile");
try {
const input = await renderChat();
await openKeyboard(input, viewport.vv, 400);
expect(getThread().style.transform).toBe("");
await act(async () => setVisualViewportOffsetTop(viewport.vv, 40));
expect(getThread().style.transform).toBe("translateY(40px)");
expect(getThread().style.willChange).toBe("transform");
expect(css.slice(css.indexOf(".chat-thread--keyboard-active {"), css.indexOf(".chat-thread-header"))).not.toMatch(/\n\s*(transform|will-change)\s*:/);
} finally { mode.mockRestore(); viewport.restore(); }
});
it("does not activate keyboard consumers for pinch zoom", async () => {
const restoreHost = mockPhoneLandscapeViewport();
const viewport = mockVisualViewport({ width: 932, height: 430 });
try {
const input = await renderChat();
observeKeyboardScroll();
Object.defineProperty(viewport.vv, "scale", { value: 1.5, writable: true, configurable: true });
await openKeyboard(input, viewport.vv, 200);
const move = new Event("touchmove", { bubbles: true, cancelable: true });
document.body.dispatchEvent(move);
// The visual-viewport writer deliberately remains scale-agnostic; this verifies the shared
// keyboard hook does not turn a pinch sample into touch keyboard state.
expect(move.defaultPrevented).toBe(false);
} finally { viewport.restore(); restoreHost(); }
});
it("no-ops cleanly when visualViewport is unavailable", async () => {
const descriptor = Object.getOwnPropertyDescriptor(window, "visualViewport");
const mode = mockViewportMode("mobile");
try {
delete (window as { visualViewport?: VisualViewport }).visualViewport;
const input = await renderChat();
await act(async () => input.focus());
expect(getThread()).not.toHaveClass("chat-thread--keyboard-active");
} finally {
mode.mockRestore();
if (descriptor) Object.defineProperty(window, "visualViewport", descriptor);
}
});
it("clears keyboard layout state during blur suppression and unmount", async () => {
const viewport = mockVisualViewport({ width: 375, height: 812 });
const mode = mockViewportMode("mobile");
try {
const input = await renderChat();
await openKeyboard(input, viewport.vv, 400);
const thread = getThread();
expect(thread).toHaveClass("chat-thread--keyboard-active");
await act(async () => input.blur());
await act(async () => setVisualViewportHeight(viewport.vv, 400));
expect(thread).not.toHaveClass("chat-thread--keyboard-active");
expect(thread.style.getPropertyValue("--chat-keyboard-accessory-clearance")).toBe("0px");
expect(thread.style.transform).toBe("");
cleanup();
expect(thread).not.toHaveClass("chat-thread--keyboard-active");
expect(thread.style.getPropertyValue("--chat-keyboard-accessory-clearance")).toBe("0px");
expect(thread.style.willChange).toBe("");
} finally { viewport.restore(); mode.mockRestore(); }
});
it("uses the header fallback when a test layout has no measurable thread rectangle", async () => {
const viewport = mockVisualViewport({ width: 375, height: 812 });
const mode = mockViewportMode("mobile");
try {
const input = await renderChat();
await openKeyboard(input, viewport.vv, 400);
expect(getThread().style.getPropertyValue("--chat-thread-viewport-top")).toBe("");
expect(css).toContain("var(--chat-thread-viewport-top, var(--header-height))");
} finally { mode.mockRestore(); viewport.restore(); }
});
});
void mockUseChat;
void mockUseChatRooms;

View File

@@ -224,7 +224,7 @@ export function mockViewportMode(mode: "mobile" | "tablet" | "desktop") {
ensureMatchMedia();
const width = { mobile: 375, tablet: 900, desktop: 1280 }[mode];
Object.defineProperty(window, "innerWidth", { value: width, configurable: true });
return vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
return vi.spyOn(window, "matchMedia").mockImplementation((query: string = "") => ({
matches:
(mode === "mobile" && (query === "(max-width: 768px)" || query === "(max-width: 768px), (max-height: 480px)")) ||
(mode === "tablet" && query.includes("min-width: 769px") && query.includes("max-width: 1024px")),
@@ -243,6 +243,105 @@ FNXC:DashboardTests 2026-08-23-03:40:
FN-9193 also covers tablet-class touch hardware at 768 CSS pixels. Touch and physical screen
signals are required by useViewportMode; without them this setup silently becomes mobile.
*/
/*
FNXC:DashboardTests 2026-08-23-16:07:
Keyboard tests need a visual viewport whose height can shrink independently from layout height. iOS
reports that gap while its software keyboard is open; syncing both values would make the keyboard
formula zero and silently make tests vacuous.
*/
export function mockVisualViewport({ width, height }: { width: number; height: number }) {
const visualViewportDescriptor = Object.getOwnPropertyDescriptor(window, "visualViewport");
const innerHeightDescriptor = Object.getOwnPropertyDescriptor(window, "innerHeight");
const clientHeightDescriptor = Object.getOwnPropertyDescriptor(document.documentElement, "clientHeight");
const vv = new EventTarget() as unknown as VisualViewport;
for (const [key, value] of Object.entries({ width, height, offsetTop: 0, offsetLeft: 0, pageTop: 0, pageLeft: 0, scale: 1 })) {
Object.defineProperty(vv, key, { value, writable: true, configurable: true });
}
Object.defineProperty(window, "visualViewport", { value: vv, configurable: true });
Object.defineProperty(window, "innerHeight", { value: height, configurable: true });
Object.defineProperty(document.documentElement, "clientHeight", { value: height, configurable: true });
return {
vv,
restore: () => {
if (visualViewportDescriptor) Object.defineProperty(window, "visualViewport", visualViewportDescriptor);
else delete (window as { visualViewport?: VisualViewport }).visualViewport;
if (innerHeightDescriptor) Object.defineProperty(window, "innerHeight", innerHeightDescriptor);
if (clientHeightDescriptor) Object.defineProperty(document.documentElement, "clientHeight", clientHeightDescriptor);
},
};
}
/* FNXC:DashboardTests 2026-08-23-16:07: A software keyboard changes only visualViewport height; retain layout height so overlap remains observable. */
export function setVisualViewportHeight(vv: VisualViewport, height: number) {
Object.defineProperty(vv, "height", { value: height, writable: true, configurable: true });
vv.dispatchEvent(new Event("resize"));
}
/* FNXC:DashboardTests 2026-08-23-16:07: A genuine layout resize changes layout and visual heights together, unlike keyboard opening. */
export function setLayoutViewportHeight(vv: VisualViewport, height: number) {
Object.defineProperty(window, "innerHeight", { value: height, configurable: true });
Object.defineProperty(document.documentElement, "clientHeight", { value: height, configurable: true });
setVisualViewportHeight(vv, height);
}
export function setVisualViewportOffsetTop(vv: VisualViewport, offsetTop: number) {
Object.defineProperty(vv, "offsetTop", { value: offsetTop, writable: true, configurable: true });
vv.dispatchEvent(new Event("scroll"));
}
export function simulateKeyboardOpen({ vv, input, visualHeight }: { vv: VisualViewport; input: HTMLElement; visualHeight: number }) {
input.focus();
input.dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
setVisualViewportHeight(vv, visualHeight);
}
/*
FNXC:DashboardTests 2026-08-23-16:07:
This reproduces a phone-class landscape screen: Chat resolves mobile from short height while the
shared hook's width heuristic sees 932px, covering the previously mismatched gate.
*/
export function mockPhoneLandscapeViewport() {
ensureMatchMedia();
const widthDescriptor = Object.getOwnPropertyDescriptor(window, "innerWidth");
const heightDescriptor = Object.getOwnPropertyDescriptor(window, "innerHeight");
const screenDescriptor = Object.getOwnPropertyDescriptor(window, "screen");
const touchDescriptor = Object.getOwnPropertyDescriptor(navigator, "maxTouchPoints");
Object.defineProperty(window, "innerWidth", { value: 932, configurable: true });
Object.defineProperty(window, "innerHeight", { value: 430, configurable: true });
Object.defineProperty(window, "screen", { value: { width: 430, height: 932 }, configurable: true });
Object.defineProperty(navigator, "maxTouchPoints", { value: 1, configurable: true });
const spy = vi.spyOn(window, "matchMedia").mockImplementation((query: string = "") => ({
matches: query === "(max-height: 480px)" || query === "(max-width: 768px), (max-height: 480px)",
media: query, onchange: null, addListener: vi.fn(), removeListener: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn(),
}));
return () => {
spy.mockRestore();
if (widthDescriptor) Object.defineProperty(window, "innerWidth", widthDescriptor);
if (heightDescriptor) Object.defineProperty(window, "innerHeight", heightDescriptor);
if (screenDescriptor) Object.defineProperty(window, "screen", screenDescriptor);
if (touchDescriptor) Object.defineProperty(navigator, "maxTouchPoints", touchDescriptor);
else delete (navigator as { maxTouchPoints?: number }).maxTouchPoints;
};
}
/* FNXC:DashboardTests 2026-08-23-16:07: Explicit desktop non-touch setup keeps desktop-negative and compact-host tests independent of harness defaults. */
export function mockDesktopNonTouchViewport() {
ensureMatchMedia();
const widthDescriptor = Object.getOwnPropertyDescriptor(window, "innerWidth");
const touchDescriptor = Object.getOwnPropertyDescriptor(navigator, "maxTouchPoints");
Object.defineProperty(window, "innerWidth", { value: 1280, configurable: true });
Object.defineProperty(navigator, "maxTouchPoints", { value: 0, configurable: true });
const spy = vi.spyOn(window, "matchMedia").mockImplementation((query: string = "") => ({
matches: false, media: query, onchange: null, addListener: vi.fn(), removeListener: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn(),
}));
return () => {
spy.mockRestore();
if (widthDescriptor) Object.defineProperty(window, "innerWidth", widthDescriptor);
if (touchDescriptor) Object.defineProperty(navigator, "maxTouchPoints", touchDescriptor);
else delete (navigator as { maxTouchPoints?: number }).maxTouchPoints;
};
}
export function mockTabletClassTouchViewport() {
ensureMatchMedia();
const previousWidth = window.innerWidth;
@@ -251,7 +350,7 @@ export function mockTabletClassTouchViewport() {
Object.defineProperty(window, "innerWidth", { value: 768, configurable: true });
Object.defineProperty(navigator, "maxTouchPoints", { value: 1, configurable: true });
Object.defineProperty(window, "screen", { value: { width: 768, height: 1024 }, configurable: true });
const spy = vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
const spy = vi.spyOn(window, "matchMedia").mockImplementation((query: string = "") => ({
matches: query.includes("max-width: 768px") && !query.includes("max-width: 600px") && !query.includes("max-height: 480px"),
media: query, onchange: null, addListener: vi.fn(), removeListener: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn(),
}));