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.
This commit is contained in:
Mohamed Boudra
2026-06-16 16:13:03 +07:00
parent e77c8e2bf3
commit 88b48c9d09
17 changed files with 106 additions and 39 deletions
+2
View File
@@ -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 = (
<View style={layoutStyles.surfaceFill}>
+12 -38
View File
@@ -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}
/>
<SidebarHeaderRow
icon={Clock}
@@ -819,10 +826,7 @@ function MobileSidebar({
variant="compact"
/>
</View>
<WorkspacesSectionHeader
serverId={activeServerId}
onNewWorkspacePress={handleNewWorkspace}
/>
<WorkspacesSectionHeader serverId={activeServerId} />
<Pressable
style={styles.mobileCloseButton}
onPress={closeSidebar}
@@ -904,6 +908,7 @@ function DesktopSidebar({
handleRefresh,
handleHostSelect,
renderHostOption,
newWorkspaceKeys,
handleNewWorkspaceNavigate,
handleOpenProject,
handleHome,
@@ -990,6 +995,7 @@ function DesktopSidebar({
onPress={handleNewWorkspaceNavigate}
testID="sidebar-global-new-workspace"
variant="compact"
shortcutKeys={newWorkspaceKeys}
/>
<SidebarHeaderRow
icon={Clock}
@@ -1001,10 +1007,7 @@ function DesktopSidebar({
/>
</View>
</View>
<WorkspacesSectionHeader
serverId={activeServerId}
onNewWorkspacePress={handleNewWorkspaceNavigate}
/>
<WorkspacesSectionHeader serverId={activeServerId} />
{isInitialLoad ? (
<SidebarAgentListSkeleton />
@@ -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({
<View style={styles.workspacesSectionHeader}>
<Text style={styles.workspacesSectionTitle}>Workspaces</Text>
<View style={styles.workspacesSectionActions}>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel="New workspace"
testID="sidebar-new-workspace"
style={searchButtonStyle}
onPress={onNewWorkspacePress}
>
{({ hovered, pressed }) => (
<Plus
size={14}
color={
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted
}
/>
)}
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<HeaderIconTooltipContent label="New workspace" />
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
@@ -5,6 +5,8 @@ import type { LucideIcon } from "lucide-react-native";
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout";
import { ICON_SIZE } from "@/styles/theme";
import type { Theme } from "@/styles/theme";
import { Shortcut } from "@/components/ui/shortcut";
import type { ShortcutKey } from "@/utils/format-shortcut";
const foregroundColorMapping = (theme: Theme) => ({ 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}
/>
<SidebarHeaderRowLabel label={label} isHighlighted={isHighlighted} />
{shortcutKeys && Boolean(state.hovered) ? (
<Shortcut chord={shortcutKeys} style={styles.shortcut} />
) : 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",
},
}));
@@ -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,
});
}
+1
View File
@@ -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.",
);
+1
View File
@@ -1528,6 +1528,7 @@ export const ar: TranslationResources = {
},
help: {
openProject: "مشروع مفتوح",
newWorkspace: "مساحة عمل جديدة",
newWorktree: "شجرة عمل جديدة",
archiveWorktree: "أرشفة شجرة العمل",
newTab: "علامة تبويب جديدة",
+1
View File
@@ -1533,6 +1533,7 @@ export const en = {
},
help: {
openProject: "Open project",
newWorkspace: "New workspace",
newWorktree: "New worktree",
archiveWorktree: "Archive worktree",
newTab: "New tab",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -1557,6 +1557,7 @@ export const ru: TranslationResources = {
},
help: {
openProject: "Открыть проект",
newWorkspace: "Новое рабочее пространство",
newWorktree: "Новое рабочее дерево",
archiveWorktree: "Архив рабочего дерева",
newTab: "Новая вкладка",
+1
View File
@@ -1509,6 +1509,7 @@ export const zhCN: TranslationResources = {
},
help: {
openProject: "打开项目",
newWorkspace: "新建 workspace",
newWorktree: "新建 worktree",
archiveWorktree: "归档 worktree",
newTab: "新建标签",
+1
View File
@@ -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"
@@ -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 };
@@ -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"],
@@ -120,6 +120,7 @@ const SHORTCUT_HELP_SECTION_LABEL_KEYS: Record<ShortcutSectionId, string> = {
const SHORTCUT_HELP_LABEL_KEYS: Record<string, string> = {
"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",
@@ -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" }],
@@ -45,6 +45,7 @@ const NONE: ShortcutAction = { kind: "none" };
const PASSTHROUGH_DISPATCH: Record<string, KeyboardActionDefinition> = {
"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" },