From 6f00d3881a197dd33c2cb43c6a11a9e759e56089 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:15:43 +0530 Subject: [PATCH] fix(web): remember panel width for each thread (#11310) --- apps/web/src/components/ChatView.tsx | 1 + apps/web/src/hooks/useResizableWidth.test.tsx | 67 +++++++++++++++++-- apps/web/src/hooks/useResizableWidth.ts | 50 ++++++++------ apps/web/src/hooks/useResizeDrag.ts | 11 ++- 4 files changed, 103 insertions(+), 26 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 574ed0f372a1bb16e2b6d5153d07e8af43bb0dee..cc8e43f467381cd93e5d59bb738c2981d991476c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -9364,6 +9364,7 @@ export default function ChatView(props: ChatViewProps) { {rightPanelPresent && !shouldUseRightPanelSheet && activeThreadRef ? ( (); +const setItem = vi.fn((key: string, value: string) => savedWidths.set(key, value)); const cancelAnimationFrame = vi.fn(); let events: EventTarget; let frame: FrameRequestCallback | undefined; @@ -40,9 +41,17 @@ function pointer(clientX = 100) { } as unknown as PointerEvent; } -function Panel({ edge = "left", maxWidth = 800 }: { edge?: "left" | "right"; maxWidth?: number }) { +function Panel({ + edge = "left", + maxWidth = 800, + storageKey = "test-panel-width", +}: { + edge?: "left" | "right"; + maxWidth?: number; + storageKey?: string; +}) { const resize = useResizableWidth({ - storageKey: "test-panel-width", + storageKey, defaultWidth: 400, minWidth: 200, maxWidth, @@ -55,6 +64,7 @@ function Panel({ edge = "left", maxWidth = 800 }: { edge?: "left" | "right"; max } beforeEach(async () => { + savedWidths.clear(); captured = false; frame = undefined; style.cursor = ""; @@ -64,7 +74,7 @@ beforeEach(async () => { vi.stubGlobal("window", { addEventListener: events.addEventListener.bind(events), removeEventListener: events.removeEventListener.bind(events), - localStorage: { getItem: () => null, setItem }, + localStorage: { getItem: (key: string) => savedWidths.get(key) ?? null, setItem }, }); vi.stubGlobal("document", { body: { style } }); vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { @@ -182,3 +192,52 @@ describe("panel resize cleanup", () => { expect(captured).toBe(false); }); }); + +describe("panel width storage changes", () => { + it("restores separate thread widths without remounting and retains them after reload", async () => { + await act(() => { + result.handlers.onPointerDown(pointer()); + result.handlers.onPointerMove(pointer(50)); + result.handlers.onPointerUp(pointer(50)); + }); + expect(result.width).toBe(450); + await act(() => renderer.update()); + expect(result.width).toBe(400); + await act(() => { + result.handlers.onPointerDown(pointer()); + result.handlers.onPointerMove(pointer(-100)); + result.handlers.onPointerUp(pointer(-100)); + }); + expect(result.width).toBe(600); + await act(() => renderer.update()); + expect(result.width).toBe(450); + await act(() => renderer.unmount()); + await act(() => { + renderer = create(); + }); + expect(result.width).toBe(600); + }); + + it("cancels an unfinished drag on a thread switch without saving it to either thread", async () => { + savedWidths.set("thread-b", "650"); + await act(() => { + result.handlers.onPointerDown(pointer()); + result.handlers.onPointerMove(pointer(50)); + }); + await act(() => frame?.(0)); + expect(result.width).toBe(450); + await act(() => result.handlers.onPointerMove(pointer(25))); + await act(() => renderer.update()); + expect(result.width).toBe(650); + expect(captured).toBe(false); + expect(style.cursor).toBe(""); + await act(() => { + frame?.(0); + result.handlers.onPointerUp(pointer(25)); + }); + expect(result.width).toBe(650); + expect(setItem).not.toHaveBeenCalled(); + await act(() => renderer.update()); + expect(result.width).toBe(400); + }); +}); diff --git a/apps/web/src/hooks/useResizableWidth.ts b/apps/web/src/hooks/useResizableWidth.ts index 93ab2218d54ad0fc397d7389cf9315834ef7746e..22ab44bdead81bbec179ca6685efb3b0efb007cf 100644 --- a/apps/web/src/hooks/useResizableWidth.ts +++ b/apps/web/src/hooks/useResizableWidth.ts @@ -36,7 +36,7 @@ export interface ResizableWidthHandlers { /** * Width state for a side-anchored panel resized via a drag handle on the - * specified edge. Width is read from localStorage on mount and persisted on + * specified edge. Width is read on mount or storage-key changes and persisted on * drag-end (not on every rAF tick — would otherwise be ~60 writes/sec). * * The hook updates an internal `width` state during drag (so the panel @@ -58,7 +58,7 @@ export function useResizableWidth(options: UseResizableWidthOptions): { ); // No cross-tab subscription: panel width is per-window state. - const [width, setWidth] = useState(() => { + const readWidth = () => { if (typeof window === "undefined") return defaultWidth; try { const stored = getLocalStorageItem(storageKey, WidthSchema); @@ -67,31 +67,39 @@ export function useResizableWidth(options: UseResizableWidthOptions): { console.error("Could not read persisted panel width.", error); return defaultWidth; } - }); + }; + const [widthState, setWidthState] = useState(() => ({ storageKey, width: readWidth() })); + // Panels stay mounted across threads; restore the destination width before paint. + if (widthState.storageKey !== storageKey) { + setWidthState({ storageKey, width: readWidth() }); + } - const clampedWidth = clamp(width); + const clampedWidth = clamp(widthState.width); const latestOptions = useRef({ clamp, storageKey }); useLayoutEffect(() => { latestOptions.current = { clamp, storageKey }; }, [clamp, storageKey]); - const handlers = useResizeDrag(() => ({ - width: clampedWidth, - edge, - resize(value) { - const nextWidth = latestOptions.current.clamp(value); - setWidth(nextWidth); - return nextWidth; - }, - finish(finalWidth) { - // Commit once at drag-end to avoid 60Hz localStorage writes. - try { - setLocalStorageItem(latestOptions.current.storageKey, finalWidth, WidthSchema); - } catch (error) { - console.error("Could not persist panel width.", error); - } - }, - })); + const handlers = useResizeDrag( + () => ({ + width: clampedWidth, + edge, + resize(value) { + const nextWidth = latestOptions.current.clamp(value); + setWidthState({ storageKey, width: nextWidth }); + return nextWidth; + }, + finish(finalWidth) { + // Commit once at drag-end to avoid 60Hz localStorage writes. + try { + setLocalStorageItem(latestOptions.current.storageKey, finalWidth, WidthSchema); + } catch (error) { + console.error("Could not persist panel width.", error); + } + }, + }), + storageKey, + ); return { width: clampedWidth, handlers }; } diff --git a/apps/web/src/hooks/useResizeDrag.ts b/apps/web/src/hooks/useResizeDrag.ts index e9e645436174304b42961dc3744ef4e0f9ac4513..6509a61767d01d1c4df4a07ac55b086f916717ee 100644 --- a/apps/web/src/hooks/useResizeDrag.ts +++ b/apps/web/src/hooks/useResizeDrag.ts @@ -1,4 +1,4 @@ -import { type PointerEvent, useCallback, useEffect, useRef } from "react"; +import { type PointerEvent, useCallback, useEffect, useLayoutEffect, useRef } from "react"; interface ResizeSession { width: number; @@ -11,6 +11,7 @@ interface ResizeSession { /** Shared pointer lifecycle for side panels, including interrupted and sub-frame drags. */ export function useResizeDrag( start: (event: PointerEvent) => ResizeSession | null, + resetKey?: string, ) { const drag = useRef<{ session: ResizeSession; @@ -54,6 +55,14 @@ export function useResizeDrag( [flush], ); + const previousResetKey = useRef(resetKey); + useLayoutEffect(() => { + if (previousResetKey.current !== resetKey) { + finish(false); + previousResetKey.current = resetKey; + } + }, [finish, resetKey]); + useEffect(() => { const onBlur = () => finish(); window.addEventListener("blur", onBlur); -- 2.54.0