fix(ui): keep file select-all inside text inputs (#3259)

This commit is contained in:
José Almeida
2026-09-02 00:03:37 +00:00
committed by GitHub
parent e6f98d996d
commit c51ec0a21b
9 changed files with 470 additions and 14 deletions
+2 -1
View File
@@ -22,6 +22,7 @@ import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { useMultiSelect } from "@/hooks/use-multi-select";
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";
@@ -346,7 +347,7 @@ function useKeyboardShortcuts(
return;
if (e.key === "Escape") {
clearSelection();
} else if ((e.ctrlKey || e.metaKey) && e.key === "a") {
} else if ((e.ctrlKey || e.metaKey) && e.key === "a" && !isEditableKeydownTarget(e)) {
e.preventDefault();
selectAll();
}
@@ -408,6 +408,41 @@ test.describe("File Tree Multi-Select", () => {
// --- Keyboard ---
test("select-all selects every visible row from non-editable tree focus", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
// @covers AC-UI-FILE-TREE-KEYBOARD-SCOPE-001.3
const git = new GitHelper(
path.join(backend.tmpDir, "repos", "e2e-repo"),
makeGitEnv(backend.tmpDir),
);
git.createFile("tree-select-all-a.ts", "a");
git.createFile("tree-select-all-b.ts", "b");
git.stageAll();
git.commit("add select-all files");
const session = await setupFileTreeTest(
testPage,
apiClient,
seedData,
"ft-tree-select-all",
"FT Tree Select All",
);
const firstFile = session.fileTreeNode("tree-select-all-a.ts");
await expect(firstFile).toBeVisible({ timeout: 15_000 });
const visibleRows = session.files.locator("[data-testid='file-tree-node']:visible");
const visibleRowCount = await visibleRows.count();
const fileBrowser = firstFile.locator("xpath=ancestor::div[@tabindex='-1'][1]");
await fileBrowser.focus();
await fileBrowser.press("ControlOrMeta+a");
await expect(session.fileTreeSelectedNodes()).toHaveCount(visibleRowCount);
});
test("escape clears selection", async ({ testPage, apiClient, seedData, backend }) => {
const git = new GitHelper(
path.join(backend.tmpDir, "repos", "e2e-repo"),
@@ -85,6 +85,49 @@ test.describe("File tree create file", () => {
.toBe(true);
});
test("select-all stays in the new-file name input", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
// @covers AC-UI-FILE-TREE-KEYBOARD-SCOPE-001.1
// @covers AC-UI-FILE-TREE-KEYBOARD-SCOPE-001.2
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
const git = new GitHelper(repoDir, makeGitEnv(backend.tmpDir));
git.createFile("select-all-alpha.ts", "alpha");
git.createFile("select-all-beta.ts", "beta");
git.stageAll();
git.commit("seed select-all files");
const session = await setupTask(
testPage,
apiClient,
seedData,
"ft-create-select-all",
"FT Create Select All",
);
await expect(session.fileTreeNode("select-all-alpha.ts")).toBeVisible({ timeout: 15_000 });
const input = await startCreateAtRoot(testPage);
const draftName = "draft-name.ts";
await input.fill(draftName);
await input.press("ControlOrMeta+a");
await expect
.poll(async () => ({
selection: await input.evaluate((element) => ({
start: element.selectionStart,
end: element.selectionEnd,
})),
selectedRows: await session.fileTreeSelectedNodes().count(),
}))
.toEqual({
selection: { start: 0, end: draftName.length },
selectedRows: 0,
});
});
test("New file inside expanded folder creates the file in that folder", async ({
testPage,
apiClient,
@@ -66,6 +66,49 @@ async function setupMobileFileViewerTest({
return { session, filePath };
}
test.describe("Mobile file tree keyboard shortcuts", () => {
test("select-all stays in the new-file name input", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
// @covers AC-UI-FILE-TREE-KEYBOARD-SCOPE-001.1
// @covers AC-UI-FILE-TREE-KEYBOARD-SCOPE-001.2
const { session, filePath } = await setupMobileFileViewerTest({
testPage,
apiClient,
seedData,
backend,
taskTitle: "Mobile File Create Select All",
});
await testPage.getByRole("button", { name: "Files" }).tap();
await expect(session.fileTreeNode(filePath)).toBeVisible({ timeout: 15_000 });
await testPage.getByRole("button", { name: "New file" }).tap();
const input = testPage.getByPlaceholder("filename...");
await expect(input).toBeFocused({ timeout: 5_000 });
const draftName = "mobile-draft.ts";
await input.fill(draftName);
await input.press("ControlOrMeta+a");
await expect
.poll(async () => ({
selection: await input.evaluate((element) => ({
start: element.selectionStart,
end: element.selectionEnd,
})),
selectedRows: await session.fileTreeSelectedNodes().count(),
}))
.toEqual({
selection: { start: 0, end: draftName.length },
selectedRows: 0,
});
});
});
test.describe("Mobile file viewer panel", () => {
test.describe.configure({ retries: 1 });
@@ -0,0 +1,105 @@
---
created: 2026-09-01
status: complete
requirements:
- REQ-UI-FILE-TREE-KEYBOARD-SCOPE-001
system_design:
- ../../specs/ui/system-design/file-tree-keyboard-scope.md
legacy_specs: []
---
# Implementation Plan: File Tree Select-All Input Scope
## Overview
Keep Command+A and Control+A inside focused Files-panel text controls without
removing the file tree's existing visible-row select-all shortcut. One
sequential frontend slice adds a focused event-target guard and proves desktop
and hardware-keyboard mobile behavior.
## Confirmed root cause
`useKeyboardShortcuts` in `apps/web/components/task/file-browser.tsx` attaches a
keydown listener to the full file-browser container. When a nested filename
input emits lowercase Command+A or Control+A, the event bubbles to that
listener. The listener checks only that focus is somewhere inside the
container, then calls `preventDefault()` and `useMultiSelect.selectAll()`.
A temporary production-build Playwright repro observed the filename selection
remaining at `13..13`, all three visible tree rows becoming selected, and the
lowercase `a` event reaching the document with `defaultPrevented: true`.
## Scope
### In scope
- Defer the file-tree select-all shortcut when its originating target is an
editable Files-panel control.
- Preserve native select-all behavior in new-file, rename, and search inputs.
- Preserve visible-row select-all when the non-editable tree surface owns
focus.
- Prove shared desktop and hardware-keyboard mobile behavior.
### Out of scope
- File creation, rename, search, multi-selection, drag/drop, or Escape behavior
beyond select-all event ownership.
- New shortcut preferences or changes to key combinations.
- Files-panel markup, layout, touch actions, scrolling, or copy.
- Backend, API, persistence, or localization changes.
## Technical approach
- Import `isEditableKeydownTarget` from
`apps/web/lib/keyboard/utils.ts` into
`apps/web/components/task/file-browser.tsx`.
- In `useKeyboardShortcuts`, gate only the Command+A / Control+A branch on the
event target being non-editable. Keep focus containment, Escape behavior,
`preventDefault`, and `useMultiSelect.selectAll` otherwise unchanged.
- Reuse the established editable-target boundary rather than adding a local
element-type helper or changing nested input handlers.
## Tests
- `AC-UI-FILE-TREE-KEYBOARD-SCOPE-001.1` and `.2`: extend
`apps/web/e2e/tests/task/file-tree-create.spec.ts` with the exact reported
flow. Type a draft filename, press lowercase `ControlOrMeta+a`, await the next
paint, then assert the full input selection range and zero selected tree
rows.
- `AC-UI-FILE-TREE-KEYBOARD-SCOPE-001.3`: extend
`apps/web/e2e/tests/file-tree-multi-select.spec.ts` to focus the non-editable
file-browser container, press lowercase `ControlOrMeta+a`, and assert every
visible row is selected.
## E2E tests
- Desktop Chromium: the new-file regression fails before the correction for
both observable symptoms; the non-editable tree case protects existing
multi-selection behavior.
- Mobile Chromium: extend
`apps/web/e2e/tests/task/mobile-file-viewer.spec.ts` to enter the existing
Files surface, focus the new-file input, and prove a hardware-keyboard
Control+A remains inside the input. Touch and visual behavior are unchanged,
so no new mobile composition or screenshot contract is needed.
## Work orders
- [x] [Task 01: Scope file-tree select-all to focus](task-01-scope-file-tree-select-all.md)
## Verification results
Task 01 completed. The targeted desktop input, desktop tree, and mobile input
Playwright checks each passed against the rebuilt production bundle. Frontend
typecheck also passed. The work-order results retain the exact commands and RED
evidence.
## Risks
- An overbroad early return could disable Escape clearing or the tree's own
select-all behavior. Gate only the select-all branch and cover both sides.
- Playwright key spelling is case-sensitive for `event.key`. Use lowercase
`ControlOrMeta+a` so the regression exercises the production branch.
## Open questions
None.
@@ -0,0 +1,99 @@
---
id: "01-scope-file-tree-select-all"
title: "Scope file-tree select-all to focus"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-UI-FILE-TREE-KEYBOARD-SCOPE-001
acceptance_criteria:
- AC-UI-FILE-TREE-KEYBOARD-SCOPE-001.1
- AC-UI-FILE-TREE-KEYBOARD-SCOPE-001.2
- AC-UI-FILE-TREE-KEYBOARD-SCOPE-001.3
system_design:
- ../../specs/ui/system-design/file-tree-keyboard-scope.md
---
# Task 01: Scope File-Tree Select-All to Focus
## Summary
Make the file-browser container defer Command+A and Control+A to focused text
controls while retaining visible-row selection for the non-editable tree. Add
desktop and mobile Playwright regressions before changing production code.
## In scope
- Add the editable-target guard to the file browser's select-all shortcut.
- Cover new-file input ownership on desktop and hardware-keyboard mobile.
- Cover retained tree-level select-all with non-editable focus.
## Out of scope
- Other file-browser keyboard behavior or shortcut configuration.
- Input markup, Files-panel layout, touch interactions, or backend behavior.
- New localization keys or public documentation.
## Acceptance
- A focused new-file control selects its complete value without selecting any
file-tree row on desktop and mobile hardware-keyboard paths.
- Other editable Files controls inherit the same guard through the shared
target classifier without per-input handlers.
- A focused non-editable tree container still selects every visible row.
## Verification
```bash
cd apps/web && pnpm e2e:run tests/task/file-tree-create.spec.ts -- --grep "select-all"
cd apps/web && pnpm e2e:run --no-build tests/file-tree-multi-select.spec.ts -- --grep "select-all"
cd apps/web && pnpm e2e:run --no-build --project mobile-chrome tests/task/mobile-file-viewer.spec.ts -- --grep "select-all"
cd apps/web && pnpm run typecheck
```
## Files likely touched
- `apps/web/components/task/file-browser.tsx`
- `apps/web/e2e/tests/task/file-tree-create.spec.ts`
- `apps/web/e2e/tests/file-tree-multi-select.spec.ts`
- `apps/web/e2e/tests/task/mobile-file-viewer.spec.ts`
## Dependencies
None.
## Risks
- Guarding the whole keydown listener would change Escape behavior; restrict
the guard to the select-all branch.
- Uppercase Playwright shortcut spelling would miss the lowercase production
comparison and yield a false green test.
## Parallelism
`sequential`
## Inputs
- [File Tree Keyboard Scope Requirements](../../specs/ui/requirements/file-tree-keyboard-scope.md)
- [File Tree Keyboard Scope System Design](../../specs/ui/system-design/file-tree-keyboard-scope.md)
- Confirmed source path in `FileBrowser.useKeyboardShortcuts` and established
editable-target behavior in `useAppShortcuts`.
## Results
Done.
- Reused `isEditableKeydownTarget` to defer only the file tree's select-all
branch when an editable descendant originated the event. Escape and
non-editable tree shortcuts remain unchanged.
- Added desktop and mobile new-file regressions plus a desktop preservation
test for visible-row select-all.
- RED evidence: before the fix, the desktop draft stayed selected at `13..13`
while three rows became selected; the mobile draft stayed at `15..15`.
- Verification passed:
- `cd apps/web && pnpm e2e:run tests/task/file-tree-create.spec.ts -- --grep "select-all"`
- `cd apps/web && pnpm e2e:run --no-build tests/file-tree-multi-select.spec.ts -- --grep "select-all"`
- `cd apps/web && pnpm e2e:run --no-build --project mobile-chrome tests/task/mobile-file-viewer.spec.ts -- --grep "select-all"`
- `cd apps/web && pnpm run typecheck`
+7 -13
View File
@@ -12,12 +12,9 @@ owners:
## Ownership
UI owns reusable presentation and responsive interaction.
## Exclusions
[Tasks](../tasks/README.md), [Agents](../agents/README.md),
[Plugins](../plugins/README.md), and [Integrations](../integrations/README.md)
own feature behavior/state.
[Plugins](../plugins/README.md), and [Integrations](../integrations/README.md) own
feature behavior and state.
## Specification map
@@ -58,6 +55,7 @@ own feature behavior/state.
- [Executor settings card spacing](requirements/executor-settings-card-spacing.md)
- [External VCS File Links](requirements/external-vcs-file-links.md)
- [File Tree Chat Context](requirements/file-tree-chat-context.md)
- [File Tree Keyboard Scope](requirements/file-tree-keyboard-scope.md)
- [Reload Kandev after frozen-tab restore](requirements/fix-duplicated-tab-stale-data.md)
- [GitHub PR Review Actions](requirements/github-pr-review-actions.md)
- [GitHub Saved-Query Default Views](requirements/github-saved-query-defaults.md)
@@ -157,6 +155,7 @@ own feature behavior/state.
- [Task PR Automation Controls System Design Part 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)
- [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)
@@ -180,13 +179,8 @@ own feature behavior/state.
- [Task Transcript History Visibility](system-design/task-prompt-transcript-visibility.md)
- [Transcript Auto-scroll Stability](system-design/transcript-auto-scroll.md)
## Migration record
Legacy source detail is still moving to the canonical requirement and
system-design documents above.
## Related systems
- [Tasks](../tasks/README.md): task state.
- [Plugins](../plugins/README.md): contributions.
- [Platform](../platform/README.md): runtime state.
- [Tasks](../tasks/README.md)
- [Plugins](../plugins/README.md)
- [Platform](../platform/README.md)
@@ -0,0 +1,54 @@
---
status: draft
system: ui
created: 2026-09-01
owners:
- kandev
---
# File Tree Keyboard Scope Requirements
## Overview
The Files panel combines tree-level multi-selection shortcuts with nested text
controls for creating, renaming, and searching files. The UI system owns which
focused surface receives a keyboard shortcut; task and filesystem systems keep
ownership of file data and mutations.
## Terminology
- **Platform select-all shortcut:** Command+A on macOS and Control+A on other
supported desktop platforms.
- **Editable Files control:** A text input, textarea, or contenteditable surface
rendered inside the Files panel.
## Requirements
### REQ-UI-FILE-TREE-KEYBOARD-SCOPE-001: Focus-owned select-all behavior
**Intent:** Keep text-editing shortcuts inside the focused editor while
preserving file-tree multi-selection when the tree itself owns keyboard focus.
**User story:** As a Kandev user, I want the platform select-all shortcut to
select the filename I am editing, so that replacing it does not also select
files in the tree.
#### Acceptance criteria
- **AC-UI-FILE-TREE-KEYBOARD-SCOPE-001.1:** When the new-file name control has
focus, invoking the platform select-all shortcut shall select the control's
complete text value and shall not change file-tree row selection.
- **AC-UI-FILE-TREE-KEYBOARD-SCOPE-001.2:** When any other editable Files
control has focus, including inline rename and file search, invoking the
platform select-all shortcut shall remain owned by that control and shall not
invoke file-tree multi-selection.
- **AC-UI-FILE-TREE-KEYBOARD-SCOPE-001.3:** When keyboard focus belongs to the
non-editable file-tree surface, invoking the platform select-all shortcut
shall continue to select every currently visible file-tree row.
## Out of scope
- Changing which file-tree rows are visible or eligible for multi-selection.
- Adding, removing, or making the file-tree shortcut configurable.
- Changing file creation, rename, search, or Escape-key behavior.
- Changing Files-panel layout, touch controls, navigation, or filesystem APIs.
@@ -0,0 +1,82 @@
---
status: draft
system: ui
requirements:
- REQ-UI-FILE-TREE-KEYBOARD-SCOPE-001
---
# File Tree Keyboard Scope System Design
## Purpose and boundaries
This design owns keyboard-event routing inside the shared `FileBrowser`. It
keeps native text editing authoritative while an editable descendant has focus
and preserves the existing visible-row selection model everywhere else. It
does not change file data, filesystem operations, task state, or shortcut
configuration.
## Requirement mapping
| Requirement | Design section |
| --- | --- |
| `REQ-UI-FILE-TREE-KEYBOARD-SCOPE-001` | [Shortcut ownership](#shortcut-ownership), [Responsive surfaces](#responsive-surfaces) |
## Components and responsibilities
`FileBrowser` in `apps/web/components/task/file-browser.tsx` owns the container
keydown listener through `useKeyboardShortcuts`. `useMultiSelect` continues to
own selected paths and the visible-row `selectAll` operation. Nested controls
such as `InlineFileInput`, `TreeNodeName`, and the file-search input continue to
own their text values and local editing behavior.
The existing `isEditableKeydownTarget` helper in
`apps/web/lib/keyboard/utils.ts` remains the shared classification boundary for
input, textarea, and contenteditable key targets. The file browser reuses that
boundary instead of defining another editable-element detector.
## Shortcut ownership
The container listener first keeps its existing check that keyboard focus is
inside the mounted file browser. Escape handling remains unchanged.
For Command+A or Control+A, the listener examines the originating keydown
target before preventing the browser default:
1. If the target is editable, the file browser returns without calling
`preventDefault` or `useMultiSelect.selectAll`. Native browser selection and
the focused control retain the event.
2. If the target is not editable, the file browser prevents native document
selection and calls `useMultiSelect.selectAll` for the current visible paths.
No new React state, event bus, store field, or browser persistence is added.
## Responsive surfaces
Desktop and mobile task layouts mount the same `FileBrowser`, so the ownership
check also applies when a hardware keyboard is used with the phone Files
surface. The nearest mobile baseline remains
`apps/web/e2e/tests/task/mobile-file-viewer.spec.ts`, which enters Files through
the existing bottom navigation. This change adds no mobile composition,
scrolling, safe-area, touch-target, or gesture behavior.
## Failure and compatibility
An unrecognized non-editable descendant follows the existing tree shortcut
path. An editable descendant follows the same established guard used by global
application and plugin shortcut dispatchers. File-tree Escape behavior, row
selection state, drag/drop, create, rename, and search flows remain unchanged.
## Verification
Desktop Playwright coverage shall reproduce the new-file flow with lowercase
`ControlOrMeta+a`, because Playwright's uppercase key spelling emits
`event.key === "A"` and bypasses the current lowercase shortcut branch. The
test shall assert both the input selection range and zero selected tree rows
after the next browser paint. A second desktop case shall prove that the same
shortcut still selects all visible rows when the non-editable tree container
has focus.
The existing mobile file-viewer spec shall exercise the filename input with a
hardware-keyboard Control+A event and prove the same focus ownership. No visual
snapshot is required because markup, geometry, and rendered styling do not
change.