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: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,17 @@
Notable API additions and breaking changes. For the full commit log, see
[GitHub Releases](https://github.com/mirrorstack-ai/web-ui-kit/releases).

## 0.5.9

- Agent sidebar reload **clobber fix**: a mount-time mutation (the host bridge's
`setOpen`, a route-driven tab change) could `put` the empty, un-hydrated strip
before the mount GET settled, permanently overwriting the server's saved
state with `{tabs:[], placeholder width}`. Reloads then "reverted to default /
new chat" and stayed. `schedulePut` now refuses to persist until the strip has
hydrated (the flag is set even on GET failure, so saves never stall). This is
the live-reproduced root cause that 0.5.7/0.5.8 did not resolve. Regression
test included (a pre-hydration mutation must not `put`).

## 0.5.8

- Agent sidebar reload reliability: the persisted drag-width and the restored
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@mirrorstack-ai/web-ui-kit",
"packageManager": "pnpm@10.29.3",
"version": "0.5.8",
"version": "0.5.9",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
Expand Down
39 changes: 39 additions & 0 deletions src/hooks/agent-chat/useAgentTabs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,45 @@ describe("useAgentTabs", () => {
expect(put).not.toHaveBeenCalled();
});

it("does NOT persist before hydration settles — a mount-time mutation must not clobber the server", async () => {
// Deferred GET: hydration is still in flight when a mount-time mutation
// fires (the host bridge's setOpen, a route-driven tab change).
let resolveGet!: (s: AgentSidebarState) => void;
const get = vi.fn().mockImplementation(
() => new Promise<AgentSidebarState>((r) => { resolveGet = r; }),
);
const put = vi.fn().mockResolvedValue(undefined);
const persistence: AgentTabsPersistence = { get, put };
const { result } = renderHook(() => useAgentTabs(persistence));

// Pre-fix this scheduled a PUT of the empty, un-hydrated strip (tabs:[],
// placeholder width), permanently clobbering the server's saved state —
// the "reload reverts to default / new chat" bug. The gate suppresses it.
act(() => {
result.current.openConversation("c-9");
});
await advance(5000); // well past the debounce window
expect(result.current.hydrated).toBe(false);
expect(put).not.toHaveBeenCalled();

// Hydration settles with the real saved state (tabs + a real width).
await act(async () => {
resolveGet(state({ width: 700 }));
});
expect(result.current.hydrated).toBe(true);
expect(result.current.tabs).toEqual(["c-1", "c-2"]); // restored, not wiped
expect(put).not.toHaveBeenCalled(); // hydration itself never writes

// A genuine post-hydrate mutation DOES persist, carrying the hydrated
// width — never a placeholder.
act(() => {
result.current.openConversation("c-3");
});
await advance(5000);
expect(put).toHaveBeenCalledTimes(1);
expect(put.mock.calls[0][0].width).toBe(700);
});

it("debounces mutations into ONE trailing PUT carrying the final state", async () => {
const { persistence, put } = fakePersistence();
const { result } = renderHook(() => useAgentTabs(persistence));
Expand Down
14 changes: 13 additions & 1 deletion src/hooks/agent-chat/useAgentTabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,19 @@ export function useAgentTabs(
const hasHydratedRef = useRef(false);

const schedulePut = useCallback(() => {
if (!persistenceRef.current || !enabledRef.current) return;
// NEVER persist before the mount GET has settled. A mount-time mutation
// (the host bridge's setOpen, a route-driven newTab/selectTab) fires
// `mutate -> schedulePut` while the strip is still the empty, un-hydrated
// placeholder; without this gate that empty strip (tabs:[], placeholder
// width) gets PUT and permanently clobbers the server's real saved state
// — the reload "reverts to default / new chat" bug. hasHydratedRef flips
// true in reconcile's finally even on GET failure, so saves never stall.
if (
!persistenceRef.current ||
!enabledRef.current ||
!hasHydratedRef.current
)
return;
pendingRef.current = true;
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
Expand Down
Loading