From 2d905538f6e8dff86ccdf05e1b6e59ecbb461831 Mon Sep 17 00:00:00 2001 From: Jiayuan Date: Sun, 28 Jun 2026 12:18:58 +0800 Subject: [PATCH] feat(inbox): open conversations in the detail pane, not the floating chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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=), 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 --- packages/core/chat/mutations.ts | 54 +++++++++- .../views/inbox/components/inbox-page.tsx | 38 +++++-- .../views/inbox/item-types/contract.test.ts | 4 +- .../views/inbox/item-types/conversation.tsx | 101 +++++++++++++++--- 4 files changed, 173 insertions(+), 24 deletions(-) diff --git a/packages/core/chat/mutations.ts b/packages/core/chat/mutations.ts index c43663b86..22ff305ff 100644 --- a/packages/core/chat/mutations.ts +++ b/packages/core/chat/mutations.ts @@ -3,7 +3,7 @@ import { api } from "../api"; import { useWorkspaceId } from "../hooks"; import { chatKeys } from "./queries"; import { createLogger } from "../logger"; -import type { ChatSession } from "../types"; +import type { ChatMessage, ChatSession } from "../types"; const logger = createLogger("chat.mut"); @@ -28,6 +28,58 @@ export function useCreateChatSession() { }); } +/** + * 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(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(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 diff --git a/packages/views/inbox/components/inbox-page.tsx b/packages/views/inbox/components/inbox-page.tsx index 51e83828a..22588998e 100644 --- a/packages/views/inbox/components/inbox-page.tsx +++ b/packages/views/inbox/components/inbox-page.tsx @@ -62,14 +62,21 @@ export function InboxPage() { const { t } = useT("inbox"); const { searchParams, replace } = useNavigation(); const urlIssue = searchParams.get("issue") ?? ""; + const urlConversation = searchParams.get("conversation") ?? ""; const wsPaths = useWorkspacePaths(); const [selectedKey, setSelectedKeyState] = useState(() => urlIssue); + const [selectedConversationId, setSelectedConversationId] = useState( + () => urlConversation, + ); // Sync from URL when searchParams change (e.g. navigation) useEffect(() => { setSelectedKeyState(urlIssue); }, [urlIssue]); + useEffect(() => { + setSelectedConversationId(urlConversation); + }, [urlConversation]); const wsId = useWorkspaceId(); const { data: rawItems = [], isLoading: loading } = useQuery(inboxListOptions(wsId)); @@ -93,6 +100,8 @@ export function InboxPage() { }, [items, sessions]); const selected = items.find((i) => (i.issue_id ?? i.id) === selectedKey) ?? null; + const selectedConversation = + sessions.find((s) => s.id === selectedConversationId) ?? null; // Notifications backed by an issue defer to the shared IssueDetail surface; // everything else renders the detail pane its item-type renderer provides. @@ -112,11 +121,21 @@ export function InboxPage() { const setSelectedKey = useCallback((key: string) => { setSelectedKeyState(key); + setSelectedConversationId(""); const inboxPath = wsPaths.inbox(); const url = key ? `${inboxPath}?issue=${key}` : inboxPath; replace(url); }, [replace, wsPaths]); + // Open a conversation in the detail pane (like opening an issue), clearing + // any notification selection. The floating chat window is intentionally not + // involved — it is being deprecated in favour of this inline surface. + const selectConversation = useCallback((id: string) => { + setSelectedConversationId(id); + setSelectedKeyState(""); + replace(`${wsPaths.inbox()}?conversation=${id}`); + }, [replace, wsPaths]); + // Shared inbox links (?issue=) may point to notifications not in this // user's inbox (archived, or never received). Fall back to the issue page // so the URL still resolves to something meaningful. But if the key was @@ -306,13 +325,14 @@ export function InboxPage() { key={`${entry.kind}:${entry.id}`} entry={entry} isSelected={ - isNotification && - (entry.notification.issue_id ?? entry.notification.id) === selectedKey + isNotification + ? (entry.notification.issue_id ?? entry.notification.id) === selectedKey + : entry.id === selectedConversationId } onSelect={() => { - // Conversations self-open the chat window in their renderer; - // notifications select into the detail pane. + // Both kinds open into the same detail pane. if (isNotification) handleSelect(entry.notification); + else selectConversation(entry.id); }} onArchive={() => { if (isNotification) handleArchive(entry.notification.id); @@ -323,7 +343,13 @@ export function InboxPage() { ); - const detailContent = selected?.issue_id ? ( + const ConversationDetailComp = getInboxItemRenderer("conversation").Detail; + const detailContent = selectedConversation && ConversationDetailComp ? ( + {}} + /> + ) : selected?.issue_id ? ( // Key by issue_id (not inbox-item id): a new comment/reaction generates a // new inbox notification for the same issue, and the dedup helper picks the // newest one — keying on its id would remount IssueDetail on every event, @@ -379,7 +405,7 @@ export function InboxPage() { } // Mobile: show detail full-screen when an item is selected - if (selected) { + if (selected || selectedConversation) { return (
diff --git a/packages/views/inbox/item-types/contract.test.ts b/packages/views/inbox/item-types/contract.test.ts index 0710399dd..568b3a186 100644 --- a/packages/views/inbox/item-types/contract.test.ts +++ b/packages/views/inbox/item-types/contract.test.ts @@ -73,7 +73,7 @@ describe("inbox item-type registry", () => { const convo = getInboxItemRenderer("conversation"); expect(typeof convo.Row).toBe("function"); - // Conversations open the chat window, so they intentionally have no pane. - expect(convo.Detail).toBeUndefined(); + // Conversations open inline in the detail pane (like an issue). + expect(typeof convo.Detail).toBe("function"); }); }); diff --git a/packages/views/inbox/item-types/conversation.tsx b/packages/views/inbox/item-types/conversation.tsx index 60a8ccd0a..92c9a9979 100644 --- a/packages/views/inbox/item-types/conversation.tsx +++ b/packages/views/inbox/item-types/conversation.tsx @@ -1,21 +1,36 @@ "use client"; +import { useEffect } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { toast } from "sonner"; import { MessageCircle } from "lucide-react"; -import { useChatStore } from "@multica/core/chat"; +import { + chatMessagesOptions, + pendingChatTaskOptions, +} from "@multica/core/chat/queries"; +import { + useSendChatMessage, + useMarkChatSessionRead, +} from "@multica/core/chat/mutations"; import { useActorName } from "@multica/core/workspace/hooks"; import { ActorAvatar } from "../../common/actor-avatar"; +import { ChatMessageList } from "../../chat/components/chat-message-list"; +import { ChatInput } from "../../chat/components/chat-input"; import { useTimeAgo } from "../components/inbox-list-item"; -import { registerInboxItemType, type InboxItemRowProps } from "./contract"; +import { + registerInboxItemType, + type InboxItemDetailProps, + type InboxItemRowProps, +} from "./contract"; /** - * Renderer for the `conversation` kind — an agent {@link ChatSession} surfaced - * inline in the inbox feed. Selecting it opens the shared chat window (Multica - * chat is a floating surface), so this kind has no detail pane of its own; the - * row is the whole renderer. + * Renderer for the `conversation` kind — an agent chat session surfaced inline + * in the inbox feed. Selecting it opens the conversation in the inbox detail + * pane (the same surface an issue notification opens into), NOT the floating + * chat window. The detail pane reuses the chat thread + composer so it stays + * in sync with the chat surface that will eventually replace the floating one. */ function ConversationRow({ entry, isSelected, onSelect }: InboxItemRowProps) { - const setActiveSession = useChatStore((s) => s.setActiveSession); - const setOpen = useChatStore((s) => s.setOpen); const { getActorName } = useActorName(); const timeAgo = useTimeAgo(); @@ -25,13 +40,7 @@ function ConversationRow({ entry, isSelected, onSelect }: InboxItemRowProps) { return (