diff --git a/src/browser/App.tsx b/src/browser/App.tsx index 267d57fc00..60acce138a 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -1251,7 +1251,10 @@ function AppInner() { return ( <> -
+ {/* mobile-bottom-inset-host: narrow layouts give this box's bottom inset up to a column that + reserves its own clearance, keyed off the column rather than the route so surfaces without + one still get it here. The sidebar is position: fixed with its own inset at these widths. */} +
= (props) => {
+ {/* The footer info bar hides its PR badge at this width, so the header carries it there. */} + diff --git a/src/browser/stories/App.phoneViewports.stories.tsx b/src/browser/stories/App.phoneViewports.stories.tsx index 0d64e3c4c8..d0c36e8679 100644 --- a/src/browser/stories/App.phoneViewports.stories.tsx +++ b/src/browser/stories/App.phoneViewports.stories.tsx @@ -12,7 +12,7 @@ import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events"; import { updatePersistedState } from "@/browser/hooks/usePersistedState"; import { LEFT_SIDEBAR_COLLAPSED_KEY } from "@/common/constants/storage"; -import { MOBILE_TOUCH_TARGET_PX } from "@/constants/layout"; +import { MOBILE_TOUCH_TARGET_PX, NARROW_VIEWPORT_MAX_WIDTH_PX } from "@/constants/layout"; import { appMeta, AppWithMocks, PIXEL_DISABLED, type AppStory } from "./meta.js"; import { createAssistantMessage, createUserMessage } from "./mocks/messages"; @@ -103,6 +103,28 @@ index 1111111..2222222 100644 `; const TOUCH_REVIEW_IMMERSIVE_NUMSTAT = "2\t0\tsrc/mobile/review.tsx"; +const PR_LINK_URL = "https://github.com/coder/mux/pull/3753"; +const PR_DETECTION_JSON = JSON.stringify({ + number: 3753, + url: PR_LINK_URL, + state: "OPEN", + mergeable: "MERGEABLE", + mergeStateStatus: "CLEAN", + title: "Redesign the workspace chrome", + isDraft: false, + headRefName: "feature/mobile-chrome", + baseRefName: "main", + statusCheckRollup: [], +}); + +function countVisiblePRLinks(containerTestId: string): number { + return [ + ...document.querySelectorAll( + `[data-testid="${containerTestId}"] a[href="${PR_LINK_URL}"]` + ), + ].filter((link) => link.getBoundingClientRect().width > 0).length; +} + export default { ...appMeta, title: "App/PhoneViewports", @@ -140,6 +162,66 @@ export const IPhone16e: AppStory = { }, }; +/** + * The PR badge lives in the footer info bar on wide viewports and in the workspace header on narrow + * ones. Pixel captures the narrow placement; the play assertion covers whichever side the ambient + * viewport selects, so the test-runner exercises the wide placement. + */ +export const IPhone16ePRLinkPlacement: AppStory = { + // Mirrors the Pixel phone variant: the fixed-width decorator does not move `window.innerWidth`, so + // without this a local reviewer would see the wide placement in a story framed as a phone. + globals: { + viewport: { value: "mobile2", isRotated: false }, + }, + render: () => ( + + setupSimpleChatStory({ + workspaceId: "ws-iphone-16e-pr-link", + workspaceName: "mobile-pr", + projectName: "mux", + messages: [...MESSAGES], + executeBash: (_workspaceId, script) => + Promise.resolve({ + success: true as const, + // Empty output for anything else falls through to the git status executor. + output: script.includes("gh pr view") ? PR_DETECTION_JSON : "", + exitCode: 0, + wall_duration_ms: 5, + }), + }) + } + /> + ), + decorators: [IPhone16eDecorator], + parameters: { + ...appMeta.parameters, + pixel: { + matrix: { themes: ["dark", "light"], viewports: ["phone"] }, + }, + }, + play: async ({ canvasElement }) => { + await stabilizePhoneViewportStory(canvasElement); + + const narrow = window.matchMedia(`(max-width: ${NARROW_VIEWPORT_MAX_WIDTH_PX}px)`).matches; + await waitFor( + () => { + const inHeader = countVisiblePRLinks("workspace-menu-bar"); + const inFooter = countVisiblePRLinks("workspace-footer-bar"); + const [expectedHeader, expectedFooter] = narrow ? [1, 0] : [0, 1]; + if (inHeader !== expectedHeader || inFooter !== expectedFooter) { + throw new Error( + `At ${window.innerWidth}px the PR link belongs ${ + narrow ? "in the header" : "in the footer" + }, but ${inHeader} were visible in the header and ${inFooter} in the footer` + ); + } + }, + { timeout: 10_000 } + ); + }, +}; + export const IPhone16eAnalyticsSidebarControl: AppStory = { globals: { viewport: { value: "mobile1", isRotated: false }, @@ -327,6 +409,11 @@ export const IPhone17ProMaxTouchReviewImmersive: AppStory = { if (canvas.queryByRole("heading", { name: "Notes" })) { throw new Error("Touch immersive mode should hide the desktop notes sidebar."); } + // The chat column is hidden here, so it must not keep claiming the bottom safe-area inset + // the app root would otherwise reserve for this view's own scroll area. + if (document.querySelectorAll("[data-bottom-inset-owner]").length > 0) { + throw new Error("Immersive review left the bottom safe-area inset unowned."); + } }, { timeout: 10_000 } ); diff --git a/src/browser/styles/globals.css b/src/browser/styles/globals.css index 51f090be4a..69e2ad0ae8 100644 --- a/src/browser/styles/globals.css +++ b/src/browser/styles/globals.css @@ -1203,6 +1203,14 @@ body, padding-left: 0 !important; } + /* Hand the bottom inset to a column that reserves its own clearance (see ChatPane and + WorkspaceFooterBar). This box clips overflow, so a child can never paint into its padding, and + the reserved band reads as dead space under the footer bar. Surfaces without such a column + (immersive review, settings, creation, loading shells) keep the inset here. */ + .mobile-bottom-inset-host:has([data-bottom-inset-owner]) { + padding-bottom: 0 !important; + } + .mobile-overlay { display: block !important; } diff --git a/src/constants/layout.ts b/src/constants/layout.ts index d7cd773e39..43aed5dbf0 100644 --- a/src/constants/layout.ts +++ b/src/constants/layout.ts @@ -11,6 +11,11 @@ export const CREATION_COLUMN_MAX_WIDTH_CLASS = "max-w-[67rem]"; // reproduce that environment, which Storybook and Pixel cannot: neither emulates `pointer: coarse`. export const MOBILE_TOUCH_TARGET_PX = 44; +// Width at or below which the app switches to its narrow layout (overlaying sidebar, PR badge in the +// workspace header instead of the footer info bar). Tailwind arbitrary variants cannot read a TS +// constant, so `[@media(max-width:768px)]` class strings repeat this literal. +export const NARROW_VIEWPORT_MAX_WIDTH_PX = 768; + // Keep composer controls aligned without relying on individual component defaults. This is a floor, // not a fixed height: mobile raises touch targets to MOBILE_TOUCH_TARGET_PX, and a pill that caps // its height would clip the controls inside it instead of growing with them.