authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-10-06 20:53:52-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-06 20:53:52-03:00
log72d5c32ba67953805feb6fe9ad3b70b632a64c47
tree34068fae2b880f72c991ad7477f4c6cc8279df9f
parent5055de3ae483d5ece45695ad682b9766ff0cf423
signature Signed by PGP key B5690EEEBB952194

feat(web): group keybindings settings by area with a page toolbar (#12822)


3 files changed, 182 insertions(+), 157 deletions(-)

apps/web/src/components/settings/KeybindingsSettings.logic.test.ts+20
......@@ -7,6 +7,7 @@ import {
77 buildKeybindingCommandOptions,
88 buildWhenVariableOptions,
99 commandLabel,
10 groupKeybindingRows,
1011 keybindingConflictLabels,
1112 keybindingFromKeyboardEvent,
1213 parseWhenExpressionDraft,
......@@ -58,6 +59,25 @@ describe("KeybindingsSettings.logic", () => {
5859 );
5960 },
6061 );
62 it("groups rows by command area in page order and drops empty groups", () => {
63 const groups = groupKeybindingRows(buildKeybindingRows(DEFAULT_RESOLVED_KEYBINDINGS, ""));
64 expect(groups.map((group) => group.title)).toEqual([
65 "Navigation",
66 "Threads",
67 "Composer",
68 "Terminal",
69 "Preview & diff",
70 "Appearance",
71 "Other",
72 ]);
73 const composer = groups.find((group) => group.id === "composer");
74 expect(composer?.rows.map((row) => row.command)).toEqual(
75 expect.arrayContaining(["composer.host", "modelPicker.toggle"]),
76 );
77 expect(groupKeybindingRows(buildKeybindingRows(DEFAULT_RESOLVED_KEYBINDINGS, "split"))).toEqual(
78 [expect.objectContaining({ id: "terminal" })],
79 );
80 });
6181 it("orders Usage bindings and command choices like the page", () => {
6282 const expected = [
6383 "usage.open",
apps/web/src/components/settings/KeybindingsSettings.logic.ts+47
......@@ -201,6 +201,53 @@ export function keybindingConflictLabels(
201201 return [...new Set(conflicts)].toSorted();
202202}
203203
204export interface KeybindingGroup {
205 readonly id: string;
206 readonly title: string;
207 readonly rows: ReadonlyArray<KeybindingRow>;
208}
209
210/** Page sections in display order; a command joins the first group listing its prefix. */
211const KEYBINDING_GROUPS = [
212 {
213 id: "navigation",
214 title: "Navigation",
215 prefixes: ["sidebar", "rightPanel", "commandPalette", "filePicker", "projectSearch", "editor"],
216 },
217 { id: "threads", title: "Threads", prefixes: ["thread", "chat", "pullRequest"] },
218 { id: "composer", title: "Composer", prefixes: ["composer", "modelPicker"] },
219 { id: "terminal", title: "Terminal", prefixes: ["terminal"] },
220 { id: "preview", title: "Preview & diff", prefixes: ["preview", "diff"] },
221 { id: "appearance", title: "Appearance", prefixes: ["theme", "appearance", "themeEditor"] },
222 { id: "scripts", title: "Project scripts", prefixes: ["script"] },
223] as const;
224const OTHER_KEYBINDING_GROUP = { id: "other", title: "Other" } as const;
225
226function keybindingGroupFor(command: KeybindingCommand): { id: string; title: string } {
227 const prefix = String(command).split(".")[0] ?? "";
228 return (
229 KEYBINDING_GROUPS.find((group) => (group.prefixes as ReadonlyArray<string>).includes(prefix)) ??
230 OTHER_KEYBINDING_GROUP
231 );
232}
233
234/** Splits sorted rows into the page's sections, dropping sections with no rows. */
235export function groupKeybindingRows(
236 rows: ReadonlyArray<KeybindingRow>,
237): ReadonlyArray<KeybindingGroup> {
238 const rowsByGroup = new Map<string, Array<KeybindingRow>>();
239 for (const row of rows) {
240 const group = keybindingGroupFor(row.command);
241 const bucket = rowsByGroup.get(group.id);
242 if (bucket) bucket.push(row);
243 else rowsByGroup.set(group.id, [row]);
244 }
245 return [...KEYBINDING_GROUPS, OTHER_KEYBINDING_GROUP].flatMap((group) => {
246 const groupRows = rowsByGroup.get(group.id);
247 return groupRows ? [{ id: group.id, title: group.title, rows: groupRows }] : [];
248 });
249}
250
204251export function buildKeybindingRows(
205252 keybindings: ResolvedKeybindingsConfig,
206253 query: string,
apps/web/src/components/settings/KeybindingsSettings.tsx+115-157
......@@ -59,13 +59,16 @@ import {
5959 keybindingConflictLabels,
6060 keybindingFromKeyboardEvent,
6161 parseWhenExpressionDraft,
62 groupKeybindingRows,
6263 type KeybindingCommandOption,
64 type KeybindingGroup,
6365 type KeybindingRow,
6466 type WhenVariableOption,
6567 unknownWhenVariables,
6668 whenAstToExpression,
6769 whenNodeRemoveLabel,
6870} from "./KeybindingsSettings.logic";
71import { SettingsGroup } from "./SettingsGroup";
6972import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout";
7073import { keybindingSearchAnchorId, searchableSetting } from "./settingsSearch";
7174import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
......@@ -104,65 +107,33 @@ function KeybindingPill({ value }: { value: string }) {
104107 );
105108}
106109
107function ExpandableHeaderSearch({
110/** Filter box in the page toolbar; Mod+F focuses it from anywhere on the page. */
111function KeybindingsSearchInput({
108112 query,
109113 onChange,
110 isOpen,
111 onOpenChange,
112114 inputRef,
113 collapsedAccessory,
114115}: {
115116 query: string;
116117 onChange: (next: string) => void;
117 isOpen: boolean;
118 onOpenChange: (next: boolean) => void;
119 inputRef?: RefObject<HTMLInputElement | null>;
120 collapsedAccessory?: ReactNode;
118 inputRef: RefObject<HTMLInputElement | null>;
121119}) {
122 if (!isOpen) {
123 return (
124 <>
125 {collapsedAccessory}
126 <Tooltip>
127 <TooltipTrigger
128 render={
129 <Button
130 type="button"
131 size="icon-xs"
132 variant="ghost-muted"
133 onClick={() => onOpenChange(true)}
134 aria-label="Search keybindings"
135 >
136 <SearchIcon />
137 </Button>
138 }
139 />
140 <TooltipPopup side="top">Search keybindings</TooltipPopup>
141 </Tooltip>
142 </>
143 );
144 }
145
146120 return (
147 <InputGroup className="w-44">
121 <InputGroup className="min-w-0 basis-full **:[input]:h-9 sm:basis-0 sm:flex-1 sm:**:[input]:h-8">
148122 <InputGroupAddon>
149 <SearchIcon aria-hidden className="size-3" />
123 <SearchIcon aria-hidden className="size-3.5" />
150124 </InputGroupAddon>
151125 <InputGroupInput
152126 ref={inputRef}
153 autoFocus
154127 type="search"
155128 value={query}
156129 onChange={(event) => onChange(event.currentTarget.value)}
157 onBlur={() => {
158 if (query.length === 0) onOpenChange(false);
159 }}
160130 onKeyDown={(event) => {
161 if (event.key === "Escape") {
162 event.preventDefault();
163 onChange("");
164 onOpenChange(false);
165 }
131 // The settings route treats an unhandled Escape as "go back";
132 // inside the search box it clears, then leaves the field.
133 if (event.key !== "Escape") return;
134 event.preventDefault();
135 if (query.length > 0) onChange("");
136 else event.currentTarget.blur();
166137 }}
167138 placeholder="Search keybindings"
168139 aria-label="Search keybindings"
......@@ -1232,7 +1203,7 @@ function NewKeybindingSettingsRow(props: NewKeybindingProps) {
12321203
12331204 return (
12341205 <SettingsRow
1235 className="rounded-none bg-muted/15"
1206 className="bg-muted/15"
12361207 title="New keybinding"
12371208 description={
12381209 <span className="flex h-6 items-center gap-1.5">
......@@ -1259,41 +1230,18 @@ function NewKeybindingSettingsRow(props: NewKeybindingProps) {
12591230 );
12601231}
12611232
1262interface KeybindingsListProps extends KeybindingRowActions {
1263 rows: ReadonlyArray<KeybindingRow>;
1264 commandOptions: ReadonlyArray<KeybindingCommandOption>;
1233interface KeybindingsGroupsProps extends KeybindingRowActions {
1234 groups: ReadonlyArray<KeybindingGroup>;
1235 anchorIds: ReadonlyMap<string, string>;
12651236 savingCommand: KeybindingCommand | null;
1266 isAddingBinding: boolean;
1267 onCancelAdd: () => void;
12681237}
12691238
1270/** The add-binding row, one settings row per binding, and the empty state. */
1271function KeybindingsList(props: KeybindingsListProps) {
1272 const { rows, commandOptions, savingCommand, isAddingBinding, onCancelAdd, ...rowActions } =
1273 props;
1274 const newProps: NewKeybindingProps = {
1275 commandOptions,
1276 allRows: rows,
1277 variables: rowActions.variables,
1278 isSaving: savingCommand !== null,
1279 onSave: rowActions.onSave,
1280 onCancel: onCancelAdd,
1281 };
1282 // Settings search jumps to a command, so only its first row anchors.
1283 const anchorIds = useMemo(() => {
1284 const ids = new Map<string, string>();
1285 const seen = new Set<KeybindingCommand>();
1286 for (const row of rows) {
1287 if (seen.has(row.command)) continue;
1288 seen.add(row.command);
1289 ids.set(row.id, keybindingSearchAnchorId(row.command));
1290 }
1291 return ids;
1292 }, [rows]);
1293 return (
1294 <div>
1295 {isAddingBinding ? <NewKeybindingSettingsRow {...newProps} /> : null}
1296 {rows.map((row) => (
1239/** One titled section per command area, each holding its binding rows. */
1240function KeybindingsGroups(props: KeybindingsGroupsProps) {
1241 const { groups, anchorIds, savingCommand, ...rowActions } = props;
1242 return groups.map((group) => (
1243 <SettingsSection key={group.id} id={`keybindings-${group.id}`} title={group.title}>
1244 {group.rows.map((row) => (
12971245 <KeybindingSettingsRow
12981246 key={row.id}
12991247 row={row}
......@@ -1302,25 +1250,29 @@ function KeybindingsList(props: KeybindingsListProps) {
13021250 {...rowActions}
13031251 />
13041252 ))}
1305 {rows.length === 0 && !isAddingBinding ? (
1306 <div className="px-4 py-12 text-center text-sm text-muted-foreground">
1307 No keybindings match your search.
1308 </div>
1309 ) : null}
1310 </div>
1311 );
1253 </SettingsSection>
1254 ));
13121255}
13131256
13141257/** Shown in the browser build only; the desktop app receives every shortcut. */
13151258function BrowserKeybindingNotice() {
1259 // The label carries the whole sentence so assistive tech reads it without
1260 // opening the tooltip.
1261 const message = "The browser may claim some shortcuts first. The desktop app receives them all.";
13161262 return (
1317 <div className="flex items-center gap-2 px-3 py-2.5 text-xs leading-normal text-muted-foreground sm:px-4">
1318 <TriangleAlertIcon className="size-3.5 shrink-0 text-warning" aria-hidden />
1319 <span>
1320 Some shortcuts may be claimed by the browser before T3 Code sees them. Use the desktop app
1321 for better keybinding support.
1322 </span>
1323 </div>
1263 <Tooltip>
1264 <TooltipTrigger
1265 delay={200}
1266 render={
1267 <Button size="icon-micro" variant="ghost-muted" aria-label={message}>
1268 <TriangleAlertIcon className="size-3.5 text-warning" />
1269 </Button>
1270 }
1271 />
1272 <TooltipPopup side="top" className="max-w-72">
1273 {message}
1274 </TooltipPopup>
1275 </Tooltip>
13241276 );
13251277}
13261278
......@@ -1347,11 +1299,22 @@ export function KeybindingsSettingsPanel() {
13471299 availableEditors,
13481300 );
13491301 const [query, setQuery] = useState("");
1350 const [isSearchOpen, setIsSearchOpen] = useState(false);
13511302 const searchInputRef = useRef<HTMLInputElement>(null);
13521303 const [savingCommand, setSavingCommand] = useState<KeybindingCommand | null>(null);
13531304 const [isAddingBinding, setIsAddingBinding] = useState(false);
13541305 const rows = useMemo(() => buildKeybindingRows(keybindings, query), [keybindings, query]);
1306 const groups = useMemo(() => groupKeybindingRows(rows), [rows]);
1307 // Settings search jumps to a command, so only its first row anchors.
1308 const anchorIds = useMemo(() => {
1309 const ids = new Map<string, string>();
1310 const seen = new Set<KeybindingCommand>();
1311 for (const row of rows) {
1312 if (seen.has(row.command)) continue;
1313 seen.add(row.command);
1314 ids.set(row.id, keybindingSearchAnchorId(row.command));
1315 }
1316 return ids;
1317 }, [rows]);
13551318 // The search-target context is provided by this panel's own page container,
13561319 // so the jump target is read from the route hash here.
13571320 const searchTargetId = useLocation({ select: (location) => location.hash.replace(/^#/, "") });
......@@ -1380,11 +1343,8 @@ export function KeybindingsSettingsPanel() {
13801343 }
13811344
13821345 event.preventDefault();
1383 setIsSearchOpen(true);
1384 requestAnimationFrame(() => {
1385 searchInputRef.current?.focus();
1386 searchInputRef.current?.select();
1387 });
1346 searchInputRef.current?.focus();
1347 searchInputRef.current?.select();
13881348 };
13891349 window.addEventListener("keydown", handleKeyDown);
13901350 return () => window.removeEventListener("keydown", handleKeyDown);
......@@ -1489,21 +1449,9 @@ export function KeybindingsSettingsPanel() {
14891449
14901450 const cancelAdd = useCallback(() => setIsAddingBinding(false), []);
14911451
1492 const bindingsCount = (
1493 <span className="text-2xs text-muted-foreground">
1494 {rows.length + (isAddingBinding ? 1 : 0)}{" "}
1495 {rows.length + (isAddingBinding ? 1 : 0) === 1 ? "binding" : "bindings"}
1496 </span>
1497 );
1498
1499 const listProps: KeybindingsListProps = {
1500 rows,
1452 const rowActions: KeybindingRowActions = {
15011453 allRows: rows,
1502 commandOptions,
15031454 variables: whenVariables,
1504 savingCommand,
1505 isAddingBinding,
1506 onCancelAdd: cancelAdd,
15071455 onSave: saveKeybinding,
15081456 onReset: resetKeybinding,
15091457 onRemove: removeKeybinding,
......@@ -1513,56 +1461,66 @@ export function KeybindingsSettingsPanel() {
15131461 <SettingsPageContainer>
15141462 <SettingsSection
15151463 {...searchableSetting("keybindings")}
1516 headerAction={
1517 <div className="flex items-center gap-1.5">
1518 <ExpandableHeaderSearch
1519 query={query}
1520 onChange={setQuery}
1521 isOpen={isSearchOpen}
1522 onOpenChange={setIsSearchOpen}
1523 inputRef={searchInputRef}
1524 collapsedAccessory={bindingsCount}
1525 />
1526 <Tooltip>
1527 <TooltipTrigger
1528 render={
1529 <Button
1530 type="button"
1531 size="icon-xs"
1532 variant="ghost-muted"
1533 onClick={() => setIsAddingBinding(true)}
1534 aria-label="Add keybinding"
1535 >
1536 <PlusIcon />
1537 </Button>
1538 }
1539 />
1540 <TooltipPopup side="top">Add keybinding</TooltipPopup>
1541 </Tooltip>
1542 <Tooltip>
1543 <TooltipTrigger
1544 render={
1545 <Button
1546 type="button"
1547 size="icon-xs"
1548 variant="ghost-muted"
1549 disabled={!keybindingsConfigPath}
1550 onClick={openKeybindingsFile}
1551 aria-label="Open keybindings.json"
1552 >
1553 <FileJsonIcon />
1554 </Button>
1555 }
1556 />
1557 <TooltipPopup side="top">Open keybindings.json</TooltipPopup>
1558 </Tooltip>
1559 </div>
1560 }
1464 headerAction={!isElectron ? <BrowserKeybindingNotice /> : null}
15611465 >
1562 {!isElectron ? <BrowserKeybindingNotice /> : null}
1563
1564 <KeybindingsList {...listProps} />
1466 <div className="flex flex-wrap items-center gap-2 px-3 py-3 sm:px-4">
1467 <KeybindingsSearchInput query={query} onChange={setQuery} inputRef={searchInputRef} />
1468 <Button
1469 type="button"
1470 variant="outline"
1471 disabled={isAddingBinding}
1472 onClick={() => setIsAddingBinding(true)}
1473 >
1474 <PlusIcon aria-hidden className="size-4" />
1475 Add keybinding
1476 </Button>
1477 <Tooltip>
1478 <TooltipTrigger
1479 render={
1480 <Button
1481 type="button"
1482 variant="outline"
1483 size="icon"
1484 disabled={!keybindingsConfigPath}
1485 onClick={openKeybindingsFile}
1486 aria-label="Open keybindings.json"
1487 >
1488 <FileJsonIcon aria-hidden className="size-4" />
1489 </Button>
1490 }
1491 />
1492 <TooltipPopup side="top">Open keybindings.json</TooltipPopup>
1493 </Tooltip>
1494 </div>
15651495 </SettingsSection>
1496
1497 {isAddingBinding ? (
1498 <SettingsGroup>
1499 <NewKeybindingSettingsRow
1500 commandOptions={commandOptions}
1501 allRows={rows}
1502 variables={whenVariables}
1503 isSaving={savingCommand !== null}
1504 onSave={saveKeybinding}
1505 onCancel={cancelAdd}
1506 />
1507 </SettingsGroup>
1508 ) : null}
1509
1510 {groups.length > 0 ? (
1511 <KeybindingsGroups
1512 groups={groups}
1513 anchorIds={anchorIds}
1514 savingCommand={savingCommand}
1515 {...rowActions}
1516 />
1517 ) : (
1518 <SettingsGroup>
1519 <div className="px-4 py-12 text-center text-sm text-muted-foreground">
1520 No keybindings match your search.
1521 </div>
1522 </SettingsGroup>
1523 )}
15661524 </SettingsPageContainer>
15671525 );
15681526}