fix: make mobile changes rows compact (#3881)

Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
João Salavisa
2026-09-23 11:59:06 +01:00
committed by GitHub
parent f2d52f1826
commit 31ad30f437
11 changed files with 786 additions and 121 deletions
@@ -1,18 +1,22 @@
import { afterEach, describe, it, expect, vi } from "vitest";
import { render, fireEvent } from "@testing-library/react";
import { cleanup, render, fireEvent } from "@testing-library/react";
import { TooltipProvider } from "@kandev/ui/tooltip";
import { FileRow } from "./changes-panel-file-row";
const responsive = vi.hoisted(() => ({ isFinePointer: true }));
const responsive = vi.hoisted(() => ({ isFinePointer: true, isMobile: false }));
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
useResponsiveBreakpoint: () => responsive,
}));
afterEach(() => {
cleanup();
responsive.isFinePointer = true;
responsive.isMobile = false;
});
const moreActionsLabel = "Show more actions";
const noop = () => {};
const noopSelect = () => false;
@@ -233,21 +237,24 @@ describe("FileRow hover swap (stats <-> actions occupy same cell)", () => {
expect(gridWrapper.className).toContain("row-start-1");
});
it("hides statistics behind always-visible actions for coarse pointers", () => {
it("keeps statistics in the identity and exposes one menu for coarse pointers", () => {
responsive.isFinePointer = false;
const { container } = renderRow("file.go");
const li = container.querySelector("li") as HTMLElement;
const gridWrapper = li.children[1] as HTMLElement;
const statsLayer = gridWrapper.children[0] as HTMLElement;
const actionsLayer = gridWrapper.children[1] as HTMLElement;
expect(statsLayer.className.split(/\s+/)).toContain("opacity-0");
expect(actionsLayer.className.split(/\s+/)).toContain("opacity-100");
const { getByTitle, getByRole, queryByTitle } = renderRow("file.go");
expect(getByTitle("file.go").textContent).toContain("+18");
expect(getByRole("button", { name: moreActionsLabel })).not.toBeNull();
expect(queryByTitle("Stage file")).toBeNull();
});
it("keeps the pending spinner inside a touch-sized target for coarse pointers", () => {
it("uses the touch menu on a phone even with a fine pointer", () => {
responsive.isMobile = true;
const { getByRole, queryByTitle } = renderRow("file.go");
expect(getByRole("button", { name: moreActionsLabel })).not.toBeNull();
expect(queryByTitle("Stage file")).toBeNull();
});
it("keeps pending feedback in the identity and disables staging in the menu", async () => {
responsive.isFinePointer = false;
const { container } = render(
const { container, getByTitle, getByRole, findByRole } = render(
<TooltipProvider>
<ul>
<FileRow
@@ -266,8 +273,11 @@ describe("FileRow hover swap (stats <-> actions occupy same cell)", () => {
const spinner = container.querySelector("svg.animate-spin");
expect(spinner).not.toBeNull();
expect(spinner?.parentElement?.className).toContain("min-h-11");
expect(spinner?.parentElement?.className).toContain("min-w-11");
expect(spinner?.closest("button")).toBe(getByTitle("pending-mobile.go"));
fireEvent.keyDown(getByRole("button", { name: moreActionsLabel }), { key: "Enter" });
expect(
(await findByRole("menuitem", { name: "Stage file" })).getAttribute("aria-disabled"),
).toBe("true");
});
});
@@ -338,10 +348,12 @@ describe("FileRow tree-mode hover stage action", () => {
expect(rightActions!.querySelector("button[title='Unstage file']")).toBeNull();
});
it("keeps the tree-mode stage action visible and touch-sized for coarse pointers", () => {
it("unstages a touch tree row through its menu without opening a diff", async () => {
responsive.isFinePointer = false;
const { container } = render(
const onUnstage = vi.fn();
const onOpenDiff = vi.fn();
const { getByRole, findByRole } = render(
<TooltipProvider>
<ul>
<FileRow
@@ -349,9 +361,9 @@ describe("FileRow tree-mode hover stage action", () => {
isPending={false}
treeMode
onSelect={noopSelect}
onOpenDiff={noop}
onOpenDiff={onOpenDiff}
onStage={noop}
onUnstage={noop}
onUnstage={onUnstage}
onDiscard={noop}
onEditFile={noop}
/>
@@ -359,13 +371,10 @@ describe("FileRow tree-mode hover stage action", () => {
</TooltipProvider>,
);
const iconSlot = container.querySelector("[data-testid='file-row-icon-action-slot']");
const action = iconSlot?.querySelector("button[title='Unstage file']");
expect(iconSlot?.className).toContain("size-11");
expect(action?.className).toContain("min-h-11");
expect(action?.className).toContain("min-w-11");
expect(action?.parentElement?.className).not.toContain("opacity-0");
fireEvent.keyDown(getByRole("button", { name: moreActionsLabel }), { key: "Enter" });
fireEvent.click(await findByRole("menuitem", { name: "Unstage file" }));
expect(onUnstage).toHaveBeenCalledWith("src/mobile.go", undefined);
expect(onOpenDiff).not.toHaveBeenCalled();
});
});
@@ -22,8 +22,7 @@ import type { ChangedFile } from "./changes-panel-helpers";
import type { OpenDiffOptions } from "./changes-diff-target";
import { useTranslation } from "react-i18next";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
const COARSE_POINTER_TARGET_CLASS = "min-h-11 min-w-11";
import { TouchFileRowContent } from "./changes-panel-touch-file-row";
const splitPath = (path: string) => {
const lastSlash = path.lastIndexOf("/");
@@ -34,7 +33,7 @@ const splitPath = (path: string) => {
};
};
type FileRowProps = {
export type FileRowProps = {
file: ChangedFile;
isPending: boolean;
isSelected?: boolean;
@@ -60,23 +59,13 @@ type FileRowProps = {
indentPx?: number;
};
export function FileRow({
file,
isPending,
isSelected,
isActive,
onSelect,
onOpenDiff,
onStage,
onUnstage,
onDiscard,
onEditFile,
treeMode,
indentPx,
}: FileRowProps) {
const { isFinePointer } = useResponsiveBreakpoint();
export type FileRowContentProps = FileRowProps & { folder: string; name: string };
export function FileRow(props: FileRowProps) {
const { file, isSelected, isActive, onSelect, onEditFile, onOpenDiff } = props;
const { isMobile, isFinePointer } = useResponsiveBreakpoint();
const touchMode = isMobile || !isFinePointer;
const { folder, file: name } = splitPath(file.path);
const showFolder = !treeMode && folder;
const handleClick = (e: React.MouseEvent) => {
if (e.button === 2) return;
@@ -101,14 +90,38 @@ export function FileRow({
data-selected={isSelected ? "true" : "false"}
data-active={isActive ? "true" : "false"}
className={cn(
"group flex items-center justify-between gap-2 rounded-md border border-transparent px-2 py-1.5 -mx-1 text-sm cursor-pointer",
"md:px-1 md:py-0.5",
"group flex items-center justify-between rounded-md border border-transparent -mx-1 text-sm cursor-pointer",
touchMode ? "gap-1 px-1 py-0.5" : "gap-2 px-2 py-1.5 md:px-1 md:py-0.5",
isSelected || isActive
? "border-primary/50 bg-card text-foreground hover:bg-muted/70"
: "hover:bg-muted/60",
)}
onClick={handleClick}
>
{touchMode ? (
<TouchFileRowContent {...props} folder={folder} name={name} />
) : (
<DesktopFileRowContent {...props} folder={folder} name={name} />
)}
</li>
);
}
function DesktopFileRowContent({
file,
isPending,
onStage,
onUnstage,
onDiscard,
onEditFile,
treeMode,
indentPx,
folder,
name,
}: FileRowContentProps) {
const showFolder = !treeMode && folder;
return (
<>
<div
className="flex items-center gap-2 min-w-0"
style={indentPx ? { paddingLeft: indentPx } : undefined}
@@ -117,7 +130,6 @@ export function FileRow({
<TreeModeFileActionSlot
name={name}
isPending={isPending}
isFinePointer={isFinePointer}
staged={file.staged}
path={file.path}
repo={file.repositoryName}
@@ -128,7 +140,6 @@ export function FileRow({
<StageButton
isPending={isPending}
staged={file.staged}
touchSized={!isFinePointer}
path={file.path}
repo={file.repositoryName}
onStage={onStage}
@@ -148,27 +159,21 @@ export function FileRow({
</button>
</div>
<div className="grid items-center shrink-0 [&>*]:col-start-1 [&>*]:row-start-1">
<FileRowStats file={file} isFinePointer={isFinePointer} />
<FileRowStats file={file} />
<FileRowActions
path={file.path}
repo={file.repositoryName}
isFinePointer={isFinePointer}
onDiscard={onDiscard}
onEditFile={onEditFile}
/>
</div>
</li>
</>
);
}
function FileRowStats({ file, isFinePointer }: { file: ChangedFile; isFinePointer: boolean }) {
function FileRowStats({ file }: { file: ChangedFile }) {
return (
<div
className={cn(
"flex items-center gap-2 justify-end transition-opacity pointer-events-none",
isFinePointer ? "group-hover:opacity-0" : "opacity-0",
)}
>
<div className="flex items-center gap-2 justify-end transition-opacity pointer-events-none group-hover:opacity-0">
<LineStat added={file.plus} removed={file.minus} />
<FileStatusIcon status={file.status} oldPath={file.oldPath} />
</div>
@@ -178,7 +183,6 @@ function FileRowStats({ file, isFinePointer }: { file: ChangedFile; isFinePointe
function TreeModeFileActionSlot({
name,
isPending,
isFinePointer,
staged,
path,
repo,
@@ -187,7 +191,6 @@ function TreeModeFileActionSlot({
}: {
name: string;
isPending: boolean;
isFinePointer: boolean;
staged: boolean;
path: string;
repo?: string;
@@ -197,31 +200,24 @@ function TreeModeFileActionSlot({
return (
<div
data-testid="file-row-icon-action-slot"
className={cn(
"grid shrink-0 items-center justify-center [&>*]:col-start-1 [&>*]:row-start-1",
isFinePointer ? "size-4" : "size-11",
)}
className="grid size-4 shrink-0 items-center justify-center [&>*]:col-start-1 [&>*]:row-start-1"
>
{isFinePointer && (
<FileIcon
fileName={name}
className={cn(
"size-4 transition-opacity pointer-events-none",
isPending ? "opacity-0" : "group-hover:opacity-0",
)}
/>
)}
<FileIcon
fileName={name}
className={cn(
"size-4 transition-opacity pointer-events-none",
isPending ? "opacity-0" : "group-hover:opacity-0",
)}
/>
<div
className={cn(
isFinePointer &&
!isPending &&
!isPending &&
"opacity-0 transition-opacity pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto",
)}
>
<StageButton
isPending={isPending}
staged={staged}
touchSized={!isFinePointer}
path={path}
repo={repo}
onStage={onStage}
@@ -235,7 +231,6 @@ function TreeModeFileActionSlot({
function StageButton({
isPending,
staged,
touchSized,
path,
repo,
onStage,
@@ -243,7 +238,6 @@ function StageButton({
}: {
isPending: boolean;
staged: boolean;
touchSized?: boolean;
path: string;
repo?: string;
onStage: (path: string, repo?: string) => void;
@@ -252,12 +246,7 @@ function StageButton({
const { t } = useTranslation();
if (isPending) {
return (
<div
className={cn(
"flex-shrink-0 flex items-center justify-center size-4",
touchSized && COARSE_POINTER_TARGET_CLASS,
)}
>
<div className="flex-shrink-0 flex items-center justify-center size-4">
<IconLoader2 className="h-3 w-3 animate-spin text-muted-foreground" />
</div>
);
@@ -267,10 +256,7 @@ function StageButton({
<button
type="button"
title={t("task:unstageFile")}
className={cn(
"group/unstage flex-shrink-0 flex items-center justify-center size-4 rounded bg-emerald-500/20 text-emerald-600 hover:bg-rose-500/20 hover:text-rose-600 cursor-pointer",
touchSized && COARSE_POINTER_TARGET_CLASS,
)}
className="group/unstage flex-shrink-0 flex items-center justify-center size-4 rounded bg-emerald-500/20 text-emerald-600 hover:bg-rose-500/20 hover:text-rose-600 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
onUnstage(path, repo);
@@ -285,10 +271,7 @@ function StageButton({
<button
type="button"
title={t("task:stageFile")}
className={cn(
"flex-shrink-0 flex items-center justify-center size-4 rounded border border-dashed border-muted-foreground/50 text-muted-foreground hover:border-emerald-500 hover:text-emerald-500 hover:bg-emerald-500/10 cursor-pointer",
touchSized && COARSE_POINTER_TARGET_CLASS,
)}
className="flex-shrink-0 flex items-center justify-center size-4 rounded border border-dashed border-muted-foreground/50 text-muted-foreground hover:border-emerald-500 hover:text-emerald-500 hover:bg-emerald-500/10 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
onStage(path, repo);
@@ -302,13 +285,11 @@ function StageButton({
function FileRowActions({
path,
repo,
isFinePointer,
onDiscard,
onEditFile,
}: {
path: string;
repo?: string;
isFinePointer: boolean;
onDiscard: (path: string, repo?: string, anchor?: HTMLElement) => void;
onEditFile: (path: string, repo?: string) => void;
}) {
@@ -316,22 +297,14 @@ function FileRowActions({
return (
<div
data-testid="file-row-hover-actions"
className={cn(
"flex items-center gap-1 justify-end transition-opacity",
isFinePointer
? "opacity-0 group-hover:opacity-100 pointer-events-none group-hover:pointer-events-auto"
: "opacity-100 pointer-events-auto",
)}
className="flex items-center gap-1 justify-end transition-opacity opacity-0 group-hover:opacity-100 pointer-events-none group-hover:pointer-events-auto"
>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={t("task:discardChanges2")}
className={cn(
"text-muted-foreground hover:text-foreground cursor-pointer",
!isFinePointer && COARSE_POINTER_TARGET_CLASS,
)}
className="text-muted-foreground hover:text-foreground cursor-pointer"
onClick={(e) => {
e.stopPropagation();
onDiscard(path, repo, e.currentTarget);
@@ -347,10 +320,7 @@ function FileRowActions({
<button
type="button"
aria-label={t("common:edit")}
className={cn(
"text-muted-foreground hover:text-foreground cursor-pointer",
!isFinePointer && COARSE_POINTER_TARGET_CLASS,
)}
className="text-muted-foreground hover:text-foreground cursor-pointer"
onClick={(e) => {
e.stopPropagation();
onEditFile(path, repo);
@@ -0,0 +1,126 @@
"use client";
import { useRef } from "react";
import { useTranslation } from "react-i18next";
import {
IconArrowBackUp,
IconDots,
IconLoader2,
IconMinus,
IconPencil,
IconPlus,
} from "@tabler/icons-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@kandev/ui/dropdown-menu";
import { FileIcon } from "@/components/ui/file-icon";
import { FileStatusIcon } from "@/components/shared/file-status-icon";
import { SymlinkIndicator } from "@/components/shared/symlink-indicator";
import { LineStat } from "@/components/diff-stat";
import type { FileRowProps, FileRowContentProps } from "./changes-panel-file-row";
export function TouchFileRowContent(props: FileRowContentProps) {
const { file, treeMode, indentPx, isPending, folder, name } = props;
return (
<>
<button
type="button"
title={file.path}
className="flex min-h-11 min-w-0 flex-1 items-center gap-2 text-left cursor-pointer"
style={indentPx ? { paddingLeft: Math.min(indentPx, 24) } : undefined}
>
{isPending ? (
<IconLoader2 className="size-3.5 shrink-0 animate-spin text-muted-foreground" />
) : (
<FileIcon fileName={name} className="size-3.5 shrink-0" />
)}
<span className="min-w-0 flex-1">
<span className="block whitespace-normal [overflow-wrap:anywhere] text-xs font-medium leading-4">
{name}
</span>
<span className="flex min-w-0 items-center gap-2 text-[11px] leading-4 text-muted-foreground">
{!treeMode && folder && <span className="min-w-0 truncate">{folder}</span>}
<LineStat
added={file.plus}
removed={file.minus}
className="shrink-0 text-[10px] gap-1"
/>
<FileStatusIcon status={file.status} oldPath={file.oldPath} />
<SymlinkIndicator isSymlink={file.isSymlink} />
</span>
</span>
</button>
<TouchFileRowActions {...props} />
</>
);
}
function TouchFileRowActions({
file,
isPending,
onStage,
onUnstage,
onEditFile,
onDiscard,
}: FileRowProps) {
const { t } = useTranslation();
const triggerRef = useRef<HTMLButtonElement>(null);
const StageIcon = file.staged ? IconMinus : IconPlus;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
ref={triggerRef}
type="button"
aria-label={t("common:showMoreActions")}
className="flex size-11 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground cursor-pointer"
onClick={(event) => event.stopPropagation()}
>
<IconDots className="size-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-72 max-w-[calc(100vw-1rem)]"
onClick={(event) => event.stopPropagation()}
>
<DropdownMenuLabel className="whitespace-normal [overflow-wrap:anywhere] text-xs font-normal text-muted-foreground">
{file.path}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
className="min-h-11 cursor-pointer"
disabled={isPending}
onSelect={() => (file.staged ? onUnstage : onStage)(file.path, file.repositoryName)}
>
{isPending ? <IconLoader2 className="animate-spin" /> : <StageIcon />}
{t(file.staged ? "task:unstageFile" : "task:stageFile")}
</DropdownMenuItem>
<DropdownMenuItem
className="min-h-11 cursor-pointer"
onSelect={() => onEditFile(file.path, file.repositoryName)}
>
<IconPencil />
{t("common:edit")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
className="min-h-11 cursor-pointer"
onSelect={() =>
onDiscard(file.path, file.repositoryName, triggerRef.current ?? undefined)
}
>
<IconArrowBackUp />
{t("task:discardChanges2")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -465,6 +465,68 @@ test.describe("Git Changes Panel", () => {
* Creates a task, modifies a file in the repository, and verifies the Changes panel
* shows the modification in real-time via WebSocket updates.
*/
// @covers AC-UI-CHANGES-FILE-ROW-CONTAINMENT-002.4
test("keeps compact desktop actions across the phone breakpoint", async ({
testPage,
apiClient,
seedData,
backend,
prCapture,
}) => {
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Improve mobile changes",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await session.waitForChatIdle();
const git = new GitHelper(path.join(backend.tmpDir, "repos", "e2e-repo"), {
...process.env,
HOME: backend.tmpDir,
});
const filePath = "status-surface-metrics.test.ts";
git.createFile(filePath, "METRICS_MARKER\n");
git.createFile("mobile-resource-metrics-display.test.ts", "MOBILE_MARKER\n");
await session.clickTab("Changes");
await session.expandChangesSection("unstaged-files-section");
const row = testPage.getByTestId(`file-row-${filePath}`);
const stage = row.getByTitle("Stage file");
await expect(stage).toBeVisible();
expect((await stage.boundingBox())!.height).toBeLessThanOrEqual(24);
await expect(row.getByRole("button", { name: "Show more actions" })).toHaveCount(0);
await prCapture.screenshot("desktop-changes", {
caption: "Desktop retains compact inline staging and hover actions.",
});
await testPage.setViewportSize({ width: 767, height: 851 });
await testPage
.getByRole("navigation")
.getByRole("button", { name: /Changes$/ })
.click();
await expect(row.getByRole("button", { name: "Show more actions" })).toBeVisible();
await expect(row.getByText(filePath, { exact: true })).toHaveCSS("white-space", "normal");
await expect(stage).toHaveCount(0);
await testPage.setViewportSize({ width: 768, height: 851 });
await session.clickTab("Changes");
await expect(stage).toBeVisible();
expect((await stage.boundingBox())!.height).toBeLessThanOrEqual(24);
await expect(row.getByRole("button", { name: "Show more actions" })).toHaveCount(0);
expect(
await testPage.evaluate(
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth,
),
).toBe(true);
});
test("shows modified files in unstaged section", async ({
testPage,
apiClient,
@@ -2,6 +2,7 @@ import fs from "node:fs";
import path from "node:path";
import { test, expect } from "../../fixtures/test-base";
import { SessionPage } from "../../pages/session-page";
import { waitForFiniteAnimations } from "../../helpers/animations";
// @covers AC-WORKSPACES-SYMLINK-001.1, AC-WORKSPACES-SYMLINK-001.2, AC-WORKSPACES-SYMLINK-001.4
test("identifies a symlink in Changes and the mobile file viewer", async ({
@@ -33,13 +34,19 @@ test("identifies a symlink in Changes and the mobile file viewer", async ({
const row = testPage.getByTestId(`file-row-${name}`);
await expect(row).toBeVisible({ timeout: 20_000 });
await expect(row.getByTestId("symlink-indicator")).toBeVisible();
const edit = row.getByRole("button", { name: "Edit", exact: true });
expect((await edit.boundingBox())!.height).toBeGreaterThanOrEqual(44);
const actions = row.getByRole("button", { name: "Show more actions", exact: true });
const actionsBounds = (await actions.boundingBox())!;
expect(actionsBounds.height).toBeGreaterThanOrEqual(44);
expect(actionsBounds.width).toBeGreaterThanOrEqual(44);
const rowBounds = (await row.boundingBox())!;
const markerBounds = (await row.getByTestId("symlink-indicator").boundingBox())!;
const editBounds = (await edit.boundingBox())!;
expect(markerBounds.x).toBeGreaterThanOrEqual(rowBounds.x);
expect(markerBounds.x + markerBounds.width).toBeLessThanOrEqual(editBounds.x);
expect(markerBounds.x + markerBounds.width).toBeLessThanOrEqual(actionsBounds.x);
await actions.tap();
const edit = testPage.getByRole("menuitem", { name: "Edit", exact: true });
await expect(edit).toBeVisible();
await waitForFiniteAnimations(testPage.getByRole("menu"));
expect((await edit.boundingBox())!.height).toBeGreaterThanOrEqual(44);
await edit.tap();
const viewer = testPage.getByTestId("mobile-file-viewer-panel");
await expect(viewer).toBeVisible();
@@ -1,6 +1,7 @@
import { test, expect } from "../../fixtures/test-base";
import { SessionPage } from "../../pages/session-page";
import { GitHelper, makeGitEnv } from "../../helpers/git-helper";
import { waitForFiniteAnimations } from "../../helpers/animations";
import type { Page } from "@playwright/test";
import path from "node:path";
@@ -86,6 +87,218 @@ test.describe("Mobile changes panel", () => {
git.exec("git clean -fd");
});
// @covers AC-UI-CHANGES-FILE-ROW-CONTAINMENT-002.1 through 002.3
test("compact file rows preserve names and expose touch actions", async ({
testPage,
apiClient,
seedData,
backend,
prCapture,
}) => {
const initialLayout = (await apiClient.getUserSettings()).settings.changes_panel_layout;
try {
await apiClient.saveUserSettings({ changes_panel_layout: "tree" });
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Improve mobile changes",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await session.waitForChatIdle();
const git = new GitHelper(
path.join(backend.tmpDir, "repos", "e2e-repo"),
makeGitEnv(backend.tmpDir),
);
const filePath = "apps/web/components/task/status-surface-metrics.test.ts";
const longPath =
"apps/web/components/task/mobile-resource-metrics-display-with-a-long-filename.test.ts";
git.createFile(filePath, "READABLE_FILE_MARKER\n");
git.createFile(longPath, "LONG_FILE_MARKER\n");
git.createFile("docs/public/operations.md", "Operations\n");
git.createFile("docs/plans/mobile-status/plan.md", "Mobile status plan\n");
await openMobileChangesPanel(testPage);
await expandSection(testPage, "unstaged-files-section");
const section = testPage.getByTestId("unstaged-files-section");
const rowId = `file-row-${filePath.replaceAll("/", "-")}`;
const row = section.getByTestId(rowId);
await expect(row).toBeVisible();
await prCapture.screenshot("compact-changes-tree", {
caption: "Phone Changes list gives filenames the primary width and groups file actions.",
});
const identity = row.getByTitle(filePath, { exact: true });
const rowBox = (await row.boundingBox())!;
const identityBox = (await identity.boundingBox())!;
expect(identityBox.width / rowBox.width).toBeGreaterThan(0.65);
expect(rowBox.height).toBeLessThanOrEqual(56);
expect(identityBox.height).toBeGreaterThanOrEqual(44);
await expect(identity.getByText("status-surface-metrics.test.ts", { exact: true })).toHaveCSS(
"white-space",
"normal",
);
const longName = section.getByText(
"mobile-resource-metrics-display-with-a-long-filename.test.ts",
{ exact: true },
);
expect(await longName.evaluate((el) => el.scrollWidth <= el.clientWidth)).toBe(true);
const more = row.getByRole("button", { name: "Show more actions" });
const moreBox = (await more.boundingBox())!;
expect(moreBox.width).toBeGreaterThanOrEqual(44);
expect(moreBox.height).toBeGreaterThanOrEqual(44);
expect(moreBox.x + moreBox.width).toBeLessThanOrEqual(rowBox.x + rowBox.width);
expect(
await more.evaluate((el) => {
const box = el.getBoundingClientRect();
return el.contains(
document.elementFromPoint(box.x + box.width / 2, box.y + box.height / 2),
);
}),
).toBe(true);
await apiClient.saveUserSettings({ changes_panel_layout: "flat" });
await testPage.reload();
await session.waitForLoad();
await openMobileChangesPanel(testPage);
await expandSection(testPage, "unstaged-files-section");
await expect(row).toBeVisible();
const folder = identity.getByText("apps/web/components/task", { exact: true });
await expect(folder).toBeVisible();
const basenameBox = (await identity
.getByText("status-surface-metrics.test.ts", { exact: true })
.boundingBox())!;
expect((await folder.boundingBox())!.y).toBeGreaterThanOrEqual(
basenameBox.y + basenameBox.height,
);
expect((await row.boundingBox())!.height).toBeLessThanOrEqual(56);
await prCapture.screenshot("compact-changes-list", {
caption: "Flat phone list with full basenames, directory context, and compact actions.",
});
for (const width of [767, 393]) {
await testPage.setViewportSize({ width, height: 851 });
if (width === 393) await openMobileChangesPanel(testPage);
await expect(more).toBeVisible();
expect((await more.boundingBox())!.width).toBeGreaterThanOrEqual(44);
await expect(
identity.getByText("status-surface-metrics.test.ts", { exact: true }),
).toHaveCSS("white-space", "normal");
expect(
await testPage.evaluate(
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth,
),
).toBe(true);
}
await more.tap();
const menu = testPage.getByRole("menu");
await expect(menu).toBeVisible();
await expect(menu.getByText(filePath, { exact: true })).toBeVisible();
await waitForFiniteAnimations(menu);
const menuBox = (await menu.boundingBox())!;
expect(menuBox.x).toBeGreaterThanOrEqual(0);
expect(menuBox.x + menuBox.width).toBeLessThanOrEqual(testPage.viewportSize()!.width);
await prCapture.screenshot("compact-file-actions", {
caption: "Full file path and touch actions in the phone menu.",
});
await testPage.keyboard.press("Escape");
await expect(more).toBeFocused();
await more.tap();
await menu.getByRole("menuitem", { name: "Stage file", exact: true }).tap();
await expandSection(testPage, "staged-files-section");
const stagedRow = testPage.getByTestId("staged-files-section").getByTestId(rowId);
await expect(stagedRow).toBeVisible();
await expect(testPage.getByTestId("mobile-diff-sheet-close")).not.toBeVisible();
await stagedRow.getByRole("button", { name: "Show more actions" }).tap();
await menu.getByRole("menuitem", { name: "Unstage file", exact: true }).tap();
await expect(row).toBeVisible();
await more.tap();
await menu.getByRole("menuitem", { name: "Discard changes", exact: true }).tap();
await expect(testPage.getByTestId("discard-local-changes-confirm")).toBeVisible();
await testPage.getByRole("button", { name: "Cancel", exact: true }).tap();
await expect(row).toBeVisible();
expect(git.exec("git status --porcelain --untracked-files=all")).toContain(filePath);
await identity.tap();
await expectDiffText(testPage, "READABLE_FILE_MARKER");
await testPage.getByTestId("mobile-diff-sheet-close").tap();
await more.tap();
await menu.getByRole("menuitem", { name: "Edit", exact: true }).tap();
await expect(testPage.getByTestId("mobile-changes-panel")).not.toBeVisible();
const viewer = testPage.getByTestId("mobile-file-viewer-panel");
await expect(viewer).toBeVisible();
await expect(viewer.getByText(filePath, { exact: true })).toBeVisible();
expect((await apiClient.getUserSettings()).settings.changes_panel_layout).toBe("flat");
} finally {
await apiClient.saveUserSettings({ changes_panel_layout: initialLayout });
}
});
// @covers AC-UI-CHANGES-FILE-ROW-CONTAINMENT-002.1
test("deep tree rows keep filenames and actions within the panel", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
const initialLayout = (await apiClient.getUserSettings()).settings.changes_panel_layout;
try {
await apiClient.saveUserSettings({ changes_panel_layout: "tree" });
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Deep changes tree",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await session.waitForChatIdle();
const git = new GitHelper(
path.join(backend.tmpDir, "repos", "e2e-repo"),
makeGitEnv(backend.tmpDir),
);
let folder = "src";
for (let depth = 0; depth < 22; depth++) {
git.createFile(`${folder}/sibling.ts`, "sibling\n");
folder += `/level-${depth}`;
}
const name = "deeply-nested-file.test.ts";
const filePath = `${folder}/${name}`;
git.createFile(filePath, "DEEP_FILE_MARKER\n");
await openMobileChangesPanel(testPage);
await expandSection(testPage, "unstaged-files-section");
const row = testPage.getByTestId(`file-row-${filePath.replaceAll("/", "-")}`);
await row.scrollIntoViewIfNeeded();
const rowBox = (await row.boundingBox())!;
const filenameBox = (await row.getByText(name, { exact: true }).boundingBox())!;
expect(filenameBox.width).toBeGreaterThan(rowBox.width * 0.5);
const statusBox = (await row.locator("[data-file-status]").boundingBox())!;
const menuButton = row.getByRole("button", { name: "Show more actions" });
const menuBox = (await menuButton.boundingBox())!;
expect(statusBox.x + statusBox.width).toBeLessThanOrEqual(menuBox.x);
expect(menuBox.x + menuBox.width).toBeLessThanOrEqual(rowBox.x + rowBox.width);
await menuButton.tap();
await expect(testPage.getByRole("menu").getByText(filePath, { exact: true })).toBeVisible();
await testPage.keyboard.press("Escape");
await row.getByTitle(filePath, { exact: true }).tap();
await expectDiffText(testPage, "DEEP_FILE_MARKER");
} finally {
await apiClient.saveUserSettings({ changes_panel_layout: initialLayout });
}
});
test("renders timeline surface and opens Diff/Review/file/commit overlays", async ({
testPage,
apiClient,
@@ -272,7 +485,8 @@ test.describe("Mobile changes panel", () => {
await expectDiffTextAbsent(testPage, "MOBILE_UNSTAGED_LAYER_MARKER");
await testPage.getByTestId("mobile-diff-sheet-close").tap();
await stagedRow.getByTitle("Unstage file").tap();
await stagedRow.getByRole("button", { name: "Show more actions" }).tap();
await testPage.getByRole("menuitem", { name: "Unstage file", exact: true }).tap();
await expect(stagedRow).toHaveCount(0);
await expect(unstagedRow).toBeVisible();
await expect(unstagedRow.getByText("+2", { exact: true })).toBeVisible();