fix(web): restore compact coarse-pointer file tree rows (#3326)

Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
Clément Fiere
2026-09-04 00:11:20 +00:00
committed by GitHub
parent 1f25ecf478
commit e8956840e0
12 changed files with 690 additions and 33 deletions
@@ -1,25 +1,29 @@
import type { ReactNode } from "react";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { FileTreeNode } from "@/lib/types/backend";
import type { FileBrowserRow } from "./file-browser-hooks";
import type * as FileContextMenu from "./file-context-menu";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("./file-context-menu", () => ({
FileContextMenu: ({ children }: { children: ReactNode }) => <>{children}</>,
useFileDeleteAction: () => null,
useFileRename: () => ({
isRenaming: false,
renameValue: "",
setRenameValue: vi.fn(),
handleStartRename: vi.fn(),
handleConfirmRename: vi.fn(),
handleRenameKeyDown: vi.fn(),
}),
TreeNodeName: ({ node }: { node: FileTreeNode }) => <span>{node.name}</span>,
getGitStatusTextClass: () => "",
}));
vi.mock("./file-context-menu", async (importOriginal) => {
const actual = await importOriginal<typeof FileContextMenu>();
return {
...actual,
FileContextMenu: ({ children }: { children: ReactNode }) => <>{children}</>,
useFileDeleteAction: () => null,
useFileRename: () => ({
isRenaming: false,
renameValue: "",
setRenameValue: vi.fn(),
handleStartRename: vi.fn(),
handleConfirmRename: vi.fn(),
handleRenameKeyDown: vi.fn(),
}),
getGitStatusTextClass: () => "",
};
});
import { TreeNodeItem } from "./file-browser-parts";
import { shouldShowFileTreeTouchActions, TreeNodeItem } from "./file-browser-parts";
const FILE_NODE: FileTreeNode = { name: "README.md", path: "README.md", is_dir: false, size: 0 };
const DIRECTORY_NODE: FileTreeNode = { name: "src", path: "src", is_dir: true, size: 0 };
@@ -115,3 +119,63 @@ describe("TreeNodeItem touch context action", () => {
expect(onAddToChatContext).toHaveBeenCalledWith(DIRECTORY_NODE);
});
});
describe("FileBrowser touch action gate", () => {
it("keeps the touch action available on a coarse-pointer desktop viewport", () => {
expect(shouldShowFileTreeTouchActions(false, false)).toBe(true);
});
it("keeps the touch action available on mobile and hidden on fine-pointer desktop", () => {
expect(shouldShowFileTreeTouchActions(true, true)).toBe(true);
expect(shouldShowFileTreeTouchActions(false, true)).toBe(false);
});
});
describe("TreeNodeItem row spacing", () => {
it("keeps the touch-action row single-line with an independent 44px hit area", () => {
render(
<TreeNodeItem
row={ROW}
activeFolderPath=""
visibleLoadingPaths={new Set()}
fileStatuses={new Map()}
tree={null}
onToggleExpand={vi.fn()}
onOpenFile={vi.fn()}
setTree={() => {}}
showTouchActions
onAddToChatContext={vi.fn()}
/>,
);
const row = screen.getByTestId("file-tree-node");
const name = screen.getByText(FILE_NODE.name);
// @covers AC-UI-FILE-TREE-CHAT-CONTEXT-001.9
expect(row.className).not.toMatch(/(?:^|\s)flex-wrap(?:\s|$)/);
expect(row.className).toContain("min-h-11");
expect(name.className).toContain("min-w-0");
expect(name.className).toContain("truncate");
});
it("keeps fine-pointer rows on the compact layout without touch-action spacing", () => {
render(
<TreeNodeItem
row={ROW}
activeFolderPath=""
visibleLoadingPaths={new Set()}
fileStatuses={new Map()}
tree={null}
onToggleExpand={vi.fn()}
onOpenFile={vi.fn()}
setTree={() => {}}
showTouchActions={false}
onAddToChatContext={vi.fn()}
/>,
);
const row = screen.getByTestId("file-tree-node");
expect(row.className).not.toContain("relative");
expect(row.className).not.toContain("min-h-11");
expect(row.className).not.toContain("pr-11");
});
});
@@ -43,6 +43,10 @@ export {
type GitFileStatus = FileInfo["status"] | undefined;
export function shouldShowFileTreeTouchActions(isMobile: boolean, isFinePointer: boolean) {
return isMobile || !isFinePointer;
}
type TreeNodeRowProps = {
row: FileBrowserRow;
activeFolderPath: string;
@@ -186,7 +190,7 @@ export function FileTreeNodeTouchActions({
data-testid="file-tree-node-actions"
data-path={node.path}
aria-label={t("chat:fileTreeActions")}
className="ml-auto flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground cursor-pointer"
className="absolute top-1/2 right-0 z-10 flex min-h-11 min-w-11 -translate-y-1/2 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground cursor-pointer"
onPointerDown={stopRowInteraction}
onMouseDown={stopRowInteraction}
onClick={stopRowInteraction}
@@ -274,7 +278,8 @@ export function TreeNodeItem(props: TreeNodeRowProps) {
role="treeitem"
className={cn(
getTreeNodeRowClass(isActive, isActiveFolder, isSelected, props.isDragging, isDropTarget),
props.showTouchActions && "flex-wrap",
showTouchActions &&
"relative min-h-11 pr-11 has-[[data-testid=file-delete-inline-confirmation]]:flex-wrap has-[[data-testid=file-delete-inline-confirmation]]:pr-2",
)}
style={{ paddingLeft: treeNodePaddingLeft(row.depth, node.is_dir) }}
onClick={handleClick}
@@ -374,6 +379,7 @@ export function SearchResultsList({
className={cn(
"group flex w-full items-center gap-1 px-2 py-0.5 text-left text-sm cursor-pointer",
"hover:bg-muted",
showTouchActions && "relative min-h-11 pr-11",
)}
onClick={() => onOpenFile(path)}
>
@@ -385,7 +391,7 @@ export function SearchResultsList({
/>
<span
className={cn(
"truncate group-hover:text-foreground",
"min-w-0 flex-1 truncate group-hover:text-foreground",
getGitStatusTextClass(gitStatus) || "text-muted-foreground",
)}
>
@@ -0,0 +1,135 @@
import type { ReactNode } from "react";
import type { FileTreeNode } from "@/lib/types/backend";
import type * as FileBrowserParts from "./file-browser-parts";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
const { responsive, node } = vi.hoisted(() => ({
responsive: { isMobile: false, isFinePointer: true },
node: { name: "README.md", path: "README.md", is_dir: false, size: 0 },
}));
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
useResponsiveBreakpoint: () => responsive,
}));
vi.mock("@/hooks/domains/session/use-session", () => ({
useSession: () => ({ session: null, isFailed: false, errorMessage: null }),
}));
vi.mock("@/hooks/domains/workspace/use-repository", () => ({ useRepository: () => null }));
vi.mock("@/hooks/domains/session/use-session-git-status", () => ({
useSessionGitStatus: () => null,
}));
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (state: unknown) => unknown) =>
selector({
sessionWorktreesBySessionId: { itemsBySessionId: {} },
workspaceFilesRefresh: { bySessionId: {} },
}),
}));
vi.mock("@/hooks/use-open-session-folder", () => ({
useOpenSessionFolder: () => ({ open: vi.fn() }),
}));
vi.mock("@/hooks/use-copy-to-clipboard", () => ({
useCopyToClipboard: () => ({ copied: false, copy: vi.fn() }),
}));
vi.mock("@/components/toast-provider", () => ({ useToast: () => ({ toast: vi.fn() }) }));
vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string) => key }) }));
vi.mock("@/hooks/use-multi-select", () => ({
useMultiSelect: () => ({
selectedPaths: new Set(),
setSelectedPaths: vi.fn(),
isSelected: () => false,
handleClick: () => false,
clearSelection: vi.fn(),
selectAll: vi.fn(),
}),
}));
vi.mock("@/lib/state/context-files-store", () => ({
useContextFilesStore: (selector: (state: { addFile: () => void }) => unknown) =>
selector({ addFile: vi.fn() }),
}));
vi.mock("./file-browser-header", () => ({ FileBrowserHeader: () => null }));
vi.mock("./file-browser-hooks", () => ({
useFileBrowserSearch: () => ({}),
useFileBrowserTree: () => ({
tree: node,
isLoadingTree: false,
loadState: "loaded",
loadError: null,
visibleRows: [],
visibleLoadingPaths: new Set(),
expandedPaths: new Set(),
setTree: vi.fn(),
setExpandedPaths: vi.fn(),
isLoading: false,
loadTree: vi.fn(),
}),
useScrollPersistence: () => {},
loadNodeChildren: vi.fn(),
toggleFolderExpand: vi.fn(),
fetchAndOpenFile: () => Promise.resolve(),
}));
vi.mock("./file-browser-path", () => ({
getFileBrowserSessionWorkspacePath: () => null,
resolveFileBrowserPaths: () => ({ fullPath: "/workspace", displayPath: "workspace" }),
}));
vi.mock("./file-tree-reveal", () => ({ useFileTreeReveal: () => {} }));
vi.mock("./file-tree-editor-menu", () => ({
FileTreeEditorProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
}));
vi.mock("./file-context-menu", () => ({ useFileDeleteAction: () => null }));
vi.mock("./file-browser-parts", async (importOriginal) => {
const actual = await importOriginal<typeof FileBrowserParts>();
return {
...actual,
FileBrowserContentArea: ({
showTouchActions,
onAddToChatContext,
}: {
showTouchActions: boolean;
onAddToChatContext: (treeNode: FileTreeNode) => void;
}) => (
<actual.FileTreeNodeTouchActions
node={node}
showTouchActions={showTouchActions}
onAddToChatContext={onAddToChatContext}
/>
),
};
});
import { FileBrowser } from "./file-browser";
function renderFileBrowser() {
render(<FileBrowser sessionId="session-1" onOpenFile={vi.fn()} />);
}
afterEach(() => {
cleanup();
responsive.isMobile = false;
responsive.isFinePointer = true;
});
describe("FileBrowser responsive touch action wiring", () => {
it("renders the 44px action for a coarse-pointer desktop workbench", () => {
responsive.isFinePointer = false;
renderFileBrowser();
const trigger = screen.getByTestId("file-tree-node-actions");
expect(trigger.className).toContain("min-h-11");
expect(trigger.className).toContain("min-w-11");
});
it("hides the touch action only for fine-pointer desktop", () => {
renderFileBrowser();
expect(screen.queryByTestId("file-tree-node-actions")).toBeNull();
});
it("renders the touch action for mobile", () => {
responsive.isMobile = true;
renderFileBrowser();
expect(screen.getByTestId("file-tree-node-actions")).toBeTruthy();
});
});
@@ -1,5 +1,5 @@
import type { ReactNode } from "react";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { FileTreeNode } from "@/lib/types/backend";
@@ -42,14 +42,16 @@ const SEARCH_PATH = "src/components/chat-input.tsx";
function renderSearchResults({
showTouchActions = false,
searchResults = [SEARCH_PATH],
onAddToChatContext = vi.fn(),
}: {
showTouchActions?: boolean;
searchResults?: string[];
onAddToChatContext?: (node: FileTreeNode) => void;
} = {}) {
const props: Parameters<typeof FileBrowserContentArea>[0] = {
isSearchActive: true,
searchResults: [SEARCH_PATH],
searchResults,
isSessionFailed: false,
sessionError: null,
loadState: "loaded",
@@ -110,4 +112,24 @@ describe("file browser search result context actions", () => {
}),
);
});
it("anchors every touch action to a multi-result row with reserved name space", () => {
const searchResults = [
"src/components/long-first-search-result.tsx",
"src/components/long-second-search-result.tsx",
];
renderSearchResults({ showTouchActions: true, searchResults });
const rows = screen.getAllByTestId("file-search-result");
const triggers = screen.getAllByTestId("file-tree-node-actions");
expect(rows).toHaveLength(searchResults.length);
expect(triggers).toHaveLength(searchResults.length);
for (const row of rows) {
expect(row.className).toContain("relative");
expect(row.className).toContain("pr-11");
expect(within(row).getByText(/long-.*-search-result/).parentElement?.className).toContain(
"min-w-0",
);
}
});
});
+16 -3
View File
@@ -25,7 +25,12 @@ import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { isEditableKeydownTarget } from "@/lib/keyboard/utils";
import { useContextFilesStore } from "@/lib/state/context-files-store";
import { FileBrowserHeader } from "./file-browser-header";
import { insertNodeInTree, removeNodeFromTree, FileBrowserContentArea } from "./file-browser-parts";
import {
insertNodeInTree,
removeNodeFromTree,
FileBrowserContentArea,
shouldShowFileTreeTouchActions,
} from "./file-browser-parts";
import {
useFileBrowserSearch,
useFileBrowserTree,
@@ -477,7 +482,15 @@ function FileBrowserTreeContent({
}) {
const { search, isSessionFailed, sessionError, treeState, fileStatuses } = data;
return (
<ScrollArea className="flex-1" ref={scrollAreaRef}>
<ScrollArea
className="flex-1 min-w-0"
ref={scrollAreaRef}
viewportProps={{
// Keep the file-tree content constrained to the viewport so row labels
// can shrink and apply their own truncation rules.
className: "[&>div]:!block [&>div]:!min-w-0 [&>div]:!w-full",
}}
>
<FileBrowserContentArea
isSearchActive={search.isSearchActive}
searchResults={search.searchResults}
@@ -535,7 +548,7 @@ export function FileBrowser({
addSourcesDisabledReason,
}: FileBrowserProps) {
const { isMobile, isFinePointer } = useResponsiveBreakpoint();
const showTouchActions = isMobile || !isFinePointer;
const showTouchActions = shouldShowFileTreeTouchActions(isMobile, isFinePointer);
const scrollAreaRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const { data, handlers, multiSelect, dnd, handleClickOutside } = useFileBrowserViewModel({
+15
View File
@@ -166,6 +166,7 @@ export const test = backendFixture.extend<
{
testPage: Page;
tabletTestPage: Page;
coarseDesktopTestPage: Page;
prCapture: PrAssetCapture;
/**
* Auto fixture that resets integration mock state and any persisted
@@ -417,6 +418,20 @@ export const test = backendFixture.extend<
await context.close();
},
coarseDesktopTestPage: async ({ browser, backend, testPage }, use) => {
void testPage;
const context = await browser.newContext({
baseURL: backend.frontendUrl,
viewport: { width: 1280, height: 900 },
hasTouch: true,
isMobile: false,
});
const page = await context.newPage();
await setupPage(page, backend);
await use(page);
await context.close();
},
// PR asset capture — gated behind CAPTURE_PR_ASSETS env var.
// When enabled, provides screenshot/recording helpers for PR descriptions.
// Destructure in tests that need it: { testPage, prCapture }
@@ -12,16 +12,26 @@ async function setupMobileContextTask(
apiClient: ApiClient,
seedData: SeedData,
backend: BackendContext,
viewport?: { width: number; height: number },
) {
if (viewport) await testPage.setViewportSize(viewport);
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const directoryPath = `mobile-context-directory-${suffix}`;
const directoryPath = `mobile-context-directory-${suffix}-with-a-long-name-that-must-truncate-in-the-file-tree-on-a-narrow-touch-viewport-with-an-extra-long-label-for-overflow-verification`;
const filePath = `mobile-context-file-${suffix}.md`;
const searchResultPrefix = `mobile-search-layout-${suffix}`;
const searchResultPaths = [
`${searchResultPrefix}-first-result.ts`,
`${searchResultPrefix}-second-result.ts`,
];
const git = new GitHelper(
path.join(backend.tmpDir, "repos", "e2e-repo"),
makeGitEnv(backend.tmpDir),
);
git.createFile(`${directoryPath}/nested.txt`, "mobile directory content\n");
git.createFile(filePath, "mobile file content\n");
searchResultPaths.forEach((searchResultPath) =>
git.createFile(searchResultPath, "search result\n"),
);
git.stageAll();
git.commit(`add mobile chat context fixture ${suffix}`);
@@ -40,7 +50,7 @@ async function setupMobileContextTask(
const session = new SessionPage(testPage);
await session.waitForLoad();
await session.waitForChatIdle({ timeout: 45_000 });
return { session, directoryPath, filePath };
return { session, directoryPath, filePath, searchResultPrefix };
}
test.describe("Mobile file tree chat context", () => {
@@ -52,17 +62,37 @@ test.describe("Mobile file tree chat context", () => {
prCapture,
}) => {
test.setTimeout(90_000);
const { session, directoryPath, filePath } = await setupMobileContextTask(
const { session, directoryPath, filePath, searchResultPrefix } = await setupMobileContextTask(
testPage,
apiClient,
seedData,
backend,
{ width: 393, height: 851 },
);
await testPage.getByRole("button", { name: "Files" }).tap();
const directoryNode = session.fileTreeNode(directoryPath);
await expect(directoryNode).toBeVisible({ timeout: 15_000 });
const rowGeometry = await directoryNode.evaluate((node) => {
const name = node.querySelector<HTMLElement>(":scope > span.min-w-0.flex-1");
return {
flexWrap: getComputedStyle(node).flexWrap,
nameWhiteSpace: name ? getComputedStyle(name).whiteSpace : null,
nameOverflow: name ? getComputedStyle(name).overflow : null,
nameTextOverflow: name ? getComputedStyle(name).textOverflow : null,
nameScrollWidth: name?.scrollWidth ?? null,
nameClientWidth: name?.clientWidth ?? null,
};
});
expect(rowGeometry.flexWrap).toBe("nowrap");
expect(rowGeometry.nameWhiteSpace).toBe("nowrap");
expect(rowGeometry.nameOverflow).toBe("hidden");
expect(rowGeometry.nameTextOverflow).toBe("ellipsis");
expect(rowGeometry.nameScrollWidth).not.toBeNull();
expect(rowGeometry.nameClientWidth).not.toBeNull();
expect(rowGeometry.nameScrollWidth).toBeGreaterThan(rowGeometry.nameClientWidth!);
const trigger = session.fileTreeNodeActions(directoryPath);
await expect(trigger).toBeVisible();
const triggerBox = await trigger.boundingBox();
@@ -109,6 +139,63 @@ test.describe("Mobile file tree chat context", () => {
await session.fileTreeTouchAddToChatContextItem().tap();
await session.fileSearchInput().press("Escape");
await session.fileSearchButton().tap();
await session.fileSearchInput().fill(searchResultPrefix);
const searchRows = testPage.locator('[data-testid="file-search-result"]:visible');
await expect(searchRows).toHaveCount(2);
const searchGeometry = await searchRows.evaluateAll((rows) =>
rows.map((row) => {
const trigger = row.querySelector<HTMLElement>('[data-testid="file-tree-node-actions"]');
const name = row.querySelector<HTMLElement>(":scope > span.min-w-0.flex-1");
const rowBox = row.getBoundingClientRect();
const triggerBox = trigger?.getBoundingClientRect();
return {
actionBottom: triggerBox?.bottom ?? null,
actionCenterX: triggerBox ? triggerBox.x + triggerBox.width / 2 : null,
actionCenterY: triggerBox ? triggerBox.y + triggerBox.height / 2 : null,
actionTop: triggerBox?.top ?? null,
path: row.getAttribute("data-path"),
rowBottom: rowBox.bottom,
rowLeft: rowBox.left,
rowRight: rowBox.right,
rowTop: rowBox.top,
nameOverflow: name ? getComputedStyle(name).overflow : null,
nameTextOverflow: name ? getComputedStyle(name).textOverflow : null,
nameScrollWidth: name?.scrollWidth ?? null,
nameClientWidth: name?.clientWidth ?? null,
};
}),
);
for (const [index, geometry] of searchGeometry.entries()) {
expect(geometry.actionCenterX).toBeGreaterThanOrEqual(geometry.rowLeft);
expect(geometry.actionCenterX).toBeLessThanOrEqual(geometry.rowRight);
expect(geometry.actionCenterY).toBeGreaterThanOrEqual(geometry.rowTop);
expect(geometry.actionCenterY).toBeLessThanOrEqual(geometry.rowBottom);
expect(geometry.actionTop).toBeGreaterThanOrEqual(geometry.rowTop);
expect(geometry.actionBottom).toBeLessThanOrEqual(geometry.rowBottom);
expect(geometry.path).toBeTruthy();
expect(geometry.nameOverflow).toBe("hidden");
expect(geometry.nameTextOverflow).toBe("ellipsis");
expect(geometry.nameScrollWidth).not.toBeNull();
expect(geometry.nameClientWidth).not.toBeNull();
expect(geometry.nameScrollWidth).toBeGreaterThan(geometry.nameClientWidth!);
if (index > 0) {
expect(searchGeometry[index - 1].actionBottom).toBeLessThanOrEqual(geometry.actionTop);
}
}
const firstSearchTrigger = searchRows.nth(0).getByTestId("file-tree-node-actions");
const firstSearchTriggerBox = await firstSearchTrigger.boundingBox();
expect(firstSearchTriggerBox).not.toBeNull();
await firstSearchTrigger.tap({
position: {
x: firstSearchTriggerBox!.width / 2,
y: firstSearchTriggerBox!.height - 1,
},
});
await expect(session.fileTreeTouchMenu()).toBeVisible();
await testPage.keyboard.press("Escape");
await session.fileSearchInput().press("Escape");
await expect(testPage.getByTestId("mobile-file-viewer-panel")).toHaveCount(0);
await testPage.getByRole("button", { name: "Chat" }).tap();
await expect(session.chatContextFile(directoryPath)).toHaveCount(1);
@@ -127,4 +214,58 @@ test.describe("Mobile file tree chat context", () => {
await expect(session.chatContextFile(directoryPath)).toHaveCount(0);
await expect(session.chatContextFile(filePath)).toHaveCount(0);
});
test("keeps coarse desktop touch actions in separate 44px rows", async ({
coarseDesktopTestPage,
apiClient,
seedData,
backend,
}) => {
test.setTimeout(90_000);
const { session, directoryPath, filePath } = await setupMobileContextTask(
coarseDesktopTestPage,
apiClient,
seedData,
backend,
);
const viewport = await coarseDesktopTestPage.evaluate(() => ({
width: window.innerWidth,
hasFinePointer: window.matchMedia("(pointer: fine)").matches,
}));
expect(viewport.width).toBeGreaterThanOrEqual(1024);
expect(viewport.hasFinePointer).toBe(false);
await session.clickTab("Files");
const rows = [session.fileTreeNode(directoryPath), session.fileTreeNode(filePath)];
for (const row of rows) {
await expect(row).toBeVisible({ timeout: 15_000 });
await expect
.poll(async () => (await row.boundingBox())?.height ?? 0, { timeout: 2_000 })
.toBeGreaterThanOrEqual(44);
}
const triggers = [
session.fileTreeNodeActions(directoryPath),
session.fileTreeNodeActions(filePath),
];
await expect(triggers[0]).toBeVisible();
await expect(triggers[1]).toBeVisible();
const triggerBoxes = await Promise.all(triggers.map((trigger) => trigger.boundingBox()));
expect(triggerBoxes[0]).not.toBeNull();
expect(triggerBoxes[1]).not.toBeNull();
expect(triggerBoxes[0]!.width).toBeGreaterThanOrEqual(44);
expect(triggerBoxes[0]!.height).toBeGreaterThanOrEqual(44);
expect(triggerBoxes[0]!.y + triggerBoxes[0]!.height).toBeLessThanOrEqual(triggerBoxes[1]!.y);
await triggers[0].tap({
position: {
x: triggerBoxes[0]!.width / 2,
y: triggerBoxes[0]!.height - 1,
},
});
await expect(session.fileTreeTouchAddToChatContextItem()).toBeVisible();
await session.fileTreeTouchAddToChatContextItem().tap();
await session.clickSessionChatTab();
await expect(session.chatContextFile(directoryPath)).toHaveCount(1);
});
});
@@ -0,0 +1,96 @@
---
created: 2026-09-03
status: complete
requirements:
- REQ-UI-FILE-TREE-CHAT-CONTEXT-001
system_design:
- ../../specs/ui/system-design/file-tree-chat-context.md
legacy_specs: []
---
# Implementation Plan: File Tree Responsive Spacing
## Overview
Restore compact file-tree row spacing on coarse-pointer devices, including
zoomed-out phones that select the desktop workbench. The existing visible touch
action remains available for every coarse pointer; all compositions keep
single-line rows.
The prior `FileBrowser` implementation enabled a 44px overflow trigger and row
wrapping for every non-fine pointer, including wide desktop workbenches. The
trigger remains required; its wrapping behavior caused the spacing in the
report.
## Scope
### In scope
- Keep the visible 44px action trigger on every coarse-pointer file tree, including desktop workbench.
- Keep file-tree rows compact and single-line in every composition.
- Add regression coverage for coarse-pointer desktop and mobile geometry.
### Out of scope
- Changing file-tree data loading, selection, drag/drop, or file operations.
- Changing the context-menu action or chat-context state and message contracts.
- Redesigning the mobile Files surface or changing panel persistence.
## Technical approach
Update `FileBrowser` in `apps/web/components/task/file-browser.tsx` to retain
the existing coarse-pointer touch-action gate. Keep `TreeNodeItem` and
`SearchResultsList` in `apps/web/components/task/file-browser-parts.tsx`
non-wrapping. Responsive rows reserve a 44px vertical interaction slot for
their absolutely positioned trigger, while labels reserve the horizontal action
space and truncate.
Add focused regression assertions for `FileBrowser` responsive action wiring
and multi-result search overlays. Extend
`apps/web/e2e/tests/task/mobile-file-tree-chat-context.spec.ts` with computed
non-wrapping/truncation geometry, two simultaneous mobile search results, and a
1280px coarse-pointer context that verifies non-overlapping 44px action slots.
## Tests
- `AC-UI-FILE-TREE-CHAT-CONTEXT-001.7`: existing touch-action component and
mobile E2E coverage continue to assert a visible trigger and 44px menu/action
targets.
- `AC-UI-FILE-TREE-CHAT-CONTEXT-001.9`: focused component coverage verifies
coarse-pointer desktop action wiring and multi-result search row anchoring;
E2E verifies non-overlapping 44px action slots on wide desktop and no-wrap,
truncation, and per-row search actions on mobile.
## E2E tests
- `apps/web/e2e/tests/task/mobile-file-tree-chat-context.spec.ts` using the
`mobile-chrome` project: verify long-name no-wrap/truncation and two
row-anchored mobile search results with non-overlapping action slots, then
create a 1280px coarse-pointer context that proves a boundary touch reaches
the intended 44px action and can add it to chat context.
- `apps/web/e2e/tests/task/file-tree-chat-context.spec.ts`: existing desktop
right-click flow remains unchanged and covers the desktop action path.
## Work orders
- [x] [Task 01: Restore responsive file-tree row spacing](task-01-restore-responsive-file-tree-spacing.md)
## Verification results
- Focused component coverage passed, including FileBrowser-level coarse,
mobile, and fine-pointer rendering plus multi-result search-overlay
anchoring.
- Mobile Chromium E2E: `pnpm e2e:run --project mobile-chrome
tests/task/mobile-file-tree-chat-context.spec.ts` passed both Pixel 5
no-wrap/truncation with two independently anchored search actions and 1280px
coarse-pointer compact-row/add-to-chat scenarios.
- `make fmt` and `make lint` passed. `make typecheck` passed before the
aggregate `make test` stage exposed unrelated ambient backend-suite failures.
## Risks
- A wide coarse-pointer device must retain the visible action rather than
relying on desktop right-click interaction.
- The responsive hook distinguishes viewport composition from pointer precision;
tests must cover both so a zoomed-out phone preserves touch access without
row wrapping.
@@ -0,0 +1,96 @@
---
id: "01-restore-responsive-file-tree-spacing"
title: "Restore responsive file-tree row spacing"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-UI-FILE-TREE-CHAT-CONTEXT-001
acceptance_criteria:
- AC-UI-FILE-TREE-CHAT-CONTEXT-001.7
- AC-UI-FILE-TREE-CHAT-CONTEXT-001.9
system_design:
- ../../specs/ui/system-design/file-tree-chat-context.md
---
# Task 01: Restore responsive file-tree row spacing
## Summary
Ensure the Files panel keeps compact single-line rows on every responsive
composition. Preserve the 44px touch action and responsive menu on
coarse-pointer devices, including desktop-workbench and zoomed-out layouts.
## In scope
- Correct the responsive presentation gate in `FileBrowser`.
- Remove row wrapping that inflates file-tree spacing.
- Add focused component regression coverage and mobile Playwright geometry
coverage without changing the existing context action flow.
## Out of scope
- File-tree data, filesystem operations, context-file storage, or chat sending.
- New responsive primitives, breakpoints, or desktop panel redesign.
## Acceptance
- Coarse-pointer file trees, including wide desktop workbench viewports, retain
the visible touch action with its 44px minimum active hitbox.
- Tree and search-result rows remain compact and non-wrapping; long names
truncate within reserved action space, and responsive rows provide an
exclusive 44px vertical action slot.
- Existing desktop and mobile file-tree context actions continue to pass.
## Verification
```bash
(cd apps && pnpm --filter @kandev/web test -- --run components/task/file-browser-context-action.test.tsx components/task/file-browser-responsive.test.tsx components/task/file-browser-search-context-action.test.tsx)
(cd apps/web && pnpm e2e:run --project mobile-chrome tests/task/mobile-file-tree-chat-context.spec.ts)
```
## Files likely touched
- `apps/web/components/task/file-browser.tsx`
- `apps/web/components/task/file-browser-parts.tsx`
- `apps/web/components/task/file-browser-context-action.test.tsx`
- `apps/web/components/task/file-browser-responsive.test.tsx`
- `apps/web/components/task/file-browser-search-context-action.test.tsx`
- `apps/web/e2e/fixtures/test-base.ts`
- `apps/web/e2e/tests/task/mobile-file-tree-chat-context.spec.ts`
## Dependencies
None.
## Risks
- A coarse-pointer desktop must retain the visible action in a 44px row slot so
adjacent triggers cannot overlap.
- Each search result must anchor its overlay independently, reserve its action
space, and truncate its label.
- Geometry assertions cover non-overlapping action slots and a boundary touch
that opens the intended row action.
## Parallelism
`sequential`
## Inputs
- `AC-UI-FILE-TREE-CHAT-CONTEXT-001.7` and `AC-UI-FILE-TREE-CHAT-CONTEXT-001.9`.
- `docs/specs/ui/system-design/file-tree-chat-context.md`.
- Existing file-tree component and Playwright tests.
## Results
- Focused component coverage passed, including FileBrowser-level wide
coarse-pointer and mobile action visibility, fine-pointer hiding, no-wrap
tree rows, and independently anchored multi-result search actions.
- Mobile Chromium E2E passed Pixel 5 long-name no-wrap/truncation with two
clipped, row-anchored search actions and a 1280px coarse-pointer desktop
context with compact short and long rows, a 44px trigger, and Add to chat
context.
- `make fmt` and `make lint` passed. The aggregate `make test` stage remains
blocked by unrelated ambient backend-suite failures.
+4 -3
View File
@@ -151,12 +151,13 @@ UI owns responsive behavior; other systems own behavior/state.
- [Compact Workflow Step Navigation](system-design/compact-workflow-step-navigation.md)
- [Workflow column visibility (Part 1)](system-design/board-step-visibility-filter-01.md)
- [Workflow column visibility (Part 2)](system-design/board-step-visibility-filter-02.md)
- [Task PR Automation Controls System Design Part 1](system-design/ci-pr-automation-01.md)
- [Task PR Automation Controls System Design Part 2](system-design/ci-pr-automation-02.md)
- [Task PR Automation Controls System Design Part 3](system-design/ci-pr-automation-03.md)
- [Task PR Automation Controls 1](system-design/ci-pr-automation-01.md)
- [Task PR Automation Controls 2](system-design/ci-pr-automation-02.md)
- [Task PR Automation Controls 3](system-design/ci-pr-automation-03.md)
- [Merge Queue Recovery Controls](system-design/ci-pr-merge-queue-recovery-controls.md)
- [Entity Reference Composer](system-design/entity-reference-composer.md)
- [File Tree Keyboard Scope](system-design/file-tree-keyboard-scope.md)
- [File Tree Chat Context](system-design/file-tree-chat-context.md)
- [Kandev MCP Tool Results](system-design/kandev-mcp-tool-results.md)
- [Mobile Task Chrome](system-design/mobile-task-chrome.md)
- [Persistent status motion](system-design/persistent-status-motion.md)
@@ -25,8 +25,9 @@ Users inspecting a task's Files tab must currently return to the chat composer a
- **AC-UI-FILE-TREE-CHAT-CONTEXT-001.4:** File and directory context items are visually distinguishable before send. Directory items do not attempt to open a file preview when selected.
- **AC-UI-FILE-TREE-CHAT-CONTEXT-001.5:** File-tree context items are ephemeral: they survive a reload and a failed send, and they are removed after the next successful send unless the user pins them through an existing context control.
- **AC-UI-FILE-TREE-CHAT-CONTEXT-001.6:** Sending the message, directly or after a busy-session send is queued, includes each selected path in the existing hidden context block and `context_files` message metadata so the agent is instructed to inspect the path and the sent user message records it. Metadata retains optional directory identity while remaining compatible with older `{ path, name }` entries.
- **AC-UI-FILE-TREE-CHAT-CONTEXT-001.7:** On phone and coarse-pointer layouts, each eligible file-tree row exposes a visible, accessible action trigger. The trigger opens the existing responsive menu treatment with a touch target at least 44px high; long press or right-click is not required.
- **AC-UI-FILE-TREE-CHAT-CONTEXT-001.7:** On phone, tablet, and coarse-pointer desktop-workbench compositions, each eligible file-tree row exposes a visible, accessible action trigger. The trigger opens the existing responsive menu treatment with a touch target at least 44px high; long press or right-click is not required.
- **AC-UI-FILE-TREE-CHAT-CONTEXT-001.8:** All new visible labels and feedback use the existing localization catalogs.
- **AC-UI-FILE-TREE-CHAT-CONTEXT-001.9:** File-tree rows shall retain compact single-line spacing in every responsive composition. A visible coarse-pointer action, including in the desktop workbench, shall not cause wrapping; long names shall truncate instead.
## Migrated source detail
@@ -42,7 +43,8 @@ Users inspecting a task's Files tab must currently return to the chat composer a
- File and directory context items are visually distinguishable before send. Directory items do not attempt to open a file preview when selected.
- File-tree context items are ephemeral: they survive a reload and a failed send, and they are removed after the next successful send unless the user pins them through an existing context control.
- Sending the message, directly or after a busy-session send is queued, includes each selected path in the existing hidden context block and `context_files` message metadata so the agent is instructed to inspect the path and the sent user message records it. Metadata retains optional directory identity while remaining compatible with older `{ path, name }` entries.
- On phone and coarse-pointer layouts, each eligible file-tree row exposes a visible, accessible action trigger. The trigger opens the existing responsive menu treatment with a touch target at least 44px high; long press or right-click is not required.
- On phone, tablet, and coarse-pointer desktop-workbench compositions, each eligible file-tree row exposes a visible, accessible action trigger. The trigger opens the existing responsive menu treatment with a touch target at least 44px high; long press or right-click is not required.
- File-tree rows retain compact single-line spacing in every responsive composition; visible coarse-pointer actions do not cause wrapping, and long names truncate instead.
- All new visible labels and feedback use the existing localization catalogs.
- Existing file opening, folder expansion, multi-selection, drag/drop, editor, rename, download, and delete behavior remains unchanged.
@@ -0,0 +1,66 @@
---
status: current
system: ui
requirements:
- REQ-UI-FILE-TREE-CHAT-CONTEXT-001
---
# File Tree Chat Context System Design
## Purpose and boundaries
The UI system owns the reusable file-tree row presentation and responsive action
surface used by task Files panels. This repair preserves the existing
file-tree interaction contract while ensuring the coarse-pointer action trigger
does not turn compact rows into multi-line rows. File data, context-file state,
and message delivery remain owned by their existing task and chat contracts.
## Requirement mapping
| Requirement | Design section |
| --- | --- |
| `AC-UI-FILE-TREE-CHAT-CONTEXT-001.7` | [Responsive row geometry](#responsive-row-geometry), [Interaction preservation](#interaction-preservation) |
| `AC-UI-FILE-TREE-CHAT-CONTEXT-001.9` | [Responsive row geometry](#responsive-row-geometry) |
The existing task and chat contracts own the other acceptance criteria for file
context state and message delivery. This design only records the responsive row
and action-surface repair.
## Components and responsibilities
`FileBrowser` in `apps/web/components/task/file-browser.tsx` determines whether
touch actions are required from the responsive breakpoint and pointer model.
`FileBrowserContentArea` and `TreeNodeItem` in
`apps/web/components/task/file-browser-parts.tsx` pass that presentation state
through the file-tree render path. `FileTreeNodeTouchActions` owns the visible
coarse-pointer overflow trigger and its responsive menu; its trigger remains a
secondary action and does not own row navigation.
## Responsive row geometry
A file-tree row remains a compact single flex line when its name and controls
fit within the panel. Responsive rows that render a 44px touch trigger establish
an exclusive 44px vertical interaction slot. The absolutely positioned trigger
stays within that slot while the filename reserves its action space, shrinks,
and truncates. This prevents adjacent action targets from overlapping without
allowing row wrapping.
The responsive action remains rendered on phone and coarse-pointer layouts,
including when the desktop task composition is selected on a phone. Fine-pointer
desktop rows retain the existing compact geometry and context-menu interaction.
## Interaction preservation
The row's primary click continues to open files or expand directories. The touch
action trigger stops row click, selection, keyboard, and pointer propagation
before opening the existing dropdown menu. The menu continues to provide the
same context action and touch-sized menu items. Search-result rows use the same
trigger geometry and action component.
## Verification
The component regression test shall verify that a touch-enabled row keeps the
non-wrapping row geometry and that a fine-pointer row does not receive the
responsive action layout. The existing desktop and mobile file-tree chat
context Playwright flows remain the integration proof that the trigger is
reachable and the action still adds the selected file or directory to chat.