mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 12:35:35 +02:00
* 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>
155 lines
4.1 KiB
TypeScript
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"]);
|
|
});
|
|
});
|