From e2d58c13e79fb542c364d1c7ae995c21d2a55860 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 22 Jul 2026 02:11:25 -0700 Subject: [PATCH 1/4] feat(web): add Tab completion to path browser --- .../components/CommandPalette.logic.test.ts | 44 +++++++++++++++++++ .../src/components/CommandPalette.logic.ts | 26 +++++++++++ apps/web/src/components/CommandPalette.tsx | 23 ++++++++++ 3 files changed, 93 insertions(+) diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 902b7e87773..2a5f2d13c4f 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -5,6 +5,7 @@ import { buildThreadActionItems, enumerateCommandPaletteItems, filterCommandPaletteGroups, + resolveBrowseTabCompletion, type CommandPaletteGroup, } from "./CommandPalette.logic"; @@ -38,6 +39,49 @@ describe("enumerateCommandPaletteItems", () => { const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); const PROJECT_ID = ProjectId.make("project-1"); +describe("resolveBrowseTabCompletion", () => { + const entries = [ + { name: "alpha", fullPath: "/workspace/alpha" }, + { name: "alpine", fullPath: "/workspace/alpine" }, + ]; + + it("uses the highlighted directory", () => { + expect( + resolveBrowseTabCompletion({ + filteredEntries: entries, + highlightedItemValue: "browse:/workspace/alpine", + }), + ).toEqual({ kind: "entry", entry: entries[1] }); + }); + + it("uses the first directory when none is highlighted", () => { + expect( + resolveBrowseTabCompletion({ + filteredEntries: entries, + highlightedItemValue: null, + }), + ).toEqual({ kind: "entry", entry: entries[0] }); + }); + + it("preserves the highlighted parent-directory action", () => { + expect( + resolveBrowseTabCompletion({ + filteredEntries: entries, + highlightedItemValue: "browse:up", + }), + ).toEqual({ kind: "up" }); + }); + + it("returns null when there are no matching directories", () => { + expect( + resolveBrowseTabCompletion({ + filteredEntries: [], + highlightedItemValue: null, + }), + ).toBeNull(); + }); +}); + function makeThread(overrides: Partial = {}): Thread { return { id: ThreadId.make("thread-1"), diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index a217d53b5b7..e09ae15e47f 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -102,6 +102,32 @@ export function filterBrowseEntries(input: { return { filteredEntries, highlightedEntry, exactEntry }; } +export type BrowseTabCompletion = + | { readonly kind: "up" } + | { readonly kind: "entry"; readonly entry: FilesystemBrowseEntry }; + +export function resolveBrowseTabCompletion(input: { + filteredEntries: ReadonlyArray; + highlightedItemValue: string | null; +}): BrowseTabCompletion | null { + if (input.highlightedItemValue === "browse:up") { + return { kind: "up" }; + } + + if (input.highlightedItemValue?.startsWith("browse:")) { + const highlightedPath = input.highlightedItemValue.slice("browse:".length); + const highlightedEntry = input.filteredEntries.find( + (entry) => entry.fullPath === highlightedPath, + ); + if (highlightedEntry) { + return { kind: "entry", entry: highlightedEntry }; + } + } + + const firstEntry = input.filteredEntries[0]; + return firstEntry ? { kind: "entry", entry: firstEntry } : null; +} + export function normalizeSearchText(value: string): string { return value.trim().toLowerCase().replace(/\s+/g, " "); } diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index bb83dfa5691..b17e7462fcf 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -105,6 +105,7 @@ import { getCommandPaletteMode, ITEM_ICON_CLASS, RECENT_THREAD_LIMIT, + resolveBrowseTabCompletion, } from "./CommandPalette.logic"; import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; import { CommandPaletteResults } from "./CommandPaletteResults"; @@ -1569,6 +1570,28 @@ function OpenCommandPaletteDialog(props: { } } + const isPlainTab = + event.key === "Tab" && !event.shiftKey && !event.altKey && !event.ctrlKey && !event.metaKey; + + if (isBrowsing && isPlainTab) { + event.preventDefault(); + + if (relativePathNeedsActiveProject || isBrowsePending) { + return; + } + + const completion = resolveBrowseTabCompletion({ + filteredEntries: filteredBrowseEntries, + highlightedItemValue, + }); + if (completion?.kind === "up") { + browseUp(); + } else if (completion?.kind === "entry") { + browseTo(completion.entry.name); + } + return; + } + if (addProjectCloneFlow?.step === "repository" && event.key === "Enter") { event.preventDefault(); void submitAddProjectCloneFlow(); From aa57195feb55113c140b0f43603ba0cd2df09e4d Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 22 Jul 2026 02:53:42 -0700 Subject: [PATCH 2/4] fix(web): allow Tab completion during browse refresh --- apps/web/src/components/CommandPalette.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index b17e7462fcf..1e496c22dcf 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1576,7 +1576,7 @@ function OpenCommandPaletteDialog(props: { if (isBrowsing && isPlainTab) { event.preventDefault(); - if (relativePathNeedsActiveProject || isBrowsePending) { + if (relativePathNeedsActiveProject || (isBrowsePending && browseResult === null)) { return; } From 37f0c50468d8e0d20aab8a861cfdba3cecd8b20a Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 22 Jul 2026 09:39:02 -0700 Subject: [PATCH 3/4] Prefer exact path match for Tab completion --- .../components/CommandPalette.logic.test.ts | 34 +++++++++++++++++++ .../src/components/CommandPalette.logic.ts | 5 +++ apps/web/src/components/CommandPalette.tsx | 1 + 3 files changed, 40 insertions(+) diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 2a5f2d13c4f..e775f548534 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -48,6 +48,7 @@ describe("resolveBrowseTabCompletion", () => { it("uses the highlighted directory", () => { expect( resolveBrowseTabCompletion({ + exactEntry: null, filteredEntries: entries, highlightedItemValue: "browse:/workspace/alpine", }), @@ -57,6 +58,7 @@ describe("resolveBrowseTabCompletion", () => { it("uses the first directory when none is highlighted", () => { expect( resolveBrowseTabCompletion({ + exactEntry: null, filteredEntries: entries, highlightedItemValue: null, }), @@ -66,6 +68,7 @@ describe("resolveBrowseTabCompletion", () => { it("preserves the highlighted parent-directory action", () => { expect( resolveBrowseTabCompletion({ + exactEntry: null, filteredEntries: entries, highlightedItemValue: "browse:up", }), @@ -75,11 +78,42 @@ describe("resolveBrowseTabCompletion", () => { it("returns null when there are no matching directories", () => { expect( resolveBrowseTabCompletion({ + exactEntry: null, filteredEntries: [], highlightedItemValue: null, }), ).toBeNull(); }); + + it("uses the case-sensitive exact entry before the first prefix match", () => { + const caseVariants = [ + { name: "Docs", fullPath: "/workspace/Docs" }, + { name: "docs", fullPath: "/workspace/docs" }, + ]; + + expect( + resolveBrowseTabCompletion({ + exactEntry: caseVariants[1] ?? null, + filteredEntries: caseVariants, + highlightedItemValue: null, + }), + ).toEqual({ kind: "entry", entry: caseVariants[1] }); + }); + + it("keeps a highlighted row ahead of a different exact entry", () => { + const caseVariants = [ + { name: "Docs", fullPath: "/workspace/Docs" }, + { name: "docs", fullPath: "/workspace/docs" }, + ]; + + expect( + resolveBrowseTabCompletion({ + exactEntry: caseVariants[1] ?? null, + filteredEntries: caseVariants, + highlightedItemValue: "browse:/workspace/Docs", + }), + ).toEqual({ kind: "entry", entry: caseVariants[0] }); + }); }); function makeThread(overrides: Partial = {}): Thread { diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index e09ae15e47f..7aefdb9498e 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -107,6 +107,7 @@ export type BrowseTabCompletion = | { readonly kind: "entry"; readonly entry: FilesystemBrowseEntry }; export function resolveBrowseTabCompletion(input: { + exactEntry: FilesystemBrowseEntry | null; filteredEntries: ReadonlyArray; highlightedItemValue: string | null; }): BrowseTabCompletion | null { @@ -124,6 +125,10 @@ export function resolveBrowseTabCompletion(input: { } } + if (input.highlightedItemValue === null && input.exactEntry) { + return { kind: "entry", entry: input.exactEntry }; + } + const firstEntry = input.filteredEntries[0]; return firstEntry ? { kind: "entry", entry: firstEntry } : null; } diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 1e496c22dcf..06c4436f13e 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1581,6 +1581,7 @@ function OpenCommandPaletteDialog(props: { } const completion = resolveBrowseTabCompletion({ + exactEntry: exactBrowseEntry, filteredEntries: filteredBrowseEntries, highlightedItemValue, }); From dc90a36e8bf7c133144970a09c5e074e9fb67128 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 22 Jul 2026 09:45:22 -0700 Subject: [PATCH 4/4] fix(web): prefer exact browse match after stale highlight --- .../src/components/CommandPalette.logic.test.ts | 15 +++++++++++++++ apps/web/src/components/CommandPalette.logic.ts | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index e775f548534..0bca08c3d78 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -114,6 +114,21 @@ describe("resolveBrowseTabCompletion", () => { }), ).toEqual({ kind: "entry", entry: caseVariants[0] }); }); + + it("uses an exact entry when a stored highlight no longer resolves", () => { + const caseVariants = [ + { name: "Docs", fullPath: "/workspace/Docs" }, + { name: "docs", fullPath: "/workspace/docs" }, + ]; + + expect( + resolveBrowseTabCompletion({ + exactEntry: caseVariants[1] ?? null, + filteredEntries: caseVariants, + highlightedItemValue: "browse:/workspace/removed", + }), + ).toEqual({ kind: "entry", entry: caseVariants[1] }); + }); }); function makeThread(overrides: Partial = {}): Thread { diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 7aefdb9498e..1a2e08a5025 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -125,7 +125,7 @@ export function resolveBrowseTabCompletion(input: { } } - if (input.highlightedItemValue === null && input.exactEntry) { + if (input.exactEntry) { return { kind: "entry", entry: input.exactEntry }; }