From fff23c1fc3528883ca232a475ee2aa14541d3a1c Mon Sep 17 00:00:00 2001 From: Jiayuan Zhang Date: Wed, 15 Jul 2026 18:09:52 +0800 Subject: [PATCH] MUL-4818: keep Inbox and sidebar unread counts static (#5461) * fix(sidebar): keep unread counters static Co-authored-by: multica-agent * fix(inbox): keep title unread count static Co-authored-by: multica-agent --------- Co-authored-by: Lambda Co-authored-by: multica-agent --- .../inbox/components/inbox-page.test.tsx | 72 +++++++++++++++++++ .../views/inbox/components/inbox-page.tsx | 1 + packages/views/layout/app-sidebar.test.tsx | 18 ++++- packages/views/layout/app-sidebar.tsx | 2 + 4 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 packages/views/inbox/components/inbox-page.test.tsx diff --git a/packages/views/inbox/components/inbox-page.test.tsx b/packages/views/inbox/components/inbox-page.test.tsx new file mode 100644 index 000000000..0f6805dd4 --- /dev/null +++ b/packages/views/inbox/components/inbox-page.test.tsx @@ -0,0 +1,72 @@ +import { render } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { InboxPage } from "./inbox-page"; + +vi.mock("react-resizable-panels", () => ({ + useDefaultLayout: () => ({ defaultLayout: undefined, onLayoutChanged: vi.fn() }), +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ data: [], isLoading: false }), +})); + +vi.mock("@multica/core/hooks", () => ({ + useWorkspaceId: () => "workspace-1", +})); + +vi.mock("@multica/core/paths", () => ({ + useWorkspacePaths: () => ({ + inbox: () => "/acme/inbox", + issueDetail: (id: string) => `/acme/issues/${id}`, + }), +})); + +vi.mock("@multica/core/modals", () => ({ + useModalStore: { getState: () => ({ open: vi.fn() }) }, +})); + +vi.mock("@multica/core/issues/stores/draft-store", () => ({ + useIssueDraftStore: { getState: () => ({ setDraft: vi.fn() }) }, +})); + +vi.mock("@multica/core/inbox/queries", () => ({ + inboxListOptions: () => ({ queryKey: ["inbox"] }), + deduplicateInboxItems: (items: unknown[]) => items, + useInboxUnreadCount: () => 2, +})); + +vi.mock("@multica/core/inbox/mutations", () => { + const mutation = () => ({ mutate: vi.fn() }); + return { + useMarkInboxRead: mutation, + useArchiveInbox: mutation, + useMarkAllInboxRead: mutation, + useArchiveAllInbox: mutation, + useArchiveAllReadInbox: mutation, + useArchiveCompletedInbox: mutation, + }; +}); + +vi.mock("../../issues/components", () => ({ IssueDetail: () => null })); + +vi.mock("../../navigation", () => ({ + useNavigation: () => ({ searchParams: new URLSearchParams(), replace: vi.fn() }), +})); + +vi.mock("@multica/ui/hooks/use-mobile", () => ({ useIsMobile: () => true })); +vi.mock("./inbox-list", () => ({ InboxList: () => null })); +vi.mock("./inbox-list-item", () => ({ useTimeAgo: () => vi.fn() })); +vi.mock("./inbox-detail-label", () => ({ useTypeLabels: () => ({}) })); +vi.mock("../../i18n", () => ({ useT: () => ({ t: () => "Inbox" }) })); + +describe("InboxPage", () => { + it("keeps the title unread count static", () => { + const { container } = render(); + const titleCount = container.querySelector("h1")?.parentElement?.querySelector( + "number-flow-react", + ) as (HTMLElement & { animated?: boolean }) | null; + + expect(titleCount?.getAttribute("aria-label")).toBe("2"); + expect(titleCount?.animated).toBe(false); + }); +}); diff --git a/packages/views/inbox/components/inbox-page.tsx b/packages/views/inbox/components/inbox-page.tsx index 749a1aede..77dba961f 100644 --- a/packages/views/inbox/components/inbox-page.tsx +++ b/packages/views/inbox/components/inbox-page.tsx @@ -231,6 +231,7 @@ export function InboxPage() { {unreadCount > 0 && ( ({ +const { appForeground, chatSessions, chatStore, detail, deletePin, inboxItems, navigation, pins, summary, workspaces } = vi.hoisted(() => ({ appForeground: { current: true }, chatSessions: { current: [] as { id?: string; unread_count?: number }[] }, chatStore: { current: { activeSessionId: null as string | null, isOpen: false } }, detail: { current: { isPending: false, isError: false, data: null as unknown, error: null as unknown } }, deletePin: vi.fn(), + inboxItems: { current: [] as { id: string; read: boolean }[] }, navigation: { current: { pathname: "/acme/issues" } }, summary: { current: [] as { workspace_id: string; count: number }[] }, workspaces: { @@ -174,6 +175,7 @@ vi.mock("@tanstack/react-query", async (importOriginal) => ({ if (queryKey[0] === "pins") return { data: pins.current }; if (queryKey[0] === "issue") return detail.current; if (queryKey[0] === "inbox" && queryKey[1] === "unread-summary") return { data: summary.current }; + if (queryKey[0] === "inbox") return { data: inboxItems.current }; if (queryKey[0] === "workspaces") return { data: workspaces.current }; if (queryKey[0] === "chat" && queryKey[2] === "sessions") return { data: chatSessions.current }; return { data: [] }; @@ -298,6 +300,7 @@ describe("workspace-switcher dropdown per-workspace dot", () => { describe("personal nav — Chat", () => { beforeEach(() => { chatSessions.current = []; + inboxItems.current = []; navigation.current = { pathname: "/acme/issues" }; chatStore.current = { activeSessionId: null, isOpen: false }; appForeground.current = true; @@ -310,6 +313,19 @@ describe("personal nav — Chat", () => { const chatBadge = (container: HTMLElement) => chatNav(container)?.querySelector("number-flow-react") ?? null; + it("keeps persistent Inbox and Chat counters static", () => { + inboxItems.current = [{ id: "inbox-1", read: false }]; + chatSessions.current = [{ id: "chat-1", unread_count: 2 }]; + const { container } = render(); + const inboxBadge = container + .querySelector('button[data-href="/acme/inbox"]') + ?.querySelector("number-flow-react") as (HTMLElement & { animated?: boolean }) | null; + const currentChatBadge = chatBadge(container) as (HTMLElement & { animated?: boolean }) | null; + + expect(inboxBadge?.animated).toBe(false); + expect(currentChatBadge?.animated).toBe(false); + }); + it("renders a Chat nav link to the workspace chat route", () => { const { container } = render(); expect(chatNav(container)).not.toBeNull(); diff --git a/packages/views/layout/app-sidebar.tsx b/packages/views/layout/app-sidebar.tsx index 85c9e88a0..841b2bfb4 100644 --- a/packages/views/layout/app-sidebar.tsx +++ b/packages/views/layout/app-sidebar.tsx @@ -683,12 +683,14 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle } {item.key === "inbox" && unreadCount > 0 && ( )} {item.key === "chat" && chatUnreadCount > 0 && ( )}