diff --git a/apps/electron/src/renderer/pages/settings/SettingsNavigator.tsx b/apps/electron/src/renderer/pages/settings/SettingsNavigator.tsx index f62509111..c960e3a0b 100644 --- a/apps/electron/src/renderer/pages/settings/SettingsNavigator.tsx +++ b/apps/electron/src/renderer/pages/settings/SettingsNavigator.tsx @@ -93,6 +93,7 @@ function SettingsItemRow({ item, isSelected, isFirst, onSelect }: SettingsItemRo + )} + +
- {/* Registry-driven sections */} - {Object.entries(actionsByCategory).map(([category, actions]) => ( - - - {actions.map(action => ( - - ))} - - - ))} - - {/* Component-specific sections */} - {componentSpecificSections.map((section) => ( - - - {section.shortcuts.map((shortcut, index) => ( - -
- {shortcut.keys.map((key, keyIndex) => ( - {key} - ))} -
-
- ))} -
-
- ))} + {hasResults ? ( + filteredSections.map((section) => ( + + + {section.rows.map((row) => ( + +
+ {row.keys.map((key, keyIndex) => ( + {key} + ))} +
+
+ ))} +
+
+ )) + ) : ( +
+ {t('common.noResultsFound')} +
+ )}
diff --git a/docs/loop/feature-ledger.md b/docs/loop/feature-ledger.md index d42e0f124..9ea31696e 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 | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| shortcuts-search | Search box on the Settings → Keyboard Shortcuts page | Codex "keypress search" + VS Code / Claude keybindings search; mirrors OpenWork's own settings-navigator search (#40) | frontend-only | pr-open | [#58](https://github.com/modelstudioai/openwork/issues/58) | [#59](https://github.com/modelstudioai/openwork/pull/59) | loop/shortcuts-search | 2026-07-05 | Pure renderer view over the action registry (`actionsByCategory` + `getHotkeyDisplay`); filters rows by action label **and** rendered key tokens ("keypress search"), hides empty sections, shows empty state. Reuses `common.search`/`noResultsFound`/`clear` (**zero** new i18n keys). Added `data-testid="settings-item-"` to settings-nav items for e2e navigation. typecheck/renderer-build/i18n-parity zero-delta vs main; touched-area tests pass. CDP assertion written (app launch egress-blocked locally, same as prior rounds). | | command-palette-recents | "Recently used" section in the Command Palette (⌘K) | VS Code ⌘⇧P / Raycast / Linear ⌘K / Claude Code Desktop command menu | frontend-only | pr-open | [#56](https://github.com/modelstudioai/openwork/issues/56) | [#57](https://github.com/modelstudioai/openwork/pull/57) | loop/command-palette-recents | 2026-07-05 | Palette (#42) had no memory of what you run. Adds a persisted recents list (`localStorage` `command-palette-recents`, dedupe/cap-6, newest-first) surfaced as a top "Recently used" group, hidden while searching. Pure `pushRecent` module + 6 unit tests (pass). One new i18n key `commands.recent` × 7 locales. typecheck/`bun test` zero-delta vs main (56 baseline fails on both, 0 new); renderer build ✅. **CDP could not run locally** (same env block as prior rounds: egress 403s Electron binary + `libsignal`/WhatsApp-worker dep) — assertion included for CI/reviewer. | | 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. | diff --git a/e2e/assertions/shortcuts-search.assert.ts b/e2e/assertions/shortcuts-search.assert.ts new file mode 100644 index 000000000..599d5c3f1 --- /dev/null +++ b/e2e/assertions/shortcuts-search.assert.ts @@ -0,0 +1,173 @@ +/** + * Feature assertion: the Settings → Keyboard Shortcuts page has a working search + * box that filters the shortcut rows by their action label AND their key + * combination ("keypress search"). + * + * Drives the real UI: opens Settings from the sidebar, navigates to the + * Shortcuts sub-page, types into the search box, and asserts that the rendered + * shortcut list narrows to matches (by label and by key token), shows an empty + * state for a no-match query, and restores fully when cleared. + * + * Backend-independent: the Shortcuts page is a pure renderer view over the + * action registry, so no session or qwen-code connection is needed. + */ + +import type { Assertion } from '../runner'; + +const SEARCH_INPUT = '[data-testid="shortcuts-search-input"]'; +const EMPTY = '[data-testid="shortcuts-search-empty"]'; +const ROW = '[data-layout="settings-row"]'; + +/** Text content (lowercased) of every rendered shortcut row. */ +function readRowTextsExpr(): string { + return `JSON.stringify(Array.from(document.querySelectorAll(${JSON.stringify( + ROW, + )})).map((el) => (el.textContent || '').replace(/\\s+/g, ' ').trim().toLowerCase()))`; +} + +/** Set a controlled input's value the way React expects, then fire `input`. */ +function setInputExpr(value: string): string { + return `(() => { + const input = document.querySelector(${JSON.stringify(SEARCH_INPUT)}); + if (!input) return false; + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; + setter.call(input, ${JSON.stringify(value)}); + input.dispatchEvent(new Event('input', { bubbles: true })); + return true; + })()`; +} + +const assertion: Assertion = { + name: 'keyboard-shortcuts settings page search filters shortcuts', + async run(app) { + const { session } = app; + + // App fully mounted. + await session.waitForFunction( + '!document.getElementById("_loader") && (document.getElementById("root")?.childElementCount ?? 0) > 0', + { timeoutMs: 30000, message: 'app did not mount' }, + ); + + // Open Settings, then the Shortcuts sub-page (real user path). + await session.click('[data-testid="nav:settings"]', { timeoutMs: 15000 }); + await session.click('[data-testid="settings-item-shortcuts"]', { + timeoutMs: 15000, + }); + + // The search box is the feature under test — its presence is the first + // signal the feature shipped. + await session.waitForSelector(SEARCH_INPUT, { + timeoutMs: 15000, + message: 'shortcuts search input did not render', + }); + await session.waitForFunction( + `document.querySelectorAll(${JSON.stringify(ROW)}).length > 1`, + { timeoutMs: 10000, message: 'shortcut rows did not render' }, + ); + + const allTexts: string[] = JSON.parse( + await session.evaluate(readRowTextsExpr()), + ); + const total = allTexts.length; + if (total < 2) throw new Error(`expected multiple shortcut rows, saw ${total}`); + + // --- 1) Filter by an action LABEL -------------------------------------- + // Derive the query from the first row's visible label so the test is + // locale-independent (labels render in whatever language is active). + const firstLabel = ( + await session.evaluate( + `(() => { const el = document.querySelector(${JSON.stringify( + ROW, + )}); if (!el) return null; const lbl = el.querySelector('div'); return (lbl ? lbl.textContent : el.textContent); })()`, + ) + ) + ?.replace(/\s+/g, ' ') + .trim(); + if (!firstLabel) throw new Error('could not read a shortcut row label to search for'); + const labelQuery = firstLabel.toLowerCase(); + + if (!(await session.evaluate(setInputExpr(firstLabel)))) { + throw new Error('search input disappeared before typing'); + } + const expectedLabelMatches = allTexts.filter((t) => t.includes(labelQuery)).length; + await session.waitForFunction( + `document.querySelectorAll(${JSON.stringify(ROW)}).length === ${expectedLabelMatches}`, + { + timeoutMs: 8000, + message: `label filter did not narrow to the ${expectedLabelMatches} predicted match(es)`, + }, + ); + const labelVisible: string[] = JSON.parse( + await session.evaluate(readRowTextsExpr()), + ); + if (labelVisible.length === 0) { + throw new Error('label query matched nothing — expected at least its own row'); + } + if (labelVisible.length >= total) { + throw new Error(`label filtering did not reduce the list (${labelVisible.length} of ${total})`); + } + for (const t of labelVisible) { + if (!t.includes(labelQuery)) { + throw new Error(`visible row "${t}" does not contain label query "${labelQuery}"`); + } + } + + // --- 2) Filter by a KEY token ("keypress search") ---------------------- + // Read a chip from the (now cleared) full list and search for it. + await session.evaluate(setInputExpr('')); + await session.waitForFunction( + `document.querySelectorAll(${JSON.stringify(ROW)}).length === ${total}`, + { timeoutMs: 8000, message: 'clearing the label query did not restore all rows' }, + ); + const keyToken = ( + await session.evaluate( + `(() => { const k = document.querySelector(${JSON.stringify( + ROW, + )} + ' kbd'); return k ? (k.textContent || '').trim() : null; })()`, + ) + )?.trim(); + if (keyToken && keyToken.length > 0) { + const keyQuery = keyToken.toLowerCase(); + await session.evaluate(setInputExpr(keyToken)); + const expectedKeyMatches = allTexts.filter((t) => t.includes(keyQuery)).length; + await session.waitForFunction( + `document.querySelectorAll(${JSON.stringify(ROW)}).length === ${expectedKeyMatches}`, + { + timeoutMs: 8000, + message: `key filter for "${keyToken}" did not narrow to the ${expectedKeyMatches} predicted match(es)`, + }, + ); + const keyVisible: string[] = JSON.parse( + await session.evaluate(readRowTextsExpr()), + ); + if (keyVisible.length === 0) { + throw new Error(`key query "${keyToken}" matched nothing — expected at least one row`); + } + for (const t of keyVisible) { + if (!t.includes(keyQuery)) { + throw new Error(`visible row "${t}" does not contain key query "${keyQuery}"`); + } + } + } + + // --- 3) No-match query shows the empty state and hides every row ------- + await session.evaluate(setInputExpr('zzqqxxnomatchzzqqxx')); + await session.waitForSelector(EMPTY, { + timeoutMs: 8000, + message: 'empty state did not show for a no-match query', + }); + const noneLeft = await session.evaluate( + `document.querySelectorAll(${JSON.stringify(ROW)}).length`, + ); + if (noneLeft !== 0) throw new Error(`expected 0 rows for a no-match query, saw ${noneLeft}`); + + // --- 4) Clearing restores the full list -------------------------------- + await session.evaluate(setInputExpr('')); + await session.waitForFunction( + `document.querySelectorAll(${JSON.stringify(ROW)}).length === ${total}`, + { timeoutMs: 8000, message: 'clearing the query did not restore all rows' }, + ); + }, +}; + +export default assertion;