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:
5
.changeset/fix-db-vacuum-command-export.md
Normal file
5
.changeset/fix-db-vacuum-command-export.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Restore the CLI db vacuum command module wiring so tests and runtime command loading succeed.
|
||||
68
packages/cli/src/commands/db.ts
Normal file
68
packages/cli/src/commands/db.ts
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user