diff --git a/apps/web/components/layout/AppShell.tsx b/apps/web/components/layout/AppShell.tsx index 3615aae08..4984eb0d6 100644 --- a/apps/web/components/layout/AppShell.tsx +++ b/apps/web/components/layout/AppShell.tsx @@ -80,7 +80,12 @@ export function AppShell({ children, noSidebarPaths = [] }: AppShellProps) { || pathMatchesPrefixes(pathname, standalonePrefixes) || pathMatchesPrefixes(pathname, noSidebarPaths); const noDock = pathMatchesPrefixes(pathname, noDockPrefixes); - const fullBleed = pathMatchesPrefixes(pathname, fullBleedCollectionPaths); + // `/runs` is a collection that owns a full-height split layout, while + // `/runs/[id]` is a standard detail page. Prefix matching alone classified + // both as full-bleed, stripping the detail page's gutters and scroll pane. + const isDedicatedRunDetail = /^\/runs\/[^/]+\/?$/.test(pathname); + const fullBleed = !isDedicatedRunDetail + && pathMatchesPrefixes(pathname, fullBleedCollectionPaths); if (standalone) { return ( diff --git a/apps/web/tests/app-shell-run-detail-layout.dom.test.tsx b/apps/web/tests/app-shell-run-detail-layout.dom.test.tsx new file mode 100644 index 000000000..597c5478b --- /dev/null +++ b/apps/web/tests/app-shell-run-detail-layout.dom.test.tsx @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render } from "@testing-library/react"; + +const { pathname } = vi.hoisted(() => ({ pathname: vi.fn(() => "/runs/run_123") })); + +vi.mock("next/navigation", () => ({ + usePathname: () => pathname(), +})); + +vi.mock("@/components/layout/sidebar", () => ({ + Sidebar: () => , + FloomMark: () => null, +})); + +vi.mock("@/components/emily/EmilyChat", () => ({ + EmilyDock: () => null, + EmilyMobileSheet: () => null, +})); + +vi.mock("@/components/CommandPalette", () => ({ CommandPalette: () => null })); +vi.mock("@/components/Ambient", () => ({ Ambient: () => null })); +vi.mock("@/components/IconSprite", () => ({ IconSprite: () => null })); +vi.mock("@/components/layout/DeepLinkRouter", () => ({ DeepLinkRouter: () => null })); +vi.mock("@/components/layout/BootSplash", () => ({ BootSplash: () => null })); +vi.mock("@/components/ui/sonner", () => ({ Toaster: () => null })); +vi.mock("@/components/TermsAcceptanceGate", () => ({ TermsAcceptanceGate: () => null })); + +function renderShell() { + return import("@/components/layout/AppShell").then(({ AppShell }) => render( + +
route content
+
, + )); +} + +describe("AppShell run detail layout", () => { + beforeEach(() => { + pathname.mockReturnValue("/runs/run_123"); + vi.stubGlobal( + "matchMedia", + vi.fn(() => ({ + matches: true, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })), + ); + }); + + it("renders /runs/[id] in the padded, vertically scrollable standard pane", async () => { + const { container, getByTestId } = await renderShell(); + const main = container.querySelector("main"); + const wrapper = getByTestId("route-content").parentElement; + + expect(main).not.toBeNull(); + expect(main?.className).toContain("overflow-y-auto"); + expect(main?.className).not.toContain("overflow-hidden"); + expect(wrapper?.className).toContain("max-w-7xl"); + expect(wrapper?.className).toContain("px-4"); + expect(wrapper?.className).toContain("sm:px-6"); + expect(wrapper?.className).toContain("py-6"); + expect(wrapper?.className).toContain("sm:py-8"); + }); + + it.each(["/runs", "/runs/"])("keeps the %s collection full-bleed", async (path) => { + pathname.mockReturnValue(path); + const { container, getByTestId } = await renderShell(); + const main = container.querySelector("main"); + + expect(main?.className).toContain("overflow-hidden"); + expect(main?.className).not.toContain("overflow-y-auto"); + expect(getByTestId("route-content").parentElement).toBe(main); + }); + + it("keeps the standalone /run/[id] share route outside authenticated chrome", async () => { + pathname.mockReturnValue("/run/run_123"); + const { container, getByTestId } = await renderShell(); + const main = container.querySelector("main"); + + expect(main?.className).toContain("overflow-y-auto"); + expect(getByTestId("route-content").parentElement).toBe(main); + expect(container.querySelector("aside")).toBeNull(); + }); +});