Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions src/components/layout/app-shell/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -391,8 +391,11 @@ function AppShellInner({
// viewport. Falls back to half the viewport when no wider width was ever
// recorded (lastOpenWidth still at the MIN_WIDTH floor).
const reopenWidth = () => {
// `>=`: lastOpenWidth === MIN_WIDTH is a real remembered width (user dragged
// to the floor), not "no saved width" — restore it instead of falling back
// to half-viewport. Only the seed default (0, below the floor) falls back.
const remembered =
lastOpenWidth > MIN_WIDTH
lastOpenWidth >= MIN_WIDTH
? lastOpenWidth
: (windowWidth || 1000) * 0.5;
return Math.min(Math.max(remembered, MIN_WIDTH), maxWidthRef.current);
Expand Down Expand Up @@ -433,7 +436,11 @@ function AppShellInner({
if (sidebarWidth <= MIN_WIDTH) {
setSidebarWidth(reopenWidth());
} else {
setSidebarWidth(MIN_WIDTH);
// Collapse via seedWidth (NOT setSidebarWidth): now that a drag to exactly
// MIN_WIDTH is a remembered width, collapsing to the floor must NOT record
// it as the open width — otherwise expanding would have nothing else to
// restore. seedWidth shrinks the view without touching lastOpenWidth.
seedWidth(MIN_WIDTH);
}
};

Expand Down
40 changes: 38 additions & 2 deletions src/context/sidebar/SidebarProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,21 @@ afterEach(() => {
});

function TestConsumer() {
const { sidebarWidth, setSidebarWidth, lastOpenWidth } = useSidebarWidth();
const { sidebarWidth, setSidebarWidth, seedWidth, lastOpenWidth } =
useSidebarWidth();
return (
<div>
<span data-testid="width">{sidebarWidth}</span>
<span data-testid="last-open">{lastOpenWidth}</span>
<button onClick={() => setSidebarWidth(500)}>Resize</button>
<button onClick={() => setSidebarWidth(0)}>Close</button>
<button onClick={() => setSidebarWidth(350)}>Collapse</button>
{/* Collapse uses seedWidth (what AppShell's collapse button does) — sets
the rendered width to the floor WITHOUT recording it as the open
width, so reopen restores the prior size. */}
<button onClick={() => seedWidth(350)}>Collapse</button>
{/* A user DRAG that lands exactly on the floor goes through
setSidebarWidth and IS a real chosen width (must be recorded). */}
<button onClick={() => setSidebarWidth(350)}>DragFloor</button>
</div>
);
}
Expand Down Expand Up @@ -163,6 +170,35 @@ describe("SidebarProvider persistence", () => {
expect(localStorage.getItem(SIDEBAR_WIDTH_STORAGE_KEY)).toBe("500");
});

it("a DRAG to exactly minOpenWidth is recorded as the open width (regression: floor was dropped by a strict `>`)", () => {
// The bug: dragging the rail all the way to its minimum (width ===
// minOpenWidth) was discarded — handleSetWidth/reopenWidth used a strict
// `>`, so lastOpenWidth kept the previous larger size and reopen restored
// THAT, not the dragged floor. A drag to the floor is a real chosen width
// and must stick. (Contrast the collapse test above, which seeds the floor
// and must NOT record.)
render(
<SidebarProvider
defaultWidth={0}
persistKey={SIDEBAR_WIDTH_STORAGE_KEY}
minOpenWidth={350}
maxOpenWidth={2000}
>
<TestConsumer />
</SidebarProvider>,
);
act(() => {
fireEvent.click(screen.getByText("Resize")); // open wide (500) first
});
act(() => {
fireEvent.click(screen.getByText("DragFloor")); // drag down to the floor
});
expect(screen.getByTestId("width").textContent).toBe("350");
// The floor IS now the remembered open width — in memory AND storage.
expect(screen.getByTestId("last-open").textContent).toBe("350");
expect(localStorage.getItem(SIDEBAR_WIDTH_STORAGE_KEY)).toBe("350");
});

it("rehydrates a valid stored width", () => {
localStorage.setItem(SIDEBAR_WIDTH_STORAGE_KEY, "480");
render(
Expand Down
69 changes: 29 additions & 40 deletions src/context/sidebar/SidebarProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,33 +168,21 @@ export function SidebarProvider({
// values (e.g. from a wider viewport, or a bad client) are ignored.
if (stored === null) return;
if (stored < min || stored > max) return;
// FIRST REAL WRITER WINS. Once a genuine user gesture (a drag through
// handleSetWidth) has recorded a width, widthHydratedRef is already true,
// and the late-arriving hydration read must NOT touch anything — not the
// rendered width AND not the remembered reopen width (lastOpenWidth).
// Previously `setLastOpenWidth(stored)` ran unconditionally here, so a GET
// that resolved just AFTER a drag overwrote the dragged size: the user
// dragged to B, the stale read stamped lastOpenWidth back to A, and the
// next reopen came back at A instead of B. A placeholder seed (seedWidth,
// used by AppShell's controlled-open effect on reload) does NOT set the
// flag, so the stored width still wins that race exactly once and reload
// restore keeps working.
if (widthHydratedRef.current) return;
widthHydratedRef.current = true;
setLastOpenWidth(stored);
// Reconcile the fetched width into the RENDERED state, not just the
// reopen memory (`lastOpenWidth`). Without this the fetched width is
// stranded — it only surfaces on the NEXT open gesture (reopenWidth reads
// lastOpenWidth), which is why close-then-reopen restored it but a reload
// did not.
//
// The first time we apply, the fetched stored width overrides whatever is
// rendered — even a non-zero PLACEHOLDER seed. On reload AppShell's
// controlled-open effect synchronously seeds a half-viewport / floor width
// the instant the host's persisted open:true hydrates (lastOpenWidth is
// still 0 at that point, so reopenWidth() returns a placeholder, not the
// real saved width). That seed lands BEFORE this async GET resolves, so a
// plain `cur <= 0` guard would discard the real stored width. Overriding
// once on first hydrate makes the persisted width win that race. A genuine
// user drag landing before the fetch sets widthHydratedRef in
// handleSetWidth, so it is treated as already-hydrated and is NOT
// clobbered — first REAL writer wins. After the one-time hydrate, the
// functional updater seeds only from the default floor (`cur <= 0`). No
// render gate: width intentionally paints default-then-hydrate to avoid an
// SSR mismatch.
if (!widthHydratedRef.current) {
widthHydratedRef.current = true;
setSidebarWidth(stored);
} else {
setSidebarWidth((cur) => (cur <= 0 ? stored : cur));
}
setSidebarWidth(stored);
};

const persistence = persistenceRef.current;
Expand All @@ -219,10 +207,11 @@ export function SidebarProvider({
apply(readPersistedWidth(persistKey, min, max));
}, []);

// Persist every meaningful open width; skip closed/collapsed-to-floor states
// so a close OR a collapse (width === minOpenWidth) doesn't wipe the
// remembered size. The injected server persistence wins; the legacy
// localStorage `persistKey` is written only when no persistence is wired.
// Persist a width. Called by handleSetWidth only for real open widths
// (>= minOpenWidth, including a drag to the floor); a close (0) is gated out
// upstream and a collapse never flows here (AppShell collapses via seedWidth).
// The injected persistence wins; the legacy localStorage `persistKey` is
// written only when no persistence is wired.
const persist = useCallback(
(width: number) => {
const persistence = persistenceRef.current;
Expand Down Expand Up @@ -252,16 +241,16 @@ export function SidebarProvider({
const handleSetWidth = useCallback(
(width: number) => {
setSidebarWidth(width);
// Strict `>`: a width of exactly `minOpenWidth` is the collapsed floor
// (AppShell collapses by setting the width to MIN_WIDTH), not a real open
// width — recording it would erase the user's chosen drag size and make
// reopen fall back to the default. Only widths strictly above the floor
// count as the remembered open width.
if (width > minOpenWidth) {
// A real user drag landing before the async fetch resolves is the
// authoritative width — mark hydrated so the late-arriving stored width
// does NOT overwrite it (first REAL writer wins). A placeholder seed at
// or below the floor never reaches here, so it can't claim hydration.
// `>=`, not `>`: minOpenWidth is the narrowest VALID OPEN width (the drag
// floor), so a user dragging all the way to it (width === minOpenWidth) is
// a deliberate choice and MUST be remembered. Only a CLOSE (width 0, below
// the floor) is excluded. The collapse button does NOT come through here —
// it uses `seedWidth`, so collapsing to the floor never overwrites the
// remembered open width. (The old strict `>` dropped a drag-to-minimum,
// leaving lastOpenWidth at the previous size, so reopen ignored it.)
if (width >= minOpenWidth) {
// A real user drag is the authoritative width — mark hydrated so a
// late-arriving stored width can't overwrite it (first REAL writer wins).
widthHydratedRef.current = true;
setLastOpenWidth(width);
persist(width);
Expand Down
28 changes: 25 additions & 3 deletions src/hooks/agent-chat/useAgentTabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,15 @@ export interface UseAgentTabsResult {
/** Sidebar open/closed — persisted, shared across hosts. */
open: boolean;
setOpen: (open: boolean) => void;
/** Shared open-sidebar width (px) — the SAME persisted record as
* open/tabs/activeTabId. 0 = no saved width (host default). */
width: number;
/** Persist a new open-sidebar width. Routes through the strip's single
* debounced writer so a width save can never race/clobber the open or tabs
* fields (and vice-versa) — there is exactly ONE writer for the record. The
* host wires this to the kit's width-persistence `set` so a drag-end folds
* width into the same PUT as the rest of the strip. */
setWidth: (width: number) => void;
/** Open tabs as conversation ids (conversation ids + local draft ids),
* strip order. Map to titled ChatTabs with deriveTabTitles. */
tabs: string[];
Expand Down Expand Up @@ -329,9 +338,11 @@ export interface UseAgentTabsResult {
* mutation is pending flush so it can't clobber newer local state.
*
* The persisted record (AgentSidebarState) also carries the shared
* open-sidebar `width`; the strip never MANAGES width (a host-side width
* persistence owns the read/write — see api-client-shared), but it preserves
* the hydrated width through every reducer so a tab PUT never drops it.
* open-sidebar `width`. The strip is the SINGLE writer of the whole record:
* `setWidth` folds a width change into the same debounced PUT as open/tabs, and
* every reducer preserves the hydrated width — so width and open/tabs can never
* race or clobber each other (the host's width-persistence `set` routes here
* instead of doing its own GET→merge→PUT).
*
* `persistence` and `resolveTabs` are read through refs — their identities
* may change per render without retriggering fetches.
Expand Down Expand Up @@ -546,6 +557,15 @@ export function useAgentTabs(
(open: boolean) => mutate((s) => (s.open === open ? s : { ...s, open })),
[mutate],
);
// Width is just another field of the one record — routing it through the same
// `mutate`/`schedulePut` makes the strip the SINGLE writer, so a width PUT
// carries the live open/tabs and an open PUT carries the live width. No second
// writer, no read-modify-write race (the old standalone width persister did a
// GET→merge→PUT that could clobber a freshly-written open).
const setWidth = useCallback(
(width: number) => mutate((s) => (s.width === width ? s : { ...s, width })),
[mutate],
);
const selectTab = useCallback((id: string) => mutate((s) => selectTabState(s, id)), [mutate]);
const closeTab = useCallback(
(id: string) => mutate((s) => closeTabState(s, id, freshDraft)),
Expand Down Expand Up @@ -578,6 +598,8 @@ export function useAgentTabs(
return {
open: strip.open,
setOpen,
width: strip.width,
setWidth,
tabs,
activeTabId: strip.activeTabId,
hydrated,
Expand Down
Loading