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));
|
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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user