From 52d124bde2e66d4f19644c154f85603f23224fe9 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 23 Aug 2026 09:55:53 -0700 Subject: [PATCH] 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) --- .../fn-9195-chat-ios-keyboard-composer.md | 7 + docs/dashboard-guide.md | 2 +- .../dashboard/app/components/ChatView.css | 44 ++- .../dashboard/app/components/ChatView.tsx | 43 ++- .../__tests__/ChatView.ios-keyboard.test.tsx | 335 ++++++++++++++++++ .../__tests__/ChatView.test-harness.tsx | 103 +++++- 6 files changed, 506 insertions(+), 28 deletions(-) create mode 100644 .changeset/fn-9195-chat-ios-keyboard-composer.md create mode 100644 packages/dashboard/app/components/__tests__/ChatView.ios-keyboard.test.tsx diff --git a/.changeset/fn-9195-chat-ios-keyboard-composer.md b/.changeset/fn-9195-chat-ios-keyboard-composer.md new file mode 100644 index 0000000000..0d5b355dbd --- /dev/null +++ b/.changeset/fn-9195-chat-ios-keyboard-composer.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 7a18d5fbf2..06d5fd6b99 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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 diff --git a/packages/dashboard/app/components/ChatView.css b/packages/dashboard/app/components/ChatView.css index fc73a5ea45..14a614a9d0 100644 --- a/packages/dashboard/app/components/ChatView.css +++ b/packages/dashboard/app/components/ChatView.css @@ -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; diff --git a/packages/dashboard/app/components/ChatView.tsx b/packages/dashboard/app/components/ChatView.tsx index 1b361be3c7..e17a8acd2f 100644 --- a/packages/dashboard/app/components/ChatView.tsx +++ b/packages/dashboard/app/components/ChatView.tsx @@ -912,6 +912,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout const isMobile = mode === "mobile"; const isTablet = mode === "tablet"; const chatViewRef = useRef(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(() => { diff --git a/packages/dashboard/app/components/__tests__/ChatView.ios-keyboard.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.ios-keyboard.test.tsx new file mode 100644 index 0000000000..8be7ebbb40 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/ChatView.ios-keyboard.test.tsx @@ -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()), + useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }), +})); +vi.mock("../../api", async (importOriginal) => ({ + ...(await importOriginal()), + 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> = {}) { + 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(); + await act(async () => { screen.getByTestId(`chat-session-${activeSessionFixture.id}`).click(); }); + setupMockChat({ ...defaultChatState, sessions: [activeSessionFixture], filteredSessions: [activeSessionFixture], activeSession: activeSessionFixture }); + view.rerender(); + 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(); + await act(async () => { screen.getByTestId("chat-room-item-keyboard-room").click(); }); + setupMockRooms({ rooms: [room], activeRoom: room }); + view.rerender(); + 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; diff --git a/packages/dashboard/app/components/__tests__/ChatView.test-harness.tsx b/packages/dashboard/app/components/__tests__/ChatView.test-harness.tsx index 69ad21e4eb..4a814030ce 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.test-harness.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.test-harness.tsx @@ -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(), }));