From 88b48c9d090e06ab66d434a7154a1d77ce007737 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 16 Jun 2026 16:13:03 +0700 Subject: [PATCH] Add keyboard shortcut to create a new workspace Cmd+N (Ctrl+N off mac) navigates to the new-workspace route through a global handler keyed on the active host. The sidebar header row surfaces the shortcut hint on hover, replacing the redundant "+" button that duplicated the action in the Workspaces section header. --- packages/app/src/app/_layout.tsx | 2 + packages/app/src/components/left-sidebar.tsx | 50 +++++-------------- .../components/sidebar/sidebar-header-row.tsx | 12 ++++- .../hooks/use-global-new-workspace-action.ts | 29 +++++++++++ packages/app/src/i18n/resources.test.ts | 1 + packages/app/src/i18n/resources/ar.ts | 1 + packages/app/src/i18n/resources/en.ts | 1 + packages/app/src/i18n/resources/es.ts | 1 + packages/app/src/i18n/resources/fr.ts | 1 + packages/app/src/i18n/resources/ru.ts | 1 + packages/app/src/i18n/resources/zh-CN.ts | 1 + packages/app/src/keyboard/actions.ts | 1 + .../keyboard/keyboard-action-dispatcher.ts | 2 + .../src/keyboard/keyboard-shortcuts.test.ts | 13 +++++ .../app/src/keyboard/keyboard-shortcuts.ts | 27 ++++++++++ .../app/src/keyboard/route-shortcut.test.ts | 1 + packages/app/src/keyboard/route-shortcut.ts | 1 + 17 files changed, 106 insertions(+), 39 deletions(-) create mode 100644 packages/app/src/hooks/use-global-new-workspace-action.ts diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index e3757c205..d361015e7 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -63,6 +63,7 @@ import { loadDesktopSettings } from "@/desktop/settings/desktop-settings"; import { RosettaCalloutSource } from "@/desktop/updates/rosetta-callout-source"; import { UpdateCalloutSource } from "@/desktop/updates/update-callout-source"; import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action"; +import { useGlobalNewWorkspaceAction } from "@/hooks/use-global-new-workspace-action"; import { useFaviconStatus } from "@/hooks/use-favicon-status"; import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-zoom-lock"; @@ -461,6 +462,7 @@ function AppContainer({ }); useActiveWorktreeNewAction(); + useGlobalNewWorkspaceAction(); const content = ( diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index cc88a88ac..b6fe074e7 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -69,6 +69,7 @@ import { buildSettingsRoute, mapPathnameToServer, } from "@/utils/host-routes"; +import type { ShortcutKey } from "@/utils/format-shortcut"; import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton"; import { SidebarCalloutSlot } from "./sidebar-callout-slot"; import { SidebarWorkspaceList } from "./sidebar-workspace-list"; @@ -112,6 +113,7 @@ interface SidebarSharedProps { active: boolean; onPress: () => void; }) => ReactElement; + newWorkspaceKeys: ShortcutKey[][] | null; } interface SidebarLabels { @@ -285,6 +287,8 @@ export const LeftSidebar = memo(function LeftSidebar({ [pathname], ); + const newWorkspaceKeys = useShortcutKeys("new-workspace"); + const labels = useMemo( (): SidebarLabels => ({ addProject: t("sidebar.actions.addProject"), @@ -320,6 +324,7 @@ export const LeftSidebar = memo(function LeftSidebar({ handleHostSelect, renderHostOption, labels, + newWorkspaceKeys, }; if (isCompactLayout) { @@ -598,6 +603,7 @@ function MobileSidebar({ handleRefresh, handleHostSelect, renderHostOption, + newWorkspaceKeys, handleNewWorkspaceNavigate, handleOpenProject, handleHome, @@ -809,6 +815,7 @@ function MobileSidebar({ onPress={handleNewWorkspace} testID="sidebar-global-new-workspace" variant="compact" + shortcutKeys={newWorkspaceKeys} /> - + - + {isInitialLoad ? ( @@ -1050,13 +1053,7 @@ function DesktopSidebar({ ); } -function WorkspacesSectionHeader({ - serverId, - onNewWorkspacePress, -}: { - serverId: string | null; - onNewWorkspacePress: () => void; -}) { +function WorkspacesSectionHeader({ serverId }: { serverId: string | null }) { const { theme } = useUnistyles(); const setCommandCenterOpen = useKeyboardShortcutsStore((state) => state.setCommandCenterOpen); const commandCenterKeys = useShortcutKeys("toggle-command-center"); @@ -1073,29 +1070,6 @@ function WorkspacesSectionHeader({ Workspaces - - - - {({ hovered, pressed }) => ( - - )} - - - - - - ({ color: theme.colors.foreground }); const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); @@ -26,6 +28,7 @@ interface SidebarHeaderRowProps { * sit in a header group whose wrapper owns the single divider. */ variant?: SidebarHeaderRowVariant; + shortcutKeys?: ShortcutKey[][] | null; } export function SidebarHeaderRow({ @@ -37,6 +40,7 @@ export function SidebarHeaderRow({ nativeID, accessibilityLabel, variant = "header", + shortcutKeys = null, }: SidebarHeaderRowProps) { const ThemedIcon = useMemo(() => withUnistyles(Icon), [Icon]); @@ -63,10 +67,13 @@ export function SidebarHeaderRow({ uniProps={isHighlighted ? foregroundColorMapping : foregroundMutedColorMapping} /> + {shortcutKeys && Boolean(state.hovered) ? ( + + ) : null} ); }, - [ThemedIcon, isActive, label], + [ThemedIcon, isActive, label, shortcutKeys], ); return ( @@ -139,4 +146,7 @@ const styles = StyleSheet.create((theme) => ({ labelHighlighted: { color: theme.colors.foreground, }, + shortcut: { + marginLeft: "auto", + }, })); diff --git a/packages/app/src/hooks/use-global-new-workspace-action.ts b/packages/app/src/hooks/use-global-new-workspace-action.ts new file mode 100644 index 000000000..c3500975c --- /dev/null +++ b/packages/app/src/hooks/use-global-new-workspace-action.ts @@ -0,0 +1,29 @@ +import { useCallback } from "react"; +import { router } from "expo-router"; +import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; +import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher"; +import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; +import { buildHostNewWorkspaceRoute } from "@/utils/host-routes"; + +const WORKSPACE_NEW_ACTIONS: readonly KeyboardActionId[] = ["workspace.new"]; + +export function useGlobalNewWorkspaceAction() { + const selection = useActiveWorkspaceSelection(); + const serverId = selection?.serverId ?? null; + + const handle = useCallback(() => { + if (!serverId) { + return false; + } + router.navigate(buildHostNewWorkspaceRoute(serverId) as never); + return true; + }, [serverId]); + + useKeyboardActionHandler({ + handlerId: "workspace-new-global", + actions: WORKSPACE_NEW_ACTIONS, + enabled: serverId !== null, + priority: 0, + handle, + }); +} diff --git a/packages/app/src/i18n/resources.test.ts b/packages/app/src/i18n/resources.test.ts index e07732cdc..8ee055362 100644 --- a/packages/app/src/i18n/resources.test.ts +++ b/packages/app/src/i18n/resources.test.ts @@ -299,6 +299,7 @@ describe("translation resources", () => { expect(en.settings.shortcuts.dialogTitle).toBe("Shortcuts"); expect(en.settings.shortcuts.sections.tabsPanes).toBe("Tabs & Panes"); expect(en.settings.shortcuts.help.toggleCommandCenter).toBe("Toggle command center"); + expect(en.settings.shortcuts.help.newWorkspace).toBe("New workspace"); expect(en.settings.shortcuts.helpNotes.showKeyboardShortcuts).toBe( "Available when focus is not in a text field or terminal.", ); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index d317596d3..9e0a58043 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1528,6 +1528,7 @@ export const ar: TranslationResources = { }, help: { openProject: "مشروع مفتوح", + newWorkspace: "مساحة عمل جديدة", newWorktree: "شجرة عمل جديدة", archiveWorktree: "أرشفة شجرة العمل", newTab: "علامة تبويب جديدة", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 4ea3bf0ff..0282b2425 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1533,6 +1533,7 @@ export const en = { }, help: { openProject: "Open project", + newWorkspace: "New workspace", newWorktree: "New worktree", archiveWorktree: "Archive worktree", newTab: "New tab", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 0a9864ddf..f4978c73c 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1565,6 +1565,7 @@ export const es: TranslationResources = { }, help: { openProject: "Abrir proyecto", + newWorkspace: "Nuevo espacio de trabajo", newWorktree: "Nuevo árbol de trabajo", archiveWorktree: "Árbol de trabajo de archivo", newTab: "Nueva pestaña", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 9990a6835..ff8c3383c 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1569,6 +1569,7 @@ export const fr: TranslationResources = { }, help: { openProject: "Projet ouvert", + newWorkspace: "Nouvel espace de travail", newWorktree: "Nouvel arbre de travail", archiveWorktree: "Arbre de travail d'archivage", newTab: "Nouvel onglet", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index ebf2319fd..fcba2642d 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1557,6 +1557,7 @@ export const ru: TranslationResources = { }, help: { openProject: "Открыть проект", + newWorkspace: "Новое рабочее пространство", newWorktree: "Новое рабочее дерево", archiveWorktree: "Архив рабочего дерева", newTab: "Новая вкладка", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index d9dab7b5c..98be203d5 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1509,6 +1509,7 @@ export const zhCN: TranslationResources = { }, help: { openProject: "打开项目", + newWorkspace: "新建 workspace", newWorktree: "新建 worktree", archiveWorktree: "归档 worktree", newTab: "新建标签", diff --git a/packages/app/src/keyboard/actions.ts b/packages/app/src/keyboard/actions.ts index 27024310c..f17e24797 100644 --- a/packages/app/src/keyboard/actions.ts +++ b/packages/app/src/keyboard/actions.ts @@ -41,6 +41,7 @@ export type KeyboardActionId = | "command-center.toggle" | "shortcuts.dialog.toggle" | "workspace.terminal.new" + | "workspace.new" | "worktree.new" | "worktree.archive" | "view.toggle.focus" diff --git a/packages/app/src/keyboard/keyboard-action-dispatcher.ts b/packages/app/src/keyboard/keyboard-action-dispatcher.ts index 0adc733e4..4ae487d99 100644 --- a/packages/app/src/keyboard/keyboard-action-dispatcher.ts +++ b/packages/app/src/keyboard/keyboard-action-dispatcher.ts @@ -26,6 +26,7 @@ export type KeyboardActionId = | "workspace.pane.close" | "workspace.terminal.new" | "sidebar.toggle.right" + | "workspace.new" | "worktree.new" | "worktree.archive"; @@ -55,6 +56,7 @@ export type KeyboardActionDefinition = | { id: "workspace.pane.close"; scope: KeyboardActionScope } | { id: "workspace.terminal.new"; scope: KeyboardActionScope } | { id: "sidebar.toggle.right"; scope: KeyboardActionScope } + | { id: "workspace.new"; scope: KeyboardActionScope } | { id: "worktree.new"; scope: KeyboardActionScope } | { id: "worktree.archive"; scope: KeyboardActionScope }; diff --git a/packages/app/src/keyboard/keyboard-shortcuts.test.ts b/packages/app/src/keyboard/keyboard-shortcuts.test.ts index de2bef18c..a797aea9b 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.test.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.test.ts @@ -128,6 +128,18 @@ describe("keyboard-shortcuts", () => { context: { isMac: true }, action: "agent.new", }, + { + name: "matches Cmd+N to create new workspace on mac", + event: { key: "n", code: "KeyN", metaKey: true }, + context: { isMac: true, commandCenterOpen: false }, + action: "workspace.new", + }, + { + name: "matches Ctrl+N to create new workspace on non-mac", + event: { key: "n", code: "KeyN", ctrlKey: true }, + context: { isMac: false, commandCenterOpen: false, focusScope: "other" }, + action: "workspace.new", + }, { name: "matches question-mark shortcut to toggle the shortcuts dialog", event: { key: "?", code: "Slash", shiftKey: true }, @@ -564,6 +576,7 @@ describe("keyboard-shortcut help sections", () => { context: { isMac: true, isDesktop: true }, expectedKeys: { "new-agent": ["mod", "shift", "O"], + "new-workspace": ["mod", "N"], "workspace-tab-new": ["mod", "T"], "workspace-jump-index": ["mod", "1-9"], "workspace-tab-jump-index": ["mod", "alt", "1-9"], diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts index 1ac31e8e9..d3fbbd088 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.ts @@ -120,6 +120,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", "new-worktree": "settings.shortcuts.help.newWorktree", "archive-worktree": "settings.shortcuts.help.archiveWorktree", "workspace-tab-new": "settings.shortcuts.help.newTab", @@ -190,6 +191,32 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ }, }, + // --- New workspace --- + { + id: "workspace-new-cmd-n-mac", + action: "workspace.new", + combo: "Cmd+N", + when: { mac: true, commandCenter: false }, + help: { + id: "new-workspace", + section: "projects", + label: "New workspace", + keys: ["mod", "N"], + }, + }, + { + id: "workspace-new-ctrl-n-non-mac", + action: "workspace.new", + combo: "Ctrl+N", + when: { mac: false, commandCenter: false, terminal: false }, + help: { + id: "new-workspace", + section: "projects", + label: "New workspace", + keys: ["mod", "N"], + }, + }, + // --- New worktree --- { id: "worktree-new-cmd-o-mac", diff --git a/packages/app/src/keyboard/route-shortcut.test.ts b/packages/app/src/keyboard/route-shortcut.test.ts index 9e40fd52d..ea569c264 100644 --- a/packages/app/src/keyboard/route-shortcut.test.ts +++ b/packages/app/src/keyboard/route-shortcut.test.ts @@ -29,6 +29,7 @@ describe("routeKeyboardShortcut — dispatch passthroughs", () => { it.each([ ["agent.interrupt", { id: "agent.interrupt", scope: "global" }], ["workspace.tab.new", { id: "workspace.tab.new", scope: "workspace" }], + ["workspace.new", { id: "workspace.new", scope: "sidebar" }], ["worktree.archive", { id: "worktree.archive", scope: "sidebar" }], ["worktree.new", { id: "worktree.new", scope: "sidebar" }], ["workspace.terminal.new", { id: "workspace.terminal.new", scope: "workspace" }], diff --git a/packages/app/src/keyboard/route-shortcut.ts b/packages/app/src/keyboard/route-shortcut.ts index fa1699f0b..03873a45b 100644 --- a/packages/app/src/keyboard/route-shortcut.ts +++ b/packages/app/src/keyboard/route-shortcut.ts @@ -45,6 +45,7 @@ const NONE: ShortcutAction = { kind: "none" }; 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" }, "worktree.archive": { id: "worktree.archive", scope: "sidebar" }, "worktree.new": { id: "worktree.new", scope: "sidebar" }, "workspace.terminal.new": { id: "workspace.terminal.new", scope: "workspace" },