Skip to content

Commit 61f1d0f

Browse files
authored
Fix plan-approval mode not sticking across app restarts (#3737)
1 parent 65af803 commit 61f1d0f

2 files changed

Lines changed: 110 additions & 3 deletions

File tree

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

Lines changed: 86 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, waitFor } 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,91 @@ 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+
// The dropdown applies the pick when its close animation completes, not
118+
// synchronously with the click, so wait for it to actually land before
119+
// moving on — otherwise this race decides the test's outcome.
120+
await waitFor(() =>
121+
expect(screen.getByRole("button", { name: "Mode" })).toHaveTextContent(
122+
"Manually approve edits",
123+
),
124+
);
125+
126+
// The remembered choice loads in after the user already picked a mode.
127+
act(() => {
128+
useSettingsStore.setState({ lastPlanApprovalMode: "acceptEdits" });
129+
});
130+
131+
await user.click(screen.getByText("Approve and proceed"));
132+
133+
expect(onSelect).toHaveBeenCalledWith("default");
134+
});
135+
136+
it("drops a manual pick when a later approval request reuses this instance", async () => {
137+
const user = userEvent.setup();
138+
const onSelect = vi.fn();
139+
const options = [AUTO, ACCEPT_EDITS, DEFAULT_MODE];
140+
const { rerender } = render(
141+
<Theme>
142+
<PlanApprovalSelector
143+
toolCall={toolCall}
144+
options={options}
145+
onSelect={onSelect}
146+
onCancel={vi.fn()}
147+
/>
148+
</Theme>,
149+
);
150+
151+
await user.click(screen.getByRole("button", { name: "Mode" }));
152+
await user.click(await screen.findByText("Manually approve edits"));
153+
// The dropdown applies the pick when its close animation completes, not
154+
// synchronously with the click — wait for it to land before rerendering,
155+
// or the pending pick can apply after (and survive) the reset below.
156+
await waitFor(() =>
157+
expect(screen.getByRole("button", { name: "Mode" })).toHaveTextContent(
158+
"Manually approve edits",
159+
),
160+
);
161+
162+
// The component isn't guaranteed to unmount between requests; a new
163+
// toolCallId means a new request even if this instance is reused.
164+
rerender(
165+
<Theme>
166+
<PlanApprovalSelector
167+
toolCall={{ ...toolCall, toolCallId: "plan-2" } as PermissionToolCall}
168+
options={options}
169+
onSelect={onSelect}
170+
onCancel={vi.fn()}
171+
/>
172+
</Theme>,
173+
);
174+
175+
await user.click(screen.getByText("Approve and proceed"));
176+
177+
expect(onSelect).toHaveBeenCalledWith("auto");
178+
});
179+
95180
it("rejects with the typed feedback", async () => {
96181
const user = userEvent.setup();
97182
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)