diff --git a/apps/electron/src/renderer/components/app-shell/LeftSidebar.tsx b/apps/electron/src/renderer/components/app-shell/LeftSidebar.tsx index 434b61454..10e41d4c4 100644 --- a/apps/electron/src/renderer/components/app-shell/LeftSidebar.tsx +++ b/apps/electron/src/renderer/components/app-shell/LeftSidebar.tsx @@ -485,6 +485,7 @@ const SidebarButton = React.forwardRef(null) const settingsItems: SettingsItem[] = useMemo(() => SETTINGS_ITEMS.map((item) => ({ @@ -159,21 +163,79 @@ export default function SettingsNavigator({ [t] ) + const normalizedQuery = query.trim().toLowerCase() + const filteredItems = useMemo(() => { + if (!normalizedQuery) return settingsItems + return settingsItems.filter((item) => + `${item.label} ${item.description}`.toLowerCase().includes(normalizedQuery) + ) + }, [settingsItems, normalizedQuery]) + + const hasQuery = normalizedQuery.length > 0 + return ( -
-
-
- {settingsItems.map((item, index) => ( - onSelectSubpage(item.id)} - /> - ))} +
+ {/* Search box — filters sections by title + description */} +
+
+ + setQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Escape' && query) { + e.preventDefault() + e.stopPropagation() + setQuery('') + } + }} + placeholder={t('common.search')} + className="w-full h-8 pl-8 pr-8 text-sm bg-transparent border-0 rounded-[8px] outline-none focus-visible:ring-0 focus-visible:outline-none placeholder:text-muted-foreground/50" + /> + {hasQuery && ( + + )}
+ +
+ {filteredItems.length > 0 ? ( +
+ {filteredItems.map((item, index) => ( + onSelectSubpage(item.id)} + /> + ))} +
+ ) : ( +
+ {t('common.noResultsFound')} +
+ )} +
) } diff --git a/docs/loop/feature-ledger.md b/docs/loop/feature-ledger.md index ed29f83ec..32e788897 100644 --- a/docs/loop/feature-ledger.md +++ b/docs/loop/feature-ledger.md @@ -32,4 +32,4 @@ log, not the system of record. | slug | title | source | feasibility | status | issue | pr | branch | updated | notes | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| _(empty)_ | _first run appends here_ | | | | | | | | | +| settings-search | Searchable/filterable settings navigation | Claude Code Desktop / VS Code / Codex desktop settings search | frontend-only | pr-open | #39 | #40 | loop/settings-search | 2026-06-30 | 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. CDP assertion `e2e/assertions/settings-search.assert.ts` passes (2/2). | diff --git a/e2e/app.ts b/e2e/app.ts index 849912f87..98c93a451 100644 --- a/e2e/app.ts +++ b/e2e/app.ts @@ -120,9 +120,16 @@ export async function launchApp(options: LaunchOptions = {}): Promise => { - if (stopped) return; - stopped = true; + // Signal the whole process group when we launched via setsid, so Xvfb and + // Electron die with the wrapper instead of being orphaned. + const signalTree = (signal: number): void => { + if (useProcessGroup && typeof proc.pid === 'number') { + try { + process.kill(-proc.pid, signal); + return; + } catch { + // group already gone — fall through to the single-process kill + } + } try { - proc.kill(); + proc.kill(signal); } catch { // already gone } + }; + const stop = async (): Promise => { + if (stopped) return; + stopped = true; + signalTree(15); // Escalate if it doesn't exit promptly. const exited = await Promise.race([ proc.exited.then(() => true), Bun.sleep(5000).then(() => false), ]); if (!exited) { - try { - proc.kill(9); - } catch { - // already gone - } + signalTree(9); } }; diff --git a/e2e/assertions/settings-search.assert.ts b/e2e/assertions/settings-search.assert.ts new file mode 100644 index 000000000..7c7082001 --- /dev/null +++ b/e2e/assertions/settings-search.assert.ts @@ -0,0 +1,122 @@ +/** + * Feature assertion: the settings navigator has a working search box that + * filters the settings sections by their title/description. + * + * Drives the real UI: opens Settings from the sidebar, types into the search + * box, and asserts that the rendered section list narrows to matches, shows an + * empty state for a no-match query, and restores fully when cleared. + */ + +import type { Assertion } from '../runner'; + +const SEARCH_INPUT = '[data-testid="settings-search-input"]'; +const ROW = '.settings-item'; +const EMPTY = '[data-testid="settings-search-empty"]'; + +/** Text content (lowercased) of every rendered settings row. */ +function readRowTextsExpr(): string { + return `JSON.stringify(Array.from(document.querySelectorAll(${JSON.stringify( + ROW, + )})).map((el) => (el.textContent || '').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: 'settings navigator search filters sections', + 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 from the sidebar (real user path). + await session.click('[data-testid="nav:settings"]', { timeoutMs: 15000 }); + + // The search box is part of the feature under test — its presence is the + // first signal the feature shipped. + await session.waitForSelector(SEARCH_INPUT, { + timeoutMs: 15000, + message: 'settings search input did not render', + }); + await session.waitForFunction( + `document.querySelectorAll(${JSON.stringify(ROW)}).length > 1`, + { timeoutMs: 10000, message: 'settings rows did not render' }, + ); + + const allTexts = JSON.parse(await session.evaluate(readRowTextsExpr())); + const total: number = allTexts.length; + if (total < 2) throw new Error(`expected multiple settings rows, saw ${total}`); + + // Derive a query from the first row's visible title so the test is + // locale-independent (the row label is whatever the active language renders). + const firstLabel = ( + await session.evaluate( + `(() => { const el = document.querySelector(${JSON.stringify( + ROW, + )} + ' .font-medium'); return el ? el.textContent : null; })()`, + ) + )?.trim(); + if (!firstLabel) throw new Error('could not read a settings row label to search for'); + const query = firstLabel.toLowerCase(); + + // Type the query and wait for the list to settle to the predicted subset. + if (!(await session.evaluate(setInputExpr(firstLabel)))) { + throw new Error('search input disappeared before typing'); + } + const expectedMatches = allTexts.filter((t: string) => t.includes(query)).length; + await session.waitForFunction( + `document.querySelectorAll(${JSON.stringify(ROW)}).length === ${expectedMatches}`, + { + timeoutMs: 8000, + message: `filtered list did not narrow to the ${expectedMatches} predicted match(es)`, + }, + ); + + // Every visible row must actually contain the query, and the searched-for + // section must still be present. + const visTexts: string[] = JSON.parse(await session.evaluate(readRowTextsExpr())); + if (visTexts.length === 0) throw new Error('query matched nothing — expected at least its own row'); + if (visTexts.length >= total) { + throw new Error(`filtering did not reduce the list (${visTexts.length} of ${total})`); + } + for (const t of visTexts) { + if (!t.includes(query)) { + throw new Error(`visible row "${t}" does not contain query "${query}"`); + } + } + + // A 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}`); + + // 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;