diff --git a/packages/app/e2e/helpers/new-workspace.ts b/packages/app/e2e/helpers/new-workspace.ts index 9e330541b..20bbd2c81 100644 --- a/packages/app/e2e/helpers/new-workspace.ts +++ b/packages/app/e2e/helpers/new-workspace.ts @@ -177,6 +177,14 @@ export async function openGlobalNewWorkspaceComposer(page: Page): Promise }); } +export async function openNewWorkspaceProjectPickerWithShortcut(page: Page): Promise { + await page.keyboard.press("Control+P"); + + const searchInput = page.getByPlaceholder("Search projects"); + await expect(searchInput).toBeVisible({ timeout: 30_000 }); + await expect(searchInput).toBeFocused(); +} + export async function expectNewWorkspaceProjectSelected( page: Page, projectDisplayName: string, diff --git a/packages/app/e2e/new-workspace-entry.spec.ts b/packages/app/e2e/new-workspace-entry.spec.ts index 90a7e4623..b2ad1c884 100644 --- a/packages/app/e2e/new-workspace-entry.spec.ts +++ b/packages/app/e2e/new-workspace-entry.spec.ts @@ -5,6 +5,7 @@ import { expectNewWorkspaceProjectSelected, openGlobalNewWorkspaceComposer, openNewWorkspaceComposer, + openNewWorkspaceProjectPickerWithShortcut, } from "./helpers/new-workspace"; import { getE2EDaemonPort } from "./helpers/daemon-port"; import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client"; @@ -106,6 +107,20 @@ test.describe("New workspace entry points", () => { } }); + test("Ctrl+P opens the project picker with search focused", async ({ page }) => { + const seeded: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-shortcut-" }); + + try { + await gotoAppShell(page); + await waitForSidebarHydration(page); + await openGlobalNewWorkspaceComposer(page); + + await openNewWorkspaceProjectPickerWithShortcut(page); + } finally { + await seeded.cleanup(); + } + }); + test("keeps the in-progress form when the remembered workspace is archived elsewhere", async ({ page, }) => { diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index ee1ecbaba..4acb2fb36 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1831,6 +1831,7 @@ export const ar: TranslationResources = { sendMessage: "أرسل رسالة", queueMessage: "رسالة قائمة الانتظار", muteUnmuteVoiceMode: "كتم وضع الصوت /unmute", + switchProject: "تبديل المشروع", }, helpNotes: { showKeyboardShortcuts: "متاح عندما لا يكون التركيز في حقل نص أو محطة طرفية.", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 63f6a4f7e..8f3d54de9 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1841,6 +1841,7 @@ export const en = { sendMessage: "Send message", queueMessage: "Queue message", muteUnmuteVoiceMode: "Mute/unmute voice mode", + switchProject: "Switch project", }, helpNotes: { showKeyboardShortcuts: "Available when focus is not in a text field or terminal.", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index fc4d6ea60..def00ef9a 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1879,6 +1879,7 @@ export const es: TranslationResources = { sendMessage: "enviar mensaje", queueMessage: "mensaje de cola", muteUnmuteVoiceMode: "Silenciar el modo de voz/unmute", + switchProject: "Cambiar proyecto", }, helpNotes: { showKeyboardShortcuts: "Disponible cuando el foco no está en un campo de texto o terminal.", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 7a69a1996..5cf4578d1 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1881,6 +1881,7 @@ export const fr: TranslationResources = { sendMessage: "Envoyer un message", queueMessage: "Message de file d'attente", muteUnmuteVoiceMode: "Mode vocal/unmutemuet", + switchProject: "Changer de projet", }, helpNotes: { showKeyboardShortcuts: diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 888d8864b..9b81dd8a9 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1847,6 +1847,7 @@ export const ja: TranslationResources = { sendMessage: "メッセージを送信", queueMessage: "メッセージをキューに追加", muteUnmuteVoiceMode: "音声モードのミュートを切り替え", + switchProject: "プロジェクトを切り替え", }, helpNotes: { showKeyboardShortcuts: diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index c1d6827e5..1027e88dc 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1862,6 +1862,7 @@ export const ptBR: TranslationResources = { sendMessage: "Enviar mensagem", queueMessage: "Enfileirar mensagem", muteUnmuteVoiceMode: "Silenciar/ativar modo de voz", + switchProject: "Trocar projeto", }, helpNotes: { showKeyboardShortcuts: diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index a46d91034..b8ec048de 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1869,6 +1869,7 @@ export const ru: TranslationResources = { sendMessage: "Отправить сообщение", queueMessage: "Сообщение в очереди", muteUnmuteVoiceMode: "Отключить голосовой режим /unmute", + switchProject: "Сменить проект", }, helpNotes: { showKeyboardShortcuts: "Доступно, когда фокус находится не в текстовом поле или терминале.", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 63f4b9730..8f5ee66d8 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1810,6 +1810,7 @@ export const zhCN: TranslationResources = { sendMessage: "发送消息", queueMessage: "消息排队", muteUnmuteVoiceMode: "静音/取消静音语音模式", + switchProject: "切换项目", }, helpNotes: { showKeyboardShortcuts: "焦点不在文本输入框或终端内时可用。", diff --git a/packages/app/src/keyboard/actions.ts b/packages/app/src/keyboard/actions.ts index 7f776cd0a..813085257 100644 --- a/packages/app/src/keyboard/actions.ts +++ b/packages/app/src/keyboard/actions.ts @@ -44,6 +44,7 @@ export type KeyboardActionId = | "shortcuts.dialog.toggle" | "workspace.terminal.new" | "workspace.new" + | "workspace.project.pick" | "worktree.new" | "workspace.archive" | "workspace.pin" diff --git a/packages/app/src/keyboard/keyboard-action-dispatcher.ts b/packages/app/src/keyboard/keyboard-action-dispatcher.ts index 0e2b35efd..a5d1abe0d 100644 --- a/packages/app/src/keyboard/keyboard-action-dispatcher.ts +++ b/packages/app/src/keyboard/keyboard-action-dispatcher.ts @@ -29,6 +29,7 @@ export type KeyboardActionId = | "workspace.terminal.new" | "sidebar.toggle.right" | "workspace.new" + | "workspace.project.pick" | "worktree.new" | "workspace.archive" | "workspace.pin"; @@ -62,6 +63,7 @@ export type KeyboardActionDefinition = | { id: "workspace.terminal.new"; scope: KeyboardActionScope } | { id: "sidebar.toggle.right"; scope: KeyboardActionScope } | { id: "workspace.new"; scope: KeyboardActionScope } + | { id: "workspace.project.pick"; scope: KeyboardActionScope } | { id: "worktree.new"; scope: KeyboardActionScope } | { id: "workspace.archive"; scope: KeyboardActionScope } | { id: "workspace.pin"; scope: KeyboardActionScope }; diff --git a/packages/app/src/keyboard/keyboard-shortcuts.test.ts b/packages/app/src/keyboard/keyboard-shortcuts.test.ts index 496288bb8..e0bd68305 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.test.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.test.ts @@ -142,6 +142,18 @@ describe("keyboard-shortcuts", () => { context: { isMac: false, commandCenterOpen: false, focusScope: "other" }, action: "workspace.new", }, + { + name: "matches Cmd+P to switch project on mac", + event: { key: "p", code: "KeyP", metaKey: true }, + context: { isMac: true, commandCenterOpen: false }, + action: "workspace.project.pick", + }, + { + name: "matches Ctrl+P to switch project on non-mac", + event: { key: "p", code: "KeyP", ctrlKey: true }, + context: { isMac: false, commandCenterOpen: false, focusScope: "other" }, + action: "workspace.project.pick", + }, { name: "matches question-mark shortcut to toggle the shortcuts dialog", event: { key: "?", code: "Slash", shiftKey: true }, @@ -410,6 +422,16 @@ describe("keyboard-shortcuts", () => { event: { key: "?", code: "Slash", shiftKey: true }, context: { focusScope: "message-input" }, }, + { + name: "does not switch project with Ctrl+P on non-mac while terminal is focused", + event: { key: "p", code: "KeyP", ctrlKey: true }, + context: { isMac: false, focusScope: "terminal" }, + }, + { + name: "does not switch project with Cmd+P while the command center is open", + event: { key: "p", code: "KeyP", metaKey: true }, + context: { isMac: true, commandCenterOpen: true }, + }, { name: "does not close tab with Ctrl+W on mac desktop (Cmd+W only)", event: { key: "w", code: "KeyW", ctrlKey: true }, diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts index 60d0d2f77..2ab7f5234 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.ts @@ -131,6 +131,7 @@ const SHORTCUT_HELP_SECTION_LABEL_KEYS: Record = { const SHORTCUT_HELP_LABEL_KEYS: Record = { "new-agent": "settings.shortcuts.help.openProject", "new-workspace": "settings.shortcuts.help.newWorkspace", + "switch-project": "settings.shortcuts.help.switchProject", "archive-workspace": "settings.shortcuts.help.archiveWorkspace", "workspace-tab-new": "settings.shortcuts.help.newTab", "workspace-tab-close-current": "settings.shortcuts.help.closeCurrentTab", @@ -231,6 +232,32 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ }, }, + // --- Switch project (New Workspace screen) --- + { + id: "workspace-project-pick-cmd-p-mac", + action: "workspace.project.pick", + combo: "Cmd+P", + when: { mac: true, commandCenter: false }, + help: { + id: "switch-project", + section: "projects", + label: "Switch project", + keys: ["mod", "P"], + }, + }, + { + id: "workspace-project-pick-ctrl-p-non-mac", + action: "workspace.project.pick", + combo: "Ctrl+P", + when: { mac: false, commandCenter: false, terminal: false }, + help: { + id: "switch-project", + section: "projects", + label: "Switch project", + keys: ["mod", "P"], + }, + }, + // --- Archive workspace --- { // COMPAT(workspaceArchiveShortcutOverride): added in v0.1.106; remove after diff --git a/packages/app/src/keyboard/route-shortcut.test.ts b/packages/app/src/keyboard/route-shortcut.test.ts index a65d428ed..5ace809b9 100644 --- a/packages/app/src/keyboard/route-shortcut.test.ts +++ b/packages/app/src/keyboard/route-shortcut.test.ts @@ -30,6 +30,7 @@ describe("routeKeyboardShortcut — dispatch passthroughs", () => { ["agent.interrupt", { id: "agent.interrupt", scope: "global" }], ["workspace.tab.new", { id: "workspace.tab.new", scope: "workspace" }], ["workspace.new", { id: "workspace.new", scope: "sidebar" }], + ["workspace.project.pick", { id: "workspace.project.pick", scope: "workspace" }], ["workspace.archive", { id: "workspace.archive", scope: "sidebar" }], ["workspace.pin", { id: "workspace.pin", scope: "sidebar" }], ["worktree.new", { id: "worktree.new", scope: "sidebar" }], diff --git a/packages/app/src/keyboard/route-shortcut.ts b/packages/app/src/keyboard/route-shortcut.ts index 4f79ba2e3..71b4395cc 100644 --- a/packages/app/src/keyboard/route-shortcut.ts +++ b/packages/app/src/keyboard/route-shortcut.ts @@ -42,6 +42,7 @@ const PASSTHROUGH_DISPATCH: Record = { "agent.interrupt": { id: "agent.interrupt", scope: "global" }, "workspace.tab.new": { id: "workspace.tab.new", scope: "workspace" }, "workspace.new": { id: "workspace.new", scope: "sidebar" }, + "workspace.project.pick": { id: "workspace.project.pick", scope: "workspace" }, "workspace.archive": { id: "workspace.archive", scope: "sidebar" }, "workspace.pin": { id: "workspace.pin", scope: "sidebar" }, "worktree.new": { id: "worktree.new", scope: "sidebar" }, diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index d846cafd0..11875ccaf 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -55,6 +55,8 @@ import { type PendingWorkspaceDraftSetup, } from "@/stores/workspace-draft-submission-store"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; +import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; +import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher"; import { useFormPreferences } from "@/hooks/use-form-preferences"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { getForgePresentation } from "@/git/forge"; @@ -181,6 +183,8 @@ interface PickerOptionData { const BRANCH_OPTION_PREFIX = "branch:"; const PR_OPTION_PREFIX = "github-pr:"; const PROJECT_ICON_FALLBACK_FONT_SIZE = 10; +// Stable reference so the keyboard-action handler doesn't re-register each render. +const PROJECT_PICK_ACTIONS: readonly KeyboardActionId[] = ["workspace.project.pick"]; // Height of a single picker-trigger badge. The Base-row spacer reserves exactly // this so toggling Isolation to Local hides the row without shifting the form. const BADGE_HEIGHT = 28; @@ -1788,6 +1792,22 @@ export function NewWorkspaceScreen({ setProjectPickerOpen(true); }, []); + // Cmd/Ctrl+P opens the project picker with its search focused so the user can + // switch projects from the keyboard. Registered only while this screen is + // mounted, so the shortcut doesn't swallow the browser's native print + // elsewhere; gated on having projects to pick. + const handleProjectPick = useCallback(() => { + openProjectPicker(); + return true; + }, [openProjectPicker]); + useKeyboardActionHandler({ + handlerId: "new-workspace-project-pick", + actions: PROJECT_PICK_ACTIONS, + enabled: projectPickerOptions.length > 0, + priority: 0, + handle: handleProjectPick, + }); + const openIsolationPicker = useCallback(() => { setIsolationPickerOpen(true); }, []);