main ← feature/filter-keyboard-shor-vs5
10 files
+801
−50
PR #4908 ↗
The keyboard shortcut settings card now has an inline search that filters core and integration actions by their translated labels. The filter changes only row visibility, so drafts, conflict warnings, and save behavior stay the same.
Why this PR exists
The keyboard shortcut settings card lists every core and integration action in one long column. Users must scan the full list to find one action, and the list grows when integrations add actions.
Who is affected: People who customize keyboard shortcuts in Settings on desktop or phone.
Result: Users type part of an action name and see only matching rows. Clearing the query restores the full list and keeps any unsaved shortcut edits.
What it does
Add a localized search field and a clear action above the shortcut rows. Match a trimmed, case-insensitive substring of translated core labels and integration labels. Keep conflict detection and the override map over the full action set. Keep the recorder from capturing keystrokes typed into the filter. Cover the behavior with component tests and desktop and phone browser tests.
Impact at a glance Breaking changes: No changes foundUser experience: Changed (1)Kandev plugin interfaces: No changes found No public plugin API changes. Plugin-declared shortcuts stay outside the filter and still contribute to conflict warnings. MCP tools: No changes found No MCP tool changes. The PR touches only the web settings card and its tests. Database changes: No changes found No database or persistence change. The query is local React state. User experience Entry point Before After Source Settings > Preferences > Keyboard Shortcuts The card shows every core and integration shortcut row. Users scan the whole list to find an action. An inline search filters core and integration rows by translated action label. A clear action restores the list, and one message appears when no action matches. Pending shortcut edits stay in the draft. Phone controls use at least 44px targets. apps/web/components/settings/keyboard-shortcuts-card.tsx
Feature flags and rollout Coverage: Feature behavior ships without flagsFlag-off UX: No UX changes with flags offThe filter ships for every installation with no feature flag. It changes only the shortcut settings card and adds no backend, storage, API, or save behavior.
No UX changes with flags off No flag gates this change, so there is no disabled path. The search field is always present in the shortcut settings card.
Architecture, end to end
The card keeps the search query in local state. The query selects visible rows only. Conflict detection still receives every entry, and recorder changes still flow to the parent route draft.
flowchart TD
Route[Settings > Preferences > Keyboard Shortcuts] --> Card[KeyboardShortcutsCard]
Card --> Filter[ShortcutFilter local query]
Filter --> Visible[Visible core IDs and integration entries]
Card --> ConflictHook[useShortcutConflictLabels]
ConflictHook --> AllEntries[Full core, integration, plugin entries]
ConflictHook --> Labels[Conflict labels per action]
Visible --> Rows[ShortcutRecorder rows]
Labels --> Rows
Rows --> Draft[Parent route draft and save]
Key code changes
Drag to pan. Use the + and − buttons to zoom. Click a node to open the full code. The arrows show how the parts interact.
−
+
Fit
Maximize Close
drag to pan · +/− to zoom · click a node for details
Shortcut settings card
Browser evidence
KeyboardShortcutsCard({ overrides, baselineOverrides, onChange, pluginEntries, integrationEntries })
Click for details →
The card stores the query locally and derives visible core and integration rows from the current translated labels.
Query and visible collections
const { t } = useTranslation();
const { isMobile, isFinePointer } = useResponsiveBreakpoint();
const [query, setQuery] = useState("");
const integrations = integrationEntries ?? buildIntegrationShortcutEntries([], [], t);
const normalizedQuery = query.trim().toLocaleLowerCase();
const matches = (label: string) => label.toLocaleLowerCase().includes(normalizedQuery);
const visibleIds = CONFIGURABLE_SHORTCUT_IDS.filter((id) =>
matches(t(CONFIGURABLE_SHORTCUTS[id].labelKey)),
);
const visibleIntegrations = integrations.filter((entry) => matches(entry.label));
const shortcuts = resolveAllShortcuts(overrides);
const baselineShortcuts = resolveAllShortcuts(baselineOverrides);Render filtered rows
</CardHeader>
<CardContent>
<ShortcutFilter query={query} onQueryChange={setQuery} />
{normalizedQuery && visibleIds.length === 0 && visibleIntegrations.length === 0 && (
<p className="text-sm text-muted-foreground">{t("settings:noMatchingShortcuts")}</p>
)}
<div className="divide-y divide-border">
{CONFIGURABLE_SHORTCUT_IDS.map((id) => (
{visibleIds.map((id) => (
<ShortcutRecorder
key={id}
shortcutId={id}
label={t(CONFIGURABLE_SHORTCUTS[id].labelKey)}
<IntegrationShortcutRows
entries={integrations}
{(!normalizedQuery || visibleIntegrations.length > 0) && (
<IntegrationShortcutRows
entries={visibleIntegrations}ShortcutFilter({ query, onQueryChange })
Click for details →
The shared input holds the query, and the clear button empties it and returns focus to the search field.
ShortcutFilter
function ShortcutFilter({
query,
onQueryChange,
}: {
query: string;
onQueryChange: (value: string) => void;
}) {
const { t } = useTranslation();
const inputRef = useRef<HTMLInputElement>(null);
return (
<div className="mb-3 flex min-w-0 items-center gap-2">
<Input
ref={inputRef}
data-shortcut-filter="true"
value={query}
onChange={(event) => onQueryChange(event.target.value)}
placeholder={t("common:searchEllipsis")}
aria-label={t("settings:searchShortcuts")}
/>
{query && (
<Button
type="button"
variant="ghost"
size="icon"
className={controlSizingClassName("icon", "shrink-0 cursor-pointer")}
onClick={() => {
onQueryChange("");
inputRef.current?.focus();
}}
aria-label={t("tasks:clearSearch")}
title={t("tasks:clearSearch")}
>
<IconX className="size-4" />
</Button>
)}
</div>
);
}handleKeyDown(e: KeyboardEvent)
Click for details →
An active recorder stops capture when the key event comes from the filter input, so typing a query cannot change a shortcut.
Key guard
(e: KeyboardEvent) => {
if (!recording) return;
if (e.target instanceof HTMLElement && e.target.dataset.shortcutFilter === "true") {
setRecording(false);
return;
}
if (["Control", "Meta", "Alt", "Shift"].includes(e.key)) return;exerciseShortcutFilter(page: Page)
Click for details →
The Playwright helper drives the real settings route through filter, clear, hidden draft, and conflict states.
Shared flow
import { expect, type Page } from "@playwright/test";
export async function exerciseShortcutFilter(page: Page) {
await page.goto("/settings/preferences/keyboard-shortcuts");
const search = page.getByRole("textbox", { name: "Search shortcuts" });
const core = page.getByTestId("shortcut-recorder-SEARCH");
const github = page.getByTestId("shortcut-recorder-integration:github");
await expect(core).toBeVisible();
await expect(github).toBeVisible();
await core.click();
await expect(core).toHaveAttribute("data-shortcut-recording", "true");
await search.click();
await page.keyboard.type("github");
await expect(search).toHaveValue("github");
await expect(github).toBeVisible();
const clear = page.getByRole("button", { name: "Clear search" });
await clear.focus();
await page.keyboard.press("Enter");
await expect(search).toBeFocused();
await expect(search).toHaveValue("");
await expect(core).toHaveAttribute("data-settings-dirty", "false");
await expect(core).toHaveAttribute("data-shortcut-recording", "false");
await search.fill(" CoMmAnD PaNeL ");
await expect(core).toBeVisible();
await expect(github).toHaveCount(0);
await search.fill(" GITHUB ");
await expect(github).toBeVisible();
await expect(core).toHaveCount(0);
await github.click();
await page.keyboard.press("Control+k");
await expect(github).toHaveAttribute("data-settings-dirty", "true");
await search.fill("command panel");
await expect(core).toBeVisible();
await expect(page.getByTitle("Same shortcut as: Open GitHub")).toBeVisible();
await search.fill("no such shortcut");
await expect(page.getByText("No matching shortcuts", { exact: true })).toHaveCount(1);
await page.getByRole("button", { name: "Clear search" }).click();
await expect(core).toBeVisible();
await expect(github).toHaveAttribute("data-settings-dirty", "true");
await expect(github).toContainText("K");
await search.fill(" GITHUB ");
return { search, clear: page.getByRole("button", { name: "Clear search" }), github };
}
Read the changes as a list
Filter state and visible row lists apps/web/components/settings/keyboard-shortcuts-card.tsx ↗ The card stores the query locally and derives visible core and integration rows from the current translated labels.
Query and visible collections
const { t } = useTranslation();
const { isMobile, isFinePointer } = useResponsiveBreakpoint();
const [query, setQuery] = useState("");
const integrations = integrationEntries ?? buildIntegrationShortcutEntries([], [], t);
const normalizedQuery = query.trim().toLocaleLowerCase();
const matches = (label: string) => label.toLocaleLowerCase().includes(normalizedQuery);
const visibleIds = CONFIGURABLE_SHORTCUT_IDS.filter((id) =>
matches(t(CONFIGURABLE_SHORTCUTS[id].labelKey)),
);
const visibleIntegrations = integrations.filter((entry) => matches(entry.label));
const shortcuts = resolveAllShortcuts(overrides);
const baselineShortcuts = resolveAllShortcuts(baselineOverrides);Render filtered rows
</CardHeader>
<CardContent>
<ShortcutFilter query={query} onQueryChange={setQuery} />
{normalizedQuery && visibleIds.length === 0 && visibleIntegrations.length === 0 && (
<p className="text-sm text-muted-foreground">{t("settings:noMatchingShortcuts")}</p>
)}
<div className="divide-y divide-border">
{CONFIGURABLE_SHORTCUT_IDS.map((id) => (
{visibleIds.map((id) => (
<ShortcutRecorder
key={id}
shortcutId={id}
label={t(CONFIGURABLE_SHORTCUTS[id].labelKey)}
<IntegrationShortcutRows
entries={integrations}
{(!normalizedQuery || visibleIntegrations.length > 0) && (
<IntegrationShortcutRows
entries={visibleIntegrations}Search input and clear action apps/web/components/settings/keyboard-shortcuts-card.tsx ↗ The shared input holds the query, and the clear button empties it and returns focus to the search field.
ShortcutFilter
function ShortcutFilter({
query,
onQueryChange,
}: {
query: string;
onQueryChange: (value: string) => void;
}) {
const { t } = useTranslation();
const inputRef = useRef<HTMLInputElement>(null);
return (
<div className="mb-3 flex min-w-0 items-center gap-2">
<Input
ref={inputRef}
data-shortcut-filter="true"
value={query}
onChange={(event) => onQueryChange(event.target.value)}
placeholder={t("common:searchEllipsis")}
aria-label={t("settings:searchShortcuts")}
/>
{query && (
<Button
type="button"
variant="ghost"
size="icon"
className={controlSizingClassName("icon", "shrink-0 cursor-pointer")}
onClick={() => {
onQueryChange("");
inputRef.current?.focus();
}}
aria-label={t("tasks:clearSearch")}
title={t("tasks:clearSearch")}
>
<IconX className="size-4" />
</Button>
)}
</div>
);
}Recorder yields keys to the filter apps/web/components/settings/keyboard-shortcuts-card.tsx ↗ An active recorder stops capture when the key event comes from the filter input, so typing a query cannot change a shortcut.
Key guard
(e: KeyboardEvent) => {
if (!recording) return;
if (e.target instanceof HTMLElement && e.target.dataset.shortcutFilter === "true") {
setRecording(false);
return;
}
if (["Control", "Meta", "Alt", "Shift"].includes(e.key)) return;Route-level filter evidence apps/web/e2e/tests/settings/shortcut-filter-helpers.ts ↗ The Playwright helper drives the real settings route through filter, clear, hidden draft, and conflict states.
Shared flow
import { expect, type Page } from "@playwright/test";
export async function exerciseShortcutFilter(page: Page) {
await page.goto("/settings/preferences/keyboard-shortcuts");
const search = page.getByRole("textbox", { name: "Search shortcuts" });
const core = page.getByTestId("shortcut-recorder-SEARCH");
const github = page.getByTestId("shortcut-recorder-integration:github");
await expect(core).toBeVisible();
await expect(github).toBeVisible();
await core.click();
await expect(core).toHaveAttribute("data-shortcut-recording", "true");
await search.click();
await page.keyboard.type("github");
await expect(search).toHaveValue("github");
await expect(github).toBeVisible();
const clear = page.getByRole("button", { name: "Clear search" });
await clear.focus();
await page.keyboard.press("Enter");
await expect(search).toBeFocused();
await expect(search).toHaveValue("");
await expect(core).toHaveAttribute("data-settings-dirty", "false");
await expect(core).toHaveAttribute("data-shortcut-recording", "false");
await search.fill(" CoMmAnD PaNeL ");
await expect(core).toBeVisible();
await expect(github).toHaveCount(0);
await search.fill(" GITHUB ");
await expect(github).toBeVisible();
await expect(core).toHaveCount(0);
await github.click();
await page.keyboard.press("Control+k");
await expect(github).toHaveAttribute("data-settings-dirty", "true");
await search.fill("command panel");
await expect(core).toBeVisible();
await expect(page.getByTitle("Same shortcut as: Open GitHub")).toBeVisible();
await search.fill("no such shortcut");
await expect(page.getByText("No matching shortcuts", { exact: true })).toHaveCount(1);
await page.getByRole("button", { name: "Clear search" }).click();
await expect(core).toBeVisible();
await expect(github).toHaveAttribute("data-settings-dirty", "true");
await expect(github).toContainText("K");
await search.fill(" GITHUB ");
return { search, clear: page.getByRole("button", { name: "Clear search" }), github };
}
Risk
3
/ 10
Low
Why this score
The filter changes only row visibility in one settings card. It adds no backend, storage, API, or save contract. A filter bug could hide a conflicting action or a pending edit. Component tests and browser tests cover both cases. The change depends on the existing draft and conflict hooks. A future refactor of those hooks must keep the full entry set.
Trade-offs and review notes
The query is local to the card and is not saved. The filter resets when the user leaves the route. Filtering uses action labels only. It does not search key combinations. Hidden rows unmount, so an active recording on a row that disappears stops. The recorded override remains in the parent draft.
Where to look first
Read the query state and both visible collections in keyboard-shortcuts-card.tsx. Confirm useShortcutConflictLabels still receives the full core, integration, and plugin entries. Check the recorder keydown guard for the filter input. Review the component and browser tests that prove hidden drafts and conflicts.