fix(site): prevent layout shift when agent chat right panel loads (#22983)

This commit is contained in:
Danielle Maywood
2026-03-12 14:09:12 +00:00
committed by GitHub
parent c3923f2ccd
commit 3aada03f52
5 changed files with 238 additions and 133 deletions
@@ -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";
// ---------------------------------------------------------------------------
+26
View File
@@ -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<ChatMessageInputRef | null>(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}
@@ -11,7 +11,6 @@ import {
AgentDetailLoadingView,
AgentDetailNotFoundView,
AgentDetailView,
RIGHT_PANEL_OPEN_KEY,
} from "./AgentDetailView";
// ---------------------------------------------------------------------------
@@ -119,6 +118,8 @@ const meta: Meta<typeof AgentDetailView> = {
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: () => (
<AgentDetailLoadingView
titleElement={<title>Loading Agents</title>}
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}
/>
),
};
+76 -90
View File
@@ -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<AgentDetailViewProps> = ({
agentId,
chatTitle,
@@ -142,6 +147,8 @@ export const AgentDetailView: FC<AgentDetailViewProps> = ({
isInterruptPending,
isSidebarCollapsed,
onToggleSidebarCollapsed,
showSidebarPanel,
onSetShowSidebarPanel,
prNumber,
diffStatusData,
gitWatcher,
@@ -162,25 +169,6 @@ export const AgentDetailView: FC<AgentDetailViewProps> = ({
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<boolean | null>(
null,
@@ -234,7 +222,7 @@ export const AgentDetailView: FC<AgentDetailViewProps> = ({
onOpenParentChat={(chatId) => onNavigateToChat(chatId)}
panel={{
showSidebarPanel,
onToggleSidebar: () => handleSetShowSidebarPanel((prev) => !prev),
onToggleSidebar: () => onSetShowSidebarPanel((prev) => !prev),
}}
workspace={{
canOpenEditors,
@@ -321,7 +309,7 @@ export const AgentDetailView: FC<AgentDetailViewProps> = ({
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<AgentDetailViewProps> = ({
),
},
]}
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<AgentDetailLoadingViewProps> = ({
@@ -387,76 +376,73 @@ export const AgentDetailLoadingView: FC<AgentDetailLoadingViewProps> = ({
modelCatalogStatusMessage,
isSidebarCollapsed,
onToggleSidebarCollapsed,
showRightPanel,
}) => {
return (
<div className="relative flex h-full min-h-0 min-w-0 flex-1 flex-col">
<div
className={cn(
"relative flex h-full min-h-0 min-w-0 flex-1",
showRightPanel && "flex-row",
)}
>
{titleElement}
<AgentDetailTopBar
panel={{
showSidebarPanel: false,
onToggleSidebar: () => {},
}}
workspace={{
canOpenEditors: false,
canOpenWorkspace: false,
onOpenInEditor: () => {},
onViewWorkspace: () => {},
onOpenTerminal: () => {},
sshCommand: undefined,
}}
onOpenParentChat={() => {}}
onArchiveAgent={() => {}}
onUnarchiveAgent={() => {}}
onArchiveAndDeleteWorkspace={() => {}}
hasWorkspace={false}
isSidebarCollapsed={isSidebarCollapsed}
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
/>
<div className="flex min-h-0 flex-1 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 className="relative flex h-full min-h-0 min-w-0 flex-1 flex-col">
<AgentDetailTopBar
panel={{
showSidebarPanel: false,
onToggleSidebar: () => {},
}}
workspace={{
canOpenEditors: false,
canOpenWorkspace: false,
onOpenInEditor: () => {},
onViewWorkspace: () => {},
onOpenTerminal: () => {},
sshCommand: undefined,
}}
onOpenParentChat={() => {}}
onArchiveAgent={() => {}}
onUnarchiveAgent={() => {}}
onArchiveAndDeleteWorkspace={() => {}}
hasWorkspace={false}
isSidebarCollapsed={isSidebarCollapsed}
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
/>
<div className="flex min-h-0 flex-1 flex-col-reverse overflow-hidden">
<div className="px-4">
<div className="mx-auto w-full max-w-3xl py-6">
<ChatConversationSkeleton />
</div>
</div>
</div>
<div className="shrink-0 px-4">
<AgentChatInput
onSend={() => {}}
initialValue=""
isDisabled={isInputDisabled}
isLoading={false}
selectedModel={effectiveSelectedModel}
onModelChange={setSelectedModel}
modelOptions={modelOptions}
modelSelectorPlaceholder={modelSelectorPlaceholder}
hasModelOptions={hasModelOptions}
inputStatusText={inputStatusText}
modelCatalogStatusMessage={modelCatalogStatusMessage}
/>
</div>
</div>
<div className="shrink-0 px-4">
<AgentChatInput
onSend={() => {}}
initialValue=""
isDisabled={isInputDisabled}
isLoading={false}
selectedModel={effectiveSelectedModel}
onModelChange={setSelectedModel}
modelOptions={modelOptions}
modelSelectorPlaceholder={modelSelectorPlaceholder}
hasModelOptions={hasModelOptions}
inputStatusText={inputStatusText}
modelCatalogStatusMessage={modelCatalogStatusMessage}
/>
</div>
{showRightPanel && (
<RightPanel
isOpen
isExpanded={false}
onToggleExpanded={() => {}}
onClose={() => {}}
isSidebarCollapsed={isSidebarCollapsed}
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
>
<RightPanelSkeleton />
</RightPanel>
)}
</div>
);
};
+110 -36
View File
@@ -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 = () => (
</div>
);
/**
* Skeleton placeholder for a chat conversation: two user message
* bubbles interleaved with assistant response lines.
*/
export const ChatConversationSkeleton: FC = () => (
<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>
);
/**
* Skeleton placeholder for the right sidebar panel: a tab bar and
* a few content lines.
*/
export const RightPanelSkeleton: FC = () => (
<div className="flex h-full min-w-0 flex-col overflow-hidden bg-surface-primary">
{/* Skeleton tab bar */}
<div className="flex shrink-0 items-center gap-2 border-0 border-b border-solid border-border-default px-3 py-1.5">
<Skeleton className="h-6 w-12 rounded-md" />
<div className="flex-1" />
</div>
{/* Skeleton panel content */}
<div className="space-y-4 p-4">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-3/4" />
</div>
</div>
);
/**
* 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 = () => (
<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" />
export const AgentDetailSkeleton: FC = () => {
const rightPanel = getRightPanelState();
return (
<div
className={cn(
"relative flex h-full min-h-0 min-w-0 flex-1",
rightPanel.open && "flex-row",
)}
>
<div className="flex h-full min-h-0 min-w-0 flex-1 flex-col">
<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">
<ChatConversationSkeleton />
</div>
</div>
</div>
</div>
{rightPanel.open && (
<div
style={
{
"--panel-width": `${rightPanel.width}px`,
} as React.CSSProperties
}
className="relative flex h-full w-[100vw] min-w-0 flex-col border-0 border-l border-solid border-border-default sm:w-[var(--panel-width)] sm:min-w-[360px] sm:max-w-[70vw]"
>
<RightPanelSkeleton />
</div>
)}
</div>
</div>
);
);
};