diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 2eef6ac8416..862bf165c3c 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -203,6 +203,12 @@ it.layer(NodeServices.layer)("keybindings", (it) => { 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"); + assert.equal(defaultsByCommand.get("modelOptionsPicker.toggle"), "mod+shift+e"); + assert.equal(defaultsByCommand.get("runtimeModePicker.toggle"), "mod+shift+a"); + assert.equal(defaultsByCommand.get("planMode.toggle"), "mod+shift+p"); + assert.equal(defaultsByCommand.get("environmentPicker.toggle"), "mod+shift+v"); + assert.equal(defaultsByCommand.get("envModePicker.toggle"), "mod+shift+l"); + assert.equal(defaultsByCommand.get("branchPicker.toggle"), "mod+shift+b"); }), ); diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index cbb9ec82b88..406eaabeab4 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -4,7 +4,12 @@ import { dedupeRemoteBranchesWithLocalMatches, deriveLocalBranchNameFromRemoteRef, resolveEnvironmentOptionLabel, + resolveAvailableBranchToolbarPicker, + resolveBranchToolbarRunContextShortcutTarget, resolveBranchSelectionTarget, + resolveBranchPickerQueryForOpenState, + resolveBranchPickerShortcutOpenState, + resolveBranchToolbarPickerOpenChange, resolveCurrentWorkspaceLabel, resolveDraftEnvModeAfterBranchChange, resolveEffectiveEnvMode, @@ -12,10 +17,171 @@ import { resolveBranchToolbarValue, resolveLockedWorkspaceLabel, resolveLocalCheckoutBranchMismatch, + shouldShowBranchPickerShortcutHint, shouldIncludeBranchPickerItem, shouldShowEnvironmentIndicator, } from "./BranchToolbar.logic"; +describe("resolveBranchToolbarRunContextShortcutTarget", () => { + const available = { + isRendered: true, + environmentPickerAvailable: true, + envLocked: false, + envModeLocked: false, + }; + + it("routes either run-context shortcut to the combined picker on mobile", () => { + expect( + resolveBranchToolbarRunContextShortcutTarget({ + ...available, + control: "environment", + isMobile: true, + }), + ).toBe("mobile-run-context"); + expect( + resolveBranchToolbarRunContextShortcutTarget({ + ...available, + control: "env-mode", + isMobile: true, + }), + ).toBe("mobile-run-context"); + }); + + it("keeps the mobile environment shortcut available when only workspace mode is locked", () => { + expect( + resolveBranchToolbarRunContextShortcutTarget({ + ...available, + control: "environment", + isMobile: true, + envModeLocked: true, + }), + ).toBe("mobile-run-context"); + }); + + it("keeps the mobile workspace shortcut available when only environment is locked", () => { + expect( + resolveBranchToolbarRunContextShortcutTarget({ + ...available, + control: "env-mode", + isMobile: true, + envLocked: true, + }), + ).toBe("mobile-run-context"); + }); + + it("rejects only the shortcut whose specific control is unavailable", () => { + expect( + resolveBranchToolbarRunContextShortcutTarget({ + ...available, + control: "environment", + isMobile: true, + environmentPickerAvailable: false, + }), + ).toBeNull(); + expect( + resolveBranchToolbarRunContextShortcutTarget({ + ...available, + control: "env-mode", + isMobile: true, + envModeLocked: true, + }), + ).toBeNull(); + }); +}); + +describe("resolveBranchToolbarPickerOpenChange", () => { + it("replaces the active picker instead of stacking keyboard-opened overlays", () => { + expect(resolveBranchToolbarPickerOpenChange("environment", "env-mode", true)).toBe("env-mode"); + expect(resolveBranchToolbarPickerOpenChange("env-mode", "branch", true)).toBe("branch"); + }); + + it("ignores a stale close event from a picker that is no longer active", () => { + expect(resolveBranchToolbarPickerOpenChange("branch", "environment", false)).toBe("branch"); + expect(resolveBranchToolbarPickerOpenChange("branch", "branch", false)).toBeNull(); + }); +}); + +describe("resolveAvailableBranchToolbarPicker", () => { + const allAvailable = { + environment: true, + envMode: true, + mobileRunContext: true, + branch: true, + }; + + it("preserves a picker while its interactive control is available", () => { + expect(resolveAvailableBranchToolbarPicker("environment", allAvailable)).toBe("environment"); + expect(resolveAvailableBranchToolbarPicker("mobile-run-context", allAvailable)).toBe( + "mobile-run-context", + ); + }); + + it("clears each picker when its interactive control stops being available", () => { + expect( + resolveAvailableBranchToolbarPicker("environment", { + ...allAvailable, + environment: false, + }), + ).toBeNull(); + expect( + resolveAvailableBranchToolbarPicker("env-mode", { ...allAvailable, envMode: false }), + ).toBeNull(); + expect( + resolveAvailableBranchToolbarPicker("mobile-run-context", { + ...allAvailable, + mobileRunContext: false, + }), + ).toBeNull(); + expect( + resolveAvailableBranchToolbarPicker("branch", { ...allAvailable, branch: false }), + ).toBeNull(); + }); +}); + +describe("shouldShowBranchPickerShortcutHint", () => { + it("only shows a hint while the corresponding shortcut can toggle the picker", () => { + expect( + shouldShowBranchPickerShortcutHint({ + shortcutHintLabel: "⌘B", + isInitialBranchesLoadPending: false, + isBranchActionPending: false, + }), + ).toBe(true); + expect( + shouldShowBranchPickerShortcutHint({ + shortcutHintLabel: "⌘B", + isInitialBranchesLoadPending: true, + isBranchActionPending: false, + }), + ).toBe(false); + expect( + shouldShowBranchPickerShortcutHint({ + shortcutHintLabel: "⌘B", + isInitialBranchesLoadPending: false, + isBranchActionPending: true, + }), + ).toBe(false); + }); +}); + +describe("resolveBranchPickerShortcutOpenState", () => { + it("always permits closing an already-open picker", () => { + expect(resolveBranchPickerShortcutOpenState({ open: true, unavailable: true })).toBe(false); + }); + + it("only permits opening while the picker is available", () => { + expect(resolveBranchPickerShortcutOpenState({ open: false, unavailable: false })).toBe(true); + expect(resolveBranchPickerShortcutOpenState({ open: false, unavailable: true })).toBeNull(); + }); +}); + +describe("resolveBranchPickerQueryForOpenState", () => { + it("clears a search when the controlled picker is closed externally", () => { + expect(resolveBranchPickerQueryForOpenState("feature/search", false)).toBe(""); + expect(resolveBranchPickerQueryForOpenState("feature/search", true)).toBe("feature/search"); + }); +}); + const localEnvironmentId = EnvironmentId.make("environment-local"); const remoteEnvironmentId = EnvironmentId.make("environment-remote"); diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index c083c335292..7edf1da98d0 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -15,6 +15,86 @@ export interface EnvironmentOption { export const EnvMode = Schema.Literals(["local", "worktree"]); export type EnvMode = typeof EnvMode.Type; +export type BranchToolbarPicker = "environment" | "env-mode" | "mobile-run-context" | "branch"; + +export type BranchToolbarRunContextControl = "environment" | "env-mode"; + +export function resolveBranchToolbarRunContextShortcutTarget(input: { + readonly control: BranchToolbarRunContextControl; + readonly isRendered: boolean; + readonly isMobile: boolean; + readonly environmentPickerAvailable: boolean; + readonly envLocked: boolean; + readonly envModeLocked: boolean; +}): BranchToolbarPicker | null { + if (!input.isRendered) return null; + + const controlAvailable = + input.control === "environment" + ? input.environmentPickerAvailable && !input.envLocked + : !input.envModeLocked; + if (!controlAvailable) return null; + + if (input.isMobile) return "mobile-run-context"; + return input.control; +} + +export interface BranchToolbarPickerAvailability { + readonly environment: boolean; + readonly envMode: boolean; + readonly mobileRunContext: boolean; + readonly branch: boolean; +} + +export function resolveAvailableBranchToolbarPicker( + picker: BranchToolbarPicker | null, + availability: BranchToolbarPickerAvailability, +): BranchToolbarPicker | null { + switch (picker) { + case "environment": + return availability.environment ? picker : null; + case "env-mode": + return availability.envMode ? picker : null; + case "mobile-run-context": + return availability.mobileRunContext ? picker : null; + case "branch": + return availability.branch ? picker : null; + case null: + return null; + } +} + +export function shouldShowBranchPickerShortcutHint(input: { + readonly shortcutHintLabel: string | null | undefined; + readonly isInitialBranchesLoadPending: boolean; + readonly isBranchActionPending: boolean; +}): boolean { + return Boolean( + input.shortcutHintLabel && !input.isInitialBranchesLoadPending && !input.isBranchActionPending, + ); +} + +export function resolveBranchPickerShortcutOpenState(input: { + readonly open: boolean; + readonly unavailable: boolean; +}): boolean | null { + if (input.open) return false; + return input.unavailable ? null : true; +} + +export function resolveBranchToolbarPickerOpenChange( + current: BranchToolbarPicker | null, + picker: BranchToolbarPicker, + open: boolean, +): BranchToolbarPicker | null { + if (open) return picker; + return current === picker ? null : current; +} + +export function resolveBranchPickerQueryForOpenState(query: string, open: boolean): string { + return open ? query : ""; +} + const GENERIC_LOCAL_ENVIRONMENT_LABELS = new Set(["local", "local environment"]); function normalizeDisplayLabel(value: string | null | undefined): string | null { diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index c2db48184c7..bf8a73001d8 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -1,5 +1,5 @@ import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; -import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import type { EnvironmentId, ResolvedKeybindingsConfig, ThreadId } from "@t3tools/contracts"; import { ChevronDownIcon, CloudIcon, @@ -8,24 +8,36 @@ import { FolderIcon, MonitorIcon, } from "lucide-react"; -import { memo, useMemo } from "react"; +import { memo, useCallback, useImperativeHandle, useLayoutEffect, useMemo, useRef } from "react"; import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; import { useProject, useThread } from "../state/entities"; +import { useLayoutScopedState } from "../hooks/useLayoutScopedOpenState"; import { useIsMobile } from "../hooks/useMediaQuery"; +import { useTerminalFocus } from "../hooks/useTerminalFocus"; +import { shortcutLabelForCommand, shouldShowCommandHintForModifiers } from "../keybindings"; +import { useShortcutModifierState } from "../shortcutModifierState"; import { + type BranchToolbarPicker, type EnvMode, type EnvironmentOption, resolveCurrentWorkspaceLabel, + resolveAvailableBranchToolbarPicker, resolveEnvModeLabel, resolveEffectiveEnvMode, + resolveBranchToolbarRunContextShortcutTarget, + resolveBranchToolbarPickerOpenChange, resolveLockedWorkspaceLabel, shouldShowEnvironmentIndicator, } from "./BranchToolbar.logic"; -import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector"; +import { + BranchToolbarBranchSelector, + type BranchToolbarBranchSelectorHandle, +} from "./BranchToolbarBranchSelector"; import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector"; import { BranchToolbarEnvModeSelector } from "./BranchToolbarEnvModeSelector"; import { Button } from "./ui/button"; +import { Kbd } from "./ui/kbd"; import { Menu, MenuGroup, @@ -38,10 +50,19 @@ import { } from "./ui/menu"; import { Separator } from "./ui/separator"; +export interface BranchToolbarHandle { + /** Each toggle returns false (without side effects) when its control is unavailable. */ + toggleEnvironmentPicker: () => boolean; + toggleEnvModePicker: () => boolean; + toggleBranchPicker: () => boolean; +} + interface BranchToolbarProps { environmentId: EnvironmentId; threadId: ThreadId; draftId?: DraftId; + keybindings: ResolvedKeybindingsConfig; + toolbarRef?: React.RefObject; onEnvModeChange: (mode: EnvMode) => void; effectiveEnvModeOverride?: EnvMode; activeThreadBranchOverride?: string | null; @@ -62,6 +83,11 @@ interface MobileRunContextSelectorProps { availableEnvironments: readonly EnvironmentOption[] | undefined; showEnvironmentPicker: boolean; showEnvironmentIndicator: boolean; + pickerAvailable: boolean; + environmentShortcutHintLabel: string | null; + envModeShortcutHintLabel: string | null; + open: boolean; + onOpenChange: (open: boolean) => void; onEnvironmentChange: ((environmentId: EnvironmentId) => void) | undefined; effectiveEnvMode: EnvMode; activeWorktreePath: string | null; @@ -75,6 +101,11 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ availableEnvironments, showEnvironmentPicker, showEnvironmentIndicator, + pickerAvailable, + environmentShortcutHintLabel, + envModeShortcutHintLabel, + open, + onOpenChange, onEnvironmentChange, effectiveEnvMode, activeWorktreePath, @@ -95,7 +126,6 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ : effectiveEnvMode === "worktree" ? resolveEnvModeLabel("worktree") : resolveCurrentWorkspaceLabel(activeWorktreePath); - const isLocked = envLocked || envModeLocked; const EnvironmentIcon = activeEnvironment?.isPrimary ? MonitorIcon : CloudIcon; const icon = showEnvironmentIndicator ? ( // Button's base styles apply `-mx-0.5` to descendant SVGs, which eats 4px @@ -116,7 +146,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ ); - if (isLocked) { + if (!pickerAvailable) { return ( {triggerContent} @@ -125,12 +155,20 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ } return ( - + } className="min-w-0 max-w-[48%] flex-1 justify-start text-muted-foreground/70 hover:text-foreground/80 md:hidden" > {triggerContent} + {environmentShortcutHintLabel ? ( + + {environmentShortcutHintLabel} + + ) : null} + {envModeShortcutHintLabel ? ( + {envModeShortcutHintLabel} + ) : null} @@ -197,6 +235,8 @@ export const BranchToolbar = memo(function BranchToolbar({ environmentId, threadId, draftId, + keybindings, + toolbarRef, onEnvModeChange, effectiveEnvModeOverride, activeThreadBranchOverride, @@ -245,6 +285,128 @@ export const BranchToolbar = memo(function BranchToolbar({ }); const isMobile = useIsMobile(); + const isRendered = hasActiveThread && activeProject !== null; + const environmentPickerAvailable = + showEnvironmentIndicator && availableEnvironments !== undefined && showEnvironmentPicker; + + const toolbarLayout = isMobile ? "mobile" : "desktop"; + const [storedActivePicker, setActivePicker] = useLayoutScopedState< + typeof toolbarLayout, + BranchToolbarPicker | null + >(toolbarLayout, null); + const environmentShortcutTarget = resolveBranchToolbarRunContextShortcutTarget({ + control: "environment", + isRendered, + isMobile, + environmentPickerAvailable, + envLocked, + envModeLocked, + }); + const envModeShortcutTarget = resolveBranchToolbarRunContextShortcutTarget({ + control: "env-mode", + isRendered, + isMobile, + environmentPickerAvailable, + envLocked, + envModeLocked, + }); + const pickerAvailability = useMemo( + () => ({ + environment: environmentShortcutTarget === "environment", + envMode: envModeShortcutTarget === "env-mode", + mobileRunContext: + environmentShortcutTarget === "mobile-run-context" || + envModeShortcutTarget === "mobile-run-context", + branch: isRendered, + }), + [envModeShortcutTarget, environmentShortcutTarget, isRendered], + ); + const activePicker = resolveAvailableBranchToolbarPicker(storedActivePicker, pickerAvailability); + useLayoutEffect(() => { + if (storedActivePicker !== activePicker) { + setActivePicker(activePicker); + } + }, [activePicker, setActivePicker, storedActivePicker]); + const environmentPickerOpen = activePicker === "environment"; + const envModePickerOpen = activePicker === "env-mode"; + const mobileRunContextOpen = activePicker === "mobile-run-context"; + const branchSelectorRef = useRef(null); + + const handlePickerOpenChange = useCallback( + (picker: BranchToolbarPicker, open: boolean) => { + setActivePicker((current) => resolveBranchToolbarPickerOpenChange(current, picker, open)); + }, + [setActivePicker], + ); + + useImperativeHandle( + toolbarRef, + () => ({ + toggleEnvironmentPicker: () => { + if (environmentShortcutTarget === null) return false; + handlePickerOpenChange( + environmentShortcutTarget, + activePicker !== environmentShortcutTarget, + ); + return true; + }, + toggleEnvModePicker: () => { + if (envModeShortcutTarget === null) return false; + handlePickerOpenChange(envModeShortcutTarget, activePicker !== envModeShortcutTarget); + return true; + }, + toggleBranchPicker: () => { + if (!isRendered) return false; + return branchSelectorRef.current?.togglePicker() ?? false; + }, + }), + [ + activePicker, + environmentShortcutTarget, + envModeShortcutTarget, + handlePickerOpenChange, + isRendered, + ], + ); + + // Hold-modifier hint badges, mirroring the composer footer controls. + const shortcutModifiers = useShortcutModifierState(); + const terminalFocus = useTerminalFocus(); + const shortcutContext = useMemo(() => ({ terminalFocus }), [terminalFocus]); + const environmentHintLabel = shouldShowCommandHintForModifiers( + shortcutModifiers, + keybindings, + "environmentPicker.toggle", + { platform: navigator.platform, context: shortcutContext }, + ) + ? shortcutLabelForCommand(keybindings, "environmentPicker.toggle", { + context: shortcutContext, + }) + : null; + const envModeHintLabel = shouldShowCommandHintForModifiers( + shortcutModifiers, + keybindings, + "envModePicker.toggle", + { platform: navigator.platform, context: shortcutContext }, + ) + ? shortcutLabelForCommand(keybindings, "envModePicker.toggle", { + context: shortcutContext, + }) + : null; + const branchHintLabel = shouldShowCommandHintForModifiers( + shortcutModifiers, + keybindings, + "branchPicker.toggle", + { platform: navigator.platform, context: shortcutContext }, + ) + ? shortcutLabelForCommand(keybindings, "branchPicker.toggle", { + context: shortcutContext, + }) + : null; + const availableEnvironmentHintLabel = + environmentShortcutTarget !== null ? environmentHintLabel : null; + const availableEnvModeHintLabel = envModeShortcutTarget !== null ? envModeHintLabel : null; + if (!hasActiveThread || !activeProject) return null; return ( @@ -257,6 +419,11 @@ export const BranchToolbar = memo(function BranchToolbar({ availableEnvironments={availableEnvironments} showEnvironmentPicker={showEnvironmentPicker} showEnvironmentIndicator={showEnvironmentIndicator} + pickerAvailable={pickerAvailability.mobileRunContext} + environmentShortcutHintLabel={availableEnvironmentHintLabel} + envModeShortcutHintLabel={availableEnvModeHintLabel} + open={mobileRunContextOpen} + onOpenChange={(open) => handlePickerOpenChange("mobile-run-context", open)} onEnvironmentChange={onEnvironmentChange} effectiveEnvMode={effectiveEnvMode} activeWorktreePath={activeWorktreePath} @@ -270,6 +437,9 @@ export const BranchToolbar = memo(function BranchToolbar({ envLocked={envLocked} environmentId={environmentId} availableEnvironments={availableEnvironments} + open={environmentPickerOpen} + onOpenChange={(open) => handlePickerOpenChange("environment", open)} + shortcutHintLabel={availableEnvironmentHintLabel} {...(showEnvironmentPicker && onEnvironmentChange ? { onEnvironmentChange } : {})} /> @@ -279,6 +449,9 @@ export const BranchToolbar = memo(function BranchToolbar({ envLocked={envModeLocked} effectiveEnvMode={effectiveEnvMode} activeWorktreePath={activeWorktreePath} + open={envModePickerOpen} + onOpenChange={(open) => handlePickerOpenChange("env-mode", open)} + shortcutHintLabel={availableEnvModeHintLabel} onEnvModeChange={onEnvModeChange} /> @@ -295,6 +468,10 @@ export const BranchToolbar = memo(function BranchToolbar({ {...(onActiveThreadBranchOverrideChange ? { onActiveThreadBranchOverrideChange } : {})} startFromOrigin={startFromOrigin} onStartFromOriginChange={onStartFromOriginChange} + open={activePicker === "branch"} + onOpenChange={(open) => handlePickerOpenChange("branch", open)} + selectorRef={branchSelectorRef} + shortcutHintLabel={branchHintLabel} {...(onCheckoutPullRequestRequest ? { onCheckoutPullRequestRequest } : {})} {...(onComposerFocusRequest ? { onComposerFocusRequest } : {})} /> diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 08ee713a932..74744ca5305 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -11,6 +11,7 @@ import { useDeferredValue, useEffect, useId, + useImperativeHandle, useLayoutEffect, useMemo, useOptimistic, @@ -35,10 +36,13 @@ import { parsePullRequestReference } from "../pullRequestReference"; import { getSourceControlPresentation } from "../sourceControlPresentation"; import { deriveLocalBranchNameFromRemoteRef, + resolveBranchPickerQueryForOpenState, + resolveBranchPickerShortcutOpenState, resolveBranchSelectionTarget, resolveBranchToolbarValue, resolveDraftEnvModeAfterBranchChange, resolveEffectiveEnvMode, + shouldShowBranchPickerShortcutHint, shouldIncludeBranchPickerItem, } from "./BranchToolbar.logic"; import { @@ -58,9 +62,15 @@ import { ComboboxStatus, ComboboxTrigger, } from "./ui/combobox"; +import { Kbd } from "./ui/kbd"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; +export interface BranchToolbarBranchSelectorHandle { + /** Returns false (without side effects) when the picker cannot open. */ + togglePicker: () => boolean; +} + interface BranchToolbarBranchSelectorProps { className?: string; environmentId: EnvironmentId; @@ -72,6 +82,10 @@ interface BranchToolbarBranchSelectorProps { onActiveThreadBranchOverrideChange?: (refName: string | null) => void; startFromOrigin: boolean; onStartFromOriginChange: (startFromOrigin: boolean) => void; + open?: boolean; + onOpenChange?: (open: boolean) => void; + selectorRef?: React.RefObject; + shortcutHintLabel?: string | null; onCheckoutPullRequestRequest?: (reference: string) => void; onComposerFocusRequest?: () => void; } @@ -106,6 +120,10 @@ export function BranchToolbarBranchSelector({ onActiveThreadBranchOverrideChange, startFromOrigin, onStartFromOriginChange, + open, + onOpenChange, + selectorRef, + shortcutHintLabel, onCheckoutPullRequestRequest, onComposerFocusRequest, }: BranchToolbarBranchSelectorProps) { @@ -217,7 +235,8 @@ export function BranchToolbarBranchSelector({ // --------------------------------------------------------------------------- // Git ref queries // --------------------------------------------------------------------------- - const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); + const [uncontrolledBranchMenuOpen, setUncontrolledBranchMenuOpen] = useState(false); + const isBranchMenuOpen = open ?? uncontrolledBranchMenuOpen; const [branchQuery, setBranchQuery] = useState(""); const deferredBranchQuery = useDeferredValue(branchQuery); @@ -317,6 +336,25 @@ export function BranchToolbarBranchSelector({ ? `Showing ${refs.length} of ${totalBranchCount} refs` : null; + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + onOpenChange?.(nextOpen); + if (open === undefined) { + setUncontrolledBranchMenuOpen(nextOpen); + } + if (!nextOpen) { + setBranchQuery(""); + return; + } + branchRefState.refresh(); + }, + [branchRefState.refresh, onOpenChange, open], + ); + + useLayoutEffect(() => { + setBranchQuery((current) => resolveBranchPickerQueryForOpenState(current, isBranchMenuOpen)); + }, [isBranchMenuOpen]); + // --------------------------------------------------------------------------- // Branch actions // --------------------------------------------------------------------------- @@ -372,7 +410,7 @@ export function BranchToolbarBranchSelector({ if (isSelectingWorktreeBase) { setThreadBranch(refName.name, null); - setIsBranchMenuOpen(false); + handleOpenChange(false); onComposerFocusRequest?.(); return; } @@ -385,7 +423,7 @@ export function BranchToolbarBranchSelector({ if (selectionTarget.reuseExistingWorktree) { setThreadBranch(refName.name, selectionTarget.nextWorktreePath); - setIsBranchMenuOpen(false); + handleOpenChange(false); onComposerFocusRequest?.(); return; } @@ -394,7 +432,7 @@ export function BranchToolbarBranchSelector({ ? deriveLocalBranchNameFromRemoteRef(refName.name) : refName.name; - setIsBranchMenuOpen(false); + handleOpenChange(false); onComposerFocusRequest?.(); runBranchAction(async () => { @@ -432,7 +470,7 @@ export function BranchToolbarBranchSelector({ const name = rawName.trim(); if (!branchCwd || !name || isBranchActionPending) return; - setIsBranchMenuOpen(false); + handleOpenChange(false); onComposerFocusRequest?.(); runBranchAction(async () => { @@ -495,16 +533,20 @@ export function BranchToolbarBranchSelector({ // --------------------------------------------------------------------------- // Combobox / list plumbing // --------------------------------------------------------------------------- - const handleOpenChange = useCallback( - (open: boolean) => { - setIsBranchMenuOpen(open); - if (!open) { - setBranchQuery(""); - return; - } - branchRefState.refresh(); - }, - [branchRefState.refresh], + useImperativeHandle( + selectorRef, + () => ({ + togglePicker: () => { + const nextOpen = resolveBranchPickerShortcutOpenState({ + open: isBranchMenuOpen, + unavailable: isInitialBranchesLoadPending || isBranchActionPending, + }); + if (nextOpen === null) return false; + handleOpenChange(nextOpen); + return true; + }, + }), + [handleOpenChange, isBranchActionPending, isBranchMenuOpen, isInitialBranchesLoadPending], ); const branchListScrollElementRef = useRef(null); @@ -602,6 +644,11 @@ export function BranchToolbarBranchSelector({ ? `Open ${sourceControlPresentation.terminology.singular} #${branchPr.number} (${branchPr.state}) in browser` : ""; const openPrLink = useOpenPrLink(); + const showShortcutHint = shouldShowBranchPickerShortcutHint({ + shortcutHintLabel, + isInitialBranchesLoadPending, + isBranchActionPending, + }); function renderPickerItem(itemValue: string, index: number) { if (checkoutPullRequestItemValue && itemValue === checkoutPullRequestItemValue) { @@ -616,8 +663,7 @@ export function BranchToolbarBranchSelector({ if (!prReference || !onCheckoutPullRequestRequest) { return; } - setIsBranchMenuOpen(false); - setBranchQuery(""); + handleOpenChange(false); onComposerFocusRequest?.(); onCheckoutPullRequestRequest(prReference); }} @@ -736,6 +782,11 @@ export function BranchToolbarBranchSelector({ > {triggerLabel} + {showShortcutHint ? ( + + {shortcutHintLabel} + + ) : null} diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index 6d06882662f..b92d4bd16b5 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -7,6 +7,7 @@ import { resolveLockedWorkspaceLabel, type EnvMode, } from "./BranchToolbar.logic"; +import { Kbd } from "./ui/kbd"; import { Select, SelectGroup, @@ -21,6 +22,9 @@ interface BranchToolbarEnvModeSelectorProps { envLocked: boolean; effectiveEnvMode: EnvMode; activeWorktreePath: string | null; + open?: boolean; + onOpenChange?: (open: boolean) => void; + shortcutHintLabel?: string | null; onEnvModeChange: (mode: EnvMode) => void; } @@ -28,6 +32,9 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe envLocked, effectiveEnvMode, activeWorktreePath, + open, + onOpenChange, + shortcutHintLabel, onEnvModeChange, }: BranchToolbarEnvModeSelectorProps) { const envModeItems = useMemo( @@ -60,6 +67,8 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe onEnvironmentChange(value as EnvironmentId)} items={environmentItems} > @@ -68,6 +77,9 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir )} + {shortcutHintLabel ? ( + {shortcutHintLabel} + ) : null} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1ef1a7a884f..eb17ee4c040 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -134,7 +134,7 @@ import { subscribePreviewAction } from "./preview/previewActionBus"; import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; import { RightPanelTabs } from "./RightPanelTabs"; import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; -import { BranchToolbar } from "./BranchToolbar"; +import { BranchToolbar, type BranchToolbarHandle } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; @@ -1214,6 +1214,7 @@ function ChatViewContent(props: ChatViewProps) { const composerElementContextsRef = useRef([]); const localComposerRef = useRef(null); const composerRef = useComposerHandleContext() ?? localComposerRef; + const branchToolbarRef = useRef(null); const [showScrollToBottom, setShowScrollToBottom] = useState(false); const [expandedImage, setExpandedImage] = useState(null); const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); @@ -4162,6 +4163,56 @@ function ChatViewContent(props: ChatViewProps) { return; } + // Each toggle reports whether its control was available; when it was + // not, leave the event alone so the browser default still fires. + if (command === "modelOptionsPicker.toggle") { + if (composerRef.current?.toggleModelOptionsPicker()) { + event.preventDefault(); + event.stopPropagation(); + } + return; + } + + if (command === "runtimeModePicker.toggle") { + if (composerRef.current?.toggleRuntimeModePicker()) { + event.preventDefault(); + event.stopPropagation(); + } + return; + } + + if (command === "planMode.toggle") { + if (composerRef.current?.toggleInteractionMode()) { + event.preventDefault(); + event.stopPropagation(); + } + return; + } + + if (command === "environmentPicker.toggle") { + if (branchToolbarRef.current?.toggleEnvironmentPicker()) { + event.preventDefault(); + event.stopPropagation(); + } + return; + } + + if (command === "envModePicker.toggle") { + if (branchToolbarRef.current?.toggleEnvModePicker()) { + event.preventDefault(); + event.stopPropagation(); + } + return; + } + + if (command === "branchPicker.toggle") { + if (branchToolbarRef.current?.toggleBranchPicker()) { + event.preventDefault(); + event.stopPropagation(); + } + return; + } + const scriptId = projectScriptIdFromCommand(command); if (!scriptId || !activeProject) return; const script = activeProject.scripts.find((entry) => entry.id === scriptId); @@ -5681,6 +5732,8 @@ function ChatViewContent(props: ChatViewProps) { environmentId={activeThread.environmentId} threadId={activeThread.id} {...(routeKind === "draft" && draftId ? { draftId } : {})} + keybindings={keybindings} + toolbarRef={branchToolbarRef} onEnvModeChange={onEnvModeChange} startFromOrigin={startFromOrigin} onStartFromOriginChange={onStartFromOriginChange} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index b427037bdf7..d4b65a30e1e 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -63,6 +63,11 @@ import { } from "../../lib/terminalContext"; import { useComposerPathSearch } from "../../lib/composerPathSearchState"; import { type ElementContextDraft } from "../../lib/elementContext"; +import { + useLayoutScopedOpenState, + useLayoutScopedState, +} from "../../hooks/useLayoutScopedOpenState"; +import { useTerminalFocus } from "../../hooks/useTerminalFocus"; import { ComposerPendingElementContexts } from "./ComposerPendingElementContexts"; import { ComposerPendingReviewComments } from "./ComposerPendingReviewComments"; import { ComposerPreviewAnnotationCards } from "./ComposerPreviewAnnotationCards"; @@ -74,7 +79,10 @@ import { type ComposerPromptEditorHandle, ComposerPromptEditor } from "../Compos import { ProviderModelPicker } from "./ProviderModelPicker"; import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu"; import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions"; -import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; +import { + compactComposerShortcutHintLabel, + CompactComposerControlsMenu, +} from "./CompactComposerControlsMenu"; import { ComposerPrimaryActions } from "./ComposerPrimaryActions"; import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel"; import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; @@ -85,7 +93,13 @@ import { getComposerPromptInjectionState, getComposerProviderState, renderProviderTraitsMenuContent, + type ComposerPicker, + type ComposerPickerState, + type CompactControlsMenuOpenSource, + resolveComposerPickerOpenChange, + toggleCompactControlsMenuForShortcut, renderProviderTraitsPicker, + resolveModelOptionsShortcutTarget, } from "./composerProviderState"; import { ContextWindowMeter } from "./ContextWindowMeter"; import { buildExpandedImagePreview, type ExpandedImagePreview } from "./ExpandedImagePreview"; @@ -190,6 +204,9 @@ import { formatProviderSkillDisplayName } from "../../providerSkillPresentation" import { searchProviderSkills } from "../../providerSkillSearch"; import { useMediaQuery } from "../../hooks/useMediaQuery"; import type { ReviewCommentContext } from "../../reviewCommentContext"; +import { shortcutLabelForCommand, shouldShowCommandHintForModifiers } from "../../keybindings"; +import { useShortcutModifierState } from "../../shortcutModifierState"; +import { ComposerControlShortcutHint } from "./ComposerControlShortcutHint"; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; @@ -267,12 +284,18 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop showPlanToggle: boolean; planSidebarLabel: string; planSidebarOpen: boolean; + runtimeModePickerOpen: boolean; + runtimeModeShortcutHintLabel: string | null; + interactionModeShortcutHintLabel: string | null; + onRuntimeModePickerOpenChange: (open: boolean) => void; onToggleInteractionMode: () => void; onRuntimeModeChange: (mode: RuntimeMode) => void; onTogglePlanSidebar: () => void; }) { const runtimeModeOption = runtimeModeConfig[props.runtimeMode]; const RuntimeModeIcon = runtimeModeOption.icon; + const interactionModeTriggerRef = useRef(null); + const runtimeModeTriggerRef = useRef(null); const interactionModeTooltip = props.interactionMode === "plan" ? "Plan mode — click to return to normal build mode" @@ -288,6 +311,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop {interactionModeTooltip} + ) : null; @@ -323,11 +351,14 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop