FN-5915: show unread badge on header mailbox

Add unread mailbox status to the desktop header toggle while keeping pending approvals prioritized.

- show an unread status dot on the desktop header mailbox toggle when unread mail exists without pending approvals
- keep pending-approval indicators taking precedence and hide mailbox indicators while the mailbox view is active
- extend Header coverage for unread-only, pending-only, combined, zero-count, and active-mailbox states
- update restart integration coverage to reuse an existing worktree during orphaned resume concurrency

Files changed:
 docs/dashboard-guide.md                            |  2 +-
 packages/dashboard/app/components/Header.tsx       |  9 +++++--
 .../app/components/__tests__/Header.test.tsx       | 26 ++++++++++++++++---
 .../src/__tests__/restart.integration.test.ts      | 29 ++++++++++++++++++++--
 4 files changed, 58 insertions(+), 8 deletions(-)

Fusion-Task-Id: FN-5915

Fusion-Task-Lineage: 3a207e2c-ca58-402f-94cf-3a9e514fe263
This commit is contained in:
gsxdsm
2026-06-02 19:19:33 -07:00
parent 39d96bc83b
commit 0041962309
4 changed files with 60 additions and 10 deletions

View File

@@ -211,7 +211,7 @@ Mailbox view shows inbox/outbox communication threads and unread state.
- reply rows in the mailbox modal can expand inline to show the replied-to message context for easier thread reading
- mailbox now includes an **Approvals** tab with pending and history filters (`approved` / `denied` / `completed`), approval detail context, and inline approve/deny actions for pending requests
- in the **Agents** tab, the agent selector now includes **All agents**, which shows one combined agent-to-agent stream (with sender + recipient labels); selecting a specific agent still shows Inbox/Outbox subtabs
- mailbox entry points now show pending-approval indicators: Header mailbox toggle dot, Header overflow mailbox badge, Mobile mailbox tab dot, and Mobile More → Mailbox badge
- mailbox entry points now show unread/pending indicators: the desktop Header mailbox toggle shows a pending-approval dot first or an unread dot when unread mail exists without pending approvals, while Header overflow + Mobile mailbox entry points continue to surface mailbox badges/dots
- approval lifecycle SSE events (`approval:requested`, `approval:updated`, `approval:decided`) trigger mailbox approvals refresh without manual reload
- when a task newly enters `awaiting-approval`, the app shows a persistent approval banner above project content with an **Open Mailbox** CTA; dismissals are remembered per approval item until that item advances or a different one arrives
- Visible message history/threading is driven by explicit `message.metadata.replyTo.messageId` links

View File

@@ -1170,9 +1170,14 @@ export function Header({
aria-pressed={view === "mailbox"}
>
<Mail size={16} />
{mailboxPendingApprovalCount > 0 && view !== "mailbox" && (
{view !== "mailbox" && mailboxPendingApprovalCount > 0 ? (
<span className="status-dot status-dot--pending header-chat-unread-dot" aria-label="Pending approvals" />
)}
) : view !== "mailbox" && mailboxUnreadCount > 0 ? (
<span
className="status-dot status-dot--online header-chat-unread-dot"
aria-label={`${mailboxUnreadCount} unread messages`}
/>
) : null}
</button>
{pluginDashboardViews
.filter((entry) => entry.view.placement === "primary")

View File

@@ -178,14 +178,34 @@ describe("Header", () => {
expect(screen.getByLabelText("Unread chat response")).toBeInTheDocument();
});
it("shows mailbox pending-approval indicator when mailbox is not active", () => {
renderHeader({ onChangeView: noop, view: "board", mailboxPendingApprovalCount: 2 });
expect(screen.getByLabelText("Pending approvals")).toBeInTheDocument();
it("shows mailbox unread indicator when there are unread messages only", () => {
renderHeader({ onChangeView: noop, view: "board", mailboxUnreadCount: 3, mailboxPendingApprovalCount: 0 });
expect(screen.getByLabelText("3 unread messages")).toBeInTheDocument();
expect(screen.queryByLabelText("Pending approvals")).toBeNull();
});
it("hides mailbox pending-approval indicator when mailbox view is active", () => {
renderHeader({ onChangeView: noop, view: "mailbox", mailboxPendingApprovalCount: 2 });
it("shows mailbox pending-approval indicator when mailbox is not active", () => {
renderHeader({ onChangeView: noop, view: "board", mailboxPendingApprovalCount: 2, mailboxUnreadCount: 0 });
expect(screen.getByLabelText("Pending approvals")).toBeInTheDocument();
expect(screen.queryByLabelText(/unread messages/)).toBeNull();
});
it("shows only the pending indicator when mailbox has both pending approvals and unread messages", () => {
renderHeader({ onChangeView: noop, view: "board", mailboxPendingApprovalCount: 2, mailboxUnreadCount: 4 });
expect(screen.getByLabelText("Pending approvals")).toBeInTheDocument();
expect(screen.queryByLabelText("4 unread messages")).toBeNull();
});
it("hides mailbox indicators when counts are zero", () => {
renderHeader({ onChangeView: noop, view: "board", mailboxPendingApprovalCount: 0, mailboxUnreadCount: 0 });
expect(screen.queryByLabelText("Pending approvals")).toBeNull();
expect(screen.queryByLabelText(/unread messages/)).toBeNull();
});
it("hides mailbox indicators when mailbox view is active", () => {
renderHeader({ onChangeView: noop, view: "mailbox", mailboxPendingApprovalCount: 2, mailboxUnreadCount: 3 });
expect(screen.queryByLabelText("Pending approvals")).toBeNull();
expect(screen.queryByLabelText(/unread messages/)).toBeNull();
});
it("hides chat unread indicator when chat view is active", () => {

View File

@@ -1298,9 +1298,34 @@ describe("Crash scenario edge cases", () => {
it("concurrent resumeOrphaned() calls don't double-execute the same task", async () => {
const store = createMockStore();
const task = makeTask("FN-092", "in-progress");
const worktreePath = "/tmp/test/.worktrees/swift-falcon";
const task = makeTask("FN-092", "in-progress", {
worktree: worktreePath,
branch: "fusion/fn-092",
});
store.listTasks.mockResolvedValue([task]);
store.getTask.mockResolvedValue(makeTaskDetail("FN-092", "in-progress"));
store.getTask.mockResolvedValue(makeTaskDetail("FN-092", "in-progress", {
worktree: worktreePath,
branch: "fusion/fn-092",
}));
mockedExecSync.mockImplementation(((cmd: unknown) => {
if (String(cmd) === "git rev-parse --is-inside-work-tree") {
return "true\n" as any;
}
if (String(cmd) === "git worktree list --porcelain") {
return [
"worktree /tmp/test",
"HEAD abc123",
"branch refs/heads/main",
"",
`worktree ${worktreePath}`,
"HEAD def456",
"branch refs/heads/fusion/fn-092",
"",
].join("\n") as any;
}
return Buffer.from("");
}) as any);
let resolvePrompt: (() => void) | undefined;
mockedCreateFnAgent.mockResolvedValue({