feat(dashboard): pin a nested sub-item to the left nav rail - #9002
Conversation
UX Review (Fable 5) — 🟡 CONCERNSUX-level review of All evidence reconciled — every control and state appears in the ten screenshots, the blind read ran, and there is no hard-swap transition needing a recording. Final review: UX-Verdict: CONCERNS Pin control works and reads correctly, but its at-cap state looks live and eats the click, and the pinned row's icon contradicts its own tab. Watch
Suggestions
[UX-REVIEWED] 1e1074e |
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Registry-backed pinning that reuses the existing layout-persistence and surface-registry seams, defaults to no change, and declines generalization until a second host exists — sound and proportionate. Suggestions
[DESIGN-REVIEWED] 1e1074e |
First Principles Review (Fable 5) — ✅ PASSPremise-level review of All evidence gathered. The change is a self-contained opt-in feature; the mechanisms it claims to mirror ( First-Principles-Verdict: PASS Every item traces to issue #8500 or a constraint in the repo; the only unearned surface is a two-line read-side cap that guards nothing reachable. What this change shipsIntent: let a user promote an Agent Capabilities tab they use daily onto the left nav rail — an ADDITION (one of #8500's four asks, by declaration).
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 1e1074e |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsNo blocking issues; one advisory active-state gap. FINDING — website/src/App.tsx:416 — [OPUS-REVIEWED] 1e1074e Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
GPT 5.6 Review — ✅ no blocking findings (all downgraded on adjudication)GPT 5.6 flagged blocking issues on This comment is updated in place on each push. Review detailsBLOCKING -- website/src/components/PinSurfaceButton.tsx:53 -- The mobile root pins Crews although no sub-item is open FINDING -- website/src/App.tsx:2852 -- On desktop Adjudication (Opus 4.8) — is blocking on each finding proportionate?I have enough to rule. Verified facts:
Harm is LOW: an unintended pin on a narrow viewport, immediately visible and reversible by the same one-click toggle, self-correcting. The remedy's permanent maintenance load (viewport coupling in a component built to avoid it) exceeds that trivially recoverable mispin. LOW — conditions: mobile root renders control (SidePanelLayout.tsx:412), False positive or not applicable? A repository writer can comment: |
75b5f51 to
1c84346
Compare
1c84346 to
fb48307
Compare
|
@- |
|
Round 2, on First: the empty description was my error and it is fixed. All three reviewers The
On the retitle alternative: I searched the catalogs for a reusable So the fork is a maintainer's, and both arms are costed: drop the standalone
Still open and not waived: no screenshots, so the blind read has not run. That |
fb48307 to
12f91f5
Compare
12f91f5 to
dfd901d
Compare
dfd901d to
b0ba576
Compare
Agent Capabilities' tabs (Steering files, Skills, Hooks and the rest) are reachable only by opening that page first, so a daily destination sits two clicks deep while the rail itself is fixed. Each of those tabs is now registered as a real surface carrying a new `pinnable` flag, which keeps it OFF the rail until the user promotes it. The pinned set persists in localStorage under `mc-nav-pinned`, mirroring the `mc-app-nav-hidden` contract the Library launchpad already uses for app rows: one module owns read/write, and a same-tab window event repaints the rail because a localStorage write does not fire `storage` in its own tab. Storing the PINNED set rather than the hidden one is deliberate. Both keys are shaped so that an id absent from storage means "leave this row as it shipped". App rows default visible, so their absent-means-default set is the hidden one; a promoted sub-item defaults to off-rail, so here it is the pinned one. Storing hidden ids would require seeding every sub-item as hidden on first run. Default is the empty set, so installing this changes nothing until a user pins something. Pins are capped at 5 because the rail's Main group lives in a non-scrolling block; the cap is enforced on write and again on read. Refs #8500
b0ba576 to
1e1074e
Compare
|
Board is green on Recording the four UX items still open, none of which I am changing on a green
Scope, restated plainly: this ships PINNING only -- one of the four asks in #8500. |
Problem / Motivation
The left nav rail is a fixed list. Everything under Agent Capabilities - Steering
files, Skills, Hooks, Prompts, Knowledge, Crews, Agent Templates, Connections,
Workflows - is reachable only by opening that page first and then picking a tab,
so a destination someone uses every day sits two clicks deep while the rail
itself cannot be changed.
Issue #8500 asks for four things. This PR ships ONE of them: pinning. See
"Deliberately not built" below.
Why it matters
The cost is paid on every visit, by exactly the people who use a sub-item most:
the deeper an item is, the more the fixed layout mismatches real usage. There is
currently no way to express "I live in Steering files" to the dashboard.
What changed (motivation -> approach -> change)
Goal. Mark a sub-item, it appears at top level; unmark it, it leaves. Persist
that per user, and change nothing for anyone who does not use it.
The two questions that decided the design.
Where does per-user dashboard layout state already live? It already exists and
is a near-exact match, so this PR introduces no new persistence mechanism.
website/src/lib/appNavHidden.tsis the sidebar pin-persistence contract for theApps group: a localStorage key, one module owning read/write, and a same-tab
window event because a localStorage write does not fire
storagein its own tab.lib/navPinned.tsmirrors it key for key. Worth recording for whoever reads#8500 next: the codebase has NO identity-keyed per-user preference store. Layout
preferences (
mc-app-nav-hidden,mc-app-nav-order,mc-nav) live in thebrowser; only appearance and onboarding (
theme_mode,language,onboarded)are server-backed through the
dashboardsection ofconfig.json. So"per-user" in this codebase means per-browser for layout, and this PR follows the
layout precedent rather than inventing a third category.
What is a "sub-item"? Nothing in the nav data model represented nesting. The
rail is a flat list of
Surfacerows; "Steering files" was aSidePanelLayouttab, and "Discover" is hardcoded JSX. Rather than invent a parallel catalog with
its own labels and icons, each promotable tab is now registered as a REAL
surface carrying a new optional
Surface.pinnable. It stays ingetBuiltinSurfaces(), so every registry-wide invariant -labelKeycoverageincluded - keeps covering it for free, and a promoted row gets the rail's
ordinary label, icon and active handling.
pinnablekeeps it off the rail untilthe user promotes it, which is the same conditional-inclusion job
appOnly,hiddenFromNavandpreviewFlagalready do on that interface.What was built.
Surface.pinnableplus a namedgetPinnableSurfaces()getter. Named ratherthan inlined per call site for the reason the neighbouring
getAdvertisedSurfaces()docstring already gives: a call site that forgets thepredicate leaks a row silently.
catalog keys
CapabilitiesPagealready renders (pages.capabilitiesPage.*_label)rather than minting
nav.*twins. One destination, one name, and zero newi18n catalog entries.
lib/navPinned.ts:mc-nav-pinned, read/toggle/subscribe/hook, mirroringappNavHidden.PinSurfaceButton, rendered from a page'sheaderRight. It derives its ownsubject from the URL instead of taking a prop, which is what keeps
SidePanelLayoutcompletely untouched - the four other pages built on thatshared component are byte-for-byte unaffected.
App.tsxdrops unpinned pinnable rows, placed in the singlederivation that file designates as the only correct place to filter.
Deliberately inverted vs the module it mirrors.
appNavHiddenstores theHIDDEN set; this stores the PINNED set. Both are shaped so an id absent from
storage means "leave this row as it shipped". App rows default visible, so their
absent-means-default set is the hidden one. A promoted sub-item defaults to
off-rail, so here it is the pinned one. Storing hidden ids would require seeding
every sub-item as hidden on first run and would put every future sub-item on
every existing user's rail unasked.
Default is the empty set, so installing this changes nothing until a user pins
something. That is asserted, not asserted-in-prose: see the first rail test.
The default tab could not be pinned, and a test of mine said that was correct
Review caught this, and it is the sharper of the two findings because the miss was
not an ABSENT test -- it was a test asserting the defect.
SidePanelLayoutexpresses"the first tab" as the ABSENCE of
?tab=on desktop: it deletes the param when youselect that tab (
SidePanelLayout.tsx:295) and its URL-sync effect never writes itback (
:369). So/capabilitieslands on its first tab with no param,PinSurfaceButtonmatched on the raw param alone, found no surface and returnednull -- the control was missing on the most prominent sub-item, the one the feature
most needs to cover.
My own test
renders nothing on the host panel with no tab namedasserted exactlythat absence, and its comment shows I had the fact and drew the wrong conclusion
from it: "SidePanelLayout shows its first tab BEFORE WRITING the param, so there is
no sub-item to attribute a pin to YET". I read a permanent desktop convention as a
transient startup state and then wrote the misreading down as intent. The case is
corrected in place rather than deleted, and its comment now records the wrong
premise, because a reader who trusted the old rationale would reintroduce the bug.
The fix: the host passes its own first tab (
defaultTab={tabs[0]?.key}) and thecontrol resolves a missing param to it -- derived, not hardcoded, from the same
array
SidePanelLayoutcomputesfirstfrom, so the two cannot drift. Thecomplement is pinned too: with no default supplied the control stays inert rather
than guessing a tab, so the fallback must come from the host.
No screenshot covered the param-less URL either, so this was invisible to the
evidence as well as to the suite. There is a scene for it now.
The dead-key guard caught an incomplete subtraction
Frontend Tests (3)went red onsrc/i18n/deadKeys.test.ts: 30 keys referencednowhere against a baseline of 29. Deleting the
hookspalette row removed the onlycall site of
components.commandPalette.providers.pagesProvider.hooksand left thekey in the catalogs -- exactly the "copy deleted without its key" case that test
names. The key is deleted from the 13 catalogs that carried it, returning the count
to 29. The baseline was NOT raised: banking a new dead key to green a gate is the
thing the guard exists to prevent. Reinstating the key reproduces the CI failure
exactly, which is how I know the diagnosis instead of assuming it.
Stale prose is a defect, and four pieces of it shipped
Review found that the artifacts contradicted the fixes, and every instance was
mine.
PinSurfaceButton's own docstring still said the control "is absent until atab is actually selected"; the Known-gaps list still named three gaps that were
closed; the Screenshots section still said "not captured"; and the
builtins.tsxglyph rationale still cited the palette
/hooksentry this PR deletes. I hadcorrected the TEST that asserted the old behaviour and left the prose asserting it,
which is the same defect in a different file -- and it is the argument I made myself
one round earlier, that a reader who trusts a stale rationale reintroduces the bug.
All four are corrected in place, with the wrong premise recorded rather than
quietly overwritten.
Correcting the glyph rationale changed the code, not just the comment.
hookshadbeen moved from its tab's Webhook glyph to Zap BECAUSE the palette's standalone
/hooksentry drew a Webhook. Deleting that entry voided the reason, and thewebhookssurface ishiddenFromNavso it has no rail row -- nothing owns Webhookon the rail. The file's own rule ("the tab's own glyph EXCEPT where it is already
spoken for on the rail") then says keep the tab's, so
hookshas its Webhook backand one of the three glyph divergences is gone. Note this one is NOT test-pinned:
mutating the glyph reddens nothing, and I am reporting that rather than implying a
guard exists.
The at-cap label, on the fourth pass
I deferred this three times on the grounds that it costs a new key in every
translated catalog. That cost was real --
catalogParity.test.tsrequires every keyin all 11 non-English catalogs in the same commit, and an English-only key reddens
it in 11 places, which I measured before writing anything. What I had not checked is
that the repo's own translation driver is built for precisely this and explicitly
does NOT call a model: "sending it is the caller's job (an agent, a contributor,
whatever)". The blocker I kept citing was a step I had never attempted.
So the control no longer lies while disabled.
components.pinSurfaceButton.pin_limit_reachedis added to
en.manual.jsonand all 11 translated catalogs, composed from eachlocale's OWN existing vocabulary rather than invented: each one reuses the verb and
the noun that locale's existing
pin_to_sidebarstring already uses, so for examplethe Chinese entry keeps the verb that string uses for pinning instead of the
different verb meaning "fix in place", and the German, Japanese and Russian entries
each reuse their own established pin verb. Shaped after the sibling precedent
components.bottomTerminalPanel.maximum_terminals("Maximum {{n}} terminals"), same role and same
{{n}}contract.en-XA.jsonwasREGENERATED with
npm run i18n:pseudo, never hand-edited, as that file requires.Declared plainly: the 11 translations were authored in this change and have not had
a native-speaker pass. The placeholder is preserved in every one and parity passes,
but the wording deserves a reviewer who reads the language. Dropping one locale's
value reddens parity, naming the key.
Declined: moving the control into SidePanelLayout
Review is right that every future host must remember
defaultTab={tabs[0]?.key}orthe control silently vanishes on that panel's landing tab, and right that
SidePanelLayoutalready ownstabs[0]. But moving the control (or the fallback)into that component changes a five-consumer shared component for the benefit of
hosts that do not exist yet, and this PR's whole shape is built on not touching it.
Review's own framing -- "when a second host adopts pinnable tabs" -- names the right
trigger, and that is the moment to do it. Until then the seam is one line in one
host, and the inert-without-a-default behaviour is test-pinned so an omission
degrades to "no control" rather than to a wrong pin.
Screenshots
Captured by
website/scripts/capture-nav-pin-subitems.mjs, committed with thechange. It drives the REAL SPA with the backend stubbed -- the same way
capture-leftnav-footer-border.mjsdoes, because the subject is the railApp.tsxrenders and an isolated entry would only prove something about are-created rail. Every scene ASSERTS its claim against the DOM before the frame
is taken and fails the run otherwise, so a broken stub cannot ship as a picture
of a working feature. That is not a hypothetical: the first attempt at this
capture produced four plausible-looking PNGs of an app that had rendered no rail
at all, and the assertions are what caught it.
Rail with nothing pinned -- a capability tab is NOT on the rail until you pin it:
Steering files pinned -- it joins the Main group, above the Apps section:
The promoted row lights while its own tab is showing:
Collapsed icon-only rail -- the promoted row keeps a glyph distinct from Discover's compass below it:
Light theme:
The control offers to pin the tab you are looking at:
Once pinned, the same control offers to unpin:
At the five-pin cap a sixth tab cannot be pinned, and the control is disabled:
The landing view of /capabilities carries no
?tab=at all, and still offers the control:Nothing pinned, and the tab is still searchable in the command palette -- the
effect the description declares, which no frame showed until now:
A regression these frames caught, which no assertion would have.
SidePanelLayoutrendersheaderRightinside aflex items-center justify-betweencontainer. Passing a Fragment therefore made the pin control andthe existing Apply & Restart button two SEPARATE flex items, and
justify-betweenstranded the pin in the middle of the header, ~250px from theaction it belongs beside. Before this change
headerRightwas a single node, sothe defect arrived with the pin control -- mine, not the layout's. Fixed by
grouping the two into one element.
aria-pressedanddisabledwere correct thewhole time; only looking at the frame showed it.
PinSurfaceButtonalso gaineddata-testid="pin-surface-button"so the harnesscan address it without locating by the label it is asserting -- the first version
matched
button[aria-pressed]and silently found a different toggle, which madeone scene pass for the wrong reason.
Command palette: a declared consequence of using the registry
Registering the tabs as real surfaces means they are ADVERTISED surfaces, and
collectPages()in the Search Everywhere provider reads the samegetAdvertisedSurfaces()list (pagesProvider.ts:161). So eight capabilitytabs become searchable in the palette whether or not the user has pinned them.
That is intended rather than incidental -- the registry's own docstring defines an
advertised surface as one offered everywhere a destination can be found, and such
a tab IS always reachable inside its host panel; only its rail row is opt-in --
but the first version of this description did not name it, so it is named here.
Two things fall out of it, both handled:
getAdvertisedSurfaces()listApp.tsxrenders the left rail from". Thischange makes that false, because the rail additionally drops an unpinned
pinnablesurface. Corrected in place rather than left standing beside acorrection.
knowledgeEXTRA_PAGES row carried the route/capabilities?tab=knowledge,byte-identical to the new
capabilities-knowledgesurface.collectPages()dedupes by exact route with the registry loop running first, so that row became
unreachable dead code. Deleted, along with its orphaned title-key row and a
now-unused icon import. Ownership of the entry moved to the registry, and the
palette test now pins the move: dropping the surface from its fixture takes the
count to 0.
SUBTRACTION TAKEN, after I declined it twice. The
hooksEXTRA_PAGES row is gonetoo, with its title key and a then-unused icon import. My earlier reason for
keeping it -- that unlike
knowledgeits route DIFFERS, so it is live code ratherthan dead -- was true but not sufficient: both routes render the same
HooksPageunder the identical title "Hooks", and this file's own
webhookscomment sets thestandard that adjacent hooks results must differ in BOTH title and icon. The
standard is the file's, not the reviewers', and this change is what broke it.
The test that used
Hooksas its exemplar of "routed-but-not-in-rail EXTRA_PAGES"was neither deleted nor loosened: it now uses
Developer, the same KIND of entry(a real route with no rail surface), so the property it exists for is unchanged and
only the specimen moved. Mutation confirms it still bites -- deleting the
developerrow reddens it.mc-agentsis deferred rather than dropped: its routeredirects to
/capabilitiesroot rather than to a tab, making it a near-twinrather than a duplicate.
A second regression the screenshots caught. On a promoted tab the rail lit TWO
rows at once -- the promoted row and its host "Agent Capabilities" -- because the
host's active test is a prefix match on
/capabilitieswhile the promoted row's isan exact
?tab=match, so both passed. Two rows claiming "you are here" answer thequestion with neither. The host now yields to the promoted row that owns the tab.
Note what missed it: this suite already asserted the promoted row IS lit, which a
two-lit rail satisfies. The new test counts lit rows instead, and the capture
harness now counts across the whole rail rather than checking one row -- its
lit > 0was the same weakness in the same shape.Overflow: a real consequence, measured
The rail is three blocks. The top block (
App.tsx, brand + Main rows + Appsheader) is
shrink-0and does NOT scroll; the only scroll region is the middleflex-1 min-h-0 overflow-y-auto, which holds the dynamic app list; the bottomblock is
shrink-0. Promoted rows join the Main group in the non-scrolling topblock, so they take height from the app list's scroll area rather than becoming
scrollable themselves.
Handled with a cap:
NAV_PINNED_LIMIT = 5, enforced on write AND again on readso a hand-edited or previously-larger stored value cannot render an unbounded
rail. This is the same defence the existing
APPS_NAV_LIMIT = 6provides for theapp list. Reaching the cap REFUSES the new pin rather than evicting an older one,
because silently dropping a row the user pinned earlier is worse than a refused
click. Residual, stated rather than hidden: on a very short viewport the app
list's scroll area gets shorter. That is true of adding any Main-group row and is
not specific to this change; the cap is what bounds it.
Deliberately not built (issue #8500 asks for four things)
built. Note the machinery is closer than it looks:
mc-app-nav-hiddenalreadyhides rows, but only ever filters the Apps group, so extending it to static
surfaces is its own change.
(
mc-app-nav-order, dnd-kit) and is a separate item.join the Main group; they are not nested under their host.
as pinnable. Settings, Developer, Schedule and Webhooks also use
SidePanelLayoutand would each need their tabs registered; the seam is nowthere for them and costs one entry each.
Tests
31 new assertions across four files, each mutation-verified (below).
lib/navPinned.test.ts(13) - storage contract: absent / malformed /non-array / tampered values all degrade to "rail as it shipped" and never
throw; toggle round-trips; the cap refuses a 6th pin WITHOUT evicting an
earlier one (per-item pairing, not just a size check); the cap also truncates
on read; unpinning still works at the cap; every persisted write dispatches the
sync event and a REFUSED pin dispatches nothing; the stored array is sorted.
test/App.navPinnedRail.test.tsx(7) - real rail, real derivation: withnothing pinned NO pinnable surface has a row (asserted across EVERY pinnable
surface, not one sample - this is the regression guard for the whole feature);
a pinned id gets a row while an unpinned sibling does not; the filter is live
on the same-tab event in both directions; the ordinary rail is set-identical
before and after a pin; and a promoted row paints as current for its own tab
but not for a sibling tab.
components/PinSurfaceButton.test.tsx(10) - inert on an unrelated page, on abare host URL, and on an unregistered tab; toggles and reports
aria-pressed;the two states do not announce identically; disabled at the cap when unpinned;
still live at the cap when already pinned, so the cap is escapable.
i18n/navLabels.test.tsx(+1) - closes a vacuity I found in the existingguard. That file rejects a raw key only by its
nav.prefix, so for asurface whose
labelKeylives in another namespace an unresolved key is truthyand does not match
/^nav\./- it would sail through. The new case assertseach pinnable label resolves to something different from the key itself, in
every
SUPPORTED_CODESlanguage.Rows are located by the
data-onboarding-navnavId, never by the label underassertion, so a test cannot pass by finding a sibling that renders the same
string, and the assertions are not coupled to catalog wording.
Mutation verification - 9 mutations, 9 DISTINCT reddened sets, each applied
from a uniqueness-asserted anchor, each read from the runner's own failure line,
each reverted and the tree confirmed clean afterwards:
slice) removedThe last two are the same property from opposite sides and redden DIFFERENT
sets, which is what says the two observables are independently pinned rather
than one of them being decorative. That the nine sets all differ is also what
rules out a harness artefact.
A tenth mutation was attempted with a multi-line anchor, silently matched
nothing, and is reported here as a missing measurement rather than a pass; it was
re-anchored to a single line and is the last row above.
Also run green, unchanged:
surfaces.test.tsx(31),previewSurfaces.test.tsx(27),
App.appNavHiddenFilter.test.tsx(5),App.test.tsx(104),CapabilitiesPage.knowledgeTab(2),CapabilitiesPage.tabLanguage(1),MembersPage.test.tsx(37).tsc --noEmitclean;eslintclean on everychanged file.
Manual verification
Performed - see the Screenshots section, ten committed frames. Every claim
above rests on the automated coverage and the mutation table, and the load-
bearing behaviours (default-unchanged rail, live toggle, active state, cap
refusal) are each pinned by a mutation-verified test rather than by a click-
through.
Screenshots / video
Captured. Ten frames under
temp-screenshots/nav-pin-subitems/, produced bywebsite/scripts/capture-nav-pin-subitems.mjs(committed) and shown in theScreenshots section above. An earlier revision of this PR said "not captured, and
I am not waiving the requirement", which was true then and became false without
this section being updated -- exactly the stale-artifact problem review flagged
elsewhere in this description. The
no-screenshotslabel was never applied: it ismaintainer-scoped, and applying it would skip the gate rather than satisfy it.
To see it: open Agent Capabilities, pick any tab, and use the pin control left of
the Restart button in the page header. The row appears in the rail immediately
(no reload). Unpin from the same control. With nothing pinned the rail is
byte-identical to today.
Related Issues
Refs #8500
Refs, notCloses, deliberately: #8500 asks for four things and this shipsone. Closing it would credit this PR with the hide/collapse, reorder,
sub-listing and badge work that is not here.
Pattern harvest
Rule candidate: review-prompt
Pattern: an invariant test that rejects a raw i18n key by matching one namespace
prefix (
/^nav\./) is vacuous for any key outside that namespace - it mustcompare the resolved string against the key itself instead. Found live in
navLabels.test.tsxwhile adding surfaces whoselabelKeyis apages.capabilitiesPage.*key; fixed in this PR for the new surfaces.Checklist
rail's contents
Notes for review
website/src/App.tsxis the file thisshares with Collapsible chat input and pinned message for more reading space #8453 (collapsible chat input) and Header: keep CPU/MEM/DSK session stats visible when width is tight #8287 (header stats when width
is tight), both in flight. Measured at the time of writing: neither had an open
PR, and none of the 17 open PRs on this account touched
App.tsx,SidePanelLayout,surfaces/orappNavHidden. My change is confined to thenav rail - one filter expression, one
activehelper, one carried-throughfield - and touches no chat composer or topbar code.
PinSurfaceButtonusespages.libraryPage.pin_to_sidebar/unpin_from_sidebar. Same grammaticalrole (the accessible label of a control that adds or removes a sidebar row) and
the same
{{name}}interpolation contract, already translated in everycatalog. The alternative - a
components.sidePanelLayout.*twin - means onesentence duplicated across 14 files, which is the drift hazard the reuse
avoids. Happy to move both keys to a shared namespace if reviewers prefer it;
that is a rename, not a translation.
"Hooks" palette entries are gone (subtraction taken), the at-cap control now
states its limit instead of promising a pin, and the bare
/capabilitiesURL nowresolves to the host's first tab. What remains: rail rows carry no
aria-currentanywhere on
main, so a promoted row's active state is class-only and the testsassert the class. Exposing
aria-currentacross the rail is a pre-existing gapand out of scope here.