From 2f9b67e0b61fa6a12c277273770d030d2330bdef Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 07:52:51 +0000 Subject: [PATCH 1/5] fix(app-shell,i18n): the keyboard-shortcuts dialog lists only shortcuts a mounted handler advertises (objectui#11674) The dialog was a static list; N, Cmd/Ctrl+E, Cmd/Ctrl+/, Cmd/Ctrl+D and a bare B had no handler. Each handler now advertises its shortcut beside itself (advertisedShortcuts.ts) and the dialog lists what is advertised. The sidebar row reads its real chord, Cmd/Ctrl+B. The seven console.shortcuts keys only the dead rows read are retired from all ten packs. Claude-Session: https://claude.ai/code/session_01FngvPpdrnhHMdHHq6vwwju Co-authored-by: Claude --- .../src/chrome/KeyboardShortcutsDialog.tsx | 131 +++++---- ...rdShortcutsDialog.wiredOnly-11674.test.tsx | 265 ++++++++++++++++++ .../src/chrome/advertisedShortcuts.ts | 147 ++++++++++ .../app-shell/src/console/ai/AiChatPage.tsx | 26 ++ .../src/context/CommandPaletteProvider.tsx | 14 + .../app-shell/src/layout/ConsoleLayout.tsx | 20 ++ .../__tests__/console-namespace-3546.test.tsx | 7 +- ...arketplace-preview-namespace-3546.test.tsx | 6 +- packages/i18n/src/locales/ar.ts | 7 - packages/i18n/src/locales/de.ts | 7 - packages/i18n/src/locales/en.ts | 7 - packages/i18n/src/locales/es.ts | 7 - packages/i18n/src/locales/fr.ts | 7 - packages/i18n/src/locales/ja.ts | 7 - packages/i18n/src/locales/ko.ts | 7 - packages/i18n/src/locales/pt.ts | 7 - packages/i18n/src/locales/ru.ts | 7 - packages/i18n/src/locales/zh.ts | 7 - 18 files changed, 558 insertions(+), 128 deletions(-) create mode 100644 packages/app-shell/src/chrome/KeyboardShortcutsDialog.wiredOnly-11674.test.tsx create mode 100644 packages/app-shell/src/chrome/advertisedShortcuts.ts diff --git a/packages/app-shell/src/chrome/KeyboardShortcutsDialog.tsx b/packages/app-shell/src/chrome/KeyboardShortcutsDialog.tsx index ad882b0dc9..7289def222 100644 --- a/packages/app-shell/src/chrome/KeyboardShortcutsDialog.tsx +++ b/packages/app-shell/src/chrome/KeyboardShortcutsDialog.tsx @@ -1,11 +1,16 @@ /** * KeyboardShortcutsDialog * - * A dialog listing all available keyboard shortcuts, triggered by pressing "?". + * A dialog listing the keyboard shortcuts that are wired right now, triggered + * by pressing "?". + * + * Its rows are not a list of its own: each one is a shortcut some mounted + * handler advertises beside itself (`advertisedShortcuts.ts`, objectui#11674). + * A shortcut whose handler is not mounted is not listed. * @module */ -import { useEffect, useMemo } from 'react'; +import { useEffect } from 'react'; import { Dialog, DialogContent, @@ -16,62 +21,85 @@ import { import { useObjectTranslation } from '@object-ui/i18n'; import { useUrlOverlay } from '../hooks/useUrlOverlay.js'; import { KEYBOARD_SHORTCUTS_PARAM } from '../urlParams.js'; +import { + SHORTCUT_GROUPS, + shortcutKeycaps, + useAdvertiseShortcut, + useAdvertisedShortcuts, + type AdvertisedShortcut, + type ShortcutGroup, + type ShortcutLabelTranslate, +} from './advertisedShortcuts.js'; + +/** `?` — the handler is this component's own keydown listener below. */ +export const SHOW_SHORTCUTS_SHORTCUT: AdvertisedShortcut = { + id: 'shortcuts-help', + group: 'general', + chord: { key: '?' }, + label: (t: ShortcutLabelTranslate) => t('console.shortcuts.showShortcuts'), +}; -interface ShortcutEntry { +/** + * `Esc` — the handler is Radix's dismiss listener, which this dialog and every + * other dialog and panel in the shell carry. Advertised here, beside the + * `Dialog` it closes. + */ +export const CLOSE_OVERLAY_SHORTCUT: AdvertisedShortcut = { + id: 'close-overlay', + group: 'general', + chord: { key: 'Escape' }, + label: (t: ShortcutLabelTranslate) => t('console.shortcuts.closeDialog'), +}; + +function groupTitle(group: ShortcutGroup, t: ShortcutLabelTranslate): string { + switch (group) { + case 'general': + return t('console.shortcuts.groups.general'); + case 'navigation': + return t('console.shortcuts.groups.navigation'); + case 'aiChat': + return t('console.shortcuts.groups.aiChat'); + } +} + +interface ShortcutRow { + id: string; keys: string[]; description: string; } -interface ShortcutGroup { +interface ShortcutSection { + group: ShortcutGroup; title: string; - shortcuts: ShortcutEntry[]; + rows: ShortcutRow[]; } export function KeyboardShortcutsDialog() { - const { t } = useObjectTranslation(); + const { t, language } = useObjectTranslation(); // URL-addressable (?shortcuts=1) so the dialog is deep-linkable and openable // from the header Help menu, not only via the `?` keyboard accelerator (ADR-0054 // C1/C2/C3). const { open, setOpen, toggleOverlay } = useUrlOverlay(KEYBOARD_SHORTCUTS_PARAM); - const shortcutGroups: ShortcutGroup[] = useMemo(() => [ - { - title: t('console.shortcuts.groups.general'), - shortcuts: [ - { keys: ['⌘', 'K'], description: t('console.shortcuts.openCommandPalette') }, - { keys: ['?'], description: t('console.shortcuts.showShortcuts') }, - { keys: ['Esc'], description: t('console.shortcuts.closeDialog') }, - ], - }, - { - title: t('console.shortcuts.groups.navigation'), - shortcuts: [ - { keys: ['B'], description: t('console.shortcuts.toggleSidebar') }, - { keys: ['⌘', '/'], description: t('console.shortcuts.focusSearch') }, - ], - }, - { - title: t('console.shortcuts.groups.dataViews'), - shortcuts: [ - { keys: ['N'], description: t('console.shortcuts.createRecord') }, - { keys: ['R'], description: t('console.shortcuts.refreshData') }, - { keys: ['⌘', 'E'], description: t('console.shortcuts.editRecord') }, - ], - }, - { - title: t('console.shortcuts.groups.aiChat', { defaultValue: 'AI assistant' }), - shortcuts: [ - { keys: ['⌘', '⇧', 'O'], description: t('console.shortcuts.newChat', { defaultValue: 'New chat' }) }, - { keys: ['⌘', '⇧', 'S'], description: t('console.shortcuts.toggleChatsList', { defaultValue: 'Toggle conversations list' }) }, - ], - }, - { - title: t('console.shortcuts.groups.preferences'), - shortcuts: [ - { keys: ['⌘', 'D'], description: t('console.shortcuts.toggleDarkMode') }, - ], - }, - ], [t]); + useAdvertiseShortcut(SHOW_SHORTCUTS_SHORTCUT); + useAdvertiseShortcut(CLOSE_OVERLAY_SHORTCUT); + const advertised = useAdvertisedShortcuts(); + + // Sections in `SHORTCUT_GROUPS` order, rows alphabetical by their text: the + // order handlers mount in is not an order a reader should see. + const translate: ShortcutLabelTranslate = (key, options) => String(t(key, options)); + const sections: ShortcutSection[] = SHORTCUT_GROUPS.map((group) => ({ + group, + title: groupTitle(group, translate), + rows: advertised + .filter((shortcut) => shortcut.group === group) + .map((shortcut) => ({ + id: shortcut.id, + keys: shortcutKeycaps(shortcut.chord), + description: shortcut.label(translate), + })) + .sort((a, b) => a.description.localeCompare(b.description, language)), + })).filter((section) => section.rows.length > 0); useEffect(() => { function handleKeyDown(e: KeyboardEvent) { @@ -108,20 +136,21 @@ export function KeyboardShortcutsDialog() {
- {shortcutGroups.map(group => ( -
+ {sections.map(section => ( +

- {group.title} + {section.title}

- {group.shortcuts.map((shortcut, idx) => ( + {section.rows.map(row => (
- {shortcut.description} + {row.description}
- {shortcut.keys.map((key, kidx) => ( + {row.keys.map((key, kidx) => ( ({ + ...(await importOriginal>()), + useObjectTranslation: () => ({ + t: (key: string, options?: Record) => String(options?.defaultValue ?? key), + language: 'en', + }), +})); + +vi.mock('../layout/ChatDock', () => ({ + useChatDockState: () => ({ + expanded: false, + width: 420, + dragging: false, + maximized: false, + toggle: () => {}, + expand: () => {}, + collapse: () => {}, + maximize: () => {}, + restore: () => {}, + onResizePointerDown: () => {}, + }), + ChatDockPanel: () => null, + ChatDockMobileSheet: () => null, +})); +vi.mock('../hooks/useAiSurface', () => ({ + useAiSurfaceEnabled: () => ({ enabled: false, isLoading: false }), +})); +vi.mock('../layout/UnifiedSidebar', () => ({ UnifiedSidebar: () => null })); +vi.mock('../layout/AppHeader', () => ({ AppHeader: () => null })); +vi.mock('../layout/ConsoleNotificationBanners', () => ({ ConsoleNotificationBanners: () => null })); +vi.mock('../preview/DraftPreviewBar', () => ({ DraftPreviewBar: () => null })); +vi.mock('../preview/UnpublishedAppBar', () => ({ UnpublishedAppBar: () => null })); + +import { KeyboardShortcutsDialog } from './KeyboardShortcutsDialog'; +import { + shortcutKeycaps, + useAdvertiseShortcut, + useAdvertisedShortcuts, + type AdvertisedShortcut, + type ShortcutChord, + type ShortcutLabelTranslate, +} from './advertisedShortcuts'; +import { ConsoleLayout } from '../layout/ConsoleLayout'; +import { NavigationProvider } from '../context/NavigationContext'; +import { useCommandPalette } from '../context/CommandPaletteProvider'; +import { + matchAiChatShortcut, + NEW_CHAT_SHORTCUT, + TOGGLE_CHATS_LIST_SHORTCUT, +} from '../console/ai/AiChatPage'; + +const APP = { name: 'crm', label: 'CRM', active: true, navigation: [] }; + +/** What the real handlers change, read from their own state. */ +function Probe() { + const sidebar = useSidebar(); + const palette = useCommandPalette(); + const sidebarOpen = sidebar.isMobile ? sidebar.openMobile : sidebar.open; + return ( + + ); +} + +function Capture({ into }: { into: { current: readonly AdvertisedShortcut[] } }) { + into.current = useAdvertisedShortcuts(); + return null; +} + +/** The console chrome as `AppContent` composes it, with the dialog inside. */ +function mountConsole(path: string, capture?: { current: readonly AdvertisedShortcut[] }) { + return render( + + + + + + {capture ? : null} + + + , + ); +} + +/** The keydowns a chord stands for: ⌘ and Ctrl both, when it has a modifier. */ +function chordEvents(chord: ShortcutChord): KeyboardEventInit[] { + const base = { key: chord.key, shiftKey: Boolean(chord.shift) }; + return chord.mod ? [{ ...base, metaKey: true }, { ...base, ctrlKey: true }] : [base]; +} + +const probe = () => screen.getByTestId('probe'); +const dialogShown = () => screen.queryByTestId('overlay:keyboard-shortcuts') !== null; + +/** + * One probe per row the console dialog lists: mount, check the state the + * handler starts from, fire `event`, check the handler changed it. + */ +const FIRES: Record Promise> = { + 'command-palette': async (event) => { + mountConsole('/apps/crm'); + expect(probe()).toHaveAttribute('data-palette-open', 'false'); + fireEvent.keyDown(document.body, event); + await waitFor(() => expect(probe()).toHaveAttribute('data-palette-open', 'true')); + }, + 'shortcuts-help': async (event) => { + mountConsole('/apps/crm'); + expect(dialogShown()).toBe(false); + fireEvent.keyDown(document.body, event); + await waitFor(() => expect(dialogShown()).toBe(true)); + }, + 'close-overlay': async (event) => { + mountConsole('/apps/crm?shortcuts=1'); + expect(dialogShown()).toBe(true); + fireEvent.keyDown(document.activeElement ?? document.body, event); + await waitFor(() => expect(dialogShown()).toBe(false)); + }, + 'toggle-sidebar': async (event) => { + mountConsole('/apps/crm'); + expect(probe()).toHaveAttribute('data-sidebar-open', 'true'); + fireEvent.keyDown(document.body, event); + await waitFor(() => expect(probe()).toHaveAttribute('data-sidebar-open', 'false')); + }, +}; + +afterEach(() => cleanup()); + +describe('the keyboard-shortcuts dialog lists only wired shortcuts (objectui#11674)', () => { + it('every row the console dialog lists fires its real handler, with ⌘ and with Ctrl', async () => { + const advertised = { current: [] as readonly AdvertisedShortcut[] }; + mountConsole('/apps/crm?shortcuts=1', advertised); + const rows = Array.from(document.querySelectorAll('[data-shortcut-id]')); + const listed = rows.map((row) => row.dataset.shortcutId as string); + + // Rows are the advertisements, keycaps included — nothing else. + expect([...listed].sort()).toEqual(advertised.current.map((s) => s.id).sort()); + for (const row of rows) { + const shortcut = advertised.current.find((s) => s.id === row.dataset.shortcutId)!; + const caps = Array.from(row.querySelectorAll('kbd'), (kbd) => kbd.textContent); + expect(caps, row.dataset.shortcutId).toEqual(shortcutKeycaps(shortcut.chord)); + } + // Every listed row has a probe, and every probe still has a row. + expect([...listed].sort()).toEqual(Object.keys(FIRES).sort()); + const chords = new Map(advertised.current.map((s) => [s.id, s.chord])); + cleanup(); + + for (const id of listed) { + for (const event of chordEvents(chords.get(id)!)) { + await FIRES[id](event); + cleanup(); + } + } + }); + + it('a listed row whose chord does nothing is caught: a bare B leaves the sidebar alone', async () => { + // The defect's own shape, against the same probe: the old row said `B`. + await expect(FIRES['toggle-sidebar']({ key: 'b' })).rejects.toThrow(); + }); +}); + +const DEMO_SHORTCUT: AdvertisedShortcut = { + id: 'demo-advertiser', + group: 'navigation', + chord: { key: 'g', mod: true, shift: true }, + label: (t: ShortcutLabelTranslate) => t('console.shortcuts.toggleSidebar'), +}; + +function DemoAdvertiser() { + useAdvertiseShortcut(DEMO_SHORTCUT); + return null; +} + +function DialogWithOptionalAdvertiser() { + const [mounted, setMounted] = useState(true); + return ( + <> + + {mounted ? : null} + + + ); +} + +describe('the list is derived from mounted advertisers (objectui#11674)', () => { + it('a row is listed while its advertiser is mounted and leaves when it unmounts', async () => { + render( + + + , + ); + const row = document.querySelector('[data-shortcut-id="demo-advertiser"]'); + expect(row).not.toBeNull(); + expect(Array.from(row!.querySelectorAll('kbd'), (kbd) => kbd.textContent)).toEqual(['⌘', '⇧', 'G']); + + act(() => { + fireEvent.click(screen.getByText('unmount')); + }); + await waitFor(() => + expect(document.querySelector('[data-shortcut-id="demo-advertiser"]')).toBeNull(), + ); + // The dialog's own advertisements stay. + expect(document.querySelector('[data-shortcut-id="shortcuts-help"]')).not.toBeNull(); + }); +}); + +describe("the AI chat page's advertisements carry chords its matcher answers (objectui#11674)", () => { + it.each([ + [NEW_CHAT_SHORTCUT.id, NEW_CHAT_SHORTCUT, 'new-chat'], + [TOGGLE_CHATS_LIST_SHORTCUT.id, TOGGLE_CHATS_LIST_SHORTCUT, 'toggle-list'], + ] as const)('%s', (_id, shortcut, action) => { + for (const event of chordEvents(shortcut.chord)) { + expect( + matchAiChatShortcut({ + key: event.key!, + metaKey: Boolean(event.metaKey), + ctrlKey: Boolean(event.ctrlKey), + shiftKey: Boolean(event.shiftKey), + altKey: false, + }), + ).toBe(action); + } + }); +}); diff --git a/packages/app-shell/src/chrome/advertisedShortcuts.ts b/packages/app-shell/src/chrome/advertisedShortcuts.ts new file mode 100644 index 0000000000..980fb5948b --- /dev/null +++ b/packages/app-shell/src/chrome/advertisedShortcuts.ts @@ -0,0 +1,147 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Advertised shortcuts — what the keyboard-shortcuts dialog lists, and the only + * way a shortcut gets onto it (objectui#11674). + * + * The dialog used to be a static list kept apart from every key handler, and + * five of its rows (`N`, `⌘E`, `⌘/`, `⌘D`, and a bare `B` for a sidebar that + * toggles on `⌘B`) had no handler at all. A static list cannot notice that. + * + * So the list is now derived from the handlers. The code that owns a handler + * advertises its shortcut BESIDE that handler, for exactly as long as the + * handler is mounted (`useAdvertiseShortcut`), and the dialog lists what is + * advertised at that moment (`useAdvertisedShortcuts`). A shortcut whose + * handler is not mounted is not listed. That holds per page as well: the AI + * chat page's `⌘⇧O` / `⌘⇧S` are advertised by that page, so they are listed + * only where that page is mounted. + * + * Two advertisers sit beside a handler they do not own the code of: `⌘B` is + * the Shadcn `SidebarProvider`'s own listener (a synced primitive, see + * `ConsoleLayout`), and `Esc` is Radix's dismiss handler on every dialog and + * panel (see `KeyboardShortcutsDialog`). The pin + * `KeyboardShortcutsDialog.wiredOnly-11674.test.tsx` fires every listed + * entry's chord against the real handler, those two included, and fails on a + * listed entry it has no way to fire. + * + * A module store rather than a React context for the same reason as + * `assistantBus`: the advertisers (the command-palette provider, the layout, + * the dialog itself) sit on different branches of the tree from the dialog, + * and an advertisement must reach it without a provider threaded through every + * layout. + * + * @module + */ + +import { useEffect, useSyncExternalStore } from 'react'; + +/** The dialog's sections, in the order it shows them. */ +export const SHORTCUT_GROUPS = ['general', 'navigation', 'aiChat'] as const; +export type ShortcutGroup = (typeof SHORTCUT_GROUPS)[number]; + +/** + * The key combination a handler answers to. + * + * `key` is the `KeyboardEvent.key` the handler matches, lower case for a + * letter. `mod` is ⌘ on macOS and Ctrl elsewhere: every handler advertised + * here accepts either. + */ +export interface ShortcutChord { + key: string; + mod?: boolean; + shift?: boolean; +} + +/** The translator the dialog hands to {@link AdvertisedShortcut.label}. */ +export type ShortcutLabelTranslate = (key: string, options?: Record) => string; + +export interface AdvertisedShortcut { + /** Stable identity: one row per id however many holders advertise it. */ + id: string; + group: ShortcutGroup; + chord: ShortcutChord; + /** + * The row's text. Written as a literal `t('…')` call so the i18n gates read + * the key; annotate the parameter as `ShortcutLabelTranslate`. + */ + label: (t: ShortcutLabelTranslate) => string; +} + +const KEYCAP_TEXT: Record = { Escape: 'Esc' }; + +/** The keycaps the dialog draws for a chord: `{ key: 'k', mod: true }` → `['⌘', 'K']`. */ +export function shortcutKeycaps(chord: ShortcutChord): string[] { + const caps: string[] = []; + if (chord.mod) caps.push('⌘'); + if (chord.shift) caps.push('⇧'); + caps.push(KEYCAP_TEXT[chord.key] ?? (chord.key.length === 1 ? chord.key.toUpperCase() : chord.key)); + return caps; +} + +interface Holding { + shortcut: AdvertisedShortcut; + holders: number; +} + +const holdings = new Map(); +const listeners = new Set<() => void>(); +// Replaced only when the set of advertised ids changes, so +// `useSyncExternalStore` sees a stable reference between changes. +let snapshot: readonly AdvertisedShortcut[] = []; + +function commit(): void { + snapshot = Array.from(holdings.values(), (holding) => holding.shortcut); + for (const listener of listeners) listener(); +} + +/** + * Advertise `shortcut` until the returned release function is called. A second + * holder of the same id shares the row; the row leaves when the last one + * releases. Prefer {@link useAdvertiseShortcut} in a component. + */ +export function advertiseShortcut(shortcut: AdvertisedShortcut): () => void { + const holding = holdings.get(shortcut.id); + if (holding) { + holding.holders += 1; + } else { + holdings.set(shortcut.id, { shortcut, holders: 1 }); + commit(); + } + let released = false; + return () => { + if (released) return; + released = true; + const current = holdings.get(shortcut.id); + if (!current) return; + current.holders -= 1; + if (current.holders === 0) { + holdings.delete(shortcut.id); + commit(); + } + }; +} + +/** + * Advertise `shortcut` while the calling component is mounted and `enabled`. + * Call it beside the handler the shortcut names, with the same gate the + * handler has, and pass a module-level constant. + */ +export function useAdvertiseShortcut(shortcut: AdvertisedShortcut, enabled = true): void { + useEffect(() => (enabled ? advertiseShortcut(shortcut) : undefined), [shortcut, enabled]); +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function getSnapshot(): readonly AdvertisedShortcut[] { + return snapshot; +} + +/** The shortcuts advertised right now, in the order they were first advertised. */ +export function useAdvertisedShortcuts(): readonly AdvertisedShortcut[] { + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} diff --git a/packages/app-shell/src/console/ai/AiChatPage.tsx b/packages/app-shell/src/console/ai/AiChatPage.tsx index 0c099899d6..200963c5f2 100644 --- a/packages/app-shell/src/console/ai/AiChatPage.tsx +++ b/packages/app-shell/src/console/ai/AiChatPage.tsx @@ -106,6 +106,11 @@ import { import { AppHeader } from '../../layout/AppHeader.js'; import { armChatDockExpanded, readDockReturnLocation } from '../../layout/chatDockState.js'; +import { + useAdvertiseShortcut, + type AdvertisedShortcut, + type ShortcutLabelTranslate, +} from '../../chrome/advertisedShortcuts.js'; import { fetchPendingDraftCount } from '../../preview/draftStatus.js'; import { emitMetadataRefresh, publishPlanApprovalPending } from '../../assistant/assistantBus.js'; import { getRuntimeConfig, isAiStudioEnabled } from '../../runtime-config.js'; @@ -695,6 +700,25 @@ export function matchAiChatShortcut(e: { } } +/** + * The two {@link matchAiChatShortcut} chords, advertised to the + * keyboard-shortcuts dialog by `AiChatPage` beside the listener that calls it, + * so they are listed only where that page is mounted (objectui#11674). + */ +export const NEW_CHAT_SHORTCUT: AdvertisedShortcut = { + id: 'ai-new-chat', + group: 'aiChat', + chord: { key: 'o', mod: true, shift: true }, + label: (t: ShortcutLabelTranslate) => t('console.shortcuts.newChat'), +}; + +export const TOGGLE_CHATS_LIST_SHORTCUT: AdvertisedShortcut = { + id: 'ai-toggle-chats-list', + group: 'aiChat', + chord: { key: 's', mod: true, shift: true }, + label: (t: ShortcutLabelTranslate) => t('console.shortcuts.toggleChatsList'), +}; + /** * ADR-0057 P3c — where the "collapse to dock" affordance navigates, in * preference order: @@ -1092,6 +1116,8 @@ export function AiChatPage({ apiBase: apiBaseProp, defaultAgent: defaultAgentPro document.addEventListener('keydown', onKeyDown); return () => document.removeEventListener('keydown', onKeyDown); }, [toggleChatsCollapsed, navigate, activeAgentRoute]); + useAdvertiseShortcut(NEW_CHAT_SHORTCUT); + useAdvertiseShortcut(TOGGLE_CHATS_LIST_SHORTCUT); const restApiBase = useMemo( () => apiBase.replace(/\/v1\/ai$/, '').replace(/\/ai$/, '') || '/api', [apiBase], diff --git a/packages/app-shell/src/context/CommandPaletteProvider.tsx b/packages/app-shell/src/context/CommandPaletteProvider.tsx index 183ed00b69..bf977124db 100644 --- a/packages/app-shell/src/context/CommandPaletteProvider.tsx +++ b/packages/app-shell/src/context/CommandPaletteProvider.tsx @@ -26,6 +26,19 @@ import { } from 'react'; import { useUrlOverlay } from '../hooks/useUrlOverlay.js'; import { COMMAND_PALETTE_PARAM } from '../urlParams.js'; +import { + useAdvertiseShortcut, + type AdvertisedShortcut, + type ShortcutLabelTranslate, +} from '../chrome/advertisedShortcuts.js'; + +/** `⌘K` / `Ctrl+K` — the keydown handler in {@link CommandPaletteProvider}. */ +export const OPEN_COMMAND_PALETTE_SHORTCUT: AdvertisedShortcut = { + id: 'command-palette', + group: 'general', + chord: { key: 'k', mod: true }, + label: (t: ShortcutLabelTranslate) => t('console.shortcuts.openCommandPalette'), +}; export interface CommandPaletteContextValue { /** Whether the palette is currently open (derived from the URL). */ @@ -61,6 +74,7 @@ export function CommandPaletteProvider({ children }: { children: ReactNode }) { document.addEventListener('keydown', onKeyDown); return () => document.removeEventListener('keydown', onKeyDown); }, [toggleOverlay]); + useAdvertiseShortcut(OPEN_COMMAND_PALETTE_SHORTCUT); const value = useMemo( () => ({ diff --git a/packages/app-shell/src/layout/ConsoleLayout.tsx b/packages/app-shell/src/layout/ConsoleLayout.tsx index 378d289d0d..740777e4b3 100644 --- a/packages/app-shell/src/layout/ConsoleLayout.tsx +++ b/packages/app-shell/src/layout/ConsoleLayout.tsx @@ -33,6 +33,11 @@ import { useResponsiveSidebar } from '../hooks/useResponsiveSidebar.js'; import { useAiSurfaceEnabled } from '../hooks/useAiSurface.js'; import { useNavigationContext } from '../context/NavigationContext.js'; import { CommandPaletteProvider } from '../context/CommandPaletteProvider.js'; +import { + useAdvertiseShortcut, + type AdvertisedShortcut, + type ShortcutLabelTranslate, +} from '../chrome/advertisedShortcuts.js'; import { resolveKeyedI18nLabel } from '../utils/index.js'; import { getProductName } from '../runtime-config.js'; import type { ConnectionState } from '@object-ui/data-objectstack'; @@ -57,9 +62,24 @@ interface ConsoleLayoutProps { userId?: string; } +/** + * `⌘B` / `Ctrl+B` toggles the sidebar. The listener is `SidebarProvider`'s own + * (`@object-ui/components`' synced Shadcn primitive, mounted by `AppShell`): + * it matches `b` with ⌘ or Ctrl, and a bare `B` does nothing. That code is not + * ours to edit, so the shortcut is advertised from inside the provider it + * belongs to, by {@link ConsoleLayoutInner} (objectui#11674). + */ +export const TOGGLE_SIDEBAR_SHORTCUT: AdvertisedShortcut = { + id: 'toggle-sidebar', + group: 'navigation', + chord: { key: 'b', mod: true }, + label: (t: ShortcutLabelTranslate) => t('console.shortcuts.toggleSidebar'), +}; + /** Inner component that can access SidebarProvider context */ function ConsoleLayoutInner({ children }: { children: React.ReactNode }) { useResponsiveSidebar(); + useAdvertiseShortcut(TOGGLE_SIDEBAR_SHORTCUT); return <>{children}; } diff --git a/packages/i18n/src/__tests__/console-namespace-3546.test.tsx b/packages/i18n/src/__tests__/console-namespace-3546.test.tsx index b8b44fbe29..19031e12fa 100644 --- a/packages/i18n/src/__tests__/console-namespace-3546.test.tsx +++ b/packages/i18n/src/__tests__/console-namespace-3546.test.tsx @@ -466,10 +466,9 @@ describe('objectui#3546 slice four — the console namespace', () => { }); it('the ru pack keeps ё, matching its own console neighbours', () => { - // `console.ai.emptyDescription` already writes «о чём угодно» and - // `console.shortcuts.toggleDarkMode` «тёмный режим»: this pack spells ё - // rather than collapsing it to е, and these two strings are where a - // backfill would most easily have dropped it. + // `console.ai.emptyDescription` already writes «о чём угодно»: this pack + // spells ё rather than collapsing it to е, and these two strings are where + // a backfill would most easily have dropped it. const { result } = renderHook(() => useObjectTranslation(), { wrapper: wrapperFor('ru') }); expect(result.current.t('console.ai.planDeferred')).toContain('Ещё'); expect(result.current.t('console.ai.unavailableDescription')).toContain('включён'); diff --git a/packages/i18n/src/__tests__/marketplace-preview-namespace-3546.test.tsx b/packages/i18n/src/__tests__/marketplace-preview-namespace-3546.test.tsx index c466a113cc..7fdcaffe06 100644 --- a/packages/i18n/src/__tests__/marketplace-preview-namespace-3546.test.tsx +++ b/packages/i18n/src/__tests__/marketplace-preview-namespace-3546.test.tsx @@ -869,9 +869,9 @@ describe('objectui#3546 slice five — the marketplace and preview namespaces', }); it('the ru pack keeps ё, matching its own neighbours', () => { - // The pack writes ё in 161 places (`grid.import.undoConfirm` has - // «обновлённые», `console.shortcuts.toggleDarkMode` «тёмный»), and - // "Ещё не проверено" is exactly where a backfill would collapse it to е. + // The pack writes ё throughout (`grid.import.undoConfirm` has + // «обновлённые»), and "Ещё не проверено" is exactly where a backfill + // would collapse it to е. const { result } = renderHook(() => useObjectTranslation(), { wrapper: wrapperFor('ru') }); expect(result.current.t('marketplace.disclosure.unreviewed')).toContain('Ещё'); }); diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index f5ef5387eb..c10a25cfdb 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -2176,21 +2176,14 @@ const ar = { groups: { general: "عام", navigation: "التنقل", - dataViews: "عروض البيانات", aiChat: "مساعد الذكاء الاصطناعي", - preferences: "التفضيلات", }, openCommandPalette: "فتح لوحة الأوامر", showShortcuts: "عرض اختصارات لوحة المفاتيح", closeDialog: "إغلاق النافذة / اللوحة", toggleSidebar: "تبديل الشريط الجانبي", - focusSearch: "التركيز على البحث", - createRecord: "إنشاء سجل جديد", - refreshData: "تحديث البيانات", - editRecord: "تعديل السجل المحدد", newChat: "محادثة جديدة", toggleChatsList: "تبديل قائمة المحادثات", - toggleDarkMode: "تبديل الوضع الداكن", }, commandPalette: { title: "لوحة الأوامر", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 7dcebf84ea..952538eaec 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -1987,21 +1987,14 @@ const de = { groups: { general: "Allgemein", navigation: "Navigation", - dataViews: "Datenansichten", aiChat: "KI-Assistent", - preferences: "Einstellungen", }, openCommandPalette: "Befehlspalette öffnen", showShortcuts: "Tastenkürzel anzeigen", closeDialog: "Dialog / Panel schließen", toggleSidebar: "Seitenleiste umschalten", - focusSearch: "Suche fokussieren", - createRecord: "Neuen Datensatz erstellen", - refreshData: "Daten aktualisieren", - editRecord: "Ausgewählten Datensatz bearbeiten", newChat: "Neuer Chat", toggleChatsList: "Chatliste umschalten", - toggleDarkMode: "Dunkelmodus umschalten", }, commandPalette: { title: "Befehlspalette", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index e07cfe946b..a6f09cf0fb 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -2081,21 +2081,14 @@ const en = { groups: { general: 'General', navigation: 'Navigation', - dataViews: 'Data Views', aiChat: 'AI assistant', - preferences: 'Preferences', }, openCommandPalette: 'Open command palette', showShortcuts: 'Show keyboard shortcuts', closeDialog: 'Close dialog / panel', toggleSidebar: 'Toggle sidebar', - focusSearch: 'Focus search', - createRecord: 'Create new record', - refreshData: 'Refresh data', - editRecord: 'Edit selected record', newChat: 'New chat', toggleChatsList: 'Toggle conversations list', - toggleDarkMode: 'Toggle dark mode', }, commandPalette: { title: 'Command palette', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index a04d465a67..5a1c746ba9 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -2034,21 +2034,14 @@ const es = { groups: { general: "General", navigation: "Navegación", - dataViews: "Vistas de datos", aiChat: "Asistente de IA", - preferences: "Preferencias", }, openCommandPalette: "Abrir paleta de comandos", showShortcuts: "Mostrar atajos de teclado", closeDialog: "Cerrar diálogo / panel", toggleSidebar: "Alternar barra lateral", - focusSearch: "Enfocar búsqueda", - createRecord: "Crear nuevo registro", - refreshData: "Actualizar datos", - editRecord: "Editar registro seleccionado", newChat: "Nuevo chat", toggleChatsList: "Alternar lista de chats", - toggleDarkMode: "Alternar modo oscuro", }, commandPalette: { title: "Paleta de comandos", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 880f14abbc..c349239150 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -2031,21 +2031,14 @@ const fr = { groups: { general: "Général", navigation: "Navigation", - dataViews: "Vues de données", aiChat: "Assistant IA", - preferences: "Préférences", }, openCommandPalette: "Ouvrir la palette de commandes", showShortcuts: "Afficher les raccourcis clavier", closeDialog: "Fermer le dialogue / panneau", toggleSidebar: "Basculer la barre latérale", - focusSearch: "Focus sur la recherche", - createRecord: "Créer un nouvel enregistrement", - refreshData: "Actualiser les données", - editRecord: "Modifier l'enregistrement sélectionné", newChat: "Nouvelle discussion", toggleChatsList: "Basculer la liste des discussions", - toggleDarkMode: "Basculer le mode sombre", }, commandPalette: { title: "Palette de commandes", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 11e2aebd74..0cb090354c 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -1989,21 +1989,14 @@ const ja = { groups: { general: "一般", navigation: "ナビゲーション", - dataViews: "データビュー", aiChat: "AI アシスタント", - preferences: "設定", }, openCommandPalette: "コマンドパレットを開く", showShortcuts: "キーボードショートカットを表示", closeDialog: "ダイアログ / パネルを閉じる", toggleSidebar: "サイドバーを切り替え", - focusSearch: "検索にフォーカス", - createRecord: "新しいレコードを作成", - refreshData: "データを更新", - editRecord: "選択したレコードを編集", newChat: "新しいチャット", toggleChatsList: "チャット一覧を切り替え", - toggleDarkMode: "ダークモードを切り替え", }, commandPalette: { title: "コマンドパレット", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 6d30546f1c..22f408913a 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -1987,21 +1987,14 @@ const ko = { groups: { general: "일반", navigation: "탐색", - dataViews: "데이터 뷰", aiChat: "AI 어시스턴트", - preferences: "환경설정", }, openCommandPalette: "명령 팔레트 열기", showShortcuts: "키보드 단축키 표시", closeDialog: "대화상자 / 패널 닫기", toggleSidebar: "사이드바 전환", - focusSearch: "검색에 포커스", - createRecord: "새 레코드 생성", - refreshData: "데이터 새로고침", - editRecord: "선택한 레코드 편집", newChat: "새 채팅", toggleChatsList: "채팅 목록 전환", - toggleDarkMode: "다크 모드 전환", }, commandPalette: { title: "명령 팔레트", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 7a1b9f6a5e..36558e1584 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -2029,21 +2029,14 @@ const pt = { groups: { general: "Geral", navigation: "Navegação", - dataViews: "Visualizações de dados", aiChat: "Assistente de IA", - preferences: "Preferências", }, openCommandPalette: "Abrir paleta de comandos", showShortcuts: "Mostrar atalhos de teclado", closeDialog: "Fechar diálogo / painel", toggleSidebar: "Alternar barra lateral", - focusSearch: "Focar na pesquisa", - createRecord: "Criar novo registro", - refreshData: "Atualizar dados", - editRecord: "Editar registro selecionado", newChat: "Nova conversa", toggleChatsList: "Alternar lista de conversas", - toggleDarkMode: "Alternar modo escuro", }, commandPalette: { title: "Paleta de comandos", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 00e9d8a946..85d44980cc 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -2090,21 +2090,14 @@ const ru = { groups: { general: "Общие", navigation: "Навигация", - dataViews: "Представления данных", aiChat: "Ассистент ИИ", - preferences: "Настройки", }, openCommandPalette: "Открыть палитру команд", showShortcuts: "Показать горячие клавиши", closeDialog: "Закрыть диалог / панель", toggleSidebar: "Переключить боковую панель", - focusSearch: "Фокус на поиске", - createRecord: "Создать новую запись", - refreshData: "Обновить данные", - editRecord: "Редактировать выбранную запись", newChat: "Новый чат", toggleChatsList: "Переключить список чатов", - toggleDarkMode: "Переключить тёмный режим", }, commandPalette: { title: "Палитра команд", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index bdc779deff..9b7ced8081 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -1785,21 +1785,14 @@ const zh = { groups: { general: '通用', navigation: '导航', - dataViews: '数据视图', aiChat: 'AI 助手', - preferences: '偏好设置', }, openCommandPalette: '打开命令面板', showShortcuts: '显示键盘快捷键', closeDialog: '关闭对话框/面板', toggleSidebar: '切换侧边栏', - focusSearch: '聚焦搜索', - createRecord: '新建记录', - refreshData: '刷新数据', - editRecord: '编辑选中记录', newChat: '新建对话', toggleChatsList: '切换对话列表', - toggleDarkMode: '切换深色模式', }, commandPalette: { title: '命令面板', From 0f63ec2742722f0c48f9d95d98a4d63671773746 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 08:04:25 +0000 Subject: [PATCH 2/5] test(app-shell): clear the sidebar state cookie between shortcut probes (objectui#11674) Claude-Session: https://claude.ai/code/session_01FngvPpdrnhHMdHHq6vwwju Co-authored-by: Claude --- .../KeyboardShortcutsDialog.wiredOnly-11674.test.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/app-shell/src/chrome/KeyboardShortcutsDialog.wiredOnly-11674.test.tsx b/packages/app-shell/src/chrome/KeyboardShortcutsDialog.wiredOnly-11674.test.tsx index 7bb631d87c..96fcc145cf 100644 --- a/packages/app-shell/src/chrome/KeyboardShortcutsDialog.wiredOnly-11674.test.tsx +++ b/packages/app-shell/src/chrome/KeyboardShortcutsDialog.wiredOnly-11674.test.tsx @@ -132,6 +132,11 @@ function chordEvents(chord: ShortcutChord): KeyboardEventInit[] { } const probe = () => screen.getByTestId('probe'); +// `SidebarProvider` persists its state in this cookie and reads it on mount, +// so one toggle would decide the next mount's starting state. +const clearSidebarCookie = () => { + document.cookie = 'sidebar_state=; path=/; max-age=0'; +}; const dialogShown = () => screen.queryByTestId('overlay:keyboard-shortcuts') !== null; /** @@ -158,6 +163,7 @@ const FIRES: Record Promise> = { await waitFor(() => expect(dialogShown()).toBe(false)); }, 'toggle-sidebar': async (event) => { + clearSidebarCookie(); mountConsole('/apps/crm'); expect(probe()).toHaveAttribute('data-sidebar-open', 'true'); fireEvent.keyDown(document.body, event); @@ -165,7 +171,10 @@ const FIRES: Record Promise> = { }, }; -afterEach(() => cleanup()); +afterEach(() => { + cleanup(); + clearSidebarCookie(); +}); describe('the keyboard-shortcuts dialog lists only wired shortcuts (objectui#11674)', () => { it('every row the console dialog lists fires its real handler, with ⌘ and with Ctrl', async () => { From 4774ed0c845f05b5536d99fc67c0c0efb95480be Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 08:05:52 +0000 Subject: [PATCH 3/5] docs(app-shell): changeset and README for the advertised-shortcuts dialog (objectui#11674) Claude-Session: https://claude.ai/code/session_01FngvPpdrnhHMdHHq6vwwju Co-authored-by: Claude --- .changeset/11674-shortcuts-dialog-wired-only.md | 14 ++++++++++++++ packages/app-shell/README.md | 13 +++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 .changeset/11674-shortcuts-dialog-wired-only.md diff --git a/.changeset/11674-shortcuts-dialog-wired-only.md b/.changeset/11674-shortcuts-dialog-wired-only.md new file mode 100644 index 0000000000..c55ad8b181 --- /dev/null +++ b/.changeset/11674-shortcuts-dialog-wired-only.md @@ -0,0 +1,14 @@ +--- +'@object-ui/app-shell': minor +'@object-ui/i18n': minor +--- + +The keyboard-shortcuts dialog (`?`) lists only shortcuts that do something (objectui#11674). + +**Clause-②: no (narrowing)**: no accept set changes; the dialog shows fewer rows, and seven unused `console.shortcuts.*` keys leave all ten locale packs. + +The dialog was a static list, separate from every key handler, and six of its rows did nothing. `N` (create record), `R` (refresh data), `⌘/Ctrl+E` (edit record), `⌘/Ctrl+/` (focus search) and `⌘/Ctrl+D` (dark mode) had no handler anywhere, and the sidebar row said `B` while the sidebar toggles on `⌘/Ctrl+B`. The AI-assistant rows (`⌘/Ctrl+Shift+O`, `⌘/Ctrl+Shift+S`) were listed inside apps, where their only handler, on the AI chat page, is not mounted. + +`@object-ui/app-shell`: each shortcut is now advertised beside its handler, for as long as that handler is mounted, and `KeyboardShortcutsDialog` lists what is advertised. Inside an app it lists `⌘K` (command palette), `?` (this dialog), `Esc` (close a dialog or panel) and `⌘B` (toggle the sidebar). The AI chat page advertises its own two shortcuts, so they are listed only where that page is mounted. Rows are grouped as before and sorted by their text. A host that mounts `KeyboardShortcutsDialog` outside the console layout now sees the shortcuts whose handlers it mounts, not a fixed list. + +`@object-ui/i18n` (BREAKING; `minor` under this repository's release model, where objectui's major follows the `@objectstack` major): `console.shortcuts.focusSearch`, `createRecord`, `refreshData`, `editRecord`, `toggleDarkMode`, `groups.dataViews` and `groups.preferences` are removed from all ten packs. Nothing reads them now. A host that calls `t()` with one of them gets the key back; supply the string from your own resources if you still need it. diff --git a/packages/app-shell/README.md b/packages/app-shell/README.md index db3e8000a8..80240f3ed1 100644 --- a/packages/app-shell/README.md +++ b/packages/app-shell/README.md @@ -974,6 +974,19 @@ a `data-testid` onto their content element and emit Radix `data-state="open|clos so overlays are locatable and their open/closed state is machine-readable by construction (C4). +## Keyboard-shortcuts dialog + +`KeyboardShortcutsDialog` (`?`, or `?shortcuts=1`) has no list of its own. Each +shortcut is advertised by the code that handles it, beside the handler and for as +long as the handler is mounted, and the dialog lists what is advertised at that +moment (objectui#11674). Inside an app that is the command palette (`⌘K`), the +dialog itself (`?`), closing a dialog or panel (`Esc`) and the sidebar (`⌘B`, the +`SidebarProvider` listener). The AI chat page advertises `⌘⇧O` and `⌘⇧S` itself, +so they are listed only where that page is mounted. A shortcut without a mounted +handler is never listed. Each listed row carries `data-shortcut-id`, and +`KeyboardShortcutsDialog.wiredOnly-11674.test.tsx` fires every row the console +lists against its real handler. + ## Settle signal (is the app idle?) `` exposes one global "no requests in flight" predicate so an From 96a042d929e19b7617f10c34f6b607ed3eacab9b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 08:07:53 +0000 Subject: [PATCH 4/5] refactor(app-shell): keep in-file shortcut advertisements module-local; read the registry in the pin through the DOM (objectui#11674) Claude-Session: https://claude.ai/code/session_01FngvPpdrnhHMdHHq6vwwju Co-authored-by: Claude --- .../src/chrome/KeyboardShortcutsDialog.tsx | 4 +-- ...rdShortcutsDialog.wiredOnly-11674.test.tsx | 26 ++++++++++++------- .../src/context/CommandPaletteProvider.tsx | 2 +- .../app-shell/src/layout/ConsoleLayout.tsx | 2 +- 4 files changed, 20 insertions(+), 14 deletions(-) diff --git a/packages/app-shell/src/chrome/KeyboardShortcutsDialog.tsx b/packages/app-shell/src/chrome/KeyboardShortcutsDialog.tsx index 7289def222..703c9c16e7 100644 --- a/packages/app-shell/src/chrome/KeyboardShortcutsDialog.tsx +++ b/packages/app-shell/src/chrome/KeyboardShortcutsDialog.tsx @@ -32,7 +32,7 @@ import { } from './advertisedShortcuts.js'; /** `?` — the handler is this component's own keydown listener below. */ -export const SHOW_SHORTCUTS_SHORTCUT: AdvertisedShortcut = { +const SHOW_SHORTCUTS_SHORTCUT: AdvertisedShortcut = { id: 'shortcuts-help', group: 'general', chord: { key: '?' }, @@ -44,7 +44,7 @@ export const SHOW_SHORTCUTS_SHORTCUT: AdvertisedShortcut = { * other dialog and panel in the shell carry. Advertised here, beside the * `Dialog` it closes. */ -export const CLOSE_OVERLAY_SHORTCUT: AdvertisedShortcut = { +const CLOSE_OVERLAY_SHORTCUT: AdvertisedShortcut = { id: 'close-overlay', group: 'general', chord: { key: 'Escape' }, diff --git a/packages/app-shell/src/chrome/KeyboardShortcutsDialog.wiredOnly-11674.test.tsx b/packages/app-shell/src/chrome/KeyboardShortcutsDialog.wiredOnly-11674.test.tsx index 96fcc145cf..18e1948bdb 100644 --- a/packages/app-shell/src/chrome/KeyboardShortcutsDialog.wiredOnly-11674.test.tsx +++ b/packages/app-shell/src/chrome/KeyboardShortcutsDialog.wiredOnly-11674.test.tsx @@ -105,20 +105,26 @@ function Probe() { ); } -function Capture({ into }: { into: { current: readonly AdvertisedShortcut[] } }) { - into.current = useAdvertisedShortcuts(); - return null; +/** The registry's own reading, beside the rows the dialog drew from it. */ +function AdvertisedReading() { + const advertised = useAdvertisedShortcuts(); + const reading = advertised.map(({ id, chord }) => ({ id, chord })); + return ; +} + +function readAdvertised(): Array<{ id: string; chord: ShortcutChord }> { + return JSON.parse(screen.getByTestId('advertised').dataset.reading ?? '[]'); } /** The console chrome as `AppContent` composes it, with the dialog inside. */ -function mountConsole(path: string, capture?: { current: readonly AdvertisedShortcut[] }) { +function mountConsole(path: string) { return render( - {capture ? : null} + , @@ -178,21 +184,21 @@ afterEach(() => { describe('the keyboard-shortcuts dialog lists only wired shortcuts (objectui#11674)', () => { it('every row the console dialog lists fires its real handler, with ⌘ and with Ctrl', async () => { - const advertised = { current: [] as readonly AdvertisedShortcut[] }; - mountConsole('/apps/crm?shortcuts=1', advertised); + mountConsole('/apps/crm?shortcuts=1'); + const advertised = readAdvertised(); const rows = Array.from(document.querySelectorAll('[data-shortcut-id]')); const listed = rows.map((row) => row.dataset.shortcutId as string); // Rows are the advertisements, keycaps included — nothing else. - expect([...listed].sort()).toEqual(advertised.current.map((s) => s.id).sort()); + expect([...listed].sort()).toEqual(advertised.map((s) => s.id).sort()); for (const row of rows) { - const shortcut = advertised.current.find((s) => s.id === row.dataset.shortcutId)!; + const shortcut = advertised.find((s) => s.id === row.dataset.shortcutId)!; const caps = Array.from(row.querySelectorAll('kbd'), (kbd) => kbd.textContent); expect(caps, row.dataset.shortcutId).toEqual(shortcutKeycaps(shortcut.chord)); } // Every listed row has a probe, and every probe still has a row. expect([...listed].sort()).toEqual(Object.keys(FIRES).sort()); - const chords = new Map(advertised.current.map((s) => [s.id, s.chord])); + const chords = new Map(advertised.map((s) => [s.id, s.chord])); cleanup(); for (const id of listed) { diff --git a/packages/app-shell/src/context/CommandPaletteProvider.tsx b/packages/app-shell/src/context/CommandPaletteProvider.tsx index bf977124db..76460db601 100644 --- a/packages/app-shell/src/context/CommandPaletteProvider.tsx +++ b/packages/app-shell/src/context/CommandPaletteProvider.tsx @@ -33,7 +33,7 @@ import { } from '../chrome/advertisedShortcuts.js'; /** `⌘K` / `Ctrl+K` — the keydown handler in {@link CommandPaletteProvider}. */ -export const OPEN_COMMAND_PALETTE_SHORTCUT: AdvertisedShortcut = { +const OPEN_COMMAND_PALETTE_SHORTCUT: AdvertisedShortcut = { id: 'command-palette', group: 'general', chord: { key: 'k', mod: true }, diff --git a/packages/app-shell/src/layout/ConsoleLayout.tsx b/packages/app-shell/src/layout/ConsoleLayout.tsx index 740777e4b3..de7e99c048 100644 --- a/packages/app-shell/src/layout/ConsoleLayout.tsx +++ b/packages/app-shell/src/layout/ConsoleLayout.tsx @@ -69,7 +69,7 @@ interface ConsoleLayoutProps { * ours to edit, so the shortcut is advertised from inside the provider it * belongs to, by {@link ConsoleLayoutInner} (objectui#11674). */ -export const TOGGLE_SIDEBAR_SHORTCUT: AdvertisedShortcut = { +const TOGGLE_SIDEBAR_SHORTCUT: AdvertisedShortcut = { id: 'toggle-sidebar', group: 'navigation', chord: { key: 'b', mod: true }, From 05c1f485ad4973b33e8a26f2baf549e90b7399a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 08:21:26 +0000 Subject: [PATCH 5/5] refactor(app-shell): the shortcut label translator takes a key only (objectui#11674) Claude-Session: https://claude.ai/code/session_01FngvPpdrnhHMdHHq6vwwju Co-authored-by: Claude --- packages/app-shell/src/chrome/KeyboardShortcutsDialog.tsx | 2 +- packages/app-shell/src/chrome/advertisedShortcuts.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app-shell/src/chrome/KeyboardShortcutsDialog.tsx b/packages/app-shell/src/chrome/KeyboardShortcutsDialog.tsx index 703c9c16e7..f766b28459 100644 --- a/packages/app-shell/src/chrome/KeyboardShortcutsDialog.tsx +++ b/packages/app-shell/src/chrome/KeyboardShortcutsDialog.tsx @@ -87,7 +87,7 @@ export function KeyboardShortcutsDialog() { // Sections in `SHORTCUT_GROUPS` order, rows alphabetical by their text: the // order handlers mount in is not an order a reader should see. - const translate: ShortcutLabelTranslate = (key, options) => String(t(key, options)); + const translate: ShortcutLabelTranslate = (key) => String(t(key)); const sections: ShortcutSection[] = SHORTCUT_GROUPS.map((group) => ({ group, title: groupTitle(group, translate), diff --git a/packages/app-shell/src/chrome/advertisedShortcuts.ts b/packages/app-shell/src/chrome/advertisedShortcuts.ts index 980fb5948b..3076244101 100644 --- a/packages/app-shell/src/chrome/advertisedShortcuts.ts +++ b/packages/app-shell/src/chrome/advertisedShortcuts.ts @@ -53,7 +53,7 @@ export interface ShortcutChord { } /** The translator the dialog hands to {@link AdvertisedShortcut.label}. */ -export type ShortcutLabelTranslate = (key: string, options?: Record) => string; +export type ShortcutLabelTranslate = (key: string) => string; export interface AdvertisedShortcut { /** Stable identity: one row per id however many holders advertise it. */