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)
873873 models: selectedProviderModels,
874874 promptInjectionState: composerPromptInjectionState,
875875 modelOptions: composerModelOptions?.[selectedInstanceId],
876 planModeEnabled: settings.planModeEnabled,
876877 }),
877878 [
878879 composerModelOptions,
......@@ -881,6 +882,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
881882 selectedModel,
882883 selectedProvider,
883884 selectedProviderModels,
885 settings.planModeEnabled,
884886 ],
885887 );
886888
......@@ -1215,6 +1217,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
12151217 modelOptions: composerModelOptions?.[selectedInstanceId],
12161218 prompt,
12171219 onPromptChange: setPromptFromTraits,
1220 planModeEnabled: settings.planModeEnabled,
12181221 });
12191222 const providerTraitsPicker = renderProviderTraitsPicker({
12201223 provider: selectedProvider,
......@@ -1226,6 +1229,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
12261229 modelOptions: composerModelOptions?.[selectedInstanceId],
12271230 prompt,
12281231 onPromptChange: setPromptFromTraits,
1232 planModeEnabled: settings.planModeEnabled,
12291233 });
12301234 const pendingPrimaryAction = useMemo(
12311235 () =>
apps/web/src/components/chat/TraitsPicker.tsx+12-1
......@@ -96,8 +96,9 @@ function getSelectedTraits(
9696 prompt: string,
9797 modelOptions: ProviderOptions | null | undefined,
9898 allowPromptInjectedEffort: boolean,
99 planModeEnabled: boolean,
99100) {
100 const caps = getProviderModelCapabilities(models, model, provider);
101 const caps = getProviderModelCapabilities(models, model, provider, planModeEnabled);
101102 const descriptors = getProviderOptionDescriptors({
102103 caps,
103104 selections: modelOptions,
......@@ -167,6 +168,7 @@ function getTraitsSectionVisibility(input: {
167168 prompt: string;
168169 modelOptions: ProviderOptions | null | undefined;
169170 allowPromptInjectedEffort?: boolean;
171 planModeEnabled: boolean;
170172}) {
171173 const selected = getSelectedTraits(
172174 input.provider,
......@@ -175,6 +177,7 @@ function getTraitsSectionVisibility(input: {
175177 input.prompt,
176178 input.modelOptions,
177179 input.allowPromptInjectedEffort ?? true,
180 input.planModeEnabled,
178181 );
179182
180183 const showEffort = selected.primarySelectDescriptor !== null;
......@@ -201,6 +204,7 @@ export function shouldRenderTraitsControls(input: {
201204 prompt: string;
202205 modelOptions: ProviderOptions | null | undefined;
203206 allowPromptInjectedEffort?: boolean;
207 planModeEnabled: boolean;
204208}): boolean {
205209 return getTraitsSectionVisibility(input).hasAnyControls;
206210}
......@@ -214,6 +218,7 @@ export interface TraitsMenuContentProps {
214218 onPromptChange: (prompt: string) => void;
215219 modelOptions?: ProviderOptions | null | undefined;
216220 allowPromptInjectedEffort?: boolean;
221 planModeEnabled: boolean;
217222 triggerVariant?: VariantProps<typeof buttonVariants>["variant"];
218223 triggerClassName?: string;
219224}
......@@ -227,6 +232,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
227232 onPromptChange,
228233 modelOptions,
229234 allowPromptInjectedEffort = true,
235 planModeEnabled,
230236 ...persistence
231237}: TraitsMenuContentProps & TraitsPersistence) {
232238 const setProviderModelOptions = useComposerDraftStore((store) => store.setProviderModelOptions);
......@@ -263,6 +269,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
263269 prompt,
264270 modelOptions,
265271 allowPromptInjectedEffort,
272 planModeEnabled,
266273 });
267274 const updateDescriptors = (nextDescriptors: ReadonlyArray<ProviderOptionDescriptor>) => {
268275 updateModelOptions(buildProviderOptionSelectionsFromDescriptors(nextDescriptors));
......@@ -451,6 +458,7 @@ export const TraitsPicker = memo(function TraitsPicker({
451458 onPromptChange,
452459 modelOptions,
453460 allowPromptInjectedEffort = true,
461 planModeEnabled,
454462 triggerVariant,
455463 triggerClassName,
456464 ...persistence
......@@ -464,6 +472,7 @@ export const TraitsPicker = memo(function TraitsPicker({
464472 prompt,
465473 modelOptions,
466474 allowPromptInjectedEffort,
475 planModeEnabled,
467476 });
468477 if (
469478 !shouldRenderTraitsControls({
......@@ -473,6 +482,7 @@ export const TraitsPicker = memo(function TraitsPicker({
473482 prompt,
474483 modelOptions,
475484 allowPromptInjectedEffort,
485 planModeEnabled,
476486 })
477487 ) {
478488 return null;
......@@ -543,6 +553,7 @@ export const TraitsPicker = memo(function TraitsPicker({
543553 onPromptChange={onPromptChange}
544554 modelOptions={modelOptions}
545555 allowPromptInjectedEffort={allowPromptInjectedEffort}
556 planModeEnabled={planModeEnabled}
546557 {...persistence}
547558 />
548559 </MenuPopup>
apps/web/src/components/chat/composerProviderState.test.tsx+61
......@@ -80,6 +80,7 @@ describe("getComposerProviderState", () => {
8080 ]),
8181 ]),
8282 modelOptions: undefined,
83 planModeEnabled: true,
8384 });
8485
8586 expect(state).toEqual({
......@@ -101,6 +102,7 @@ describe("getComposerProviderState", () => {
101102 booleanDescriptor("fastMode"),
102103 ]),
103104 modelOptions: selections(["effort", "low"], ["fastMode", true]),
105 planModeEnabled: true,
104106 });
105107
106108 expect(state).toEqual({
......@@ -119,6 +121,7 @@ describe("getComposerProviderState", () => {
119121 booleanDescriptor("fastMode"),
120122 ]),
121123 modelOptions: selections(["effort", "high"], ["fastMode", false]),
124 planModeEnabled: true,
122125 });
123126
124127 expect(state.modelOptionsForDispatch).toEqual(
......@@ -132,6 +135,7 @@ describe("getComposerProviderState", () => {
132135 model: MODEL,
133136 models: modelWith([booleanDescriptor("thinking")]),
134137 modelOptions: selections(["effort", "max"], ["thinking", false]),
138 planModeEnabled: true,
135139 });
136140
137141 expect(state).toEqual({
......@@ -157,6 +161,7 @@ describe("getComposerProviderState", () => {
157161 ]),
158162 ]),
159163 modelOptions: selections(["agent", "plan"]),
164 planModeEnabled: true,
160165 });
161166
162167 expect(state.promptEffort).toBe("high");
......@@ -165,12 +170,65 @@ describe("getComposerProviderState", () => {
165170 );
166171 });
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
168225 it("returns undefined dispatch options when the model declares no descriptors", () => {
169226 const state = getComposerProviderState({
170227 provider: PROVIDER,
171228 model: MODEL,
172229 models: modelWith([]),
173230 modelOptions: selections(["anything", "value"]),
231 planModeEnabled: true,
174232 });
175233
176234 expect(state).toEqual({
......@@ -199,6 +257,7 @@ describe("getComposerProviderState", () => {
199257 "Ultrathink:\nInvestigate this failure",
200258 ),
201259 modelOptions: selections(["effort", "medium"]),
260 planModeEnabled: true,
202261 });
203262
204263 expect(state).toEqual({
......@@ -220,6 +279,7 @@ describe("getComposerProviderState", () => {
220279 "Ultrathink:\nInvestigate this failure",
221280 ),
222281 modelOptions: undefined,
282 planModeEnabled: true,
223283 });
224284
225285 expect(state).not.toHaveProperty("composerFrameClassName");
......@@ -240,6 +300,7 @@ describe("provider traits render guards", () => {
240300 modelOptions: undefined,
241301 prompt: "",
242302 onPromptChange: () => {},
303 planModeEnabled: true,
243304 };
244305
245306 expect(renderProviderTraitsPicker(args)).toBeNull();
apps/web/src/components/chat/composerProviderState.tsx+21-3
......@@ -23,6 +23,7 @@ export type ComposerProviderStateInput = {
2323 models: ReadonlyArray<ServerProviderModel>;
2424 promptInjectionState?: ComposerPromptInjectionState;
2525 modelOptions: ReadonlyArray<ProviderOptionSelection> | null | undefined;
26 planModeEnabled: boolean;
2627};
2728
2829export type ComposerPromptInjectionState = "none" | "ultrathink";
......@@ -46,6 +47,7 @@ type TraitsRenderInput = {
4647 modelOptions: ReadonlyArray<ProviderOptionSelection> | undefined;
4748 prompt: string;
4849 onPromptChange: (prompt: string) => void;
50 planModeEnabled: boolean;
4951};
5052
5153export function getComposerPromptInjectionState(prompt: string): ComposerPromptInjectionState {
......@@ -53,8 +55,15 @@ export function getComposerPromptInjectionState(prompt: string): ComposerPromptI
5355}
5456
5557export function getComposerProviderState(input: ComposerProviderStateInput): ComposerProviderState {
56 const { provider, model, models, modelOptions, promptInjectionState = "none" } = input;
57 const caps = getProviderModelCapabilities(models, model, provider);
58 const {
59 provider,
60 model,
61 models,
62 modelOptions,
63 promptInjectionState = "none",
64 planModeEnabled,
65 } = input;
66 const caps = getProviderModelCapabilities(models, model, provider, planModeEnabled);
5867 const descriptors = getProviderOptionDescriptors({ caps, selections: modelOptions });
5968 const primarySelectDescriptor = descriptors.find(
6069 (descriptor): descriptor is Extract<(typeof descriptors)[number], { type: "select" }> =>
......@@ -94,11 +103,19 @@ function renderTraitsControl(
94103 modelOptions,
95104 prompt,
96105 onPromptChange,
106 planModeEnabled,
97107 } = input;
98108 const hasTarget = threadRef !== undefined || draftId !== undefined;
99109 if (
100110 !hasTarget ||
101 !shouldRenderTraitsControls({ provider, models, model, modelOptions, prompt })
111 !shouldRenderTraitsControls({
112 provider,
113 models,
114 model,
115 modelOptions,
116 prompt,
117 planModeEnabled,
118 })
102119 ) {
103120 return null;
104121 }
......@@ -113,6 +130,7 @@ function renderTraitsControl(
113130 modelOptions={modelOptions}
114131 prompt={prompt}
115132 onPromptChange={onPromptChange}
133 planModeEnabled={planModeEnabled}
116134 />
117135 );
118136}
apps/web/src/components/settings/ProjectSettingsPanel.tsx+1
......@@ -831,6 +831,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
831831 onPromptChange={() => {}}
832832 modelOptions={resolvedSelection.options ?? []}
833833 allowPromptInjectedEffort={false}
834 planModeEnabled={settings.planModeEnabled}
834835 triggerVariant="outline"
835836 triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground"
836837 onModelOptionsChange={(nextOptions) => {
apps/web/src/components/settings/SettingsPanels.tsx+27-3
......@@ -68,6 +68,7 @@ import { useDesktopUpdateState } from "../../state/desktopUpdate";
6868import {
6969 getCustomModelOptionsByInstance,
7070 resolveAppModelSelectionState,
71 withoutPlanAgentSelection,
7172} from "../../modelSelection";
7273import {
7374 applyProviderInstanceSettings,
......@@ -1710,9 +1711,31 @@ function LegacyFeaturesSection() {
17101711 control={
17111712 <Switch
17121713 checked={settings.planModeEnabled}
1713 onCheckedChange={(checked) =>
1714 updateSettings({ planModeEnabled: Boolean(checked) })
1715 }
1714 onCheckedChange={(checked) => {
1715 const planModeEnabled = Boolean(checked);
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 }}
17161739 aria-label="Plan mode (legacy)"
17171740 />
17181741 }
......@@ -2332,6 +2355,7 @@ export function GeneralSettingsPanel() {
23322355 onPromptChange={() => {}}
23332356 modelOptions={textGenModelOptions}
23342357 allowPromptInjectedEffort={false}
2358 planModeEnabled={settings.planModeEnabled}
23352359 triggerVariant="outline"
23362360 triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground"
23372361 onModelOptionsChange={(nextOptions) => {
apps/web/src/modelSelection.test.ts+82
......@@ -1,11 +1,14 @@
11import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts";
22import { DEFAULT_UNIFIED_SETTINGS, type UnifiedSettings } from "@t3tools/contracts/settings";
33import { describe, expect, it } from "vite-plus/test";
4import { createModelSelection } from "@t3tools/shared/model";
45import { deriveProviderInstanceEntries } from "./providerInstances";
56import {
67 getAppModelOptionsForInstance,
78 resolveAppModelSelectionForInstance,
89 resolveAppModelSelectionState,
10 resolvePlanAgentHealPatch,
11 withoutPlanAgentSelection,
912} from "./modelSelection";
1013
1114function provider(input: {
......@@ -321,3 +324,82 @@ describe("instance-scoped model selection", () => {
321324 });
322325 });
323326});
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 {
66 ProviderDriverKind,
77 ProviderInstanceId,
88 type ServerProvider,
9 type ServerSettingsPatch,
910} from "@t3tools/contracts";
1011import {
1112 createModelSelection,
......@@ -277,6 +278,51 @@ export function getCustomModelOptionsByInstance(
277278 return out;
278279}
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
280326export function resolveAppModelSelectionState(
281327 settings: UnifiedSettings,
282328 providers: ReadonlyArray<ServerProvider>,
......@@ -308,6 +354,7 @@ export function resolveAppModelSelectionState(
308354 model,
309355 models: entry.models,
310356 modelOptions: selectedEntry ? selection.options : undefined,
357 planModeEnabled: settings.planModeEnabled,
311358 });
312359
313360 return createModelSelection(entry.instanceId, model, modelOptionsForDispatch);
......@@ -325,6 +372,7 @@ export function resolveAppModelSelectionState(
325372 model,
326373 models: getProviderModels(providers, provider),
327374 modelOptions: keptSelectedProvider ? selection.options : undefined,
375 planModeEnabled: settings.planModeEnabled,
328376 });
329377
330378 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(
8181 models: ReadonlyArray<ServerProviderModel>,
8282 model: string | null | undefined,
8383 provider: ProviderDriverKind,
84 planModeEnabled = true,
8485): ModelCapabilities {
8586 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 };
87118}
88119
89120export function getDefaultServerModel(
apps/web/src/routes/__root.tsx+2
......@@ -31,6 +31,7 @@ import {
3131import { resolveAndPersistPreferredEditor } from "../editorPreferences";
3232import { applyAppearanceFontVariables } from "~/appearanceFonts";
3333import { useClientSettings } from "../hooks/useSettings";
34import { PlanAgentSelectionHeal } from "../planAgentSelectionHeal";
3435import {
3536 deriveLogicalProjectKeyFromSettings,
3637 derivePhysicalProjectKeyFromPath,
......@@ -140,6 +141,7 @@ function RootRouteView() {
140141 <SlowRpcRequestToastCoordinator />
141142 <HostedStaticEnvironmentBootstrap />
142143 {primaryEnvironmentAuthenticated ? <EventRouter /> : null}
144 {primaryEnvironmentAuthenticated ? <PlanAgentSelectionHeal /> : null}
143145 {primaryEnvironmentAuthenticated ? <ProviderUpdateLaunchNotification /> : null}
144146 {appShell}
145147 {/* Above the router: a theme draft is judged by walking the app, so the