mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 21:33:41 +02:00
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>
889 lines
34 KiB
TypeScript
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>;
|