authorgravatar for 73941998+UtkarshUsername@users.noreply.github.comUtkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> 2026-08-20 01:26:45+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-19 15:56:45-04:00
log80c37f1a721d36bde86b31c475fcc8d3db75f706
tree315bfa72da44695100104cb32c3ebe2cde606312
parent07f8027d9a542f72b12db927caceebb2c9c72aa7
signature Signed by PGP key B5690EEEBB952194

fix(web): hide opencode's plan agent when legacy plan mode is off (#6420)


11 files changed, 342 insertions(+), 8 deletions(-)

apps/web/src/components/chat/ChatComposer.tsx+4
...@@ -873,6 +873,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -873,6 +873,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
873 models: selectedProviderModels,873 models: selectedProviderModels,
874 promptInjectionState: composerPromptInjectionState,874 promptInjectionState: composerPromptInjectionState,
875 modelOptions: composerModelOptions?.[selectedInstanceId],875 modelOptions: composerModelOptions?.[selectedInstanceId],
876 planModeEnabled: settings.planModeEnabled,
876 }),877 }),
877 [878 [
878 composerModelOptions,879 composerModelOptions,
...@@ -881,6 +882,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -881,6 +882,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
881 selectedModel,882 selectedModel,
882 selectedProvider,883 selectedProvider,
883 selectedProviderModels,884 selectedProviderModels,
885 settings.planModeEnabled,
884 ],886 ],
885 );887 );
886888
...@@ -1215,6 +1217,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1215,6 +1217,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1215 modelOptions: composerModelOptions?.[selectedInstanceId],1217 modelOptions: composerModelOptions?.[selectedInstanceId],
1216 prompt,1218 prompt,
1217 onPromptChange: setPromptFromTraits,1219 onPromptChange: setPromptFromTraits,
1220 planModeEnabled: settings.planModeEnabled,
1218 });1221 });
1219 const providerTraitsPicker = renderProviderTraitsPicker({1222 const providerTraitsPicker = renderProviderTraitsPicker({
1220 provider: selectedProvider,1223 provider: selectedProvider,
...@@ -1226,6 +1229,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1226,6 +1229,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1226 modelOptions: composerModelOptions?.[selectedInstanceId],1229 modelOptions: composerModelOptions?.[selectedInstanceId],
1227 prompt,1230 prompt,
1228 onPromptChange: setPromptFromTraits,1231 onPromptChange: setPromptFromTraits,
1232 planModeEnabled: settings.planModeEnabled,
1229 });1233 });
1230 const pendingPrimaryAction = useMemo(1234 const pendingPrimaryAction = useMemo(
1231 () =>1235 () =>
apps/web/src/components/chat/TraitsPicker.tsx+12-1
...@@ -96,8 +96,9 @@ function getSelectedTraits(...@@ -96,8 +96,9 @@ function getSelectedTraits(
96 prompt: string,96 prompt: string,
97 modelOptions: ProviderOptions | null | undefined,97 modelOptions: ProviderOptions | null | undefined,
98 allowPromptInjectedEffort: boolean,98 allowPromptInjectedEffort: boolean,
99 planModeEnabled: boolean,
99) {100) {
100 const caps = getProviderModelCapabilities(models, model, provider);101 const caps = getProviderModelCapabilities(models, model, provider, planModeEnabled);
101 const descriptors = getProviderOptionDescriptors({102 const descriptors = getProviderOptionDescriptors({
102 caps,103 caps,
103 selections: modelOptions,104 selections: modelOptions,
...@@ -167,6 +168,7 @@ function getTraitsSectionVisibility(input: {...@@ -167,6 +168,7 @@ function getTraitsSectionVisibility(input: {
167 prompt: string;168 prompt: string;
168 modelOptions: ProviderOptions | null | undefined;169 modelOptions: ProviderOptions | null | undefined;
169 allowPromptInjectedEffort?: boolean;170 allowPromptInjectedEffort?: boolean;
171 planModeEnabled: boolean;
170}) {172}) {
171 const selected = getSelectedTraits(173 const selected = getSelectedTraits(
172 input.provider,174 input.provider,
...@@ -175,6 +177,7 @@ function getTraitsSectionVisibility(input: {...@@ -175,6 +177,7 @@ function getTraitsSectionVisibility(input: {
175 input.prompt,177 input.prompt,
176 input.modelOptions,178 input.modelOptions,
177 input.allowPromptInjectedEffort ?? true,179 input.allowPromptInjectedEffort ?? true,
180 input.planModeEnabled,
178 );181 );
179182
180 const showEffort = selected.primarySelectDescriptor !== null;183 const showEffort = selected.primarySelectDescriptor !== null;
...@@ -201,6 +204,7 @@ export function shouldRenderTraitsControls(input: {...@@ -201,6 +204,7 @@ export function shouldRenderTraitsControls(input: {
201 prompt: string;204 prompt: string;
202 modelOptions: ProviderOptions | null | undefined;205 modelOptions: ProviderOptions | null | undefined;
203 allowPromptInjectedEffort?: boolean;206 allowPromptInjectedEffort?: boolean;
207 planModeEnabled: boolean;
204}): boolean {208}): boolean {
205 return getTraitsSectionVisibility(input).hasAnyControls;209 return getTraitsSectionVisibility(input).hasAnyControls;
206}210}
...@@ -214,6 +218,7 @@ export interface TraitsMenuContentProps {...@@ -214,6 +218,7 @@ export interface TraitsMenuContentProps {
214 onPromptChange: (prompt: string) => void;218 onPromptChange: (prompt: string) => void;
215 modelOptions?: ProviderOptions | null | undefined;219 modelOptions?: ProviderOptions | null | undefined;
216 allowPromptInjectedEffort?: boolean;220 allowPromptInjectedEffort?: boolean;
221 planModeEnabled: boolean;
217 triggerVariant?: VariantProps<typeof buttonVariants>["variant"];222 triggerVariant?: VariantProps<typeof buttonVariants>["variant"];
218 triggerClassName?: string;223 triggerClassName?: string;
219}224}
...@@ -227,6 +232,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({...@@ -227,6 +232,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
227 onPromptChange,232 onPromptChange,
228 modelOptions,233 modelOptions,
229 allowPromptInjectedEffort = true,234 allowPromptInjectedEffort = true,
235 planModeEnabled,
230 ...persistence236 ...persistence
231}: TraitsMenuContentProps & TraitsPersistence) {237}: TraitsMenuContentProps & TraitsPersistence) {
232 const setProviderModelOptions = useComposerDraftStore((store) => store.setProviderModelOptions);238 const setProviderModelOptions = useComposerDraftStore((store) => store.setProviderModelOptions);
...@@ -263,6 +269,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({...@@ -263,6 +269,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
263 prompt,269 prompt,
264 modelOptions,270 modelOptions,
265 allowPromptInjectedEffort,271 allowPromptInjectedEffort,
272 planModeEnabled,
266 });273 });
267 const updateDescriptors = (nextDescriptors: ReadonlyArray<ProviderOptionDescriptor>) => {274 const updateDescriptors = (nextDescriptors: ReadonlyArray<ProviderOptionDescriptor>) => {
268 updateModelOptions(buildProviderOptionSelectionsFromDescriptors(nextDescriptors));275 updateModelOptions(buildProviderOptionSelectionsFromDescriptors(nextDescriptors));
...@@ -451,6 +458,7 @@ export const TraitsPicker = memo(function TraitsPicker({...@@ -451,6 +458,7 @@ export const TraitsPicker = memo(function TraitsPicker({
451 onPromptChange,458 onPromptChange,
452 modelOptions,459 modelOptions,
453 allowPromptInjectedEffort = true,460 allowPromptInjectedEffort = true,
461 planModeEnabled,
454 triggerVariant,462 triggerVariant,
455 triggerClassName,463 triggerClassName,
456 ...persistence464 ...persistence
...@@ -464,6 +472,7 @@ export const TraitsPicker = memo(function TraitsPicker({...@@ -464,6 +472,7 @@ export const TraitsPicker = memo(function TraitsPicker({
464 prompt,472 prompt,
465 modelOptions,473 modelOptions,
466 allowPromptInjectedEffort,474 allowPromptInjectedEffort,
475 planModeEnabled,
467 });476 });
468 if (477 if (
469 !shouldRenderTraitsControls({478 !shouldRenderTraitsControls({
...@@ -473,6 +482,7 @@ export const TraitsPicker = memo(function TraitsPicker({...@@ -473,6 +482,7 @@ export const TraitsPicker = memo(function TraitsPicker({
473 prompt,482 prompt,
474 modelOptions,483 modelOptions,
475 allowPromptInjectedEffort,484 allowPromptInjectedEffort,
485 planModeEnabled,
476 })486 })
477 ) {487 ) {
478 return null;488 return null;
...@@ -543,6 +553,7 @@ export const TraitsPicker = memo(function TraitsPicker({...@@ -543,6 +553,7 @@ export const TraitsPicker = memo(function TraitsPicker({
543 onPromptChange={onPromptChange}553 onPromptChange={onPromptChange}
544 modelOptions={modelOptions}554 modelOptions={modelOptions}
545 allowPromptInjectedEffort={allowPromptInjectedEffort}555 allowPromptInjectedEffort={allowPromptInjectedEffort}
556 planModeEnabled={planModeEnabled}
546 {...persistence}557 {...persistence}
547 />558 />
548 </MenuPopup>559 </MenuPopup>
apps/web/src/components/chat/composerProviderState.test.tsx+61
...@@ -80,6 +80,7 @@ describe("getComposerProviderState", () => {...@@ -80,6 +80,7 @@ describe("getComposerProviderState", () => {
80 ]),80 ]),
81 ]),81 ]),
82 modelOptions: undefined,82 modelOptions: undefined,
83 planModeEnabled: true,
83 });84 });
8485
85 expect(state).toEqual({86 expect(state).toEqual({
...@@ -101,6 +102,7 @@ describe("getComposerProviderState", () => {...@@ -101,6 +102,7 @@ describe("getComposerProviderState", () => {
101 booleanDescriptor("fastMode"),102 booleanDescriptor("fastMode"),
102 ]),103 ]),
103 modelOptions: selections(["effort", "low"], ["fastMode", true]),104 modelOptions: selections(["effort", "low"], ["fastMode", true]),
105 planModeEnabled: true,
104 });106 });
105107
106 expect(state).toEqual({108 expect(state).toEqual({
...@@ -119,6 +121,7 @@ describe("getComposerProviderState", () => {...@@ -119,6 +121,7 @@ describe("getComposerProviderState", () => {
119 booleanDescriptor("fastMode"),121 booleanDescriptor("fastMode"),
120 ]),122 ]),
121 modelOptions: selections(["effort", "high"], ["fastMode", false]),123 modelOptions: selections(["effort", "high"], ["fastMode", false]),
124 planModeEnabled: true,
122 });125 });
123126
124 expect(state.modelOptionsForDispatch).toEqual(127 expect(state.modelOptionsForDispatch).toEqual(
...@@ -132,6 +135,7 @@ describe("getComposerProviderState", () => {...@@ -132,6 +135,7 @@ describe("getComposerProviderState", () => {
132 model: MODEL,135 model: MODEL,
133 models: modelWith([booleanDescriptor("thinking")]),136 models: modelWith([booleanDescriptor("thinking")]),
134 modelOptions: selections(["effort", "max"], ["thinking", false]),137 modelOptions: selections(["effort", "max"], ["thinking", false]),
138 planModeEnabled: true,
135 });139 });
136140
137 expect(state).toEqual({141 expect(state).toEqual({
...@@ -157,6 +161,7 @@ describe("getComposerProviderState", () => {...@@ -157,6 +161,7 @@ describe("getComposerProviderState", () => {
157 ]),161 ]),
158 ]),162 ]),
159 modelOptions: selections(["agent", "plan"]),163 modelOptions: selections(["agent", "plan"]),
164 planModeEnabled: true,
160 });165 });
161166
162 expect(state.promptEffort).toBe("high");167 expect(state.promptEffort).toBe("high");
...@@ -165,12 +170,65 @@ describe("getComposerProviderState", () => {...@@ -165,12 +170,65 @@ describe("getComposerProviderState", () => {
165 );170 );
166 });171 });
167172
173 it("drops the plan agent from dispatch when legacy plan mode is disabled", () => {
174 const state = getComposerProviderState({
175 provider: PROVIDER,
176 model: MODEL,
177 models: modelWith([
178 selectDescriptor("agent", [
179 { id: "build", label: "Build", isDefault: true },
180 { id: "plan", label: "Plan" },
181 ]),
182 ]),
183 modelOptions: selections(["agent", "plan"]),
184 planModeEnabled: false,
185 });
186
187 expect(state.modelOptionsForDispatch).toEqual(selections(["agent", "build"]));
188 });
189
190 it("drops the agent descriptor entirely when plan is the only option and plan mode is disabled", () => {
191 const state = getComposerProviderState({
192 provider: PROVIDER,
193 model: MODEL,
194 models: modelWith([
195 selectDescriptor("agent", [{ id: "plan", label: "Plan", isDefault: true }]),
196 ]),
197 modelOptions: selections(["agent", "plan"]),
198 planModeEnabled: false,
199 });
200
201 expect(state).toEqual({
202 provider: PROVIDER,
203 promptEffort: null,
204 modelOptionsForDispatch: undefined,
205 });
206 });
207
208 it("falls back to a surviving agent when plan was the descriptor default and plan mode is disabled", () => {
209 const state = getComposerProviderState({
210 provider: PROVIDER,
211 model: MODEL,
212 models: modelWith([
213 selectDescriptor("agent", [
214 { id: "plan", label: "Plan", isDefault: true },
215 { id: "research", label: "Research" },
216 ]),
217 ]),
218 modelOptions: undefined,
219 planModeEnabled: false,
220 });
221
222 expect(state.modelOptionsForDispatch).toEqual(selections(["agent", "research"]));
223 });
224
168 it("returns undefined dispatch options when the model declares no descriptors", () => {225 it("returns undefined dispatch options when the model declares no descriptors", () => {
169 const state = getComposerProviderState({226 const state = getComposerProviderState({
170 provider: PROVIDER,227 provider: PROVIDER,
171 model: MODEL,228 model: MODEL,
172 models: modelWith([]),229 models: modelWith([]),
173 modelOptions: selections(["anything", "value"]),230 modelOptions: selections(["anything", "value"]),
231 planModeEnabled: true,
174 });232 });
175233
176 expect(state).toEqual({234 expect(state).toEqual({
...@@ -199,6 +257,7 @@ describe("getComposerProviderState", () => {...@@ -199,6 +257,7 @@ describe("getComposerProviderState", () => {
199 "Ultrathink:\nInvestigate this failure",257 "Ultrathink:\nInvestigate this failure",
200 ),258 ),
201 modelOptions: selections(["effort", "medium"]),259 modelOptions: selections(["effort", "medium"]),
260 planModeEnabled: true,
202 });261 });
203262
204 expect(state).toEqual({263 expect(state).toEqual({
...@@ -220,6 +279,7 @@ describe("getComposerProviderState", () => {...@@ -220,6 +279,7 @@ describe("getComposerProviderState", () => {
220 "Ultrathink:\nInvestigate this failure",279 "Ultrathink:\nInvestigate this failure",
221 ),280 ),
222 modelOptions: undefined,281 modelOptions: undefined,
282 planModeEnabled: true,
223 });283 });
224284
225 expect(state).not.toHaveProperty("composerFrameClassName");285 expect(state).not.toHaveProperty("composerFrameClassName");
...@@ -240,6 +300,7 @@ describe("provider traits render guards", () => {...@@ -240,6 +300,7 @@ describe("provider traits render guards", () => {
240 modelOptions: undefined,300 modelOptions: undefined,
241 prompt: "",301 prompt: "",
242 onPromptChange: () => {},302 onPromptChange: () => {},
303 planModeEnabled: true,
243 };304 };
244305
245 expect(renderProviderTraitsPicker(args)).toBeNull();306 expect(renderProviderTraitsPicker(args)).toBeNull();
apps/web/src/components/chat/composerProviderState.tsx+21-3
...@@ -23,6 +23,7 @@ export type ComposerProviderStateInput = {...@@ -23,6 +23,7 @@ export type ComposerProviderStateInput = {
23 models: ReadonlyArray<ServerProviderModel>;23 models: ReadonlyArray<ServerProviderModel>;
24 promptInjectionState?: ComposerPromptInjectionState;24 promptInjectionState?: ComposerPromptInjectionState;
25 modelOptions: ReadonlyArray<ProviderOptionSelection> | null | undefined;25 modelOptions: ReadonlyArray<ProviderOptionSelection> | null | undefined;
26 planModeEnabled: boolean;
26};27};
2728
28export type ComposerPromptInjectionState = "none" | "ultrathink";29export type ComposerPromptInjectionState = "none" | "ultrathink";
...@@ -46,6 +47,7 @@ type TraitsRenderInput = {...@@ -46,6 +47,7 @@ type TraitsRenderInput = {
46 modelOptions: ReadonlyArray<ProviderOptionSelection> | undefined;47 modelOptions: ReadonlyArray<ProviderOptionSelection> | undefined;
47 prompt: string;48 prompt: string;
48 onPromptChange: (prompt: string) => void;49 onPromptChange: (prompt: string) => void;
50 planModeEnabled: boolean;
49};51};
5052
51export function getComposerPromptInjectionState(prompt: string): ComposerPromptInjectionState {53export function getComposerPromptInjectionState(prompt: string): ComposerPromptInjectionState {
...@@ -53,8 +55,15 @@ export function getComposerPromptInjectionState(prompt: string): ComposerPromptI...@@ -53,8 +55,15 @@ export function getComposerPromptInjectionState(prompt: string): ComposerPromptI
53}55}
5456
55export function getComposerProviderState(input: ComposerProviderStateInput): ComposerProviderState {57export function getComposerProviderState(input: ComposerProviderStateInput): ComposerProviderState {
56 const { provider, model, models, modelOptions, promptInjectionState = "none" } = input;58 const {
57 const caps = getProviderModelCapabilities(models, model, provider);59 provider,
60 model,
61 models,
62 modelOptions,
63 promptInjectionState = "none",
64 planModeEnabled,
65 } = input;
66 const caps = getProviderModelCapabilities(models, model, provider, planModeEnabled);
58 const descriptors = getProviderOptionDescriptors({ caps, selections: modelOptions });67 const descriptors = getProviderOptionDescriptors({ caps, selections: modelOptions });
59 const primarySelectDescriptor = descriptors.find(68 const primarySelectDescriptor = descriptors.find(
60 (descriptor): descriptor is Extract<(typeof descriptors)[number], { type: "select" }> =>69 (descriptor): descriptor is Extract<(typeof descriptors)[number], { type: "select" }> =>
...@@ -94,11 +103,19 @@ function renderTraitsControl(...@@ -94,11 +103,19 @@ function renderTraitsControl(
94 modelOptions,103 modelOptions,
95 prompt,104 prompt,
96 onPromptChange,105 onPromptChange,
106 planModeEnabled,
97 } = input;107 } = input;
98 const hasTarget = threadRef !== undefined || draftId !== undefined;108 const hasTarget = threadRef !== undefined || draftId !== undefined;
99 if (109 if (
100 !hasTarget ||110 !hasTarget ||
101 !shouldRenderTraitsControls({ provider, models, model, modelOptions, prompt })111 !shouldRenderTraitsControls({
112 provider,
113 models,
114 model,
115 modelOptions,
116 prompt,
117 planModeEnabled,
118 })
102 ) {119 ) {
103 return null;120 return null;
104 }121 }
...@@ -113,6 +130,7 @@ function renderTraitsControl(...@@ -113,6 +130,7 @@ function renderTraitsControl(
113 modelOptions={modelOptions}130 modelOptions={modelOptions}
114 prompt={prompt}131 prompt={prompt}
115 onPromptChange={onPromptChange}132 onPromptChange={onPromptChange}
133 planModeEnabled={planModeEnabled}
116 />134 />
117 );135 );
118}136}
apps/web/src/components/settings/ProjectSettingsPanel.tsx+1
...@@ -831,6 +831,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {...@@ -831,6 +831,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
831 onPromptChange={() => {}}831 onPromptChange={() => {}}
832 modelOptions={resolvedSelection.options ?? []}832 modelOptions={resolvedSelection.options ?? []}
833 allowPromptInjectedEffort={false}833 allowPromptInjectedEffort={false}
834 planModeEnabled={settings.planModeEnabled}
834 triggerVariant="outline"835 triggerVariant="outline"
835 triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground"836 triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground"
836 onModelOptionsChange={(nextOptions) => {837 onModelOptionsChange={(nextOptions) => {
apps/web/src/components/settings/SettingsPanels.tsx+27-3
...@@ -68,6 +68,7 @@ import { useDesktopUpdateState } from "../../state/desktopUpdate";...@@ -68,6 +68,7 @@ import { useDesktopUpdateState } from "../../state/desktopUpdate";
68import {68import {
69 getCustomModelOptionsByInstance,69 getCustomModelOptionsByInstance,
70 resolveAppModelSelectionState,70 resolveAppModelSelectionState,
71 withoutPlanAgentSelection,
71} from "../../modelSelection";72} from "../../modelSelection";
72import {73import {
73 applyProviderInstanceSettings,74 applyProviderInstanceSettings,
...@@ -1710,9 +1711,31 @@ function LegacyFeaturesSection() {...@@ -1710,9 +1711,31 @@ function LegacyFeaturesSection() {
1710 control={1711 control={
1711 <Switch1712 <Switch
1712 checked={settings.planModeEnabled}1713 checked={settings.planModeEnabled}
1713 onCheckedChange={(checked) =>1714 onCheckedChange={(checked) => {
1714 updateSettings({ planModeEnabled: Boolean(checked) })1715 const planModeEnabled = Boolean(checked);
1715 }1716 const textGenerationModelSelection = withoutPlanAgentSelection(
1717 settings.textGenerationModelSelection,
1718 );
1719 const sourceControlWriterModelSelection = withoutPlanAgentSelection(
1720 settings.sourceControlWriterModelSelection,
1721 );
1722 updateSettings({
1723 planModeEnabled,
1724 ...(planModeEnabled
1725 ? {}
1726 : {
1727 ...(textGenerationModelSelection &&
1728 textGenerationModelSelection !== settings.textGenerationModelSelection
1729 ? { textGenerationModelSelection }
1730 : {}),
1731 ...(sourceControlWriterModelSelection &&
1732 sourceControlWriterModelSelection !==
1733 settings.sourceControlWriterModelSelection
1734 ? { sourceControlWriterModelSelection }
1735 : {}),
1736 }),
1737 });
1738 }}
1716 aria-label="Plan mode (legacy)"1739 aria-label="Plan mode (legacy)"
1717 />1740 />
1718 }1741 }
...@@ -2332,6 +2355,7 @@ export function GeneralSettingsPanel() {...@@ -2332,6 +2355,7 @@ export function GeneralSettingsPanel() {
2332 onPromptChange={() => {}}2355 onPromptChange={() => {}}
2333 modelOptions={textGenModelOptions}2356 modelOptions={textGenModelOptions}
2334 allowPromptInjectedEffort={false}2357 allowPromptInjectedEffort={false}
2358 planModeEnabled={settings.planModeEnabled}
2335 triggerVariant="outline"2359 triggerVariant="outline"
2336 triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground"2360 triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground"
2337 onModelOptionsChange={(nextOptions) => {2361 onModelOptionsChange={(nextOptions) => {
apps/web/src/modelSelection.test.ts+82
...@@ -1,11 +1,14 @@...@@ -1,11 +1,14 @@
1import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts";1import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts";
2import { DEFAULT_UNIFIED_SETTINGS, type UnifiedSettings } from "@t3tools/contracts/settings";2import { DEFAULT_UNIFIED_SETTINGS, type UnifiedSettings } from "@t3tools/contracts/settings";
3import { describe, expect, it } from "vite-plus/test";3import { describe, expect, it } from "vite-plus/test";
4import { createModelSelection } from "@t3tools/shared/model";
4import { deriveProviderInstanceEntries } from "./providerInstances";5import { deriveProviderInstanceEntries } from "./providerInstances";
5import {6import {
6 getAppModelOptionsForInstance,7 getAppModelOptionsForInstance,
7 resolveAppModelSelectionForInstance,8 resolveAppModelSelectionForInstance,
8 resolveAppModelSelectionState,9 resolveAppModelSelectionState,
10 resolvePlanAgentHealPatch,
11 withoutPlanAgentSelection,
9} from "./modelSelection";12} from "./modelSelection";
1013
11function provider(input: {14function provider(input: {
...@@ -321,3 +324,82 @@ describe("instance-scoped model selection", () => {...@@ -321,3 +324,82 @@ describe("instance-scoped model selection", () => {
321 });324 });
322 });325 });
323});326});
327
328describe("withoutPlanAgentSelection", () => {
329 const instance = ProviderInstanceId.make("opencode");
330 const model = "opencode/gpt-5.4";
331
332 it("drops a stored plan agent option", () => {
333 const selection = createModelSelection(instance, model, [
334 { id: "variant", value: "high" },
335 { id: "agent", value: "plan" },
336 ]);
337 expect(withoutPlanAgentSelection(selection)).toEqual(
338 createModelSelection(instance, model, [{ id: "variant", value: "high" }]),
339 );
340 });
341
342 it("keeps non-plan agent options", () => {
343 const selection = createModelSelection(instance, model, [{ id: "agent", value: "build" }]);
344 expect(withoutPlanAgentSelection(selection)).toBe(selection);
345 });
346
347 it("omits options entirely when plan was the only stored option", () => {
348 const selection = createModelSelection(instance, model, [{ id: "agent", value: "plan" }]);
349 expect(withoutPlanAgentSelection(selection)).toEqual({ instanceId: instance, model });
350 });
351
352 it("returns null and undefined selections unchanged", () => {
353 expect(withoutPlanAgentSelection(null)).toBeNull();
354 expect(withoutPlanAgentSelection(undefined)).toBeUndefined();
355 });
356});
357
358describe("resolvePlanAgentHealPatch", () => {
359 const instance = ProviderInstanceId.make("opencode");
360 const model = "opencode/gpt-5.4";
361 const healed = createModelSelection(instance, model, [{ id: "variant", value: "high" }]);
362 const storedPlan = createModelSelection(instance, model, [
363 { id: "variant", value: "high" },
364 { id: "agent", value: "plan" },
365 ]);
366 const nullPatch = {
367 planModeEnabled: true,
368 textGenerationModelSelection: storedPlan,
369 sourceControlWriterModelSelection: null,
370 };
371
372 it("returns null when plan mode is on", () => {
373 expect(resolvePlanAgentHealPatch(nullPatch)).toBeNull();
374 });
375
376 it("returns null when nothing needs healing", () => {
377 expect(
378 resolvePlanAgentHealPatch({
379 planModeEnabled: false,
380 textGenerationModelSelection: healed,
381 sourceControlWriterModelSelection: null,
382 }),
383 ).toBeNull();
384 });
385
386 it("patches the stored text generation selection to drop the plan agent", () => {
387 expect(
388 resolvePlanAgentHealPatch({
389 planModeEnabled: false,
390 textGenerationModelSelection: storedPlan,
391 sourceControlWriterModelSelection: null,
392 }),
393 ).toEqual({ textGenerationModelSelection: healed });
394 });
395
396 it("patches a stored source control writer selection that uses the plan agent", () => {
397 expect(
398 resolvePlanAgentHealPatch({
399 planModeEnabled: false,
400 textGenerationModelSelection: healed,
401 sourceControlWriterModelSelection: storedPlan,
402 }),
403 ).toEqual({ sourceControlWriterModelSelection: healed });
404 });
405});
apps/web/src/modelSelection.ts+48
...@@ -6,6 +6,7 @@ import {...@@ -6,6 +6,7 @@ import {
6 ProviderDriverKind,6 ProviderDriverKind,
7 ProviderInstanceId,7 ProviderInstanceId,
8 type ServerProvider,8 type ServerProvider,
9 type ServerSettingsPatch,
9} from "@t3tools/contracts";10} from "@t3tools/contracts";
10import {11import {
11 createModelSelection,12 createModelSelection,
...@@ -277,6 +278,51 @@ export function getCustomModelOptionsByInstance(...@@ -277,6 +278,51 @@ export function getCustomModelOptionsByInstance(
277 return out;278 return out;
278}279}
279280
281/**
282 * Drop the opencode "plan" agent option from a stored model selection.
283 * Used when legacy plan mode is turned off so server-side text-generation
284 * tasks (title, branch, PR) cannot keep dispatching the plan agent.
285 */
286export function withoutPlanAgentSelection(
287 selection: ModelSelection | null | undefined,
288): ModelSelection | null | undefined {
289 if (!selection?.options) {
290 return selection;
291 }
292 const options = selection.options.filter(
293 (option) => !(option.id === "agent" && option.value === "plan"),
294 );
295 if (options.length === selection.options.length) {
296 return selection;
297 }
298 return createModelSelection(selection.instanceId, selection.model, options);
299}
300
301// The dropdown hides the opencode "plan" agent while legacy plan mode is off,
302// but the persisted text-generation selections are only healed when the toggle
303// flips. Users who already have plan mode off and a stored "plan" selection
304// never trip the toggle handler, so resolve the heal once per settings load.
305export function resolvePlanAgentHealPatch(input: {
306 readonly planModeEnabled: boolean;
307 readonly textGenerationModelSelection: ModelSelection | null | undefined;
308 readonly sourceControlWriterModelSelection: ModelSelection | null | undefined;
309}): ServerSettingsPatch | null {
310 if (input.planModeEnabled) {
311 return null;
312 }
313 const healedText = withoutPlanAgentSelection(input.textGenerationModelSelection);
314 const healedSourceControl = withoutPlanAgentSelection(input.sourceControlWriterModelSelection);
315 const patch: ServerSettingsPatch = {
316 ...(healedText && healedText !== input.textGenerationModelSelection
317 ? { textGenerationModelSelection: healedText }
318 : {}),
319 ...(healedSourceControl && healedSourceControl !== input.sourceControlWriterModelSelection
320 ? { sourceControlWriterModelSelection: healedSourceControl }
321 : {}),
322 };
323 return Object.keys(patch).length > 0 ? patch : null;
324}
325
280export function resolveAppModelSelectionState(326export function resolveAppModelSelectionState(
281 settings: UnifiedSettings,327 settings: UnifiedSettings,
282 providers: ReadonlyArray<ServerProvider>,328 providers: ReadonlyArray<ServerProvider>,
...@@ -308,6 +354,7 @@ export function resolveAppModelSelectionState(...@@ -308,6 +354,7 @@ export function resolveAppModelSelectionState(
308 model,354 model,
309 models: entry.models,355 models: entry.models,
310 modelOptions: selectedEntry ? selection.options : undefined,356 modelOptions: selectedEntry ? selection.options : undefined,
357 planModeEnabled: settings.planModeEnabled,
311 });358 });
312359
313 return createModelSelection(entry.instanceId, model, modelOptionsForDispatch);360 return createModelSelection(entry.instanceId, model, modelOptionsForDispatch);
...@@ -325,6 +372,7 @@ export function resolveAppModelSelectionState(...@@ -325,6 +372,7 @@ export function resolveAppModelSelectionState(
325 model,372 model,
326 models: getProviderModels(providers, provider),373 models: getProviderModels(providers, provider),
327 modelOptions: keptSelectedProvider ? selection.options : undefined,374 modelOptions: keptSelectedProvider ? selection.options : undefined,
375 planModeEnabled: settings.planModeEnabled,
328 });376 });
329377
330 return createModelSelection(defaultInstanceIdForDriver(provider), model, modelOptionsForDispatch);378 return createModelSelection(defaultInstanceIdForDriver(provider), model, modelOptionsForDispatch);
apps/web/src/planAgentSelectionHeal.tsx created+52
...@@ -0,0 +1,52 @@
1import { useEffect } from "react";
2
3import {
4 useClientSettingsHydrated,
5 usePrimarySettings,
6 useUpdatePrimarySettings,
7} from "./hooks/useSettings";
8import { resolvePlanAgentHealPatch } from "./modelSelection";
9
10/**
11 * Heals persisted text-generation model selections that still reference the
12 * opencode "plan" agent. The dropdown hides the option while legacy plan mode
13 * is off, but the toggle handler only runs when the setting flips; users who
14 * already have plan mode off with a stored "plan" selection need this pass
15 * whenever the settings load.
16 */
17export function PlanAgentSelectionHeal() {
18 const planModeEnabled = usePrimarySettings((settings) => settings.planModeEnabled);
19 const textGenerationModelSelection = usePrimarySettings(
20 (settings) => settings.textGenerationModelSelection,
21 );
22 const sourceControlWriterModelSelection = usePrimarySettings(
23 (settings) => settings.sourceControlWriterModelSelection,
24 );
25 const settingsHydrated = useClientSettingsHydrated();
26 const updateSettings = useUpdatePrimarySettings();
27
28 useEffect(() => {
29 // planModeEnabled reads as false until client settings hydrate, so never
30 // heal before then: we would strip a stored plan selection from a user
31 // whose plan mode is actually on.
32 if (!settingsHydrated) {
33 return;
34 }
35 const patch = resolvePlanAgentHealPatch({
36 planModeEnabled,
37 textGenerationModelSelection,
38 sourceControlWriterModelSelection,
39 });
40 if (patch) {
41 updateSettings(patch);
42 }
43 }, [
44 planModeEnabled,
45 settingsHydrated,
46 textGenerationModelSelection,
47 sourceControlWriterModelSelection,
48 updateSettings,
49 ]);
50
51 return null;
52}
apps/web/src/providerModels.ts+32-1
...@@ -81,9 +81,40 @@ export function getProviderModelCapabilities(...@@ -81,9 +81,40 @@ export function getProviderModelCapabilities(
81 models: ReadonlyArray<ServerProviderModel>,81 models: ReadonlyArray<ServerProviderModel>,
82 model: string | null | undefined,82 model: string | null | undefined,
83 provider: ProviderDriverKind,83 provider: ProviderDriverKind,
84 planModeEnabled = true,
84): ModelCapabilities {85): ModelCapabilities {
85 const slug = normalizeModelSlug(model, provider);86 const slug = normalizeModelSlug(model, provider);
86 return models.find((candidate) => candidate.slug === slug)?.capabilities ?? EMPTY_CAPABILITIES;87 const caps =
88 models.find((candidate) => candidate.slug === slug)?.capabilities ?? EMPTY_CAPABILITIES;
89 if (planModeEnabled) {
90 return caps;
91 }
92 return withoutPlanAgentOption(caps);
93}
94
95// The opencode "plan" agent is only reachable while legacy plan mode is on.
96// With it off, drop the option so it cannot be selected or dispatched, and
97// drop the descriptor entirely when nothing remains selectable. currentValue
98// is re-resolved against the surviving options so a stale or defaulted "plan"
99// value cannot leak back into dispatch.
100function withoutPlanAgentOption(caps: ModelCapabilities): ModelCapabilities {
101 return {
102 ...caps,
103 optionDescriptors: (caps.optionDescriptors ?? []).flatMap((descriptor) => {
104 if (descriptor.type !== "select" || descriptor.id !== "agent") {
105 return [descriptor];
106 }
107 const options = descriptor.options.filter((option) => option.id !== "plan");
108 if (options.length === 0) {
109 return [];
110 }
111 const currentValue =
112 descriptor.currentValue && options.some((option) => option.id === descriptor.currentValue)
113 ? descriptor.currentValue
114 : (options.find((option) => option.isDefault)?.id ?? options[0]?.id);
115 return [{ ...descriptor, options, ...(currentValue ? { currentValue } : {}) }];
116 }),
117 };
87}118}
88119
89export function getDefaultServerModel(120export function getDefaultServerModel(
apps/web/src/routes/__root.tsx+2
...@@ -31,6 +31,7 @@ import {...@@ -31,6 +31,7 @@ import {
31import { resolveAndPersistPreferredEditor } from "../editorPreferences";31import { resolveAndPersistPreferredEditor } from "../editorPreferences";
32import { applyAppearanceFontVariables } from "~/appearanceFonts";32import { applyAppearanceFontVariables } from "~/appearanceFonts";
33import { useClientSettings } from "../hooks/useSettings";33import { useClientSettings } from "../hooks/useSettings";
34import { PlanAgentSelectionHeal } from "../planAgentSelectionHeal";
34import {35import {
35 deriveLogicalProjectKeyFromSettings,36 deriveLogicalProjectKeyFromSettings,
36 derivePhysicalProjectKeyFromPath,37 derivePhysicalProjectKeyFromPath,
...@@ -140,6 +141,7 @@ function RootRouteView() {...@@ -140,6 +141,7 @@ function RootRouteView() {
140 <SlowRpcRequestToastCoordinator />141 <SlowRpcRequestToastCoordinator />
141 <HostedStaticEnvironmentBootstrap />142 <HostedStaticEnvironmentBootstrap />
142 {primaryEnvironmentAuthenticated ? <EventRouter /> : null}143 {primaryEnvironmentAuthenticated ? <EventRouter /> : null}
144 {primaryEnvironmentAuthenticated ? <PlanAgentSelectionHeal /> : null}
143 {primaryEnvironmentAuthenticated ? <ProviderUpdateLaunchNotification /> : null}145 {primaryEnvironmentAuthenticated ? <ProviderUpdateLaunchNotification /> : null}
144 {appShell}146 {appShell}
145 {/* Above the router: a theme draft is judged by walking the app, so the147 {/* Above the router: a theme draft is judged by walking the app, so the