diff --git a/app/(main)/[locale]/pro/page.tsx b/app/(main)/[locale]/pro/page.tsx index 1d1005f0..5d4a512c 100644 --- a/app/(main)/[locale]/pro/page.tsx +++ b/app/(main)/[locale]/pro/page.tsx @@ -139,7 +139,7 @@ export default function ProHome() { const focusedPaneId = useProTabStore((s) => s.focusedPaneId); const loadedTabIds = useProTabStore((s) => s.loadedTabIds); const openTab = useProTabStore((s) => s.openTab); - const closeTab = useProTabStore((s) => s.closeTab); + const requestCloseTab = useProTabStore((s) => s.requestCloseTab); const setActiveTab = useProTabStore((s) => s.setActiveTab); const setFocusedPane = useProTabStore((s) => s.setFocusedPane); const moveTabToPane = useProTabStore((s) => s.moveTabToPane); @@ -354,7 +354,7 @@ export default function ProHome() { activeMainTabId={activeMainTabId} activeSplitTabId={activeSplitTabId} onActivate={setActiveTab} - onClose={closeTab} + onClose={requestCloseTab} onDragStateChange={setIsTabDragging} /> diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 23b24ae8..3544eb09 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -133,6 +133,13 @@ interface EmailComposerProps { }) => void | Promise; onScheduledSendCreated?: () => void | Promise; onClose?: () => void; + /** + * When provided, the composer assigns its close handler to `current`. The + * handler shows the unsaved-changes dialog when the draft is dirty, so a + * host (e.g. the Pro tab bar's close button) can route an external close + * request through the same guard instead of discarding silently. + */ + requestCloseRef?: React.MutableRefObject<(() => void) | null>; onDiscardDraft?: (draftId: string) => void; onSaveState?: (data: ComposerDraftData) => void; className?: string; @@ -229,6 +236,7 @@ export function EmailComposer({ onSend, onScheduledSendCreated, onClose, + requestCloseRef, onDiscardDraft, onSaveState, className, @@ -1815,6 +1823,17 @@ export function EmailComposer({ } }; + // Expose the dirty-aware close handler so external hosts (e.g. the Pro tab + // bar) can trigger the same "Save or discard draft?" guard. Re-assigned on + // every render to capture the latest closure over the live refs/state. + useEffect(() => { + if (!requestCloseRef) return; + requestCloseRef.current = handleClose; + return () => { + requestCloseRef.current = null; + }; + }); + const handleComposerKeyDown = (e: React.KeyboardEvent) => { if (e.defaultPrevented) return; diff --git a/components/pro/pro-compose-tab-body.tsx b/components/pro/pro-compose-tab-body.tsx index a175e157..039331bf 100644 --- a/components/pro/pro-compose-tab-body.tsx +++ b/components/pro/pro-compose-tab-body.tsx @@ -7,7 +7,7 @@ import { ErrorBoundary, ComposerErrorFallback } from "@/components/error"; import { useAuthStore } from "@/stores/auth-store"; import { useEmailStore } from "@/stores/email-store"; import { toast } from "@/stores/toast-store"; -import { useProTabStore, type ProComposeTabData } from "@/stores/pro-tab-store"; +import { useProTabStore, registerProTabCloseInterceptor, type ProComposeTabData } from "@/stores/pro-tab-store"; import { debug } from "@/lib/debug"; interface ProComposeTabBodyProps { @@ -38,6 +38,10 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) { const tabIdRef = useRef(tabId); tabIdRef.current = tabId; + // Set by the composer to its dirty-aware close handler. Lets the Pro tab + // bar's "X" route through the same "Save or discard draft?" guard. + const requestCloseRef = useRef<(() => void) | null>(null); + const handleScheduledSendCreated = useCallback(async () => { if (client) { await refreshScheduledMetadata(client); @@ -120,6 +124,20 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) { closeTab(tabIdRef.current); }, [closeTab]); + // Register a close interceptor so closing the tab from the tab bar (the + // "X" button or middle-click) goes through the composer's unsaved-changes + // guard, mirroring the non-Pro inline composer. + useEffect(() => { + const id = tabIdRef.current; + return registerProTabCloseInterceptor(id, () => { + if (requestCloseRef.current) { + requestCloseRef.current(); + } else { + closeTab(id); + } + }); + }, [closeTab]); + const handleDiscardDraft = useCallback(async (draftId: string) => { if (!client) return; try { @@ -160,6 +178,7 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) { onSend={handleSend} onScheduledSendCreated={handleScheduledSendCreated} onClose={handleClose} + requestCloseRef={requestCloseRef} onDiscardDraft={handleDiscardDraft} onSaveState={handleSaveState} className="flex-1" diff --git a/stores/pro-tab-store.ts b/stores/pro-tab-store.ts index 0178f370..a4d239bb 100644 --- a/stores/pro-tab-store.ts +++ b/stores/pro-tab-store.ts @@ -87,6 +87,12 @@ interface ProTabState { openComposeTab: (data: ProComposeTabData) => string; openEmailTab: (data: ProEmailTabData) => string; closeTab: (id: string) => void; + /** + * Request closing a tab, honouring any registered close interceptor (e.g. a + * compose tab with unsaved changes that wants to show the "Save or discard + * draft?" dialog first). Falls back to `closeTab` when none is registered. + */ + requestCloseTab: (id: string) => void; setActiveTab: (id: string) => void; setFocusedPane: (paneId: ProPaneId) => void; @@ -131,6 +137,21 @@ const HOME_TAB: ProTab = { paneId: 'main', }; +/** + * Module-level registry of tab close interceptors. Kept outside the persisted + * Zustand state so functions are never serialised. A compose tab registers a + * handler here so an external close request (tab-bar "X", middle-click) routes + * through the composer's unsaved-changes guard instead of closing instantly. + */ +const closeInterceptors = new Map void>(); + +export function registerProTabCloseInterceptor(id: string, fn: () => void): () => void { + closeInterceptors.set(id, fn); + return () => { + if (closeInterceptors.get(id) === fn) closeInterceptors.delete(id); + }; +} + function makeId(): string { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID(); @@ -265,11 +286,23 @@ export const useProTabStore = create()( return newTab.id; }, + requestCloseTab: (id) => { + const interceptor = closeInterceptors.get(id); + if (interceptor) { + interceptor(); + return; + } + get().closeTab(id); + }, + closeTab: (id) => { const state = get(); const tab = state.tabs.find((t) => t.id === id); if (!tab || !tab.closeable) return; + // Drop any registered close interceptor for this tab. + closeInterceptors.delete(id); + const removedPane = tab.paneId; const newTabs = state.tabs.filter((t) => t.id !== id); const newLoaded = state.loadedTabIds.filter((tid) => tid !== id);