Compare commits

...

2 Commits

Author SHA1 Message Date
J
d47c1f6246 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>
2026-06-26 12:23:45 +08:00
J
b56efd8f41 feat(sidebar): dot the workspace switcher when other workspaces have unread inbox (MUL-3695)
Adds a cross-workspace unread summary so the workspace switcher shows the
existing brand dot when a workspace OTHER than the active one has unread
inbox items. The active workspace's own unread stays on the Inbox nav
count to avoid a duplicate signal, and the dot is shared with the pending-
invitation indicator.

Backend: new GET /api/inbox/unread-summary returns per-workspace unread
counts for the user, scoped via a member join so a left workspace can't
light the dot. One account-level query instead of N per-workspace inbox
fetches.

Frontend: schema-guarded api.getInboxUnreadSummary, a single account-level
TanStack Query, and a derived "other workspace has unread" boolean in
AppSidebar (shared by web + desktop). Inbox WS events (new/read/archived/
batch) and reconnect invalidate the summary, so the dot appears and clears
in realtime even for events from a non-active workspace.

Closes multica-ai/multica#3773

Co-authored-by: multica-agent <github@multica.ai>
2026-06-25 19:06:38 +08:00
18 changed files with 526 additions and 14 deletions

View File

@@ -28,6 +28,7 @@ import type {
CreateRuntimeProfileRequest,
UpdateRuntimeProfileRequest,
InboxItem,
InboxWorkspaceUnread,
IssueSubscriber,
Comment,
CommentTriggerPreview,
@@ -201,6 +202,8 @@ import {
EMPTY_BILLING_CHECKOUT_SESSION_STATUS,
EMPTY_CREATE_BILLING_PORTAL_SESSION_RESPONSE,
EMPTY_CANCEL_TASK_RESPONSE,
InboxUnreadSummarySchema,
EMPTY_INBOX_UNREAD_SUMMARY,
} from "./schemas";
/** Identifies the calling client to the server.
@@ -1459,6 +1462,17 @@ export class ApiClient {
return this.fetch("/api/inbox/unread-count");
}
// Cross-workspace unread summary: one entry per workspace the user belongs
// to that has unread inbox items. Backs the workspace-switcher dot for
// OTHER workspaces. Schema-guarded so a contract drift hides the dot rather
// than crashing the sidebar.
async getInboxUnreadSummary(): Promise<InboxWorkspaceUnread[]> {
const raw = await this.fetch<unknown>("/api/inbox/unread-summary");
return parseWithFallback(raw, InboxUnreadSummarySchema, EMPTY_INBOX_UNREAD_SUMMARY, {
endpoint: "GET /api/inbox/unread-summary",
});
}
async markAllInboxRead(): Promise<{ count: number }> {
return this.fetch("/api/inbox/mark-all-read", { method: "POST" });
}

View File

@@ -5,7 +5,9 @@ import {
DashboardUsageByAgentListSchema,
DashboardUsageDailyListSchema,
DuplicateIssueErrorBodySchema,
EMPTY_INBOX_UNREAD_SUMMARY,
EMPTY_USER,
InboxUnreadSummarySchema,
IssueTriggerPreviewSchema,
ListIssuesResponseSchema,
RuntimeHourlyActivityListSchema,
@@ -415,3 +417,43 @@ describe("AppConfigSchema cdn_signed drift", () => {
expect(parsed.cdn_signed).toBe(true);
});
});
describe("InboxUnreadSummarySchema", () => {
const ENDPOINT = { endpoint: "GET /api/inbox/unread-summary" };
it("parses a well-formed summary and tolerates extra fields", () => {
const parsed = parseWithFallback(
[
{ workspace_id: "ws-1", count: 2 },
{ workspace_id: "ws-2", count: 0, future_field: "ignored" },
],
InboxUnreadSummarySchema,
EMPTY_INBOX_UNREAD_SUMMARY,
ENDPOINT,
);
expect(parsed).toEqual([
{ workspace_id: "ws-1", count: 2 },
{ workspace_id: "ws-2", count: 0, future_field: "ignored" },
]);
});
it("returns the empty fallback (dot hidden) for a non-array body", () => {
expect(
parseWithFallback({ rows: [] }, InboxUnreadSummarySchema, EMPTY_INBOX_UNREAD_SUMMARY, ENDPOINT),
).toBe(EMPTY_INBOX_UNREAD_SUMMARY);
expect(
parseWithFallback(null, InboxUnreadSummarySchema, EMPTY_INBOX_UNREAD_SUMMARY, ENDPOINT),
).toBe(EMPTY_INBOX_UNREAD_SUMMARY);
});
it("returns the empty fallback when an entry has a wrong-typed count", () => {
expect(
parseWithFallback(
[{ workspace_id: "ws-1", count: "lots" }],
InboxUnreadSummarySchema,
EMPTY_INBOX_UNREAD_SUMMARY,
ENDPOINT,
),
).toBe(EMPTY_INBOX_UNREAD_SUMMARY);
});
});

View File

@@ -15,6 +15,7 @@ import type {
CreateBillingCheckoutSessionResponse,
CreateBillingPortalSessionResponse,
GroupedIssuesResponse,
InboxWorkspaceUnread,
ListIssuesResponse,
ListWebhookDeliveriesResponse,
Squad,
@@ -863,6 +864,25 @@ export const EMPTY_USER: User = {
updated_at: "",
};
// ---------------------------------------------------------------------------
// Cross-workspace unread inbox summary (`/api/inbox/unread-summary` GET).
// One entry per workspace the user belongs to that has unread items; the
// sidebar derives the workspace-switcher dot from it. Lenient per the usual
// rules so a future field addition can't blank the dot — on malformed JSON
// parseWithFallback returns the empty list, which simply hides the dot.
// ---------------------------------------------------------------------------
export const InboxUnreadSummarySchema = z.array(
z
.object({
workspace_id: z.string(),
count: z.number(),
})
.loose(),
);
export const EMPTY_INBOX_UNREAD_SUMMARY: InboxWorkspaceUnread[] = [];
// ---------------------------------------------------------------------------
// Billing schemas (cloud-billing proxy surface)
//

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { InboxItem } from "../types";
import { deduplicateInboxItems } from "./queries";
import type { InboxItem, InboxWorkspaceUnread } from "../types";
import { deduplicateInboxItems, hasOtherWorkspaceUnread, inboxKeys, unreadWorkspaceIds } from "./queries";
function item(overrides: Partial<InboxItem>): InboxItem {
return {
@@ -72,3 +72,83 @@ describe("deduplicateInboxItems", () => {
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"]);
});
});

View File

@@ -1,10 +1,13 @@
import { queryOptions, useQuery } from "@tanstack/react-query";
import { api } from "../api";
import type { InboxItem } from "../types";
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) {
@@ -14,6 +17,41 @@ export function inboxListOptions(wsId: string) {
});
}
/**
* 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

View File

@@ -1,6 +1,6 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import { QueryClient } from "@tanstack/react-query";
import { onInboxIssueDeleted, onInboxIssueStatusChanged } from "./ws-updaters";
import { onInboxIssueDeleted, onInboxIssueStatusChanged, onInboxSummaryInvalidate } from "./ws-updaters";
import { inboxKeys } from "./queries";
import type { InboxItem } from "../types";
@@ -56,6 +56,28 @@ describe("onInboxIssueDeleted", () => {
});
});
describe("onInboxSummaryInvalidate", () => {
it("invalidates the account-level summary key regardless of active workspace", () => {
const qc = new QueryClient();
const spy = vi.spyOn(qc, "invalidateQueries");
onInboxSummaryInvalidate(qc);
expect(spy).toHaveBeenCalledWith({ queryKey: inboxKeys.unreadSummary() });
});
it("does not disturb a workspace-scoped inbox list cache", () => {
const qc = new QueryClient();
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), [makeItem("i1", "issue-a")]);
onInboxSummaryInvalidate(qc);
// The list cache entry is untouched (different key); only the summary
// query is marked stale.
expect(qc.getQueryData<InboxItem[]>(inboxKeys.list(wsId))?.[0]?.id).toBe("i1");
});
});
describe("onInboxIssueStatusChanged", () => {
it("updates issue_status only for items referencing the issue", () => {
const qc = new QueryClient();

View File

@@ -41,3 +41,12 @@ export function onInboxIssueDeleted(
export function onInboxInvalidate(qc: QueryClient, wsId: string) {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
}
// Refresh the cross-workspace unread summary (workspace-switcher dot). The
// summary spans every workspace, so it is invalidated on ANY inbox event
// regardless of which workspace the event came from — including read/archive
// events from a workspace other than the active one, which the workspace-
// scoped list invalidation cannot reach.
export function onInboxSummaryInvalidate(qc: QueryClient) {
qc.invalidateQueries({ queryKey: inboxKeys.unreadSummary() });
}

View File

@@ -103,8 +103,8 @@ describe("useRealtimeSync — ws instance change", () => {
// Should have called invalidateQueries for all workspace-scoped keys
// (15 workspace-scoped + 6 per-issue prefixes + 1 workspaceKeys.list()
// = 22 calls)
expect(invalidateSpy).toHaveBeenCalledTimes(22);
// + 1 cross-workspace inbox unread summary = 23 calls)
expect(invalidateSpy).toHaveBeenCalledTimes(23);
});
it("does not re-invalidate when rerendered with the same ws instance", () => {

View File

@@ -30,7 +30,7 @@ import {
onIssueLabelsChanged,
onIssueMetadataChanged,
} from "../issues/ws-updaters";
import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged, onInboxIssueDeleted } from "../inbox/ws-updaters";
import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged, onInboxIssueDeleted, onInboxSummaryInvalidate } from "../inbox/ws-updaters";
import { inboxKeys } from "../inbox/queries";
import {
notificationPreferenceOptions,
@@ -230,6 +230,9 @@ export async function handleInboxNew(
): Promise<void> {
const sourceWsId = item.workspace_id;
if (sourceWsId) onInboxNew(qc, sourceWsId, item);
// A new item in ANY workspace can light the workspace-switcher dot, so
// refresh the cross-workspace summary regardless of the active workspace.
onInboxSummaryInvalidate(qc);
// Fire a native OS notification only when the app isn't focused. When
// the user is already looking at Multica, the inbox sidebar's unread
// styling is enough — no need to interrupt with a banner. `desktopAPI`
@@ -320,6 +323,9 @@ function invalidateWorkspaceScopedQueries(qc: QueryClient): void {
qc.invalidateQueries({ queryKey: chatKeys.all(wsId) });
qc.invalidateQueries({ queryKey: labelKeys.all(wsId) });
}
// Cross-workspace, so outside the wsId guard: a reconnect may have missed
// inbox events from any workspace, so re-pull the switcher-dot summary.
onInboxSummaryInvalidate(qc);
// Per-issue caches are keyed without wsId, so the issueKeys.all(wsId)
// prefix above does not reach them. They rely entirely on WS events for
// freshness (staleTime: Infinity), so events missed while disconnected
@@ -394,6 +400,12 @@ export function useRealtimeSync(
inbox: () => {
const wsId = getCurrentWsId();
if (wsId) onInboxInvalidate(qc, wsId);
// inbox:read / inbox:archived / batch events arrive here. They can
// originate from a workspace other than the active one (personal
// events fan out to all the user's connections), so always refresh
// the cross-workspace summary — its dot must clear when another
// workspace's items are read/archived.
onInboxSummaryInvalidate(qc);
},
agent: () => {
const wsId = getCurrentWsId();

View File

@@ -22,6 +22,17 @@ export type InboxItemType =
| "quick_create_done"
| "quick_create_failed";
/**
* One workspace's unread inbox count in the cross-workspace summary
* (`GET /api/inbox/unread-summary`). The sidebar uses this to light a dot on
* the workspace switcher when a workspace OTHER than the active one has
* unread items.
*/
export interface InboxWorkspaceUnread {
workspace_id: string;
count: number;
}
export interface InboxItem {
id: string;
workspace_id: string;

View File

@@ -61,7 +61,7 @@ export type {
} from "./agent";
export { RUNTIME_PROFILE_PROTOCOL_FAMILIES } from "./agent";
export type { Workspace, WorkspaceRepo, Member, MemberRole, User, MemberWithUser, Invitation } from "./workspace";
export type { InboxItem, InboxSeverity, InboxItemType } from "./inbox";
export type { InboxItem, InboxSeverity, InboxItemType, InboxWorkspaceUnread } from "./inbox";
export type { NotificationGroupKey, NotificationGroupValue, NotificationPreferences, NotificationPreferenceResponse } from "./notification-preference";
export type { Comment, CommentType, CommentAuthorType, CommentTriggerPreview, CommentTriggerPreviewAgent, CommentTriggerSource, Reaction } from "./comment";
export type { Label, CreateLabelRequest, UpdateLabelRequest, ListLabelsResponse, IssueLabelsResponse } from "./label";

View File

@@ -3,10 +3,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { ApiError } from "@multica/core/api";
import { AppSidebar } from "./app-sidebar";
const { detail, deletePin, navigation, pins } = vi.hoisted(() => ({
const { detail, deletePin, navigation, pins, summary, workspaces } = vi.hoisted(() => ({
detail: { current: { isPending: false, isError: false, data: null as unknown, error: null as unknown } },
deletePin: vi.fn(),
navigation: { current: { pathname: "/acme/issues" } },
summary: { current: [] as { workspace_id: string; count: number }[] },
workspaces: {
current: [] as { id: string; name: string; slug: string; avatar_url: string | null }[],
},
pins: {
current: [
{
@@ -62,7 +66,7 @@ vi.mock("@multica/ui/components/ui/sidebar", () => ({
}));
vi.mock("@multica/ui/components/ui/dropdown-menu", () => ({
DropdownMenu: ({ children }: { children: React.ReactNode }) => <>{children}</>,
DropdownMenuContent: () => null,
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
DropdownMenuGroup: ({ children }: { children: React.ReactNode }) => <>{children}</>,
DropdownMenuItem: ({ children }: { children: React.ReactNode }) => <>{children}</>,
DropdownMenuLabel: ({ children }: { children: React.ReactNode }) => <>{children}</>,
@@ -122,7 +126,17 @@ vi.mock("@multica/core/api", async (importOriginal) => {
},
};
});
vi.mock("@multica/core/inbox/queries", () => ({ deduplicateInboxItems: (items: unknown[]) => items, inboxKeys: { list: () => ["inbox"] } }));
vi.mock("@multica/core/inbox/queries", () => ({
deduplicateInboxItems: (items: unknown[]) => items,
inboxKeys: { list: () => ["inbox"], unreadSummary: () => ["inbox", "unread-summary"] },
inboxUnreadSummaryOptions: () => ({ queryKey: ["inbox", "unread-summary"] }),
hasOtherWorkspaceUnread: (
entries: { workspace_id: string; count: number }[],
currentWsId: string | null,
) => entries.some((s) => s.workspace_id !== currentWsId && s.count > 0),
unreadWorkspaceIds: (entries: { workspace_id: string; count: number }[]) =>
new Set(entries.filter((s) => s.count > 0).map((s) => s.workspace_id)),
}));
vi.mock("@multica/core/issues/queries", () => ({ issueDetailOptions: () => ({ queryKey: ["issue"] }) }));
vi.mock("@multica/core/issues/stores/create-mode-store", () => ({
useCreateModeStore: { getState: () => ({ lastMode: "agent" }) },
@@ -145,6 +159,8 @@ vi.mock("@tanstack/react-query", async (importOriginal) => ({
useQuery: ({ queryKey }: { queryKey: readonly unknown[] }) => {
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] === "workspaces") return { data: workspaces.current };
return { data: [] };
},
useQueryClient: () => ({ fetchQuery: vi.fn(), invalidateQueries: vi.fn() }),
@@ -155,6 +171,8 @@ describe("PinRow", () => {
deletePin.mockReset();
navigation.current.pathname = "/acme/issues";
detail.current = { isPending: false, isError: false, data: null, error: null };
summary.current = [];
workspaces.current = [];
});
it("unpins missing details", async () => {
@@ -194,3 +212,70 @@ describe("PinRow", () => {
expect(container.querySelector('button[data-href="/acme/issues"]')).not.toHaveAttribute("data-active");
});
});
describe("workspace-switcher unread dot", () => {
beforeEach(() => {
summary.current = [];
workspaces.current = [];
});
// The aggregate switcher dot is the only `.ring-sidebar` span in the tree
// (DraftDot is null when there's no draft, and there are no invitations).
const dot = (container: HTMLElement) => container.querySelector("span.bg-brand.ring-sidebar");
it("shows a dot when another workspace has unread inbox items", () => {
summary.current = [{ workspace_id: "ws-2", count: 3 }];
const { container } = render(<AppSidebar />);
expect(dot(container)).not.toBeNull();
});
it("does not show a dot when only the active workspace has unread", () => {
// Active workspace is ws-1 (see useCurrentWorkspace mock).
summary.current = [{ workspace_id: "ws-1", count: 3 }];
const { container } = render(<AppSidebar />);
expect(dot(container)).toBeNull();
});
it("does not show a dot when no workspace has unread", () => {
summary.current = [];
const { container } = render(<AppSidebar />);
expect(dot(container)).toBeNull();
});
});
describe("workspace-switcher dropdown per-workspace dot", () => {
beforeEach(() => {
summary.current = [];
// Active workspace is ws-1 (see useCurrentWorkspace mock); "Other" is ws-2.
workspaces.current = [
{ id: "ws-1", name: "Active WS", slug: "active", avatar_url: null },
{ id: "ws-2", name: "Other WS", slug: "other", avatar_url: null },
];
});
// Row dots are brand dots WITHOUT the aggregate avatar dot's `ring-sidebar`.
const rowDots = (container: HTMLElement) =>
container.querySelectorAll("span.bg-brand:not(.ring-sidebar)");
it("dots the specific other workspace that has unread", () => {
summary.current = [{ workspace_id: "ws-2", count: 3 }];
const { container } = render(<AppSidebar />);
// Exactly one row dot, sitting right after the "Other WS" name; the active
// row shows the check, not a dot.
expect(rowDots(container)).toHaveLength(1);
expect(screen.getByText("Other WS").nextElementSibling?.className).toContain("bg-brand");
expect(screen.getByText("Active WS").nextElementSibling?.className ?? "").not.toContain("bg-brand");
});
it("does not dot a workspace whose unread count is zero", () => {
summary.current = [{ workspace_id: "ws-2", count: 0 }];
const { container } = render(<AppSidebar />);
expect(rowDots(container)).toHaveLength(0);
});
it("never dots the active workspace even when it has unread", () => {
summary.current = [{ workspace_id: "ws-1", count: 5 }];
const { container } = render(<AppSidebar />);
expect(rowDots(container)).toHaveLength(0);
});
});

View File

@@ -70,7 +70,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 } from "@multica/core/inbox/queries";
import { inboxKeys, deduplicateInboxItems, inboxUnreadSummaryOptions, hasOtherWorkspaceUnread, unreadWorkspaceIds } from "@multica/core/inbox/queries";
import { api, ApiError } from "@multica/core/api";
import { useModalStore } from "@multica/core/modals";
import { useConfigStore } from "@multica/core/config";
@@ -101,6 +101,7 @@ 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
// against the current workspace slug at render time (see AppSidebar body).
@@ -364,6 +365,20 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
() => deduplicateInboxItems(inboxItems).filter((i) => !i.read).length,
[inboxItems],
);
// Cross-workspace unread summary backs the workspace-switcher dot. One
// shared cache entry across workspaces; gated on an active workspace since
// the endpoint resolves through the workspace-member middleware.
const { data: unreadSummary = EMPTY_INBOX_SUMMARY } = useQuery({
...inboxUnreadSummaryOptions(),
enabled: !!wsId,
});
const otherWorkspaceUnread = React.useMemo(
() => hasOtherWorkspaceUnread(unreadSummary, wsId),
[unreadSummary, wsId],
);
// Which workspaces have unread, so the switcher dropdown can point at the
// specific one(s) rather than just the aggregate avatar dot.
const unreadWsIds = React.useMemo(() => unreadWorkspaceIds(unreadSummary), [unreadSummary]);
const hasRuntimeUpdates = useMyRuntimesNeedUpdate(wsId);
const { data: pinnedItems = EMPTY_PINS } = useQuery({
...pinListOptions(wsId ?? "", userId ?? ""),
@@ -486,7 +501,11 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
<SidebarMenuButton>
<span className="relative">
<WorkspaceAvatar name={workspace?.name ?? "M"} avatarUrl={workspace?.avatar_url} size="sm" />
{myInvitations.length > 0 && (
{/* Shared brand dot: a pending invitation OR another
workspace with unread inbox items. The active
workspace's own unread stays on the Inbox nav count
(below), so it is deliberately excluded here. */}
{(myInvitations.length > 0 || otherWorkspaceUnread) && (
<span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-brand ring-1 ring-sidebar" />
)}
</span>
@@ -533,6 +552,14 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
>
<WorkspaceAvatar name={ws.name} avatarUrl={ws.avatar_url} size="sm" />
<span className="flex-1 truncate">{ws.name}</span>
{/* Points at the specific workspace holding unread
inbox items. Sits 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. */}
{ws.id !== workspace?.id && unreadWsIds.has(ws.id) && (
<span className="size-2 rounded-full bg-brand" />
)}
{ws.id === workspace?.id && (
<Check className="h-3.5 w-3.5 text-primary" />
)}

View File

@@ -793,6 +793,61 @@ func TestInboxThroughRouter(t *testing.T) {
}
}
func TestInboxUnreadSummaryThroughRouter(t *testing.T) {
ctx := context.Background()
// Seed one unread inbox item for the test user in the test workspace.
var itemID string
if err := testPool.QueryRow(ctx, `
INSERT INTO inbox_item (workspace_id, recipient_type, recipient_id, type, title)
VALUES ($1, 'member', $2, 'issue_assigned', 'Summary fixture')
RETURNING id
`, testWorkspaceID, testUserID).Scan(&itemID); err != nil {
t.Fatalf("failed to seed inbox item: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM inbox_item WHERE id = $1`, itemID)
})
resp := authRequest(t, "GET", "/api/inbox/unread-summary", nil)
if resp.StatusCode != 200 {
t.Fatalf("UnreadInboxSummary: expected 200, got %d", resp.StatusCode)
}
var summary []struct {
WorkspaceID string `json:"workspace_id"`
Count int64 `json:"count"`
}
readJSON(t, resp, &summary)
var found bool
for _, s := range summary {
if s.WorkspaceID == testWorkspaceID {
found = true
if s.Count < 1 {
t.Fatalf("expected unread count >= 1 for test workspace, got %d", s.Count)
}
}
}
if !found {
t.Fatalf("expected test workspace %s in unread summary, got %+v", testWorkspaceID, summary)
}
// After marking it read, the workspace should drop out of the summary.
if _, err := testPool.Exec(ctx, `UPDATE inbox_item SET read = true WHERE id = $1`, itemID); err != nil {
t.Fatalf("failed to mark item read: %v", err)
}
resp = authRequest(t, "GET", "/api/inbox/unread-summary", nil)
if resp.StatusCode != 200 {
t.Fatalf("UnreadInboxSummary (after read): expected 200, got %d", resp.StatusCode)
}
readJSON(t, resp, &summary)
for _, s := range summary {
if s.WorkspaceID == testWorkspaceID && s.Count > 0 {
t.Fatalf("expected no unread for test workspace after read, got count %d", s.Count)
}
}
}
// ---- 404 for non-existent resources ----
func TestNonExistentResources(t *testing.T) {

View File

@@ -1066,6 +1066,9 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
r.Route("/api/inbox", func(r chi.Router) {
r.Get("/", h.ListInbox)
r.Get("/unread-count", h.CountUnreadInbox)
// Cross-workspace unread summary: account-level, keyed on the
// user. Backs the workspace-switcher dot for OTHER workspaces.
r.Get("/unread-summary", h.UnreadInboxSummary)
r.Post("/mark-all-read", h.MarkAllInboxRead)
r.Post("/archive-all", h.ArchiveAllInbox)
r.Post("/archive-all-read", h.ArchiveAllReadInbox)

View File

@@ -195,6 +195,42 @@ func (h *Handler) CountUnreadInbox(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]int64{"count": count})
}
// InboxWorkspaceUnreadResponse is one workspace's unread inbox count in the
// cross-workspace summary.
type InboxWorkspaceUnreadResponse struct {
WorkspaceID string `json:"workspace_id"`
Count int64 `json:"count"`
}
// UnreadInboxSummary returns per-workspace unread inbox counts across every
// workspace the user belongs to. The sidebar uses it to light a dot on the
// workspace switcher when a workspace OTHER than the active one has unread
// items, without fetching each workspace's full inbox list. It is
// account-level by nature: it ignores the active workspace and keys only on
// the authenticated user.
func (h *Handler) UnreadInboxSummary(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
return
}
rows, err := h.Queries.CountUnreadInboxByWorkspace(r.Context(), parseUUID(userID))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to summarize unread inbox")
return
}
resp := make([]InboxWorkspaceUnreadResponse, len(rows))
for i, row := range rows {
resp[i] = InboxWorkspaceUnreadResponse{
WorkspaceID: uuidToString(row.WorkspaceID),
Count: row.Count,
}
}
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) MarkAllInboxRead(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {

View File

@@ -175,6 +175,48 @@ func (q *Queries) CountUnreadInbox(ctx context.Context, arg CountUnreadInboxPara
return count, err
}
const countUnreadInboxByWorkspace = `-- name: CountUnreadInboxByWorkspace :many
SELECT i.workspace_id, count(*) AS count
FROM inbox_item i
JOIN member m ON m.workspace_id = i.workspace_id AND m.user_id = i.recipient_id
WHERE i.recipient_type = 'member'
AND i.recipient_id = $1
AND i.read = false
AND i.archived = false
GROUP BY i.workspace_id
`
type CountUnreadInboxByWorkspaceRow struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
Count int64 `json:"count"`
}
// Per-workspace unread (non-archived) inbox counts for a recipient member,
// across every workspace they currently belong to. Powers the sidebar
// "other workspaces have unread" dot without fetching each workspace's full
// inbox list. The member join keeps counts scoped to workspaces the user is
// still a member of, so a stale item left behind in a workspace the user
// has since left cannot light the dot.
func (q *Queries) CountUnreadInboxByWorkspace(ctx context.Context, recipientID pgtype.UUID) ([]CountUnreadInboxByWorkspaceRow, error) {
rows, err := q.db.Query(ctx, countUnreadInboxByWorkspace, recipientID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []CountUnreadInboxByWorkspaceRow{}
for rows.Next() {
var i CountUnreadInboxByWorkspaceRow
if err := rows.Scan(&i.WorkspaceID, &i.Count); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const createInboxItem = `-- name: CreateInboxItem :one
INSERT INTO inbox_item (
workspace_id, recipient_type, recipient_id,

View File

@@ -45,6 +45,22 @@ RETURNING recipient_type, recipient_id;
SELECT count(*) FROM inbox_item
WHERE workspace_id = $1 AND recipient_type = $2 AND recipient_id = $3 AND read = false AND archived = false;
-- name: CountUnreadInboxByWorkspace :many
-- Per-workspace unread (non-archived) inbox counts for a recipient member,
-- across every workspace they currently belong to. Powers the sidebar
-- "other workspaces have unread" dot without fetching each workspace's full
-- inbox list. The member join keeps counts scoped to workspaces the user is
-- still a member of, so a stale item left behind in a workspace the user
-- has since left cannot light the dot.
SELECT i.workspace_id, count(*) AS count
FROM inbox_item i
JOIN member m ON m.workspace_id = i.workspace_id AND m.user_id = i.recipient_id
WHERE i.recipient_type = 'member'
AND i.recipient_id = $1
AND i.read = false
AND i.archived = false
GROUP BY i.workspace_id;
-- name: MarkAllInboxRead :execrows
UPDATE inbox_item SET read = true
WHERE workspace_id = $1 AND recipient_type = 'member' AND recipient_id = $2 AND archived = false AND read = false;