Files
multica/packages/core/chat/mutations.ts
Naiyuan Qing cec071dc06 fix(chat): correct unread — archived sessions + mount-race auto-read (MUL-4360) (#5315)
* feat(skills): search runtime local skills

* feat(skills): highlight matched substrings in runtime local skill search

Reuse the shared HighlightText (the same component the global search
command uses) to highlight matched substrings in a result's name,
provider, description, and path, so styling stays consistent across the
app. Narrow the search to the fields the row actually renders and drop
`key`, so every match maps to something visible. While a query is active,
lift the description's 2-line clamp so a match past the first two lines
stays on screen instead of being clipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(chat): zero unread for archived chat sessions across all badges (MUL-4360)

Archiving a chat session flips status but deliberately does not advance
last_read_at, and ListAllChatSessionsByCreator counted unread
unconditionally. So an archived session that had unread replies kept
reporting has_unread=true / unread_count>0 — a stuck badge the user can
never clear (archived sessions are read-only and hidden from history, so
there is no mark-as-read entry). MUL-4372 fixed only the quick-chat FAB
surface; the sidebar Chat tab badge and the chat-window "other unread"
header still counted it.

Fix at the source: derive unread_count = 0 for status='archived' rows in
ListAllChatSessionsByCreator. Because has_unread is server-derived as
unread_count > 0, and all surfaces (FAB, sidebar via
countUnreadChatMessages, chat-window header, and mobile) read this one
payload, every badge drops archived sessions with no per-surface filter.
last_read_at is left untouched so unarchiving restores the true unread
state. Installed desktop clients benefit without an app update.

Also zero unread optimistically in the archive mutation so no badge
counts a just-archived session in the frame before the refetch lands
(FAB already filtered archived; this keeps sidebar/header consistent).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(chat): stop auto-mark-read from clearing a transiently-active session on mount (MUL-4360)

The chat page persists `activeSessionId`, so on a bare `/chat` navigation it
restores the last-open session as active for one frame before its URL→store
effect (which runs AFTER useChatController's effects, since the hook is called
first) clears it back to null. The auto-mark-read effect fired in that gap and
marked the restored-but-never-opened session read — its unread badge vanished
though the right pane still showed "select a chat" and the user never opened it.
This is why the sidebar count dropped (e.g. 2 → 1) just by entering the tab.

Defer the read by a tick and cancel it on cleanup: a session that is only
momentarily active (restored on mount, then cleared) has its pending read
cancelled when activeSessionId changes; only a session that stays active past
the tick — a real select, deep link, or refresh — is marked read. A live-store
re-check in the timer is a belt-and-suspenders guard.

Adds the previously-missing auto-mark-read coverage: a stable-active session is
read after the tick; a momentarily-active one is not.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: coderbaozi <YHbaozi1988@163.com>
Co-authored-by: abun <103836393+coderbaozi@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 15:59:20 +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) });
},
});
}