authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-16 23:17:57-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-16 23:17:57-03:00
log71d12d8c4a05bc50344d6e3fac1b967c6daaff84
tree9b917154ffe59afd766e5ba8bfc9f48ee84d9380
parentd359e94cabca4722029359c77d60cb6d13da1e59
signature Signed by PGP key B5690EEEBB952194

feat(web): make keybindings searchable from settings search (#12175)


6 files changed, 139 insertions(+), 5 deletions(-)

apps/web/src/components/CommandPalette.logic.test.ts+31
...@@ -11,6 +11,7 @@ import {...@@ -11,6 +11,7 @@ import {
11 filterPinnedBrowseEntries,11 filterPinnedBrowseEntries,
12 filterCommandPaletteGroups,12 filterCommandPaletteGroups,
13 reduceCommandPaletteUiState,13 reduceCommandPaletteUiState,
14 type CommandPaletteActionItem,
14 type CommandPaletteGroup,15 type CommandPaletteGroup,
15} from "./CommandPalette.logic";16} from "./CommandPalette.logic";
1617
...@@ -738,3 +739,33 @@ it.each([...@@ -738,3 +739,33 @@ it.each([
738 "Implementation",739 "Implementation",
739 ]);740 ]);
740});741});
742
743describe("filterCommandPaletteGroups", () => {
744 it("sorts secondary settings results after other matches", () => {
745 const item = (value: string, title: string, secondary?: boolean) =>
746 ({
747 kind: "action",
748 value,
749 title,
750 searchTerms: [title, "General"],
751 icon: null,
752 run: async () => undefined,
753 ...(secondary ? { secondary } : {}),
754 }) satisfies CommandPaletteActionItem;
755 const [group] = filterCommandPaletteGroups({
756 activeGroups: [],
757 query: "model",
758 isInSubmenu: false,
759 projectSearchItems: [],
760 settingsSearchItems: [
761 item("setting:keybinding-modelPicker.toggle", "Model Picker: Toggle", true),
762 item("setting:default-model", "Default model"),
763 ],
764 threadSearchItems: [],
765 });
766 expect(group?.items.map((entry) => entry.value)).toEqual([
767 "setting:default-model",
768 "setting:keybinding-modelPicker.toggle",
769 ]);
770 });
771});
apps/web/src/components/CommandPalette.logic.ts+8-1
...@@ -142,6 +142,8 @@ export interface CommandPaletteItem {...@@ -142,6 +142,8 @@ export interface CommandPaletteItem {
142 /** Optional content rendered inline after the title text (before the timestamp). */142 /** Optional content rendered inline after the title text (before the timestamp). */
143 readonly titleTrailingContent?: ReactNode;143 readonly titleTrailingContent?: ReactNode;
144 readonly shortcutCommand?: KeybindingCommand;144 readonly shortcutCommand?: KeybindingCommand;
145 /** Sorts after every other match in its group; see `SettingsSearchItem.secondary`. */
146 readonly secondary?: boolean;
145}147}
146148
147export interface CommandPaletteActionItem extends CommandPaletteItem {149export interface CommandPaletteActionItem extends CommandPaletteItem {
...@@ -434,7 +436,12 @@ export function filterCommandPaletteGroups(input: {...@@ -434,7 +436,12 @@ export function filterCommandPaletteGroups(input: {
434 rank: rankCommandPaletteItemMatch(item, normalizedQuery, queryTokens),436 rank: rankCommandPaletteItemMatch(item, normalizedQuery, queryTokens),
435 });437 });
436 })438 })
437 .toSorted((left, right) => right.rank - left.rank || left.index - right.index)439 .toSorted(
440 (left, right) =>
441 Number(left.item.secondary ?? false) - Number(right.item.secondary ?? false) ||
442 right.rank - left.rank ||
443 left.index - right.index,
444 )
438 .map((entry) => entry.item);445 .map((entry) => entry.item);
439446
440 if (items.length === 0) {447 if (items.length === 0) {
apps/web/src/components/CommandPalette.tsx+1
...@@ -2058,6 +2058,7 @@ function OpenCommandPaletteDialog(props: {...@@ -2058,6 +2058,7 @@ function OpenCommandPaletteDialog(props: {
2058 searchTerms: [item.title, SETTINGS_SECTION_LABELS[item.to], ...(item.searchTerms ?? [])],2058 searchTerms: [item.title, SETTINGS_SECTION_LABELS[item.to], ...(item.searchTerms ?? [])],
2059 title: item.title,2059 title: item.title,
2060 description: `Settings · ${SETTINGS_SECTION_LABELS[item.to]}`,2060 description: `Settings · ${SETTINGS_SECTION_LABELS[item.to]}`,
2061 ...(item.secondary ? { secondary: true } : {}),
2061 icon: <SettingsIcon className={ITEM_ICON_CLASS} />,2062 icon: <SettingsIcon className={ITEM_ICON_CLASS} />,
2062 run: async () => {2063 run: async () => {
2063 await navigate({2064 await navigate({
apps/web/src/components/settings/KeybindingsSettings.tsx+31-3
...@@ -9,6 +9,7 @@ import {...@@ -9,6 +9,7 @@ import {
9 TriangleAlertIcon,9 TriangleAlertIcon,
10 XIcon,10 XIcon,
11} from "lucide-react";11} from "lucide-react";
12import { useLocation } from "@tanstack/react-router";
12import {13import {
13 type KeyboardEvent,14 type KeyboardEvent,
14 type ReactNode,15 type ReactNode,
...@@ -65,7 +66,7 @@ import {...@@ -65,7 +66,7 @@ import {
65 whenNodeRemoveLabel,66 whenNodeRemoveLabel,
66} from "./KeybindingsSettings.logic";67} from "./KeybindingsSettings.logic";
67import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout";68import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout";
68import { searchableSetting } from "./settingsSearch";69import { keybindingSearchAnchorId, searchableSetting } from "./settingsSearch";
69import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";70import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
70import { useAtomCommand } from "../../state/use-atom-command";71import { useAtomCommand } from "../../state/use-atom-command";
7172
...@@ -823,7 +824,11 @@ interface KeybindingRowActions {...@@ -823,7 +824,11 @@ interface KeybindingRowActions {
823 onRemove: (row: KeybindingRow) => void;824 onRemove: (row: KeybindingRow) => void;
824}825}
825826
826type KeybindingRowProps = KeybindingRowActions & { row: KeybindingRow; isSaving: boolean };827type KeybindingRowProps = KeybindingRowActions & {
828 row: KeybindingRow;
829 isSaving: boolean;
830 anchorId?: string | undefined;
831};
827832
828/** Shortcut pill that turns into a capture input when clicked, plus Save once the draft changes. */833/** Shortcut pill that turns into a capture input when clicked, plus Save once the draft changes. */
829function KeybindingKeyControl({834function KeybindingKeyControl({
...@@ -1034,11 +1039,12 @@ function KeybindingHoverRowMenu(props: {...@@ -1034,11 +1039,12 @@ function KeybindingHoverRowMenu(props: {
10341039
1035/** One binding as a settings row: pills flush right, actions fading in beside them on hover. */1040/** One binding as a settings row: pills flush right, actions fading in beside them on hover. */
1036function KeybindingSettingsRow(props: KeybindingRowProps) {1041function KeybindingSettingsRow(props: KeybindingRowProps) {
1037 const { row, isSaving, allRows, variables, onSave, onReset, onRemove } = props;1042 const { row, isSaving, anchorId, allRows, variables, onSave, onReset, onRemove } = props;
1038 const editor = useKeybindingRowEditor({ row, allRows, onSave });1043 const editor = useKeybindingRowEditor({ row, allRows, onSave });
10391044
1040 return (1045 return (
1041 <SettingsRow1046 <SettingsRow
1047 id={anchorId}
1042 className="group/row rounded-none"1048 className="group/row rounded-none"
1043 title={<KeybindingRowTitle row={row} />}1049 title={<KeybindingRowTitle row={row} />}
1044 description={<KeybindingRowWhen row={row} editor={editor} variables={variables} />}1050 description={<KeybindingRowWhen row={row} editor={editor} variables={variables} />}
...@@ -1296,6 +1302,17 @@ function KeybindingsList(props: KeybindingsListProps) {...@@ -1296,6 +1302,17 @@ function KeybindingsList(props: KeybindingsListProps) {
1296 onSave: rowActions.onSave,1302 onSave: rowActions.onSave,
1297 onCancel: onCancelAdd,1303 onCancel: onCancelAdd,
1298 };1304 };
1305 // Settings search jumps to a command, so only its first row anchors.
1306 const anchorIds = useMemo(() => {
1307 const ids = new Map<string, string>();
1308 const seen = new Set<KeybindingCommand>();
1309 for (const row of rows) {
1310 if (seen.has(row.command)) continue;
1311 seen.add(row.command);
1312 ids.set(row.id, keybindingSearchAnchorId(row.command));
1313 }
1314 return ids;
1315 }, [rows]);
1299 return (1316 return (
1300 <div>1317 <div>
1301 {isAddingBinding ? <NewKeybindingSettingsRow {...newProps} /> : null}1318 {isAddingBinding ? <NewKeybindingSettingsRow {...newProps} /> : null}
...@@ -1303,6 +1320,7 @@ function KeybindingsList(props: KeybindingsListProps) {...@@ -1303,6 +1320,7 @@ function KeybindingsList(props: KeybindingsListProps) {
1303 <KeybindingSettingsRow1320 <KeybindingSettingsRow
1304 key={row.id}1321 key={row.id}
1305 row={row}1322 row={row}
1323 anchorId={anchorIds.get(row.id)}
1306 isSaving={savingCommand === row.command}1324 isSaving={savingCommand === row.command}
1307 {...rowActions}1325 {...rowActions}
1308 />1326 />
...@@ -1357,6 +1375,16 @@ export function KeybindingsSettingsPanel() {...@@ -1357,6 +1375,16 @@ export function KeybindingsSettingsPanel() {
1357 const [savingCommand, setSavingCommand] = useState<KeybindingCommand | null>(null);1375 const [savingCommand, setSavingCommand] = useState<KeybindingCommand | null>(null);
1358 const [isAddingBinding, setIsAddingBinding] = useState(false);1376 const [isAddingBinding, setIsAddingBinding] = useState(false);
1359 const rows = useMemo(() => buildKeybindingRows(keybindings, query), [keybindings, query]);1377 const rows = useMemo(() => buildKeybindingRows(keybindings, query), [keybindings, query]);
1378 // The search-target context is provided by this panel's own page container,
1379 // so the jump target is read from the route hash here.
1380 const searchTargetId = useLocation({ select: (location) => location.hash.replace(/^#/, "") });
1381 const [handledSearchTargetId, setHandledSearchTargetId] = useState(searchTargetId);
1382
1383 // A settings-search jump must not be hidden by the page's own filter.
1384 if (searchTargetId !== handledSearchTargetId) {
1385 setHandledSearchTargetId(searchTargetId);
1386 if (searchTargetId.startsWith("keybinding-")) setQuery("");
1387 }
1360 const commandOptions = useMemo(() => buildKeybindingCommandOptions(keybindings), [keybindings]);1388 const commandOptions = useMemo(() => buildKeybindingCommandOptions(keybindings), [keybindings]);
1361 const whenVariables = useMemo(() => buildWhenVariableOptions(), []);1389 const whenVariables = useMemo(() => buildWhenVariableOptions(), []);
13621390
apps/web/src/components/settings/settingsSearch.test.ts+26
...@@ -216,6 +216,32 @@ describe("searchSettings", () => {...@@ -216,6 +216,32 @@ describe("searchSettings", () => {
216 ]);216 ]);
217 });217 });
218218
219 it("finds keybinding commands by label, command id, and default key", () => {
220 expect(searchSettings("toggle sidebar")[0]?.id).toBe("keybinding-sidebar.toggle");
221 expect(searchSettings("sidebar.toggle")[0]?.id).toBe("keybinding-sidebar.toggle");
222 expect(searchSettings("mod+b")[0]?.id).toBe("keybinding-sidebar.toggle");
223 expect(searchSettings("copy link")[0]).toMatchObject({
224 id: "keybinding-thread.copyReference",
225 to: "/settings/keybindings",
226 });
227 });
228
229 it("ranks keybinding commands after other settings", () => {
230 const ids = searchSettings("model").map((item) => item.id);
231 expect(ids[0]).toBe("default-model");
232 expect(ids.indexOf("keybinding-modelPicker.toggle")).toBeGreaterThan(
233 ids.indexOf("text-generation-model"),
234 );
235 });
236
237 it("sends commands without a default binding to the section", () => {
238 expect(searchSettings("thread.stop")[0]).toMatchObject({
239 id: "keybinding-thread.stop",
240 targetId: "keybindings",
241 });
242 expect(searchSettings("sidebar.toggle")[0]?.targetId).toBeUndefined();
243 });
244
219 it("keeps catalog result ids unique", () => {245 it("keeps catalog result ids unique", () => {
220 const ids = SETTINGS_SEARCH_ITEMS.map((item) => item.id);246 const ids = SETTINGS_SEARCH_ITEMS.map((item) => item.id);
221 expect(new Set(ids).size).toBe(ids.length);247 expect(new Set(ids).size).toBe(ids.length);
apps/web/src/components/settings/settingsSearch.ts+42-1
...@@ -1,7 +1,10 @@...@@ -1,7 +1,10 @@
1import { isElectron } from "~/env";1import { isElectron } from "~/env";
2import { isMacPlatform, isWindowsPlatform, normalizeSearchText } from "~/lib/utils";2import { isMacPlatform, isWindowsPlatform, normalizeSearchText } from "~/lib/utils";
3import { STATIC_KEYBINDING_COMMANDS, type KeybindingCommand } from "@t3tools/contracts";
3import type { EnvironmentId } from "@t3tools/contracts";4import type { EnvironmentId } from "@t3tools/contracts";
4import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";5import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
6import { DEFAULT_KEYBINDINGS } from "@t3tools/shared/keybindings";
7import { commandLabel } from "./KeybindingsSettings.logic";
5import {8import {
6 validateSettingsScopeSearch,9 validateSettingsScopeSearch,
7 type ResolvedSettingsScope,10 type ResolvedSettingsScope,
...@@ -54,6 +57,11 @@ export interface SettingsSearchItem {...@@ -54,6 +57,11 @@ export interface SettingsSearchItem {
54 readonly localBackendManagementOnly?: boolean;57 readonly localBackendManagementOnly?: boolean;
55 readonly localEnvironmentOnly?: boolean;58 readonly localEnvironmentOnly?: boolean;
56 readonly wslAvailableOnly?: boolean;59 readonly wslAvailableOnly?: boolean;
60 /**
61 * Sorts after every other match. Keybinding commands mirror rows on other
62 * surfaces, so "model" must still lead with Default model, not Model Picker.
63 */
64 readonly secondary?: boolean;
57 readonly requiresThreadAutoSettlement?: boolean;65 readonly requiresThreadAutoSettlement?: boolean;
58}66}
5967
...@@ -84,6 +92,33 @@ export const SETTINGS_SECTION_LABELS: Readonly<Record<SettingsPath, string>> = {...@@ -84,6 +92,33 @@ export const SETTINGS_SECTION_LABELS: Readonly<Record<SettingsPath, string>> = {
84 "/settings/archived": "Archive",92 "/settings/archived": "Archive",
85};93};
8694
95/** Anchor id of the first row bound to `command` on the Keybindings page. */
96export function keybindingSearchAnchorId<Command extends KeybindingCommand>(command: Command) {
97 return `keybinding-${command}` as const;
98}
99
100/**
101 * One result per built-in command, alphabetical by label. The anchor is
102 * the command's first row; default keys are searchable so "mod+b" lands on
103 * Sidebar: Toggle. A command with no default binding may have no row, so it
104 * points at the section instead.
105 */
106const KEYBINDING_SEARCH_ITEMS = STATIC_KEYBINDING_COMMANDS.toSorted((left, right) =>
107 commandLabel(left).localeCompare(commandLabel(right)),
108).map((command) => {
109 const defaultKeys = DEFAULT_KEYBINDINGS.filter((binding) => binding.command === command).map(
110 (binding) => binding.key,
111 );
112 return {
113 id: keybindingSearchAnchorId(command),
114 title: commandLabel(command),
115 to: "/settings/keybindings" as const,
116 searchTerms: [command, ...defaultKeys],
117 secondary: true,
118 ...(defaultKeys.length === 0 ? { targetId: "keybindings" } : {}),
119 };
120});
121
87/**122/**
88 * Searchable settings and stable destinations, in result order. Rows with a123 * Searchable settings and stable destinations, in result order. Rows with a
89 * dedicated anchor render their id and title via `searchableSetting`; items124 * dedicated anchor render their id and title via `searchableSetting`; items
...@@ -429,6 +464,7 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -429,6 +464,7 @@ export const SETTINGS_SEARCH_ITEMS = [
429 to: "/settings/keybindings",464 to: "/settings/keybindings",
430 searchTerms: ["keyboard shortcuts hotkeys commands bindings json"],465 searchTerms: ["keyboard shortcuts hotkeys commands bindings json"],
431 },466 },
467 ...KEYBINDING_SEARCH_ITEMS,
432 {468 {
433 id: "snap-shot-enabled",469 id: "snap-shot-enabled",
434 title: "SnapShots",470 title: "SnapShots",
...@@ -914,6 +950,11 @@ export function searchSettings(...@@ -914,6 +950,11 @@ export function searchSettings(
914 : 0;950 : 0;
915 return [{ item, index, rank }];951 return [{ item, index, rank }];
916 })952 })
917 .toSorted((left, right) => right.rank - left.rank || left.index - right.index)953 .toSorted(
954 (left, right) =>
955 Number(left.item.secondary ?? false) - Number(right.item.secondary ?? false) ||
956 right.rank - left.rank ||
957 left.index - right.index,
958 )
918 .map(({ item }) => item);959 .map(({ item }) => item);
919}960}