mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
875 lines
26 KiB
TypeScript
875 lines
26 KiB
TypeScript
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<void>;
|
|
onDeleteQueuedMessage: (id: number) => Promise<void>;
|
|
chatInputRef: React.RefObject<ChatMessageInputRef | null>;
|
|
inputValueRef: React.RefObject<string>;
|
|
}) {
|
|
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<string>).current =
|
|
editorInitialValue;
|
|
}
|
|
}, [editorInitialValue, inputValueRef]);
|
|
|
|
// -- History editing state --
|
|
const [editingMessageId, setEditingMessageId] = useState<number | null>(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<AgentsOutletContext>();
|
|
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<HTMLDivElement | null>(null);
|
|
const chatInputRef = useRef<ChatMessageInputRef | null>(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<TypesGen.Workspace | undefined>(
|
|
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<ChatMessageInputRef | null>
|
|
)?.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<ReturnType<typeof sendMutation.mutateAsync>>;
|
|
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 = (
|
|
<title>
|
|
{chatTitle ? pageTitle(chatTitle, "Agents") : pageTitle("Agents")}
|
|
</title>
|
|
);
|
|
|
|
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 (
|
|
<AgentDetailLoadingView
|
|
titleElement={titleElement}
|
|
isInputDisabled={isInputDisabled}
|
|
effectiveSelectedModel={effectiveSelectedModel}
|
|
setSelectedModel={setSelectedModel}
|
|
modelOptions={modelOptions}
|
|
modelSelectorPlaceholder={modelSelectorPlaceholder}
|
|
hasModelOptions={hasModelOptions}
|
|
inputStatusText={inputStatusText}
|
|
modelCatalogStatusMessage={modelCatalogStatusMessage}
|
|
isSidebarCollapsed={isSidebarCollapsed}
|
|
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
|
|
showRightPanel={showSidebarPanel}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (!chatQuery.data || !chatMessagesQuery.data?.pages?.length || !agentId) {
|
|
return (
|
|
<AgentDetailNotFoundView
|
|
titleElement={titleElement}
|
|
isSidebarCollapsed={isSidebarCollapsed}
|
|
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<AgentDetailView
|
|
agentId={agentId}
|
|
chatTitle={chatTitle}
|
|
parentChat={parentChat}
|
|
chatErrorReasons={chatErrorReasons}
|
|
chatRecord={chatRecord}
|
|
isArchived={isArchived}
|
|
hasWorkspace={Boolean(workspaceId)}
|
|
store={store}
|
|
editing={editing}
|
|
pendingEditMessageId={pendingEditMessageId}
|
|
effectiveSelectedModel={effectiveSelectedModel}
|
|
setSelectedModel={setSelectedModel}
|
|
modelOptions={modelOptions}
|
|
modelSelectorPlaceholder={modelSelectorPlaceholder}
|
|
hasModelOptions={hasModelOptions}
|
|
inputStatusText={inputStatusText}
|
|
modelCatalogStatusMessage={modelCatalogStatusMessage}
|
|
compressionThreshold={compressionThreshold}
|
|
isInputDisabled={isInputDisabled}
|
|
isSubmissionPending={isSubmissionPending}
|
|
isInterruptPending={interruptMutation.isPending}
|
|
isSidebarCollapsed={isSidebarCollapsed}
|
|
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
|
|
onOpenAnalytics={onOpenAnalytics}
|
|
showSidebarPanel={showSidebarPanel}
|
|
onSetShowSidebarPanel={handleSetShowSidebarPanel}
|
|
prNumber={prNumber}
|
|
diffStatusData={chatQuery.data?.diff_status}
|
|
gitWatcher={gitWatcher}
|
|
canOpenEditors={canOpenEditors}
|
|
canOpenWorkspace={canOpenWorkspace}
|
|
sshCommand={sshCommand}
|
|
handleOpenInEditor={handleOpenInEditor}
|
|
handleViewWorkspace={handleViewWorkspace}
|
|
handleOpenTerminal={handleOpenTerminal}
|
|
handleCommit={handleCommit}
|
|
onNavigateToChat={(chatId) => 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;
|