diff --git a/CHANGELOG.md b/CHANGELOG.md index 67176ca27a6a..4a2e96eb3fec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/ui/src/app/app-host.test.ts b/ui/src/app/app-host.test.ts index 4823a37fd4fb..ac17043d8d4f 100644 --- a/ui/src/app/app-host.test.ts +++ b/ui/src/app/app-host.test.ts @@ -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, }); diff --git a/ui/src/app/app-host.ts b/ui/src/app/app-host.ts index 170884d236f2..52e45a7d2002 100644 --- a/ui/src/app/app-host.ts +++ b/ui/src/app/app-host.ts @@ -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(); diff --git a/ui/src/components/command-palette-contract.ts b/ui/src/components/command-palette-contract.ts index bb752585a6fe..152c4971233d 100644 --- a/ui/src/components/command-palette-contract.ts +++ b/ui/src/components/command-palette-contract.ts @@ -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 = { diff --git a/ui/src/components/exec-approval.test.ts b/ui/src/components/exec-approval.test.ts index c11f05caf4ab..3dc868b32c10 100644 --- a/ui/src/components/exec-approval.test.ts +++ b/ui/src/components/exec-approval.test.ts @@ -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"], diff --git a/ui/src/components/exec-approval.ts b/ui/src/components/exec-approval.ts index 3653d6c66685..db3236d10c3a 100644 --- a/ui/src/components/exec-approval.ts +++ b/ui/src/components/exec-approval.ts @@ -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 { diff --git a/ui/src/components/menu-shortcuts.ts b/ui/src/components/menu-shortcuts.ts index 3674c7ecb4c4..18ec8d0789e9 100644 --- a/ui/src/components/menu-shortcuts.ts +++ b/ui/src/components/menu-shortcuts.ts @@ -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(`[data-shortcut="${key}"]`); diff --git a/ui/src/components/native-link-menu.test.ts b/ui/src/components/native-link-menu.test.ts index b9b195ee9702..a7b5ea8a1545 100644 --- a/ui/src/components/native-link-menu.test.ts +++ b/ui/src/components/native-link-menu.test.ts @@ -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"]); }); diff --git a/ui/src/components/session-menu.test.ts b/ui/src/components/session-menu.test.ts index a3efc61bc781..b8abc80727b7 100644 --- a/ui/src/components/session-menu.test.ts +++ b/ui/src/components/session-menu.test.ts @@ -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); diff --git a/ui/src/lib/keyboard-shortcuts.test.ts b/ui/src/lib/keyboard-shortcuts.test.ts new file mode 100644 index 000000000000..aa543095c311 --- /dev/null +++ b/ui/src/lib/keyboard-shortcuts.test.ts @@ -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(); + }); +}); diff --git a/ui/src/lib/keyboard-shortcuts.ts b/ui/src/lib/keyboard-shortcuts.ts new file mode 100644 index 000000000000..b5309aa18a62 --- /dev/null +++ b/ui/src/lib/keyboard-shortcuts.ts @@ -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; +} diff --git a/ui/src/pages/chat/chat-pane-lifecycle.ts b/ui/src/pages/chat/chat-pane-lifecycle.ts index 00b30abada55..1eb7d1760407 100644 --- a/ui/src/pages/chat/chat-pane-lifecycle.ts +++ b/ui/src/pages/chat/chat-pane-lifecycle.ts @@ -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) { diff --git a/ui/src/pages/chat/chat-pane.test.ts b/ui/src/pages/chat/chat-pane.test.ts index 2b73615742ac..1aa17f798b2a 100644 --- a/ui/src/pages/chat/chat-pane.test.ts +++ b/ui/src/pages/chat/chat-pane.test.ts @@ -55,7 +55,8 @@ function createDeferred() { function dispatchSidebarShortcut(pane: TestChatPane, shiftKey = true) { const event = new KeyboardEvent("keydown", { cancelable: true, - key: "b", + key: "и", + code: "KeyB", metaKey: true, shiftKey, }); diff --git a/ui/src/pages/chat/chat-view.test.ts b/ui/src/pages/chat/chat-view.test.ts index 809e48ec499b..dd78ceb3593a 100644 --- a/ui/src/pages/chat/chat-view.test.ts +++ b/ui/src/pages/chat/chat-view.test.ts @@ -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"); diff --git a/ui/src/pages/chat/chat-view.ts b/ui/src/pages/chat/chat-view.ts index 53da516d7839..64aed502a8ea 100644 --- a/ui/src/pages/chat/chat-view.ts +++ b/ui/src/pages/chat/chat-view.ts @@ -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); }