From 3aada03f52f0a764d702ffaca4933858f6859832 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 12 Mar 2026 14:09:12 +0000 Subject: [PATCH] fix(site): prevent layout shift when agent chat right panel loads (#22983) --- .../pages/AgentsPage/AgentDetail.stories.tsx | 3 +- site/src/pages/AgentsPage/AgentDetail.tsx | 26 +++ .../AgentsPage/AgentDetailView.stories.tsx | 30 +++- site/src/pages/AgentsPage/AgentDetailView.tsx | 166 ++++++++---------- site/src/pages/AgentsPage/AgentsSkeletons.tsx | 146 +++++++++++---- 5 files changed, 238 insertions(+), 133 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentDetail.stories.tsx b/site/src/pages/AgentsPage/AgentDetail.stories.tsx index 5f9058af5d..51f9c6e0b2 100644 --- a/site/src/pages/AgentsPage/AgentDetail.stories.tsx +++ b/site/src/pages/AgentsPage/AgentDetail.stories.tsx @@ -27,8 +27,7 @@ import { reactRouterOutlet, reactRouterParameters, } from "storybook-addon-remix-react-router"; -import AgentDetail from "./AgentDetail"; -import { RIGHT_PANEL_OPEN_KEY } from "./AgentDetailView"; +import AgentDetail, { RIGHT_PANEL_OPEN_KEY } from "./AgentDetail"; import type { AgentsOutletContext } from "./AgentsPage"; // --------------------------------------------------------------------------- diff --git a/site/src/pages/AgentsPage/AgentDetail.tsx b/site/src/pages/AgentsPage/AgentDetail.tsx index eba5e4caf4..4d80d03e38 100644 --- a/site/src/pages/AgentsPage/AgentDetail.tsx +++ b/site/src/pages/AgentsPage/AgentDetail.tsx @@ -83,6 +83,9 @@ import { import { useFileAttachments } from "./useFileAttachments"; import { useGitWatcher } from "./useGitWatcher"; +/** localStorage key controlling whether the right panel is visible. */ +export const RIGHT_PANEL_OPEN_KEY = "agents.right-panel-open"; + const localHosts = new Set(["localhost", "127.0.0.1", "0.0.0.0"]); const lastModelConfigIDStorageKey = "agents.last-model-config-id"; @@ -576,6 +579,26 @@ const AgentDetail: FC = () => { const chatInputRef = useRef(null); const inputValueRef = useRef(""); + // Right panel open/closed state is owned here so the loading + // skeleton and the loaded view share the same layout, preventing + // a horizontal shift when data arrives. + const [showSidebarPanel, setShowSidebarPanel] = useState(() => { + if (typeof window === "undefined") return false; + return localStorage.getItem(RIGHT_PANEL_OPEN_KEY) === "true"; + }); + const handleSetShowSidebarPanel = useCallback( + (next: boolean | ((prev: boolean) => boolean)) => { + setShowSidebarPanel((prev) => { + const value = typeof next === "function" ? next(prev) : next; + if (typeof window !== "undefined") { + localStorage.setItem(RIGHT_PANEL_OPEN_KEY, String(value)); + } + return value; + }); + }, + [], + ); + const chatQuery = useQuery({ ...chat(agentId ?? ""), enabled: Boolean(agentId), @@ -1065,6 +1088,7 @@ const AgentDetail: FC = () => { modelCatalogStatusMessage={modelCatalogStatusMessage} isSidebarCollapsed={isSidebarCollapsed} onToggleSidebarCollapsed={onToggleSidebarCollapsed} + showRightPanel={showSidebarPanel} /> ); } @@ -1103,6 +1127,8 @@ const AgentDetail: FC = () => { isInterruptPending={interruptMutation.isPending} isSidebarCollapsed={isSidebarCollapsed} onToggleSidebarCollapsed={onToggleSidebarCollapsed} + showSidebarPanel={showSidebarPanel} + onSetShowSidebarPanel={handleSetShowSidebarPanel} prNumber={prNumber} diffStatusData={diffStatusQuery.data} gitWatcher={gitWatcher} diff --git a/site/src/pages/AgentsPage/AgentDetailView.stories.tsx b/site/src/pages/AgentsPage/AgentDetailView.stories.tsx index 9ba3790236..e850178c9e 100644 --- a/site/src/pages/AgentsPage/AgentDetailView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentDetailView.stories.tsx @@ -11,7 +11,6 @@ import { AgentDetailLoadingView, AgentDetailNotFoundView, AgentDetailView, - RIGHT_PANEL_OPEN_KEY, } from "./AgentDetailView"; // --------------------------------------------------------------------------- @@ -119,6 +118,8 @@ const meta: Meta = { isInterruptPending: false, isSidebarCollapsed: false, onToggleSidebarCollapsed: fn(), + showSidebarPanel: false, + onSetShowSidebarPanel: fn(), prNumber: undefined, diffStatusData: undefined, gitWatcher: defaultGitWatcher, @@ -192,11 +193,8 @@ export const SubmissionPending: Story = { /** Right sidebar panel is open with diff status data. */ export const WithSidebarPanel: Story = { - beforeEach: () => { - localStorage.setItem(RIGHT_PANEL_OPEN_KEY, "true"); - return () => localStorage.removeItem(RIGHT_PANEL_OPEN_KEY); - }, args: { + showSidebarPanel: true, prNumber: 123, diffStatusData: { chat_id: AGENT_ID, @@ -254,6 +252,7 @@ export const Loading: Story = { modelCatalogStatusMessage={null} isSidebarCollapsed={false} onToggleSidebarCollapsed={fn()} + showRightPanel={false} /> ), }; @@ -273,6 +272,26 @@ export const LoadingWithModelOptions: Story = { modelCatalogStatusMessage={null} isSidebarCollapsed={false} onToggleSidebarCollapsed={fn()} + showRightPanel={false} + /> + ), +}; +/** Loading state with the right panel pre-opened. */ +export const LoadingWithRightPanel: Story = { + render: () => ( + Loading — Agents} + isInputDisabled + effectiveSelectedModel="openai:gpt-4o" + setSelectedModel={fn()} + modelOptions={defaultModelOptions} + modelSelectorPlaceholder="Select a model" + hasModelOptions + inputStatusText={null} + modelCatalogStatusMessage={null} + isSidebarCollapsed={false} + onToggleSidebarCollapsed={fn()} + showRightPanel /> ), }; @@ -292,6 +311,7 @@ export const LoadingSidebarCollapsed: Story = { modelCatalogStatusMessage={null} isSidebarCollapsed onToggleSidebarCollapsed={fn()} + showRightPanel={false} /> ), }; diff --git a/site/src/pages/AgentsPage/AgentDetailView.tsx b/site/src/pages/AgentsPage/AgentDetailView.tsx index 9af1957672..af7c127c79 100644 --- a/site/src/pages/AgentsPage/AgentDetailView.tsx +++ b/site/src/pages/AgentsPage/AgentDetailView.tsx @@ -1,9 +1,8 @@ import type { ChatDiffStatusResponse } from "api/api"; import type * as TypesGen from "api/typesGenerated"; import type { ModelSelectorOption } from "components/ai-elements"; -import { Skeleton } from "components/Skeleton/Skeleton"; import { ArchiveIcon } from "lucide-react"; -import { type FC, type RefObject, useCallback, useMemo, useState } from "react"; +import { type FC, type RefObject, useMemo, useState } from "react"; import type { UrlTransform } from "streamdown"; import { cn } from "utils/cn"; import { pageTitle } from "utils/page"; @@ -11,6 +10,10 @@ import { AgentChatInput, type ChatMessageInputRef } from "./AgentChatInput"; import { AgentDetailInput, AgentDetailTimeline } from "./AgentDetail"; import type { useChatStore } from "./AgentDetail/ChatContext"; import { AgentDetailTopBar } from "./AgentDetail/TopBar"; +import { + ChatConversationSkeleton, + RightPanelSkeleton, +} from "./AgentsSkeletons"; import { GitPanel } from "./GitPanel"; import { RightPanel } from "./RightPanel"; import { SidebarTabView } from "./SidebarTabView"; @@ -79,6 +82,11 @@ interface AgentDetailViewProps { isSidebarCollapsed: boolean; onToggleSidebarCollapsed: () => void; + // Right panel state (owned by the parent so loading and + // loaded views share the same layout). + showSidebarPanel: boolean; + onSetShowSidebarPanel: (next: boolean | ((prev: boolean) => boolean)) => void; + // Sidebar content data. prNumber: number | undefined; diffStatusData: ChatDiffStatusResponse | undefined; @@ -115,9 +123,6 @@ interface AgentDetailViewProps { urlTransform?: UrlTransform; } -/** localStorage key controlling whether the right panel is visible. */ -export const RIGHT_PANEL_OPEN_KEY = "agents.right-panel-open"; - export const AgentDetailView: FC = ({ agentId, chatTitle, @@ -142,6 +147,8 @@ export const AgentDetailView: FC = ({ isInterruptPending, isSidebarCollapsed, onToggleSidebarCollapsed, + showSidebarPanel, + onSetShowSidebarPanel, prNumber, diffStatusData, gitWatcher, @@ -162,25 +169,6 @@ export const AgentDetailView: FC = ({ scrollContainerRef, urlTransform, }) => { - // Panel/sidebar UI state – purely visual, no data-fetching - // implications. The open/closed state is persisted to localStorage - // so users get a consistent layout when switching between chats. - const [showSidebarPanel, setShowSidebarPanel] = useState(() => { - if (typeof window === "undefined") return false; - return localStorage.getItem(RIGHT_PANEL_OPEN_KEY) === "true"; - }); - const handleSetShowSidebarPanel = useCallback( - (next: boolean | ((prev: boolean) => boolean)) => { - setShowSidebarPanel((prev) => { - const value = typeof next === "function" ? next(prev) : next; - if (typeof window !== "undefined") { - localStorage.setItem(RIGHT_PANEL_OPEN_KEY, String(value)); - } - return value; - }); - }, - [], - ); const [isRightPanelExpanded, setIsRightPanelExpanded] = useState(false); const [dragVisualExpanded, setDragVisualExpanded] = useState( null, @@ -234,7 +222,7 @@ export const AgentDetailView: FC = ({ onOpenParentChat={(chatId) => onNavigateToChat(chatId)} panel={{ showSidebarPanel, - onToggleSidebar: () => handleSetShowSidebarPanel((prev) => !prev), + onToggleSidebar: () => onSetShowSidebarPanel((prev) => !prev), }} workspace={{ canOpenEditors, @@ -321,7 +309,7 @@ export const AgentDetailView: FC = ({ isOpen={shouldShowSidebar} isExpanded={isRightPanelExpanded} onToggleExpanded={() => setIsRightPanelExpanded((prev) => !prev)} - onClose={() => handleSetShowSidebarPanel(false)} + onClose={() => onSetShowSidebarPanel(false)} onVisualExpandedChange={setDragVisualExpanded} isSidebarCollapsed={isSidebarCollapsed} onToggleSidebarCollapsed={onToggleSidebarCollapsed} @@ -349,7 +337,7 @@ export const AgentDetailView: FC = ({ ), }, ]} - onClose={() => handleSetShowSidebarPanel(false)} + onClose={() => onSetShowSidebarPanel(false)} isExpanded={visualExpanded} onToggleExpanded={() => setIsRightPanelExpanded((prev) => !prev)} isSidebarCollapsed={isSidebarCollapsed} @@ -373,6 +361,7 @@ interface AgentDetailLoadingViewProps { modelCatalogStatusMessage: string | null; isSidebarCollapsed: boolean; onToggleSidebarCollapsed: () => void; + showRightPanel: boolean; } export const AgentDetailLoadingView: FC = ({ @@ -387,76 +376,73 @@ export const AgentDetailLoadingView: FC = ({ modelCatalogStatusMessage, isSidebarCollapsed, onToggleSidebarCollapsed, + showRightPanel, }) => { return ( -
+
{titleElement} - {}, - }} - workspace={{ - canOpenEditors: false, - canOpenWorkspace: false, - onOpenInEditor: () => {}, - onViewWorkspace: () => {}, - onOpenTerminal: () => {}, - sshCommand: undefined, - }} - onOpenParentChat={() => {}} - onArchiveAgent={() => {}} - onUnarchiveAgent={() => {}} - onArchiveAndDeleteWorkspace={() => {}} - hasWorkspace={false} - isSidebarCollapsed={isSidebarCollapsed} - onToggleSidebarCollapsed={onToggleSidebarCollapsed} - /> -
-
-
-
- {/* User message bubble (right-aligned) */} -
- -
- {/* Assistant response lines (left-aligned) */} -
- - - -
- {/* Second user message bubble */} -
- -
- {/* Second assistant response */} -
- - - - - -
{" "} +
+ {}, + }} + workspace={{ + canOpenEditors: false, + canOpenWorkspace: false, + onOpenInEditor: () => {}, + onViewWorkspace: () => {}, + onOpenTerminal: () => {}, + sshCommand: undefined, + }} + onOpenParentChat={() => {}} + onArchiveAgent={() => {}} + onUnarchiveAgent={() => {}} + onArchiveAndDeleteWorkspace={() => {}} + hasWorkspace={false} + isSidebarCollapsed={isSidebarCollapsed} + onToggleSidebarCollapsed={onToggleSidebarCollapsed} + /> +
+
+
+
+
+ {}} + initialValue="" + isDisabled={isInputDisabled} + isLoading={false} + selectedModel={effectiveSelectedModel} + onModelChange={setSelectedModel} + modelOptions={modelOptions} + modelSelectorPlaceholder={modelSelectorPlaceholder} + hasModelOptions={hasModelOptions} + inputStatusText={inputStatusText} + modelCatalogStatusMessage={modelCatalogStatusMessage} + /> +
-
- {}} - initialValue="" - isDisabled={isInputDisabled} - isLoading={false} - selectedModel={effectiveSelectedModel} - onModelChange={setSelectedModel} - modelOptions={modelOptions} - modelSelectorPlaceholder={modelSelectorPlaceholder} - hasModelOptions={hasModelOptions} - inputStatusText={inputStatusText} - modelCatalogStatusMessage={modelCatalogStatusMessage} - /> -
+ {showRightPanel && ( + {}} + onClose={() => {}} + isSidebarCollapsed={isSidebarCollapsed} + onToggleSidebarCollapsed={onToggleSidebarCollapsed} + > + + + )}
); }; diff --git a/site/src/pages/AgentsPage/AgentsSkeletons.tsx b/site/src/pages/AgentsPage/AgentsSkeletons.tsx index cfab1bd993..343c90d44b 100644 --- a/site/src/pages/AgentsPage/AgentsSkeletons.tsx +++ b/site/src/pages/AgentsPage/AgentsSkeletons.tsx @@ -1,5 +1,29 @@ import { Skeleton } from "components/Skeleton/Skeleton"; import type { FC } from "react"; +import { cn } from "utils/cn"; + +/** localStorage keys shared with the agents panel components. */ +const RIGHT_PANEL_OPEN_KEY = "agents.right-panel-open"; +const RIGHT_PANEL_WIDTH_KEY = "agents.right-panel-width"; +const DEFAULT_PANEL_WIDTH = 480; +const MIN_PANEL_WIDTH = 360; + +/** Read persisted right-panel state for use in static skeletons. */ +function getRightPanelState(): { open: boolean; width: number } { + if (typeof window === "undefined") { + return { open: false, width: DEFAULT_PANEL_WIDTH }; + } + const open = localStorage.getItem(RIGHT_PANEL_OPEN_KEY) === "true"; + const stored = localStorage.getItem(RIGHT_PANEL_WIDTH_KEY); + let width = DEFAULT_PANEL_WIDTH; + if (stored) { + const parsed = Number.parseInt(stored, 10); + if (!Number.isNaN(parsed) && parsed >= MIN_PANEL_WIDTH) { + width = parsed; + } + } + return { open, width }; +} /** * Skeleton shown while the AgentsPage chunk is loading. Mimics the @@ -43,49 +67,99 @@ export const AgentsPageSkeleton: FC = () => (
); +/** + * Skeleton placeholder for a chat conversation: two user message + * bubbles interleaved with assistant response lines. + */ +export const ChatConversationSkeleton: FC = () => ( +
+ {/* User message bubble (right-aligned) */} +
+ +
+ {/* Assistant response lines (left-aligned) */} +
+ + + +
+ {/* Second user message bubble */} +
+ +
+ {/* Second assistant response */} +
+ + + + + +
+
+); + +/** + * Skeleton placeholder for the right sidebar panel: a tab bar and + * a few content lines. + */ +export const RightPanelSkeleton: FC = () => ( +
+ {/* Skeleton tab bar */} +
+ +
+
+ {/* Skeleton panel content */} +
+ + + +
+
+); + /** * Skeleton shown while the AgentDetail chunk is loading. Mimics a * top bar + chat conversation layout so the user sees navigable * structure during the brief Suspense fallback. */ -export const AgentDetailSkeleton: FC = () => ( -
- {/* Minimal skeleton top bar */} -
- - -
- -
-
-
-
-
- {/* User message bubble (right-aligned) */} -
- -
- {/* Assistant response lines (left-aligned) */} -
- - - -
- {/* Second user message bubble */} -
- -
- {/* Second assistant response */} -
- - - - - +export const AgentDetailSkeleton: FC = () => { + const rightPanel = getRightPanelState(); + + return ( +
+
+
+ + +
+ +
+
+
+
+
+ {rightPanel.open && ( +
+ +
+ )}
-
-); + ); +};