Files
multica/packages/views/chat/components/use-chat-controller.ts
Jiayuan Zhang a3fe6d91dd MUL-5150: add project context to Chat (#5765)
* feat(chat): add project context

Co-authored-by: multica-agent <github@multica.ai>

* fix(chat): resolve MUL-5150 review blockers

- Renumber project-context migrations to unique prefixes after current main:
  206_chat_session_project -> 212 (column), 207_chat_session_project_index ->
  213 (concurrent index). 206/207 collided with 206_agent_disabled_runtime_skills
  and main's 207-211 client_usage_daily set.
- Add the 4 missing chat input.project_context keys to ja/ko locales so the
  locale parity test passes (en/zh-Hans already had them).
- Lock the project-context control while a send is in flight (isSubmitting),
  not just while the agent is running. A brand-new chat creates its session
  lazily during send bound to the project at click time; switching project
  mid-send would create the session against the stale project and clear the
  editor as if the send landed on the new selection. Add a regression test.

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

* fix(chat): complete project context handling

* fix(chat): pin fresh chat to open session's agent on project switch

Switching an existing session to a different project opens a fresh chat but
only cleared the active session, dropping selection back to the stored
`selectedAgentId`. When that preference was stale (open session belongs to
agent B while the persisted pick is still agent A), the lazily-created session
and its first send bound to the wrong agent (agent A).

Extract the project-switch decision into a shared `planProjectContextChange`
pure helper in use-chat-controller.ts and route both chat surfaces (the chat
tab controller and the floating ChatWindow) through it, so the fresh chat is
pinned to the open session's agent and the rule cannot drift between the two
copies. Add a dual-entry regression test (pure-fn guard + controller
integration) covering the stale selectedAgentId case.

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

* chore(ci): re-trigger required checks on latest head

The prior push updated the branch ref but GitHub did not emit a pull_request
synchronize for it (PR head-sync lag), so CI/Mobile Verify never ran on the
commit carrying the stale-agent project-switch fix. Empty commit to force a
fresh synchronize on a head that includes it.

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

* fix(chat): renumber project migrations to 213/214 after main added 212

Current main added 212_agent_service_tier; the PR's 212/213 chat migrations
collided with it on the merge ref, failing TestMigrationNumericPrefixesStay
UniqueAfterLegacySet. Merge current main and move the chat column migration to
213 and the concurrent index migration to 214 (column before index preserved).

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

* fix(chat): lock ProjectPicker clear control during send (keyboard path)

The send-pending lock only put pointer-events-none on the wrapper, which
blocks the mouse but leaves ProjectPicker's inline clear button in the tab
order — a keyboard user could Tab to "Remove from project" and press Enter
mid-send, detaching the project after the lazily-created session already went
out with the old one (reopens the mid-send retarget path via keyboard).

Add an explicit `disabled` capability to the shared ProjectPicker that locks
the trigger, the menu (forced closed), and the inline clear button (disabled +
out of the tab order). Defaults to false, so issue/create/autopilot callers
keep their hover/keyboard clear. ChatInput passes disabled while the project
selection is locked.

Tests: real-ProjectPicker regression (keyboard activation of the clear control
is inert when disabled; still works when enabled) + ChatInput wiring assertion
that the picker is disabled mid-send.

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

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Walt <walt@multica.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com>
Co-authored-by: NevilleQingNY <nevilleqing@gmail.com>
2026-07-24 11:30:27 +08:00

885 lines
33 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 { 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 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,
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>;