From 3d6298be138941535767b42c16c328753c07c569 Mon Sep 17 00:00:00 2001 From: kevin9327 <5299031+kevin9327@users.noreply.github.com> Date: Wed, 16 Sep 2026 19:52:26 +0900 Subject: [PATCH] Start a new chat with Shift+N on a layout that does not write Latin letters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings lists New chat as Shift+N, and `matchesHotkey` compared `KeyboardEvent.key`. `key` follows the layout, which is right on Dvorak, where the key that writes N is not the key QWERTY calls N. A layout that writes another script has no key that writes an N at all. Shift and the N key write "Т" on Russian and "Ν" (Greek capital nu) on Greek, so the shortcut never fired for anybody with one of those layouts selected. The combo's key is now read from `key` as before, unless `key` is a single character outside ASCII. Then it comes from the physical key in `code` (`KeyN` reads as "n"). Latin layouts behave exactly as they did, the modifiers are still compared exactly, and a non-Latin character on a key with no letter of its own matches nothing. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 7 +++ app/src/lib/hotkeys/hotkeys.ts | 18 +++++++- app/tests/hotkey-layouts.test.ts | 78 ++++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 app/tests/hotkey-layouts.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 831690d21..eee28f179 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ## Unreleased +### The New chat shortcut works on a Russian or Greek keyboard layout + +Settings lists New chat as Shift+N, and the app matched the character the keystroke wrote. A layout +that writes another script has no key that writes an N: Shift and the N key write "Т" on Russian and +"Ν" on Greek, so the shortcut never fired there. When the character is not ASCII, the app now reads +the physical key instead. A layout that writes Latin letters, such as Dvorak, still goes by the letter. + ### A vendor that broke no longer reads as a refusal to a Bot running its own loop When a Bot that calls tools back from its own process, such as the LangGraph Bots, called a tool diff --git a/app/src/lib/hotkeys/hotkeys.ts b/app/src/lib/hotkeys/hotkeys.ts index 7aa303618..7c2c3b7a3 100644 --- a/app/src/lib/hotkeys/hotkeys.ts +++ b/app/src/lib/hotkeys/hotkeys.ts @@ -52,6 +52,22 @@ const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform); +/** + * The key a keystroke names, spelled the way a combo spells it. + * + * `key` first, because it follows the layout: on Dvorak the letter N is not where QWERTY puts it, and + * the key a person presses when told "N" is the one that writes an N. A layout that writes another + * script has no such key. Shift and the N key write "Т" on Russian and "Ν" (Greek capital nu, not a + * Latin N) on Greek, so a shortcut read from `key` alone never fired there. When `key` is a single + * character outside ASCII, the physical key in `code` is the only N there is. + */ +function keyOf(event: KeyboardEvent): string { + const written = event.key.toLowerCase(); + if (written.length !== 1 || written.charCodeAt(0) < 0x80) return written; + const physical = /^(?:Key|Digit)([A-Z0-9])$/.exec(event.code); + return physical?.[1]?.toLowerCase() ?? written; +} + /** * Whether this keystroke is this combo — exactly, not at-least. * @@ -66,7 +82,7 @@ export function matchesHotkey( const mod = isMac ? event.metaKey : event.ctrlKey; const otherMod = isMac ? event.ctrlKey : event.metaKey; return ( - event.key.toLowerCase() === combo.key && + keyOf(event) === combo.key && event.shiftKey === Boolean(combo.shift) && mod === Boolean(combo.mod) && !otherMod && diff --git a/app/tests/hotkey-layouts.test.ts b/app/tests/hotkey-layouts.test.ts new file mode 100644 index 000000000..8815d3289 --- /dev/null +++ b/app/tests/hotkey-layouts.test.ts @@ -0,0 +1,78 @@ +import { expect, test } from "bun:test"; +import { getHotkey, matchesHotkey } from "@/lib/hotkeys/hotkeys"; + +/** + * The New chat shortcut on a keyboard layout that does not write Latin letters. + * + * Settings shows it as Shift+N, and `matchesHotkey` compared `KeyboardEvent.key`. That is right for + * a layout that moves the letter, such as Dvorak: the key a person presses when told "N" is the one + * that writes an N. A layout that writes another script has no key that writes an N at + * all. Shift and the N key write "Т" on Russian and "Ν" (Greek capital nu, not a Latin N) on Greek, + * so the shortcut Settings lists never fired for anybody with one of those layouts selected. + */ + +const combo = getHotkey("new-chat").combo; + +/** A keydown as a browser reports it: `key` from the layout, `code` from the physical key. */ +function keydown( + key: string, + code: string, + modifiers: Partial< + Pick + > = {}, +): KeyboardEvent { + return { + key, + code, + shiftKey: false, + ctrlKey: false, + metaKey: false, + altKey: false, + ...modifiers, + } as KeyboardEvent; +} + +test("Shift and the N key start a new chat on a layout that writes another script", () => { + const pressed = [ + ["Russian", keydown("Т", "KeyN", { shiftKey: true })], + ["Greek", keydown("Ν", "KeyN", { shiftKey: true })], + ] as const; + + expect( + pressed.map(([layout, event]) => [layout, matchesHotkey(event, combo)]), + ).toEqual([ + ["Russian", true], + ["Greek", true], + ]); +}); + +test("a layout that writes Latin letters still goes by the letter, wherever its key is", () => { + // Dvorak writes N on the key QWERTY calls L, and B on the key QWERTY calls N. + expect(matchesHotkey(keydown("N", "KeyL", { shiftKey: true }), combo)).toBe( + true, + ); + expect(matchesHotkey(keydown("B", "KeyN", { shiftKey: true }), combo)).toBe( + false, + ); +}); + +test("the modifiers are still exact on the physical key", () => { + expect(matchesHotkey(keydown("т", "KeyN"), combo)).toBe(false); + expect( + matchesHotkey( + keydown("Т", "KeyN", { shiftKey: true, ctrlKey: true, metaKey: true }), + combo, + ), + ).toBe(false); + // A character from another script on some other key is not N, and neither is one on a key that + // has no letter of its own, nor the Shift key itself. + expect(matchesHotkey(keydown("Ь", "KeyM", { shiftKey: true }), combo)).toBe( + false, + ); + expect( + matchesHotkey(keydown("Ё", "Backquote", { shiftKey: true }), combo), + ).toBe(false); + expect( + matchesHotkey(keydown("Shift", "ShiftLeft", { shiftKey: true }), combo), + ).toBe(false); +});