diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 51b9c5eabc4..23ca2c86fb5 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -8895,6 +8895,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,9 @@ function pointer(clientX = 100) { } as unknown as PointerEvent; } -function Panel() { +function Panel({ storageKey = "test-panel-width" }: { storageKey?: string }) { const resize = useResizableWidth({ - storageKey: "test-panel-width", + storageKey, defaultWidth: 400, minWidth: 200, maxWidth: 800, @@ -55,6 +56,7 @@ function Panel() { } beforeEach(async () => { + savedWidths.clear(); captured = false; frame = undefined; style.cursor = ""; @@ -64,7 +66,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) => { @@ -125,3 +127,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 7bdb6be339a..d22188eba3d 100644 --- a/apps/web/src/hooks/useResizableWidth.ts +++ b/apps/web/src/hooks/useResizableWidth.ts @@ -35,7 +35,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 @@ -57,7 +57,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); @@ -66,9 +66,15 @@ export function useResizableWidth(options: UseResizableWidthOptions): { console.error("Could not read persisted panel width.", error); return defaultWidth; } - }); + }; + const [widthState, setWidthState] = useState(() => ({ storageKey, width: readWidth() })); + // The panel stays mounted across threads. Load the new thread's width before + // committing its first render, without remounting its preview or terminal. + if (widthState.storageKey !== storageKey) { + setWidthState({ storageKey, width: readWidth() }); + } - const clampedWidth = clamp(width); + const clampedWidth = clamp(widthState.width); const dragStateRef = useRef<{ pointerId: number; @@ -102,8 +108,8 @@ export function useResizableWidth(options: UseResizableWidthOptions): { const state = dragStateRef.current; if (!state) return; releasePointer(state.pointerId); - setWidth(state.startWidth); - }, [releasePointer]); + setWidthState({ storageKey, width: state.startWidth }); + }, [releasePointer, storageKey]); useEffect(() => { window.addEventListener("blur", cancelDrag); @@ -151,10 +157,10 @@ export function useResizableWidth(options: UseResizableWidthOptions): { const active = dragStateRef.current; if (!active) return; active.rafId = null; - setWidth(active.pending); + setWidthState({ storageKey, width: active.pending }); }); }, - [clamp, edge], + [clamp, edge, storageKey], ); const onPointerUp = useCallback( @@ -169,7 +175,7 @@ export function useResizableWidth(options: UseResizableWidthOptions): { } catch (error) { console.error("Could not persist panel width.", error); } - setWidth(finalWidth); + setWidthState({ storageKey, width: finalWidth }); }, [clamp, releasePointer, storageKey], );