mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-25 12:05:06 +02:00
fix(chat): unify unread badge counting across sidebar, mobile, and thread list (MUL-4286) (#5239)
The aggregate chat unread badge used two different definitions: web/ desktop's sidebar summed unread messages while mobile's tab badge (and the since-removed ChatFab badge it mirrored) counted sessions — the same account state showed e.g. 5 on web and 2 on iOS. The sidebar also summed ALL sessions while the thread list zeroes the row being viewed, so a reply landing in the open conversation flashed a sidebar count with no matching row. - packages/core/chat/unread.ts: countUnreadChatMessages() as the single shared definition (IM-style message total, optional viewed-session exclusion); pure so mobile can import it. - app-sidebar: use the helper and exclude the actively-viewed session, but only while a chat surface is actually showing it (chat route or floating window open) — a remembered selection with both surfaces closed still counts, since nothing will auto mark-read there. - mobile: tab badge switches to the shared message count (99+ cap like the sidebar), ChatSessionSchema parses unread_count, and markRead's optimistic patch zeroes unread_count alongside has_unread. Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -30,7 +30,7 @@ import { useColorScheme } from "@/lib/use-color-scheme";
|
||||
import { THEME } from "@/lib/theme";
|
||||
import {
|
||||
useInboxUnreadCount,
|
||||
useChatUnreadSessionCount,
|
||||
useChatUnreadMessageCount,
|
||||
} from "@/lib/unread-counts";
|
||||
import { MoreTabDropdownAnchor } from "@/components/nav/more-tab-dropdown";
|
||||
|
||||
@@ -49,15 +49,14 @@ export default function TabsLayout() {
|
||||
|
||||
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
|
||||
const inboxUnread = useInboxUnreadCount(wsId);
|
||||
const chatUnread = useChatUnreadSessionCount(wsId);
|
||||
const chatUnread = useChatUnreadMessageCount(wsId);
|
||||
|
||||
// Truncation aligned with web: inbox 99+, chat 9+ (matches sidebar +
|
||||
// ChatFab respectively). `undefined` makes React Navigation hide the
|
||||
// badge, so zero-count is a free no-op.
|
||||
// Truncation aligned with web's sidebar badges: 99+ for both. `undefined`
|
||||
// makes React Navigation hide the badge, so zero-count is a free no-op.
|
||||
const inboxBadge =
|
||||
inboxUnread > 0 ? (inboxUnread > 99 ? "99+" : String(inboxUnread)) : undefined;
|
||||
const chatBadge =
|
||||
chatUnread > 0 ? (chatUnread > 9 ? "9+" : String(chatUnread)) : undefined;
|
||||
chatUnread > 0 ? (chatUnread > 99 ? "99+" : String(chatUnread)) : undefined;
|
||||
|
||||
// Imperative handle into the More tab's dropdown — listeners.tabPress
|
||||
// calls .open(); the @rn-primitives Trigger measures itself inside
|
||||
|
||||
@@ -70,9 +70,15 @@ export function useMarkChatSessionRead() {
|
||||
const key = chatKeys.sessions(wsId);
|
||||
await qc.cancelQueries({ queryKey: key });
|
||||
const prev = qc.getQueryData<ChatSession[]>(key);
|
||||
// Zero unread_count together with has_unread — the tab badge sums
|
||||
// unread_count (see lib/unread-counts.ts), so clearing only the flag
|
||||
// would leave a stale badge until the settle refetch. Mirrors web's
|
||||
// useMarkChatSessionRead in packages/core/chat/mutations.ts.
|
||||
qc.setQueryData<ChatSession[]>(key, (old) =>
|
||||
old?.map((s) =>
|
||||
s.id === sessionId ? { ...s, has_unread: false } : s,
|
||||
s.id === sessionId
|
||||
? { ...s, has_unread: false, unread_count: 0 }
|
||||
: s,
|
||||
),
|
||||
);
|
||||
return { prev, key };
|
||||
|
||||
@@ -246,6 +246,10 @@ export const ChatSessionSchema: z.ZodType<ChatSession> = z.object({
|
||||
// unknown server values fall back to "active" so the row still renders.
|
||||
status: z.enum(["active", "archived"]).catch("active"),
|
||||
has_unread: z.boolean().default(false),
|
||||
// Unread assistant messages after the read cursor. Optional (not defaulted)
|
||||
// so the badge math can tell "older server didn't send it" from a real 0 —
|
||||
// the tab badge sums `unread_count ?? 0`, same rule as web's sidebar.
|
||||
unread_count: z.number().optional(),
|
||||
created_at: z.string().default(""),
|
||||
updated_at: z.string().default(""),
|
||||
}).loose();
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
*
|
||||
* Mirrors the counting logic from:
|
||||
* - packages/core/inbox/queries.ts::useInboxUnreadCount (inbox)
|
||||
* - packages/views/chat/components/chat-fab.tsx (chat: sessions with unread)
|
||||
* - 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
|
||||
@@ -15,6 +17,7 @@
|
||||
* the N rendered here MUST equal the N web shows for the same user/workspace.
|
||||
*/
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { countUnreadChatMessages } from "@multica/core/chat/unread";
|
||||
import { inboxListOptions } from "@/data/queries/inbox";
|
||||
import { chatSessionsOptions } from "@/data/queries/chat";
|
||||
import { deduplicateInboxItems } from "@/lib/inbox-display";
|
||||
@@ -34,16 +37,23 @@ export function useInboxUnreadCount(wsId: string | null | undefined): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of chat sessions that have at least one unread assistant reply.
|
||||
* Matches web ChatFab's `sessions.filter(s => s.has_unread).length` — this
|
||||
* is a session count, not a message count.
|
||||
* Total unread assistant *messages* across chat sessions (IM-style), the
|
||||
* same number web/desktop's sidebar Chat badge shows. Was a session count
|
||||
* before MUL-4286; that matched the (since removed) web ChatFab badge and
|
||||
* disagreed with the sidebar.
|
||||
*
|
||||
* No excludeSessionId here: the chat tab renders the active conversation
|
||||
* itself, and the focused-screen auto mark-read (chat.tsx) plus the
|
||||
* mutation's optimistic unread_count reset clear that session's share of
|
||||
* the badge immediately — a badge decrementing on the tab you are already
|
||||
* inside is normal IM behavior, not a phantom.
|
||||
*/
|
||||
export function useChatUnreadSessionCount(
|
||||
export function useChatUnreadMessageCount(
|
||||
wsId: string | null | undefined,
|
||||
): number {
|
||||
const { data } = useQuery({
|
||||
...chatSessionsOptions(wsId ?? null),
|
||||
select: (sessions) => sessions.filter((s) => s.has_unread).length,
|
||||
select: (sessions) => countUnreadChatMessages(sessions),
|
||||
});
|
||||
return data ?? 0;
|
||||
}
|
||||
|
||||
46
packages/core/chat/unread.test.ts
Normal file
46
packages/core/chat/unread.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { countUnreadChatMessages } from "./unread";
|
||||
import type { ChatSession } from "../types/chat";
|
||||
|
||||
function session(id: string, unread_count?: number): ChatSession {
|
||||
return {
|
||||
id,
|
||||
workspace_id: "ws-1",
|
||||
agent_id: "agent-1",
|
||||
creator_id: "user-1",
|
||||
title: id,
|
||||
status: "active",
|
||||
has_unread: (unread_count ?? 0) > 0,
|
||||
unread_count,
|
||||
created_at: "2026-07-10T00:00:00Z",
|
||||
updated_at: "2026-07-10T00:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
describe("countUnreadChatMessages", () => {
|
||||
it("sums unread messages across sessions (not session count)", () => {
|
||||
expect(
|
||||
countUnreadChatMessages([session("a", 2), session("b", 3), session("c", 0)]),
|
||||
).toBe(5);
|
||||
});
|
||||
|
||||
it("treats a missing unread_count (older server) as 0", () => {
|
||||
expect(countUnreadChatMessages([session("a"), session("b", 4)])).toBe(4);
|
||||
});
|
||||
|
||||
it("returns 0 for an empty or unloaded list", () => {
|
||||
expect(countUnreadChatMessages([])).toBe(0);
|
||||
expect(countUnreadChatMessages(undefined)).toBe(0);
|
||||
});
|
||||
|
||||
it("excludes the session being viewed from the sum", () => {
|
||||
const sessions = [session("a", 2), session("b", 3)];
|
||||
expect(countUnreadChatMessages(sessions, "a")).toBe(3);
|
||||
expect(countUnreadChatMessages(sessions, "b")).toBe(2);
|
||||
});
|
||||
|
||||
it("ignores an exclude id that is not in the list", () => {
|
||||
expect(countUnreadChatMessages([session("a", 2)], "ghost")).toBe(2);
|
||||
expect(countUnreadChatMessages([session("a", 2)], null)).toBe(2);
|
||||
});
|
||||
});
|
||||
31
packages/core/chat/unread.ts
Normal file
31
packages/core/chat/unread.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { ChatSession } from "../types/chat";
|
||||
|
||||
/**
|
||||
* The chat unread badge number: total unread assistant *messages* across
|
||||
* sessions (IM-style), NOT the number of sessions with unread.
|
||||
*
|
||||
* Single source of truth for every surface that renders an aggregate chat
|
||||
* unread count (web/desktop sidebar, mobile tab bar) — the platforms must
|
||||
* show the same N for the same account state (see "Behavioral parity" in
|
||||
* apps/mobile/CLAUDE.md). Pure function so mobile can import it directly.
|
||||
*
|
||||
* `excludeSessionId` drops one session from the sum: the session the user is
|
||||
* currently reading. The chat thread list renders that session's own badge
|
||||
* as 0 (its unread is about to be cleared by the viewer's auto mark-read),
|
||||
* so an aggregate that still counted it would show a number the list can't
|
||||
* account for. Callers pass it only while a chat surface is actually showing
|
||||
* that session; a background pointer (e.g. a remembered selection while the
|
||||
* chat page is closed) must NOT be excluded, or new replies would never
|
||||
* badge.
|
||||
*/
|
||||
export function countUnreadChatMessages(
|
||||
sessions: readonly ChatSession[] | undefined,
|
||||
excludeSessionId?: string | null,
|
||||
): number {
|
||||
if (!sessions) return 0;
|
||||
return sessions.reduce(
|
||||
(sum, s) =>
|
||||
s.id === excludeSessionId ? sum : sum + (s.unread_count ?? 0),
|
||||
0,
|
||||
);
|
||||
}
|
||||
@@ -50,6 +50,7 @@
|
||||
"./chat": "./chat/index.ts",
|
||||
"./chat/queries": "./chat/queries.ts",
|
||||
"./chat/mutations": "./chat/mutations.ts",
|
||||
"./chat/unread": "./chat/unread.ts",
|
||||
"./runtimes": "./runtimes/index.ts",
|
||||
"./runtimes/queries": "./runtimes/queries.ts",
|
||||
"./runtimes/mutations": "./runtimes/mutations.ts",
|
||||
|
||||
@@ -3,8 +3,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ApiError } from "@multica/core/api";
|
||||
import { AppSidebar } from "./app-sidebar";
|
||||
|
||||
const { chatSessions, detail, deletePin, navigation, pins, summary, workspaces } = vi.hoisted(() => ({
|
||||
chatSessions: { current: [] as { unread_count?: number }[] },
|
||||
const { chatSessions, chatStore, detail, deletePin, navigation, pins, summary, workspaces } = vi.hoisted(() => ({
|
||||
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(),
|
||||
navigation: { current: { pathname: "/acme/issues" } },
|
||||
@@ -98,6 +99,14 @@ vi.mock("@multica/ui/components/common/actor-avatar", () => ({ ActorAvatar: () =
|
||||
vi.mock("@multica/core/auth", () => ({
|
||||
useAuthStore: (selector: (state: { user: { id: string } }) => unknown) => selector({ user: { id: "user-1" } }),
|
||||
}));
|
||||
// Callable-store shape (selectorFn + getState) per the repo testing rules.
|
||||
vi.mock("@multica/core/chat", () => ({
|
||||
useChatStore: Object.assign(
|
||||
(selector: (state: { activeSessionId: string | null; isOpen: boolean }) => unknown) =>
|
||||
selector(chatStore.current),
|
||||
{ getState: () => chatStore.current },
|
||||
),
|
||||
}));
|
||||
vi.mock("@multica/core/paths", () => ({
|
||||
paths: { workspace: (slug: string) => ({ issues: () => `/${slug}/issues` }) },
|
||||
useCurrentWorkspace: () => ({ id: "ws-1", name: "Acme", slug: "acme" }),
|
||||
@@ -286,6 +295,7 @@ describe("personal nav — Chat", () => {
|
||||
beforeEach(() => {
|
||||
chatSessions.current = [];
|
||||
navigation.current = { pathname: "/acme/issues" };
|
||||
chatStore.current = { activeSessionId: null, isOpen: false };
|
||||
});
|
||||
|
||||
// The mocked SidebarMenuButton exposes the AppLink target as `data-href`
|
||||
@@ -299,14 +309,43 @@ describe("personal nav — Chat", () => {
|
||||
});
|
||||
|
||||
it("badges the Chat nav with the summed unread_count of chat sessions", () => {
|
||||
chatSessions.current = [{ unread_count: 3 }, { unread_count: 2 }, { unread_count: 0 }];
|
||||
chatSessions.current = [{ id: "a", unread_count: 3 }, { id: "b", unread_count: 2 }, { id: "c", unread_count: 0 }];
|
||||
const { container } = render(<AppSidebar />);
|
||||
expect(chatNav(container)?.textContent).toContain("5");
|
||||
});
|
||||
|
||||
it("shows no Chat unread badge when every session is read", () => {
|
||||
chatSessions.current = [{ unread_count: 0 }, {}];
|
||||
chatSessions.current = [{ id: "a", unread_count: 0 }, { id: "b" }];
|
||||
const { container } = render(<AppSidebar />);
|
||||
expect(chatNav(container)?.textContent ?? "").not.toMatch(/\d/);
|
||||
});
|
||||
|
||||
it("excludes the session being viewed on the chat page from the badge", () => {
|
||||
// The thread list zeroes the open session's row badge; the aggregate
|
||||
// must follow, or a reply landing in the open conversation flashes a
|
||||
// count with no matching row.
|
||||
chatSessions.current = [{ id: "a", unread_count: 2 }, { id: "b", unread_count: 3 }];
|
||||
navigation.current = { pathname: "/acme/chat" };
|
||||
chatStore.current = { activeSessionId: "a", isOpen: false };
|
||||
const { container } = render(<AppSidebar />);
|
||||
expect(chatNav(container)?.textContent).toContain("3");
|
||||
});
|
||||
|
||||
it("excludes the viewed session when the floating chat window is open off-route", () => {
|
||||
chatSessions.current = [{ id: "a", unread_count: 2 }, { id: "b", unread_count: 3 }];
|
||||
navigation.current = { pathname: "/acme/issues" };
|
||||
chatStore.current = { activeSessionId: "a", isOpen: true };
|
||||
const { container } = render(<AppSidebar />);
|
||||
expect(chatNav(container)?.textContent).toContain("3");
|
||||
});
|
||||
|
||||
it("still counts a remembered selection when no chat surface is showing it", () => {
|
||||
// activeSessionId persists after the chat page closes; with both
|
||||
// surfaces closed nothing will auto mark-read, so the badge must count.
|
||||
chatSessions.current = [{ id: "a", unread_count: 2 }, { id: "b", unread_count: 3 }];
|
||||
navigation.current = { pathname: "/acme/issues" };
|
||||
chatStore.current = { activeSessionId: "a", isOpen: false };
|
||||
const { container } = render(<AppSidebar />);
|
||||
expect(chatNav(container)?.textContent).toContain("5");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -73,6 +73,8 @@ 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 { chatSessionsOptions } from "@multica/core/chat/queries";
|
||||
import { countUnreadChatMessages } from "@multica/core/chat/unread";
|
||||
import { useChatStore } from "@multica/core/chat";
|
||||
import { api, ApiError } from "@multica/core/api";
|
||||
import { useModalStore } from "@multica/core/modals";
|
||||
import { useConfigStore } from "@multica/core/config";
|
||||
@@ -369,15 +371,30 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
|
||||
() => deduplicateInboxItems(inboxItems).filter((i) => !i.read).length,
|
||||
[inboxItems],
|
||||
);
|
||||
// Chat tab unread badge: number of chat threads with a fresh reply.
|
||||
// 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).
|
||||
const { data: chatSessions = [] } = useQuery({
|
||||
...chatSessionsOptions(wsId ?? ""),
|
||||
enabled: !!wsId,
|
||||
});
|
||||
// IM-style: total unread *messages* across all chat threads (not thread count).
|
||||
// The session the user is reading right now must not count: the thread list
|
||||
// renders its row badge as 0 (auto mark-read is about to clear it), and a
|
||||
// reply landing in the open conversation would otherwise flash a sidebar
|
||||
// count with no matching row. "Reading right now" = a session is active AND
|
||||
// a chat surface is actually showing it (chat page route or the floating
|
||||
// window). A remembered selection while both surfaces are closed still
|
||||
// counts — auto mark-read won't fire there, so the badge must.
|
||||
const activeChatSessionId = useChatStore((s) => s.activeSessionId);
|
||||
const floatingChatOpen = useChatStore((s) => s.isOpen);
|
||||
const chatHref = p.chat();
|
||||
const viewedChatSessionId =
|
||||
floatingChatOpen || isNavActive(pathname, chatHref)
|
||||
? activeChatSessionId
|
||||
: null;
|
||||
const chatUnreadCount = React.useMemo(
|
||||
() => chatSessions.reduce((sum, s) => sum + (s.unread_count ?? 0), 0),
|
||||
[chatSessions],
|
||||
() => countUnreadChatMessages(chatSessions, viewedChatSessionId),
|
||||
[chatSessions, viewedChatSessionId],
|
||||
);
|
||||
// Cross-workspace unread summary backs the workspace-switcher dot. One
|
||||
// shared cache entry across workspaces; gated on an active workspace since
|
||||
|
||||
Reference in New Issue
Block a user