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) {...@@ -9364,6 +9364,7 @@ export default function ChatView(props: ChatViewProps) {
9364 {rightPanelPresent && !shouldUseRightPanelSheet && activeThreadRef ? (9364 {rightPanelPresent && !shouldUseRightPanelSheet && activeThreadRef ? (
9365 <RightPanelTabs9365 <RightPanelTabs
9366 mode="inline"9366 mode="inline"
9367 widthStorageKey={`t3code:preview-panel-width:${activeThreadKey}`}
9367 open={rightPanelOpen}9368 open={rightPanelOpen}
9368 maximized={rightPanelMaximized}9369 maximized={rightPanelMaximized}
9369 surfaces={renderedRightPanelSurfaces}9370 surfaces={renderedRightPanelSurfaces}
apps/web/src/hooks/useResizableWidth.test.tsx+63-4
...@@ -24,7 +24,8 @@ const style = {...@@ -24,7 +24,8 @@ const style = {
24 if (property === "user-select") this.userSelect = "";24 if (property === "user-select") this.userSelect = "";
25 },25 },
26};26};
27const setItem = vi.fn();27const savedWidths = new Map<string, string>();
28const setItem = vi.fn((key: string, value: string) => savedWidths.set(key, value));
28const cancelAnimationFrame = vi.fn();29const cancelAnimationFrame = vi.fn();
29let events: EventTarget;30let events: EventTarget;
30let frame: FrameRequestCallback | undefined;31let frame: FrameRequestCallback | undefined;
...@@ -40,9 +41,17 @@ function pointer(clientX = 100) {...@@ -40,9 +41,17 @@ function pointer(clientX = 100) {
40 } as unknown as PointerEvent<HTMLElement>;41 } as unknown as PointerEvent<HTMLElement>;
41}42}
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}) {
44 const resize = useResizableWidth({53 const resize = useResizableWidth({
45 storageKey: "test-panel-width",54 storageKey,
46 defaultWidth: 400,55 defaultWidth: 400,
47 minWidth: 200,56 minWidth: 200,
48 maxWidth,57 maxWidth,
...@@ -55,6 +64,7 @@ function Panel({ edge = "left", maxWidth = 800 }: { edge?: "left" | "right"; max...@@ -55,6 +64,7 @@ function Panel({ edge = "left", maxWidth = 800 }: { edge?: "left" | "right"; max
55}64}
5665
57beforeEach(async () => {66beforeEach(async () => {
67 savedWidths.clear();
58 captured = false;68 captured = false;
59 frame = undefined;69 frame = undefined;
60 style.cursor = "";70 style.cursor = "";
...@@ -64,7 +74,7 @@ beforeEach(async () => {...@@ -64,7 +74,7 @@ beforeEach(async () => {
64 vi.stubGlobal("window", {74 vi.stubGlobal("window", {
65 addEventListener: events.addEventListener.bind(events),75 addEventListener: events.addEventListener.bind(events),
66 removeEventListener: events.removeEventListener.bind(events),76 removeEventListener: events.removeEventListener.bind(events),
67 localStorage: { getItem: () => null, setItem },77 localStorage: { getItem: (key: string) => savedWidths.get(key) ?? null, setItem },
68 });78 });
69 vi.stubGlobal("document", { body: { style } });79 vi.stubGlobal("document", { body: { style } });
70 vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {80 vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
...@@ -182,3 +192,52 @@ describe("panel resize cleanup", () => {...@@ -182,3 +192,52 @@ describe("panel resize cleanup", () => {
182 expect(captured).toBe(false);192 expect(captured).toBe(false);
183 });193 });
184});194});
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 {...@@ -36,7 +36,7 @@ export interface ResizableWidthHandlers {
3636
37/**37/**
38 * Width state for a side-anchored panel resized via a drag handle on the38 * 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 on39 * specified edge. Width is read on mount or storage-key changes and persisted on
40 * drag-end (not on every rAF tick — would otherwise be ~60 writes/sec).40 * drag-end (not on every rAF tick — would otherwise be ~60 writes/sec).
41 *41 *
42 * The hook updates an internal `width` state during drag (so the panel42 * The hook updates an internal `width` state during drag (so the panel
...@@ -58,7 +58,7 @@ export function useResizableWidth(options: UseResizableWidthOptions): {...@@ -58,7 +58,7 @@ export function useResizableWidth(options: UseResizableWidthOptions): {
58 );58 );
5959
60 // No cross-tab subscription: panel width is per-window state.60 // No cross-tab subscription: panel width is per-window state.
61 const [width, setWidth] = useState<number>(() => {61 const readWidth = () => {
62 if (typeof window === "undefined") return defaultWidth;62 if (typeof window === "undefined") return defaultWidth;
63 try {63 try {
64 const stored = getLocalStorageItem(storageKey, WidthSchema);64 const stored = getLocalStorageItem(storageKey, WidthSchema);
...@@ -67,31 +67,39 @@ export function useResizableWidth(options: UseResizableWidthOptions): {...@@ -67,31 +67,39 @@ export function useResizableWidth(options: UseResizableWidthOptions): {
67 console.error("Could not read persisted panel width.", error);67 console.error("Could not read persisted panel width.", error);
68 return defaultWidth;68 return defaultWidth;
69 }69 }
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);
73 const latestOptions = useRef({ clamp, storageKey });78 const latestOptions = useRef({ clamp, storageKey });
74 useLayoutEffect(() => {79 useLayoutEffect(() => {
75 latestOptions.current = { clamp, storageKey };80 latestOptions.current = { clamp, storageKey };
76 }, [clamp, storageKey]);81 }, [clamp, storageKey]);
7782
78 const handlers = useResizeDrag<HTMLElement>(() => ({83 const handlers = useResizeDrag<HTMLElement>(
79 width: clampedWidth,84 () => ({
80 edge,85 width: clampedWidth,
81 resize(value) {86 edge,
82 const nextWidth = latestOptions.current.clamp(value);87 resize(value) {
83 setWidth(nextWidth);88 const nextWidth = latestOptions.current.clamp(value);
84 return nextWidth;89 setWidthState({ storageKey, width: nextWidth });
85 },90 return nextWidth;
86 finish(finalWidth) {91 },
87 // Commit once at drag-end to avoid 60Hz localStorage writes.92 finish(finalWidth) {
88 try {93 // Commit once at drag-end to avoid 60Hz localStorage writes.
89 setLocalStorageItem(latestOptions.current.storageKey, finalWidth, WidthSchema);94 try {
90 } catch (error) {95 setLocalStorageItem(latestOptions.current.storageKey, finalWidth, WidthSchema);
91 console.error("Could not persist panel width.", error);96 } catch (error) {
92 }97 console.error("Could not persist panel width.", error);
93 },98 }
94 }));99 },
100 }),
101 storageKey,
102 );
95103
96 return { width: clampedWidth, handlers };104 return { width: clampedWidth, handlers };
97}105}
apps/web/src/hooks/useResizeDrag.ts+10-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { type PointerEvent, useCallback, useEffect, useRef } from "react";1import { type PointerEvent, useCallback, useEffect, useLayoutEffect, useRef } from "react";
22
3interface ResizeSession {3interface ResizeSession {
4 width: number;4 width: number;
...@@ -11,6 +11,7 @@ interface ResizeSession {...@@ -11,6 +11,7 @@ interface ResizeSession {
11/** Shared pointer lifecycle for side panels, including interrupted and sub-frame drags. */11/** Shared pointer lifecycle for side panels, including interrupted and sub-frame drags. */
12export function useResizeDrag<T extends HTMLElement>(12export function useResizeDrag<T extends HTMLElement>(
13 start: (event: PointerEvent<T>) => ResizeSession | null,13 start: (event: PointerEvent<T>) => ResizeSession | null,
14 resetKey?: string,
14) {15) {
15 const drag = useRef<{16 const drag = useRef<{
16 session: ResizeSession;17 session: ResizeSession;
...@@ -54,6 +55,14 @@ export function useResizeDrag<T extends HTMLElement>(...@@ -54,6 +55,14 @@ export function useResizeDrag<T extends HTMLElement>(
54 [flush],55 [flush],
55 );56 );
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
57 useEffect(() => {66 useEffect(() => {
58 const onBlur = () => finish();67 const onBlur = () => finish();
59 window.addEventListener("blur", onBlur);68 window.addEventListener("blur", onBlur);