fix(app): preserve number groups when rebinding navigation shortcuts (#7065)

Recording a digit for tab or workspace navigation replaced the existing
1–9 wildcard with one literal key. Preserve the group when capturing a
replacement and display its range after saving and reloading.

Adapted the number-group fix from getpaseo/paseo#5237, with independent
failing-first renderer reproduction and tab/workspace matcher coverage.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: dbunk903 <74584941+dbunk903@users.noreply.github.com>
This commit is contained in:
paseo-bot[bot]
2026-10-16 04:14:28 +02:00
committed by GitHub
co-authored by dbunk903
parent e0de0fdf6f
commit 5c2a1552df
5 changed files with 104 additions and 3 deletions
@@ -4,6 +4,7 @@ import { keyboardEventToComboString } from "./shortcut-string";
import {
buildKeyboardShortcutHelpSections,
buildEffectiveBindings,
normalizeCapturedShortcutCombo,
getBindingIdForAction,
getDefaultKeysForAction,
getWorkspaceIndexJumpModifierKey,
@@ -823,9 +824,9 @@ describe("keyboard-shortcut help sections", () => {
expect(rowChord({}, "show-shortcuts")).toEqual([["?"]]);
});
it("replaces the default-only wildcard when the index jump is rebound", () => {
it("keeps the digit range readable when the index jump is rebound", () => {
expect(rowChord({ [MAC_INDEX_BINDING]: "Ctrl+Digit" }, "workspace-jump-index")).toEqual([
["ctrl", "Digit"],
["ctrl", "1-9"],
]);
});
@@ -1347,3 +1348,45 @@ describe("direct new-tab target shortcuts", () => {
).toEqual([["ctrl", "shift", "H"]]);
});
});
describe("number-group shortcut capture", () => {
const tabBindingId = "workspace-tab-navigate-index-cmd-alt-digit-mac-desktop";
it.each([
{ id: tabBindingId, action: "workspace.tab.navigate.index", row: "workspace-tab-jump-index" },
{
id: "workspace-navigate-index-cmd-digit-mac",
action: "workspace.navigate.index",
row: "workspace-jump-index",
},
])("captures Ctrl+1 as the entire number group for $row", ({ id, action, row }) => {
const combo = normalizeCapturedShortcutCombo(id, "Ctrl+1");
expect(combo).toBe("Ctrl+Digit");
const overrides = { [id]: combo };
const bindings = buildEffectiveBindings(overrides);
for (let index = 1; index <= 9; index++) {
const result = resolveShortcut({
event: { key: String(index), code: `Digit${index}`, ctrlKey: true },
context: { isMac: true, isDesktop: true, focusScope: "message-input" },
bindings,
});
expect(result.match?.action).toBe(action);
expect(result.match?.payload).toEqual({ index });
}
expect(resolveShortcutKeysForAction(row, overrides, { isMac: true, isDesktop: true })).toEqual([
["ctrl", "1-9"],
]);
expect(
resolveShortcut({
event: { key: "0", code: "Digit0", ctrlKey: true },
context: { isMac: true, isDesktop: true },
bindings,
}).match,
).toBeNull();
});
it("preserves literal numbers for non-index actions and existing nonnumeric combos", () => {
expect(normalizeCapturedShortcutCombo("show-shortcuts", "Ctrl+1")).toBe("Ctrl+1");
expect(normalizeCapturedShortcutCombo(tabBindingId, "Ctrl+K")).toBe("Ctrl+K");
expect(normalizeCapturedShortcutCombo(tabBindingId, "Ctrl+0")).toBe("Ctrl+0");
expect(normalizeCapturedShortcutCombo(tabBindingId, "Ctrl+9")).toBe("Ctrl+Digit");
});
});
@@ -1210,6 +1210,13 @@ export const DEFAULT_BINDINGS: readonly ParsedShortcutBinding[] =
export type ShortcutOverrides = Record<string, string | null>;
/** Keep number-group actions bound to all nine indices when recording a new modifier. */
export function normalizeCapturedShortcutCombo(bindingId: string, combo: string): string {
const binding = DEFAULT_BINDINGS.find((candidate) => candidate.id === bindingId);
if (binding?.payload?.type !== "index") return combo;
return combo.replace(/(^|\+)[1-9]$/, "$1Digit");
}
export function buildEffectiveBindings(overrides: ShortcutOverrides): ParsedShortcutBinding[] {
return DEFAULT_BINDINGS.map(function (binding) {
const override = overrides[binding.id];
@@ -192,6 +192,7 @@ const MODIFIER_CODES = new Set([
* override on a named key renders as a raw code — `ARROWLEFT`, not `←`.
*/
const DISPLAY_KEY_ALIASES: Record<string, string> = {
Digit: "1-9",
ArrowLeft: "Left",
ArrowRight: "Right",
ArrowUp: "Up",
@@ -19,6 +19,7 @@ import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-over
import {
buildKeyboardShortcutHelpSections,
getBindingIdForAction,
normalizeCapturedShortcutCombo,
getDefaultKeysForAction,
resolveShortcutKeysForAction,
type KeyboardShortcutHelpRow,
@@ -389,7 +390,8 @@ export function KeyboardShortcutsSection() {
}
setHeldModifiers(null);
setCapturedCombos((current) => [...current, comboString]);
const capturedCombo = normalizeCapturedShortcutCombo(capturingBindingId!, comboString);
setCapturedCombos((current) => [...current, capturedCombo]);
}
window.addEventListener("keydown", handleKeyDown, true);
@@ -1,3 +1,5 @@
import { clickNewChat, gotoWorkspace } from "../../app/e2e/support/helpers/launcher";
import { clickSettingsBackToWorkspace } from "../../app/e2e/support/helpers/settings";
import type { Page } from "@playwright/test";
import { test, expect } from "../../app/e2e/support/fixtures";
import { gotoAppShell, openSettings } from "../../app/e2e/support/helpers/app";
@@ -213,3 +215,49 @@ for (const { platform, keys, label, firstCombo, secondCombo } of [
await expectInterruptShortcut(page, label);
});
}
async function rebindTabNumbers(page: Page) {
await captureShortcut(page, "Jump to tab", "workspace-tab-jump-index", "Control+1");
await page.getByRole("button", { name: "Done", exact: true }).click();
}
async function expectTabNumberRange(page: Page) {
const row = page.getByText("Jump to tab", { exact: true }).locator("..");
await expect(row.getByText("⌃1-9", { exact: true })).toBeVisible();
}
async function openTwoDraftTabs(page: Page, workspaceId: string) {
await installDesktopBridge(page);
await gotoWorkspace(page, workspaceId);
await clickNewChat(page);
await clickNewChat(page);
await expect(
page.locator('[data-testid^="workspace-tab-draft_"]').filter({ visible: true }),
).toHaveCount(2);
await openSettings(page);
await openSettingsSection(page, "shortcuts");
}
async function expectTabNumberNavigation(page: Page) {
const tabs = page.locator('[data-testid^="workspace-tab-draft_"]').filter({ visible: true });
await page.keyboard.press("Control+1");
await expect(tabs.nth(0)).toHaveAttribute("aria-selected", "true");
await page.keyboard.press("Control+2");
await expect(tabs.nth(1)).toHaveAttribute("aria-selected", "true");
}
test("rebinding Jump to tab preserves the number group after reload", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace({ prefix: "tab-number-rebind-" });
await openTwoDraftTabs(page, workspace.workspaceId);
await rebindTabNumbers(page);
await clickSettingsBackToWorkspace(page);
await expectTabNumberNavigation(page);
await page.reload();
await expectTabNumberNavigation(page);
await openSettings(page);
await openSettingsSection(page, "shortcuts");
await expectTabNumberRange(page);
});