diff --git a/packages/app/src/components/workspace-setup-dialog.tsx b/packages/app/src/components/workspace-setup-dialog.tsx index 1954394176..3a326aed3d 100644 --- a/packages/app/src/components/workspace-setup-dialog.tsx +++ b/packages/app/src/components/workspace-setup-dialog.tsx @@ -125,8 +125,7 @@ function buildCreateAgentOptions({ provider, }: { composerState: { - modeOptions: { id: string }[]; - selectedMode: string; + effectiveModeId: string; effectiveModelId: string | null; effectiveThinkingOptionId: string | null; }; @@ -137,20 +136,11 @@ function buildCreateAgentOptions({ workspaceId: string; provider: CreateAgentRequestOptions["provider"]; }): CreateAgentRequestOptions { - // Reconcile the selected mode against the discovered modes. The mode picker - // shows modeOptions[0] when the stored mode isn't in the list (e.g. a stale - // globally-remembered mode this workspace's provider config no longer - // defines), so the submitted mode must match that display rather than send a - // stale mode the provider would reject. - const modeOptionIds = composerState.modeOptions.map((mode) => mode.id); - const reconciledMode = modeOptionIds.includes(composerState.selectedMode) - ? composerState.selectedMode - : (modeOptionIds[0] ?? ""); return { provider, cwd: workspaceDirectory, workspaceId, - ...(reconciledMode !== "" ? { modeId: reconciledMode } : {}), + ...(composerState.effectiveModeId ? { modeId: composerState.effectiveModeId } : {}), ...(composerState.effectiveModelId ? { model: composerState.effectiveModelId } : {}), ...(composerState.effectiveThinkingOptionId ? { thinkingOptionId: composerState.effectiveThinkingOptionId } diff --git a/packages/app/src/composer/draft/input-draft.ts b/packages/app/src/composer/draft/input-draft.ts index 7e6805b0b3..c64753694a 100644 --- a/packages/app/src/composer/draft/input-draft.ts +++ b/packages/app/src/composer/draft/input-draft.ts @@ -17,6 +17,7 @@ import { } from "@/composer/draft/input-draft-core"; import { buildDraftCommandTarget, + resolveEffectiveComposerModeId, resolveEffectiveComposerModelId, resolveEffectiveComposerThinkingOptionId, type ProviderSelectionState, @@ -46,6 +47,7 @@ interface UseAgentInputDraftInput { type DraftComposerState = UseAgentFormStateResult & { workingDir: string; + effectiveModeId: string; effectiveModelId: string; effectiveThinkingOptionId: string; featureValues: Record | undefined; @@ -256,6 +258,11 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr ], ); + const effectiveModeId = useMemo( + () => resolveEffectiveComposerModeId(providerSelection, formState.isModelLoading), + [formState.isModelLoading, providerSelection], + ); + const effectiveModelId = useMemo( () => resolveEffectiveComposerModelId(providerSelection), [providerSelection], @@ -315,6 +322,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr return { ...formState, workingDir, + effectiveModeId, effectiveModelId, effectiveThinkingOptionId, featureValues: draftFeatureValues, @@ -329,6 +337,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr }, [ commandDraft, composerOptions, + effectiveModeId, effectiveModelId, effectiveThinkingOptionId, draftFeatures, @@ -355,6 +364,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr export const __private__ = { resolveDraftKey, + resolveEffectiveComposerModeId, resolveEffectiveComposerModelId, resolveEffectiveComposerThinkingOptionId, buildDraftCommandTarget, diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index 6e9c45eab5..4aa22a324d 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -86,46 +86,30 @@ function resolveAutoSubmitConfig( }; } -// Reconcile the form's selected mode against the currently discovered modes. -// The mode picker displays modeOptions[0] when the stored mode isn't in the -// list (e.g. a globally-remembered "plan" that this workspace's OpenCode config -// no longer defines), so the submitted mode must match that display — otherwise -// we'd send a stale mode the provider rejects while the UI showed a valid one. -function reconcileSelectedMode(modeOptionIds: readonly string[], selectedMode: string): string { - if (modeOptionIds.length === 0) { - return ""; - } - return modeOptionIds.includes(selectedMode) ? selectedMode : (modeOptionIds[0] ?? ""); -} - function resolveDraftModeIdOverride(input: { autoSubmitConfig: AutoSubmitConfig | null; - modeOptionIds: readonly string[]; - selectedMode: string; + effectiveModeId: string; }): { modeId: string } | Record { - const { autoSubmitConfig, modeOptionIds, selectedMode } = input; + const { autoSubmitConfig, effectiveModeId } = input; if (autoSubmitConfig?.modeId) { return { modeId: autoSubmitConfig.modeId }; } - const reconciled = reconcileSelectedMode(modeOptionIds, selectedMode); - if (reconciled !== "") { - return { modeId: reconciled }; + if (effectiveModeId !== "") { + return { modeId: effectiveModeId }; } return {}; } function resolveDraftModeId(input: { autoSubmitConfig: AutoSubmitConfig | null; - modeOptionIds: readonly string[]; - selectedMode: string; + effectiveModeId: string; }): string | null { - const { autoSubmitConfig, modeOptionIds, selectedMode } = input; + const { autoSubmitConfig, effectiveModeId } = input; if (autoSubmitConfig?.modeId !== undefined) { return autoSubmitConfig.modeId; } - const reconciled = reconcileSelectedMode(modeOptionIds, selectedMode); - if (reconciled !== "") { - return reconciled; + if (effectiveModeId !== "") { + return effectiveModeId; } return null; } @@ -143,8 +127,7 @@ async function submitDraftCreateRequest(input: { autoSubmitConfig: AutoSubmitConfig | null; composerState: { selectedProvider: string | null; - selectedMode: string; - modeOptions: readonly { id: string }[]; + effectiveModeId: string; effectiveModelId: string | null; effectiveThinkingOptionId: string | null; featureValues: Record | undefined; @@ -177,8 +160,7 @@ async function submitDraftCreateRequest(input: { } const modeIdOverride = resolveDraftModeIdOverride({ autoSubmitConfig, - modeOptionIds: composerState.modeOptions.map((mode) => mode.id), - selectedMode: composerState.selectedMode, + effectiveModeId: composerState.effectiveModeId, }); const config = buildWorkspaceDraftAgentConfig({ provider, @@ -219,8 +201,7 @@ function buildDraftAgentSnapshot(input: { composerState: { effectiveModelId: string | null; effectiveThinkingOptionId: string | null; - modeOptions: readonly { id: string }[]; - selectedMode: string; + effectiveModeId: string; selectedProvider: string | null; agentControls: { features?: Agent["features"] }; }; @@ -234,8 +215,7 @@ function buildDraftAgentSnapshot(input: { autoSubmitConfig?.thinkingOptionId ?? (composerState.effectiveThinkingOptionId || null); const modeId = resolveDraftModeId({ autoSubmitConfig, - modeOptionIds: composerState.modeOptions.map((mode) => mode.id), - selectedMode: composerState.selectedMode, + effectiveModeId: composerState.effectiveModeId, }); const provider = autoSubmitConfig?.provider ?? composerState.selectedProvider; if (!provider) { diff --git a/packages/app/src/provider-selection/provider-selection.test.ts b/packages/app/src/provider-selection/provider-selection.test.ts index 690bd439af..6c13ad3d83 100644 --- a/packages/app/src/provider-selection/provider-selection.test.ts +++ b/packages/app/src/provider-selection/provider-selection.test.ts @@ -9,6 +9,7 @@ import { buildSelectedTriggerLabel, filterAndRankModelRows, matchesModelSearch, + resolveEffectiveComposerModeId, resolveSelectedModelLabel, resolveSubmissionReadiness, } from "./provider-selection"; @@ -441,3 +442,56 @@ function getAllModelLabels(providers: ReturnType { + const selection = { + provider: "pi", + modelId: "", + thinkingOptionId: "", + availableModels: [], + }; + + it("drops a remembered mode when the provider offers no modes", () => { + expect( + resolveEffectiveComposerModeId({ ...selection, modeId: "build", modeOptions: [] }, false), + ).toBe(""); + }); + + it("submits the mode the picker shows when the remembered mode is not offered", () => { + expect( + resolveEffectiveComposerModeId( + { + ...selection, + modeId: "plan", + modeOptions: [ + { id: "build", label: "Build" }, + { id: "ask", label: "Ask" }, + ], + }, + false, + ), + ).toBe("build"); + }); + + it("keeps a selected mode the provider offers", () => { + expect( + resolveEffectiveComposerModeId( + { + ...selection, + modeId: "ask", + modeOptions: [ + { id: "build", label: "Build" }, + { id: "ask", label: "Ask" }, + ], + }, + false, + ), + ).toBe("ask"); + }); + + it("keeps the selected mode while the provider's modes are still loading", () => { + expect( + resolveEffectiveComposerModeId({ ...selection, modeId: "build", modeOptions: [] }, true), + ).toBe("build"); + }); +}); diff --git a/packages/app/src/provider-selection/provider-selection.ts b/packages/app/src/provider-selection/provider-selection.ts index ae00dd9528..f66a1b1476 100644 --- a/packages/app/src/provider-selection/provider-selection.ts +++ b/packages/app/src/provider-selection/provider-selection.ts @@ -272,6 +272,23 @@ export function resolveEffectiveComposerModelId(selection: ProviderSelectionStat ); } +// The mode picker shows the first offered mode when the selected one isn't +// offered (e.g. a remembered mode the provider no longer advertises), so the +// submitted mode must match that display. A provider with no modes gets none. +// While the provider is still loading its modes aren't known yet, so the +// selected mode is kept. +export function resolveEffectiveComposerModeId( + selection: ProviderSelectionState, + isModelLoading: boolean, +): string { + const selectedModeId = selection.modeId.trim(); + if (isModelLoading) { + return selectedModeId; + } + const modeIds = selection.modeOptions.map((mode) => mode.id); + return modeIds.includes(selectedModeId) ? selectedModeId : (modeIds[0] ?? ""); +} + export function resolveEffectiveComposerThinkingOptionId( selection: ProviderSelectionState, effectiveModelId: string, diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 1cf8d76f23..39c414d9e7 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -891,7 +891,7 @@ function buildWorkspaceDraftSetupFromComposer(input: { return { provider: input.provider, cwd: input.cwd, - modeId: input.composerState.selectedMode || null, + modeId: input.composerState.effectiveModeId || null, model: input.composerState.effectiveModelId || null, thinkingOptionId: input.composerState.effectiveThinkingOptionId || null, featureValues: input.composerState.featureValues ?? {}, @@ -971,7 +971,7 @@ async function createWorkspaceChatAgent(input: CreateChatAgentInput): Promise