| author | |
| committer | |
| log | 9130d932f4ddb8abab595b7d12553b46a11c7b22 |
| tree | 9aeccfa9a844542051bfe2a2a5d9181b0e55d028 |
| parent | 33118d9ab263904e4180b3bf2cbf0a36fe0695e2 |
| signature | Signed by PGP key B5690EEEBB952194 |
24 files changed, 694 insertions(+), 49 deletions(-)
apps/server/src/keybindings.ts+1-20| ... | @@ -45,7 +45,7 @@ import { writeFileStringAtomically } from "./atomicWrite.ts"; | ... | @@ -45,7 +45,7 @@ import { writeFileStringAtomically } from "./atomicWrite.ts"; |
| 45 | import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson"; | 45 | import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson"; |
| 46 | import { | 46 | import { |
| 47 | DEFAULT_KEYBINDINGS, | 47 | DEFAULT_KEYBINDINGS, |
| 48 | DEFAULT_RESOLVED_KEYBINDINGS, | 48 | mergeWithDefaultKeybindings, |
| 49 | compileResolvedKeybindingRule, | 49 | compileResolvedKeybindingRule, |
| 50 | compileResolvedKeybindingsConfig, | 50 | compileResolvedKeybindingsConfig, |
| 51 | parseKeybindingShortcut, | 51 | parseKeybindingShortcut, |
| ... | @@ -201,25 +201,6 @@ function invalidEntryIssue(index: number, detail: string): ServerConfigIssue { | ... | @@ -201,25 +201,6 @@ function invalidEntryIssue(index: number, detail: string): ServerConfigIssue { |
| 201 | }; | 201 | }; |
| 202 | } | 202 | } |
| 203 | 203 | ||
| 204 | function mergeWithDefaultKeybindings(custom: ResolvedKeybindingsConfig): ResolvedKeybindingsConfig { | ||
| 205 | if (custom.length === 0) { | ||
| 206 | return [...DEFAULT_RESOLVED_KEYBINDINGS]; | ||
| 207 | } | ||
| 208 | |||
| 209 | const overriddenCommands = new Set(custom.map((binding) => binding.command)); | ||
| 210 | const retainedDefaults = DEFAULT_RESOLVED_KEYBINDINGS.filter( | ||
| 211 | (binding) => !overriddenCommands.has(binding.command), | ||
| 212 | ); | ||
| 213 | const merged = [...retainedDefaults, ...custom]; | ||
| 214 | |||
| 215 | if (merged.length <= MAX_KEYBINDINGS_COUNT) { | ||
| 216 | return merged; | ||
| 217 | } | ||
| 218 | |||
| 219 | // Keep the latest rules when the config exceeds max size; later rules have higher precedence. | ||
| 220 | return merged.slice(-MAX_KEYBINDINGS_COUNT); | ||
| 221 | } | ||
| 222 | |||
| 223 | /** | 204 | /** |
| 224 | * Keybindings - Service tag for keybinding configuration operations. | 205 | * Keybindings - Service tag for keybinding configuration operations. |
| 225 | */ | 206 | */ |
apps/web/src/components/BranchToolbar.tsx+47-2| ... | @@ -8,7 +8,17 @@ import { | ... | @@ -8,7 +8,17 @@ import { |
| 8 | HistoryIcon, | 8 | HistoryIcon, |
| 9 | ScaleIcon, | 9 | ScaleIcon, |
| 10 | } from "lucide-react"; | 10 | } from "lucide-react"; |
| 11 | import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; | 11 | import { |
| 12 | type Ref, | ||
| 13 | memo, | ||
| 14 | useImperativeHandle, | ||
| 15 | useCallback, | ||
| 16 | useEffect, | ||
| 17 | useLayoutEffect, | ||
| 18 | useMemo, | ||
| 19 | useRef, | ||
| 20 | useState, | ||
| 21 | } from "react"; | ||
| 12 | 22 | ||
| 13 | import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; | 23 | import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; |
| 14 | import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; | 24 | import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; |
| ... | @@ -25,7 +35,10 @@ import { | ... | @@ -25,7 +35,10 @@ import { |
| 25 | resolvePreviousWorktreeSeed, | 35 | resolvePreviousWorktreeSeed, |
| 26 | shouldShowEnvironmentIndicator, | 36 | shouldShowEnvironmentIndicator, |
| 27 | } from "./BranchToolbar.logic"; | 37 | } from "./BranchToolbar.logic"; |
| 28 | import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector"; | 38 | import { |
| 39 | BranchToolbarBranchSelector, | ||
| 40 | type BranchToolbarBranchSelectorHandle, | ||
| 41 | } from "./BranchToolbarBranchSelector"; | ||
| 29 | import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector"; | 42 | import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector"; |
| 30 | import { BranchToolbarEnvModeSelector } from "./BranchToolbarEnvModeSelector"; | 43 | import { BranchToolbarEnvModeSelector } from "./BranchToolbarEnvModeSelector"; |
| 31 | import { Button } from "./ui/button"; | 44 | import { Button } from "./ui/button"; |
| ... | @@ -46,7 +59,13 @@ import { measureRestingComposerControls } from "./chat/restingComposerControlsMe | ... | @@ -46,7 +59,13 @@ import { measureRestingComposerControls } from "./chat/restingComposerControlsMe |
| 46 | import { resolveRestingComposerControlsNaturalWidth } from "./composerFooterLayout"; | 59 | import { resolveRestingComposerControlsNaturalWidth } from "./composerFooterLayout"; |
| 47 | import { cn } from "~/lib/utils"; | 60 | import { cn } from "~/lib/utils"; |
| 48 | 61 | ||
| 62 | export interface BranchToolbarHandle { | ||
| 63 | openBranchPicker: () => void; | ||
| 64 | usePreviousWorktree: () => void; | ||
| 65 | } | ||
| 66 | |||
| 49 | interface BranchToolbarProps { | 67 | interface BranchToolbarProps { |
| 68 | ref?: Ref<BranchToolbarHandle>; | ||
| 50 | environmentId: EnvironmentId; | 69 | environmentId: EnvironmentId; |
| 51 | threadId: ThreadId; | 70 | threadId: ThreadId; |
| 52 | showGitControls: boolean; | 71 | showGitControls: boolean; |
| ... | @@ -169,6 +188,10 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ | ... | @@ -169,6 +188,10 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ |
| 169 | render={<Button variant="ghost" size="xs" />} | 188 | render={<Button variant="ghost" size="xs" />} |
| 170 | className="min-w-0 max-w-[48%] flex-initial justify-start font-normal text-muted-foreground/70 text-xs! hover:text-foreground/80" | 189 | className="min-w-0 max-w-[48%] flex-initial justify-start font-normal text-muted-foreground/70 text-xs! hover:text-foreground/80" |
| 171 | data-composer-context-control | 190 | data-composer-context-control |
| 191 | data-composer-shortcut={[ | ||
| 192 | showEnvironmentPicker && !envLocked ? "composer.host" : "", | ||
| 193 | !envModeLocked ? "composer.workspace" : "", | ||
| 194 | ].join(" ")} | ||
| 172 | > | 195 | > |
| 173 | {triggerContent} | 196 | {triggerContent} |
| 174 | <ChevronDownIcon className="size-3 shrink-0 opacity-50" /> | 197 | <ChevronDownIcon className="size-3 shrink-0 opacity-50" /> |
| ... | @@ -438,6 +461,7 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { | ... | @@ -438,6 +461,7 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { |
| 438 | } | 461 | } |
| 439 | 462 | ||
| 440 | export const BranchToolbar = memo(function BranchToolbar({ | 463 | export const BranchToolbar = memo(function BranchToolbar({ |
| 464 | ref, | ||
| 441 | environmentId, | 465 | environmentId, |
| 442 | threadId, | 466 | threadId, |
| 443 | showGitControls, | 467 | showGitControls, |
| ... | @@ -458,6 +482,7 @@ export const BranchToolbar = memo(function BranchToolbar({ | ... | @@ -458,6 +482,7 @@ export const BranchToolbar = memo(function BranchToolbar({ |
| 458 | composerControlsHostRef, | 482 | composerControlsHostRef, |
| 459 | contextStripVisible = true, | 483 | contextStripVisible = true, |
| 460 | }: BranchToolbarProps) { | 484 | }: BranchToolbarProps) { |
| 485 | const branchSelectorRef = useRef<BranchToolbarBranchSelectorHandle>(null); | ||
| 461 | const threadRef = useMemo( | 486 | const threadRef = useMemo( |
| 462 | () => scopeThreadRef(environmentId, threadId), | 487 | () => scopeThreadRef(environmentId, threadId), |
| 463 | [environmentId, threadId], | 488 | [environmentId, threadId], |
| ... | @@ -518,6 +543,25 @@ export const BranchToolbar = memo(function BranchToolbar({ | ... | @@ -518,6 +543,25 @@ export const BranchToolbar = memo(function BranchToolbar({ |
| 518 | }); | 543 | }); |
| 519 | }, [activeProjectRef, draftId, previousWorktreeSeed, setDraftThreadContext, threadRef]); | 544 | }, [activeProjectRef, draftId, previousWorktreeSeed, setDraftThreadContext, threadRef]); |
| 520 | 545 | ||
| 546 | useImperativeHandle( | ||
| 547 | ref, | ||
| 548 | () => ({ | ||
| 549 | openBranchPicker: () => branchSelectorRef.current?.open(), | ||
| 550 | usePreviousWorktree: () => { | ||
| 551 | if (!showGitControls || !canUsePreviousWorktree || !previousWorktreeSeed) return; | ||
| 552 | onUsePreviousWorktree(); | ||
| 553 | onComposerFocusRequest?.(); | ||
| 554 | }, | ||
| 555 | }), | ||
| 556 | [ | ||
| 557 | canUsePreviousWorktree, | ||
| 558 | onComposerFocusRequest, | ||
| 559 | onUsePreviousWorktree, | ||
| 560 | previousWorktreeSeed, | ||
| 561 | showGitControls, | ||
| 562 | ], | ||
| 563 | ); | ||
| 564 | |||
| 521 | const showEnvironmentPicker = Boolean( | 565 | const showEnvironmentPicker = Boolean( |
| 522 | availableEnvironments && availableEnvironments.length > 1 && onEnvironmentChange, | 566 | availableEnvironments && availableEnvironments.length > 1 && onEnvironmentChange, |
| 523 | ); | 567 | ); |
| ... | @@ -618,6 +662,7 @@ export const BranchToolbar = memo(function BranchToolbar({ | ... | @@ -618,6 +662,7 @@ export const BranchToolbar = memo(function BranchToolbar({ |
| 618 | 662 | ||
| 619 | {showGitControls ? ( | 663 | {showGitControls ? ( |
| 620 | <BranchToolbarBranchSelector | 664 | <BranchToolbarBranchSelector |
| 665 | ref={branchSelectorRef} | ||
| 621 | className="min-w-0 flex-initial justify-end @3xl/composer-surface:ml-auto" | 666 | className="min-w-0 flex-initial justify-end @3xl/composer-surface:ml-auto" |
| 622 | environmentId={environmentId} | 667 | environmentId={environmentId} |
| 623 | threadId={threadId} | 668 | threadId={threadId} |
apps/web/src/components/BranchToolbarBranchSelector.tsx+19| ... | @@ -15,6 +15,7 @@ import { | ... | @@ -15,6 +15,7 @@ import { |
| 15 | useDeferredValue, | 15 | useDeferredValue, |
| 16 | useEffect, | 16 | useEffect, |
| 17 | useId, | 17 | useId, |
| 18 | useImperativeHandle, | ||
| 18 | useLayoutEffect, | 19 | useLayoutEffect, |
| 19 | useMemo, | 20 | useMemo, |
| 20 | useOptimistic, | 21 | useOptimistic, |
| ... | @@ -22,6 +23,7 @@ import { | ... | @@ -22,6 +23,7 @@ import { |
| 22 | useState, | 23 | useState, |
| 23 | useTransition, | 24 | useTransition, |
| 24 | type MouseEvent as ReactMouseEvent, | 25 | type MouseEvent as ReactMouseEvent, |
| 26 | type Ref, | ||
| 25 | } from "react"; | 27 | } from "react"; |
| 26 | 28 | ||
| 27 | import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; | 29 | import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; |
| ... | @@ -72,7 +74,12 @@ import { | ... | @@ -72,7 +74,12 @@ import { |
| 72 | import { stackedThreadToast, toastManager } from "./ui/toast"; | 74 | import { stackedThreadToast, toastManager } from "./ui/toast"; |
| 73 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; | 75 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 74 | 76 | ||
| 77 | export interface BranchToolbarBranchSelectorHandle { | ||
| 78 | open: () => void; | ||
| 79 | } | ||
| 80 | |||
| 75 | interface BranchToolbarBranchSelectorProps { | 81 | interface BranchToolbarBranchSelectorProps { |
| 82 | ref?: Ref<BranchToolbarBranchSelectorHandle>; | ||
| 76 | className?: string; | 83 | className?: string; |
| 77 | environmentId: EnvironmentId; | 84 | environmentId: EnvironmentId; |
| 78 | threadId: ThreadId; | 85 | threadId: ThreadId; |
| ... | @@ -92,6 +99,7 @@ function toBranchActionErrorMessage(error: unknown): string { | ... | @@ -92,6 +99,7 @@ function toBranchActionErrorMessage(error: unknown): string { |
| 92 | } | 99 | } |
| 93 | 100 | ||
| 94 | export function BranchToolbarBranchSelector({ | 101 | export function BranchToolbarBranchSelector({ |
| 102 | ref, | ||
| 95 | className, | 103 | className, |
| 96 | environmentId, | 104 | environmentId, |
| 97 | threadId, | 105 | threadId, |
| ... | @@ -535,6 +543,17 @@ export function BranchToolbarBranchSelector({ | ... | @@ -535,6 +543,17 @@ export function BranchToolbarBranchSelector({ |
| 535 | } | 543 | } |
| 536 | }, []); | 544 | }, []); |
| 537 | 545 | ||
| 546 | useImperativeHandle( | ||
| 547 | ref, | ||
| 548 | () => ({ | ||
| 549 | open: () => { | ||
| 550 | if (isInitialBranchesLoadPending || isBranchActionPending) return; | ||
| 551 | handleOpenChange(true); | ||
| 552 | }, | ||
| 553 | }), | ||
| 554 | [handleOpenChange, isBranchActionPending, isInitialBranchesLoadPending], | ||
| 555 | ); | ||
| 556 | |||
| 538 | const [showTopBranchScrollFade, setShowTopBranchScrollFade] = useState(false); | 557 | const [showTopBranchScrollFade, setShowTopBranchScrollFade] = useState(false); |
| 539 | const [showBottomBranchScrollFade, setShowBottomBranchScrollFade] = useState(false); | 558 | const [showBottomBranchScrollFade, setShowBottomBranchScrollFade] = useState(false); |
| 540 | const fetchNextBranchPage = useCallback(() => { | 559 | const fetchNextBranchPage = useCallback(() => { |
apps/web/src/components/BranchToolbarEnvModeSelector.tsx+1| ... | @@ -93,6 +93,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe | ... | @@ -93,6 +93,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe |
| 93 | size="xs" | 93 | size="xs" |
| 94 | className="min-w-0 shrink font-normal text-xs!" | 94 | className="min-w-0 shrink font-normal text-xs!" |
| 95 | aria-label="Workspace" | 95 | aria-label="Workspace" |
| 96 | data-composer-shortcut="composer.workspace" | ||
| 96 | data-composer-context-control | 97 | data-composer-context-control |
| 97 | > | 98 | > |
| 98 | {effectiveEnvMode === "worktree" ? ( | 99 | {effectiveEnvMode === "worktree" ? ( |
apps/web/src/components/BranchToolbarEnvironmentSelector.tsx+1| ... | @@ -95,6 +95,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir | ... | @@ -95,6 +95,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir |
| 95 | size="xs" | 95 | size="xs" |
| 96 | className="min-w-0 max-w-full font-normal text-xs!" | 96 | className="min-w-0 max-w-full font-normal text-xs!" |
| 97 | aria-label="Run on" | 97 | aria-label="Run on" |
| 98 | data-composer-shortcut="composer.host" | ||
| 98 | data-composer-context-control | 99 | data-composer-context-control |
| 99 | > | 100 | > |
| 100 | {autoEnvironmentLabel ? ( | 101 | {autoEnvironmentLabel ? ( |
apps/web/src/components/ChatView.tsx+47-10| ... | @@ -219,7 +219,7 @@ import { | ... | @@ -219,7 +219,7 @@ import { |
| 219 | deriveAgentPanelModel, | 219 | deriveAgentPanelModel, |
| 220 | foldSubagentActivities, | 220 | foldSubagentActivities, |
| 221 | } from "@t3tools/client-runtime/state/subagentRuntime"; | 221 | } from "@t3tools/client-runtime/state/subagentRuntime"; |
| 222 | import { BranchToolbar } from "./BranchToolbar"; | 222 | import { BranchToolbar, type BranchToolbarHandle } from "./BranchToolbar"; |
| 223 | import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; | 223 | import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; |
| 224 | import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; | 224 | import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; |
| 225 | import { | 225 | import { |
| ... | @@ -1617,6 +1617,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -1617,6 +1617,7 @@ export default function ChatView(props: ChatViewProps) { |
| 1617 | const composerTerminalContextsRef = useRef<TerminalContextDraft[]>([]); | 1617 | const composerTerminalContextsRef = useRef<TerminalContextDraft[]>([]); |
| 1618 | const localComposerRef = useRef<ChatComposerHandle | null>(null); | 1618 | const localComposerRef = useRef<ChatComposerHandle | null>(null); |
| 1619 | const composerRef = useComposerHandleContext() ?? localComposerRef; | 1619 | const composerRef = useComposerHandleContext() ?? localComposerRef; |
| 1620 | const branchToolbarRef = useRef<BranchToolbarHandle>(null); | ||
| 1620 | const pasteAsTextShortcutUntilRef = useRef(0); | 1621 | const pasteAsTextShortcutUntilRef = useRef(0); |
| 1621 | const [restingComposerControlsHost, setRestingComposerControlsHost] = | 1622 | const [restingComposerControlsHost, setRestingComposerControlsHost] = |
| 1622 | useState<HTMLDivElement | null>(null); | 1623 | useState<HTMLDivElement | null>(null); |
| ... | @@ -6397,6 +6398,17 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -6397,6 +6398,17 @@ export default function ChatView(props: ChatViewProps) { |
| 6397 | terminalUiOpenByThreadRef.current[activeThreadKey] = current; | 6398 | terminalUiOpenByThreadRef.current[activeThreadKey] = current; |
| 6398 | }, [activeThreadKey, focusComposer, terminalUiState.terminalOpen]); | 6399 | }, [activeThreadKey, focusComposer, terminalUiState.terminalOpen]); |
| 6399 | 6400 | ||
| 6401 | const getShortcutContext = useCallback( | ||
| 6402 | () => ({ | ||
| 6403 | terminalFocus: getTerminalFocusOwner() !== null, | ||
| 6404 | terminalOpen: Boolean(terminalUiState.terminalOpen), | ||
| 6405 | previewFocus: isPreviewFocused(), | ||
| 6406 | previewOpen: previewPanelOpen, | ||
| 6407 | modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false, | ||
| 6408 | }), | ||
| 6409 | [composerRef, previewPanelOpen, terminalUiState.terminalOpen], | ||
| 6410 | ); | ||
| 6411 | |||
| 6400 | useEffect(() => { | 6412 | useEffect(() => { |
| 6401 | const handler = (event: globalThis.KeyboardEvent) => { | 6413 | const handler = (event: globalThis.KeyboardEvent) => { |
| 6402 | if (preventRepeatedTerminalCloseShortcut(event, keybindings)) { | 6414 | if (preventRepeatedTerminalCloseShortcut(event, keybindings)) { |
| ... | @@ -6417,13 +6429,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -6417,13 +6429,7 @@ export default function ChatView(props: ChatViewProps) { |
| 6417 | if (event.defaultPrevented && terminalFocusOwner === null) { | 6429 | if (event.defaultPrevented && terminalFocusOwner === null) { |
| 6418 | return; | 6430 | return; |
| 6419 | } | 6431 | } |
| 6420 | const shortcutContext = { | 6432 | const shortcutContext = getShortcutContext(); |
| 6421 | terminalFocus: terminalFocusOwner !== null, | ||
| 6422 | terminalOpen: Boolean(terminalUiState.terminalOpen), | ||
| 6423 | previewFocus: isPreviewFocused(), | ||
| 6424 | previewOpen: previewPanelOpen, | ||
| 6425 | modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false, | ||
| 6426 | }; | ||
| 6427 | 6433 | ||
| 6428 | if ( | 6434 | if ( |
| 6429 | !shortcutContext.terminalFocus && | 6435 | !shortcutContext.terminalFocus && |
| ... | @@ -6588,7 +6594,33 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -6588,7 +6594,33 @@ export default function ChatView(props: ChatViewProps) { |
| 6588 | if (command === "modelPicker.toggle") { | 6594 | if (command === "modelPicker.toggle") { |
| 6589 | event.preventDefault(); | 6595 | event.preventDefault(); |
| 6590 | event.stopPropagation(); | 6596 | event.stopPropagation(); |
| 6591 | composerRef.current?.toggleModelPicker(); | 6597 | if (!event.repeat) composerRef.current?.toggleModelPicker(); |
| 6598 | return; | ||
| 6599 | } | ||
| 6600 | |||
| 6601 | if ( | ||
| 6602 | command === "composer.host" || | ||
| 6603 | command === "composer.effort" || | ||
| 6604 | command === "composer.mode" || | ||
| 6605 | command === "composer.workspace" | ||
| 6606 | ) { | ||
| 6607 | event.preventDefault(); | ||
| 6608 | event.stopPropagation(); | ||
| 6609 | if (!event.repeat) composerRef.current?.openControl(command); | ||
| 6610 | return; | ||
| 6611 | } | ||
| 6612 | |||
| 6613 | if (command === "composer.branch") { | ||
| 6614 | event.preventDefault(); | ||
| 6615 | event.stopPropagation(); | ||
| 6616 | if (!event.repeat) branchToolbarRef.current?.openBranchPicker(); | ||
| 6617 | return; | ||
| 6618 | } | ||
| 6619 | |||
| 6620 | if (command === "composer.previousWorktree") { | ||
| 6621 | event.preventDefault(); | ||
| 6622 | event.stopPropagation(); | ||
| 6623 | if (!event.repeat) branchToolbarRef.current?.usePreviousWorktree(); | ||
| 6592 | return; | 6624 | return; |
| 6593 | } | 6625 | } |
| 6594 | 6626 | ||
| ... | @@ -6643,7 +6675,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -6643,7 +6675,7 @@ export default function ChatView(props: ChatViewProps) { |
| 6643 | supportsSettlement, | 6675 | supportsSettlement, |
| 6644 | confirmAndUnpinThread, | 6676 | confirmAndUnpinThread, |
| 6645 | copyActiveThreadReference, | 6677 | copyActiveThreadReference, |
| 6646 | previewPanelOpen, | 6678 | getShortcutContext, |
| 6647 | toggleRightPanel, | 6679 | toggleRightPanel, |
| 6648 | toggleRightPanelMaximized, | 6680 | toggleRightPanelMaximized, |
| 6649 | toggleTerminalVisibility, | 6681 | toggleTerminalVisibility, |
| ... | @@ -8708,6 +8740,10 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -8708,6 +8740,10 @@ export default function ChatView(props: ChatViewProps) { |
| 8708 | // reader's feet. A link the agent wrote can open any other one here, and that one has to be | 8740 | // reader's feet. A link the agent wrote can open any other one here, and that one has to be |
| 8709 | // checkable out like it is anywhere else. | 8741 | // checkable out like it is anywhere else. |
| 8710 | <PullRequestDetailPanel | 8742 | <PullRequestDetailPanel |
| 8743 | getShortcutContext={getShortcutContext} | ||
| 8744 | shortcutsEnabled={ | ||
| 8745 | rightPanelOpen && activeRightPanelSurface?.id === renderedRightPanelSurface.id | ||
| 8746 | } | ||
| 8711 | key={`${renderedRightPanelSurface.host ?? ""}:${renderedRightPanelSurface.repository}#${renderedRightPanelSurface.number}`} | 8747 | key={`${renderedRightPanelSurface.host ?? ""}:${renderedRightPanelSurface.repository}#${renderedRightPanelSurface.number}`} |
| 8712 | environmentId={activeThread.environmentId} | 8748 | environmentId={activeThread.environmentId} |
| 8713 | onSelectPullRequest={(reference) => { | 8749 | onSelectPullRequest={(reference) => { |
| ... | @@ -9231,6 +9267,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -9231,6 +9267,7 @@ export default function ChatView(props: ChatViewProps) { |
| 9231 | {mountComposerContextStrip && ( | 9267 | {mountComposerContextStrip && ( |
| 9232 | <div className="pointer-events-auto"> | 9268 | <div className="pointer-events-auto"> |
| 9233 | <BranchToolbar | 9269 | <BranchToolbar |
| 9270 | ref={branchToolbarRef} | ||
| 9234 | environmentId={activeThread.environmentId} | 9271 | environmentId={activeThread.environmentId} |
| 9235 | threadId={activeThread.id} | 9272 | threadId={activeThread.id} |
| 9236 | showGitControls={isGitRepo} | 9273 | showGitControls={isGitRepo} |
apps/web/src/components/chat/ChatComposer.tsx+26-1| ... | @@ -15,6 +15,7 @@ import { | ... | @@ -15,6 +15,7 @@ import { |
| 15 | } from "../../questionAttachments"; | 15 | } from "../../questionAttachments"; |
| 16 | import type { | 16 | import type { |
| 17 | ApprovalRequestId, | 17 | ApprovalRequestId, |
| 18 | KeybindingCommand, | ||
| 18 | AssistantCitation, | 19 | AssistantCitation, |
| 19 | ChatFileAttachment, | 20 | ChatFileAttachment, |
| 20 | EnvironmentId, | 21 | EnvironmentId, |
| ... | @@ -62,7 +63,7 @@ import { | ... | @@ -62,7 +63,7 @@ import { |
| 62 | useState, | 63 | useState, |
| 63 | useSyncExternalStore, | 64 | useSyncExternalStore, |
| 64 | } from "react"; | 65 | } from "react"; |
| 65 | import { createPortal } from "react-dom"; | 66 | import { createPortal, flushSync } from "react-dom"; |
| 66 | import { | 67 | import { |
| 67 | clampCollapsedComposerCursor, | 68 | clampCollapsedComposerCursor, |
| 68 | type ComposerSubmissionIntent, | 69 | type ComposerSubmissionIntent, |
| ... | @@ -1103,6 +1104,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop | ... | @@ -1103,6 +1104,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop |
| 1103 | <TooltipTrigger | 1104 | <TooltipTrigger |
| 1104 | render={ | 1105 | render={ |
| 1105 | <ComposerSelectControl | 1106 | <ComposerSelectControl |
| 1107 | data-composer-shortcut="composer.mode" | ||
| 1106 | size={size} | 1108 | size={size} |
| 1107 | className={size === "xs" ? undefined : "font-medium"} | 1109 | className={size === "xs" ? undefined : "font-medium"} |
| 1108 | aria-label="Runtime mode" | 1110 | aria-label="Runtime mode" |
| ... | @@ -1231,6 +1233,7 @@ export interface ChatComposerHandle { | ... | @@ -1231,6 +1233,7 @@ export interface ChatComposerHandle { |
| 1231 | ) => boolean; | 1233 | ) => boolean; |
| 1232 | openModelPicker: () => void; | 1234 | openModelPicker: () => void; |
| 1233 | toggleModelPicker: () => void; | 1235 | toggleModelPicker: () => void; |
| 1236 | openControl: (command: KeybindingCommand) => void; | ||
| 1234 | isModelPickerOpen: () => boolean; | 1237 | isModelPickerOpen: () => boolean; |
| 1235 | compactContext: () => void; | 1238 | compactContext: () => void; |
| 1236 | readSnapshot: () => { | 1239 | readSnapshot: () => { |
| ... | @@ -5743,6 +5746,28 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -5743,6 +5746,28 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 5743 | openModelPicker(); | 5746 | openModelPicker(); |
| 5744 | } | 5747 | } |
| 5745 | }, | 5748 | }, |
| 5749 | openControl: (command) => { | ||
| 5750 | if (composerBlurFrameRef.current !== null) { | ||
| 5751 | window.cancelAnimationFrame(composerBlurFrameRef.current); | ||
| 5752 | composerBlurFrameRef.current = null; | ||
| 5753 | } | ||
| 5754 | flushSync(() => { | ||
| 5755 | setIsComposerScrollCollapsed(false); | ||
| 5756 | setIsComposerFocused(true); | ||
| 5757 | }); | ||
| 5758 | const shell = composerFormRef.current?.closest('[data-slot="composer-shell"]'); | ||
| 5759 | const trigger = Array.from( | ||
| 5760 | shell?.querySelectorAll<HTMLButtonElement>( | ||
| 5761 | `button[data-composer-shortcut~="${command}"]:not(:disabled)`, | ||
| 5762 | ) ?? [], | ||
| 5763 | ).find( | ||
| 5764 | (element) => | ||
| 5765 | !element.closest("[inert]") && element.checkVisibility({ visibilityProperty: true }), | ||
| 5766 | ); | ||
| 5767 | if (!trigger) return; | ||
| 5768 | trigger.focus({ preventScroll: true }); | ||
| 5769 | trigger.click(); | ||
| 5770 | }, | ||
| 5746 | compactContext: compactThreadContext, | 5771 | compactContext: compactThreadContext, |
| 5747 | isModelPickerOpen: () => isComposerModelPickerOpen, | 5772 | isModelPickerOpen: () => isComposerModelPickerOpen, |
| 5748 | readSnapshot: () => { | 5773 | readSnapshot: () => { |
apps/web/src/components/chat/CompactComposerControlsMenu.tsx+3| ... | @@ -40,6 +40,9 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls | ... | @@ -40,6 +40,9 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls |
| 40 | variant="ghost" | 40 | variant="ghost" |
| 41 | className={size === "xs" ? "shrink-0" : "shrink-0 px-2"} | 41 | className={size === "xs" ? "shrink-0" : "shrink-0 px-2"} |
| 42 | aria-label="More composer controls" | 42 | aria-label="More composer controls" |
| 43 | data-composer-shortcut={ | ||
| 44 | props.traitsMenuContent ? "composer.mode composer.effort" : "composer.mode" | ||
| 45 | } | ||
| 43 | /> | 46 | /> |
| 44 | } | 47 | } |
| 45 | > | 48 | > |
apps/web/src/components/chat/ModelPickerContent.test.ts+72| ... | @@ -8,6 +8,7 @@ import { describe, expect, it } from "vite-plus/test"; | ... | @@ -8,6 +8,7 @@ import { describe, expect, it } from "vite-plus/test"; |
| 8 | 8 | ||
| 9 | import { deriveProviderInstanceEntries } from "../../providerInstances"; | 9 | import { deriveProviderInstanceEntries } from "../../providerInstances"; |
| 10 | import { | 10 | import { |
| 11 | adjacentModelPickerProvider, | ||
| 11 | resolveModelPickerSelectedModel, | 12 | resolveModelPickerSelectedModel, |
| 12 | shouldIncludeModelPickerOption, | 13 | shouldIncludeModelPickerOption, |
| 13 | shouldOfferModelPickerSetup, | 14 | shouldOfferModelPickerSetup, |
| ... | @@ -213,3 +214,74 @@ describe("shouldOfferModelPickerSetup", () => { | ... | @@ -213,3 +214,74 @@ describe("shouldOfferModelPickerSetup", () => { |
| 213 | ).toBe(true); | 214 | ).toBe(true); |
| 214 | }); | 215 | }); |
| 215 | }); | 216 | }); |
| 217 | |||
| 218 | describe("adjacentModelPickerProvider", () => { | ||
| 219 | const codex = entry("ready", "codex"); | ||
| 220 | const claude = entry("ready", "claudeAgent"); | ||
| 221 | const unavailable = entry("error"); | ||
| 222 | const input = { | ||
| 223 | entries: [codex, unavailable, claude], | ||
| 224 | disabledInstanceIds: undefined, | ||
| 225 | selectableUnavailableInstanceIds: undefined, | ||
| 226 | }; | ||
| 227 | |||
| 228 | it("wraps through favorites and ready instances, skipping unavailable providers", () => { | ||
| 229 | expect( | ||
| 230 | adjacentModelPickerProvider({ ...input, selectedInstanceId: codex.instanceId, direction: 1 }), | ||
| 231 | ).toBe(claude.instanceId); | ||
| 232 | expect( | ||
| 233 | adjacentModelPickerProvider({ ...input, selectedInstanceId: "favorites", direction: -1 }), | ||
| 234 | ).toBe(claude.instanceId); | ||
| 235 | expect( | ||
| 236 | adjacentModelPickerProvider({ | ||
| 237 | ...input, | ||
| 238 | selectedInstanceId: claude.instanceId, | ||
| 239 | direction: 1, | ||
| 240 | }), | ||
| 241 | ).toBe("favorites"); | ||
| 242 | }); | ||
| 243 | |||
| 244 | it("keeps thread locks and the selected unavailable catalog", () => { | ||
| 245 | expect( | ||
| 246 | adjacentModelPickerProvider({ | ||
| 247 | ...input, | ||
| 248 | disabledInstanceIds: new Set([claude.instanceId]), | ||
| 249 | selectedInstanceId: codex.instanceId, | ||
| 250 | direction: 1, | ||
| 251 | }), | ||
| 252 | ).toBe("favorites"); | ||
| 253 | expect( | ||
| 254 | adjacentModelPickerProvider({ | ||
| 255 | ...input, | ||
| 256 | selectableUnavailableInstanceIds: new Set([unavailable.instanceId]), | ||
| 257 | selectedInstanceId: codex.instanceId, | ||
| 258 | direction: 1, | ||
| 259 | }), | ||
| 260 | ).toBe(unavailable.instanceId); | ||
| 261 | }); | ||
| 262 | |||
| 263 | it("handles an empty catalog and a removed selection in either direction", () => { | ||
| 264 | expect( | ||
| 265 | adjacentModelPickerProvider({ | ||
| 266 | ...input, | ||
| 267 | entries: [], | ||
| 268 | selectedInstanceId: codex.instanceId, | ||
| 269 | direction: -1, | ||
| 270 | }), | ||
| 271 | ).toBe("favorites"); | ||
| 272 | expect( | ||
| 273 | adjacentModelPickerProvider({ | ||
| 274 | ...input, | ||
| 275 | selectedInstanceId: unavailable.instanceId, | ||
| 276 | direction: 1, | ||
| 277 | }), | ||
| 278 | ).toBe("favorites"); | ||
| 279 | expect( | ||
| 280 | adjacentModelPickerProvider({ | ||
| 281 | ...input, | ||
| 282 | selectedInstanceId: unavailable.instanceId, | ||
| 283 | direction: -1, | ||
| 284 | }), | ||
| 285 | ).toBe(claude.instanceId); | ||
| 286 | }); | ||
| 287 | }); |
apps/web/src/components/chat/ModelPickerContent.tsx+76-1| ... | @@ -113,6 +113,34 @@ export function shouldOfferModelPickerSetup( | ... | @@ -113,6 +113,34 @@ export function shouldOfferModelPickerSetup( |
| 113 | ); | 113 | ); |
| 114 | } | 114 | } |
| 115 | 115 | ||
| 116 | export function adjacentModelPickerProvider(input: { | ||
| 117 | entries: ReadonlyArray<ProviderInstanceEntry>; | ||
| 118 | selectedInstanceId: ProviderInstanceId | "favorites"; | ||
| 119 | direction: 1 | -1; | ||
| 120 | disabledInstanceIds: ReadonlySet<ProviderInstanceId> | undefined; | ||
| 121 | selectableUnavailableInstanceIds: ReadonlySet<ProviderInstanceId> | undefined; | ||
| 122 | }) { | ||
| 123 | const providers: Array<ProviderInstanceId | "favorites"> = [ | ||
| 124 | "favorites", | ||
| 125 | ...input.entries | ||
| 126 | .filter( | ||
| 127 | (entry) => | ||
| 128 | !input.disabledInstanceIds?.has(entry.instanceId) && | ||
| 129 | (isProviderInstancePickerReady(entry) || | ||
| 130 | input.selectableUnavailableInstanceIds?.has(entry.instanceId)), | ||
| 131 | ) | ||
| 132 | .map((entry) => entry.instanceId), | ||
| 133 | ]; | ||
| 134 | const index = providers.indexOf(input.selectedInstanceId); | ||
| 135 | return providers[ | ||
| 136 | index < 0 | ||
| 137 | ? input.direction === 1 | ||
| 138 | ? 0 | ||
| 139 | : providers.length - 1 | ||
| 140 | : (index + input.direction + providers.length) % providers.length | ||
| 141 | ]!; | ||
| 142 | } | ||
| 143 | |||
| 116 | const EMPTY_MODEL_JUMP_LABELS = new Map<string, string>(); | 144 | const EMPTY_MODEL_JUMP_LABELS = new Map<string, string>(); |
| 117 | 145 | ||
| 118 | function ModelListSeparator() { | 146 | function ModelListSeparator() { |
| ... | @@ -687,6 +715,20 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { | ... | @@ -687,6 +715,20 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 687 | platform: navigator.platform, | 715 | platform: navigator.platform, |
| 688 | context: modelJumpShortcutContext, | 716 | context: modelJumpShortcutContext, |
| 689 | }); | 717 | }); |
| 718 | if (command === "modelPicker.previousProvider" || command === "modelPicker.nextProvider") { | ||
| 719 | event.preventDefault(); | ||
| 720 | event.stopPropagation(); | ||
| 721 | const next = adjacentModelPickerProvider({ | ||
| 722 | entries: sidebarInstanceEntries, | ||
| 723 | selectedInstanceId, | ||
| 724 | direction: command === "modelPicker.nextProvider" ? 1 : -1, | ||
| 725 | disabledInstanceIds: lockedDisabledInstanceIds, | ||
| 726 | selectableUnavailableInstanceIds, | ||
| 727 | }); | ||
| 728 | setSearchQuery(""); | ||
| 729 | handleSelectInstance(next); | ||
| 730 | return; | ||
| 731 | } | ||
| 690 | const jumpIndex = modelPickerJumpIndexFromCommand(command ?? ""); | 732 | const jumpIndex = modelPickerJumpIndexFromCommand(command ?? ""); |
| 691 | if (jumpIndex === null) { | 733 | if (jumpIndex === null) { |
| 692 | return; | 734 | return; |
| ... | @@ -710,7 +752,17 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { | ... | @@ -710,7 +752,17 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 710 | return () => { | 752 | return () => { |
| 711 | window.removeEventListener("keydown", onWindowKeyDown, true); | 753 | window.removeEventListener("keydown", onWindowKeyDown, true); |
| 712 | }; | 754 | }; |
| 713 | }, [handleModelSelect, keybindings, modelJumpModelKeys, modelJumpShortcutContext]); | 755 | }, [ |
| 756 | handleModelSelect, | ||
| 757 | handleSelectInstance, | ||
| 758 | keybindings, | ||
| 759 | lockedDisabledInstanceIds, | ||
| 760 | modelJumpModelKeys, | ||
| 761 | modelJumpShortcutContext, | ||
| 762 | selectableUnavailableInstanceIds, | ||
| 763 | selectedInstanceId, | ||
| 764 | sidebarInstanceEntries, | ||
| 765 | ]); | ||
| 714 | 766 | ||
| 715 | useLayoutEffect(() => { | 767 | useLayoutEffect(() => { |
| 716 | setShowTopScrollFade(false); | 768 | setShowTopScrollFade(false); |
| ... | @@ -737,6 +789,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { | ... | @@ -737,6 +789,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 737 | <ModelPickerSidebar | 789 | <ModelPickerSidebar |
| 738 | selectedInstanceId={selectedInstanceId} | 790 | selectedInstanceId={selectedInstanceId} |
| 739 | onSelectInstance={handleSelectInstance} | 791 | onSelectInstance={handleSelectInstance} |
| 792 | onFocusSearch={focusSearchInput} | ||
| 740 | instanceEntries={sidebarInstanceEntries} | 793 | instanceEntries={sidebarInstanceEntries} |
| 741 | showFavorites | 794 | showFavorites |
| 742 | {...(selectableUnavailableInstanceIds ? { selectableUnavailableInstanceIds } : {})} | 795 | {...(selectableUnavailableInstanceIds ? { selectableUnavailableInstanceIds } : {})} |
| ... | @@ -805,6 +858,28 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { | ... | @@ -805,6 +858,28 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 805 | value={searchQuery} | 858 | value={searchQuery} |
| 806 | onChange={(e) => setSearchQuery(e.target.value)} | 859 | onChange={(e) => setSearchQuery(e.target.value)} |
| 807 | onKeyDown={(e) => { | 860 | onKeyDown={(e) => { |
| 861 | if ( | ||
| 862 | showSidebar && | ||
| 863 | !e.altKey && | ||
| 864 | !e.ctrlKey && | ||
| 865 | !e.metaKey && | ||
| 866 | ((e.key === "ArrowLeft" && !e.shiftKey && searchQuery.length === 0) || | ||
| 867 | (e.key === "Tab" && e.shiftKey)) | ||
| 868 | ) { | ||
| 869 | const sidebar = e.currentTarget | ||
| 870 | .closest("[data-model-picker-content]") | ||
| 871 | ?.querySelector("[data-model-picker-sidebar]"); | ||
| 872 | const button = | ||
| 873 | sidebar?.querySelector<HTMLButtonElement>( | ||
| 874 | 'button[aria-pressed="true"]:not(:disabled)', | ||
| 875 | ) ?? sidebar?.querySelector<HTMLButtonElement>("button:not(:disabled)"); | ||
| 876 | if (button) { | ||
| 877 | e.preventDefault(); | ||
| 878 | e.stopPropagation(); | ||
| 879 | button.focus(); | ||
| 880 | return; | ||
| 881 | } | ||
| 882 | } | ||
| 808 | if (e.key === "Escape") { | 883 | if (e.key === "Escape") { |
| 809 | e.preventDefault(); | 884 | e.preventDefault(); |
| 810 | e.stopPropagation(); | 885 | e.stopPropagation(); |
apps/web/src/components/chat/ModelPickerSidebar.tsx+24-6| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { Toolbar } from "@base-ui/react/toolbar"; | ||
| 1 | import { type ProviderInstanceId } from "@t3tools/contracts"; | 2 | import { type ProviderInstanceId } from "@t3tools/contracts"; |
| 2 | import { memo, useLayoutEffect, useRef, useState } from "react"; | 3 | import { memo, useLayoutEffect, useRef, useState } from "react"; |
| 3 | import { SparklesIcon, StarIcon } from "lucide-react"; | 4 | import { SparklesIcon, StarIcon } from "lucide-react"; |
| ... | @@ -43,6 +44,7 @@ const PICKER_TOOLTIP_CLASS = "max-w-64 text-balance font-normal leading-snug"; | ... | @@ -43,6 +44,7 @@ const PICKER_TOOLTIP_CLASS = "max-w-64 text-balance font-normal leading-snug"; |
| 43 | export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { | 44 | export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { |
| 44 | selectedInstanceId: ProviderInstanceId | "favorites"; | 45 | selectedInstanceId: ProviderInstanceId | "favorites"; |
| 45 | onSelectInstance: (instanceId: ProviderInstanceId | "favorites") => void; | 46 | onSelectInstance: (instanceId: ProviderInstanceId | "favorites") => void; |
| 47 | onFocusSearch: () => void; | ||
| 46 | /** | 48 | /** |
| 47 | * Instance entries to render as rail buttons. Each entry becomes one icon | 49 | * Instance entries to render as rail buttons. Each entry becomes one icon |
| 48 | * keyed by `instanceId`, so the default built-in Codex and a user-authored | 50 | * keyed by `instanceId`, so the default built-in Codex and a user-authored |
| ... | @@ -87,7 +89,20 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { | ... | @@ -87,7 +89,20 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { |
| 87 | }, [props.instanceEntries, props.selectedInstanceId, showFavorites]); | 89 | }, [props.instanceEntries, props.selectedInstanceId, showFavorites]); |
| 88 | 90 | ||
| 89 | return ( | 91 | return ( |
| 90 | <div className="w-11 shrink-0 overflow-hidden bg-muted/30" data-model-picker-sidebar="true"> | 92 | <Toolbar.Root |
| 93 | className="w-11 shrink-0 overflow-hidden bg-muted/30" | ||
| 94 | data-model-picker-sidebar="true" | ||
| 95 | aria-label="Providers" | ||
| 96 | orientation="vertical" | ||
| 97 | onKeyDown={(event) => { | ||
| 98 | if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return; | ||
| 99 | if (event.key === "ArrowRight") { | ||
| 100 | event.preventDefault(); | ||
| 101 | props.onFocusSearch(); | ||
| 102 | return; | ||
| 103 | } | ||
| 104 | }} | ||
| 105 | > | ||
| 91 | <div className="h-full overflow-y-auto overscroll-contain [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"> | 106 | <div className="h-full overflow-y-auto overscroll-contain [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"> |
| 92 | <div ref={sidebarContentRef} className="relative flex min-h-full flex-col gap-1 p-1"> | 107 | <div ref={sidebarContentRef} className="relative flex min-h-full flex-col gap-1 p-1"> |
| 93 | {selectedIndicatorTop !== null ? ( | 108 | {selectedIndicatorTop !== null ? ( |
| ... | @@ -107,16 +122,17 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { | ... | @@ -107,16 +122,17 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { |
| 107 | <Tooltip> | 122 | <Tooltip> |
| 108 | <TooltipTrigger | 123 | <TooltipTrigger |
| 109 | render={ | 124 | render={ |
| 110 | <button | 125 | <Toolbar.Button |
| 111 | className={cn( | 126 | className={cn( |
| 112 | "relative isolate flex w-full cursor-pointer aspect-square items-center justify-center rounded-md transition-colors hover:bg-[color-mix(in_srgb,var(--popover)_90%,var(--contrast-foreground))] focus-visible:bg-[color-mix(in_srgb,var(--popover)_90%,var(--contrast-foreground))] focus-visible:outline-none", | 127 | "relative isolate flex w-full cursor-pointer aspect-square items-center justify-center rounded-md transition-colors hover:bg-[color-mix(in_srgb,var(--popover)_90%,var(--contrast-foreground))] focus-visible:bg-[color-mix(in_srgb,var(--popover)_90%,var(--contrast-foreground))] focus-visible:outline-none", |
| 113 | )} | 128 | )} |
| 114 | onClick={() => handleSelect("favorites")} | 129 | onClick={() => handleSelect("favorites")} |
| 115 | type="button" | 130 | type="button" |
| 116 | aria-label="Favorites" | 131 | aria-label="Favorites" |
| 132 | aria-pressed={props.selectedInstanceId === "favorites"} | ||
| 117 | > | 133 | > |
| 118 | <StarIcon className="size-5 fill-current shrink-0" aria-hidden /> | 134 | <StarIcon className="size-5 fill-current shrink-0" aria-hidden /> |
| 119 | </button> | 135 | </Toolbar.Button> |
| 120 | } | 136 | } |
| 121 | /> | 137 | /> |
| 122 | <TooltipPopup | 138 | <TooltipPopup |
| ... | @@ -155,7 +171,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { | ... | @@ -155,7 +171,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { |
| 155 | : entry.displayName; | 171 | : entry.displayName; |
| 156 | 172 | ||
| 157 | const button = ( | 173 | const button = ( |
| 158 | <button | 174 | <Toolbar.Button |
| 159 | className={cn( | 175 | className={cn( |
| 160 | "relative isolate flex w-full cursor-pointer aspect-square items-center justify-center rounded-md transition-colors hover:bg-[color-mix(in_srgb,var(--popover)_90%,var(--contrast-foreground))] focus-visible:bg-[color-mix(in_srgb,var(--popover)_90%,var(--contrast-foreground))] focus-visible:outline-none", | 176 | "relative isolate flex w-full cursor-pointer aspect-square items-center justify-center rounded-md transition-colors hover:bg-[color-mix(in_srgb,var(--popover)_90%,var(--contrast-foreground))] focus-visible:bg-[color-mix(in_srgb,var(--popover)_90%,var(--contrast-foreground))] focus-visible:outline-none", |
| 161 | isDisabled && "opacity-50 cursor-not-allowed hover:bg-transparent", | 177 | isDisabled && "opacity-50 cursor-not-allowed hover:bg-transparent", |
| ... | @@ -171,6 +187,8 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { | ... | @@ -171,6 +187,8 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { |
| 171 | setHoveredInstanceId((current) => (current === entry.instanceId ? null : current)) | 187 | setHoveredInstanceId((current) => (current === entry.instanceId ? null : current)) |
| 172 | } | 188 | } |
| 173 | disabled={isDisabled} | 189 | disabled={isDisabled} |
| 190 | focusableWhenDisabled={!isDisabled} | ||
| 191 | aria-pressed={isSelected} | ||
| 174 | type="button" | 192 | type="button" |
| 175 | aria-label={ | 193 | aria-label={ |
| 176 | isUnavailable || isContextDisabled | 194 | isUnavailable || isContextDisabled |
| ... | @@ -203,7 +221,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { | ... | @@ -203,7 +221,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { |
| 203 | <SparklesIcon className="size-2" /> | 221 | <SparklesIcon className="size-2" /> |
| 204 | </span> | 222 | </span> |
| 205 | ) : null} | 223 | ) : null} |
| 206 | </button> | 224 | </Toolbar.Button> |
| 207 | ); | 225 | ); |
| 208 | 226 | ||
| 209 | const trigger = isDisabled ? ( | 227 | const trigger = isDisabled ? ( |
| ... | @@ -234,6 +252,6 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { | ... | @@ -234,6 +252,6 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { |
| 234 | })} | 252 | })} |
| 235 | </div> | 253 | </div> |
| 236 | </div> | 254 | </div> |
| 237 | </div> | 255 | </Toolbar.Root> |
| 238 | ); | 256 | ); |
| 239 | }); | 257 | }); |
apps/web/src/components/chat/TraitsPicker.tsx+1| ... | @@ -618,6 +618,7 @@ export const TraitsPicker = memo(function TraitsPicker({ | ... | @@ -618,6 +618,7 @@ export const TraitsPicker = memo(function TraitsPicker({ |
| 618 | <MenuTrigger | 618 | <MenuTrigger |
| 619 | render={ | 619 | render={ |
| 620 | <ComposerControl | 620 | <ComposerControl |
| 621 | data-composer-shortcut={isComposerOwned ? "composer.effort" : undefined} | ||
| 621 | variant={triggerVariant ?? "ghost"} | 622 | variant={triggerVariant ?? "ghost"} |
| 622 | size={size} | 623 | size={size} |
| 623 | className={cn( | 624 | className={cn( |
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+53-2| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; | 1 | import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; |
| 2 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { usePullRequestStack } from "~/state/usePullRequestStack"; | 3 | import { usePullRequestStack } from "~/state/usePullRequestStack"; |
| 3 | import { RefreshIcon } from "~/components/ui/refresh-icon"; | 4 | import { RefreshIcon } from "~/components/ui/refresh-icon"; |
| 4 | import { scopedThreadKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; | 5 | import { scopedThreadKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; |
| ... | @@ -21,6 +22,7 @@ import { | ... | @@ -21,6 +22,7 @@ import { |
| 21 | ArrowUpRightIcon, | 22 | ArrowUpRightIcon, |
| 22 | BookOpenIcon, | 23 | BookOpenIcon, |
| 23 | CircleDotIcon, | 24 | CircleDotIcon, |
| 25 | CopyIcon, | ||
| 24 | ChevronDownIcon, | 26 | ChevronDownIcon, |
| 25 | ExternalLinkIcon, | 27 | ExternalLinkIcon, |
| 26 | FileDiffIcon, | 28 | FileDiffIcon, |
| ... | @@ -49,6 +51,7 @@ import { | ... | @@ -49,6 +51,7 @@ import { |
| 49 | type MouseEvent as ReactMouseEvent, | 51 | type MouseEvent as ReactMouseEvent, |
| 50 | useCallback, | 52 | useCallback, |
| 51 | useEffect, | 53 | useEffect, |
| 54 | useEffectEvent, | ||
| 52 | useLayoutEffect, | 55 | useLayoutEffect, |
| 53 | useMemo, | 56 | useMemo, |
| 54 | useRef, | 57 | useRef, |
| ... | @@ -57,7 +60,14 @@ import { | ... | @@ -57,7 +60,14 @@ import { |
| 57 | 60 | ||
| 58 | import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; | 61 | import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; |
| 59 | import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; | 62 | import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; |
| 60 | import { useCopyToClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard"; | 63 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; |
| 64 | import { isCommandPaletteOpen } from "~/commandPaletteBus"; | ||
| 65 | import { | ||
| 66 | resolveShortcutCommand, | ||
| 67 | shortcutLabelForCommand, | ||
| 68 | type ShortcutMatchContext, | ||
| 69 | } from "~/keybindings"; | ||
| 70 | import { primaryServerKeybindingsAtom } from "~/state/server"; | ||
| 61 | import { useClientSettings } from "~/hooks/useSettings"; | 71 | import { useClientSettings } from "~/hooks/useSettings"; |
| 62 | import { | 72 | import { |
| 63 | deriveLogicalProjectKeyFromSettings, | 73 | deriveLogicalProjectKeyFromSettings, |
| ... | @@ -104,6 +114,7 @@ import { | ... | @@ -104,6 +114,7 @@ import { |
| 104 | MenuRadioGroup, | 114 | MenuRadioGroup, |
| 105 | MenuRadioItem, | 115 | MenuRadioItem, |
| 106 | MenuSeparator, | 116 | MenuSeparator, |
| 117 | MenuShortcut, | ||
| 107 | MenuTrigger, | 118 | MenuTrigger, |
| 108 | } from "../ui/menu"; | 119 | } from "../ui/menu"; |
| 109 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; | 120 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; |
| ... | @@ -457,6 +468,8 @@ function PullRequestBaseFreshnessWarning({ | ... | @@ -457,6 +468,8 @@ function PullRequestBaseFreshnessWarning({ |
| 457 | 468 | ||
| 458 | export function PullRequestDetailPanel({ | 469 | export function PullRequestDetailPanel({ |
| 459 | environmentId, | 470 | environmentId, |
| 471 | shortcutsEnabled, | ||
| 472 | getShortcutContext, | ||
| 460 | threadRef = null, | 473 | threadRef = null, |
| 461 | reference: requestedReference, | 474 | reference: requestedReference, |
| 462 | listEntry = null, | 475 | listEntry = null, |
| ... | @@ -469,6 +482,8 @@ export function PullRequestDetailPanel({ | ... | @@ -469,6 +482,8 @@ export function PullRequestDetailPanel({ |
| 469 | onSelectPullRequest, | 482 | onSelectPullRequest, |
| 470 | }: { | 483 | }: { |
| 471 | environmentId: EnvironmentId; | 484 | environmentId: EnvironmentId; |
| 485 | shortcutsEnabled: boolean; | ||
| 486 | getShortcutContext: () => ShortcutMatchContext; | ||
| 472 | onSelectPullRequest?: ((reference: PullRequestRef) => void) | undefined; | 487 | onSelectPullRequest?: ((reference: PullRequestRef) => void) | undefined; |
| 473 | /** | 488 | /** |
| 474 | * The thread this panel sits beside, if any. Links that are not the pull | 489 | * The thread this panel sits beside, if any. Links that are not the pull |
| ... | @@ -707,6 +722,32 @@ export function PullRequestDetailPanel({ | ... | @@ -707,6 +722,32 @@ export function PullRequestDetailPanel({ |
| 707 | }, | 722 | }, |
| 708 | [activity, coreDetail], | 723 | [activity, coreDetail], |
| 709 | ); | 724 | ); |
| 725 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); | ||
| 726 | const { copyToClipboard: copyReference } = useCopyToClipboard<string>({ | ||
| 727 | target: "pull request reference", | ||
| 728 | onCopy: (label) => toastManager.add({ type: "success", title: `${label} copied` }), | ||
| 729 | onError: (error, label) => | ||
| 730 | toastManager.add({ | ||
| 731 | type: "error", | ||
| 732 | title: `Failed to copy ${label}`, | ||
| 733 | description: error.message, | ||
| 734 | }), | ||
| 735 | }); | ||
| 736 | const copyFromShortcut = useEffectEvent((event: KeyboardEvent) => { | ||
| 737 | if (!shortcutsEnabled || event.defaultPrevented || isCommandPaletteOpen()) return; | ||
| 738 | const command = resolveShortcutCommand(event, keybindings, { | ||
| 739 | context: getShortcutContext(), | ||
| 740 | }); | ||
| 741 | if (command !== "pullRequest.copyNumber") return; | ||
| 742 | event.preventDefault(); | ||
| 743 | event.stopPropagation(); | ||
| 744 | if (!event.repeat) copyReference(`#${reference.number}`, "PR number"); | ||
| 745 | }); | ||
| 746 | useEffect(() => { | ||
| 747 | const onKeyDown = (event: KeyboardEvent) => copyFromShortcut(event); | ||
| 748 | window.addEventListener("keydown", onKeyDown, true); | ||
| 749 | return () => window.removeEventListener("keydown", onKeyDown, true); | ||
| 750 | }, []); | ||
| 710 | useEffect(() => { | 751 | useEffect(() => { |
| 711 | if (detail?.autoMergeMethod !== undefined) setMergeMethod(detail.autoMergeMethod); | 752 | if (detail?.autoMergeMethod !== undefined) setMergeMethod(detail.autoMergeMethod); |
| 712 | }, [detail?.autoMergeMethod, pullRequestKey]); | 753 | }, [detail?.autoMergeMethod, pullRequestKey]); |
| ... | @@ -2080,9 +2121,19 @@ export function PullRequestDetailPanel({ | ... | @@ -2080,9 +2121,19 @@ export function PullRequestDetailPanel({ |
| 2080 | <ArrowUpRightIcon className="size-3.5" /> | 2121 | <ArrowUpRightIcon className="size-3.5" /> |
| 2081 | {openOnHostLabel(detail.provider)} | 2122 | {openOnHostLabel(detail.provider)} |
| 2082 | </MenuItem> | 2123 | </MenuItem> |
| 2083 | <MenuItem onClick={() => void writeTextToClipboard(detail.url)}> | 2124 | <MenuItem onClick={() => copyReference(detail.url, "PR link")}> |
| 2084 | <LinkIcon className="size-3.5" /> | 2125 | <LinkIcon className="size-3.5" /> |
| 2085 | Copy link | 2126 | Copy link |
| 2127 | <MenuShortcut> | ||
| 2128 | {shortcutLabelForCommand(keybindings, "thread.copyReference")} | ||
| 2129 | </MenuShortcut> | ||
| 2130 | </MenuItem> | ||
| 2131 | <MenuItem onClick={() => copyReference(`#${reference.number}`, "PR number")}> | ||
| 2132 | <CopyIcon className="size-3.5" /> | ||
| 2133 | Copy PR number | ||
| 2134 | <MenuShortcut> | ||
| 2135 | {shortcutLabelForCommand(keybindings, "pullRequest.copyNumber")} | ||
| 2136 | </MenuShortcut> | ||
| 2086 | </MenuItem> | 2137 | </MenuItem> |
| 2087 | {detail.state === "open" && can("close") ? ( | 2138 | {detail.state === "open" && can("close") ? ( |
| 2088 | <> | 2139 | <> |
apps/web/src/components/settings/KeybindingsSettings.logic.test.ts+26| ... | @@ -17,6 +17,32 @@ import { | ... | @@ -17,6 +17,32 @@ import { |
| 17 | } from "./KeybindingsSettings.logic"; | 17 | } from "./KeybindingsSettings.logic"; |
| 18 | 18 | ||
| 19 | describe("KeybindingsSettings.logic", () => { | 19 | describe("KeybindingsSettings.logic", () => { |
| 20 | it("lists composer, provider, and pull request commands with editable defaults", () => { | ||
| 21 | const rows = buildKeybindingRows(DEFAULT_RESOLVED_KEYBINDINGS, ""); | ||
| 22 | for (const command of [ | ||
| 23 | "composer.host", | ||
| 24 | "composer.effort", | ||
| 25 | "composer.mode", | ||
| 26 | "composer.workspace", | ||
| 27 | "composer.branch", | ||
| 28 | "composer.previousWorktree", | ||
| 29 | "modelPicker.previousProvider", | ||
| 30 | "modelPicker.nextProvider", | ||
| 31 | "thread.copyReference", | ||
| 32 | "pullRequest.copyNumber", | ||
| 33 | ]) { | ||
| 34 | expect(rows.find((row) => row.command === command)).toMatchObject({ | ||
| 35 | source: "Default", | ||
| 36 | conflicts: [], | ||
| 37 | }); | ||
| 38 | } | ||
| 39 | }); | ||
| 40 | it("finds the existing URL shortcut in Settings", () => { | ||
| 41 | const rows = buildKeybindingRows(DEFAULT_RESOLVED_KEYBINDINGS, "PR URL"); | ||
| 42 | expect(rows).toEqual([ | ||
| 43 | expect.objectContaining({ command: "thread.copyReference", key: "mod+shift+c" }), | ||
| 44 | ]); | ||
| 45 | }); | ||
| 20 | it("builds searchable rows with readable key and when values", () => { | 46 | it("builds searchable rows with readable key and when values", () => { |
| 21 | const rows = buildKeybindingRows( | 47 | const rows = buildKeybindingRows( |
| 22 | [ | 48 | [ |
apps/web/src/components/settings/KeybindingsSettings.logic.ts+2| ... | @@ -209,6 +209,7 @@ export function buildKeybindingRows( | ... | @@ -209,6 +209,7 @@ export function buildKeybindingRows( |
| 209 | return rowsWithConflicts.filter((row) => { | 209 | return rowsWithConflicts.filter((row) => { |
| 210 | return ( | 210 | return ( |
| 211 | row.command.toLowerCase().includes(normalizedQuery) || | 211 | row.command.toLowerCase().includes(normalizedQuery) || |
| 212 | commandLabel(row.command).toLowerCase().includes(normalizedQuery) || | ||
| 212 | row.key.toLowerCase().includes(normalizedQuery) || | 213 | row.key.toLowerCase().includes(normalizedQuery) || |
| 213 | row.when.toLowerCase().includes(normalizedQuery) || | 214 | row.when.toLowerCase().includes(normalizedQuery) || |
| 214 | row.source.toLowerCase().includes(normalizedQuery) | 215 | row.source.toLowerCase().includes(normalizedQuery) |
| ... | @@ -275,6 +276,7 @@ export function buildKeybindingCommandOptions( | ... | @@ -275,6 +276,7 @@ export function buildKeybindingCommandOptions( |
| 275 | } | 276 | } |
| 276 | 277 | ||
| 277 | export function commandLabel(command: KeybindingCommand): string { | 278 | export function commandLabel(command: KeybindingCommand): string { |
| 279 | if (command === "thread.copyReference") return "Thread: Copy PR URL or Thread ID"; | ||
| 278 | const raw = String(command); | 280 | const raw = String(command); |
| 279 | if (raw.startsWith("script.") && raw.endsWith(".run")) { | 281 | if (raw.startsWith("script.") && raw.endsWith(".run")) { |
| 280 | return `Run Script: ${titleCaseCommandSegment(raw.slice("script.".length, -".run".length))}`; | 282 | return `Run Script: ${titleCaseCommandSegment(raw.slice("script.".length, -".run".length))}`; |
apps/web/src/components/settings/KeybindingsSettings.tsx+6-2| ... | @@ -26,7 +26,7 @@ import { | ... | @@ -26,7 +26,7 @@ import { |
| 26 | type ServerRemoveKeybindingInput, | 26 | type ServerRemoveKeybindingInput, |
| 27 | type ServerUpsertKeybindingInput, | 27 | type ServerUpsertKeybindingInput, |
| 28 | } from "@t3tools/contracts"; | 28 | } from "@t3tools/contracts"; |
| 29 | import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; | 29 | import { mergeWithDefaultKeybindings } from "@t3tools/shared/keybindings"; |
| 30 | import { | 30 | import { |
| 31 | isAtomCommandInterrupted, | 31 | isAtomCommandInterrupted, |
| 32 | squashAtomCommandFailure, | 32 | squashAtomCommandFailure, |
| ... | @@ -1334,7 +1334,11 @@ export function KeybindingsSettingsPanel() { | ... | @@ -1334,7 +1334,11 @@ export function KeybindingsSettingsPanel() { |
| 1334 | // fan out to every connected environment in the selection, so one | 1334 | // fan out to every connected environment in the selection, so one |
| 1335 | // shortcut change reaches each machine the user runs T3 Code on. | 1335 | // shortcut change reaches each machine the user runs T3 Code on. |
| 1336 | const { environment: primaryEnvironment, connectedEnvironments } = useSettingsScope(); | 1336 | const { environment: primaryEnvironment, connectedEnvironments } = useSettingsScope(); |
| 1337 | const keybindings = primaryEnvironment?.serverConfig?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS; | 1337 | const serverKeybindings = primaryEnvironment?.serverConfig?.keybindings; |
| 1338 | const keybindings = useMemo( | ||
| 1339 | () => mergeWithDefaultKeybindings(serverKeybindings ?? []), | ||
| 1340 | [serverKeybindings], | ||
| 1341 | ); | ||
| 1338 | const keybindingsConfigPath = primaryEnvironment?.serverConfig?.keybindingsConfigPath ?? null; | 1342 | const keybindingsConfigPath = primaryEnvironment?.serverConfig?.keybindingsConfigPath ?? null; |
| 1339 | const availableEditors = primaryEnvironment?.serverConfig?.availableEditors ?? []; | 1343 | const availableEditors = primaryEnvironment?.serverConfig?.availableEditors ?? []; |
| 1340 | const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, { | 1344 | const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, { |
apps/web/src/keybindings.test.ts+196| ... | @@ -1,4 +1,9 @@ | ... | @@ -1,4 +1,9 @@ |
| 1 | import { assert, describe, it } from "vite-plus/test"; | 1 | import { assert, describe, it } from "vite-plus/test"; |
| 2 | import { | ||
| 3 | compileResolvedKeybindingsConfig, | ||
| 4 | DEFAULT_RESOLVED_KEYBINDINGS, | ||
| 5 | mergeWithDefaultKeybindings, | ||
| 6 | } from "@t3tools/shared/keybindings"; | ||
| 2 | 7 | ||
| 3 | import { | 8 | import { |
| 4 | type KeybindingCommand, | 9 | type KeybindingCommand, |
| ... | @@ -1057,3 +1062,194 @@ describe("plus key parsing", () => { | ... | @@ -1057,3 +1062,194 @@ describe("plus key parsing", () => { |
| 1057 | ); | 1062 | ); |
| 1058 | }); | 1063 | }); |
| 1059 | }); | 1064 | }); |
| 1065 | |||
| 1066 | describe("composer and pull request shortcuts", () => { | ||
| 1067 | it("fills missing number shortcuts without replacing the saved URL binding", () => { | ||
| 1068 | const olderServerBindings = DEFAULT_RESOLVED_KEYBINDINGS.filter( | ||
| 1069 | (binding) => | ||
| 1070 | binding.command !== "pullRequest.copyNumber" && binding.command !== "thread.copyReference", | ||
| 1071 | ); | ||
| 1072 | const bindings = mergeWithDefaultKeybindings([ | ||
| 1073 | ...olderServerBindings, | ||
| 1074 | ...compileResolvedKeybindingsConfig([ | ||
| 1075 | { key: "mod+shift+8", command: "thread.copyReference", when: "!terminalFocus" }, | ||
| 1076 | ]), | ||
| 1077 | ]); | ||
| 1078 | for (const [key, command] of [ | ||
| 1079 | ["k", "pullRequest.copyNumber"], | ||
| 1080 | ["8", "thread.copyReference"], | ||
| 1081 | ["c", null], | ||
| 1082 | ["y", null], | ||
| 1083 | ] as const) { | ||
| 1084 | assert.strictEqual( | ||
| 1085 | resolveShortcutCommand(event({ key, metaKey: true, shiftKey: true }), bindings, { | ||
| 1086 | platform: "MacIntel", | ||
| 1087 | }), | ||
| 1088 | command, | ||
| 1089 | ); | ||
| 1090 | } | ||
| 1091 | }); | ||
| 1092 | |||
| 1093 | it.each(["terminalOpen", "previewFocus", "previewOpen", "modelPickerOpen"])( | ||
| 1094 | "honors custom PR shortcut conditions for %s", | ||
| 1095 | (condition) => { | ||
| 1096 | const bindings = compileResolvedKeybindingsConfig([ | ||
| 1097 | { key: "mod+shift+k", command: "thread.copyReference", when: condition }, | ||
| 1098 | { key: "mod+shift+k", command: "pullRequest.copyNumber", when: `!${condition}` }, | ||
| 1099 | ]); | ||
| 1100 | const input = event({ key: "k", ctrlKey: true, shiftKey: true }); | ||
| 1101 | for (const enabled of [false, true]) { | ||
| 1102 | assert.strictEqual( | ||
| 1103 | resolveShortcutCommand(input, bindings, { | ||
| 1104 | platform: "Linux", | ||
| 1105 | context: { [condition]: enabled }, | ||
| 1106 | }), | ||
| 1107 | enabled ? "thread.copyReference" : "pullRequest.copyNumber", | ||
| 1108 | ); | ||
| 1109 | } | ||
| 1110 | }, | ||
| 1111 | ); | ||
| 1112 | |||
| 1113 | const shortcuts = [ | ||
| 1114 | ["h", "composer.host"], | ||
| 1115 | ["e", "composer.effort"], | ||
| 1116 | ["a", "composer.mode"], | ||
| 1117 | ["x", "composer.workspace"], | ||
| 1118 | ["g", "composer.branch"], | ||
| 1119 | ["l", "composer.previousWorktree"], | ||
| 1120 | ["c", "thread.copyReference"], | ||
| 1121 | ["k", "pullRequest.copyNumber"], | ||
| 1122 | ] as const; | ||
| 1123 | |||
| 1124 | for (const platform of ["MacIntel", "Win32", "Linux"]) { | ||
| 1125 | it.each(shortcuts)( | ||
| 1126 | `resolves %s on ${platform} and leaves terminal input alone`, | ||
| 1127 | (key, command) => { | ||
| 1128 | const input = event({ | ||
| 1129 | key, | ||
| 1130 | shiftKey: true, | ||
| 1131 | metaKey: platform === "MacIntel", | ||
| 1132 | ctrlKey: platform !== "MacIntel", | ||
| 1133 | }); | ||
| 1134 | assert.strictEqual( | ||
| 1135 | resolveShortcutCommand(input, DEFAULT_RESOLVED_KEYBINDINGS, { | ||
| 1136 | platform, | ||
| 1137 | context: { terminalFocus: false }, | ||
| 1138 | }), | ||
| 1139 | command, | ||
| 1140 | ); | ||
| 1141 | assert.isNull( | ||
| 1142 | resolveShortcutCommand(input, DEFAULT_RESOLVED_KEYBINDINGS, { | ||
| 1143 | platform, | ||
| 1144 | context: { terminalFocus: true }, | ||
| 1145 | }), | ||
| 1146 | ); | ||
| 1147 | }, | ||
| 1148 | ); | ||
| 1149 | } | ||
| 1150 | |||
| 1151 | for (const platform of ["MacIntel", "Win32", "Linux"]) { | ||
| 1152 | it.each([ | ||
| 1153 | ["s", "thread.settle"], | ||
| 1154 | ["p", "thread.pin"], | ||
| 1155 | ])(`preserves the existing %s shortcut on ${platform}`, (key, command) => { | ||
| 1156 | assert.strictEqual( | ||
| 1157 | resolveShortcutCommand( | ||
| 1158 | event({ | ||
| 1159 | key, | ||
| 1160 | shiftKey: true, | ||
| 1161 | metaKey: platform === "MacIntel", | ||
| 1162 | ctrlKey: platform !== "MacIntel", | ||
| 1163 | }), | ||
| 1164 | DEFAULT_RESOLVED_KEYBINDINGS, | ||
| 1165 | { platform }, | ||
| 1166 | ), | ||
| 1167 | command, | ||
| 1168 | ); | ||
| 1169 | }); | ||
| 1170 | } | ||
| 1171 | |||
| 1172 | const altEffortBindings = compileResolvedKeybindingsConfig([ | ||
| 1173 | { key: "mod+alt+e", command: "composer.effort", when: "!terminalFocus" }, | ||
| 1174 | ]); | ||
| 1175 | |||
| 1176 | it("leaves AltGr text entry alone with a custom Alt binding", () => { | ||
| 1177 | for (const platform of ["Win32", "Linux"]) { | ||
| 1178 | const input = event({ | ||
| 1179 | key: "€", | ||
| 1180 | code: "KeyE", | ||
| 1181 | ctrlKey: true, | ||
| 1182 | altKey: true, | ||
| 1183 | getModifierState: (key) => key === "AltGraph", | ||
| 1184 | }); | ||
| 1185 | assert.isNull(resolveShortcutCommand(input, altEffortBindings, { platform })); | ||
| 1186 | assert.strictEqual( | ||
| 1187 | resolveShortcutCommand({ ...input, getModifierState: () => false }, altEffortBindings, { | ||
| 1188 | platform, | ||
| 1189 | }), | ||
| 1190 | "composer.effort", | ||
| 1191 | ); | ||
| 1192 | } | ||
| 1193 | }); | ||
| 1194 | |||
| 1195 | it("keeps Firefox modifier reporting usable on Windows and macOS", () => { | ||
| 1196 | const getModifierState = (key: string) => key === "AltGraph"; | ||
| 1197 | assert.strictEqual( | ||
| 1198 | resolveShortcutCommand( | ||
| 1199 | event({ key: "e", ctrlKey: true, altKey: true, getModifierState }), | ||
| 1200 | altEffortBindings, | ||
| 1201 | { platform: "Win32" }, | ||
| 1202 | ), | ||
| 1203 | "composer.effort", | ||
| 1204 | ); | ||
| 1205 | assert.strictEqual( | ||
| 1206 | resolveShortcutCommand( | ||
| 1207 | event({ key: "´", code: "KeyE", metaKey: true, altKey: true, getModifierState }), | ||
| 1208 | altEffortBindings, | ||
| 1209 | { platform: "MacIntel" }, | ||
| 1210 | ), | ||
| 1211 | "composer.effort", | ||
| 1212 | ); | ||
| 1213 | }); | ||
| 1214 | |||
| 1215 | it.each(shortcuts)("uses a custom binding for %s", (_key, command) => { | ||
| 1216 | const bindings = compileResolvedKeybindingsConfig([ | ||
| 1217 | { key: "mod+shift+y", command, when: "!terminalFocus" }, | ||
| 1218 | ]); | ||
| 1219 | assert.strictEqual( | ||
| 1220 | resolveShortcutCommand( | ||
| 1221 | event({ key: "Y", code: "KeyY", ctrlKey: true, shiftKey: true }), | ||
| 1222 | bindings, | ||
| 1223 | { platform: "Linux" }, | ||
| 1224 | ), | ||
| 1225 | command, | ||
| 1226 | ); | ||
| 1227 | }); | ||
| 1228 | |||
| 1229 | for (const platform of ["MacIntel", "Win32", "Linux"]) { | ||
| 1230 | it.each([ | ||
| 1231 | ["ArrowUp", "modelPicker.previousProvider"], | ||
| 1232 | ["ArrowDown", "modelPicker.nextProvider"], | ||
| 1233 | ] as const)(`limits %s to the model picker on ${platform}`, (key, command) => { | ||
| 1234 | const input = event({ | ||
| 1235 | key, | ||
| 1236 | shiftKey: true, | ||
| 1237 | metaKey: platform === "MacIntel", | ||
| 1238 | ctrlKey: platform !== "MacIntel", | ||
| 1239 | }); | ||
| 1240 | assert.strictEqual( | ||
| 1241 | resolveShortcutCommand(input, DEFAULT_RESOLVED_KEYBINDINGS, { | ||
| 1242 | platform, | ||
| 1243 | context: { modelPickerOpen: true }, | ||
| 1244 | }), | ||
| 1245 | command, | ||
| 1246 | ); | ||
| 1247 | assert.isNull( | ||
| 1248 | resolveShortcutCommand(input, DEFAULT_RESOLVED_KEYBINDINGS, { | ||
| 1249 | platform, | ||
| 1250 | context: { modelPickerOpen: false }, | ||
| 1251 | }), | ||
| 1252 | ); | ||
| 1253 | }); | ||
| 1254 | } | ||
| 1255 | }); |
apps/web/src/keybindings.ts+7| ... | @@ -11,6 +11,7 @@ import { | ... | @@ -11,6 +11,7 @@ import { |
| 11 | import { isMacPlatform } from "./lib/utils"; | 11 | import { isMacPlatform } from "./lib/utils"; |
| 12 | 12 | ||
| 13 | export interface ShortcutEventLike { | 13 | export interface ShortcutEventLike { |
| 14 | getModifierState?: (key: "AltGraph") => boolean; | ||
| 14 | type?: string; | 15 | type?: string; |
| 15 | code?: string; | 16 | code?: string; |
| 16 | key: string; | 17 | key: string; |
| ... | @@ -123,6 +124,12 @@ function matchesShortcut( | ... | @@ -123,6 +124,12 @@ function matchesShortcut( |
| 123 | shortcut: KeybindingShortcut, | 124 | shortcut: KeybindingShortcut, |
| 124 | platform = navigator.platform, | 125 | platform = navigator.platform, |
| 125 | ): boolean { | 126 | ): boolean { |
| 127 | if ( | ||
| 128 | !isMacPlatform(platform) && | ||
| 129 | event.getModifierState?.("AltGraph") && | ||
| 130 | !/^[a-z0-9]$/i.test(event.key) | ||
| 131 | ) | ||
| 132 | return false; | ||
| 126 | if (!matchesShortcutModifiers(event, shortcut, platform)) return false; | 133 | if (!matchesShortcutModifiers(event, shortcut, platform)) return false; |
| 127 | return resolveEventKeys(event).has(shortcut.key); | 134 | return resolveEventKeys(event).has(shortcut.key); |
| 128 | } | 135 | } |
apps/web/src/routes/_chat.pull-requests.tsx+13-1| ... | @@ -151,6 +151,16 @@ import { cn } from "~/lib/utils"; | ... | @@ -151,6 +151,16 @@ import { cn } from "~/lib/utils"; |
| 151 | import { primaryServerKeybindingsAtom } from "~/state/server"; | 151 | import { primaryServerKeybindingsAtom } from "~/state/server"; |
| 152 | import { getSourceControlPresentationForKind } from "~/sourceControlPresentation"; | 152 | import { getSourceControlPresentationForKind } from "~/sourceControlPresentation"; |
| 153 | 153 | ||
| 154 | function getShortcutContext() { | ||
| 155 | return { | ||
| 156 | terminalFocus: isTerminalFocused(), | ||
| 157 | terminalOpen: false, | ||
| 158 | previewFocus: false, | ||
| 159 | previewOpen: false, | ||
| 160 | modelPickerOpen: false, | ||
| 161 | }; | ||
| 162 | } | ||
| 163 | |||
| 154 | export interface PullRequestsSearch extends PullRequestListPreferences { | 164 | export interface PullRequestsSearch extends PullRequestListPreferences { |
| 155 | /** | 165 | /** |
| 156 | * Narrows the list to one server. Absent means every connected one, which is the default the | 166 | * Narrows the list to one server. Absent means every connected one, which is the default the |
| ... | @@ -1925,7 +1935,7 @@ function PullRequestsRouteView() { | ... | @@ -1925,7 +1935,7 @@ function PullRequestsRouteView() { |
| 1925 | const onKeyDown = (event: KeyboardEvent) => { | 1935 | const onKeyDown = (event: KeyboardEvent) => { |
| 1926 | if (event.defaultPrevented || isCommandPaletteOpen()) return; | 1936 | if (event.defaultPrevented || isCommandPaletteOpen()) return; |
| 1927 | const command = resolveShortcutCommand(event, keybindings, { | 1937 | const command = resolveShortcutCommand(event, keybindings, { |
| 1928 | context: { terminalFocus: isTerminalFocused() }, | 1938 | context: getShortcutContext(), |
| 1929 | }); | 1939 | }); |
| 1930 | if (command === "rightPanel.close") closeActiveSurfaceFromShortcut(event); | 1940 | if (command === "rightPanel.close") closeActiveSurfaceFromShortcut(event); |
| 1931 | if (command === "rightPanel.toggle") toggleRightPanelFromShortcut(event); | 1941 | if (command === "rightPanel.toggle") toggleRightPanelFromShortcut(event); |
| ... | @@ -1992,6 +2002,8 @@ function PullRequestsRouteView() { | ... | @@ -1992,6 +2002,8 @@ function PullRequestsRouteView() { |
| 1992 | pullRequestStatusSeeds={listedPullRequestTabStatuses} | 2002 | pullRequestStatusSeeds={listedPullRequestTabStatuses} |
| 1993 | > | 2003 | > |
| 1994 | <PullRequestDetailPanel | 2004 | <PullRequestDetailPanel |
| 2005 | getShortcutContext={getShortcutContext} | ||
| 2006 | shortcutsEnabled={activePullRequestSurface?.id === renderedPullRequestSurface.id} | ||
| 1995 | key={renderedPullRequestSurface.id} | 2007 | key={renderedPullRequestSurface.id} |
| 1996 | environmentId={panelEnvironmentId} | 2008 | environmentId={panelEnvironmentId} |
| 1997 | onSelectPullRequest={(reference) => { | 2009 | onSelectPullRequest={(reference) => { |
apps/web/src/state/server.ts+3-4| ... | @@ -10,7 +10,7 @@ import { | ... | @@ -10,7 +10,7 @@ import { |
| 10 | } from "@t3tools/contracts"; | 10 | } from "@t3tools/contracts"; |
| 11 | import { createServerEnvironmentAtoms } from "@t3tools/client-runtime/state/server"; | 11 | import { createServerEnvironmentAtoms } from "@t3tools/client-runtime/state/server"; |
| 12 | import { createEnvironmentServerConfigsAtom } from "@t3tools/client-runtime/state/shell"; | 12 | import { createEnvironmentServerConfigsAtom } from "@t3tools/client-runtime/state/shell"; |
| 13 | import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; | 13 | import { mergeWithDefaultKeybindings } from "@t3tools/shared/keybindings"; |
| 14 | import * as Option from "effect/Option"; | 14 | import * as Option from "effect/Option"; |
| 15 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | 15 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| 16 | 16 | ||
| ... | @@ -90,9 +90,8 @@ export const primaryServerProvidersAtom = Atom.make( | ... | @@ -90,9 +90,8 @@ export const primaryServerProvidersAtom = Atom.make( |
| 90 | get(primaryServerConfigAtom)?.providers ?? EMPTY_SERVER_PROVIDERS, | 90 | get(primaryServerConfigAtom)?.providers ?? EMPTY_SERVER_PROVIDERS, |
| 91 | ).pipe(Atom.withLabel("web-primary-server-providers")); | 91 | ).pipe(Atom.withLabel("web-primary-server-providers")); |
| 92 | 92 | ||
| 93 | export const primaryServerKeybindingsAtom = Atom.make( | 93 | export const primaryServerKeybindingsAtom = Atom.make((get): ServerConfig["keybindings"] => |
| 94 | (get): ServerConfig["keybindings"] => | 94 | mergeWithDefaultKeybindings(get(primaryServerConfigAtom)?.keybindings ?? []), |
| 95 | get(primaryServerConfigAtom)?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS, | ||
| 96 | ).pipe(Atom.withLabel("web-primary-server-keybindings")); | 95 | ).pipe(Atom.withLabel("web-primary-server-keybindings")); |
| 97 | 96 | ||
| 98 | export const primaryServerAvailableEditorsAtom = Atom.make( | 97 | export const primaryServerAvailableEditorsAtom = Atom.make( |
docs/user/keybindings.md+25| ... | @@ -3,6 +3,31 @@ | ... | @@ -3,6 +3,31 @@ |
| 3 | Customize shortcuts in **Settings → Keybindings** on web and desktop. That page | 3 | Customize shortcuts in **Settings → Keybindings** on web and desktop. That page |
| 4 | also lists the command IDs and defaults available in your version. | 4 | also lists the command IDs and defaults available in your version. |
| 5 | 5 | ||
| 6 | ## Composer controls | ||
| 7 | |||
| 8 | Use `mod+shift+m` to choose a model and `mod+shift+h` to choose a host. | ||
| 9 | Use `mod+shift+e` for effort, `mod+shift+a` for access mode, `mod+shift+x` for the | ||
| 10 | workspace, and `mod+shift+g` for the Git branch. The workspace menu includes the | ||
| 11 | current checkout, a new worktree, and the previous worktree when available. | ||
| 12 | Use `mod+shift+l` to reuse the previous worktree directly. | ||
| 13 | |||
| 14 | In the model picker, press Left in an empty search field or Shift+Tab to reach | ||
| 15 | the provider list. Use Up/Down to move and Enter to choose. Right returns to | ||
| 16 | model search. `mod+shift+up` and `mod+shift+down` switch providers directly and clear the | ||
| 17 | search. These provider shortcuts can also be changed in Settings. | ||
| 18 | |||
| 19 | These shortcuts run inside the focused web or desktop client. `mod` uses Command | ||
| 20 | on macOS and Ctrl on Windows and Linux, including GNOME, KDE Plasma, Niri, and | ||
| 21 | Hyprland. If a custom desktop shortcut takes the same keys, choose another binding | ||
| 22 | in Settings. | ||
| 23 | |||
| 24 | ## Copy pull request references | ||
| 25 | |||
| 26 | With a PR open in the right panel or on the Pull Requests page, use `mod+shift+c` | ||
| 27 | to copy its URL and `mod+shift+k` to copy its number with a `#` prefix. | ||
| 28 | Both shortcuts can be changed in Settings. Search for “Copy PR URL or Thread ID” | ||
| 29 | or “Copy Number”. They copy the selected PR and leave terminal input alone. | ||
| 30 | |||
| 6 | ## Edit the configuration file | 31 | ## Edit the configuration file |
| 7 | 32 | ||
| 8 | Keybindings live on the environment's machine, in | 33 | Keybindings live on the environment's machine, in |
packages/contracts/src/keybindings.test.ts+7| ... | @@ -122,6 +122,13 @@ it.effect("parses keybinding rules", () => | ... | @@ -122,6 +122,13 @@ it.effect("parses keybinding rules", () => |
| 122 | }); | 122 | }); |
| 123 | assert.strictEqual(parsedThreadCopyReference.command, "thread.copyReference"); | 123 | assert.strictEqual(parsedThreadCopyReference.command, "thread.copyReference"); |
| 124 | 124 | ||
| 125 | const parsedPullRequestCopyNumber = yield* decode(KeybindingRule, { | ||
| 126 | key: "mod+shift+k", | ||
| 127 | command: "pullRequest.copyNumber", | ||
| 128 | when: "!terminalFocus", | ||
| 129 | }); | ||
| 130 | assert.strictEqual(parsedPullRequestCopyNumber.command, "pullRequest.copyNumber"); | ||
| 131 | |||
| 125 | const parsedThreadStop = yield* decode(KeybindingRule, { | 132 | const parsedThreadStop = yield* decode(KeybindingRule, { |
| 126 | key: "mod+escape", | 133 | key: "mod+escape", |
| 127 | command: "thread.stop", | 134 | command: "thread.stop", |
packages/contracts/src/keybindings.ts+9| ... | @@ -47,6 +47,8 @@ export type ThreadKeybindingCommand = (typeof THREAD_KEYBINDING_COMMANDS)[number | ... | @@ -47,6 +47,8 @@ export type ThreadKeybindingCommand = (typeof THREAD_KEYBINDING_COMMANDS)[number |
| 47 | 47 | ||
| 48 | const MODEL_PICKER_KEYBINDING_COMMANDS = [ | 48 | const MODEL_PICKER_KEYBINDING_COMMANDS = [ |
| 49 | "modelPicker.toggle", | 49 | "modelPicker.toggle", |
| 50 | "modelPicker.previousProvider", | ||
| 51 | "modelPicker.nextProvider", | ||
| 50 | ...MODEL_PICKER_JUMP_KEYBINDING_COMMANDS, | 52 | ...MODEL_PICKER_JUMP_KEYBINDING_COMMANDS, |
| 51 | ] as const; | 53 | ] as const; |
| 52 | export type ModelPickerKeybindingCommand = (typeof MODEL_PICKER_KEYBINDING_COMMANDS)[number]; | 54 | export type ModelPickerKeybindingCommand = (typeof MODEL_PICKER_KEYBINDING_COMMANDS)[number]; |
| ... | @@ -61,6 +63,7 @@ export const STATIC_KEYBINDING_COMMANDS = [ | ... | @@ -61,6 +63,7 @@ export const STATIC_KEYBINDING_COMMANDS = [ |
| 61 | "rightPanel.toggle", | 63 | "rightPanel.toggle", |
| 62 | "rightPanel.toggleMaximized", | 64 | "rightPanel.toggleMaximized", |
| 63 | "rightPanel.close", | 65 | "rightPanel.close", |
| 66 | "pullRequest.copyNumber", | ||
| 64 | "diff.toggle", | 67 | "diff.toggle", |
| 65 | "preview.toggle", | 68 | "preview.toggle", |
| 66 | "preview.refresh", | 69 | "preview.refresh", |
| ... | @@ -73,6 +76,12 @@ export const STATIC_KEYBINDING_COMMANDS = [ | ... | @@ -73,6 +76,12 @@ export const STATIC_KEYBINDING_COMMANDS = [ |
| 73 | "projectSearch.toggle", | 76 | "projectSearch.toggle", |
| 74 | "themeEditor.toggle", | 77 | "themeEditor.toggle", |
| 75 | "composer.stash", | 78 | "composer.stash", |
| 79 | "composer.host", | ||
| 80 | "composer.effort", | ||
| 81 | "composer.mode", | ||
| 82 | "composer.workspace", | ||
| 83 | "composer.previousWorktree", | ||
| 84 | "composer.branch", | ||
| 76 | "chat.new", | 85 | "chat.new", |
| 77 | "chat.newLocal", | 86 | "chat.newLocal", |
| 78 | "editor.openFavorite", | 87 | "editor.openFavorite", |
packages/shared/src/keybindings.ts+29| ... | @@ -44,6 +44,15 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [ | ... | @@ -44,6 +44,15 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [ |
| 44 | { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, | 44 | { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, |
| 45 | { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, | 45 | { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, |
| 46 | { key: "mod+shift+m", command: "modelPicker.toggle", when: "!terminalFocus" }, | 46 | { key: "mod+shift+m", command: "modelPicker.toggle", when: "!terminalFocus" }, |
| 47 | { key: "mod+shift+h", command: "composer.host", when: "!terminalFocus" }, | ||
| 48 | { key: "mod+shift+e", command: "composer.effort", when: "!terminalFocus" }, | ||
| 49 | { key: "mod+shift+a", command: "composer.mode", when: "!terminalFocus" }, | ||
| 50 | { key: "mod+shift+x", command: "composer.workspace", when: "!terminalFocus" }, | ||
| 51 | { key: "mod+shift+g", command: "composer.branch", when: "!terminalFocus" }, | ||
| 52 | { key: "mod+shift+l", command: "composer.previousWorktree", when: "!terminalFocus" }, | ||
| 53 | { key: "mod+shift+k", command: "pullRequest.copyNumber", when: "!terminalFocus" }, | ||
| 54 | { key: "mod+shift+arrowup", command: "modelPicker.previousProvider", when: "modelPickerOpen" }, | ||
| 55 | { key: "mod+shift+arrowdown", command: "modelPicker.nextProvider", when: "modelPickerOpen" }, | ||
| 47 | { key: "mod+o", command: "editor.openFavorite" }, | 56 | { key: "mod+o", command: "editor.openFavorite" }, |
| 48 | { key: "mod+shift+[", command: "thread.previous" }, | 57 | { key: "mod+shift+[", command: "thread.previous" }, |
| 49 | { key: "mod+shift+]", command: "thread.next" }, | 58 | { key: "mod+shift+]", command: "thread.next" }, |
| ... | @@ -299,3 +308,23 @@ export function compileResolvedKeybindingsConfig( | ... | @@ -299,3 +308,23 @@ export function compileResolvedKeybindingsConfig( |
| 299 | } | 308 | } |
| 300 | 309 | ||
| 301 | export const DEFAULT_RESOLVED_KEYBINDINGS = compileResolvedKeybindingsConfig(DEFAULT_KEYBINDINGS); | 310 | export const DEFAULT_RESOLVED_KEYBINDINGS = compileResolvedKeybindingsConfig(DEFAULT_KEYBINDINGS); |
| 311 | |||
| 312 | export function mergeWithDefaultKeybindings( | ||
| 313 | custom: ResolvedKeybindingsConfig, | ||
| 314 | ): ResolvedKeybindingsConfig { | ||
| 315 | if (custom.length === 0) { | ||
| 316 | return [...DEFAULT_RESOLVED_KEYBINDINGS]; | ||
| 317 | } | ||
| 318 | |||
| 319 | const overriddenCommands = new Set(custom.map((binding) => binding.command)); | ||
| 320 | const retainedDefaults = DEFAULT_RESOLVED_KEYBINDINGS.filter( | ||
| 321 | (binding) => !overriddenCommands.has(binding.command), | ||
| 322 | ); | ||
| 323 | const merged = [...retainedDefaults, ...custom]; | ||
| 324 | |||
| 325 | if (merged.length <= MAX_KEYBINDINGS_COUNT) { | ||
| 326 | return merged; | ||
| 327 | } | ||
| 328 | |||
| 329 | return merged.slice(-MAX_KEYBINDINGS_COUNT); | ||
| 330 | } |