From e3cc089e62106196a4aa5573d8c6fb0a6f57f49d Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 22 Jul 2026 05:51:22 -0700 Subject: [PATCH 01/11] Keyboard-select composer controls with hold-modifier hints Make every selector in the thread first-prompt composer keyboard-operable through the existing customizable keybindings system, alongside the pre-existing modelPicker.toggle command: - modelOptionsPicker.toggle (mod+shift+e): reasoning effort / traits menu - runtimeModePicker.toggle (mod+shift+a): access level select - planMode.toggle (mod+shift+p): Plan/Build toggle - environmentPicker.toggle (mod+shift+v): branch toolbar environment - envModePicker.toggle (mod+shift+l): local vs worktree - branchPicker.toggle (mod+shift+b): branch selector All six are rebindable in Settings -> Keybindings (labels derived by the existing commandLabel helper) and are upserted into existing configs by the server's startup sync. Holding the platform mod key reveals Kbd hint badges on each control via shouldShowComposerControlHintsForModifiers, which uses subset (not exact) modifier matching so a bare Ctrl/Cmd reveals the chords. Compact footer routes the effort/runtime toggles to CompactComposerControlsMenu; mobile routes environment/env-mode to the combined run-context menu. Toggles report availability so unavailable controls leave the browser default intact. Co-Authored-By: Claude Opus 4.8 --- apps/server/src/keybindings.test.ts | 6 + apps/web/src/components/BranchToolbar.tsx | 96 ++++++++- .../BranchToolbarBranchSelector.tsx | 28 +++ .../BranchToolbarEnvModeSelector.tsx | 12 ++ .../BranchToolbarEnvironmentSelector.tsx | 12 ++ apps/web/src/components/ChatView.tsx | 55 +++++- apps/web/src/components/chat/ChatComposer.tsx | 92 +++++++++ .../chat/CompactComposerControlsMenu.tsx | 7 +- .../components/chat/ProviderModelPicker.tsx | 7 + apps/web/src/components/chat/TraitsPicker.tsx | 30 ++- .../components/chat/composerProviderState.tsx | 20 +- apps/web/src/keybindings.test.ts | 183 ++++++++++++++++++ apps/web/src/keybindings.ts | 45 +++++ packages/contracts/src/keybindings.test.ts | 16 ++ packages/contracts/src/keybindings.ts | 11 ++ packages/shared/src/keybindings.ts | 6 + 16 files changed, 613 insertions(+), 13 deletions(-) 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.tsx b/apps/web/src/components/BranchToolbar.tsx index c2db48184c7..b5310910722 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,11 +8,16 @@ import { FolderIcon, MonitorIcon, } from "lucide-react"; -import { memo, useMemo } from "react"; +import { memo, useImperativeHandle, useMemo, useRef, useState } from "react"; import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; import { useProject, useThread } from "../state/entities"; import { useIsMobile } from "../hooks/useMediaQuery"; +import { + shortcutLabelForCommand, + shouldShowComposerControlHintsForModifiers, +} from "../keybindings"; +import { useShortcutModifierState } from "../shortcutModifierState"; import { type EnvMode, type EnvironmentOption, @@ -22,7 +27,10 @@ import { 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"; @@ -38,10 +46,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 +79,8 @@ interface MobileRunContextSelectorProps { availableEnvironments: readonly EnvironmentOption[] | undefined; showEnvironmentPicker: boolean; showEnvironmentIndicator: boolean; + open: boolean; + onOpenChange: (open: boolean) => void; onEnvironmentChange: ((environmentId: EnvironmentId) => void) | undefined; effectiveEnvMode: EnvMode; activeWorktreePath: string | null; @@ -75,6 +94,8 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ availableEnvironments, showEnvironmentPicker, showEnvironmentIndicator, + open, + onOpenChange, onEnvironmentChange, effectiveEnvMode, activeWorktreePath, @@ -125,7 +146,7 @@ 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" @@ -197,6 +218,8 @@ export const BranchToolbar = memo(function BranchToolbar({ environmentId, threadId, draftId, + keybindings, + toolbarRef, onEnvModeChange, effectiveEnvModeOverride, activeThreadBranchOverride, @@ -245,6 +268,61 @@ export const BranchToolbar = memo(function BranchToolbar({ }); const isMobile = useIsMobile(); + const [environmentPickerOpen, setEnvironmentPickerOpen] = useState(false); + const [envModePickerOpen, setEnvModePickerOpen] = useState(false); + const [mobileRunContextOpen, setMobileRunContextOpen] = useState(false); + const branchSelectorRef = useRef(null); + + const isRendered = hasActiveThread && activeProject !== null; + const environmentPickerAvailable = + showEnvironmentIndicator && availableEnvironments !== undefined && showEnvironmentPicker; + useImperativeHandle( + toolbarRef, + () => ({ + toggleEnvironmentPicker: () => { + if (!isRendered || !environmentPickerAvailable || envLocked) return false; + if (isMobile) { + if (envModeLocked) return false; + setMobileRunContextOpen((open) => !open); + return true; + } + setEnvironmentPickerOpen((open) => !open); + return true; + }, + toggleEnvModePicker: () => { + if (!isRendered || envModeLocked) return false; + if (isMobile) { + setMobileRunContextOpen((open) => !open); + return true; + } + setEnvModePickerOpen((open) => !open); + return true; + }, + toggleBranchPicker: () => { + if (!isRendered) return false; + return branchSelectorRef.current?.togglePicker() ?? false; + }, + }), + [envLocked, envModeLocked, environmentPickerAvailable, isMobile, isRendered], + ); + + // Hold-modifier hint badges, mirroring the composer footer controls. + const shortcutModifiers = useShortcutModifierState(); + const showControlHints = shouldShowComposerControlHintsForModifiers( + shortcutModifiers, + keybindings, + { platform: navigator.platform }, + ); + const environmentHintLabel = showControlHints + ? shortcutLabelForCommand(keybindings, "environmentPicker.toggle") + : null; + const envModeHintLabel = showControlHints + ? shortcutLabelForCommand(keybindings, "envModePicker.toggle") + : null; + const branchHintLabel = showControlHints + ? shortcutLabelForCommand(keybindings, "branchPicker.toggle") + : null; + if (!hasActiveThread || !activeProject) return null; return ( @@ -257,6 +335,8 @@ export const BranchToolbar = memo(function BranchToolbar({ availableEnvironments={availableEnvironments} showEnvironmentPicker={showEnvironmentPicker} showEnvironmentIndicator={showEnvironmentIndicator} + open={mobileRunContextOpen} + onOpenChange={setMobileRunContextOpen} onEnvironmentChange={onEnvironmentChange} effectiveEnvMode={effectiveEnvMode} activeWorktreePath={activeWorktreePath} @@ -270,6 +350,9 @@ export const BranchToolbar = memo(function BranchToolbar({ envLocked={envLocked} environmentId={environmentId} availableEnvironments={availableEnvironments} + open={environmentPickerOpen} + onOpenChange={setEnvironmentPickerOpen} + shortcutHintLabel={environmentHintLabel} {...(showEnvironmentPicker && onEnvironmentChange ? { onEnvironmentChange } : {})} /> @@ -279,6 +362,9 @@ export const BranchToolbar = memo(function BranchToolbar({ envLocked={envModeLocked} effectiveEnvMode={effectiveEnvMode} activeWorktreePath={activeWorktreePath} + open={envModePickerOpen} + onOpenChange={setEnvModePickerOpen} + shortcutHintLabel={envModeHintLabel} onEnvModeChange={onEnvModeChange} /> @@ -295,6 +381,8 @@ export const BranchToolbar = memo(function BranchToolbar({ {...(onActiveThreadBranchOverrideChange ? { onActiveThreadBranchOverrideChange } : {})} startFromOrigin={startFromOrigin} onStartFromOriginChange={onStartFromOriginChange} + 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..834a85d2237 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, @@ -58,9 +59,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 +79,8 @@ interface BranchToolbarBranchSelectorProps { onActiveThreadBranchOverrideChange?: (refName: string | null) => void; startFromOrigin: boolean; onStartFromOriginChange: (startFromOrigin: boolean) => void; + selectorRef?: React.RefObject; + shortcutHintLabel?: string | null; onCheckoutPullRequestRequest?: (reference: string) => void; onComposerFocusRequest?: () => void; } @@ -106,6 +115,8 @@ export function BranchToolbarBranchSelector({ onActiveThreadBranchOverrideChange, startFromOrigin, onStartFromOriginChange, + selectorRef, + shortcutHintLabel, onCheckoutPullRequestRequest, onComposerFocusRequest, }: BranchToolbarBranchSelectorProps) { @@ -507,6 +518,18 @@ export function BranchToolbarBranchSelector({ [branchRefState.refresh], ); + useImperativeHandle( + selectorRef, + () => ({ + togglePicker: () => { + if (isInitialBranchesLoadPending || isBranchActionPending) return false; + handleOpenChange(!isBranchMenuOpen); + return true; + }, + }), + [handleOpenChange, isBranchActionPending, isBranchMenuOpen, isInitialBranchesLoadPending], + ); + const branchListScrollElementRef = useRef(null); const [showTopBranchScrollFade, setShowTopBranchScrollFade] = useState(false); const [showBottomBranchScrollFade, setShowBottomBranchScrollFade] = useState(false); @@ -736,6 +759,11 @@ export function BranchToolbarBranchSelector({ > {triggerLabel} + {shortcutHintLabel ? ( + + {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..c89ee8dd94b 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -190,6 +190,12 @@ import { formatProviderSkillDisplayName } from "../../providerSkillPresentation" import { searchProviderSkills } from "../../providerSkillSearch"; import { useMediaQuery } from "../../hooks/useMediaQuery"; import type { ReviewCommentContext } from "../../reviewCommentContext"; +import { + shortcutLabelForCommand, + shouldShowComposerControlHintsForModifiers, +} from "../../keybindings"; +import { useShortcutModifierState } from "../../shortcutModifierState"; +import { Kbd } from "../ui/kbd"; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; @@ -267,6 +273,10 @@ 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; @@ -310,6 +320,11 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop {props.interactionMode === "plan" ? "Plan" : "Build"} + {props.interactionModeShortcutHintLabel ? ( + + {props.interactionModeShortcutHintLabel} + + ) : null} {interactionModeTooltip} @@ -323,6 +338,8 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop {runtimeModeOption.description} + {interactionModeToggle} diff --git a/apps/web/src/components/chat/ComposerControlShortcutHint.tsx b/apps/web/src/components/chat/ComposerControlShortcutHint.tsx new file mode 100644 index 00000000000..63cbb3591bd --- /dev/null +++ b/apps/web/src/components/chat/ComposerControlShortcutHint.tsx @@ -0,0 +1,37 @@ +import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"; +import type * as React from "react"; + +import { Kbd } from "../ui/kbd"; + +/** + * Hold-modifier shortcut hint for a composer control. Rendered as a detached, + * always-open tooltip anchored below the control's trigger so the trigger keeps + * showing its current value while hints are visible. Portaled because the + * composer footer is a horizontal scroll container that would clip an inline + * absolutely-positioned badge. + */ +export function ComposerControlShortcutHint(props: { + anchorRef: React.RefObject; + label: string | null; +}) { + if (!props.label) return null; + return ( + + + + + + {props.label} + + + + + + ); +} diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index cbb6d73ee17..ab08c07c9e9 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -3,14 +3,14 @@ import { type ProviderDriverKind, type ResolvedKeybindingsConfig, } from "@t3tools/contracts"; -import { memo, useEffect, useMemo, useState } from "react"; +import { memo, useEffect, useMemo, useRef, useState } from "react"; import type { VariantProps } from "class-variance-authority"; import { ChevronDownIcon } from "lucide-react"; import { Button, buttonVariants } from "../ui/button"; -import { Kbd } from "../ui/kbd"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { cn } from "~/lib/utils"; +import { ComposerControlShortcutHint } from "./ComposerControlShortcutHint"; import { ModelPickerContent } from "./ModelPickerContent"; import { ProviderInstanceIcon } from "./ProviderInstanceIcon"; import { @@ -46,6 +46,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { onInstanceModelChange: (instanceId: ProviderInstanceId, model: string) => void; }) { const [uncontrolledIsMenuOpen, setUncontrolledIsMenuOpen] = useState(false); + const triggerRef = useRef(null); const isMenuOpen = props.open ?? uncontrolledIsMenuOpen; // Resolve the active instance entry by exact routing key. The composer @@ -148,6 +149,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { {triggerLabel} - {props.shortcutHintLabel ? ( - - {props.shortcutHintLabel} - - ) : null} @@ -213,6 +210,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { onInstanceModelChange={handleInstanceModelChange} /> + ); }); diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 4f95a626e3e..e597123a374 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -14,7 +14,7 @@ import { getProviderOptionDescriptors, isClaudeUltrathinkPrompt, } from "@t3tools/shared/model"; -import { memo, useCallback, useState } from "react"; +import { memo, useCallback, useRef, useState } from "react"; import type { VariantProps } from "class-variance-authority"; import { ChevronDownIcon } from "lucide-react"; import { Button, buttonVariants } from "../ui/button"; @@ -31,7 +31,7 @@ import { useComposerDraftStore, DraftId } from "../../composerDraftStore"; import { getProviderModelCapabilities } from "../../providerModels"; import { cn } from "~/lib/utils"; import { Badge } from "../ui/badge"; -import { Kbd } from "../ui/kbd"; +import { ComposerControlShortcutHint } from "./ComposerControlShortcutHint"; type ProviderOptions = ReadonlyArray; @@ -403,6 +403,7 @@ export const TraitsPicker = memo(function TraitsPicker({ ...persistence }: TraitsMenuContentProps & TraitsPickerControlProps & TraitsPersistence) { const [uncontrolledMenuOpen, setUncontrolledMenuOpen] = useState(false); + const triggerRef = useRef(null); const isMenuOpen = open ?? uncontrolledMenuOpen; const { descriptors, primarySelectDescriptor, ultrathinkPromptControlled } = getTraitsSectionVisibility({ @@ -459,6 +460,7 @@ export const TraitsPicker = memo(function TraitsPicker({ {triggerLabel} - {shortcutHintLabel ? ( - - {shortcutHintLabel} - - ) : null} ); }); From 10a509b112e8942e6bad0ad17103631f0a404451 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 22 Jul 2026 08:56:32 -0700 Subject: [PATCH 07/11] Fix responsive shortcut availability --- .../components/BranchToolbar.logic.test.ts | 68 +++++++++++++++++++ .../web/src/components/BranchToolbar.logic.ts | 22 ++++++ apps/web/src/components/BranchToolbar.tsx | 63 ++++++++++------- apps/web/src/components/chat/ChatComposer.tsx | 17 +++-- .../chat/composerProviderState.test.tsx | 38 +++++++++++ .../components/chat/composerProviderState.tsx | 15 ++++ 6 files changed, 192 insertions(+), 31 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index f6e2356e763..64adaab93e9 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -5,6 +5,7 @@ import { deriveLocalBranchNameFromRemoteRef, resolveEnvironmentOptionLabel, resolveAvailableBranchToolbarPicker, + resolveBranchToolbarRunContextShortcutTarget, resolveBranchSelectionTarget, resolveBranchPickerQueryForOpenState, resolveBranchToolbarPickerOpenChange, @@ -20,6 +21,73 @@ import { 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"); diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 35408a9c0b7..c269ef3cbe3 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -17,6 +17,28 @@ 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; diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index b96d97e9277..e6e5fbbfc54 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -28,6 +28,7 @@ import { resolveAvailableBranchToolbarPicker, resolveEnvModeLabel, resolveEffectiveEnvMode, + resolveBranchToolbarRunContextShortcutTarget, resolveBranchToolbarPickerOpenChange, resolveLockedWorkspaceLabel, shouldShowEnvironmentIndicator, @@ -84,6 +85,7 @@ interface MobileRunContextSelectorProps { availableEnvironments: readonly EnvironmentOption[] | undefined; showEnvironmentPicker: boolean; showEnvironmentIndicator: boolean; + pickerAvailable: boolean; open: boolean; onOpenChange: (open: boolean) => void; onEnvironmentChange: ((environmentId: EnvironmentId) => void) | undefined; @@ -99,6 +101,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ availableEnvironments, showEnvironmentPicker, showEnvironmentIndicator, + pickerAvailable, open, onOpenChange, onEnvironmentChange, @@ -121,7 +124,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 @@ -142,7 +144,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ ); - if (isLocked) { + if (!pickerAvailable) { return ( {triggerContent} @@ -282,14 +284,32 @@ export const BranchToolbar = memo(function BranchToolbar({ 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: isRendered && !isMobile && environmentPickerAvailable && !envLocked, - envMode: isRendered && !isMobile && !envModeLocked, - mobileRunContext: isRendered && isMobile && !envLocked && !envModeLocked, + environment: environmentShortcutTarget === "environment", + envMode: envModeShortcutTarget === "env-mode", + mobileRunContext: + environmentShortcutTarget === "mobile-run-context" || + envModeShortcutTarget === "mobile-run-context", branch: isRendered, }), - [envLocked, envModeLocked, environmentPickerAvailable, isMobile, isRendered], + [envModeShortcutTarget, environmentShortcutTarget, isRendered], ); const activePicker = resolveAvailableBranchToolbarPicker(storedActivePicker, pickerAvailability); useLayoutEffect(() => { @@ -313,22 +333,16 @@ export const BranchToolbar = memo(function BranchToolbar({ toolbarRef, () => ({ toggleEnvironmentPicker: () => { - if (!isRendered || !environmentPickerAvailable || envLocked) return false; - if (isMobile) { - if (envModeLocked) return false; - handlePickerOpenChange("mobile-run-context", !mobileRunContextOpen); - return true; - } - handlePickerOpenChange("environment", !environmentPickerOpen); + if (environmentShortcutTarget === null) return false; + handlePickerOpenChange( + environmentShortcutTarget, + activePicker !== environmentShortcutTarget, + ); return true; }, toggleEnvModePicker: () => { - if (!isRendered || envModeLocked) return false; - if (isMobile) { - handlePickerOpenChange("mobile-run-context", !mobileRunContextOpen); - return true; - } - handlePickerOpenChange("env-mode", !envModePickerOpen); + if (envModeShortcutTarget === null) return false; + handlePickerOpenChange(envModeShortcutTarget, activePicker !== envModeShortcutTarget); return true; }, toggleBranchPicker: () => { @@ -337,15 +351,11 @@ export const BranchToolbar = memo(function BranchToolbar({ }, }), [ - envLocked, - envModeLocked, - envModePickerOpen, - environmentPickerAvailable, - environmentPickerOpen, + activePicker, + environmentShortcutTarget, + envModeShortcutTarget, handlePickerOpenChange, - isMobile, isRendered, - mobileRunContextOpen, ], ); @@ -386,6 +396,7 @@ export const BranchToolbar = memo(function BranchToolbar({ availableEnvironments={availableEnvironments} showEnvironmentPicker={showEnvironmentPicker} showEnvironmentIndicator={showEnvironmentIndicator} + pickerAvailable={pickerAvailability.mobileRunContext} open={mobileRunContextOpen} onOpenChange={(open) => handlePickerOpenChange("mobile-run-context", open)} onEnvironmentChange={onEnvironmentChange} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index c9e0feb8141..9df6a5b1148 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -88,6 +88,7 @@ import { getComposerProviderState, renderProviderTraitsMenuContent, renderProviderTraitsPicker, + resolveModelOptionsShortcutTarget, } from "./composerProviderState"; import { ContextWindowMeter } from "./ContextWindowMeter"; import { buildExpandedImagePreview, type ExpandedImagePreview } from "./ExpandedImagePreview"; @@ -2162,13 +2163,18 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }, isModelPickerOpen: () => isComposerModelPickerOpen, toggleModelOptionsPicker: () => { - if (isComposerCollapsedMobile || isComposerApprovalState) return false; - if (isComposerFooterCompact) { + const target = resolveModelOptionsShortcutTarget({ + isComposerUnavailable: isComposerCollapsedMobile || isComposerApprovalState, + isCompact: isComposerFooterCompact, + compactTraitsAvailable: providerTraitsMenuContent !== null, + expandedTraitsAvailable: providerTraitsPicker !== null, + }); + if (target === null) return false; + if (target === "compact-controls-menu") { setIsCompactControlsMenuOpen((open) => !open); - return true; + } else { + setIsComposerTraitsPickerOpen((open) => !open); } - if (!providerTraitsPicker) return false; - setIsComposerTraitsPickerOpen((open) => !open); return true; }, toggleRuntimeModePicker: () => { @@ -2276,6 +2282,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isComposerFooterCompact, composerProviderControls, providerTraitsPicker, + providerTraitsMenuContent, toggleInteractionMode, pendingUserInputs.length, projectSelectionRequired, diff --git a/apps/web/src/components/chat/composerProviderState.test.tsx b/apps/web/src/components/chat/composerProviderState.test.tsx index 067e71ef1bf..59ecb628620 100644 --- a/apps/web/src/components/chat/composerProviderState.test.tsx +++ b/apps/web/src/components/chat/composerProviderState.test.tsx @@ -10,6 +10,7 @@ import { getComposerProviderState, renderProviderTraitsMenuContent, renderProviderTraitsPicker, + resolveModelOptionsShortcutTarget, } from "./composerProviderState"; // Everything in composerProviderState is now data-driven by the model's @@ -61,6 +62,43 @@ const ULTRATHINK_FRAME_CLASSES = { modelPickerIconClassName: "ultrathink-chroma", } as const; +describe("resolveModelOptionsShortcutTarget", () => { + const available = { + isComposerUnavailable: false, + compactTraitsAvailable: true, + expandedTraitsAvailable: true, + }; + + it("opens the layout-specific model options control", () => { + expect(resolveModelOptionsShortcutTarget({ ...available, isCompact: true })).toBe( + "compact-controls-menu", + ); + expect(resolveModelOptionsShortcutTarget({ ...available, isCompact: false })).toBe( + "traits-picker", + ); + }); + + it("does not open the generic compact menu when model options are unavailable", () => { + expect( + resolveModelOptionsShortcutTarget({ + ...available, + isCompact: true, + compactTraitsAvailable: false, + }), + ).toBeNull(); + }); + + it("rejects the shortcut while the composer controls are unavailable", () => { + expect( + resolveModelOptionsShortcutTarget({ + ...available, + isCompact: false, + isComposerUnavailable: true, + }), + ).toBeNull(); + }); +}); + describe("getComposerProviderState", () => { it("derives a stable prompt injection state for ordinary prompt edits", () => { expect(getComposerPromptInjectionState("Investigate this failure")).toBe("none"); diff --git a/apps/web/src/components/chat/composerProviderState.tsx b/apps/web/src/components/chat/composerProviderState.tsx index 3c36a24cc48..507cf0f3c2a 100644 --- a/apps/web/src/components/chat/composerProviderState.tsx +++ b/apps/web/src/components/chat/composerProviderState.tsx @@ -41,6 +41,21 @@ export type ComposerProviderState = { modelPickerIconClassName?: string; }; +export type ModelOptionsShortcutTarget = "compact-controls-menu" | "traits-picker"; + +export function resolveModelOptionsShortcutTarget(input: { + readonly isComposerUnavailable: boolean; + readonly isCompact: boolean; + readonly compactTraitsAvailable: boolean; + readonly expandedTraitsAvailable: boolean; +}): ModelOptionsShortcutTarget | null { + if (input.isComposerUnavailable) return null; + if (input.isCompact) { + return input.compactTraitsAvailable ? "compact-controls-menu" : null; + } + return input.expandedTraitsAvailable ? "traits-picker" : null; +} + type TraitsRenderInput = { provider: ProviderDriverKind; instanceId?: ProviderInstanceId; From ed05f994df81d94dea6579f1edac731ddb17b46f Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 22 Jul 2026 09:22:19 -0700 Subject: [PATCH 08/11] fix(web): scope shortcut hints and compact toggles --- apps/web/src/components/BranchToolbar.tsx | 25 ++++--- apps/web/src/components/chat/ChatComposer.tsx | 74 ++++++++++--------- .../chat/composerProviderState.test.tsx | 17 +++++ .../components/chat/composerProviderState.tsx | 8 ++ apps/web/src/keybindings.test.ts | 25 +++++++ apps/web/src/keybindings.ts | 22 +++--- 6 files changed, 118 insertions(+), 53 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index e6e5fbbfc54..c8afd5cce68 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -15,10 +15,7 @@ import { useProject, useThread } from "../state/entities"; import { useLayoutScopedState } from "../hooks/useLayoutScopedOpenState"; import { useIsMobile } from "../hooks/useMediaQuery"; import { useTerminalFocus } from "../hooks/useTerminalFocus"; -import { - shortcutLabelForCommand, - shouldShowComposerControlHintsForModifiers, -} from "../keybindings"; +import { shortcutLabelForCommand, shouldShowCommandHintForModifiers } from "../keybindings"; import { useShortcutModifierState } from "../shortcutModifierState"; import { type BranchToolbarPicker, @@ -363,22 +360,32 @@ export const BranchToolbar = memo(function BranchToolbar({ const shortcutModifiers = useShortcutModifierState(); const terminalFocus = useTerminalFocus(); const shortcutContext = useMemo(() => ({ terminalFocus }), [terminalFocus]); - const showControlHints = shouldShowComposerControlHintsForModifiers( + const environmentHintLabel = shouldShowCommandHintForModifiers( shortcutModifiers, keybindings, + "environmentPicker.toggle", { platform: navigator.platform, context: shortcutContext }, - ); - const environmentHintLabel = showControlHints + ) ? shortcutLabelForCommand(keybindings, "environmentPicker.toggle", { context: shortcutContext, }) : null; - const envModeHintLabel = showControlHints + const envModeHintLabel = shouldShowCommandHintForModifiers( + shortcutModifiers, + keybindings, + "envModePicker.toggle", + { platform: navigator.platform, context: shortcutContext }, + ) ? shortcutLabelForCommand(keybindings, "envModePicker.toggle", { context: shortcutContext, }) : null; - const branchHintLabel = showControlHints + const branchHintLabel = shouldShowCommandHintForModifiers( + shortcutModifiers, + keybindings, + "branchPicker.toggle", + { platform: navigator.platform, context: shortcutContext }, + ) ? shortcutLabelForCommand(keybindings, "branchPicker.toggle", { context: shortcutContext, }) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 9df6a5b1148..f9656e2650d 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -63,7 +63,10 @@ import { } from "../../lib/terminalContext"; import { useComposerPathSearch } from "../../lib/composerPathSearchState"; import { type ElementContextDraft } from "../../lib/elementContext"; -import { useLayoutScopedOpenState } from "../../hooks/useLayoutScopedOpenState"; +import { + useLayoutScopedOpenState, + useLayoutScopedState, +} from "../../hooks/useLayoutScopedOpenState"; import { useTerminalFocus } from "../../hooks/useTerminalFocus"; import { ComposerPendingElementContexts } from "./ComposerPendingElementContexts"; import { ComposerPendingReviewComments } from "./ComposerPendingReviewComments"; @@ -87,6 +90,8 @@ import { getComposerPromptInjectionState, getComposerProviderState, renderProviderTraitsMenuContent, + type CompactControlsMenuOpenSource, + toggleCompactControlsMenuForShortcut, renderProviderTraitsPicker, resolveModelOptionsShortcutTarget, } from "./composerProviderState"; @@ -193,10 +198,7 @@ import { formatProviderSkillDisplayName } from "../../providerSkillPresentation" import { searchProviderSkills } from "../../providerSkillSearch"; import { useMediaQuery } from "../../hooks/useMediaQuery"; import type { ReviewCommentContext } from "../../reviewCommentContext"; -import { - shortcutLabelForCommand, - shouldShowComposerControlHintsForModifiers, -} from "../../keybindings"; +import { shortcutLabelForCommand, shouldShowCommandHintForModifiers } from "../../keybindings"; import { useShortcutModifierState } from "../../shortcutModifierState"; import { ComposerControlShortcutHint } from "./ComposerControlShortcutHint"; @@ -995,8 +997,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) useLayoutScopedOpenState(composerControlsLayout); const [isComposerRuntimeModePickerOpen, setIsComposerRuntimeModePickerOpen] = useLayoutScopedOpenState(composerControlsLayout); - const [isCompactControlsMenuOpen, setIsCompactControlsMenuOpen] = - useLayoutScopedOpenState(composerControlsLayout); + const [compactControlsMenuOpenSource, setCompactControlsMenuOpenSource] = useLayoutScopedState< + typeof composerControlsLayout, + CompactControlsMenuOpenSource | null + >(composerControlsLayout, null); + const isCompactControlsMenuOpen = compactControlsMenuOpenSource !== null; const [isComposerFocused, setIsComposerFocused] = useState(false); const [composerMenuAnchor, setComposerMenuAnchor] = useState(null); const isMobileViewport = useMediaQuery("max-sm"); @@ -1234,31 +1239,22 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const shortcutModifiers = useShortcutModifierState(); const terminalFocus = useTerminalFocus(); const shortcutContext = useMemo(() => ({ terminalFocus }), [terminalFocus]); - const showComposerControlHints = shouldShowComposerControlHintsForModifiers( - shortcutModifiers, - keybindings, - { platform: navigator.platform, context: shortcutContext }, - ); - const composerControlHintLabels = useMemo( - () => - showComposerControlHints - ? { - modelPicker: shortcutLabelForCommand(keybindings, "modelPicker.toggle", { - context: shortcutContext, - }), - modelOptionsPicker: shortcutLabelForCommand(keybindings, "modelOptionsPicker.toggle", { - context: shortcutContext, - }), - runtimeModePicker: shortcutLabelForCommand(keybindings, "runtimeModePicker.toggle", { - context: shortcutContext, - }), - planMode: shortcutLabelForCommand(keybindings, "planMode.toggle", { - context: shortcutContext, - }), - } - : null, - [keybindings, shortcutContext, showComposerControlHints], - ); + const composerControlHintLabels = useMemo(() => { + const hintLabel = (command: Parameters[1]) => + shouldShowCommandHintForModifiers(shortcutModifiers, keybindings, command, { + platform: navigator.platform, + context: shortcutContext, + }) + ? shortcutLabelForCommand(keybindings, command, { context: shortcutContext }) + : null; + + return { + modelPicker: hintLabel("modelPicker.toggle"), + modelOptionsPicker: hintLabel("modelOptionsPicker.toggle"), + runtimeModePicker: hintLabel("runtimeModePicker.toggle"), + planMode: hintLabel("planMode.toggle"), + }; + }, [keybindings, shortcutContext, shortcutModifiers]); const providerTraitsPicker = renderProviderTraitsPicker({ provider: selectedProvider, @@ -2171,7 +2167,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }); if (target === null) return false; if (target === "compact-controls-menu") { - setIsCompactControlsMenuOpen((open) => !open); + setCompactControlsMenuOpenSource((current) => + toggleCompactControlsMenuForShortcut(current, "model-options"), + ); } else { setIsComposerTraitsPickerOpen((open) => !open); } @@ -2180,7 +2178,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) toggleRuntimeModePicker: () => { if (isComposerCollapsedMobile || isComposerApprovalState) return false; if (isComposerFooterCompact) { - setIsCompactControlsMenuOpen((open) => !open); + setCompactControlsMenuOpenSource((current) => + toggleCompactControlsMenuForShortcut(current, "runtime-mode"), + ); return true; } setIsComposerRuntimeModePickerOpen((open) => !open); @@ -2790,7 +2790,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) showInteractionModeToggle={composerProviderControls.showInteractionModeToggle} traitsMenuContent={providerTraitsMenuContent} open={isCompactControlsMenuOpen} - onOpenChange={setIsCompactControlsMenuOpen} + onOpenChange={(open) => { + setCompactControlsMenuOpenSource((current) => + open ? (current ?? "direct") : null, + ); + }} onToggleInteractionMode={toggleInteractionMode} onTogglePlanSidebar={togglePlanSidebar} onRuntimeModeChange={handleRuntimeModeChange} diff --git a/apps/web/src/components/chat/composerProviderState.test.tsx b/apps/web/src/components/chat/composerProviderState.test.tsx index 59ecb628620..07c2f4801fc 100644 --- a/apps/web/src/components/chat/composerProviderState.test.tsx +++ b/apps/web/src/components/chat/composerProviderState.test.tsx @@ -11,6 +11,7 @@ import { renderProviderTraitsMenuContent, renderProviderTraitsPicker, resolveModelOptionsShortcutTarget, + toggleCompactControlsMenuForShortcut, } from "./composerProviderState"; // Everything in composerProviderState is now data-driven by the model's @@ -99,6 +100,22 @@ describe("resolveModelOptionsShortcutTarget", () => { }); }); +describe("toggleCompactControlsMenuForShortcut", () => { + it("keeps the shared menu open when switching shortcut targets", () => { + expect(toggleCompactControlsMenuForShortcut("model-options", "runtime-mode")).toBe( + "runtime-mode", + ); + expect(toggleCompactControlsMenuForShortcut("runtime-mode", "model-options")).toBe( + "model-options", + ); + }); + + it("closes the shared menu when the same shortcut is repeated", () => { + expect(toggleCompactControlsMenuForShortcut("model-options", "model-options")).toBeNull(); + expect(toggleCompactControlsMenuForShortcut("runtime-mode", "runtime-mode")).toBeNull(); + }); +}); + describe("getComposerProviderState", () => { it("derives a stable prompt injection state for ordinary prompt edits", () => { expect(getComposerPromptInjectionState("Investigate this failure")).toBe("none"); diff --git a/apps/web/src/components/chat/composerProviderState.tsx b/apps/web/src/components/chat/composerProviderState.tsx index 507cf0f3c2a..08abdef84af 100644 --- a/apps/web/src/components/chat/composerProviderState.tsx +++ b/apps/web/src/components/chat/composerProviderState.tsx @@ -42,6 +42,14 @@ export type ComposerProviderState = { }; export type ModelOptionsShortcutTarget = "compact-controls-menu" | "traits-picker"; +export type CompactControlsMenuOpenSource = "direct" | "model-options" | "runtime-mode"; + +export function toggleCompactControlsMenuForShortcut( + current: CompactControlsMenuOpenSource | null, + source: Exclude, +): CompactControlsMenuOpenSource | null { + return current === source ? null : source; +} export function resolveModelOptionsShortcutTarget(input: { readonly isComposerUnavailable: boolean; diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 8f0b5a8e038..a0946013c0f 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -22,6 +22,7 @@ import { isTerminalToggleShortcut, resolveShortcutCommand, shouldShowComposerControlHintsForModifiers, + shouldShowCommandHintForModifiers, shouldShowModelPickerJumpHints, shouldShowThreadJumpHints, shortcutLabelForCommand, @@ -592,6 +593,30 @@ describe("shouldShowComposerControlHintsForModifiers", () => { ); }); + it("matches each control against its own customized modifiers", () => { + const custom = compile([ + { shortcut: modShortcut("e", { altKey: true }), command: "modelOptionsPicker.toggle" }, + { shortcut: modShortcut("l", { shiftKey: true }), command: "runtimeModePicker.toggle" }, + ]); + + assert.isTrue( + shouldShowCommandHintForModifiers( + modifiers({ ctrlKey: true, altKey: true }), + custom, + "modelOptionsPicker.toggle", + { platform: "Linux" }, + ), + ); + assert.isFalse( + shouldShowCommandHintForModifiers( + modifiers({ ctrlKey: true, altKey: true }), + custom, + "runtimeModePicker.toggle", + { platform: "Linux" }, + ), + ); + }); + it("returns false when no composer control command is bound", () => { const noComposerBindings = compile([ { shortcut: modShortcut("j"), command: "terminal.toggle" }, diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 38b053891e8..ea3e780756a 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -376,20 +376,24 @@ export function shouldShowComposerControlHintsForModifiers( modifiers: ShortcutModifierStateLike, keybindings: ResolvedKeybindingsConfig, options?: ShortcutMatchOptions, +): boolean { + return COMPOSER_CONTROL_HINT_COMMANDS.some((command) => + shouldShowCommandHintForModifiers(modifiers, keybindings, command, options), + ); +} + +export function shouldShowCommandHintForModifiers( + modifiers: ShortcutModifierStateLike, + keybindings: ResolvedKeybindingsConfig, + command: KeybindingCommand, + options?: ShortcutMatchOptions, ): boolean { const platform = resolvePlatform(options); const modKeyHeld = isMacPlatform(platform) ? modifiers.metaKey : modifiers.ctrlKey; if (!modKeyHeld) return false; - for (const command of COMPOSER_CONTROL_HINT_COMMANDS) { - const shortcut = findEffectiveShortcutForCommand(keybindings, command, options); - if (!shortcut) continue; - if (modifiersAreSubsetOfShortcutModifiers(modifiers, shortcut, platform)) { - return true; - } - } - - return false; + const shortcut = findEffectiveShortcutForCommand(keybindings, command, options); + return shortcut ? modifiersAreSubsetOfShortcutModifiers(modifiers, shortcut, platform) : false; } export function isTerminalToggleShortcut( From 6c57621e901fd55e0880c130feb299b8b6a179a7 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 22 Jul 2026 09:30:02 -0700 Subject: [PATCH 09/11] fix(web): close directly opened compact controls --- apps/web/src/components/chat/composerProviderState.test.tsx | 5 +++++ apps/web/src/components/chat/composerProviderState.tsx | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/composerProviderState.test.tsx b/apps/web/src/components/chat/composerProviderState.test.tsx index 07c2f4801fc..09501e0150b 100644 --- a/apps/web/src/components/chat/composerProviderState.test.tsx +++ b/apps/web/src/components/chat/composerProviderState.test.tsx @@ -114,6 +114,11 @@ describe("toggleCompactControlsMenuForShortcut", () => { expect(toggleCompactControlsMenuForShortcut("model-options", "model-options")).toBeNull(); expect(toggleCompactControlsMenuForShortcut("runtime-mode", "runtime-mode")).toBeNull(); }); + + it("closes a menu that was opened directly", () => { + expect(toggleCompactControlsMenuForShortcut("direct", "model-options")).toBeNull(); + expect(toggleCompactControlsMenuForShortcut("direct", "runtime-mode")).toBeNull(); + }); }); describe("getComposerProviderState", () => { diff --git a/apps/web/src/components/chat/composerProviderState.tsx b/apps/web/src/components/chat/composerProviderState.tsx index 08abdef84af..14d3c929505 100644 --- a/apps/web/src/components/chat/composerProviderState.tsx +++ b/apps/web/src/components/chat/composerProviderState.tsx @@ -48,7 +48,9 @@ export function toggleCompactControlsMenuForShortcut( current: CompactControlsMenuOpenSource | null, source: Exclude, ): CompactControlsMenuOpenSource | null { - return current === source ? null : source; + if (current === null) return source; + if (current === "direct" || current === source) return null; + return source; } export function resolveModelOptionsShortcutTarget(input: { From 0c612c0609d75464b9b04ae697dfee74485a80f2 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 22 Jul 2026 18:25:10 -0700 Subject: [PATCH 10/11] fix(web): keep composer pickers mutually exclusive --- apps/web/src/components/chat/ChatComposer.tsx | 99 ++++++++++++++++--- .../chat/composerProviderState.test.tsx | 53 ++++++++++ .../components/chat/composerProviderState.tsx | 36 +++++++ 3 files changed, 173 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index f9656e2650d..bc8cf46f02c 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -90,7 +90,10 @@ import { getComposerPromptInjectionState, getComposerProviderState, renderProviderTraitsMenuContent, + type ComposerPicker, + type ComposerPickerState, type CompactControlsMenuOpenSource, + resolveComposerPickerOpenChange, toggleCompactControlsMenuForShortcut, renderProviderTraitsPicker, resolveModelOptionsShortcutTarget, @@ -1002,6 +1005,42 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) CompactControlsMenuOpenSource | null >(composerControlsLayout, null); const isCompactControlsMenuOpen = compactControlsMenuOpenSource !== null; + const composerPickerStateRef = useRef({ + modelOpen: isComposerModelPickerOpen, + traitsOpen: isComposerTraitsPickerOpen, + runtimeModeOpen: isComposerRuntimeModePickerOpen, + compactControlsMenuOpenSource, + }); + composerPickerStateRef.current = { + modelOpen: isComposerModelPickerOpen, + traitsOpen: isComposerTraitsPickerOpen, + runtimeModeOpen: isComposerRuntimeModePickerOpen, + compactControlsMenuOpenSource, + }; + const applyComposerPickerOpenChange = useCallback( + ( + picker: ComposerPicker, + open: boolean, + nextCompactControlsMenuOpenSource: CompactControlsMenuOpenSource = "direct", + ) => { + const nextState = resolveComposerPickerOpenChange( + composerPickerStateRef.current, + picker, + open, + nextCompactControlsMenuOpenSource, + ); + composerPickerStateRef.current = nextState; + setIsComposerModelPickerOpen(nextState.modelOpen); + setIsComposerTraitsPickerOpen(nextState.traitsOpen); + setIsComposerRuntimeModePickerOpen(nextState.runtimeModeOpen); + setCompactControlsMenuOpenSource(nextState.compactControlsMenuOpenSource); + }, + [ + setCompactControlsMenuOpenSource, + setIsComposerRuntimeModePickerOpen, + setIsComposerTraitsPickerOpen, + ], + ); const [isComposerFocused, setIsComposerFocused] = useState(false); const [composerMenuAnchor, setComposerMenuAnchor] = useState(null); const isMobileViewport = useMediaQuery("max-sm"); @@ -1267,7 +1306,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) prompt, onPromptChange: setPromptFromTraits, open: isComposerTraitsPickerOpen, - onOpenChange: setIsComposerTraitsPickerOpen, + onOpenChange: (open) => { + applyComposerPickerOpenChange("traits", open); + }, shortcutHintLabel: composerControlHintLabels?.modelOptionsPicker ?? null, }); const pendingPrimaryAction = useMemo( @@ -1735,7 +1776,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }); if (applied) { setComposerHighlightedItemId(null); - setIsComposerModelPickerOpen(true); + applyComposerPickerOpenChange("model", true); } return; } @@ -1785,7 +1826,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return; } }, - [applyPromptReplacement, handleInteractionModeChange, resolveActiveComposerTrigger], + [ + applyComposerPickerOpenChange, + applyPromptReplacement, + handleInteractionModeChange, + resolveActiveComposerTrigger, + ], ); const onComposerMenuItemHighlighted = useCallback( @@ -2152,10 +2198,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }, insertTextAtEnd: insertComposerTextAtEnd, openModelPicker: () => { - setIsComposerModelPickerOpen(true); + applyComposerPickerOpenChange("model", true); }, toggleModelPicker: () => { - setIsComposerModelPickerOpen((open) => !open); + applyComposerPickerOpenChange("model", !composerPickerStateRef.current.modelOpen); }, isModelPickerOpen: () => isComposerModelPickerOpen, toggleModelOptionsPicker: () => { @@ -2167,23 +2213,38 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }); if (target === null) return false; if (target === "compact-controls-menu") { - setCompactControlsMenuOpenSource((current) => - toggleCompactControlsMenuForShortcut(current, "model-options"), + const nextSource = toggleCompactControlsMenuForShortcut( + composerPickerStateRef.current.compactControlsMenuOpenSource, + "model-options", + ); + applyComposerPickerOpenChange( + "compact-controls-menu", + nextSource !== null, + nextSource ?? "model-options", ); } else { - setIsComposerTraitsPickerOpen((open) => !open); + applyComposerPickerOpenChange("traits", !composerPickerStateRef.current.traitsOpen); } return true; }, toggleRuntimeModePicker: () => { if (isComposerCollapsedMobile || isComposerApprovalState) return false; if (isComposerFooterCompact) { - setCompactControlsMenuOpenSource((current) => - toggleCompactControlsMenuForShortcut(current, "runtime-mode"), + const nextSource = toggleCompactControlsMenuForShortcut( + composerPickerStateRef.current.compactControlsMenuOpenSource, + "runtime-mode", + ); + applyComposerPickerOpenChange( + "compact-controls-menu", + nextSource !== null, + nextSource ?? "runtime-mode", ); return true; } - setIsComposerRuntimeModePickerOpen((open) => !open); + applyComposerPickerOpenChange( + "runtime-mode", + !composerPickerStateRef.current.runtimeModeOpen, + ); return true; }, toggleInteractionMode: () => { @@ -2284,10 +2345,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) providerTraitsPicker, providerTraitsMenuContent, toggleInteractionMode, + applyComposerPickerOpenChange, + compactControlsMenuOpenSource, pendingUserInputs.length, projectSelectionRequired, applyPromptReplacement, isComposerModelPickerOpen, + isComposerRuntimeModePickerOpen, + isComposerTraitsPickerOpen, readComposerSnapshot, selectedModel, selectedModelOptionsForDispatch, @@ -2773,7 +2838,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } : {})} onOpenChange={(open) => { - setIsComposerModelPickerOpen(open); + applyComposerPickerOpenChange("model", open); }} getModelDisabledReason={getModelDisabledReason} onInstanceModelChange={onProviderModelSelect} @@ -2791,8 +2856,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) traitsMenuContent={providerTraitsMenuContent} open={isCompactControlsMenuOpen} onOpenChange={(open) => { - setCompactControlsMenuOpenSource((current) => - open ? (current ?? "direct") : null, + applyComposerPickerOpenChange( + "compact-controls-menu", + open, + composerPickerStateRef.current.compactControlsMenuOpenSource ?? "direct", ); }} onToggleInteractionMode={toggleInteractionMode} @@ -2819,7 +2886,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerControlHintLabels?.runtimeModePicker ?? null } interactionModeShortcutHintLabel={composerControlHintLabels?.planMode ?? null} - onRuntimeModePickerOpenChange={setIsComposerRuntimeModePickerOpen} + onRuntimeModePickerOpenChange={(open) => { + applyComposerPickerOpenChange("runtime-mode", open); + }} onToggleInteractionMode={toggleInteractionMode} onRuntimeModeChange={handleRuntimeModeChange} onTogglePlanSidebar={togglePlanSidebar} diff --git a/apps/web/src/components/chat/composerProviderState.test.tsx b/apps/web/src/components/chat/composerProviderState.test.tsx index 09501e0150b..e9e6eaf6805 100644 --- a/apps/web/src/components/chat/composerProviderState.test.tsx +++ b/apps/web/src/components/chat/composerProviderState.test.tsx @@ -10,6 +10,7 @@ import { getComposerProviderState, renderProviderTraitsMenuContent, renderProviderTraitsPicker, + resolveComposerPickerOpenChange, resolveModelOptionsShortcutTarget, toggleCompactControlsMenuForShortcut, } from "./composerProviderState"; @@ -121,6 +122,58 @@ describe("toggleCompactControlsMenuForShortcut", () => { }); }); +describe("resolveComposerPickerOpenChange", () => { + const closed = { + modelOpen: false, + traitsOpen: false, + runtimeModeOpen: false, + compactControlsMenuOpenSource: null, + } as const; + + it("keeps keyboard-opened composer pickers mutually exclusive", () => { + const modelOpen = resolveComposerPickerOpenChange(closed, "model", true); + const traitsOpen = resolveComposerPickerOpenChange(modelOpen, "traits", true); + const runtimeModeOpen = resolveComposerPickerOpenChange(traitsOpen, "runtime-mode", true); + + expect(modelOpen).toEqual({ ...closed, modelOpen: true }); + expect(traitsOpen).toEqual({ ...closed, traitsOpen: true }); + expect(runtimeModeOpen).toEqual({ ...closed, runtimeModeOpen: true }); + }); + + it("treats the compact controls menu as the only open picker", () => { + const modelOpen = resolveComposerPickerOpenChange(closed, "model", true); + const compactOpen = resolveComposerPickerOpenChange( + modelOpen, + "compact-controls-menu", + true, + "model-options", + ); + const switchedSource = resolveComposerPickerOpenChange( + compactOpen, + "compact-controls-menu", + true, + "runtime-mode", + ); + + expect(compactOpen).toEqual({ + ...closed, + compactControlsMenuOpenSource: "model-options", + }); + expect(switchedSource).toEqual({ + ...closed, + compactControlsMenuOpenSource: "runtime-mode", + }); + }); + + it("does not close the active picker when a previous picker reports closing", () => { + const runtimeModeOpen = { ...closed, runtimeModeOpen: true }; + + expect(resolveComposerPickerOpenChange(runtimeModeOpen, "traits", false)).toEqual( + runtimeModeOpen, + ); + }); +}); + describe("getComposerProviderState", () => { it("derives a stable prompt injection state for ordinary prompt edits", () => { expect(getComposerPromptInjectionState("Investigate this failure")).toBe("none"); diff --git a/apps/web/src/components/chat/composerProviderState.tsx b/apps/web/src/components/chat/composerProviderState.tsx index 14d3c929505..dc6df55189d 100644 --- a/apps/web/src/components/chat/composerProviderState.tsx +++ b/apps/web/src/components/chat/composerProviderState.tsx @@ -43,6 +43,42 @@ export type ComposerProviderState = { export type ModelOptionsShortcutTarget = "compact-controls-menu" | "traits-picker"; export type CompactControlsMenuOpenSource = "direct" | "model-options" | "runtime-mode"; +export type ComposerPicker = "model" | "traits" | "runtime-mode" | "compact-controls-menu"; + +export type ComposerPickerState = { + readonly modelOpen: boolean; + readonly traitsOpen: boolean; + readonly runtimeModeOpen: boolean; + readonly compactControlsMenuOpenSource: CompactControlsMenuOpenSource | null; +}; + +export function resolveComposerPickerOpenChange( + current: ComposerPickerState, + picker: ComposerPicker, + open: boolean, + compactControlsMenuOpenSource: CompactControlsMenuOpenSource = "direct", +): ComposerPickerState { + if (!open) { + switch (picker) { + case "model": + return { ...current, modelOpen: false }; + case "traits": + return { ...current, traitsOpen: false }; + case "runtime-mode": + return { ...current, runtimeModeOpen: false }; + case "compact-controls-menu": + return { ...current, compactControlsMenuOpenSource: null }; + } + } + + return { + modelOpen: picker === "model", + traitsOpen: picker === "traits", + runtimeModeOpen: picker === "runtime-mode", + compactControlsMenuOpenSource: + picker === "compact-controls-menu" ? compactControlsMenuOpenSource : null, + }; +} export function toggleCompactControlsMenuForShortcut( current: CompactControlsMenuOpenSource | null, From 6e28fd63d56b037e70db087dcb22296195918550 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Thu, 23 Jul 2026 09:30:05 -0700 Subject: [PATCH 11/11] fix(web): complete composer shortcut feedback --- .../components/BranchToolbar.logic.test.ts | 12 ++ .../web/src/components/BranchToolbar.logic.ts | 8 + apps/web/src/components/BranchToolbar.tsx | 22 ++- .../BranchToolbarBranchSelector.tsx | 9 +- apps/web/src/components/chat/ChatComposer.tsx | 16 +- .../chat/CompactComposerControlsMenu.test.ts | 54 +++++++ .../chat/CompactComposerControlsMenu.tsx | 151 ++++++++++-------- 7 files changed, 203 insertions(+), 69 deletions(-) create mode 100644 apps/web/src/components/chat/CompactComposerControlsMenu.test.ts diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index 64adaab93e9..406eaabeab4 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -8,6 +8,7 @@ import { resolveBranchToolbarRunContextShortcutTarget, resolveBranchSelectionTarget, resolveBranchPickerQueryForOpenState, + resolveBranchPickerShortcutOpenState, resolveBranchToolbarPickerOpenChange, resolveCurrentWorkspaceLabel, resolveDraftEnvModeAfterBranchChange, @@ -163,6 +164,17 @@ describe("shouldShowBranchPickerShortcutHint", () => { }); }); +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(""); diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index c269ef3cbe3..7edf1da98d0 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -74,6 +74,14 @@ export function shouldShowBranchPickerShortcutHint(input: { ); } +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, diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index c8afd5cce68..bf8a73001d8 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -37,6 +37,7 @@ import { import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector"; import { BranchToolbarEnvModeSelector } from "./BranchToolbarEnvModeSelector"; import { Button } from "./ui/button"; +import { Kbd } from "./ui/kbd"; import { Menu, MenuGroup, @@ -83,6 +84,8 @@ interface MobileRunContextSelectorProps { showEnvironmentPicker: boolean; showEnvironmentIndicator: boolean; pickerAvailable: boolean; + environmentShortcutHintLabel: string | null; + envModeShortcutHintLabel: string | null; open: boolean; onOpenChange: (open: boolean) => void; onEnvironmentChange: ((environmentId: EnvironmentId) => void) | undefined; @@ -99,6 +102,8 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ showEnvironmentPicker, showEnvironmentIndicator, pickerAvailable, + environmentShortcutHintLabel, + envModeShortcutHintLabel, open, onOpenChange, onEnvironmentChange, @@ -156,6 +161,14 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ 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} @@ -390,6 +403,9 @@ export const BranchToolbar = memo(function BranchToolbar({ context: shortcutContext, }) : null; + const availableEnvironmentHintLabel = + environmentShortcutTarget !== null ? environmentHintLabel : null; + const availableEnvModeHintLabel = envModeShortcutTarget !== null ? envModeHintLabel : null; if (!hasActiveThread || !activeProject) return null; @@ -404,6 +420,8 @@ export const BranchToolbar = memo(function BranchToolbar({ showEnvironmentPicker={showEnvironmentPicker} showEnvironmentIndicator={showEnvironmentIndicator} pickerAvailable={pickerAvailability.mobileRunContext} + environmentShortcutHintLabel={availableEnvironmentHintLabel} + envModeShortcutHintLabel={availableEnvModeHintLabel} open={mobileRunContextOpen} onOpenChange={(open) => handlePickerOpenChange("mobile-run-context", open)} onEnvironmentChange={onEnvironmentChange} @@ -421,7 +439,7 @@ export const BranchToolbar = memo(function BranchToolbar({ availableEnvironments={availableEnvironments} open={environmentPickerOpen} onOpenChange={(open) => handlePickerOpenChange("environment", open)} - shortcutHintLabel={environmentHintLabel} + shortcutHintLabel={availableEnvironmentHintLabel} {...(showEnvironmentPicker && onEnvironmentChange ? { onEnvironmentChange } : {})} /> @@ -433,7 +451,7 @@ export const BranchToolbar = memo(function BranchToolbar({ activeWorktreePath={activeWorktreePath} open={envModePickerOpen} onOpenChange={(open) => handlePickerOpenChange("env-mode", open)} - shortcutHintLabel={envModeHintLabel} + shortcutHintLabel={availableEnvModeHintLabel} onEnvModeChange={onEnvModeChange} /> diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index e8c19d846a8..74744ca5305 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -37,6 +37,7 @@ import { getSourceControlPresentation } from "../sourceControlPresentation"; import { deriveLocalBranchNameFromRemoteRef, resolveBranchPickerQueryForOpenState, + resolveBranchPickerShortcutOpenState, resolveBranchSelectionTarget, resolveBranchToolbarValue, resolveDraftEnvModeAfterBranchChange, @@ -536,8 +537,12 @@ export function BranchToolbarBranchSelector({ selectorRef, () => ({ togglePicker: () => { - if (isInitialBranchesLoadPending || isBranchActionPending) return false; - handleOpenChange(!isBranchMenuOpen); + const nextOpen = resolveBranchPickerShortcutOpenState({ + open: isBranchMenuOpen, + unavailable: isInitialBranchesLoadPending || isBranchActionPending, + }); + if (nextOpen === null) return false; + handleOpenChange(nextOpen); return true; }, }), diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index bc8cf46f02c..d4b65a30e1e 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -79,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"; @@ -2853,6 +2856,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) planSidebarOpen={planSidebarOpen} runtimeMode={runtimeMode} showInteractionModeToggle={composerProviderControls.showInteractionModeToggle} + shortcutHintLabel={compactComposerShortcutHintLabel({ + modelOptions: composerControlHintLabels?.modelOptionsPicker ?? null, + modelOptionsAvailable: providerTraitsMenuContent !== null, + runtimeMode: composerControlHintLabels?.runtimeModePicker ?? null, + runtimeModeAvailable: !isComposerCollapsedMobile && !isComposerApprovalState, + planMode: composerControlHintLabels?.planMode ?? null, + planModeAvailable: + !isComposerCollapsedMobile && + !isComposerApprovalState && + composerProviderControls.showInteractionModeToggle, + })} traitsMenuContent={providerTraitsMenuContent} open={isCompactControlsMenuOpen} onOpenChange={(open) => { diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.test.ts b/apps/web/src/components/chat/CompactComposerControlsMenu.test.ts new file mode 100644 index 00000000000..177441edd2c --- /dev/null +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { compactComposerShortcutHintLabel } from "./CompactComposerControlsMenu"; + +describe("compactComposerShortcutHintLabel", () => { + it("keeps every available compact-control hint visible", () => { + expect( + compactComposerShortcutHintLabel({ + modelOptions: "⌘O", + modelOptionsAvailable: true, + runtimeMode: "⌘R", + runtimeModeAvailable: true, + planMode: "⌘P", + planModeAvailable: true, + }), + ).toBe("Options ⌘O · Access ⌘R · Plan ⌘P"); + }); + + it("omits unavailable controls and hides an empty hint", () => { + expect( + compactComposerShortcutHintLabel({ + modelOptions: null, + modelOptionsAvailable: true, + runtimeMode: "⌘R", + runtimeModeAvailable: true, + planMode: null, + planModeAvailable: true, + }), + ).toBe("Access ⌘R"); + expect( + compactComposerShortcutHintLabel({ + modelOptions: null, + modelOptionsAvailable: true, + runtimeMode: null, + runtimeModeAvailable: true, + planMode: null, + planModeAvailable: true, + }), + ).toBeNull(); + }); + + it("omits labels whose corresponding action is unavailable", () => { + expect( + compactComposerShortcutHintLabel({ + modelOptions: "⌘O", + modelOptionsAvailable: false, + runtimeMode: "⌘R", + runtimeModeAvailable: true, + planMode: "⌘P", + planModeAvailable: false, + }), + ).toBe("Access ⌘R"); + }); +}); diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx index 4d880f8e833..c522f66f6fa 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx @@ -1,5 +1,5 @@ import { ProviderInteractionMode, RuntimeMode } from "@t3tools/contracts"; -import { memo, type ReactNode } from "react"; +import { memo, type ReactNode, useRef } from "react"; import { EllipsisIcon, ListTodoIcon } from "lucide-react"; import { Button } from "../ui/button"; import { @@ -11,6 +11,23 @@ import { MenuSeparator as MenuDivider, MenuTrigger, } from "../ui/menu"; +import { ComposerControlShortcutHint } from "./ComposerControlShortcutHint"; + +export function compactComposerShortcutHintLabel(input: { + modelOptions: string | null; + modelOptionsAvailable: boolean; + runtimeMode: string | null; + runtimeModeAvailable: boolean; + planMode: string | null; + planModeAvailable: boolean; +}): string | null { + const labels = [ + input.modelOptionsAvailable && input.modelOptions ? `Options ${input.modelOptions}` : null, + input.runtimeModeAvailable && input.runtimeMode ? `Access ${input.runtimeMode}` : null, + input.planModeAvailable && input.planMode ? `Plan ${input.planMode}` : null, + ].filter((label): label is string => label !== null); + return labels.length > 0 ? labels.join(" · ") : null; +} export const CompactComposerControlsMenu = memo(function CompactComposerControlsMenu(props: { activePlan: boolean; @@ -20,77 +37,83 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls runtimeMode: RuntimeMode; showInteractionModeToggle: boolean; traitsMenuContent?: ReactNode; + shortcutHintLabel: string | null; open?: boolean; onOpenChange?: (open: boolean) => void; onToggleInteractionMode: () => void; onTogglePlanSidebar: () => void; onRuntimeModeChange: (mode: RuntimeMode) => void; }) { + const triggerRef = useRef(null); return ( - - - } + <> + - + + + {props.traitsMenuContent ? ( + <> + {props.traitsMenuContent} + + + ) : null} + {props.showInteractionModeToggle ? ( + <> +
Mode
+ { + if (!value || value === props.interactionMode) return; + props.onToggleInteractionMode(); + }} + > + Chat + Plan + + + + ) : null} +
Access
+ { + if (!value || value === props.runtimeMode) return; + props.onRuntimeModeChange(value as RuntimeMode); + }} + > + Supervised + Auto-accept edits + Auto + Full access + + {props.activePlan ? ( + <> + + + + {props.planSidebarOpen + ? `Hide ${props.planSidebarLabel.toLowerCase()} sidebar` + : `Show ${props.planSidebarLabel.toLowerCase()} sidebar`} + + + ) : null} +
+
+ + ); });