fix(app): your sidebar layout survives the agent restarting as the app starts (BRA-3095)
Summary
After an update, the sidebar sometimes came back as the default layout: Customize sidebar's arrangement looked lost. The layout is read once, at connect, from the laptop agent (GET /v1/app/sidebar). If that read failed, for example because the agent was restarting while the updated app started, the app kept the default for the whole launch and marked it loaded. The next change then saved the default over sidebar.json, which made the reset permanent.
connect
loadNav()
- GET /v1/app/sidebar ── fails ──> layout = default, loaded = true (never read again)
+ GET /v1/app/sidebar ── fails ──> layout = default, loaded = false
+ poll(): again after 1 s, doubling to 10 s, not while hidden
+ event stream reconnects ──> retryNav(): try now
+ ── succeeds ──> layout = stored, loaded = true (a missing or empty file is the default)
change (Customize sheet, right-click, drag, ⌘K)
- save(default + change) // could write over the stored file
+ navReady()? no ──> toast "Your saved layout hasn't loaded yet. Trying again…", nothing saved
+ yes ──> save, one at a time (oneAtATime), keeping unavailable ids in place
A failed read is now tried again until the stored layout arrives, and nothing can change the layout before that. More's Customize sidebar… is disabled with a note. Any other change shows the same note as a toast.
The PR also fixes two smaller problems on the same write path:
- Places that weren't available were dropped on save.
placeandmovesaved only the ids available at that moment. A pinned plugin that was off or still loading lost its place the next time anything else changed. Those ids now stay at their stored index. - A failed save could undo a later one. Saves ran concurrently, so a write that failed could roll back over one that had succeeded after it (Codex adversarial review). Saves now run in turn.
app/src/lib/
├── nav-core.ts # arrange / migrate / place / move / navSync: pure, node-tested
├── nav-core.test.ts # new
└── nav.ts # wires navSync to the agent; loadNav (poll), retryNav, navReady
The file format doesn't change, and no Go code changes.
Same shape elsewhere, not changed here: loadProjects (lib/project-groups.ts) also marks itself loaded after a failed read and never retries, so project sections can be missing for one launch. It can't overwrite the file the way the sidebar did, because saveProjects re-reads the document before it writes. Its fallback "write what this window has" only applies when that re-read also fails, and then the PUT almost certainly fails too. A shared /v1/app/<key> sync could cover both later.
Evidence
Real agent (scripts/sidebar-layout-restart.sh). The script seeds sidebar.json with Kits pinned and Automations hidden. It runs a laptop agent of this checkout with an empty box, and stops the agent the moment the app sends GET /v1/app/sidebar. The agent starts again 2 s later.
- Before (
origin/main's build): after the restart the app stays on the default layout. Kits isn't pinned and Automations is back.

- After (this branch): the read fails (
sidebar layout: The app lost its connection to the agent on this computer…in the console). It is tried again, and the stored layout appears.

sidebar.jsonis byte-identical afterwards:{"ok":true,"sidebar_json_before":"4f21c652…af48","sidebar_json_after":"4f21c652…af48"}
This reproduces the failure on a real agent restart that I timed into the read. That an update's restart actually lands in that window on a Mac is still the likely trigger, not a proven one: it is intermittent, and the app's other first reads succeeded in the reported launch.
Before the layout has loaded (mock, drive.mjs sidebar-layout-reload, ?failapp=sidebar).
- More's Customize sidebar… is disabled, with the note:

- Hide on a row's right-click is refused with the same note, and the stored document is unchanged:

- A pin survives a reload:

Tests:
- e2e (
e2e/sidebar-layout-load.spec.ts, new, passing): a pin survives a reload. The route fails, so the default shows, Customize is blocked and a change is refused. The route recovers, the stored layout appears and Customize opens.plugin-nav-badgesandrailstill pass. - unit (
nav-core.test.ts, 15 tests):arrangeandmigrate. A save with a plugin id unavailable keeps that id's place. A save before a successful load is refused and writes nothing. A missing file counts as loaded. Saves run in turn, so an earlier failed write can't undo a later one.
Checks run: pnpm test (643), tsc, tsc -p e2e, check:titles, check:type, check:grid, check:csp, pnpm build and the docs build. Not run: go vet/go test (no Go changes) and cargo check (no Rust changes).
Verify skill:
features/sidebar-nav.mdgainsnav-layout-load.- New scenarios:
sidebar-layout-reloadandsidebar-layout-restart-live. - New scripts:
scripts/agent-box.sh(a laptop agent with an empty box, on ports 9467/9468/7546) andscripts/sidebar-layout-restart.sh. - The mock keeps the sidebar document in session storage across reloads.
?failapp=sidebarfails it until__mockApp.recover("sidebar").
Merge Danger
Door: two-way
The only side effect is ~/.berth/app/sidebar.json, and its format is unchanged, so reverting leaves a valid file. Ids kept for unavailable plugins are already skipped by arrange() at render.
Blast Radius: sidebar
The changes are all in the sidebar's places: the list, the rail and zen's switcher. Customize sidebar can't be used until the layout loads; if the agent never answers, it stays disabled with a note while the app keeps retrying. A first-run user with no file isn't affected: a missing file counts as loaded.
🤖 Generated with Claude Code
![View with [code]smith](https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v3-3x.png)
![Autofix with [code]smith](https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark-3x.png)
Need help on this PR? Tag @codesmith-bot with what you need.