diff --git a/apps/mobile/app/(app)/[workspace]/(tabs)/_layout.tsx b/apps/mobile/app/(app)/[workspace]/(tabs)/_layout.tsx index 4e539827c9..676d1346ea 100644 --- a/apps/mobile/app/(app)/[workspace]/(tabs)/_layout.tsx +++ b/apps/mobile/app/(app)/[workspace]/(tabs)/_layout.tsx @@ -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 diff --git a/apps/mobile/data/mutations/chat.ts b/apps/mobile/data/mutations/chat.ts index be08cfb366..7dbacc336b 100644 --- a/apps/mobile/data/mutations/chat.ts +++ b/apps/mobile/data/mutations/chat.ts @@ -70,9 +70,15 @@ export function useMarkChatSessionRead() { const key = chatKeys.sessions(wsId); await qc.cancelQueries({ queryKey: key }); const prev = qc.getQueryData(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(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 }; diff --git a/apps/mobile/data/schemas.ts b/apps/mobile/data/schemas.ts index 596ad82346..842900aef1 100644 --- a/apps/mobile/data/schemas.ts +++ b/apps/mobile/data/schemas.ts @@ -246,6 +246,10 @@ export const ChatSessionSchema: z.ZodType = 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(); diff --git a/apps/mobile/lib/unread-counts.ts b/apps/mobile/lib/unread-counts.ts index 60ad74c1e3..c739f07577 100644 --- a/apps/mobile/lib/unread-counts.ts +++ b/apps/mobile/lib/unread-counts.ts @@ -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; } diff --git a/packages/core/chat/unread.test.ts b/packages/core/chat/unread.test.ts new file mode 100644 index 0000000000..977e878f48 --- /dev/null +++ b/packages/core/chat/unread.test.ts @@ -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); + }); +}); diff --git a/packages/core/chat/unread.ts b/packages/core/chat/unread.ts new file mode 100644 index 0000000000..dd647ccf4b --- /dev/null +++ b/packages/core/chat/unread.ts @@ -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, + ); +} diff --git a/packages/core/package.json b/packages/core/package.json index 714e7aca68..4ce09d0045 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", diff --git a/packages/views/layout/app-sidebar.test.tsx b/packages/views/layout/app-sidebar.test.tsx index 094188e653..4d383140fc 100644 --- a/packages/views/layout/app-sidebar.test.tsx +++ b/packages/views/layout/app-sidebar.test.tsx @@ -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(); 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(); 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(); + 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(); + 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(); + expect(chatNav(container)?.textContent).toContain("5"); + }); }); diff --git a/packages/views/layout/app-sidebar.tsx b/packages/views/layout/app-sidebar.tsx index b5da730443..c8912a5aa8 100644 --- a/packages/views/layout/app-sidebar.tsx +++ b/packages/views/layout/app-sidebar.tsx @@ -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