Files
multica/packages/core/inbox/queries.test.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

155 lines
4.1 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { InboxItem, InboxWorkspaceUnread } from "../types";
import { deduplicateInboxItems, hasOtherWorkspaceUnread, inboxKeys, unreadWorkspaceIds } from "./queries";
function item(overrides: Partial<InboxItem>): InboxItem {
return {
id: "inbox-1",
workspace_id: "workspace-1",
recipient_type: "member",
recipient_id: "member-1",
actor_type: "agent",
actor_id: "agent-1",
type: "new_comment",
severity: "info",
issue_id: "issue-1",
title: "Issue title",
body: null,
issue_status: null,
read: false,
archived: false,
created_at: "2026-06-15T08:00:00Z",
details: null,
...overrides,
};
}
describe("deduplicateInboxItems", () => {
it("keeps the newest issue row while preserving an older comment anchor", () => {
const merged = deduplicateInboxItems([
item({
id: "comment-notification",
type: "new_comment",
created_at: "2026-06-15T08:00:00Z",
details: { comment_id: "comment-1" },
}),
item({
id: "status-notification",
type: "status_changed",
created_at: "2026-06-15T08:01:00Z",
details: { from: "in_progress", to: "in_review" },
}),
]);
expect(merged).toHaveLength(1);
expect(merged[0]).toMatchObject({
id: "status-notification",
type: "status_changed",
details: {
from: "in_progress",
to: "in_review",
comment_id: "comment-1",
},
});
});
it("preserves the newest row's own comment anchor", () => {
const merged = deduplicateInboxItems([
item({
id: "older-comment",
created_at: "2026-06-15T08:00:00Z",
details: { comment_id: "comment-1" },
}),
item({
id: "newer-comment",
created_at: "2026-06-15T08:02:00Z",
details: { comment_id: "comment-2" },
}),
]);
expect(merged).toHaveLength(1);
expect(merged[0]?.id).toBe("newer-comment");
expect(merged[0]?.details?.comment_id).toBe("comment-2");
});
});
describe("hasOtherWorkspaceUnread", () => {
const summary = (entries: InboxWorkspaceUnread[]) => entries;
it("is true when a workspace other than the active one has unread", () => {
expect(
hasOtherWorkspaceUnread(
summary([{ workspace_id: "ws-2", count: 3 }]),
"ws-1",
),
).toBe(true);
});
it("excludes the active workspace's own unread", () => {
expect(
hasOtherWorkspaceUnread(
summary([{ workspace_id: "ws-1", count: 5 }]),
"ws-1",
),
).toBe(false);
});
it("ignores other workspaces whose count is zero", () => {
expect(
hasOtherWorkspaceUnread(
summary([{ workspace_id: "ws-2", count: 0 }]),
"ws-1",
),
).toBe(false);
});
it("is true when at least one non-active workspace has unread", () => {
expect(
hasOtherWorkspaceUnread(
summary([
{ workspace_id: "ws-1", count: 4 },
{ workspace_id: "ws-2", count: 1 },
]),
"ws-1",
),
).toBe(true);
});
it("is false for an empty summary", () => {
expect(hasOtherWorkspaceUnread([], "ws-1")).toBe(false);
});
it("counts every workspace as 'other' when there is no active workspace", () => {
expect(
hasOtherWorkspaceUnread(
summary([{ workspace_id: "ws-1", count: 2 }]),
null,
),
).toBe(true);
});
});
describe("unreadWorkspaceIds", () => {
it("collects only workspaces with a non-zero count", () => {
const ids = unreadWorkspaceIds([
{ workspace_id: "ws-1", count: 0 },
{ workspace_id: "ws-2", count: 3 },
{ workspace_id: "ws-3", count: 1 },
]);
expect(ids.has("ws-1")).toBe(false);
expect(ids.has("ws-2")).toBe(true);
expect(ids.has("ws-3")).toBe(true);
expect(ids.size).toBe(2);
});
it("returns an empty set for an empty summary", () => {
expect(unreadWorkspaceIds([]).size).toBe(0);
});
});
describe("inboxKeys.unreadSummary", () => {
it("is a stable account-level key independent of any workspace", () => {
expect(inboxKeys.unreadSummary()).toEqual(["inbox", "unread-summary"]);
});
});