Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 475dec06df |
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
@@ -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>,
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user