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 51235562a..fe078ad18 100644 --- a/docs/loop/feature-ledger.md +++ b/docs/loop/feature-ledger.md @@ -32,9 +32,13 @@ log, not the system of record. | slug | title | source | feasibility | status | issue | pr | branch | updated | notes | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| 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/reduce-motion | 2026-07-03 | Renderer-only pref (localStorage) applied app-wide via `` + `data-reduce-motion` on `` + global CSS guard. Off ⇒ `reducedMotion="user"` (still honors OS). New `ReduceMotionProvider` in `main.tsx`; toggle in Appearance→Interface; 2 new i18n keys ×7 locales. typecheck/`bun test` zero-delta vs main (56-failure set byte-identical); renderer build ✅; i18n parity ✅. CDP assertion included; **could not run locally** (egress 403s Electron binary download). | -| composer-expand | Expand / collapse (maximize) toggle for the chat composer | Claude/ChatGPT/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/composer-expand | 2026-07-03 | Opened by a prior run. Adds `isComposerExpanded` toggle in `FreeFormInput`; 2 new i18n keys. Awaiting review. | -| scroll-to-bottom | "Jump to latest" (scroll-to-bottom) button in the chat transcript | Claude Code / ChatGPT / Codex desktop | frontend-only | pr-open | [#46](https://github.com/modelstudioai/openwork/issues/46) | [#47](https://github.com/modelstudioai/openwork/pull/47) | loop/scroll-to-bottom | 2026-07-02 | Opened by a prior run. Floating jump button in `ChatDisplay` + `seed()` harness hook. Awaiting review. | -| thinking-level-picker | Thinking-level (reasoning effort) picker in the chat composer | Claude Code Desktop effort menu (⌘⇧E) + OpenWork's own model picker | frontend-only | merged | [#44](https://github.com/modelstudioai/openwork/issues/44) | [#45](https://github.com/modelstudioai/openwork/pull/45) | loop/thinking-level-picker | 2026-07-03 | **Merged** into `main` (2026-07-02). `thinkingLevel`/`onThinkingLevelChange` already plumbed to `FreeFormInput`; only the UI trigger was missing. Reuses `thinking.*` + `settings.ai.thinking` i18n keys (zero new keys). | +| 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 group 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 | Prior round: branch + PR #57 already on GitHub (this run reconciled the ledger; PR #57 was missing the `loop-bot` label — added). | +| thinking-menu-shortcut | ⌘⇧E keyboard shortcut to open the composer thinking menu | Claude Code Desktop effort-menu shortcut (⌘⇧E) | 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 | Prior round; PR open awaiting review. | +| prompt-history | Recall previously-sent prompts with Up / Down in the composer | Codex desktop composer up-arrow recall / shell history | 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 | Prior round; PR open awaiting review. | +| reduce-motion | "Reduce motion" accessibility toggle 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/reduce-motion | 2026-07-03 | Prior round; PR open awaiting review. | +| composer-expand | Expand / collapse (maximize) toggle for the chat composer | Claude / ChatGPT / 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/composer-expand | 2026-07-03 | Prior round; PR open awaiting review. | +| scroll-to-bottom | "Jump to latest" (scroll-to-bottom) button in the transcript | Claude Code Desktop / ChatGPT / Codex desktop jump-to-latest | frontend-only | pr-open | [#46](https://github.com/modelstudioai/openwork/issues/46) | [#47](https://github.com/modelstudioai/openwork/pull/47) | loop/scroll-to-bottom | 2026-07-02 | Prior round; PR open awaiting review. Added a reusable `seed()` harness hook (not yet on `main`). | +| thinking-level-picker | Thinking-level (reasoning effort) picker in the chat composer | Claude Code Desktop effort menu (⌘⇧E) + OpenWork's own model picker | frontend-only | merged | [#44](https://github.com/modelstudioai/openwork/issues/44) | [#45](https://github.com/modelstudioai/openwork/pull/45) | loop/thinking-level-picker | 2026-07-05 | **Merged into `main`.** `thinkingLevel`/`onThinkingLevelChange` were already plumbed to `FreeFormInput`; only the UI trigger was missing. Reuses `thinking.*` + `settings.ai.thinking` i18n keys (zero new keys). | | command-palette | Global command palette (⌘K/Ctrl+K) to search & run any action | Claude Code Desktop ⌘K / VS Code & Codex ⌘⇧P / Linear ⌘K | frontend-only | merged | [#41](https://github.com/modelstudioai/openwork/issues/41) | [#42](https://github.com/modelstudioai/openwork/pull/42) | loop/command-palette | 2026-07-02 | Merged into `main`. Reuses action registry `execute()` + cmdk primitives; zero new i18n keys. CDP e2e 2/2 pass. typecheck/test +0 vs main. | | settings-search | Searchable/filterable settings navigation | Claude Code Desktop / VS Code / Codex desktop settings search | frontend-only | merged | [#39](https://github.com/modelstudioai/openwork/issues/39) | [#40](https://github.com/modelstudioai/openwork/pull/40) | loop/settings-search | 2026-07-01 | Merged into `main`. Filters `SettingsNavigator` by title+description; reuses `common.search`/`common.noResultsFound` (no new locale keys). Also hardened `e2e/app.ts` teardown (per-launch profile dir + setsid process-group kill) so multiple CDP assertions run under headless xvfb. | 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;