PR #4908
Sections
Review

feat: filter keyboard shortcut settings ↗

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

Impact at a glance

User experience

Entry pointBeforeAfterSource
Settings > Preferences > Keyboard ShortcutsThe 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

The 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.

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
1 low5 medium10 high

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

Where to look first

  1. Read the query state and both visible collections in keyboard-shortcuts-card.tsx.
  2. Confirm useShortcutConflictLabels still receives the full core, integration, and plugin entries.
  3. Check the recorder keydown guard for the filter input.
  4. Review the component and browser tests that prove hidden drafts and conflicts.