feat(FN-4060): fix mobile keyboard layout regression in QuickChatFAB compos

Fixes the keyboard-up composer gap in QuickChatFAB (Step 2), with companion regression tests covering the mobile keyboard layout and QuickChatFAB component behavior.

Fusion-Task-Id: FN-4060

Fusion-Task-Lineage: 341b16b1-47db-4e23-b9fe-87cf0782da6a
This commit is contained in:
Fusion
2026-05-11 19:51:48 -07:00
committed by gsxdsm
parent f8eead872b
commit 0f5c08603f
6 changed files with 141 additions and 2 deletions

View File

@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { loadAllAppCss } from "../test/cssFixture";
function extractMobileMediaBlocks(content: string): string {
const blocks: string[] = [];
const regex = /@media\s*\(\s*max-width:\s*768px\s*\)\s*\{/g;
let match: RegExpExecArray | null;
while ((match = regex.exec(content)) !== null) {
const startIdx = match.index + match[0].length;
let braceCount = 1;
let endIdx = startIdx;
while (braceCount > 0 && endIdx < content.length) {
if (content[endIdx] === "{") braceCount += 1;
if (content[endIdx] === "}") braceCount -= 1;
endIdx += 1;
}
if (braceCount === 0) {
blocks.push(content.slice(startIdx, endIdx - 1));
}
}
return blocks.join("\n");
}
describe("quick-chat mobile keyboard layout css", () => {
const css = loadAllAppCss();
const mobileCss = extractMobileMediaBlocks(css);
it("drops safe-area bottom inset from composer padding while keyboard-open class is active", () => {
const keyboardOpenRule = /\.quick-chat-panel\.quick-chat-panel--keyboard-open\s+\.quick-chat-panel-input\s*\{[^}]*padding-bottom:\s*calc\(var\(--space-sm\)\s*\+\s*var\(--space-xs\)\)\s*;/m;
expect(keyboardOpenRule.test(mobileCss)).toBe(true);
});
});

View File

@@ -795,6 +795,11 @@
padding-bottom: calc(var(--space-sm) + var(--space-xs) + env(safe-area-inset-bottom, 0px));
}
.quick-chat-panel.quick-chat-panel--keyboard-open .quick-chat-panel-input {
/* When keyboard is open, do not add home-indicator inset gap above it. */
padding-bottom: calc(var(--space-sm) + var(--space-xs));
}
.quick-chat-attachment-previews {
padding: var(--space-sm) max(var(--space-md), env(safe-area-inset-left, 0px));
}

View File

@@ -882,7 +882,7 @@ export function QuickChatFAB({
// directly on the panel DOM in a layout effect below — going through
// React state introduces a per-event reconciliation lag that the human
// eye reads as jank while the iOS keyboard is animating in.
useMobileKeyboard({ enabled: isOpen });
const { keyboardOpen } = useMobileKeyboard({ enabled: isOpen });
const viewportMode = useViewportMode();
const isMobile = viewportMode === "mobile";
@@ -2161,7 +2161,7 @@ export function QuickChatFAB({
{isOpen && (
<div
className="quick-chat-panel"
className={`quick-chat-panel${isMobile && keyboardOpen ? " quick-chat-panel--keyboard-open" : ""}`}
ref={panelRef}
data-testid="quick-chat-panel"
style={{

View File

@@ -5,6 +5,7 @@ import type { ChatSession } from "@fusion/core";
import * as apiModule from "../../api";
import { useAgents } from "../../hooks/useAgents";
import { useViewportMode } from "../../hooks/useViewportMode";
import { useMobileKeyboard } from "../../hooks/useMobileKeyboard";
import { QuickChatFAB } from "../QuickChatFAB";
vi.mock("../../api", () => ({
@@ -21,6 +22,7 @@ vi.mock("../../api", () => ({
vi.mock("../../hooks/useAgents", () => ({ useAgents: vi.fn() }));
vi.mock("../../hooks/useViewportMode", () => ({ useViewportMode: vi.fn() }));
vi.mock("../../hooks/useMobileKeyboard", () => ({ useMobileKeyboard: vi.fn() }));
const mockFetchResumeChatSession = vi.mocked(apiModule.fetchResumeChatSession);
const mockFetchChatSessions = vi.mocked(apiModule.fetchChatSessions);
@@ -32,6 +34,7 @@ const mockStreamChatResponse = vi.mocked(apiModule.streamChatResponse);
const mockCancelChatResponse = vi.mocked(apiModule.cancelChatResponse);
const mockUseAgents = vi.mocked(useAgents);
const mockUseViewportMode = vi.mocked(useViewportMode);
const mockUseMobileKeyboard = vi.mocked(useMobileKeyboard);
const agents: Agent[] = [
{ id: "agent-001", name: "Agent One", role: "executor", state: "active", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), metadata: {} },
@@ -80,6 +83,12 @@ describe("QuickChatFAB session-first UX", () => {
localStorage.clear();
mockUseAgents.mockReturnValue({ agents, activeAgents: agents, stats: null, isLoading: false, loadAgents: vi.fn(), loadStats: vi.fn() });
mockUseViewportMode.mockReturnValue("desktop");
mockUseMobileKeyboard.mockReturnValue({
keyboardOverlap: 0,
viewportHeight: null,
viewportOffsetTop: 0,
keyboardOpen: false,
});
mockFetchResumeChatSession.mockResolvedValue({ session: modelSession });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
mockFetchChatSessions.mockResolvedValue({ sessions: [modelSession, agentSession] });
@@ -671,6 +680,24 @@ describe("QuickChatFAB session-first UX", () => {
});
});
it("applies keyboard-open panel class on mobile to remove composer safe-area gap", async () => {
Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 });
window.dispatchEvent(new Event("resize"));
mockUseViewportMode.mockReturnValue("mobile");
mockUseMobileKeyboard.mockReturnValue({
keyboardOverlap: 160,
viewportHeight: 500,
viewportOffsetTop: 0,
keyboardOpen: true,
});
render(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
const panel = await screen.findByTestId("quick-chat-panel");
expect(panel).toHaveClass("quick-chat-panel--keyboard-open");
});
it("FN-4040: mobile visibility restore re-anchors quick chat to latest", async () => {
mockUseViewportMode.mockReturnValue("mobile");
mockFetchChatMessages.mockResolvedValue({