diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 9a7787b15d1b3..8da4465e608b3 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -25,8 +25,12 @@ import { useState, } from "react"; import { flushSync } from "react-dom"; +import { useAtomValue } from "@effect/atom-react"; import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; +import { shortcutLabelForCommand } from "../keybindings"; +import { primaryServerKeybindingsAtom } from "../state/server"; +import { Kbd } from "./ui/kbd"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { useProject, useServerConfigs, useThreadShellsForProjectRefs } from "../state/entities"; import { @@ -93,6 +97,7 @@ import { observeResize } from "~/lib/observeResize"; export interface BranchToolbarHandle { openBranchPicker: () => void; + toggleWorktreeMode: () => void; usePreviousWorktree: () => void; } @@ -216,8 +221,11 @@ const WorkspaceMenu = memo(function WorkspaceMenu({ onUsePreviousWorktree, }: WorkspaceMenuProps) { const composerFloatingLayerProps = useComposerMenuProps(); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const branchShortcut = branch ? shortcutLabelForCommand(keybindings, "composer.branch") : null; const [workspaceOpen, setWorkspaceOpen] = useState(false); const popupRef = useRef(null); + const openedWithoutPointerRef = useRef(false); const open = branch?.open ?? workspaceOpen; const setOpen = branch?.onOpenChange ?? setWorkspaceOpen; const inWorktree = forceNewWorktree || effectiveEnvMode === "worktree" || activeWorktreePath; @@ -253,12 +261,17 @@ const WorkspaceMenu = memo(function WorkspaceMenu({ return ( { + // A shortcut or Enter on the trigger clicks with no pointer (detail 0): typing filters refs. + openedWithoutPointerRef.current = + nextOpen && details.event instanceof MouseEvent && details.event.detail === 0; + setOpen(nextOpen); + }} onOpenChangeComplete={(nextOpen) => { if (nextOpen) { popupRef.current ?.querySelector( - branch?.focusSearch + branch?.focusSearch || (branch && openedWithoutPointerRef.current) ? 'input[role="combobox"]' : '[data-composer-workspace] [aria-checked="true"]', ) @@ -305,6 +318,7 @@ const WorkspaceMenu = memo(function WorkspaceMenu({ {branch ? {branch.label} : null} {workspaceLabel} + {branchShortcut ? {branchShortcut} : null} @@ -407,12 +421,16 @@ const MachineMenu = memo(function MachineMenu({ }: MachineMenuProps) { const composerFloatingLayerProps = useComposerMenuProps(); const connectedEnvironmentIds = useConnectedEnvironmentIds(); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); const popupRef = useRef(null); const activeEnvironment = availableEnvironments?.find( (env) => env.environmentId === environmentId, ); const environmentLabel = autoEnvironmentLabel ?? activeEnvironment?.label ?? environmentId; const canPickEnvironment = (showEnvironmentPicker && !envLocked) || addTargets.length > 0; + const hostShortcut = canPickEnvironment + ? shortcutLabelForCommand(keybindings, "composer.host") + : null; const icon = autoEnvironmentLabel ? ( ) : ( @@ -423,8 +441,13 @@ const MachineMenu = memo(function MachineMenu({ ); const tooltip = ( - {environmentLabel} - {environmentUnavailable ? " (offline)" : ""} + + + {environmentLabel} + {environmentUnavailable ? " (offline)" : ""} + + {hostShortcut ? {hostShortcut} : null} + ); @@ -1212,6 +1235,11 @@ export const BranchToolbar = memo(function BranchToolbar({ ref, () => ({ openBranchPicker: () => branchSelectorRef.current?.open(), + // Same gates as the Workspace menu's radios: a started worktree or a forced one is fixed. + toggleWorktreeMode: () => { + if (!showGitControls || envModeLocked || forceNewWorktree) return; + onEnvModeChange(effectiveEnvMode === "worktree" ? "local" : "worktree"); + }, usePreviousWorktree: () => { if (!showGitControls || !canUsePreviousWorktree || !previousWorktreeSeed) return; onUsePreviousWorktree(); @@ -1220,6 +1248,10 @@ export const BranchToolbar = memo(function BranchToolbar({ }), [ canUsePreviousWorktree, + effectiveEnvMode, + envModeLocked, + forceNewWorktree, + onEnvModeChange, onComposerFocusRequest, onUsePreviousWorktree, previousWorktreeSeed, diff --git a/apps/web/src/components/chat/composerShortcuts.ts b/apps/web/src/components/chat/composerShortcuts.ts index 30beb9771968f..da0095a80af45 100644 --- a/apps/web/src/components/chat/composerShortcuts.ts +++ b/apps/web/src/components/chat/composerShortcuts.ts @@ -31,6 +31,9 @@ export function composerShortcutCommands( "composer.branch": ({ event }) => { if (!event.repeat) branchToolbarRef.current?.openBranchPicker(); }, + "composer.toggleWorktree": ({ event }) => { + if (!event.repeat) branchToolbarRef.current?.toggleWorktreeMode(); + }, "composer.previousWorktree": ({ event }) => { if (!event.repeat) branchToolbarRef.current?.usePreviousWorktree(); }, diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.ts index 458b8368265c7..91abb822a6967 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.ts @@ -391,6 +391,7 @@ const COMMAND_LABELS: Partial> = { "composer.effort": "Choose effort", "composer.mode": "Choose access mode", "composer.workspace": "Choose workspace", + "composer.toggleWorktree": "Toggle new worktree", "composer.previousWorktree": "Use previous worktree", "composer.branch": "Choose branch", "chat.new": "New thread", diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 5864424b8acad..2d4eadade25bc 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -839,6 +839,34 @@ describe("composer and pull request shortcuts", () => { "terminal.splitVertical", ); }); + it.each(["MacIntel", "Win32", "Linux"])( + "opens the machine and branch pickers and toggles the worktree outside the terminal on %s", + (platform) => { + const mod = { metaKey: platform === "MacIntel", ctrlKey: platform !== "MacIntel" }; + for (const [input, command] of [ + [{ key: "h", shiftKey: true }, "composer.host"], + [{ key: "g", shiftKey: true }, "composer.branch"], + [{ key: "x", shiftKey: true }, "composer.workspace"], + [{ key: "x", code: "KeyX", altKey: true }, "composer.toggleWorktree"], + ] as const) { + const shortcut = event({ ...mod, ...input }); + assert.strictEqual( + resolveShortcutCommand(shortcut, DEFAULT_RESOLVED_KEYBINDINGS, { + platform, + context: { terminalFocus: false }, + }), + command, + ); + assert.isNull( + resolveShortcutCommand(shortcut, DEFAULT_RESOLVED_KEYBINDINGS, { + platform, + context: { terminalFocus: true }, + }), + ); + } + }, + ); + it("fills missing number shortcuts without replacing the saved URL binding", () => { const olderServerBindings = DEFAULT_RESOLVED_KEYBINDINGS.filter( (binding) => diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index bba651c2133d8..a77a0ee29535e 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -33,7 +33,9 @@ To step a new thread to the next machine instead of opening the menu, bind Use `mod+shift+e` for effort, `mod+shift+a` for permissions, `mod+shift+x` for the workspace, and `mod+shift+g` for the Git branch. The workspace menu includes the current checkout, a new worktree, and the previous worktree when available. -Use `mod+shift+l` to reuse the previous worktree directly. +Use `mod+alt+x` to switch a new thread between the current checkout and a new +worktree without opening the menu, and `mod+shift+l` to reuse the previous worktree +directly. To change effort one step without opening the picker, bind **Composer: Increase Effort** and **Composer: Decrease Effort** in Keybindings. diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 8a7584ce6b017..3e0cc19b76370 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -134,6 +134,7 @@ export const STATIC_KEYBINDING_COMMANDS = [ "composer.decreaseEffort", "composer.mode", "composer.workspace", + "composer.toggleWorktree", "composer.previousWorktree", "composer.branch", "chat.new", diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index f5407921f6073..f7bb91730ee81 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -177,6 +177,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+shift+e", command: "composer.effort", when: "!terminalFocus" }, { key: "mod+shift+a", command: "composer.mode", when: "!terminalFocus" }, { key: "mod+shift+x", command: "composer.workspace", when: "!terminalFocus" }, + { key: "mod+alt+x", command: "composer.toggleWorktree", when: "!terminalFocus" }, { key: "mod+shift+g", command: "composer.branch", when: "!terminalFocus" }, // A selected assistant passage takes the chord from the worktree switch. {