Files
multica/packages/views/chat/components/use-chat-controller.ts
Lambda 68cd730fec feat(chat): Chat V2 — first-class IM-style Chat tab (MUL-4171)
Replace the floating chat FAB/window with a first-class Chat tab under
Inbox, laid out as an IM-style two-pane surface (thread list + conversation).

Highlights:
- New Chat page (packages/views/chat/chat-page.tsx) with URL-addressable
  session selection; web + desktop routing wired up. Removes the old
  chat-fab / chat-window / resize-handles / context-items paths.
- IM thread list: agent avatar + last-message preview + IM timestamp, red
  unread *count* badge (read-cursor model), presence-gated typing vs waiting.
  Rename lives only in the conversation header ⋯ menu (not the list hover).
- Per-session conversation header (rename / view agent / delete), agent-aware
  empty state (avatar + name + description + starter prompts), and a
  deterministic clean-title derivation from the first message.
- Server: read-cursor unread model (migration 145) and per-user pinned agents
  (migration 146, dedicated chat_pinned_agent table + handler/queries).
  New-agent welcome chat auto-enqueues a real agent run (LLM intro, no
  static template).
- Design: fade the global --border token; borderless list headers on
  Chat/Inbox, kept (faded) on the conversation header.

Verified: pnpm typecheck (all packages), go build ./..., go vet, gofmt.
Co-authored-by: multica-agent <github@multica.ai>
2026-07-08 15:12:57 +08:00

574 lines
19 KiB
TypeScript

"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import {
useInfiniteQuery,
useQuery,
useQueryClient,
type InfiniteData,
} from "@tanstack/react-query";
import { toast } from "sonner";
import { useWorkspaceId } from "@multica/core/hooks";
import { useAuthStore } from "@multica/core/auth";
import { agentListOptions, memberListOptions } from "@multica/core/workspace/queries";
import { canAssignAgent } from "@multica/views/issues/components";
import { api } from "@multica/core/api";
import { useAgentPresenceDetail, useWorkspaceAgentAvailability } from "@multica/core/agents";
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
import {
chatSessionsOptions,
chatMessagesPageOptions,
pendingChatTaskOptions,
chatKeys,
isTaskMessageTaskId,
} from "@multica/core/chat/queries";
import {
useCreateChatSession,
useMarkChatSessionRead,
} from "@multica/core/chat/mutations";
import { useChatStore } from "@multica/core/chat";
import { createLogger } from "@multica/core/logger";
import type {
Agent,
Attachment,
ChatMessage,
ChatMessagesPage,
ChatPendingTask,
} from "@multica/core/types";
import { useT } from "../../i18n";
const uiLogger = createLogger("chat.ui");
const apiLogger = createLogger("chat.api");
// Derive a concise session title from the first user message: first line,
// markdown stripped, whitespace collapsed, capped. A deterministic title
// (no LLM) — the server has no summarization model, so this is the sensible
// default until a runtime-generated title is wired up.
const CHAT_TITLE_MAX = 30;
export function deriveChatTitle(content: string): string {
const firstLine = (content.split("\n").find((l) => l.trim()) ?? content).trim();
const cleaned = firstLine
.replace(/```[\s\S]*?```/g, " ")
.replace(/[#*`>~_]/g, "")
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1") // markdown links/images → their text
.replace(/\s+/g, " ")
.trim();
if (cleaned.length <= CHAT_TITLE_MAX) return cleaned;
return cleaned.slice(0, CHAT_TITLE_MAX - 1).trimEnd() + "…";
}
const CHAT_VIRTUOSO_INITIAL_FIRST_ITEM_INDEX = 1_000_000;
function appendChatMessageToLatestPageCache(
qc: ReturnType<typeof useQueryClient>,
sessionId: string,
message: ChatMessage,
) {
qc.setQueryData<InfiniteData<ChatMessagesPage>>(
chatKeys.messagesPage(sessionId),
(old) => {
if (!old) {
return {
pages: [{
messages: [message],
limit: 50,
has_more: false,
next_cursor: null,
}],
pageParams: [null],
};
}
if (old.pages.some((page) => page.messages.some((m) => m.id === message.id))) {
return old;
}
return {
...old,
pages: old.pages.map((page, index) =>
index === 0 ? { ...page, messages: [...page.messages, message] } : page,
),
};
},
);
}
function removeChatMessageFromPageCache(
qc: ReturnType<typeof useQueryClient>,
sessionId: string,
messageId: string,
) {
qc.setQueryData<InfiniteData<ChatMessagesPage> | undefined>(
chatKeys.messagesPage(sessionId),
(old) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page) => ({
...page,
messages: page.messages.filter((m) => m.id !== messageId),
})),
};
},
);
}
export function removeChatMessageFromCaches(
qc: ReturnType<typeof useQueryClient>,
sessionId: string,
messageId: string,
) {
qc.setQueryData<ChatMessage[]>(
chatKeys.messages(sessionId),
(old) => old?.filter((m) => m.id !== messageId) ?? old,
);
removeChatMessageFromPageCache(qc, sessionId, messageId);
}
function replaceOptimisticChatMessageId(
qc: ReturnType<typeof useQueryClient>,
sessionId: string,
optimisticId: string,
messageId: string,
taskId: string,
) {
const replace = (messages: ChatMessage[] | undefined) => {
if (!messages) return messages;
if (messages.some((m) => m.id === messageId)) {
return messages.filter((m) => m.id !== optimisticId);
}
return messages.map((m) =>
m.id === optimisticId ? { ...m, id: messageId, task_id: taskId } : m,
);
};
qc.setQueryData<ChatMessage[]>(chatKeys.messages(sessionId), replace);
qc.setQueryData<InfiniteData<ChatMessagesPage> | undefined>(
chatKeys.messagesPage(sessionId),
(old) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page) => ({
...page,
messages: replace(page.messages) ?? page.messages,
})),
};
},
);
}
/**
* Layout-agnostic chat controller. Holds every piece of chat conversation
* state and behavior — agent resolution, session lookup, the optimistic
* send/stop/cancel burst, message pagination, and auto-mark-read — so that
* both surfaces render the same conversation logic:
*
* - ChatWindow: the floating FAB overlay (adds resize / expand / minimize).
* - ChatPage: the first-class Chat tab (two-pane thread list + conversation).
*
* The only thing the caller supplies is `isActive` — whether its surface is
* currently on screen — which gates auto-mark-read so a background overlay
* doesn't silently clear unread state the user hasn't actually seen.
*/
export function useChatController(opts?: { isActive?: boolean }) {
const isActive = opts?.isActive ?? true;
const { t } = useT("chat");
const wsId = useWorkspaceId();
const activeSessionId = useChatStore((s) => s.activeSessionId);
const selectedAgentId = useChatStore((s) => s.selectedAgentId);
const setActiveSession = useChatStore((s) => s.setActiveSession);
const setSelectedAgentId = useChatStore((s) => s.setSelectedAgentId);
const user = useAuthStore((s) => s.user);
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const { data: members = [] } = useQuery(memberListOptions(wsId));
const { data: sessions = [] } = useQuery(chatSessionsOptions(wsId));
const {
data: rawMessagePages,
isLoading: messagesLoading,
fetchNextPage: fetchOlderMessages,
hasNextPage: hasOlderMessages,
isFetchingNextPage: isFetchingOlderMessages,
} = useInfiniteQuery(chatMessagesPageOptions(activeSessionId ?? ""));
const messagePages = activeSessionId ? rawMessagePages?.pages ?? [] : [];
const messages = [...messagePages].reverse().flatMap((page) => page.messages);
const olderMessageCount = messagePages
.slice(1)
.reduce((sum, page) => sum + page.messages.length, 0);
const firstItemIndex =
messages.length > 0
? CHAT_VIRTUOSO_INITIAL_FIRST_ITEM_INDEX - olderMessageCount
: 0;
const showSkeleton = !!activeSessionId && messagesLoading;
const { data: pendingTask } = useQuery(
pendingChatTaskOptions(activeSessionId ?? ""),
);
const pendingTaskId = pendingTask?.task_id ?? null;
const stopRequestedBeforeTaskRef = useRef(false);
const [restoreDraftRequest, setRestoreDraftRequest] = useState<{
id: string;
content: string;
attachments?: Attachment[];
sessionId?: string;
} | null>(null);
const handleRestoreDraftConsumed = useCallback(() => {
setRestoreDraftRequest(null);
}, []);
const currentSession = activeSessionId
? sessions.find((s) => s.id === activeSessionId)
: null;
const isSessionArchived = currentSession?.status === "archived";
const qc = useQueryClient();
const createSession = useCreateChatSession();
const markRead = useMarkChatSessionRead();
const currentMember = members.find((m) => m.user_id === user?.id);
const memberRole = currentMember?.role;
const availableAgents = agents.filter(
(a) => !a.archived_at && canAssignAgent(a, user?.id, memberRole),
);
// Resolve selected agent: stored preference → first available
const activeAgent =
availableAgents.find((a) => a.id === selectedAgentId) ??
availableAgents[0] ??
null;
const agentAvailability = useWorkspaceAgentAvailability();
const noAgent = agentAvailability === "none";
const presenceDetail = useAgentPresenceDetail(wsId, activeAgent?.id);
const availability =
presenceDetail === "loading" ? undefined : presenceDetail.availability;
// Auto mark-as-read whenever the user is actively looking at a session with
// unread state. `isActive` lets the caller say "my surface is on screen":
// the floating overlay passes `isOpen`, the tab passes `true`.
const currentHasUnread =
sessions.find((s) => s.id === activeSessionId)?.has_unread ?? false;
useEffect(() => {
if (!isActive || !activeSessionId) return;
if (!currentHasUnread) return;
uiLogger.info("auto markRead", { sessionId: activeSessionId });
markRead.mutate(activeSessionId);
// eslint-disable-next-line react-hooks/exhaustive-deps -- markRead ref stable
}, [isActive, activeSessionId, currentHasUnread]);
const { uploadWithToast } = useFileUpload(api);
const sessionPromiseRef = useRef<Promise<string | null> | null>(null);
const ensureSession = useCallback(
async (titleSeed: string): Promise<string | null> => {
if (activeSessionId) return activeSessionId;
if (!activeAgent) return null;
if (sessionPromiseRef.current) return sessionPromiseRef.current;
const promise = (async () => {
try {
const session = await createSession.mutateAsync({
agent_id: activeAgent.id,
title: deriveChatTitle(titleSeed),
});
return session.id;
} finally {
sessionPromiseRef.current = null;
}
})();
sessionPromiseRef.current = promise;
return promise;
},
[activeSessionId, activeAgent, createSession],
);
const handleUploadFile = useCallback(
async (file: File) => {
if (!activeAgent) return null;
return uploadWithToast(file);
},
[activeAgent, uploadWithToast],
);
const cancelChatTask = useCallback(
async (
taskId: string,
sessionId: string,
options: { restoreDraftToInput: boolean; source: string },
) => {
apiLogger.info("cancelTask.start", {
taskId,
sessionId,
source: options.source,
});
qc.setQueryData(chatKeys.pendingTask(sessionId), {});
try {
const result = await api.cancelTaskById(taskId);
const restored = result.cancelled_chat_message;
if (restored?.restore_to_input) {
removeChatMessageFromCaches(qc, restored.chat_session_id, restored.message_id);
if (options.restoreDraftToInput && restored.chat_session_id === sessionId) {
setRestoreDraftRequest({
id: restored.message_id,
content: restored.content,
attachments: restored.attachments,
sessionId: restored.chat_session_id,
});
}
}
qc.invalidateQueries({ queryKey: chatKeys.messages(sessionId) });
qc.invalidateQueries({ queryKey: chatKeys.messagesPage(sessionId) });
apiLogger.info("cancelTask.success", {
taskId,
sessionId,
restoredToInput: !!restored?.restore_to_input && options.restoreDraftToInput,
});
return result;
} catch (err) {
apiLogger.warn("cancelTask.error (task may have already finished)", {
taskId,
sessionId,
err,
});
qc.invalidateQueries({ queryKey: chatKeys.messages(sessionId) });
qc.invalidateQueries({ queryKey: chatKeys.messagesPage(sessionId) });
return null;
}
},
[qc],
);
const handleSend = useCallback(
async (
content: string,
attachmentIds?: string[],
commitInput?: (options?: { extraDraftKeys?: string[]; clearEditor?: boolean }) => void,
draftAttachments: Attachment[] = [],
): Promise<boolean> => {
if (!activeAgent) {
apiLogger.warn("sendChatMessage skipped: no active agent");
return false;
}
const finalContent = content;
const isNewSession = !activeSessionId;
apiLogger.info("sendChatMessage.start", {
sessionId: activeSessionId,
isNewSession,
agentId: activeAgent.id,
contentLength: finalContent.length,
attachmentCount: attachmentIds?.length ?? 0,
});
let sessionId: string | null = null;
try {
sessionId = await ensureSession(finalContent);
} catch (err) {
apiLogger.error("sendChatMessage.ensureSession.error", err);
toast.error(t(($) => $.input.send_failed_toast));
return false;
}
if (!sessionId) {
apiLogger.warn("sendChatMessage aborted: ensureSession returned null");
return false;
}
const sentAt = new Date().toISOString();
const optimistic: ChatMessage = {
id: `optimistic-${Date.now()}`,
chat_session_id: sessionId,
role: "user",
content: finalContent,
task_id: null,
created_at: sentAt,
attachments: draftAttachments,
};
appendChatMessageToLatestPageCache(qc, sessionId, optimistic);
qc.setQueryData<ChatMessage[]>(
chatKeys.messages(sessionId),
(old) => (old ? [...old, optimistic] : [optimistic]),
);
qc.setQueryData<ChatPendingTask>(chatKeys.pendingTask(sessionId), {
task_id: `optimistic-${optimistic.id}`,
status: "queued",
created_at: sentAt,
});
const live = useChatStore.getState();
const stillOnSourceSession =
live.activeSessionId === activeSessionId &&
(activeSessionId !== null || live.selectedAgentId === selectedAgentId);
if (stillOnSourceSession) {
setActiveSession(sessionId);
}
commitInput?.({ extraDraftKeys: [sessionId], clearEditor: stillOnSourceSession });
apiLogger.debug("sendChatMessage.optimistic", { sessionId, optimisticId: optimistic.id });
let result;
try {
result = await api.sendChatMessage(sessionId, finalContent, attachmentIds);
} catch (err) {
apiLogger.error("sendChatMessage.error.rollback", { sessionId, optimisticId: optimistic.id, err });
stopRequestedBeforeTaskRef.current = false;
removeChatMessageFromCaches(qc, sessionId, optimistic.id);
qc.setQueryData(chatKeys.pendingTask(sessionId), {});
setRestoreDraftRequest({
id: `send-failed-${optimistic.id}`,
content: finalContent,
attachments: draftAttachments,
sessionId,
});
toast.error(t(($) => $.input.send_failed_toast));
return false;
}
apiLogger.info("sendChatMessage.success", {
sessionId,
messageId: result.message_id,
taskId: result.task_id,
});
replaceOptimisticChatMessageId(qc, sessionId, optimistic.id, result.message_id, result.task_id);
qc.setQueryData<ChatPendingTask>(chatKeys.pendingTask(sessionId), {
task_id: result.task_id,
status: "queued",
created_at: result.created_at,
});
if (stopRequestedBeforeTaskRef.current) {
stopRequestedBeforeTaskRef.current = false;
await cancelChatTask(result.task_id, sessionId, {
restoreDraftToInput: true,
source: "deferred-send",
});
return false;
}
if (attachmentIds && attachmentIds.length > 0 && result.attachment_ids) {
const boundIds = new Set(result.attachment_ids);
const missing = attachmentIds.filter((id) => !boundIds.has(id));
if (missing.length > 0) {
apiLogger.warn("sendChatMessage.attachments missing after send", {
sessionId,
messageId: result.message_id,
missing,
});
toast.error(t(($) => $.input.attachment_bind_failed_toast));
}
}
qc.invalidateQueries({ queryKey: chatKeys.messages(sessionId) });
qc.invalidateQueries({ queryKey: chatKeys.messagesPage(sessionId) });
return true;
},
[
activeSessionId,
selectedAgentId,
activeAgent,
ensureSession,
cancelChatTask,
qc,
setActiveSession,
t,
],
);
const handleStop = useCallback(() => {
if (!pendingTaskId || !activeSessionId) {
apiLogger.debug("cancelTask skipped: no pending task");
return;
}
if (!isTaskMessageTaskId(pendingTaskId)) {
stopRequestedBeforeTaskRef.current = true;
apiLogger.info("cancelTask.deferred until server task id", {
taskId: pendingTaskId,
sessionId: activeSessionId,
});
return;
}
void cancelChatTask(pendingTaskId, activeSessionId, {
restoreDraftToInput: true,
source: "active-input",
});
}, [pendingTaskId, activeSessionId, cancelChatTask]);
const handleNewChat = useCallback(() => {
uiLogger.info("newChat", {
previousSessionId: activeSessionId,
previousPendingTask: pendingTaskId,
});
setActiveSession(null);
}, [activeSessionId, pendingTaskId, setActiveSession]);
// Start a fresh chat bound to a chosen agent. Unlike handleSelectAgent this
// does not no-op when the agent is unchanged — "new chat" always clears the
// active session so the user lands on an empty compose for that agent. The
// session row is created lazily on the first send (see ensureSession).
const handleStartNewChat = useCallback(
(agent: Agent) => {
uiLogger.info("startNewChat", {
agentId: agent.id,
previousSessionId: activeSessionId,
});
setSelectedAgentId(agent.id);
setActiveSession(null);
},
[activeSessionId, setSelectedAgentId, setActiveSession],
);
const handleSelectSession = useCallback(
(session: { id: string; agent_id: string }) => {
// Sessions are bound 1:1 to an agent — picking a session from a
// different agent implicitly switches the agent too.
if (activeAgent && session.agent_id !== activeAgent.id) {
uiLogger.info("selectSession (cross-agent)", {
from: activeAgent.id,
toAgent: session.agent_id,
toSession: session.id,
});
setSelectedAgentId(session.agent_id);
}
setActiveSession(session.id);
},
[activeAgent, setSelectedAgentId, setActiveSession],
);
const hasMessages = messages.length > 0 || !!pendingTaskId;
return {
// identity / lists
wsId,
user,
agents,
availableAgents,
sessions,
activeSessionId,
selectedAgentId,
currentSession,
isSessionArchived,
activeAgent,
noAgent,
availability,
// messages
messages,
pendingTask,
pendingTaskId,
showSkeleton,
hasMessages,
firstItemIndex,
hasOlderMessages: !!hasOlderMessages,
isFetchingOlderMessages,
fetchOlderMessages,
// draft restore
restoreDraftRequest,
handleRestoreDraftConsumed,
// actions
handleSend,
handleStop,
handleUploadFile,
handleNewChat,
handleStartNewChat,
handleSelectSession,
// store setters (for surfaces that sync selection to the URL, etc.)
setActiveSession,
setSelectedAgentId,
};
}
export type ChatController = ReturnType<typeof useChatController>;