diff --git a/packages/dashboard/app/components/MailboxModal.css b/packages/dashboard/app/components/MailboxModal.css index 1a127f55ea..c19e55805e 100644 --- a/packages/dashboard/app/components/MailboxModal.css +++ b/packages/dashboard/app/components/MailboxModal.css @@ -870,6 +870,104 @@ The list pane is fixed to its inline `width` (`flex: 0 0 auto`) so the divider d margin: 0; } +/* +FNXC:MailboxMobile 2026-07-17-13:43: +FN-8238 keeps the full-page mobile title above one compact action row: unread badge, +Compose, Mark all read, and Refresh remain visible together. This runtime-mode class +mirrors isMobileViewport(); CSS media and pointer queries cannot observe its physical-screen +or visualViewport classification, so desktop and tablet headers retain their shared chrome. +*/ +.mailbox-view--mobile .view-header { + flex-wrap: wrap; + gap: var(--space-sm); +} + +.mailbox-view--mobile .view-header__title { + flex: 0 0 100%; +} + +.mailbox-view--mobile .view-header__actions { + flex: 1 1 100%; + min-width: 0; + flex-wrap: nowrap; + justify-content: flex-start; + gap: var(--space-xs); + margin-left: 0; +} + +.mailbox-view--mobile .view-header__actions .btn { + min-width: 0; + flex-shrink: 1; + padding: var(--space-xs); + white-space: nowrap; +} + +.mailbox-view--mobile .view-header__actions .btn-icon, +.mailbox-view--mobile .mailbox-unread-badge { + flex-shrink: 0; +} + +/* +FNXC:MailboxMobile 2026-07-17-13:43: +FN-8238 requires Inbox, Outbox, Agents, and Approvals to remain on one compact mobile row. +Tabs may shrink while retaining their badge and use horizontal scrolling only as the narrowest +viewport fallback; this applies only when the runtime mobile class is present. +*/ +.mailbox-view--mobile .mailbox-tabs { + flex-wrap: nowrap; + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} + +.mailbox-view--mobile .mailbox-tab { + min-width: 0; + flex: 1 1 0; + flex-shrink: 1; + padding: var(--space-xs); + font-size: var(--font-size-xs, 0.8rem); +} + +/* +FNXC:MailboxMobile 2026-07-17-13:43: +FN-8238 keeps mobile message-detail Back, message meta, and Delete (plus Reply for agent +messages) on one row. Meta may truncate before controls wrap, and this does not affect the +separate approvals Back button outside this header or non-mobile mailbox layouts. +*/ +.mailbox-view--mobile .mailbox-message-detail-header { + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + gap: var(--space-xs); +} + +.mailbox-view--mobile .mailbox-message-detail-header > .btn, +.mailbox-view--mobile .mailbox-message-detail-actions { + flex-shrink: 0; +} + +.mailbox-view--mobile .mailbox-message-detail-meta { + min-width: 0; + flex: 1 1 auto; + overflow: hidden; + white-space: nowrap; +} + +.mailbox-view--mobile .mailbox-message-detail-meta > * { + overflow: hidden; + text-overflow: ellipsis; +} + +.mailbox-view--mobile .mailbox-message-detail-actions { + flex-wrap: nowrap; + gap: var(--space-xs); +} + +.mailbox-view--mobile .mailbox-message-detail-actions .btn { + min-width: 0; + padding: var(--space-xs); + white-space: nowrap; +} + /* ── Mailbox — Mobile (≤ 768px) ───────────────────────────────────── */ @media (max-width: 768px) { @@ -994,7 +1092,14 @@ The list pane is fixed to its inline `width` (`flex: 0 0 auto`) so the divider d padding: var(--space-sm) var(--space-md); } - /* Mailbox View (full-page) mobile overrides */ + /* + FNXC:MailboxMobile 2026-07-17-14:10: + FN-8238's runtime mobile class owns the compact full-page tabs and message-detail row. + Keep these legacy width-query fallbacks off that class: this media block follows the + compact rules in source order and would otherwise restore tab overflow and a wrapped + detail header on every actual phone viewport. + */ + /* Mailbox View (full-page) width-query fallbacks outside runtime mobile mode */ .mailbox-view .mailbox-header { flex-wrap: wrap; gap: var(--space-sm); @@ -1014,7 +1119,7 @@ The list pane is fixed to its inline `width` (`flex: 0 0 auto`) so the divider d display: none; } - .mailbox-view .mailbox-tab { + .mailbox-view:not(.mailbox-view--mobile) .mailbox-tab { flex-shrink: 0; padding: var(--space-sm) var(--space-md); font-size: var(--font-size-xs, 0.8rem); @@ -1053,12 +1158,12 @@ The list pane is fixed to its inline `width` (`flex: 0 0 auto`) so the divider d overflow: visible; } - .mailbox-view .mailbox-message-detail-header { + .mailbox-view:not(.mailbox-view--mobile) .mailbox-message-detail-header { flex-direction: column; align-items: flex-start; } - .mailbox-view .mailbox-message-detail-actions { + .mailbox-view:not(.mailbox-view--mobile) .mailbox-message-detail-actions { flex-wrap: wrap; } diff --git a/packages/dashboard/app/components/MailboxView.tsx b/packages/dashboard/app/components/MailboxView.tsx index 8e597a3fc5..7090de1665 100644 --- a/packages/dashboard/app/components/MailboxView.tsx +++ b/packages/dashboard/app/components/MailboxView.tsx @@ -1348,8 +1348,18 @@ export function MailboxView({ ); }; + /* + FNXC:MailboxMobile 2026-07-17-13:43: + FN-8238 gates the full-page mailbox's compact mobile layout on this class, mirroring + isMobileViewport() exactly. CSS media or pointer queries cannot read the runtime + physical-screen and visualViewport signals that determine the mobile classification. + */ return ( -
+
{/* FNXC:Navigation 2026-06-22-01:10: Mailbox adopts the shared ViewHeader (Command Center-modeled) for a consistent main-content title row. The unread count badge stays beside the title (preserving the mailbox-unread-badge test id), and Compose / Mark-all-read / Refresh controls move into the header actions cluster so they keep working. Tabs remain below the header as their own row. diff --git a/packages/dashboard/app/components/__tests__/MailboxView.test.tsx b/packages/dashboard/app/components/__tests__/MailboxView.test.tsx index fb49328a50..6bbf566677 100644 --- a/packages/dashboard/app/components/__tests__/MailboxView.test.tsx +++ b/packages/dashboard/app/components/__tests__/MailboxView.test.tsx @@ -2283,6 +2283,79 @@ describe("MailboxView", () => { }); describe("mobile layout CSS regressions", () => { + it("adds the runtime mobile layout gate only in mobile mode", async () => { + mockFetchInbox.mockResolvedValue(makeInboxResponse([], 0)); + mockUseViewportMode.mockReturnValue("mobile"); + + const { unmount } = render(); + expect(await screen.findByTestId("mailbox-view")).toHaveClass("mailbox-view--mobile"); + unmount(); + + mockUseViewportMode.mockReturnValue("desktop"); + render(); + expect(await screen.findByTestId("mailbox-view")).not.toHaveClass("mailbox-view--mobile"); + }); + + it("defines class-gated, compact single-row mailbox mobile layout rules", () => { + const css = loadAllAppCss(); + + expect(css).toMatch(/\.mailbox-view--mobile\s+\.view-header\s*\{[^}]*flex-wrap:\s*wrap;[^}]*\}/); + expect(css).toMatch(/\.mailbox-view--mobile\s+\.view-header__actions\s*\{[^}]*flex:\s*1\s+1\s+100%;[^}]*min-width:\s*0;[^}]*flex-wrap:\s*nowrap;[^}]*margin-left:\s*0;[^}]*\}/); + expect(css).toMatch(/\.mailbox-view--mobile\s+\.mailbox-tabs\s*\{[^}]*flex-wrap:\s*nowrap;[^}]*overflow-x:\s*auto;[^}]*\}/); + expect(css).toMatch(/\.mailbox-view--mobile\s+\.mailbox-tab\s*\{[^}]*min-width:\s*0;[^}]*flex:\s*1\s+1\s+0;[^}]*flex-shrink:\s*1;[^}]*\}/); + expect(css).toMatch(/\.mailbox-view--mobile\s+\.mailbox-message-detail-header\s*\{[^}]*flex-direction:\s*row;[^}]*flex-wrap:\s*nowrap;[^}]*\}/); + expect(css).toMatch(/\.mailbox-view--mobile\s+\.mailbox-message-detail-actions\s*\{[^}]*flex-wrap:\s*nowrap;[^}]*\}/); + // The later width-query block must not override the runtime compact tab/detail rules on phones. + expect(css).toMatch(/\.mailbox-view:not\(\.mailbox-view--mobile\)\s+\.mailbox-tab\s*\{[^}]*flex-shrink:\s*0;[^}]*\}/); + expect(css).toMatch(/\.mailbox-view:not\(\.mailbox-view--mobile\)\s+\.mailbox-message-detail-header\s*\{[^}]*flex-direction:\s*column;[^}]*\}/); + expect(css).toMatch(/\.mailbox-view:not\(\.mailbox-view--mobile\)\s+\.mailbox-message-detail-actions\s*\{[^}]*flex-wrap:\s*wrap;[^}]*\}/); + + // The runtime class, not a height or pointer media proxy, is the only FN-8238 gate. + expect(css).not.toMatch(/@media\s*\([^)]*(?:max-height:\s*480px|pointer:\s*coarse)[^)]*\)\s*\{[\s\S]*?\.mailbox-view--mobile/); + }); + + it("keeps system and agent message actions on the mobile detail row", async () => { + mockUseViewportMode.mockReturnValue("mobile"); + const systemMessage: Message = { + ...mockMessage, + id: "msg-system", + fromId: "system", + fromType: "system", + type: "system", + }; + mockFetchInbox.mockResolvedValue(makeInboxResponse([systemMessage], 0)); + mockFetchConversation.mockResolvedValue([systemMessage]); + + const { unmount } = render(); + await screen.findByTestId("mailbox-item-msg-system"); + fireEvent.click(screen.getByTestId("mailbox-item-msg-system")); + await screen.findByTestId("mailbox-message-detail"); + expect(screen.getByTestId("mailbox-delete")).toBeInTheDocument(); + expect(screen.queryByTestId("mailbox-reply")).toBeNull(); + unmount(); + + mockFetchInbox.mockResolvedValue(makeInboxResponse([mockMessage], 0)); + mockFetchConversation.mockResolvedValue([mockMessage]); + render(); + await screen.findByTestId("mailbox-item-msg-001"); + fireEvent.click(screen.getByTestId("mailbox-item-msg-001")); + await screen.findByTestId("mailbox-message-detail"); + expect(screen.getByTestId("mailbox-reply")).toBeInTheDocument(); + expect(screen.getByTestId("mailbox-delete")).toBeInTheDocument(); + }); + + it("renders every unread Inbox header action together in mobile mode", async () => { + mockUseViewportMode.mockReturnValue("mobile"); + mockFetchInbox.mockResolvedValue(makeInboxResponse([mockMessage], 1)); + + render(); + + expect(await screen.findByTestId("mailbox-unread-badge")).toBeInTheDocument(); + expect(screen.getByTestId("mailbox-header-compose")).toBeInTheDocument(); + expect(screen.getByTestId("mailbox-mark-all-read")).toBeInTheDocument(); + expect(screen.getByTestId("mailbox-refresh")).toBeInTheDocument(); + }); + it("defines .mailbox-view base flex layout with min-height: 0", async () => { const fs = await import("fs"); const path = await import("path");