mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 09:01:33 +00:00
fix(ui): support shortcuts on non-Latin keyboards
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}"]`);
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
49
ui/src/lib/keyboard-shortcuts.test.ts
Normal file
49
ui/src/lib/keyboard-shortcuts.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
28
ui/src/lib/keyboard-shortcuts.ts
Normal file
28
ui/src/lib/keyboard-shortcuts.ts
Normal 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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user