mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-02 01:45:52 +02:00
* 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>
290 lines
11 KiB
TypeScript
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) });
|
|
},
|
|
});
|
|
}
|