From e558877c72c3e14c0d7db966169cbfb5a17cadc0 Mon Sep 17 00:00:00 2001 From: UtkarshUsername Date: Thu, 13 Aug 2026 07:00:08 +0530 Subject: [PATCH 1/9] fix(server): hide opencode plan agent since plan mode is legacy --- apps/server/src/provider/opencodeRuntime.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 95a6a0045343..defa6c130d68 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -171,7 +171,7 @@ const AGENT_HEADER_RE = /^(.+)\s+\((\S+)\)\s*$/; // Agents that are always hidden in OpenCode but the CLI "agent list" command // does not expose the hidden flag. Keep in sync with OpenCode agent // definitions (in the OpenCode repo: packages/opencode/src/agent/agent.ts). -const KNOWN_HIDDEN_AGENTS = new Set(["compaction", "summary", "title"]); +const KNOWN_HIDDEN_AGENTS = new Set(["compaction", "plan", "summary", "title"]); /** @internal */ export function parseModelsCliOutput(stdout: string): { From 5d726455ac4bc66070cadcbbfc5d2b93d5902e03 Mon Sep 17 00:00:00 2001 From: UtkarshUsername Date: Thu, 13 Aug 2026 07:00:40 +0530 Subject: [PATCH 2/9] docs(server): note why plan is in known hidden agents --- apps/server/src/provider/opencodeRuntime.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index defa6c130d68..e54b614232b4 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -171,6 +171,8 @@ const AGENT_HEADER_RE = /^(.+)\s+\((\S+)\)\s*$/; // Agents that are always hidden in OpenCode but the CLI "agent list" command // does not expose the hidden flag. Keep in sync with OpenCode agent // definitions (in the OpenCode repo: packages/opencode/src/agent/agent.ts). +// "plan" is not hidden in OpenCode itself; we hide it because T3's plan mode +// is a legacy feature and the agent select must not surface it. const KNOWN_HIDDEN_AGENTS = new Set(["compaction", "plan", "summary", "title"]); /** @internal */ From 92f5dc442803712b2dd9f26fbf6ca7400c9f0c89 Mon Sep 17 00:00:00 2001 From: UtkarshUsername Date: Thu, 13 Aug 2026 07:42:43 +0530 Subject: [PATCH 3/9] revert(server): stop hiding opencode plan agent unconditionally --- apps/server/src/provider/opencodeRuntime.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index e54b614232b4..95a6a0045343 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -171,9 +171,7 @@ const AGENT_HEADER_RE = /^(.+)\s+\((\S+)\)\s*$/; // Agents that are always hidden in OpenCode but the CLI "agent list" command // does not expose the hidden flag. Keep in sync with OpenCode agent // definitions (in the OpenCode repo: packages/opencode/src/agent/agent.ts). -// "plan" is not hidden in OpenCode itself; we hide it because T3's plan mode -// is a legacy feature and the agent select must not surface it. -const KNOWN_HIDDEN_AGENTS = new Set(["compaction", "plan", "summary", "title"]); +const KNOWN_HIDDEN_AGENTS = new Set(["compaction", "summary", "title"]); /** @internal */ export function parseModelsCliOutput(stdout: string): { From 18f24b268170c9eda9e32dc394d636fd216bfe0b Mon Sep 17 00:00:00 2001 From: UtkarshUsername Date: Thu, 13 Aug 2026 07:42:59 +0530 Subject: [PATCH 4/9] fix(web): only surface opencode plan agent with legacy plan mode on --- apps/web/src/components/chat/ChatComposer.tsx | 4 +++ apps/web/src/components/chat/TraitsPicker.tsx | 12 ++++++++- .../chat/composerProviderState.test.tsx | 26 +++++++++++++++++++ .../components/chat/composerProviderState.tsx | 23 +++++++++++++--- apps/web/src/modelSelection.ts | 2 ++ apps/web/src/providerModels.ts | 21 ++++++++++++++- 6 files changed, 83 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index e918e7758688..fecbe1c13f03 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -862,6 +862,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) models: selectedProviderModels, promptInjectionState: composerPromptInjectionState, modelOptions: composerModelOptions?.[selectedInstanceId], + planModeEnabled: settings.planModeEnabled, }), [ composerModelOptions, @@ -870,6 +871,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) selectedModel, selectedProvider, selectedProviderModels, + settings.planModeEnabled, ], ); @@ -1206,6 +1208,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) modelOptions: composerModelOptions?.[selectedInstanceId], prompt, onPromptChange: setPromptFromTraits, + planModeEnabled: settings.planModeEnabled, }); const providerTraitsPicker = renderProviderTraitsPicker({ provider: selectedProvider, @@ -1217,6 +1220,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) modelOptions: composerModelOptions?.[selectedInstanceId], prompt, onPromptChange: setPromptFromTraits, + planModeEnabled: settings.planModeEnabled, }); const pendingPrimaryAction = useMemo( () => diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 8462757700e7..e958d57afde2 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -29,6 +29,7 @@ import { } from "../ui/menu"; import { useComposerDraftStore, DraftId } from "../../composerDraftStore"; import { getProviderModelCapabilities } from "../../providerModels"; +import { useClientSettings } from "~/hooks/useSettings"; import { cn } from "~/lib/utils"; import { Badge } from "../ui/badge"; import { ComposerControl, ComposerControlChevron, ComposerControlIcon } from "./ComposerControl"; @@ -96,8 +97,9 @@ function getSelectedTraits( prompt: string, modelOptions: ProviderOptions | null | undefined, allowPromptInjectedEffort: boolean, + planModeEnabled: boolean, ) { - const caps = getProviderModelCapabilities(models, model, provider); + const caps = getProviderModelCapabilities(models, model, provider, planModeEnabled); const descriptors = getProviderOptionDescriptors({ caps, selections: modelOptions, @@ -167,6 +169,7 @@ function getTraitsSectionVisibility(input: { prompt: string; modelOptions: ProviderOptions | null | undefined; allowPromptInjectedEffort?: boolean; + planModeEnabled: boolean; }) { const selected = getSelectedTraits( input.provider, @@ -175,6 +178,7 @@ function getTraitsSectionVisibility(input: { input.prompt, input.modelOptions, input.allowPromptInjectedEffort ?? true, + input.planModeEnabled, ); const showEffort = selected.primarySelectDescriptor !== null; @@ -201,6 +205,7 @@ export function shouldRenderTraitsControls(input: { prompt: string; modelOptions: ProviderOptions | null | undefined; allowPromptInjectedEffort?: boolean; + planModeEnabled: boolean; }): boolean { return getTraitsSectionVisibility(input).hasAnyControls; } @@ -230,6 +235,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ ...persistence }: TraitsMenuContentProps & TraitsPersistence) { const setProviderModelOptions = useComposerDraftStore((store) => store.setProviderModelOptions); + const planModeEnabled = useClientSettings((settings) => settings.planModeEnabled); const updateModelOptions = useCallback( (nextOptions: ProviderOptions | undefined) => { if ("onModelOptionsChange" in persistence) { @@ -263,6 +269,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ prompt, modelOptions, allowPromptInjectedEffort, + planModeEnabled, }); const updateDescriptors = (nextDescriptors: ReadonlyArray) => { updateModelOptions(buildProviderOptionSelectionsFromDescriptors(nextDescriptors)); @@ -449,6 +456,7 @@ export const TraitsPicker = memo(function TraitsPicker({ ...persistence }: TraitsMenuContentProps & TraitsPersistence) { const [isMenuOpen, setIsMenuOpen] = useState(false); + const planModeEnabled = useClientSettings((settings) => settings.planModeEnabled); const { descriptors, primarySelectDescriptor, ultrathinkPromptControlled } = getTraitsSectionVisibility({ provider, @@ -457,6 +465,7 @@ export const TraitsPicker = memo(function TraitsPicker({ prompt, modelOptions, allowPromptInjectedEffort, + planModeEnabled, }); if ( !shouldRenderTraitsControls({ @@ -466,6 +475,7 @@ export const TraitsPicker = memo(function TraitsPicker({ prompt, modelOptions, allowPromptInjectedEffort, + planModeEnabled, }) ) { return null; diff --git a/apps/web/src/components/chat/composerProviderState.test.tsx b/apps/web/src/components/chat/composerProviderState.test.tsx index 067e71ef1bfc..faad6cd2b22a 100644 --- a/apps/web/src/components/chat/composerProviderState.test.tsx +++ b/apps/web/src/components/chat/composerProviderState.test.tsx @@ -80,6 +80,7 @@ describe("getComposerProviderState", () => { ]), ]), modelOptions: undefined, + planModeEnabled: true, }); expect(state).toEqual({ @@ -101,6 +102,7 @@ describe("getComposerProviderState", () => { booleanDescriptor("fastMode"), ]), modelOptions: selections(["effort", "low"], ["fastMode", true]), + planModeEnabled: true, }); expect(state).toEqual({ @@ -119,6 +121,7 @@ describe("getComposerProviderState", () => { booleanDescriptor("fastMode"), ]), modelOptions: selections(["effort", "high"], ["fastMode", false]), + planModeEnabled: true, }); expect(state.modelOptionsForDispatch).toEqual( @@ -132,6 +135,7 @@ describe("getComposerProviderState", () => { model: MODEL, models: modelWith([booleanDescriptor("thinking")]), modelOptions: selections(["effort", "max"], ["thinking", false]), + planModeEnabled: true, }); expect(state).toEqual({ @@ -157,6 +161,7 @@ describe("getComposerProviderState", () => { ]), ]), modelOptions: selections(["agent", "plan"]), + planModeEnabled: true, }); expect(state.promptEffort).toBe("high"); @@ -165,12 +170,30 @@ describe("getComposerProviderState", () => { ); }); + it("drops the plan agent from dispatch when legacy plan mode is disabled", () => { + const state = getComposerProviderState({ + provider: PROVIDER, + model: MODEL, + models: modelWith([ + selectDescriptor("agent", [ + { id: "build", label: "Build", isDefault: true }, + { id: "plan", label: "Plan" }, + ]), + ]), + modelOptions: selections(["agent", "plan"]), + planModeEnabled: false, + }); + + expect(state.modelOptionsForDispatch).toEqual(selections(["agent", "build"])); + }); + it("returns undefined dispatch options when the model declares no descriptors", () => { const state = getComposerProviderState({ provider: PROVIDER, model: MODEL, models: modelWith([]), modelOptions: selections(["anything", "value"]), + planModeEnabled: true, }); expect(state).toEqual({ @@ -199,6 +222,7 @@ describe("getComposerProviderState", () => { "Ultrathink:\nInvestigate this failure", ), modelOptions: selections(["effort", "medium"]), + planModeEnabled: true, }); expect(state).toEqual({ @@ -220,6 +244,7 @@ describe("getComposerProviderState", () => { "Ultrathink:\nInvestigate this failure", ), modelOptions: undefined, + planModeEnabled: true, }); expect(state).not.toHaveProperty("composerFrameClassName"); @@ -240,6 +265,7 @@ describe("provider traits render guards", () => { modelOptions: undefined, prompt: "", onPromptChange: () => {}, + planModeEnabled: true, }; expect(renderProviderTraitsPicker(args)).toBeNull(); diff --git a/apps/web/src/components/chat/composerProviderState.tsx b/apps/web/src/components/chat/composerProviderState.tsx index 1349e2509b7b..1528c401b3ef 100644 --- a/apps/web/src/components/chat/composerProviderState.tsx +++ b/apps/web/src/components/chat/composerProviderState.tsx @@ -23,6 +23,7 @@ export type ComposerProviderStateInput = { models: ReadonlyArray; promptInjectionState?: ComposerPromptInjectionState; modelOptions: ReadonlyArray | null | undefined; + planModeEnabled: boolean; }; export type ComposerPromptInjectionState = "none" | "ultrathink"; @@ -46,6 +47,7 @@ type TraitsRenderInput = { modelOptions: ReadonlyArray | undefined; prompt: string; onPromptChange: (prompt: string) => void; + planModeEnabled: boolean; }; export function getComposerPromptInjectionState(prompt: string): ComposerPromptInjectionState { @@ -53,8 +55,15 @@ export function getComposerPromptInjectionState(prompt: string): ComposerPromptI } export function getComposerProviderState(input: ComposerProviderStateInput): ComposerProviderState { - const { provider, model, models, modelOptions, promptInjectionState = "none" } = input; - const caps = getProviderModelCapabilities(models, model, provider); + const { + provider, + model, + models, + modelOptions, + promptInjectionState = "none", + planModeEnabled, + } = input; + const caps = getProviderModelCapabilities(models, model, provider, planModeEnabled); const descriptors = getProviderOptionDescriptors({ caps, selections: modelOptions }); const primarySelectDescriptor = descriptors.find( (descriptor): descriptor is Extract<(typeof descriptors)[number], { type: "select" }> => @@ -94,11 +103,19 @@ function renderTraitsControl( modelOptions, prompt, onPromptChange, + planModeEnabled, } = input; const hasTarget = threadRef !== undefined || draftId !== undefined; if ( !hasTarget || - !shouldRenderTraitsControls({ provider, models, model, modelOptions, prompt }) + !shouldRenderTraitsControls({ + provider, + models, + model, + modelOptions, + prompt, + planModeEnabled, + }) ) { return null; } diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index 2763245299db..59d93330d758 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -308,6 +308,7 @@ export function resolveAppModelSelectionState( model, models: entry.models, modelOptions: selectedEntry ? selection.options : undefined, + planModeEnabled: settings.planModeEnabled, }); return createModelSelection(entry.instanceId, model, modelOptionsForDispatch); @@ -325,6 +326,7 @@ export function resolveAppModelSelectionState( model, models: getProviderModels(providers, provider), modelOptions: keptSelectedProvider ? selection.options : undefined, + planModeEnabled: settings.planModeEnabled, }); return createModelSelection(defaultInstanceIdForDriver(provider), model, modelOptionsForDispatch); diff --git a/apps/web/src/providerModels.ts b/apps/web/src/providerModels.ts index 9715344cba80..45d2b93f6ec4 100644 --- a/apps/web/src/providerModels.ts +++ b/apps/web/src/providerModels.ts @@ -81,9 +81,28 @@ export function getProviderModelCapabilities( models: ReadonlyArray, model: string | null | undefined, provider: ProviderDriverKind, + planModeEnabled = true, ): ModelCapabilities { const slug = normalizeModelSlug(model, provider); - return models.find((candidate) => candidate.slug === slug)?.capabilities ?? EMPTY_CAPABILITIES; + const caps = + models.find((candidate) => candidate.slug === slug)?.capabilities ?? EMPTY_CAPABILITIES; + if (planModeEnabled) { + return caps; + } + return withoutPlanAgentOption(caps); +} + +// The opencode "plan" agent is only reachable while legacy plan mode is on. +// With it off, drop the option so it cannot be selected or dispatched. +function withoutPlanAgentOption(caps: ModelCapabilities): ModelCapabilities { + return { + ...caps, + optionDescriptors: (caps.optionDescriptors ?? []).map((descriptor) => + descriptor.type === "select" && descriptor.id === "agent" + ? { ...descriptor, options: descriptor.options.filter((option) => option.id !== "plan") } + : descriptor, + ), + }; } export function getDefaultServerModel( From 4b1503747fd3e07947e26b98e3d86480adb32eb0 Mon Sep 17 00:00:00 2001 From: UtkarshUsername Date: Thu, 13 Aug 2026 08:05:04 +0530 Subject: [PATCH 5/9] fix(web): drop empty agent control when plan is the only agent --- .../chat/composerProviderState.test.tsx | 18 ++++++++++++++++++ apps/web/src/providerModels.ts | 15 +++++++++------ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/chat/composerProviderState.test.tsx b/apps/web/src/components/chat/composerProviderState.test.tsx index faad6cd2b22a..6d928c77ef02 100644 --- a/apps/web/src/components/chat/composerProviderState.test.tsx +++ b/apps/web/src/components/chat/composerProviderState.test.tsx @@ -187,6 +187,24 @@ describe("getComposerProviderState", () => { expect(state.modelOptionsForDispatch).toEqual(selections(["agent", "build"])); }); + it("drops the agent descriptor entirely when plan is the only option and plan mode is disabled", () => { + const state = getComposerProviderState({ + provider: PROVIDER, + model: MODEL, + models: modelWith([ + selectDescriptor("agent", [{ id: "plan", label: "Plan", isDefault: true }]), + ]), + modelOptions: selections(["agent", "plan"]), + planModeEnabled: false, + }); + + expect(state).toEqual({ + provider: PROVIDER, + promptEffort: null, + modelOptionsForDispatch: undefined, + }); + }); + it("returns undefined dispatch options when the model declares no descriptors", () => { const state = getComposerProviderState({ provider: PROVIDER, diff --git a/apps/web/src/providerModels.ts b/apps/web/src/providerModels.ts index 45d2b93f6ec4..5fca20ac457f 100644 --- a/apps/web/src/providerModels.ts +++ b/apps/web/src/providerModels.ts @@ -93,15 +93,18 @@ export function getProviderModelCapabilities( } // The opencode "plan" agent is only reachable while legacy plan mode is on. -// With it off, drop the option so it cannot be selected or dispatched. +// With it off, drop the option so it cannot be selected or dispatched, and +// drop the descriptor entirely when nothing remains selectable. function withoutPlanAgentOption(caps: ModelCapabilities): ModelCapabilities { return { ...caps, - optionDescriptors: (caps.optionDescriptors ?? []).map((descriptor) => - descriptor.type === "select" && descriptor.id === "agent" - ? { ...descriptor, options: descriptor.options.filter((option) => option.id !== "plan") } - : descriptor, - ), + optionDescriptors: (caps.optionDescriptors ?? []).flatMap((descriptor) => { + if (descriptor.type !== "select" || descriptor.id !== "agent") { + return [descriptor]; + } + const options = descriptor.options.filter((option) => option.id !== "plan"); + return options.length > 0 ? [{ ...descriptor, options }] : []; + }), }; } From 1282dde6d77bc38adf298a62a7c90a0418e45023 Mon Sep 17 00:00:00 2001 From: UtkarshUsername Date: Thu, 13 Aug 2026 08:25:19 +0530 Subject: [PATCH 6/9] fix(web): pass planModeEnabled as prop to break settings import cycle --- apps/web/src/components/chat/TraitsPicker.tsx | 7 ++++--- apps/web/src/components/chat/composerProviderState.tsx | 1 + apps/web/src/components/settings/ProjectSettingsPanel.tsx | 1 + apps/web/src/components/settings/SettingsPanels.tsx | 1 + 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index e958d57afde2..ac02abc918fa 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -29,7 +29,6 @@ import { } from "../ui/menu"; import { useComposerDraftStore, DraftId } from "../../composerDraftStore"; import { getProviderModelCapabilities } from "../../providerModels"; -import { useClientSettings } from "~/hooks/useSettings"; import { cn } from "~/lib/utils"; import { Badge } from "../ui/badge"; import { ComposerControl, ComposerControlChevron, ComposerControlIcon } from "./ComposerControl"; @@ -219,6 +218,7 @@ export interface TraitsMenuContentProps { onPromptChange: (prompt: string) => void; modelOptions?: ProviderOptions | null | undefined; allowPromptInjectedEffort?: boolean; + planModeEnabled: boolean; triggerVariant?: VariantProps["variant"]; triggerClassName?: string; } @@ -232,10 +232,10 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ onPromptChange, modelOptions, allowPromptInjectedEffort = true, + planModeEnabled, ...persistence }: TraitsMenuContentProps & TraitsPersistence) { const setProviderModelOptions = useComposerDraftStore((store) => store.setProviderModelOptions); - const planModeEnabled = useClientSettings((settings) => settings.planModeEnabled); const updateModelOptions = useCallback( (nextOptions: ProviderOptions | undefined) => { if ("onModelOptionsChange" in persistence) { @@ -451,12 +451,12 @@ export const TraitsPicker = memo(function TraitsPicker({ onPromptChange, modelOptions, allowPromptInjectedEffort = true, + planModeEnabled, triggerVariant, triggerClassName, ...persistence }: TraitsMenuContentProps & TraitsPersistence) { const [isMenuOpen, setIsMenuOpen] = useState(false); - const planModeEnabled = useClientSettings((settings) => settings.planModeEnabled); const { descriptors, primarySelectDescriptor, ultrathinkPromptControlled } = getTraitsSectionVisibility({ provider, @@ -546,6 +546,7 @@ export const TraitsPicker = memo(function TraitsPicker({ onPromptChange={onPromptChange} modelOptions={modelOptions} allowPromptInjectedEffort={allowPromptInjectedEffort} + planModeEnabled={planModeEnabled} {...persistence} /> diff --git a/apps/web/src/components/chat/composerProviderState.tsx b/apps/web/src/components/chat/composerProviderState.tsx index 1528c401b3ef..459f8e3d669c 100644 --- a/apps/web/src/components/chat/composerProviderState.tsx +++ b/apps/web/src/components/chat/composerProviderState.tsx @@ -130,6 +130,7 @@ function renderTraitsControl( modelOptions={modelOptions} prompt={prompt} onPromptChange={onPromptChange} + planModeEnabled={planModeEnabled} /> ); } diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index 50cf9c318040..90b7825a2b3c 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -848,6 +848,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { onPromptChange={() => {}} modelOptions={resolvedSelection.options ?? []} allowPromptInjectedEffort={false} + planModeEnabled={settings.planModeEnabled} triggerVariant="outline" triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground" onModelOptionsChange={(nextOptions) => { diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index e4cfbe9ac033..ff2f48fee512 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -2254,6 +2254,7 @@ export function GeneralSettingsPanel() { onPromptChange={() => {}} modelOptions={textGenModelOptions} allowPromptInjectedEffort={false} + planModeEnabled={settings.planModeEnabled} triggerVariant="outline" triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground" onModelOptionsChange={(nextOptions) => { From 5ce1d4bc2fef92dcda81c03d61ac8ec7a4beecea Mon Sep 17 00:00:00 2001 From: UtkarshUsername Date: Thu, 13 Aug 2026 09:08:32 +0530 Subject: [PATCH 7/9] fix(web): heal stored plan agent selections when plan mode turns off --- .../components/settings/SettingsPanels.tsx | 29 +++++++++++++++-- apps/web/src/modelSelection.test.ts | 32 +++++++++++++++++++ apps/web/src/modelSelection.ts | 20 ++++++++++++ 3 files changed, 78 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index ff2f48fee512..77fb7b66aa19 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -68,6 +68,7 @@ import { useDesktopUpdateState } from "../../state/desktopUpdate"; import { getCustomModelOptionsByInstance, resolveAppModelSelectionState, + withoutPlanAgentSelection, } from "../../modelSelection"; import { applyProviderInstanceSettings, @@ -1683,9 +1684,31 @@ function LegacyFeaturesSection() { control={ - updateSettings({ planModeEnabled: Boolean(checked) }) - } + onCheckedChange={(checked) => { + const planModeEnabled = Boolean(checked); + const textGenerationModelSelection = withoutPlanAgentSelection( + settings.textGenerationModelSelection, + ); + const sourceControlWriterModelSelection = withoutPlanAgentSelection( + settings.sourceControlWriterModelSelection, + ); + updateSettings({ + planModeEnabled, + ...(planModeEnabled + ? {} + : { + ...(textGenerationModelSelection && + textGenerationModelSelection !== settings.textGenerationModelSelection + ? { textGenerationModelSelection } + : {}), + ...(sourceControlWriterModelSelection && + sourceControlWriterModelSelection !== + settings.sourceControlWriterModelSelection + ? { sourceControlWriterModelSelection } + : {}), + }), + }); + }} aria-label="Plan mode (legacy)" /> } diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index a35fb752b446..cd9a45af4df5 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -1,11 +1,13 @@ import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; import { DEFAULT_UNIFIED_SETTINGS, type UnifiedSettings } from "@t3tools/contracts/settings"; import { describe, expect, it } from "vite-plus/test"; +import { createModelSelection } from "@t3tools/shared/model"; import { deriveProviderInstanceEntries } from "./providerInstances"; import { getAppModelOptionsForInstance, resolveAppModelSelectionForInstance, resolveAppModelSelectionState, + withoutPlanAgentSelection, } from "./modelSelection"; function provider(input: { @@ -321,3 +323,33 @@ describe("instance-scoped model selection", () => { }); }); }); + +describe("withoutPlanAgentSelection", () => { + const instance = ProviderInstanceId.make("opencode"); + const model = "opencode/gpt-5.4"; + + it("drops a stored plan agent option", () => { + const selection = createModelSelection(instance, model, [ + { id: "variant", value: "high" }, + { id: "agent", value: "plan" }, + ]); + expect(withoutPlanAgentSelection(selection)).toEqual( + createModelSelection(instance, model, [{ id: "variant", value: "high" }]), + ); + }); + + it("keeps non-plan agent options", () => { + const selection = createModelSelection(instance, model, [{ id: "agent", value: "build" }]); + expect(withoutPlanAgentSelection(selection)).toBe(selection); + }); + + it("omits options entirely when plan was the only stored option", () => { + const selection = createModelSelection(instance, model, [{ id: "agent", value: "plan" }]); + expect(withoutPlanAgentSelection(selection)).toEqual({ instanceId: instance, model }); + }); + + it("returns null and undefined selections unchanged", () => { + expect(withoutPlanAgentSelection(null)).toBeNull(); + expect(withoutPlanAgentSelection(undefined)).toBeUndefined(); + }); +}); diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index 59d93330d758..884020e1a3a5 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -277,6 +277,26 @@ export function getCustomModelOptionsByInstance( return out; } +/** + * Drop the opencode "plan" agent option from a stored model selection. + * Used when legacy plan mode is turned off so server-side text-generation + * tasks (title, branch, PR) cannot keep dispatching the plan agent. + */ +export function withoutPlanAgentSelection( + selection: ModelSelection | null | undefined, +): ModelSelection | null | undefined { + if (!selection?.options) { + return selection; + } + const options = selection.options.filter( + (option) => !(option.id === "agent" && option.value === "plan"), + ); + if (options.length === selection.options.length) { + return selection; + } + return createModelSelection(selection.instanceId, selection.model, options); +} + export function resolveAppModelSelectionState( settings: UnifiedSettings, providers: ReadonlyArray, From 329da66358a9254cf9eb829b9af8204e63a8e9b4 Mon Sep 17 00:00:00 2001 From: UtkarshUsername Date: Thu, 13 Aug 2026 22:55:22 +0530 Subject: [PATCH 8/9] fix(web): heal stale plan agent selections on load --- apps/web/src/modelSelection.test.ts | 50 +++++++++++++++++++++++++ apps/web/src/modelSelection.ts | 26 +++++++++++++ apps/web/src/planAgentSelectionHeal.tsx | 40 ++++++++++++++++++++ apps/web/src/routes/__root.tsx | 2 + 4 files changed, 118 insertions(+) create mode 100644 apps/web/src/planAgentSelectionHeal.tsx diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index cd9a45af4df5..405366d9fcbe 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -7,6 +7,7 @@ import { getAppModelOptionsForInstance, resolveAppModelSelectionForInstance, resolveAppModelSelectionState, + resolvePlanAgentHealPatch, withoutPlanAgentSelection, } from "./modelSelection"; @@ -353,3 +354,52 @@ describe("withoutPlanAgentSelection", () => { expect(withoutPlanAgentSelection(undefined)).toBeUndefined(); }); }); + +describe("resolvePlanAgentHealPatch", () => { + const instance = ProviderInstanceId.make("opencode"); + const model = "opencode/gpt-5.4"; + const healed = createModelSelection(instance, model, [{ id: "variant", value: "high" }]); + const storedPlan = createModelSelection(instance, model, [ + { id: "variant", value: "high" }, + { id: "agent", value: "plan" }, + ]); + const nullPatch = { + planModeEnabled: true, + textGenerationModelSelection: storedPlan, + sourceControlWriterModelSelection: null, + }; + + it("returns null when plan mode is on", () => { + expect(resolvePlanAgentHealPatch(nullPatch)).toBeNull(); + }); + + it("returns null when nothing needs healing", () => { + expect( + resolvePlanAgentHealPatch({ + planModeEnabled: false, + textGenerationModelSelection: healed, + sourceControlWriterModelSelection: null, + }), + ).toBeNull(); + }); + + it("patches the stored text generation selection to drop the plan agent", () => { + expect( + resolvePlanAgentHealPatch({ + planModeEnabled: false, + textGenerationModelSelection: storedPlan, + sourceControlWriterModelSelection: null, + }), + ).toEqual({ textGenerationModelSelection: healed }); + }); + + it("patches a stored source control writer selection that uses the plan agent", () => { + expect( + resolvePlanAgentHealPatch({ + planModeEnabled: false, + textGenerationModelSelection: healed, + sourceControlWriterModelSelection: storedPlan, + }), + ).toEqual({ sourceControlWriterModelSelection: healed }); + }); +}); diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index 884020e1a3a5..ccdffdda1004 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -6,6 +6,7 @@ import { ProviderDriverKind, ProviderInstanceId, type ServerProvider, + type ServerSettingsPatch, } from "@t3tools/contracts"; import { createModelSelection, @@ -297,6 +298,31 @@ export function withoutPlanAgentSelection( return createModelSelection(selection.instanceId, selection.model, options); } +// The dropdown hides the opencode "plan" agent while legacy plan mode is off, +// but the persisted text-generation selections are only healed when the toggle +// flips. Users who already have plan mode off and a stored "plan" selection +// never trip the toggle handler, so resolve the heal once per settings load. +export function resolvePlanAgentHealPatch(input: { + readonly planModeEnabled: boolean; + readonly textGenerationModelSelection: ModelSelection | null | undefined; + readonly sourceControlWriterModelSelection: ModelSelection | null | undefined; +}): ServerSettingsPatch | null { + if (input.planModeEnabled) { + return null; + } + const healedText = withoutPlanAgentSelection(input.textGenerationModelSelection); + const healedSourceControl = withoutPlanAgentSelection(input.sourceControlWriterModelSelection); + const patch: ServerSettingsPatch = { + ...(healedText && healedText !== input.textGenerationModelSelection + ? { textGenerationModelSelection: healedText } + : {}), + ...(healedSourceControl && healedSourceControl !== input.sourceControlWriterModelSelection + ? { sourceControlWriterModelSelection: healedSourceControl } + : {}), + }; + return Object.keys(patch).length > 0 ? patch : null; +} + export function resolveAppModelSelectionState( settings: UnifiedSettings, providers: ReadonlyArray, diff --git a/apps/web/src/planAgentSelectionHeal.tsx b/apps/web/src/planAgentSelectionHeal.tsx new file mode 100644 index 000000000000..a1c8cbd576d1 --- /dev/null +++ b/apps/web/src/planAgentSelectionHeal.tsx @@ -0,0 +1,40 @@ +import { useEffect } from "react"; + +import { usePrimarySettings, useUpdatePrimarySettings } from "./hooks/useSettings"; +import { resolvePlanAgentHealPatch } from "./modelSelection"; + +/** + * Heals persisted text-generation model selections that still reference the + * opencode "plan" agent. The dropdown hides the option while legacy plan mode + * is off, but the toggle handler only runs when the setting flips; users who + * already have plan mode off with a stored "plan" selection need this pass + * whenever the settings load. + */ +export function PlanAgentSelectionHeal() { + const planModeEnabled = usePrimarySettings((settings) => settings.planModeEnabled); + const textGenerationModelSelection = usePrimarySettings( + (settings) => settings.textGenerationModelSelection, + ); + const sourceControlWriterModelSelection = usePrimarySettings( + (settings) => settings.sourceControlWriterModelSelection, + ); + const updateSettings = useUpdatePrimarySettings(); + + useEffect(() => { + const patch = resolvePlanAgentHealPatch({ + planModeEnabled, + textGenerationModelSelection, + sourceControlWriterModelSelection, + }); + if (patch) { + updateSettings(patch); + } + }, [ + planModeEnabled, + textGenerationModelSelection, + sourceControlWriterModelSelection, + updateSettings, + ]); + + return null; +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index c4f65564efc7..91381301418e 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -31,6 +31,7 @@ import { import { resolveAndPersistPreferredEditor } from "../editorPreferences"; import { applyAppearanceFontVariables } from "~/appearanceFonts"; import { useClientSettings } from "../hooks/useSettings"; +import { PlanAgentSelectionHeal } from "../planAgentSelectionHeal"; import { deriveLogicalProjectKeyFromSettings, derivePhysicalProjectKeyFromPath, @@ -140,6 +141,7 @@ function RootRouteView() { {primaryEnvironmentAuthenticated ? : null} + {primaryEnvironmentAuthenticated ? : null} {primaryEnvironmentAuthenticated ? : null} {appShell} {/* Above the router: a theme draft is judged by walking the app, so the From 72040e12d4d89bff763f3fd591214851829a7ac0 Mon Sep 17 00:00:00 2001 From: UtkarshUsername Date: Thu, 13 Aug 2026 23:01:15 +0530 Subject: [PATCH 9/9] fix(web): gate plan agent heal on client settings hydration --- apps/web/src/planAgentSelectionHeal.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/web/src/planAgentSelectionHeal.tsx b/apps/web/src/planAgentSelectionHeal.tsx index a1c8cbd576d1..4f6760e50fae 100644 --- a/apps/web/src/planAgentSelectionHeal.tsx +++ b/apps/web/src/planAgentSelectionHeal.tsx @@ -1,6 +1,10 @@ import { useEffect } from "react"; -import { usePrimarySettings, useUpdatePrimarySettings } from "./hooks/useSettings"; +import { + useClientSettingsHydrated, + usePrimarySettings, + useUpdatePrimarySettings, +} from "./hooks/useSettings"; import { resolvePlanAgentHealPatch } from "./modelSelection"; /** @@ -18,9 +22,16 @@ export function PlanAgentSelectionHeal() { const sourceControlWriterModelSelection = usePrimarySettings( (settings) => settings.sourceControlWriterModelSelection, ); + const settingsHydrated = useClientSettingsHydrated(); const updateSettings = useUpdatePrimarySettings(); useEffect(() => { + // planModeEnabled reads as false until client settings hydrate, so never + // heal before then: we would strip a stored plan selection from a user + // whose plan mode is actually on. + if (!settingsHydrated) { + return; + } const patch = resolvePlanAgentHealPatch({ planModeEnabled, textGenerationModelSelection, @@ -31,6 +42,7 @@ export function PlanAgentSelectionHeal() { } }, [ planModeEnabled, + settingsHydrated, textGenerationModelSelection, sourceControlWriterModelSelection, updateSettings,