From 5c51762b181a1ae54264612f659c73238e6a29d8 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:27:27 +0000 Subject: [PATCH 1/5] =?UTF-8?q?=F0=9F=A4=96=20fix:=20reclaim=20the=20mobil?= =?UTF-8?q?e=20bottom=20band=20and=20move=20the=20PR=20badge=20to=20the=20?= =?UTF-8?q?header?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workspace footer bar reserved the full iOS bottom safe-area inset, but every ancestor between it and the app root clips overflow at the root's content edge, so its negative margin never reached the screen edge. Drop the root's bottom inset at narrow widths and let the footer own a small clearance instead. Also move the PR badge to the workspace header below 768px (it was overlapping the drift toggle on phones because the touch-target min-width lets a link shrink below its content), and add a phone story asserting the placement. --- src/browser/App.tsx | 6 +- .../ChatPane/WorkspaceFooterBar.tsx | 15 +++- .../components/PRLinkBadge/PRLinkBadge.tsx | 10 ++- .../WorkspaceLinks/WorkspaceLinks.tsx | 6 +- .../WorkspaceMenuBar/WorkspaceMenuBar.tsx | 6 ++ .../stories/App.phoneViewports.stories.tsx | 79 ++++++++++++++++++- src/constants/layout.ts | 5 ++ 7 files changed, 116 insertions(+), 11 deletions(-) diff --git a/src/browser/App.tsx b/src/browser/App.tsx index 267d57fc00..26ea416b7e 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -1251,7 +1251,11 @@ function AppInner() { return ( <> -
+ {/* Narrow viewports hand the bottom inset to the chat column's last row instead: this box + clips overflow, so reserving the inset here strands dead space under the workspace footer + bar that no child can paint into. Safe because the sidebar is position: fixed with its own + inset at these widths and the right sidebar is hidden. */} +
= (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. + // At narrow widths the app root drops its bottom inset so this bar reaches the screen edge, and + // this row owns the inset instead: a small clearance for the home indicator rather than the full + // inset, which is the band of empty space we are reclaiming. Wider viewports still get their + // clearance from the root.
{/* min-h rather than a fixed height: mobile raises these buttons to 44px touch targets, and a capped row would clip them along the same axis overflow-x-auto makes scrollable. */} @@ -296,7 +298,12 @@ export const WorkspaceFooterBar: React.FC = (props) => workspaceName={props.workspaceName} tooltipSide="top" /> - + {/* User request: on narrow viewports the PR badge moves to the workspace header, where it + cannot scroll out of view with this row. */} + {hasRepository && ( <> void; + className?: string; } /** @@ -180,7 +181,7 @@ export function getTooltipContent(prLink: GitHubPRLinkWithStatus): string { return lines.join("\n"); } -export function PRLinkBadge({ prLink }: PRLinkBadgeProps) { +export function PRLinkBadge({ prLink, className }: PRLinkBadgeProps) { const colorClass = getStatusColorClass(prLink); // Show pulse effect when refreshing with cached status (optimistic UI) const isRefreshing = prLink.loading && prLink.status != null; @@ -192,9 +193,12 @@ export function PRLinkBadge({ prLink }: PRLinkBadgeProps) { variant="ghost" size="sm" className={cn( - "h-6 gap-1 px-2 text-xs font-medium", + // shrink-0 because the mobile touch-target rule puts an explicit `min-width` on links, + // replacing the flex min-content floor and letting the label spill over its neighbour. + "h-6 shrink-0 gap-1 px-2 text-xs font-medium", colorClass, - isRefreshing && "animate-pulse" + isRefreshing && "animate-pulse", + className )} asChild > diff --git a/src/browser/components/WorkspaceLinks/WorkspaceLinks.tsx b/src/browser/components/WorkspaceLinks/WorkspaceLinks.tsx index 8700f6dcb7..56ab0a4f59 100644 --- a/src/browser/components/WorkspaceLinks/WorkspaceLinks.tsx +++ b/src/browser/components/WorkspaceLinks/WorkspaceLinks.tsx @@ -8,14 +8,16 @@ import { PRLinkBadge } from "../PRLinkBadge/PRLinkBadge"; interface WorkspaceLinksProps { workspaceId: string; + /** Applied to the badge itself so callers can hide it without leaving an empty flex item. */ + className?: string; } -export function WorkspaceLinks({ workspaceId }: WorkspaceLinksProps) { +export function WorkspaceLinks({ workspaceId, className }: WorkspaceLinksProps) { const workspacePR = useWorkspacePR(workspaceId); if (!workspacePR) { return null; } - return ; + return ; } diff --git a/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx b/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx index 20d73c17b6..e308601fae 100644 --- a/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx +++ b/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx @@ -37,6 +37,7 @@ import { WorkspaceActionsMenuContent } from "../WorkspaceActionsMenuContent/Work import { WorkspaceTerminalIcon } from "../icons/WorkspaceTerminalIcon/WorkspaceTerminalIcon"; import { SkillIndicator } from "../SkillIndicator/SkillIndicator"; +import { WorkspaceLinks } from "../WorkspaceLinks/WorkspaceLinks"; import { useAPI } from "@/browser/contexts/API"; import { useAgent } from "@/browser/contexts/AgentContext"; @@ -556,6 +557,11 @@ export const WorkspaceMenuBar: React.FC = ({
+ {/* 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..dd46f85f85 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,61 @@ 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 = { + 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 }, 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. From d00ceaeaf9cb9a5c54bfec981488550cbc2a5674 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:39:51 +0000 Subject: [PATCH 2/5] review: tighten comments and drop stale header-only docblocks --- src/browser/App.tsx | 8 ++++---- src/browser/components/ChatPane/WorkspaceFooterBar.tsx | 10 ++++------ src/browser/components/PRLinkBadge/PRLinkBadge.tsx | 4 ---- .../components/WorkspaceLinks/WorkspaceLinks.tsx | 5 +---- 4 files changed, 9 insertions(+), 18 deletions(-) diff --git a/src/browser/App.tsx b/src/browser/App.tsx index 26ea416b7e..c6ee27bfcb 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -1251,10 +1251,10 @@ function AppInner() { return ( <> - {/* Narrow viewports hand the bottom inset to the chat column's last row instead: this box - clips overflow, so reserving the inset here strands dead space under the workspace footer - bar that no child can paint into. Safe because the sidebar is position: fixed with its own - inset at these widths and the right sidebar is hidden. */} + {/* Narrow layouts let the main column run to the screen edge and leave the bottom inset to + whichever row holds controls there (WorkspaceFooterBar): this box clips overflow, so a + child cannot paint into its padding, and the reserved band reads as dead space. The + sidebar is position: fixed with its own inset at these widths. */}
= (props) => : null; return ( - // At narrow widths the app root drops its bottom inset so this bar reaches the screen edge, and - // this row owns the inset instead: a small clearance for the home indicator rather than the full - // inset, which is the band of empty space we are reclaiming. Wider viewports still get their - // clearance from the root. + // 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.