mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
refactor(site): refactor AgentsPage createPortal soup (#22438)
This commit is contained in:
@@ -3,10 +3,15 @@ import {
|
||||
MockWorkspace,
|
||||
MockWorkspaceAgent,
|
||||
} from "testHelpers/entities";
|
||||
import { withAuthProvider, withWebSocket } from "testHelpers/storybook";
|
||||
import {
|
||||
withAuthProvider,
|
||||
withDashboardProvider,
|
||||
withWebSocket,
|
||||
} from "testHelpers/storybook";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { API } from "api/api";
|
||||
import {
|
||||
chatDiffContentsKey,
|
||||
chatDiffStatusKey,
|
||||
chatKey,
|
||||
chatModelsKey,
|
||||
@@ -14,7 +19,7 @@ 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 type { FC } from "react";
|
||||
import { Outlet } from "react-router";
|
||||
import { expect, spyOn, userEvent, waitFor, within } from "storybook/test";
|
||||
import {
|
||||
@@ -25,45 +30,25 @@ 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.
|
||||
// Layout wrapper – provides outlet context for the child route.
|
||||
// ---------------------------------------------------------------------------
|
||||
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 className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<Outlet
|
||||
context={
|
||||
{
|
||||
chatErrorReasons: {},
|
||||
setChatErrorReason: () => {},
|
||||
clearChatErrorReason: () => {},
|
||||
requestArchiveAgent: () => {},
|
||||
isSidebarCollapsed: false,
|
||||
onToggleSidebarCollapsed: () => {},
|
||||
} satisfies AgentsOutletContext
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref={rightPanelRef}
|
||||
className={
|
||||
rightPanelOpen ? "w-[400px] border-l border-border" : "hidden"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -128,6 +113,21 @@ const baseChatFields = {
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A small sample unified diff for stories that show the diff panel. */
|
||||
const sampleDiff = `diff --git a/main.go b/main.go
|
||||
index abc1234..def5678 100644
|
||||
--- a/main.go
|
||||
+++ b/main.go
|
||||
@@ -10,6 +10,9 @@ func main() {
|
||||
fmt.Println("hello")
|
||||
+ fmt.Println("new feature")
|
||||
+ fmt.Println("added line")
|
||||
+ fmt.Println("another addition")
|
||||
fmt.Println("world")
|
||||
- fmt.Println("old line")
|
||||
}
|
||||
`;
|
||||
|
||||
/** Build `parameters.queries` entries for a given chat data object. */
|
||||
const buildQueries = (
|
||||
chatData: TypesGen.ChatWithMessages,
|
||||
@@ -146,6 +146,14 @@ const buildQueries = (
|
||||
changed_files: opts?.diffUrl ? 2 : 0,
|
||||
} satisfies TypesGen.ChatDiffStatus,
|
||||
},
|
||||
{
|
||||
key: chatDiffContentsKey(CHAT_ID),
|
||||
data: {
|
||||
chat_id: CHAT_ID,
|
||||
diff: opts?.diffUrl ? sampleDiff : undefined,
|
||||
pull_request_url: opts?.diffUrl,
|
||||
} satisfies TypesGen.ChatDiffContents,
|
||||
},
|
||||
{
|
||||
key: workspaceByIdKey(mockWorkspace.id),
|
||||
data: mockWorkspace,
|
||||
@@ -167,7 +175,7 @@ const wrapSSE = (payload: unknown): string =>
|
||||
const meta: Meta<typeof AgentDetailLayout> = {
|
||||
title: "pages/AgentsPage/AgentDetail",
|
||||
component: AgentDetailLayout,
|
||||
decorators: [withAuthProvider, withWebSocket],
|
||||
decorators: [withAuthProvider, withDashboardProvider, withWebSocket],
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
user: MockUserOwner,
|
||||
|
||||
@@ -27,6 +27,7 @@ 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 } from "./AgentChatInput";
|
||||
import {
|
||||
@@ -54,9 +55,11 @@ import {
|
||||
parseMessagesWithMergedTools,
|
||||
} from "./AgentDetail/messageParsing";
|
||||
import { buildStreamTools } from "./AgentDetail/streamState";
|
||||
import { AgentDetailTopBarPortals } from "./AgentDetail/TopBarPortals";
|
||||
import { AgentDetailTopBar } from "./AgentDetail/TopBar";
|
||||
import { useMessageWindow } from "./AgentDetail/useMessageWindow";
|
||||
import type { AgentsOutletContext } from "./AgentsPage";
|
||||
import { DiffRightPanel } from "./DiffRightPanel";
|
||||
import { FilesChangedPanel } from "./FilesChangedPanel";
|
||||
import {
|
||||
getModelCatalogStatusMessage,
|
||||
getModelOptionsFromCatalog,
|
||||
@@ -68,8 +71,6 @@ const noopSetChatErrorReason: AgentsOutletContext["setChatErrorReason"] =
|
||||
() => {};
|
||||
const noopClearChatErrorReason: AgentsOutletContext["clearChatErrorReason"] =
|
||||
() => {};
|
||||
const noopSetRightPanelOpen: AgentsOutletContext["setRightPanelOpen"] =
|
||||
() => {};
|
||||
const noopRequestArchiveAgent: AgentsOutletContext["requestArchiveAgent"] =
|
||||
() => {};
|
||||
const lastModelConfigIDStorageKey = "agents.last-model-config-id";
|
||||
@@ -471,23 +472,13 @@ const AgentDetail: FC = () => {
|
||||
outletContext?.setChatErrorReason ?? noopSetChatErrorReason;
|
||||
const clearChatErrorReason =
|
||||
outletContext?.clearChatErrorReason ?? noopClearChatErrorReason;
|
||||
const setRightPanelOpen =
|
||||
outletContext?.setRightPanelOpen ?? noopSetRightPanelOpen;
|
||||
const requestArchiveAgent =
|
||||
outletContext?.requestArchiveAgent ?? noopRequestArchiveAgent;
|
||||
const isSidebarCollapsed = outletContext?.isSidebarCollapsed ?? false;
|
||||
const onToggleSidebarCollapsed =
|
||||
outletContext?.onToggleSidebarCollapsed ?? (() => {});
|
||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// When switching between chats, reset the scroll container to the
|
||||
// bottom (scrollTop 0 in a flex-col-reverse container) so the user
|
||||
// always sees the most recent messages instead of a stale scroll
|
||||
// position from the previous chat.
|
||||
useEffect(() => {
|
||||
void agentId;
|
||||
if (scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollTop = 0;
|
||||
}
|
||||
}, [agentId]);
|
||||
|
||||
const chatQuery = useQuery({
|
||||
...chat(agentId ?? ""),
|
||||
enabled: Boolean(agentId),
|
||||
@@ -523,16 +514,6 @@ 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.
|
||||
useEffect(() => {
|
||||
setRightPanelOpen(hasDiffStatus && showDiffPanel);
|
||||
return () => {
|
||||
setRightPanelOpen(false);
|
||||
};
|
||||
}, [hasDiffStatus, setRightPanelOpen, showDiffPanel]);
|
||||
|
||||
const modelOptions = useMemo(
|
||||
() =>
|
||||
getModelOptionsFromCatalog(
|
||||
@@ -786,9 +767,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.
|
||||
@@ -870,6 +848,24 @@ const AgentDetail: FC = () => {
|
||||
if (chatQuery.isLoading) {
|
||||
return (
|
||||
<div className="relative flex h-full min-h-0 min-w-0 flex-1 flex-col">
|
||||
<AgentDetailTopBar
|
||||
diff={{
|
||||
hasDiffStatus: false,
|
||||
diffStatus: undefined,
|
||||
showDiffPanel: false,
|
||||
onToggleFilesChanged: () => {},
|
||||
}}
|
||||
workspace={{
|
||||
canOpenEditors: false,
|
||||
canOpenWorkspace: false,
|
||||
onOpenInEditor: () => {},
|
||||
onViewWorkspace: () => {},
|
||||
}}
|
||||
onOpenParentChat={() => {}}
|
||||
onArchiveAgent={() => {}}
|
||||
isSidebarCollapsed={isSidebarCollapsed}
|
||||
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
|
||||
/>
|
||||
<div className="flex h-full flex-col-reverse overflow-hidden">
|
||||
<div className="px-4">
|
||||
<div className="mx-auto w-full max-w-3xl py-6">
|
||||
@@ -921,80 +917,106 @@ const AgentDetail: FC = () => {
|
||||
|
||||
if (!chatQuery.data || !agentId) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center text-content-secondary">
|
||||
Chat not found
|
||||
<div className="flex h-full min-h-0 min-w-0 flex-1 flex-col">
|
||||
<AgentDetailTopBar
|
||||
diff={{
|
||||
hasDiffStatus: false,
|
||||
diffStatus: undefined,
|
||||
showDiffPanel: false,
|
||||
onToggleFilesChanged: () => {},
|
||||
}}
|
||||
workspace={{
|
||||
canOpenEditors: false,
|
||||
canOpenWorkspace: false,
|
||||
onOpenInEditor: () => {},
|
||||
onViewWorkspace: () => {},
|
||||
}}
|
||||
onOpenParentChat={() => {}}
|
||||
onArchiveAgent={() => {}}
|
||||
isSidebarCollapsed={isSidebarCollapsed}
|
||||
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
|
||||
/>
|
||||
<div className="flex flex-1 items-center justify-center text-content-secondary">
|
||||
Chat not found
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
className="pointer-events-none absolute inset-x-0 top-0 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
|
||||
ref={scrollContainerRef}
|
||||
className="flex h-full flex-col-reverse overflow-y-auto [scrollbar-width:thin] [scrollbar-color:hsl(var(--surface-quaternary))_transparent]"
|
||||
>
|
||||
<div className="px-4">
|
||||
<AgentDetailConversation
|
||||
store={store}
|
||||
chatID={agentId}
|
||||
persistedErrorReason={
|
||||
chatErrorReasons[agentId] || chatRecord?.last_error || undefined
|
||||
}
|
||||
compressionThreshold={compressionThreshold}
|
||||
onDeleteQueuedMessage={handleDeleteQueuedMessage}
|
||||
onPromoteQueuedMessage={handlePromoteQueuedMessage}
|
||||
onSend={handleSend}
|
||||
onInterrupt={handleInterrupt}
|
||||
isInputDisabled={isInputDisabled}
|
||||
isSendPending={isSubmissionPending}
|
||||
isInterruptPending={interruptMutation.isPending}
|
||||
hasModelOptions={hasModelOptions}
|
||||
selectedModel={selectedModel}
|
||||
onModelChange={setSelectedModel}
|
||||
modelOptions={modelOptions}
|
||||
modelSelectorPlaceholder={modelSelectorPlaceholder}
|
||||
inputStatusText={inputStatusText}
|
||||
modelCatalogStatusMessage={modelCatalogStatusMessage}
|
||||
savingMessageId={pendingEditMessageId}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-0 min-w-0 flex-1",
|
||||
shouldShowDiffPanel && "flex-col xl:flex-row",
|
||||
)}
|
||||
>
|
||||
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<AgentDetailTopBar
|
||||
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}
|
||||
isSidebarCollapsed={isSidebarCollapsed}
|
||||
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 top-0 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
|
||||
ref={scrollContainerRef}
|
||||
className="flex h-full flex-col-reverse overflow-y-auto [scrollbar-width:thin] [scrollbar-color:hsl(var(--surface-quaternary))_transparent]"
|
||||
>
|
||||
<div className="px-4">
|
||||
<AgentDetailConversation
|
||||
store={store}
|
||||
chatID={agentId}
|
||||
persistedErrorReason={
|
||||
chatErrorReasons[agentId] || chatRecord?.last_error || undefined
|
||||
}
|
||||
compressionThreshold={compressionThreshold}
|
||||
onDeleteQueuedMessage={handleDeleteQueuedMessage}
|
||||
onPromoteQueuedMessage={handlePromoteQueuedMessage}
|
||||
onSend={handleSend}
|
||||
onInterrupt={handleInterrupt}
|
||||
isInputDisabled={isInputDisabled}
|
||||
isSendPending={isSubmissionPending}
|
||||
isInterruptPending={interruptMutation.isPending}
|
||||
hasModelOptions={hasModelOptions}
|
||||
selectedModel={selectedModel}
|
||||
onModelChange={setSelectedModel}
|
||||
modelOptions={modelOptions}
|
||||
modelSelectorPlaceholder={modelSelectorPlaceholder}
|
||||
inputStatusText={inputStatusText}
|
||||
modelCatalogStatusMessage={modelCatalogStatusMessage}
|
||||
savingMessageId={pendingEditMessageId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DiffRightPanel isOpen={shouldShowDiffPanel}>
|
||||
<FilesChangedPanel chatId={agentId} />
|
||||
</DiffRightPanel>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
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 { AgentDetailTopBar } from "./TopBar";
|
||||
|
||||
const mockDiffStatus: ChatDiffStatusResponse = {
|
||||
chat_id: "chat-1",
|
||||
changes_requested: false,
|
||||
additions: 42,
|
||||
deletions: 7,
|
||||
changed_files: 5,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
chatTitle: "Build authentication feature",
|
||||
onOpenParentChat: () => {},
|
||||
diff: {
|
||||
hasDiffStatus: false,
|
||||
diffStatus: undefined,
|
||||
showDiffPanel: false,
|
||||
onToggleFilesChanged: () => {},
|
||||
},
|
||||
workspace: {
|
||||
canOpenEditors: true,
|
||||
canOpenWorkspace: true,
|
||||
onOpenInEditor: () => {},
|
||||
onViewWorkspace: () => {},
|
||||
},
|
||||
onArchiveAgent: () => {},
|
||||
isSidebarCollapsed: false,
|
||||
onToggleSidebarCollapsed: () => {},
|
||||
} satisfies React.ComponentProps<typeof AgentDetailTopBar>;
|
||||
|
||||
const meta: Meta<typeof AgentDetailTopBar> = {
|
||||
title: "pages/AgentsPage/AgentDetail/TopBar",
|
||||
component: AgentDetailTopBar,
|
||||
decorators: [withAuthProvider, withDashboardProvider],
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
user: MockUserOwner,
|
||||
},
|
||||
args: defaultProps,
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AgentDetailTopBar>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const WithDiffStats: Story = {
|
||||
args: {
|
||||
diff: {
|
||||
hasDiffStatus: true,
|
||||
diffStatus: mockDiffStatus,
|
||||
showDiffPanel: false,
|
||||
onToggleFilesChanged: () => {},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDiffPanelOpen: Story = {
|
||||
args: {
|
||||
diff: {
|
||||
hasDiffStatus: true,
|
||||
diffStatus: mockDiffStatus,
|
||||
showDiffPanel: true,
|
||||
onToggleFilesChanged: () => {},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithParentChat: Story = {
|
||||
args: {
|
||||
parentChat: {
|
||||
id: "parent-chat-1",
|
||||
owner_id: "owner-id",
|
||||
last_model_config_id: "model-config-1",
|
||||
title: "Set up CI/CD pipeline",
|
||||
status: "completed",
|
||||
last_error: null,
|
||||
created_at: "2026-02-18T00:00:00.000Z",
|
||||
updated_at: "2026-02-18T00:00:00.000Z",
|
||||
archived: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const SidebarCollapsed: Story = {
|
||||
args: {
|
||||
isSidebarCollapsed: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const NoTitle: Story = {
|
||||
args: {
|
||||
chatTitle: undefined,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,218 @@
|
||||
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 { useAuthenticated } from "hooks";
|
||||
import {
|
||||
ArchiveIcon,
|
||||
ArrowLeftIcon,
|
||||
ChevronRightIcon,
|
||||
EllipsisIcon,
|
||||
ExternalLinkIcon,
|
||||
MonitorIcon,
|
||||
PanelLeftIcon,
|
||||
PanelRightCloseIcon,
|
||||
PanelRightOpenIcon,
|
||||
} from "lucide-react";
|
||||
import { UserDropdown } from "modules/dashboard/Navbar/UserDropdown/UserDropdown";
|
||||
import { useDashboard } from "modules/dashboard/useDashboard";
|
||||
import type { FC } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
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 (
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={onToggle}
|
||||
className="gap-3 px-2 py-1 text-content-secondary 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" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
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 AgentDetailTopBarProps = {
|
||||
chatTitle?: string;
|
||||
parentChat?: TypesGen.Chat;
|
||||
onOpenParentChat: (chatId: string) => void;
|
||||
diff: DiffPanelState;
|
||||
workspace: WorkspaceActions;
|
||||
onArchiveAgent: () => void;
|
||||
isSidebarCollapsed: boolean;
|
||||
onToggleSidebarCollapsed: () => void;
|
||||
};
|
||||
|
||||
export const AgentDetailTopBar: FC<AgentDetailTopBarProps> = ({
|
||||
chatTitle,
|
||||
parentChat,
|
||||
onOpenParentChat,
|
||||
diff,
|
||||
workspace,
|
||||
onArchiveAgent,
|
||||
isSidebarCollapsed,
|
||||
onToggleSidebarCollapsed,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const { user, signOut } = useAuthenticated();
|
||||
const { appearance, buildInfo } = useDashboard();
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-2 px-4 py-0.5">
|
||||
{/* Mobile back button */}
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="icon"
|
||||
onClick={() => navigate("/agents")}
|
||||
aria-label="Back"
|
||||
className="inline-flex h-7 w-7 min-w-0 shrink-0 md:hidden"
|
||||
>
|
||||
<ArrowLeftIcon />
|
||||
</Button>
|
||||
{/* Desktop expand button: visible when sidebar is manually collapsed. */}
|
||||
{isSidebarCollapsed && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="icon"
|
||||
onClick={onToggleSidebarCollapsed}
|
||||
aria-label="Expand sidebar"
|
||||
className="hidden h-7 w-7 min-w-0 shrink-0 md:inline-flex"
|
||||
>
|
||||
<PanelLeftIcon />
|
||||
</Button>
|
||||
)}
|
||||
{/* Title area */}
|
||||
<div className="flex min-w-0 flex-1 items-center">
|
||||
{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={() => 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>
|
||||
)}
|
||||
</div>
|
||||
{/* Actions area */}
|
||||
<div className="flex items-center gap-2">
|
||||
{diff.hasDiffStatus && diff.diffStatus && (
|
||||
<DiffStatsBadge
|
||||
status={diff.diffStatus}
|
||||
isOpen={diff.showDiffPanel}
|
||||
onToggle={diff.onToggleFilesChanged}
|
||||
/>
|
||||
)}
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex items-center [&_span]:!rounded-full [&_span]:!size-8 [&_span]:!text-xs">
|
||||
<UserDropdown
|
||||
user={user}
|
||||
buildInfo={buildInfo}
|
||||
supportLinks={
|
||||
appearance.support_links?.filter(
|
||||
(link) => link.location !== "navbar",
|
||||
) ?? []
|
||||
}
|
||||
onSignOut={signOut}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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,7 +1,6 @@
|
||||
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,
|
||||
@@ -24,32 +23,9 @@ const modelOptions = [
|
||||
|
||||
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,
|
||||
@@ -62,6 +38,8 @@ const meta: Meta<typeof AgentsEmptyStateWithPortal> = {
|
||||
modelCatalogError: undefined,
|
||||
canSetSystemPrompt: true,
|
||||
canManageChatModelConfigs: false,
|
||||
isConfigureAgentsDialogOpen: false,
|
||||
onConfigureAgentsDialogOpenChange: fn(),
|
||||
},
|
||||
beforeEach: () => {
|
||||
localStorage.clear();
|
||||
@@ -73,7 +51,7 @@ const meta: Meta<typeof AgentsEmptyStateWithPortal> = {
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AgentsEmptyStateWithPortal>;
|
||||
type Story = StoryObj<typeof AgentsEmptyState>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
@@ -120,18 +98,10 @@ export const WithWorkspaces: Story = {
|
||||
};
|
||||
|
||||
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",
|
||||
}),
|
||||
);
|
||||
|
||||
args: {
|
||||
isConfigureAgentsDialogOpen: true,
|
||||
},
|
||||
play: async () => {
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
const textarea = await within(dialog).findByPlaceholderText(
|
||||
"Optional. Set deployment-wide instructions for all new chats.",
|
||||
@@ -147,56 +117,3 @@ export const SavesBehaviorPromptAndRestores: Story = {
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
SelectValue,
|
||||
} from "components/Select/Select";
|
||||
import { useAuthenticated } from "hooks";
|
||||
import { ArrowLeftIcon, MonitorIcon, PanelLeftIcon } from "lucide-react";
|
||||
import { MonitorIcon, PanelLeftIcon } from "lucide-react";
|
||||
import { UserDropdown } from "modules/dashboard/Navbar/UserDropdown/UserDropdown";
|
||||
import { useDashboard } from "modules/dashboard/useDashboard";
|
||||
import {
|
||||
@@ -38,7 +38,6 @@ 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 { toast } from "sonner";
|
||||
@@ -47,7 +46,6 @@ import { pageTitle } from "utils/page";
|
||||
import { AgentChatInput } from "./AgentChatInput";
|
||||
import { AgentsSidebar } from "./AgentsSidebar";
|
||||
import { ConfigureAgentsDialog } from "./ConfigureAgentsDialog";
|
||||
import { DiffRightPanel } from "./DiffRightPanel";
|
||||
import {
|
||||
getModelCatalogStatusMessage,
|
||||
getModelOptionsFromCatalog,
|
||||
@@ -88,11 +86,9 @@ 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;
|
||||
isSidebarCollapsed: boolean;
|
||||
onToggleSidebarCollapsed: () => void;
|
||||
}
|
||||
|
||||
const AgentsPage: FC = () => {
|
||||
@@ -125,7 +121,8 @@ const AgentsPage: FC = () => {
|
||||
const createMutation = useMutation(createChat(queryClient));
|
||||
const archiveMutation = useMutation(archiveChat(queryClient));
|
||||
const [archivingChatId, setArchivingChatId] = useState<string | null>(null);
|
||||
const [isRightPanelOpen, setIsRightPanelOpen] = useState(false);
|
||||
const [isConfigureAgentsDialogOpen, setConfigureAgentsDialogOpen] =
|
||||
useState(false);
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||
const [chatErrorReasons, setChatErrorReasons] = useState<
|
||||
Record<string, string>
|
||||
@@ -185,9 +182,6 @@ 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,22 +214,26 @@ const AgentsPage: FC = () => {
|
||||
},
|
||||
[archiveMutation, queryClient, agentId, navigate, clearChatErrorReason],
|
||||
);
|
||||
const handleToggleSidebarCollapsed = useCallback(
|
||||
() => setIsSidebarCollapsed((prev) => !prev),
|
||||
[],
|
||||
);
|
||||
const outletContext: AgentsOutletContext = useMemo(
|
||||
() => ({
|
||||
chatErrorReasons,
|
||||
setChatErrorReason,
|
||||
clearChatErrorReason,
|
||||
topBarTitleRef,
|
||||
topBarActionsRef,
|
||||
rightPanelRef,
|
||||
setRightPanelOpen: setIsRightPanelOpen,
|
||||
requestArchiveAgent,
|
||||
isSidebarCollapsed,
|
||||
onToggleSidebarCollapsed: handleToggleSidebarCollapsed,
|
||||
}),
|
||||
[
|
||||
chatErrorReasons,
|
||||
setChatErrorReason,
|
||||
clearChatErrorReason,
|
||||
requestArchiveAgent,
|
||||
isSidebarCollapsed,
|
||||
handleToggleSidebarCollapsed,
|
||||
],
|
||||
);
|
||||
const handleCreateChat = async (options: CreateChatOptions) => {
|
||||
@@ -360,12 +358,6 @@ const AgentsPage: FC = () => {
|
||||
onNewAgent: handleNewAgent,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!agentId) {
|
||||
setIsRightPanelOpen(false);
|
||||
}
|
||||
}, [agentId]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-surface-primary md:flex-row">
|
||||
<div
|
||||
@@ -397,15 +389,15 @@ const AgentsPage: FC = () => {
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-0 min-w-0 bg-surface-primary",
|
||||
agentId ? "flex-1" : "order-1 md:order-none flex-none md:flex-1",
|
||||
isRightPanelOpen && "flex-col xl:flex-row",
|
||||
"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",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col bg-surface-primary">
|
||||
<div className="flex shrink-0 items-center gap-2 px-4 py-0.5">
|
||||
{/* Mobile logo: visible when no agent is selected. */}
|
||||
{!agentId && (
|
||||
{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 opacity-50 md:hidden"
|
||||
@@ -420,52 +412,43 @@ const AgentsPage: FC = () => {
|
||||
<CoderIcon className="h-6 w-6 fill-content-primary" />
|
||||
)}
|
||||
</NavLink>
|
||||
)}
|
||||
{/* Mobile back button: visible on mobile when an agent is selected. */}
|
||||
{agentId && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="icon"
|
||||
onClick={() => navigate("/agents")}
|
||||
aria-label="Back"
|
||||
className="inline-flex h-7 w-7 min-w-0 shrink-0 md:hidden"
|
||||
>
|
||||
<ArrowLeftIcon />
|
||||
</Button>
|
||||
)}
|
||||
{/* Desktop expand button: visible when sidebar is manually collapsed. */}
|
||||
{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
|
||||
ref={topBarTitleRef}
|
||||
className="flex min-w-0 flex-1 items-center"
|
||||
/>
|
||||
<div ref={topBarActionsRef} className="flex items-center gap-2" />
|
||||
<div className="flex items-center [&_span]:!rounded-full [&_span]:!size-8 [&_span]:!text-xs">
|
||||
<UserDropdown
|
||||
user={user}
|
||||
buildInfo={buildInfo}
|
||||
supportLinks={
|
||||
appearance.support_links?.filter(
|
||||
(link) => link.location !== "navbar",
|
||||
) ?? []
|
||||
}
|
||||
onSignOut={signOut}
|
||||
/>
|
||||
{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">
|
||||
{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 className="flex items-center [&_span]:!rounded-full [&_span]:!size-8 [&_span]:!text-xs">
|
||||
<UserDropdown
|
||||
user={user}
|
||||
buildInfo={buildInfo}
|
||||
supportLinks={
|
||||
appearance.support_links?.filter(
|
||||
(link) => link.location !== "navbar",
|
||||
) ?? []
|
||||
}
|
||||
onSignOut={signOut}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{agentId ? (
|
||||
<Outlet context={outletContext} />
|
||||
) : (
|
||||
<AgentsEmptyState
|
||||
onCreateChat={handleCreateChat}
|
||||
isCreating={createMutation.isPending}
|
||||
@@ -478,14 +461,11 @@ const AgentsPage: FC = () => {
|
||||
modelCatalogError={chatModelsQuery.error}
|
||||
canSetSystemPrompt={canSetSystemPrompt}
|
||||
canManageChatModelConfigs={isAgentsAdmin}
|
||||
topBarActionsRef={topBarActionsRef}
|
||||
isConfigureAgentsDialogOpen={isConfigureAgentsDialogOpen}
|
||||
onConfigureAgentsDialogOpenChange={setConfigureAgentsDialogOpen}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<DiffRightPanel
|
||||
ref={rightPanelRef}
|
||||
isOpen={Boolean(agentId && isRightPanelOpen)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -503,7 +483,8 @@ interface AgentsEmptyStateProps {
|
||||
modelCatalogError: unknown;
|
||||
canSetSystemPrompt: boolean;
|
||||
canManageChatModelConfigs: boolean;
|
||||
topBarActionsRef: React.RefObject<HTMLDivElement | null>;
|
||||
isConfigureAgentsDialogOpen: boolean;
|
||||
onConfigureAgentsDialogOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const AgentsEmptyState: FC<AgentsEmptyStateProps> = ({
|
||||
@@ -518,7 +499,8 @@ export const AgentsEmptyState: FC<AgentsEmptyStateProps> = ({
|
||||
modelCatalogError,
|
||||
canSetSystemPrompt,
|
||||
canManageChatModelConfigs,
|
||||
topBarActionsRef,
|
||||
isConfigureAgentsDialogOpen,
|
||||
onConfigureAgentsDialogOpenChange,
|
||||
}) => {
|
||||
const [inputValue, setInputValue] = useState(() => {
|
||||
if (typeof window === "undefined") {
|
||||
@@ -595,8 +577,6 @@ export const AgentsEmptyState: FC<AgentsEmptyStateProps> = ({
|
||||
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>(
|
||||
() => {
|
||||
@@ -720,20 +700,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 && (
|
||||
@@ -794,7 +760,7 @@ export const AgentsEmptyState: FC<AgentsEmptyStateProps> = ({
|
||||
{hasAdminControls && (
|
||||
<ConfigureAgentsDialog
|
||||
open={isConfigureAgentsDialogOpen}
|
||||
onOpenChange={setConfigureAgentsDialogOpen}
|
||||
onOpenChange={onConfigureAgentsDialogOpenChange}
|
||||
canManageChatModelConfigs={canManageChatModelConfigs}
|
||||
canSetSystemPrompt={canSetSystemPrompt}
|
||||
systemPromptDraft={systemPromptDraft}
|
||||
|
||||
@@ -45,35 +45,44 @@ export const AgentsPageSkeleton: FC = () => (
|
||||
|
||||
/**
|
||||
* Skeleton shown while the AgentDetail chunk is loading. Mimics a
|
||||
* chat conversation layout (user bubble + assistant response lines)
|
||||
* inside the same scroll/padding wrapper used by the real view.
|
||||
* top bar + chat conversation layout so the user sees navigable
|
||||
* structure during the brief Suspense fallback.
|
||||
*/
|
||||
export const AgentDetailSkeleton: FC = () => (
|
||||
<div className="flex h-full 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 className="flex h-full min-h-0 min-w-0 flex-1 flex-col">
|
||||
{/* Minimal skeleton top bar */}
|
||||
<div className="flex shrink-0 items-center gap-2 px-4 py-2">
|
||||
<Skeleton className="h-7 w-7 rounded" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<div className="flex-1" />
|
||||
<Skeleton className="h-8 w-8 rounded-full" />
|
||||
</div>
|
||||
<div className="flex h-full 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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
type ReactNode,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
type Ref,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
@@ -29,17 +29,16 @@ function loadPersistedWidth(): number {
|
||||
}
|
||||
|
||||
interface DiffRightPanelProps {
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
isOpen: boolean;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* The right-side panel for the diff/files-changed view. Always mounted
|
||||
* so the portal ref is always available (fixes blank-on-reopen). When
|
||||
* closed the panel is hidden via CSS and takes no layout space. On xl+
|
||||
* The right-side panel for the diff/files-changed view. When 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 +92,6 @@ export const DiffRightPanel = ({ ref, isOpen }: DiffRightPanelProps) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-testid="agents-detail-right-panel"
|
||||
style={
|
||||
isOpen
|
||||
@@ -103,7 +101,7 @@ export const DiffRightPanel = ({ ref, isOpen }: DiffRightPanelProps) => {
|
||||
className={cn(
|
||||
"relative min-h-0 min-w-0 border-t border-border-default bg-surface-primary",
|
||||
isOpen
|
||||
? "h-[42dvh] min-h-[260px] max-h-[56dvh] xl:h-auto xl:max-h-none xl:w-[var(--panel-width)] xl:min-w-[360px] xl:max-w-[960px] xl:border-l xl:border-t-0"
|
||||
? "flex h-[42dvh] min-h-[260px] max-h-[56dvh] flex-col xl:h-auto xl:max-h-none xl:w-[var(--panel-width)] xl:min-w-[360px] xl:max-w-[960px] xl:border-l xl:border-t-0"
|
||||
: "hidden",
|
||||
)}
|
||||
>
|
||||
@@ -114,6 +112,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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user