feat(FN-3614): improve mobile keyboard handling in ChatView and GitManager

This merge delivers five major themes: a large mobile chat UX pass (viewport-aware GitManager CSS, compact tool-call layout, keyboard-safe modals, and iterative fixes for the send-button double-fire behavior), a complete refactor of the TaskDetailModal test suite from a 6745-line monolith into eight

Fusion-Task-Id: FN-3614
This commit is contained in:
Fusion
2026-05-06 20:45:27 -07:00
committed by gsxdsm
parent a41157545d
commit 31f2998a8e
4 changed files with 122 additions and 9 deletions

View File

@@ -1,5 +1,5 @@
import "./ScriptsModal.css";
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useState, useEffect, useCallback, useRef, useMemo, type CSSProperties } from "react";
import type { Task } from "@fusion/core";
import { getErrorMessage } from "@fusion/core";
import type { ToastType } from "../hooks/useToast";
@@ -7,6 +7,9 @@ import { useConfirm } from "../hooks/useConfirm";
import { getPathBasename } from "../utils/pathDisplay";
import { useModalResizePersist } from "../hooks/useModalResizePersist";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { useViewportMode } from "../hooks/useViewportMode";
import type {
GitStatus,
GitCommit,
@@ -176,12 +179,37 @@ interface GitManagerModalProps {
export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, projectId }: GitManagerModalProps) {
const confirmContext = useConfirm();
const viewportMode = useViewportMode();
useMobileScrollLock(isOpen);
const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } = useMobileKeyboard({
enabled: viewportMode === "mobile",
});
const keyboardStyle: CSSProperties = keyboardOpen
? ({
"--keyboard-overlap": `${keyboardOverlap}px`,
"--vv-offset-top": `${viewportOffsetTop}px`,
...(viewportHeight !== null ? { "--vv-height": `${viewportHeight}px` } : {}),
} as CSSProperties)
: {};
const handleClose = useCallback(() => {
if (viewportMode === "mobile") {
const activeElement = document.activeElement;
if (activeElement instanceof HTMLElement) {
activeElement.blur();
}
window.scrollTo(0, 0);
requestAnimationFrame(() => {
window.scrollTo(0, 0);
});
}
onClose();
}, [onClose, viewportMode]);
const [activeSection, setActiveSection] = useState<SectionId>("status");
const [loading, setLoading] = useState(false);
const [sectionError, setSectionError] = useState<string | null>(null);
const modalRef = useRef<HTMLDivElement>(null);
useModalResizePersist(modalRef, isOpen, "fusion:git-modal-size");
const overlayDismissProps = useOverlayDismiss(onClose);
const overlayDismissProps = useOverlayDismiss(handleClose);
const copyToClipboard = useCopyToClipboard(addToast);
// ── Status state
@@ -300,7 +328,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
if (!isOpen) return;
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
handleClose();
return;
}
// Arrow key navigation between sections
@@ -316,7 +344,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
};
document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey);
}, [isOpen, onClose, activeSection]);
}, [isOpen, handleClose, activeSection]);
// ── Changes Handlers ────────────────────────────────────────────
@@ -741,8 +769,8 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
if (!isOpen) return null;
return (
<div className="modal-overlay open" {...overlayDismissProps} role="dialog" aria-modal="true">
<div className="modal gm-modal" ref={modalRef}>
<div className="modal-overlay open git-manager-modal-overlay" {...overlayDismissProps} role="dialog" aria-modal="true">
<div className="modal gm-modal" ref={modalRef} style={keyboardStyle}>
<div className="modal-header">
<h3>
<FolderGit2 size={18} style={{ marginRight: 8, verticalAlign: "middle" }} />
@@ -757,7 +785,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
>
<RefreshCw size={14} className={loading ? "spin" : ""} />
</button>
<button className="modal-close" onClick={onClose} aria-label="Close">
<button className="modal-close" onClick={handleClose} aria-label="Close">
<X size={18} />
</button>
</div>

View File

@@ -3438,9 +3438,10 @@
}
}
@media (max-width: 640px) {
@media (max-width: 768px) {
/* Full-screen sheet on mobile — drop overlay padding so the modal
actually fills the viewport instead of being pushed below it. */
.modal-overlay.git-manager-modal-overlay,
.modal-overlay:has(.gm-modal) {
padding-top: 0;
align-items: stretch;
@@ -3452,12 +3453,21 @@
min-width: 0;
max-width: 100vw;
height: 100dvh;
min-height: 0;
min-height: 100dvh;
max-height: 100dvh;
margin: 0;
border: none;
border-radius: 0;
resize: none;
flex: 1 1 auto;
}
.modal.gm-modal[style*="--keyboard-overlap"] {
height: var(--vv-height, 100dvh);
min-height: var(--vv-height, 100dvh);
max-height: var(--vv-height, 100dvh);
transform: translateY(var(--vv-offset-top, 0px));
will-change: transform;
}
/* Same treatment for the Automations modal — same min-width: 480px would

View File

@@ -5,6 +5,26 @@ import { GitManagerModal } from "../GitManagerModal";
import type { Task } from "@fusion/core";
import { loadAllAppCss } from "../../test/cssFixture";
const mockUseViewportMode = vi.fn(() => "desktop");
const mockUseMobileKeyboard = vi.fn(() => ({
keyboardOverlap: 0,
viewportHeight: null,
viewportOffsetTop: 0,
keyboardOpen: false,
}));
vi.mock("../../hooks/useViewportMode", () => ({
useViewportMode: () => mockUseViewportMode(),
}));
vi.mock("../../hooks/useMobileKeyboard", () => ({
useMobileKeyboard: () => mockUseMobileKeyboard(),
}));
vi.mock("../../hooks/useMobileScrollLock", () => ({
useMobileScrollLock: vi.fn(),
}));
// Mock the API module with all functions
vi.mock("../../api", async () => {
return {
@@ -114,6 +134,13 @@ const mockTasks: Task[] = [
describe("GitManagerModal", () => {
beforeEach(() => {
vi.clearAllMocks();
mockUseViewportMode.mockReturnValue("desktop");
mockUseMobileKeyboard.mockReturnValue({
keyboardOverlap: 0,
viewportHeight: null,
viewportOffsetTop: 0,
keyboardOpen: false,
});
mockConfirm.mockReset();
mockConfirm.mockResolvedValue(true);
@@ -212,6 +239,39 @@ describe("GitManagerModal", () => {
});
});
it("renders git-manager overlay class hook for mobile fullscreen CSS", async () => {
const { container } = render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
await waitFor(() => {
expect(screen.getByText("Git Manager")).toBeInTheDocument();
});
expect(container.querySelector(".modal-overlay.git-manager-modal-overlay")).toBeTruthy();
});
it("applies mobile keyboard CSS variables to gm-modal when keyboard is open", async () => {
mockUseViewportMode.mockReturnValue("mobile");
mockUseMobileKeyboard.mockReturnValue({
keyboardOverlap: 240,
viewportHeight: 620,
viewportOffsetTop: 18,
keyboardOpen: true,
});
const { container } = render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
await waitFor(() => {
expect(screen.getByText("Git Manager")).toBeInTheDocument();
});
const modal = container.querySelector(".modal.gm-modal") as HTMLElement;
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("240px");
expect(modal.style.getPropertyValue("--vv-height")).toBe("620px");
expect(modal.style.getPropertyValue("--vv-offset-top")).toBe("18px");
});
it("renders all navigation sections", async () => {
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />

View File

@@ -159,6 +159,21 @@ describe("core modals mobile css coverage", () => {
expect(mobileBlock).toContain("overflow-y: auto;");
});
it("GitManagerModal: mobile fullscreen block includes explicit overlay class and keyboard viewport rule", () => {
const css = loadAllAppCss();
const mobileBlock = getMainMobileBlock(css);
expect(mobileBlock).toContain(".modal-overlay.git-manager-modal-overlay,");
expect(mobileBlock).toContain(".modal.gm-modal[style*=\"--keyboard-overlap\"]");
const keyboardRule = mobileBlock.match(/\.modal\.gm-modal\[style\*=\"--keyboard-overlap\"\]\s*\{[^}]+\}/s);
expect(keyboardRule).not.toBeNull();
expect(keyboardRule![0]).toContain("height: var(--vv-height, 100dvh)");
expect(keyboardRule![0]).toContain("min-height: var(--vv-height, 100dvh)");
expect(keyboardRule![0]).toContain("max-height: var(--vv-height, 100dvh)");
expect(keyboardRule![0]).toContain("translateY(var(--vv-offset-top, 0px))");
});
it("GitManagerModal: changes rows/actions wrap without widening viewport on mobile", () => {
const css = loadAllAppCss();
const mobileBlock = getMainMobileBlock(css);