import { API, watchWorkspace } from "api/api"; import { isApiError } from "api/errors"; import { chat, chatMessagesForInfiniteScroll, createChatMessage, deleteChatQueuedMessage, editChatMessage, interruptChat, promoteChatQueuedMessage, } from "api/queries/chats"; import { deploymentSSHConfig } from "api/queries/deployment"; import { workspaceById, workspaceByIdKey } from "api/queries/workspaces"; import type * as TypesGen from "api/typesGenerated"; import type { ChatMessagePart } from "api/typesGenerated"; import { useProxy } from "contexts/ProxyContext"; import { getTerminalHref, getVSCodeHref, openAppInNewWindow, } from "modules/apps/apps"; import { type FC, useEffect, useLayoutEffect, useRef, useState } from "react"; import { useInfiniteQuery, useMutation, useQuery, useQueryClient, } from "react-query"; import { useNavigate, useOutletContext, useParams } from "react-router"; import { toast } from "sonner"; import type { UrlTransform } from "streamdown"; import { isMobileViewport } from "utils/mobile"; import { pageTitle } from "utils/page"; import { portForwardURL } from "utils/portForward"; import type { ChatMessageInputRef } from "./AgentChatInput"; import { useChatStore } from "./AgentDetail/ChatContext"; import { getParentChatID, getWorkspaceAgent } from "./AgentDetail/chatHelpers"; import { useWorkspaceCreationWatcher } from "./AgentDetail/useWorkspaceCreationWatcher"; import { AgentDetailLoadingView, AgentDetailNotFoundView, AgentDetailView, } from "./AgentDetailView"; import type { AgentsOutletContext } from "./AgentsPage"; import { getModelCatalogStatusMessage, getModelSelectorPlaceholder, hasConfiguredModelsInCatalog, } from "./modelOptions"; import { parsePullRequestUrl } from "./pullRequest"; import { formatUsageLimitMessage, isUsageLimitData } from "./usageLimitMessage"; import { useGitWatcher } from "./useGitWatcher"; /** localStorage key controlling whether the right panel is visible. */ export const RIGHT_PANEL_OPEN_KEY = "agents.right-panel-open"; const localHosts = new Set(["localhost", "127.0.0.1", "0.0.0.0"]); const lastModelConfigIDStorageKey = "agents.last-model-config-id"; /** @internal Exported for testing. */ export const draftInputStorageKeyPrefix = "agents.draft-input."; /** @internal Exported for testing. */ export function useConversationEditingState(deps: { chatID: string | undefined; onSend: ( message: string, fileIds?: string[], editedMessageID?: number, ) => Promise; onDeleteQueuedMessage: (id: number) => Promise; chatInputRef: React.RefObject; inputValueRef: React.RefObject; }) { const { chatID, onSend, onDeleteQueuedMessage, chatInputRef, inputValueRef } = deps; const draftStorageKey = chatID ? `${draftInputStorageKeyPrefix}${chatID}` : null; const [editorInitialValue, setEditorInitialValue] = useState(() => { if (typeof window === "undefined" || !draftStorageKey) { return ""; } return localStorage.getItem(draftStorageKey) ?? ""; }); // Sync the ref with the initial draft value so callers that // read inputValueRef.current see the persisted draft. Uses a // layout effect so the value is available before paint. const initialSyncDone = useRef(false); useLayoutEffect(() => { if (!initialSyncDone.current && editorInitialValue) { initialSyncDone.current = true; (inputValueRef as React.MutableRefObject).current = editorInitialValue; } }, [editorInitialValue, inputValueRef]); // -- History editing state -- const [editingMessageId, setEditingMessageId] = useState(null); const [draftBeforeHistoryEdit, setDraftBeforeHistoryEdit] = useState< string | null >(null); const [editingFileBlocks, setEditingFileBlocks] = useState< readonly ChatMessagePart[] >([]); const handleEditUserMessage = ( messageId: number, text: string, fileBlocks?: readonly ChatMessagePart[], ) => { setDraftBeforeHistoryEdit((prev) => editingMessageId !== null ? prev : inputValueRef.current, ); setEditingMessageId(messageId); setEditorInitialValue(text); inputValueRef.current = text; setEditingFileBlocks(fileBlocks ?? []); }; const handleCancelHistoryEdit = () => { setEditorInitialValue(draftBeforeHistoryEdit ?? ""); inputValueRef.current = draftBeforeHistoryEdit ?? ""; setEditingMessageId(null); setDraftBeforeHistoryEdit(null); setEditingFileBlocks([]); chatInputRef.current?.clear(); if (draftBeforeHistoryEdit) { chatInputRef.current?.insertText(draftBeforeHistoryEdit); } }; // -- Queue editing state -- const [editingQueuedMessageID, setEditingQueuedMessageID] = useState< number | null >(null); const [draftBeforeQueueEdit, setDraftBeforeQueueEdit] = useState< string | null >(null); const handleStartQueueEdit = ( id: number, text: string, fileBlocks: readonly ChatMessagePart[], ) => { setDraftBeforeQueueEdit((prev) => editingQueuedMessageID === null ? inputValueRef.current : prev, ); setEditingQueuedMessageID(id); setEditorInitialValue(text); inputValueRef.current = text; setEditingFileBlocks(fileBlocks); }; const handleCancelQueueEdit = () => { setEditorInitialValue(draftBeforeQueueEdit ?? ""); inputValueRef.current = draftBeforeQueueEdit ?? ""; setEditingQueuedMessageID(null); setDraftBeforeQueueEdit(null); setEditingFileBlocks([]); }; // Wraps the parent onSend to clear local input/editing state // and handle queue-edit deletion. const handleSendFromInput = async (message: string, fileIds?: string[]) => { const editedMessageID = editingMessageId !== null ? editingMessageId : undefined; const queueEditID = editingQueuedMessageID; await onSend(message, fileIds, editedMessageID); // Clear input and editing state on success. chatInputRef.current?.clear(); if (!isMobileViewport()) { chatInputRef.current?.focus(); } inputValueRef.current = ""; if (typeof window !== "undefined" && draftStorageKey) { localStorage.removeItem(draftStorageKey); } if (editingMessageId !== null) { setEditingMessageId(null); setDraftBeforeHistoryEdit(null); setEditingFileBlocks([]); } if (queueEditID !== null) { setEditingQueuedMessageID(null); setDraftBeforeQueueEdit(null); setEditingFileBlocks([]); void onDeleteQueuedMessage(queueEditID); } }; const handleContentChange = (content: string) => { inputValueRef.current = content; if (typeof window !== "undefined" && draftStorageKey) { if (content) { localStorage.setItem(draftStorageKey, content); } else { localStorage.removeItem(draftStorageKey); } } }; return { inputValueRef, chatInputRef, editorInitialValue, editingMessageId, editingFileBlocks, handleEditUserMessage, handleCancelHistoryEdit, editingQueuedMessageID, handleStartQueueEdit, handleCancelQueueEdit, handleSendFromInput, handleContentChange, }; } const AgentDetail: FC = () => { const navigate = useNavigate(); const { agentId } = useParams<{ agentId: string }>(); const outletContext = useOutletContext(); const queryClient = useQueryClient(); const [selectedModel, setSelectedModel] = useState(""); const [pendingEditMessageId, setPendingEditMessageId] = useState< number | null >(null); const { chatErrorReasons, setChatErrorReason, clearChatErrorReason, requestArchiveAgent, requestArchiveAndDeleteWorkspace, requestUnarchiveAgent, onOpenAnalytics, isSidebarCollapsed, onToggleSidebarCollapsed, modelOptions, modelConfigIDByModelID, modelIDByConfigID, modelConfigs, modelCatalog, isModelCatalogLoading, modelCatalogError, desktopEnabled, } = outletContext; const scrollContainerRef = useRef(null); const chatInputRef = useRef(null); const inputValueRef = useRef( typeof window !== "undefined" && agentId ? (localStorage.getItem(`${draftInputStorageKeyPrefix}${agentId}`) ?? "") : "", ); // Right panel open/closed state is owned here so the loading // skeleton and the loaded view share the same layout, preventing // a horizontal shift when data arrives. const [showSidebarPanel, setShowSidebarPanel] = useState(() => { if (typeof window === "undefined") return false; return localStorage.getItem(RIGHT_PANEL_OPEN_KEY) === "true"; }); const handleSetShowSidebarPanel = ( next: boolean | ((prev: boolean) => boolean), ) => { setShowSidebarPanel((prev) => { const value = typeof next === "function" ? next(prev) : next; if (typeof window !== "undefined") { localStorage.setItem(RIGHT_PANEL_OPEN_KEY, String(value)); } return value; }); }; const chatQuery = useQuery({ ...chat(agentId ?? ""), enabled: Boolean(agentId), }); const chatMessagesQuery = useInfiniteQuery({ ...chatMessagesForInfiniteScroll(agentId ?? ""), enabled: Boolean(agentId), }); const parentChatID = getParentChatID(chatQuery.data); const parentChatQuery = useQuery({ ...chat(parentChatID ?? ""), enabled: Boolean(parentChatID), }); const workspaceId = chatQuery.data?.workspace_id; const workspaceQuery = useQuery({ ...workspaceById(workspaceId ?? ""), enabled: Boolean(workspaceId), }); // Subscribe to live workspace updates so that agent status changes // (e.g. connected/disconnected) are reflected without a page refresh. useEffect(() => { if (!workspaceId) { return; } const socket = watchWorkspace(workspaceId); socket.addEventListener("message", (event) => { if (event.parseError) { return; } if (event.parsedMessage.type === "data") { const next = event.parsedMessage.data as TypesGen.Workspace; queryClient.setQueryData( workspaceByIdKey(workspaceId), (prev) => { // Return the same reference when nothing the UI // reads has changed. This prevents react-query // from notifying subscribers and avoids a full // AgentDetail re-render on every heartbeat. if ( prev && prev.latest_build.status === next.latest_build.status && prev.latest_build.resources === next.latest_build.resources && prev.name === next.name && prev.owner_name === next.owner_name ) { return prev; } return next; }, ); } }); return () => socket.close(); }, [workspaceId, queryClient]); const sshConfigQuery = useQuery(deploymentSSHConfig()); const workspace = workspaceQuery.data; const workspaceAgent = getWorkspaceAgent(workspace, undefined); const { proxy } = useProxy(); // Extract the primitive fields used by the transform so the // compiler can see the real dependencies and avoid invalidating // the closure when the workspace object reference changes but // the relevant fields haven't. const proxyHost = proxy.preferredWildcardHostname; const agentName = workspaceAgent?.name; const wsName = workspace?.name; const wsOwner = workspace?.owner_name; const urlTransform: UrlTransform = (url) => { if (!proxyHost || !agentName || !wsName || !wsOwner) { return url; } try { const parsed = new URL(url); if (!localHosts.has(parsed.hostname)) { return url; } return portForwardURL( proxyHost, Number.parseInt(parsed.port, 10), agentName, wsName, wsOwner, "http", parsed.pathname, parsed.search, ); } catch { return url; } }; const chatRecord = chatQuery.data; // Flatten paginated messages into chronological order. // Pages arrive newest-first per page, and pages[0] is the // most recent page. const chatMessagesList = (() => { const pages = chatMessagesQuery.data?.pages; if (!pages || pages.length === 0) return undefined; // Collect all messages, then sort chronologically by ID. const all = pages.flatMap((p) => p.messages); // Sort ascending by ID for chronological order. all.sort((a, b) => a.id - b.id); return all; })(); // Queued messages are only in the first page (most recent). const chatQueuedMessages = chatMessagesQuery.data?.pages[0]?.queued_messages; // Build a synthetic ChatMessagesResponse from the flattened // data for backward compat with useChatStore. const chatMessagesData: TypesGen.ChatMessagesResponse | undefined = chatMessagesList ? { messages: chatMessagesList, queued_messages: chatQueuedMessages ?? [], has_more: chatMessagesQuery.data?.pages.at(-1)?.has_more ?? false, } : undefined; const isArchived = chatRecord?.archived ?? false; const chatLastModelConfigID = chatRecord?.last_model_config_id; const sendMutation = useMutation( createChatMessage(queryClient, agentId ?? ""), ); const editMutation = useMutation(editChatMessage(queryClient, agentId ?? "")); const interruptMutation = useMutation( interruptChat(queryClient, agentId ?? ""), ); const deleteQueuedMutation = useMutation( deleteChatQueuedMessage(queryClient, agentId ?? ""), ); const promoteQueuedMutation = useMutation( promoteChatQueuedMessage(queryClient, agentId ?? ""), ); const { store, clearStreamError } = useChatStore({ chatID: agentId, chatMessages: chatMessagesList, chatRecord, chatMessagesData, chatQueuedMessages, setChatErrorReason, clearChatErrorReason, }); // Git watcher: runs regardless of sidebar visibility, but only // connects when the workspace agent is in the "connected" state // to avoid an infinite reconnect loop against a missing agent. const gitWatcher = useGitWatcher({ chatId: agentId, agentStatus: workspaceAgent?.status, }); // Detect workspace creation so the sidebar can resolve the // workspace and display agent/git info. useWorkspaceCreationWatcher({ store, chatID: agentId, }); const handleCommit = (repoRoot: string) => { const commitPrompt = `Commit and push the working changes in ${repoRoot}. If there are unstaged files, commit them too.`; const current = inputValueRef.current; if (current.includes(commitPrompt)) { return; } const prefix = current.trim() ? "\n\n" : ""; chatInputRef.current?.insertText(prefix + commitPrompt); chatInputRef.current?.focus(); }; // Prefer the explicit PR number from the API, and only fall back to URL // parsing when older metadata does not provide it. const parsedPrNumber = Number( parsePullRequestUrl(chatQuery.data?.diff_status?.url)?.number, ); const prNumber = chatQuery.data?.diff_status?.pr_number ?? (parsedPrNumber || undefined); // Compute an effective selected model by validating the user's // explicit choice against the current model options, falling // back to the chat's last model or the first available option. const effectiveSelectedModel = (() => { if ( selectedModel && modelOptions.some((model) => model.id === selectedModel) ) { return selectedModel; } if (chatLastModelConfigID) { const fromChat = modelIDByConfigID.get(chatLastModelConfigID); if (fromChat && modelOptions.some((model) => model.id === fromChat)) { return fromChat; } } return modelOptions[0]?.id ?? ""; })(); const compressionThreshold = chatLastModelConfigID ? modelConfigs.find((c) => c.id === chatLastModelConfigID) ?.compression_threshold : undefined; const hasModelOptions = modelOptions.length > 0; const hasConfiguredModels = hasConfiguredModelsInCatalog(modelCatalog); const modelSelectorPlaceholder = getModelSelectorPlaceholder( modelOptions, isModelCatalogLoading, hasConfiguredModels, ); const modelCatalogStatusMessage = getModelCatalogStatusMessage( modelCatalog, modelOptions, isModelCatalogLoading, Boolean(modelCatalogError), ); const inputStatusText = hasModelOptions ? null : hasConfiguredModels ? "Models are configured but unavailable. Ask an admin." : "No models configured. Ask an admin."; const isSubmissionPending = sendMutation.isPending || editMutation.isPending || interruptMutation.isPending; const isInputDisabled = !hasModelOptions || isArchived; const handleUsageLimitError = (error: unknown): void => { if (!agentId) { return; } if ( isApiError(error) && error.response?.status === 409 && isUsageLimitData(error.response.data) ) { setChatErrorReason(agentId, { kind: "usage-limit", message: formatUsageLimitMessage(error.response.data), }); } else if (isApiError(error)) { setChatErrorReason(agentId, { kind: "generic", message: error.message || "An unexpected error occurred.", }); } }; const handleSend = async ( message: string, fileIds?: string[], editedMessageID?: number, ) => { const chatInputHandle = ( editing.chatInputRef as React.RefObject )?.current; // Walk the Lexical tree in document order so file-reference // parts appear at the correct position relative to the // surrounding text the user typed. const editorParts = chatInputHandle?.getContentParts() ?? []; const hasFileReferences = editorParts.some( (p) => p.type === "file-reference", ); const hasContent = message.trim() || (fileIds && fileIds.length > 0) || hasFileReferences; if (!hasContent || isSubmissionPending || !agentId || !hasModelOptions) { return; } const content: TypesGen.ChatInputPart[] = []; // Emit parts in document order — text segments and // file-reference chips are interleaved as they appear in // the editor. for (const part of editorParts) { if (part.type === "text") { const trimmed = part.text.trim(); if (trimmed) { content.push({ type: "text", text: part.text }); } } else { const r = part.reference; content.push({ type: "file-reference", file_name: r.fileName, start_line: r.startLine, end_line: r.endLine, content: r.content, }); } } // Add pre-uploaded file references. if (fileIds && fileIds.length > 0) { for (const fileId of fileIds) { content.push({ type: "file", file_id: fileId }); } } if (editedMessageID !== undefined) { const request: TypesGen.EditChatMessageRequest = { content }; clearChatErrorReason(agentId); clearStreamError(); setPendingEditMessageId(editedMessageID); if (scrollContainerRef.current) { scrollContainerRef.current.scrollTop = 0; } store.clearStreamState(); try { await editMutation.mutateAsync({ messageId: editedMessageID, req: request, }); setPendingEditMessageId(null); } catch (error) { setPendingEditMessageId(null); handleUsageLimitError(error); throw error; } return; } const selectedModelConfigID = (effectiveSelectedModel && modelConfigIDByModelID.get(effectiveSelectedModel)) || undefined; const request: TypesGen.CreateChatMessageRequest = { content, model_config_id: selectedModelConfigID, }; clearChatErrorReason(agentId); clearStreamError(); if (scrollContainerRef.current) { scrollContainerRef.current.scrollTop = 0; } // No optimistic rendering — the message will appear in the // timeline when the server confirms via the POST response or // via the SSE stream. store.clearStreamState(); let response: Awaited>; try { response = await sendMutation.mutateAsync(request); } catch (error) { handleUsageLimitError(error); throw error; } // When the server accepts the message immediately (not // queued), insert it into the store so it appears in the // timeline without waiting for the SSE stream. if (!response.queued && response.message) { store.upsertDurableMessage(response.message); } if (typeof window !== "undefined") { if (selectedModelConfigID) { localStorage.setItem( lastModelConfigIDStorageKey, selectedModelConfigID, ); } else { localStorage.removeItem(lastModelConfigIDStorageKey); } } }; const handleInterrupt = () => { if (!agentId || interruptMutation.isPending) { return; } void interruptMutation.mutateAsync(); }; const handleDeleteQueuedMessage = async (id: number) => { const previousQueuedMessages = store.getSnapshot().queuedMessages; store.setQueuedMessages( previousQueuedMessages.filter((message) => message.id !== id), ); try { await deleteQueuedMutation.mutateAsync(id); } catch (error) { store.setQueuedMessages(previousQueuedMessages); throw error; } }; const handlePromoteQueuedMessage = async (id: number) => { const previousSnapshot = store.getSnapshot(); const previousQueuedMessages = previousSnapshot.queuedMessages; const previousChatStatus = previousSnapshot.chatStatus; store.setQueuedMessages( previousQueuedMessages.filter((message) => message.id !== id), ); store.clearStreamState(); if (agentId) { clearChatErrorReason(agentId); } store.clearStreamError(); store.setChatStatus("pending"); try { const promotedMessage = await promoteQueuedMutation.mutateAsync(id); // Insert the promoted message into the store immediately // so it appears in the timeline without waiting for the // WebSocket to deliver it. store.upsertDurableMessage(promotedMessage); } catch (error) { store.setQueuedMessages(previousQueuedMessages); store.setChatStatus(previousChatStatus); handleUsageLimitError(error); throw error; } }; const editing = useConversationEditingState({ chatID: agentId, onSend: handleSend, onDeleteQueuedMessage: handleDeleteQueuedMessage, chatInputRef, inputValueRef, }); const chatTitle = chatQuery.data?.title; const titleElement = ( {chatTitle ? pageTitle(chatTitle, "Agents") : pageTitle("Agents")} ); const parentChat = parentChatQuery.data; const workspaceRoute = workspace ? `/@${workspace.owner_name}/${workspace.name}` : null; const canOpenWorkspace = Boolean(workspaceRoute); const canOpenEditors = Boolean(workspace && workspaceAgent); const terminalHref = workspace && workspaceAgent ? getTerminalHref({ username: workspace.owner_name, workspace: workspace.name, agent: workspaceAgent.name, }) : null; const sshCommand = workspace && workspaceAgent && sshConfigQuery.data?.hostname_suffix ? `ssh ${workspaceAgent.name}.${workspace.name}.${workspace.owner_name}.${sshConfigQuery.data.hostname_suffix}` : undefined; const generateKeyMutation = useMutation({ mutationFn: () => API.getApiKey(), }); const handleOpenInEditor = (editor: "cursor" | "vscode") => { if (!workspace || !workspaceAgent) { return; } generateKeyMutation.mutate(undefined, { onSuccess: ({ key }) => { location.href = getVSCodeHref(editor, { owner: workspace.owner_name, workspace: workspace.name, token: key, agent: workspaceAgent.name, folder: workspaceAgent.expanded_directory, chatId: agentId, }); }, onError: () => { toast.error( editor === "cursor" ? "Failed to open in Cursor." : "Failed to open in VS Code.", ); }, }); }; const handleViewWorkspace = () => { if (!workspaceRoute) { return; } window.open(workspaceRoute, "_blank"); }; const handleOpenTerminal = () => { if (!terminalHref) { return; } openAppInNewWindow(terminalHref); }; const handleArchiveAgentAction = () => { if (!agentId || isArchived) { return; } requestArchiveAgent(agentId); }; const handleArchiveAndDeleteWorkspaceAction = () => { if (!agentId || isArchived || !workspaceId) { return; } requestArchiveAndDeleteWorkspace(agentId, workspaceId); }; const handleUnarchiveAgentAction = () => { if (!agentId || !isArchived) { return; } requestUnarchiveAgent(agentId); }; if (chatQuery.isLoading || chatMessagesQuery.isLoading) { return ( ); } if (!chatQuery.data || !chatMessagesQuery.data?.pages?.length || !agentId) { return ( ); } return ( navigate(`/agents/${chatId}`)} handleInterrupt={handleInterrupt} handleDeleteQueuedMessage={handleDeleteQueuedMessage} handlePromoteQueuedMessage={handlePromoteQueuedMessage} handleArchiveAgentAction={handleArchiveAgentAction} handleUnarchiveAgentAction={handleUnarchiveAgentAction} handleArchiveAndDeleteWorkspaceAction={ handleArchiveAndDeleteWorkspaceAction } urlTransform={urlTransform} scrollContainerRef={scrollContainerRef} hasMoreMessages={chatMessagesQuery.hasNextPage ?? false} isFetchingMoreMessages={chatMessagesQuery.isFetchingNextPage} onFetchMoreMessages={chatMessagesQuery.fetchNextPage} desktopChatId={desktopEnabled ? agentId : undefined} /> ); }; export default AgentDetail;