authorgravatar for 62337003+Bil0000@users.noreply.github.comBilal Bakr <62337003+Bil0000@users.noreply.github.com> 2026-09-14 21:10:20+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-14 15:10:20-03:00
log9130d932f4ddb8abab595b7d12553b46a11c7b22
tree9aeccfa9a844542051bfe2a2a5d9181b0e55d028
parent33118d9ab263904e4180b3bf2cbf0a36fe0695e2
signature Signed by PGP key B5690EEEBB952194

feat(web): add composer and PR number shortcuts (#11615)


24 files changed, 694 insertions(+), 49 deletions(-)

apps/server/src/keybindings.ts+1-20
......@@ -45,7 +45,7 @@ import { writeFileStringAtomically } from "./atomicWrite.ts";
4545import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson";
4646import {
4747 DEFAULT_KEYBINDINGS,
48 DEFAULT_RESOLVED_KEYBINDINGS,
48 mergeWithDefaultKeybindings,
4949 compileResolvedKeybindingRule,
5050 compileResolvedKeybindingsConfig,
5151 parseKeybindingShortcut,
......@@ -201,25 +201,6 @@ function invalidEntryIssue(index: number, detail: string): ServerConfigIssue {
201201 };
202202}
203203
204function 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
223204/**
224205 * Keybindings - Service tag for keybinding configuration operations.
225206 */
apps/web/src/components/BranchToolbar.tsx+47-2
......@@ -8,7 +8,17 @@ import {
88 HistoryIcon,
99 ScaleIcon,
1010} from "lucide-react";
11import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
11import {
12 type Ref,
13 memo,
14 useImperativeHandle,
15 useCallback,
16 useEffect,
17 useLayoutEffect,
18 useMemo,
19 useRef,
20 useState,
21} from "react";
1222
1323import { useComposerDraftStore, type DraftId } from "../composerDraftStore";
1424import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
......@@ -25,7 +35,10 @@ import {
2535 resolvePreviousWorktreeSeed,
2636 shouldShowEnvironmentIndicator,
2737} from "./BranchToolbar.logic";
28import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector";
38import {
39 BranchToolbarBranchSelector,
40 type BranchToolbarBranchSelectorHandle,
41} from "./BranchToolbarBranchSelector";
2942import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector";
3043import { BranchToolbarEnvModeSelector } from "./BranchToolbarEnvModeSelector";
3144import { Button } from "./ui/button";
......@@ -46,7 +59,13 @@ import { measureRestingComposerControls } from "./chat/restingComposerControlsMe
4659import { resolveRestingComposerControlsNaturalWidth } from "./composerFooterLayout";
4760import { cn } from "~/lib/utils";
4861
62export interface BranchToolbarHandle {
63 openBranchPicker: () => void;
64 usePreviousWorktree: () => void;
65}
66
4967interface BranchToolbarProps {
68 ref?: Ref<BranchToolbarHandle>;
5069 environmentId: EnvironmentId;
5170 threadId: ThreadId;
5271 showGitControls: boolean;
......@@ -169,6 +188,10 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
169188 render={<Button variant="ghost" size="xs" />}
170189 className="min-w-0 max-w-[48%] flex-initial justify-start font-normal text-muted-foreground/70 text-xs! hover:text-foreground/80"
171190 data-composer-context-control
191 data-composer-shortcut={[
192 showEnvironmentPicker && !envLocked ? "composer.host" : "",
193 !envModeLocked ? "composer.workspace" : "",
194 ].join(" ")}
172195 >
173196 {triggerContent}
174197 <ChevronDownIcon className="size-3 shrink-0 opacity-50" />
......@@ -438,6 +461,7 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean {
438461}
439462
440463export const BranchToolbar = memo(function BranchToolbar({
464 ref,
441465 environmentId,
442466 threadId,
443467 showGitControls,
......@@ -458,6 +482,7 @@ export const BranchToolbar = memo(function BranchToolbar({
458482 composerControlsHostRef,
459483 contextStripVisible = true,
460484}: BranchToolbarProps) {
485 const branchSelectorRef = useRef<BranchToolbarBranchSelectorHandle>(null);
461486 const threadRef = useMemo(
462487 () => scopeThreadRef(environmentId, threadId),
463488 [environmentId, threadId],
......@@ -518,6 +543,25 @@ export const BranchToolbar = memo(function BranchToolbar({
518543 });
519544 }, [activeProjectRef, draftId, previousWorktreeSeed, setDraftThreadContext, threadRef]);
520545
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
521565 const showEnvironmentPicker = Boolean(
522566 availableEnvironments && availableEnvironments.length > 1 && onEnvironmentChange,
523567 );
......@@ -618,6 +662,7 @@ export const BranchToolbar = memo(function BranchToolbar({
618662
619663 {showGitControls ? (
620664 <BranchToolbarBranchSelector
665 ref={branchSelectorRef}
621666 className="min-w-0 flex-initial justify-end @3xl/composer-surface:ml-auto"
622667 environmentId={environmentId}
623668 threadId={threadId}
apps/web/src/components/BranchToolbarBranchSelector.tsx+19
......@@ -15,6 +15,7 @@ import {
1515 useDeferredValue,
1616 useEffect,
1717 useId,
18 useImperativeHandle,
1819 useLayoutEffect,
1920 useMemo,
2021 useOptimistic,
......@@ -22,6 +23,7 @@ import {
2223 useState,
2324 useTransition,
2425 type MouseEvent as ReactMouseEvent,
26 type Ref,
2527} from "react";
2628
2729import { useComposerDraftStore, type DraftId } from "../composerDraftStore";
......@@ -72,7 +74,12 @@ import {
7274import { stackedThreadToast, toastManager } from "./ui/toast";
7375import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
7476
77export interface BranchToolbarBranchSelectorHandle {
78 open: () => void;
79}
80
7581interface BranchToolbarBranchSelectorProps {
82 ref?: Ref<BranchToolbarBranchSelectorHandle>;
7683 className?: string;
7784 environmentId: EnvironmentId;
7885 threadId: ThreadId;
......@@ -92,6 +99,7 @@ function toBranchActionErrorMessage(error: unknown): string {
9299}
93100
94101export function BranchToolbarBranchSelector({
102 ref,
95103 className,
96104 environmentId,
97105 threadId,
......@@ -535,6 +543,17 @@ export function BranchToolbarBranchSelector({
535543 }
536544 }, []);
537545
546 useImperativeHandle(
547 ref,
548 () => ({
549 open: () => {
550 if (isInitialBranchesLoadPending || isBranchActionPending) return;
551 handleOpenChange(true);
552 },
553 }),
554 [handleOpenChange, isBranchActionPending, isInitialBranchesLoadPending],
555 );
556
538557 const [showTopBranchScrollFade, setShowTopBranchScrollFade] = useState(false);
539558 const [showBottomBranchScrollFade, setShowBottomBranchScrollFade] = useState(false);
540559 const fetchNextBranchPage = useCallback(() => {
apps/web/src/components/BranchToolbarEnvModeSelector.tsx+1
......@@ -93,6 +93,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe
9393 size="xs"
9494 className="min-w-0 shrink font-normal text-xs!"
9595 aria-label="Workspace"
96 data-composer-shortcut="composer.workspace"
9697 data-composer-context-control
9798 >
9899 {effectiveEnvMode === "worktree" ? (
apps/web/src/components/BranchToolbarEnvironmentSelector.tsx+1
......@@ -95,6 +95,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir
9595 size="xs"
9696 className="min-w-0 max-w-full font-normal text-xs!"
9797 aria-label="Run on"
98 data-composer-shortcut="composer.host"
9899 data-composer-context-control
99100 >
100101 {autoEnvironmentLabel ? (
apps/web/src/components/ChatView.tsx+47-10
......@@ -219,7 +219,7 @@ import {
219219 deriveAgentPanelModel,
220220 foldSubagentActivities,
221221} from "@t3tools/client-runtime/state/subagentRuntime";
222import { BranchToolbar } from "./BranchToolbar";
222import { BranchToolbar, type BranchToolbarHandle } from "./BranchToolbar";
223223import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
224224import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
225225import {
......@@ -1617,6 +1617,7 @@ export default function ChatView(props: ChatViewProps) {
16171617 const composerTerminalContextsRef = useRef<TerminalContextDraft[]>([]);
16181618 const localComposerRef = useRef<ChatComposerHandle | null>(null);
16191619 const composerRef = useComposerHandleContext() ?? localComposerRef;
1620 const branchToolbarRef = useRef<BranchToolbarHandle>(null);
16201621 const pasteAsTextShortcutUntilRef = useRef(0);
16211622 const [restingComposerControlsHost, setRestingComposerControlsHost] =
16221623 useState<HTMLDivElement | null>(null);
......@@ -6397,6 +6398,17 @@ export default function ChatView(props: ChatViewProps) {
63976398 terminalUiOpenByThreadRef.current[activeThreadKey] = current;
63986399 }, [activeThreadKey, focusComposer, terminalUiState.terminalOpen]);
63996400
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
64006412 useEffect(() => {
64016413 const handler = (event: globalThis.KeyboardEvent) => {
64026414 if (preventRepeatedTerminalCloseShortcut(event, keybindings)) {
......@@ -6417,13 +6429,7 @@ export default function ChatView(props: ChatViewProps) {
64176429 if (event.defaultPrevented && terminalFocusOwner === null) {
64186430 return;
64196431 }
6420 const shortcutContext = {
6421 terminalFocus: terminalFocusOwner !== null,
6422 terminalOpen: Boolean(terminalUiState.terminalOpen),
6423 previewFocus: isPreviewFocused(),
6424 previewOpen: previewPanelOpen,
6425 modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false,
6426 };
6432 const shortcutContext = getShortcutContext();
64276433
64286434 if (
64296435 !shortcutContext.terminalFocus &&
......@@ -6588,7 +6594,33 @@ export default function ChatView(props: ChatViewProps) {
65886594 if (command === "modelPicker.toggle") {
65896595 event.preventDefault();
65906596 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();
65926624 return;
65936625 }
65946626
......@@ -6643,7 +6675,7 @@ export default function ChatView(props: ChatViewProps) {
66436675 supportsSettlement,
66446676 confirmAndUnpinThread,
66456677 copyActiveThreadReference,
6646 previewPanelOpen,
6678 getShortcutContext,
66476679 toggleRightPanel,
66486680 toggleRightPanelMaximized,
66496681 toggleTerminalVisibility,
......@@ -8708,6 +8740,10 @@ export default function ChatView(props: ChatViewProps) {
87088740 // reader's feet. A link the agent wrote can open any other one here, and that one has to be
87098741 // checkable out like it is anywhere else.
87108742 <PullRequestDetailPanel
8743 getShortcutContext={getShortcutContext}
8744 shortcutsEnabled={
8745 rightPanelOpen && activeRightPanelSurface?.id === renderedRightPanelSurface.id
8746 }
87118747 key={`${renderedRightPanelSurface.host ?? ""}:${renderedRightPanelSurface.repository}#${renderedRightPanelSurface.number}`}
87128748 environmentId={activeThread.environmentId}
87138749 onSelectPullRequest={(reference) => {
......@@ -9231,6 +9267,7 @@ export default function ChatView(props: ChatViewProps) {
92319267 {mountComposerContextStrip && (
92329268 <div className="pointer-events-auto">
92339269 <BranchToolbar
9270 ref={branchToolbarRef}
92349271 environmentId={activeThread.environmentId}
92359272 threadId={activeThread.id}
92369273 showGitControls={isGitRepo}
apps/web/src/components/chat/ChatComposer.tsx+26-1
......@@ -15,6 +15,7 @@ import {
1515} from "../../questionAttachments";
1616import type {
1717 ApprovalRequestId,
18 KeybindingCommand,
1819 AssistantCitation,
1920 ChatFileAttachment,
2021 EnvironmentId,
......@@ -62,7 +63,7 @@ import {
6263 useState,
6364 useSyncExternalStore,
6465} from "react";
65import { createPortal } from "react-dom";
66import { createPortal, flushSync } from "react-dom";
6667import {
6768 clampCollapsedComposerCursor,
6869 type ComposerSubmissionIntent,
......@@ -1103,6 +1104,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
11031104 <TooltipTrigger
11041105 render={
11051106 <ComposerSelectControl
1107 data-composer-shortcut="composer.mode"
11061108 size={size}
11071109 className={size === "xs" ? undefined : "font-medium"}
11081110 aria-label="Runtime mode"
......@@ -1231,6 +1233,7 @@ export interface ChatComposerHandle {
12311233 ) => boolean;
12321234 openModelPicker: () => void;
12331235 toggleModelPicker: () => void;
1236 openControl: (command: KeybindingCommand) => void;
12341237 isModelPickerOpen: () => boolean;
12351238 compactContext: () => void;
12361239 readSnapshot: () => {
......@@ -5743,6 +5746,28 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
57435746 openModelPicker();
57445747 }
57455748 },
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 },
57465771 compactContext: compactThreadContext,
57475772 isModelPickerOpen: () => isComposerModelPickerOpen,
57485773 readSnapshot: () => {
apps/web/src/components/chat/CompactComposerControlsMenu.tsx+3
......@@ -40,6 +40,9 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls
4040 variant="ghost"
4141 className={size === "xs" ? "shrink-0" : "shrink-0 px-2"}
4242 aria-label="More composer controls"
43 data-composer-shortcut={
44 props.traitsMenuContent ? "composer.mode composer.effort" : "composer.mode"
45 }
4346 />
4447 }
4548 >
apps/web/src/components/chat/ModelPickerContent.test.ts+72
......@@ -8,6 +8,7 @@ import { describe, expect, it } from "vite-plus/test";
88
99import { deriveProviderInstanceEntries } from "../../providerInstances";
1010import {
11 adjacentModelPickerProvider,
1112 resolveModelPickerSelectedModel,
1213 shouldIncludeModelPickerOption,
1314 shouldOfferModelPickerSetup,
......@@ -213,3 +214,74 @@ describe("shouldOfferModelPickerSetup", () => {
213214 ).toBe(true);
214215 });
215216});
217
218describe("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(
113113 );
114114}
115115
116export 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
116144const EMPTY_MODEL_JUMP_LABELS = new Map<string, string>();
117145
118146function ModelListSeparator() {
......@@ -687,6 +715,20 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
687715 platform: navigator.platform,
688716 context: modelJumpShortcutContext,
689717 });
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 }
690732 const jumpIndex = modelPickerJumpIndexFromCommand(command ?? "");
691733 if (jumpIndex === null) {
692734 return;
......@@ -710,7 +752,17 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
710752 return () => {
711753 window.removeEventListener("keydown", onWindowKeyDown, true);
712754 };
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 ]);
714766
715767 useLayoutEffect(() => {
716768 setShowTopScrollFade(false);
......@@ -737,6 +789,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
737789 <ModelPickerSidebar
738790 selectedInstanceId={selectedInstanceId}
739791 onSelectInstance={handleSelectInstance}
792 onFocusSearch={focusSearchInput}
740793 instanceEntries={sidebarInstanceEntries}
741794 showFavorites
742795 {...(selectableUnavailableInstanceIds ? { selectableUnavailableInstanceIds } : {})}
......@@ -805,6 +858,28 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
805858 value={searchQuery}
806859 onChange={(e) => setSearchQuery(e.target.value)}
807860 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 }
808883 if (e.key === "Escape") {
809884 e.preventDefault();
810885 e.stopPropagation();
apps/web/src/components/chat/ModelPickerSidebar.tsx+24-6
......@@ -1,3 +1,4 @@
1import { Toolbar } from "@base-ui/react/toolbar";
12import { type ProviderInstanceId } from "@t3tools/contracts";
23import { memo, useLayoutEffect, useRef, useState } from "react";
34import { SparklesIcon, StarIcon } from "lucide-react";
......@@ -43,6 +44,7 @@ const PICKER_TOOLTIP_CLASS = "max-w-64 text-balance font-normal leading-snug";
4344export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: {
4445 selectedInstanceId: ProviderInstanceId | "favorites";
4546 onSelectInstance: (instanceId: ProviderInstanceId | "favorites") => void;
47 onFocusSearch: () => void;
4648 /**
4749 * Instance entries to render as rail buttons. Each entry becomes one icon
4850 * keyed by `instanceId`, so the default built-in Codex and a user-authored
......@@ -87,7 +89,20 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: {
8789 }, [props.instanceEntries, props.selectedInstanceId, showFavorites]);
8890
8991 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 >
91106 <div className="h-full overflow-y-auto overscroll-contain [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
92107 <div ref={sidebarContentRef} className="relative flex min-h-full flex-col gap-1 p-1">
93108 {selectedIndicatorTop !== null ? (
......@@ -107,16 +122,17 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: {
107122 <Tooltip>
108123 <TooltipTrigger
109124 render={
110 <button
125 <Toolbar.Button
111126 className={cn(
112127 "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",
113128 )}
114129 onClick={() => handleSelect("favorites")}
115130 type="button"
116131 aria-label="Favorites"
132 aria-pressed={props.selectedInstanceId === "favorites"}
117133 >
118134 <StarIcon className="size-5 fill-current shrink-0" aria-hidden />
119 </button>
135 </Toolbar.Button>
120136 }
121137 />
122138 <TooltipPopup
......@@ -155,7 +171,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: {
155171 : entry.displayName;
156172
157173 const button = (
158 <button
174 <Toolbar.Button
159175 className={cn(
160176 "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",
161177 isDisabled && "opacity-50 cursor-not-allowed hover:bg-transparent",
......@@ -171,6 +187,8 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: {
171187 setHoveredInstanceId((current) => (current === entry.instanceId ? null : current))
172188 }
173189 disabled={isDisabled}
190 focusableWhenDisabled={!isDisabled}
191 aria-pressed={isSelected}
174192 type="button"
175193 aria-label={
176194 isUnavailable || isContextDisabled
......@@ -203,7 +221,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: {
203221 <SparklesIcon className="size-2" />
204222 </span>
205223 ) : null}
206 </button>
224 </Toolbar.Button>
207225 );
208226
209227 const trigger = isDisabled ? (
......@@ -234,6 +252,6 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: {
234252 })}
235253 </div>
236254 </div>
237 </div>
255 </Toolbar.Root>
238256 );
239257});
apps/web/src/components/chat/TraitsPicker.tsx+1
......@@ -618,6 +618,7 @@ export const TraitsPicker = memo(function TraitsPicker({
618618 <MenuTrigger
619619 render={
620620 <ComposerControl
621 data-composer-shortcut={isComposerOwned ? "composer.effort" : undefined}
621622 variant={triggerVariant ?? "ghost"}
622623 size={size}
623624 className={cn(
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+53-2
......@@ -1,4 +1,5 @@
11import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl";
2import { useAtomValue } from "@effect/atom-react";
23import { usePullRequestStack } from "~/state/usePullRequestStack";
34import { RefreshIcon } from "~/components/ui/refresh-icon";
45import { scopedThreadKey, scopeProjectRef } from "@t3tools/client-runtime/environment";
......@@ -21,6 +22,7 @@ import {
2122 ArrowUpRightIcon,
2223 BookOpenIcon,
2324 CircleDotIcon,
25 CopyIcon,
2426 ChevronDownIcon,
2527 ExternalLinkIcon,
2628 FileDiffIcon,
......@@ -49,6 +51,7 @@ import {
4951 type MouseEvent as ReactMouseEvent,
5052 useCallback,
5153 useEffect,
54 useEffectEvent,
5255 useLayoutEffect,
5356 useMemo,
5457 useRef,
......@@ -57,7 +60,14 @@ import {
5760
5861import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";
5962import { useNewThreadHandler } from "~/hooks/useHandleNewThread";
60import { useCopyToClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard";
63import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
64import { isCommandPaletteOpen } from "~/commandPaletteBus";
65import {
66 resolveShortcutCommand,
67 shortcutLabelForCommand,
68 type ShortcutMatchContext,
69} from "~/keybindings";
70import { primaryServerKeybindingsAtom } from "~/state/server";
6171import { useClientSettings } from "~/hooks/useSettings";
6272import {
6373 deriveLogicalProjectKeyFromSettings,
......@@ -104,6 +114,7 @@ import {
104114 MenuRadioGroup,
105115 MenuRadioItem,
106116 MenuSeparator,
117 MenuShortcut,
107118 MenuTrigger,
108119} from "../ui/menu";
109120import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
......@@ -457,6 +468,8 @@ function PullRequestBaseFreshnessWarning({
457468
458469export function PullRequestDetailPanel({
459470 environmentId,
471 shortcutsEnabled,
472 getShortcutContext,
460473 threadRef = null,
461474 reference: requestedReference,
462475 listEntry = null,
......@@ -469,6 +482,8 @@ export function PullRequestDetailPanel({
469482 onSelectPullRequest,
470483}: {
471484 environmentId: EnvironmentId;
485 shortcutsEnabled: boolean;
486 getShortcutContext: () => ShortcutMatchContext;
472487 onSelectPullRequest?: ((reference: PullRequestRef) => void) | undefined;
473488 /**
474489 * The thread this panel sits beside, if any. Links that are not the pull
......@@ -707,6 +722,32 @@ export function PullRequestDetailPanel({
707722 },
708723 [activity, coreDetail],
709724 );
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 }, []);
710751 useEffect(() => {
711752 if (detail?.autoMergeMethod !== undefined) setMergeMethod(detail.autoMergeMethod);
712753 }, [detail?.autoMergeMethod, pullRequestKey]);
......@@ -2080,9 +2121,19 @@ export function PullRequestDetailPanel({
20802121 <ArrowUpRightIcon className="size-3.5" />
20812122 {openOnHostLabel(detail.provider)}
20822123 </MenuItem>
2083 <MenuItem onClick={() => void writeTextToClipboard(detail.url)}>
2124 <MenuItem onClick={() => copyReference(detail.url, "PR link")}>
20842125 <LinkIcon className="size-3.5" />
20852126 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>
20862137 </MenuItem>
20872138 {detail.state === "open" && can("close") ? (
20882139 <>
apps/web/src/components/settings/KeybindingsSettings.logic.test.ts+26
......@@ -17,6 +17,32 @@ import {
1717} from "./KeybindingsSettings.logic";
1818
1919describe("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 });
2046 it("builds searchable rows with readable key and when values", () => {
2147 const rows = buildKeybindingRows(
2248 [
apps/web/src/components/settings/KeybindingsSettings.logic.ts+2
......@@ -209,6 +209,7 @@ export function buildKeybindingRows(
209209 return rowsWithConflicts.filter((row) => {
210210 return (
211211 row.command.toLowerCase().includes(normalizedQuery) ||
212 commandLabel(row.command).toLowerCase().includes(normalizedQuery) ||
212213 row.key.toLowerCase().includes(normalizedQuery) ||
213214 row.when.toLowerCase().includes(normalizedQuery) ||
214215 row.source.toLowerCase().includes(normalizedQuery)
......@@ -275,6 +276,7 @@ export function buildKeybindingCommandOptions(
275276}
276277
277278export function commandLabel(command: KeybindingCommand): string {
279 if (command === "thread.copyReference") return "Thread: Copy PR URL or Thread ID";
278280 const raw = String(command);
279281 if (raw.startsWith("script.") && raw.endsWith(".run")) {
280282 return `Run Script: ${titleCaseCommandSegment(raw.slice("script.".length, -".run".length))}`;
apps/web/src/components/settings/KeybindingsSettings.tsx+6-2
......@@ -26,7 +26,7 @@ import {
2626 type ServerRemoveKeybindingInput,
2727 type ServerUpsertKeybindingInput,
2828} from "@t3tools/contracts";
29import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
29import { mergeWithDefaultKeybindings } from "@t3tools/shared/keybindings";
3030import {
3131 isAtomCommandInterrupted,
3232 squashAtomCommandFailure,
......@@ -1334,7 +1334,11 @@ export function KeybindingsSettingsPanel() {
13341334 // fan out to every connected environment in the selection, so one
13351335 // shortcut change reaches each machine the user runs T3 Code on.
13361336 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 );
13381342 const keybindingsConfigPath = primaryEnvironment?.serverConfig?.keybindingsConfigPath ?? null;
13391343 const availableEditors = primaryEnvironment?.serverConfig?.availableEditors ?? [];
13401344 const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, {
apps/web/src/keybindings.test.ts+196
......@@ -1,4 +1,9 @@
11import { assert, describe, it } from "vite-plus/test";
2import {
3 compileResolvedKeybindingsConfig,
4 DEFAULT_RESOLVED_KEYBINDINGS,
5 mergeWithDefaultKeybindings,
6} from "@t3tools/shared/keybindings";
27
38import {
49 type KeybindingCommand,
......@@ -1057,3 +1062,194 @@ describe("plus key parsing", () => {
10571062 );
10581063 });
10591064});
1065
1066describe("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 {
1111import { isMacPlatform } from "./lib/utils";
1212
1313export interface ShortcutEventLike {
14 getModifierState?: (key: "AltGraph") => boolean;
1415 type?: string;
1516 code?: string;
1617 key: string;
......@@ -123,6 +124,12 @@ function matchesShortcut(
123124 shortcut: KeybindingShortcut,
124125 platform = navigator.platform,
125126): boolean {
127 if (
128 !isMacPlatform(platform) &&
129 event.getModifierState?.("AltGraph") &&
130 !/^[a-z0-9]$/i.test(event.key)
131 )
132 return false;
126133 if (!matchesShortcutModifiers(event, shortcut, platform)) return false;
127134 return resolveEventKeys(event).has(shortcut.key);
128135}
apps/web/src/routes/_chat.pull-requests.tsx+13-1
......@@ -151,6 +151,16 @@ import { cn } from "~/lib/utils";
151151import { primaryServerKeybindingsAtom } from "~/state/server";
152152import { getSourceControlPresentationForKind } from "~/sourceControlPresentation";
153153
154function getShortcutContext() {
155 return {
156 terminalFocus: isTerminalFocused(),
157 terminalOpen: false,
158 previewFocus: false,
159 previewOpen: false,
160 modelPickerOpen: false,
161 };
162}
163
154164export interface PullRequestsSearch extends PullRequestListPreferences {
155165 /**
156166 * Narrows the list to one server. Absent means every connected one, which is the default the
......@@ -1925,7 +1935,7 @@ function PullRequestsRouteView() {
19251935 const onKeyDown = (event: KeyboardEvent) => {
19261936 if (event.defaultPrevented || isCommandPaletteOpen()) return;
19271937 const command = resolveShortcutCommand(event, keybindings, {
1928 context: { terminalFocus: isTerminalFocused() },
1938 context: getShortcutContext(),
19291939 });
19301940 if (command === "rightPanel.close") closeActiveSurfaceFromShortcut(event);
19311941 if (command === "rightPanel.toggle") toggleRightPanelFromShortcut(event);
......@@ -1992,6 +2002,8 @@ function PullRequestsRouteView() {
19922002 pullRequestStatusSeeds={listedPullRequestTabStatuses}
19932003 >
19942004 <PullRequestDetailPanel
2005 getShortcutContext={getShortcutContext}
2006 shortcutsEnabled={activePullRequestSurface?.id === renderedPullRequestSurface.id}
19952007 key={renderedPullRequestSurface.id}
19962008 environmentId={panelEnvironmentId}
19972009 onSelectPullRequest={(reference) => {
apps/web/src/state/server.ts+3-4
......@@ -10,7 +10,7 @@ import {
1010} from "@t3tools/contracts";
1111import { createServerEnvironmentAtoms } from "@t3tools/client-runtime/state/server";
1212import { createEnvironmentServerConfigsAtom } from "@t3tools/client-runtime/state/shell";
13import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
13import { mergeWithDefaultKeybindings } from "@t3tools/shared/keybindings";
1414import * as Option from "effect/Option";
1515import { AsyncResult, Atom } from "effect/unstable/reactivity";
1616
......@@ -90,9 +90,8 @@ export const primaryServerProvidersAtom = Atom.make(
9090 get(primaryServerConfigAtom)?.providers ?? EMPTY_SERVER_PROVIDERS,
9191).pipe(Atom.withLabel("web-primary-server-providers"));
9292
93export const primaryServerKeybindingsAtom = Atom.make(
94 (get): ServerConfig["keybindings"] =>
95 get(primaryServerConfigAtom)?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS,
93export const primaryServerKeybindingsAtom = Atom.make((get): ServerConfig["keybindings"] =>
94 mergeWithDefaultKeybindings(get(primaryServerConfigAtom)?.keybindings ?? []),
9695).pipe(Atom.withLabel("web-primary-server-keybindings"));
9796
9897export const primaryServerAvailableEditorsAtom = Atom.make(
docs/user/keybindings.md+25
......@@ -3,6 +3,31 @@
33Customize shortcuts in **Settings → Keybindings** on web and desktop. That page
44also lists the command IDs and defaults available in your version.
55
6## Composer controls
7
8Use `mod+shift+m` to choose a model and `mod+shift+h` to choose a host.
9Use `mod+shift+e` for effort, `mod+shift+a` for access mode, `mod+shift+x` for the
10workspace, and `mod+shift+g` for the Git branch. The workspace menu includes the
11current checkout, a new worktree, and the previous worktree when available.
12Use `mod+shift+l` to reuse the previous worktree directly.
13
14In the model picker, press Left in an empty search field or Shift+Tab to reach
15the provider list. Use Up/Down to move and Enter to choose. Right returns to
16model search. `mod+shift+up` and `mod+shift+down` switch providers directly and clear the
17search. These provider shortcuts can also be changed in Settings.
18
19These shortcuts run inside the focused web or desktop client. `mod` uses Command
20on macOS and Ctrl on Windows and Linux, including GNOME, KDE Plasma, Niri, and
21Hyprland. If a custom desktop shortcut takes the same keys, choose another binding
22in Settings.
23
24## Copy pull request references
25
26With a PR open in the right panel or on the Pull Requests page, use `mod+shift+c`
27to copy its URL and `mod+shift+k` to copy its number with a `#` prefix.
28Both shortcuts can be changed in Settings. Search for “Copy PR URL or Thread ID”
29or “Copy Number”. They copy the selected PR and leave terminal input alone.
30
631## Edit the configuration file
732
833Keybindings live on the environment's machine, in
packages/contracts/src/keybindings.test.ts+7
......@@ -122,6 +122,13 @@ it.effect("parses keybinding rules", () =>
122122 });
123123 assert.strictEqual(parsedThreadCopyReference.command, "thread.copyReference");
124124
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
125132 const parsedThreadStop = yield* decode(KeybindingRule, {
126133 key: "mod+escape",
127134 command: "thread.stop",
packages/contracts/src/keybindings.ts+9
......@@ -47,6 +47,8 @@ export type ThreadKeybindingCommand = (typeof THREAD_KEYBINDING_COMMANDS)[number
4747
4848const MODEL_PICKER_KEYBINDING_COMMANDS = [
4949 "modelPicker.toggle",
50 "modelPicker.previousProvider",
51 "modelPicker.nextProvider",
5052 ...MODEL_PICKER_JUMP_KEYBINDING_COMMANDS,
5153] as const;
5254export type ModelPickerKeybindingCommand = (typeof MODEL_PICKER_KEYBINDING_COMMANDS)[number];
......@@ -61,6 +63,7 @@ export const STATIC_KEYBINDING_COMMANDS = [
6163 "rightPanel.toggle",
6264 "rightPanel.toggleMaximized",
6365 "rightPanel.close",
66 "pullRequest.copyNumber",
6467 "diff.toggle",
6568 "preview.toggle",
6669 "preview.refresh",
......@@ -73,6 +76,12 @@ export const STATIC_KEYBINDING_COMMANDS = [
7376 "projectSearch.toggle",
7477 "themeEditor.toggle",
7578 "composer.stash",
79 "composer.host",
80 "composer.effort",
81 "composer.mode",
82 "composer.workspace",
83 "composer.previousWorktree",
84 "composer.branch",
7685 "chat.new",
7786 "chat.newLocal",
7887 "editor.openFavorite",
packages/shared/src/keybindings.ts+29
......@@ -44,6 +44,15 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
4444 { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" },
4545 { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" },
4646 { 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" },
4756 { key: "mod+o", command: "editor.openFavorite" },
4857 { key: "mod+shift+[", command: "thread.previous" },
4958 { key: "mod+shift+]", command: "thread.next" },
......@@ -299,3 +308,23 @@ export function compileResolvedKeybindingsConfig(
299308}
300309
301310export const DEFAULT_RESOLVED_KEYBINDINGS = compileResolvedKeybindingsConfig(DEFAULT_KEYBINDINGS);
311
312export 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}