Files
multica/apps/mobile/data/mutations/chat.ts
Jiayuan Zhang e10bb3fff0 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>
2026-07-11 02:46:12 +08:00

94 lines
3.5 KiB
TypeScript

/**
* Mobile chat mutations — create session, delete session, mark session read.
*
* Send-message is NOT a mutation: the chat screen runs a hand-written
* optimistic burst (seed messages cache → seed pendingTask cache → flip
* activeSession → POST → patch with real task_id) that doesn't map cleanly
* onto useMutation. See the chat tab screen for the send path.
*
* Mirrors the optimistic-update + rollback + onSettled-invalidate pattern
* of data/mutations/inbox.ts and web's packages/core/chat/mutations.ts.
*/
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { ChatSession } from "@multica/core/types";
import { api } from "@/data/api";
import { useWorkspaceStore } from "@/data/workspace-store";
import { chatKeys } from "@/data/queries/chat";
export function useCreateChatSession() {
const qc = useQueryClient();
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
return useMutation({
mutationFn: (data: { agent_id: string; title?: string }) =>
api.createChatSession(data),
onSettled: () => {
// Optimistic prepend isn't done here — the chat screen seeds caches
// synchronously around its send burst and uses the returned session
// id directly. The invalidate ensures the dropdown picks up the new
// row (and any has_unread / title server defaults) without a refetch
// race on switch.
qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) });
},
});
}
export function useDeleteChatSession() {
const qc = useQueryClient();
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
return useMutation({
mutationFn: (id: string) => api.deleteChatSession(id),
onMutate: async (id) => {
const key = chatKeys.sessions(wsId);
await qc.cancelQueries({ queryKey: key });
const prev = qc.getQueryData<ChatSession[]>(key);
qc.setQueryData<ChatSession[]>(key, (old) =>
old ? old.filter((s) => s.id !== id) : old,
);
return { prev, key };
},
onError: (_err, _id, ctx) => {
if (ctx?.prev) qc.setQueryData(ctx.key, ctx.prev);
},
onSettled: (_data, _err, id) => {
qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) });
// Detail-side caches the screen may still hold for this id.
qc.removeQueries({ queryKey: chatKeys.messages(id) });
qc.removeQueries({ queryKey: chatKeys.pendingTask(id) });
},
});
}
export function useMarkChatSessionRead() {
const qc = useQueryClient();
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
return useMutation({
mutationFn: (sessionId: string) => api.markChatSessionRead(sessionId),
onMutate: async (sessionId) => {
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, unread_count: 0 }
: s,
),
);
return { prev, key };
},
onError: (_err, _id, ctx) => {
if (ctx?.prev) qc.setQueryData(ctx.key, ctx.prev);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) });
},
});
}