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();
+100
View File
@@ -0,0 +1,100 @@
---
created: 2026-09-23
status: implemented
requirements:
- REQ-UI-CHANGES-FILE-ROW-CONTAINMENT-002
system_design:
- ../../specs/ui/system-design/changes-file-row-containment.md
legacy_specs: []
---
# Implementation Plan: Mobile changes density
## Overview
Restore readable working-tree filenames with compact touch rows and one file
action menu. The user explicitly requested AFK planning and implementation in
one turn, followed by a PR with screenshots. Execute sequentially here.
## Scope
Change working-tree row presentation and its mobile action entry point. Keep
Git semantics, desktop composition, file ordering, and the task toolbar intact.
UI owns the existing reusable file-row presentation contract; no domain or
architecture boundary changes.
## Technical approach
Branch `FileRow` content using the canonical responsive hook. Add a small
touch-content component and shared DropdownMenu action surface, forwarding the
existing callbacks. Reuse translated action labels. Keep `PanelBody` as the
single scroll owner and existing viewport/safe-area handling. Update the public
review guide to describe the touch menu.
## ASCII UI preview
UI-01: Changes tab, unstaged list, phone/coarse pointer.
```text
Before: [+ large] folder/file... [discard] [edit]
After:
UNSTAGED (8) Stage all
[file] status-surface-metrics.test.ts [...]
apps/web/components/task +12 -3
[file] long-basename-wraps-onto-
another-line.test.ts [...]
apps/web/components/task
Menu: full/path/status-surface-metrics.test.ts
Stage file
Edit
Discard changes
```
UI-02: Fine-pointer desktop, existing compact row.
```text
[+] folder/basename.ts +12 -3 M
Hover: [undo] [edit]
```
Structural requirements: basename receives available width; path/stats below;
one visible secondary action target; menu exposes the full path; 44px touch
targets and preserved desktop density. Pixel spacing is illustrative. Pending
staging shows a spinner and disables its menu item. Cancel discard preserves
the file and returns to the list. Covers AC-002.1 through AC-002.4.
## Tests
Existing `changes-panel-tree.test.tsx` guards tree action wiring. Update the
existing file-row tests for touch menus, pending state, and fine-pointer phones.
Playwright owns rendered geometry, deep-tree containment, and action routing.
## E2E tests
`task/mobile-changes-panel.spec.ts` (mobile-chrome) gains compact-row geometry,
menu/action flow, and breakpoint assertions for all four criteria. Existing
`git/git-changes-panel.spec.ts` (chromium) guards desktop staging and unstaging
and adds the 767px/768px fine-pointer composition transition. The existing
`git/mobile-symlink-identification.spec.ts` follows the file menu to Edit while
retaining symlink-marker containment and viewer checks.
## Work orders
- [x] [Task 01: Compact touch file rows](task-01-compact-touch-file-rows.md)
## Verification results
Completed: 10 mobile browser tests, 4 focused desktop browser tests, 24 file-row
and tree unit tests, typecheck, ESLint, Prettier, staged i18n ratchet, documentation catalog,
36 specification-linter tests, full spec lint, and diff checks passed. The
[work order](task-01-compact-touch-file-rows.md#results) records commands, the
baseline failure, and a temporary desktop-discovery override needed for this
worktree's name. Four inspected screenshots use disposable E2E data and are
published separately from the product branch.
## Risks
Menu close can race confirmation focus; pass the persistent trigger as anchor.
Tree indentation and long paths must not clip touch controls. Existing mobile
tests using inline Unstage must use the menu.
@@ -0,0 +1,125 @@
---
id: "01-compact-touch-file-rows"
title: "Compact touch file rows"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-UI-CHANGES-FILE-ROW-CONTAINMENT-002
acceptance_criteria:
- AC-UI-CHANGES-FILE-ROW-CONTAINMENT-002.1
- AC-UI-CHANGES-FILE-ROW-CONTAINMENT-002.2
- AC-UI-CHANGES-FILE-ROW-CONTAINMENT-002.3
- AC-UI-CHANGES-FILE-ROW-CONTAINMENT-002.4
system_design:
- ../../specs/ui/system-design/changes-file-row-containment.md
---
# Task 01: Compact touch file rows
## Summary
Give filenames the primary width and group touch actions in one menu. Preserve
desktop rows and the existing repository/layer-aware handlers.
## In scope
Touch rows, menu, pending feedback, browser regression tests, screenshots, docs.
## Out of scope
Toolbar redesign, Git transport, provider rows, saved preferences, new APIs.
## Acceptance
- Phone rows follow UI-01 and meet the geometry/action criteria.
- Stage, Unstage, Edit, Discard cancellation, and diff opening work by touch.
- Desktop inline actions and tree behavior remain valid.
## ASCII UI preview
UI-01 from the [full preview](plan.md#ascii-ui-preview):
```text
[file] status-surface-metrics.test.ts [...]
apps/web/components/task +12 -3
Menu: full path / Stage file / Edit / Discard changes
```
UI-02 desktop retains `[+] folder/name.ts +12 -3 M` and hover actions.
AC-002.1 through AC-002.4 require the filename hierarchy, single touch menu,
44px targets, action parity, and unchanged desktop density.
## Verification
Run from the repository root after `pnpm install --frozen-lockfile` in `apps`:
```bash
(cd apps/web && pnpm e2e:run --host --project mobile-chrome tests/task/mobile-changes-panel.spec.ts)
(cd apps/web && pnpm e2e:run --host --no-build --project mobile-chrome tests/git/mobile-symlink-identification.spec.ts -- --retries 0)
(cd apps/web
cat > e2e/playwright.local-desktop.config.ts <<'EOF'
import config from "./playwright.config";
export default {
...config,
projects: config.projects?.map((project) => project.name === "chromium"
? { ...project, testIgnore: [/\/mobile-[^/]*\.spec\.ts$/, ...(project.testIgnore as RegExp[]).slice(1)] }
: project),
};
EOF
pnpm e2e:run --host --no-build --project chromium tests/git/git-changes-panel.spec.ts -- --config e2e/playwright.local-desktop.config.ts --grep 'keeps compact desktop|keeps stage and unstage|shows same path|shows modified files in unstaged'
desktop_result=$?
rm e2e/playwright.local-desktop.config.ts
exit "$desktop_result")
(cd apps/web && pnpm exec vitest run components/task/changes-panel-file-row.test.tsx components/task/changes-panel-tree.test.tsx)
(cd apps/web && pnpm run typecheck)
(cd apps/web && pnpm exec eslint components/task/changes-panel-file-row.tsx components/task/changes-panel-touch-file-row.tsx)
(cd apps/web && pnpm run i18n:ratchet)
python3 scripts/list-docs.py validate
python3 scripts/lint-spec-files.test.py
python3 scripts/lint-spec-files.py --all
git diff --check
git status --short -- docs/plans/mobile-changes-density
```
## Files likely touched
- `apps/web/components/task/changes-panel-file-row.tsx`
- `apps/web/components/task/changes-panel-file-row.test.tsx`
- `apps/web/components/task/changes-panel-touch-file-row.tsx`
- `apps/web/e2e/tests/task/mobile-changes-panel.spec.ts`
- `apps/web/e2e/tests/git/git-changes-panel.spec.ts`
- `apps/web/e2e/tests/git/mobile-symlink-identification.spec.ts`
- `docs/public/sessions-and-review.md`
## Dependencies
None.
## Risks
Preserve trigger focus around portaled menus and discard confirmation.
## Parallelism
`sequential`
## Inputs
The linked file-row requirement/design, existing GitHelper fixtures, shared
DropdownMenu primitives, and mobile UI language contextual-action pattern.
## Results
- RED: the phone filename-width assertion failed at 46.7% against a required minimum of 65%.
- GREEN: managed production build and mobile run: **9 passed**, including flat/tree geometry, 22-level tree containment, stage/unstage, discard cancellation, Edit, and layer/PR diff routing.
- Symlink mobile regression: **1 passed**, with retries disabled. CI and the local RED run timed out waiting for the removed inline Edit button. The updated test checks the menu trigger and Edit item touch targets, marker containment, and the same file-viewer behavior. Production rendering is unchanged by this test-only follow-up.
- Desktop: **4 passed**, including the new 767px/768px transition and existing stage/unstage pending feedback and mixed-layer scenarios.
- The default desktop discovery excluded this worktree because its absolute path contains `mobile-`. The temporary config above scopes that exclusion to the spec basename; it changes no test behavior and was removed after the run.
- File-row and tree Vitest suites: **24 passed**. Updated obsolete coarse-pointer assertions and covered the pending menu and fine-pointer phone presentation.
- Review regression RED: a 22-level tree left zero filename width. GREEN: touch indentation is capped at 24px; the same browser test preserves more than half the row width for the basename and keeps status/menu contained.
- Typecheck, ESLint with zero warnings (both components, the file-row unit tests, and both browser specs), Prettier check, and staged i18n ratchet: **passed**.
- Catalog validation: **299 decisions and 1112 specifications**. Specification linter tests: **36 passed**. Full specification lint and `git diff --check`: **passed**.
- Four fresh, inspected, compressed screenshots cover phone flat/tree lists, the action sheet, and desktop. Assets use disposable E2E data and remain outside the product branch.
- Rendered hierarchy matches UI-01/UI-02. No new API, persistence, or locale keys. Public review guide updated with the menu entry point.
+6
View File
@@ -312,6 +312,12 @@ Changes are grouped by repository and then by state:
From this panel you can stage or unstage files, discard working-tree changes, commit, amend, reset or revert commits, pull, rebase, merge, push, force-push, rename the task branch, choose a base branch, and create or open a pull request or merge request. Operations apply to the selected repository. Discarding a file is permanent, and history-changing operations can lose work or invalidate review; read [Git operations](git-operations.md) before using them.
On phones and touch devices, working-tree rows give filenames the main space.
Tap a filename to open its diff, or tap the row's **Show more actions** menu to
stage or unstage, edit, or discard that file. The menu shows the full path, and
discarding still requires confirmation. In list view, the folder appears below
the filename; long filenames wrap.
Changes-panel Git operations use Kandev's control path, not the agent's shell. They can work when a restricted agent mode blocks shell writes to Git metadata. If the error says that `.git/index.lock` already exists or is held, stop other Git operations and inspect the lock before retrying. Remove a stale lock only after you confirm that no Git process owns it. The Changes panel uses the same worktree, so it does not bypass an active lock. If the agent cannot create `.git/index.lock` because of its permission mode, use the Changes panel. Read [Git operations](git-operations.md#prerequisites-and-trust-boundary) before you change the agent mode.
For a linked fork pull request, the Changes header shows the exact comparison target, such as
@@ -31,8 +31,19 @@ path is longer than the available panel width.
- **AC-UI-CHANGES-FILE-ROW-CONTAINMENT-001.3:** Truncation shall preserve the row's existing full-path title and its click or tap outcome, including the pull-request and repository identity used to open the diff.
- **AC-UI-CHANGES-FILE-ROW-CONTAINMENT-001.4:** Desktop and phone Changes surfaces shall provide the same containment behavior without adding horizontal overflow to the panel or document.
### REQ-UI-CHANGES-FILE-ROW-CONTAINMENT-002: Compact touch file rows
**Intent:** Let phone users scan file names without inline actions consuming most of each row.
#### Acceptance criteria
- **AC-UI-CHANGES-FILE-ROW-CONTAINMENT-002.1:** On phones and coarse-pointer devices, working-tree rows shall prioritize the basename, place directory context below it in list mode, and allow long basenames to wrap. Deep tree indentation shall not consume the filename or metadata width. A normal one-line basename and directory shall fit in a row no taller than 56px at the standard root font.
- **AC-UI-CHANGES-FILE-ROW-CONTAINMENT-002.2:** Each touch row shall offer one visible action-menu trigger with a small icon and a hit target of at least 44px in both dimensions. Stage or Unstage, Edit, and Discard shall remain reachable through that menu; tapping the file identity shall still open its diff or image.
- **AC-UI-CHANGES-FILE-ROW-CONTAINMENT-002.3:** The menu shall expose the full path, retain repository and staged-layer identity, disable staging while pending, and preserve discard confirmation and cancellation. Closing it without an action shall return focus to its trigger.
- **AC-UI-CHANGES-FILE-ROW-CONTAINMENT-002.4:** Fine-pointer desktop rows shall retain their inline staging, hover actions, tree indentation, and compact sizing. Responsive presentation shall not change saved file-list preferences.
## Out of scope
- Changing file-path, diff-statistic, or file-status data.
- Changing row ordering, grouping, density, actions, or diff-source precedence.
- Changing row ordering, grouping, Git action semantics, or diff-source precedence.
- Changing the Changes panel's resize limits, scroll ownership, or mobile entry point.
@@ -3,6 +3,7 @@ status: current
system: ui
requirements:
- REQ-UI-CHANGES-FILE-ROW-CONTAINMENT-001
- REQ-UI-CHANGES-FILE-ROW-CONTAINMENT-002
---
# File-row containment
@@ -20,6 +21,7 @@ selection.
| Requirement | Design section |
| --- | --- |
| `REQ-UI-CHANGES-FILE-ROW-CONTAINMENT-001` | [Row layout contract](#row-layout-contract), [Responsive composition](#responsive-composition), [Verification](#verification) |
| `REQ-UI-CHANGES-FILE-ROW-CONTAINMENT-002` | [Touch file rows](#touch-file-rows), [Verification](#verification) |
## Components and responsibilities
@@ -42,8 +44,35 @@ the basename also accepts truncation when it alone exceeds the remaining width.
The trailing statistics-and-status region does not shrink. The existing full
path `title` remains on the path control.
This is a CSS layout correction only. It adds no responsive state, JavaScript
measurement, path rewriting, tooltip, or alternate row component.
Provider rows retain their existing containment layout. Working-tree rows also
apply the touch presentation below without rewriting paths or measuring text.
## Touch file rows
`FileRow` uses `useResponsiveBreakpoint` to choose the compact presentation
when `isMobile || !isFinePointer`. The existing row click handler continues
to own selection, image editing, and repository/layer-aware diff opening.
`FileRow` splits the path once for either presentation.
`TouchFileRowContent` renders a small file icon, a flexible filename-first
identity button, and one 44px ellipsis trigger. List mode puts the directory
on a secondary line; tree mode retains parent context and caps file indentation
at 24px so deep nesting cannot consume the filename or metadata width. Long
basenames wrap instead of ending in an ellipsis. The identity button itself
is at least 44px tall. Inline statistics share the secondary line without
taking space from the basename.
`TouchFileRowActions` reuses the shared DropdownMenu primitives, including
their inset phone sheet below 640px. Its label shows the full path with wrapping.
Stage/Unstage, Edit, and Discard call the existing callbacks with repository
identity. Menu events stop propagation so actions cannot open the diff. Pending
staging uses the existing per-file pending flag. Discard passes the persistent
ellipsis trigger as its confirmation anchor, outside the transient menu.
This follows the mobile UI language's visible contextual menu pattern and
the shipped `MobileChangesPanel` full-height content surface. Primary content
stays inline because scanning files is frequent; temporary action choices
use the existing bottom menu. No new state, persistence, scroll owner, or
viewport container is introduced. Fine-pointer desktop composition is retained.
## Responsive composition
@@ -54,12 +83,12 @@ measurement, path rewriting, tooltip, or alternate row component.
its single `PanelBody` scroll owner remain unchanged.
- **Nearest shipped exemplars:** the working-tree `FileRow` supplies the flex
shrink geometry, while `MobileChangesPanel` supplies the phone composition.
- **Hierarchy and primary action:** file identity stays first, trailing change
evidence stays visible, and tapping the row still opens its diff.
- **Hierarchy and primary action:** file identity stays first, change evidence
stays visible, and tapping the row still opens its diff.
- **Surface rationale:** file rows are primary, frequently scanned content, so
in-place truncation fits better than a drawer, route, or horizontal scroller.
- **Shared logic:** all data, selection, and diff handlers remain shared;
viewport-specific presentation is unchanged.
- **Shared logic:** all data, selection, Git mutations, and diff handlers remain
shared; working-tree touch rows specialize presentation only.
- **Geometry:** dynamic viewport behavior, safe-area handling, touch sizing,
and vertical scroll ownership are unchanged. The correction must not add
panel-level or document-level horizontal overflow.
@@ -73,6 +102,12 @@ measurement, path rewriting, tooltip, or alternate row component.
hit-testing, diff opening, and no row or document horizontal overflow.
- Pixel 5 Playwright exercises the same seeded PR row through the existing
mobile Changes entry point and proves the same visible and actionable result.
- Working-tree phone Playwright proves filename width, row density, long-name
wrapping, menu containment and focus return, staging/unstaging, Edit, discard
cancellation, diff navigation, and a 22-level tree. It checks 393px and 767px
touch widths; the fine-pointer desktop test checks the 767px/768px transition.
- Desktop Git E2E and the existing file-row and tree Vitest suites guard inline
actions, touch-menu routing, pending feedback, and tree behavior. Screenshots record the phone list, menu, and desktop surface.
## Related decisions