feat(FN-3750): stabilize mailbox mobile keyboard and wake handling

- Wire mobile keyboard viewport signals into mailbox view and modal containers for anchored mobile layout
- Keep reply composer focused and visible while keyboard is open, with updated component styles and regression tests
- Make messaging wake dispatch fire-and-forget to avoid request hangs and expand route tests for async behavior
- Add desktop vitest workspace aliases for @fusion/* imports and include FN-3750/FN-3751 changesets

Fusion-Task-Id: FN-3750
This commit is contained in:
Fusion
2026-05-08 11:26:43 -07:00
committed by gsxdsm
parent ceb113c680
commit 955902d761
8 changed files with 240 additions and 6 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix mobile mailbox reply: anchor MailboxView/MailboxModal to the visual viewport so the message composer stays visible when the on-screen keyboard appears.

View File

@@ -656,6 +656,20 @@
padding-bottom: calc(var(--space-md) + env(safe-area-inset-bottom, 0px) + var(--standalone-bottom-gap));
}
.mailbox-view[style*="--keyboard-overlap"],
.mailbox-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;
}
.mailbox-view[style*="--keyboard-overlap"] .mailbox-content,
.mailbox-modal[style*="--keyboard-overlap"] .mailbox-content {
overflow-y: auto;
min-height: 0;
}
.mailbox-modal .mailbox-message-detail-header {
flex-direction: column;
align-items: flex-start;
@@ -736,6 +750,10 @@
padding-bottom: calc(var(--mobile-nav-height) + env(safe-area-inset-bottom, 0px) + var(--standalone-bottom-gap) + var(--space-lg));
}
.mailbox-view[style*="--keyboard-overlap"] .mailbox-content {
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + var(--space-md));
}
.mailbox-view .mailbox-split-layout {
display: block;
height: auto;

View File

@@ -1,5 +1,5 @@
import "./MailboxModal.css";
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useMemo, type CSSProperties } from "react";
import {
X,
Mail,
@@ -33,6 +33,8 @@ import {
import { MessageComposer } from "./MessageComposer";
import type { Agent } from "../api";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
import { useViewportMode } from "./Header";
import { subscribeSse } from "../sse-bus";
// ── Types ─────────────────────────────────────────────────────────────────
@@ -142,6 +144,20 @@ export function MailboxModal({
const [replyContextLoading, setReplyContextLoading] = useState<Record<string, boolean>>({});
const [replyContextErrors, setReplyContextErrors] = useState<Record<string, string>>({});
const [replyContextCache, setReplyContextCache] = useState<Map<string, Message>>(new Map());
const viewportMode = useViewportMode();
const isMobile = viewportMode === "mobile";
const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } = useMobileKeyboard({ enabled: isMobile });
const containerKeyboardStyle = useMemo<CSSProperties | undefined>(() => {
if (!keyboardOpen) {
return undefined;
}
return {
"--keyboard-overlap": `${keyboardOverlap}px`,
"--vv-offset-top": `${viewportOffsetTop}px`,
...(viewportHeight != null ? { "--vv-height": `${viewportHeight}px` } : {}),
} as CSSProperties;
}, [keyboardOpen, keyboardOverlap, viewportHeight, viewportOffsetTop]);
// ── Data fetching ─────────────────────────────────────────────────────
@@ -479,7 +495,7 @@ export function MailboxModal({
aria-modal="true"
data-testid="mailbox-modal-overlay"
>
<div className="modal modal-lg mailbox-modal" data-testid="mailbox-modal">
<div className="modal modal-lg mailbox-modal" style={containerKeyboardStyle} data-testid="mailbox-modal">
{/* Header */}
<div className="modal-header mailbox-header">
<div className="mailbox-title">

View File

@@ -1,5 +1,5 @@
import "./MailboxModal.css";
import { useState, useEffect, useCallback, useMemo } from "react";
import { useState, useEffect, useCallback, useMemo, type CSSProperties } from "react";
import {
Mail,
Send,
@@ -31,6 +31,7 @@ import {
import { MessageComposer } from "./MessageComposer";
import { subscribeSse } from "../sse-bus";
import { useViewportMode } from "../hooks/useViewportMode";
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
// ── Types ─────────────────────────────────────────────────────────────────
@@ -155,6 +156,18 @@ export function MailboxView({
const viewportMode = useViewportMode();
const isMobile = viewportMode === "mobile";
const isSplitPane = !isMobile;
const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } = useMobileKeyboard({ enabled: isMobile });
const containerKeyboardStyle = useMemo<CSSProperties | undefined>(() => {
if (!keyboardOpen) {
return undefined;
}
return {
"--keyboard-overlap": `${keyboardOverlap}px`,
"--vv-offset-top": `${viewportOffsetTop}px`,
...(viewportHeight != null ? { "--vv-height": `${viewportHeight}px` } : {}),
} as CSSProperties;
}, [keyboardOpen, keyboardOverlap, viewportHeight, viewportOffsetTop]);
// ── Data fetching ─────────────────────────────────────────────────────
@@ -714,7 +727,7 @@ export function MailboxView({
};
return (
<div className="mailbox-view" data-testid="mailbox-view">
<div className="mailbox-view" style={containerKeyboardStyle} data-testid="mailbox-view">
{/* Header */}
<div className="mailbox-header">
<div className="mailbox-title">

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, useMemo } from "react";
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
import { X, Send, Loader2, Bot, AlertCircle } from "lucide-react";
import type { ParticipantType, MessageType } from "@fusion/core";
import { getErrorMessage } from "@fusion/core";
@@ -46,6 +46,7 @@ export function MessageComposer({
const [wakeRecipient, setWakeRecipient] = useState(false);
const [isSending, setIsSending] = useState(false);
const [error, setError] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const selectedAgent = useMemo(() => agents.find((agent) => agent.id === toId), [agents, toId]);
const recipientIsAgent = toType === "agent";
@@ -93,6 +94,29 @@ export function MessageComposer({
setToType("agent");
}, []);
useEffect(() => {
if (!replyContext) {
return;
}
textareaRef.current?.focus();
}, [replyContext]);
useEffect(() => {
if (!replyContext || typeof window === "undefined" || window.visualViewport == null) {
return;
}
const handleVisualViewportResize = () => {
textareaRef.current?.scrollIntoView({ block: "center", behavior: "auto" });
};
window.visualViewport.addEventListener("resize", handleVisualViewportResize);
return () => {
window.visualViewport?.removeEventListener("resize", handleVisualViewportResize);
};
}, [replyContext]);
return (
<div className="message-composer" data-testid="message-composer">
<div className="message-composer-header">
@@ -161,6 +185,7 @@ export function MessageComposer({
</label>
<textarea
id="message-content"
ref={textareaRef}
className="message-composer-textarea"
placeholder="Type your message…"
value={content}

View File

@@ -3,6 +3,7 @@ import { loadAllAppCss } from "../../test/cssFixture";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import { MailboxModal } from "../MailboxModal";
import * as apiModule from "../../api";
import * as mobileKeyboardModule from "../../hooks/useMobileKeyboard";
import type { Agent } from "../../api";
import type { Message } from "@fusion/core";
@@ -20,6 +21,14 @@ vi.mock("../../api", () => ({
sendMessage: vi.fn(),
}));
vi.mock("../../hooks/useMobileKeyboard", () => ({
useMobileKeyboard: vi.fn(),
}));
vi.mock("../Header", () => ({
useViewportMode: vi.fn(() => "mobile"),
}));
// Mock lucide-react icons
vi.mock("lucide-react", () => ({
X: () => <span data-testid="icon-x">X</span>,
@@ -51,6 +60,7 @@ const mockDeleteMessage = vi.mocked(apiModule.deleteMessage);
const mockFetchConversation = vi.mocked(apiModule.fetchConversation);
const mockFetchMessage = vi.mocked(apiModule.fetchMessage);
const mockSendMessage = vi.mocked(apiModule.sendMessage);
const mockUseMobileKeyboard = vi.mocked(mobileKeyboardModule.useMobileKeyboard);
const mockAgents: Agent[] = [
{
@@ -117,6 +127,12 @@ const defaultProps = {
describe("MailboxModal", () => {
beforeEach(() => {
vi.clearAllMocks();
mockUseMobileKeyboard.mockReturnValue({
keyboardOverlap: 0,
viewportHeight: null,
viewportOffsetTop: 0,
keyboardOpen: false,
});
mockFetchInbox.mockResolvedValue({ messages: [mockMessage, mockReadMessage], total: 2, unreadCount: 1 });
mockFetchOutbox.mockResolvedValue({ messages: [], total: 0 });
mockFetchUnreadCount.mockResolvedValue({ unreadCount: 1 });
@@ -138,6 +154,21 @@ describe("MailboxModal", () => {
expect(screen.getByTestId("mailbox-modal")).toBeDefined();
});
it("applies visual viewport CSS variables when mobile keyboard is open", async () => {
mockUseMobileKeyboard.mockReturnValue({
keyboardOverlap: 220,
viewportHeight: 460,
viewportOffsetTop: 28,
keyboardOpen: true,
});
render(<MailboxModal {...defaultProps} />);
const modal = await screen.findByTestId("mailbox-modal");
expect(modal.getAttribute("style")).toContain("--vv-offset-top: 28px");
expect(modal.getAttribute("style")).toContain("--vv-height: 460px");
});
it("shows the Mailbox title with unread count badge", async () => {
render(<MailboxModal {...defaultProps} />);
expect(screen.getByText("Mailbox")).toBeDefined();

View File

@@ -4,6 +4,7 @@ import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"
import { MailboxView } from "../MailboxView";
import * as apiModule from "../../api";
import * as viewportModule from "../../hooks/useViewportMode";
import * as mobileKeyboardModule from "../../hooks/useMobileKeyboard";
import type { Agent } from "../../api";
import type { Message } from "@fusion/core";
@@ -25,6 +26,10 @@ vi.mock("../../hooks/useViewportMode", () => ({
useViewportMode: vi.fn(),
}));
vi.mock("../../hooks/useMobileKeyboard", () => ({
useMobileKeyboard: vi.fn(),
}));
// Mock lucide-react icons
vi.mock("lucide-react", () => ({
X: () => <span data-testid="icon-x">X</span>,
@@ -55,6 +60,7 @@ const mockDeleteMessage = vi.mocked(apiModule.deleteMessage);
const mockFetchConversation = vi.mocked(apiModule.fetchConversation);
const mockSendMessage = vi.mocked(apiModule.sendMessage);
const mockUseViewportMode = vi.mocked(viewportModule.useViewportMode);
const mockUseMobileKeyboard = vi.mocked(mobileKeyboardModule.useMobileKeyboard);
const mockAgents: Agent[] = [
{
@@ -148,6 +154,12 @@ describe("MailboxView", () => {
beforeEach(() => {
vi.clearAllMocks();
mockUseViewportMode.mockReturnValue("desktop");
mockUseMobileKeyboard.mockReturnValue({
keyboardOverlap: 0,
viewportHeight: null,
viewportOffsetTop: 0,
keyboardOpen: false,
});
mockFetchUnreadCount.mockResolvedValue({ unreadCount: 2 });
mockFetchAgents.mockResolvedValue(mockAgents);
mockSendMessage.mockResolvedValue({ ...mockMessage, id: "msg-sent" });
@@ -453,6 +465,27 @@ describe("MailboxView", () => {
});
});
it("applies visual viewport CSS variables when mobile keyboard is open", async () => {
mockUseViewportMode.mockReturnValue("mobile");
mockUseMobileKeyboard.mockReturnValue({
keyboardOverlap: 240,
viewportHeight: 480,
viewportOffsetTop: 32,
keyboardOpen: true,
});
mockFetchInbox.mockResolvedValue({
messages: [],
unreadCount: 0,
total: 0,
});
render(<MailboxView {...defaultProps} />);
const mailboxView = await screen.findByTestId("mailbox-view");
expect(mailboxView.getAttribute("style")).toContain("--vv-offset-top: 32px");
expect(mailboxView.getAttribute("style")).toContain("--vv-height: 480px");
});
it("keeps mobile single-pane flow for detail open and back navigation", async () => {
mockUseViewportMode.mockReturnValue("mobile");
mockFetchInbox.mockResolvedValue({
@@ -1388,6 +1421,15 @@ describe("MailboxView", () => {
expect(css).not.toMatch(/\.mailbox-agent-subtab\s*\{[^}]*background:\s*transparent;[^}]*\}/);
});
it("includes keyboard-overlap viewport anchoring rules for mailbox containers", async () => {
const css = loadAllAppCss();
expect(css).toContain('.mailbox-view[style*="--keyboard-overlap"],');
expect(css).toContain('.mailbox-modal[style*="--keyboard-overlap"]');
expect(css).toContain("height: var(--vv-height, 100dvh);");
expect(css).toContain("transform: translateY(var(--vv-offset-top, 0px));");
});
it("renders structural elements that mobile CSS targets", async () => {
mockFetchInbox.mockResolvedValue({
messages: [mockMessage],

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { MessageComposer } from "../MessageComposer";
import * as apiModule from "../../api";
@@ -41,6 +41,8 @@ const defaultProps = {
};
describe("MessageComposer", () => {
const originalVisualViewport = window.visualViewport;
beforeEach(() => {
vi.clearAllMocks();
mockSendMessage.mockResolvedValue({
@@ -57,6 +59,14 @@ describe("MessageComposer", () => {
});
});
afterEach(() => {
Object.defineProperty(window, "visualViewport", {
configurable: true,
value: originalVisualViewport,
writable: true,
});
});
it("renders the composer with header", () => {
render(<MessageComposer {...defaultProps} />);
expect(screen.getByText("New Message")).toBeDefined();
@@ -171,6 +181,80 @@ describe("MessageComposer", () => {
expect(defaultProps.onCancel).toHaveBeenCalledOnce();
});
it("auto-focuses textarea when reply context is provided", () => {
render(
<MessageComposer
{...defaultProps}
recipient={{ id: "agent-001", type: "agent" }}
replyContext={{ messageId: "m1", preview: "Previous" }}
/>,
);
expect(document.activeElement).toBe(screen.getByTestId("message-composer-content"));
});
it("scrolls textarea into view on visualViewport resize when replying", () => {
const addEventListener = vi.fn();
const removeEventListener = vi.fn();
let resizeHandler: (() => void) | undefined;
addEventListener.mockImplementation((event: string, handler: () => void) => {
if (event === "resize") {
resizeHandler = handler;
}
});
Object.defineProperty(window, "visualViewport", {
configurable: true,
value: {
addEventListener,
removeEventListener,
},
writable: true,
});
if (!("scrollIntoView" in HTMLElement.prototype)) {
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
configurable: true,
value: () => undefined,
writable: true,
});
}
const scrollIntoViewSpy = vi.spyOn(HTMLElement.prototype, "scrollIntoView").mockImplementation(() => undefined);
render(
<MessageComposer
{...defaultProps}
recipient={{ id: "agent-001", type: "agent" }}
replyContext={{ messageId: "m1", preview: "Previous" }}
/>,
);
expect(addEventListener).toHaveBeenCalledWith("resize", expect.any(Function));
resizeHandler?.();
expect(scrollIntoViewSpy).toHaveBeenCalledWith({ block: "center", behavior: "auto" });
scrollIntoViewSpy.mockRestore();
});
it("does not throw when visualViewport is unavailable", () => {
Object.defineProperty(window, "visualViewport", {
configurable: true,
value: undefined,
writable: true,
});
expect(() => {
render(
<MessageComposer
{...defaultProps}
recipient={{ id: "agent-001", type: "agent" }}
replyContext={{ messageId: "m1", preview: "Previous" }}
/>,
);
}).not.toThrow();
});
it("pre-fills recipient when provided", () => {
render(
<MessageComposer