perf(inbox): derive the unread badge from the summary endpoint, not the full list

The sidebar nav badge, the desktop dock badge and the mobile tab badge were
all computed by downloading the entire inbox with `GET /api/inbox` and
counting it client-side. That query has no LIMIT and no pagination, so every
app start pulled every unarchived notification the user has — each carrying a
full untruncated comment body — to render one number, whether or not the user
ever opened the Inbox.

`GET /api/inbox/unread-summary` already returns that number: one small row per
workspace, and its SQL applies the same newest-per-issue rule
`deduplicateInboxItems` applies before render, so the count is unchanged. Web
already fetches it for the workspace-switcher dot, so the badge there now
costs no request at all; mobile trades an unbounded list fetch for this one.

`GET /api/inbox/unread-count` is deliberately not used: it counts raw rows, so
one issue with three unread notifications would read as 3 where the inbox
shows a single row. Documented on the client method so the trap is visible.

The badge is now server-computed, so the optimistic list patches no longer
move it on their own. Each mutation that patches the list re-derives the
workspace's summary entry from the patched rows through the same dedup helper
the inbox renders through — the badge still moves in the same frame and
cannot disagree with the rows on screen. Mutations that cannot predict the
outcome invalidate the summary on settle instead, which they now all do: it
lives under an account-level key that `inboxKeys.all(wsId)` does not reach.

Refs MUL-6967.

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
J
2026-09-02 15:28:08 +08:00
co-authored by multica-agent
parent a2d819ea40
commit b0d06e1672
15 changed files with 476 additions and 62 deletions
+22
View File
@@ -26,6 +26,7 @@ import type {
CreateProjectRequest,
CreateProjectResourceRequest,
InboxItem,
InboxWorkspaceUnread,
Issue,
IssueLabelsResponse,
Label,
@@ -92,6 +93,7 @@ import {
EMPTY_CHAT_SESSION_LIST,
EMPTY_COMMENT,
EMPTY_INBOX_LIST,
EMPTY_INBOX_UNREAD_SUMMARY,
EMPTY_ISSUE_FALLBACK,
EMPTY_LIST_LABELS_RESPONSE,
EMPTY_LIST_PROJECT_RESOURCES_RESPONSE,
@@ -107,6 +109,7 @@ import {
EMPTY_USER,
EMPTY_WORKSPACE_LIST,
InboxListSchema,
InboxUnreadSummarySchema,
NotificationPreferenceResponseSchema,
ListLabelsResponseSchema,
ListProjectResourcesResponseSchema,
@@ -479,6 +482,25 @@ class ApiClient {
});
}
/**
* Cross-workspace unread inbox counts, one entry per workspace with unread
* items. Backs the inbox tab badge — see lib/unread-counts.ts for why the
* badge reads this instead of counting `listInbox()` locally.
*/
async getInboxUnreadSummary(opts?: {
signal?: AbortSignal;
}): Promise<InboxWorkspaceUnread[]> {
const raw = await this.fetch<unknown>("/api/inbox/unread-summary", {
signal: opts?.signal,
});
return parseWithFallback(
raw,
InboxUnreadSummarySchema,
EMPTY_INBOX_UNREAD_SUMMARY,
{ endpoint: "getInboxUnreadSummary" },
);
}
async markInboxRead(id: string): Promise<InboxItem> {
return this.fetch<InboxItem>(`/api/inbox/${id}/read`, { method: "POST" });
}
+50 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { InboxListSchema } from "./schemas";
import { InboxListSchema, InboxUnreadSummarySchema } from "./schemas";
/**
* Tests for mobile's CLIENT-SIDE parsing of GET /api/inbox.
@@ -94,3 +94,52 @@ describe("inbox list schema", () => {
expect(parsed.success).toBe(true);
});
});
/**
* GET /api/inbox/unread-summary — the source of the inbox tab badge.
*
* Blast radius differs from the list above: `getInboxUnreadSummary` falls back
* to an empty array, which reads as "nothing unread" and simply hides the
* badge. A wrong number would be worse than no number, so the schema stays
* strict about the shape and lenient only about extra fields.
*/
describe("inbox unread summary schema", () => {
it("parses the documented server payload", () => {
const parsed = InboxUnreadSummarySchema.safeParse([
{ workspace_id: "ws-1", count: 3 },
{ workspace_id: "ws-2", count: 1 },
]);
expect(parsed.success).toBe(true);
expect(parsed.success && parsed.data[1]?.count).toBe(1);
});
it("passes through a field this client does not know yet", () => {
const parsed = InboxUnreadSummarySchema.safeParse([
{ workspace_id: "ws-1", count: 3, unread_mentions: 2 },
]);
expect(parsed.success).toBe(true);
expect(parsed.success && parsed.data[0]?.count).toBe(3);
});
it("reads a non-numeric count as zero rather than failing the list", () => {
// One malformed row must not blank every other workspace's count.
const parsed = InboxUnreadSummarySchema.safeParse([
{ workspace_id: "ws-1", count: "many" },
{ workspace_id: "ws-2", count: 5 },
]);
expect(parsed.success).toBe(true);
expect(parsed.success && parsed.data[0]?.count).toBe(0);
expect(parsed.success && parsed.data[1]?.count).toBe(5);
});
it("rejects a row with no workspace id", () => {
// Without an id the entry can never be matched to a workspace, so it would
// silently contribute nothing — fail loudly into the empty fallback.
expect(
InboxUnreadSummarySchema.safeParse([{ count: 3 }]).success,
).toBe(false);
});
});
+54 -2
View File
@@ -26,10 +26,46 @@
* Just invalidate on settle. Matches web.
*/
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { InboxItem } from "@multica/core/types";
import type { QueryClient } from "@tanstack/react-query";
import type { InboxItem, InboxWorkspaceUnread } from "@multica/core/types";
import { api } from "@/data/api";
import { inboxKeys } from "@/data/queries/inbox";
import { useWorkspaceStore } from "@/data/workspace-store";
import { deduplicateInboxItems } from "@/lib/inbox-display";
/**
* Refresh the cross-workspace unread summary that backs the tab badge. It
* lives under its own account-level key, so invalidating the workspace list
* does not reach it — every mutation here can change the number it holds.
*/
function invalidateUnreadSummary(qc: QueryClient) {
qc.invalidateQueries({ queryKey: inboxKeys.unreadSummary() });
}
/**
* Re-derive this workspace's unread count from the just-patched list cache
* and write it into the summary cache, so the tab badge follows an optimistic
* patch instead of waiting for the round-trip.
*
* Mirrors syncUnreadSummaryFromList in packages/core/inbox/mutations.ts, and
* reuses the same `deduplicateInboxItems` the inbox screen renders through —
* the badge can therefore never disagree with the rows on screen. Both caches
* are read defensively: absent means nothing to be optimistic about, and the
* server value stands until `onSettled`.
*/
function syncUnreadSummaryFromList(qc: QueryClient, wsId: string | null) {
if (!wsId) return;
const items = qc.getQueryData<InboxItem[]>(inboxKeys.list(wsId));
if (!items) return;
const count = deduplicateInboxItems(items).filter((i) => !i.read).length;
qc.setQueryData<InboxWorkspaceUnread[]>(inboxKeys.unreadSummary(), (old) => {
if (!old) return old;
// Order carries no meaning — consumers look up by workspace id. A
// zero-count workspace is dropped, mirroring the server response.
const others = old.filter((entry) => entry.workspace_id !== wsId);
return count > 0 ? [...others, { workspace_id: wsId, count }] : others;
});
}
export function useMarkInboxRead() {
const qc = useQueryClient();
@@ -43,6 +79,9 @@ export function useMarkInboxRead() {
qc.setQueryData<InboxItem[]>(key, (old) =>
old?.map((item) => (item.id === id ? { ...item, read: true } : item)),
);
// Same frame as the row patch, so the badge and the row never disagree
// across the transition.
syncUnreadSummaryFromList(qc, wsId);
// Then the standard cancel + snapshot dance for rollback.
await qc.cancelQueries({ queryKey: key });
const prev = qc.getQueryData<InboxItem[]>(key);
@@ -50,9 +89,11 @@ export function useMarkInboxRead() {
},
onError: (_err, _id, ctx) => {
if (ctx?.prev) qc.setQueryData(ctx.key, ctx.prev);
syncUnreadSummaryFromList(qc, wsId);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
invalidateUnreadSummary(qc);
},
});
}
@@ -81,13 +122,17 @@ export function useArchiveInbox() {
: item,
),
);
// Archiving an unread issue group drops it out of the badge at once.
syncUnreadSummaryFromList(qc, wsId);
return { prev, key };
},
onError: (_err, _id, ctx) => {
if (ctx?.prev) qc.setQueryData(ctx.key, ctx.prev);
syncUnreadSummaryFromList(qc, wsId);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
invalidateUnreadSummary(qc);
},
});
}
@@ -107,13 +152,16 @@ export function useMarkAllInboxRead() {
!item.archived ? { ...item, read: true } : item,
),
);
syncUnreadSummaryFromList(qc, wsId);
return { prev, key };
},
onError: (_err, _vars, ctx) => {
if (ctx?.prev) qc.setQueryData(ctx.key, ctx.prev);
syncUnreadSummaryFromList(qc, wsId);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
invalidateUnreadSummary(qc);
},
});
}
@@ -121,7 +169,8 @@ export function useMarkAllInboxRead() {
// Batch archive mutations — invalidate-only, matching web. The optimistic
// path isn't worth the complexity: archive-completed depends on the issue
// status of each linked issue (not carried on InboxItem), and predicting
// that on the client risks divergence with the server's SQL filter.
// that on the client risks divergence with the server's SQL filter. The badge
// therefore catches up on settle rather than moving instantly.
export function useArchiveAllInbox() {
const qc = useQueryClient();
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
@@ -129,6 +178,7 @@ export function useArchiveAllInbox() {
mutationFn: () => api.archiveAllInbox(),
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
invalidateUnreadSummary(qc);
},
});
}
@@ -140,6 +190,7 @@ export function useArchiveAllReadInbox() {
mutationFn: () => api.archiveAllReadInbox(),
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
invalidateUnreadSummary(qc);
},
});
}
@@ -151,6 +202,7 @@ export function useArchiveCompletedInbox() {
mutationFn: () => api.archiveCompletedInbox(),
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
invalidateUnreadSummary(qc);
},
});
}
+17
View File
@@ -12,6 +12,10 @@ export const inboxKeys = {
all: (wsId: string | null) => ["inbox", wsId] as const,
list: (wsId: string | null) =>
[...inboxKeys.all(wsId), "list"] as const,
// Account-level, not workspace-scoped: one cache entry holding unread
// counts for every workspace the user belongs to. Same key shape as web
// (packages/core/inbox/queries.ts) so the mental model stays shared.
unreadSummary: () => ["inbox", "unread-summary"] as const,
};
export const inboxListOptions = (wsId: string | null) =>
@@ -20,3 +24,16 @@ export const inboxListOptions = (wsId: string | null) =>
queryFn: ({ signal }) => api.listInbox({ signal }),
enabled: !!wsId,
});
/**
* Cross-workspace unread inbox summary — the source of the tab badge count.
*
* Gated on an active workspace because the endpoint resolves through the
* workspace-member middleware, same as web's sidebar does.
*/
export const inboxUnreadSummaryOptions = (wsId: string | null) =>
queryOptions({
queryKey: inboxKeys.unreadSummary(),
queryFn: ({ signal }) => api.getInboxUnreadSummary({ signal }),
enabled: !!wsId,
});
@@ -3,13 +3,15 @@
*
* Two subscription groups:
*
* 1. `inbox:*` events → invalidate the inbox query. inbox payloads are
* small and (apart from inbox:new) rare, so refetching is cheaper than
* maintaining per-event patchers. Multi-device parity: subscribing to
* inbox:read / inbox:archived means a read/archive on web reaches
* mobile within the next WS frame (web's use-realtime-sync deliberately
* DOESN'T subscribe to those, but mobile's stricter freshness wins for
* multi-device users).
* 1. `inbox:*` events → invalidate the inbox list AND the cross-workspace
* unread summary that backs the tab badge (the summary lives under its
* own account-level key, so the list invalidation does not reach it).
* inbox payloads are small and (apart from inbox:new) rare, so refetching
* is cheaper than maintaining per-event patchers. Multi-device parity:
* subscribing to inbox:read / inbox:archived means a read/archive on web
* reaches mobile within the next WS frame (web's use-realtime-sync
* deliberately DOESN'T subscribe to those, but mobile's stricter freshness
* wins for multi-device users).
*
* 2. `issue:*` events → patch the inbox cache directly via the dedicated
* updaters (inbox-ws-updaters.ts). Required because:
@@ -35,11 +37,13 @@ export function useInboxRealtime() {
useWSSubscriptions(
(ws, wsId) => {
const invalidate = () =>
const invalidate = () => {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
qc.invalidateQueries({ queryKey: inboxKeys.unreadSummary() });
};
return [
// Inbox-domain events: refetch the small inbox list.
// Inbox-domain events: refetch the inbox list and the badge count.
ws.on("inbox:new", invalidate),
ws.on("inbox:read", invalidate),
// Mobile has no mark-unread affordance yet (web/desktop right-click
+19
View File
@@ -20,6 +20,7 @@ import type {
ChatSession,
Comment,
InboxItem,
InboxWorkspaceUnread,
IssueLabelsResponse,
Label,
ListLabelsResponse,
@@ -588,6 +589,24 @@ const InboxItemSchema: z.ZodType<InboxItem> = z.object({
export const InboxListSchema = z.array(InboxItemSchema).default([]);
export const EMPTY_INBOX_LIST: InboxItem[] = [];
// Cross-workspace unread summary (`GET /api/inbox/unread-summary`): one entry
// per workspace the user belongs to that has unread items, already
// deduplicated per issue server-side. Backs the inbox tab badge. Mirrors
// InboxUnreadSummarySchema in packages/core/api/schemas.ts. On malformed JSON
// the fallback is an empty list, which reads as "nothing unread" — the badge
// simply hides rather than showing a wrong number.
const InboxWorkspaceUnreadSchema: z.ZodType<InboxWorkspaceUnread> = z
.object({
workspace_id: z.string(),
count: z.number().catch(0),
})
.loose();
export const InboxUnreadSummarySchema = z
.array(InboxWorkspaceUnreadSchema)
.default([]);
export const EMPTY_INBOX_UNREAD_SUMMARY: InboxWorkspaceUnread[] = [];
export const MemberWithUserSchema: z.ZodType<MemberWithUser> = z.object({
id: z.string(),
workspace_id: z.string().default(""),
+23 -11
View File
@@ -2,13 +2,14 @@
* Unread count hooks for the bottom tab bar badges.
*
* Mirrors the counting logic from:
* - packages/core/inbox/queries.ts::useInboxUnreadCount (inbox)
* - packages/core/inbox/queries.ts::useInboxUnreadCount (inbox — which,
* like this one, reads the server-computed cross-workspace summary)
* - packages/core/chat/unread.ts::countUnreadChatMessages (chat — the
* shared pure function IS the definition; web's sidebar calls the same
* one, so the platforms cannot drift apart)
*
* Both queries (`inboxListOptions`, `chatSessionsOptions`) are already kept
* fresh by listing-level realtime hooks mounted in
* Both queries (`inboxUnreadSummaryOptions`, `chatSessionsOptions`) are
* already kept fresh by listing-level realtime hooks mounted in
* `app/(app)/[workspace]/_layout.tsx`, so these hooks only attach a `select`
* to derive a scalar count — re-rendering the tab layout only when the
* number actually changes (TQ compares select output with Object.is).
@@ -18,20 +19,31 @@
*/
import { useQuery } from "@tanstack/react-query";
import { countUnreadChatMessages } from "@multica/core/chat/unread";
import { inboxListOptions } from "@/data/queries/inbox";
import { inboxUnreadSummaryOptions } from "@/data/queries/inbox";
import { chatSessionsOptions } from "@/data/queries/chat";
import { deduplicateInboxItems } from "@/lib/inbox-display";
/**
* Unread inbox count, aligned with what the inbox list renders: archived
* items dropped, then deduplicated by issue (one entry per issue), then
* filtered to unread. Same definition as web's sidebar badge.
* Unread inbox count for the tab badge.
*
* Read from the cross-workspace unread summary, not from the inbox list.
* The summary is one small server-computed row per workspace, and the server
* applies the same newest-per-issue rule `deduplicateInboxItems`
* (lib/inbox-display.ts) applies before render — so the N here still equals
* the N web shows and the N the inbox tab lists. Counting the list locally
* meant fetching the entire unbounded inbox on app start just to render this
* number (MUL-6967).
*
* The lookup mirrors `unreadCountForWorkspace` in
* packages/core/inbox/queries.ts rather than importing it — that module pulls
* in core's API client, which mobile does not use (same reason
* `deduplicateInboxItems` is mirrored into lib/inbox-display.ts). A workspace
* with nothing unread is absent from the response, so a missing entry is zero.
*/
export function useInboxUnreadCount(wsId: string | null | undefined): number {
const { data } = useQuery({
...inboxListOptions(wsId ?? null),
select: (items) =>
deduplicateInboxItems(items).filter((i) => !i.read).length,
...inboxUnreadSummaryOptions(wsId ?? null),
select: (summary) =>
wsId ? (summary.find((s) => s.workspace_id === wsId)?.count ?? 0) : 0,
});
return data ?? 0;
}
+6
View File
@@ -2458,6 +2458,12 @@ export class ApiClient {
return this.fetch(`/api/inbox/${id}/unarchive`, { method: "POST" });
}
// Raw unread ROW count — not the number any badge shows. The inbox renders
// one row per issue, so a single issue with three unread notifications
// counts once there and three times here. `getInboxUnreadSummary` is the
// deduplicated, per-workspace count the UI is built on (see
// `useInboxUnreadCount`); reach for this one only when raw rows are what
// you actually mean.
async getUnreadInboxCount(): Promise<{ count: number }> {
return this.fetch("/api/inbox/unread-count");
}
+4 -3
View File
@@ -189,9 +189,10 @@ export function inboxFiltersForPrioritySupport(
* touched") than the one the row then displays, so selecting Alice would fill
* the list with Bob. What you filter by is what you see.
*
* `unreadOnly` likewise reads the rendered row's `read`, which is what the
* unread badge counts (`useInboxUnreadCount`) — so "only unread" and the
* number next to Inbox can never disagree.
* `unreadOnly` likewise reads the rendered row's `read`. The unread badge
* (`useInboxUnreadCount`) counts the same thing from the server's summary
* endpoint, which applies that newest-per-issue rule in SQL — so "only unread"
* and the number next to Inbox still cannot disagree.
*/
export function filterInboxItems(
items: InboxItem[],
+130 -2
View File
@@ -8,8 +8,8 @@ import type { ReactNode } from "react";
import { setApiInstance } from "../api";
import type { ApiClient } from "../api/client";
import type { InboxItem } from "../types";
import { useMarkInboxUnread, useUnarchiveInbox } from "./mutations";
import type { InboxItem, InboxWorkspaceUnread } from "../types";
import { useMarkInboxRead, useMarkInboxUnread, useUnarchiveInbox } from "./mutations";
import { inboxKeys } from "./queries";
vi.mock("../hooks", () => ({
@@ -56,6 +56,13 @@ function listCache(qc: QueryClient) {
return qc.getQueryData<InboxItem[]>(inboxKeys.list(WORKSPACE_ID)) ?? [];
}
function summaryCount(qc: QueryClient) {
const summary = qc.getQueryData<InboxWorkspaceUnread[]>(
inboxKeys.unreadSummary(),
);
return summary?.find((e) => e.workspace_id === WORKSPACE_ID)?.count;
}
describe("useMarkInboxUnread", () => {
let queryClient: QueryClient;
let markInboxUnread: ReturnType<typeof vi.fn>;
@@ -210,3 +217,124 @@ describe("useUnarchiveInbox", () => {
expect(archivedCache(queryClient)).toEqual(original);
});
});
/**
* The unread badge reads the server-computed cross-workspace summary rather
* than counting the inbox list (MUL-6967), so a mutation that would have moved
* the badge through its list patch has to move the summary cache too — or the
* number sits still until the round-trip lands.
*/
describe("optimistic unread summary", () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
setApiInstance({
markInboxRead: vi.fn(async (id: string) => item({ id, read: true })),
markInboxUnread: vi.fn(async (id: string) => item({ id, read: false })),
} as unknown as ApiClient);
});
function seed(items: InboxItem[], summary: InboxWorkspaceUnread[]) {
queryClient.setQueryData<InboxItem[]>(inboxKeys.list(WORKSPACE_ID), items);
queryClient.setQueryData<InboxWorkspaceUnread[]>(
inboxKeys.unreadSummary(),
summary,
);
}
it("drops the workspace entry once its last unread group is read", async () => {
seed(
[item({ id: "inbox-1", read: false, archived: false })],
[
{ workspace_id: WORKSPACE_ID, count: 1 },
{ workspace_id: "workspace-2", count: 4 },
],
);
const { result } = renderHook(() => useMarkInboxRead(), {
wrapper: createWrapper(queryClient),
});
result.current.mutate("inbox-1");
// Zero is expressed as an absent entry, mirroring the server response.
await waitFor(() => expect(summaryCount(queryClient)).toBeUndefined());
// Other workspaces are untouched — this patch is scoped to one entry.
expect(
queryClient
.getQueryData<InboxWorkspaceUnread[]>(inboxKeys.unreadSummary())
?.find((e) => e.workspace_id === "workspace-2")?.count,
).toBe(4);
});
it("counts issue groups, not rows, like the list the user sees", async () => {
// Two unread notifications on one issue plus one on another: the inbox
// renders two rows, so the badge must read 2 — not 3.
seed(
[
item({ id: "a1", issue_id: "issue-1", read: false, archived: false }),
item({ id: "a2", issue_id: "issue-1", read: false, archived: false, created_at: "2026-06-15T09:00:00Z" }),
item({ id: "b1", issue_id: "issue-2", read: false, archived: false }),
item({ id: "c1", issue_id: "issue-3", read: false, archived: false }),
],
[{ workspace_id: WORKSPACE_ID, count: 3 }],
);
const { result } = renderHook(() => useMarkInboxRead(), {
wrapper: createWrapper(queryClient),
});
result.current.mutate("c1");
await waitFor(() => expect(summaryCount(queryClient)).toBe(2));
});
it("adds the workspace back when a notification is flipped unread", async () => {
seed([item({ id: "inbox-1", read: true, archived: false })], []);
const { result } = renderHook(() => useMarkInboxUnread(), {
wrapper: createWrapper(queryClient),
});
result.current.mutate("inbox-1");
await waitFor(() => expect(summaryCount(queryClient)).toBe(1));
});
it("leaves the summary alone when the inbox list was never loaded", async () => {
// At app start only the badge is mounted, so there is no list to derive
// from — the server value must stand rather than being reset to zero.
queryClient.setQueryData<InboxWorkspaceUnread[]>(inboxKeys.unreadSummary(), [
{ workspace_id: WORKSPACE_ID, count: 9 },
]);
const { result } = renderHook(() => useMarkInboxRead(), {
wrapper: createWrapper(queryClient),
});
result.current.mutate("inbox-1");
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(summaryCount(queryClient)).toBe(9);
});
it("restores the previous count when the request fails", async () => {
setApiInstance({
markInboxRead: vi.fn().mockRejectedValue(new Error("boom")),
} as unknown as ApiClient);
seed(
[
item({ id: "inbox-1", issue_id: "issue-1", read: false, archived: false }),
item({ id: "inbox-2", issue_id: "issue-2", read: false, archived: false }),
],
[{ workspace_id: WORKSPACE_ID, count: 2 }],
);
const { result } = renderHook(() => useMarkInboxRead(), {
wrapper: createWrapper(queryClient),
});
result.current.mutate("inbox-1");
await waitFor(() => expect(result.current.isError).toBe(true));
expect(summaryCount(queryClient)).toBe(2);
});
});
+68 -9
View File
@@ -1,8 +1,50 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { QueryClient } from "@tanstack/react-query";
import { api } from "../api";
import { inboxKeys } from "./queries";
import { deduplicateInboxItems, inboxKeys } from "./queries";
import { useWorkspaceId } from "../hooks";
import type { InboxItem } from "../types";
import type { InboxItem, InboxWorkspaceUnread } from "../types";
/**
* Refresh the cross-workspace unread summary.
*
* The unread badge reads that summary (`useInboxUnreadCount`), and it lives
* under its own account-level key which `inboxKeys.all(wsId)` does not reach.
* Every mutation here can change the number it holds, so each one refreshes it
* on settle rather than waiting for the WebSocket echo of its own action.
*/
function invalidateUnreadSummary(qc: QueryClient) {
qc.invalidateQueries({ queryKey: inboxKeys.unreadSummary() });
}
/**
* Re-derive this workspace's unread count from the inbox list cache and write
* it into the summary cache, so the badge tracks an optimistic patch instead
* of waiting for a round-trip.
*
* The summary is server-computed and cannot be recalculated from nothing — but
* every caller below has just patched the list cache, and the list holds
* exactly the rows the count is defined over. Re-running the same
* `deduplicateInboxItems` the inbox view uses keeps the badge in lockstep with
* the rows on screen; `onSettled` still re-pulls the authoritative value.
*
* Both caches are read defensively. No list cache means no inbox surface is
* mounted, and no summary cache means the badge has not loaded — in either
* case there is nothing to be optimistic about, and the server value stands.
*/
function syncUnreadSummaryFromList(qc: QueryClient, wsId: string) {
const items = qc.getQueryData<InboxItem[]>(inboxKeys.list(wsId));
if (!items) return;
const count = deduplicateInboxItems(items).filter((i) => !i.read).length;
qc.setQueryData<InboxWorkspaceUnread[]>(inboxKeys.unreadSummary(), (old) => {
if (!old) return old;
// Order carries no meaning here — every consumer scans by workspace id.
// A zero-count workspace is dropped, mirroring the server response, which
// omits workspaces with nothing unread.
const others = old.filter((entry) => entry.workspace_id !== wsId);
return count > 0 ? [...others, { workspace_id: wsId, count }] : others;
});
}
export function useMarkInboxRead() {
const qc = useQueryClient();
@@ -20,14 +62,17 @@ export function useMarkInboxRead() {
// patch that cache as well, or its unread dot would sit there until the
// next refetch.
qc.setQueryData<InboxItem[]>(inboxKeys.archived(wsId), markRead);
syncUnreadSummaryFromList(qc, wsId);
return { prev, prevArchived };
},
onError: (_err, _id, ctx) => {
if (ctx?.prev) qc.setQueryData(inboxKeys.list(wsId), ctx.prev);
if (ctx?.prevArchived) qc.setQueryData(inboxKeys.archived(wsId), ctx.prevArchived);
syncUnreadSummaryFromList(qc, wsId);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.all(wsId) });
invalidateUnreadSummary(qc);
},
});
}
@@ -39,6 +84,7 @@ export function useRetrySourceContextQuickCreate() {
mutationFn: (taskId: string) => api.retrySourceContextQuickCreate(taskId),
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.all(wsId) });
invalidateUnreadSummary(qc);
},
});
}
@@ -51,10 +97,10 @@ export function useRetrySourceContextQuickCreate() {
* can be actioned from either list, and leaving the other one stale would show
* two different read states for one notification after a view switch.
*
* The unread badge is derived from the main list's cache (`useInboxUnreadCount`
* dedupes it client-side), so the optimistic patch raises the badge without
* waiting for the round-trip; `onSettled` re-pulls the cross-workspace summary,
* which is server-computed and cannot be patched here.
* The unread badge reads the server-computed cross-workspace summary, so the
* patch alone would not raise it — `syncUnreadSummaryFromList` re-derives that
* workspace's entry from the freshly patched list so the badge moves without
* waiting for the round-trip. `onSettled` still re-pulls the real value.
*/
export function useMarkInboxUnread() {
const qc = useQueryClient();
@@ -69,17 +115,19 @@ export function useMarkInboxUnread() {
old?.map((item) => (item.id === id ? { ...item, read: false } : item));
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), markUnread);
qc.setQueryData<InboxItem[]>(inboxKeys.archived(wsId), markUnread);
syncUnreadSummaryFromList(qc, wsId);
return { prev, prevArchived };
},
onError: (_err, _id, ctx) => {
if (ctx?.prev) qc.setQueryData(inboxKeys.list(wsId), ctx.prev);
if (ctx?.prevArchived) qc.setQueryData(inboxKeys.archived(wsId), ctx.prevArchived);
syncUnreadSummaryFromList(qc, wsId);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.all(wsId) });
// The switcher dot must light again when the workspace goes back to
// having unread items — that count lives on the server.
qc.invalidateQueries({ queryKey: inboxKeys.unreadSummary() });
invalidateUnreadSummary(qc);
},
});
}
@@ -102,14 +150,18 @@ export function useArchiveInbox() {
: item,
),
);
// Archiving an unread issue group drops it out of the badge immediately.
syncUnreadSummaryFromList(qc, wsId);
return { prev };
},
onError: (_err, _id, ctx) => {
if (ctx?.prev) qc.setQueryData(inboxKeys.list(wsId), ctx.prev);
syncUnreadSummaryFromList(qc, wsId);
},
onSettled: () => {
// Both lists: the item just moved from the main inbox into the archive.
qc.invalidateQueries({ queryKey: inboxKeys.all(wsId) });
invalidateUnreadSummary(qc);
},
});
}
@@ -153,7 +205,7 @@ export function useUnarchiveInbox() {
// Both lists: the item moves from one to the other, and the unread badge
// rises again when it was archived unread.
qc.invalidateQueries({ queryKey: inboxKeys.all(wsId) });
qc.invalidateQueries({ queryKey: inboxKeys.unreadSummary() });
invalidateUnreadSummary(qc);
},
});
}
@@ -171,19 +223,23 @@ export function useMarkAllInboxRead() {
!item.archived ? { ...item, read: true } : item,
),
);
syncUnreadSummaryFromList(qc, wsId);
return { prev };
},
onError: (_err, _vars, ctx) => {
if (ctx?.prev) qc.setQueryData(inboxKeys.list(wsId), ctx.prev);
syncUnreadSummaryFromList(qc, wsId);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
invalidateUnreadSummary(qc);
},
});
}
// The three batch-archive mutations below all move items into the archive, so
// each invalidates BOTH lists on settle.
// each invalidates BOTH lists on settle — plus the unread summary, since an
// archived unread group leaves the badge.
export function useArchiveAllInbox() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
@@ -191,6 +247,7 @@ export function useArchiveAllInbox() {
mutationFn: () => api.archiveAllInbox(),
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.all(wsId) });
invalidateUnreadSummary(qc);
},
});
}
@@ -202,6 +259,7 @@ export function useArchiveAllReadInbox() {
mutationFn: () => api.archiveAllReadInbox(),
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.all(wsId) });
invalidateUnreadSummary(qc);
},
});
}
@@ -213,6 +271,7 @@ export function useArchiveCompletedInbox() {
mutationFn: () => api.archiveCompletedInbox(),
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.all(wsId) });
invalidateUnreadSummary(qc);
},
});
}
+29
View File
@@ -5,6 +5,7 @@ import {
deduplicateInboxItems,
hasOtherWorkspaceUnread,
inboxKeys,
unreadCountForWorkspace,
unreadWorkspaceIds,
} from "./queries";
@@ -215,3 +216,31 @@ describe("inboxKeys.unreadSummary", () => {
expect(inboxKeys.unreadSummary()).toEqual(["inbox", "unread-summary"]);
});
});
// The inbox nav / tab / dock badges all read this instead of counting the
// inbox list, so that the count costs no list fetch (MUL-6967).
describe("unreadCountForWorkspace", () => {
const summary: InboxWorkspaceUnread[] = [
{ workspace_id: "ws-1", count: 3 },
{ workspace_id: "ws-2", count: 7 },
];
it("returns the requested workspace's count", () => {
expect(unreadCountForWorkspace(summary, "ws-2")).toBe(7);
});
it("reads an absent workspace as zero", () => {
// The endpoint omits workspaces with nothing unread rather than sending a
// zero row, so "missing" must mean 0 and not "unknown".
expect(unreadCountForWorkspace(summary, "ws-3")).toBe(0);
});
it("returns zero without a workspace", () => {
expect(unreadCountForWorkspace(summary, null)).toBe(0);
expect(unreadCountForWorkspace(summary, undefined)).toBe(0);
});
it("returns zero for an empty summary", () => {
expect(unreadCountForWorkspace([], "ws-1")).toBe(0);
});
});
+29 -7
View File
@@ -68,17 +68,39 @@ export function unreadWorkspaceIds(summary: InboxWorkspaceUnread[]): Set<string>
}
/**
* Unread inbox count for the given workspace, aligned with what the inbox
* list UI renders: archived items excluded, then deduplicated by issue so a
* single issue with three unread notifications counts once.
* Unread inbox count for one workspace within the cross-workspace summary.
* A workspace with nothing unread is absent from the response entirely, so a
* missing entry means zero rather than "not loaded yet".
*/
export function unreadCountForWorkspace(
summary: InboxWorkspaceUnread[],
wsId: string | null | undefined,
): number {
if (!wsId) return 0;
return summary.find((s) => s.workspace_id === wsId)?.count ?? 0;
}
/**
* Unread inbox count for the given workspace — the number the sidebar nav
* badge and the desktop dock badge render.
*
* Read from the cross-workspace summary, NOT from the inbox list. The summary
* is one small server-computed row per workspace and the sidebar already
* fetches it for the workspace-switcher dot, so the badge costs no request of
* its own; deriving it from `listInbox()` instead downloaded the entire
* unbounded inbox on every app start just to render a number (MUL-6967).
*
* `GET /api/inbox/unread-count` is deliberately not the source: it counts raw
* notification rows, while the inbox renders one row per issue. The summary
* endpoint applies the same newest-per-issue rule `deduplicateInboxItems`
* applies client-side, so this number matches the list the user sees.
*/
export function useInboxUnreadCount(wsId: string | null | undefined): number {
const { data } = useQuery({
queryKey: inboxKeys.list(wsId ?? ""),
queryFn: () => api.listInbox(),
...inboxUnreadSummaryOptions(),
enabled: !!wsId,
select: (items: InboxItem[]) =>
deduplicateInboxItems(items).filter((i) => !i.read).length,
select: (summary: InboxWorkspaceUnread[]) =>
unreadCountForWorkspace(summary, wsId),
});
return data ?? 0;
}
+7 -7
View File
@@ -3,14 +3,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { ApiError } from "@multica/core/api";
import { AppSidebar } from "./app-sidebar";
const { appForeground, chatSessions, chatStore, detail, deletePin, inboxItems, navigation, pins, sidebarState, summary, workspaces } = vi.hoisted(() => ({
const { appForeground, chatSessions, chatStore, detail, deletePin, navigation, pins, sidebarState, summary, workspaces } = vi.hoisted(() => ({
appForeground: { current: true },
sidebarState: { setOpenMobile: vi.fn() },
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: {
@@ -149,9 +148,11 @@ vi.mock("@multica/core/api", async (importOriginal) => {
};
});
vi.mock("@multica/core/inbox/queries", () => ({
deduplicateInboxItems: (items: unknown[]) => items,
inboxKeys: { list: () => ["inbox"], unreadSummary: () => ["inbox", "unread-summary"] },
inboxUnreadSummaryOptions: () => ({ queryKey: ["inbox", "unread-summary"] }),
// The nav badge and the switcher dot read the SAME cross-workspace summary,
// so the fixture that drives one drives the other.
useInboxUnreadCount: (currentWsId: string | null) =>
summary.current.find((s) => s.workspace_id === currentWsId)?.count ?? 0,
hasOtherWorkspaceUnread: (
entries: { workspace_id: string; count: number }[],
currentWsId: string | null,
@@ -181,7 +182,6 @@ 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: [] };
@@ -336,7 +336,7 @@ describe("workspace-switcher dropdown per-workspace dot", () => {
describe("personal nav — Chat", () => {
beforeEach(() => {
chatSessions.current = [];
inboxItems.current = [];
summary.current = [];
navigation.current = { pathname: "/acme/issues" };
chatStore.current = { activeSessionId: null, isOpen: false };
appForeground.current = true;
@@ -350,7 +350,7 @@ describe("personal nav — Chat", () => {
chatNav(container)?.querySelector("number-flow-react") ?? null;
it("keeps persistent Inbox and Chat counters static", () => {
inboxItems.current = [{ id: "inbox-1", read: false }];
summary.current = [{ workspace_id: "ws-1", count: 1 }];
chatSessions.current = [{ id: "chat-1", unread_count: 2 }];
const { container } = render(<AppSidebar />);
const inboxBadge = container
+5 -11
View File
@@ -67,7 +67,7 @@ import { useCurrentWorkspace, useWorkspacePaths, paths } from "@multica/core/pat
import { workspaceListOptions, myInvitationListOptions, workspaceKeys } from "@multica/core/workspace/queries";
import { resolvePublicFileUrl } from "@multica/core/workspace/avatar-url";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { inboxKeys, deduplicateInboxItems, inboxUnreadSummaryOptions, hasOtherWorkspaceUnread, unreadWorkspaceIds } from "@multica/core/inbox/queries";
import { inboxUnreadSummaryOptions, useInboxUnreadCount, hasOtherWorkspaceUnread, unreadWorkspaceIds } from "@multica/core/inbox/queries";
import { chatSessionsOptions } from "@multica/core/chat/queries";
import { countUnreadChatMessages } from "@multica/core/chat/unread";
import { useChatStore } from "@multica/core/chat";
@@ -104,7 +104,6 @@ function isNavActive(pathname: string, href: string): boolean {
const EMPTY_PINS: PinnedItem[] = [];
const EMPTY_WORKSPACES: Awaited<ReturnType<typeof api.listWorkspaces>> = [];
const EMPTY_INVITATIONS: Awaited<ReturnType<typeof api.listMyInvitations>> = [];
const EMPTY_INBOX: Awaited<ReturnType<typeof api.listInbox>> = [];
const EMPTY_INBOX_SUMMARY: Awaited<ReturnType<typeof api.getInboxUnreadSummary>> = [];
// Nav items reference WorkspacePaths method names so they can be resolved
@@ -447,15 +446,10 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
}, [pathname, setOpenMobile]);
const wsId = workspace?.id;
const { data: inboxItems = EMPTY_INBOX } = useQuery({
queryKey: wsId ? inboxKeys.list(wsId) : ["inbox", "disabled"],
queryFn: () => api.listInbox(),
enabled: !!wsId,
});
const unreadCount = React.useMemo(
() => deduplicateInboxItems(inboxItems).filter((i) => !i.read).length,
[inboxItems],
);
// Nav badge. Reads the cross-workspace unread summary fetched just below
// for the switcher dot, so the count costs no request of its own — it used
// to download the whole inbox list here just to count it (MUL-6967).
const unreadCount = useInboxUnreadCount(wsId);
// Chat tab unread badge: IM-style total of unread *messages* across chat
// threads (countUnreadChatMessages is the shared definition — mobile's tab
// badge derives from the same function, keeping the platforms in agreement).