diff --git a/src/renderer/src/components/settings/Settings.load-performance.test.ts b/src/renderer/src/components/settings/Settings.load-performance.test.ts index 60928b422e0..521e9aa7b8f 100644 --- a/src/renderer/src/components/settings/Settings.load-performance.test.ts +++ b/src/renderer/src/components/settings/Settings.load-performance.test.ts @@ -27,7 +27,7 @@ describe('Settings load-performance helpers', () => { expect(Array.from(needed).sort()).toEqual(['general']) }) - it('adds matched sections immediately when search is non-empty', () => { + it('keeps search mounting scoped to the active section', () => { const needed = deriveNeededSectionIds({ navSectionIds: ['general', 'agents', 'appearance', 'terminal', 'stats', 'repo-a'], mountedSectionIds: new Set(['general']), @@ -37,6 +37,20 @@ describe('Settings load-performance helpers', () => { visibleSectionIds: new Set(['stats']) }) + expect(needed.has('stats')).toBe(false) + expect(needed.has('general')).toBe(false) + }) + + it('mounts the active matched section during search', () => { + const needed = deriveNeededSectionIds({ + navSectionIds: ['general', 'agents', 'appearance', 'terminal', 'stats', 'repo-a'], + mountedSectionIds: new Set(['general']), + activeSectionId: 'stats', + pendingSectionId: null, + query: 'stats', + visibleSectionIds: new Set(['stats']) + }) + expect(needed.has('stats')).toBe(true) }) diff --git a/src/renderer/src/components/settings/SettingsSection.tsx b/src/renderer/src/components/settings/SettingsSection.tsx index 4ee84cbdc60..fbe1555c756 100644 --- a/src/renderer/src/components/settings/SettingsSection.tsx +++ b/src/renderer/src/components/settings/SettingsSection.tsx @@ -23,9 +23,9 @@ type SettingsSectionProps = { badgeAccessory?: React.ReactNode forceVisible?: boolean /** When true, this section is the one currently selected in the sidebar. - * Sections render only when active or when a non-empty search matches them - * — that way the Settings page shows one focused pane at a time instead of - * one giant scrolling document. */ + * Sections render only when active. During search, the sidebar lists every + * match while the content pane stays focused on the selected match instead + * of mounting every matching settings surface at once. */ isActive?: boolean /** Rendered in the section header's upper-right corner — intended for * section-scoped actions (e.g. "Import from Ghostty") that would otherwise @@ -54,7 +54,7 @@ export function SettingsSection({ const matchesQuery = !searchEntries || matchesSettingsSearch(query, searchEntries) if (!forceVisible) { if (hasQuery) { - if (!matchesQuery) { + if (!sectionIsActive || !matchesQuery) { return null } } else if (!sectionIsActive) { diff --git a/src/renderer/src/components/settings/settings-load-performance.ts b/src/renderer/src/components/settings/settings-load-performance.ts index 4e1b4fdd6f5..d20ed23beb5 100644 --- a/src/renderer/src/components/settings/settings-load-performance.ts +++ b/src/renderer/src/components/settings/settings-load-performance.ts @@ -16,23 +16,24 @@ export function deriveNeededSectionIds(args: { query: string visibleSectionIds: Set }): Set { - const next = new Set(args.mountedSectionIds) - for (const sectionId of args.navSectionIds) { - if (EAGER_SECTION_IDS.has(sectionId)) { - next.add(sectionId) + const hasSearchQuery = args.query.trim() !== '' + const next = hasSearchQuery ? new Set() : new Set(args.mountedSectionIds) + if (!hasSearchQuery) { + for (const sectionId of args.navSectionIds) { + if (EAGER_SECTION_IDS.has(sectionId)) { + next.add(sectionId) + } } } - if (args.activeSectionId) { + if ( + args.activeSectionId && + (!hasSearchQuery || args.visibleSectionIds.has(args.activeSectionId)) + ) { next.add(args.activeSectionId) } if (args.pendingSectionId) { next.add(args.pendingSectionId) } - if (args.query.trim() !== '') { - for (const visibleSectionId of args.visibleSectionIds) { - next.add(visibleSectionId) - } - } return next } diff --git a/tests/e2e/settings-search-responsiveness.spec.ts b/tests/e2e/settings-search-responsiveness.spec.ts new file mode 100644 index 00000000000..f22a671331f --- /dev/null +++ b/tests/e2e/settings-search-responsiveness.spec.ts @@ -0,0 +1,72 @@ +import path from 'path' +import { test, expect } from './helpers/orca-app' +import { waitForSessionReady } from './helpers/store' +import type { Repo } from '../../src/shared/types' + +const MATCHING_PROJECT_COUNT = 240 + +function buildProjectPaths(): string[] { + return Array.from({ length: MATCHING_PROJECT_COUNT }, (_, index) => + path.join(process.cwd(), '.e2e-settings-search', `project-${String(index).padStart(3, '0')}`) + ) +} + +test.describe('Settings search responsiveness', () => { + test('renders only the active settings pane when many projects match search', async ({ + orcaPage + }) => { + await waitForSessionReady(orcaPage) + + await orcaPage.evaluate( + ({ projectPaths, projectCount }) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const state = store.getState() + const seedRepo = state.repos[0] + if (!seedRepo) { + throw new Error('Expected seeded repo for settings search regression') + } + const now = Date.now() + const repos: Repo[] = Array.from({ length: projectCount }, (_, index) => ({ + ...seedRepo, + id: `settings-search-repo-${index}`, + path: projectPaths[index], + displayName: `Project Long Search ${String(index).padStart(3, '0')}`, + addedAt: now + index, + upstream: null, + hookSettings: undefined + })) + store.setState({ repos }) + state.openSettingsPage() + }, + { projectPaths: buildProjectPaths(), projectCount: MATCHING_PROJECT_COUNT } + ) + + const searchInput = orcaPage.getByPlaceholder('Search settings') + await expect(searchInput).toBeVisible() + await searchInput.fill('Project Long Search') + + await expect + .poll(() => orcaPage.evaluate(() => window.__store?.getState().settingsSearchQuery ?? ''), { + timeout: 5_000, + message: 'settings search query did not apply' + }) + .toBe('Project Long Search') + + await expect(orcaPage.getByRole('button', { name: 'Project Long Search 000' })).toBeVisible() + await expect + .poll(() => orcaPage.locator('section.scroll-mt-8[data-settings-section]').count(), { + timeout: 5_000, + message: 'settings search rendered more than the active pane' + }) + .toBe(1) + + const renderedSectionId = await orcaPage + .locator('section.scroll-mt-8[data-settings-section]') + .first() + .getAttribute('data-settings-section') + expect(renderedSectionId).toBe('repo-settings-search-repo-0') + }) +})