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>
This commit is contained in:
Jiayuan
2026-06-28 12:18:58 +08:00
parent aece0c2864
commit 2d905538f6
4 changed files with 173 additions and 24 deletions

View File

@@ -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<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

View File

@@ -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=<id>) 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() {
</div>
);
const detailContent = selected?.issue_id ? (
const ConversationDetailComp = getInboxItemRenderer("conversation").Detail;
const detailContent = selectedConversation && ConversationDetailComp ? (
<ConversationDetailComp
entry={conversationEntry(selectedConversation)}
onArchive={() => {}}
/>
) : 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 (
<div className="flex flex-1 flex-col min-h-0">
<div className="flex h-12 shrink-0 items-center border-b px-2">

View File

@@ -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");
});
});

View File

@@ -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 (
<button
type="button"
onClick={() => {
// Open the conversation in the shared chat window rather than the
// inbox detail pane.
setActiveSession(session.id);
setOpen(true);
onSelect();
}}
onClick={onSelect}
className={`group flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors ${
isSelected ? "bg-accent" : "hover:bg-accent/50"
}`}
@@ -64,7 +73,69 @@ function ConversationRow({ entry, isSelected, onSelect }: InboxItemRowProps) {
);
}
function ConversationDetail({ entry }: InboxItemDetailProps) {
const session = entry.kind === "conversation" ? entry.conversation : null;
const sessionId = session?.id ?? "";
const agentId = session?.agent_id ?? "";
const { getActorName } = useActorName();
const { data: messages = [] } = useQuery(chatMessagesOptions(sessionId));
const { data: pendingTask } = useQuery(pendingChatTaskOptions(sessionId));
const sendMessage = useSendChatMessage(sessionId);
const markRead = useMarkChatSessionRead();
// Mark read when the conversation is opened in the pane (mirrors the inbox
// notification auto-mark-read). Optimistic, so it settles in one pass.
const markReadMutate = markRead.mutate;
const isUnread = entry.kind === "conversation" && entry.unread;
useEffect(() => {
if (!sessionId || !isUnread) return;
markReadMutate(sessionId);
}, [sessionId, isUnread, markReadMutate]);
if (!session) return null;
return (
<div className="flex h-full min-h-0 flex-col">
<div className="flex h-12 shrink-0 items-center gap-2.5 border-b px-4">
<ActorAvatar actorType="agent" actorId={agentId} size={24} enableHoverCard />
<div className="min-w-0">
<p className="truncate text-sm font-semibold">{session.title}</p>
<p className="truncate text-xs text-muted-foreground">
{getActorName("agent", agentId)}
</p>
</div>
</div>
<div className="min-h-0 flex-1">
<ChatMessageList
key={sessionId}
messages={messages}
pendingTask={pendingTask}
availability={undefined}
/>
</div>
<ChatInput
onSend={async (content, attachmentIds, commitInput) => {
const text = content.trim();
if (!text) return false;
commitInput?.({ clearEditor: true });
try {
await sendMessage.mutateAsync({ content: text, attachmentIds });
return true;
} catch {
toast.error("Failed to send message");
return false;
}
}}
isRunning={!!pendingTask?.task_id}
agentName={getActorName("agent", agentId)}
/>
</div>
);
}
registerInboxItemType({
kind: "conversation",
Row: ConversationRow,
Detail: ConversationDetail,
});