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 && (
)}