feat(FN-3337): recover chat isGenerating state on session reload and fix Pl

This merge includes three features: FN-3337 fixes mobile keyboard handling in the PlanningModeModal by wiring in `useMobileKeyboard` and adding keyboard-aware CSS sizing, with regression tests; FN-3336 recovers the `isGenerating` chat state on session reload to prevent UI flickering; and FN-3338 fix

Fusion-Task-Id: FN-3337
This commit is contained in:
Fusion
2026-05-03 17:10:03 -07:00
committed by gsxdsm
parent 1ff52a6f83
commit 66b673b18f
4 changed files with 125 additions and 2 deletions

View File

@@ -356,6 +356,12 @@
border-radius: 0;
resize: none;
}
.modal.planning-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;
}
.planning-modal-body--split {
flex-direction: column;
}

View File

@@ -1,5 +1,5 @@
import "./PlanningModeModal.css";
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
import { useState, useCallback, useEffect, useRef, useMemo, type CSSProperties } from "react";
import type { Task, PlanningQuestion, PlanningSummary } from "@fusion/core";
import { getErrorMessage } from "@fusion/core";
import {
@@ -43,6 +43,7 @@ import { OnboardingDisclosure } from "./OnboardingDisclosure";
import { useSessionLock } from "../hooks/useSessionLock";
import { useAiSessionSync } from "../hooks/useAiSessionSync";
import { useViewportMode } from "../hooks/useViewportMode";
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
import { getSessionTabId } from "../utils/getSessionTabId";
interface PlanningModeModalProps {
@@ -186,6 +187,17 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
useModalResizePersist(modalRef, isOpen, "fusion:planning-modal-size");
const viewportMode = useViewportMode();
const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } =
useMobileKeyboard({ enabled: viewportMode === "mobile" });
const modalKeyboardStyle: CSSProperties = keyboardOpen
? ({
"--keyboard-overlap": `${keyboardOverlap}px`,
"--vv-offset-top": `${viewportOffsetTop}px`,
...(viewportHeight !== null ? { "--vv-height": `${viewportHeight}px` } : {}),
} as CSSProperties)
: {};
// Mirror streamingOutput into a ref so SSE handlers can read the latest
// value without stale closure issues.
useEffect(() => {
@@ -1508,7 +1520,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
role="dialog"
aria-modal="true"
>
<div className="modal modal-lg planning-modal" ref={modalRef}>
<div className="modal modal-lg planning-modal" ref={modalRef} style={modalKeyboardStyle}>
<div className="modal-header">
<div className="detail-title-row">
{mobileShowDetail && (

View File

@@ -82,6 +82,28 @@ vi.mock("../../hooks/useConfirm", () => ({
useConfirm: () => ({ confirm: mockConfirm }),
}));
const mockUseViewportMode = vi.fn<() => "mobile" | "tablet" | "desktop">(() => "desktop");
vi.mock("../../hooks/useViewportMode", () => ({
useViewportMode: () => mockUseViewportMode(),
}));
const mockUseMobileKeyboard = vi.fn<() => {
keyboardOverlap: number;
viewportHeight: number | null;
viewportOffsetTop: number;
keyboardOpen: boolean;
}>(() => ({
keyboardOverlap: 0,
viewportHeight: null,
viewportOffsetTop: 0,
keyboardOpen: false,
}));
vi.mock("../../hooks/useMobileKeyboard", () => ({
useMobileKeyboard: (...args: any[]) => mockUseMobileKeyboard(...args),
}));
const mockTasks: Task[] = [
{
id: "FN-001",
@@ -216,6 +238,7 @@ function getMediaBlocks(css: string, mediaQuery: string): string[] {
}
function mockViewport(mode: "mobile" | "desktop" | "tablet") {
mockUseViewportMode.mockReturnValue(mode);
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => {
@@ -3337,4 +3360,81 @@ describe("useSessionLock", () => {
);
});
describe("Mobile keyboard behavior (FN-3337)", () => {
beforeEach(() => {
mockUseViewportMode.mockReturnValue("desktop");
mockUseMobileKeyboard.mockReturnValue({
keyboardOverlap: 0,
viewportHeight: null,
viewportOffsetTop: 0,
keyboardOpen: false,
});
});
it("applies keyboard CSS variables when keyboard is open on mobile", () => {
mockUseViewportMode.mockReturnValue("mobile");
mockUseMobileKeyboard.mockReturnValue({
keyboardOverlap: 300,
viewportHeight: 400,
viewportOffsetTop: 50,
keyboardOpen: true,
});
render(
<PlanningModeModal
isOpen={true}
onClose={vi.fn()}
/>,
);
const modal = screen.getByRole("dialog").querySelector(".planning-modal");
expect(modal).toBeTruthy();
expect(modal!.getAttribute("style")).toContain("--keyboard-overlap");
expect(modal!.getAttribute("style")).toContain("--vv-height");
expect(modal!.getAttribute("style")).toContain("--vv-offset-top");
});
it("does not apply keyboard CSS variables when keyboard is closed", () => {
mockUseViewportMode.mockReturnValue("mobile");
mockUseMobileKeyboard.mockReturnValue({
keyboardOverlap: 0,
viewportHeight: null,
viewportOffsetTop: 0,
keyboardOpen: false,
});
render(
<PlanningModeModal
isOpen={true}
onClose={vi.fn()}
/>,
);
const modal = screen.getByRole("dialog").querySelector(".planning-modal");
expect(modal).toBeTruthy();
expect(modal!.getAttribute("style")).toBeNull();
});
it("does not apply keyboard CSS variables on desktop", () => {
mockUseViewportMode.mockReturnValue("desktop");
mockUseMobileKeyboard.mockReturnValue({
keyboardOverlap: 0,
viewportHeight: null,
viewportOffsetTop: 0,
keyboardOpen: false,
});
render(
<PlanningModeModal
isOpen={true}
onClose={vi.fn()}
/>,
);
const modal = screen.getByRole("dialog").querySelector(".planning-modal");
expect(modal).toBeTruthy();
expect(modal!.getAttribute("style")).toBeNull();
});
});
});