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:
Jiayuan Zhang
2026-07-11 02:46:12 +08:00
committed by GitHub
parent 835b1d5e4f
commit e10bb3fff0
9 changed files with 174 additions and 21 deletions

View File

@@ -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

View File

@@ -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 };

View File

@@ -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();

View File

@@ -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;
}

View 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);
});
});

View 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,
);
}

View File

@@ -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",

View File

@@ -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");
});
});

View File

@@ -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