From 478b819702ae5e01d12941fe479b9326c8fa8ea6 Mon Sep 17 00:00:00 2001 From: Gary Basin Date: Mon, 20 Jul 2026 11:28:25 -0400 Subject: [PATCH] fix(web): render settings sections as real tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The settings sidebar looked like tabs but was implemented as scroll anchors: every section rendered at once and clicking a section only called scrollIntoView. Two things went wrong with that. The scroll container was never height-constrained. Its parent was a plain block div, so the child's `flex-1` did nothing and the scroller sized to its content, leaving scrollHeight === clientHeight — which makes scrollIntoView a guaranteed no-op. The overflow escaped to the shell's `overflow-hidden` and was clipped instead, so on a short window (~420px) the Agents and About sections were unreachable entirely, with no scrollbar to signal it. Even with that fixed, the whole surface is only ~680px tall, so at normal desktop heights everything already fit and clicking a section still moved nothing. With no section headings in the content column either, a click produced no feedback at all. Render only the active section instead. That drops the scroll dependency, so section switching cannot silently no-op at any viewport size. The nav is now a fixed rail with only the panel scrolling, and the active section gets a heading that labels the panel via aria-labelledby. Verified in-browser across desktop, short (380px) and mobile widths. - Add a guard asserting inactive sections are absent from the DOM. - notificationSettings.test.tsx asserted across sections, so it now opens the section holding the controls under test. --- .../src/components/SettingsSurface.test.tsx | 15 ++ .../web/src/components/SettingsSurface.tsx | 203 ++++++++---------- .../web/test/notificationSettings.test.tsx | 27 ++- 3 files changed, 120 insertions(+), 125 deletions(-) diff --git a/surface/web/src/components/SettingsSurface.test.tsx b/surface/web/src/components/SettingsSurface.test.tsx index 6a070846c..f3e1830d5 100644 --- a/surface/web/src/components/SettingsSurface.test.tsx +++ b/surface/web/src/components/SettingsSurface.test.tsx @@ -65,6 +65,21 @@ describe('SettingsSurface sections', () => { expect(activeSectionLabel()).toBe('Connections'); }); + it('renders only the active section', async () => { + renderSettings(); + + // Appearance is the default section; its controls are present and the other + // sections' controls are not rendered at all. + expect(screen.getByRole('heading', { name: 'Appearance' })).toBeTruthy(); + expect(screen.queryByRole('combobox', { name: 'Message notifications' })).toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: 'Notifications' })); + + await waitFor(() => expect(screen.getByRole('heading', { name: 'Notifications' })).toBeTruthy()); + expect(screen.queryByRole('button', { name: 'High contrast' })).toBeNull(); + expect(screen.getByRole('combobox', { name: 'Message notifications' })).toBeTruthy(); + }); + it('updates the active section on back navigation', async () => { renderSettings(); diff --git a/surface/web/src/components/SettingsSurface.tsx b/surface/web/src/components/SettingsSurface.tsx index d342e3b17..8dc5c3161 100644 --- a/surface/web/src/components/SettingsSurface.tsx +++ b/surface/web/src/components/SettingsSurface.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, type ReactNode } from 'react'; +import { useState, type ReactNode } from 'react'; import { ACCENTS, FONT_SCALES, @@ -74,6 +74,8 @@ const SETTINGS_SECTIONS = [ type SettingsSectionSlug = (typeof SETTINGS_SECTIONS)[number]['slug']; +const SECTION_HEADING_ID = 'settings-section-heading'; + const DEFAULT_SETTINGS_SECTION: SettingsSectionSlug = SETTINGS_SECTIONS[0].slug; function isSettingsSectionSlug(value: string | null | undefined): value is SettingsSectionSlug { @@ -108,19 +110,17 @@ export function SettingsSurface({

Settings

-
- -
+ ); } @@ -151,8 +151,7 @@ function SettingsControls({ const { prefs, setPrefs } = useTheme(); const requestedSection = route?.surface === 'settings' ? route.settingsSection : null; const activeSection = resolveSettingsSection(requestedSection); - const shouldScrollToSection = route?.surface === 'settings'; - const sectionRefs = useRef>>({}); + const activeLabel = SETTINGS_SECTIONS.find((section) => section.slug === activeSection)!.label; const segmentButton = (active: boolean) => `h-8 flex-1 rounded px-2 text-xs font-medium max-md:h-11 max-md:px-3 max-md:text-sm ${ active ? 'bg-accent text-on-accent' : 'text-fg-tertiary hover:bg-surface-overlay hover:text-fg-body' @@ -175,56 +174,46 @@ function SettingsControls({ setPrefs({ notifications: { ...prefs.notifications, sessions } }); const setNotificationCalls = (calls: boolean) => setPrefs({ notifications: { ...prefs.notifications, calls } }); const notificationsDisabled = notify === 'denied' || notify === 'unsupported'; - const setSectionRef = (section: SettingsSectionSlug) => (element: HTMLElement | null) => { - if (element) sectionRefs.current[section] = element; - else delete sectionRefs.current[section]; - }; const openSection = (section: SettingsSectionSlug) => { navigate(`/settings/${section}`); }; - useEffect(() => { - if (!shouldScrollToSection) return; - sectionRefs.current[activeSection]?.scrollIntoView?.({ block: 'start' }); - }, [activeSection, shouldScrollToSection]); - return ( -
-
- - -
-
-
-
+ + +
+
+

+ {activeLabel} +

+
+ {activeSection === 'appearance' ? ( + <>
{THEME_OPTIONS.map((option) => ( @@ -307,15 +296,11 @@ function SettingsControls({ ))}
-
- - + + ) : null} -
+ {activeSection === 'notifications' ? ( + <> -
- - + + ) : null} -
+ {activeSection === 'connections' ? ( + <> -
- - + + ) : null} -
+ {activeSection === 'agents' ? ( + <> -
- - - -
-
- Atrium is AGPL-3.0-or-later.{' '} - - Source - - {' · '} - - License - -
-
-
-
+ + ) : null} + + {activeSection === 'about' ? ( +
+ Atrium is AGPL-3.0-or-later.{' '} + + Source + + {' · '} + + License + +
+ ) : null} +
); } -function SettingsSectionDivider() { - return
; -} - function SettingRow({ label, children }: { label: string; children: ReactNode }) { return (
diff --git a/surface/web/test/notificationSettings.test.tsx b/surface/web/test/notificationSettings.test.tsx index c761a36ee..327d3584d 100644 --- a/surface/web/test/notificationSettings.test.tsx +++ b/surface/web/test/notificationSettings.test.tsx @@ -24,7 +24,13 @@ vi.mock('../src/notify', () => ({ toggleNotifications: vi.fn(async () => 'on'), })); -function renderSettings(props: Partial[0]> = {}) { +// Settings renders one section at a time, keyed off /settings/:section, so each +// test has to open the section holding the controls it asserts on. +function renderSettings( + section: 'appearance' | 'notifications' | 'connections' | 'agents' | 'about', + props: Partial[0]> = {}, +) { + window.history.replaceState(null, '', `/settings/${section}`); render( [0]> = } beforeEach(() => { + window.history.replaceState(null, '', '/settings'); mockTheme.prefs = { ...DEFAULT_PREFS, notifications: { ...DEFAULT_PREFS.notifications } }; mockTheme.setPrefs.mockClear(); }); @@ -44,9 +51,9 @@ afterEach(cleanup); describe('notification settings', () => { it('renders device and per-type notification controls', () => { - renderSettings(); + renderSettings('notifications'); - expect(screen.getByText('Notifications')).toBeTruthy(); + expect(screen.getByRole('heading', { name: 'Notifications' })).toBeTruthy(); expect(screen.getByRole('button', { name: /Device notifications off/ })).toBeTruthy(); expect(screen.getByRole('combobox', { name: 'Message notifications' })).toHaveProperty('value', 'dm_mention'); expect(screen.getByRole('button', { name: 'Agent notifications' })).toBeTruthy(); @@ -54,7 +61,7 @@ describe('notification settings', () => { }); it('writes complete notification preference objects', () => { - renderSettings(); + renderSettings('notifications'); fireEvent.change(screen.getByRole('combobox', { name: 'Message notifications' }), { target: { value: 'all' }, @@ -75,19 +82,21 @@ describe('notification settings', () => { }); it('shows unavailable GitHub connection state without breaking provider settings', () => { - renderSettings({ connectionsAvailable: false }); - + renderSettings('connections', { connectionsAvailable: false }); expect(screen.getByRole('button', { name: /Unavailable/ })).toBeTruthy(); + cleanup(); + + renderSettings('agents', { connectionsAvailable: false }); expect(screen.getByText('Claude Code')).toBeTruthy(); expect(screen.getByText('Codex')).toBeTruthy(); }); it('shows explicit GitHub fallback and needs-auth states', () => { - renderSettings({ connectionsAvailable: true }); + renderSettings('connections', { connectionsAvailable: true }); expect(screen.getByRole('button', { name: /Public read/ })).toBeTruthy(); cleanup(); - renderSettings({ + renderSettings('connections', { connectionsAvailable: true, githubConnection: { id: 'github:app_installation', @@ -130,7 +139,7 @@ describe('notification settings', () => { updatedAt: null, }; - renderSettings({ githubConnection }); + renderSettings('connections', { githubConnection }); expect(screen.getByRole('button', { name: /acme · App/ })).toBeTruthy(); });