FN-6183: preserve mobile mailbox scroll on refresh
Keep the mobile mailbox list anchored after refreshes and SSE updates. - capture the mobile mailbox content scroll position before inbox, outbox, agent, and approval reloads - restore the saved scroll position after refreshed mailbox data renders and wire the content container through a ref - allow vertical scrolling in the mailbox view content area and cover the SSE refresh behavior with a mobile regression test Files changed: packages/dashboard/app/components/MailboxModal.css | 3 +- packages/dashboard/app/components/MailboxView.tsx | 58 +++++++++++++++++++--- .../app/components/__tests__/MailboxView.test.tsx | 35 +++++++++++++ 3 files changed, 89 insertions(+), 7 deletions(-) Fusion-Task-Id: FN-6183 Fusion-Task-Lineage: ea8be80b-4834-4b77-96cd-254e6b0b0795
This commit is contained in:
@@ -655,7 +655,8 @@
|
||||
.mailbox-view .mailbox-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-xl);
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
@@ -232,6 +232,8 @@ export function MailboxView({
|
||||
const isSplitPane = !isMobile;
|
||||
const [sidebarWidth, setSidebarWidth] = useState<number>(() => readMailboxSidebarWidth(projectId));
|
||||
const splitLayoutRef = useRef<HTMLDivElement>(null);
|
||||
const mailboxContentRef = useRef<HTMLDivElement>(null);
|
||||
const pendingScrollTopRef = useRef<number | null>(null);
|
||||
const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } = useMobileKeyboard({ enabled: isMobile });
|
||||
const containerKeyboardStyle = useMemo<CSSProperties | undefined>(() => {
|
||||
if (!keyboardOpen) {
|
||||
@@ -314,9 +316,45 @@ export function MailboxView({
|
||||
}
|
||||
}, [isSplitPane, sidebarWidth]);
|
||||
|
||||
const captureMailboxScroll = useCallback(() => {
|
||||
if (!isMobile) {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = mailboxContentRef.current;
|
||||
if (!content) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingScrollTopRef.current = content.scrollTop;
|
||||
}, [isMobile]);
|
||||
|
||||
const restoreMailboxScroll = useCallback(() => {
|
||||
const scrollTop = pendingScrollTopRef.current;
|
||||
if (scrollTop === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const restore = () => {
|
||||
const content = mailboxContentRef.current;
|
||||
if (content) {
|
||||
content.scrollTop = scrollTop;
|
||||
}
|
||||
pendingScrollTopRef.current = null;
|
||||
};
|
||||
|
||||
if (typeof window !== "undefined" && typeof window.requestAnimationFrame === "function") {
|
||||
window.requestAnimationFrame(restore);
|
||||
return;
|
||||
}
|
||||
|
||||
restore();
|
||||
}, []);
|
||||
|
||||
// ── Data fetching ─────────────────────────────────────────────────────
|
||||
|
||||
const loadInbox = useCallback(async () => {
|
||||
captureMailboxScroll();
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await fetchInbox({ limit: 50 }, projectId);
|
||||
@@ -328,9 +366,10 @@ export function MailboxView({
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [projectId, onUnreadCountChange]);
|
||||
}, [projectId, onUnreadCountChange, captureMailboxScroll]);
|
||||
|
||||
const loadOutbox = useCallback(async () => {
|
||||
captureMailboxScroll();
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await fetchOutbox({ limit: 50 }, projectId);
|
||||
@@ -340,9 +379,10 @@ export function MailboxView({
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
}, [projectId, captureMailboxScroll]);
|
||||
|
||||
const loadAgentMailbox = useCallback(async (agentId: string) => {
|
||||
captureMailboxScroll();
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await fetchAgentMailbox(agentId, projectId);
|
||||
@@ -352,9 +392,10 @@ export function MailboxView({
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
}, [projectId, captureMailboxScroll]);
|
||||
|
||||
const loadAllAgentsMailbox = useCallback(async () => {
|
||||
captureMailboxScroll();
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await fetchAllAgentMailbox(projectId);
|
||||
@@ -364,7 +405,11 @@ export function MailboxView({
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
}, [projectId, captureMailboxScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
restoreMailboxScroll();
|
||||
}, [inbox, outbox, agentMailbox, allAgentsMailbox, approvals, selectedApproval, restoreMailboxScroll]);
|
||||
|
||||
const loadAgents = useCallback(async () => {
|
||||
try {
|
||||
@@ -387,6 +432,7 @@ export function MailboxView({
|
||||
}, [projectId, onUnreadCountChange]);
|
||||
|
||||
const loadApprovals = useCallback(async (status: "pending" | "history") => {
|
||||
captureMailboxScroll();
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const list = await fetchApprovals({ status: status === "pending" ? "pending" : undefined, limit: 100 }, projectId);
|
||||
@@ -406,7 +452,7 @@ export function MailboxView({
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
}, [projectId, captureMailboxScroll]);
|
||||
|
||||
// Load data on tab change
|
||||
useEffect(() => {
|
||||
@@ -1219,7 +1265,7 @@ export function MailboxView({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mailbox-content" data-testid="mailbox-content">
|
||||
<div className="mailbox-content" data-testid="mailbox-content" ref={mailboxContentRef}>
|
||||
{isSplitPane ? (
|
||||
<div className="mailbox-split-layout" data-testid="mailbox-split-layout" ref={splitLayoutRef}>
|
||||
<div
|
||||
|
||||
@@ -1814,6 +1814,7 @@ describe("MailboxView", () => {
|
||||
const contentBlock = contentBlockMatch![1];
|
||||
expect(contentBlock).toContain("flex: 1;");
|
||||
expect(contentBlock).toContain("min-height: 0;");
|
||||
expect(contentBlock).toContain("overflow-y: auto;");
|
||||
expect(contentBlock).toContain("max-height: none;");
|
||||
});
|
||||
|
||||
@@ -1918,6 +1919,40 @@ describe("MailboxView", () => {
|
||||
expect(css).toContain("transform: translateY(var(--vv-offset-top, 0px));");
|
||||
});
|
||||
|
||||
it("preserves mobile mailbox scroll after SSE refreshes inbox data", async () => {
|
||||
mockUseViewportMode.mockReturnValue("mobile");
|
||||
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
|
||||
callback(0);
|
||||
return 0;
|
||||
}) as typeof window.requestAnimationFrame;
|
||||
mockFetchInbox
|
||||
.mockResolvedValueOnce(makeInboxResponse([mockMessage], 1))
|
||||
.mockResolvedValueOnce(makeInboxResponse([{ ...mockReadMessage, id: "msg-sse", content: "Message refreshed by SSE" }], 0));
|
||||
mockFetchUnreadCount.mockResolvedValue({ unreadCount: 0 });
|
||||
|
||||
try {
|
||||
render(<MailboxView {...defaultProps} />);
|
||||
await screen.findByText("Hello, this is a test message from the agent.");
|
||||
|
||||
const content = screen.getByTestId("mailbox-content");
|
||||
content.scrollTop = 144;
|
||||
|
||||
const latest = sseSubscriptions.at(-1);
|
||||
expect(latest).toBeDefined();
|
||||
await act(async () => {
|
||||
latest?.["message:received"]?.();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Message refreshed by SSE")).toBeDefined();
|
||||
});
|
||||
expect(content.scrollTop).toBe(144);
|
||||
} finally {
|
||||
window.requestAnimationFrame = originalRequestAnimationFrame;
|
||||
}
|
||||
});
|
||||
|
||||
it("renders structural elements that mobile CSS targets", async () => {
|
||||
mockFetchInbox.mockResolvedValue({
|
||||
messages: [mockMessage],
|
||||
|
||||
Reference in New Issue
Block a user