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:
gsxdsm
2026-06-10 00:25:10 -07:00
parent c9d48fb750
commit 5e35385594
3 changed files with 89 additions and 7 deletions

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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],