Files
multica/packages/views/chat/components/use-chat-controller.ts
Naiyuan Qing 7d2f20f2ee feat(chat): warn when agent daemon is too old for project context (#5867)
Chat project context (MUL-5150, #5765) is rendered into the run brief by the
daemon, so daemons older than the release that ships it silently drop the
project description while still honoring the server-extracted repos. Mirror
the handoff-note soft gate: resolve the active agent's runtime cli_version
from the warm runtime cache and, when it is a stale release build, surface a
warning next to the composer chip and inside the project submenu. Selection
is never blocked — dev-describe builds and unknown runtimes stay silent.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 13:32:44 +08:00

889 lines
34 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 { projectListOptions } from "@multica/core/projects/queries";
import { canAssignAgent } from "@multica/views/issues/components";
import { api, dispatchReasonCode } from "@multica/core/api";
import { useAgentPresenceDetail, useWorkspaceAgentAvailability } from "@multica/core/agents";
// Direct module path, not the `../../editor` barrel: this controller is
// headless, and the barrel would pull the whole Tiptap tree in behind it.
import { useEditorUpload } from "../../editor/use-editor-upload";
import {
chatSessionsOptions,
chatMessagesPageOptions,
pendingChatTaskOptions,
chatKeys,
isTaskMessageTaskId,
sortChatSessions,
} from "@multica/core/chat/queries";
import {
useCreateChatSession,
useMarkChatSessionRead,
useSetChatSessionProject,
useSetChatSessionArchived,
} from "@multica/core/chat/mutations";
import { useChatStore } from "@multica/core/chat";
import { removeChatMessageFromCaches } from "@multica/core/realtime";
import { useChatDraftRestore } from "./use-chat-draft-restore";
import { useChatProjectContextSupport } from "./use-chat-project-context-support";
import { createLogger } from "@multica/core/logger";
import type {
Agent,
Attachment,
ChatMessage,
ChatMessagesPage,
ChatPendingTask,
} from "@multica/core/types";
import { useT } from "../../i18n";
import { useAppForeground } from "../../common/use-app-foreground";
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() + "…";
}
/**
* After a send resolves: is the user still composing to the target they sent
* from? Decides whether to scrub the composer and open the sent session, or
* treat the send as fire-and-forget (the reply surfaces as unread instead).
*
* The active session answers this on its own, deliberately. The new-chat
* composer is ONE box per workspace (see DRAFT_NEW_SESSION), so moving the
* agent picker re-points where the next send goes without moving the view or
* the draft slot — that is not "navigating away" (MUL-4864). Counting it as
* such would leave a completed send's text sitting in the composer, primed to
* be sent a second time to the agent just picked.
*
* Shared by both send chains — the chat tab's controller and the floating
* ChatWindow — so the rule cannot drift between the two surfaces.
*/
export function isStillOnComposeTarget(
liveActiveSessionId: string | null,
sentFromSessionId: string | null,
): boolean {
return liveActiveSessionId === sentFromSessionId;
}
/**
* Decide what a project-context change should do, given the open session.
*
* - `awaitSession`: an active session id is set but its row has not loaded
* yet. Bail so a persisted selection resolving before its sessions query
* cannot misfile a project change into the new-chat draft.
* - `detachCurrent`: removing context from the open session — safe in place,
* it only changes what future turns receive.
* - `startFreshChat`: switching to a DIFFERENT project. A fresh chat is
* started so the old project's provider memory / reused workdir cannot
* bleed in. It must stay bound to the agent whose session we are leaving
* (`agentId`): clearing the active session otherwise drops selection back
* to the stored `selectedAgentId`, which can be a stale preference for a
* different agent, sending the lazily-created session to the wrong agent.
* - `setDraftProject`: no open session, so this only adjusts the new-chat
* draft's project.
*
* Shared by both send chains — the chat tab's controller and the floating
* ChatWindow — so the stale-agent rule cannot drift between the two surfaces.
*/
export type ProjectContextChange =
| { kind: "awaitSession" }
| { kind: "detachCurrent"; sessionId: string }
| { kind: "startFreshChat"; agentId: string; projectId: string }
| { kind: "setDraftProject"; projectId: string | null };
export function planProjectContextChange(input: {
targetProjectId: string | null;
activeSessionId: string | null;
currentSession: { id: string; agent_id: string } | null;
}): ProjectContextChange {
if (input.activeSessionId) {
if (!input.currentSession) return { kind: "awaitSession" };
if (input.targetProjectId === null) {
return { kind: "detachCurrent", sessionId: input.currentSession.id };
}
return {
kind: "startFreshChat",
agentId: input.currentSession.agent_id,
projectId: input.targetProjectId,
};
}
return { kind: "setDraftProject", projectId: input.targetProjectId };
}
// True when a session has an in-flight optimistic write — an `optimistic-`
// message or a pending task in the cache. That is the signal of a just-created
// (or actively-sending) session still awaiting server confirmation, before the
// sessions-list refetch includes it. Deliberately NOT "has cached messages": a
// session deleted elsewhere can still have real cached history, which must not
// exempt it from the stale-session self-heal.
export function hasOptimisticInFlight(
qc: ReturnType<typeof useQueryClient>,
sessionId: string,
): boolean {
const pending = qc.getQueryData<ChatPendingTask>(chatKeys.pendingTask(sessionId));
if (pending?.task_id) return true;
const flat = qc.getQueryData<ChatMessage[]>(chatKeys.messages(sessionId));
if (flat?.some((m) => m.id.startsWith("optimistic-"))) return true;
const paged = qc.getQueryData<InfiniteData<ChatMessagesPage>>(
chatKeys.messagesPage(sessionId),
);
return Boolean(
paged?.pages.some((page) =>
page.messages.some((m) => m.id.startsWith("optimistic-")),
),
);
}
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 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 selectedProjectId = useChatStore((s) => s.selectedProjectId);
const setActiveSession = useChatStore((s) => s.setActiveSession);
const setSelectedAgentId = useChatStore((s) => s.setSelectedAgentId);
const setSelectedProjectId = useChatStore((s) => s.setSelectedProjectId);
const user = useAuthStore((s) => s.user);
const { data: agents = [], isSuccess: agentsLoaded } = useQuery(
agentListOptions(wsId),
);
const { data: members = [], isSuccess: membersLoaded } = useQuery(
memberListOptions(wsId),
);
const { data: sessions = [], isSuccess: sessionsLoaded } = useQuery(
chatSessionsOptions(wsId),
);
const { data: projects = [], isSuccess: projectsLoaded } = useQuery(
projectListOptions(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);
// Durable deferred-cancellation draft restores (#5219). The whole lifecycle —
// fetch, offer, skip-and-re-offer, apply, consume, reconcile — lives in this
// hook, shared with the floating chat window.
//
// Gated on isActive AND app foreground: a backgrounded browser tab still renders
// this controller, and it must not fetch/apply/consume a restore the user is
// waiting on in a foreground surface. It recovers on its next fetch once the
// surface is on screen and the app is refocused. (appForeground also gates auto
// mark-read below.)
const appForeground = useAppForeground();
const { restoreDraftRequest, enqueueLocalRestore, handleRestoreDraftApplied } =
useChatDraftRestore(activeSessionId, isActive && appForeground);
// Nonce handed to ChatInput to pull focus into the compose box when a new
// chat starts. Bumped by handleNewChat / handleStartNewChat only, so
// selecting an existing chat or a deep link never steals focus.
const [focusInputRequest, setFocusInputRequest] = useState(0);
const requestInputFocus = useCallback(
() => setFocusInputRequest((n) => n + 1),
[],
);
const currentSession = activeSessionId
? sessions.find((s) => s.id === activeSessionId)
: null;
const isSessionArchived = currentSession?.status === "archived";
const candidateProjectId = currentSession
? currentSession.project_id ?? null
: selectedProjectId;
const activeProjectId = candidateProjectId &&
(!projectsLoaded || projects.some((project) => project.id === candidateProjectId))
? candidateProjectId
: null;
// A project may be deleted on another client while this workspace's next
// chat preference is still persisted locally. Normalize it as soon as the
// authoritative project list settles so a future send cannot carry a stale
// selection.
useEffect(() => {
if (!projectsLoaded || !selectedProjectId) return;
if (projects.some((project) => project.id === selectedProjectId)) return;
setSelectedProjectId(null);
}, [projectsLoaded, projects, selectedProjectId, setSelectedProjectId]);
const qc = useQueryClient();
const createSession = useCreateChatSession();
const markRead = useMarkChatSessionRead();
const setSessionProject = useSetChatSessionProject();
const setArchived = useSetChatSessionArchived();
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),
);
// `availableAgents` is only trustworthy once BOTH queries above succeeded:
// the permission filter reads the member role, so agents-without-members
// misreports a public_to agent as unavailable. Consumers that must tell
// "still loading" apart from "settled and not available" (the `?agent=`
// deep link) gate on this instead of sniffing list emptiness. Query errors
// deliberately keep this false — a failed fetch is not a permission verdict.
const agentsSettled = agentsLoaded && membersLoaded;
// The agent bound to the OPEN session, resolved from the full agent list
// (archived included, since agentListOptions passes include_archived). An
// archived agent is filtered out of `availableAgents`, so resolving the
// active agent only from that list would make an archived-agent session
// silently render some *other* available agent — wrong avatar/name/presence
// in the header, and a send that targets the wrong agent. Binding to the
// session's real agent keeps the conversation honest; the archived state
// then makes it read-only (see isAgentArchived).
const sessionAgent = currentSession
? agents.find((a) => a.id === currentSession.agent_id) ?? null
: null;
const isAgentArchived = !!sessionAgent?.archived_at;
// Resolve selected agent: open session's agent → stored preference → first
// available. New chats have no session, so they fall through to the picker.
const activeAgent =
sessionAgent ??
availableAgents.find((a) => a.id === selectedAgentId) ??
availableAgents[0] ??
null;
const agentAvailability = useWorkspaceAgentAvailability();
const noAgent = agentAvailability === "none";
const projectContextSupport = useChatProjectContextSupport(wsId, activeAgent);
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`. `appForeground`
// additionally requires the window to be visible and focused: a reply landing
// while the app is backgrounded must stay unread so the sidebar badges it
// (MUL-4485); it clears the moment the user returns and this effect re-runs.
//
// The read is deferred by a tick and cancelled on cleanup, so a session that
// is only *momentarily* active never gets marked read. This is the fix for
// MUL-4360's mount race: `activeSessionId` is persisted, so on a bare `/chat`
// navigation the page restores the last session for one frame before its
// URL→store effect (which runs AFTER this hook's effects, since the hook is
// called first) clears it back to null. Without the defer, that restored-but-
// never-opened session was marked read in that gap — its badge vanished
// though the user never opened it (right pane still shows "select a chat").
// Deferring lets the subsequent activeSessionId change cancel the pending
// read via cleanup; the store re-check is a belt-and-suspenders guard. Only a
// session that stays active past the tick — a real select, deep link, or
// refresh — is read.
const currentHasUnread =
sessions.find((s) => s.id === activeSessionId)?.has_unread ?? false;
useEffect(() => {
if (!isActive || !appForeground || !activeSessionId) return;
if (!currentHasUnread) return;
const sessionId = activeSessionId;
const timer = setTimeout(() => {
if (useChatStore.getState().activeSessionId !== sessionId) return;
uiLogger.info("auto markRead", { sessionId });
markRead.mutate(sessionId);
}, 0);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps -- markRead ref stable
}, [isActive, appForeground, activeSessionId, currentHasUnread]);
const { uploadWithToast } = useEditorUpload();
const sessionPromiseRef = useRef<Promise<string | null> | null>(null);
const ensureSession = useCallback(
async (titleSeed: string): Promise<string | null> => {
// Trust the current session id only when it's real: present in the
// loaded list, or a just-created one still awaiting the list refetch
// (has an optimistic write). A dangling id (deleted / no access) must not
// be treated as an existing session — fall through and create a fresh one
// so the message lands somewhere instead of POSTing into a 404.
if (
activeSessionId &&
(!sessionsLoaded ||
sessions.some((s) => s.id === activeSessionId) ||
hasOptimisticInFlight(qc, 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),
project_id: activeProjectId,
});
return session.id;
} finally {
sessionPromiseRef.current = null;
}
})();
sessionPromiseRef.current = promise;
return promise;
},
[
activeSessionId,
activeAgent,
activeProjectId,
createSession,
sessions,
sessionsLoaded,
qc,
],
);
// Self-heal a dangling `activeSessionId`. Once the sessions list has loaded
// and it isn't in the list — with no in-flight optimistic write exempting a
// just-created session — the id was deleted, lost access, or never existed
// (a stale `?session=` deep link, or a persisted floating-window selection).
// Clearing it stops BOTH surfaces (the tab and the floating window) from
// rendering an editable empty chat whose send would POST into a nonexistent
// session. Lives in the shared controller so every surface self-heals.
useEffect(() => {
if (!activeSessionId || !sessionsLoaded) return;
if (sessions.some((s) => s.id === activeSessionId)) return;
if (hasOptimisticInFlight(qc, activeSessionId)) return;
uiLogger.info("clearing dangling activeSessionId", { sessionId: activeSessionId });
setActiveSession(null);
}, [activeSessionId, sessionsLoaded, sessions, qc, setActiveSession]);
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) {
enqueueLocalRestore({
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, enqueueLocalRestore],
);
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;
}
// Read-only conversation: the agent is retired and can no longer pick up
// work, so refuse to enqueue a task that would sit orphaned forever. The
// input is disabled in this state; this is the belt-and-braces guard.
if (isAgentArchived) {
apiLogger.warn("sendChatMessage skipped: agent is archived", {
sessionId: activeSessionId,
agentId: activeAgent.id,
});
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);
// A revoked invoke permission blocks session create with a structured
// 403 (MUL-4525) — name the cause instead of a generic failure.
toast.error(
dispatchReasonCode(err) === "invocation_not_allowed"
? t(($) => $.input.send_blocked_toast)
: 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,
});
// Cache primed → safe to publish the new active session, but only if the
// user hasn't navigated away mid-send. See isStillOnComposeTarget.
const live = useChatStore.getState();
const stillOnSourceSession = isStillOnComposeTarget(live.activeSessionId, activeSessionId);
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), {});
enqueueLocalRestore({
id: `send-failed-${optimistic.id}`,
content: finalContent,
attachments: draftAttachments,
sessionId,
});
// Invoke permission can be revoked mid-session; the send is refused with
// a structured 403 before anything persists (MUL-4525). Surface the
// specific cause so the user knows it is a permission change, not a
// transient failure they should retry.
toast.error(
dispatchReasonCode(err) === "invocation_not_allowed"
? t(($) => $.input.send_blocked_toast)
: 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,
activeAgent,
isAgentArchived,
ensureSession,
cancelChatTask,
qc,
setActiveSession,
enqueueLocalRestore,
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,
});
// A fresh chat has no project unless the user explicitly chooses one.
// The open session's project is server-owned history, not a default for
// the next session.
setSelectedProjectId(null);
setActiveSession(null);
requestInputFocus();
}, [
activeSessionId,
pendingTaskId,
setSelectedProjectId,
setActiveSession,
requestInputFocus,
]);
// 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);
setSelectedProjectId(null);
setActiveSession(null);
requestInputFocus();
},
[
activeSessionId,
setSelectedAgentId,
setSelectedProjectId,
setActiveSession,
requestInputFocus,
],
);
const handleSelectSession = useCallback(
(session: { id: string; agent_id: string; project_id?: string | null }) => {
// 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 handleProjectChange = useCallback(
(projectId: string | null) => {
if (projectId === activeProjectId) return;
uiLogger.info("selectProjectContext", {
from: activeProjectId,
to: projectId,
previousSessionId: activeSessionId,
});
const plan = planProjectContextChange({
targetProjectId: projectId,
activeSessionId,
currentSession: currentSession ?? null,
});
switch (plan.kind) {
case "awaitSession":
return;
case "detachCurrent":
setSessionProject.mutate({ sessionId: plan.sessionId, projectId: null });
break;
case "startFreshChat":
setSelectedAgentId(plan.agentId);
setSelectedProjectId(plan.projectId);
setActiveSession(null);
break;
case "setDraftProject":
setSelectedProjectId(plan.projectId);
break;
}
requestInputFocus();
}, [
activeProjectId,
activeSessionId,
currentSession,
setSessionProject,
setSelectedAgentId,
setSelectedProjectId,
setActiveSession,
requestInputFocus,
],
);
// Archiving the chat currently in view would otherwise strand the
// conversation pane on a now read-only, "dangling" session. Mirror the Inbox
// list: advance selection to the next chat in the (sorted, non-archived)
// history, fall back to the previous one, and clear only when nothing is
// left. Routing the non-null advance through handleSelectSession keeps
// selectedAgentId in sync, so a follow-up "new chat" still defaults to the
// right agent even when the next chat belongs to a different agent. A no-op
// when the archived session isn't the open one — that selection stays put.
const advanceSelectionAfterArchive = useCallback(
(session: { id: string; agent_id: string }) => {
if (activeSessionId !== session.id) return;
const history = sortChatSessions(
sessions.filter((s) => s.status !== "archived"),
);
const idx = history.findIndex((s) => s.id === session.id);
const next = history[idx + 1] ?? history[idx - 1] ?? null;
if (next) handleSelectSession(next);
else setActiveSession(null);
},
[activeSessionId, sessions, handleSelectSession, setActiveSession],
);
const archiveSession = useCallback(
(sessionId: string) => setArchived.mutate({ sessionId, archived: true }),
[setArchived],
);
const hasMessages = messages.length > 0 || !!pendingTaskId;
return {
// identity / lists
wsId,
user,
agents,
availableAgents,
agentsSettled,
sessions,
projects,
activeSessionId,
selectedAgentId,
activeProjectId,
projectContextUnsupported: projectContextSupport === false,
isProjectUpdating:
setSessionProject.isPending || (!!activeSessionId && !currentSession),
currentSession,
isSessionArchived,
isAgentArchived,
activeAgent,
noAgent,
availability,
// messages
messages,
pendingTask,
pendingTaskId,
showSkeleton,
hasMessages,
firstItemIndex,
hasOlderMessages: !!hasOlderMessages,
isFetchingOlderMessages,
fetchOlderMessages,
// draft restore
restoreDraftRequest,
handleRestoreDraftApplied,
// compose-box focus nonce (bumped on new chat)
focusInputRequest,
// actions
handleSend,
handleStop,
handleUploadFile,
handleNewChat,
handleStartNewChat,
handleSelectSession,
handleProjectChange,
advanceSelectionAfterArchive,
archiveSession,
// store setters (for surfaces that sync selection to the URL, etc.)
setActiveSession,
setSelectedAgentId,
};
}
export type ChatController = ReturnType<typeof useChatController>;