From 9cdddab5048e8fcf87c0e8541af5947617b66d78 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 17 Jul 2026 13:10:39 -0700 Subject: [PATCH] FN-8231: dismiss mobile mailbox details on Back Mobile mailbox overlays now close through the shared navigation history before leaving the message list. - Register mobile message, composer, and approval overlays as navigation modals. - Remove matching history entries when mailbox overlays close or change state. - Add mobile back-navigation coverage and document the behavior. Files changed: docs/dashboard-guide.md | 3 +- packages/dashboard/app/components/MailboxView.tsx | 117 ++++++++++++---- .../app/components/__tests__/MailboxView.test.tsx | 148 +++++++++++++++++++++ 3 files changed, 241 insertions(+), 27 deletions(-) Fusion-Task-Id: FN-8231 Fusion-Task-Lineage: b6f06ed9-b03f-45d9-b90b-28b565231bcc Co-authored-by: Fusion (runfusion.ai) --- docs/dashboard-guide.md | 3 +- .../dashboard/app/components/MailboxView.tsx | 117 +++++++++++--- .../components/__tests__/MailboxView.test.tsx | 148 ++++++++++++++++++ 3 files changed, 241 insertions(+), 27 deletions(-) diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 96d5a33c86..6b59c0491d 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -74,8 +74,9 @@ The installed mobile/PWA home-screen icons are generated from `packages/dashboar The dashboard now handles browser back navigation consistently on desktop and mobile. Using Back will first dismiss open modals and then step back through in-app view changes before leaving the app. -On mobile, an open navigation-bar **More** sheet is dismissed by one browser Back action, iOS edge-swipe, or Android Back action before the current dashboard view changes. +On mobile, an open navigation-bar **More** sheet or mailbox message detail is dismissed by one browser Back action, iOS edge-swipe, or Android Back action before the current dashboard view changes. + When task detail is open from a board card, task popup, mobile list row, right-dock/activity/onboarding link, deep link, or another task detail link, one browser, iOS edge-swipe, or Android Back action closes the current detail first and restores the prior dashboard context (for example, nested task detail → previous task detail, or task detail → board/list). On mobile board-card detail, **Back to board** also restores the prior board/card scroll position so the same lane context remains visible. diff --git a/packages/dashboard/app/components/MailboxView.tsx b/packages/dashboard/app/components/MailboxView.tsx index af1120644e..8e597a3fc5 100644 --- a/packages/dashboard/app/components/MailboxView.tsx +++ b/packages/dashboard/app/components/MailboxView.tsx @@ -1,5 +1,5 @@ import "./MailboxModal.css"; -import { useState, useEffect, useCallback, useMemo, useRef, type CSSProperties } from "react"; +import { useState, useEffect, useCallback, useContext, useMemo, useRef, type CSSProperties } from "react"; import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; import { @@ -46,6 +46,7 @@ import { GatedActionApprovalDetails } from "./GatedActionApprovalDetails"; import { subscribeSse } from "../sse-bus"; import { useViewportMode } from "../hooks/useViewportMode"; import { useMobileKeyboard } from "../hooks/useMobileKeyboard"; +import { NavigationHistoryContext } from "../hooks/useNavigationHistory"; import { getScopedItem, setScopedItem } from "../utils/projectStorage"; import { getRelativeTimeBucket } from "../utils/relativeTimeAgo"; @@ -263,6 +264,7 @@ export function MailboxView({ ); const viewportMode = useViewportMode(); const isMobile = viewportMode === "mobile"; + const navigationHistory = useContext(NavigationHistoryContext); const isSplitPane = !isMobile; const [sidebarWidth, setSidebarWidth] = useState(() => readMailboxSidebarWidth(projectId)); const splitLayoutRef = useRef(null); @@ -682,6 +684,11 @@ export function MailboxView({ setConversationMessages([]); }, [consumeCurrentDeepLink]); + const dismissMessage = useCallback(() => { + navigationHistory?.removeNav(handleCloseMessage); + handleCloseMessage(); + }, [handleCloseMessage, navigationHistory]); + const handleMarkAllRead = useCallback(async () => { try { const result = await markAllMessagesRead(projectId); @@ -706,8 +713,7 @@ export function MailboxView({ consumeCurrentDeepLink(); try { await deleteMessage(id, projectId); - setSelectedMessage(null); - setConversationMessages([]); + dismissMessage(); // Refresh current tab if (activeTab === "inbox") loadInbox(); else if (activeTab === "outbox") loadOutbox(); @@ -717,31 +723,44 @@ export function MailboxView({ } catch { addToast?.("Failed to delete message", "error"); } - }, [projectId, activeTab, selectedAgentId, loadInbox, loadOutbox, loadAgentMailbox, loadAllAgentsMailbox, addToast, consumeCurrentDeepLink]); + }, [projectId, activeTab, selectedAgentId, loadInbox, loadOutbox, loadAgentMailbox, loadAllAgentsMailbox, addToast, consumeCurrentDeepLink, dismissMessage]); const handleReply = useCallback((message: Message) => { - consumeCurrentDeepLink(); + dismissMessage(); setComposeRecipient({ id: message.fromId, type: message.fromType }); setComposeReplyContext({ messageId: message.id, preview: messagePreview(message.content, 120), }); setShowComposer(true); - }, [consumeCurrentDeepLink]); + }, [dismissMessage]); - const handleMessageSent = useCallback(() => { + const handleCloseComposer = useCallback(() => { + consumeCurrentDeepLink(); setShowComposer(false); setComposeRecipient(null); setComposeReplyContext(null); + }, [consumeCurrentDeepLink]); + + const dismissComposer = useCallback(() => { + navigationHistory?.removeNav(handleCloseComposer); + handleCloseComposer(); + }, [handleCloseComposer, navigationHistory]); + + const handleMessageSent = useCallback(() => { + dismissComposer(); addToast?.("Message sent", "success"); // Refresh current tab if (activeTab === "outbox") loadOutbox(); else if (activeTab === "agents" && selectedAgentId === ALL_AGENTS_MAILBOX_ID) loadAllAgentsMailbox(); else if (activeTab === "agents" && selectedAgentId) loadAgentMailbox(selectedAgentId); refreshUnreadCount(); - }, [activeTab, loadOutbox, selectedAgentId, loadAgentMailbox, loadAllAgentsMailbox, addToast, refreshUnreadCount]); + }, [activeTab, loadOutbox, selectedAgentId, loadAgentMailbox, loadAllAgentsMailbox, addToast, refreshUnreadCount, dismissComposer]); const handleOpenCompose = useCallback(() => { + if (isMobile && selectedMessage) { + dismissMessage(); + } consumeCurrentDeepLink(); // Pre-fill recipient from selected agent if available if (activeTab === "agents" && selectedAgentId && selectedAgentId !== ALL_AGENTS_MAILBOX_ID) { @@ -751,14 +770,9 @@ export function MailboxView({ } setComposeReplyContext(null); setShowComposer(true); - }, [activeTab, selectedAgentId, consumeCurrentDeepLink]); + }, [activeTab, selectedAgentId, consumeCurrentDeepLink, dismissMessage, isMobile, selectedMessage]); - const handleComposeCancel = useCallback(() => { - consumeCurrentDeepLink(); - setShowComposer(false); - setComposeRecipient(null); - setComposeReplyContext(null); - }, [consumeCurrentDeepLink]); + const handleComposeCancel = dismissComposer; const handleOpenApproval = useCallback(async (request: ApprovalRequestSummary) => { consumeCurrentDeepLink(); @@ -771,6 +785,15 @@ export function MailboxView({ } }, [projectId, addToast, consumeCurrentDeepLink]); + const handleCloseApproval = useCallback(() => { + setSelectedApproval(null); + }, []); + + const dismissApproval = useCallback(() => { + navigationHistory?.removeNav(handleCloseApproval); + handleCloseApproval(); + }, [handleCloseApproval, navigationHistory]); + const handleApprovalDecision = useCallback(async (decision: "approve" | "deny") => { if (!selectedApproval || approvalDecisionLoading) return; setApprovalDecisionLoading(decision); @@ -788,6 +811,48 @@ export function MailboxView({ } }, [selectedApproval, approvalDecisionLoading, approvalComment, projectId, loadApprovals, approvalSubTab, addToast]); + /* + FNXC:MailboxMobile 2026-07-16-16:00: + Mobile mailbox overlays must register modal history entries so iOS swipe-back, + Android native Back, and browser Back dismiss the current overlay before leaving + the mailbox. Programmatic closers remove their matching entries; nullable context + keeps the provider-less MailboxView test and embedded renders operational. + */ + useEffect(() => { + if (!isMobile || !selectedMessage || showComposer || !navigationHistory) return; + navigationHistory.pushNav({ type: "modal", close: handleCloseMessage }); + }, [handleCloseMessage, isMobile, navigationHistory, selectedMessage, showComposer]); + + useEffect(() => { + if (!isMobile || !showComposer || !navigationHistory) return; + navigationHistory.pushNav({ type: "modal", close: handleCloseComposer }); + }, [handleCloseComposer, isMobile, navigationHistory, showComposer]); + + useEffect(() => { + if (!isMobile || !selectedApproval || !navigationHistory) return; + navigationHistory.pushNav({ type: "modal", close: handleCloseApproval }); + }, [handleCloseApproval, isMobile, navigationHistory, selectedApproval]); + + const handleSelectTab = useCallback((tab: MailboxTab) => { + consumeCurrentDeepLink(); + dismissMessage(); + dismissApproval(); + setActiveTab(tab); + }, [consumeCurrentDeepLink, dismissApproval, dismissMessage]); + + const handleAgentSelection = useCallback((agentId: string) => { + consumeCurrentDeepLink(); + dismissMessage(); + setSelectedAgentId(agentId); + setAgentSubTab("inbox"); + }, [consumeCurrentDeepLink, dismissMessage]); + + const handleAgentSubTab = useCallback((tab: "inbox" | "outbox") => { + consumeCurrentDeepLink(); + dismissMessage(); + setAgentSubTab(tab); + }, [consumeCurrentDeepLink, dismissMessage]); + // ── Render ──────────────────────────────────────────────────────────── const renderMessageDetail = () => { @@ -801,7 +866,7 @@ export function MailboxView({ {isMobile && (