Files
multica/packages/core/inbox/queries.ts
Bohan Jiang 9e807efc62 feat(sidebar): per-workspace switcher dot + count unread per issue (MUL-3695) (#4591)
* feat(sidebar): mark which workspace has unread in the switcher dropdown (MUL-3695)

The aggregate avatar dot only says "some other workspace has unread". When
the user opens the workspace switcher they couldn't tell which one. Add a
per-row brand dot next to each OTHER workspace that has unread inbox items,
in the same right-edge slot as the active-workspace check (the active
workspace is excluded — its unread is the Inbox nav count — so dot and
check never collide on one row).

Reuses the existing cross-workspace summary data; no backend change. New
pure helper unreadWorkspaceIds() + unit tests, and AppSidebar dropdown
tests covering: dot only on the other unread workspace, no dot at count 0,
and never on the active workspace.

Co-authored-by: multica-agent <github@multica.ai>

* fix(inbox): count switcher unread per issue, matching the inbox dedup (MUL-3695)

The unread-summary that drives the workspace-switcher dot counted raw
unread inbox_item rows, but the inbox UI deduplicates notifications per
issue and treats an issue as read when its NEWEST non-archived item is
read. Opening an issue marks only that newest item read (markInboxRead is
per-item; only archive cascades to siblings), so older siblings stay
unread in the DB. Result: a workspace whose inbox the user sees as empty
still lit the dot (reported on bohan-personal showing a dot for Multica AI
with no unread).

Rewrite CountUnreadInboxByWorkspace to pick the newest non-archived item
per (workspace, issue-or-id group) via DISTINCT ON and count only groups
whose newest item is unread — the exact semantics of
deduplicateInboxItems(...).filter(!read) on the client. No schema/handler
change; query-only. Adds TestInboxUnreadSummaryDedupesByIssue covering the
read-newest / unread-older case and its inverse.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-26 13:28:45 +08:00

113 lines
3.7 KiB
TypeScript

import { queryOptions, useQuery } from "@tanstack/react-query";
import { api } from "../api";
import type { InboxItem, InboxWorkspaceUnread } from "../types";
export const inboxKeys = {
all: (wsId: string) => ["inbox", wsId] as const,
list: (wsId: string) => [...inboxKeys.all(wsId), "list"] as const,
// Account-level (not workspace-scoped): a single shared cache entry that
// holds unread counts for every workspace the user belongs to.
unreadSummary: () => ["inbox", "unread-summary"] as const,
};
export function inboxListOptions(wsId: string) {
return queryOptions({
queryKey: inboxKeys.list(wsId),
queryFn: () => api.listInbox(),
});
}
/**
* Cross-workspace unread inbox summary. One cache entry shared across all
* workspaces — the data is account-level, so switching workspaces does not
* refetch it; only the derived "is this for another workspace" view changes.
*/
export function inboxUnreadSummaryOptions() {
return queryOptions({
queryKey: inboxKeys.unreadSummary(),
queryFn: () => api.getInboxUnreadSummary(),
});
}
/**
* Whether any workspace OTHER than `currentWsId` has unread inbox items.
* Drives the workspace-switcher dot: the active workspace's own unread is
* already surfaced by the Inbox nav count, so it is excluded here to avoid a
* duplicate signal.
*/
export function hasOtherWorkspaceUnread(
summary: InboxWorkspaceUnread[],
currentWsId: string | null | undefined,
): boolean {
return summary.some((s) => s.workspace_id !== currentWsId && s.count > 0);
}
/**
* Set of workspace ids that have unread inbox items. Lets the workspace
* switcher dropdown mark WHICH workspace a pending message lives in (the
* aggregate switcher dot only says "somewhere else"). Workspaces with a zero
* count are excluded.
*/
export function unreadWorkspaceIds(summary: InboxWorkspaceUnread[]): Set<string> {
return new Set(summary.filter((s) => s.count > 0).map((s) => s.workspace_id));
}
/**
* 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.
*/
export function useInboxUnreadCount(wsId: string | null | undefined): number {
const { data } = useQuery({
queryKey: inboxKeys.list(wsId ?? ""),
queryFn: () => api.listInbox(),
enabled: !!wsId,
select: (items: InboxItem[]) =>
deduplicateInboxItems(items).filter((i) => !i.read).length,
});
return data ?? 0;
}
/**
* Deduplicate inbox items by issue_id (one entry per issue, Linear-style).
* Exported for consumers to use in useMemo — not in queryOptions select
* (to avoid new array references on every cache update).
*/
export function deduplicateInboxItems(items: InboxItem[]): InboxItem[] {
const active = items.filter((i) => !i.archived);
const groups = new Map<string, InboxItem[]>();
for (const item of active) {
const key = item.issue_id ?? item.id;
const group = groups.get(key) ?? [];
group.push(item);
groups.set(key, group);
}
const merged: InboxItem[] = [];
for (const group of groups.values()) {
group.sort(
(a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
);
const newest = group[0];
if (!newest) continue;
const commentId =
newest.details?.comment_id ??
group.find((item) => item.details?.comment_id)?.details?.comment_id;
if (commentId && newest.details?.comment_id !== commentId) {
merged.push({
...newest,
details: { ...(newest.details ?? {}), comment_id: commentId },
});
continue;
}
merged.push(newest);
}
return merged.sort(
(a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
);
}