diff --git a/apps/desktop/src/main/__tests__/shortcut-label-platform.test.ts b/apps/desktop/src/main/__tests__/shortcut-label-platform.test.ts new file mode 100644 index 0000000000..e509ae70cc --- /dev/null +++ b/apps/desktop/src/main/__tests__/shortcut-label-platform.test.ts @@ -0,0 +1,170 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * What the shell's two shortcut surfaces SAY, on all three platforms. + * + * The palette hints and the shortcuts sheet used to be written into both locale + * catalogs as macOS glyphs, so a Windows user was told to press ⌥⌘S for a side + * chat that answers to Ctrl+Alt+S (#3876). These read the real catalogs through + * the real formatter — the same call the panel and the palette make — so a glyph + * reintroduced into either catalog fails here rather than shipping. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { formatShortcut } from '@maka/ui'; +import type { UiLocale } from '@maka/core/ui-locale'; +import { buildCommandList } from '../../renderer/command-palette-commands.js'; +import { getShellCopy } from '../../renderer/locales/shell-copy.js'; + +const PLATFORMS = ['darwin', 'win32', 'linux'] as const; +const LOCALES: readonly UiLocale[] = ['en', 'zh']; +const APPLE_GLYPHS = /[\u2318\u2325\u21E7\u2303]/u; + +function hints(locale: UiLocale, platform: string): Map { + const commands = buildCommandList({ + locale, + platform, + activeSessionId: 'session-1', + themePref: 'auto', + connections: [], + defaultSlug: null, + onNewChat: () => {}, + onOpenSideChat: () => {}, + onOpenSettings: () => {}, + onOpenSettingsSection: () => {}, + onOpenShortcuts: () => {}, + onSetTheme: () => {}, + // The diagnostics rows only register when the shell wires their action, and + // two of them are what this file is about. + onCopyDiagnostics: () => {}, + onOpenWorkspace: () => {}, + }); + return new Map(commands.map((command) => [command.id, command.hint])); +} + +test('palette hints lead with the shortcut this platform answers to', () => { + const mac = hints('en', 'darwin'); + assert.equal(mac.get('action:open-settings'), '⌘,'); + assert.equal(mac.get('action:side-chat'), '⌥⌘S'); + + for (const platform of ['win32', 'linux']) { + const pc = hints('en', platform); + assert.equal(pc.get('action:open-settings'), 'Ctrl+,'); + assert.equal(pc.get('action:side-chat'), 'Ctrl+Alt+S'); + } +}); + +test('a hint that also carries prose keeps the prose, in its own locale', () => { + // `⇧⌘D · Redacted logs · clipboard only` was one hard-coded string. The keys + // are now derived and the sentence after them is still the catalog's. + assert.equal( + hints('en', 'darwin').get('diag:copy-diagnostics'), + '⇧⌘D · Redacted logs · clipboard only', + ); + assert.equal( + hints('en', 'win32').get('diag:copy-diagnostics'), + 'Ctrl+Shift+D · Redacted logs · clipboard only', + ); + assert.equal( + hints('zh', 'win32').get('diag:copy-diagnostics'), + 'Ctrl+Shift+D · 脱敏日志 · 仅写入剪贴板', + ); +}); + +test('a command without a shortcut keeps the hint the catalog wrote', () => { + // Positive control: the derivation must not reach commands it was never + // about, or every hint in the palette would grow a shortcut it does not have. + assert.equal(hints('en', 'win32').get('action:new-chat'), 'Start a new task'); + assert.equal(hints('en', 'win32').get('action:keyboard-help'), '?'); + assert.equal(hints('zh', 'darwin').get('diag:open-workspace'), 'Finder'); +}); + +test('no palette hint spells a modifier with an Apple glyph off macOS', () => { + for (const locale of LOCALES) { + for (const platform of ['win32', 'linux']) { + for (const [id, hint] of hints(locale, platform)) { + assert.ok( + !hint || !APPLE_GLYPHS.test(hint), + `${locale}/${platform} hint for ${id} still reads ${hint}`, + ); + } + } + } +}); + +test('the shortcuts sheet reads Ctrl off macOS and ⌘ on it', () => { + for (const locale of LOCALES) { + const rows = getShellCopy(locale).keyboardHelp.sections.flatMap((section) => section.rows); + const general = new Map( + rows.map((row) => [row.keys.join('+'), row.keys] as const), + ); + const newTask = general.get('mod+n'); + const palette = general.get('mod+k'); + const diagnostics = general.get('mod+shift+d'); + const lineBreak = general.get('alt+enter'); + assert.ok(newTask && palette && diagnostics && lineBreak, `${locale} sheet lost a row`); + + assert.equal(formatShortcut(newTask, 'darwin'), '⌘N'); + assert.equal(formatShortcut(palette, 'darwin'), '⌘K'); + assert.equal(formatShortcut(diagnostics, 'darwin'), '⇧⌘D'); + assert.equal(formatShortcut(lineBreak, 'darwin'), '⌥↵'); + + for (const platform of ['win32', 'linux']) { + assert.equal(formatShortcut(newTask, platform), 'Ctrl+N'); + assert.equal(formatShortcut(palette, platform), 'Ctrl+K'); + assert.equal(formatShortcut(diagnostics, platform), 'Ctrl+Shift+D'); + assert.equal(formatShortcut(lineBreak, platform), 'Alt+↵'); + } + } +}); + +test('the sheet is authored in tokens, so no row can be macOS-only', () => { + // The bug was a glyph in the copy, not in the renderer. A row that reaches + // the panel already spelled has no platform left to answer to. + for (const locale of LOCALES) { + for (const section of getShellCopy(locale).keyboardHelp.sections) { + for (const row of section.rows) { + for (const key of row.keys) { + assert.ok( + !APPLE_GLYPHS.test(key), + `${locale} sheet row "${row.description}" hard-codes ${key}`, + ); + } + } + } + } +}); + +test('every sheet row renders something on every platform', () => { + for (const locale of LOCALES) { + for (const platform of PLATFORMS) { + for (const section of getShellCopy(locale).keyboardHelp.sections) { + for (const row of section.rows) { + const rendered = formatShortcut(row.keys, platform); + assert.ok( + rendered.trim().length > 0, + `${locale}/${platform} row "${row.description}" rendered nothing`, + ); + } + } + } + } +}); diff --git a/apps/desktop/src/renderer/app-shell-command-actions.ts b/apps/desktop/src/renderer/app-shell-command-actions.ts index bb42b79a18..cc615b5d75 100644 --- a/apps/desktop/src/renderer/app-shell-command-actions.ts +++ b/apps/desktop/src/renderer/app-shell-command-actions.ts @@ -64,6 +64,8 @@ type RefBox = { current: T }; export interface AppShellCommandListOptions { uiLocale: UiLocale; + /** Host OS, so shortcut hints read `Ctrl+,` off macOS and `⌘,` on it. */ + hostPlatform: string | undefined; activeId: string | undefined; activePermissionMode: PermissionMode | undefined; canSetPermissionMode: boolean; @@ -131,6 +133,7 @@ export function buildAppShellCommandList( return buildCommandList({ locale: options.uiLocale, + platform: options.hostPlatform, activeSessionId: options.activeId, themePref: options.themePref, connections: options.connections, diff --git a/apps/desktop/src/renderer/app-shell-effects.ts b/apps/desktop/src/renderer/app-shell-effects.ts index 20c83217a8..c9bf420a29 100644 --- a/apps/desktop/src/renderer/app-shell-effects.ts +++ b/apps/desktop/src/renderer/app-shell-effects.ts @@ -17,7 +17,7 @@ * under the License. */ -import { useEffect, useEffectEvent, useLayoutEffect } from 'react'; +import { useEffect, useEffectEvent, useLayoutEffect, useState } from 'react'; import { useHotkeys } from '@astryxdesign/core/hooks'; import type { ConnectionEvent } from '@maka/core/connections'; import type { SessionChangedEvent, SessionSummary, StoredMessage } from '@maka/core/session'; @@ -87,19 +87,33 @@ export function useAppShellNavRefSync(options: { navSelection: NavSelection; nav }, [options.navSelection]); } -export function useAppShellHostEffects() { - // Tag the document with the host OS so glass-material CSS rules - // (sidebar vibrancy passthrough) - // can light up only on macOS, where `BrowserWindow({ vibrancy: 'sidebar' })` - // paints the native blur material behind the renderer. Other platforms - // keep their opaque chrome since vibrancy is a no-op there. +/** + * The host OS, once the main process names it. + * + * Two readers, one round trip. It is tagged onto the document so + * glass-material CSS rules (sidebar vibrancy passthrough) can light up only on + * macOS, where `BrowserWindow({ vibrancy: 'sidebar' })` paints the native blur + * material behind the renderer — other platforms keep their opaque chrome + * since vibrancy is a no-op there. It is returned so `HostPlatformProvider` + * can hand it to every surface that shows a keyboard shortcut, which has to + * spell it `Ctrl` here and `⌘` there (#3876). + * + * Undefined until the answer arrives, and undefined forever if it never does. + * No label waits on it: the shortcut formatter reads `navigator` in the + * meantime, so the only thing an unanswered `app.info()` costs is the vibrancy + * rules, exactly as before. + */ +export function useResolvedHostPlatform(): string | undefined { + const [platform, setPlatform] = useState(undefined); + useEffect(() => { let cancelled = false; void window.maka.app .info() .then((info) => { - if (cancelled) return; - document.documentElement.setAttribute('data-os', info.platform); + if (cancelled) return; + document.documentElement.setAttribute('data-os', info.platform); + setPlatform(info.platform); }) .catch(() => { /* swallow — leaves data-os unset, CSS falls back to opaque chrome */ @@ -109,6 +123,10 @@ export function useAppShellHostEffects() { }; }, []); + return platform; +} + +export function useAppShellHostEffects() { // Modal-open titlebar dimming/hiding is driven by observing the top layer // (`dialog:modal`) rather than the shell's own modal state, so dialogs // mounted deep in module pages — the scheduled-task form above all — are diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index d398cbff8e..90a32b458f 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -53,6 +53,7 @@ import { type MakaUriDest, MakaUriContext, AstryxLocaleProvider, + HostPlatformProvider, LocaleProvider, ToastProvider, type ToastDiagnosticTarget, @@ -67,6 +68,7 @@ import { enqueueInteraction, getConversationCopy, reconcileInteractions, + useHostPlatform, } from '@maka/ui'; import type { ConnectionEvent } from '@maka/core/connections'; import { GitBranch, MessageCircleQuestion, Minimize2, Network } from '@maka/ui/icons'; @@ -193,6 +195,7 @@ import { useAppShellHostEffects, useAppShellPersistenceEffects, useAppShellNavRefSync, + useResolvedHostPlatform, useSessionEventHealthPolling, useShellRunUpdates, } from './app-shell-effects'; @@ -280,6 +283,7 @@ export function AppShell({ initialOnboardingSnapshot = null }: AppShellProps = { const [uiLocaleOverride, setUiLocaleOverride] = useState(null); const systemUiLocale = useSystemUiLocale(); const uiLocale = resolveUiLocale(uiLocalePreference, systemUiLocale, uiLocaleOverride); + const hostPlatform = useResolvedHostPlatform(); const errorToastAction = useMemo( () => ({ label: getShellCopy(uiLocale).errorBoundary.copyReport, @@ -303,17 +307,22 @@ export function AppShell({ initialOnboardingSnapshot = null }: AppShellProps = { `useUiLocale()` throws before anything renders. Still above every Astryx subtree. */} - - - - - + {/* Above the shell, not inside it: the rail's new-task hint and the + shortcuts sheet both spell `mod` per platform, and they sit in + different subtrees. */} + + + + + + + ); @@ -333,6 +342,7 @@ function AppShellContent({ setUiLocalePreference: Dispatch>; }) { const toastApi = useToast(); + const hostPlatform = useHostPlatform(); const [appUpdateStatus, setAppUpdateStatus] = useState(null); const updateInstallInFlightRef = useRef(false); const notifiedInstallErrorRef = useRef(null); @@ -2598,6 +2608,7 @@ function AppShellContent({ !activeMessageLoadError; const commandOptions: AppShellCommandListOptions = { uiLocale, + hostPlatform, activeId, activePermissionMode, canSetPermissionMode: activeBoundarySurface.localInteractionAvailable, diff --git a/apps/desktop/src/renderer/command-palette-commands.ts b/apps/desktop/src/renderer/command-palette-commands.ts index b9a020bec0..e95ca7b864 100644 --- a/apps/desktop/src/renderer/command-palette-commands.ts +++ b/apps/desktop/src/renderer/command-palette-commands.ts @@ -51,8 +51,8 @@ import { isRetiredProvider } from '@maka/core/provider-registry'; import type { PermissionMode } from '@maka/core/permission'; import type { SessionSummary } from '@maka/core/session'; import type { UiLocale } from '@maka/core/ui-locale'; -import type { NavSelection } from '@maka/ui'; -import { getShellCopy } from './locales/shell-copy.js'; +import { formatShortcut, type NavSelection } from '@maka/ui'; +import { getShellCopy, STATIC_COMMAND_SHORTCUTS } from './locales/shell-copy.js'; import { SETTINGS_NAV } from './settings/settings-nav.js'; import type { Command } from './command-palette-types.js'; @@ -64,6 +64,11 @@ import type { Command } from './command-palette-types.js'; */ export function buildCommandList(args: { locale: UiLocale; + /** + * Host OS, so a hint spells its shortcut the way this platform does. Omitted + * outside the desktop shell, where the formatter falls back to `navigator`. + */ + platform?: string; activeSessionId: string | undefined; themePref: ThemePreference; connections: LlmConnection[]; @@ -147,7 +152,16 @@ export function buildCommandList(args: { onStartScheduledTask?(): void; }): Command[] { const copy = getShellCopy(args.locale).commandPalette; - const staticCopy = (id: keyof typeof copy.commands) => copy.commands[id]; + // A shortcut leads the hint, and the localized detail follows it behind the + // separator this file already uses everywhere else ("诊断 · 不打开设置"). + // Commands without a shortcut keep their hint exactly as written. + const staticCopy = (id: keyof typeof copy.commands) => { + const entry = copy.commands[id]; + const keys = STATIC_COMMAND_SHORTCUTS[id]; + if (!keys) return entry; + const shortcut = formatShortcut(keys, args.platform); + return { ...entry, hint: entry.hint ? `${shortcut} · ${entry.hint}` : shortcut }; + }; const cmds: Command[] = [ { id: 'action:new-chat', diff --git a/apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx b/apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx index 2788295ec4..61098c33b7 100644 --- a/apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx +++ b/apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx @@ -70,7 +70,6 @@ import { Card } from '@astryxdesign/core/Card'; import { ContextMenu } from '@astryxdesign/core/ContextMenu'; import { Heading } from '@astryxdesign/core/Heading'; import { Icon } from '@astryxdesign/core/Icon'; -import { Kbd } from '@astryxdesign/core/Kbd'; import { List, ListItem } from '@astryxdesign/core/List'; import { Section } from '@astryxdesign/core/Section'; import { Spinner } from '@astryxdesign/core/Spinner'; @@ -88,6 +87,7 @@ import { terminalRefFromWorkbarTab, } from '../model/workbar-tabs'; import { useSessionTasks } from '../tools/tasks/use-session-tasks'; +import { ShortcutKeys } from '../../../shortcut-keys'; import { WorkbarToggle } from './workbar-toggle'; import { WorkBoardPanel } from '../../../work-board-panel.js'; import { getDesktopConversationCopy } from '../../../locales/conversation-copy.js'; @@ -660,7 +660,7 @@ function WorkbarLauncher(props: { description={action.description} endContent={ action.shortcut ? ( - + ) : undefined } isDisabled={action.disabled} diff --git a/apps/desktop/src/renderer/keyboard-help.tsx b/apps/desktop/src/renderer/keyboard-help.tsx index fab861cdaa..a2d3a271c4 100644 --- a/apps/desktop/src/renderer/keyboard-help.tsx +++ b/apps/desktop/src/renderer/keyboard-help.tsx @@ -28,7 +28,6 @@ import { useState } from 'react'; import { ICON_SIZE, Keyboard } from '@maka/ui/icons'; import { useUiLocale } from '@maka/ui'; import { Heading } from '@astryxdesign/core/Heading'; -import { Kbd } from '@astryxdesign/core/Kbd'; import { useHotkeys } from '@astryxdesign/core/hooks'; import { Dialog, @@ -36,20 +35,7 @@ import { } from '@astryxdesign/core/Dialog'; import { Layout, LayoutContent } from '@astryxdesign/core/Layout'; import { getShellCopy } from './locales/shell-copy'; - -const ASTRYX_KEY_TOKENS: Readonly> = { - '⌘': 'mod', - '↑': 'up', - '↓': 'down', - '←': 'left', - '→': 'right', - esc: 'escape', -}; - -function toAstryxKeyToken(key: string): string { - const normalized = key.toLowerCase(); - return ASTRYX_KEY_TOKENS[key] ?? ASTRYX_KEY_TOKENS[normalized] ?? normalized; -} +import { ShortcutKeys } from './shortcut-keys'; /** * Manages the global key listener that opens and closes the help modal. @@ -118,16 +104,7 @@ export function KeyboardHelpModal(props: {
{row.description}
- {row.keys.map((key, index) => ( - - {index > 0 && ( - - )} - - - ))} +
))} diff --git a/apps/desktop/src/renderer/locales/shell-copy.ts b/apps/desktop/src/renderer/locales/shell-copy.ts index a858a39464..2eb97d16fd 100644 --- a/apps/desktop/src/renderer/locales/shell-copy.ts +++ b/apps/desktop/src/renderer/locales/shell-copy.ts @@ -63,9 +63,29 @@ export type StaticCommandId = (typeof STATIC_COMMAND_IDS)[number]; type CommandCopy = { label: string; group: string; + /** + * Localized detail only. A command that answers to a shortcut leads its hint + * with that shortcut, but the keys are not localized and not spelled here — + * see STATIC_COMMAND_SHORTCUTS. + */ hint?: string; }; +/** + * Which palette commands carry a shortcut, in neutral tokens. + * + * Locale-independent, because `⌘,` and `Ctrl+,` read the same in Chinese and + * in English, and platform-independent, because `formatShortcut` spells them: + * these used to be written into both catalogs as macOS glyphs and shown to + * Windows and Linux unchanged (#3876). `mod` is the same token the bindings + * use, so the label cannot drift from the key that triggers it. + */ +export const STATIC_COMMAND_SHORTCUTS: Partial> = { + 'action:side-chat': ['mod', 'alt', 's'], + 'action:open-settings': ['mod', ','], + 'diag:copy-diagnostics': ['mod', 'shift', 'd'], +}; + const STATIC_COMMAND_KEYWORDS: Record = { 'action:new-chat': ['new', 'chat', 'start', '新', '建', '任务'], 'action:side-chat': [ @@ -419,6 +439,12 @@ type ShellCopy = { title: string; sections: Array<{ heading: string; + /** + * `keys` are neutral tokens (`mod`, `shift`, `alt`, `enter`, `up`, a bare + * character), never glyphs: `formatShortcut` in `@maka/ui` decides + * between ⇧⌘D and Ctrl+Shift+D at the point of display, so the same rows + * serve every platform (#3876). + */ rows: Array<{ keys: string[]; description: string }>; }>; }; @@ -532,7 +558,6 @@ const ZH_STATIC_COMMANDS: Record = { 'action:new-chat': { label: '新建任务', hint: '开始新的任务', group: '操作' }, 'action:side-chat': { label: '打开侧边对话', - hint: '⌥⌘S', group: '操作', }, 'action:new-deep-research': { @@ -545,7 +570,7 @@ const ZH_STATIC_COMMANDS: Record = { hint: '打开定时任务表单', group: '操作', }, - 'action:open-settings': { label: '打开设置', hint: '⌘,', group: '操作' }, + 'action:open-settings': { label: '打开设置', group: '操作' }, 'action:keyboard-help': { label: '查看键盘快捷键', hint: '?', group: '操作' }, 'theme:light': { label: '主题 · 浅色', group: '主题' }, 'theme:dark': { label: '主题 · 深色', group: '主题' }, @@ -597,7 +622,7 @@ const ZH_STATIC_COMMANDS: Record = { }, 'diag:copy-diagnostics': { label: '复制诊断信息', - hint: '⇧⌘D · 脱敏日志 · 仅写入剪贴板', + hint: '脱敏日志 · 仅写入剪贴板', group: '诊断', }, 'diag:test-network-proxy': { @@ -620,7 +645,6 @@ const EN_STATIC_COMMANDS: Record = { }, 'action:side-chat': { label: 'Open side chat', - hint: '⌥⌘S', group: 'Actions', }, 'action:new-deep-research': { @@ -635,7 +659,6 @@ const EN_STATIC_COMMANDS: Record = { }, 'action:open-settings': { label: 'Open Settings', - hint: '⌘,', group: 'Actions', }, 'action:keyboard-help': { @@ -693,7 +716,7 @@ const EN_STATIC_COMMANDS: Record = { }, 'diag:copy-diagnostics': { label: 'Copy diagnostics', - hint: '⇧⌘D · Redacted logs · clipboard only', + hint: 'Redacted logs · clipboard only', group: 'Diagnostics', }, 'diag:test-network-proxy': { @@ -1116,52 +1139,52 @@ const SHELL_COPY_BY_LOCALE = { heading: '通用', rows: [ { - keys: ['⌘', 'K'], + keys: ['mod', 'k'], description: '打开命令面板(跳任务 / 设置 / 主题等)', }, { keys: ['?'], description: '打开 / 关闭此快捷键面板' }, - { keys: ['⌘', 'N'], description: '新建任务' }, - { keys: ['⌘', ','], description: '打开设置' }, + { keys: ['mod', 'n'], description: '新建任务' }, + { keys: ['mod', ','], description: '打开设置' }, { - keys: ['⌘', 'Shift', 'D'], + keys: ['mod', 'shift', 'd'], description: '复制当前上下文的诊断信息', }, - { keys: ['Esc'], description: '关闭当前模态框' }, + { keys: ['escape'], description: '关闭当前模态框' }, ], }, { heading: 'Composer 输入', rows: [ - { keys: ['Enter'], description: '发送消息' }, - { keys: ['Shift', 'Enter'], description: '插入换行' }, - { keys: ['Alt', 'Enter'], description: '插入换行(备用)' }, + { keys: ['enter'], description: '发送消息' }, + { keys: ['shift', 'enter'], description: '插入换行' }, + { keys: ['alt', 'enter'], description: '插入换行(备用)' }, ], }, { heading: '任务列表', rows: [ - { keys: ['Tab'], description: '在任务与导航之间移动焦点' }, - { keys: ['↑', '↓'], description: '上下移动聚焦的任务' }, - { keys: ['Home', 'End'], description: '跳到列表顶部 / 底部' }, - { keys: ['Enter'], description: '打开聚焦的任务' }, - { keys: ['Delete'], description: '弹出删除确认(永远不静默删除)' }, - { keys: ['F'], description: '聚焦任务列表搜索框(按 Esc 清空)' }, + { keys: ['tab'], description: '在任务与导航之间移动焦点' }, + { keys: ['up', 'down'], description: '上下移动聚焦的任务' }, + { keys: ['home', 'end'], description: '跳到列表顶部 / 底部' }, + { keys: ['enter'], description: '打开聚焦的任务' }, + { keys: ['delete'], description: '弹出删除确认(永远不静默删除)' }, + { keys: ['f'], description: '聚焦任务列表搜索框(按 Esc 清空)' }, ], }, { heading: '聊天区', rows: [ - { keys: ['Tab'], description: '聚焦工具活动 / 复制按钮' }, - { keys: ['Space', 'Enter'], description: '展开 / 折叠工具调用' }, + { keys: ['tab'], description: '聚焦工具活动 / 复制按钮' }, + { keys: ['space', 'enter'], description: '展开 / 折叠工具调用' }, ], }, { heading: '面板调整', rows: [ - { keys: ['Tab'], description: '聚焦左右分割条' }, - { keys: ['←', '→'], description: '微调任务列表宽度(±10 px)' }, - { keys: ['Shift', '←', '→'], description: '快速调整(±50 px)' }, - { keys: ['Home', 'End'], description: '直接拉到最小 / 最大宽度' }, + { keys: ['tab'], description: '聚焦左右分割条' }, + { keys: ['left', 'right'], description: '微调任务列表宽度(±10 px)' }, + { keys: ['shift', 'left', 'right'], description: '快速调整(±50 px)' }, + { keys: ['home', 'end'], description: '直接拉到最小 / 最大宽度' }, ], }, ], @@ -1648,26 +1671,26 @@ const SHELL_COPY_BY_LOCALE = { heading: 'General', rows: [ { - keys: ['⌘', 'K'], + keys: ['mod', 'k'], description: 'Open the command palette (tasks, Settings, themes, and more)', }, { keys: ['?'], description: 'Open or close this shortcuts panel' }, - { keys: ['⌘', 'N'], description: 'Create a new task' }, - { keys: ['⌘', ','], description: 'Open Settings' }, + { keys: ['mod', 'n'], description: 'Create a new task' }, + { keys: ['mod', ','], description: 'Open Settings' }, { - keys: ['⌘', 'Shift', 'D'], + keys: ['mod', 'shift', 'd'], description: 'Copy diagnostics for the current context', }, - { keys: ['Esc'], description: 'Close the current dialog' }, + { keys: ['escape'], description: 'Close the current dialog' }, ], }, { heading: 'Composer', rows: [ - { keys: ['Enter'], description: 'Send the message' }, - { keys: ['Shift', 'Enter'], description: 'Insert a line break' }, + { keys: ['enter'], description: 'Send the message' }, + { keys: ['shift', 'enter'], description: 'Insert a line break' }, { - keys: ['Alt', 'Enter'], + keys: ['alt', 'enter'], description: 'Insert a line break (alternative)', }, ], @@ -1676,24 +1699,24 @@ const SHELL_COPY_BY_LOCALE = { heading: 'Task list', rows: [ { - keys: ['Tab'], + keys: ['tab'], description: 'Move focus between tasks and navigation', }, { - keys: ['↑', '↓'], + keys: ['up', 'down'], description: 'Move through focused tasks', }, { - keys: ['Home', 'End'], + keys: ['home', 'end'], description: 'Jump to the top or bottom of the list', }, - { keys: ['Enter'], description: 'Open the focused task' }, + { keys: ['enter'], description: 'Open the focused task' }, { - keys: ['Delete'], + keys: ['delete'], description: 'Open the delete confirmation (never delete silently)', }, { - keys: ['F'], + keys: ['f'], description: 'Focus task search (press Esc to clear)', }, ], @@ -1702,11 +1725,11 @@ const SHELL_COPY_BY_LOCALE = { heading: 'Chat', rows: [ { - keys: ['Tab'], + keys: ['tab'], description: 'Focus tool activity and Copy buttons', }, { - keys: ['Space', 'Enter'], + keys: ['space', 'enter'], description: 'Expand or collapse a tool call', }, ], @@ -1714,17 +1737,17 @@ const SHELL_COPY_BY_LOCALE = { { heading: 'Panel sizing', rows: [ - { keys: ['Tab'], description: 'Focus the left or right splitter' }, + { keys: ['tab'], description: 'Focus the left or right splitter' }, { - keys: ['←', '→'], + keys: ['left', 'right'], description: 'Adjust task-list width (±10 px)', }, { - keys: ['Shift', '←', '→'], + keys: ['shift', 'left', 'right'], description: 'Adjust quickly (±50 px)', }, { - keys: ['Home', 'End'], + keys: ['home', 'end'], description: 'Jump directly to minimum or maximum width', }, ], diff --git a/apps/desktop/src/renderer/settings/about-settings-page.tsx b/apps/desktop/src/renderer/settings/about-settings-page.tsx index 6b5c0c056e..f78247fb50 100644 --- a/apps/desktop/src/renderer/settings/about-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/about-settings-page.tsx @@ -19,7 +19,6 @@ import { useEffect, useId, useState, type ReactNode } from 'react'; import { Badge, Link, List, ListItem } from '@astryxdesign/core'; -import { Kbd } from '@astryxdesign/core/Kbd'; import { Sparkles } from '@maka/ui/icons'; import { Banner, @@ -30,6 +29,7 @@ import { useUiLocale, } from '@maka/ui'; import type { AppUpdateStatus } from '../../preload/bridge-contract.js'; +import { ShortcutKeys } from '../shortcut-keys.js'; import { SettingsActions, SettingsPage, SettingsSection } from './settings-section.js'; import { SettingRow } from './settings-rows.js'; import { settingsActionErrorMessage } from './settings-error-copy.js'; @@ -253,7 +253,7 @@ export function AboutSettingsPage(props: { onOpenKeyboardHelp?(): void }) { onClick={() => void copyDiagnostics()} label={copyingDiagnostics ? copy.copying : copy.copyDiagnostics} /> - + {copy.reportIssueLabel} diff --git a/apps/desktop/src/renderer/shortcut-keys.tsx b/apps/desktop/src/renderer/shortcut-keys.tsx new file mode 100644 index 0000000000..ed82a66404 --- /dev/null +++ b/apps/desktop/src/renderer/shortcut-keys.tsx @@ -0,0 +1,77 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// apps/desktop/src/renderer/shortcut-keys.tsx +// +// Keycap chips for a shortcut that carries a modifier. +// +// Astryx's own Kbd resolves `mod` per platform (⌘ on macOS, Ctrl elsewhere) but +// spells `ctrl`, `alt` and `shift` with Apple glyphs on every platform, so a +// Windows user reading the shortcuts sheet saw `Ctrl ⇧ D` — half translated +// (#3876). Kbd takes only its token vocabulary, so there is no argument that +// asks it for the word: the chips are ours, drawn from the same theme tokens +// Kbd draws from, and one platform answer — the one the main process gave us — +// decides every glyph. +// +// Kbd stays where a shortcut names no modifier: the palette footer's ↑ ↓ ↵ Esc +// are the same on every keyboard, so there is nothing there to decide. + +import { Fragment } from 'react'; +import { + formatShortcutKey, + orderShortcutKeys, + parseShortcutKeys, + shortcutLabel, + useHostPlatform, +} from '@maka/ui'; + +export function ShortcutKeys(props: { + /** Neutral tokens, or one Astryx-style `'mod+shift+d'` string. */ + keys: readonly string[] | string; + /** + * Whether a `+` sits between the chips. The shortcuts sheet spells the chord + * out because its rows are read as instructions; a lone chip beside a button + * is a label and takes the plainer form. + */ + separator?: 'plus' | 'gap'; +}) { + const platform = useHostPlatform(); + const tokens = typeof props.keys === 'string' ? parseShortcutKeys(props.keys) : props.keys; + const ordered = orderShortcutKeys(tokens, platform); + + // One accessible name for the whole chord ("Control + Shift + D"), on the + // wrapper: the glyphs inside announce as nothing useful, and a per-chip label + // makes a screen reader read one shortcut as three unrelated images. + return ( + + {ordered.map((key, index) => ( + + {index > 0 && props.separator === 'plus' && ( + + )} + + + ))} + + ); +} diff --git a/apps/desktop/src/renderer/styles.css b/apps/desktop/src/renderer/styles.css index 00637bce8e..734bc80a58 100644 --- a/apps/desktop/src/renderer/styles.css +++ b/apps/desktop/src/renderer/styles.css @@ -52,6 +52,7 @@ @import "./styles/plan-mode.css" layer(components); @import "./styles/agent-graph.css" layer(components); @import "./styles/error.css" layer(components); +@import "./styles/shortcut-keys.css" layer(components); @import "./styles/help.css" layer(components); @import "./styles/palette.css" layer(components); @import "./styles/hero.css" layer(components); diff --git a/apps/desktop/src/renderer/styles/help.css b/apps/desktop/src/renderer/styles/help.css index 2f5b189f64..ef8782b317 100644 --- a/apps/desktop/src/renderer/styles/help.css +++ b/apps/desktop/src/renderer/styles/help.css @@ -63,13 +63,5 @@ justify-self: end; } -.maka-help-section dd > span { - display: inline-flex; - align-items: center; - gap: var(--space-1); -} - -.maka-help-plus { - font: var(--maka-text-supporting); - color: var(--muted-foreground); -} +/* The keys themselves are `.maka-shortcut-keys` (styles/shortcut-keys.css), + which owns the row's inline layout and the `+` between chips. */ diff --git a/apps/desktop/src/renderer/styles/shortcut-keys.css b/apps/desktop/src/renderer/styles/shortcut-keys.css new file mode 100644 index 0000000000..aef3e212f8 --- /dev/null +++ b/apps/desktop/src/renderer/styles/shortcut-keys.css @@ -0,0 +1,61 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/* ============================================================================ + Shortcut keycaps (shortcut-keys.tsx) + ---------------------------------------------------------------------------- + The chip Astryx's Kbd draws, drawn from the same theme tokens, because the + product had to take over WHICH KEY the chip names (#3876) and a shortcut must + not change appearance depending on who rendered it. Every value below is the + variable Kbd.tsx itself reads — keep them in step when Astryx restyles. */ + +.maka-shortcut-keys { + display: inline-flex; + align-items: center; + gap: var(--spacing-1); + flex-shrink: 0; +} + +.maka-shortcut-kbd { + /* A composes the mono variant of every role (`:where(code, kbd, samp, + pre)` in maka-tokens.css is the one monospace authority), and a keycap opts + out the same way .maka-nav-kbd does: naming the family axis re-composes the + role the element already carries. */ + --maka-font-family: var(--font-family-body); + display: inline-flex; + align-items: center; + justify-content: center; + min-width: var(--spacing-5); + height: var(--spacing-5); + padding-inline: var(--spacing-1); + border: 0; + border-bottom: 2px solid var(--color-border-emphasized); + border-radius: var(--radius-inner); + background-color: var(--color-neutral); + box-shadow: none; + color: var(--color-text-secondary); + font: var(--maka-text-supporting); + font-weight: var(--font-weight-medium); + user-select: none; +} + +.maka-shortcut-plus { + font: var(--maka-text-supporting); + color: var(--muted-foreground); +} diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index acfdde61e2..23dda1b4c3 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -5,7 +5,7 @@ Each row is one on-disk product surface file. Regenerated inventory must stay in Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding. -**Totals:** 213 files — blocker 0, polish 1, aligned 212. +**Totals:** 216 files — blocker 0, polish 1, aligned 215. ## Exclusions (explicit) @@ -117,6 +117,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `apps/desktop/src/renderer/settings/tasks-settings-page.tsx` | settings-page | Button, EmptyState, HStack, List, ListItem, TextInput | aligned — uses Astryx (Button, EmptyState, HStack, List, ListItem, TextInput) | aligned | | `apps/desktop/src/renderer/settings/usage-settings-page.tsx` | settings-page | Banner, Button, Card, EmptyState, SegmentedControl, SegmentedControlItem, Selector, Tab, TabList | aligned — uses Astryx (Banner, Button, Card, EmptyState, SegmentedControl, SegmentedControlItem, Selector, Tab) | aligned | | `apps/desktop/src/renderer/settings/web-search-settings-page.tsx` | settings-page | Banner, Button, EmptyState, Selector | aligned — uses Astryx (Banner, Button, EmptyState, Selector) | aligned | +| `apps/desktop/src/renderer/shortcut-keys.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/styles.css` | styles | n/a (css) | aligned — no off-rhythm control heights flagged | aligned | | `apps/desktop/src/renderer/styles/agent-graph.css` | styles | n/a (css) | aligned — no off-rhythm control heights flagged | aligned | | `apps/desktop/src/renderer/styles/astryx-mount.css` | styles | n/a (css) | aligned — no off-rhythm control heights flagged | aligned | @@ -168,6 +169,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `apps/desktop/src/renderer/styles/settings/web-search.css` | settings-module | n/a (css) | aligned — no off-rhythm control heights flagged | aligned | | `apps/desktop/src/renderer/styles/settings/wechat.css` | settings-module | n/a (css) | aligned — no off-rhythm control heights flagged | aligned | | `apps/desktop/src/renderer/styles/shell-layout.css` | styles | n/a (css) | aligned — no off-rhythm control heights flagged | aligned | +| `apps/desktop/src/renderer/styles/shortcut-keys.css` | styles | n/a (css) | aligned — no off-rhythm control heights flagged | aligned | | `apps/desktop/src/renderer/styles/sidebar.css` | shell-chrome-or-panel | n/a (css) | aligned — no off-rhythm control heights flagged | aligned | | `apps/desktop/src/renderer/styles/task-ledger.css` | styles | n/a (css) | aligned — no off-rhythm control heights flagged | aligned | | `apps/desktop/src/renderer/styles/theme-glass.css` | styles | n/a (css) | aligned — no off-rhythm control heights flagged | aligned | @@ -196,6 +198,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `packages/ui/src/composer-message-queue.tsx` | shell-chrome-or-panel | Button, IconButton, List, ListItem | raw ` { + const shortcuts = [ + ['mod', 'n'], + ['mod', 'k'], + ['mod', ','], + ['mod', 'shift', 'd'], + ['mod', 'alt', 's'], + ]; + assert.deepEqual( + shortcuts.map((keys) => formatShortcut(keys, 'darwin')), + ['⌘N', '⌘K', '⌘,', '⇧⌘D', '⌥⌘S'], + ); + assert.deepEqual( + shortcuts.map((keys) => formatShortcut(keys, 'win32')), + ['Ctrl+N', 'Ctrl+K', 'Ctrl+,', 'Ctrl+Shift+D', 'Ctrl+Alt+S'], + ); + // Linux is not a third spelling. It shares Windows' keyboard conventions, + // and the point of asserting it separately is that `darwin` must be the + // exception rather than every other platform having to opt in. + assert.deepEqual( + shortcuts.map((keys) => formatShortcut(keys, 'linux')), + ['Ctrl+N', 'Ctrl+K', 'Ctrl+,', 'Ctrl+Shift+D', 'Ctrl+Alt+S'], + ); +}); + +test('the rail keeps its single space on every platform', () => { + // `⌘ N` is what the new-task row has always shown, and a 32px row has no + // space for `Ctrl+N`'s extra character either. The separator is the caller's + // to choose; which modifier it separates is not. + assert.equal(formatShortcut(['mod', 'n'], 'darwin', { separator: ' ' }), '⌘ N'); + assert.equal(formatShortcut(['mod', 'n'], 'win32', { separator: ' ' }), 'Ctrl N'); + assert.equal(formatShortcut(['mod', 'n'], 'linux', { separator: ' ' }), 'Ctrl N'); +}); + +test('modifiers lead in the order the platform prints them', () => { + // Not a glyph substitution: Apple puts Command last so it sits against the + // character (`⇧⌘D`), and Windows leads with Control (`Ctrl+Shift+D`). + // Swapping glyphs alone would have spelled `Shift+Ctrl+D`, which no Windows + // app writes. + assert.deepEqual(orderShortcutKeys(['mod', 'shift', 'd'], 'darwin'), ['shift', 'mod', 'd']); + assert.deepEqual(orderShortcutKeys(['mod', 'shift', 'd'], 'win32'), ['mod', 'shift', 'd']); + // Authored order survives among non-modifiers: ← and → are two alternatives + // for one row, not a chord to be sorted. + assert.deepEqual(orderShortcutKeys(['shift', 'left', 'right'], 'win32'), [ + 'shift', + 'left', + 'right', + ]); +}); + +test('ctrl is Control on both, and never the Command glyph', () => { + // A binding that names `ctrl` rather than `mod` means the physical Control + // key, including on macOS, where it is ⌃ and NOT ⌘. + assert.equal(formatShortcutKey('ctrl', 'darwin'), '⌃'); + assert.equal(formatShortcutKey('ctrl', 'win32'), 'Ctrl'); + // Off macOS `mod` IS Control, so both tokens land on the same word rather + // than one of them announcing a key the keyboard does not have. + assert.equal(formatShortcutKey('mod', 'win32'), 'Ctrl'); +}); + +test('keys that are the same everywhere are spelled once', () => { + for (const platform of ['darwin', 'win32', 'linux']) { + assert.equal(formatShortcutKey('up', platform), '↑'); + assert.equal(formatShortcutKey('enter', platform), '↵'); + assert.equal(formatShortcutKey('tab', platform), '⇥'); + assert.equal(formatShortcutKey('escape', platform), 'Esc'); + // A bare character reads as it does on the keycap, and punctuation is left + // alone by the same rule. + assert.equal(formatShortcutKey('n', platform), 'N'); + assert.equal(formatShortcutKey(',', platform), ','); + assert.equal(formatShortcutKey('?', platform), '?'); + } +}); + +test('a screen reader hears words, never glyphs', () => { + assert.equal(shortcutLabel(['mod', 'shift', 'd'], 'darwin'), 'Shift + Command + D'); + assert.equal(shortcutLabel(['mod', 'shift', 'd'], 'win32'), 'Control + Shift + D'); + assert.equal(shortcutLabel(['alt', 'enter'], 'linux'), 'Alt + Enter'); +}); + +test('an Astryx-style spec parses into the same tokens', () => { + assert.deepEqual(parseShortcutKeys('mod+shift+d'), ['mod', 'shift', 'd']); + assert.deepEqual(parseShortcutKeys('Ctrl+`'), ['ctrl', '`']); + // `plus` is the word for the `+` key, so splitting on `+` cannot lose it. + assert.deepEqual(parseShortcutKeys('shift+plus'), ['shift', 'plus']); + assert.equal(formatShortcut(parseShortcutKeys('shift+plus'), 'win32'), 'Shift++'); +}); + +test('an unresolved platform is answered by the browser, not defaulted', () => { + // The authoritative platform arrives over async IPC and the first paint + // happens before it does. Reading `navigator` in the meantime is what keeps + // a Mac from showing `Ctrl N` for a frame and then flipping to `⌘ N`. + const original = Object.getOwnPropertyDescriptor(globalThis, 'navigator'); + const stubNavigator = (platform: string) => { + Object.defineProperty(globalThis, 'navigator', { + value: { platform }, + configurable: true, + }); + }; + try { + stubNavigator('MacIntel'); + assert.equal(usesAppleShortcutGlyphs(undefined), true); + assert.equal(formatShortcut(['mod', 'n'], undefined, { separator: ' ' }), '⌘ N'); + + stubNavigator('Win32'); + assert.equal(usesAppleShortcutGlyphs(undefined), false); + assert.equal(formatShortcut(['mod', 'n'], undefined, { separator: ' ' }), 'Ctrl N'); + } finally { + if (original) Object.defineProperty(globalThis, 'navigator', original); + else Reflect.deleteProperty(globalThis, 'navigator'); + } +}); + +test('an explicit platform wins over whatever the browser claims', () => { + const original = Object.getOwnPropertyDescriptor(globalThis, 'navigator'); + Object.defineProperty(globalThis, 'navigator', { + value: { platform: 'MacIntel' }, + configurable: true, + }); + try { + // Electron reports the real host; a rewritten user agent does not get to + // overrule it. + assert.equal(usesAppleShortcutGlyphs('win32'), false); + assert.equal(formatShortcut(['mod', 'n'], 'win32', { separator: ' ' }), 'Ctrl N'); + } finally { + if (original) Object.defineProperty(globalThis, 'navigator', original); + else Reflect.deleteProperty(globalThis, 'navigator'); + } +}); diff --git a/packages/ui/src/__tests__/sidebar-new-task-shortcut.test.tsx b/packages/ui/src/__tests__/sidebar-new-task-shortcut.test.tsx new file mode 100644 index 0000000000..485d59874a --- /dev/null +++ b/packages/ui/src/__tests__/sidebar-new-task-shortcut.test.tsx @@ -0,0 +1,63 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * The rail's new-task hint, which is where #3876 was reported from: the row + * read `⌘ N` on Windows, where the key that creates a task is Ctrl N. + * + * Rendered through the real provider rather than calling the formatter again, + * because the hint reaches this row through four components that know nothing + * about the host OS — the context is the part worth covering. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { HostPlatformProvider } from '../host-platform-context.js'; +import { LocaleProvider } from '../locale-context.js'; +import { SessionSidebarNav } from '../session-sidebar-nav.js'; + +function renderRailHint(platform?: string): string | null { + const markup = renderToStaticMarkup( + + + undefined} + onNew={() => undefined} + /> + + , + ); + const match = /class="maka-nav-kbd"[^>]*>([^<]*) { + assert.equal(renderRailHint('darwin'), '⌘ N'); + assert.equal(renderRailHint('win32'), 'Ctrl N'); + assert.equal(renderRailHint('linux'), 'Ctrl N'); +}); + +test('the hint renders before the main process has named the platform', () => { + // `app.info()` is async, and a row that renders nothing until it answers + // would move the label when the hint appeared. Undefined resolves through + // `navigator`, which under `renderToStaticMarkup` is not Apple. + assert.equal(renderRailHint(undefined), 'Ctrl N'); +}); diff --git a/packages/ui/src/host-platform-context.tsx b/packages/ui/src/host-platform-context.tsx new file mode 100644 index 0000000000..b67ed2c117 --- /dev/null +++ b/packages/ui/src/host-platform-context.tsx @@ -0,0 +1,53 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createContext, useContext, type ReactNode } from 'react'; +import type { ShortcutPlatform } from './keyboard-shortcut-display.js'; + +const HostPlatformContext = createContext(undefined); + +/** + * Publishes the host OS to everything that has to spell something the way this + * platform spells it — today the keyboard-shortcut labels (#3876). + * + * A context rather than a prop: the rail's new-task hint sits four components + * below the shell that knows the answer, and none of the components in between + * have anything to do with the host OS. + */ +export function HostPlatformProvider(props: { + /** As `process.platform` spells it: `darwin`, `win32`, `linux`. */ + platform?: ShortcutPlatform; + children: ReactNode; +}) { + return ( + + {props.children} + + ); +} + +/** + * The host OS, or undefined before the main process has answered — and in + * Storybook, which has no main process at all. Undefined is a legitimate + * value, not an error: every consumer resolves it from `navigator` (see + * `usesAppleShortcutGlyphs`), so nothing has to wait for IPC to render. + */ +export function useHostPlatform(): ShortcutPlatform | undefined { + return useContext(HostPlatformContext); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 247863794c..fd1c9ff454 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -41,6 +41,8 @@ export * from './input-history.js'; export * from './daily-review-helpers.js'; export * from './locale-helpers.js'; export * from './locale-context.js'; +export * from './host-platform-context.js'; +export * from './keyboard-shortcut-display.js'; export { MakaUriContext } from './markdown.js'; export * from './maka-uri.js'; export * from './materialize.js'; diff --git a/packages/ui/src/keyboard-shortcut-display.ts b/packages/ui/src/keyboard-shortcut-display.ts new file mode 100644 index 0000000000..be9fea4589 --- /dev/null +++ b/packages/ui/src/keyboard-shortcut-display.ts @@ -0,0 +1,210 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// packages/ui/src/keyboard-shortcut-display.ts +// +// How a shortcut is SPELLED, for every surface that shows one. Bindings are a +// separate concern and stay where they are: `useHotkeys` already takes `mod` +// and resolves it to Command on macOS and Control everywhere else, so the keys +// a user presses were never platform-specific — only the labels were, and they +// were written once, in macOS glyphs, and shown to everyone (#3876). +// +// Shortcuts are not localized, so this lives outside the locale catalogs: `⌘,` +// and `Ctrl+,` are the same copy in Chinese and in English. What varies is the +// host, so the platform is the only argument. + +/** + * Host platform, spelled as Electron's `process.platform` spells it + * (`darwin` / `win32` / `linux`), which is what `app.info()` reports and what + * `data-os` carries. + */ +export type ShortcutPlatform = string; + +/** + * Modifiers in the order the platform prints them. + * + * Apple's own order is Control-Option-Shift-Command, so Command lands next to + * the character it modifies (`⇧⌘D`). Windows and Linux lead with Control + * instead (`Ctrl+Shift+D`), which is why this is a reordering and not a glyph + * substitution: swapping ⇧⌘ for Shift+Ctrl would spell a real shortcut in an + * order no Windows app uses. + */ +const APPLE_MODIFIER_ORDER = ['ctrl', 'alt', 'shift', 'mod'] as const; +const PC_MODIFIER_ORDER = ['mod', 'ctrl', 'alt', 'shift'] as const; + +const APPLE_KEY_DISPLAY: Readonly> = { + mod: '\u2318', // ⌘ + ctrl: '\u2303', // ⌃ + alt: '\u2325', // ⌥ + shift: '\u21E7', // ⇧ +}; + +const PC_KEY_DISPLAY: Readonly> = { + // `mod` IS Control off macOS — the same key, named twice, so both spell it + // the way the platform prints it rather than one of them saying "Cmd". + mod: 'Ctrl', + ctrl: 'Ctrl', + alt: 'Alt', + shift: 'Shift', +}; + +/** + * Keys that are neither modifiers nor single characters, and whose glyph is + * the same on every platform: an arrow key is ↑ on a Mac and on a ThinkPad. + */ +const SHARED_KEY_DISPLAY: Readonly> = { + up: '\u2191', + down: '\u2193', + left: '\u2190', + right: '\u2192', + enter: '\u21B5', // ↵ + tab: '\u21E5', // ⇥ + backspace: '\u232B', // ⌫ + escape: 'Esc', + plus: '+', +}; + +/** Spoken names, for the accessible label a glyph cannot carry. */ +const APPLE_KEY_LABEL: Readonly> = { mod: 'Command' }; +const SHARED_KEY_LABEL: Readonly> = { + mod: 'Control', + ctrl: 'Control', + alt: 'Alt', + shift: 'Shift', + up: 'Up arrow', + down: 'Down arrow', + left: 'Left arrow', + right: 'Right arrow', + enter: 'Enter', + tab: 'Tab', + backspace: 'Backspace', + escape: 'Escape', + plus: 'Plus', +}; + +const MODIFIERS = new Set([...PC_MODIFIER_ORDER]); + +/** + * Whether this host prints Apple's modifier glyphs. + * + * A missing platform is answered from the browser rather than defaulted, + * because the authoritative value arrives over async IPC (`app.info()`) and + * the first paint happens before it does. `navigator` is available + * synchronously and is the same signal Astryx's own `Kbd` reads, so a label is + * right from the first frame instead of flipping from ⌘ to Ctrl once the main + * process answers. + */ +export function usesAppleShortcutGlyphs(platform?: ShortcutPlatform | null): boolean { + if (platform) return /^(darwin|mac)/i.test(platform); + return detectApplePlatformFromNavigator(); +} + +function detectApplePlatformFromNavigator(): boolean { + if (typeof navigator === 'undefined') return false; + const uaData: unknown = 'userAgentData' in navigator ? navigator.userAgentData : null; + if (uaData && typeof uaData === 'object' && 'platform' in uaData) { + const uaPlatform = (uaData as { platform?: unknown }).platform; + // A blank platform is no answer, not a negative one — fall through to the + // deprecated field rather than reading '' as "not Apple". + if (typeof uaPlatform === 'string' && uaPlatform.trim() !== '') { + return /mac/i.test(uaPlatform); + } + } + return /Mac|iPhone|iPad|iPod/i.test(navigator.platform ?? ''); +} + +/** + * Splits an Astryx-style `keys` string (`'mod+shift+d'`) into tokens. `plus` + * is the literal `+` key, which is why it is spelled as a word. + */ +export function parseShortcutKeys(keys: string): string[] { + return keys + .split('+') + .map((key) => key.trim().toLowerCase()) + .filter((key) => key.length > 0); +} + +/** One key, spelled for this platform. */ +export function formatShortcutKey(key: string, platform?: ShortcutPlatform | null): string { + const token = key.trim().toLowerCase(); + const apple = usesAppleShortcutGlyphs(platform); + const modifier = apple ? APPLE_KEY_DISPLAY[token] : PC_KEY_DISPLAY[token]; + if (modifier) return modifier; + const shared = SHARED_KEY_DISPLAY[token]; + if (shared) return shared; + // A bare character is printed as it appears on the keycap: `N`, not `n`, and + // `,` unchanged — uppercasing is what a keycap does to a letter and nothing + // to punctuation. + return token.toUpperCase(); +} + +/** One key, named for a screen reader. */ +function shortcutKeyLabel(key: string, platform?: ShortcutPlatform | null): string { + const token = key.trim().toLowerCase(); + if (usesAppleShortcutGlyphs(platform)) { + const apple = APPLE_KEY_LABEL[token]; + if (apple) return apple; + } + return SHARED_KEY_LABEL[token] ?? token.toUpperCase(); +} + +/** + * Modifiers first, in the host's own order, then the rest as authored. + * + * Only modifiers move. `['shift', 'left', 'right']` keeps ← before → because + * those are two alternatives for one row, not a chord to be sorted. + */ +export function orderShortcutKeys( + keys: readonly string[], + platform?: ShortcutPlatform | null, +): string[] { + const tokens = keys.map((key) => key.trim().toLowerCase()); + const order = usesAppleShortcutGlyphs(platform) ? APPLE_MODIFIER_ORDER : PC_MODIFIER_ORDER; + const modifiers = order.filter((modifier) => tokens.includes(modifier)); + return [...modifiers, ...tokens.filter((token) => !MODIFIERS.has(token))]; +} + +/** + * A whole shortcut as one string. + * + * The default separator is the platform's: macOS runs its glyphs together + * (`⇧⌘D`) where Windows and Linux spell the chord out (`Ctrl+Shift+D`). + * Callers that sit in a tighter space pass their own — the rail's new-task + * hint has always used a single space (`⌘ N`), and keeps it (`Ctrl N`). + */ +export function formatShortcut( + keys: readonly string[], + platform?: ShortcutPlatform | null, + options?: { separator?: string }, +): string { + const separator = options?.separator ?? (usesAppleShortcutGlyphs(platform) ? '' : '+'); + return orderShortcutKeys(keys, platform) + .map((key) => formatShortcutKey(key, platform)) + .join(separator); +} + +/** A whole shortcut, named for a screen reader. */ +export function shortcutLabel( + keys: readonly string[], + platform?: ShortcutPlatform | null, +): string { + return orderShortcutKeys(keys, platform) + .map((key) => shortcutKeyLabel(key, platform)) + .join(' + '); +} diff --git a/packages/ui/src/session-sidebar-nav.tsx b/packages/ui/src/session-sidebar-nav.tsx index e7d3b71ff1..0322d39082 100644 --- a/packages/ui/src/session-sidebar-nav.tsx +++ b/packages/ui/src/session-sidebar-nav.tsx @@ -21,6 +21,8 @@ import type { ScheduledTask } from '@maka/core/scheduled-task'; import { AlertCircle, Blocks, Download, Network, Settings, SquarePen, Timer } from './icons.js'; import type { NavModuleMemory, NavSelection } from './nav-selection.js'; import { useUiLocale } from './locale-context.js'; +import { useHostPlatform } from './host-platform-context.js'; +import { formatShortcut } from './keyboard-shortcut-display.js'; import { getShellControlsCopy } from './shell-controls-copy.js'; import { Icon } from '@astryxdesign/core/Icon'; import { IconButton } from '@astryxdesign/core/IconButton'; @@ -41,6 +43,12 @@ export function SessionSidebarNav(props: { }) { const locale = useUiLocale(); const copy = getShellControlsCopy(locale).navigation; + const platform = useHostPlatform(); + // A single space, which is what this hint has always used, rather than the + // `+` a chord takes elsewhere: the row is 32px and the label is already + // beside it, so `Ctrl N` (`⌘ N` on macOS) is as much punctuation as it can + // carry. `mod+n` is the binding app-shell-effects.ts registers, unchanged. + const newTaskShortcut = formatShortcut(['mod', 'n'], platform, { separator: ' ' }); const extensionsActive = props.selection.section === 'extensions'; const automationsActive = props.selection.section === 'automations'; const moduleMemory = props.moduleMemory ?? { extensions: 'skills', automations: 'scheduled-tasks' }; @@ -66,7 +74,7 @@ export function SessionSidebarNav(props: { icon={SquarePen} size="md" onClick={props.onNew} - endContent={} + endContent={} /> {props.workHubEntry ? (