Files
multica/packages/core/chat/mutations.ts
Jiayuan 2d905538f6 feat(inbox): open conversations in the detail pane, not the floating chat
Selecting a conversation in the inbox now opens it inline in the right
detail pane (the same surface a notification's issue opens into), instead
of popping the floating bottom-right chat window — which is being
deprecated.

- conversation renderer gains an inline Detail: header + the chat thread
  (reuses ChatMessageList) + a composer (reuses ChatInput). Marks the
  session read on open.
- conversation Row now selects into the pane (no setActiveSession/setOpen);
  the floating chat is no longer involved.
- inbox-page tracks conversation selection (URL ?conversation=<id>),
  dispatches the detail pane to the conversation renderer, and keeps the
  notification selection / IssueDetail path untouched. Mobile shows the
  conversation full-screen like a notification.
- add useSendChatMessage (core): optimistic append + send to an existing
  session, invalidating thread / pending-task / sessions on settle.

Refs MUL-3788

Co-authored-by: multica-agent <github@multica.ai>
2026-06-28 12:18:58 +08:00

194 lines
7.0 KiB
TypeScript

import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api";
import { useWorkspaceId } from "../hooks";
import { chatKeys } from "./queries";
import { createLogger } from "../logger";
import type { ChatMessage, ChatSession } from "../types";
const logger = createLogger("chat.mut");
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) });
},
});
}
/**
* Sends a user message to an existing chat session. Optimistically appends
* the message to the cached thread so it shows instantly, then invalidates
* the thread, the session's pending-task signal, and the sessions list (the
* reply bumps updated_at / re-orders the inbox feed) once the request
* settles. Rolls the optimistic message back on failure.
*
* Scoped to an existing session id — the floating chat's lazy
* session-creation path is separate; this is the inline (inbox) surface.
*/
export function useSendChatMessage(sessionId: string) {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: (vars: { content: string; attachmentIds?: string[] }) => {
logger.info("sendChatMessage.start", {
sessionId,
contentLength: vars.content.length,
attachmentCount: vars.attachmentIds?.length ?? 0,
});
return api.sendChatMessage(sessionId, vars.content, vars.attachmentIds);
},
onMutate: async (vars) => {
await qc.cancelQueries({ queryKey: chatKeys.messages(sessionId) });
const prev = qc.getQueryData<ChatMessage[]>(chatKeys.messages(sessionId));
const optimistic: ChatMessage = {
id: `optimistic-${Date.now()}`,
chat_session_id: sessionId,
role: "user",
content: vars.content,
task_id: null,
created_at: new Date().toISOString(),
attachments: [],
};
qc.setQueryData<ChatMessage[]>(chatKeys.messages(sessionId), (old) =>
old ? [...old, optimistic] : [optimistic],
);
return { prev };
},
onError: (err, _vars, ctx) => {
logger.error("sendChatMessage.error.rollback", err);
if (ctx?.prev) qc.setQueryData(chatKeys.messages(sessionId), ctx.prev);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: chatKeys.messages(sessionId) });
qc.invalidateQueries({ queryKey: chatKeys.pendingTask(sessionId) });
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 } : 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) });
},
});
}
/**
* 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) });
},
});
}