mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user