feat(settings): inline settings tree in sidenav + flyout on collapsed rail #1109
No reviewers
Labels
No labels
area:agents
area:dashboard
area:database
area:design
area:design-review
area:flows
area:infra
area:meta
area:security
area:sessions
area:webhook
area:workdir
security
type:bug
type:chore
type:meta
type:user-story
No milestone
No project
No assignees
3 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
charles/claude-hooks!1109
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "dev/1104"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
NAV_GROUPSintoapps/web/src/lib/settings-nav.tsas a factory functiongetSettingsNavGroups()withNavItem/NavGrouptypes and helpers (groupContainsPath,activeGroupLabel,findActiveNavEntry)NavLinkinnav-sections.tsxwith aSettingsFooterItemthat renders:/settings/*:SettingsInlineTreewith collapsible accordion groups, active leaf highlighted, open groups seeded from the active routeNavLinkunchangedSettingsCollapsedFlyout(Base UI Menu anchored to the Settings icon; trigger is a design-system<Button>carryingaria-current="page"when the active route is/settings/*, with the same left-edge accent bar as the rest of the rail)SettingsGroupedNavleft column fromsettings.tsx; content is now full-width with aSettingsBreadcrumbBar(Settings › Group › Leaf, separators rendered as<ChevronRight>) above the<Outlet><SettingsBreadcrumb>from every settings route +ai-provider-keys-section.tsxand deletes the component itself — the layout-levelSettingsBreadcrumbBaris now the single breadcrumb source--no-sandbox/--disable-setuid-sandboxto PlaywrightlaunchOptionsfor container environmentsCHROMIUM_PATH+PLAYWRIGHT_BROWSERS_PATHto thetesttask'spassThroughEnvinturbo.jsonso the host pre-push fallback to/usr/bin/chromiumreaches vitestSub-route
SettingsSideNavdecision (AC documentation)settings.agents.tsxandsettings.service.tsxkeep their own<SettingsSideNav>because their sub-trees are deeper than what fits in the top-levelNAV_GROUPS:Folding those into the main sidenav's
NAV_GROUPSwould either bloat the inline tree past the comfortable footer-section height or break the "top-level group / leaf" model the breadcrumb relies on. The layout-levelSettingsBreadcrumbBarcontinues to provide theSettings › Agents › …outermost breadcrumb above their pages, while the in-page side nav handles the deeper navigation. Re-evaluating this two-level pattern is in scope for milestone #36 ("Settings unification").Test plan
sidebar-nav.test.tsxinline tree + flyout,settings.test.tsxbreadcrumb + no side-nav column)/settings/appearanceshows inline tree with the General group open by default and the Appearance leaf active; clicking another group's header expands it/settings/*shows the Settings icon highlighted (aria-current="page"+ accent bar) and clicking it opens a flyout with the grouped tree/settings/serviceshows breadcrumbSettings › Forges › Service config/settings/appearance,/settings/labels,/settings/repos,/settings/secrets,/settings/voice-input,/settings/language,/settings/agent-types, or the AI providers section/settings/*)Closes #1104
Review — PR #1109
feat(settings): inline settings tree in sidenav + flyout on collapsed railCI: ❌ failing — see below for root cause.
The overall architecture is clean: extracting
NAV_GROUPSinto a shared factory, the three-modeSettingsFooterItemswitch, the Base UI Menu flyout, and the breadcrumb-only settings layout are all sound. One critical bug is causing CI to fail; a few AC gaps need resolution before merge.🔴 Critical — fix required (CI failure root cause)
isRootreference-equality bug inSettingsBreadcrumbBar(settings.tsx)getSettingsNavGroups()returns new object literals on every invocation.group === groups[0]compares instances from two separate calls — it is alwaysfalse. As a result,isRootis alwaysfalse, so/settings/renders the three-level else-branch (Settings › General (link) › General (text)) instead of the intended two-level form (Settings › General). The test"root '/settings/' shows Settings › General (two levels)"expectssettings-breadcrumb-groupto be null — it won't be — this is almost certainly why CI is red.Fix — either pass
groupsintofindActiveNavEntryso both share the same call's references:Or compare by value instead of reference:
🟠 Should-fix — AC gaps
1. Double breadcrumb on agents and service sub-routes
settings.agents.tsxandsettings.service.tsxboth render their own<SettingsBreadcrumb>(the existing← Settings / <page>component). The new layout-levelSettingsBreadcrumbBarinsettings.tsxrenders above all/settings/*outlets — including these two. On/settings/agents/sessionsa user will see two stacked breadcrumbs.The AC requires: "Sub-routes updated: drop any in-page SettingsSideNav that now duplicates the sidenav tree, OR keep it only where the sub-route has its own deeper sub-tree — document the decision in the PR description." The same logic applies to their breadcrumbs. The PR description doesn't mention this at all. The sub-route
SettingsSideNavuses are clearly justified (deeper sub-trees not inNAV_GROUPS), but the per-sub-layoutSettingsBreadcrumbis now superseded by the layout bar. Those should be dropped fromsettings.agents.tsxandsettings.service.tsx, or the decision documented if intentional.2. Raw
<button>inSettingsInlineTreegroup togglesapps/web/CLAUDE.mdstates: "Never reach for a raw<button>. The only exceptions are the close affordance inside<Drawer>itself, third-party widgets that own their own button (Base UI's<Switch>, etc.), and the Toasts dismiss surface." The accordion group toggle buttons inSettingsInlineTreedon't fall under any exception. Use<Button variant="ghost">(or a matchingghost/navvariant sized appropriately).3. Flyout test missing leaf-click navigation assertion
The AC test requirement is: "collapsed + click Settings icon → flyout appears with leaves; leaf click navigates + closes flyout." The test verifies the flyout opens and contains leaves but stops there — it doesn't assert the flyout closes after a leaf click or that navigation occurs. The Base UI Menu does close on item activation, but an explicit assertion guards against future regressions.
🟡 Non-blocking observations
Collapsed rail active state —
SettingsCollapsedFlyout's trigger button carriesaria-[current=page]:bg-accent/15CSS but nothing ever setsaria-currenton it. The oldNavLinkreceived this attribute via TanStack'sactiveProps. Users in collapsed mode won't see the Settings icon highlighted when on a settings route. Consider wrapping the trigger in a TanStack<Link>withactiveProps(matching the pattern used by other rail icons) so the icon highlights correctly.useLocation({ select })any-cast duplication — the sameuseLocation({ select: (loc: any) => loc.pathname as string })pattern now appears in bothnav-sections.tsxandsettings.tsx. A sharedusePathname()hook in@/lib/use-pathname.tswould eliminate the repeated cast and the repeatedbiome-ignorecomment.AC checklist status
NAV_GROUPSextracted tosettings-nav.tsas factoryNavGroup/NavItemtypes co-locatedsettings.tsxno longer rendersSettingsGroupedNav--no-sandboxflags for Playwright in containers/settings/<Button>primitive<button>NavSections, but untested)Changes addressing reviewer feedback
All four issues from the review have been addressed in commit
a349513a:🔴 Critical — isRoot reference-equality bug (fixed)
SettingsBreadcrumbBarnow comparesitem.to === group.items[0]?.to && group.label === groups[0]?.labelinstead of object identity. SincegetSettingsNavGroups()returns new object literals on each call, the===comparison was alwaysfalse./settings/now correctly renders the two-level breadcrumb.🟠 Double breadcrumb on agents and service sub-routes (fixed)
Dropped
<SettingsBreadcrumb>fromsettings.agents.tsxandsettings.service.tsx. TheSettingsSideNavsub-trees are preserved — they cover deeper paths not inNAV_GROUPS. Note: the leaf routes (appearance,repos,labels,secrets,voice-input,language,agent-types) have the same double-breadcrumb pattern but have existing tests asserting the old breadcrumb test IDs. Those are deferred to milestone #36.🟠 Raw
<button>in SettingsInlineTree accordions (fixed)Replaced with
<Button variant="ghost" size="sm">per the CLAUDE.md convention.🟠 Flyout leaf-click test incomplete (fixed)
Added a third flyout test:
"leaf click closes the flyout"— opens the flyout, clickssnav-appearance, then assertssettings-flyoutis no longer in the DOM.Review — PR #1109
CI is red and there's a behavioural gap in collapsed mode. Two changes needed before merge; the rest are minor.
🔴 Blocker 1 — CI is failing
The aggregate CI status for head SHA
a349513isfailure. Must be green before merge. Please dig into the run log at https://forge.jacquin.app/charles/claude-hooks/actions/runs/1944 and share what's failing. A few candidates from reading the diff:btn.click()on a<button>rendered through<Menu.Trigger render={…}>wrapped in<Tooltip>. Depending on how the Tooltip component forward refs / clones its child, the Menu.Trigger's synthesisedonClickmight not reach the DOM button in the JSDOM/Playwright environment. If the Tooltip adds a wrapper element,getByTestId("nav-settings")still finds the button correctly, but the synthetic click fired by Base UI may not register without a proper pointer event sequence. Worth checking whetheruserEvent.click(btn)from@testing-library/user-eventis needed instead of the raw.click()used in the other two flyout tests.getSettingsNavGroups()is called at render time insideSettingsBreadcrumbBarandSettingsInlineTree. If the Paraglide runtime isn't fully initialised in thesettings.test.tsxenvironment for the new file path, message calls return the key string rather than the translated value — the breadcrumb text-content assertions (toContain("General"),toContain("Forges")) would then fail.🔴 Blocker 2 — Collapsed rail: Settings icon never shows active state
SettingsCollapsedFlyoutrenders a plain<button>— not a TanStack<Link>. It carries thearia-[current=page]:…classes but nothing ever setsaria-current="page"on it, so the icon stays visually inert on all/settings/*routes in collapsed mode.Every other collapsed icon (board, flows, agents-live, etc.) goes through
NavLinkwhich wraps a TanStack<Link>and getsaria-current="page"viaactivePropsautomatically. Visiting/settings/appearancewith the rail collapsed now produces a rail where nothing is highlighted — a regression from the pre-PR state where the Settings link carried the active style viaNavLink.Fix: derive
isOnSettings(same logic already inSettingsFooterItem) and conditionally apply the active class, or pass anaria-currentprop directly:🟡 AC gap — sub-route
SettingsSideNavdecision undocumentedThe AC requires: "document the decision in the PR description" about whether per-page
SettingsSideNavusage is kept or dropped.From the diff,
settings.agents.tsxandsettings.service.tsxboth keep their ownSettingsSideNavfor their deeper sub-trees (agent types / per-agent secrets / config history / sessions / admin; service forge / ai-providers / container / watchdogs / design). That's the right call — these sub-trees are not in the top-levelNAV_GROUPS— but the PR description doesn't mention it. Please add a sentence or two explaining the decision so future reviewers understand the two-level nav isn't accidental.🟡 Null-safety:
groupFirstIteminSettingsBreadcrumbBarNavGroup.itemsisReadonlyArray<NavItem>. Index access on aReadonlyArrayreturnsNavItem | undefinedundernoUncheckedIndexedAccess. Even without that flag, an empty group at runtime would throw here. Given that you guard!group || !itemabove, also guard!groupFirstItemor usegroupFirstItem?.to ?? group.items[0]?.to ?? "/settings/":✅ What's good
getSettingsNavGroups()insettings-nav.tsis cleanly separated as a factory (not a module-level constant), which correctly defers Paraglide locale resolution to render time. The comment explaining why is appreciated.{!open ? <Tooltip>…</Tooltip> : trigger}correctly prevents the tooltip from firing while the flyout is open.new Set(groups.map(g => g.label))is a clean way to start all groups expanded without any state initialisation logic.currentPathmodule-level variable approach withbeforeEach/afterEachresets is sound.en.jsonandfr.jsonupdated in the same commit. ✓--no-sandboxPlaywright flags — correct fix for containerised CI; the comment explaining why is clear.isRootcondition (item.to === group.items[0]?.to && group.label === groups[0]?.label) correctly produces two-level breadcrumb only at/settings/and three-level everywhere else.Summary: Two blocking items (CI red; collapsed active state regression) and two non-blocking clean-ups. Happy to re-review once CI is green and the active indicator is wired up.
Review —
feat(settings): inline settings tree in sidenav + flyout on collapsed railThe architecture is solid and the three-mode pattern (
collapsed flyout / expanded-on-settings inline tree / expanded-other plain link) is clean and well-documented. The extraction ofsettings-nav.tsas a locale-safe factory is the right call. One critical bug blocks merge; several smaller issues need addressing.🔴 Critical — Double breadcrumb on most settings pages
SettingsBreadcrumbBaris injected at the layout level (settings.tsx→<Outlet/>), so it now appears above every/settings/*child page. But the old per-pageSettingsBreadcrumbcomponent was only removed from two routes (settings.agents.tsx,settings.service.tsx). Eight others still render their own breadcrumb inside the page content:settings.agent-types.tsx—<SettingsBreadcrumb current="Agent types" />settings.appearance.tsx—<SettingsBreadcrumb current={m.settings_appearance_heading()} />settings.language.tsx—<SettingsBreadcrumb current={m.settings_language_heading()} />settings.labels.tsx—<SettingsBreadcrumb current="Labels" />settings.repos.tsx—<SettingsBreadcrumb current="Repositories" />settings.secrets.tsx—<SettingsBreadcrumb current="Secrets" />settings.voice-input.tsx—<SettingsBreadcrumb current="Voice input" />features/service-config/ai-provider-keys-section.tsx—<SettingsBreadcrumb current="AI providers" />Users on those pages will see two stacked breadcrumbs. Remove
SettingsBreadcrumbfrom all of them (the component itself can be deleted once no callers remain, or kept for other uses if there are any).🟠 Convention violations
1. Raw
<button>inSettingsCollapsedFlyout(nav-sections.tsx)apps/web/CLAUDE.mdis explicit: "Never reach for a raw<button>. The only exceptions are the close affordance inside<Drawer>itself, third-party widgets that own their own button (<Switch>, etc.), and the Toasts dismiss surface."Menu.Triggeraccepts arenderprop — pass<Button variant="ghost" size="icon" …>the same waySettingsInlineTreeuses<Button variant="ghost" size="sm">for the group toggles. This keeps styling consistent too (the icon button used in collapsed mode currently re-implements hover/focus classes by hand instead of inheriting them from the primitive).2. Unicode breadcrumb separator
›(settings.tsx)apps/web/CLAUDE.md: "Never paste a Unicode glyph into a JSX expression — pick the matching lucide icon." Use<ChevronRight size={12} aria-hidden className="text-text-dim" />(already imported in related components;ChevronDownis imported in this PR already).3. Test clicks use raw
.element().click()on enabled elements (sidebar-nav.test.tsx)Per
apps/web/CLAUDE.md,.element().click()is the escape hatch only for disabled controls that Playwright refuses to drive. For enabled elements, use the locator API:This applies to all three flyout tests.
🟡 Quality issues
4.
SettingsInlineTreeopens all groups unconditionallyThe removed
SettingsGroupedNavwas smarter — it seeded the initial open set with General + the group containing the active route. Opening every group by default is noisier as the tree grows. Consider seeding with just the active group (falling back togroups[0]when none matches), mirroring the old behaviour:Note this also requires reading
pathnameinside the initializer —useLocationshould be called beforeuseState.5.
getSettingsNavGroups()called twice inSettingsBreadcrumbBarfindActiveNavEntryalready accepts apathnameand returns the full{ group, item }pair. Either passgroupsinto it (add an optional second arg) or derivegroupfromactivewithout the top-levelgetSettingsNavGroups()call. Minor perf, but avoids rebuilding the array twice per render.6. Group toggle buttons lack an accessible label (
SettingsInlineTree)The button's accessible name comes from the visible
<span>text, which reads as e.g. "General" — screen readers won't know it's a toggle. Addaria-label={Toggle ${group.label} group}(oraria-controlspointing at the item listid) so the affordance is unambiguous.🟢 Good
settings-nav.tsfactory pattern correctly avoids frozen-locale strings at module init time.biome-ignoreannotations are all justified and consistently explained.--no-sandbox/--disable-setuid-sandboxaddition is well-commented and appropriate for containerised CI.en.jsonandfr.json. ✅NON_FIELD_SETTINGS_TESTIDSupdated correctly insettings-manifest.ts. ✅test-router-stub.tsxuseLocationupgrade is clean and backward-compatible. ✅Summary of required changes before merge:
<SettingsBreadcrumb>from all 8 remaining settings pages (andai-provider-keys-section.tsx).<button>inSettingsCollapsedFlyoutwith<Button>.›separators with<ChevronRight>..element().click()flyout test calls to locator.click().SettingsInlineTreeopen-groups from the active route rather than opening all.6f956dab794d5e8d79a2Review — PR #1109
feat(settings): inline settings tree in sidenav + flyout on collapsed railCI: ✅ green | Mergeable: ✅ | Verdict: ✅ APPROVED
AC coverage
NAV_GROUPSextracted tosettings-nav.tsgetSettingsNavGroups()— locale-reactive, not a frozen constantNavGroup/NavItemtypes co-locatedSettingsSideNav/settings/*+ expandedSettingsInlineTreewith all 4 groupsactiveProps+aria-[current=page]:*CSSSettingsFooterItembranch onisOnSettingsopenGroupsstate seeded to all labelsSettingsCollapsedFlyoutusing@base-ui-components/react/menuside="right" sideOffset={8}!open ? <Tooltip>…</Tooltip> : triggerMenu.Item render={<Link>}merges Base UI's close handler; confirmed by the "leaf click closes the flyout" browser testSettingsGroupedNavdropped fromsettings.tsxsm:flex-rowdropped; single flex columnSettings › Group › LeafSettingsBreadcrumbBar— correct 2-level at root, 3-level elsewhere/settings/; group → first leaf of groupSettingsBreadcrumbcomponent deletedsettings-manifest.tsupdatedNON_FIELD_SETTINGS_TESTIDSsettings_breadcrumb_aria_label+settings_breadcrumb_rootSettingsFooterItemis inNavSectionswhich feeds the drawer;collapsed=falsein drawer → inline tree pathvitest.config.ts—--no-sandboxturbo.json—passThroughEnvCHROMIUM_PATH+PLAYWRIGHT_BROWSERS_PATHvisible to test taskTest coverage
sidebar-nav.test.tsx: inline tree on/settings/appearance✅; Appearance leaf active ✅; other leaves not active ✅; all 9 testIds present ✅; no tree on/board✅; plain link present on non-settings route ✅; collapsed → flyout opens ✅; flyout contains leaves ✅; leaf click closes flyout ✅settings.test.tsx:settings-side-navabsent from DOM ✅; outlet visible ✅; breadcrumb at root shows 2-level ✅;/settings/appearance→General › Appearance✅;/settings/service→Forges › Service✅;/settings/secrets→Data › Secrets✅Notes (non-blocking)
SettingsSideNavnot updated to import fromsettings-nav.tsThe AC says "all current consumers import from the shared module."
settings-side-nav.tsxisn't touched. This is intentional and justified: theSettingsSideNavcomponent serves deeper sub-trees inside the agents and service-config pages (agent-types / instance tabs, container / AI-provider sections) — these items are not inNAV_GROUPSat all, so there's nothing to import. The AC was written before that distinction was clear. Decision documented in the PR description ✓. No action needed unless #36 later moves those sub-trees into the shared manifest.Redundant active-state application in inline tree leaves
Each leaf
<Link>carries botharia-[current=page]:bg-accent/15 aria-[current=page]:text-text-primary(CSS utility) andactiveProps={{ className: "bg-accent/15 text-text-primary" }}(direct injection). Both paths land on the same Tailwind classes, so the duplication is harmless, but the CSS-utility variant is redundant given thatactivePropsalready injects the class. Fine to leave; just worth knowing if you're debugging active-highlight specificity later.isRootreference-equality dependencygroup === groups[0]works becausefindActiveNavEntryreturns the same object reference from thegroupsarray passed in — and the comment above it explicitly calls this out ("Pass the samegroupsinstance"). Correct and documented ✓.Solid execution. The factory pattern for locale-reactive nav data, the tooltip suppression on flyout open, and the clean test stubs (active-route-aware
ActiveLink,useLocationselect shim) are all well-thought-out. Merging.