diff --git a/site/src/pages/AgentsPage/AgentDetail.stories.tsx b/site/src/pages/AgentsPage/AgentDetail.stories.tsx index 16b68563dc..83d43be700 100644 --- a/site/src/pages/AgentsPage/AgentDetail.stories.tsx +++ b/site/src/pages/AgentsPage/AgentDetail.stories.tsx @@ -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(null); - const topBarActionsRef = useRef(null); - const rightPanelRef = useRef(null); - const [rightPanelOpen, setRightPanelOpen] = useState(false); - return (
-
-
-
-
-
-
- {}, - clearChatErrorReason: () => {}, - topBarTitleRef, - topBarActionsRef, - rightPanelRef, - setRightPanelOpen, - requestArchiveAgent: () => {}, - } satisfies AgentsOutletContext - } - /> -
+
+ {}, + clearChatErrorReason: () => {}, + requestArchiveAgent: () => {}, + isSidebarCollapsed: false, + onToggleSidebarCollapsed: () => {}, + } satisfies AgentsOutletContext + } + />
-
); }; @@ -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 = { title: "pages/AgentsPage/AgentDetail", component: AgentDetailLayout, - decorators: [withAuthProvider, withWebSocket], + decorators: [withAuthProvider, withDashboardProvider, withWebSocket], parameters: { layout: "fullscreen", user: MockUserOwner, diff --git a/site/src/pages/AgentsPage/AgentDetail.tsx b/site/src/pages/AgentsPage/AgentDetail.tsx index dcdb3e47aa..52d09596f1 100644 --- a/site/src/pages/AgentsPage/AgentDetail.tsx +++ b/site/src/pages/AgentsPage/AgentDetail.tsx @@ -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(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 (
+ {}, + }} + workspace={{ + canOpenEditors: false, + canOpenWorkspace: false, + onOpenInEditor: () => {}, + onViewWorkspace: () => {}, + }} + onOpenParentChat={() => {}} + onArchiveAgent={() => {}} + isSidebarCollapsed={isSidebarCollapsed} + onToggleSidebarCollapsed={onToggleSidebarCollapsed} + />
@@ -921,80 +917,106 @@ const AgentDetail: FC = () => { if (!chatQuery.data || !agentId) { return ( -
- Chat not found +
+ {}, + }} + workspace={{ + canOpenEditors: false, + canOpenWorkspace: false, + onOpenInEditor: () => {}, + onViewWorkspace: () => {}, + }} + onOpenParentChat={() => {}} + onArchiveAgent={() => {}} + isSidebarCollapsed={isSidebarCollapsed} + onToggleSidebarCollapsed={onToggleSidebarCollapsed} + /> +
+ Chat not found +
); } return ( -
- 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} - /> - -
-
-
- +
+
+ 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} + /> +
+
+
+ +
+ + +
); }; diff --git a/site/src/pages/AgentsPage/AgentDetail/TopBar.stories.tsx b/site/src/pages/AgentsPage/AgentDetail/TopBar.stories.tsx new file mode 100644 index 0000000000..703a09bd4b --- /dev/null +++ b/site/src/pages/AgentsPage/AgentDetail/TopBar.stories.tsx @@ -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; + +const meta: Meta = { + title: "pages/AgentsPage/AgentDetail/TopBar", + component: AgentDetailTopBar, + decorators: [withAuthProvider, withDashboardProvider], + parameters: { + layout: "fullscreen", + user: MockUserOwner, + }, + args: defaultProps, +}; +export default meta; +type Story = StoryObj; + +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, + }, +}; diff --git a/site/src/pages/AgentsPage/AgentDetail/TopBar.tsx b/site/src/pages/AgentsPage/AgentDetail/TopBar.tsx new file mode 100644 index 0000000000..12267d3918 --- /dev/null +++ b/site/src/pages/AgentsPage/AgentDetail/TopBar.tsx @@ -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 = ({ + status, + isOpen, + onToggle, +}) => { + const additions = status.additions ?? 0; + const deletions = status.deletions ?? 0; + + return ( + + ); +}; + +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 = ({ + chatTitle, + parentChat, + onOpenParentChat, + diff, + workspace, + onArchiveAgent, + isSidebarCollapsed, + onToggleSidebarCollapsed, +}) => { + const navigate = useNavigate(); + const { user, signOut } = useAuthenticated(); + const { appearance, buildInfo } = useDashboard(); + + return ( +
+ {/* Mobile back button */} + + {/* Desktop expand button: visible when sidebar is manually collapsed. */} + {isSidebarCollapsed && ( + + )} + {/* Title area */} +
+ {chatTitle && ( +
+ {parentChat && ( + <> + + + + )} + + {chatTitle} + +
+ )} +
+ {/* Actions area */} +
+ {diff.hasDiffStatus && diff.diffStatus && ( + + )} + + + + + + { + workspace.onOpenInEditor("cursor"); + }} + > + + Open in Cursor + + { + workspace.onOpenInEditor("vscode"); + }} + > + + Open in VS Code + + + + View Workspace + + + + Archive Agent + + + +
+
+ link.location !== "navbar", + ) ?? [] + } + onSignOut={signOut} + /> +
+
+ ); +}; diff --git a/site/src/pages/AgentsPage/AgentDetail/TopBarPortals.tsx b/site/src/pages/AgentsPage/AgentDetail/TopBarPortals.tsx deleted file mode 100644 index f88352d1f0..0000000000 --- a/site/src/pages/AgentsPage/AgentDetail/TopBarPortals.tsx +++ /dev/null @@ -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 = ({ - status, - isOpen, - onToggle, -}) => { - const additions = status.additions ?? 0; - const deletions = status.deletions ?? 0; - - return ( -
{ - 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" - > - - +{additions} - - - −{deletions} - - {isOpen ? ( - - ) : ( - - )} -
- ); -}; - -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; - topBarActionsRef?: RefObject; - rightPanelRef?: RefObject; - chatTitle?: string; - parentChat?: TypesGen.Chat; - onOpenParentChat: (chatId: string) => void; - diff: DiffPanelState; - workspace: WorkspaceActions; - onArchiveAgent: () => void; - shouldShowDiffPanel: boolean; - agentId: string; -}; - -export const AgentDetailTopBarPortals: FC = ({ - topBarTitleRef, - topBarActionsRef, - rightPanelRef, - chatTitle, - parentChat, - onOpenParentChat, - diff, - workspace, - onArchiveAgent, - shouldShowDiffPanel, - agentId, -}) => { - return ( - <> - {chatTitle && - topBarTitleRef?.current && - createPortal( -
- {parentChat && ( - <> - - - - )} - - {chatTitle} - -
, - topBarTitleRef.current, - )} - {diff.hasDiffStatus && - diff.diffStatus && - topBarActionsRef?.current && - createPortal( - , - topBarActionsRef.current, - )} - {topBarActionsRef?.current && - createPortal( - - - - - - { - workspace.onOpenInEditor("cursor"); - }} - > - - Open in Cursor - - { - workspace.onOpenInEditor("vscode"); - }} - > - - Open in VS Code - - - - View Workspace - - - - Archive Agent - - - , - topBarActionsRef.current, - )} - {shouldShowDiffPanel && - rightPanelRef?.current && - createPortal( - , - rightPanelRef.current, - )} - - ); -}; diff --git a/site/src/pages/AgentsPage/AgentsPage.stories.tsx b/site/src/pages/AgentsPage/AgentsPage.stories.tsx index 8bae8797c4..35d80c3aaf 100644 --- a/site/src/pages/AgentsPage/AgentsPage.stories.tsx +++ b/site/src/pages/AgentsPage/AgentsPage.stories.tsx @@ -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, - "topBarActionsRef" - >, -) => { - const topBarActionsRef = useRef(null); - return ( - <> -
- - - ); -}; - -const meta: Meta = { +const meta: Meta = { title: "pages/AgentsPage/AgentsEmptyState", - component: AgentsEmptyStateWithPortal, + component: AgentsEmptyState, args: { onCreateChat: fn(), isCreating: false, @@ -62,6 +38,8 @@ const meta: Meta = { modelCatalogError: undefined, canSetSystemPrompt: true, canManageChatModelConfigs: false, + isConfigureAgentsDialogOpen: false, + onConfigureAgentsDialogOpenChange: fn(), }, beforeEach: () => { localStorage.clear(); @@ -73,7 +51,7 @@ const meta: Meta = { }; export default meta; -type Story = StoryObj; +type Story = StoryObj; 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", - }), - ); - }); - }, -}; diff --git a/site/src/pages/AgentsPage/AgentsPage.tsx b/site/src/pages/AgentsPage/AgentsPage.tsx index 370932287c..4d27543e59 100644 --- a/site/src/pages/AgentsPage/AgentsPage.tsx +++ b/site/src/pages/AgentsPage/AgentsPage.tsx @@ -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; setChatErrorReason: (chatId: string, reason: string) => void; clearChatErrorReason: (chatId: string) => void; - topBarTitleRef: React.RefObject; - topBarActionsRef: React.RefObject; - rightPanelRef: React.RefObject; - 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(null); - const [isRightPanelOpen, setIsRightPanelOpen] = useState(false); + const [isConfigureAgentsDialogOpen, setConfigureAgentsDialogOpen] = + useState(false); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); const [chatErrorReasons, setChatErrorReasons] = useState< Record @@ -185,9 +182,6 @@ const AgentsPage: FC = () => { return next; }); }, []); - const topBarTitleRef = useRef(null); - const topBarActionsRef = useRef(null); - const rightPanelRef = useRef(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 (
{
-
-
- {/* Mobile logo: visible when no agent is selected. */} - {!agentId && ( + {agentId ? ( + + ) : ( + <> +
{ )} - )} - {/* Mobile back button: visible on mobile when an agent is selected. */} - {agentId && ( - - )} - {/* Desktop expand button: visible when sidebar is manually collapsed. */} - {isSidebarCollapsed && ( - - )} -
-
-
- link.location !== "navbar", - ) ?? [] - } - onSignOut={signOut} - /> + {isSidebarCollapsed && ( + + )} +
+
+ {isAgentsAdmin && ( + + )} +
+
+ link.location !== "navbar", + ) ?? [] + } + onSignOut={signOut} + /> +
-
- {agentId ? ( - - ) : ( { modelCatalogError={chatModelsQuery.error} canSetSystemPrompt={canSetSystemPrompt} canManageChatModelConfigs={isAgentsAdmin} - topBarActionsRef={topBarActionsRef} + isConfigureAgentsDialogOpen={isConfigureAgentsDialogOpen} + onConfigureAgentsDialogOpenChange={setConfigureAgentsDialogOpen} /> - )} -
- + + )}
); @@ -503,7 +483,8 @@ interface AgentsEmptyStateProps { modelCatalogError: unknown; canSetSystemPrompt: boolean; canManageChatModelConfigs: boolean; - topBarActionsRef: React.RefObject; + isConfigureAgentsDialogOpen: boolean; + onConfigureAgentsDialogOpenChange: (open: boolean) => void; } export const AgentsEmptyState: FC = ({ @@ -518,7 +499,8 @@ export const AgentsEmptyState: FC = ({ modelCatalogError, canSetSystemPrompt, canManageChatModelConfigs, - topBarActionsRef, + isConfigureAgentsDialogOpen, + onConfigureAgentsDialogOpenChange, }) => { const [inputValue, setInputValue] = useState(() => { if (typeof window === "undefined") { @@ -595,8 +577,6 @@ export const AgentsEmptyState: FC = ({ 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( () => { @@ -720,20 +700,6 @@ export const AgentsEmptyState: FC = ({ return (
- {hasAdminControls && - topBarActionsRef.current && - createPortal( - , - topBarActionsRef.current, - )} -
{createError ? : null} {workspacesQuery.isError && ( @@ -794,7 +760,7 @@ export const AgentsEmptyState: FC = ({ {hasAdminControls && ( ( /** * 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 = () => ( -
-
-
-
- {/* User message bubble (right-aligned) */} -
- -
- {/* Assistant response lines (left-aligned) */} -
- - - -
- {/* Second user message bubble */} -
- -
- {/* Second assistant response */} -
- - - - - +
+ {/* Minimal skeleton top bar */} +
+ + +
+ +
+
+
+
+
+ {/* User message bubble (right-aligned) */} +
+ +
+ {/* Assistant response lines (left-aligned) */} +
+ + + +
+ {/* Second user message bubble */} +
+ +
+ {/* Second assistant response */} +
+ + + + + +
diff --git a/site/src/pages/AgentsPage/DiffRightPanel.tsx b/site/src/pages/AgentsPage/DiffRightPanel.tsx index 1f9a35b9af..2489ac2a17 100644 --- a/site/src/pages/AgentsPage/DiffRightPanel.tsx +++ b/site/src/pages/AgentsPage/DiffRightPanel.tsx @@ -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; 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 (
{ 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}
); };