Compare commits

...

1 Commits

Author SHA1 Message Date
Danielle Maywood 475dec06df refactor(site): remove createPortal/ref-slot pattern from AgentsPage
Replace the createPortal + useRef slot pattern with direct layout
composition. The parent (AgentsPage) now renders all shell chrome
(top bar title, top bar actions, right panel content) directly,
instead of providing empty <div ref={...}> targets that children
portal into.

Changes:
- Flatten /agents route: remove nested Outlet, render AgentDetail
  directly inside AgentsPage as a child component with explicit props
- Convert AgentDetail from default export with useOutletContext to
  named export with AgentDetailProps interface
- Lift top bar content via onTopBarChange callback + useState in parent
- Move DiffStatsBadge and dropdown menu from TopBarPortals into
  AgentDetail's onTopBarChange useEffect
- Change DiffRightPanel to accept children instead of ref
- Move admin button and ConfigureAgentsDialog from AgentsEmptyState
  into AgentsPage
- Delete TopBarPortals.tsx entirely
- Simplify Storybook stories (remove portal/outlet wrappers)
2026-02-28 17:06:22 +00:00
7 changed files with 306 additions and 577 deletions
@@ -14,59 +14,10 @@ import {
} from "api/queries/chats";
import { workspaceByIdKey } from "api/queries/workspaces";
import type * as TypesGen from "api/typesGenerated";
import { type FC, useRef, useState } from "react";
import { Outlet } from "react-router";
import { fn } from "storybook/test";
import { expect, spyOn, userEvent, waitFor, within } from "storybook/test";
import {
reactRouterOutlet,
reactRouterParameters,
} from "storybook-addon-remix-react-router";
import AgentDetail from "./AgentDetail";
import type { AgentsOutletContext } from "./AgentsPage";
// ---------------------------------------------------------------------------
// Layout wrapper provides portal targets for the top-bar and right panel
// so the component can render its portaled actions menu and diff panel.
// ---------------------------------------------------------------------------
const AgentDetailLayout: FC = () => {
const topBarTitleRef = useRef<HTMLDivElement>(null);
const topBarActionsRef = useRef<HTMLDivElement>(null);
const rightPanelRef = useRef<HTMLDivElement>(null);
const [rightPanelOpen, setRightPanelOpen] = useState(false);
return (
<div className="flex h-full">
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex items-center gap-2 border-b border-border px-4 py-2">
<div ref={topBarTitleRef} className="flex-1" />
<div ref={topBarActionsRef} />
</div>
<div className="flex-1 overflow-hidden">
<Outlet
context={
{
chatErrorReasons: {},
setChatErrorReason: () => {},
clearChatErrorReason: () => {},
topBarTitleRef,
topBarActionsRef,
rightPanelRef,
setRightPanelOpen,
requestArchiveAgent: () => {},
} satisfies AgentsOutletContext
}
/>
</div>
</div>
<div
ref={rightPanelRef}
className={
rightPanelOpen ? "w-[400px] border-l border-border" : "hidden"
}
/>
</div>
);
};
import { reactRouterParameters } from "storybook-addon-remix-react-router";
import { AgentDetail } from "./AgentDetail";
// ---------------------------------------------------------------------------
// Shared mock data
@@ -164,10 +115,19 @@ const wrapSSE = (payload: unknown): string =>
// ---------------------------------------------------------------------------
// Meta
// ---------------------------------------------------------------------------
const meta: Meta<typeof AgentDetailLayout> = {
const meta: Meta<typeof AgentDetail> = {
title: "pages/AgentsPage/AgentDetail",
component: AgentDetailLayout,
component: AgentDetail,
decorators: [withAuthProvider, withWebSocket],
args: {
agentId: CHAT_ID,
chatErrorReasons: {},
setChatErrorReason: fn(),
clearChatErrorReason: fn(),
requestArchiveAgent: fn(),
onDiffPanelStateChange: fn(),
onTopBarChange: fn(),
},
parameters: {
layout: "fullscreen",
user: MockUserOwner,
@@ -177,7 +137,6 @@ const meta: Meta<typeof AgentDetailLayout> = {
path: `/agents/${CHAT_ID}`,
pathParams: { agentId: CHAT_ID },
},
routing: reactRouterOutlet({ path: "/agents/:agentId" }, <AgentDetail />),
}),
},
beforeEach: () => {
@@ -186,7 +145,7 @@ const meta: Meta<typeof AgentDetailLayout> = {
};
export default meta;
type Story = StoryObj<typeof AgentDetailLayout>;
type Story = StoryObj<typeof AgentDetail>;
// ---------------------------------------------------------------------------
// Stories
@@ -195,7 +154,7 @@ type Story = StoryObj<typeof AgentDetailLayout>;
/** Skeleton placeholder when no query data is available yet. */
export const Loading: Story = {};
/** Full layout with actions menu and diff panel portaled to the right slot. */
/** Full layout with actions menu and diff panel. */
export const CompletedWithDiffPanel: Story = {
parameters: {
queries: buildQueries(
@@ -216,7 +175,7 @@ export const CompletedWithDiffPanel: Story = {
const canvas = within(canvasElement);
const user = userEvent.setup();
// Wait for the actions menu trigger to appear in the top bar.
// Wait for the actions menu trigger to appear.
const menuTrigger = await canvas.findByRole("button", {
name: "Open agent actions",
});
+196 -75
View File
@@ -25,7 +25,7 @@ import {
useState,
} from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { useNavigate, useOutletContext, useParams } from "react-router";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import { pageTitle } from "utils/page";
import { AgentChatInput } from "./AgentChatInput";
@@ -54,9 +54,7 @@ import {
parseMessagesWithMergedTools,
} from "./AgentDetail/messageParsing";
import { buildStreamTools } from "./AgentDetail/streamState";
import { AgentDetailTopBarPortals } from "./AgentDetail/TopBarPortals";
import { useMessageWindow } from "./AgentDetail/useMessageWindow";
import type { AgentsOutletContext } from "./AgentsPage";
import {
getModelCatalogStatusMessage,
getModelOptionsFromCatalog,
@@ -64,14 +62,6 @@ import {
hasConfiguredModelsInCatalog,
} from "./modelOptions";
const noopSetChatErrorReason: AgentsOutletContext["setChatErrorReason"] =
() => {};
const noopClearChatErrorReason: AgentsOutletContext["clearChatErrorReason"] =
() => {};
const noopSetRightPanelOpen: AgentsOutletContext["setRightPanelOpen"] =
() => {};
const noopRequestArchiveAgent: AgentsOutletContext["requestArchiveAgent"] =
() => {};
const lastModelConfigIDStorageKey = "agents.last-model-config-id";
type ChatStoreHandle = ReturnType<typeof useChatStore>["store"];
@@ -456,25 +446,97 @@ const AgentDetailConversation: FC<AgentDetailConversationProps> = ({
);
};
const AgentDetail: FC = () => {
import type { ChatDiffStatusResponse } from "api/api";
import { Button } from "components/Button/Button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "components/DropdownMenu/DropdownMenu";
import {
ArchiveIcon,
ChevronRightIcon,
EllipsisIcon,
ExternalLinkIcon,
MonitorIcon,
PanelRightCloseIcon,
PanelRightOpenIcon,
} from "lucide-react";
import type { ReactNode } from "react";
export interface TopBarContent {
title: ReactNode;
actions: ReactNode;
}
interface DiffStatsBadgeProps {
status: ChatDiffStatusResponse;
isOpen: boolean;
onToggle: () => void;
}
const DiffStatsBadge: FC<DiffStatsBadgeProps> = ({
status,
isOpen,
onToggle,
}) => {
const additions = status.additions ?? 0;
const deletions = status.deletions ?? 0;
return (
<div
role="button"
tabIndex={0}
onClick={onToggle}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
onToggle();
}
}}
className="flex cursor-pointer items-center gap-3 px-2 py-1 text-content-secondary transition-colors hover:text-content-primary"
>
<span className="font-mono text-sm font-semibold text-content-success">
+{additions}
</span>
<span className="font-mono text-sm font-semibold text-content-destructive">
{deletions}
</span>
{isOpen ? (
<PanelRightCloseIcon className="h-4 w-4" />
) : (
<PanelRightOpenIcon className="h-4 w-4" />
)}
</div>
);
};
export interface AgentDetailProps {
agentId: string;
chatErrorReasons: Record<string, string>;
setChatErrorReason: (chatId: string, reason: string) => void;
clearChatErrorReason: (chatId: string) => void;
requestArchiveAgent: (chatId: string) => void;
onDiffPanelStateChange: (isOpen: boolean) => void;
onTopBarChange: (content: TopBarContent | null) => void;
}
export const AgentDetail: FC<AgentDetailProps> = ({
agentId,
chatErrorReasons,
setChatErrorReason,
clearChatErrorReason,
requestArchiveAgent,
onDiffPanelStateChange,
onTopBarChange,
}) => {
const navigate = useNavigate();
const { agentId } = useParams<{ agentId: string }>();
const outletContext = useOutletContext<AgentsOutletContext | undefined>();
const queryClient = useQueryClient();
const [selectedModel, setSelectedModel] = useState("");
const [showDiffPanel, setShowDiffPanel] = useState(false);
const [pendingEditMessageId, setPendingEditMessageId] = useState<
number | null
>(null);
const chatErrorReasons = outletContext?.chatErrorReasons ?? {};
const setChatErrorReason =
outletContext?.setChatErrorReason ?? noopSetChatErrorReason;
const clearChatErrorReason =
outletContext?.clearChatErrorReason ?? noopClearChatErrorReason;
const setRightPanelOpen =
outletContext?.setRightPanelOpen ?? noopSetRightPanelOpen;
const requestArchiveAgent =
outletContext?.requestArchiveAgent ?? noopRequestArchiveAgent;
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
// When switching between chats, reset the scroll container to the
@@ -489,7 +551,7 @@ const AgentDetail: FC = () => {
}, [agentId]);
const chatQuery = useQuery({
...chat(agentId ?? ""),
...chat(agentId),
enabled: Boolean(agentId),
});
const chatsQuery = useQuery(chats());
@@ -526,13 +588,13 @@ const AgentDetail: FC = () => {
// Notify the parent layout about right panel visibility. This
// useEffect is necessary because we're synchronizing with state
// owned by the parent outlet, not adjusting our own state.
// owned by the parent, not adjusting our own state.
useEffect(() => {
setRightPanelOpen(hasDiffStatus && showDiffPanel);
onDiffPanelStateChange(hasDiffStatus && showDiffPanel);
return () => {
setRightPanelOpen(false);
onDiffPanelStateChange(false);
};
}, [hasDiffStatus, setRightPanelOpen, showDiffPanel]);
}, [hasDiffStatus, onDiffPanelStateChange, showDiffPanel]);
const modelOptions = useMemo(
() =>
@@ -572,17 +634,17 @@ const AgentDetail: FC = () => {
}, [modelConfigIDByModelID]);
const sendMutation = useMutation(
createChatMessage(queryClient, agentId ?? ""),
createChatMessage(queryClient, agentId),
);
const editMutation = useMutation(editChatMessage(queryClient, agentId ?? ""));
const editMutation = useMutation(editChatMessage(queryClient, agentId));
const interruptMutation = useMutation(
interruptChat(queryClient, agentId ?? ""),
interruptChat(queryClient, agentId),
);
const deleteQueuedMutation = useMutation(
deleteChatQueuedMessage(queryClient, agentId ?? ""),
deleteChatQueuedMessage(queryClient, agentId),
);
const promoteQueuedMutation = useMutation(
promoteChatQueuedMessage(queryClient, agentId ?? ""),
promoteChatQueuedMessage(queryClient, agentId),
);
const { store, clearStreamError } = useChatStore({
@@ -649,7 +711,6 @@ const AgentDetail: FC = () => {
if (
!message.trim() ||
isSubmissionPending ||
!agentId ||
!hasModelOptions
) {
return;
@@ -743,7 +804,7 @@ const AgentDetail: FC = () => {
};
const handleInterrupt = () => {
if (!agentId || interruptMutation.isPending) {
if (interruptMutation.isPending) {
return;
}
void interruptMutation.mutateAsync();
@@ -787,9 +848,6 @@ const AgentDetail: FC = () => {
[promoteQueuedMutation, store],
);
const topBarTitleRef = outletContext?.topBarTitleRef;
const topBarActionsRef = outletContext?.topBarActionsRef;
const rightPanelRef = outletContext?.rightPanelRef;
const chatTitle = chatQuery.data?.chat?.title;
// Update the browser tab title when navigating to / between agents.
@@ -811,9 +869,8 @@ const AgentDetail: FC = () => {
: null;
const canOpenWorkspace = Boolean(workspaceRoute);
const canOpenEditors = Boolean(workspace && workspaceAgent);
const shouldShowDiffPanel = hasDiffStatus && showDiffPanel;
const handleOpenInEditor = async (editor: "cursor" | "vscode") => {
const handleOpenInEditor = useCallback(async (editor: "cursor" | "vscode") => {
if (!workspace || !workspaceAgent) {
return;
}
@@ -852,21 +909,110 @@ const AgentDetail: FC = () => {
: "Failed to open in VS Code.",
);
}
};
}, [workspace, workspaceAgent]);
const handleViewWorkspace = () => {
const handleViewWorkspace = useCallback(() => {
if (!workspaceRoute) {
return;
}
navigate(workspaceRoute);
};
}, [navigate, workspaceRoute]);
const handleArchiveAgentAction = () => {
if (!agentId) {
return;
}
const handleArchiveAgentAction = useCallback(() => {
requestArchiveAgent(agentId);
};
}, [requestArchiveAgent, agentId]);
// Notify the parent about top bar content whenever relevant
// values change. The parent renders this content in the top bar
// slots without portals.
useEffect(() => {
onTopBarChange({
title: chatTitle ? (
<div className="flex min-w-0 items-center gap-1.5">
{parentChat && (
<>
<Button
size="sm"
variant="subtle"
className="h-auto max-w-[16rem] rounded-sm px-1 py-0.5 text-xs text-content-secondary shadow-none hover:bg-transparent hover:text-content-primary"
onClick={() => navigate(`/agents/${parentChat.id}`)}
>
<span className="truncate">{parentChat.title}</span>
</Button>
<ChevronRightIcon className="h-3.5 w-3.5 shrink-0 text-content-secondary/70" />
</>
)}
<span className="truncate text-sm text-content-primary">
{chatTitle}
</span>
</div>
) : null,
actions: (
<>
{hasDiffStatus && diffStatusQuery.data && (
<DiffStatsBadge
status={diffStatusQuery.data}
isOpen={showDiffPanel}
onToggle={() => setShowDiffPanel((prev) => !prev)}
/>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="subtle"
className="h-7 w-7 text-content-secondary hover:text-content-primary"
aria-label="Open agent actions"
>
<EllipsisIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
disabled={!canOpenEditors}
onSelect={() => {
void handleOpenInEditor("cursor");
}}
>
<ExternalLinkIcon className="h-3.5 w-3.5" />
Open in Cursor
</DropdownMenuItem>
<DropdownMenuItem
disabled={!canOpenEditors}
onSelect={() => {
void handleOpenInEditor("vscode");
}}
>
<ExternalLinkIcon className="h-3.5 w-3.5" />
Open in VS Code
</DropdownMenuItem>
<DropdownMenuItem
disabled={!canOpenWorkspace}
onSelect={handleViewWorkspace}
>
<MonitorIcon className="h-3.5 w-3.5" />
View Workspace
</DropdownMenuItem>
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
onSelect={handleArchiveAgentAction}
>
<ArchiveIcon className="h-3.5 w-3.5" />
Archive Agent
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
),
});
return () => onTopBarChange(null);
}, [
onTopBarChange, chatTitle, parentChat, hasDiffStatus,
diffStatusQuery.data, showDiffPanel, canOpenEditors,
canOpenWorkspace, handleViewWorkspace, handleArchiveAgentAction,
navigate, handleOpenInEditor,
]);
if (chatQuery.isLoading) {
return (
@@ -920,7 +1066,7 @@ const AgentDetail: FC = () => {
);
}
if (!chatQuery.data || !agentId) {
if (!chatQuery.data) {
return (
<div className="flex flex-1 items-center justify-center text-content-secondary">
Chat not found
@@ -930,31 +1076,6 @@ const AgentDetail: FC = () => {
return (
<div className="relative flex h-full min-h-0 min-w-0 flex-1 flex-col">
<AgentDetailTopBarPortals
topBarTitleRef={topBarTitleRef}
topBarActionsRef={topBarActionsRef}
rightPanelRef={rightPanelRef}
chatTitle={chatTitle}
parentChat={parentChat}
onOpenParentChat={(chatId) => navigate(`/agents/${chatId}`)}
diff={{
hasDiffStatus,
diffStatus: diffStatusQuery.data,
showDiffPanel,
onToggleFilesChanged: () => setShowDiffPanel((prev) => !prev),
}}
workspace={{
canOpenEditors,
canOpenWorkspace,
onOpenInEditor: (editor) => {
void handleOpenInEditor(editor);
},
onViewWorkspace: handleViewWorkspace,
}}
onArchiveAgent={handleArchiveAgentAction}
shouldShowDiffPanel={shouldShowDiffPanel}
agentId={agentId}
/>
<div
aria-hidden
@@ -998,4 +1119,4 @@ const AgentDetail: FC = () => {
);
};
export default AgentDetail;
@@ -1,199 +0,0 @@
import type { ChatDiffStatusResponse } from "api/api";
import type * as TypesGen from "api/typesGenerated";
import { Button } from "components/Button/Button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "components/DropdownMenu/DropdownMenu";
import {
ArchiveIcon,
ChevronRightIcon,
EllipsisIcon,
ExternalLinkIcon,
MonitorIcon,
PanelRightCloseIcon,
PanelRightOpenIcon,
} from "lucide-react";
import type { FC, RefObject } from "react";
import { createPortal } from "react-dom";
import { FilesChangedPanel } from "../FilesChangedPanel";
interface DiffStatsBadgeProps {
status: ChatDiffStatusResponse;
isOpen: boolean;
onToggle: () => void;
}
const DiffStatsBadge: FC<DiffStatsBadgeProps> = ({
status,
isOpen,
onToggle,
}) => {
const additions = status.additions ?? 0;
const deletions = status.deletions ?? 0;
return (
<div
role="button"
tabIndex={0}
onClick={onToggle}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
onToggle();
}
}}
className="flex cursor-pointer items-center gap-3 px-2 py-1 text-content-secondary transition-colors hover:text-content-primary"
>
<span className="font-mono text-sm font-semibold text-content-success">
+{additions}
</span>
<span className="font-mono text-sm font-semibold text-content-destructive">
{deletions}
</span>
{isOpen ? (
<PanelRightCloseIcon className="h-4 w-4" />
) : (
<PanelRightOpenIcon className="h-4 w-4" />
)}
</div>
);
};
interface DiffPanelState {
hasDiffStatus: boolean;
diffStatus: ChatDiffStatusResponse | undefined;
showDiffPanel: boolean;
onToggleFilesChanged: () => void;
}
interface WorkspaceActions {
canOpenEditors: boolean;
canOpenWorkspace: boolean;
onOpenInEditor: (editor: "cursor" | "vscode") => void;
onViewWorkspace: () => void;
}
type AgentDetailTopBarPortalsProps = {
topBarTitleRef?: RefObject<HTMLDivElement | null>;
topBarActionsRef?: RefObject<HTMLDivElement | null>;
rightPanelRef?: RefObject<HTMLDivElement | null>;
chatTitle?: string;
parentChat?: TypesGen.Chat;
onOpenParentChat: (chatId: string) => void;
diff: DiffPanelState;
workspace: WorkspaceActions;
onArchiveAgent: () => void;
shouldShowDiffPanel: boolean;
agentId: string;
};
export const AgentDetailTopBarPortals: FC<AgentDetailTopBarPortalsProps> = ({
topBarTitleRef,
topBarActionsRef,
rightPanelRef,
chatTitle,
parentChat,
onOpenParentChat,
diff,
workspace,
onArchiveAgent,
shouldShowDiffPanel,
agentId,
}) => {
return (
<>
{chatTitle &&
topBarTitleRef?.current &&
createPortal(
<div className="flex min-w-0 items-center gap-1.5">
{parentChat && (
<>
<Button
size="sm"
variant="subtle"
className="h-auto max-w-[16rem] rounded-sm px-1 py-0.5 text-xs text-content-secondary shadow-none hover:bg-transparent hover:text-content-primary"
onClick={() => onOpenParentChat(parentChat.id)}
>
<span className="truncate">{parentChat.title}</span>
</Button>
<ChevronRightIcon className="h-3.5 w-3.5 shrink-0 text-content-secondary/70" />
</>
)}
<span className="truncate text-sm text-content-primary">
{chatTitle}
</span>
</div>,
topBarTitleRef.current,
)}
{diff.hasDiffStatus &&
diff.diffStatus &&
topBarActionsRef?.current &&
createPortal(
<DiffStatsBadge
status={diff.diffStatus}
isOpen={diff.showDiffPanel}
onToggle={diff.onToggleFilesChanged}
/>,
topBarActionsRef.current,
)}
{topBarActionsRef?.current &&
createPortal(
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="subtle"
className="h-7 w-7 text-content-secondary hover:text-content-primary"
aria-label="Open agent actions"
>
<EllipsisIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
disabled={!workspace.canOpenEditors}
onSelect={() => {
workspace.onOpenInEditor("cursor");
}}
>
<ExternalLinkIcon className="h-3.5 w-3.5" />
Open in Cursor
</DropdownMenuItem>
<DropdownMenuItem
disabled={!workspace.canOpenEditors}
onSelect={() => {
workspace.onOpenInEditor("vscode");
}}
>
<ExternalLinkIcon className="h-3.5 w-3.5" />
Open in VS Code
</DropdownMenuItem>
<DropdownMenuItem
disabled={!workspace.canOpenWorkspace}
onSelect={workspace.onViewWorkspace}
>
<MonitorIcon className="h-3.5 w-3.5" />
View Workspace
</DropdownMenuItem>
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
onSelect={onArchiveAgent}
>
<ArchiveIcon className="h-3.5 w-3.5" />
Archive Agent
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>,
topBarActionsRef.current,
)}
{shouldShowDiffPanel &&
rightPanelRef?.current &&
createPortal(
<FilesChangedPanel chatId={agentId} />,
rightPanelRef.current,
)}
</>
);
};
@@ -1,11 +1,9 @@
import { MockWorkspace } from "testHelpers/entities";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { API } from "api/api";
import { useRef } from "react";
import {
expect,
fn,
screen,
spyOn,
userEvent,
waitFor,
@@ -22,34 +20,9 @@ const modelOptions = [
},
] as const;
const behaviorStorageKey = "agents.system-prompt";
/**
* Wrapper that creates the top-bar actions ref that AgentsEmptyState
* portals its admin button into.
*/
const AgentsEmptyStateWithPortal = (
props: Omit<
React.ComponentProps<typeof AgentsEmptyState>,
"topBarActionsRef"
>,
) => {
const topBarActionsRef = useRef<HTMLDivElement>(null);
return (
<>
<div
ref={topBarActionsRef}
data-testid="topbar-actions-host"
className="flex items-center gap-2"
/>
<AgentsEmptyState {...props} topBarActionsRef={topBarActionsRef} />
</>
);
};
const meta: Meta<typeof AgentsEmptyStateWithPortal> = {
const meta: Meta<typeof AgentsEmptyState> = {
title: "pages/AgentsPage/AgentsEmptyState",
component: AgentsEmptyStateWithPortal,
component: AgentsEmptyState,
args: {
onCreateChat: fn(),
isCreating: false,
@@ -60,8 +33,6 @@ const meta: Meta<typeof AgentsEmptyStateWithPortal> = {
modelConfigs: [],
isModelConfigsLoading: false,
modelCatalogError: undefined,
canSetSystemPrompt: true,
canManageChatModelConfigs: false,
},
beforeEach: () => {
localStorage.clear();
@@ -73,7 +44,7 @@ const meta: Meta<typeof AgentsEmptyStateWithPortal> = {
};
export default meta;
type Story = StoryObj<typeof AgentsEmptyStateWithPortal>;
type Story = StoryObj<typeof AgentsEmptyState>;
export const Default: Story = {};
@@ -118,85 +89,3 @@ export const WithWorkspaces: Story = {
await within(canvasElement.ownerDocument.body).findByRole("listbox");
},
};
export const SavesBehaviorPromptAndRestores: Story = {
play: async ({ canvasElement }) => {
const host = canvasElement.ownerDocument.querySelector(
'[data-testid="topbar-actions-host"]',
)!;
// Open the admin dialog via the portalled button.
await userEvent.click(
await within(host as HTMLElement).findByRole("button", {
name: "Admin",
}),
);
const dialog = await screen.findByRole("dialog");
const textarea = await within(dialog).findByPlaceholderText(
"Optional. Set deployment-wide instructions for all new chats.",
);
await userEvent.type(textarea, "You are a focused coding assistant.");
await userEvent.click(within(dialog).getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(localStorage.getItem(behaviorStorageKey)).toBe(
"You are a focused coding assistant.",
);
});
},
};
export const UsesSavedBehaviorPromptOnSend: Story = {
play: async ({ canvasElement, args }) => {
const host = canvasElement.ownerDocument.querySelector(
'[data-testid="topbar-actions-host"]',
)!;
// First, save a behavior prompt.
await userEvent.click(
await within(host as HTMLElement).findByRole("button", {
name: "Admin",
}),
);
const dialog = await screen.findByRole("dialog");
const textarea = await within(dialog).findByPlaceholderText(
"Optional. Set deployment-wide instructions for all new chats.",
);
await userEvent.type(textarea, "Use concise and actionable answers.");
await userEvent.click(within(dialog).getByRole("button", { name: "Save" }));
// Modify without saving, then close.
await userEvent.clear(textarea);
await userEvent.type(textarea, "Unsaved draft prompt");
await userEvent.click(
within(dialog).getByRole("button", { name: "Close" }),
);
// Wait for the dialog to fully close (exit animation) before
// interacting with the page content underneath.
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
// Type a chat message and send.
await userEvent.type(
screen.getByPlaceholderText(
"Ask Coder to build, fix bugs, or explore your project...",
),
"Create a README checklist",
);
await userEvent.click(screen.getByRole("button", { name: "Send" }));
await waitFor(() => {
expect(args.onCreateChat).toHaveBeenCalledWith(
expect.objectContaining({
message: "Create a README checklist",
}),
);
});
},
};
+83 -110
View File
@@ -38,16 +38,17 @@ import {
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { NavLink, Outlet, useNavigate, useParams } from "react-router";
import { NavLink, useNavigate, useParams } from "react-router";
import { toast } from "sonner";
import { cn } from "utils/cn";
import { pageTitle } from "utils/page";
import { AgentDetail, type TopBarContent } from "./AgentDetail";
import { AgentChatInput } from "./AgentChatInput";
import { AgentsSidebar } from "./AgentsSidebar";
import { ConfigureAgentsDialog } from "./ConfigureAgentsDialog";
import { DiffRightPanel } from "./DiffRightPanel";
import { FilesChangedPanel } from "./FilesChangedPanel";
import {
getModelCatalogStatusMessage,
getModelOptionsFromCatalog,
@@ -84,16 +85,6 @@ function isChatListSSEEvent(
);
}
export interface AgentsOutletContext {
chatErrorReasons: Record<string, string>;
setChatErrorReason: (chatId: string, reason: string) => void;
clearChatErrorReason: (chatId: string) => void;
topBarTitleRef: React.RefObject<HTMLDivElement | null>;
topBarActionsRef: React.RefObject<HTMLDivElement | null>;
rightPanelRef: React.RefObject<HTMLDivElement | null>;
setRightPanelOpen: (isOpen: boolean) => void;
requestArchiveAgent: (chatId: string) => void;
}
const AgentsPage: FC = () => {
const queryClient = useQueryClient();
@@ -105,6 +96,41 @@ const AgentsPage: FC = () => {
permissions.editDeploymentConfig ||
user.roles.some((role) => role.name === "owner" || role.name === "admin");
const canSetSystemPrompt = isAgentsAdmin;
const canManageChatModelConfigs = isAgentsAdmin;
const hasAdminControls = canSetSystemPrompt || canManageChatModelConfigs;
const initialSystemPrompt = () => {
if (typeof window === "undefined") {
return "";
}
return localStorage.getItem(systemPromptStorageKey) ?? "";
};
const [savedSystemPrompt, setSavedSystemPrompt] =
useState(initialSystemPrompt);
const [systemPromptDraft, setSystemPromptDraft] =
useState(initialSystemPrompt);
const [isConfigureAgentsDialogOpen, setConfigureAgentsDialogOpen] =
useState(false);
const isSystemPromptDirty = systemPromptDraft !== savedSystemPrompt;
const handleSaveSystemPrompt = useCallback(
(event: FormEvent) => {
event.preventDefault();
if (!isSystemPromptDirty) {
return;
}
setSavedSystemPrompt(systemPromptDraft);
if (typeof window !== "undefined") {
if (systemPromptDraft) {
localStorage.setItem(systemPromptStorageKey, systemPromptDraft);
} else {
localStorage.removeItem(systemPromptStorageKey);
}
}
},
[isSystemPromptDirty, systemPromptDraft],
);
// The global CSS sets scrollbar-gutter: stable on <html> to prevent
// layout shift on pages that toggle scrollbars. The agents page uses
@@ -126,6 +152,7 @@ const AgentsPage: FC = () => {
const archiveMutation = useMutation(archiveChat(queryClient));
const [archivingChatId, setArchivingChatId] = useState<string | null>(null);
const [isRightPanelOpen, setIsRightPanelOpen] = useState(false);
const [topBarContent, setTopBarContent] = useState<TopBarContent | null>(null);
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const [chatErrorReasons, setChatErrorReasons] = useState<
Record<string, string>
@@ -185,9 +212,7 @@ const AgentsPage: FC = () => {
return next;
});
}, []);
const topBarTitleRef = useRef<HTMLDivElement>(null);
const topBarActionsRef = useRef<HTMLDivElement>(null);
const rightPanelRef = useRef<HTMLDivElement>(null);
const chatList = chatsQuery.data ?? [];
const requestArchiveAgent = useCallback(
async (chatId: string) => {
@@ -220,24 +245,7 @@ const AgentsPage: FC = () => {
},
[archiveMutation, queryClient, agentId, navigate, clearChatErrorReason],
);
const outletContext: AgentsOutletContext = useMemo(
() => ({
chatErrorReasons,
setChatErrorReason,
clearChatErrorReason,
topBarTitleRef,
topBarActionsRef,
rightPanelRef,
setRightPanelOpen: setIsRightPanelOpen,
requestArchiveAgent,
}),
[
chatErrorReasons,
setChatErrorReason,
clearChatErrorReason,
requestArchiveAgent,
],
);
const handleCreateChat = async (options: CreateChatOptions) => {
const { message, workspaceId, model } = options;
const modelConfigID =
@@ -446,10 +454,23 @@ const AgentsPage: FC = () => {
</Button>
)}
<div
ref={topBarTitleRef}
className="flex min-w-0 flex-1 items-center"
/>
<div ref={topBarActionsRef} className="flex items-center gap-2" />
>
{topBarContent?.title}
</div>
<div className="flex items-center gap-2">
{!agentId && hasAdminControls && (
<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>
)}
{topBarContent?.actions}
</div>
<div className="flex items-center [&_span]:!rounded-full [&_span]:!size-8 [&_span]:!text-xs">
<UserDropdown
user={user}
@@ -464,7 +485,15 @@ const AgentsPage: FC = () => {
</div>
</div>
{agentId ? (
<Outlet context={outletContext} />
<AgentDetail
agentId={agentId}
chatErrorReasons={chatErrorReasons}
setChatErrorReason={setChatErrorReason}
clearChatErrorReason={clearChatErrorReason}
requestArchiveAgent={requestArchiveAgent}
onDiffPanelStateChange={setIsRightPanelOpen}
onTopBarChange={setTopBarContent}
/>
) : (
<AgentsEmptyState
onCreateChat={handleCreateChat}
@@ -476,17 +505,27 @@ const AgentsPage: FC = () => {
isModelCatalogLoading={chatModelsQuery.isLoading}
isModelConfigsLoading={chatModelConfigsQuery.isLoading}
modelCatalogError={chatModelsQuery.error}
canSetSystemPrompt={canSetSystemPrompt}
canManageChatModelConfigs={isAgentsAdmin}
topBarActionsRef={topBarActionsRef}
/>
)}
</div>
<DiffRightPanel
ref={rightPanelRef}
isOpen={Boolean(agentId && isRightPanelOpen)}
/>
<DiffRightPanel isOpen={Boolean(agentId && isRightPanelOpen)}>
{agentId && isRightPanelOpen && <FilesChangedPanel chatId={agentId} />}
</DiffRightPanel>
</div>
{hasAdminControls && (
<ConfigureAgentsDialog
open={isConfigureAgentsDialogOpen}
onOpenChange={setConfigureAgentsDialogOpen}
canManageChatModelConfigs={canManageChatModelConfigs}
canSetSystemPrompt={canSetSystemPrompt}
systemPromptDraft={systemPromptDraft}
onSystemPromptDraftChange={setSystemPromptDraft}
onSaveSystemPrompt={handleSaveSystemPrompt}
isSystemPromptDirty={isSystemPromptDirty}
isDisabled={createMutation.isPending}
/>
)}
</div>
);
};
@@ -501,9 +540,6 @@ interface AgentsEmptyStateProps {
modelConfigs: readonly TypesGen.ChatModelConfig[];
isModelConfigsLoading: boolean;
modelCatalogError: unknown;
canSetSystemPrompt: boolean;
canManageChatModelConfigs: boolean;
topBarActionsRef: React.RefObject<HTMLDivElement | null>;
}
export const AgentsEmptyState: FC<AgentsEmptyStateProps> = ({
@@ -516,9 +552,6 @@ export const AgentsEmptyState: FC<AgentsEmptyStateProps> = ({
isModelCatalogLoading,
isModelConfigsLoading,
modelCatalogError,
canSetSystemPrompt,
canManageChatModelConfigs,
topBarActionsRef,
}) => {
const [inputValue, setInputValue] = useState(() => {
if (typeof window === "undefined") {
@@ -526,12 +559,6 @@ export const AgentsEmptyState: FC<AgentsEmptyStateProps> = ({
}
return localStorage.getItem(emptyInputStorageKey) ?? "";
});
const initialSystemPrompt = () => {
if (typeof window === "undefined") {
return "";
}
return localStorage.getItem(systemPromptStorageKey) ?? "";
};
const [initialLastModelConfigID] = useState(() => {
if (typeof window === "undefined") {
return "";
@@ -591,12 +618,6 @@ export const AgentsEmptyState: FC<AgentsEmptyStateProps> = ({
modelOptions.some((modelOption) => modelOption.id === userSelectedModel)
? userSelectedModel
: preferredModelID;
const [savedSystemPrompt, setSavedSystemPrompt] =
useState(initialSystemPrompt);
const [systemPromptDraft, setSystemPromptDraft] =
useState(initialSystemPrompt);
const [isConfigureAgentsDialogOpen, setConfigureAgentsDialogOpen] =
useState(false);
const workspacesQuery = useQuery(workspaces({ q: "owner:me", limit: 50 }));
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState<string | null>(
() => {
@@ -606,7 +627,6 @@ export const AgentsEmptyState: FC<AgentsEmptyStateProps> = ({
);
const workspaceOptions = workspacesQuery.data?.workspaces ?? [];
const autoCreateWorkspaceValue = "__auto_create_workspace__";
const hasAdminControls = canSetSystemPrompt || canManageChatModelConfigs;
const hasModelOptions = modelOptions.length > 0;
const hasConfiguredModels = hasConfiguredModelsInCatalog(modelCatalog);
const modelSelectorPlaceholder = getModelSelectorPlaceholder(
@@ -654,7 +674,6 @@ export const AgentsEmptyState: FC<AgentsEmptyStateProps> = ({
selectedWorkspaceIdRef.current = selectedWorkspaceId;
const selectedModelRef = useRef(selectedModel);
selectedModelRef.current = selectedModel;
const isSystemPromptDirty = systemPromptDraft !== savedSystemPrompt;
const handleWorkspaceChange = (value: string) => {
if (value === autoCreateWorkspaceValue) {
@@ -681,24 +700,6 @@ export const AgentsEmptyState: FC<AgentsEmptyStateProps> = ({
setUserSelectedModel(value);
}, []);
const handleSaveSystemPrompt = useCallback(
(event: FormEvent) => {
event.preventDefault();
if (!isSystemPromptDirty) {
return;
}
setSavedSystemPrompt(systemPromptDraft);
if (typeof window !== "undefined") {
if (systemPromptDraft) {
localStorage.setItem(systemPromptStorageKey, systemPromptDraft);
} else {
localStorage.removeItem(systemPromptStorageKey);
}
}
},
[isSystemPromptDirty, systemPromptDraft],
);
const handleSend = useCallback(
(message: string) => {
@@ -720,20 +721,6 @@ export const AgentsEmptyState: FC<AgentsEmptyStateProps> = ({
return (
<div className="flex min-h-0 flex-1 items-start justify-center overflow-auto p-4 pt-12 md:h-full md:items-center md:pt-4">
{hasAdminControls &&
topBarActionsRef.current &&
createPortal(
<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>,
topBarActionsRef.current,
)}
<div className="mx-auto flex w-full max-w-3xl flex-col gap-4">
{createError ? <ErrorAlert error={createError} /> : null}
{workspacesQuery.isError && (
@@ -790,20 +777,6 @@ export const AgentsEmptyState: FC<AgentsEmptyStateProps> = ({
}
/>
</div>
{hasAdminControls && (
<ConfigureAgentsDialog
open={isConfigureAgentsDialogOpen}
onOpenChange={setConfigureAgentsDialogOpen}
canManageChatModelConfigs={canManageChatModelConfigs}
canSetSystemPrompt={canSetSystemPrompt}
systemPromptDraft={systemPromptDraft}
onSystemPromptDraftChange={setSystemPromptDraft}
onSaveSystemPrompt={handleSaveSystemPrompt}
isSystemPromptDirty={isSystemPromptDirty}
isDisabled={isCreating}
/>
)}
</div>
);
};
+4 -4
View File
@@ -1,6 +1,6 @@
import {
type PointerEvent as ReactPointerEvent,
type Ref,
type ReactNode,
useCallback,
useEffect,
useRef,
@@ -29,8 +29,8 @@ function loadPersistedWidth(): number {
}
interface DiffRightPanelProps {
ref?: Ref<HTMLDivElement>;
isOpen: boolean;
children?: ReactNode;
}
/**
@@ -39,7 +39,7 @@ interface DiffRightPanelProps {
* closed the panel is hidden via CSS and takes no layout space. On xl+
* screens the panel is horizontally resizable via a drag handle.
*/
export const DiffRightPanel = ({ ref, isOpen }: DiffRightPanelProps) => {
export const DiffRightPanel = ({ isOpen, children }: DiffRightPanelProps) => {
const [width, setWidth] = useState(loadPersistedWidth);
const isDragging = useRef(false);
const startX = useRef(0);
@@ -93,7 +93,6 @@ export const DiffRightPanel = ({ ref, isOpen }: DiffRightPanelProps) => {
return (
<div
ref={ref}
data-testid="agents-detail-right-panel"
style={
isOpen
@@ -114,6 +113,7 @@ export const DiffRightPanel = ({ ref, isOpen }: DiffRightPanelProps) => {
onPointerUp={handlePointerUp}
className="absolute top-0 left-0 z-10 hidden h-full w-1 cursor-col-resize select-none transition-colors hover:bg-content-link xl:block"
/>
{children}
</div>
);
};
+3 -17
View File
@@ -344,12 +344,7 @@ const ProvisionerJobsPage = lazy(
),
);
const AgentsPage = lazy(() => import("./pages/AgentsPage/AgentsPage"));
const AgentDetail = lazy(() => import("./pages/AgentsPage/AgentDetail"));
import {
AgentDetailSkeleton,
AgentsPageSkeleton,
} from "./pages/AgentsPage/AgentsSkeletons";
import { AgentsPageSkeleton } from "./pages/AgentsPage/AgentsSkeletons";
const TasksPage = lazy(() => import("./pages/TasksPage/TasksPage"));
const TaskPage = lazy(() => import("./pages/TaskPage/TaskPage"));
@@ -632,22 +627,13 @@ export const router = createBrowserRouter(
<Route path="/icons" element={<IconsPage />} />
<Route path="/tasks/:username/:taskId" element={<TaskPage />} />
<Route
path="/agents"
path="/agents/:agentId?"
element={
<Suspense fallback={<AgentsPageSkeleton />}>
<AgentsPage />
</Suspense>
}
>
<Route
path=":agentId"
element={
<Suspense fallback={<AgentDetailSkeleton />}>
<AgentDetail />
</Suspense>
}
/>
</Route>
/>
</Route>
</Route>,
),