Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit 56fc0e9

Browse files
committed
Fix plan-approval mode not sticking across app restarts
Settings persist asynchronously (an IPC round trip on desktop), so PlanApprovalSelector can mount before lastPlanApprovalMode has loaded from disk -- e.g. resuming a task with an already-pending plan approval. The pre-selected mode was seeded once via useState and never revisited, so it stayed on the pre-hydration fallback ("auto") even after the real remembered mode loaded moments later. Only the user's own pick now lives in state; the pre-selected mode derives from lastApprovalMode on every render instead, so it tracks the settings store live and self-corrects once hydration lands. If this component survives to a later approval request without remounting, reset that pick when the request's toolCallId changes so it doesn't leak into (and potentially not exist among the options of) the next request.
1 parent 744d66a commit 56fc0e9

2 files changed

Lines changed: 94 additions & 3 deletions

File tree

‎packages/ui/src/features/permissions/PlanApprovalSelector.test.tsx‎

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { PermissionOption } from "@agentclientprotocol/sdk";
22
import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore";
33
import { Theme } from "@radix-ui/themes";
4-
import { render, screen } from "@testing-library/react";
4+
import { act, render, screen } from "@testing-library/react";
55
import userEvent from "@testing-library/user-event";
66
import { beforeEach, describe, expect, it, vi } from "vitest";
77
import { PlanApprovalSelector } from "./PlanApprovalSelector";
@@ -92,6 +92,75 @@ describe("PlanApprovalSelector", () => {
9292
expect(useSettingsStore.getState().lastPlanApprovalMode).toBe("auto");
9393
});
9494

95+
it("picks up a remembered mode that loads after mount", async () => {
96+
// Settings persist asynchronously (an IPC round trip on desktop), so the
97+
// selector can mount before `lastPlanApprovalMode` has loaded from disk.
98+
const user = userEvent.setup();
99+
const { onSelect } = renderSelector([AUTO, ACCEPT_EDITS, DEFAULT_MODE]);
100+
101+
// The remembered choice loads in after mount.
102+
act(() => {
103+
useSettingsStore.setState({ lastPlanApprovalMode: "acceptEdits" });
104+
});
105+
106+
await user.click(screen.getByText("Approve and proceed"));
107+
108+
expect(onSelect).toHaveBeenCalledWith("acceptEdits");
109+
});
110+
111+
it("does not clobber a mode the user already picked", async () => {
112+
const user = userEvent.setup();
113+
const { onSelect } = renderSelector([AUTO, ACCEPT_EDITS, DEFAULT_MODE]);
114+
115+
await user.click(screen.getByRole("button", { name: "Mode" }));
116+
await user.click(await screen.findByText("Manually approve edits"));
117+
118+
// The remembered choice loads in after the user already picked a mode.
119+
act(() => {
120+
useSettingsStore.setState({ lastPlanApprovalMode: "acceptEdits" });
121+
});
122+
123+
await user.click(screen.getByText("Approve and proceed"));
124+
125+
expect(onSelect).toHaveBeenCalledWith("default");
126+
});
127+
128+
it("drops a manual pick when a later approval request reuses this instance", async () => {
129+
const user = userEvent.setup();
130+
const onSelect = vi.fn();
131+
const options = [AUTO, ACCEPT_EDITS, DEFAULT_MODE];
132+
const { rerender } = render(
133+
<Theme>
134+
<PlanApprovalSelector
135+
toolCall={toolCall}
136+
options={options}
137+
onSelect={onSelect}
138+
onCancel={vi.fn()}
139+
/>
140+
</Theme>,
141+
);
142+
143+
await user.click(screen.getByRole("button", { name: "Mode" }));
144+
await user.click(await screen.findByText("Manually approve edits"));
145+
146+
// The component isn't guaranteed to unmount between requests; a new
147+
// toolCallId means a new request even if this instance is reused.
148+
rerender(
149+
<Theme>
150+
<PlanApprovalSelector
151+
toolCall={{ ...toolCall, toolCallId: "plan-2" } as PermissionToolCall}
152+
options={options}
153+
onSelect={onSelect}
154+
onCancel={vi.fn()}
155+
/>
156+
</Theme>,
157+
);
158+
159+
await user.click(screen.getByText("Approve and proceed"));
160+
161+
expect(onSelect).toHaveBeenCalledWith("auto");
162+
});
163+
95164
it("rejects with the typed feedback", async () => {
96165
const user = userEvent.setup();
97166
const { onSelect } = renderSelector([DEFAULT_MODE, REJECT]);

‎packages/ui/src/features/permissions/PlanApprovalSelector.tsx‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ function isInteractiveElementInDifferentCell(
6161
* `onSelect(<rejectOptionId>, feedback)`.
6262
*/
6363
export function PlanApprovalSelector({
64+
toolCall,
6465
options,
6566
onSelect,
6667
onCancel,
@@ -80,6 +81,11 @@ export function PlanApprovalSelector({
8081

8182
// Resolution order: the mode last approved with (remembered preference),
8283
// then "auto", then manual-approve, then any single-use mode, then the first.
84+
// Settings persist asynchronously (an IPC round trip on desktop), so
85+
// `lastApprovalMode` can still be its pre-hydration default on mount — e.g.
86+
// resuming a task with an already-pending plan approval. Recomputing this
87+
// via `useMemo` (rather than seeding a `useState` once) means it stays
88+
// correct once the store finishes hydrating.
8389
const initialMode = useMemo(() => {
8490
const has = (id: string) => approveOptions.some((o) => o.optionId === id);
8591
return (
@@ -93,7 +99,23 @@ export function PlanApprovalSelector({
9399
);
94100
}, [approveOptions, lastApprovalMode]);
95101

96-
const [selectedMode, setSelectedMode] = useState(initialMode);
102+
// Only the user's own pick lives in state; everything else derives from
103+
// `initialMode` so it tracks `lastApprovalMode` live instead of freezing it
104+
// at mount — derive it, don't duplicate it.
105+
const [explicitMode, setExplicitMode] = useState<string | undefined>(
106+
undefined,
107+
);
108+
// This component can survive to a later approval request without
109+
// remounting, so a pick made for the previous request must not leak into
110+
// (and potentially not exist in) this one. Reset during render rather than
111+
// in an effect: it takes effect before this render paints instead of one
112+
// render later, avoiding a flash of the stale mode.
113+
const lastToolCallIdRef = useRef(toolCall.toolCallId);
114+
if (lastToolCallIdRef.current !== toolCall.toolCallId) {
115+
lastToolCallIdRef.current = toolCall.toolCallId;
116+
setExplicitMode(undefined);
117+
}
118+
const selectedMode = explicitMode ?? initialMode;
97119
const [selectedIndex, setSelectedIndex] = useState(0);
98120
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
99121
const [feedback, setFeedback] = useState("");
@@ -285,7 +307,7 @@ export function PlanApprovalSelector({
285307
<Box onClick={(e) => e.stopPropagation()}>
286308
<ModeSelector
287309
modeOption={modeConfigOption}
288-
onChange={(value) => setSelectedMode(value)}
310+
onChange={(value) => setExplicitMode(value)}
289311
allowBypassPermissions
290312
/>
291313
</Box>

0 commit comments

Comments
 (0)