From a24ebb9d386b7486b51959ca3acd3a887e176eb7 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 5 May 2026 20:08:21 +0700 Subject: [PATCH] fix: keep agents desktop layout at 200% zoom (#24699) Fixes layout issues on the agents empty state page. 1. At 200% zoom on a 1440 px desktop, the CSS viewport shrinks to 720 px, which was below the previous `md:` breakpoint (768 px) and collapsed the page into the mobile stack. Switching the page shell and shell-level controls to the `sm:` breakpoint (640 px) keeps the sidebar and chat area side-by-side at common zoom levels while preserving the mobile stack for real phone viewports. 2. The empty state stays bottom-aligned on mobile and centered on the desktop branch, with tighter spacing so the chat input sits closer to the bottom of the screen at 200% zoom. 3. The inner stack gap shrinks from `gap-4` (16 px) to `gap-2` (8 px) and the footer paragraph drops its `mt-1`, tightening the space around the organization selector, the chat input, and the "Introductory access to Coder Agents through September 2026" line. 4. Sidebar header/footer controls, the page header, the chat top bar, and the plan-mode badge now use the same `sm:` desktop breakpoint as the page shell. A collapsed sidebar can be expanded again at 640 to 767 px. Dropdown full-width CSS (`@media (max-width: 767px)`) and the `isBelowMdViewport` helper are intentionally left at 768 px. Those govern dropdown UX rather than page layout, and the chat pane is still narrow at 640 to 767 px after the sidebar is visible. The page is in desktop mode in that range while dropdowns stay full-width. Screenshot 2026-04-30 at 23 03 48 Screenshot 2026-04-30 at 23 03 57 Regression coverage in Storybook: - `AgentsPageView.stories.tsx > EmptyStateZoom200Desktop` pins a new 720 px Chromatic viewport and asserts the rendered layout is horizontal, the sidebar is left of the main panel, and the sidebar header/footer controls are visible. - `AgentsPageView.stories.tsx > CollapsedSidebarZoom200Desktop` pins the same 720 px viewport and asserts the expand-sidebar control is visible when the sidebar is collapsed. - `AgentCreateForm.stories.tsx > OrgPickerTightSpacing` measures the vertical gap between the org selector row and the chat-input composer and expects it to stay below 16 px. --- Generated by Coder Agents. --- site/.storybook/preview.tsx | 13 ++ .../AgentsPage/AgentsPageView.stories.tsx | 117 +++++++++++++++++- site/src/pages/AgentsPage/AgentsPageView.tsx | 21 ++-- .../components/AgentChatInput.stories.tsx | 4 + .../AgentsPage/components/AgentChatInput.tsx | 7 +- .../components/AgentCreateForm.stories.tsx | 24 ++++ .../AgentsPage/components/AgentCreateForm.tsx | 6 +- .../components/AgentPageHeader.stories.tsx | 4 +- .../AgentsPage/components/AgentPageHeader.tsx | 16 +-- .../AgentsPage/components/AgentsSkeletons.tsx | 8 +- .../AgentsPage/components/ChatTopBar.tsx | 8 +- .../components/Sidebar/AgentsSidebar.tsx | 16 +-- 12 files changed, 202 insertions(+), 42 deletions(-) diff --git a/site/.storybook/preview.tsx b/site/.storybook/preview.tsx index a864321530..ebfc1b383c 100644 --- a/site/.storybook/preview.tsx +++ b/site/.storybook/preview.tsx @@ -50,6 +50,19 @@ export const parameters: Parameters = { }, type: "mobile", }, + // Approximates a 1440x900 desktop viewed at 200% browser zoom, + // which collapses the CSS viewport to 720x450. Used by stories + // that verify the desktop layout still renders at common zoom + // levels. Below the Tailwind sm: breakpoint (640 px), the + // AgentsPage collapses into the mobile stack, so 720 px stays + // on the desktop branch. + desktopZoom200: { + name: "Desktop @ 200% zoom (720x450)", + styles: { + height: "450px", + width: "720px", + }, + }, terminal: { name: "Terminal", styles: { diff --git a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx index 930e415a3d..f68f0f64b6 100644 --- a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import dayjs from "dayjs"; import { type ComponentProps, useState } from "react"; -import { Navigate } from "react-router"; +import { Navigate, useOutletContext } from "react-router"; import { expect, fn, @@ -35,8 +35,9 @@ import AgentSettingsInstructionsPage from "./AgentSettingsInstructionsPage"; import AgentSettingsLifecyclePage from "./AgentSettingsLifecyclePage"; import AgentSettingsPage from "./AgentSettingsPage"; import AgentSettingsSpendPage from "./AgentSettingsSpendPage"; -import { AgentsPageView } from "./AgentsPageView"; +import { type AgentsOutletContext, AgentsPageView } from "./AgentsPageView"; import type { ModelSelectorOption } from "./components/ChatElements"; +import { ChatTopBar } from "./components/ChatTopBar"; const defaultModelConfigID = "model-config-1"; @@ -213,6 +214,32 @@ const agentsRouting = { ], }; +const AgentTopBarRouteElement = () => { + const { isSidebarCollapsed, onToggleSidebarCollapsed } = + useOutletContext(); + return ( + + ); +}; + +const agentsWithChatTopBarRouting = { + ...agentsRouting, + children: agentsRouting.children.map((route) => + "path" in route && route.path === ":agentId" + ? { ...route, element: } + : route, + ), +}; + const defaultArgs: ComponentProps = { agentId: undefined, chatList: [], @@ -497,6 +524,92 @@ export const WithToolbarEndContent: Story = { }, }; +export const EmptyStateZoom200Desktop: Story = { + parameters: { + viewport: { defaultViewport: "desktopZoom200" }, + chromatic: { viewports: [720] }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const layout = await canvas.findByTestId("agents-page-layout"); + const sidebar = await canvas.findByTestId("agents-sidebar-panel"); + const main = await canvas.findByTestId("agents-main-panel"); + + await waitFor(() => { + const layoutStyles = getComputedStyle(layout); + const sidebarStyles = getComputedStyle(sidebar); + const mainStyles = getComputedStyle(main); + const sidebarRect = sidebar.getBoundingClientRect(); + const mainRect = main.getBoundingClientRect(); + + expect(layoutStyles.flexDirection).toBe("row"); + expect(sidebarStyles.display).not.toBe("none"); + expect(mainStyles.display).toBe("flex"); + expect(sidebarRect.width).toBeGreaterThan(0); + expect(mainRect.width).toBeGreaterThan(0); + expect(sidebarRect.left).toBeLessThan(mainRect.left); + expect(sidebarRect.right).toBeLessThanOrEqual(mainRect.left + 1); + }); + + await expect(canvas.getByRole("link", { name: "Settings" })).toBeVisible(); + await expect(canvas.getByRole("link", { name: "New Agent" })).toBeVisible(); + await expect( + canvas.getByRole("button", { name: "Collapse sidebar" }), + ).toBeVisible(); + await expect( + canvas.getByRole("button", { name: /TestUser/ }), + ).toBeVisible(); + }, +}; + +export const CollapsedSidebarZoom200Desktop: Story = { + args: { + isSidebarCollapsed: true, + }, + parameters: { + viewport: { defaultViewport: "desktopZoom200" }, + chromatic: { viewports: [720] }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const expandButton = await canvas.findByRole("button", { + name: "Expand sidebar", + }); + + await expect(expandButton).toBeVisible(); + }, +}; + +export const CollapsedSidebarZoom200DesktopWithAgent: Story = { + args: { + agentId: "chat-1", + isSidebarCollapsed: true, + chatList: [ + buildChat({ + id: "chat-1", + title: "Collapsed sidebar agent", + updated_at: todayTimestamp, + }), + ], + }, + parameters: { + viewport: { defaultViewport: "desktopZoom200" }, + chromatic: { viewports: [720] }, + reactRouter: reactRouterParameters({ + location: { path: "/agents/chat-1" }, + routing: agentsWithChatTopBarRouting, + }), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const expandButton = await canvas.findByRole("button", { + name: "Expand sidebar", + }); + + await expect(expandButton).toBeVisible(); + }, +}; + export const CreatingAgent: Story = { args: { isCreating: true, diff --git a/site/src/pages/AgentsPage/AgentsPageView.tsx b/site/src/pages/AgentsPage/AgentsPageView.tsx index b23a111333..f4d7787574 100644 --- a/site/src/pages/AgentsPage/AgentsPageView.tsx +++ b/site/src/pages/AgentsPage/AgentsPageView.tsx @@ -156,17 +156,21 @@ export const AgentsPageView: FC = ({ }; return ( -
+
{pageTitle("Agents")}
= ({ />
diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx index 21b818523b..c9a936f1df 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx @@ -652,6 +652,10 @@ export const PlanningIndicator: Story = { planModeEnabled: true, onPlanModeToggle: fn(), }, + parameters: { + viewport: { defaultViewport: "desktopZoom200" }, + chromatic: { viewports: [720] }, + }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); expect(canvas.getByText("Planning")).toBeVisible(); diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.tsx index 1bc2b5eb94..a6c1721a94 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.tsx @@ -736,8 +736,9 @@ export const AgentChatInput: FC = ({ )}
= ({ /> )} {planModeEnabled && ( - + Planning {onPlanModeToggle && ( @@ -1115,7 +1116,7 @@ export const AgentChatInput: FC = ({ * when there's no overflow but still occupies * layout space, preventing measurement flicker. */} {workspace && workspaceAgent && chatId && ( - + { + const canvas = within(canvasElement); + const orgTrigger = await canvas.findByTestId("compact-org-selector"); + const composer = await canvas.findByTestId("chat-composer"); + + const orgRect = orgTrigger.getBoundingClientRect(); + const composerRect = composer.getBoundingClientRect(); + const gap = composerRect.top - orgRect.bottom; + expect(gap).toBeGreaterThanOrEqual(0); + expect(gap).toBeLessThan(16); + }, +}; + /** * Standalone story for the org-change confirmation dialog. Renders * the ConfirmDialog directly in its open state, following the same diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index 3bfabaec5b..a5a6d20e92 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -448,8 +448,8 @@ export const AgentCreateForm: FC = ({ return ( <> -
-
+
+
{isForbidden ? ( ) : createError ? ( @@ -534,7 +534,7 @@ export const AgentCreateForm: FC = ({ {modelSelectorHelp}
) : null} -

+

{ const dispatch = (): void => { const event = { matches: desktop, - media: "(min-width: 768px)", + media: "(min-width: 640px)", } as MediaQueryListEvent; for (const listener of listeners) { listener(event); @@ -52,7 +52,7 @@ const createMatchMediaController = (initialDesktop: boolean) => { }; const matchMedia = ((query: string): MediaQueryList => { - const isDesktopQuery = /\(\s*min-width\s*:\s*768px\s*\)/.test(query); + const isDesktopQuery = /\(\s*min-width\s*:\s*640px\s*\)/.test(query); return { matches: isDesktopQuery ? desktop : false, media: query, diff --git a/site/src/pages/AgentsPage/components/AgentPageHeader.tsx b/site/src/pages/AgentsPage/components/AgentPageHeader.tsx index d4b1d577b8..c37ce8998a 100644 --- a/site/src/pages/AgentsPage/components/AgentPageHeader.tsx +++ b/site/src/pages/AgentsPage/components/AgentPageHeader.tsx @@ -63,11 +63,11 @@ export const AgentPageHeader: FC = ({ const chimeEnabled = controlledChimeEnabled ?? internalChimeEnabled; const webPush = controlledWebPush ?? internalWebPush; const [isDesktop, setIsDesktop] = useState(() => { - return window.matchMedia("(min-width: 768px)").matches; + return window.matchMedia("(min-width: 640px)").matches; }); useEffect(() => { - const mediaQuery = window.matchMedia("(min-width: 768px)"); + const mediaQuery = window.matchMedia("(min-width: 640px)"); const onMediaChange = (event: MediaQueryListEvent) => { setIsDesktop(event.matches); }; @@ -115,21 +115,21 @@ export const AgentPageHeader: FC = ({ }; return ( -

+
{mobileBack ? ( ) : ( -
+
@@ -142,14 +142,14 @@ export const AgentPageHeader: FC = ({ size="icon" onClick={onExpandSidebar} aria-label="Expand sidebar" - className="hidden h-7 w-7 min-w-0 shrink-0 md:inline-flex" + className="hidden h-7 w-7 min-w-0 shrink-0 sm:inline-flex" > )}
{children && isDesktop && ( -
{children}
+
{children}
)} {/* Mobile: meatball menu with all actions */} {!mobileBack && !isDesktop && ( @@ -159,7 +159,7 @@ export const AgentPageHeader: FC = ({ variant="subtle" size="icon" aria-label="More options" - className="h-7 w-7 text-content-secondary hover:text-content-primary md:hidden" + className="h-7 w-7 text-content-secondary hover:text-content-primary sm:hidden" > diff --git a/site/src/pages/AgentsPage/components/AgentsSkeletons.tsx b/site/src/pages/AgentsPage/components/AgentsSkeletons.tsx index e7fb815da1..18278448ad 100644 --- a/site/src/pages/AgentsPage/components/AgentsSkeletons.tsx +++ b/site/src/pages/AgentsPage/components/AgentsSkeletons.tsx @@ -29,11 +29,11 @@ function getRightPanelState(): { open: boolean; width: number } { * immediately instead of a fullscreen spinner. */ export const AgentsPageSkeleton: FC = () => ( -
-
+
+
-
+
@@ -67,7 +67,7 @@ export const AgentsPageSkeleton: FC = () => (
-
+
); diff --git a/site/src/pages/AgentsPage/components/ChatTopBar.tsx b/site/src/pages/AgentsPage/components/ChatTopBar.tsx index 75c8106509..740b5bb618 100644 --- a/site/src/pages/AgentsPage/components/ChatTopBar.tsx +++ b/site/src/pages/AgentsPage/components/ChatTopBar.tsx @@ -86,7 +86,7 @@ export const ChatTopBar: FC = ({ asChild variant="subtle" size="icon" - className="inline-flex h-7 w-7 min-w-0 shrink-0 md:hidden" + className="inline-flex h-7 w-7 min-w-0 shrink-0 sm:hidden" > = ({ size="icon" onClick={onToggleSidebarCollapsed} aria-label="Expand sidebar" - className="hidden h-7 w-7 min-w-0 shrink-0 md:inline-flex" + className="hidden h-7 w-7 min-w-0 shrink-0 sm:inline-flex" > @@ -173,10 +173,10 @@ export const ChatTopBar: FC = ({ draft={prDraft} className="!size-3.5 shrink-0" /> - + {prTitle || (prNumberMatch ? `#${prNumberMatch}` : "PR")} - + {prNumberMatch ? prNumberMatch : "PR"} diff --git a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx index fdf4fe00b5..9d38553fa7 100644 --- a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx +++ b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx @@ -1079,13 +1079,13 @@ export const AgentsSidebar: FC = (props) => { {/* ── Panel 1: Chats ── */}
-
+
@@ -1140,10 +1140,10 @@ export const AgentsSidebar: FC = (props) => { viewportClassName={cn( "[mask-image:linear-gradient(to_bottom,transparent_0,black_20px,black_calc(100%-20px),transparent_100%)]", "[-webkit-mask-image:linear-gradient(to_bottom,transparent_0,black_20px,black_calc(100%-20px),transparent_100%)]", - "md:[mask-image:none] md:[-webkit-mask-image:none]", + "sm:[mask-image:none] sm:[-webkit-mask-image:none]", )} > -
+
{loadError ? (
@@ -1284,7 +1284,7 @@ export const AgentsSidebar: FC = (props) => {
-
+
@@ -1325,14 +1325,14 @@ export const AgentsSidebar: FC = (props) => { {/* ── Panel 2: Sub-navigation (Settings) ── */}
{/* Back header */} -
+
{subNavTitle} @@ -1371,7 +1371,7 @@ export const AgentsSidebar: FC = (props) => { size="icon" onClick={onCollapse} aria-label="Collapse sidebar" - className="relative z-10 hidden h-7 w-7 min-w-0 text-content-secondary hover:text-content-primary md:inline-flex" + className="relative z-10 hidden h-7 w-7 min-w-0 text-content-secondary hover:text-content-primary sm:inline-flex" >