From 5e35385594cb601aa874fb33e6998babae6c4cfe Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 10 Jun 2026 00:25:10 -0700 Subject: [PATCH] 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 --- .../dashboard/app/components/MailboxModal.css | 3 +- .../dashboard/app/components/MailboxView.tsx | 58 +++++++++++++++++-- .../components/__tests__/MailboxView.test.tsx | 35 +++++++++++ 3 files changed, 89 insertions(+), 7 deletions(-) diff --git a/packages/dashboard/app/components/MailboxModal.css b/packages/dashboard/app/components/MailboxModal.css index 4d62583b31..35cdbadca8 100644 --- a/packages/dashboard/app/components/MailboxModal.css +++ b/packages/dashboard/app/components/MailboxModal.css @@ -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; } diff --git a/packages/dashboard/app/components/MailboxView.tsx b/packages/dashboard/app/components/MailboxView.tsx index e3d08fa7e2..f4c43df12b 100644 --- a/packages/dashboard/app/components/MailboxView.tsx +++ b/packages/dashboard/app/components/MailboxView.tsx @@ -232,6 +232,8 @@ export function MailboxView({ const isSplitPane = !isMobile; const [sidebarWidth, setSidebarWidth] = useState(() => readMailboxSidebarWidth(projectId)); const splitLayoutRef = useRef(null); + const mailboxContentRef = useRef(null); + const pendingScrollTopRef = useRef(null); const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } = useMobileKeyboard({ enabled: isMobile }); const containerKeyboardStyle = useMemo(() => { 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({ -
+
{isSplitPane ? (
{ 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(); + 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],