Files
multica/packages/views/chat/components/use-chat-controller.ts
Naiyuan Qing c7a8b1699f fix(chat): route settled chat messages through one cache door (MUL-5711) (#6559)
* fix(chat): route settled chat messages through one cache door (MUL-5711)

A human's own chat message was the only row in the transcript that no
WebSocket handler ever wrote into the cache. `chat:message` carries the
whole message (id, role, content, task_id, created_at) but the handler
read `chat_session_id` off it and dropped the rest, so a user's prompt
reached the UI solely through the refetch that invalidate scheduled —
while the agent's reply is inline-inserted by `chat:done` and is never at
risk. Any client that did not write the message locally (a second window
or device, a send whose HTTP response failed after the server committed,
a surface mounting mid-flight) lost it whenever that refetch was dropped.

The refetch is dropped by the quick-actions supplement: it cancels the
in-flight messages fetch before patching, and `cancelQueries` defaults to
`revert: true`, so the cache is rolled BACK to the snapshot taken when
that fetch started rather than merely left alone. Both message caches are
`staleTime: Infinity` with no further trigger, so the hole survived until
a remount — which is why a refresh always brought the message back.

- Add `upsertChatMessageToCaches` in core: one door for settled messages,
  writing both the flat and paged caches, idempotent by `message.id`.
  On a repeat the cached row wins every field it defines and the incoming
  row fills only what is missing, so a send response carrying attachments
  and its attachment-less `chat:message` echo converge in either arrival
  order. Merges happen in place; only a genuinely new message is appended,
  and only to the newest page.
- Route all five writers through it: `chat:message` (now writes the user
  turn instead of discarding the payload), `chat:done`, the chat page
  send, the floating window send, and the Agent Builder send — which
  wrote the flat cache only, leaving the paged cache the chat surfaces
  read without the message.
- `applyChatQuickActionsToCache` re-invalidates after its cancel+patch.
  This cannot lose the pills: the server persists the actions before
  broadcasting the event (SupplementChatQuickActions).
- Drop the two copied `appendChatMessageToLatestPageCache` helpers and
  the now-unused `patchLatestChatMessagePage`.

The virtual-list / `firstItemIndex` change from the earlier attempt is
deliberately NOT here — it is a separate defect (scroll drift, MUL-1613)
and gets its own PR.

Verified: pnpm typecheck (6/6), pnpm lint (no new problems), pnpm test —
core 1338, views 3718, desktop 442, web 188. The quick-actions regression
test fails with the re-invalidate removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(agents): clear the paged message cache in Builder cleanup (MUL-5711 review)

Routing the Agent Builder send through upsertChatMessageToCaches made it
seed `chatKeys.messagesPage` as well as the flat cache, but its cleanup
paths still only knew about the flat one. `messagesPage` is
staleTime: Infinity, so anything left there stays fresh forever and any
chat surface that later opens the same session reads it instead of the
server — the same "one cache updated, the other drifts permanently" shape
this PR exists to remove.

- destroy: remove the paged cache alongside the flat one, so a discarded
  conversation cannot leave its transcript behind.
- stop: invalidate the paged cache on both the success and the failure
  path. A failed cancel may still have landed server-side, so the failure
  path now re-reads the messages too instead of only the pending marker.
- stop: when the cancel reports a restored prompt, drop that message from
  both caches before reconciling — same order as the chat surfaces'
  cancelChatTask. The server deleted that row; without this the paged
  cache keeps serving it.

Verified: pnpm typecheck (6/6), pnpm lint (no new problems), pnpm test —
core 1338, views 3722, desktop 442, web 188, docs 17. The three cleanup
tests fail with this commit's fix reverted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-07 14:59:28 +08:00

833 lines
32 KiB
TypeScript

"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import {
useInfiniteQuery,
useQuery,
useQueryClient,
} 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 {
isAgentRuntimeBound as hasAgentRuntime,
useAgentPresenceDetail,
useWorkspaceAgentAvailability,
} from "@multica/core/agents";
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 { upsertChatMessageToCaches } from "@multica/core/chat/message-cache";
import {
enqueuePendingChatTask,
hideQueuedChatMessages,
} from "@multica/core/chat/pending";
import { useChatDraftRestore } from "./use-chat-draft-restore";
import { useChatTaskActions } from "./use-chat-task-actions";
import { useChatProjectContextSupport } from "./use-chat-project-context-support";
import { createLogger } from "@multica/core/logger";
import type {
Agent,
Attachment,
ChatMessage,
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 pending task in the cache — the signal
// of a just-created (or actively-sending) session still awaiting server
// confirmation, before the sessions-list refetch includes it. `handleSend`
// seeds this task from the server response the instant the send is accepted, so
// it is populated before `setActiveSession` publishes the new session.
// 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 hasInFlightPendingTask(
qc: ReturnType<typeof useQueryClient>,
sessionId: string,
): boolean {
const pending = qc.getQueryData<ChatPendingTask>(chatKeys.pendingTask(sessionId));
return Boolean(pending?.task_id);
}
export function seedAcceptedPendingTask(
qc: ReturnType<typeof useQueryClient>,
sessionId: string,
task: {
task_id: string;
created_at: string;
message_id: string;
content: string;
supports_queue?: boolean;
queued?: boolean;
},
) {
qc.setQueryData<ChatPendingTask>(
chatKeys.pendingTask(sessionId),
(old) => {
const next = enqueuePendingChatTask(old, {
task_id: task.task_id,
status: "queued",
created_at: task.created_at,
message_id: task.message_id,
content: task.content,
}, task.queued);
if (task.supports_queue === true || old?.supports_queue === true) {
next.supports_queue = true;
}
return next;
},
);
qc.invalidateQueries({ queryKey: chatKeys.pendingTask(sessionId) });
}
const CHAT_VIRTUOSO_INITIAL_FIRST_ITEM_INDEX = 1_000_000;
/**
* Layout-agnostic chat controller. Holds every piece of chat conversation
* state and behavior — agent resolution, session lookup, the await-then-render
* send/stop/cancel flow, 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 allMessages = [...messagePages].reverse().flatMap((page) => page.messages);
const { data: pendingTask, isLoading: pendingTaskLoading } = useQuery(
pendingChatTaskOptions(activeSessionId ?? ""),
);
const showSkeleton =
!!activeSessionId && (messagesLoading || pendingTaskLoading);
const messages = hideQueuedChatMessages(allMessages, pendingTask);
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 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);
const {
cancelChatTask,
handleEditQueuedTask,
handleRemoveQueuedTask,
handleClearQueuedTasks,
handleSendQueuedTaskNow,
} = useChatTaskActions(activeSessionId, enqueueLocalRestore);
// 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 isAgentRuntimeBound = !!activeAgent && hasAgentRuntime(activeAgent);
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 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) ||
hasInFlightPendingTask(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 (hasInFlightPendingTask(qc, activeSessionId)) return;
uiLogger.info("clearing dangling activeSessionId", { sessionId: activeSessionId });
setActiveSession(null);
}, [activeSessionId, sessionsLoaded, sessions, qc, setActiveSession]);
// Upload transport moved into the coordinated-upload engine inside ChatInput
// (MUL-5181 L2); surfaces only forward whether the affordance exists.
const uploadEnabled = !!activeAgent;
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;
}
if (pendingTaskId && pendingTask?.supports_queue !== true) {
apiLogger.warn("sendChatMessage skipped: server does not support follow-up queues", {
sessionId: activeSessionId,
});
return false;
}
if (!isAgentRuntimeBound) {
toast.error(t(($) => $.input.runtime_required_toast));
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.
const reason = dispatchReasonCode(err);
toast.error(
reason === "invocation_not_allowed"
? t(($) => $.input.send_blocked_toast)
: reason === "agent_runtime_required"
? t(($) => $.input.runtime_required_toast)
: t(($) => $.input.send_failed_toast),
);
return false;
}
if (!sessionId) {
apiLogger.warn("sendChatMessage aborted: ensureSession returned null");
return false;
}
// Await-then-render: the composer keeps the user's text and attachments
// in place (editor locked, button spinning via `submitting`) until the
// server accepts the send. Nothing is written into the caches, and the
// draft is never cleared, before the roundtrip settles — a slow send never
// reads as "posted but the box is still full", and a rejected one keeps
// the draft for retry (ChatInput never cleared it).
let result;
try {
result = await api.sendChatMessage(sessionId, finalContent, attachmentIds);
} catch (err) {
apiLogger.error("sendChatMessage.error", { sessionId, err });
// 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.
const reason = dispatchReasonCode(err);
toast.error(
reason === "invocation_not_allowed"
? t(($) => $.input.send_blocked_toast)
: reason === "agent_runtime_required"
? t(($) => $.input.runtime_required_toast)
: t(($) => $.input.send_failed_toast),
);
return false;
}
apiLogger.info("sendChatMessage.success", {
sessionId,
messageId: result.message_id,
taskId: result.task_id,
});
// Render the accepted message from the server response. Prime the message
// caches BEFORE publishing the session so the first useQuery read after
// activeSessionId flips hits data synchronously (no new-chat skeleton
// flash), and seed the pending task with the server's real id and
// created_at so the StatusPill mounts anchored to the true clock and the
// stale-session self-heal exempts this just-created session until the
// sessions-list refetch includes it.
const sent: ChatMessage = {
id: result.message_id,
chat_session_id: sessionId,
role: "user",
content: finalContent,
task_id: result.task_id,
created_at: result.created_at,
attachments: draftAttachments,
};
// Single door into the message caches (MUL-5711): idempotent by id, so
// this row and the chat:message echo of the same send converge in either
// arrival order, and this richer row (it carries the draft attachments)
// is never downgraded by the echo, which has no attachments field.
upsertChatMessageToCaches(qc, sessionId, sent, { seedIfMissing: true });
seedAcceptedPendingTask(qc, sessionId, {
task_id: result.task_id,
created_at: result.created_at,
message_id: result.message_id,
content: finalContent,
supports_queue: result.supports_queue,
queued: result.queued,
});
// Cache primed → publish the new active session, but only if the user
// hasn't navigated away mid-send. See isStillOnComposeTarget. commitInput
// clears the sent draft, and scrubs the shared editor only when the user
// is still on the session they sent from.
const live = useChatStore.getState();
const stillOnSourceSession = isStillOnComposeTarget(live.activeSessionId, activeSessionId);
if (stillOnSourceSession) {
setActiveSession(sessionId);
}
commitInput?.({ extraDraftKeys: [sessionId], clearEditor: stillOnSourceSession });
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,
pendingTask,
pendingTaskId,
isAgentRuntimeBound,
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,
});
// 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,
isAgentRuntimeBound,
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,
handleSendQueuedTaskNow,
handleEditQueuedTask,
handleRemoveQueuedTask,
handleClearQueuedTasks,
uploadEnabled,
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>;