fix: stop sidebar reload from losing saved width and stranding new-chat tab#309
Merged
Merged
Conversation
…at tab
Two independent client-side restore races on reload, both in the kit. The
agent server and the save path are sound; this is entirely about RESTORE.
Bug 1 — width (deterministic loss on reload when the sidebar was left open).
SidebarProvider mounts with defaultWidth 0, so sidebarWidth and lastOpenWidth
start at 0. The instant the host's persisted open:true hydrates, AppShell's
controlled-open effect synchronously seeds a PLACEHOLDER width
(reopenWidth() returns the half-viewport fallback because lastOpenWidth is
still 0 mid-restore; in web-applications AgentSessionBridge separately seeds
the 350 floor). The async width GET resolves LATER and only seeded a
default-0 width (`cur <= 0 ? stored : cur`), so the non-zero placeholder won
the race and the user's saved drag width was discarded. Fix: a
widthHydratedRef makes the fetched stored width OVERRIDE a placeholder seed
exactly once on first hydrate; a genuine user drag (handleSetWidth, width >
minOpenWidth) marks the width hydrated so it still wins ("first REAL writer
wins"). AppShell now seeds the placeholder through a new `seedWidth` that does
NOT mark hydrated (so the GET can override it) and never persists.
Bug 2 — tabs/active (intermittent "new chat wins" on reload). The mount
hydrate restores remote tabs+activeTabId only when keepDrafts is false, but
keepDrafts was `mode === 'refetch' || touchedRef`. A focus/visibilitychange
firing reconcile('refetch'), or a host mutation setting touchedRef, DURING the
in-flight hydrate made keepDrafts true (or bumped the shared fetchSeq so the
hydrate GET early-returned), so the fresh placeholder draft survived and stayed
active — the restored conversation was stranded. Fix: a hasHydratedRef forces
the first successful load authoritative (keepDrafts=false regardless of
mode/touchedRef) and early-returns focus/visibility refetches until the first
hydrate settles.
Tests: SidebarProvider gains a seed-vs-fetch race test (a non-zero placeholder
set before get() resolves to 700; asserts the rendered width becomes 700) plus
its inverse (a real pre-fetch drag still wins). useAgentTabs gains two
race tests (a host mutation, and a focus/visibility event, each during the
in-flight hydrate) asserting the remote tabs+active are restored and the draft
is dropped. All four fail on the pre-fix code.
No version bump — rides the next kit rollup. web-applications can optionally
drop its redundant AgentSessionBridge width seed in a follow-up; not required
now since the kit override makes the stored width win the race regardless.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Contributor
|
Storybook Preview: https://mirrorstack-ai.github.io/web-ui-kit/pr/309/ |
Merged
I-am-nothing
added a commit
that referenced
this pull request
Jun 14, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two independent client-side restore races on reload, both confirmed against deployed
origin/main(kit tagv0.5.7). The agent server and the SAVE path are sound — this is entirely about RESTORE, fixed in the kit. No version bump; rides the next kit rollup.Bug 1 — width (deterministic loss on reload when the sidebar was left open)
SidebarProvidermounts withdefaultWidth=0, sosidebarWidthandlastOpenWidthboth start at 0. The instant the host's persistedopen:truehydrates, AppShell's controlled-open seed runs synchronously:reopenWidth()returns the half-viewport fallback (becauselastOpenWidthis still 0 mid-restore — the real width is restored in the same asyncapply()that hasn't run yet). In web-applications,AgentSessionBridgeseparately seeds the 350 floor. Either way a non-zero placeholder is written.The async width GET resolves later and only seeded a default-0 width (
setSidebarWidth(cur => cur <= 0 ? stored : cur)), so the placeholder (cur > 0) won and the user's saved drag width was discarded — reload reopened at half-viewport / 350. (Close-then-reopen worked becauselastOpenWidthalready held the real width in memory; reload resets it to 0.)Fix: a
widthHydratedRefmakes the fetched stored width override a placeholder seed exactly once on first hydrate. A genuine pre-fetch user drag (handleSetWidth,width > minOpenWidth) marks the width hydrated so it still wins — "first REAL writer wins". AppShell now seeds through a newseedWidththat does not mark hydrated (so the GET can override it) and never persists; genuine gestures (drag, FAB open, collapse-toggle, close) keep usingsetSidebarWidth. Covers both hosts regardless of which writer seeded.Bug 2 — tabs/active (intermittent "new chat wins" on reload)
The mount hydrate restores remote
tabs+activeTabIdonly withkeepDrafts=false, butkeepDrafts = mode === 'refetch' || touchedRef.current. If a focus/visibilitychangefiresreconcile('refetch'), or a host mutation setstouchedRef(route-changenewTab/selectTab, bridgesetOpen), during the in-flight hydrate GET,keepDraftsbecomes true (or the sharedfetchSeqbump makes the hydrate GET early-return). The fresh placeholder draft survives and stays active — the restored conversation is stranded. (enabledflips false→true on reload whileauth.me()is in flight, widening the overlap window.)Fix: a
hasHydratedRefforces the first successful load authoritative —keepDraftsforced false regardless of mode/touchedRef — and early-returns focus/visibility refetches until the first hydrate settles, so an interleaving refetch/mutation can no longer pre-empt it viafetchSeq.hydratedis now surfaced whenever a load wins its seq (covers the rare case a refetch beats the hydrate effect).Tests (all FAIL on pre-fix code)
SidebarProvider.test.tsx: a non-zero placeholder is set beforeget()resolves to 700; asserts the rendered width becomes 700 (old code left the placeholder). Plus the inverse: a real pre-fetch drag still wins over the late GET.useAgentTabs.test.ts: a host mutation, and a focus/visibility event, each fire during the in-flight hydrate; assert remote tabs + active tab are restored and the placeholder draft is dropped.Verified each new test red on the old behavior, green on the fix.
Verify
pnpm exec tsc --noEmit— 0 errors (pre-existing react-markdown/remark-gfm noise is unrelated; none in changed files).pnpm test -- src/context/sidebar/SidebarProvider.test.tsx src/hooks/agent-chat/useAgentTabs.test.ts src/components/layout/app-shell/AppShell.test.tsx— 772 passed.Follow-up (optional, not required)
web-applications can drop the redundant
AgentSessionBridgewidth seed (setSidebarWidth(open ? AGENT_SIDEBAR_OPEN_WIDTH : 0)) so the bridge only drives the open flag. Not needed now — the kit override makes the stored width win the race regardless.🤖 Generated with Claude Code