diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 39285438d1a..0c648b3e295 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -24,6 +24,9 @@ import { isBranchMismatchDismissedForSession, reconcileMountedTerminalThreadIds, reconcileRetainedMountedThreadIds, + resolvePlanSidebarAutoOpen, + resolvePlanSidebarRestoration, + resolvePlanSidebarTurnKey, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, startNewThreadForProject, @@ -131,6 +134,132 @@ describe("buildLoadingThreadFromShell", () => { }); }); +describe("resolvePlanSidebarAutoOpen", () => { + const planTurnId = TurnId.make("turn-plan"); + const common = { + autoOpen: true, + isThreadActivation: false, + threadDetailLoading: false, + preserveThreadPanelState: false, + hasNewPlanUpdate: true, + planSidebarOpen: false, + hasActivePlan: true, + activePlanTurnId: planTurnId, + latestTurnId: planTurnId, + proposedPlanTurnId: null, + dismissedTurnKey: null, + }; + + it("preserves a closed panel when reopening a thread with existing steps", () => { + expect( + resolvePlanSidebarAutoOpen({ + ...common, + isThreadActivation: true, + preserveThreadPanelState: true, + }), + ).toBe("preserve"); + }); + + it("waits for thread detail before establishing the restoration baseline", () => { + expect( + resolvePlanSidebarAutoOpen({ + ...common, + isThreadActivation: true, + threadDetailLoading: true, + hasActivePlan: false, + activePlanTurnId: null, + }), + ).toBe("wait"); + }); + + it("opens for steps that arrive after the thread restoration baseline", () => { + expect(resolvePlanSidebarAutoOpen(common)).toBe("open"); + }); + + it("auto-opens existing steps on a first visit without saved panel state", () => { + expect( + resolvePlanSidebarAutoOpen({ + ...common, + isThreadActivation: true, + }), + ).toBe("open"); + }); + + it("does not reopen for the plan update recorded as the restoration baseline", () => { + expect( + resolvePlanSidebarAutoOpen({ + ...common, + hasNewPlanUpdate: false, + }), + ).toBe("none"); + }); + + it("keeps the panel closed after it is dismissed for the active turn", () => { + expect( + resolvePlanSidebarAutoOpen({ + ...common, + dismissedTurnKey: planTurnId, + }), + ).toBe("none"); + }); + + it("does not open tasks carried over from a previous turn", () => { + expect( + resolvePlanSidebarAutoOpen({ + ...common, + activePlanTurnId: TurnId.make("turn-previous"), + }), + ).toBe("none"); + }); + + it("does not open without an active plan", () => { + expect( + resolvePlanSidebarAutoOpen({ + ...common, + hasActivePlan: false, + activePlanTurnId: null, + }), + ).toBe("none"); + }); + + it("does not open when automatic opening is disabled or the panel is already open", () => { + expect(resolvePlanSidebarAutoOpen({ ...common, autoOpen: false })).toBe("none"); + expect(resolvePlanSidebarAutoOpen({ ...common, planSidebarOpen: true })).toBe("none"); + }); +}); + +describe("resolvePlanSidebarRestoration", () => { + it("preserves an open panel regardless of its active surface", () => { + expect(resolvePlanSidebarRestoration({ panelIsOpen: true, activeSurfaceId: "diff" })).toEqual({ + preservePanelState: true, + seedDismissal: false, + }); + }); + + it("preserves and dismisses a previously closed plan surface", () => { + expect(resolvePlanSidebarRestoration({ panelIsOpen: false, activeSurfaceId: "plan" })).toEqual({ + preservePanelState: true, + seedDismissal: true, + }); + }); + + it("does not treat an inactive background surface as panel intent", () => { + expect( + resolvePlanSidebarRestoration({ panelIsOpen: false, activeSurfaceId: "preview" }), + ).toEqual({ preservePanelState: false, seedDismissal: false }); + }); +}); + +describe("resolvePlanSidebarTurnKey", () => { + it("prefers the active plan turn and falls back through proposed and unattributed plans", () => { + expect( + resolvePlanSidebarTurnKey(TurnId.make("turn-active"), TurnId.make("turn-proposed")), + ).toBe("turn-active"); + expect(resolvePlanSidebarTurnKey(null, TurnId.make("turn-proposed"))).toBe("turn-proposed"); + expect(resolvePlanSidebarTurnKey(null, null)).toBe("__dismissed__"); + }); +}); + describe("resolveThreadMetadataUpdateForNextTurn", () => { const modelSelection = { instanceId: ProviderInstanceId.make("codex"), diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 04b35fd4551..b462fd1d95f 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -339,6 +339,64 @@ export function shouldShowBranchMismatchBanner(input: { return input.composerHasContent || input.wasShownForCurrentMismatch; } +export type PlanSidebarAutoOpenDecision = "wait" | "preserve" | "open" | "none"; + +export function resolvePlanSidebarRestoration(input: { + panelIsOpen: boolean; + activeSurfaceId: string | null; +}): { preservePanelState: boolean; seedDismissal: boolean } { + const planWasActive = input.activeSurfaceId === "plan"; + return { + preservePanelState: input.panelIsOpen || planWasActive, + seedDismissal: !input.panelIsOpen && planWasActive, + }; +} + +export function resolvePlanSidebarTurnKey( + activePlanTurnId: TurnId | null, + proposedPlanTurnId: TurnId | null | undefined, +): string { + return activePlanTurnId ?? proposedPlanTurnId ?? "__dismissed__"; +} + +export function resolvePlanSidebarAutoOpen(input: { + autoOpen: boolean; + isThreadActivation: boolean; + threadDetailLoading: boolean; + preserveThreadPanelState: boolean; + hasNewPlanUpdate: boolean; + planSidebarOpen: boolean; + hasActivePlan: boolean; + activePlanTurnId: TurnId | null; + latestTurnId: TurnId | null; + proposedPlanTurnId: TurnId | null | undefined; + dismissedTurnKey: string | null; +}): PlanSidebarAutoOpenDecision { + if (input.isThreadActivation && input.threadDetailLoading) { + return "wait"; + } + + // Entering a thread restores its persisted panel state. Existing steps are + // the baseline, not a new update that should override a previous close. + if (input.isThreadActivation && input.preserveThreadPanelState) { + return "preserve"; + } + + const turnKey = resolvePlanSidebarTurnKey(input.activePlanTurnId, input.proposedPlanTurnId); + if ( + !input.autoOpen || + !input.hasNewPlanUpdate || + !input.hasActivePlan || + input.planSidebarOpen || + (input.latestTurnId !== null && input.activePlanTurnId !== input.latestTurnId) || + input.dismissedTurnKey === turnKey + ) { + return "none"; + } + + return "open"; +} + // Session-scoped (module-level so it survives ChatView remounts, e.g. route // changes). Durable cross-device dismissal is planned as a server-side ack. const sessionDismissedBranchMismatchKeys = new Set(); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c260c9e9118..86daedf16dd 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -281,6 +281,9 @@ import { deriveLockedProvider, readFileAsDataUrl, reconcileMountedTerminalThreadIds, + resolvePlanSidebarAutoOpen, + resolvePlanSidebarRestoration, + resolvePlanSidebarTurnKey, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, revokeBlobPreviewUrl, @@ -1314,6 +1317,10 @@ function ChatViewContent(props: ChatViewProps) { const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = useState>({}); const shouldUsePlanSidebarSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); + // Existing steps establish a baseline when a thread is opened. Only updates + // observed after that baseline may auto-open the sidebar. + const planSidebarActivationThreadKeyRef = useRef(routeThreadKey); + const planSidebarObservedPlanUpdateKeyRef = useRef(null); // When set, the thread-change reset effect will open the sidebar instead of closing it. // Used by "Implement in a new thread" to carry the sidebar-open intent across navigation. const planSidebarOpenOnNextThreadRef = useRef(false); @@ -2145,6 +2152,11 @@ function ChatViewContent(props: ChatViewProps) { () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined), [activeLatestTurn?.turnId, threadActivities], ); + const activePlanUpdateKey = activePlan?.updateId ?? null; + const planSidebarRestoration = resolvePlanSidebarRestoration({ + panelIsOpen: rightPanelState.isOpen, + activeSurfaceId: rightPanelState.activeSurfaceId, + }); const planSidebarLabel = sidebarProposedPlan || interactionMode === "plan" ? "Plan" : "Tasks"; const showPlanFollowUpPrompt = pendingUserInputs.length === 0 && @@ -3125,7 +3137,7 @@ function ChatViewContent(props: ChatViewProps) { if (!activeThreadKey) return; dismissPlanSidebarForTurn( activeThreadKey, - activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__", + resolvePlanSidebarTurnKey(activePlan?.turnId ?? null, sidebarProposedPlan?.turnId), ); }, [activeThreadKey, activePlan?.turnId, sidebarProposedPlan?.turnId]); const togglePlanSidebar = useCallback(() => { @@ -3864,35 +3876,81 @@ function ChatViewContent(props: ChatViewProps) { activeTimelineAnchorIndexRef.current = null; showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); - if (planSidebarOpenOnNextThreadRef.current) { + if (planSidebarOpenOnNextThreadRef.current && activeThreadRef) { planSidebarOpenOnNextThreadRef.current = false; - if (activeThreadRef) { - clearPlanSidebarDismissal(scopedThreadKey(activeThreadRef)); - useRightPanelStore.getState().open(activeThreadRef, "plan"); - } + clearPlanSidebarDismissal(scopedThreadKey(activeThreadRef)); + useRightPanelStore.getState().open(activeThreadRef, "plan"); } // activeThreadRef resets transitively with the active thread. }, [activeThread?.id]); + useEffect(() => { + planSidebarActivationThreadKeyRef.current = routeThreadKey; + planSidebarObservedPlanUpdateKeyRef.current = null; + }, [routeThreadKey]); + // Auto-open the plan sidebar when plan/todo steps arrive for the current turn. // Don't auto-open for plans carried over from a previous turn (the user can open manually). useEffect(() => { - if (!autoOpenPlanSidebar) return; - if (!activePlan) return; - if (planSidebarOpen) return; const latestTurnId = activeLatestTurn?.turnId ?? null; - if (latestTurnId && activePlan.turnId !== latestTurnId) return; - const turnKey = activePlan.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; + const planSidebarThreadKey = activeThreadKey ?? routeThreadKey; + const planSidebarTurnKey = resolvePlanSidebarTurnKey( + activePlan?.turnId ?? null, + sidebarProposedPlan?.turnId, + ); + const hasNewPlanUpdate = + activePlanUpdateKey !== null && + activePlanUpdateKey !== planSidebarObservedPlanUpdateKeyRef.current; + const decision = resolvePlanSidebarAutoOpen({ + autoOpen: autoOpenPlanSidebar, + isThreadActivation: planSidebarActivationThreadKeyRef.current === routeThreadKey, + threadDetailLoading, + preserveThreadPanelState: planSidebarRestoration.preservePanelState, + hasNewPlanUpdate, + planSidebarOpen, + hasActivePlan: activePlan !== null, + activePlanTurnId: activePlan?.turnId ?? null, + latestTurnId, + proposedPlanTurnId: sidebarProposedPlan?.turnId, + dismissedTurnKey: isPlanSidebarDismissedForTurn(planSidebarThreadKey, planSidebarTurnKey) + ? planSidebarTurnKey + : null, + }); + + if (decision === "wait") return; + planSidebarActivationThreadKeyRef.current = null; + + if (decision === "preserve") { + planSidebarObservedPlanUpdateKeyRef.current = activePlanUpdateKey; + if (planSidebarRestoration.seedDismissal && activePlan !== null) { + dismissPlanSidebarForTurn(planSidebarThreadKey, planSidebarTurnKey); + } + return; + } + + if (decision === "none") { + if (hasNewPlanUpdate) { + planSidebarObservedPlanUpdateKeyRef.current = activePlanUpdateKey; + } + return; + } + if (!activeThreadRef) return; - if (isPlanSidebarDismissedForTurn(scopedThreadKey(activeThreadRef), turnKey)) return; + planSidebarObservedPlanUpdateKeyRef.current = activePlanUpdateKey; useRightPanelStore.getState().open(activeThreadRef, "plan"); }, [ - activePlan, + activePlan?.turnId, + activePlanUpdateKey, activeLatestTurn?.turnId, + activeThreadKey, activeThreadRef, autoOpenPlanSidebar, planSidebarOpen, + planSidebarRestoration.preservePanelState, + planSidebarRestoration.seedDismissal, + routeThreadKey, sidebarProposedPlan?.turnId, + threadDetailLoading, ]); useEffect(() => { @@ -5450,6 +5508,8 @@ function ChatViewContent(props: ChatViewProps) { }, [ activeThread, + activeThreadKey, + activeThreadRef, activeProposedPlan, beginLocalDispatch, isConnecting, @@ -5459,6 +5519,7 @@ function ChatViewContent(props: ChatViewProps) { persistThreadSettingsForNextTurn, resetLocalDispatch, runtimeMode, + routeThreadKey, setComposerDraftInteractionMode, setThreadError, startThreadTurn, diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index cdb2119ac8d..f1ec342086b 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -352,6 +352,7 @@ describe("deriveActivePlanState", () => { ]; expect(deriveActivePlanState(activities, TurnId.make("turn-1"))).toEqual({ + updateId: "plan-latest", createdAt: "2026-02-23T00:00:02.000Z", turnId: "turn-1", explanation: "Refined plan", @@ -377,6 +378,7 @@ describe("deriveActivePlanState", () => { // Current turn is turn-2, which has no plan activity — should fall back to turn-1's plan const result = deriveActivePlanState(activities, TurnId.make("turn-2")); expect(result).toEqual({ + updateId: "plan-from-turn-1", createdAt: "2026-02-23T00:00:01.000Z", turnId: "turn-1", steps: [{ step: "Write tests", status: "completed" }], diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index a1ff70bc043..24fc08564be 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -119,6 +119,7 @@ export interface PendingUserInput { } export interface ActivePlanState { + updateId: OrchestrationThreadActivity["id"]; createdAt: string; turnId: TurnId | null; explanation?: string | null; @@ -580,6 +581,7 @@ export function deriveActivePlanState( return null; } return { + updateId: latest.id, createdAt: latest.createdAt, turnId: latest.turnId, ...(payload && "explanation" in payload