Files
coder/site/src/pages/AgentsPage/AgentDetail.tsx
T

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;