Skip to content

Commit 509d497

Browse files
authored
feat(web): put legacy plan mode behind a setting, and adopt #6420 (#59)
* feat(web): put legacy plan mode behind a setting Reverses the F6 decision recorded in the upstream ledger. Pylon kept the composer's Build/Plan toggle when upstream removed it in #5551, on the argument that plan mode is first-class in Claude Code and Codex. In practice it goes unused here, so Pylon now matches upstream: the toggle and the /plan and /default slash commands are gated behind a `planModeEnabled` client setting, default off. Ported rather than cherry-picked. #5551 is 338 commits back and touches ChatComposer.tsx, which has diverged hard (4240 lines here vs upstream's 2824); its Beta-panel placement and sidebar-v2 search entries have since been superseded upstream anyway. This implements upstream's current end state against Pylon's tree. Gating the composer's `showInteractionModeToggle` covers both entry points, because Pylon already derives the /plan slash command from that same flag. ChatView forces the effective mode back to "default" while the setting is off so a thread saved in plan mode is not stranded with its toggle hidden. Pylon Mobile already carries a device-local `planModeEnabled` preference, documented as the counterpart of this key, so the two surfaces now agree. This unblocks upstream #6420, which depends on the flag. * fix(web): hide opencode's plan agent when legacy plan mode is off (#6420) (cherry picked from commit 80c37f1a721d36bde86b31c475fcc8d3db75f706) Adopted now that Pylon has the `planModeEnabled` flag it depends on. Skipped in the third 2026-08-19 batch because it required the setting F6 had rejected. Two adaptations. The import collisions in `modelSelection.ts` and `SettingsPanels.tsx` were unioned — Pylon's `getBackgroundTextGenerationProviders` and `providerInstancePrioritySortKey` sit alongside upstream's `withoutPlanAgentSelection`. And the `legacy-plan-mode` settings-search id was registered against `/settings/general`, matching where Pylon keeps its other legacy rows. * docs(upstream): record the F6 reversal on legacy plan mode
1 parent fe7387b commit 509d497

15 files changed

Lines changed: 411 additions & 10 deletions

.agents/upstream-review.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1839,6 +1839,38 @@ Still open from this batch, not acted on:
18391839
- `flattenOpenCodeSkills` copies the full description into `shortDescription` and never
18401840
sets `scope`, unlike the Codex and Claude adapters.
18411841

1842+
## 2026-08-19 (F6 reversal) — legacy plan mode
1843+
1844+
Not a batch: a reversal of a standing decision, recorded so the ledger does not
1845+
keep asserting something that is no longer true.
1846+
1847+
**F6 (`48aa875c0` / `#5551`) was skipped** on the argument that plan mode is
1848+
first-class in Claude Code and Codex, so dropping the composer's Build/Plan
1849+
toggle would cost Pylon more than it cost T3. **H1 extended it** by dropping
1850+
upstream's plan-mode Settings row. The developer has since concluded the toggle
1851+
goes unused in practice and asked to match upstream.
1852+
1853+
Pylon now has a `planModeEnabled` client setting, default off, gating the
1854+
composer toggle and the `/plan` and `/default` slash commands, with `ChatView`
1855+
forcing the effective mode back to `"default"` while it is off so no thread is
1856+
stranded in plan mode with its toggle hidden. Pylon Mobile already carried a
1857+
device-local `planModeEnabled` preference documented as the counterpart of this
1858+
key, so the surfaces now agree rather than diverge.
1859+
1860+
**Ported, not cherry-picked.** `#5551` is 338 commits back and touches
1861+
`ChatComposer.tsx`, which has diverged hard — 4240 lines here against upstream's
1862+
2824 — and its Beta-panel placement plus `sidebar-v2` search entries were
1863+
superseded upstream anyway. Replaying that snapshot would have imported a shape
1864+
neither project has. Pylon implements upstream's current end state instead.
1865+
1866+
With the flag in place, **`80c37f1a7` (`#6420`) was adopted**, closing the skip
1867+
recorded in the third 2026-08-19 batch. Its two conflicts were additive and
1868+
unioned; the `legacy-plan-mode` settings-search id is registered against
1869+
`/settings/general`, where Pylon keeps its other legacy rows.
1870+
1871+
F6 and H1 stay in their original batch tables as historical record — they
1872+
describe what was decided then. This entry is what supersedes them.
1873+
18421874
## Deferred register
18431875

18441876
_The register is currently empty. DEF-1 and DEF-2 were adopted on 2026-08-11

apps/web/src/components/ChatView.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1661,8 +1661,13 @@ function ChatViewContent(props: ChatViewProps) {
16611661
// the branch mismatch banner.
16621662
const [, setThreadErrorBannerDismissTick] = useState(0);
16631663
const runtimeMode = composerRuntimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE;
1664-
const interactionMode =
1665-
composerInteractionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE;
1664+
const planModeEnabled = useClientSettings((clientSettings) => clientSettings.planModeEnabled);
1665+
// With legacy plan mode off, force the effective mode to "default" so a
1666+
// thread saved in plan mode is not stranded there with its toggle hidden.
1667+
// The next send persists "default" back to the thread.
1668+
const interactionMode = planModeEnabled
1669+
? (composerInteractionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE)
1670+
: DEFAULT_INTERACTION_MODE;
16661671
const isLocalDraftThread = !isServerThread && localDraftThread !== undefined;
16671672
const canCheckoutPullRequestIntoThread = isLocalDraftThread;
16681673
const activeThreadId = activeThread?.id ?? null;
@@ -5433,6 +5438,7 @@ function ChatViewContent(props: ChatViewProps) {
54335438
return;
54345439
}
54355440
const standaloneSlashCommand =
5441+
planModeEnabled &&
54365442
composerImages.length === 0 &&
54375443
sendableComposerTerminalContexts.length === 0 &&
54385444
composerElementContexts.length === 0 &&

apps/web/src/components/chat/ChatComposer.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1593,6 +1593,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
15931593
models: selectedProviderModels,
15941594
promptInjectionState: composerPromptInjectionState,
15951595
modelOptions: composerModelOptions?.[selectedInstanceId],
1596+
planModeEnabled: settings.planModeEnabled,
15961597
}),
15971598
[
15981599
composerModelOptions,
@@ -1601,21 +1602,28 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
16011602
selectedModel,
16021603
selectedProvider,
16031604
selectedProviderModels,
1605+
settings.planModeEnabled,
16041606
],
16051607
);
16061608

16071609
const selectedPromptEffort = composerProviderState.promptEffort;
16081610
const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch;
1611+
// Plan mode is legacy and off by default. Gating here covers both entry
1612+
// points, because the /plan and /default slash commands are derived from
1613+
// this same flag. ChatView forces the effective mode back to "default" while
1614+
// it is off, so hiding the toggle cannot strand a thread in plan mode.
1615+
const planModeUiEnabled = settings.planModeEnabled;
16091616
const composerProviderControls = useMemo(
16101617
() => ({
16111618
showInteractionModeToggle:
1612-
selectedProviderEntry?.snapshot.showInteractionModeToggle ??
1613-
getProviderInteractionModeToggle(providerStatuses, selectedProvider),
1619+
planModeUiEnabled &&
1620+
(selectedProviderEntry?.snapshot.showInteractionModeToggle ??
1621+
getProviderInteractionModeToggle(providerStatuses, selectedProvider)),
16141622
supportedRuntimeModes: getServerProviderSupportedRuntimeModes(
16151623
selectedProviderEntry?.snapshot,
16161624
),
16171625
}),
1618-
[providerStatuses, selectedProvider, selectedProviderEntry],
1626+
[planModeUiEnabled, providerStatuses, selectedProvider, selectedProviderEntry],
16191627
);
16201628
const resolvedRuntimeMode = resolveServerProviderRuntimeMode(
16211629
selectedProviderEntry?.snapshot,
@@ -1972,6 +1980,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
19721980
modelOptions: composerModelOptions?.[selectedInstanceId],
19731981
prompt,
19741982
onPromptChange: setPromptFromTraits,
1983+
planModeEnabled: settings.planModeEnabled,
19751984
});
19761985
const providerTraitsPicker = renderProviderTraitsPicker({
19771986
provider: selectedProvider,
@@ -1983,6 +1992,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
19831992
modelOptions: composerModelOptions?.[selectedInstanceId],
19841993
prompt,
19851994
onPromptChange: setPromptFromTraits,
1995+
planModeEnabled: settings.planModeEnabled,
19861996
});
19871997
const pendingPrimaryAction = useMemo(
19881998
() =>

apps/web/src/components/chat/TraitsPicker.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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();

0 commit comments

Comments
 (0)