mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-15 14:19:13 +02:00
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>
194 lines
7.0 KiB
TypeScript
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) });
|
|
},
|
|
});
|
|
}
|