refactor(site): separate AgentsPage and AgentDetail into container/view pairs (#22812)

This commit is contained in:
Danielle Maywood
2026-03-10 12:09:48 +00:00
committed by GitHub
parent c933ddcffd
commit d61772dc52
7 changed files with 1417 additions and 444 deletions
@@ -11,7 +11,7 @@ import {
waitFor,
within,
} from "storybook/test";
import { AgentsEmptyState } from "./AgentsPage";
import { AgentCreateForm } from "./AgentsPage";
const modelOptions = [
{
@@ -22,9 +22,9 @@ const modelOptions = [
},
] as const;
const meta: Meta<typeof AgentsEmptyState> = {
title: "pages/AgentsPage/AgentsEmptyState",
component: AgentsEmptyState,
const meta: Meta<typeof AgentCreateForm> = {
title: "pages/AgentsPage/AgentCreateForm",
component: AgentCreateForm,
decorators: [withDashboardProvider],
args: {
onCreateChat: fn(),
@@ -55,7 +55,7 @@ const meta: Meta<typeof AgentsEmptyState> = {
};
export default meta;
type Story = StoryObj<typeof AgentsEmptyState>;
type Story = StoryObj<typeof AgentCreateForm>;
export const Default: Story = {};
+81 -308
View File
@@ -15,8 +15,6 @@ import { deploymentSSHConfig } from "api/queries/deployment";
import { workspaceById, workspaceByIdKey } from "api/queries/workspaces";
import type * as TypesGen from "api/typesGenerated";
import type { ModelSelectorOption } from "components/ai-elements";
import { Skeleton } from "components/Skeleton/Skeleton";
import { ArchiveIcon } from "lucide-react";
import {
getTerminalHref,
getVSCodeHref,
@@ -34,7 +32,6 @@ import {
import { useMutation, useQuery, useQueryClient } from "react-query";
import { useNavigate, useOutletContext, useParams } from "react-router";
import { toast } from "sonner";
import { cn } from "utils/cn";
import { pageTitle } from "utils/page";
import {
AgentChatInput,
@@ -66,32 +63,23 @@ import {
parseMessagesWithMergedTools,
} from "./AgentDetail/messageParsing";
import { buildStreamTools } from "./AgentDetail/streamState";
import { AgentDetailTopBar } from "./AgentDetail/TopBar";
import { useMessageWindow } from "./AgentDetail/useMessageWindow";
import { useWorkspaceCreationWatcher } from "./AgentDetail/useWorkspaceCreationWatcher";
import {
AgentDetailLoadingView,
AgentDetailNotFoundView,
AgentDetailView,
} from "./AgentDetailView";
import type { AgentsOutletContext } from "./AgentsPage";
import { GitPanel } from "./GitPanel";
import {
getModelCatalogStatusMessage,
getModelOptionsFromCatalog,
getModelSelectorPlaceholder,
hasConfiguredModelsInCatalog,
} from "./modelOptions";
import { RightPanel } from "./RightPanel";
import { type SidebarTab, SidebarTabView } from "./SidebarTabView";
import { useFileAttachments } from "./useFileAttachments";
import { useGitWatcher } from "./useGitWatcher";
const noopSetChatErrorReason: AgentsOutletContext["setChatErrorReason"] =
() => {};
const noopClearChatErrorReason: AgentsOutletContext["clearChatErrorReason"] =
() => {};
const noopRequestArchiveAgent: AgentsOutletContext["requestArchiveAgent"] =
() => {};
const noopRequestArchiveAndDeleteWorkspace: AgentsOutletContext["requestArchiveAndDeleteWorkspace"] =
() => {};
const noopRequestUnarchiveAgent: AgentsOutletContext["requestUnarchiveAgent"] =
() => {};
const lastModelConfigIDStorageKey = "agents.last-model-config-id";
/** @internal Exported for testing. */
export const draftInputStorageKeyPrefix = "agents.draft-input.";
@@ -114,7 +102,7 @@ interface AgentDetailTimelineProps {
savingMessageId?: number | null;
}
const AgentDetailTimeline: FC<AgentDetailTimelineProps> = ({
export const AgentDetailTimeline: FC<AgentDetailTimelineProps> = ({
store,
chatID,
persistedErrorReason,
@@ -229,7 +217,7 @@ interface AgentDetailInputProps {
}[];
}
const AgentDetailInput: FC<AgentDetailInputProps> = ({
export const AgentDetailInput: FC<AgentDetailInputProps> = ({
store,
compressionThreshold,
onSend,
@@ -560,36 +548,22 @@ export function useConversationEditingState(deps: {
const AgentDetail: FC = () => {
const navigate = useNavigate();
const { agentId } = useParams<{ agentId: string }>();
const outletContext = useOutletContext<AgentsOutletContext | undefined>();
const outletContext = useOutletContext<AgentsOutletContext>();
const queryClient = useQueryClient();
const [selectedModel, setSelectedModel] = useState("");
const [showSidebarPanel, setShowSidebarPanel] = useState(false);
const [isRightPanelExpanded, setIsRightPanelExpanded] = useState(false);
// Tracks the live visual expanded state during drag so sibling
// content hides/shows in real-time rather than on pointer-up.
// Null means "no drag override, use isRightPanelExpanded".
const [dragVisualExpanded, setDragVisualExpanded] = useState<boolean | null>(
null,
);
const visualExpanded = dragVisualExpanded ?? isRightPanelExpanded;
const [pendingEditMessageId, setPendingEditMessageId] = useState<
number | null
>(null);
const chatErrorReasons = outletContext?.chatErrorReasons ?? {};
const setChatErrorReason =
outletContext?.setChatErrorReason ?? noopSetChatErrorReason;
const clearChatErrorReason =
outletContext?.clearChatErrorReason ?? noopClearChatErrorReason;
const requestArchiveAgent =
outletContext?.requestArchiveAgent ?? noopRequestArchiveAgent;
const requestArchiveAndDeleteWorkspace =
outletContext?.requestArchiveAndDeleteWorkspace ??
noopRequestArchiveAndDeleteWorkspace;
const requestUnarchiveAgent =
outletContext?.requestUnarchiveAgent ?? noopRequestUnarchiveAgent;
const isSidebarCollapsed = outletContext?.isSidebarCollapsed ?? false;
const onToggleSidebarCollapsed =
outletContext?.onToggleSidebarCollapsed ?? (() => {});
const {
chatErrorReasons,
setChatErrorReason,
clearChatErrorReason,
requestArchiveAgent,
requestArchiveAndDeleteWorkspace,
requestUnarchiveAgent,
isSidebarCollapsed,
onToggleSidebarCollapsed,
} = outletContext;
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const chatInputRef = useRef<ChatMessageInputRef | null>(null);
const inputValueRef = useRef("");
@@ -632,7 +606,6 @@ const AgentDetail: FC = () => {
const chatModelsQuery = useQuery(chatModels());
const chatModelConfigsQuery = useQuery(chatModelConfigs());
const sshConfigQuery = useQuery(deploymentSSHConfig());
const hasDiffStatus = Boolean(diffStatusQuery.data?.url);
const workspace = workspaceQuery.data;
const workspaceAgent = getWorkspaceAgent(workspace, undefined);
const chatData = chatQuery.data;
@@ -642,16 +615,6 @@ const AgentDetail: FC = () => {
const chatQueuedMessages = chatData?.queued_messages;
const chatLastModelConfigID = chatRecord?.last_model_config_id;
// Auto-open the diff panel when diff status first appears.
// See: https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes
const [prevHasDiffStatus, setPrevHasDiffStatus] = useState(false);
if (hasDiffStatus !== prevHasDiffStatus) {
setPrevHasDiffStatus(hasDiffStatus);
if (hasDiffStatus && !window.matchMedia("(max-width: 767px)").matches) {
setShowSidebarPanel(true);
}
}
const modelOptions = useMemo(
() =>
getModelOptionsFromCatalog(
@@ -739,17 +702,6 @@ const AgentDetail: FC = () => {
chatInputRef.current?.focus();
}, []);
// Auto-open sidebar when git watcher receives its first non-empty
// repositories update.
const [prevHasGitRepos, setPrevHasGitRepos] = useState(false);
const hasGitRepos = gitWatcher.repositories.size > 0;
if (hasGitRepos !== prevHasGitRepos) {
setPrevHasGitRepos(hasGitRepos);
if (hasGitRepos && !window.matchMedia("(max-width: 767px)").matches) {
setShowSidebarPanel(true);
}
}
// Extract PR number from diff status URL.
const prMatch = diffStatusQuery.data?.url?.match(/\/pull\/(\d+)/)?.[1];
const prNumber = prMatch ? Number(prMatch) : undefined;
@@ -996,7 +948,6 @@ const AgentDetail: FC = () => {
workspace && workspaceAgent && sshConfigQuery.data?.hostname_suffix
? `ssh ${workspaceAgent.name}.${workspace.name}.${workspace.owner_name}.${sshConfigQuery.data.hostname_suffix}`
: undefined;
const shouldShowSidebar = showSidebarPanel;
const generateKeyMutation = useMutation({
mutationFn: () => API.getApiKey(),
@@ -1064,255 +1015,77 @@ const AgentDetail: FC = () => {
if (chatQuery.isLoading) {
return (
<div className="relative flex h-full min-h-0 min-w-0 flex-1 flex-col">
{titleElement}
<AgentDetailTopBar
panel={{
showSidebarPanel: false,
onToggleSidebar: () => {},
}}
workspace={{
canOpenEditors: false,
canOpenWorkspace: false,
onOpenInEditor: () => {},
onViewWorkspace: () => {},
onOpenTerminal: () => {},
sshCommand: undefined,
}}
onOpenParentChat={() => {}}
onArchiveAgent={() => {}}
onUnarchiveAgent={() => {}}
onArchiveAndDeleteWorkspace={() => {}}
hasWorkspace={false}
isSidebarCollapsed={isSidebarCollapsed}
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
/>
<div className="flex min-h-0 flex-1 flex-col-reverse overflow-hidden">
<div className="px-4">
<div className="mx-auto w-full max-w-3xl py-6">
<div className="flex flex-col gap-3">
{/* User message bubble (right-aligned) */}
<div className="flex w-full justify-end">
<Skeleton className="h-10 w-2/3 rounded-lg" />
</div>
{/* Assistant response lines (left-aligned) */}
<div className="space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-4/6" />
</div>
{/* Second user message bubble */}
<div className="mt-3 flex w-full justify-end">
<Skeleton className="h-10 w-1/2 rounded-lg" />
</div>
{/* Second assistant response */}
<div className="space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-4/6" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/5" />
</div>{" "}
</div>
</div>
</div>
</div>
<div className="shrink-0 px-4">
<AgentChatInput
onSend={() => {}}
initialValue=""
isDisabled={isInputDisabled}
isLoading={false}
selectedModel={effectiveSelectedModel}
onModelChange={setSelectedModel}
modelOptions={modelOptions}
modelSelectorPlaceholder={modelSelectorPlaceholder}
hasModelOptions={hasModelOptions}
inputStatusText={inputStatusText}
modelCatalogStatusMessage={modelCatalogStatusMessage}
/>
</div>
</div>
<AgentDetailLoadingView
titleElement={titleElement}
isInputDisabled={isInputDisabled}
effectiveSelectedModel={effectiveSelectedModel}
setSelectedModel={setSelectedModel}
modelOptions={modelOptions}
modelSelectorPlaceholder={modelSelectorPlaceholder}
hasModelOptions={hasModelOptions}
inputStatusText={inputStatusText}
modelCatalogStatusMessage={modelCatalogStatusMessage}
isSidebarCollapsed={isSidebarCollapsed}
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
/>
);
}
if (!chatQuery.data || !agentId) {
return (
<div className="flex h-full min-h-0 min-w-0 flex-1 flex-col">
{titleElement}
<AgentDetailTopBar
panel={{
showSidebarPanel: false,
onToggleSidebar: () => {},
}}
workspace={{
canOpenEditors: false,
canOpenWorkspace: false,
onOpenInEditor: () => {},
onViewWorkspace: () => {},
onOpenTerminal: () => {},
sshCommand: undefined,
}}
onOpenParentChat={() => {}}
onArchiveAgent={() => {}}
onUnarchiveAgent={() => {}}
onArchiveAndDeleteWorkspace={() => {}}
hasWorkspace={false}
isSidebarCollapsed={isSidebarCollapsed}
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
/>
<div className="flex flex-1 items-center justify-center text-content-secondary">
Chat not found
</div>{" "}
</div>
<AgentDetailNotFoundView
titleElement={titleElement}
isSidebarCollapsed={isSidebarCollapsed}
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
/>
);
}
return (
<div
className={cn(
"relative flex min-h-0 min-w-0 flex-1",
shouldShowSidebar && !visualExpanded && "flex-row",
)}
>
{titleElement}
<div
className={cn(
"relative flex min-h-0 min-w-0 flex-1 flex-col",
visualExpanded && "hidden",
shouldShowSidebar && "max-md:hidden",
)}
>
<div className="relative z-10 shrink-0 overflow-visible">
<AgentDetailTopBar
chatTitle={chatTitle}
parentChat={parentChat}
onOpenParentChat={(chatId) => navigate(`/agents/${chatId}`)}
panel={{
showSidebarPanel,
onToggleSidebar: () => setShowSidebarPanel((prev) => !prev),
}}
workspace={{
canOpenEditors,
canOpenWorkspace,
onOpenInEditor: handleOpenInEditor,
onViewWorkspace: handleViewWorkspace,
onOpenTerminal: handleOpenTerminal,
sshCommand,
}}
onArchiveAgent={handleArchiveAgentAction}
onUnarchiveAgent={handleUnarchiveAgentAction}
onArchiveAndDeleteWorkspace={handleArchiveAndDeleteWorkspaceAction}
hasWorkspace={Boolean(workspaceId)}
isArchived={isArchived}
isSidebarCollapsed={isSidebarCollapsed}
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
/>
{isArchived && (
<div className="flex shrink-0 items-center gap-2 border-b border-border-default bg-surface-secondary px-4 py-2 text-xs text-content-secondary">
<ArchiveIcon className="h-4 w-4 shrink-0" />
This agent has been archived and is read-only.
</div>
)}
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-full z-10 h-6 bg-surface-primary"
style={{
maskImage:
"linear-gradient(to bottom, black 0%, rgba(0,0,0,0.6) 40%, rgba(0,0,0,0.2) 70%, transparent 100%)",
WebkitMaskImage:
"linear-gradient(to bottom, black 0%, rgba(0,0,0,0.6) 40%, rgba(0,0,0,0.2) 70%, transparent 100%)",
}}
/>
</div>
<div
ref={scrollContainerRef}
className="flex min-h-0 flex-1 flex-col-reverse overflow-y-auto [scrollbar-gutter:stable] [scrollbar-width:thin] [scrollbar-color:hsl(var(--surface-quaternary))_transparent]"
>
<div className="px-4">
<AgentDetailTimeline
store={store}
chatID={agentId}
persistedErrorReason={
chatErrorReasons[agentId] || chatRecord?.last_error || undefined
}
onEditUserMessage={editing.handleEditUserMessage}
editingMessageId={editing.editingMessageId}
savingMessageId={pendingEditMessageId}
/>
</div>
</div>
<div className="shrink-0 overflow-y-auto px-4 [scrollbar-gutter:stable] [scrollbar-width:thin]">
<AgentDetailInput
store={store}
compressionThreshold={compressionThreshold}
onSend={editing.handleSendFromInput}
onDeleteQueuedMessage={handleDeleteQueuedMessage}
onPromoteQueuedMessage={handlePromoteQueuedMessage}
onInterrupt={handleInterrupt}
isInputDisabled={isInputDisabled}
isSendPending={isSubmissionPending}
isInterruptPending={interruptMutation.isPending}
hasModelOptions={hasModelOptions}
selectedModel={effectiveSelectedModel}
onModelChange={setSelectedModel}
modelOptions={modelOptions}
modelSelectorPlaceholder={modelSelectorPlaceholder}
inputStatusText={inputStatusText}
modelCatalogStatusMessage={modelCatalogStatusMessage}
inputRef={editing.chatInputRef}
initialValue={editing.editorInitialValue}
onContentChange={editing.handleContentChange}
editingQueuedMessageID={editing.editingQueuedMessageID}
onStartQueueEdit={editing.handleStartQueueEdit}
onCancelQueueEdit={editing.handleCancelQueueEdit}
isEditingHistoryMessage={editing.editingMessageId !== null}
onCancelHistoryEdit={editing.handleCancelHistoryEdit}
editingFileBlocks={editing.editingFileBlocks}
/>
</div>
</div>
<RightPanel
isOpen={shouldShowSidebar}
isExpanded={isRightPanelExpanded}
onToggleExpanded={() => setIsRightPanelExpanded((prev) => !prev)}
onClose={() => setShowSidebarPanel(false)}
onVisualExpandedChange={setDragVisualExpanded}
isSidebarCollapsed={isSidebarCollapsed}
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
>
<SidebarTabView
tabs={
[
(hasDiffStatus || hasGitRepos) && {
id: "git",
label: "Git",
content: (
<GitPanel
prTab={
prNumber && agentId
? { prNumber, chatId: agentId }
: undefined
}
repositories={gitWatcher.repositories}
onRefresh={gitWatcher.refresh}
onCommit={handleCommit}
isExpanded={visualExpanded}
remoteDiffStats={diffStatusQuery.data}
chatInputRef={editing.chatInputRef}
/>
),
},
].filter(Boolean) as SidebarTab[]
}
onClose={() => setShowSidebarPanel(false)}
isExpanded={visualExpanded}
onToggleExpanded={() => setIsRightPanelExpanded((prev) => !prev)}
isSidebarCollapsed={isSidebarCollapsed}
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
chatTitle={chatTitle}
/>
</RightPanel>{" "}
</div>
<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}
prNumber={prNumber}
diffStatusData={diffStatusQuery.data}
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
}
scrollContainerRef={scrollContainerRef}
/>
);
};
@@ -0,0 +1,318 @@
import { MockUserOwner } from "testHelpers/entities";
import { withAuthProvider, withDashboardProvider } from "testHelpers/storybook";
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { ChatDiffStatusResponse } from "api/api";
import type * as TypesGen from "api/typesGenerated";
import type { ModelSelectorOption } from "components/ai-elements";
import { fn } from "storybook/test";
import { reactRouterParameters } from "storybook-addon-remix-react-router";
import { createChatStore } from "./AgentDetail/ChatContext";
import {
AgentDetailLoadingView,
AgentDetailNotFoundView,
AgentDetailView,
} from "./AgentDetailView";
// ---------------------------------------------------------------------------
// Shared constants & helpers
// ---------------------------------------------------------------------------
const AGENT_ID = "agent-detail-view-1";
const defaultModelOptions: ModelSelectorOption[] = [
{
id: "openai:gpt-4o",
provider: "openai",
model: "gpt-4o",
displayName: "GPT-4o",
},
];
const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
const buildChat = (overrides: Partial<TypesGen.Chat> = {}): TypesGen.Chat => ({
id: AGENT_ID,
owner_id: "owner-1",
title: "Help me refactor",
status: "completed",
last_model_config_id: "model-config-1",
created_at: oneWeekAgo,
updated_at: oneWeekAgo,
archived: false,
last_error: null,
...overrides,
});
const defaultEditing = {
chatInputRef: { current: null },
editorInitialValue: "",
editingMessageId: null,
editingFileBlocks: [] as readonly {
mediaType: string;
data?: string;
fileId?: string;
}[],
handleEditUserMessage: fn(),
handleCancelHistoryEdit: fn(),
editingQueuedMessageID: null,
handleStartQueueEdit: fn(),
handleCancelQueueEdit: fn(),
handleSendFromInput: fn(),
handleContentChange: fn(),
};
const defaultGitWatcher: {
repositories: ReadonlyMap<string, TypesGen.WorkspaceAgentRepoChanges>;
refresh: () => void;
} = {
repositories: new Map(),
refresh: fn(),
};
const agentsRouting = [
{ path: "/agents/:agentId", useStoryElement: true },
{ path: "/agents", useStoryElement: true },
] satisfies [
{ path: string; useStoryElement: boolean },
...{ path: string; useStoryElement: boolean }[],
];
// ---------------------------------------------------------------------------
// Meta
// ---------------------------------------------------------------------------
const meta: Meta<typeof AgentDetailView> = {
title: "pages/AgentsPage/AgentDetailView",
component: AgentDetailView,
decorators: [withAuthProvider, withDashboardProvider],
parameters: {
layout: "fullscreen",
user: MockUserOwner,
reactRouter: reactRouterParameters({
location: {
path: `/agents/${AGENT_ID}`,
pathParams: { agentId: AGENT_ID },
},
routing: agentsRouting,
}),
},
args: {
agentId: AGENT_ID,
chatTitle: "Help me refactor",
parentChat: undefined,
chatErrorReasons: {},
chatRecord: buildChat(),
isArchived: false,
hasWorkspace: true,
store: createChatStore(),
editing: defaultEditing,
pendingEditMessageId: null,
effectiveSelectedModel: "openai:gpt-4o",
setSelectedModel: fn(),
modelOptions: defaultModelOptions,
modelSelectorPlaceholder: "Select a model",
hasModelOptions: true,
inputStatusText: null,
modelCatalogStatusMessage: null,
compressionThreshold: undefined,
isInputDisabled: false,
isSubmissionPending: false,
isInterruptPending: false,
isSidebarCollapsed: false,
onToggleSidebarCollapsed: fn(),
prNumber: undefined,
diffStatusData: undefined,
gitWatcher: defaultGitWatcher,
canOpenEditors: false,
canOpenWorkspace: false,
sshCommand: undefined,
handleOpenInEditor: fn(),
handleViewWorkspace: fn(),
handleOpenTerminal: fn(),
handleCommit: fn(),
onNavigateToChat: fn(),
handleInterrupt: fn(),
handleDeleteQueuedMessage: fn(),
handlePromoteQueuedMessage: fn(),
handleArchiveAgentAction: fn(),
handleUnarchiveAgentAction: fn(),
handleArchiveAndDeleteWorkspaceAction: fn(),
scrollContainerRef: { current: null },
},
};
export default meta;
type Story = StoryObj<typeof AgentDetailView>;
// ---------------------------------------------------------------------------
// AgentDetailView stories
// ---------------------------------------------------------------------------
/** Basic conversation view with a chat title, workspace, and no archive. */
export const Default: Story = {};
/** Archived agent displays the read-only banner below the top bar. */
export const Archived: Story = {
args: {
isArchived: true,
chatRecord: buildChat({ archived: true }),
isInputDisabled: true,
},
};
/** Shows the parent chat link in the top bar when a parent exists. */
export const WithParentChat: Story = {
args: {
parentChat: buildChat({
id: "parent-chat-1",
title: "Root agent",
}),
},
};
/** Persisted error reason shown in the timeline area. */
export const WithError: Story = {
args: {
chatErrorReasons: { [AGENT_ID]: "Model rate limited" },
},
};
/** Input area appears disabled when `isInputDisabled` is true. */
export const InputDisabled: Story = {
args: {
isInputDisabled: true,
},
};
/** Shows a sending/pending state for the input. */
export const SubmissionPending: Story = {
args: {
isSubmissionPending: true,
},
};
/** Right sidebar panel is open with diff status data. */
export const WithSidebarPanel: Story = {
args: {
prNumber: 123,
diffStatusData: {
chat_id: AGENT_ID,
url: "https://github.com/coder/coder/pull/123",
changes_requested: false,
additions: 42,
deletions: 7,
changed_files: 5,
} satisfies ChatDiffStatusResponse,
},
};
/** Left sidebar is collapsed. */
export const SidebarCollapsed: Story = {
args: {
isSidebarCollapsed: true,
},
};
/** No model options available — shows a disabled status message. */
export const NoModelOptions: Story = {
args: {
hasModelOptions: false,
modelOptions: [],
inputStatusText: "No models configured. Ask an admin.",
isInputDisabled: true,
},
};
/** Top bar has workspace action buttons visible. */
export const WithWorkspaceActions: Story = {
args: {
canOpenEditors: true,
canOpenWorkspace: true,
sshCommand: "ssh coder.workspace",
},
};
// ---------------------------------------------------------------------------
// AgentDetailLoadingView stories
// ---------------------------------------------------------------------------
/** Default loading state with skeleton placeholders. */
export const Loading: Story = {
render: () => (
<AgentDetailLoadingView
titleElement={<title>Loading — Agents</title>}
isInputDisabled
effectiveSelectedModel="openai:gpt-4o"
setSelectedModel={fn()}
modelOptions={defaultModelOptions}
modelSelectorPlaceholder="Select a model"
hasModelOptions
inputStatusText={null}
modelCatalogStatusMessage={null}
isSidebarCollapsed={false}
onToggleSidebarCollapsed={fn()}
/>
),
};
/** Loading state with the model selector populated. */
export const LoadingWithModelOptions: Story = {
render: () => (
<AgentDetailLoadingView
titleElement={<title>Loading — Agents</title>}
isInputDisabled={false}
effectiveSelectedModel="openai:gpt-4o"
setSelectedModel={fn()}
modelOptions={defaultModelOptions}
modelSelectorPlaceholder="Select a model"
hasModelOptions
inputStatusText={null}
modelCatalogStatusMessage={null}
isSidebarCollapsed={false}
onToggleSidebarCollapsed={fn()}
/>
),
};
/** Loading state with the left sidebar collapsed. */
export const LoadingSidebarCollapsed: Story = {
render: () => (
<AgentDetailLoadingView
titleElement={<title>Loading — Agents</title>}
isInputDisabled
effectiveSelectedModel="openai:gpt-4o"
setSelectedModel={fn()}
modelOptions={defaultModelOptions}
modelSelectorPlaceholder="Select a model"
hasModelOptions
inputStatusText={null}
modelCatalogStatusMessage={null}
isSidebarCollapsed
onToggleSidebarCollapsed={fn()}
/>
),
};
// ---------------------------------------------------------------------------
// AgentDetailNotFoundView stories
// ---------------------------------------------------------------------------
/** Shows the "Chat not found" message. */
export const NotFound: Story = {
render: () => (
<AgentDetailNotFoundView
titleElement={<title>Not Found — Agents</title>}
isSidebarCollapsed={false}
onToggleSidebarCollapsed={fn()}
/>
),
};
/** "Chat not found" with the left sidebar collapsed. */
export const NotFoundSidebarCollapsed: Story = {
render: () => (
<AgentDetailNotFoundView
titleElement={<title>Not Found — Agents</title>}
isSidebarCollapsed
onToggleSidebarCollapsed={fn()}
/>
),
};
@@ -0,0 +1,488 @@
import type { ChatDiffStatusResponse } from "api/api";
import type * as TypesGen from "api/typesGenerated";
import type { ModelSelectorOption } from "components/ai-elements";
import { Skeleton } from "components/Skeleton/Skeleton";
import { ArchiveIcon } from "lucide-react";
import { type FC, type RefObject, useState } from "react";
import { cn } from "utils/cn";
import { pageTitle } from "utils/page";
import { AgentChatInput, type ChatMessageInputRef } from "./AgentChatInput";
import { AgentDetailInput, AgentDetailTimeline } from "./AgentDetail";
import type { useChatStore } from "./AgentDetail/ChatContext";
import { AgentDetailTopBar } from "./AgentDetail/TopBar";
import { GitPanel } from "./GitPanel";
import { RightPanel } from "./RightPanel";
import { type SidebarTab, SidebarTabView } from "./SidebarTabView";
type ChatStoreHandle = ReturnType<typeof useChatStore>["store"];
// Re-use the inner presentational components directly. They are
interface EditingState {
chatInputRef: RefObject<ChatMessageInputRef | null>;
editorInitialValue: string;
editingMessageId: number | null;
editingFileBlocks: readonly {
mediaType: string;
data?: string;
fileId?: string;
}[];
handleEditUserMessage: (
messageId: number,
text: string,
fileBlocks?: readonly {
mediaType: string;
data?: string;
fileId?: string;
}[],
) => void;
handleCancelHistoryEdit: () => void;
editingQueuedMessageID: number | null;
handleStartQueueEdit: (id: number, text: string) => void;
handleCancelQueueEdit: () => void;
handleSendFromInput: (message: string, fileIds?: string[]) => void;
handleContentChange: (content: string) => void;
}
interface AgentDetailViewProps {
// Chat data.
agentId: string;
chatTitle: string | undefined;
parentChat: TypesGen.Chat | undefined;
chatErrorReasons: Record<string, string>;
chatRecord: TypesGen.Chat | undefined;
isArchived: boolean;
hasWorkspace: boolean;
// Store handle.
store: ChatStoreHandle;
// Editing state.
editing: EditingState;
pendingEditMessageId: number | null;
// Model/input configuration.
effectiveSelectedModel: string;
setSelectedModel: (model: string) => void;
modelOptions: readonly ModelSelectorOption[];
modelSelectorPlaceholder: string;
hasModelOptions: boolean;
inputStatusText: string | null;
modelCatalogStatusMessage: string | null;
compressionThreshold: number | undefined;
isInputDisabled: boolean;
isSubmissionPending: boolean;
isInterruptPending: boolean;
// Sidebar / panel state.
isSidebarCollapsed: boolean;
onToggleSidebarCollapsed: () => void;
// Sidebar content data.
prNumber: number | undefined;
diffStatusData: ChatDiffStatusResponse | undefined;
gitWatcher: {
repositories: ReadonlyMap<string, TypesGen.WorkspaceAgentRepoChanges>;
refresh: () => void;
};
// Workspace action handlers.
canOpenEditors: boolean;
canOpenWorkspace: boolean;
sshCommand: string | undefined;
handleOpenInEditor: (editor: "cursor" | "vscode") => void;
handleViewWorkspace: () => void;
handleOpenTerminal: () => void;
handleCommit: (repoRoot: string) => void;
// Navigation.
onNavigateToChat: (chatId: string) => void;
// Chat action handlers.
handleInterrupt: () => void;
handleDeleteQueuedMessage: (id: number) => Promise<void>;
handlePromoteQueuedMessage: (id: number) => Promise<void>;
// Archive actions.
handleArchiveAgentAction: () => void;
handleUnarchiveAgentAction: () => void;
handleArchiveAndDeleteWorkspaceAction: () => void;
// Scroll container ref.
scrollContainerRef: RefObject<HTMLDivElement | null>;
}
export const AgentDetailView: FC<AgentDetailViewProps> = ({
agentId,
chatTitle,
parentChat,
chatErrorReasons,
chatRecord,
isArchived,
hasWorkspace,
store,
editing,
pendingEditMessageId,
effectiveSelectedModel,
setSelectedModel,
modelOptions,
modelSelectorPlaceholder,
hasModelOptions,
inputStatusText,
modelCatalogStatusMessage,
compressionThreshold,
isInputDisabled,
isSubmissionPending,
isInterruptPending,
isSidebarCollapsed,
onToggleSidebarCollapsed,
prNumber,
diffStatusData,
gitWatcher,
canOpenEditors,
canOpenWorkspace,
sshCommand,
handleOpenInEditor,
handleViewWorkspace,
handleOpenTerminal,
handleCommit,
onNavigateToChat,
handleInterrupt,
handleDeleteQueuedMessage,
handlePromoteQueuedMessage,
handleArchiveAgentAction,
handleUnarchiveAgentAction,
handleArchiveAndDeleteWorkspaceAction,
scrollContainerRef,
}) => {
// Panel/sidebar UI state – purely visual, no data-fetching
// implications.
const [showSidebarPanel, setShowSidebarPanel] = useState(false);
const [isRightPanelExpanded, setIsRightPanelExpanded] = useState(false);
const [dragVisualExpanded, setDragVisualExpanded] = useState<boolean | null>(
null,
);
const visualExpanded = dragVisualExpanded ?? isRightPanelExpanded;
// Derive trivial booleans the View can compute itself.
const hasDiffStatus = Boolean(diffStatusData?.url);
const hasGitRepos = gitWatcher.repositories.size > 0;
// Auto-open the diff panel when diff status first appears.
// See: https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes
const [prevHasDiffStatus, setPrevHasDiffStatus] = useState(false);
if (hasDiffStatus !== prevHasDiffStatus) {
setPrevHasDiffStatus(hasDiffStatus);
if (hasDiffStatus && !window.matchMedia("(max-width: 767px)").matches) {
setShowSidebarPanel(true);
}
}
// Auto-open sidebar when git watcher receives its first non-empty
// repositories update.
const [prevHasGitRepos, setPrevHasGitRepos] = useState(false);
if (hasGitRepos !== prevHasGitRepos) {
setPrevHasGitRepos(hasGitRepos);
if (hasGitRepos && !window.matchMedia("(max-width: 767px)").matches) {
setShowSidebarPanel(true);
}
}
const titleElement = (
<title>
{chatTitle ? pageTitle(chatTitle, "Agents") : pageTitle("Agents")}
</title>
);
const shouldShowSidebar = showSidebarPanel;
return (
<div
className={cn(
"relative flex min-h-0 min-w-0 flex-1",
shouldShowSidebar && !visualExpanded && "flex-row",
)}
>
{titleElement}
<div
className={cn(
"relative flex min-h-0 min-w-0 flex-1 flex-col",
visualExpanded && "hidden",
shouldShowSidebar && "max-md:hidden",
)}
>
<div className="relative z-10 shrink-0 overflow-visible">
<AgentDetailTopBar
chatTitle={chatTitle}
parentChat={parentChat}
onOpenParentChat={(chatId) => onNavigateToChat(chatId)}
panel={{
showSidebarPanel,
onToggleSidebar: () => setShowSidebarPanel((prev) => !prev),
}}
workspace={{
canOpenEditors,
canOpenWorkspace,
onOpenInEditor: handleOpenInEditor,
onViewWorkspace: handleViewWorkspace,
onOpenTerminal: handleOpenTerminal,
sshCommand,
}}
onArchiveAgent={handleArchiveAgentAction}
onUnarchiveAgent={handleUnarchiveAgentAction}
onArchiveAndDeleteWorkspace={handleArchiveAndDeleteWorkspaceAction}
hasWorkspace={hasWorkspace}
isArchived={isArchived}
isSidebarCollapsed={isSidebarCollapsed}
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
/>
{isArchived && (
<div className="flex shrink-0 items-center gap-2 border-b border-border-default bg-surface-secondary px-4 py-2 text-xs text-content-secondary">
<ArchiveIcon className="h-4 w-4 shrink-0" />
This agent has been archived and is read-only.
</div>
)}
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-full z-10 h-6 bg-surface-primary"
style={{
maskImage:
"linear-gradient(to bottom, black 0%, rgba(0,0,0,0.6) 40%, rgba(0,0,0,0.2) 70%, transparent 100%)",
WebkitMaskImage:
"linear-gradient(to bottom, black 0%, rgba(0,0,0,0.6) 40%, rgba(0,0,0,0.2) 70%, transparent 100%)",
}}
/>
</div>
<div
ref={scrollContainerRef}
className="flex min-h-0 flex-1 flex-col-reverse overflow-y-auto [scrollbar-gutter:stable] [scrollbar-width:thin] [scrollbar-color:hsl(var(--surface-quaternary))_transparent]"
>
<div className="px-4">
<AgentDetailTimeline
store={store}
chatID={agentId}
persistedErrorReason={
chatErrorReasons[agentId] || chatRecord?.last_error || undefined
}
onEditUserMessage={editing.handleEditUserMessage}
editingMessageId={editing.editingMessageId}
savingMessageId={pendingEditMessageId}
/>
</div>
</div>
<div className="shrink-0 overflow-y-auto px-4 [scrollbar-gutter:stable] [scrollbar-width:thin]">
<AgentDetailInput
store={store}
compressionThreshold={compressionThreshold}
onSend={editing.handleSendFromInput}
onDeleteQueuedMessage={handleDeleteQueuedMessage}
onPromoteQueuedMessage={handlePromoteQueuedMessage}
onInterrupt={handleInterrupt}
isInputDisabled={isInputDisabled}
isSendPending={isSubmissionPending}
isInterruptPending={isInterruptPending}
hasModelOptions={hasModelOptions}
selectedModel={effectiveSelectedModel}
onModelChange={setSelectedModel}
modelOptions={modelOptions}
modelSelectorPlaceholder={modelSelectorPlaceholder}
inputStatusText={inputStatusText}
modelCatalogStatusMessage={modelCatalogStatusMessage}
inputRef={editing.chatInputRef}
initialValue={editing.editorInitialValue}
onContentChange={editing.handleContentChange}
editingQueuedMessageID={editing.editingQueuedMessageID}
onStartQueueEdit={editing.handleStartQueueEdit}
onCancelQueueEdit={editing.handleCancelQueueEdit}
isEditingHistoryMessage={editing.editingMessageId !== null}
onCancelHistoryEdit={editing.handleCancelHistoryEdit}
editingFileBlocks={editing.editingFileBlocks}
/>
</div>
</div>
<RightPanel
isOpen={shouldShowSidebar}
isExpanded={isRightPanelExpanded}
onToggleExpanded={() => setIsRightPanelExpanded((prev) => !prev)}
onClose={() => setShowSidebarPanel(false)}
onVisualExpandedChange={setDragVisualExpanded}
isSidebarCollapsed={isSidebarCollapsed}
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
>
<SidebarTabView
tabs={
[
(hasDiffStatus || hasGitRepos) && {
id: "git",
label: "Git",
content: (
<GitPanel
prTab={
prNumber && agentId
? { prNumber, chatId: agentId }
: undefined
}
repositories={gitWatcher.repositories}
onRefresh={gitWatcher.refresh}
onCommit={handleCommit}
isExpanded={visualExpanded}
remoteDiffStats={diffStatusData}
chatInputRef={editing.chatInputRef}
/>
),
},
].filter(Boolean) as SidebarTab[]
}
onClose={() => setShowSidebarPanel(false)}
isExpanded={visualExpanded}
onToggleExpanded={() => setIsRightPanelExpanded((prev) => !prev)}
isSidebarCollapsed={isSidebarCollapsed}
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
chatTitle={chatTitle}
/>
</RightPanel>{" "}
</div>
);
};
interface AgentDetailLoadingViewProps {
titleElement: React.ReactNode;
isInputDisabled: boolean;
effectiveSelectedModel: string;
setSelectedModel: (model: string) => void;
modelOptions: readonly ModelSelectorOption[];
modelSelectorPlaceholder: string;
hasModelOptions: boolean;
inputStatusText: string | null;
modelCatalogStatusMessage: string | null;
isSidebarCollapsed: boolean;
onToggleSidebarCollapsed: () => void;
}
export const AgentDetailLoadingView: FC<AgentDetailLoadingViewProps> = ({
titleElement,
isInputDisabled,
effectiveSelectedModel,
setSelectedModel,
modelOptions,
modelSelectorPlaceholder,
hasModelOptions,
inputStatusText,
modelCatalogStatusMessage,
isSidebarCollapsed,
onToggleSidebarCollapsed,
}) => {
return (
<div className="relative flex h-full min-h-0 min-w-0 flex-1 flex-col">
{titleElement}
<AgentDetailTopBar
panel={{
showSidebarPanel: false,
onToggleSidebar: () => {},
}}
workspace={{
canOpenEditors: false,
canOpenWorkspace: false,
onOpenInEditor: () => {},
onViewWorkspace: () => {},
onOpenTerminal: () => {},
sshCommand: undefined,
}}
onOpenParentChat={() => {}}
onArchiveAgent={() => {}}
onUnarchiveAgent={() => {}}
onArchiveAndDeleteWorkspace={() => {}}
hasWorkspace={false}
isSidebarCollapsed={isSidebarCollapsed}
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
/>
<div className="flex min-h-0 flex-1 flex-col-reverse overflow-hidden">
<div className="px-4">
<div className="mx-auto w-full max-w-3xl py-6">
<div className="flex flex-col gap-3">
{/* User message bubble (right-aligned) */}
<div className="flex w-full justify-end">
<Skeleton className="h-10 w-2/3 rounded-lg" />
</div>
{/* Assistant response lines (left-aligned) */}
<div className="space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-4/6" />
</div>
{/* Second user message bubble */}
<div className="mt-3 flex w-full justify-end">
<Skeleton className="h-10 w-1/2 rounded-lg" />
</div>
{/* Second assistant response */}
<div className="space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-4/6" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/5" />
</div>{" "}
</div>
</div>
</div>
</div>
<div className="shrink-0 px-4">
<AgentChatInput
onSend={() => {}}
initialValue=""
isDisabled={isInputDisabled}
isLoading={false}
selectedModel={effectiveSelectedModel}
onModelChange={setSelectedModel}
modelOptions={modelOptions}
modelSelectorPlaceholder={modelSelectorPlaceholder}
hasModelOptions={hasModelOptions}
inputStatusText={inputStatusText}
modelCatalogStatusMessage={modelCatalogStatusMessage}
/>
</div>
</div>
);
};
interface AgentDetailNotFoundViewProps {
titleElement: React.ReactNode;
isSidebarCollapsed: boolean;
onToggleSidebarCollapsed: () => void;
}
export const AgentDetailNotFoundView: FC<AgentDetailNotFoundViewProps> = ({
titleElement,
isSidebarCollapsed,
onToggleSidebarCollapsed,
}) => {
return (
<div className="flex h-full min-h-0 min-w-0 flex-1 flex-col">
{titleElement}
<AgentDetailTopBar
panel={{
showSidebarPanel: false,
onToggleSidebar: () => {},
}}
workspace={{
canOpenEditors: false,
canOpenWorkspace: false,
onOpenInEditor: () => {},
onViewWorkspace: () => {},
onOpenTerminal: () => {},
sshCommand: undefined,
}}
onOpenParentChat={() => {}}
onArchiveAgent={() => {}}
onUnarchiveAgent={() => {}}
onArchiveAndDeleteWorkspace={() => {}}
hasWorkspace={false}
isSidebarCollapsed={isSidebarCollapsed}
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
/>
<div className="flex flex-1 items-center justify-center text-content-secondary">
Chat not found
</div>{" "}
</div>
);
};
+33 -131
View File
@@ -19,7 +19,6 @@ import type * as TypesGen from "api/typesGenerated";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { ChevronDownIcon } from "components/AnimatedIcons/ChevronDown";
import type { ModelSelectorOption } from "components/ai-elements";
import { Button } from "components/Button/Button";
import {
Combobox,
ComboboxContent,
@@ -29,10 +28,8 @@ import {
ComboboxList,
ComboboxTrigger,
} from "components/Combobox/Combobox";
import { ExternalImage } from "components/ExternalImage/ExternalImage";
import { CoderIcon } from "components/Icons/CoderIcon";
import { useAuthenticated } from "hooks";
import { MonitorIcon, PanelLeftIcon } from "lucide-react";
import { MonitorIcon } from "lucide-react";
import { useDashboard } from "modules/dashboard/useDashboard";
import {
type FC,
@@ -44,15 +41,13 @@ import {
useState,
} from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { NavLink, Outlet, useNavigate, useParams } from "react-router";
import { useNavigate, useParams } from "react-router";
import { toast } from "sonner";
import { cn } from "utils/cn";
import { pageTitle } from "utils/page";
import { createReconnectingWebSocket } from "utils/reconnectingWebSocket";
import { AgentChatInput } from "./AgentChatInput";
import { maybePlayChime } from "./AgentDetail/useAgentChime";
import { AgentsSidebar } from "./AgentsSidebar";
import { ChimeButton } from "./ChimeButton";
import type { AgentsOutletContext } from "./AgentsPageView";
import { AgentsPageView } from "./AgentsPageView";
import { ConfigureAgentsDialog } from "./ConfigureAgentsDialog";
import {
getModelCatalogStatusMessage,
@@ -63,7 +58,6 @@ import {
import { useAgentsPageKeybindings } from "./useAgentsPageKeybindings";
import { useAgentsPWA } from "./useAgentsPWA";
import { useFileAttachments } from "./useFileAttachments";
import { WebPushButton } from "./WebPushButton";
/** @internal Exported for testing. */
export const emptyInputStorageKey = "agents.empty-input";
@@ -73,7 +67,7 @@ const nilUUID = "00000000-0000-0000-0000-000000000000";
type ChatModelOption = ModelSelectorOption;
type CreateChatOptions = {
export type CreateChatOptions = {
message: string;
fileIDs?: string[];
workspaceId?: string;
@@ -94,19 +88,7 @@ function isChatListSSEEvent(
);
}
export interface AgentsOutletContext {
chatErrorReasons: Record<string, string>;
setChatErrorReason: (chatId: string, reason: string) => void;
clearChatErrorReason: (chatId: string) => void;
requestArchiveAgent: (chatId: string) => void;
requestUnarchiveAgent: (chatId: string) => void;
requestArchiveAndDeleteWorkspace: (
chatId: string,
workspaceId: string,
) => void;
isSidebarCollapsed: boolean;
onToggleSidebarCollapsed: () => void;
}
export type { AgentsOutletContext } from "./AgentsPageView";
const AgentsPage: FC = () => {
useAgentsPWA();
@@ -118,7 +100,6 @@ const AgentsPage: FC = () => {
const isAgentsAdmin =
permissions.editDeploymentConfig ||
user.roles.some((role) => role.name === "owner" || role.name === "admin");
const canSetSystemPrompt = isAgentsAdmin;
// The global CSS sets scrollbar-gutter: stable on <html> to prevent
// layout shift on pages that toggle scrollbars. The agents page
@@ -207,8 +188,6 @@ const AgentsPage: FC = () => {
toast.error(getErrorMessage(error, "Failed to unarchive agent."));
},
});
const [isConfigureAgentsDialogOpen, setConfigureAgentsDialogOpen] =
useState(false);
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const [chatErrorReasons, setChatErrorReasons] = useState<
Record<string, string>
@@ -514,108 +493,31 @@ const AgentsPage: FC = () => {
});
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-surface-primary md:flex-row">
<title>{pageTitle("Agents")}</title>
<div
className={cn(
"md:h-full md:w-[320px] md:min-h-0 md:border-b-0",
agentId
? "hidden md:block shrink-0 h-[42dvh] min-h-[240px] border-b border-border-default"
: "order-2 md:order-none flex-1 min-h-0 border-t border-border-default md:flex-none md:border-t-0",
isSidebarCollapsed && "md:hidden",
)}
>
<AgentsSidebar
chats={chatList}
chatErrorReasons={chatErrorReasons}
modelOptions={catalogModelOptions}
modelConfigs={chatModelConfigsQuery.data ?? []}
logoUrl={appearance.logo_url}
onArchiveAgent={requestArchiveAgent}
onUnarchiveAgent={requestUnarchiveAgent}
onArchiveAndDeleteWorkspace={requestArchiveAndDeleteWorkspace}
onNewAgent={handleNewAgent}
isCreating={createMutation.isPending}
isArchiving={isArchiving}
archivingChatId={archivingChatId}
isLoading={chatsQuery.isLoading}
loadError={chatsQuery.isError ? chatsQuery.error : undefined}
onRetryLoad={() => void chatsQuery.refetch()}
onCollapse={() => setIsSidebarCollapsed(true)}
/>
</div>
<div
className={cn(
"flex min-h-0 min-w-0 flex-1 flex-col bg-surface-primary",
!agentId && "order-1 md:order-none flex-none md:flex-1",
)}
>
{agentId ? (
<Outlet key={agentId} context={outletContext} />
) : (
<>
<div className="flex shrink-0 items-center gap-2 px-4 py-0.5">
<NavLink
to="/workspaces"
className="inline-flex shrink-0 md:hidden"
>
{appearance.logo_url ? (
<ExternalImage
className="h-6"
src={appearance.logo_url}
alt="Logo"
/>
) : (
<CoderIcon className="h-6 w-6 fill-content-primary" />
)}
</NavLink>
{isSidebarCollapsed && (
<Button
variant="subtle"
size="icon"
onClick={() => setIsSidebarCollapsed(false)}
aria-label="Expand sidebar"
className="hidden h-7 w-7 min-w-0 shrink-0 md:inline-flex"
>
<PanelLeftIcon />
</Button>
)}
<div className="flex min-w-0 flex-1 items-center" />
<div className="flex items-center gap-2">
<ChimeButton />
<WebPushButton />{" "}
{isAgentsAdmin && (
<Button
variant="subtle"
disabled={createMutation.isPending}
className="h-8 gap-1.5 border-none bg-transparent px-1 text-[13px] shadow-none hover:bg-transparent"
onClick={() => setConfigureAgentsDialogOpen(true)}
>
Admin
</Button>
)}
</div>
</div>
<AgentsEmptyState
onCreateChat={handleCreateChat}
isCreating={createMutation.isPending}
createError={createMutation.error}
modelCatalog={chatModelsQuery.data}
modelOptions={catalogModelOptions}
modelConfigs={chatModelConfigsQuery.data ?? []}
isModelCatalogLoading={chatModelsQuery.isLoading}
isModelConfigsLoading={chatModelConfigsQuery.isLoading}
modelCatalogError={chatModelsQuery.error}
canSetSystemPrompt={canSetSystemPrompt}
canManageChatModelConfigs={isAgentsAdmin}
isConfigureAgentsDialogOpen={isConfigureAgentsDialogOpen}
onConfigureAgentsDialogOpenChange={setConfigureAgentsDialogOpen}
/>
</>
)}
</div>
</div>
<AgentsPageView
agentId={agentId}
chatList={chatList}
catalogModelOptions={catalogModelOptions}
modelConfigs={chatModelConfigsQuery.data ?? []}
logoUrl={appearance.logo_url}
handleNewAgent={handleNewAgent}
isCreating={createMutation.isPending}
isArchiving={isArchiving}
archivingChatId={archivingChatId}
isChatsLoading={chatsQuery.isLoading}
chatsLoadError={chatsQuery.error}
onRetryChatsLoad={() => void chatsQuery.refetch()}
onCollapseSidebar={() => setIsSidebarCollapsed(true)}
isSidebarCollapsed={isSidebarCollapsed}
onExpandSidebar={() => setIsSidebarCollapsed(false)}
outletContext={outletContext}
onCreateChat={handleCreateChat}
createError={createMutation.error}
modelCatalog={chatModelsQuery.data}
isModelCatalogLoading={chatModelsQuery.isLoading}
isModelConfigsLoading={chatModelConfigsQuery.isLoading}
modelCatalogError={chatModelsQuery.error}
isAgentsAdmin={isAgentsAdmin}
/>
);
};
@@ -673,7 +575,7 @@ export function useEmptyStateDraft() {
};
}
interface AgentsEmptyStateProps {
interface AgentCreateFormProps {
onCreateChat: (options: CreateChatOptions) => Promise<void>;
isCreating: boolean;
createError: unknown;
@@ -689,7 +591,7 @@ interface AgentsEmptyStateProps {
onConfigureAgentsDialogOpenChange: (open: boolean) => void;
}
export const AgentsEmptyState: FC<AgentsEmptyStateProps> = ({
export const AgentCreateForm: FC<AgentCreateFormProps> = ({
onCreateChat,
isCreating,
createError,
@@ -0,0 +1,301 @@
import { MockUserOwner } from "testHelpers/entities";
import { withAuthProvider, withDashboardProvider } from "testHelpers/storybook";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { API } from "api/api";
import type * as TypesGen from "api/typesGenerated";
import type { Chat } from "api/typesGenerated";
import type { ModelSelectorOption } from "components/ai-elements";
import { fn, spyOn } from "storybook/test";
import { reactRouterParameters } from "storybook-addon-remix-react-router";
import { AgentsPageView } from "./AgentsPageView";
const defaultModelOptions: ModelSelectorOption[] = [
{
id: "openai:gpt-4o",
provider: "openai",
model: "gpt-4o",
displayName: "GPT-4o",
},
];
const defaultModelConfigs: TypesGen.ChatModelConfig[] = [
{
id: "config-openai-gpt-4o",
provider: "openai",
model: "gpt-4o",
display_name: "GPT-4o",
enabled: true,
is_default: false,
context_limit: 200000,
compression_threshold: 70,
created_at: "2026-02-18T00:00:00.000Z",
updated_at: "2026-02-18T00:00:00.000Z",
},
];
const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
const todayTimestamp = new Date().toISOString();
const buildChat = (overrides: Partial<Chat> = {}): Chat => ({
id: "chat-default",
owner_id: "owner-1",
title: "Agent",
status: "completed",
last_model_config_id: defaultModelConfigs[0].id,
created_at: oneWeekAgo,
updated_at: oneWeekAgo,
archived: false,
last_error: null,
...overrides,
});
const agentsRouting = [
{ path: "/agents/:agentId", useStoryElement: true },
{ path: "/agents", useStoryElement: true },
] satisfies [
{ path: string; useStoryElement: boolean },
...{ path: string; useStoryElement: boolean }[],
];
const meta: Meta<typeof AgentsPageView> = {
title: "pages/AgentsPage/AgentsPageView",
component: AgentsPageView,
decorators: [withAuthProvider, withDashboardProvider],
parameters: {
layout: "fullscreen",
user: MockUserOwner,
reactRouter: reactRouterParameters({
location: { path: "/agents" },
routing: agentsRouting,
}),
},
args: {
agentId: undefined,
chatList: [],
catalogModelOptions: defaultModelOptions,
modelConfigs: defaultModelConfigs,
logoUrl: "",
handleNewAgent: fn(),
isCreating: false,
isArchiving: false,
archivingChatId: undefined,
isChatsLoading: false,
chatsLoadError: null,
onRetryChatsLoad: fn(),
onCollapseSidebar: fn(),
isSidebarCollapsed: false,
onExpandSidebar: fn(),
outletContext: {
chatErrorReasons: {},
setChatErrorReason: fn(),
clearChatErrorReason: fn(),
requestArchiveAgent: fn(),
requestUnarchiveAgent: fn(),
requestArchiveAndDeleteWorkspace: fn(),
isSidebarCollapsed: false,
onToggleSidebarCollapsed: fn(),
},
isAgentsAdmin: false,
onCreateChat: fn(),
createError: undefined,
modelCatalog: undefined,
isModelCatalogLoading: false,
isModelConfigsLoading: false,
modelCatalogError: undefined,
},
beforeEach: () => {
spyOn(API, "getWorkspaces").mockResolvedValue({
workspaces: [],
count: 0,
});
},
};
export default meta;
type Story = StoryObj<typeof AgentsPageView>;
export const EmptyState: Story = {};
export const WithChatList: Story = {
args: {
chatList: [
buildChat({
id: "chat-1",
title: "Refactor authentication module",
status: "completed",
updated_at: todayTimestamp,
}),
buildChat({
id: "chat-2",
title: "Add unit tests for API layer",
status: "running",
updated_at: todayTimestamp,
}),
buildChat({
id: "chat-3",
title: "Fix database migration issue",
status: "error",
last_error: "Connection timeout",
updated_at: todayTimestamp,
}),
buildChat({
id: "chat-4",
title: "Update CI/CD pipeline config",
status: "waiting",
updated_at: todayTimestamp,
}),
buildChat({
id: "chat-5",
title: "Implement WebSocket handler",
status: "completed",
updated_at: todayTimestamp,
}),
buildChat({
id: "chat-6",
title: "Debug memory leak in worker",
status: "paused",
updated_at: todayTimestamp,
}),
],
},
};
export const LoadingChats: Story = {
args: {
isChatsLoading: true,
chatList: [],
},
};
export const ChatsLoadError: Story = {
args: {
chatsLoadError: new Error("Failed to fetch chats"),
},
};
export const SidebarCollapsed: Story = {
args: {
isSidebarCollapsed: true,
chatList: [
buildChat({
id: "chat-1",
title: "Collapsed sidebar agent",
updated_at: todayTimestamp,
}),
],
outletContext: {
chatErrorReasons: {},
setChatErrorReason: fn(),
clearChatErrorReason: fn(),
requestArchiveAgent: fn(),
requestUnarchiveAgent: fn(),
requestArchiveAndDeleteWorkspace: fn(),
isSidebarCollapsed: true,
onToggleSidebarCollapsed: fn(),
},
},
};
export const WithToolbarEndContent: Story = {
args: {
isAgentsAdmin: true,
},
};
export const CreatingAgent: Story = {
args: {
isCreating: true,
chatList: [
buildChat({
id: "chat-1",
title: "Existing agent",
updated_at: todayTimestamp,
}),
],
},
};
export const ArchivingAgent: Story = {
args: {
isArchiving: true,
archivingChatId: "chat-1",
chatList: [
buildChat({
id: "chat-1",
title: "Agent being archived",
updated_at: todayTimestamp,
}),
buildChat({
id: "chat-2",
title: "Another agent",
updated_at: todayTimestamp,
}),
],
},
};
export const WithAgentSelected: Story = {
args: {
agentId: "chat-1",
chatList: [
buildChat({
id: "chat-1",
title: "Selected agent",
status: "running",
updated_at: todayTimestamp,
}),
buildChat({
id: "chat-2",
title: "Another agent",
updated_at: todayTimestamp,
}),
],
},
parameters: {
reactRouter: reactRouterParameters({
location: {
path: "/agents/chat-1",
pathParams: { agentId: "chat-1" },
},
routing: agentsRouting,
}),
},
};
export const WithErrorReasons: Story = {
args: {
chatList: [
buildChat({
id: "chat-1",
title: "Rate limited agent",
status: "error",
updated_at: todayTimestamp,
}),
buildChat({
id: "chat-2",
title: "Healthy agent",
status: "running",
updated_at: todayTimestamp,
}),
buildChat({
id: "chat-3",
title: "Another errored agent",
status: "error",
updated_at: todayTimestamp,
}),
],
outletContext: {
chatErrorReasons: {
"chat-1": "Model rate limited",
"chat-3": "Context window exceeded",
},
setChatErrorReason: fn(),
clearChatErrorReason: fn(),
requestArchiveAgent: fn(),
requestUnarchiveAgent: fn(),
requestArchiveAndDeleteWorkspace: fn(),
isSidebarCollapsed: false,
onToggleSidebarCollapsed: fn(),
},
},
};
@@ -0,0 +1,191 @@
import type * as TypesGen from "api/typesGenerated";
import type { ModelSelectorOption } from "components/ai-elements";
import { Button } from "components/Button/Button";
import { ExternalImage } from "components/ExternalImage/ExternalImage";
import { CoderIcon } from "components/Icons/CoderIcon";
import { PanelLeftIcon } from "lucide-react";
import { type FC, useState } from "react";
import { NavLink, Outlet } from "react-router";
import { cn } from "utils/cn";
import { pageTitle } from "utils/page";
import { AgentCreateForm, type CreateChatOptions } from "./AgentsPage";
import { AgentsSidebar } from "./AgentsSidebar";
import { ChimeButton } from "./ChimeButton";
import { WebPushButton } from "./WebPushButton";
type ChatModelOption = ModelSelectorOption;
export interface AgentsOutletContext {
chatErrorReasons: Record<string, string>;
setChatErrorReason: (chatId: string, reason: string) => void;
clearChatErrorReason: (chatId: string) => void;
requestArchiveAgent: (chatId: string) => void;
requestUnarchiveAgent: (chatId: string) => void;
requestArchiveAndDeleteWorkspace: (
chatId: string,
workspaceId: string,
) => void;
isSidebarCollapsed: boolean;
onToggleSidebarCollapsed: () => void;
}
interface AgentsPageViewProps {
agentId: string | undefined;
chatList: TypesGen.Chat[];
catalogModelOptions: readonly ChatModelOption[];
modelConfigs: readonly TypesGen.ChatModelConfig[];
logoUrl: string;
handleNewAgent: () => void;
isCreating: boolean;
isArchiving: boolean;
archivingChatId: string | undefined;
isChatsLoading: boolean;
chatsLoadError: Error | null;
onRetryChatsLoad: () => void;
onCollapseSidebar: () => void;
isSidebarCollapsed: boolean;
onExpandSidebar: () => void;
outletContext: AgentsOutletContext;
isAgentsAdmin: boolean;
onCreateChat: (options: CreateChatOptions) => Promise<void>;
createError: unknown;
modelCatalog: TypesGen.ChatModelsResponse | null | undefined;
isModelCatalogLoading: boolean;
isModelConfigsLoading: boolean;
modelCatalogError: unknown;
}
export const AgentsPageView: FC<AgentsPageViewProps> = ({
agentId,
chatList,
catalogModelOptions,
modelConfigs,
logoUrl,
handleNewAgent,
isCreating,
isArchiving,
archivingChatId,
isChatsLoading,
chatsLoadError,
onRetryChatsLoad,
onCollapseSidebar,
isSidebarCollapsed,
onExpandSidebar,
outletContext,
isAgentsAdmin,
onCreateChat,
createError,
modelCatalog,
isModelCatalogLoading,
isModelConfigsLoading,
modelCatalogError,
}) => {
const {
chatErrorReasons,
requestArchiveAgent,
requestUnarchiveAgent,
requestArchiveAndDeleteWorkspace,
} = outletContext;
const [isConfigureAgentsDialogOpen, setConfigureAgentsDialogOpen] =
useState(false);
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-surface-primary md:flex-row">
<title>{pageTitle("Agents")}</title>
<div
className={cn(
"md:h-full md:w-[320px] md:min-h-0 md:border-b-0",
agentId
? "hidden md:block shrink-0 h-[42dvh] min-h-[240px] border-b border-border-default"
: "order-2 md:order-none flex-1 min-h-0 border-t border-border-default md:flex-none md:border-t-0",
isSidebarCollapsed && "md:hidden",
)}
>
<AgentsSidebar
chats={chatList}
chatErrorReasons={chatErrorReasons}
modelOptions={catalogModelOptions}
modelConfigs={modelConfigs}
logoUrl={logoUrl}
onArchiveAgent={requestArchiveAgent}
onUnarchiveAgent={requestUnarchiveAgent}
onArchiveAndDeleteWorkspace={requestArchiveAndDeleteWorkspace}
onNewAgent={handleNewAgent}
isCreating={isCreating}
isArchiving={isArchiving}
archivingChatId={archivingChatId}
isLoading={isChatsLoading}
loadError={chatsLoadError}
onRetryLoad={onRetryChatsLoad}
onCollapse={onCollapseSidebar}
/>
</div>
<div
className={cn(
"flex min-h-0 min-w-0 flex-1 flex-col bg-surface-primary",
!agentId && "order-1 md:order-none flex-none md:flex-1",
)}
>
{agentId ? (
<Outlet key={agentId} context={outletContext} />
) : (
<>
<div className="flex shrink-0 items-center gap-2 px-4 py-0.5">
<NavLink
to="/workspaces"
className="inline-flex shrink-0 md:hidden"
>
{logoUrl ? (
<ExternalImage className="h-6" src={logoUrl} alt="Logo" />
) : (
<CoderIcon className="h-6 w-6 fill-content-primary" />
)}
</NavLink>
{isSidebarCollapsed && (
<Button
variant="subtle"
size="icon"
onClick={onExpandSidebar}
aria-label="Expand sidebar"
className="hidden h-7 w-7 min-w-0 shrink-0 md:inline-flex"
>
<PanelLeftIcon />
</Button>
)}
<div className="flex min-w-0 flex-1 items-center" />
<div className="flex items-center gap-2">
<ChimeButton />
<WebPushButton />
{isAgentsAdmin && (
<Button
variant="subtle"
disabled={isCreating}
className="h-8 gap-1.5 border-none bg-transparent px-1 text-[13px] shadow-none hover:bg-transparent"
onClick={() => setConfigureAgentsDialogOpen(true)}
>
Admin
</Button>
)}
</div>
</div>
<AgentCreateForm
onCreateChat={onCreateChat}
isCreating={isCreating}
createError={createError}
modelCatalog={modelCatalog}
modelOptions={catalogModelOptions}
modelConfigs={modelConfigs}
isModelCatalogLoading={isModelCatalogLoading}
isModelConfigsLoading={isModelConfigsLoading}
modelCatalogError={modelCatalogError}
canSetSystemPrompt={isAgentsAdmin}
canManageChatModelConfigs={isAgentsAdmin}
isConfigureAgentsDialogOpen={isConfigureAgentsDialogOpen}
onConfigureAgentsDialogOpenChange={setConfigureAgentsDialogOpen}
/>
</>
)}
</div>
</div>
);
};