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.