mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
refactor(site): separate AgentsPage and AgentDetail into container/view pairs (#22812)
This commit is contained in:
+5
-5
@@ -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 = {};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user