diff --git a/apps/web/components/task/file-browser-context-action.test.tsx b/apps/web/components/task/file-browser-context-action.test.tsx
index 805c01c98f..af44a19d77 100644
--- a/apps/web/components/task/file-browser-context-action.test.tsx
+++ b/apps/web/components/task/file-browser-context-action.test.tsx
@@ -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 }) => {node.name},
- getGitStatusTextClass: () => "",
-}));
+vi.mock("./file-context-menu", async (importOriginal) => {
+ const actual = await importOriginal();
+ 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(
+ {}}
+ 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(
+ {}}
+ 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");
+ });
+});
diff --git a/apps/web/components/task/file-browser-parts.tsx b/apps/web/components/task/file-browser-parts.tsx
index fe1eee167c..433a0a8c70 100644
--- a/apps/web/components/task/file-browser-parts.tsx
+++ b/apps/web/components/task/file-browser-parts.tsx
@@ -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({
/>
diff --git a/apps/web/components/task/file-browser-responsive.test.tsx b/apps/web/components/task/file-browser-responsive.test.tsx
new file mode 100644
index 0000000000..844541555c
--- /dev/null
+++ b/apps/web/components/task/file-browser-responsive.test.tsx
@@ -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();
+ return {
+ ...actual,
+ FileBrowserContentArea: ({
+ showTouchActions,
+ onAddToChatContext,
+ }: {
+ showTouchActions: boolean;
+ onAddToChatContext: (treeNode: FileTreeNode) => void;
+ }) => (
+
+ ),
+ };
+});
+
+import { FileBrowser } from "./file-browser";
+
+function renderFileBrowser() {
+ render();
+}
+
+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();
+ });
+});
diff --git a/apps/web/components/task/file-browser-search-context-action.test.tsx b/apps/web/components/task/file-browser-search-context-action.test.tsx
index 247bb852b1..61d5f6cd61 100644
--- a/apps/web/components/task/file-browser-search-context-action.test.tsx
+++ b/apps/web/components/task/file-browser-search-context-action.test.tsx
@@ -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[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",
+ );
+ }
+ });
});
diff --git a/apps/web/components/task/file-browser.tsx b/apps/web/components/task/file-browser.tsx
index efdfd3612c..0c516d9861 100644
--- a/apps/web/components/task/file-browser.tsx
+++ b/apps/web/components/task/file-browser.tsx
@@ -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 (
-
+ div]:!block [&>div]:!min-w-0 [&>div]:!w-full",
+ }}
+ >
(null);
const containerRef = useRef(null);
const { data, handlers, multiSelect, dnd, handleClickOutside } = useFileBrowserViewModel({
diff --git a/apps/web/e2e/fixtures/test-base.ts b/apps/web/e2e/fixtures/test-base.ts
index a4de652354..6a42407c70 100644
--- a/apps/web/e2e/fixtures/test-base.ts
+++ b/apps/web/e2e/fixtures/test-base.ts
@@ -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 }
diff --git a/apps/web/e2e/tests/task/mobile-file-tree-chat-context.spec.ts b/apps/web/e2e/tests/task/mobile-file-tree-chat-context.spec.ts
index 24ca6101bb..9f29c6c183 100644
--- a/apps/web/e2e/tests/task/mobile-file-tree-chat-context.spec.ts
+++ b/apps/web/e2e/tests/task/mobile-file-tree-chat-context.spec.ts
@@ -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(":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('[data-testid="file-tree-node-actions"]');
+ const name = row.querySelector(":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);
+ });
});
diff --git a/docs/plans/file-tree-responsive-spacing/plan.md b/docs/plans/file-tree-responsive-spacing/plan.md
new file mode 100644
index 0000000000..5edbf3d84f
--- /dev/null
+++ b/docs/plans/file-tree-responsive-spacing/plan.md
@@ -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.
diff --git a/docs/plans/file-tree-responsive-spacing/task-01-restore-responsive-file-tree-spacing.md b/docs/plans/file-tree-responsive-spacing/task-01-restore-responsive-file-tree-spacing.md
new file mode 100644
index 0000000000..442e87d8bf
--- /dev/null
+++ b/docs/plans/file-tree-responsive-spacing/task-01-restore-responsive-file-tree-spacing.md
@@ -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.
diff --git a/docs/specs/ui/README.md b/docs/specs/ui/README.md
index 4fdce7afe3..a16872c1a4 100644
--- a/docs/specs/ui/README.md
+++ b/docs/specs/ui/README.md
@@ -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)
diff --git a/docs/specs/ui/requirements/file-tree-chat-context.md b/docs/specs/ui/requirements/file-tree-chat-context.md
index a672240189..e4977b3965 100644
--- a/docs/specs/ui/requirements/file-tree-chat-context.md
+++ b/docs/specs/ui/requirements/file-tree-chat-context.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.
diff --git a/docs/specs/ui/system-design/file-tree-chat-context.md b/docs/specs/ui/system-design/file-tree-chat-context.md
new file mode 100644
index 0000000000..8940ce75d8
--- /dev/null
+++ b/docs/specs/ui/system-design/file-tree-chat-context.md
@@ -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.