fix(app): let touch workspace titles use the available width (#4698)

* fix(app): let touch workspace titles use the available width

Omit trailing stats when the permanent touch menu replaces them. Keep concealed stats in layout only for temporary desktop shortcut hints, preserving hover and shortcut geometry.

* test(app): express sidebar width checks as user journeys
This commit is contained in:
Mohamed Boudra
2026-09-11 10:16:33 +02:00
committed by GitHub
parent 772ff8d6f3
commit f22a37e613
5 changed files with 181 additions and 17 deletions
@@ -0,0 +1,153 @@
import { rm, writeFile } from "node:fs/promises";
import path from "node:path";
import type { Locator, Page, TestInfo } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import {
closeSidebarDisplayPreferences,
openMobileAgentSidebar,
openSidebarDisplayPage,
selectSidebarStatusGrouping,
} from "../support/helpers/sidebar";
const TITLE = "Linux desktop sandbox launch support";
async function seedChangedWorkspace() {
const workspace = await seedWorkspace({
repoPrefix: "sidebar-title-",
title: TITLE,
repo: { withRemote: true },
});
try {
await rm(path.join(workspace.repoPath, "remote.git"), { recursive: true });
await workspace.client.renameProject(workspace.projectId, "Paseo");
await writeFile(path.join(workspace.repoPath, "README.md"), "Changed line\n".repeat(12345));
await workspace.client.checkoutRefresh(workspace.repoPath);
await expect
.poll(async () => {
const { entries } = await workspace.client.fetchWorkspaces();
return entries.find((entry) => entry.id === workspace.workspaceId)?.diffStat?.additions;
})
.toBe(12345);
return workspace;
} catch (error) {
await workspace.cleanup();
throw error;
}
}
function workspaceRow(page: Page) {
return page.getByRole("button", { name: new RegExp(TITLE) }).filter({ visible: true });
}
async function titleWidth(row: Locator) {
return row
.getByText(TITLE, { exact: true })
.evaluate((element) => element.getBoundingClientRect().width);
}
async function toggleTrailing(page: Page, label: "Diff stats" | "Last activity", touch = false) {
await openSidebarDisplayPage(page, "sidebar-display-show");
await page.getByRole("menuitem", { name: label, exact: true }).click();
if (touch) {
await page
.getByRole("button", { name: "Bottom sheet backdrop", exact: true })
.click({ position: { x: 5, y: 5 } });
await expect(
page.getByRole("button", { name: "Bottom sheet backdrop", exact: true }),
).toHaveCount(0);
} else {
await closeSidebarDisplayPreferences(page);
}
await page.mouse.move(0, 0);
}
async function expectTouchTitleWidth(page: Page) {
const row = workspaceRow(page);
const withDiff = await titleWidth(row);
await toggleTrailing(page, "Diff stats", true);
const withoutStats = await titleWidth(row);
expect(withDiff).toBeCloseTo(withoutStats, 0);
await toggleTrailing(page, "Last activity", true);
expect(await titleWidth(row)).toBeCloseTo(withoutStats, 0);
await toggleTrailing(page, "Diff stats", true);
}
let workspace: SeededWorkspace;
test.beforeEach(async () => {
workspace = await seedChangedWorkspace();
});
test.afterEach(async () => {
await workspace?.cleanup();
});
async function openTouchWorkspaceList(page: Page, testInfo: TestInfo) {
await page.setViewportSize({ width: 390, height: 844 });
await page.emulateMedia({ colorScheme: "dark" });
await gotoAppShell(page);
await openMobileAgentSidebar(page);
await expect(workspaceRow(page)).toBeVisible();
await page.mouse.move(0, 0);
await page.screenshot({ path: testInfo.outputPath("sidebar.png") });
}
async function pinWorkspaceForLayout(page: Page) {
await workspace.client.setWorkspacePinned(workspace.workspaceId, true);
await expect(page.getByTestId("sidebar-pinned-section")).toBeVisible();
}
async function moveWorkspaceToStatusGrouping(page: Page) {
await workspace.client.setWorkspacePinned(workspace.workspaceId, false);
await selectSidebarStatusGrouping(page);
}
async function openDesktopWorkspaceList(page: Page) {
await gotoAppShell(page);
const row = workspaceRow(page);
await expect(row).toBeVisible();
await page.mouse.move(0, 0);
await expect(row.getByText("+12.3k", { exact: true })).toBeVisible();
return titleWidth(row);
}
async function expectHoverKeepsTitleWidth(page: Page, width: number, testInfo: TestInfo) {
const row = workspaceRow(page);
await row.hover();
await expect(row.getByLabel("Workspace actions", { exact: true })).toBeVisible();
expect(await titleWidth(row)).toBeCloseTo(width, 0);
await page.screenshot({ path: testInfo.outputPath("desktop-hover.png") });
}
async function expectShortcutHintsKeepTitleWidth(page: Page, width: number, testInfo: TestInfo) {
const row = workspaceRow(page);
await page.keyboard.down("Alt");
await expect(row.getByText("1", { exact: true })).toBeVisible();
expect(await titleWidth(row)).toBeCloseTo(width, 0);
await page.screenshot({ path: testInfo.outputPath("desktop-shortcuts.png") });
await page.keyboard.up("Alt");
await expect(row.getByText("1", { exact: true })).toHaveCount(0);
}
async function expectDisablingStatsExpandsTitle(page: Page, width: number) {
await toggleTrailing(page, "Diff stats");
expect(await titleWidth(workspaceRow(page))).toBeGreaterThan(width);
}
test("touch titles use the space occupied by hidden stats in every grouping", async ({
page,
}, testInfo) => {
await openTouchWorkspaceList(page, testInfo);
await expectTouchTitleWidth(page);
await pinWorkspaceForLayout(page);
await expectTouchTitleWidth(page);
await moveWorkspaceToStatusGrouping(page);
await expectTouchTitleWidth(page);
});
test("desktop hover and shortcut hints preserve title width", async ({ page }, testInfo) => {
const width = await openDesktopWorkspaceList(page);
await expectHoverKeepsTitleWidth(page, width, testInfo);
await expectShortcutHintsKeepTitleWidth(page, width, testInfo);
await expectDisablingStatsExpandsTitle(page, width);
});
@@ -645,7 +645,7 @@ function WorkspaceRowRightGroup({
const trailing = useSidebarWorkspaceTrailing();
const showShortcut = showShortcutBadge && shortcutNumber !== null;
const {
showTrailing,
trailingPresentation,
showKebab: showKebabInSlot,
showScrim,
renderSlot,
@@ -667,7 +667,7 @@ function WorkspaceRowRightGroup({
) : null}
{renderSlot ? (
<SidebarWorkspaceTrailingActionSlot reserveWidth={reserveSlotWidth}>
<SidebarWorkspaceTrailingActionBase visible={showTrailing}>
<SidebarWorkspaceTrailingActionBase presentation={trailingPresentation}>
<SidebarWorkspaceTrailingContent workspace={workspace} trailing={trailing} />
</SidebarWorkspaceTrailingActionBase>
<SidebarWorkspaceTrailingActionOverlay
@@ -53,6 +53,7 @@ import {
SidebarWorkspaceRowFrame,
SidebarWorkspaceRowContent,
resolveTrailingActionVisibility,
type SidebarWorkspaceTrailingPresentation,
SidebarWorkspaceTrailingActionBase,
SidebarWorkspaceTrailingActionOverlay,
SidebarWorkspaceTrailingActionSlot,
@@ -828,7 +829,7 @@ function StatusWorkspaceRowInnerContent({
{({ isHovered, contextMenuOpen, onContextMenuOpenChange, hoverHandlers }) => {
const showShortcut = showShortcutBadge && shortcutNumber !== null;
const {
showTrailing,
trailingPresentation,
showKebab: showKebabInSlot,
showScrim,
renderSlot,
@@ -907,7 +908,7 @@ function StatusWorkspaceRowInnerContent({
workspace={workspace}
backdrop={backdrop}
trailing={trailing}
showBase={showTrailing}
trailingPresentation={trailingPresentation}
showKebab={showKebabInSlot}
showScrim={showScrim}
reserveSlotWidth={reserveSlotWidth}
@@ -938,7 +939,7 @@ function StatusWorkspaceActionSlot({
workspace,
backdrop,
trailing,
showBase,
trailingPresentation,
showKebab,
showScrim,
reserveSlotWidth,
@@ -958,7 +959,7 @@ function StatusWorkspaceActionSlot({
workspace: SidebarWorkspaceEntry;
backdrop: SidebarSurfaceBackdrop;
trailing: SidebarWorkspaceTrailing;
showBase: boolean;
trailingPresentation: SidebarWorkspaceTrailingPresentation;
showKebab: boolean;
showScrim: boolean;
reserveSlotWidth: boolean;
@@ -978,7 +979,7 @@ function StatusWorkspaceActionSlot({
const kebab = useOpenKebabMenuVisibility(showKebab);
return (
<SidebarWorkspaceTrailingActionSlot reserveWidth={reserveSlotWidth}>
<SidebarWorkspaceTrailingActionBase visible={showBase}>
<SidebarWorkspaceTrailingActionBase presentation={trailingPresentation}>
<SidebarWorkspaceTrailingContent workspace={workspace} trailing={trailing} />
</SidebarWorkspaceTrailingActionBase>
<SidebarWorkspaceTrailingActionOverlay
@@ -343,6 +343,8 @@ export function SidebarWorkspaceShortcutBadge({ number }: { number: number }) {
);
}
export type SidebarWorkspaceTrailingPresentation = "visible" | "hidden" | "absent";
/**
* What the trailing slot shows for a row. Derived in one place because three row renderers
* share it: the two project-mode rows and the status-mode row. The rule used to be copied
@@ -368,7 +370,7 @@ export function resolveTrailingActionVisibility({
isTouchPlatform: boolean;
showShortcut: boolean;
}): {
showTrailing: boolean;
trailingPresentation: SidebarWorkspaceTrailingPresentation;
showKebab: boolean;
showScrim: boolean;
renderSlot: boolean;
@@ -376,9 +378,13 @@ export function resolveTrailingActionVisibility({
} {
const hasTrailing = hasSidebarWorkspaceTrailing({ workspace, trailing });
const showKebab = Boolean(hasArchiveAction && (isHovered || isTouchPlatform)) && !showShortcut;
const showTrailing = hasTrailing && !showShortcut && (isHovered || !showKebab);
// Touch permanently replaces the stats with the menu. Only temporary shortcut hints
// conceal content while retaining its width, so desktop rows do not shift.
const hasContent = hasTrailing && !(hasArchiveAction && isTouchPlatform);
let trailingPresentation: SidebarWorkspaceTrailingPresentation = "absent";
if (hasContent) trailingPresentation = showShortcut ? "hidden" : "visible";
return {
showTrailing,
trailingPresentation,
showKebab,
// The scrim paints the row's own hover background, so it can only be drawn on a hovered
// row — over an unhovered one the gradient fades to the wrong color. That is also why
@@ -389,7 +395,7 @@ export function resolveTrailingActionVisibility({
// does; the kebab only does on touch, where there is no hover for it to appear on and so
// no scrim to let it overlay the title. Everywhere else the width goes back to the title
// and the kebab fades in over its tail.
reserveSlotWidth: hasTrailing || (hasArchiveAction && isTouchPlatform),
reserveSlotWidth: hasContent || (hasArchiveAction && isTouchPlatform),
};
}
@@ -414,14 +420,18 @@ export function SidebarWorkspaceTrailingActionSlot({
}
export function SidebarWorkspaceTrailingActionBase({
visible,
presentation,
children,
}: {
visible: boolean;
presentation: SidebarWorkspaceTrailingPresentation;
children: ReactNode;
}) {
if (!children) return null;
return <View style={visible ? undefined : sidebarWorkspaceRowStyles.hidden}>{children}</View>;
if (presentation === "absent") return null;
return (
<View style={presentation === "hidden" ? sidebarWorkspaceRowStyles.hidden : undefined}>
{children}
</View>
);
}
export function SidebarWorkspaceTrailingActionOverlay({
@@ -403,7 +403,7 @@ function WorkspaceRowTrailingActions({
const { t } = useTranslation();
const showShortcut = showShortcutBadge && shortcutNumber !== null;
const {
showTrailing,
trailingPresentation,
showKebab: showKebabInSlot,
showScrim,
renderSlot,
@@ -425,7 +425,7 @@ function WorkspaceRowTrailingActions({
) : null}
{renderSlot ? (
<SidebarWorkspaceTrailingActionSlot reserveWidth={reserveSlotWidth}>
<SidebarWorkspaceTrailingActionBase visible={showTrailing}>
<SidebarWorkspaceTrailingActionBase presentation={trailingPresentation}>
<SidebarWorkspaceTrailingContent workspace={workspace} trailing={trailing} />
</SidebarWorkspaceTrailingActionBase>
<SidebarWorkspaceTrailingActionOverlay