From d958e8711002d577622e910aa62ca80bf1697012 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Thu, 23 Apr 2026 00:40:01 +0000 Subject: [PATCH 1/3] feat(gastown): update town defaults, free-tier small_model, and custom model picker UI - Update TownConfigSchema defaults: staged_convoys_default=true, merge_strategy='pr', refinery.review_mode='comments', auto_resolve_pr_feedback=true, auto_merge_delay_minutes=5 - Set small_model='kilo-auto/free' when free preset is selected during onboarding - Add defaultModel and smallModel fields to CustomModels type - Rework custom model picker: primary Default Model selector + collapsible per-role overrides accordion - Preset role pickers now read-only (disabled) when a named preset is active --- .../onboarding/OnboardingStepModel.tsx | 192 ++++++++++++++---- .../gastown/onboarding/onboarding.domain.ts | 25 ++- services/gastown/src/types.ts | 10 +- 3 files changed, 179 insertions(+), 48 deletions(-) diff --git a/apps/web/src/app/(app)/gastown/onboarding/OnboardingStepModel.tsx b/apps/web/src/app/(app)/gastown/onboarding/OnboardingStepModel.tsx index 49ccf378da..32971315b6 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(''); + const [customMayor, setCustomMayor] = useState(''); + const [customRefinery, setCustomRefinery] = useState(''); + const [customPolecat, setCustomPolecat] = useState(''); + // 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..dad1b4c6f7 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,14 @@ 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'; return { - default_model: mayorModel, + default_model: customModels.defaultModel || undefined, role_models: { - mayor: mayorModel, - refinery: customModels.refinery ?? 'kilo-auto/balanced', - polecat: customModels.polecat ?? 'kilo-auto/balanced', + mayor: customModels.mayor || undefined, + refinery: customModels.refinery || undefined, + polecat: customModels.polecat || undefined, }, + small_model: customModels.smallModel || undefined, }; } @@ -133,10 +135,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/services/gastown/src/types.ts b/services/gastown/src/types.ts index 73b7bf604b..e78cf05de8 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,17 +279,17 @@ 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(), @@ -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) From 5cf21bab6d662d8386cf5ad7a7020062e8837bb0 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Thu, 23 Apr 2026 00:49:13 +0000 Subject: [PATCH 2/3] =?UTF-8?q?fix(gastown):=20address=20review=20feedback?= =?UTF-8?q?=20=E2=80=94=20refinery=20defaults,=20custom=20model=20remount,?= =?UTF-8?q?=20and=20empty-model=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gastown/onboarding/OnboardingStepModel.tsx | 8 ++++---- .../(app)/gastown/onboarding/onboarding.domain.ts | 9 +++++---- .../app/(app)/gastown/onboarding/onboarding.test.ts | 13 ++++++++++++- services/gastown/src/dos/town/pr-feedback.test.ts | 6 +++--- services/gastown/src/types.ts | 2 +- 5 files changed, 25 insertions(+), 13 deletions(-) diff --git a/apps/web/src/app/(app)/gastown/onboarding/OnboardingStepModel.tsx b/apps/web/src/app/(app)/gastown/onboarding/OnboardingStepModel.tsx index 32971315b6..59359c5fbd 100644 --- a/apps/web/src/app/(app)/gastown/onboarding/OnboardingStepModel.tsx +++ b/apps/web/src/app/(app)/gastown/onboarding/OnboardingStepModel.tsx @@ -314,10 +314,10 @@ function CustomModelPicker({ export function OnboardingStepModel() { const { state, setModelPreset, setCustomModels } = useOnboarding(); - const [customDefault, setCustomDefault] = useState(''); - const [customMayor, setCustomMayor] = useState(''); - const [customRefinery, setCustomRefinery] = useState(''); - const [customPolecat, setCustomPolecat] = useState(''); + 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 { 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 dad1b4c6f7..05cd3f267e 100644 --- a/apps/web/src/app/(app)/gastown/onboarding/onboarding.domain.ts +++ b/apps/web/src/app/(app)/gastown/onboarding/onboarding.domain.ts @@ -112,12 +112,13 @@ 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 fallback = 'kilo-auto/balanced'; return { - default_model: customModels.defaultModel || undefined, + default_model: customModels.defaultModel || fallback, role_models: { - mayor: customModels.mayor || undefined, - refinery: customModels.refinery || undefined, - polecat: customModels.polecat || undefined, + mayor: customModels.mayor || fallback, + refinery: customModels.refinery || fallback, + polecat: customModels.polecat || fallback, }, small_model: customModels.smallModel || undefined, }; 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..56bb67533a 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', @@ -217,7 +218,7 @@ describe('presetToConfig', () => { }); test('uses kilo-auto/balanced for partially-specified custom models', () => { - const config = presetToConfig('custom', { mayor: 'openai/gpt-4.1' }); + 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', @@ -226,6 +227,16 @@ describe('presetToConfig', () => { }); }); + 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', + polecat: 'kilo-auto/balanced', + }); + }); + test('returns fallback for unknown preset key', () => { const config = presetToConfig('nonexistent' as ModelPreset, {}); expect(config.default_model).toBe('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 e78cf05de8..60c1ecf67e 100644 --- a/services/gastown/src/types.ts +++ b/services/gastown/src/types.ts @@ -291,7 +291,7 @@ export const TownConfigSchema = z.object({ * 0 = immediate, null = disabled (require manual merge). */ 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(), From 69554b5db9b04bcc9b2fac40ae9276cdb65e468f Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Thu, 23 Apr 2026 00:53:11 +0000 Subject: [PATCH 3/3] fix(gastown): use defaultModel as fallback for unset custom role overrides Empty per-role selectors (labeled 'Use default') now fall back to the selected defaultModel instead of hardcoded 'kilo-auto/balanced'. A user who picks Default Model = openai/gpt-4.1 but leaves the role overrides blank will get that model for all roles rather than kilo-auto/balanced. Update test expectations to match the corrected behaviour. --- .../app/(app)/gastown/onboarding/onboarding.domain.ts | 9 +++++---- .../src/app/(app)/gastown/onboarding/onboarding.test.ts | 6 +++--- 2 files changed, 8 insertions(+), 7 deletions(-) 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 05cd3f267e..2d68b45f9a 100644 --- a/apps/web/src/app/(app)/gastown/onboarding/onboarding.domain.ts +++ b/apps/web/src/app/(app)/gastown/onboarding/onboarding.domain.ts @@ -113,12 +113,13 @@ export const PRESETS: PresetConfig[] = [ export function presetToConfig(preset: ModelPreset, customModels: CustomModels) { if (preset === 'custom') { const fallback = 'kilo-auto/balanced'; + const defaultModel = customModels.defaultModel || fallback; return { - default_model: customModels.defaultModel || fallback, + default_model: defaultModel, role_models: { - mayor: customModels.mayor || fallback, - refinery: customModels.refinery || fallback, - polecat: customModels.polecat || fallback, + mayor: customModels.mayor || defaultModel, + refinery: customModels.refinery || defaultModel, + polecat: customModels.polecat || defaultModel, }, small_model: customModels.smallModel || undefined, }; 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 56bb67533a..f709654fa3 100644 --- a/apps/web/src/app/(app)/gastown/onboarding/onboarding.test.ts +++ b/apps/web/src/app/(app)/gastown/onboarding/onboarding.test.ts @@ -217,13 +217,13 @@ describe('presetToConfig', () => { }); }); - test('uses kilo-auto/balanced for partially-specified custom models', () => { + 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: 'kilo-auto/balanced', - polecat: 'kilo-auto/balanced', + refinery: 'openai/gpt-4.1', + polecat: 'openai/gpt-4.1', }); });