From 4cfb1fd17d685b757d31851e0d8e7e51a258ce1f Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 22 Jul 2026 02:32:07 -0700 Subject: [PATCH 1/9] Add configurable add-project shortcut --- apps/server/src/keybindings.test.ts | 1 + apps/web/src/components/CommandPalette.tsx | 73 ++++++++++++---------- apps/web/src/keybindings.test.ts | 23 +++++++ packages/contracts/src/keybindings.test.ts | 6 ++ packages/contracts/src/keybindings.ts | 1 + packages/shared/src/keybindings.ts | 1 + 6 files changed, 73 insertions(+), 32 deletions(-) diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 2eef6ac8416..c1d5f1fa157 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -200,6 +200,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m"); assert.equal(defaultsByCommand.get("sidebar.toggle"), "mod+b"); assert.equal(defaultsByCommand.get("rightPanel.toggle"), "mod+alt+b"); + assert.equal(defaultsByCommand.get("project.add"), "mod+alt+a"); assert.equal(defaultsByCommand.get("terminal.splitVertical"), "mod+shift+d"); assert.equal(defaultsByCommand.get("modelPicker.jump.1"), "mod+1"); assert.equal(defaultsByCommand.get("modelPicker.jump.9"), "mod+9"); diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index aa7547c8ba6..b90f35fab91 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -408,16 +408,20 @@ export function CommandPalette({ children }: { children: ReactNode }) { terminalOpen, }, }); - if (command !== "commandPalette.toggle") { + if (command !== "commandPalette.toggle" && command !== "project.add") { return; } event.preventDefault(); event.stopPropagation(); - toggleOpen(); + if (command === "project.add") { + openAddProject(); + } else { + toggleOpen(); + } }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - }, [keybindings, terminalOpen, toggleOpen]); + }, [keybindings, openAddProject, terminalOpen, toggleOpen]); useEffect( () => @@ -1146,6 +1150,35 @@ function OpenCommandPaletteDialog(props: { ]); const actionItems: Array = []; + const addProjectAction: CommandPaletteActionItem = { + kind: "action", + value: "action:add-project", + searchTerms: [ + "add project", + "folder", + "directory", + "browse", + "clone", + "remote", + "repository", + "repo", + "git", + "github", + "gitlab", + "bitbucket", + "azure", + "devops", + "url", + "environment", + ], + title: "Add project", + icon: , + shortcutCommand: "project.add", + keepOpen: true, + run: async () => { + openAddProjectFlow(); + }, + }; if (projects.length > 0) { const activeProjectTitle = @@ -1182,38 +1215,14 @@ function OpenCommandPaletteDialog(props: { title: "New thread in...", icon: , addonIcon: , - groups: [{ value: "projects", label: "Projects", items: projectThreadItems }], + groups: [ + { value: "projects", label: "Projects", items: projectThreadItems }, + { value: "actions", label: "Actions", items: [addProjectAction] }, + ], }); } - actionItems.push({ - kind: "action", - value: "action:add-project", - searchTerms: [ - "add project", - "folder", - "directory", - "browse", - "clone", - "remote", - "repository", - "repo", - "git", - "github", - "gitlab", - "bitbucket", - "azure", - "devops", - "url", - "environment", - ], - title: "Add project", - icon: , - keepOpen: true, - run: async () => { - openAddProjectFlow(); - }, - }); + actionItems.push(addProjectAction); if (wslAddProjectEnvironmentOption) { actionItems.push({ diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index c0d326edd55..7ae59b6b4e1 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -125,6 +125,11 @@ const DEFAULT_BINDINGS = compile([ }, { shortcut: modShortcut("o", { shiftKey: true }), command: "chat.new" }, { shortcut: modShortcut("n", { shiftKey: true }), command: "chat.newLocal" }, + { + shortcut: modShortcut("a", { altKey: true }), + command: "project.add", + whenAst: whenNot(whenIdentifier("terminalFocus")), + }, { shortcut: modShortcut("o"), command: "editor.openFavorite" }, { shortcut: modShortcut("[", { shiftKey: true }), command: "thread.previous" }, { shortcut: modShortcut("]", { shiftKey: true }), command: "thread.next" }, @@ -327,6 +332,7 @@ describe("shortcutLabelForCommand", () => { shortcutLabelForCommand(DEFAULT_BINDINGS, "commandPalette.toggle", "MacIntel"), "⌘K", ); + assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "project.add", "MacIntel"), "⌥⌘A"); assert.strictEqual( shortcutLabelForCommand(DEFAULT_BINDINGS, "modelPicker.toggle", "Linux"), "Ctrl+Shift+M", @@ -514,6 +520,23 @@ describe("chat/editor shortcuts", () => { ); }); + it("matches project.add shortcut outside terminal focus", () => { + assert.strictEqual( + resolveShortcutCommand(event({ key: "a", metaKey: true, altKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: false }, + }), + "project.add", + ); + assert.notStrictEqual( + resolveShortcutCommand(event({ key: "a", metaKey: true, altKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: true }, + }), + "project.add", + ); + }); + it("matches diff.toggle shortcut outside terminal focus", () => { assert.isTrue( isDiffToggleShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, { diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 33ecd38039f..d9e07b82b6a 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -65,6 +65,12 @@ it.effect("parses keybinding rules", () => }); assert.strictEqual(parsedLocal.command, "chat.newLocal"); + const parsedAddProject = yield* decode(KeybindingRule, { + key: "mod+alt+a", + command: "project.add", + }); + assert.strictEqual(parsedAddProject.command, "project.add"); + const parsedModelPickerToggle = yield* decode(KeybindingRule, { key: "mod+shift+m", command: "modelPicker.toggle", diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index d966c1e7e61..bfdb65658e8 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -66,6 +66,7 @@ const STATIC_KEYBINDING_COMMANDS = [ "composer.stash", "chat.new", "chat.newLocal", + "project.add", "editor.openFavorite", ...MODEL_PICKER_KEYBINDING_COMMANDS, ...THREAD_KEYBINDING_COMMANDS, diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 0688cf07254..96bfa3f0323 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -39,6 +39,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, + { key: "mod+alt+a", command: "project.add", when: "!terminalFocus" }, { key: "mod+shift+m", command: "modelPicker.toggle", when: "!terminalFocus" }, { key: "mod+o", command: "editor.openFavorite" }, { key: "mod+shift+[", command: "thread.previous" }, From 8b2b6b6e4d2ad5976e9d1a3760229d7a064c1494 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 22 Jul 2026 02:57:14 -0700 Subject: [PATCH 2/9] Use Alt+A for add-project shortcut --- apps/server/src/keybindings.test.ts | 2 +- apps/web/src/keybindings.test.ts | 15 +++++++++++---- packages/contracts/src/keybindings.test.ts | 2 +- packages/shared/src/keybindings.ts | 2 +- 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index c1d5f1fa157..76285d26cca 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -200,7 +200,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m"); assert.equal(defaultsByCommand.get("sidebar.toggle"), "mod+b"); assert.equal(defaultsByCommand.get("rightPanel.toggle"), "mod+alt+b"); - assert.equal(defaultsByCommand.get("project.add"), "mod+alt+a"); + assert.equal(defaultsByCommand.get("project.add"), "alt+a"); assert.equal(defaultsByCommand.get("terminal.splitVertical"), "mod+shift+d"); assert.equal(defaultsByCommand.get("modelPicker.jump.1"), "mod+1"); assert.equal(defaultsByCommand.get("modelPicker.jump.9"), "mod+9"); diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 7ae59b6b4e1..90a797506c6 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -126,7 +126,14 @@ const DEFAULT_BINDINGS = compile([ { shortcut: modShortcut("o", { shiftKey: true }), command: "chat.new" }, { shortcut: modShortcut("n", { shiftKey: true }), command: "chat.newLocal" }, { - shortcut: modShortcut("a", { altKey: true }), + shortcut: { + key: "a", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: true, + modKey: false, + }, command: "project.add", whenAst: whenNot(whenIdentifier("terminalFocus")), }, @@ -332,7 +339,7 @@ describe("shortcutLabelForCommand", () => { shortcutLabelForCommand(DEFAULT_BINDINGS, "commandPalette.toggle", "MacIntel"), "⌘K", ); - assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "project.add", "MacIntel"), "⌥⌘A"); + assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "project.add", "MacIntel"), "⌥A"); assert.strictEqual( shortcutLabelForCommand(DEFAULT_BINDINGS, "modelPicker.toggle", "Linux"), "Ctrl+Shift+M", @@ -522,14 +529,14 @@ describe("chat/editor shortcuts", () => { it("matches project.add shortcut outside terminal focus", () => { assert.strictEqual( - resolveShortcutCommand(event({ key: "a", metaKey: true, altKey: true }), DEFAULT_BINDINGS, { + resolveShortcutCommand(event({ key: "å", code: "KeyA", altKey: true }), DEFAULT_BINDINGS, { platform: "MacIntel", context: { terminalFocus: false }, }), "project.add", ); assert.notStrictEqual( - resolveShortcutCommand(event({ key: "a", metaKey: true, altKey: true }), DEFAULT_BINDINGS, { + resolveShortcutCommand(event({ key: "å", code: "KeyA", altKey: true }), DEFAULT_BINDINGS, { platform: "MacIntel", context: { terminalFocus: true }, }), diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index d9e07b82b6a..15ddc6d4a65 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -66,7 +66,7 @@ it.effect("parses keybinding rules", () => assert.strictEqual(parsedLocal.command, "chat.newLocal"); const parsedAddProject = yield* decode(KeybindingRule, { - key: "mod+alt+a", + key: "alt+a", command: "project.add", }); assert.strictEqual(parsedAddProject.command, "project.add"); diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 96bfa3f0323..bb37a5a86ba 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -39,7 +39,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, - { key: "mod+alt+a", command: "project.add", when: "!terminalFocus" }, + { key: "alt+a", command: "project.add", when: "!terminalFocus" }, { key: "mod+shift+m", command: "modelPicker.toggle", when: "!terminalFocus" }, { key: "mod+o", command: "editor.openFavorite" }, { key: "mod+shift+[", command: "thread.previous" }, From d5df62a6cac78f39840d28976d530c8c0154684d Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Thu, 23 Jul 2026 22:56:20 -0700 Subject: [PATCH 3/9] fix(web): preserve editable Alt+A input --- .../components/CommandPalette.logic.test.ts | 27 +++++++++++++++++++ .../src/components/CommandPalette.logic.ts | 10 +++++++ apps/web/src/components/CommandPalette.tsx | 14 +++++++++- 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 902b7e87773..98265e53f44 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -5,9 +5,36 @@ import { buildThreadActionItems, enumerateCommandPaletteItems, filterCommandPaletteGroups, + shouldHandleCommandPaletteShortcut, type CommandPaletteGroup, } from "./CommandPalette.logic"; +describe("shouldHandleCommandPaletteShortcut", () => { + it("does not capture the add-project shortcut from an editable target", () => { + expect( + shouldHandleCommandPaletteShortcut({ + command: "project.add", + editableTarget: true, + }), + ).toBe(false); + }); + + it("still handles add-project outside editors and the palette toggle everywhere", () => { + expect( + shouldHandleCommandPaletteShortcut({ + command: "project.add", + editableTarget: false, + }), + ).toBe(true); + expect( + shouldHandleCommandPaletteShortcut({ + command: "commandPalette.toggle", + editableTarget: true, + }), + ).toBe(true); + }); +}); + describe("enumerateCommandPaletteItems", () => { it("assigns positional jump shortcuts to the first nine displayed items", () => { const items = Array.from({ length: 10 }, (_, index) => ({ diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index f69c38e1a0f..5b08337ce7e 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -15,6 +15,16 @@ export const RECENT_THREAD_LIMIT = 12; export const ITEM_ICON_CLASS = "size-4 text-muted-foreground/80"; export const ADDON_ICON_CLASS = "size-4"; +export function shouldHandleCommandPaletteShortcut(input: { + command: KeybindingCommand | null; + editableTarget: boolean; +}): boolean { + if (input.command === "commandPalette.toggle") { + return true; + } + return input.command === "project.add" && !input.editableTarget; +} + export interface CommandPaletteItem { readonly kind: "action" | "submenu"; readonly value: string; diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index b90f35fab91..fbc9bc8cb5c 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -102,6 +102,7 @@ import { getCommandPaletteMode, ITEM_ICON_CLASS, RECENT_THREAD_LIMIT, + shouldHandleCommandPaletteShortcut, } from "./CommandPalette.logic"; import { orderItemsByPreferredIds, sortLogicalProjectsForSidebar } from "./Sidebar.logic"; import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; @@ -408,7 +409,18 @@ export function CommandPalette({ children }: { children: ReactNode }) { terminalOpen, }, }); - if (command !== "commandPalette.toggle" && command !== "project.add") { + const target = + event.target instanceof Element + ? event.target.closest( + 'input, textarea, select, [contenteditable]:not([contenteditable="false"])', + ) + : null; + if ( + !shouldHandleCommandPaletteShortcut({ + command, + editableTarget: target !== null, + }) + ) { return; } event.preventDefault(); From d4ec74200f7b94c096fe1e7071e1653318a1e110 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 22 Jul 2026 03:19:57 -0700 Subject: [PATCH 4/9] fix(web): guard add-project shortcut --- apps/web/src/components/CommandPalette.tsx | 25 +++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index fbc9bc8cb5c..89c4dec3eac 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -377,6 +377,19 @@ function reduceCommandPaletteUiState( } } +function isEditableKeyboardTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) { + return false; + } + return ( + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + target instanceof HTMLSelectElement || + target.isContentEditable || + target.closest('[contenteditable]:not([contenteditable="false"])') !== null + ); +} + export function CommandPalette({ children }: { children: ReactNode }) { const [state, dispatch] = useReducer(reduceCommandPaletteUiState, { open: false, @@ -402,7 +415,7 @@ export function CommandPalette({ children }: { children: ReactNode }) { useEffect(() => { const onKeyDown = (event: globalThis.KeyboardEvent) => { - if (event.defaultPrevented) return; + if (event.defaultPrevented || event.repeat) return; const command = resolveShortcutCommand(event, keybindings, { context: { terminalFocus: isTerminalFocused(), @@ -423,6 +436,9 @@ export function CommandPalette({ children }: { children: ReactNode }) { ) { return; } + if (command === "project.add" && isEditableKeyboardTarget(event.target)) { + return; + } event.preventDefault(); event.stopPropagation(); if (command === "project.add") { @@ -516,6 +532,7 @@ function OpenCommandPaletteDialog(props: { const keybindings = useAtomValue(primaryServerKeybindingsAtom); const providers = useAtomValue(primaryServerProvidersAtom); const [viewStack, setViewStack] = useState([]); + const addProjectFlowOpenRef = useRef(false); const currentView = viewStack.at(-1) ?? null; const [browseGeneration, setBrowseGeneration] = useState(0); const [addProjectEnvironmentId, setAddProjectEnvironmentId] = useState( @@ -906,6 +923,7 @@ function OpenCommandPaletteDialog(props: { setAddProjectCloneFlow(null); if (viewStack.length <= 1) { setAddProjectEnvironmentId(null); + addProjectFlowOpenRef.current = false; } setViewStack((previousViews) => previousViews.slice(0, -1)); setHighlightedItemValue(null); @@ -1089,7 +1107,11 @@ function OpenCommandPaletteDialog(props: { ); const openAddProjectFlow = useCallback(() => { + if (addProjectFlowOpenRef.current) { + return; + } if (addProjectEnvironmentOptions.length > 1) { + addProjectFlowOpenRef.current = true; pushPaletteView({ addonIcon: , groups: addProjectEnvironmentGroups, @@ -1109,6 +1131,7 @@ function OpenCommandPaletteDialog(props: { return; } + addProjectFlowOpenRef.current = true; void startAddProjectSourceSelection(environmentId); }, [ addProjectEnvironmentGroups, From ecaac77154f6e6e0282d5803d63665423548f279 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 22 Jul 2026 03:36:23 -0700 Subject: [PATCH 5/9] fix(web): track all add-project entry paths --- apps/web/src/components/CommandPalette.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 89c4dec3eac..896a2bc84e9 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -940,6 +940,7 @@ function OpenCommandPaletteDialog(props: { const startAddProjectBrowse = useCallback( (environmentId: EnvironmentId): void => { + addProjectFlowOpenRef.current = true; setAddProjectEnvironmentId(environmentId); setAddProjectCloneFlow(null); pushPaletteView({ @@ -953,6 +954,7 @@ function OpenCommandPaletteDialog(props: { const startAddProjectClone = useCallback( (environmentId: EnvironmentId, source: AddProjectRemoteSource): void => { + addProjectFlowOpenRef.current = true; setAddProjectEnvironmentId(environmentId); setAddProjectCloneFlow({ step: "repository", environmentId, source }); pushPaletteView({ @@ -1065,6 +1067,7 @@ function OpenCommandPaletteDialog(props: { const startAddProjectSourceSelection = useCallback( (environmentId: EnvironmentId): void => { + addProjectFlowOpenRef.current = true; setAddProjectEnvironmentId(environmentId); setAddProjectCloneFlow(null); pushPaletteView({ From c0d8dc78956748565e0870b70bcc6a810b1b9b78 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 22 Jul 2026 05:19:09 -0700 Subject: [PATCH 6/9] fix(web): reset nested add-project flow guard --- .../components/CommandPalette.logic.test.ts | 11 +++++ .../src/components/CommandPalette.logic.ts | 7 ++++ apps/web/src/components/CommandPalette.tsx | 40 +++++++++++++------ 3 files changed, 46 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 98265e53f44..9c2343f3df1 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -6,6 +6,7 @@ import { enumerateCommandPaletteItems, filterCommandPaletteGroups, shouldHandleCommandPaletteShortcut, + shouldResetPaletteFlowOnPop, type CommandPaletteGroup, } from "./CommandPalette.logic"; @@ -65,6 +66,16 @@ describe("enumerateCommandPaletteItems", () => { const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); const PROJECT_ID = ProjectId.make("project-1"); +describe("shouldResetPaletteFlowOnPop", () => { + it("resets a flow opened from a nested parent when its first view is popped", () => { + expect(shouldResetPaletteFlowOnPop(1, 2)).toBe(true); + }); + + it("keeps a flow active while popping between its own nested views", () => { + expect(shouldResetPaletteFlowOnPop(1, 3)).toBe(false); + }); +}); + 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 5b08337ce7e..06d0a5b6b06 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -78,6 +78,13 @@ export function enumerateCommandPaletteItems( }); } +export function shouldResetPaletteFlowOnPop( + flowBaseDepth: number | null, + currentDepth: number, +): boolean { + return flowBaseDepth !== null && Math.max(0, currentDepth - 1) <= flowBaseDepth; +} + export type CommandPaletteMode = "root" | "root-browse" | "submenu" | "submenu-browse"; export function filterBrowseEntries(input: { diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 896a2bc84e9..d31295965e7 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -103,6 +103,7 @@ import { ITEM_ICON_CLASS, RECENT_THREAD_LIMIT, shouldHandleCommandPaletteShortcut, + shouldResetPaletteFlowOnPop, } from "./CommandPalette.logic"; import { orderItemsByPreferredIds, sortLogicalProjectsForSidebar } from "./Sidebar.logic"; import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; @@ -532,7 +533,9 @@ function OpenCommandPaletteDialog(props: { const keybindings = useAtomValue(primaryServerKeybindingsAtom); const providers = useAtomValue(primaryServerProvidersAtom); const [viewStack, setViewStack] = useState([]); - const addProjectFlowOpenRef = useRef(false); + const viewStackDepthRef = useRef(0); + viewStackDepthRef.current = viewStack.length; + const addProjectFlowBaseDepthRef = useRef(null); const currentView = viewStack.at(-1) ?? null; const [browseGeneration, setBrowseGeneration] = useState(0); const [addProjectEnvironmentId, setAddProjectEnvironmentId] = useState( @@ -921,9 +924,12 @@ function OpenCommandPaletteDialog(props: { function popView(): void { setAddProjectCloneFlow(null); - if (viewStack.length <= 1) { + if ( + viewStack.length <= 1 || + shouldResetPaletteFlowOnPop(addProjectFlowBaseDepthRef.current, viewStack.length) + ) { setAddProjectEnvironmentId(null); - addProjectFlowOpenRef.current = false; + addProjectFlowBaseDepthRef.current = null; } setViewStack((previousViews) => previousViews.slice(0, -1)); setHighlightedItemValue(null); @@ -938,9 +944,13 @@ function OpenCommandPaletteDialog(props: { } } + const markAddProjectFlowOpen = useCallback((): void => { + addProjectFlowBaseDepthRef.current ??= viewStackDepthRef.current; + }, []); + const startAddProjectBrowse = useCallback( (environmentId: EnvironmentId): void => { - addProjectFlowOpenRef.current = true; + markAddProjectFlowOpen(); setAddProjectEnvironmentId(environmentId); setAddProjectCloneFlow(null); pushPaletteView({ @@ -949,12 +959,12 @@ function OpenCommandPaletteDialog(props: { initialQuery: getAddProjectInitialQueryForEnvironment(environmentId), }); }, - [getAddProjectInitialQueryForEnvironment], + [getAddProjectInitialQueryForEnvironment, markAddProjectFlowOpen], ); const startAddProjectClone = useCallback( (environmentId: EnvironmentId, source: AddProjectRemoteSource): void => { - addProjectFlowOpenRef.current = true; + markAddProjectFlowOpen(); setAddProjectEnvironmentId(environmentId); setAddProjectCloneFlow({ step: "repository", environmentId, source }); pushPaletteView({ @@ -963,7 +973,7 @@ function OpenCommandPaletteDialog(props: { initialQuery: "", }); }, - [], + [markAddProjectFlowOpen], ); const openSourceControlSettings = useCallback(() => { @@ -1067,7 +1077,7 @@ function OpenCommandPaletteDialog(props: { const startAddProjectSourceSelection = useCallback( (environmentId: EnvironmentId): void => { - addProjectFlowOpenRef.current = true; + markAddProjectFlowOpen(); setAddProjectEnvironmentId(environmentId); setAddProjectCloneFlow(null); pushPaletteView({ @@ -1080,7 +1090,12 @@ function OpenCommandPaletteDialog(props: { ), }); }, - [browseEnvironmentId, buildAddProjectSourceGroups, sourceControlDiscovery.data], + [ + browseEnvironmentId, + buildAddProjectSourceGroups, + markAddProjectFlowOpen, + sourceControlDiscovery.data, + ], ); const addProjectEnvironmentItems: CommandPaletteActionItem[] = addProjectEnvironmentOptions.map( @@ -1110,11 +1125,11 @@ function OpenCommandPaletteDialog(props: { ); const openAddProjectFlow = useCallback(() => { - if (addProjectFlowOpenRef.current) { + if (addProjectFlowBaseDepthRef.current !== null) { return; } if (addProjectEnvironmentOptions.length > 1) { - addProjectFlowOpenRef.current = true; + markAddProjectFlowOpen(); pushPaletteView({ addonIcon: , groups: addProjectEnvironmentGroups, @@ -1134,12 +1149,13 @@ function OpenCommandPaletteDialog(props: { return; } - addProjectFlowOpenRef.current = true; + markAddProjectFlowOpen(); void startAddProjectSourceSelection(environmentId); }, [ addProjectEnvironmentGroups, addProjectEnvironmentOptions.length, defaultAddProjectEnvironmentId, + markAddProjectFlowOpen, startAddProjectSourceSelection, ]); From 1d91d7e30ca0ccfbd678a0eb2d46e46ced5c7d2b Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 22 Jul 2026 09:33:11 -0700 Subject: [PATCH 7/9] fix(web): reset palette flow for new thread intent --- .../components/CommandPalette.logic.test.ts | 53 +++++++++ .../src/components/CommandPalette.logic.ts | 20 ++++ apps/web/src/components/CommandPalette.tsx | 102 ++++++++++-------- 3 files changed, 131 insertions(+), 44 deletions(-) diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 9c2343f3df1..4100ef2f65f 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -2,11 +2,14 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; import type { Thread } from "../types"; import { + buildNewThreadInGroups, buildThreadActionItems, enumerateCommandPaletteItems, filterCommandPaletteGroups, + resetAddProjectFlowState, shouldHandleCommandPaletteShortcut, shouldResetPaletteFlowOnPop, + type CommandPaletteActionItem, type CommandPaletteGroup, } from "./CommandPalette.logic"; @@ -76,6 +79,56 @@ describe("shouldResetPaletteFlowOnPop", () => { }); }); +describe("resetAddProjectFlowState", () => { + it("clears every piece of add-project state before opening another palette flow", () => { + const flowBaseDepthRef = { current: 2 as number | null }; + const clearEnvironment = vi.fn(); + const clearCloneFlow = vi.fn(); + + resetAddProjectFlowState({ + flowBaseDepthRef, + clearEnvironment, + clearCloneFlow, + }); + + expect(clearEnvironment).toHaveBeenCalledOnce(); + expect(clearCloneFlow).toHaveBeenCalledOnce(); + expect(flowBaseDepthRef.current).toBeNull(); + }); +}); + +describe("buildNewThreadInGroups", () => { + it("includes Add project alongside projects for every new-thread picker entry point", () => { + const projectItem: CommandPaletteActionItem = { + kind: "action", + value: "new-thread-in:environment-1:project-1", + searchTerms: ["Project"], + title: "Project", + icon: null, + run: async () => undefined, + }; + const addProjectAction: CommandPaletteActionItem = { + kind: "action", + value: "action:add-project", + searchTerms: ["Add project"], + title: "Add project", + icon: null, + shortcutCommand: "project.add", + run: async () => undefined, + }; + + const groups = buildNewThreadInGroups({ + projectItems: [projectItem], + addProjectAction, + }); + + expect(groups.map((group) => group.value)).toEqual(["projects", "actions"]); + expect(groups[0]?.items).toEqual([projectItem]); + expect(groups[1]?.items).toEqual([addProjectAction]); + expect(groups[1]?.items[0]?.shortcutCommand).toBe("project.add"); + }); +}); + 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 06d0a5b6b06..b013104377c 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -85,6 +85,26 @@ export function shouldResetPaletteFlowOnPop( return flowBaseDepth !== null && Math.max(0, currentDepth - 1) <= flowBaseDepth; } +export function resetAddProjectFlowState(input: { + flowBaseDepthRef: { current: number | null }; + clearEnvironment: () => void; + clearCloneFlow: () => void; +}): void { + input.clearCloneFlow(); + input.clearEnvironment(); + input.flowBaseDepthRef.current = null; +} + +export function buildNewThreadInGroups(input: { + projectItems: ReadonlyArray; + addProjectAction: CommandPaletteActionItem; +}): CommandPaletteGroup[] { + return [ + { value: "projects", label: "Projects", items: input.projectItems }, + { value: "actions", label: "Actions", items: [input.addProjectAction] }, + ]; +} + export type CommandPaletteMode = "root" | "root-browse" | "submenu" | "submenu-browse"; export function filterBrowseEntries(input: { diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index d31295965e7..c78f9968cdb 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -89,6 +89,7 @@ import { import { ADDON_ICON_CLASS, buildBrowseGroups, + buildNewThreadInGroups, buildProjectActionItems, buildRootGroups, buildThreadActionItems, @@ -102,6 +103,7 @@ import { getCommandPaletteMode, ITEM_ICON_CLASS, RECENT_THREAD_LIMIT, + resetAddProjectFlowState, shouldHandleCommandPaletteShortcut, shouldResetPaletteFlowOnPop, } from "./CommandPalette.logic"; @@ -901,6 +903,18 @@ function OpenCommandPaletteDialog(props: { ); const recentThreadItems = allThreadItems.slice(0, RECENT_THREAD_LIMIT); + const resetAddProjectFlow = useCallback((): void => { + resetAddProjectFlowState({ + flowBaseDepthRef: addProjectFlowBaseDepthRef, + clearEnvironment: () => { + setAddProjectEnvironmentId(null); + }, + clearCloneFlow: () => { + setAddProjectCloneFlow(null); + }, + }); + }, []); + function pushPaletteView(view: CommandPaletteView): void { setViewStack((previousViews) => [ ...previousViews, @@ -923,13 +937,13 @@ function OpenCommandPaletteDialog(props: { } function popView(): void { - setAddProjectCloneFlow(null); if ( viewStack.length <= 1 || shouldResetPaletteFlowOnPop(addProjectFlowBaseDepthRef.current, viewStack.length) ) { - setAddProjectEnvironmentId(null); - addProjectFlowBaseDepthRef.current = null; + resetAddProjectFlow(); + } else { + setAddProjectCloneFlow(null); } setViewStack((previousViews) => previousViews.slice(0, -1)); setHighlightedItemValue(null); @@ -1159,6 +1173,39 @@ function OpenCommandPaletteDialog(props: { startAddProjectSourceSelection, ]); + const addProjectAction = useMemo( + () => ({ + kind: "action", + value: "action:add-project", + searchTerms: [ + "add project", + "folder", + "directory", + "browse", + "clone", + "remote", + "repository", + "repo", + "git", + "github", + "gitlab", + "bitbucket", + "azure", + "devops", + "url", + "environment", + ], + title: "Add project", + icon: , + shortcutCommand: "project.add", + keepOpen: true, + run: async () => { + openAddProjectFlow(); + }, + }), + [openAddProjectFlow], + ); + useLayoutEffect(() => { if (openIntent?.kind !== "add-project") { return; @@ -1172,7 +1219,7 @@ function OpenCommandPaletteDialog(props: { return; } clearOpenIntent(); - setAddProjectCloneFlow(null); + resetAddProjectFlow(); setViewStack([]); setQuery(""); const currentPrefix = @@ -1187,52 +1234,22 @@ function OpenCommandPaletteDialog(props: { : projectThreadItems; pushPaletteView({ addonIcon: , - groups: [ - { - value: "projects", - label: "Projects", - items: enumerateCommandPaletteItems(prioritized), - }, - ], + groups: buildNewThreadInGroups({ + projectItems: enumerateCommandPaletteItems(prioritized), + addProjectAction, + }), }); }, [ + addProjectAction, clearOpenIntent, currentProjectEnvironmentId, currentProjectId, openIntent, projectThreadItems, + resetAddProjectFlow, ]); const actionItems: Array = []; - const addProjectAction: CommandPaletteActionItem = { - kind: "action", - value: "action:add-project", - searchTerms: [ - "add project", - "folder", - "directory", - "browse", - "clone", - "remote", - "repository", - "repo", - "git", - "github", - "gitlab", - "bitbucket", - "azure", - "devops", - "url", - "environment", - ], - title: "Add project", - icon: , - shortcutCommand: "project.add", - keepOpen: true, - run: async () => { - openAddProjectFlow(); - }, - }; if (projects.length > 0) { const activeProjectTitle = @@ -1269,10 +1286,7 @@ function OpenCommandPaletteDialog(props: { title: "New thread in...", icon: , addonIcon: , - groups: [ - { value: "projects", label: "Projects", items: projectThreadItems }, - { value: "actions", label: "Actions", items: [addProjectAction] }, - ], + groups: buildNewThreadInGroups({ projectItems: projectThreadItems, addProjectAction }), }); } From 2fd81d58780619b8e4b478072a638542050e0122 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 22 Jul 2026 17:40:55 -0700 Subject: [PATCH 8/9] fix(web): allow add-project shortcut in palette --- .../components/CommandPalette.logic.test.ts | 25 +++++++++++++++++++ .../src/components/CommandPalette.logic.ts | 3 ++- apps/web/src/components/CommandPalette.tsx | 19 ++------------ 3 files changed, 29 insertions(+), 18 deletions(-) diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 4100ef2f65f..2ae9ea3ce9f 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -18,6 +18,7 @@ describe("shouldHandleCommandPaletteShortcut", () => { expect( shouldHandleCommandPaletteShortcut({ command: "project.add", + paletteOpen: false, editableTarget: true, }), ).toBe(false); @@ -27,12 +28,14 @@ describe("shouldHandleCommandPaletteShortcut", () => { expect( shouldHandleCommandPaletteShortcut({ command: "project.add", + paletteOpen: false, editableTarget: false, }), ).toBe(true); expect( shouldHandleCommandPaletteShortcut({ command: "commandPalette.toggle", + paletteOpen: false, editableTarget: true, }), ).toBe(true); @@ -69,6 +72,28 @@ describe("enumerateCommandPaletteItems", () => { const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); const PROJECT_ID = ProjectId.make("project-1"); +describe("shouldHandleCommandPaletteShortcut in an open palette", () => { + it("allows Alt+A from the editable search input while the new-task palette is open", () => { + expect( + shouldHandleCommandPaletteShortcut({ + command: "project.add", + paletteOpen: true, + editableTarget: true, + }), + ).toBe(true); + }); + + it("continues to ignore Alt+A from editors outside the palette", () => { + expect( + shouldHandleCommandPaletteShortcut({ + command: "project.add", + paletteOpen: false, + editableTarget: true, + }), + ).toBe(false); + }); +}); + describe("shouldResetPaletteFlowOnPop", () => { it("resets a flow opened from a nested parent when its first view is popped", () => { expect(shouldResetPaletteFlowOnPop(1, 2)).toBe(true); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index b013104377c..8e10d17fc99 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -17,12 +17,13 @@ export const ADDON_ICON_CLASS = "size-4"; export function shouldHandleCommandPaletteShortcut(input: { command: KeybindingCommand | null; + paletteOpen: boolean; editableTarget: boolean; }): boolean { if (input.command === "commandPalette.toggle") { return true; } - return input.command === "project.add" && !input.editableTarget; + return input.command === "project.add" && (!input.editableTarget || input.paletteOpen); } export interface CommandPaletteItem { diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index c78f9968cdb..36d7ac7af73 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -380,19 +380,6 @@ function reduceCommandPaletteUiState( } } -function isEditableKeyboardTarget(target: EventTarget | null): boolean { - if (!(target instanceof HTMLElement)) { - return false; - } - return ( - target instanceof HTMLInputElement || - target instanceof HTMLTextAreaElement || - target instanceof HTMLSelectElement || - target.isContentEditable || - target.closest('[contenteditable]:not([contenteditable="false"])') !== null - ); -} - export function CommandPalette({ children }: { children: ReactNode }) { const [state, dispatch] = useReducer(reduceCommandPaletteUiState, { open: false, @@ -434,14 +421,12 @@ export function CommandPalette({ children }: { children: ReactNode }) { if ( !shouldHandleCommandPaletteShortcut({ command, + paletteOpen: state.open, editableTarget: target !== null, }) ) { return; } - if (command === "project.add" && isEditableKeyboardTarget(event.target)) { - return; - } event.preventDefault(); event.stopPropagation(); if (command === "project.add") { @@ -452,7 +437,7 @@ export function CommandPalette({ children }: { children: ReactNode }) { }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - }, [keybindings, openAddProject, terminalOpen, toggleOpen]); + }, [keybindings, openAddProject, state.open, terminalOpen, toggleOpen]); useEffect( () => From dad869d760054e86debf115ed5ced676f9b56e9b Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 22 Jul 2026 17:50:38 -0700 Subject: [PATCH 9/9] fix(web): read live palette state for shortcut --- apps/web/src/components/CommandPalette.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 36d7ac7af73..9c21a702523 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -74,7 +74,7 @@ import { isUnsupportedWindowsProjectPath, resolveProjectPathForDispatch, } from "../lib/projectPaths"; -import { onOpenCommandPalette } from "../commandPaletteBus"; +import { isCommandPaletteOpen, onOpenCommandPalette } from "../commandPaletteBus"; import { isTerminalFocused } from "../lib/terminalFocus"; import { getLatestThreadForProject, sortThreads } from "../lib/threadSort"; import { cn, isMacPlatform, isWindowsPlatform, newProjectId } from "../lib/utils"; @@ -421,7 +421,7 @@ export function CommandPalette({ children }: { children: ReactNode }) { if ( !shouldHandleCommandPaletteShortcut({ command, - paletteOpen: state.open, + paletteOpen: isCommandPaletteOpen(), editableTarget: target !== null, }) ) { @@ -437,7 +437,7 @@ export function CommandPalette({ children }: { children: ReactNode }) { }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - }, [keybindings, openAddProject, state.open, terminalOpen, toggleOpen]); + }, [keybindings, openAddProject, terminalOpen, toggleOpen]); useEffect( () =>