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:
7
.changeset/fn-9195-chat-ios-keyboard-composer.md
Normal file
7
.changeset/fn-9195-chat-ios-keyboard-composer.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user