diff --git a/apps/web/src/app/(app)/gastown/onboarding/OnboardingStepModel.tsx b/apps/web/src/app/(app)/gastown/onboarding/OnboardingStepModel.tsx index 49ccf378da..59359c5fbd 100644 --- a/apps/web/src/app/(app)/gastown/onboarding/OnboardingStepModel.tsx +++ b/apps/web/src/app/(app)/gastown/onboarding/OnboardingStepModel.tsx @@ -1,9 +1,16 @@ 'use client'; -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { motion } from 'motion/react'; +import { X } from 'lucide-react'; import { cn } from '@/lib/utils'; import { ModelCombobox, type ModelOption } from '@/components/shared/ModelCombobox'; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from '@/components/ui/accordion'; import { useModelSelectorList } from '@/app/api/openrouter/hooks'; import { useOnboarding } from './OnboardingContext'; import { PRESETS } from './onboarding.domain'; @@ -171,15 +178,11 @@ const ROLES = [ function ModelRolePickers({ models, - isCustom, - onUpdate, modelOptions, isLoadingModels, modelsError, }: { models: { mayor: string; refinery: string; polecat: string }; - isCustom: boolean; - onUpdate: (models: { mayor?: string; refinery?: string; polecat?: string }) => void; modelOptions: ModelOption[]; isLoadingModels: boolean; modelsError: string | undefined; @@ -202,23 +205,107 @@ function ModelRolePickers({ label="" models={modelOptions} value={models[key]} - onValueChange={value => onUpdate({ ...models, [key]: value })} + onValueChange={() => {}} isLoading={isLoadingModels} error={modelsError} placeholder="Select a model" - disabled={!isCustom} - className={cn( - 'border-white/[0.08] bg-white/[0.03] text-sm text-white/85', - !isCustom && 'opacity-70' - )} + disabled + className="border-white/[0.08] bg-white/[0.03] text-sm text-white/85 opacity-70" /> ))} - {!isCustom && ( -

- Select “Custom” to change models -

- )} +

+ Select “Custom” to change models +

+ + + ); +} + +function CustomModelPicker({ + customDefault, + setCustomDefault, + customMayor, + setCustomMayor, + customRefinery, + setCustomRefinery, + customPolecat, + setCustomPolecat, + modelOptions, + isLoadingModels, +}: { + customDefault: string; + setCustomDefault: (v: string) => void; + customMayor: string; + setCustomMayor: (v: string) => void; + customRefinery: string; + setCustomRefinery: (v: string) => void; + customPolecat: string; + setCustomPolecat: (v: string) => void; + modelOptions: ModelOption[]; + isLoadingModels: boolean; +}) { + const roleRows: [string, string, (v: string) => void][] = [ + ['Mayor', customMayor, setCustomMayor], + ['Refinery', customRefinery, setCustomRefinery], + ['Polecat', customPolecat, setCustomPolecat], + ]; + + return ( + +
+ {/* Primary default model */} +
+ + +
+ + {/* Per-role overrides — collapsible */} + + + + Override by role (optional) + + + {roleRows.map(([label, value, setValue]) => ( +
+ {label} + + {value && ( + + )} +
+ ))} +
+
+
); @@ -227,6 +314,11 @@ function ModelRolePickers({ export function OnboardingStepModel() { const { state, setModelPreset, setCustomModels } = useOnboarding(); + const [customDefault, setCustomDefault] = useState(state.customModels.defaultModel ?? ''); + const [customMayor, setCustomMayor] = useState(state.customModels.mayor ?? ''); + const [customRefinery, setCustomRefinery] = useState(state.customModels.refinery ?? ''); + const [customPolecat, setCustomPolecat] = useState(state.customModels.polecat ?? ''); + // Fetch available models for the Custom picker (no org context during onboarding) const { data: modelsData, @@ -239,15 +331,8 @@ export function OnboardingStepModel() { [modelsData] ); - // Resolve current models for display: preset values or custom overrides - const currentModels = useMemo(() => { - if (state.modelPreset === 'custom') { - return { - mayor: state.customModels.mayor ?? 'kilo-auto/balanced', - refinery: state.customModels.refinery ?? 'kilo-auto/balanced', - polecat: state.customModels.polecat ?? 'kilo-auto/balanced', - }; - } + // Resolve current models for display in the read-only preset role picker + const currentPresetModels = useMemo(() => { const preset = PRESETS.find(p => p.key === state.modelPreset); if (preset) return preset.models; return { @@ -255,7 +340,7 @@ export function OnboardingStepModel() { refinery: 'kilo-auto/balanced', polecat: 'kilo-auto/balanced', }; - }, [state.modelPreset, state.customModels]); + }, [state.modelPreset]); const isCustom = state.modelPreset === 'custom'; @@ -263,8 +348,25 @@ export function OnboardingStepModel() { setModelPreset(preset); } - function handleCustomUpdate(models: { mayor?: string; refinery?: string; polecat?: string }) { - setCustomModels(models); + // Sync custom model state up to context whenever any field changes + function handleSetCustomDefault(value: string) { + setCustomDefault(value); + setCustomModels({ defaultModel: value, mayor: customMayor, refinery: customRefinery, polecat: customPolecat }); + } + + function handleSetCustomMayor(value: string) { + setCustomMayor(value); + setCustomModels({ defaultModel: customDefault, mayor: value, refinery: customRefinery, polecat: customPolecat }); + } + + function handleSetCustomRefinery(value: string) { + setCustomRefinery(value); + setCustomModels({ defaultModel: customDefault, mayor: customMayor, refinery: value, polecat: customPolecat }); + } + + function handleSetCustomPolecat(value: string) { + setCustomPolecat(value); + setCustomModels({ defaultModel: customDefault, mayor: customMayor, refinery: customRefinery, polecat: value }); } return ( @@ -292,15 +394,31 @@ export function OnboardingStepModel() { handlePresetSelect('custom')} /> - {/* Always-visible model role pickers */} - + {/* Preset model role pickers (read-only) — shown when a preset is active */} + {!isCustom && ( + + )} + + {/* Custom model picker — shown when custom is selected */} + {isCustom && ( + + )} ); diff --git a/apps/web/src/app/(app)/gastown/onboarding/onboarding.domain.ts b/apps/web/src/app/(app)/gastown/onboarding/onboarding.domain.ts index 107214e8c6..2d68b45f9a 100644 --- a/apps/web/src/app/(app)/gastown/onboarding/onboarding.domain.ts +++ b/apps/web/src/app/(app)/gastown/onboarding/onboarding.domain.ts @@ -43,9 +43,11 @@ export function resolveGitUrlFromRepo( export type ModelPreset = 'frontier' | 'balanced' | 'cost-effective' | 'free' | 'custom'; export type CustomModels = { + defaultModel?: string; mayor?: string; refinery?: string; polecat?: string; + smallModel?: string; }; export type PresetConfig = { @@ -110,14 +112,16 @@ export const PRESETS: PresetConfig[] = [ /** Derive the config shape stored in OnboardingState from a preset. */ export function presetToConfig(preset: ModelPreset, customModels: CustomModels) { if (preset === 'custom') { - const mayorModel = customModels.mayor ?? 'kilo-auto/balanced'; + const fallback = 'kilo-auto/balanced'; + const defaultModel = customModels.defaultModel || fallback; return { - default_model: mayorModel, + default_model: defaultModel, role_models: { - mayor: mayorModel, - refinery: customModels.refinery ?? 'kilo-auto/balanced', - polecat: customModels.polecat ?? 'kilo-auto/balanced', + mayor: customModels.mayor || defaultModel, + refinery: customModels.refinery || defaultModel, + polecat: customModels.polecat || defaultModel, }, + small_model: customModels.smallModel || undefined, }; } @@ -133,10 +137,21 @@ export function presetToConfig(preset: ModelPreset, customModels: CustomModels) if (refinery !== mayor) role_models.refinery = refinery; if (polecat !== mayor) role_models.polecat = polecat; - return { + const config: { + default_model: string; + role_models: Record; + small_model?: string; + } = { default_model: mayor, role_models, }; + + // When the free preset is selected, also set small_model to the same free model + if (mayor === 'kilo-auto/free') { + config.small_model = 'kilo-auto/free'; + } + + return config; } // --------------------------------------------------------------------------- diff --git a/apps/web/src/app/(app)/gastown/onboarding/onboarding.test.ts b/apps/web/src/app/(app)/gastown/onboarding/onboarding.test.ts index 6a1a28dc0e..f709654fa3 100644 --- a/apps/web/src/app/(app)/gastown/onboarding/onboarding.test.ts +++ b/apps/web/src/app/(app)/gastown/onboarding/onboarding.test.ts @@ -194,6 +194,7 @@ describe('presetToConfig', () => { test('returns custom config with provided models', () => { const config = presetToConfig('custom', { + defaultModel: 'openai/gpt-4.1', mayor: 'openai/gpt-4.1', refinery: 'anthropic/claude-opus-4', polecat: 'openai/gpt-4.1-mini', @@ -216,9 +217,19 @@ describe('presetToConfig', () => { }); }); - test('uses kilo-auto/balanced for partially-specified custom models', () => { - const config = presetToConfig('custom', { mayor: 'openai/gpt-4.1' }); + test('uses defaultModel as fallback for unset role overrides', () => { + const config = presetToConfig('custom', { defaultModel: 'openai/gpt-4.1', mayor: 'openai/gpt-4.1' }); expect(config.default_model).toBe('openai/gpt-4.1'); + expect(config.role_models).toEqual({ + mayor: 'openai/gpt-4.1', + refinery: 'openai/gpt-4.1', + polecat: 'openai/gpt-4.1', + }); + }); + + test('uses kilo-auto/balanced for default_model when no defaultModel specified', () => { + const config = presetToConfig('custom', { mayor: 'openai/gpt-4.1' }); + expect(config.default_model).toBe('kilo-auto/balanced'); expect(config.role_models).toEqual({ mayor: 'openai/gpt-4.1', refinery: 'kilo-auto/balanced', diff --git a/services/gastown/src/dos/town/pr-feedback.test.ts b/services/gastown/src/dos/town/pr-feedback.test.ts index 4320437188..9f62c30c02 100644 --- a/services/gastown/src/dos/town/pr-feedback.test.ts +++ b/services/gastown/src/dos/town/pr-feedback.test.ts @@ -16,12 +16,12 @@ describe('TownConfigSchema refinery extensions', () => { expect(config.refinery?.code_review).toBe(false); }); - it('defaults auto_resolve_pr_feedback to false', () => { + it('defaults auto_resolve_pr_feedback to true', () => { const config = TownConfigSchema.parse({}); - expect(config.refinery).toBeUndefined(); + expect(config.refinery?.auto_resolve_pr_feedback).toBe(true); const configWithRefinery = TownConfigSchema.parse({ refinery: {} }); - expect(configWithRefinery.refinery?.auto_resolve_pr_feedback).toBe(false); + expect(configWithRefinery.refinery?.auto_resolve_pr_feedback).toBe(true); }); it('defaults auto_merge_delay_minutes to null', () => { diff --git a/services/gastown/src/types.ts b/services/gastown/src/types.ts index 73b7bf604b..60c1ecf67e 100644 --- a/services/gastown/src/types.ts +++ b/services/gastown/src/types.ts @@ -264,7 +264,7 @@ export const TownConfigSchema = z.object({ * - 'direct': Refinery pushes directly to main (no PR) * - 'pr': Refinery creates a GitHub PR / GitLab MR for human review */ - merge_strategy: MergeStrategy.default('direct'), + merge_strategy: MergeStrategy.default('pr'), /** Refinery configuration */ refinery: z @@ -279,19 +279,19 @@ export const TownConfigSchema = z.object({ /** Controls how the refinery communicates review findings: * - 'rework': creates internal rework beads via gt_request_changes (default) * - 'comments': posts GitHub review comments on the PR (requires merge_strategy: 'pr') */ - review_mode: z.enum(['rework', 'comments']).default('rework'), + review_mode: z.enum(['rework', 'comments']).default('comments'), /** When enabled, a polecat is automatically dispatched to address * unresolved review comments and failing CI checks on open PRs. */ - auto_resolve_pr_feedback: z.boolean().default(false), + auto_resolve_pr_feedback: z.boolean().default(true), /** When enabled, a polecat is automatically dispatched to rebase and * resolve merge conflicts on open PRs. */ auto_resolve_merge_conflicts: z.boolean().default(true).optional(), /** After all CI checks pass and all review threads are resolved, * automatically merge the PR after this many minutes. * 0 = immediate, null = disabled (require manual merge). */ - auto_merge_delay_minutes: z.number().int().min(0).nullable().default(null), + auto_merge_delay_minutes: z.number().int().min(0).nullable().default(5), }) - .optional(), + .default({}), /** Alarm interval when agents are active (seconds) */ alarm_interval_active: z.number().int().min(5).max(600).optional(), @@ -307,7 +307,7 @@ export const TownConfigSchema = z.object({ .optional(), /** When true, all convoys are created as staged by default (agents not dispatched until started). */ - staged_convoys_default: z.boolean().default(false), + staged_convoys_default: z.boolean().default(true), /** Default merge mode for new convoys. * - 'review-then-land': beads merge into a convoy feature branch, then a single landing PR is created (default)