Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
192 changes: 155 additions & 37 deletions apps/web/src/app/(app)/gastown/onboarding/OnboardingStepModel.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
Expand All @@ -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"
/>
</div>
))}
{!isCustom && (
<p className="text-center text-[10px] text-white/20">
Select &ldquo;Custom&rdquo; to change models
</p>
)}
<p className="text-center text-[10px] text-white/20">
Select &ldquo;Custom&rdquo; to change models
</p>
</div>
</motion.div>
);
}

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 (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
transition={{ duration: 0.2 }}
className="mt-4 overflow-hidden"
>
<div className="space-y-3 rounded-lg border border-white/[0.06] bg-white/[0.02] p-4">
{/* Primary default model */}
<div>
<label className="mb-1 block text-xs text-white/50">Default Model</label>
<ModelCombobox
label=""
models={modelOptions}
value={customDefault}
onValueChange={setCustomDefault}
isLoading={isLoadingModels}
placeholder="Select a model"
className="border-white/[0.08] bg-white/[0.03] text-sm text-white/85"
/>
</div>

{/* Per-role overrides — collapsible */}
<Accordion type="single" collapsible>
<AccordionItem value="roles" className="border-white/[0.06]">
<AccordionTrigger className="py-2 text-xs text-white/50 hover:text-white/70 hover:no-underline">
Override by role (optional)
</AccordionTrigger>
<AccordionContent className="space-y-2 pb-1 pt-1">
{roleRows.map(([label, value, setValue]) => (
<div key={label} className="flex items-center gap-2">
<span className="w-16 shrink-0 text-xs text-white/40">{label}</span>
<ModelCombobox
label=""
models={modelOptions}
value={value}
onValueChange={setValue}
isLoading={isLoadingModels}
placeholder="Use default"
className="flex-1 border-white/[0.08] bg-white/[0.03] text-sm text-white/85"
/>
{value && (
<button
type="button"
onClick={() => setValue('')}
className="shrink-0 text-white/30 hover:text-white/60"
>
<X className="size-3" />
</button>
)}
</div>
))}
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</motion.div>
);
Expand All @@ -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,
Expand All @@ -239,32 +331,42 @@ 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 {
mayor: 'kilo-auto/balanced',
refinery: 'kilo-auto/balanced',
polecat: 'kilo-auto/balanced',
};
}, [state.modelPreset, state.customModels]);
}, [state.modelPreset]);

const isCustom = state.modelPreset === 'custom';

function handlePresetSelect(preset: ModelPreset) {
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 (
Expand Down Expand Up @@ -292,15 +394,31 @@ export function OnboardingStepModel() {
<CustomCard isSelected={isCustom} onSelect={() => handlePresetSelect('custom')} />
</div>

{/* Always-visible model role pickers */}
<ModelRolePickers
models={currentModels}
isCustom={isCustom}
onUpdate={handleCustomUpdate}
modelOptions={modelOptions}
isLoadingModels={isLoadingModels}
modelsError={modelsError?.message}
/>
{/* Preset model role pickers (read-only) — shown when a preset is active */}
{!isCustom && (
<ModelRolePickers
models={currentPresetModels}
modelOptions={modelOptions}
isLoadingModels={isLoadingModels}
modelsError={modelsError?.message}
/>
)}

{/* Custom model picker — shown when custom is selected */}
{isCustom && (
<CustomModelPicker
customDefault={customDefault}
setCustomDefault={handleSetCustomDefault}
customMayor={customMayor}
setCustomMayor={handleSetCustomMayor}
customRefinery={customRefinery}
setCustomRefinery={handleSetCustomRefinery}
customPolecat={customPolecat}
setCustomPolecat={handleSetCustomPolecat}
modelOptions={modelOptions}
isLoadingModels={isLoadingModels}
/>
)}
</div>
</div>
);
Expand Down
27 changes: 21 additions & 6 deletions apps/web/src/app/(app)/gastown/onboarding/onboarding.domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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,
};
}

Expand All @@ -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<string, string>;
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;
}

// ---------------------------------------------------------------------------
Expand Down
15 changes: 13 additions & 2 deletions apps/web/src/app/(app)/gastown/onboarding/onboarding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand Down
6 changes: 3 additions & 3 deletions services/gastown/src/dos/town/pr-feedback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading