+ {/* 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) => {
= (props) =>
: null;
return (
- // As the chat column's last row, this bar owns the mobile bottom inset: padding clears the
- // home indicator, and the negative margin lets its fill reach the screen edge.
+ // Narrow layouts drop the app root's bottom inset, so this row owns it: a capped clearance for
+ // the home indicator instead of the full inset, whose reserved band is the empty space we are
+ // reclaiming. Wider layouts still take their clearance from the root.
+ {/* 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.