Skip to content

fix: stop sidebar reload from losing saved width and stranding new-chat tab#309

Merged
I-am-nothing merged 1 commit into
mainfrom
fix/sidebar-width-and-tab-restore-race
Jun 14, 2026
Merged

fix: stop sidebar reload from losing saved width and stranding new-chat tab#309
I-am-nothing merged 1 commit into
mainfrom
fix/sidebar-width-and-tab-restore-race

Conversation

@I-am-nothing

Copy link
Copy Markdown
Contributor

Summary

Two independent client-side restore races on reload, both confirmed against deployed origin/main (kit tag v0.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)

SidebarProvider mounts with defaultWidth=0, so sidebarWidth and lastOpenWidth both start at 0. The instant the host's persisted open:true hydrates, AppShell's controlled-open seed runs synchronously: reopenWidth() returns the half-viewport fallback (because lastOpenWidth is still 0 mid-restore — the real width is restored in the same async apply() that hasn't run yet). In web-applications, AgentSessionBridge separately 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 because lastOpenWidth already held the real width in memory; reload resets it to 0.)

Fix: a widthHydratedRef makes 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 new seedWidth that does not mark hydrated (so the GET can override it) and never persists; genuine gestures (drag, FAB open, collapse-toggle, close) keep using setSidebarWidth. Covers both hosts regardless of which writer seeded.

Bug 2 — tabs/active (intermittent "new chat wins" on reload)

The mount hydrate restores remote tabs + activeTabId only with keepDrafts=false, but keepDrafts = mode === 'refetch' || touchedRef.current. If a focus/visibilitychange fires reconcile('refetch'), or a host mutation sets touchedRef (route-change newTab/selectTab, bridge setOpen), during the in-flight hydrate GET, keepDrafts becomes true (or the shared fetchSeq bump makes the hydrate GET early-return). The fresh placeholder draft survives and stays active — the restored conversation is stranded. (enabled flips false→true on reload while auth.me() is in flight, widening the overlap window.)

Fix: a hasHydratedRef forces the first successful load authoritativekeepDrafts forced 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 via fetchSeq. hydrated is 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 before get() 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 AgentSessionBridge width 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

…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>
@github-actions

Copy link
Copy Markdown
Contributor

Storybook Preview: https://mirrorstack-ai.github.io/web-ui-kit/pr/309/

github-actions Bot added a commit that referenced this pull request Jun 14, 2026
@I-am-nothing I-am-nothing merged commit c835c76 into main Jun 14, 2026
4 checks passed
@github-project-automation github-project-automation Bot moved this from Backlog to Done in MirrorStack Platform Jun 14, 2026
github-actions Bot added a commit that referenced this pull request Jun 14, 2026
I-am-nothing added a commit that referenced this pull request Jun 14, 2026
Rollup of #309 (sidebar reload race: width + tab restore), #307 (delete-conversation confirm dialog), #308 (gutter xl:px-10). Bump + CHANGELOG; release label publishes 0.5.8 on merge.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant