Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,7 @@ const SidebarButton = React.forwardRef<HTMLButtonElement, SidebarButtonProps & R
if (!isOverlay && itemProps?.ref) itemProps.ref(el)
}}
onClick={isOverlay ? undefined : link.onClick}
data-testid={link.id}
data-tutorial={link.dataTutorial}
className={cn(
"group flex w-full items-center gap-2 rounded-[6px] text-[13px] select-none outline-none",
Expand Down
90 changes: 76 additions & 14 deletions apps/electron/src/renderer/pages/settings/SettingsNavigator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@
*
* Navigator panel content for settings. Displays a list of settings sections
* (App, Workspace, Shortcuts, Preferences) that can be selected to show in the details panel.
* A search box at the top filters the sections by their title and description,
* matching the searchable-settings affordance found in comparable desktop apps.
*
* Styling follows SessionList/SourcesListPanel patterns for visual consistency.
*/

import { useState, useMemo } from 'react'
import { useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { MoreHorizontal, AppWindow } from 'lucide-react'
import { MoreHorizontal, AppWindow, Search, X } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuTrigger,
Expand Down Expand Up @@ -148,6 +150,8 @@ export default function SettingsNavigator({
onSelectSubpage,
}: SettingsNavigatorProps) {
const { t } = useTranslation()
const [query, setQuery] = useState('')
const inputRef = useRef<HTMLInputElement>(null)

const settingsItems: SettingsItem[] = useMemo(() =>
SETTINGS_ITEMS.map((item) => ({
Expand All @@ -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 (
<div className="flex flex-col h-full">
<div className="flex-1 overflow-y-auto">
<div className="pt-2">
{settingsItems.map((item, index) => (
<SettingsItemRow
key={item.id}
item={item}
isSelected={selectedSubpage === item.id}
isFirst={index === 0}
onSelect={() => onSelectSubpage(item.id)}
/>
))}
<div className="flex flex-col h-full" data-testid="settings-navigator">
{/* Search box — filters sections by title + description */}
<div className="shrink-0 px-2 pt-2 pb-1.5 border-b border-border/50">
<div className="relative rounded-[8px] shadow-minimal bg-muted/50 has-[:focus-visible]:bg-background">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<input
ref={inputRef}
type="text"
role="searchbox"
aria-label={t('common.search')}
data-testid="settings-search-input"
value={query}
onChange={(e) => 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 && (
<button
type="button"
onClick={() => {
setQuery('')
inputRef.current?.focus()
}}
className="absolute right-2 top-1/2 -translate-y-1/2 p-0.5 hover:bg-foreground/10 rounded"
title={t('common.clear')}
aria-label={t('common.clear')}
>
<X className="h-3.5 w-3.5 text-muted-foreground" />
</button>
)}
</div>
</div>

<div className="flex-1 overflow-y-auto">
{filteredItems.length > 0 ? (
<div className="pt-2">
{filteredItems.map((item, index) => (
<SettingsItemRow
key={item.id}
item={item}
isSelected={selectedSubpage === item.id}
isFirst={index === 0}
onSelect={() => onSelectSubpage(item.id)}
/>
))}
</div>
) : (
<div
data-testid="settings-search-empty"
className="px-4 py-8 text-center text-sm text-muted-foreground"
>
{t('common.noResultsFound')}
</div>
)}
</div>
</div>
)
}
2 changes: 1 addition & 1 deletion docs/loop/feature-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |
50 changes: 37 additions & 13 deletions e2e/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,9 +120,16 @@ export async function launchApp(options: LaunchOptions = {}): Promise<LaunchedAp
// - CRAFT_USER_DATA_DIR → Electron userData (cache, cookies, local storage)
// - CRAFT_CONFIG_DIR → app config + project-workspace registry (~/.craft-agent)
// - QWEN_DEFAULT_WORKSPACE_DIR → the default conversation workspace (else ~/Documents)
const userDataDir = join(E2E_DIR, 'user-data');
const configDir = join(E2E_DIR, 'config');
const workspaceDir = join(E2E_DIR, 'workspace');
//
// Keyed on the (unique-per-launch) debug port so each assertion gets its own
// profile. Electron's single-instance lock lives in userData; if a prior
// instance lingers a moment past teardown, a shared profile would make the
// next launch hand off to the old instance and exit — and no new renderer
// target would ever appear.
const profileKey = `p${port}`;
const userDataDir = join(E2E_DIR, 'user-data', profileKey);
const configDir = join(E2E_DIR, 'config', profileKey);
const workspaceDir = join(E2E_DIR, 'workspace', profileKey);
mkdirSync(userDataDir, { recursive: true });
mkdirSync(configDir, { recursive: true });
mkdirSync(workspaceDir, { recursive: true });
Expand All @@ -138,9 +145,17 @@ export async function launchApp(options: LaunchOptions = {}): Promise<LaunchedAp
// Electron under a virtual framebuffer and disable the Chromium sandbox, which
// cannot initialize as root inside a container.
const headlessLinux = process.platform === 'linux' && !process.env.DISPLAY;
const cmd = headlessLinux
const baseCmd = headlessLinux
? ['xvfb-run', '-a', ELECTRON_BIN, ...electronArgs, '--no-sandbox']
: [ELECTRON_BIN, ...electronArgs];
// Under headless Linux, launch in a fresh process group (via setsid) so
// teardown can signal the whole tree. `xvfb-run` forks Xvfb and Electron as
// separate children; a plain kill() of the wrapper orphans them, leaking the
// Electron instance (and its single-instance lock) into the next assertion.
// Other platforms launch Electron directly, so the wrapper problem — and the
// need for `setsid`, which isn't present on macOS — does not apply.
const useProcessGroup = headlessLinux;
const cmd = useProcessGroup ? ['setsid', ...baseCmd] : baseCmd;

const proc: Subprocess = spawn({
cmd,
Expand All @@ -158,25 +173,34 @@ export async function launchApp(options: LaunchOptions = {}): Promise<LaunchedAp
});

let stopped = false;
const stop = async (): Promise<void> => {
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<void> => {
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);
}
};

Expand Down
122 changes: 122 additions & 0 deletions e2e/assertions/settings-search.assert.ts
Original file line number Diff line number Diff line change
@@ -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<string>(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<string | null>(
`(() => { 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<boolean>(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<string>(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<number>(
`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;