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:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user