feat(FN-3343): add Escape key keyboard handling to modals

This merge delivers v0.17.2 and ships FN-3343 keyboard handling across four dashboard modals (NewTaskModal, SubtaskBreakdownModal, MilestoneSliceInterviewModal, SettingsModal) with mobile CSS overrides, plus keyboard style regression tests for each modal and a minor test alignment for the onboarding

Fusion-Task-Id: FN-3343
This commit is contained in:
Fusion
2026-05-04 01:37:33 -07:00
committed by gsxdsm
parent 3092f89379
commit 2d72eddb9c
10 changed files with 216 additions and 7 deletions

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { useState, useCallback, useEffect, useMemo, useRef, type CSSProperties } from "react";
import type { PlanningQuestion } from "@fusion/core";
import { getErrorMessage } from "@fusion/core";
import {
@@ -29,7 +29,9 @@ import {
import { ConversationHistory } from "./ConversationHistory";
import { useSessionLock } from "../hooks/useSessionLock";
import { useAiSessionSync } from "../hooks/useAiSessionSync";
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { useViewportMode } from "../hooks/useViewportMode";
import { getSessionTabId } from "../utils/getSessionTabId";
interface MilestoneSliceInterviewModalProps {
@@ -74,7 +76,18 @@ export function MilestoneSliceInterviewModal({
projectId,
resumeSessionId,
}: MilestoneSliceInterviewModalProps) {
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 [view, setView] = useState<ViewState>({ type: "initial" });
const [error, setError] = useState<string | null>(null);
const [responseHistory, setResponseHistory] = useState<QuestionResponse[]>([]);
@@ -451,7 +464,7 @@ export function MilestoneSliceInterviewModal({
aria-modal="true"
data-testid="milestone-slice-interview-modal"
>
<div className="modal modal-lg planning-modal">
<div className="modal modal-lg planning-modal" style={keyboardStyle}>
<div className="modal-header">
<div className="detail-title-row">
<Sparkles size={20} className="icon-triage" />

View File

@@ -345,6 +345,13 @@
flex-direction: column;
}
.modal.new-task-modal[style*="--keyboard-overlap"] {
height: var(--vv-height, 100dvh);
max-height: var(--vv-height, 100dvh);
transform: translateY(var(--vv-offset-top, 0px));
will-change: transform;
}
.new-task-modal .modal-body {
max-height: unset;
overflow-y: auto;

View File

@@ -10,8 +10,10 @@ import { useSetupReadiness } from "../hooks/useSetupReadiness";
import { SetupWarningBanner } from "./SetupWarningBanner";
import { TaskForm, type PendingImage } from "./TaskForm";
import { useConfirm } from "../hooks/useConfirm";
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { useNodes } from "../hooks/useNodes";
import { useViewportMode } from "../hooks/useViewportMode";
interface NewTaskModalProps {
isOpen: boolean;
@@ -26,7 +28,18 @@ interface NewTaskModalProps {
export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, addToast, onPlanningMode, onSubtaskBreakdown }: NewTaskModalProps) {
const { confirm } = useConfirm();
const viewportMode = useViewportMode();
useMobileScrollLock(isOpen);
const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } = useMobileKeyboard({
enabled: viewportMode === "mobile",
});
const keyboardStyle: React.CSSProperties = keyboardOpen
? ({
"--keyboard-overlap": `${keyboardOverlap}px`,
"--vv-offset-top": `${viewportOffsetTop}px`,
...(viewportHeight !== null ? { "--vv-height": `${viewportHeight}px` } : {}),
} as React.CSSProperties)
: {};
const [description, setDescription] = useState("");
const [dependencies, setDependencies] = useState<string[]>([]);
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
@@ -418,6 +431,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
<div
className="modal modal-lg new-task-modal"
onClick={(e) => e.stopPropagation()}
style={keyboardStyle}
>
<div className="modal-header">
<h3>New Task</h3>

View File

@@ -109,6 +109,13 @@
border-radius: 0;
resize: none;
}
.modal.settings-modal[style*="--keyboard-overlap"] {
height: var(--vv-height, 100dvh);
max-height: var(--vv-height, 100dvh);
transform: translateY(var(--vv-offset-top, 0px));
will-change: transform;
}
}
/* === Settings Layout === */

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useRef, lazy, Suspense, type MouseEvent } from "react";
import { useState, useEffect, useCallback, useRef, lazy, Suspense, type CSSProperties, type MouseEvent } from "react";
import { Globe, Folder, RefreshCw, Star, HelpCircle, Loader2, CheckCircle, AlertTriangle } from "lucide-react";
import {
THINKING_LEVELS,
@@ -40,8 +40,10 @@ import { CustomProvidersSection } from "./CustomProvidersSection";
import { applyPresetToSelection, generateUniquePresetId } from "../utils/modelPresets";
import { appendTokenQuery } from "../auth";
import { useConfirm } from "../hooks/useConfirm";
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { useNodes } from "../hooks/useNodes";
import { useViewportMode } from "../hooks/useViewportMode";
import { NodeHealthDot } from "./NodeHealthDot";
import { filterVisibleOnboardingAndSettingsProviders } from "./providerVisibility";
@@ -353,7 +355,18 @@ export function SettingsModal({
onReopenOnboarding,
}: SettingsModalProps) {
const { confirm } = useConfirm();
const viewportMode = useViewportMode();
useMobileScrollLock(true);
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 modalRef = useRef<HTMLDivElement>(null);
const settingsContentRef = useRef<HTMLDivElement>(null);
useModalResizePersist(modalRef, true, "fusion:settings-modal-size");
@@ -5302,7 +5315,7 @@ export function SettingsModal({
return (
<div className="modal-overlay open" {...overlayDismissProps} role="dialog" aria-modal="true">
<div className="modal modal-lg settings-modal" ref={modalRef}>
<div className="modal modal-lg settings-modal" ref={modalRef} style={keyboardStyle}>
<div className="modal-header">
<div className="settings-modal-heading">
<h3>Settings</h3>

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import type { Task } from "@fusion/core";
import { getErrorMessage } from "@fusion/core";
import {
@@ -22,7 +22,9 @@ import { ConversationHistory } from "./ConversationHistory";
import { useSessionLock } from "../hooks/useSessionLock";
import { useAiSessionSync } from "../hooks/useAiSessionSync";
import { useConfirm } from "../hooks/useConfirm";
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { useViewportMode } from "../hooks/useViewportMode";
import { getSessionTabId } from "../utils/getSessionTabId";
interface SubtaskBreakdownModalProps {
@@ -73,7 +75,18 @@ function hasDependencyCycle(subtasks: SubtaskItem[]): boolean {
}
export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onTasksCreated, parentTaskId, projectId, resumeSessionId }: SubtaskBreakdownModalProps) {
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 [view, setView] = useState<ViewState>({ type: "initial" });
const [subtasks, setSubtasks] = useState<SubtaskItem[]>([]);
const [conversationHistory, setConversationHistory] = useState<ConversationHistoryEntry[]>([]);
@@ -571,7 +584,7 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
return (
<div className="modal-overlay open" onClick={(event) => event.target === event.currentTarget && void handleClose()} role="dialog" aria-modal="true">
<div className="modal modal-lg planning-modal">
<div className="modal modal-lg planning-modal" style={keyboardStyle}>
<div className="modal-header">
<div className="detail-title-row">
<ListTree size={20} className="icon-triage" />

View File

@@ -59,6 +59,14 @@ vi.mock("../../utils/getSessionTabId", () => ({
getSessionTabId: vi.fn(() => "test-tab-id"),
}));
const mockUseMobileKeyboard = vi.fn();
vi.mock("../../hooks/useMobileKeyboard", () => ({
useMobileKeyboard: (...args: unknown[]) => mockUseMobileKeyboard(...args),
}));
vi.mock("../../hooks/useViewportMode", () => ({
useViewportMode: () => "mobile",
}));
vi.mock("lucide-react", () => ({
X: () => <span data-testid="x-icon">X</span>,
Loader2: ({ className }: any) => <span data-testid="loader-icon" className={className}>Loader</span>,
@@ -102,6 +110,12 @@ describe("MilestoneSliceInterviewModal", () => {
mockFetchAiSession.mockReset();
mockParseConversationHistory.mockReset();
mockParseConversationHistory.mockReturnValue([]);
mockUseMobileKeyboard.mockReturnValue({
keyboardOpen: false,
keyboardOverlap: 0,
viewportHeight: null,
viewportOffsetTop: 0,
});
// Setup stream handlers capture
mockConnectMilestoneInterviewStream.mockImplementation((sessionId, projectId, handlers) => {
@@ -235,7 +249,32 @@ describe("MilestoneSliceInterviewModal", () => {
});
});
it("shows loading state after clicking Start Interview", async () => {
it("applies keyboard CSS variables to planning modal when keyboard is open", () => {
mockUseMobileKeyboard.mockReturnValue({
keyboardOpen: true,
keyboardOverlap: 250,
viewportHeight: 400,
viewportOffsetTop: 50,
});
const { container } = render(
<MilestoneSliceInterviewModal
isOpen={true}
onClose={vi.fn()}
onApplied={vi.fn()}
targetType="milestone"
targetId="milestone-1"
targetTitle="Milestone 1"
/>,
);
const modal = container.querySelector(".planning-modal");
expect(mockUseMobileKeyboard).toHaveBeenCalledWith({ enabled: true });
expect(modal?.getAttribute("style")).toContain("--keyboard-overlap: 250px");
expect(modal?.getAttribute("style")).toContain("--vv-height: 400px");
});
it("shows loading state after clicking Start Interview", async () => {
mockStartMilestoneInterview.mockResolvedValue({ sessionId: "session-123" });
mockConnectMilestoneInterviewStream.mockReturnValue({
close: vi.fn(),

View File

@@ -41,6 +41,15 @@ vi.mock("../../hooks/useConfirm", () => ({
useConfirm: () => ({ confirm: mockConfirm }),
}));
const mockUseMobileKeyboard = vi.fn();
vi.mock("../../hooks/useMobileKeyboard", () => ({
useMobileKeyboard: (...args: unknown[]) => mockUseMobileKeyboard(...args),
}));
vi.mock("../../hooks/useViewportMode", () => ({
useViewportMode: () => "mobile",
}));
function makeTask(id: string): Task {
return {
id,
@@ -77,6 +86,37 @@ describe("NewTaskModal", () => {
vi.clearAllMocks();
mockConfirm.mockReset();
mockConfirm.mockResolvedValue(true);
mockUseMobileKeyboard.mockReturnValue({
keyboardOpen: false,
keyboardOverlap: 0,
viewportHeight: null,
viewportOffsetTop: 0,
});
});
it("applies keyboard CSS variables when mobile keyboard is open", () => {
mockUseMobileKeyboard.mockReturnValue({
keyboardOpen: true,
keyboardOverlap: 250,
viewportHeight: 400,
viewportOffsetTop: 50,
});
const { container } = renderNewTaskModal();
const modal = container.querySelector(".new-task-modal");
expect(mockUseMobileKeyboard).toHaveBeenCalledWith({ enabled: true });
expect(modal?.getAttribute("style")).toContain("--keyboard-overlap: 250px");
expect(modal?.getAttribute("style")).toContain("--vv-height: 400px");
expect(modal?.getAttribute("style")).toContain("--vv-offset-top: 50px");
});
it("does not apply keyboard CSS variables when keyboard is closed", () => {
const { container } = renderNewTaskModal();
const modal = container.querySelector(".new-task-modal");
expect(mockUseMobileKeyboard).toHaveBeenCalledWith({ enabled: true });
expect(modal?.getAttribute("style") ?? "").not.toContain("--keyboard-overlap");
});
it("renders all form fields when open", async () => {

View File

@@ -107,6 +107,14 @@ vi.mock("../../hooks/useMemoryBackendStatus", () => ({
useMemoryBackendStatus: (...args: unknown[]) => mockUseMemoryBackendStatus(...args),
}));
const mockUseMobileKeyboard = vi.fn();
vi.mock("../../hooks/useMobileKeyboard", () => ({
useMobileKeyboard: (...args: unknown[]) => mockUseMobileKeyboard(...args),
}));
vi.mock("../../hooks/useViewportMode", () => ({
useViewportMode: () => "mobile",
}));
vi.mock("lucide-react", async (importOriginal) => {
const actual = await importOriginal<typeof import("lucide-react")>();
return {
@@ -191,6 +199,23 @@ const MODEL_FIXTURE = [
];
describe("SettingsModal", () => {
it("applies keyboard CSS variables when mobile keyboard is open", async () => {
mockUseMobileKeyboard.mockReturnValue({
keyboardOpen: true,
keyboardOverlap: 250,
viewportHeight: 400,
viewportOffsetTop: 50,
});
const { container } = renderModal();
await waitForSettingsModalReady();
const modal = container.querySelector(".settings-modal");
expect(mockUseMobileKeyboard).toHaveBeenCalledWith({ enabled: true });
expect(modal?.getAttribute("style")).toContain("--keyboard-overlap: 250px");
expect(modal?.getAttribute("style")).toContain("--vv-height: 400px");
});
it("renders section headings with the shared settings-section-heading class", async () => {
const { container } = renderModal();
await waitForSettingsModalReady();
@@ -207,6 +232,12 @@ describe("SettingsModal", () => {
beforeEach(() => {
vi.clearAllMocks();
mockUseMobileKeyboard.mockReturnValue({
keyboardOpen: false,
keyboardOverlap: 0,
viewportHeight: null,
viewportOffsetTop: 0,
});
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({

View File

@@ -38,6 +38,15 @@ vi.mock("../../hooks/useConfirm", () => ({
useConfirm: () => ({ confirm: mockConfirm }),
}));
const mockUseMobileKeyboard = vi.fn();
vi.mock("../../hooks/useMobileKeyboard", () => ({
useMobileKeyboard: (...args: unknown[]) => mockUseMobileKeyboard(...args),
}));
vi.mock("../../hooks/useViewportMode", () => ({
useViewportMode: () => "mobile",
}));
const SAMPLE_SUBTASKS = [
{ id: "subtask-1", title: "First", description: "Do first", suggestedSize: "S" as const, dependsOn: [] },
{ id: "subtask-2", title: "Second", description: "Do second", suggestedSize: "M" as const, dependsOn: ["subtask-1"] },
@@ -80,6 +89,12 @@ describe("SubtaskBreakdownModal", () => {
mockForceAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
mockConfirm.mockReset();
mockConfirm.mockResolvedValue(true);
mockUseMobileKeyboard.mockReturnValue({
keyboardOpen: false,
keyboardOverlap: 0,
viewportHeight: null,
viewportOffsetTop: 0,
});
});
afterEach(() => {
@@ -97,6 +112,23 @@ describe("SubtaskBreakdownModal", () => {
);
}
it("applies keyboard CSS variables to planning modal when keyboard is open", async () => {
mockUseMobileKeyboard.mockReturnValue({
keyboardOpen: true,
keyboardOverlap: 250,
viewportHeight: 400,
viewportOffsetTop: 50,
});
const { container } = renderModal();
await waitFor(() => expect(mockStartSubtaskBreakdown).toHaveBeenCalled());
const modal = container.querySelector(".planning-modal");
expect(mockUseMobileKeyboard).toHaveBeenCalledWith({ enabled: true });
expect(modal?.getAttribute("style")).toContain("--keyboard-overlap: 250px");
expect(modal?.getAttribute("style")).toContain("--vv-height: 400px");
});
it("shows generating state after auto-start", async () => {
renderModal();
await waitFor(() => expect(mockStartSubtaskBreakdown).toHaveBeenCalledWith("Build a complex feature", undefined));