Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ import {
isBranchMismatchDismissedForSession,
reconcileMountedTerminalThreadIds,
reconcileRetainedMountedThreadIds,
resolvePlanSidebarAutoOpen,
resolvePlanSidebarRestoration,
resolvePlanSidebarTurnKey,
resolveThreadMetadataUpdateForNextTurn,
resolveSendEnvMode,
startNewThreadForProject,
Expand Down Expand Up @@ -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"),
Expand Down
58 changes: 58 additions & 0 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
Expand Down
87 changes: 74 additions & 13 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,9 @@ import {
deriveLockedProvider,
readFileAsDataUrl,
reconcileMountedTerminalThreadIds,
resolvePlanSidebarAutoOpen,
resolvePlanSidebarRestoration,
resolvePlanSidebarTurnKey,
resolveThreadMetadataUpdateForNextTurn,
resolveSendEnvMode,
revokeBlobPreviewUrl,
Expand Down Expand Up @@ -1314,6 +1317,10 @@ function ChatViewContent(props: ChatViewProps) {
const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] =
useState<Record<string, number>>({});
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<string | null>(routeThreadKey);
const planSidebarObservedPlanUpdateKeyRef = useRef<string | null>(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);
Expand Down Expand Up @@ -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 &&
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -5450,6 +5508,8 @@ function ChatViewContent(props: ChatViewProps) {
},
[
activeThread,
activeThreadKey,
activeThreadRef,
activeProposedPlan,
beginLocalDispatch,
isConnecting,
Expand All @@ -5459,6 +5519,7 @@ function ChatViewContent(props: ChatViewProps) {
persistThreadSettingsForNextTurn,
resetLocalDispatch,
runtimeMode,
routeThreadKey,
setComposerDraftInteractionMode,
setThreadError,
startThreadTurn,
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/session-logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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" }],
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/session-logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ export interface PendingUserInput {
}

export interface ActivePlanState {
updateId: OrchestrationThreadActivity["id"];
createdAt: string;
turnId: TurnId | null;
explanation?: string | null;
Expand Down Expand Up @@ -580,6 +581,7 @@ export function deriveActivePlanState(
return null;
}
return {
updateId: latest.id,
createdAt: latest.createdAt,
turnId: latest.turnId,
...(payload && "explanation" in payload
Expand Down
Loading