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) => ({