From 4329f7f7e94d3a0e8bb85357331ba24a3349a6c9 Mon Sep 17 00:00:00 2001 From: James Villarrubia Date: Thu, 8 Oct 2026 17:16:45 -0400 Subject: [PATCH] fix(web): a maximized right panel stays maximized when you come back to its thread ChatView kept the maximize flag in local state holding one thread key, so maximizing another thread's panel, leaving the thread routes, or reloading dropped it. The flag now lives in the per-thread right panel store. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/ChatView.tsx | 16 +++++---------- apps/web/src/rightPanelStore.test.ts | 29 ++++++++++++++++++++++++++++ apps/web/src/rightPanelStore.ts | 13 +++++++++++++ 3 files changed, 47 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d6baf94b5ff4..bc1f555d9489 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1912,9 +1912,6 @@ export default function ChatView(props: ChatViewProps) { const isRevertingCheckpoint = useComposerDraftStore((store) => store.rewindingThreadKeys.has(routeThreadKey), ); - const [maximizedRightPanelThreadKey, setMaximizedRightPanelThreadKey] = useState( - null, - ); const userInputResponsesInFlight = useRef(new Set()); const [respondingRequestIds, setRespondingRequestIds] = useState([]); @@ -2390,8 +2387,7 @@ export default function ChatView(props: ChatViewProps) { renderedRightPanelSurface, ); const canMaximizeRightPanel = rightPanelOpen && !shouldUsePlanSidebarSheet; - const rightPanelMaximized = - canMaximizeRightPanel && maximizedRightPanelThreadKey === routeThreadKey; + const rightPanelMaximized = canMaximizeRightPanel && rightPanelState.maximized === true; const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUsePlanSidebarSheet; const [threadPanelPresentation, setThreadPanelPresentation] = useState("inline"); @@ -5866,7 +5862,7 @@ export default function ChatView(props: ChatViewProps) { .getState() .open(activeThreadRef, { kind: "device", ...activeRightPanelSurface.target }); } - setMaximizedRightPanelThreadKey(null); + useRightPanelStore.getState().setMaximized(activeThreadRef, false); useRightPanelStore.getState().close(activeThreadRef); } }, [activeRightPanelSurface, activeThreadRef]); @@ -6057,11 +6053,9 @@ export default function ChatView(props: ChatViewProps) { useRightPanelStore.getState().toggleThreadPanel(activeThreadRef, threadPanelPresentation); }, [activeThreadRef, threadPanelPresentation]); const toggleRightPanelMaximized = useCallback(() => { - if (!canMaximizeRightPanel) return; - setMaximizedRightPanelThreadKey((threadKey) => - threadKey === routeThreadKey ? null : routeThreadKey, - ); - }, [canMaximizeRightPanel, routeThreadKey]); + if (!canMaximizeRightPanel || !activeThreadRef) return; + useRightPanelStore.getState().setMaximized(activeThreadRef, !rightPanelMaximized); + }, [activeThreadRef, canMaximizeRightPanel, rightPanelMaximized]); const cleanupRightPanelSurfaces = useCallback( (surfaces: readonly RightPanelSurface[]) => { if (!activeThreadRef) return; diff --git a/apps/web/src/rightPanelStore.test.ts b/apps/web/src/rightPanelStore.test.ts index 3babb2ed749a..55d5cd3e37e2 100644 --- a/apps/web/src/rightPanelStore.test.ts +++ b/apps/web/src/rightPanelStore.test.ts @@ -322,6 +322,35 @@ describe("rightPanelStore", () => { expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("diff"); }); + it("keeps a maximized panel per thread without counting it as a manual choice", () => { + const store = useRightPanelStore.getState(); + store.open(refA, "preview"); + store.open(refB, "diff"); + const revision = store.getUserActionRevision(refA); + store.setMaximized(refA, true); + store.setMaximized(refB, true); + store.setMaximized(refB, false); + + const { byThreadKey } = useRightPanelStore.getState(); + expect(selectThreadRightPanelState(byThreadKey, refA).maximized).toBe(true); + expect(selectThreadRightPanelState(byThreadKey, refB).maximized).toBeUndefined(); + expect(store.getUserActionRevision(refA)).toBe(revision); + }); + + it("restores a saved maximized panel during migration", () => { + const migrated = migratePersistedRightPanelState({ + byThreadKey: { + "env-1:thread-A": { + isOpen: true, + activeSurfaceId: "browser:new", + surfaces: [{ id: "browser:new", kind: "preview", resourceId: null }], + maximized: true, + }, + }, + }); + expect(selectThreadRightPanelState(migrated.byThreadKey, refA).maximized).toBe(true); + }); + it("drops the legacy singleton terminal surface during migration", () => { expect( migratePersistedRightPanelState({ diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index 6124297ebef2..af0cc9bea9b7 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -112,6 +112,8 @@ export interface ThreadRightPanelState { activeSurfaceId: string | null; surfaces: RightPanelSurface[]; dismissedDeviceSurfaceIds?: string[]; + /** Kept with the thread so the layout survives a ChatView remount or a reload. */ + maximized?: true; } export interface ThreadPanelVisibility { @@ -177,6 +179,7 @@ interface RightPanelStoreState { reconcileFileSurfaces: (ref: ScopedThreadRef, workspaceAvailable: boolean) => void; show: (ref: ScopedThreadRef) => void; close: (ref: ScopedThreadRef) => void; + setMaximized: (ref: ScopedThreadRef, maximized: boolean) => void; toggleVisibility: (ref: ScopedThreadRef) => void; toggle: ( ref: ScopedThreadRef, @@ -563,6 +566,7 @@ export function migratePersistedRightPanelState(persistedState: unknown): { ), } : {}), + ...(validThreadState?.maximized === true ? { maximized: true as const } : {}), }, ]; }), @@ -943,6 +947,15 @@ export const useRightPanelStore = create()( current.isOpen ? { ...current, isOpen: false } : current, ), ), + // Layout only: it changes no surface, so proactive panels still apply. + setMaximized: (ref, maximized) => + set((state) => + automaticUpdate(state, scopedThreadKey(ref), (current) => { + if ((current.maximized ?? false) === maximized) return current; + const { maximized: _maximized, ...rest } = current; + return maximized ? { ...rest, maximized: true } : rest; + }), + ), toggleVisibility: (ref) => set((state) => userAction(state, scopedThreadKey(ref), (current) => ({