From 88fa5a7abfa1197039a319d47fdaeaa487960173 Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Wed, 22 Jul 2026 14:19:19 -0700 Subject: [PATCH 1/2] Add a persisted "mode after plan approval" setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, the mode pre-selected when approving a plan was either the sticky "last used" choice or a hardcoded fallback to "auto" — there was no way to pin a specific mode as the permanent default, so picking a different mode once (e.g. for one sensitive plan) silently became the default for every future approval. Adds `defaultPlanApprovalMode` to the settings store (default `"last_used"`, fully backward compatible), exposed as "Mode after plan approval" in Settings → General, right below "Initial task mode". `PlanApprovalSelector` now prefers this pinned mode over the remembered last-used mode when set. Since both the Claude and Codex adapters route their plan-approval prompt through the same shared `PlanApprovalSelector` UI, this covers both without touching `packages/agent` or `packages/core`. Generated-By: PostHog Code Task-Id: 602d18f7-fc49-4e98-865e-6cbf9e27a91f --- .../permissions/PlanApprovalSelector.test.tsx | 28 ++++++++++-- .../permissions/PlanApprovalSelector.tsx | 13 ++++-- .../settings/sections/GeneralSettings.tsx | 44 +++++++++++++++++++ .../features/settings/settingsStore.test.ts | 1 + .../ui/src/features/settings/settingsStore.ts | 9 ++++ 5 files changed, 89 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/features/permissions/PlanApprovalSelector.test.tsx b/packages/ui/src/features/permissions/PlanApprovalSelector.test.tsx index 06f257c430..5fc1d78233 100644 --- a/packages/ui/src/features/permissions/PlanApprovalSelector.test.tsx +++ b/packages/ui/src/features/permissions/PlanApprovalSelector.test.tsx @@ -53,27 +53,49 @@ function renderSelector(options: PermissionOption[]) { describe("PlanApprovalSelector", () => { beforeEach(() => { // Reset the remembered choice so tests don't leak through persistence. - useSettingsStore.setState({ lastPlanApprovalMode: null }); + useSettingsStore.setState({ + lastPlanApprovalMode: null, + defaultPlanApprovalMode: "last_used", + }); }); it.each([ { label: "there is no prior or remembered mode", lastMode: null, + defaultMode: "last_used", options: [AUTO, ACCEPT_EDITS, DEFAULT_MODE, REJECT], expected: "auto", }, { label: "a last choice is remembered", lastMode: "acceptEdits", + defaultMode: "last_used", + options: [AUTO, ACCEPT_EDITS, DEFAULT_MODE], + expected: "acceptEdits", + }, + { + label: "a pinned default mode overrides the remembered choice", + lastMode: "acceptEdits", + defaultMode: "auto", + options: [AUTO, ACCEPT_EDITS, DEFAULT_MODE], + expected: "auto", + }, + { + label: "the pinned default mode isn't offered", + lastMode: "acceptEdits", + defaultMode: "bypassPermissions", options: [AUTO, ACCEPT_EDITS, DEFAULT_MODE], expected: "acceptEdits", }, ] as const)( "defaults to $expected when $label", - async ({ lastMode, options, expected }) => { + async ({ lastMode, defaultMode, options, expected }) => { const user = userEvent.setup(); - useSettingsStore.setState({ lastPlanApprovalMode: lastMode }); + useSettingsStore.setState({ + lastPlanApprovalMode: lastMode, + defaultPlanApprovalMode: defaultMode, + }); const { onSelect } = renderSelector([...options]); await user.click(screen.getByText("Approve and proceed")); diff --git a/packages/ui/src/features/permissions/PlanApprovalSelector.tsx b/packages/ui/src/features/permissions/PlanApprovalSelector.tsx index 1cd5e3541f..05e7a28125 100644 --- a/packages/ui/src/features/permissions/PlanApprovalSelector.tsx +++ b/packages/ui/src/features/permissions/PlanApprovalSelector.tsx @@ -77,12 +77,19 @@ export function PlanApprovalSelector({ const setLastApprovalMode = useSettingsStore( (s) => s.setLastPlanApprovalMode, ); + const defaultApprovalMode = useSettingsStore( + (s) => s.defaultPlanApprovalMode, + ); - // Resolution order: the mode last approved with (remembered preference), - // then "auto", then manual-approve, then any single-use mode, then the first. + // Resolution order: a pinned default mode (if configured and offered), else + // the mode last approved with (remembered preference), then "auto", then + // manual-approve, then any single-use mode, then the first. const initialMode = useMemo(() => { const has = (id: string) => approveOptions.some((o) => o.optionId === id); return ( + (defaultApprovalMode !== "last_used" && has(defaultApprovalMode) + ? defaultApprovalMode + : undefined) ?? (lastApprovalMode && has(lastApprovalMode) ? lastApprovalMode : undefined) ?? @@ -91,7 +98,7 @@ export function PlanApprovalSelector({ approveOptions.find((o) => o.kind === "allow_once")?.optionId ?? approveOptions[0]?.optionId ); - }, [approveOptions, lastApprovalMode]); + }, [approveOptions, lastApprovalMode, defaultApprovalMode]); const [selectedMode, setSelectedMode] = useState(initialMode); const [selectedIndex, setSelectedIndex] = useState(0); diff --git a/packages/ui/src/features/settings/sections/GeneralSettings.tsx b/packages/ui/src/features/settings/sections/GeneralSettings.tsx index bdf56b0ffe..63dfb8cccd 100644 --- a/packages/ui/src/features/settings/sections/GeneralSettings.tsx +++ b/packages/ui/src/features/settings/sections/GeneralSettings.tsx @@ -7,11 +7,13 @@ import { COLLAPSE_MODE_OPTIONS, type CollapseMode, } from "@posthog/ui/features/sessions/components/new-thread/conversationThreadConfig"; +import { MODE_LABELS } from "@posthog/ui/features/sessions/modeStyles"; import { SettingRow } from "@posthog/ui/features/settings/SettingRow"; import { type AutoConvertLongText, type DefaultInitialTaskMode, type DefaultMessagingMode, + type DefaultPlanApprovalMode, type DefaultReasoningEffort, type DiffOpenMode, type SendMessagesWith, @@ -77,6 +79,7 @@ export function GeneralSettings() { autoConvertLongText, defaultInitialTaskMode, defaultMessagingMode, + defaultPlanApprovalMode, defaultReasoningEffort, diffOpenMode, sendMessagesWith, @@ -87,6 +90,7 @@ export function GeneralSettings() { setAutoConvertLongText, setDefaultInitialTaskMode, setDefaultMessagingMode, + setDefaultPlanApprovalMode, setDefaultReasoningEffort, setDiffOpenMode, setSendMessagesWith, @@ -146,6 +150,18 @@ export function GeneralSettings() { [defaultInitialTaskMode, setDefaultInitialTaskMode], ); + const handleDefaultPlanApprovalModeChange = useCallback( + (value: DefaultPlanApprovalMode) => { + track(ANALYTICS_EVENTS.SETTING_CHANGED, { + setting_name: "default_plan_approval_mode", + new_value: value, + old_value: defaultPlanApprovalMode, + }); + setDefaultPlanApprovalMode(value); + }, + [defaultPlanApprovalMode, setDefaultPlanApprovalMode], + ); + const handleDefaultMessagingModeChange = useCallback( (value: DefaultMessagingMode) => { track(ANALYTICS_EVENTS.SETTING_CHANGED, { @@ -298,6 +314,34 @@ export function GeneralSettings() { + + + handleDefaultPlanApprovalModeChange( + value as DefaultPlanApprovalMode, + ) + } + size="1" + > + + + Last used + {MODE_LABELS.auto} + {MODE_LABELS.default} + + {MODE_LABELS.acceptEdits} + + + {MODE_LABELS.bypassPermissions} + + + + + { ["slotMachineMode", false, true], ["dismissibleUpdateBanners", false, true], ["showSidebarWorktrees", false, true], + ["defaultPlanApprovalMode", "last_used", "auto"], ] as const)("rehydrates %s", async (field, initial, persisted) => { getItem.mockResolvedValue( JSON.stringify({ state: { [field]: persisted }, version: 0 }), diff --git a/packages/ui/src/features/settings/settingsStore.ts b/packages/ui/src/features/settings/settingsStore.ts index 2d71ae4c17..3ee837afb0 100644 --- a/packages/ui/src/features/settings/settingsStore.ts +++ b/packages/ui/src/features/settings/settingsStore.ts @@ -16,6 +16,7 @@ export type LocalWorkspaceMode = "worktree" | "local"; export const DEFAULT_WORKSPACE_MODE: WorkspaceMode = "cloud"; export type AgentAdapter = Adapter; export type DefaultInitialTaskMode = "plan" | "last_used"; +export type DefaultPlanApprovalMode = ExecutionMode | "last_used"; export type DefaultMessagingMode = "queue" | "steer"; export type DefaultReasoningEffort = | "low" @@ -117,6 +118,9 @@ interface SettingsStore { lastUsedInitialTaskMode: ExecutionMode; // Mode last chosen when approving a plan; pre-selected on the next approval. lastPlanApprovalMode: ExecutionMode | null; + // Pins plan approval to always pre-select a specific mode, overriding + // lastPlanApprovalMode; "last_used" preserves the old sticky behavior. + defaultPlanApprovalMode: DefaultPlanApprovalMode; defaultReasoningEffort: DefaultReasoningEffort; defaultMessagingMode: DefaultMessagingMode; setDefaultMessagingMode: (mode: DefaultMessagingMode) => void; @@ -140,6 +144,7 @@ interface SettingsStore { setDefaultInitialTaskMode: (mode: DefaultInitialTaskMode) => void; setLastUsedInitialTaskMode: (mode: ExecutionMode) => void; setLastPlanApprovalMode: (mode: ExecutionMode) => void; + setDefaultPlanApprovalMode: (mode: DefaultPlanApprovalMode) => void; setDefaultReasoningEffort: (effort: DefaultReasoningEffort) => void; // Notifications @@ -306,6 +311,7 @@ export const useSettingsStore = create()( defaultInitialTaskMode: "plan", lastUsedInitialTaskMode: "plan", lastPlanApprovalMode: null, + defaultPlanApprovalMode: "last_used", defaultReasoningEffort: "last_used", defaultMessagingMode: "queue", setDefaultRunMode: (mode) => set({ defaultRunMode: mode }), @@ -348,6 +354,8 @@ export const useSettingsStore = create()( setLastUsedInitialTaskMode: (mode) => set({ lastUsedInitialTaskMode: mode }), setLastPlanApprovalMode: (mode) => set({ lastPlanApprovalMode: mode }), + setDefaultPlanApprovalMode: (mode) => + set({ defaultPlanApprovalMode: mode }), setDefaultReasoningEffort: (effort) => set({ defaultReasoningEffort: effort }), setDefaultMessagingMode: (mode) => set({ defaultMessagingMode: mode }), @@ -531,6 +539,7 @@ export const useSettingsStore = create()( defaultInitialTaskMode: state.defaultInitialTaskMode, lastUsedInitialTaskMode: state.lastUsedInitialTaskMode, lastPlanApprovalMode: state.lastPlanApprovalMode, + defaultPlanApprovalMode: state.defaultPlanApprovalMode, defaultReasoningEffort: state.defaultReasoningEffort, defaultMessagingMode: state.defaultMessagingMode, From 2a141a7915e996875344de9734a2e7fa869088fe Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Wed, 22 Jul 2026 14:31:05 -0700 Subject: [PATCH 2/2] Simplify plan-approval mode resolution and settings list Extract PlanApprovalSelector's mode-resolution chain into a standalone, testable `resolveInitialPlanApprovalMode` function, and derive the settings dropdown's mode options from a shared `PLAN_APPROVAL_MODES` list in `modeStyles.tsx` instead of hardcoding them separately in `GeneralSettings.tsx`. Generated-By: PostHog Code Task-Id: 602d18f7-fc49-4e98-865e-6cbf9e27a91f --- .../permissions/PlanApprovalSelector.tsx | 58 +++++++++++++------ .../ui/src/features/sessions/modeStyles.tsx | 12 ++++ .../settings/sections/GeneralSettings.tsx | 18 +++--- 3 files changed, 60 insertions(+), 28 deletions(-) diff --git a/packages/ui/src/features/permissions/PlanApprovalSelector.tsx b/packages/ui/src/features/permissions/PlanApprovalSelector.tsx index 05e7a28125..3035782342 100644 --- a/packages/ui/src/features/permissions/PlanApprovalSelector.tsx +++ b/packages/ui/src/features/permissions/PlanApprovalSelector.tsx @@ -5,7 +5,10 @@ import type { import type { ExecutionMode } from "@posthog/shared"; import { ModeSelector } from "@posthog/ui/features/message-editor/components/ModeSelector"; import { MODE_LABELS } from "@posthog/ui/features/sessions/modeStyles"; -import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; +import { + type DefaultPlanApprovalMode, + useSettingsStore, +} from "@posthog/ui/features/settings/settingsStore"; import { ActionSelector, InlineEditableText, @@ -32,6 +35,32 @@ function hasCustomInput(option: PermissionOption): boolean { ); } +// Resolution order: a pinned default mode (if configured and offered), else +// the mode last approved with (remembered preference), then "auto", then +// manual-approve, then any single-use mode, then the first. +function resolveInitialPlanApprovalMode( + approveOptions: PermissionOption[], + defaultApprovalMode: DefaultPlanApprovalMode, + lastApprovalMode: ExecutionMode | null, +): string | undefined { + const has = (id: string) => approveOptions.some((o) => o.optionId === id); + + if (defaultApprovalMode !== "last_used" && has(defaultApprovalMode)) { + return defaultApprovalMode; + } + if (lastApprovalMode && has(lastApprovalMode)) { + return lastApprovalMode; + } + if (has("auto")) { + return "auto"; + } + return ( + approveOptions.find((o) => o.optionId === "default")?.optionId ?? + approveOptions.find((o) => o.kind === "allow_once")?.optionId ?? + approveOptions[0]?.optionId + ); +} + // Don't steal focus from an interactive element in a different grid cell // (multi-task view). Mirrors the guard in useActionSelectorState. function isInteractiveElementInDifferentCell( @@ -81,24 +110,15 @@ export function PlanApprovalSelector({ (s) => s.defaultPlanApprovalMode, ); - // Resolution order: a pinned default mode (if configured and offered), else - // the mode last approved with (remembered preference), then "auto", then - // manual-approve, then any single-use mode, then the first. - const initialMode = useMemo(() => { - const has = (id: string) => approveOptions.some((o) => o.optionId === id); - return ( - (defaultApprovalMode !== "last_used" && has(defaultApprovalMode) - ? defaultApprovalMode - : undefined) ?? - (lastApprovalMode && has(lastApprovalMode) - ? lastApprovalMode - : undefined) ?? - (has("auto") ? "auto" : undefined) ?? - approveOptions.find((o) => o.optionId === "default")?.optionId ?? - approveOptions.find((o) => o.kind === "allow_once")?.optionId ?? - approveOptions[0]?.optionId - ); - }, [approveOptions, lastApprovalMode, defaultApprovalMode]); + const initialMode = useMemo( + () => + resolveInitialPlanApprovalMode( + approveOptions, + defaultApprovalMode, + lastApprovalMode, + ), + [approveOptions, lastApprovalMode, defaultApprovalMode], + ); const [selectedMode, setSelectedMode] = useState(initialMode); const [selectedIndex, setSelectedIndex] = useState(0); diff --git a/packages/ui/src/features/sessions/modeStyles.tsx b/packages/ui/src/features/sessions/modeStyles.tsx index 065fd2da45..d89fc3e4c3 100644 --- a/packages/ui/src/features/sessions/modeStyles.tsx +++ b/packages/ui/src/features/sessions/modeStyles.tsx @@ -7,6 +7,7 @@ import { Robot, ShieldCheck, } from "@phosphor-icons/react"; +import type { ExecutionMode } from "@posthog/shared"; export interface ModeStyle { icon: React.ReactNode; @@ -66,3 +67,14 @@ export const MODE_LABELS: Record = { "read-only": "Read-only", "full-access": "Full access", }; + +// Concrete modes `buildExitPlanModePermissionOptions` (packages/agent) can +// offer when a plan is approved, excluding "plan" itself. Kept here, next to +// the label map settings/approval UI already share, so both pick modes from +// one list. +export const PLAN_APPROVAL_MODES: readonly ExecutionMode[] = [ + "auto", + "default", + "acceptEdits", + "bypassPermissions", +]; diff --git a/packages/ui/src/features/settings/sections/GeneralSettings.tsx b/packages/ui/src/features/settings/sections/GeneralSettings.tsx index 63dfb8cccd..6b07376010 100644 --- a/packages/ui/src/features/settings/sections/GeneralSettings.tsx +++ b/packages/ui/src/features/settings/sections/GeneralSettings.tsx @@ -7,7 +7,10 @@ import { COLLAPSE_MODE_OPTIONS, type CollapseMode, } from "@posthog/ui/features/sessions/components/new-thread/conversationThreadConfig"; -import { MODE_LABELS } from "@posthog/ui/features/sessions/modeStyles"; +import { + MODE_LABELS, + PLAN_APPROVAL_MODES, +} from "@posthog/ui/features/sessions/modeStyles"; import { SettingRow } from "@posthog/ui/features/settings/SettingRow"; import { type AutoConvertLongText, @@ -330,14 +333,11 @@ export function GeneralSettings() { Last used - {MODE_LABELS.auto} - {MODE_LABELS.default} - - {MODE_LABELS.acceptEdits} - - - {MODE_LABELS.bypassPermissions} - + {PLAN_APPROVAL_MODES.map((mode) => ( + + {MODE_LABELS[mode]} + + ))}