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:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user