fix(ui): support shortcuts on non-Latin keyboards

This commit is contained in:
Vincent Koc
2026-07-29 22:43:46 +08:00
parent e94693aa52
commit fd28cd3bec
15 changed files with 143 additions and 17 deletions

View File

@@ -52,6 +52,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- **macOS and Control UI keyboard navigation:** let Tab traverse links and controls inside embedded Dashboard, browser, and Canvas web views, and keep shortcuts working on non-Latin keyboard layouts without firing during IME composition.
- **Control UI session diffs:** hide unchanged checkout modifications and untracked files that already existed when a thread started, so the diff panel attributes only files touched by that session. Fixes #115628.
- **Code Mode small-model repair:** give malformed pre-dispatch `exec` calls one bounded correction turn, expose typed failure-phase and bridge-dispatch evidence, and stop retries after nested tools begin. Fixes #115311.
- **Shared state corruption recovery:** evict only the exact cached SQLite owner after proven read or write corruption so a repaired database recovers without a Gateway restart while caller-injected handles remain untouched. Fixes #114269. Thanks @rizquuula.

View File

@@ -844,7 +844,6 @@ describe("OpenClaw shell keyboard shortcuts", () => {
const shell = document.createElement("openclaw-app-shell") as unknown as ShellLazySurfaceState;
shell.commandPaletteElement = element;
Object.defineProperty(shell, "updateComplete", {
configurable: true,
get: () => Promise.resolve(true),
});
Object.defineProperty(shell, "commandPalette", {
@@ -855,7 +854,8 @@ describe("OpenClaw shell keyboard shortcuts", () => {
: undefined,
});
const event = new KeyboardEvent("keydown", {
key: "k",
key: "л",
code: "KeyK",
ctrlKey: true,
cancelable: true,
});

View File

@@ -60,6 +60,7 @@ import type { BoardFace } from "../lib/board/settings.ts";
import { copyToClipboard } from "../lib/clipboard.ts";
import { isGatewayMethodAdvertised } from "../lib/gateway-methods.ts";
import { createIdleImport } from "../lib/idle-import.ts";
import { resolveAsciiShortcutKey } from "../lib/keyboard-shortcuts.ts";
import { isWorkboardEnabledInConfigSnapshot } from "../lib/plugin-activation.ts";
import { resolveSessionDisplayName } from "../lib/session-display.ts";
import {
@@ -1275,7 +1276,7 @@ class OpenClawShell extends OpenClawLightDomElement {
return;
}
const commandKey = event.metaKey && !event.ctrlKey && !event.altKey;
if (!commandKey || event.shiftKey || event.key.toLowerCase() !== "b") {
if (!commandKey || event.shiftKey || resolveAsciiShortcutKey(event) !== "b") {
return;
}
event.preventDefault();

View File

@@ -1,3 +1,5 @@
import { resolveAsciiShortcutKey } from "../lib/keyboard-shortcuts.ts";
export const COMMAND_PALETTE_TARGET_EVENT = "openclaw-command-palette-target";
export const COMMAND_PALETTE_OPEN_EVENT = "openclaw:command-palette-open";
export const SHELL_NAV_DRAWER_TOGGLE_EVENT = "openclaw:shell-nav-drawer-toggle";
@@ -7,7 +9,12 @@ export type ShellNavDrawerToggleDetail = {
};
export function isCommandPaletteShortcut(event: KeyboardEvent): boolean {
return (event.metaKey || event.ctrlKey) && !event.shiftKey && event.key.toLowerCase() === "k";
return (
(event.metaKey || event.ctrlKey) &&
!event.altKey &&
!event.shiftKey &&
resolveAsciiShortcutKey(event) === "k"
);
}
export type CommandPaletteTargetDetail = {

View File

@@ -162,7 +162,7 @@ describe("openclaw-exec-approval", () => {
modal.dispatchEvent(chord("Enter"));
modal.dispatchEvent(chord("Enter", { shiftKey: true }));
modal.dispatchEvent(chord("d", { metaKey: false, ctrlKey: true }));
modal.dispatchEvent(chord("в", { code: "KeyD", metaKey: false, ctrlKey: true }));
expect(onDecision.mock.calls).toEqual([
["approval-1", "allow-once"],

View File

@@ -4,6 +4,7 @@ import { property, query, state } from "lit/decorators.js";
import { modalApprovalQueue } from "../app/approval-presentation.ts";
import type { ExecApprovalDecision, ExecApprovalRequest } from "../app/exec-approval.ts";
import { t } from "../i18n/index.ts";
import { resolveAsciiShortcutKey } from "../lib/keyboard-shortcuts.ts";
import { OpenClawLightDomContentsElement } from "../lit/openclaw-element.ts";
import {
approvalRemainingLabel,
@@ -85,7 +86,7 @@ function shortcutDecision(event: KeyboardEvent): ExecApprovalDecision | null {
if (event.key === "Enter") {
return event.shiftKey ? "allow-always" : "allow-once";
}
return !event.shiftKey && event.key.toLowerCase() === "d" ? "deny" : null;
return !event.shiftKey && resolveAsciiShortcutKey(event) === "d" ? "deny" : null;
}
class ExecApproval extends OpenClawLightDomContentsElement {

View File

@@ -1,4 +1,5 @@
import { html } from "lit";
import { resolveAsciiShortcutKey } from "../lib/keyboard-shortcuts.ts";
// Single-letter context-menu shortcuts. Items opt in via data-shortcut plus a
// rendered hint; menu hosts route non-Escape keydowns here so a bare letter
@@ -13,11 +14,8 @@ export function activateMenuShortcut(root: ParentNode, event: KeyboardEvent): bo
if (event.metaKey || event.ctrlKey || event.altKey) {
return false;
}
const key = event.key.toLowerCase();
// Letters and digits only (digits number submenu entries): keeps the
// querySelector below safe and leaves navigation keys (arrows, Tab, Enter)
// to native menu focus handling.
if (!/^[a-z0-9]$/.test(key)) {
const key = resolveAsciiShortcutKey(event);
if (!key) {
return false;
}
const item = root.querySelector<HTMLElement & { disabled?: boolean }>(`[data-shortcut="${key}"]`);

View File

@@ -104,7 +104,12 @@ describe("native link menu", () => {
expect(hints).toEqual(["S", "B", "C"]);
document.dispatchEvent(
new KeyboardEvent("keydown", { key: "c", bubbles: true, cancelable: true }),
new KeyboardEvent("keydown", {
key: "с",
code: "KeyC",
bubbles: true,
cancelable: true,
}),
);
expect(calls).toEqual(["close", "copy"]);
});

View File

@@ -333,7 +333,12 @@ describe("session menu", () => {
menuItem(submenu, "Projects").querySelector(".session-menu__shortcut")?.textContent,
).toBe("2");
const keydown = new KeyboardEvent("keydown", { key: "2", bubbles: true, cancelable: true });
const keydown = new KeyboardEvent("keydown", {
key: "٢",
code: "Digit2",
bubbles: true,
cancelable: true,
});
document.dispatchEvent(keydown);
expect(onAction).toHaveBeenCalledWith({ kind: "move-to-group", category: "Projects" });
expect(keydown.defaultPrevented).toBe(true);
@@ -406,7 +411,12 @@ describe("session menu", () => {
expect(pin.getAttribute("aria-keyshortcuts")).toBe("P");
expect(menuItem(menu, "Move to group").dataset.shortcut).toBeUndefined();
const keydown = new KeyboardEvent("keydown", { key: "p", bubbles: true, cancelable: true });
const keydown = new KeyboardEvent("keydown", {
key: "з",
code: "KeyP",
bubbles: true,
cancelable: true,
});
document.dispatchEvent(keydown);
expect(calls).toEqual(["close", "toggle-pin"]);
expect(keydown.defaultPrevented).toBe(true);

View File

@@ -0,0 +1,49 @@
/* @vitest-environment jsdom */
import { describe, expect, it } from "vitest";
import { resolveAsciiShortcutKey } from "./keyboard-shortcuts.ts";
describe("resolveAsciiShortcutKey", () => {
it("prefers the produced ASCII character for alternate Latin layouts", () => {
const event = new KeyboardEvent("keydown", { key: "k", code: "KeyV" });
expect(resolveAsciiShortcutKey(event)).toBe("k");
});
it("falls back to physical letters and digits for non-Latin layouts", () => {
const letter = new KeyboardEvent("keydown", { key: "л", code: "KeyK" });
const digit = new KeyboardEvent("keydown", { key: "٢", code: "Digit2" });
expect(resolveAsciiShortcutKey(letter)).toBe("k");
expect(resolveAsciiShortcutKey(digit)).toBe("2");
});
it("ignores composition, modified symbols, and non-printable keys", () => {
const composing = new KeyboardEvent("keydown", {
key: "Process",
code: "KeyK",
isComposing: true,
});
const shiftedDigit = new KeyboardEvent("keydown", {
key: "@",
code: "Digit2",
shiftKey: true,
});
const optionSymbol = new KeyboardEvent("keydown", {
key: "∂",
code: "KeyD",
altKey: true,
});
const deadKey = new KeyboardEvent("keydown", { key: "Dead", code: "KeyE" });
const navigationKey = new KeyboardEvent("keydown", {
key: "ArrowLeft",
code: "KeyK",
});
expect(resolveAsciiShortcutKey(composing)).toBeNull();
expect(resolveAsciiShortcutKey(shiftedDigit)).toBeNull();
expect(resolveAsciiShortcutKey(optionSymbol)).toBeNull();
expect(resolveAsciiShortcutKey(deadKey)).toBeNull();
expect(resolveAsciiShortcutKey(navigationKey)).toBeNull();
});
});

View File

@@ -0,0 +1,28 @@
const ASCII_SHORTCUT_KEY = /^[a-z0-9]$/;
const PHYSICAL_LETTER_KEY = /^Key([A-Z])$/;
const PHYSICAL_DIGIT_KEY = /^Digit([0-9])$/;
export function resolveAsciiShortcutKey(event: KeyboardEvent): string | null {
if (event.isComposing || event.keyCode === 229) {
return null;
}
const key = event.key.toLowerCase();
if (ASCII_SHORTCUT_KEY.test(key)) {
return key;
}
if (event.altKey || event.key === "Dead" || Array.from(event.key).length !== 1) {
return null;
}
// Preserve character-based shortcuts for alternate Latin layouts, then
// fall back to the physical key so non-Latin layouts can reach the command.
const letter = PHYSICAL_LETTER_KEY.exec(event.code)?.[1];
if (letter) {
return letter.toLowerCase();
}
if (!event.shiftKey) {
return PHYSICAL_DIGIT_KEY.exec(event.code)?.[1] ?? null;
}
return null;
}

View File

@@ -17,6 +17,7 @@ import {
type BrowserAnnotationDraft,
} from "../../components/browser/browser-annotation.ts";
import { t } from "../../i18n/index.ts";
import { resolveAsciiShortcutKey } from "../../lib/keyboard-shortcuts.ts";
import { resolveChatPaneObserverRunId } from "../../lib/observer-digest.ts";
import { sessionPullRequestsForGateway } from "../../lib/session-pull-requests.ts";
import { parseCatalogSessionKey } from "../../lib/sessions/catalog-key.ts";
@@ -319,7 +320,7 @@ export abstract class ChatPaneLifecycle extends ChatPaneBoard {
event.shiftKey &&
event.metaKey &&
!event.ctrlKey &&
event.key.toLowerCase() === "b"
resolveAsciiShortcutKey(event) === "b"
) {
const state = this.state;
if (!state) {

View File

@@ -55,7 +55,8 @@ function createDeferred<T>() {
function dispatchSidebarShortcut(pane: TestChatPane, shiftKey = true) {
const event = new KeyboardEvent("keydown", {
cancelable: true,
key: "b",
key: "и",
code: "KeyB",
metaKey: true,
shiftKey,
});

View File

@@ -1847,6 +1847,24 @@ afterEach(() => {
});
describe("per-pane chat presentation state", () => {
it("opens thread search from the physical shortcut on a non-Latin layout", () => {
const onRequestUpdate = vi.fn();
const container = renderChatView({ onRequestUpdate });
const chat = requireElement(container, "section.card.chat", "chat view");
const event = new KeyboardEvent("keydown", {
key: "а",
code: "KeyF",
ctrlKey: true,
bubbles: true,
cancelable: true,
});
chat.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
expect(onRequestUpdate).toHaveBeenCalledOnce();
});
it("keeps slash menus independent and resets only the targeted pane", () => {
const paneA = document.createElement("div");
const paneB = document.createElement("div");

View File

@@ -30,6 +30,7 @@ import type {
} from "../../lib/chat/chat-types.ts";
import type { ControlUiFollowUpMode } from "../../lib/chat/follow-up-mode.ts";
import type { EmbedSandboxMode } from "../../lib/chat/tool-display.ts";
import { resolveAsciiShortcutKey } from "../../lib/keyboard-shortcuts.ts";
import type { ProviderUsageDisplayProps } from "../../lib/provider-quota-summary.ts";
import type { SessionToolOverrides } from "../../lib/sessions/patch.ts";
import type { UiSessionDefaultsHost } from "../../lib/sessions/session-key.ts";
@@ -519,7 +520,12 @@ export function renderChat(props: ChatProps) {
props.onClearReply?.();
return;
}
if ((event.metaKey || event.ctrlKey) && !event.shiftKey && event.key === "f") {
if (
(event.metaKey || event.ctrlKey) &&
!event.altKey &&
!event.shiftKey &&
resolveAsciiShortcutKey(event) === "f"
) {
event.preventDefault();
toggleChatThreadSearch(props.paneId, requestUpdate);
}