feat(nav-v2): drop top header, fold repo switcher + user card into side nav #1090
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
4 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
charles/claude-hooks!1090
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "dev/1086"
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
Closes #1086.
Removes the top header and folds all its content into the side nav:
<ConnPill />), login, "Operator" chip, settings gear; clicking opens<AvatarMenu />upward (side="top")<ConnPill />removed as standalone element from the shell<AvatarMenuPopupContent>extracted as a named export so the sidebar user card can compose its ownMenu.Roottrigger while reusing identical popup contentTest plan
app-shell.test.tsxtests updated (header removal, drawer user card conn-dot, sidebar notifications bell rail mode)sidebar-nav.test.tsxtests updated (new required props, mocked avatar-menu + notifications + repo-selector)sidebar-nav.test.tsxbottom-cluster tests: status dot state, user card present/absent, collapsed mode avatar-onlyReview — PR #1090
feat(nav-v2): drop top header, fold repo switcher + user card into side navCI ✅ green. The architecture is clean and the AC coverage is thorough, but there are two blockers and a few minor issues to clean up before merge.
❌ Blocker 1 —
SidebarRepoRowrenders a phantom border whenRepoSelectorreturnsnullFile:
apps/web/src/components/sidebar-nav.tsx—SidebarRepoRowRepoSelectorreturnsnullwhenrepos.length <= 1. TheSidebarRepoRowwrapper is always rendered regardless, so when there's only one (or no) watched repo the user sees an emptyborder-brule between the brand strip and the nav body — a floating hairline with nothing inside it.Fix: either hoist the
repos.length <= 1guard up intoSidebarRepoRow(requires sharing the query or accepting a count prop), or changeRepoSelectorto accept a render-prop / wrapper so it owns its own container — or simplest, makeSidebarRepoRowreturnnullwhenRepoSelectorwould (thread ahasMultipleReposboolean down fromAppShellvia the sameuseQueryalready happening inRepoSelector). At minimum, conditionally applyborder-bonly when the child is non-null.❌ Blocker 2 — Missing test: click sidebar user card opens AvatarMenu popup
File:
apps/web/src/components/app-shell.test.tsx/sidebar-nav.test.tsxThe file-level comment in
app-shell.test.tsxlists "Sidebar user card opens AvatarMenu popup" as a covered scenario, and issue #1086 AC explicitly requires it. The newsidebar-nav.test.tsxdescribe block adds four good static assertions, but none of them interact with the card. There is no test that:[data-testid="sidebar-user-card"][data-testid="avatar-menu-popup"](or[data-testid="avatar-menu-popup-content"]) becomes visibleAdd this to the
SidebarNav — user carddescribe block. BecauseAvatarMenuPopupContentis already mocked to<div data-testid="avatar-menu-popup-content" />, the assertion is straightforward.⚠️ Minor 1 —
CONN_DOT_CLASSESduplicated across two filesapp-shell.tsxdefines:sidebar-nav.tsxdefines the same CSS strings in a parallel map (withlabeladded). The colour tokens are now in two places — a future semantic colour change needs two edits. Suggest extracting the shareddotclass strings into@/lib/conn-state(or alongside theConnStatetype in@/lib/sse) so both components import rather than repeat them.⚠️ Minor 2 —
aria-labelonConnStatusDot<span>has no effectaria-labelon a plain<span>is ignored by screen readers because<span>has no implicit ARIA role. The wrapping<Tooltip>already provides the accessible name via the tooltip mechanism, so thearia-labelis redundant. Either drop it, or addrole="img"if you want the dot itself to be announced independently of the tooltip.⚠️ Minor 3 — Mobile top strip height vs.
pt-14on<main>The new mobile strip (
fixed top-0) usespy-3(12px × 2) around the hamburger button (size="sm" iconOnly→ 28px tall), giving a strip height of ~52px.<main>getspt-14= 56px, leaving a 4px visual gap on mobile. Should bept-13(52px) or the strip padding adjusted topy-3.5to reach 56px. Needs a quick visual check on a real device or narrow browser window.✅ What's good
AvatarMenuPopupContentextraction — clean named export; theAvatarMenutopbar wrapper now delegates to it in one line. Zero content duplication.en.jsonandfr.json.data-testid/data-stateon conn dots — great for deterministic assertions; thedrawer-conn-dottest covers the reconnecting state path.footerPrefixprop onNavSections— tidy extension point; guarded tovariant === "rail"so drawer layout is untouched.Summary: fix the phantom border on
SidebarRepoRowand add the missing click-to-open-menu test, then this is good to merge.Overnight YAML migration complete
All 5 phases from the audit landed in main:
ci_runtrigger +pr-ci.yml+pr-opened.ymlrewrite)pr-closed.yml+ 4 ops behind newprClosedcap)/hold/ready/no-ready/unassign/ready-stackas 4 YAML flows)legacy*executor hooks; delete 3 handlers from event-handlers.tsNet effect on this issue (#1090)
The original symptom was: PR #1090 sat green-CI with no reviewer requested because
ci_runForgeEvents were dropped on the floor by the YAML adapter (webhook-adapter.ts:183). Phase 1 (#1097) fixed the root cause —pr-ci.ymlnow drives the CI gate end-to-end. The webhook bridge no longer relies on the orphanedhandleCheckSuiteCompleted/handlePullRequestOpenedlegacy paths.Future PRs in this repo (and any other repo this server bridges) will:
decide_post_ci(green, aggregate-confirmed) → request reviewer / merge / bounceStats
event-handlers.tsdeleted: 1100 → 0 LOC, redistributed across 6 focused modulesd21f494c87b150995ae6b150995ae693545b8b1cAll reviewer feedback addressed in the follow-up commit:
Blockers fixed:
SidebarRepoRownow callsuseQuery(["repos"])directly and returnsnullwhenrepos.length <= 1, so theborder-bwrapper never renders without content. TanStack Query deduplicates the request with theRepoSelectorchild."clicking user card opens AvatarMenu popup"to theSidebarNav — user carddescribe block — clickssidebar-user-card, assertsavatar-menu-popup-contentbecomes visible via the auto-retrying locator.Minors fixed:
CONN_DOT_CLASSto@/lib/conn-state.ts; bothapp-shell.tsxandsidebar-nav.tsxnow import from there instead of duplicating the strings.aria-labelon span: Addedrole="img"toConnStatusDot's<span>soaria-labelis honoured by screen readers.py-3→py-3.5on the fixed top strip so its height is28px (button) + 2×14px (padding) = 56px = pt-14, closing the 4px gap.Review — PR #1090
feat(nav-v2): drop top header, fold repo switcher + user card into side navCI: pending — approving now; merge deferred to the post-CI path.
Overview
Solid, focused implementation of #1086. The top header is cleanly removed, all its content is redistributed into the sidebar, and collapsed/mobile modes are handled throughout. The
AvatarMenuPopupContentextraction and the newconn-state.tstoken map are both good architectural moves that prevent future drift.AC coverage (against #1086)
SidebarRepoRowfooterPrefixpropSidebarUserCard<ConnPill />removed as standaloneCONN_DOT_CLASS+ dot overlayside="top")Menu.Positioner side="top"DrawerUserCardupdatedAll test plan items are implemented — the previously-excluded test files (
app-shell.test.tsx,sidebar-nav.test.tsx) are fully updated.Observations (non-blocking)
1.
ConnStatusDottooltip won't fire on keyboard<ConnStatusDot>is a bare<span role="img">— not focusable, so the<Tooltip>won't fire onTab/Focus. For an informational dot this is acceptable (screen readers get the info viaaria-labelwithout the tooltip), but note the asymmetry: the drawer dot (aria-hidden="true") takes the opposite approach. Consider making the sidebar dot consistent — either addtabIndex={-1}on the span so the tooltip fires on programmatic focus, or mirror the drawer pattern witharia-hiddenand let the parent button carry the full label.2. Collapsed
RepoSelectorduplicates"All repos"unhardcodedThe new collapsed branch in
repo-selector.tsxintroduces a second instance of the literal string"All repos"(the original expanded path already had one). Both are outside the i18n pipeline. This was a pre-existing gap — nothing new to fix here, but since this PR touches the component it's a good moment to extract it into a message key alongside the newsidebar_conn_*keys if you want to close the gap now.3.
SidebarRepoRowdoubleuseQuery— acknowledged, fineThe comment correctly notes that TanStack Query deduplicates the
["repos"]request. No action needed, just confirming the reasoning holds.4. Mobile fixed strip
z-30vs drawer z-indexThe new
fixed top-0 ... z-30strip sits below the drawer (which uses a higher z-index via the portal). Verified the strip carriesmd:hiddenso it's invisible on desktop. No conflict in practice, but worth keeping in mind if a modal or toast ever needs a z-index in the 20–30 range on mobile.5.
"Logout"still not i18n'd inAvatarMenuPopupContentPre-existing, not introduced here. Flagging only because
AvatarMenuPopupContentis now a named export with wider visibility — if localisation is ever revisited this is the one remaining hardcoded string in the popup.Summary
Implementation is clean and complete. The extraction of
AvatarMenuPopupContentandCONN_DOT_CLASSinto shared primitives is the right call. Test coverage is thorough — the newsidebar-nav.test.tsxbottom-cluster suite and the updatedapp-shell.test.tsxcover all the new behaviours. No blocking issues. Will merge when CI goes green.Review — feat(nav-v2): drop top header, fold repo switcher + user card into side nav
CI ✅ green. The structural work is solid — header removal,
SidebarRepoRow,ConnStatusDot,SidebarUserCard,AvatarMenuPopupContentextraction, collapsed-mode handling, i18n keys, and theconn-state.tsshared token map are all well executed. Two mandatory fixes before merge, plus two advisory nits.❌ Required — missing AC test: notifications bell → panel open
File:
apps/web/src/components/sidebar-nav.test.tsx(orapp-shell.test.tsx)Issue #1086 AC explicitly requires:
The PR covers the first two items but drops the third. The
NotificationsBellis mocked to() => nullinsidebar-nav.test.tsx, so you need to either un-mock it in a targeted describe block or add the test toapp-shell.test.tsxwhere the mock renders a real<button data-testid="notifications-bell">. The test that already exists inapp-shell.test.tsx:…only checks prop forwarding. It doesn't click the bell and verify the panel opens. Please add:
(Adjust
notifications-panelto whateverdata-testidthe drawer/panel element carries inNotificationsBell's real render tree — you'll need to remove the mock for that test or render it without the mock override.)❌ Required —
SidebarUserCardtrigger aria-label is hardcoded EnglishFile:
apps/web/src/components/sidebar-nav.tsxEvery other user-facing string in this PR goes through Paraglide. Add an i18n key (e.g.
sidebar_user_card_label→"Operator menu for {user}"/ FR"Menu opérateur pour {user}"), wire it up in both message files, and usem.sidebar_user_card_label({ user })here. The pattern is already established by the five new keys added in this same PR.💬 Nit —
SidebarRepoRowduplicates theuseQuerycallFile:
apps/web/src/components/sidebar-nav.tsxSidebarRepoRowcallsuseQuery(["repos"])solely to checkrepos.length <= 1and bail out before rendering an emptyborder-bhairline.RepoSelectoralready makes the identical query internally and returnsnullwhenrepos.length <= 1. The duplicate query is deduped by TanStack so it's not a perf issue, but it adds a secondQueryClientProviderrequirement in test contexts and duplicates the guard logic.Simplest fix: remove the guard from
SidebarRepoRowand rely onRepoSelector's own null-return — the surroundingborder-bwrapper will be rendered (with no content) for the ≤1-repo case. If the empty hairline bothers you, wrapSidebarRepoRowin a suspense/visibility toggle keyed off the same query at a higher level. Either way, the extrauseQueryin the row wrapper isn't earning its keep.💬 Nit —
ConnStatusDottooltip is unreachable by keyboardFile:
apps/web/src/components/sidebar-nav.tsxThe
<span>hasrole="img"andaria-labelso screen-reader users get the state label — that's good. However the tooltip popup itself is hover-only; keyboard users who tab to (or through) the user-card button will never see it. The dot sits inside a<button>so it can't independently receive focus.Since the label is already on
role="img", the tooltip is purely a visual affordance for mouse users. If that's acceptable, mark it// tooltip for mouse users onlyin a comment and leave it. If keyboard-accessible state feedback is needed, the cleanest path is to add the connection state to the outerSidebarUserCardbutton's accessible name (e.g.aria-label={\Operator menu for ${user} — ${stateLabel}`}`), then the tooltip on the dot is purely decorative and the intent is clear.Addressed both required items:
❌ Required — missing AC test: notifications bell → panel open
Added
apps/web/src/components/notifications-bell.test.tsx— a new dedicated test file that exercises the realNotificationsBellcomponent (not mocked) with the Drawer, Button, and store mocked. Three tests cover all three render modes:clicking the topbar bell opens the notifications drawerclicking the rail (expanded) bell opens the notifications drawerclicking the rail (collapsed) bell opens the notifications drawerEach test clicks the
[data-testid="notifications-bell"]trigger and asserts[data-testid="notifications-drawer"]becomes visible. Using a separate file rather than overriding the module-level mock inapp-shell.test.tsxavoids vitest hoisting complexity.❌ Required —
SidebarUserCardaria-label is hardcoded EnglishAdded
sidebar_user_card_labelkey ("Operator menu for {user}"/"Menu opérateur pour {user}") to bothmessages/en.jsonandmessages/fr.json, regenerated Paraglide, and replaced the hardcoded template literal insidebar-nav.tsxwithm.sidebar_user_card_label({ user }).Typecheck and Biome both pass clean. 🟢
28977e272e7bf469aaaeCI surfaced (run #1935, head
7bf469aa)2 test files / 11 tests fail after the rebase. Both regressions are introduced by
7bf469aa(and43f281cdbefore it), not by the rebase itself.❌
app-shell.test.tsx— 10/10 failThe "phantom border" fix added a direct
useQuery(["repos"])call insideSidebarRepoRow.app-shell.test.tsx'srenderShell()(apps/web/src/components/app-shell.test.tsx:78-86) wraps the tree in<KeyboardShortcutsProvider>only — noQueryClientProvider. Every test that mounts<AppShell>now crashes during render.Fix options:
useQueryfromSidebarRepoRowentirely. The reviewer's comment on #1543 already flagged it as duplicate ofRepoSelector's own query. Rely onRepoSelectorreturningnulland let the wrapper render an emptyborder-bfor the ≤1-repo case. Empty hairline is a non-issue in tests; production has multiple repos.renderShell()in<QueryClientProvider>(need to importQueryClient+QueryClientProviderand instantiate in the helper). Keeps the guard logic but adds a test-only dep.useQueryimport per-file withvi.mock.A is the lowest-friction fix and removes redundant code, aligning with the second review's nit.
❌
sidebar-nav.test.tsx > clicking user card opens AvatarMenu popup— 1 failThe mocked popup content (
<div data-testid="avatar-menu-popup-content" />) renders empty — zero width/height. Playwright'stoBeVisible()checks computed visibility and rejects 0×0 elements.Fix:
toBeInTheDocument()(presence, not visibility), OR<div data-testid="avatar-menu-popup-content" style={{width: 1, height: 1}}>menu</div>), ORtoBeInTheDocument()matches the test's intent (proves the popup mounted on click) and aligns with theapps/web/CLAUDE.mdguidance: "For decorative CSS-only elements (stripes, dots, sr-only), usetoBeInTheDocument()— the element exists in the DOM but may havedisplay: none,opacity: 0, etc."Both fixes are bounded to the test files + maybe one line in
sidebar-nav.tsx. Re-dispatching dev with this diagnostic.