Files
multica/packages/core/chat/mutations.ts
Naiyuan Qing 47f9c5813f fix(chat): reland archived-session unread + mount-race auto-read (MUL-4360) (#5333)
* fix(chat): correct unread — archived sessions + mount-race auto-read (MUL-4360)

Reland of #5315, which was reverted by #5332 as collateral in an unrelated
release-wide revert (to unwind 162/163 migration BLOCK from other PRs), not for
any defect in this code — Howard/Preflight assessed these changes WARN /
non-blocking. Restores all three fixes verbatim off current main:

- backend: ListAllChatSessionsByCreator derives unread_count=0 / has_unread=false
  for status='archived' rows via a CASE gate. last_read_at is untouched, so
  unarchiving restores the true unread state. Single source of truth for every
  unread surface (quick-chat FAB, sidebar Chat tab, chat-window header, mobile);
  installed desktop clients benefit with no app update.
- frontend: the archive mutation onMutate optimistically zeroes the row's unread
  so no badge counts a just-archived session in the frame before the refetch
  lands. Unarchive does not fabricate a count — the true state returns from the
  server refetch.
- frontend: auto-mark-read is deferred a tick and cancelled on cleanup, so a
  session that is only momentarily active on mount (persisted activeSessionId
  restored for one frame, then cleared by the URL->store effect) is not marked
  read; only a session that stays active past the tick is.

Verification: sqlc regenerate produces no drift; go test ./internal/handler
-run 'TestListChatSessions_ArchivedSessionReportsZeroUnread|TestSetChatSessionArchived_ClearsChannelBinding'
passes against a real Postgres; vitest mutations.test.tsx (3) and
use-chat-controller.test.tsx (8) pass; core + views typecheck clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(chat): converge archived unread on chat:session_updated realtime event (MUL-4360)

Howard's #5333 review found a real cross-tab gap: the chat:session_updated
handler patched status but never unread, and chatSessionsOptions is
staleTime: Infinity, so a session archived in one tab kept its unread badge lit
in another tab/device forever — the same stuck-badge bug, one surface over.

Extract the inline handler into applyChatSessionUpdatedToCache and force
unread_count=0 / has_unread=false when payload.status === "archived", mirroring
the archive mutation's optimistic patch and the backend deriving unread=0 for
archived rows. Unarchive does NOT fabricate a count — the true unread returns
from the server refetch (last_read_at untouched). No sessions-list
invalidation; minimal field patch as reviewed.

Adds use-realtime-sync.test.ts coverage: an archived event zeroes a cached
unread row; an active event does not resurrect unread; a rename-only event
leaves unread untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-14 09:37:02 +08:00

290 lines
11 KiB
TypeScript

import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api";
import { useWorkspaceId } from "../hooks";
import { chatKeys, sortChatSessions } from "./queries";
import { createLogger } from "../logger";
import type { ChatSession, ChatPinnedAgent } from "../types";
const logger = createLogger("chat.mut");
/** Pin an agent to the quick-agent bar (optimistic append). */
export function usePinChatAgent() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: (agentId: string) => api.pinChatAgent(agentId),
onMutate: async (agentId) => {
await qc.cancelQueries({ queryKey: chatKeys.pinnedAgents(wsId) });
const prev = qc.getQueryData<ChatPinnedAgent[]>(chatKeys.pinnedAgents(wsId));
qc.setQueryData<ChatPinnedAgent[]>(chatKeys.pinnedAgents(wsId), (old) => {
if (old?.some((p) => p.agent_id === agentId)) return old;
const maxPos = old?.reduce((m, p) => Math.max(m, p.position), 0) ?? 0;
return [...(old ?? []), { agent_id: agentId, position: maxPos + 1 }];
});
return { prev };
},
onError: (err, agentId, ctx) => {
logger.error("pinChatAgent.error.rollback", { agentId, err });
if (ctx?.prev) qc.setQueryData(chatKeys.pinnedAgents(wsId), ctx.prev);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: chatKeys.pinnedAgents(wsId) });
},
});
}
/** Unpin an agent from the quick-agent bar (optimistic removal). */
export function useUnpinChatAgent() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: (agentId: string) => api.unpinChatAgent(agentId),
onMutate: async (agentId) => {
await qc.cancelQueries({ queryKey: chatKeys.pinnedAgents(wsId) });
const prev = qc.getQueryData<ChatPinnedAgent[]>(chatKeys.pinnedAgents(wsId));
qc.setQueryData<ChatPinnedAgent[]>(chatKeys.pinnedAgents(wsId), (old) =>
old?.filter((p) => p.agent_id !== agentId),
);
return { prev };
},
onError: (err, agentId, ctx) => {
logger.error("unpinChatAgent.error.rollback", { agentId, err });
if (ctx?.prev) qc.setQueryData(chatKeys.pinnedAgents(wsId), ctx.prev);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: chatKeys.pinnedAgents(wsId) });
},
});
}
export function useCreateChatSession() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: (data: { agent_id: string; title?: string }) => {
logger.info("createChatSession.start", { agent_id: data.agent_id, titleLength: data.title?.length ?? 0 });
return api.createChatSession(data);
},
onSuccess: (session) => {
logger.info("createChatSession.success", { sessionId: session.id, agentId: session.agent_id });
},
onError: (err) => {
logger.error("createChatSession.error", err);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) });
},
});
}
/**
* Clears the session's unread state server-side. Optimistically flips
* has_unread to false in the cached list so the FAB badge drops
* immediately. The server broadcasts chat:session_read so other devices
* also sync.
*/
export function useMarkChatSessionRead() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: (sessionId: string) => {
logger.info("markChatSessionRead.start", { sessionId });
return api.markChatSessionRead(sessionId);
},
onMutate: async (sessionId) => {
await qc.cancelQueries({ queryKey: chatKeys.sessions(wsId) });
const prevSessions = qc.getQueryData<ChatSession[]>(chatKeys.sessions(wsId));
const clear = (old?: ChatSession[]) =>
old?.map((s) => (s.id === sessionId ? { ...s, has_unread: false, unread_count: 0 } : s));
qc.setQueryData<ChatSession[]>(chatKeys.sessions(wsId), clear);
return { prevSessions };
},
onError: (err, sessionId, ctx) => {
logger.error("markChatSessionRead.error.rollback", { sessionId, err });
if (ctx?.prevSessions) qc.setQueryData(chatKeys.sessions(wsId), ctx.prevSessions);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) });
},
});
}
/**
* Renames a chat session. Optimistically swaps the title in the cached
* list so the dropdown reflects the new label immediately; rolls back on
* error. The matching `chat:session_updated` WS event keeps other
* tabs/devices in sync — see use-realtime-sync.ts.
*/
export function useUpdateChatSession() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: (data: { sessionId: string; title: string }) => {
logger.info("updateChatSession.start", {
sessionId: data.sessionId,
titleLength: data.title.length,
});
return api.updateChatSession(data.sessionId, { title: data.title });
},
onMutate: async ({ sessionId, title }) => {
await qc.cancelQueries({ queryKey: chatKeys.sessions(wsId) });
const prevSessions = qc.getQueryData<ChatSession[]>(chatKeys.sessions(wsId));
const patch = (old?: ChatSession[]) =>
old?.map((s) => (s.id === sessionId ? { ...s, title } : s));
qc.setQueryData<ChatSession[]>(chatKeys.sessions(wsId), patch);
return { prevSessions };
},
onError: (err, vars, ctx) => {
logger.error("updateChatSession.error.rollback", { sessionId: vars.sessionId, err });
if (ctx?.prevSessions) qc.setQueryData(chatKeys.sessions(wsId), ctx.prevSessions);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) });
},
});
}
/**
* Pins or unpins a chat. Optimistically flips `pinned` and re-sorts the cached
* list (pinned first, then by activity) so the row jumps to / from the top
* instantly; rolls back on error. The matching `chat:session_updated` WS event
* carries the new pin state to other tabs/devices — see use-realtime-sync.ts.
*/
export function useSetChatSessionPinned() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: (data: { sessionId: string; pinned: boolean }) => {
logger.info("setChatSessionPinned.start", data);
return api.setChatSessionPinned(data.sessionId, data.pinned);
},
onMutate: async ({ sessionId, pinned }) => {
await qc.cancelQueries({ queryKey: chatKeys.sessions(wsId) });
const prevSessions = qc.getQueryData<ChatSession[]>(chatKeys.sessions(wsId));
const patch = (old?: ChatSession[]) =>
old &&
sortChatSessions(old.map((s) => (s.id === sessionId ? { ...s, pinned } : s)));
qc.setQueryData<ChatSession[]>(chatKeys.sessions(wsId), patch);
return { prevSessions };
},
onError: (err, vars, ctx) => {
logger.error("setChatSessionPinned.error.rollback", { sessionId: vars.sessionId, err });
if (ctx?.prevSessions) qc.setQueryData(chatKeys.sessions(wsId), ctx.prevSessions);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) });
},
});
}
/**
* Archives or unarchives a chat session. Optimistically flips `status` in the
* cached list so the row moves between the active list and the "Archived" view
* instantly (both filter on status locally); rolls back on error. Bumps
* `updated_at` so the row re-sorts by activity in whichever view it lands.
* The matching `chat:session_updated` WS event carries the new status to other
* tabs/devices — see use-realtime-sync.ts.
*
* Archiving also zeroes the row's unread locally so every badge (FAB, sidebar
* Chat tab, chat-window header) drops it in the same frame the row moves to the
* Archived view. The backend already forces unread to 0 for archived rows (see
* ListAllChatSessionsByCreator / MUL-4360); this is the optimistic half so there
* is no window where the header/sidebar still count a just-archived session
* before the refetch lands. Unarchive does NOT restore a count here — the true
* unread state comes back from the server refetch (last_read_at is untouched).
*/
export function useSetChatSessionArchived() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: (data: { sessionId: string; archived: boolean }) => {
logger.info("setChatSessionArchived.start", data);
return api.setChatSessionArchived(data.sessionId, data.archived);
},
onMutate: async ({ sessionId, archived }) => {
await qc.cancelQueries({ queryKey: chatKeys.sessions(wsId) });
const prevSessions = qc.getQueryData<ChatSession[]>(chatKeys.sessions(wsId));
const nowIso = new Date().toISOString();
const patch = (old?: ChatSession[]) =>
old &&
sortChatSessions(
old.map((s) =>
s.id === sessionId
? {
...s,
status: archived ? "archived" : "active",
updated_at: nowIso,
...(archived ? { unread_count: 0, has_unread: false } : {}),
}
: s,
),
);
qc.setQueryData<ChatSession[]>(chatKeys.sessions(wsId), patch);
return { prevSessions };
},
onError: (err, vars, ctx) => {
logger.error("setChatSessionArchived.error.rollback", { sessionId: vars.sessionId, err });
if (ctx?.prevSessions) qc.setQueryData(chatKeys.sessions(wsId), ctx.prevSessions);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) });
},
});
}
/**
* Hard-deletes a chat session. Optimistically removes the row from the
* sessions list so the dropdown updates instantly; rolls back on error.
* The matching `chat:session_deleted` WS event keeps other tabs/devices
* in sync — see use-realtime-sync.ts.
*/
export function useDeleteChatSession() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: (sessionId: string) => {
logger.info("deleteChatSession.start", { sessionId });
return api.deleteChatSession(sessionId);
},
onMutate: async (sessionId) => {
await qc.cancelQueries({ queryKey: chatKeys.sessions(wsId) });
const prevSessions = qc.getQueryData<ChatSession[]>(chatKeys.sessions(wsId));
const drop = (old?: ChatSession[]) => old?.filter((s) => s.id !== sessionId);
qc.setQueryData<ChatSession[]>(chatKeys.sessions(wsId), drop);
logger.debug("deleteChatSession.optimistic", { sessionId });
return { prevSessions };
},
onError: (err, sessionId, ctx) => {
logger.error("deleteChatSession.error.rollback", { sessionId, err });
if (ctx?.prevSessions) qc.setQueryData(chatKeys.sessions(wsId), ctx.prevSessions);
},
onSettled: (_data, _err, sessionId) => {
logger.debug("deleteChatSession.settled", { sessionId });
qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) });
},
});
}