diff --git a/apps/electron/src/renderer/actions/action-i18n.ts b/apps/electron/src/renderer/actions/action-i18n.ts index 7edfd681b..8f69ea005 100644 --- a/apps/electron/src/renderer/actions/action-i18n.ts +++ b/apps/electron/src/renderer/actions/action-i18n.ts @@ -36,6 +36,7 @@ export const ACTION_LABEL_KEYS: Partial> = { 'panel.focusPrev': 'shortcuts.action.focusPrevPanel', 'chat.stopProcessing': 'shortcuts.action.stopProcessing', 'chat.cyclePermissionMode': 'shortcuts.action.cyclePermissionMode', + 'chat.openThinkingMenu': 'shortcuts.action.openThinkingMenu', 'chat.nextSearchMatch': 'shortcuts.action.nextSearchMatch', 'chat.prevSearchMatch': 'shortcuts.action.prevSearchMatch', } diff --git a/apps/electron/src/renderer/actions/definitions.ts b/apps/electron/src/renderer/actions/definitions.ts index 82ecf3521..f0fd6b792 100644 --- a/apps/electron/src/renderer/actions/definitions.ts +++ b/apps/electron/src/renderer/actions/definitions.ts @@ -202,6 +202,13 @@ export const actions = { category: 'Chat', when: '!inputFocus && !menuOpen', }, + 'chat.openThinkingMenu': { + id: 'chat.openThinkingMenu', + label: 'Open Thinking Menu', + description: 'Open the thinking (reasoning effort) menu in the chat composer', + defaultHotkey: 'mod+shift+e', + category: 'Chat', + }, 'chat.nextSearchMatch': { id: 'chat.nextSearchMatch', label: 'Next Search Match', diff --git a/apps/electron/src/renderer/components/app-shell/AppShell.tsx b/apps/electron/src/renderer/components/app-shell/AppShell.tsx index f76515b6d..dc174dae4 100644 --- a/apps/electron/src/renderer/components/app-shell/AppShell.tsx +++ b/apps/electron/src/renderer/components/app-shell/AppShell.tsx @@ -222,6 +222,7 @@ import { hasOpenOverlay } from '@/lib/overlay-detection' import { getNextPermissionMode } from '@/lib/permission-mode-cycle' import { clearSourceIconCaches } from '@/lib/icon-cache' import { dispatchFocusInputEvent } from './input/focus-input-events' +import { dispatchOpenComposerMenuEvent } from './input/composer-menu-events' import { resolveEffectiveConnectionSlug } from '@config/llm-connections' import { getWorkspaceDisplayName } from '@/utils/workspace' @@ -1820,6 +1821,12 @@ function AppShellContent({ useAction('nav.goBackAlt', goBack) useAction('nav.goForwardAlt', goForward) + // Open the composer's thinking (reasoning effort) menu (CMD+SHIFT+E). + // Targets the focused panel's composer via the scoped composer-menu event. + useAction('chat.openThinkingMenu', () => + dispatchOpenComposerMenuEvent({ menu: 'thinking' }), + ) + // Search match navigation (CMD+G next, CMD+SHIFT+G prev) useAction( 'chat.nextSearchMatch', diff --git a/apps/electron/src/renderer/components/app-shell/input/FreeFormInput.tsx b/apps/electron/src/renderer/components/app-shell/input/FreeFormInput.tsx index 37dd22440..8761b1e2c 100644 --- a/apps/electron/src/renderer/components/app-shell/input/FreeFormInput.tsx +++ b/apps/electron/src/renderer/components/app-shell/input/FreeFormInput.tsx @@ -102,6 +102,10 @@ import { clearPendingFocusForSession, consumePendingFocusForSession, } from './focus-input-events'; +import { + COMPOSER_MENU_EVENT, + type ComposerMenuEventDetail, +} from './composer-menu-events'; import { getRecentWorkingDirs, addRecentWorkingDir, @@ -1291,6 +1295,33 @@ export function FreeFormInput({ window.removeEventListener('craft:focus-input', handleFocusInput); }, [sessionId, isFocusedPanel, richInputRef]); + // Listen for craft:open-composer-menu events (open a toolbar menu from a + // global keyboard shortcut). Scoped like focus-input so the focused panel's + // composer responds. + React.useEffect(() => { + const handleOpenMenu = (e: Event) => { + const detail = (e as CustomEvent).detail; + if ( + !shouldHandleScopedInputEvent({ + sessionId, + isFocusedPanel, + targetSessionId: detail?.sessionId, + }) + ) + return; + + if (detail?.menu === 'thinking') { + // The thinking picker only renders in the full (non-compact) toolbar. + if (compactMode || !onThinkingLevelChange) return; + setThinkingDropdownOpen(true); + } + }; + + window.addEventListener(COMPOSER_MENU_EVENT, handleOpenMenu); + return () => + window.removeEventListener(COMPOSER_MENU_EVENT, handleOpenMenu); + }, [sessionId, isFocusedPanel, compactMode, onThinkingLevelChange]); + // Recover queued focus requests after session switch/mount races. React.useEffect(() => { if (!consumePendingFocusForSession(sessionId)) return; diff --git a/apps/electron/src/renderer/components/app-shell/input/composer-menu-events.ts b/apps/electron/src/renderer/components/app-shell/input/composer-menu-events.ts new file mode 100644 index 000000000..d03c0e1ef --- /dev/null +++ b/apps/electron/src/renderer/components/app-shell/input/composer-menu-events.ts @@ -0,0 +1,32 @@ +/** + * Cross-component bridge for opening a composer toolbar menu from a global + * keyboard shortcut. + * + * The action registry lives at the app shell (it owns the global hotkey + * listener), while the composer's toolbar dropdowns (thinking / model) own + * their controlled `open` state inside `FreeFormInput`. This event lets the + * shell ask the focused composer to open one of its menus, mirroring the + * scoping model of `focus-input-events.ts` so multi-panel layouts target the + * right composer. + */ + +/** Composer toolbar menus that can be opened via a shortcut. */ +export type ComposerMenu = 'thinking' + +export interface ComposerMenuEventDetail { + /** Restrict to a specific session's composer; omit to target the focused panel. */ + sessionId?: string + menu: ComposerMenu +} + +export const COMPOSER_MENU_EVENT = 'craft:open-composer-menu' + +/** + * Ask a composer to open one of its toolbar menus. With no `sessionId`, the + * focused panel's composer handles it (see `shouldHandleScopedInputEvent`). + */ +export function dispatchOpenComposerMenuEvent(detail: ComposerMenuEventDetail): void { + window.dispatchEvent( + new CustomEvent(COMPOSER_MENU_EVENT, { detail }), + ) +} diff --git a/docs/loop/feature-ledger.md b/docs/loop/feature-ledger.md index 8533dd40f..ca77ffcf9 100644 --- a/docs/loop/feature-ledger.md +++ b/docs/loop/feature-ledger.md @@ -33,6 +33,7 @@ log, not the system of record. | slug | title | source | feasibility | status | issue | pr | branch | updated | notes | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| thinking-menu-shortcut | Keyboard shortcut (⌘⇧E) to open the composer's thinking (effort) menu | Claude Code Desktop effort-menu shortcut (⌘⇧E); explicit follow-up flagged in #44 | frontend-only | pr-open | [#54](https://github.com/modelstudioai/openwork/issues/54) | [#55](https://github.com/modelstudioai/openwork/pull/55) | loop/thinking-menu-shortcut | 2026-07-04 | New `chat.openThinkingMenu` action (`mod+shift+e`) → scoped `craft:open-composer-menu` event → `FreeFormInput` opens its already-controlled `thinkingDropdownOpen`. Mirrors `craft:focus-input`/`shouldHandleScopedInputEvent` for multi-panel safety. Auto-appears in Command Palette + Shortcuts reference. 1 new i18n key across 7 locales. Avoids ⌘⇧I (reserved for DevTools) / ⌘⇧M (permission mode already has Shift+Tab). typecheck zero-delta (11 pre-existing); `bun test` zero-delta (56 identical failures, diffed vs main); i18n parity OK; renderer build ✅; assertion transpiles ✅. **CDP blocked locally** (same egress limit: `libsignal` GitHub dep + Electron binary 403). | | prompt-history-recall | Recall previously-sent prompts in the composer with Up/Down arrows | Claude Code CLI `↑` history + Codex desktop "recover your previous prompt by pressing the up arrow" | frontend-only | pr-open | [#52](https://github.com/modelstudioai/openwork/issues/52) | [#53](https://github.com/modelstudioai/openwork/pull/53) | loop/composer-prompt-history | 2026-07-04 | New pure module `prompt-history.ts` (push/prev/next/entry-at) + global `localStorage['craft-prompt-history']`. Wired into `FreeFormInput.handleKeyDown`: Up recalls when composer empty/recalling, Down walks newer & restores draft, edit/submit/session-switch exit recall. Zero new i18n keys. `data-testid="composer-input"` added for e2e. **DoD:** typecheck:all +0 vs main (11 pre-existing electron errors only); packages/ui clean; `bun test` failure set byte-identical to main (56 pre-existing, verified by stash-diff) + 20 new passing unit tests; renderer build ✅; assertion transpiles. **CDP could not run locally** (egress 403s the Electron binary download, same as prior loop PRs) — assertion included for CI/reviewer. | | reduce-motion | "Reduce motion" accessibility setting in Appearance | Claude desktop / macOS / Windows "Reduce motion" + `prefers-reduced-motion` | frontend-only | pr-open | [#50](https://github.com/modelstudioai/openwork/issues/50) | [#51](https://github.com/modelstudioai/openwork/pull/51) | (loop) | 2026-07-03 | Reconciled from GitHub: PR open, awaiting review. Not re-selectable. | | composer-expand | Expand / collapse (maximize) toggle for the chat composer | Claude desktop / ChatGPT desktop / Codex desktop composer maximize | frontend-only | pr-open | [#48](https://github.com/modelstudioai/openwork/issues/48) | [#49](https://github.com/modelstudioai/openwork/pull/49) | (loop) | 2026-07-03 | Reconciled from GitHub: PR open, awaiting review. Not re-selectable. | diff --git a/e2e/assertions/thinking-menu-shortcut.assert.ts b/e2e/assertions/thinking-menu-shortcut.assert.ts new file mode 100644 index 000000000..98f2e7e58 --- /dev/null +++ b/e2e/assertions/thinking-menu-shortcut.assert.ts @@ -0,0 +1,93 @@ +/** + * Feature assertion: the keyboard shortcut that opens the composer's thinking + * (reasoning effort) menu. + * + * Mirrors Claude Code Desktop's effort-menu shortcut (⌘⇧E). Drives the real + * built app over CDP: + * composer renders a thinking-level trigger with its menu CLOSED → dispatch + * the Cmd/Ctrl+Shift+E keydown at the window level → the thinking menu OPENS + * (lists all six levels). + * + * Asserting the menu is closed *before* the keypress and open *after* it proves + * the shortcut actually opens the menu — not merely that the menu can render. + */ + +import type { Assertion } from '../runner'; + +const TRIGGER = '[data-testid="thinking-level-trigger"]'; +const ITEM = '[data-testid="thinking-level-item"]'; + +/** The six thinking levels are the single source of truth for the count. */ +const EXPECTED_LEVEL_COUNT = 6; + +/** Menu items that are actually visible (Radix renders them in a portal). */ +const VISIBLE_ITEMS_EXPR = `[...document.querySelectorAll(${JSON.stringify( + ITEM, +)})].filter((el) => el.offsetParent !== null)`; + +/** + * Dispatch the effort-menu shortcut at the window level, where the action + * registry's capture-phase keydown listener lives. `mod` resolves to Cmd on + * macOS and Ctrl elsewhere; set both `metaKey` and `ctrlKey` so the same event + * matches on either platform (the registry only checks the platform's mod key). + */ +const PRESS_SHORTCUT_EXPR = `(() => { + window.dispatchEvent(new KeyboardEvent('keydown', { + key: 'e', + code: 'KeyE', + ctrlKey: true, + metaKey: true, + shiftKey: true, + bubbles: true, + cancelable: true, + })); + return true; +})()`; + +const assertion: Assertion = { + name: 'Cmd/Ctrl+Shift+E opens the composer thinking-level menu', + async run(app) { + const { session } = app; + + // App fully mounted. + await session.waitForFunction( + '!document.getElementById("_loader") && (document.getElementById("root")?.childElementCount ?? 0) > 0', + { timeoutMs: 30000, message: 'React UI did not mount' }, + ); + + // Reach the ready AppShell (not onboarding / workspace picker) — the same + // stable, non-localized anchor the other composer assertions wait on. + await session.waitForSelector('[aria-label="Craft menu"]', { + timeoutMs: 30000, + message: 'app did not reach the ready AppShell state', + }); + + // The composer renders a thinking-level trigger. + await session.waitForSelector(TRIGGER, { + timeoutMs: 20000, + message: 'thinking-level picker trigger did not render in the composer', + }); + + // Precondition: the menu is closed (no visible items). + if ((await session.evaluate(`${VISIBLE_ITEMS_EXPR}.length`)) !== 0) { + throw new Error('thinking-level menu was already open before pressing the shortcut'); + } + + // Press the shortcut at the window level. + if (!(await session.evaluate(PRESS_SHORTCUT_EXPR))) { + throw new Error('failed to dispatch the Cmd/Ctrl+Shift+E keydown'); + } + + // The menu opens and lists all six thinking levels — proving the shortcut + // opened it. + await session.waitForFunction( + `${VISIBLE_ITEMS_EXPR}.length === ${EXPECTED_LEVEL_COUNT}`, + { + timeoutMs: 8000, + message: `thinking-level menu did not open (expected ${EXPECTED_LEVEL_COUNT} levels) after pressing Cmd/Ctrl+Shift+E`, + }, + ); + }, +}; + +export default assertion; diff --git a/packages/shared/src/i18n/locales/de.json b/packages/shared/src/i18n/locales/de.json index 7293edbea..b50908787 100644 --- a/packages/shared/src/i18n/locales/de.json +++ b/packages/shared/src/i18n/locales/de.json @@ -1173,6 +1173,7 @@ "shortcuts.action.newChatInPanel": "Neuer Chat im Panel", "shortcuts.action.newWindow": "Neues Fenster", "shortcuts.action.nextSearchMatch": "Nächster Treffer", + "shortcuts.action.openThinkingMenu": "Denk-Menü öffnen", "shortcuts.action.prevSearchMatch": "Vorheriger Treffer", "shortcuts.action.quit": "Beenden", "shortcuts.action.search": "Suchen", diff --git a/packages/shared/src/i18n/locales/en.json b/packages/shared/src/i18n/locales/en.json index a25879e84..bf574f915 100644 --- a/packages/shared/src/i18n/locales/en.json +++ b/packages/shared/src/i18n/locales/en.json @@ -1173,6 +1173,7 @@ "shortcuts.action.newChatInPanel": "New Chat in Panel", "shortcuts.action.newWindow": "New Window", "shortcuts.action.nextSearchMatch": "Next Search Match", + "shortcuts.action.openThinkingMenu": "Open Thinking Menu", "shortcuts.action.prevSearchMatch": "Previous Search Match", "shortcuts.action.quit": "Quit", "shortcuts.action.search": "Search", diff --git a/packages/shared/src/i18n/locales/es.json b/packages/shared/src/i18n/locales/es.json index ae802bd4e..6e03a8dfc 100644 --- a/packages/shared/src/i18n/locales/es.json +++ b/packages/shared/src/i18n/locales/es.json @@ -1173,6 +1173,7 @@ "shortcuts.action.newChatInPanel": "Nuevo chat en panel", "shortcuts.action.newWindow": "Nueva ventana", "shortcuts.action.nextSearchMatch": "Siguiente coincidencia", + "shortcuts.action.openThinkingMenu": "Abrir menú de razonamiento", "shortcuts.action.prevSearchMatch": "Coincidencia anterior", "shortcuts.action.quit": "Salir", "shortcuts.action.search": "Buscar", diff --git a/packages/shared/src/i18n/locales/hu.json b/packages/shared/src/i18n/locales/hu.json index e9480d987..d2fa1a894 100644 --- a/packages/shared/src/i18n/locales/hu.json +++ b/packages/shared/src/i18n/locales/hu.json @@ -1173,6 +1173,7 @@ "shortcuts.action.newChatInPanel": "Új chat a panelben", "shortcuts.action.newWindow": "Új ablak", "shortcuts.action.nextSearchMatch": "Következő találat", + "shortcuts.action.openThinkingMenu": "Gondolkodás menü megnyitása", "shortcuts.action.prevSearchMatch": "Előző találat", "shortcuts.action.quit": "Kilépés", "shortcuts.action.search": "Keresés", diff --git a/packages/shared/src/i18n/locales/ja.json b/packages/shared/src/i18n/locales/ja.json index 156d290b1..675a173f6 100644 --- a/packages/shared/src/i18n/locales/ja.json +++ b/packages/shared/src/i18n/locales/ja.json @@ -1173,6 +1173,7 @@ "shortcuts.action.newChatInPanel": "パネルで新規チャット", "shortcuts.action.newWindow": "新規ウィンドウ", "shortcuts.action.nextSearchMatch": "次の検索結果", + "shortcuts.action.openThinkingMenu": "思考メニューを開く", "shortcuts.action.prevSearchMatch": "前の検索結果", "shortcuts.action.quit": "終了", "shortcuts.action.search": "検索", diff --git a/packages/shared/src/i18n/locales/pl.json b/packages/shared/src/i18n/locales/pl.json index 3e19a32ca..c23ee5133 100644 --- a/packages/shared/src/i18n/locales/pl.json +++ b/packages/shared/src/i18n/locales/pl.json @@ -1173,6 +1173,7 @@ "shortcuts.action.newChatInPanel": "Nowy czat w panelu", "shortcuts.action.newWindow": "Nowe okno", "shortcuts.action.nextSearchMatch": "Następny wynik wyszukiwania", + "shortcuts.action.openThinkingMenu": "Otwórz menu myślenia", "shortcuts.action.prevSearchMatch": "Poprzedni wynik wyszukiwania", "shortcuts.action.quit": "Zakończ", "shortcuts.action.search": "Szukaj", diff --git a/packages/shared/src/i18n/locales/zh-Hans.json b/packages/shared/src/i18n/locales/zh-Hans.json index 2dd8337aa..94fc36ceb 100644 --- a/packages/shared/src/i18n/locales/zh-Hans.json +++ b/packages/shared/src/i18n/locales/zh-Hans.json @@ -1173,6 +1173,7 @@ "shortcuts.action.newChatInPanel": "在面板中新建聊天", "shortcuts.action.newWindow": "新建窗口", "shortcuts.action.nextSearchMatch": "下一个搜索匹配", + "shortcuts.action.openThinkingMenu": "打开思考菜单", "shortcuts.action.prevSearchMatch": "上一个搜索匹配", "shortcuts.action.quit": "退出", "shortcuts.action.search": "搜索",