authorgravatar for 91240327+shivamhwp@users.noreply.github.comshivam <91240327+shivamhwp@users.noreply.github.com> 2026-09-14 16:15:43+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-14 16:15:43+05:30
log6f00d3881a197dd33c2cb43c6a11a9e759e56089
tree71d7686c11723058d57d08a4271d5fb01aa7e537
parent8ef478eb0a6deeef81c5421dd7bb1c6b8c4d0e7d
signature Signed by PGP key B5690EEEBB952194

fix(web): remember panel width for each thread (#11310)


4 files changed, 103 insertions(+), 26 deletions(-)

apps/web/src/components/ChatView.tsx+1
......@@ -9364,6 +9364,7 @@ export default function ChatView(props: ChatViewProps) {
93649364 {rightPanelPresent && !shouldUseRightPanelSheet && activeThreadRef ? (
93659365 <RightPanelTabs
93669366 mode="inline"
9367 widthStorageKey={`t3code:preview-panel-width:${activeThreadKey}`}
93679368 open={rightPanelOpen}
93689369 maximized={rightPanelMaximized}
93699370 surfaces={renderedRightPanelSurfaces}
apps/web/src/hooks/useResizableWidth.test.tsx+63-4
......@@ -24,7 +24,8 @@ const style = {
2424 if (property === "user-select") this.userSelect = "";
2525 },
2626};
27const setItem = vi.fn();
27const savedWidths = new Map<string, string>();
28const setItem = vi.fn((key: string, value: string) => savedWidths.set(key, value));
2829const cancelAnimationFrame = vi.fn();
2930let events: EventTarget;
3031let frame: FrameRequestCallback | undefined;
......@@ -40,9 +41,17 @@ function pointer(clientX = 100) {
4041 } as unknown as PointerEvent<HTMLElement>;
4142}
4243
43function Panel({ edge = "left", maxWidth = 800 }: { edge?: "left" | "right"; maxWidth?: number }) {
44function Panel({
45 edge = "left",
46 maxWidth = 800,
47 storageKey = "test-panel-width",
48}: {
49 edge?: "left" | "right";
50 maxWidth?: number;
51 storageKey?: string;
52}) {
4453 const resize = useResizableWidth({
45 storageKey: "test-panel-width",
54 storageKey,
4655 defaultWidth: 400,
4756 minWidth: 200,
4857 maxWidth,
......@@ -55,6 +64,7 @@ function Panel({ edge = "left", maxWidth = 800 }: { edge?: "left" | "right"; max
5564}
5665
5766beforeEach(async () => {
67 savedWidths.clear();
5868 captured = false;
5969 frame = undefined;
6070 style.cursor = "";
......@@ -64,7 +74,7 @@ beforeEach(async () => {
6474 vi.stubGlobal("window", {
6575 addEventListener: events.addEventListener.bind(events),
6676 removeEventListener: events.removeEventListener.bind(events),
67 localStorage: { getItem: () => null, setItem },
77 localStorage: { getItem: (key: string) => savedWidths.get(key) ?? null, setItem },
6878 });
6979 vi.stubGlobal("document", { body: { style } });
7080 vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
......@@ -182,3 +192,52 @@ describe("panel resize cleanup", () => {
182192 expect(captured).toBe(false);
183193 });
184194});
195
196describe("panel width storage changes", () => {
197 it("restores separate thread widths without remounting and retains them after reload", async () => {
198 await act(() => {
199 result.handlers.onPointerDown(pointer());
200 result.handlers.onPointerMove(pointer(50));
201 result.handlers.onPointerUp(pointer(50));
202 });
203 expect(result.width).toBe(450);
204 await act(() => renderer.update(<Panel storageKey="thread-b" />));
205 expect(result.width).toBe(400);
206 await act(() => {
207 result.handlers.onPointerDown(pointer());
208 result.handlers.onPointerMove(pointer(-100));
209 result.handlers.onPointerUp(pointer(-100));
210 });
211 expect(result.width).toBe(600);
212 await act(() => renderer.update(<Panel />));
213 expect(result.width).toBe(450);
214 await act(() => renderer.unmount());
215 await act(() => {
216 renderer = create(<Panel storageKey="thread-b" />);
217 });
218 expect(result.width).toBe(600);
219 });
220
221 it("cancels an unfinished drag on a thread switch without saving it to either thread", async () => {
222 savedWidths.set("thread-b", "650");
223 await act(() => {
224 result.handlers.onPointerDown(pointer());
225 result.handlers.onPointerMove(pointer(50));
226 });
227 await act(() => frame?.(0));
228 expect(result.width).toBe(450);
229 await act(() => result.handlers.onPointerMove(pointer(25)));
230 await act(() => renderer.update(<Panel storageKey="thread-b" />));
231 expect(result.width).toBe(650);
232 expect(captured).toBe(false);
233 expect(style.cursor).toBe("");
234 await act(() => {
235 frame?.(0);
236 result.handlers.onPointerUp(pointer(25));
237 });
238 expect(result.width).toBe(650);
239 expect(setItem).not.toHaveBeenCalled();
240 await act(() => renderer.update(<Panel />));
241 expect(result.width).toBe(400);
242 });
243});
apps/web/src/hooks/useResizableWidth.ts+29-21
......@@ -36,7 +36,7 @@ export interface ResizableWidthHandlers {
3636
3737/**
3838 * Width state for a side-anchored panel resized via a drag handle on the
39 * specified edge. Width is read from localStorage on mount and persisted on
39 * specified edge. Width is read on mount or storage-key changes and persisted on
4040 * drag-end (not on every rAF tick — would otherwise be ~60 writes/sec).
4141 *
4242 * The hook updates an internal `width` state during drag (so the panel
......@@ -58,7 +58,7 @@ export function useResizableWidth(options: UseResizableWidthOptions): {
5858 );
5959
6060 // No cross-tab subscription: panel width is per-window state.
61 const [width, setWidth] = useState<number>(() => {
61 const readWidth = () => {
6262 if (typeof window === "undefined") return defaultWidth;
6363 try {
6464 const stored = getLocalStorageItem(storageKey, WidthSchema);
......@@ -67,31 +67,39 @@ export function useResizableWidth(options: UseResizableWidthOptions): {
6767 console.error("Could not read persisted panel width.", error);
6868 return defaultWidth;
6969 }
70 });
70 };
71 const [widthState, setWidthState] = useState(() => ({ storageKey, width: readWidth() }));
72 // Panels stay mounted across threads; restore the destination width before paint.
73 if (widthState.storageKey !== storageKey) {
74 setWidthState({ storageKey, width: readWidth() });
75 }
7176
72 const clampedWidth = clamp(width);
77 const clampedWidth = clamp(widthState.width);
7378 const latestOptions = useRef({ clamp, storageKey });
7479 useLayoutEffect(() => {
7580 latestOptions.current = { clamp, storageKey };
7681 }, [clamp, storageKey]);
7782
78 const handlers = useResizeDrag<HTMLElement>(() => ({
79 width: clampedWidth,
80 edge,
81 resize(value) {
82 const nextWidth = latestOptions.current.clamp(value);
83 setWidth(nextWidth);
84 return nextWidth;
85 },
86 finish(finalWidth) {
87 // Commit once at drag-end to avoid 60Hz localStorage writes.
88 try {
89 setLocalStorageItem(latestOptions.current.storageKey, finalWidth, WidthSchema);
90 } catch (error) {
91 console.error("Could not persist panel width.", error);
92 }
93 },
94 }));
83 const handlers = useResizeDrag<HTMLElement>(
84 () => ({
85 width: clampedWidth,
86 edge,
87 resize(value) {
88 const nextWidth = latestOptions.current.clamp(value);
89 setWidthState({ storageKey, width: nextWidth });
90 return nextWidth;
91 },
92 finish(finalWidth) {
93 // Commit once at drag-end to avoid 60Hz localStorage writes.
94 try {
95 setLocalStorageItem(latestOptions.current.storageKey, finalWidth, WidthSchema);
96 } catch (error) {
97 console.error("Could not persist panel width.", error);
98 }
99 },
100 }),
101 storageKey,
102 );
95103
96104 return { width: clampedWidth, handlers };
97105}
apps/web/src/hooks/useResizeDrag.ts+10-1
......@@ -1,4 +1,4 @@
1import { type PointerEvent, useCallback, useEffect, useRef } from "react";
1import { type PointerEvent, useCallback, useEffect, useLayoutEffect, useRef } from "react";
22
33interface ResizeSession {
44 width: number;
......@@ -11,6 +11,7 @@ interface ResizeSession {
1111/** Shared pointer lifecycle for side panels, including interrupted and sub-frame drags. */
1212export function useResizeDrag<T extends HTMLElement>(
1313 start: (event: PointerEvent<T>) => ResizeSession | null,
14 resetKey?: string,
1415) {
1516 const drag = useRef<{
1617 session: ResizeSession;
......@@ -54,6 +55,14 @@ export function useResizeDrag<T extends HTMLElement>(
5455 [flush],
5556 );
5657
58 const previousResetKey = useRef(resetKey);
59 useLayoutEffect(() => {
60 if (previousResetKey.current !== resetKey) {
61 finish(false);
62 previousResetKey.current = resetKey;
63 }
64 }, [finish, resetKey]);
65
5766 useEffect(() => {
5867 const onBlur = () => finish();
5968 window.addEventListener("blur", onBlur);