feat(KB-184): add model presets for task creation

- Add model preset types and settings fields with API validation
- Create model preset utility helpers for size-based auto-selection
- Add settings UI for managing model presets (create, edit, delete)
- Integrate preset selection into NewTaskModal with custom override option
- Add inline preset selection to InlineCreateCard component
- Persist modelPresetId in task store and preserve through archive/unarchive
- Add changeset and update AGENTS.md with feature documentation
This commit is contained in:
gsxdsm
2026-03-31 02:04:45 -07:00
parent 82d8ebb82b
commit 714f4bc415
17 changed files with 959 additions and 43 deletions

View File

@@ -99,6 +99,7 @@ export function createTask(input: TaskCreateInput): Promise<Task> {
column,
dependencies,
breakIntoSubtasks,
modelPresetId,
modelProvider,
modelId,
validatorModelProvider,
@@ -113,6 +114,7 @@ export function createTask(input: TaskCreateInput): Promise<Task> {
column,
dependencies,
breakIntoSubtasks,
modelPresetId,
modelProvider,
modelId,
validatorModelProvider,

View File

@@ -1,10 +1,11 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { Brain, Link, Lightbulb, ListTree } from "lucide-react";
import type { Task, TaskCreateInput } from "@kb/core";
import { Brain, Link, Lightbulb, ListTree, Zap } from "lucide-react";
import type { Task, TaskCreateInput, Settings } from "@kb/core";
import type { ToastType } from "../hooks/useToast";
import { fetchModels, uploadAttachment } from "../api";
import { fetchModels, uploadAttachment, fetchSettings } from "../api";
import type { ModelInfo } from "../api";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { applyPresetToSelection } from "../utils/modelPresets";
const ALLOWED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
@@ -68,6 +69,9 @@ export function InlineCreateCard({
const [showDeps, setShowDeps] = useState(false);
const [depSearch, setDepSearch] = useState("");
const [showModels, setShowModels] = useState(false);
const [showPresets, setShowPresets] = useState(false);
const [settings, setSettings] = useState<Settings | null>(null);
const [selectedPresetId, setSelectedPresetId] = useState<string | undefined>(undefined);
const [executorProvider, setExecutorProvider] = useState<string | undefined>(undefined);
const [executorModelId, setExecutorModelId] = useState<string | undefined>(undefined);
const [validatorProvider, setValidatorProvider] = useState<string | undefined>(undefined);
@@ -136,6 +140,18 @@ export function InlineCreateCard({
}
});
fetchSettings()
.then((nextSettings) => {
if (!cancelled) {
setSettings(nextSettings);
}
})
.catch(() => {
if (!cancelled) {
setSettings(null);
}
});
return () => {
cancelled = true;
};
@@ -143,6 +159,8 @@ export function InlineCreateCard({
const executorSelectionValue = getModelSelectionValue(executorProvider, executorModelId);
const validatorSelectionValue = getModelSelectionValue(validatorProvider, validatorModelId);
const availablePresets = settings?.modelPresets || [];
const selectedPreset = availablePresets.find((preset) => preset.id === selectedPresetId);
const hasExecutorOverride = Boolean(executorProvider && executorModelId);
const hasValidatorOverride = Boolean(validatorProvider && validatorModelId);
@@ -172,7 +190,8 @@ export function InlineCreateCard({
!hasExecutorOverride &&
!hasValidatorOverride &&
!showDeps &&
!showModels
!showModels &&
!showPresets
) {
onCancel();
}
@@ -187,6 +206,7 @@ export function InlineCreateCard({
hasValidatorOverride,
showDeps,
showModels,
showPresets,
onCancel,
]);
@@ -239,6 +259,7 @@ export function InlineCreateCard({
description: description.trim(),
column: "triage",
dependencies: dependencies.length ? dependencies : undefined,
modelPresetId: selectedPresetId,
modelProvider: hasExecutorOverride ? executorProvider : undefined,
modelId: hasExecutorOverride ? executorModelId : undefined,
validatorModelProvider: hasValidatorOverride ? validatorProvider : undefined,
@@ -264,6 +285,7 @@ export function InlineCreateCard({
pendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl));
setPendingImages([]);
setSelectedPresetId(undefined);
addToast(`Created ${task.id}`, "success");
} catch (err: any) {
addToast(err.message, "error");
@@ -359,8 +381,10 @@ export function InlineCreateCard({
setExecutorModelId(undefined);
setValidatorProvider(undefined);
setValidatorModelId(undefined);
setSelectedPresetId(undefined);
setShowDeps(false);
setShowModels(false);
setShowPresets(false);
}, [description, onPlanningMode, addToast]);
const handleSubtaskClick = useCallback(() => {
@@ -377,8 +401,10 @@ export function InlineCreateCard({
setExecutorModelId(undefined);
setValidatorProvider(undefined);
setValidatorModelId(undefined);
setSelectedPresetId(undefined);
setShowDeps(false);
setShowModels(false);
setShowPresets(false);
}, [description, onSubtaskBreakdown, addToast]);
const truncate = (s: string, len: number) =>
@@ -478,6 +504,70 @@ export function InlineCreateCard({
</div>
<div className="inline-create-model-wrap">
<button
type="button"
className="btn btn-sm inline-create-model-trigger"
onClick={() => {
setShowPresets((prev) => {
const next = !prev;
if (next) {
setShowDeps(false);
setShowModels(false);
}
return next;
});
}}
aria-expanded={showPresets}
aria-haspopup="listbox"
>
<Zap size={12} style={{ verticalAlign: "middle" }} />
{selectedPreset ? ` ${selectedPreset.name}` : " Preset"}
</button>
{showPresets && (
<div className="inline-create-model-dropdown" onMouseDown={handleModelDropdownMouseDown}>
<button
type="button"
className="btn btn-sm"
onClick={() => {
setSelectedPresetId(undefined);
setExecutorProvider(undefined);
setExecutorModelId(undefined);
setValidatorProvider(undefined);
setValidatorModelId(undefined);
setShowPresets(false);
}}
>
Use default
</button>
{availablePresets.map((preset) => (
<button
key={preset.id}
type="button"
className="btn btn-sm"
onClick={() => {
const selection = applyPresetToSelection(preset);
const executor = parseModelSelection(selection.executorValue);
const validator = parseModelSelection(selection.validatorValue);
setSelectedPresetId(preset.id);
setExecutorProvider(executor.provider);
setExecutorModelId(executor.modelId);
setValidatorProvider(validator.provider);
setValidatorModelId(validator.modelId);
setShowPresets(false);
}}
>
{preset.name}
</button>
))}
<button
type="button"
className="btn btn-sm"
onClick={() => setShowPresets(false)}
>
Custom
</button>
</div>
)}
<button
type="button"
className="btn btn-sm inline-create-model-trigger"
@@ -486,9 +576,11 @@ export function InlineCreateCard({
aria-haspopup="dialog"
>
<Brain size={12} style={{ verticalAlign: "middle" }} />
{selectedModelCount > 0
? ` ${selectedModelCount} model${selectedModelCount === 1 ? "" : "s"}`
: " Models"}
{selectedPreset
? ` ${selectedPreset.name} · ${selectedModelCount} model${selectedModelCount === 1 ? "" : "s"}`
: selectedModelCount > 0
? ` ${selectedModelCount} model${selectedModelCount === 1 ? "" : "s"}`
: " Models"}
</button>
{showModels && (
<div

View File

@@ -1,9 +1,10 @@
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
import type { Task, TaskCreateInput } from "@kb/core";
import type { Task, TaskCreateInput, ModelPreset, Settings } from "@kb/core";
import type { ToastType } from "../hooks/useToast";
import { uploadAttachment, fetchModels, updateTask } from "../api";
import { uploadAttachment, fetchModels, fetchSettings } from "../api";
import type { ModelInfo } from "../api";
import { filterModels } from "../utils/modelFilter";
import { applyPresetToSelection, getRecommendedPresetForSize } from "../utils/modelPresets";
import { ProviderIcon } from "./ProviderIcon";
const ALLOWED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
@@ -311,6 +312,9 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
const [modelsLoading, setModelsLoading] = useState(false);
const [executorModel, setExecutorModel] = useState("");
const [validatorModel, setValidatorModel] = useState("");
const [settings, setSettings] = useState<Settings | null>(null);
const [selectedPresetId, setSelectedPresetId] = useState<string>("");
const [presetMode, setPresetMode] = useState<"default" | "preset" | "custom">("default");
const [enablePlanningMode, setEnablePlanningMode] = useState(false);
const [hasDirtyState, setHasDirtyState] = useState(false);
@@ -326,6 +330,9 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
.then((models) => setAvailableModels(models))
.catch(() => {/* silently fail - models just won't be available */})
.finally(() => setModelsLoading(false));
fetchSettings()
.then((nextSettings) => setSettings(nextSettings))
.catch(() => setSettings(null));
}
}, [isOpen]);
@@ -341,6 +348,21 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
setHasDirtyState(isDirty);
}, [description, dependencies, pendingImages, executorModel, validatorModel, enablePlanningMode]);
const availablePresets = settings?.modelPresets || [];
const selectedPreset = availablePresets.find((preset) => preset.id === selectedPresetId);
useEffect(() => {
if (!isOpen || !settings?.autoSelectModelPreset) return;
const recommended = getRecommendedPresetForSize(undefined, settings.defaultPresetBySize || {}, availablePresets);
if (recommended) {
const selection = applyPresetToSelection(recommended);
setSelectedPresetId(recommended.id);
setPresetMode("preset");
setExecutorModel(selection.executorValue);
setValidatorModel(selection.validatorValue);
}
}, [isOpen, settings, availablePresets]);
// Auto-focus description textarea when modal opens
useEffect(() => {
if (isOpen) {
@@ -430,6 +452,8 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
setDependencies([]);
setExecutorModel("");
setValidatorModel("");
setSelectedPresetId("");
setPresetMode("default");
setEnablePlanningMode(false);
setHasDirtyState(false);
onClose();
@@ -452,6 +476,8 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
setDependencies([]);
setExecutorModel("");
setValidatorModel("");
setSelectedPresetId("");
setPresetMode("default");
setEnablePlanningMode(false);
// Close modal and trigger planning mode
@@ -466,11 +492,19 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
setIsSubmitting(true);
try {
// Create the base task
const executorSlashIdx = executorModel.indexOf("/");
const validatorSlashIdx = validatorModel.indexOf("/");
const task = await onCreateTask({
title: undefined,
description: trimmedDesc,
column: "triage",
dependencies: dependencies.length ? dependencies : undefined,
modelPresetId: presetMode === "preset" ? selectedPresetId || undefined : undefined,
modelProvider: executorModel && executorSlashIdx !== -1 ? executorModel.slice(0, executorSlashIdx) : undefined,
modelId: executorModel && executorSlashIdx !== -1 ? executorModel.slice(executorSlashIdx + 1) : undefined,
validatorModelProvider: validatorModel && validatorSlashIdx !== -1 ? validatorModel.slice(0, validatorSlashIdx) : undefined,
validatorModelId: validatorModel && validatorSlashIdx !== -1 ? validatorModel.slice(validatorSlashIdx + 1) : undefined,
});
// Upload pending images as attachments
@@ -488,28 +522,6 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
}
}
// Update task with model settings if specified
const executorSlashIdx = executorModel.indexOf("/");
const validatorSlashIdx = validatorModel.indexOf("/");
if (executorModel || validatorModel) {
const updates: Parameters<typeof updateTask>[1] = {};
if (executorModel && executorSlashIdx !== -1) {
updates.modelProvider = executorModel.slice(0, executorSlashIdx);
updates.modelId = executorModel.slice(executorSlashIdx + 1);
}
if (validatorModel && validatorSlashIdx !== -1) {
updates.validatorModelProvider = validatorModel.slice(0, validatorSlashIdx);
updates.validatorModelId = validatorModel.slice(validatorSlashIdx + 1);
}
if (Object.keys(updates).length > 0) {
await updateTask(task.id, updates);
}
}
// Clean up
pendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl));
setPendingImages([]);
@@ -517,6 +529,8 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
setDependencies([]);
setExecutorModel("");
setValidatorModel("");
setSelectedPresetId("");
setPresetMode("default");
setEnablePlanningMode(false);
addToast(`Created ${task.id}`, "success");
@@ -663,15 +677,68 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
<small>No models available. Configure authentication in Settings.</small>
) : (
<>
<div className="model-select-row">
<label htmlFor="model-preset" className="model-select-label">Preset</label>
<select
id="model-preset"
value={presetMode === "preset" ? selectedPresetId : presetMode}
onChange={(e) => {
const value = e.target.value;
if (value === "default") {
setPresetMode("default");
setSelectedPresetId("");
setExecutorModel("");
setValidatorModel("");
return;
}
if (value === "custom") {
setPresetMode("custom");
setSelectedPresetId("");
return;
}
const preset = availablePresets.find((entry) => entry.id === value);
const selection = applyPresetToSelection(preset);
setPresetMode("preset");
setSelectedPresetId(value);
setExecutorModel(selection.executorValue);
setValidatorModel(selection.validatorValue);
}}
disabled={isSubmitting}
>
<option value="default">Use default</option>
{availablePresets.length > 0 ? <option disabled></option> : null}
{availablePresets.map((preset) => (
<option key={preset.id} value={preset.id}>{preset.name}</option>
))}
<option value="custom">Custom</option>
</select>
</div>
{presetMode === "preset" && selectedPreset ? (
<small>Using preset: {selectedPreset.name}</small>
) : null}
{presetMode === "preset" ? (
<button
type="button"
className="btn btn-sm"
onClick={() => setPresetMode("custom")}
disabled={isSubmitting}
>
Override
</button>
) : null}
<div className="model-select-row">
<label htmlFor="executor-model" className="model-select-label">Executor</label>
<ModelCombobox
id="executor-model"
label="Executor Model"
value={executorModel}
onChange={setExecutorModel}
onChange={(value) => {
setPresetMode("custom");
setSelectedPresetId("");
setExecutorModel(value);
}}
models={availableModels}
disabled={isSubmitting}
disabled={isSubmitting || presetMode === "preset"}
/>
</div>
<div className="model-select-row">
@@ -680,9 +747,13 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
id="validator-model"
label="Validator Model"
value={validatorModel}
onChange={setValidatorModel}
onChange={(value) => {
setPresetMode("custom");
setSelectedPresetId("");
setValidatorModel(value);
}}
models={availableModels}
disabled={isSubmitting}
disabled={isSubmitting || presetMode === "preset"}
/>
</div>
</>

View File

@@ -1,11 +1,12 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { THINKING_LEVELS } from "@kb/core";
import type { Settings, ThemeMode, ColorTheme } from "@kb/core";
import type { Settings, ThemeMode, ColorTheme, ModelPreset } from "@kb/core";
import { fetchSettings, updateSettings, fetchAuthStatus, loginProvider, logoutProvider, fetchModels, testNtfyNotification } from "../api";
import type { AuthProvider, ModelInfo } from "../api";
import type { ToastType } from "../hooks/useToast";
import { ThemeSelector } from "./ThemeSelector";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { applyPresetToSelection, generatePresetId, validatePresetId } from "../utils/modelPresets";
/**
* Settings sections configuration.
@@ -29,6 +30,7 @@ import { CustomModelDropdown } from "./CustomModelDropdown";
const SETTINGS_SECTIONS = [
{ id: "general", label: "General" },
{ id: "model", label: "Model" },
{ id: "model-presets", label: "Model Presets" },
{ id: "appearance", label: "Appearance" },
{ id: "scheduling", label: "Scheduling" },
{ id: "worktrees", label: "Worktrees" },
@@ -81,6 +83,9 @@ export function SettingsModal({
// Test notification state
const [testNotificationLoading, setTestNotificationLoading] = useState(false);
const [editingPresetId, setEditingPresetId] = useState<string | null>(null);
const [presetDraft, setPresetDraft] = useState<ModelPreset | null>(null);
const [presetIdTouched, setPresetIdTouched] = useState(false);
useEffect(() => {
fetchSettings()
@@ -208,7 +213,7 @@ export function SettingsModal({
);
const handleSave = useCallback(async () => {
if (prefixError) return;
if (prefixError || presetDraft) return;
try {
const payload = {
...form,
@@ -223,6 +228,44 @@ export function SettingsModal({
}
}, [form, prefixError, onClose, addToast]);
const savePresetDraft = () => {
if (!presetDraft) return;
const nextId = presetDraft.id.trim();
const nextName = presetDraft.name.trim();
if (!nextName || !nextId || !validatePresetId(nextId)) {
addToast("Preset name is required and ID must be 132 letters, numbers, hyphens, or underscores", "error");
return;
}
const presets = form.modelPresets || [];
if (presets.some((preset) => preset.id === nextId && preset.id !== editingPresetId)) {
addToast("Preset ID must be unique", "error");
return;
}
const normalizedDraft: ModelPreset = {
id: nextId,
name: nextName,
executorProvider: presetDraft.executorProvider,
executorModelId: presetDraft.executorModelId,
validatorProvider: presetDraft.validatorProvider,
validatorModelId: presetDraft.validatorModelId,
};
setForm((current) => {
const existing = current.modelPresets || [];
const nextPresets = editingPresetId
? existing.map((preset) => (preset.id === editingPresetId ? normalizedDraft : preset))
: [...existing, normalizedDraft];
return { ...current, modelPresets: nextPresets };
});
setEditingPresetId(null);
setPresetDraft(null);
setPresetIdTouched(false);
};
const renderSectionFields = () => {
switch (activeSection) {
case "general":
@@ -387,6 +430,223 @@ export function SettingsModal({
</>
);
}
case "model-presets": {
const presets = form.modelPresets || [];
const presetOptions = presets.map((preset) => ({ id: preset.id, name: preset.name }));
const inUsePresetIds = new Set(Object.values(form.defaultPresetBySize || {}).filter(Boolean));
return (
<>
<h4 className="settings-section-heading">Model Presets</h4>
<div className="form-group">
<label>Configured presets</label>
{presets.length === 0 ? (
<div className="settings-empty-state settings-muted">No presets configured yet.</div>
) : (
<div className="settings-preset-list">
{presets.map((preset) => {
const selection = applyPresetToSelection(preset);
const summary = `${selection.executorValue || "default"} / ${selection.validatorValue || "default"}`;
return (
<div key={preset.id} className="auth-provider-row">
<div className="auth-provider-info">
<strong>{preset.name}</strong>
<span className="settings-muted">{summary}</span>
</div>
<div>
<button
type="button"
className="btn btn-sm"
onClick={() => {
setEditingPresetId(preset.id);
setPresetDraft({ ...preset });
setPresetIdTouched(true);
}}
>
Edit
</button>
<button
type="button"
className="btn btn-sm"
onClick={() => {
if (inUsePresetIds.has(preset.id) && !confirm(`Preset \"${preset.name}\" is used in auto-selection. Delete it anyway?`)) {
return;
}
setForm((current) => ({
...current,
modelPresets: (current.modelPresets || []).filter((entry) => entry.id !== preset.id),
defaultPresetBySize: Object.fromEntries(
Object.entries(current.defaultPresetBySize || {}).filter(([, value]) => value !== preset.id),
) as Settings["defaultPresetBySize"],
}));
if (editingPresetId === preset.id) {
setEditingPresetId(null);
setPresetDraft(null);
setPresetIdTouched(false);
}
}}
>
Delete
</button>
</div>
</div>
);
})}
</div>
)}
{!presetDraft ? (
<button
type="button"
className="btn btn-sm"
onClick={() => {
setEditingPresetId(null);
setPresetDraft({ id: "", name: "", executorProvider: undefined, executorModelId: undefined, validatorProvider: undefined, validatorModelId: undefined });
setPresetIdTouched(false);
}}
>
Add Preset
</button>
) : null}
</div>
{presetDraft ? (
<div className="form-group">
<label>Preset editor</label>
<div className="form-group">
<label htmlFor="preset-name">Name</label>
<input
id="preset-name"
type="text"
value={presetDraft.name}
onChange={(e) => {
const name = e.target.value;
setPresetDraft((current) => current ? {
...current,
name,
id: presetIdTouched ? current.id : generatePresetId(name),
} : current);
}}
/>
</div>
<div className="form-group">
<label htmlFor="preset-id">ID</label>
<input
id="preset-id"
type="text"
value={presetDraft.id}
onChange={(e) => {
setPresetIdTouched(true);
setPresetDraft((current) => current ? { ...current, id: e.target.value } : current);
}}
/>
{presetDraft.id && !validatePresetId(presetDraft.id) ? (
<small className="field-error">ID must be 132 letters, numbers, hyphens, or underscores</small>
) : (
<small>Slug-friendly unique identifier used for preset mappings.</small>
)}
</div>
{availableModels.length === 0 ? (
<small>No models available. Configure authentication first.</small>
) : (
<>
<div className="form-group">
<label htmlFor="preset-executor-model">Executor model</label>
<CustomModelDropdown
id="preset-executor-model"
label="Preset executor model"
models={availableModels}
value={presetDraft.executorProvider && presetDraft.executorModelId ? `${presetDraft.executorProvider}/${presetDraft.executorModelId}` : ""}
onChange={(val) => {
if (!val) {
setPresetDraft((current) => current ? { ...current, executorProvider: undefined, executorModelId: undefined } : current);
return;
}
const slashIdx = val.indexOf("/");
setPresetDraft((current) => current ? {
...current,
executorProvider: val.slice(0, slashIdx),
executorModelId: val.slice(slashIdx + 1),
} : current);
}}
placeholder="Use default"
/>
</div>
<div className="form-group">
<label htmlFor="preset-validator-model">Validator model</label>
<CustomModelDropdown
id="preset-validator-model"
label="Preset validator model"
models={availableModels}
value={presetDraft.validatorProvider && presetDraft.validatorModelId ? `${presetDraft.validatorProvider}/${presetDraft.validatorModelId}` : ""}
onChange={(val) => {
if (!val) {
setPresetDraft((current) => current ? { ...current, validatorProvider: undefined, validatorModelId: undefined } : current);
return;
}
const slashIdx = val.indexOf("/");
setPresetDraft((current) => current ? {
...current,
validatorProvider: val.slice(0, slashIdx),
validatorModelId: val.slice(slashIdx + 1),
} : current);
}}
placeholder="Use default"
/>
</div>
</>
)}
<div className="modal-actions" style={{ justifyContent: "flex-start" }}>
<button type="button" className="btn btn-primary btn-sm" onClick={savePresetDraft}>Save preset</button>
<button type="button" className="btn btn-sm" onClick={() => { setEditingPresetId(null); setPresetDraft(null); setPresetIdTouched(false); }}>Cancel</button>
</div>
</div>
) : null}
<div className="form-group">
<label htmlFor="autoSelectModelPreset" className="checkbox-label">
<input
id="autoSelectModelPreset"
type="checkbox"
checked={form.autoSelectModelPreset || false}
onChange={(e) => setForm((current) => ({ ...current, autoSelectModelPreset: e.target.checked }))}
/>
Auto-select preset based on task size
</label>
</div>
{form.autoSelectModelPreset ? (
<>
{(["S", "M", "L"] as const).map((sizeKey) => (
<div className="form-group" key={sizeKey}>
<label htmlFor={`preset-size-${sizeKey}`}>
{sizeKey === "S" ? "Small tasks (S):" : sizeKey === "M" ? "Medium tasks (M):" : "Large tasks (L):"}
</label>
<select
id={`preset-size-${sizeKey}`}
value={form.defaultPresetBySize?.[sizeKey] || ""}
onChange={(e) => {
const value = e.target.value || undefined;
setForm((current) => ({
...current,
defaultPresetBySize: {
...(current.defaultPresetBySize || {}),
[sizeKey]: value,
},
}));
}}
>
<option value="">No preset</option>
{presetOptions.map((preset) => (
<option key={preset.id} value={preset.id}>{preset.name}</option>
))}
</select>
</div>
))}
</>
) : null}
</>
);
}
case "appearance":
return (
<>

View File

@@ -3,7 +3,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { InlineCreateCard } from "../InlineCreateCard";
import type { Task, Column } from "@kb/core";
import { fetchModels } from "../../api";
import { fetchModels, fetchSettings } from "../../api";
import type { ModelInfo } from "../../api";
// Mock lucide-react
@@ -16,11 +16,17 @@ vi.mock("lucide-react", () => ({
Terminal: () => null,
Lightbulb: () => null,
ListTree: () => null,
Zap: () => null,
}));
// Mock the api module
vi.mock("../../api", () => ({
fetchModels: vi.fn().mockResolvedValue([]),
fetchSettings: vi.fn().mockResolvedValue({
modelPresets: [],
autoSelectModelPreset: false,
defaultPresetBySize: {},
}),
uploadAttachment: vi.fn(),
}));
@@ -85,6 +91,11 @@ function chooseModel(label: "Executor Model" | "Validator Model", optionText: st
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(fetchModels).mockResolvedValue(MOCK_MODELS);
vi.mocked(fetchSettings).mockResolvedValue({
modelPresets: [],
autoSelectModelPreset: false,
defaultPresetBySize: {},
});
});
describe("InlineCreateCard blur-to-cancel", () => {
@@ -266,6 +277,48 @@ describe("InlineCreateCard model selector", () => {
expect(props.onCancel).not.toHaveBeenCalled();
});
it("does NOT call onCancel when focus leaves while the preset dropdown is open", () => {
vi.mocked(fetchSettings).mockResolvedValueOnce({
modelPresets: [{ id: "budget", name: "Budget", executorProvider: "anthropic", executorModelId: "claude-sonnet-4-5" }],
autoSelectModelPreset: false,
defaultPresetBySize: {},
});
const { props } = renderCard();
const textarea = screen.getByPlaceholderText("What needs to be done?");
fireEvent.click(screen.getByRole("button", { name: /Preset/i }));
textarea.focus();
fireEvent.focusOut(textarea, { relatedTarget: null });
expect(props.onCancel).not.toHaveBeenCalled();
});
it("includes selected preset id in the submit payload", async () => {
vi.mocked(fetchSettings).mockResolvedValueOnce({
modelPresets: [{ id: "budget", name: "Budget", executorProvider: "anthropic", executorModelId: "claude-sonnet-4-5", validatorProvider: "openai", validatorModelId: "gpt-4o" }],
autoSelectModelPreset: false,
defaultPresetBySize: {},
});
const { props } = renderCard();
const textarea = screen.getByPlaceholderText("What needs to be done?");
fireEvent.change(textarea, { target: { value: "Task with preset" } });
fireEvent.click(screen.getByRole("button", { name: /Preset/i }));
fireEvent.click(screen.getByRole("button", { name: "Budget" }));
fireEvent.click(screen.getByRole("button", { name: /Save/i }));
await waitFor(() => {
expect(props.onSubmit).toHaveBeenCalledWith(expect.objectContaining({
description: "Task with preset",
modelPresetId: "budget",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
validatorModelProvider: "openai",
validatorModelId: "gpt-4o",
}));
});
});
it("does NOT call onCancel after a model override is selected and focus leaves the card", () => {
const { props } = renderCard();
const textarea = screen.getByPlaceholderText("What needs to be done?");

View File

@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { NewTaskModal } from "../NewTaskModal";
import type { Task, Column } from "@kb/core";
import { fetchSettings } from "../../api";
// Mock the api module
vi.mock("../../api", () => ({
@@ -10,7 +11,11 @@ vi.mock("../../api", () => ({
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 },
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
]),
updateTask: vi.fn().mockResolvedValue({}),
fetchSettings: vi.fn().mockResolvedValue({
modelPresets: [],
autoSelectModelPreset: false,
defaultPresetBySize: {},
}),
}));
function makeTask(id: string): Task {
@@ -196,6 +201,67 @@ describe("NewTaskModal", () => {
});
});
it("creates task with selected preset id and resolved models", async () => {
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
modelPresets: [
{
id: "budget",
name: "Budget",
executorProvider: "openai",
executorModelId: "gpt-4o",
validatorProvider: "anthropic",
validatorModelId: "claude-sonnet-4-5",
},
],
autoSelectModelPreset: false,
defaultPresetBySize: {},
});
const { props } = renderNewTaskModal();
fireEvent.change(screen.getByLabelText(/Description/i), { target: { value: "Preset task" } });
await waitFor(() => expect(screen.getByLabelText("Preset")).toBeTruthy());
fireEvent.change(screen.getByLabelText("Preset"), { target: { value: "budget" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await waitFor(() => {
expect(props.onCreateTask).toHaveBeenCalledWith(expect.objectContaining({
description: "Preset task",
modelPresetId: "budget",
modelProvider: "openai",
modelId: "gpt-4o",
validatorModelProvider: "anthropic",
validatorModelId: "claude-sonnet-4-5",
}));
});
});
it("allows overriding a selected preset with custom models", async () => {
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
modelPresets: [
{
id: "budget",
name: "Budget",
executorProvider: "openai",
executorModelId: "gpt-4o",
},
],
autoSelectModelPreset: false,
defaultPresetBySize: {},
});
renderNewTaskModal();
await waitFor(() => expect(screen.getByLabelText("Preset")).toBeTruthy());
fireEvent.change(screen.getByLabelText("Preset"), { target: { value: "budget" } });
expect(screen.getByText("Using preset: Budget")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Override" }));
expect(screen.queryByText("Using preset: Budget")).toBeNull();
fireEvent.change(screen.getByLabelText("Preset"), { target: { value: "custom" } });
expect((screen.getByLabelText("Preset") as HTMLSelectElement).value).toBe("custom");
});
it("toggles planning mode checkbox", () => {
renderNewTaskModal();

View File

@@ -17,6 +17,9 @@ const defaultSettings: Settings = {
buildCommand: "",
autoResolveConflicts: true,
smartConflictResolution: true,
modelPresets: [],
autoSelectModelPreset: false,
defaultPresetBySize: {},
ntfyEnabled: false,
ntfyTopic: undefined,
taskStuckTimeoutMs: undefined,
@@ -373,6 +376,55 @@ describe("SettingsModal", () => {
expect(screen.getAllByText("Model").length).toBeGreaterThanOrEqual(1);
});
it("supports creating and saving a model preset", async () => {
const user = userEvent.setup();
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Model Presets"));
await user.click(screen.getByText("Add Preset"));
await user.type(screen.getByLabelText("Name"), "Budget");
expect((screen.getByLabelText("ID") as HTMLInputElement).value).toBe("budget");
await user.click(screen.getByText("Save preset"));
await user.click(screen.getByText("Save"));
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(payload.modelPresets).toEqual([
expect.objectContaining({ id: "budget", name: "Budget" }),
]);
});
it("supports auto-select preset mappings by size", async () => {
const user = userEvent.setup();
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...defaultSettings,
modelPresets: [
{ id: "budget", name: "Budget" },
{ id: "normal", name: "Normal" },
{ id: "complex", name: "Complex" },
],
});
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Model Presets"));
await user.click(screen.getByLabelText("Auto-select preset based on task size"));
fireEvent.change(screen.getByLabelText("Small tasks (S):"), { target: { value: "budget" } });
fireEvent.change(screen.getByLabelText("Medium tasks (M):"), { target: { value: "normal" } });
fireEvent.change(screen.getByLabelText("Large tasks (L):"), { target: { value: "complex" } });
await user.click(screen.getByText("Save"));
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(payload.autoSelectModelPreset).toBe(true);
expect(payload.defaultPresetBySize).toEqual({ S: "budget", M: "normal", L: "complex" });
});
it("shows model selector with available models grouped by provider", async () => {
const user = userEvent.setup();
render(<SettingsModal onClose={onClose} addToast={addToast} />);

View File

@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import type { ModelPreset } from "@kb/core";
import {
applyPresetToSelection,
generatePresetId,
getPresetByName,
getRecommendedPresetForSize,
validatePresetId,
} from "./modelPresets";
const presets: ModelPreset[] = [
{
id: "budget",
name: "Budget",
executorProvider: "openai",
executorModelId: "gpt-4o-mini",
validatorProvider: "openai",
validatorModelId: "gpt-4o-mini",
},
{
id: "complex",
name: "Complex",
executorProvider: "anthropic",
executorModelId: "claude-sonnet-4-5",
},
];
describe("modelPresets utils", () => {
it("finds presets by case-insensitive display name", () => {
expect(getPresetByName(presets, "budget")).toEqual(presets[0]);
expect(getPresetByName(presets, " COMPLEX ")).toEqual(presets[1]);
expect(getPresetByName(presets, "missing")).toBeUndefined();
});
it("applies a preset to dropdown selection values", () => {
expect(applyPresetToSelection(presets[0])).toEqual({
executorValue: "openai/gpt-4o-mini",
validatorValue: "openai/gpt-4o-mini",
});
expect(applyPresetToSelection(undefined)).toEqual({
executorValue: "",
validatorValue: "",
});
});
it("recommends the mapped preset for a task size", () => {
expect(
getRecommendedPresetForSize("S", { S: "budget", M: "complex" }, presets),
).toEqual(presets[0]);
expect(
getRecommendedPresetForSize("L", { S: "budget", M: "complex" }, presets),
).toBeUndefined();
expect(getRecommendedPresetForSize(undefined, { S: "budget" }, presets)).toBeUndefined();
});
it("validates preset ids", () => {
expect(validatePresetId("budget")).toBe(true);
expect(validatePresetId("budget_v2")).toBe(true);
expect(validatePresetId("budget-v2")).toBe(true);
expect(validatePresetId("")).toBe(false);
expect(validatePresetId("has spaces")).toBe(false);
expect(validatePresetId("invalid!char")).toBe(false);
expect(validatePresetId("a".repeat(33))).toBe(false);
});
it("generates slug-friendly preset ids", () => {
expect(generatePresetId("Budget")).toBe("budget");
expect(generatePresetId(" Normal Mode ")).toBe("normal-mode");
expect(generatePresetId("Complex / Reviewer")).toBe("complex-reviewer");
expect(generatePresetId("!!!")).toBe("preset");
expect(generatePresetId("a".repeat(40))).toBe("a".repeat(32));
});
});

View File

@@ -0,0 +1,47 @@
import type { ModelPreset } from "@kb/core";
export function getPresetByName(presets: ModelPreset[], name: string): ModelPreset | undefined {
const normalizedName = name.trim().toLowerCase();
return presets.find((preset) => preset.name.trim().toLowerCase() === normalizedName);
}
export function applyPresetToSelection(preset: ModelPreset | undefined): {
executorValue: string;
validatorValue: string;
} {
return {
executorValue: preset?.executorProvider && preset?.executorModelId
? `${preset.executorProvider}/${preset.executorModelId}`
: "",
validatorValue: preset?.validatorProvider && preset?.validatorModelId
? `${preset.validatorProvider}/${preset.validatorModelId}`
: "",
};
}
export function getRecommendedPresetForSize(
size: "S" | "M" | "L" | undefined,
defaultPresetBySize: Record<string, string>,
presets: ModelPreset[],
): ModelPreset | undefined {
if (!size) return undefined;
const presetId = defaultPresetBySize[size];
if (!presetId) return undefined;
return presets.find((preset) => preset.id === presetId);
}
export function validatePresetId(id: string): boolean {
return /^[A-Za-z0-9_-]{1,32}$/.test(id);
}
export function generatePresetId(name: string): string {
const slug = name
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^[-_]+|[-_]+$/g, "")
.slice(0, 32);
return slug || "preset";
}

View File

@@ -4440,6 +4440,53 @@ describe("PUT /settings", () => {
expect(store.updateSettings).toHaveBeenCalledWith({ maxWorktrees: 10 });
});
it("validates and forwards model presets", async () => {
const updatedSettings = {
...DEFAULT_SETTINGS,
modelPresets: [{ id: "budget", name: "Budget", executorProvider: "openai", executorModelId: "gpt-4o-mini" }],
};
(store.updateSettings as ReturnType<typeof vi.fn>).mockResolvedValue(updatedSettings);
const res = await REQUEST(
buildApp(),
"PUT",
"/api/settings",
JSON.stringify({ modelPresets: [{ id: "budget", name: "Budget", executorProvider: "openai", executorModelId: "gpt-4o-mini" }] }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(store.updateSettings).toHaveBeenCalledWith(expect.objectContaining({
modelPresets: [{ id: "budget", name: "Budget", executorProvider: "openai", executorModelId: "gpt-4o-mini", validatorProvider: undefined, validatorModelId: undefined }],
}));
});
it("rejects duplicate preset ids", async () => {
const res = await REQUEST(
buildApp(),
"PUT",
"/api/settings",
JSON.stringify({ modelPresets: [{ id: "budget", name: "Budget" }, { id: "budget", name: "Budget 2" }] }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("duplicate id");
});
it("rejects incomplete model provider/modelId pairs", async () => {
const res = await REQUEST(
buildApp(),
"PUT",
"/api/settings",
JSON.stringify({ modelPresets: [{ id: "budget", name: "Budget", executorProvider: "openai" }] }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("must include both provider and modelId or neither");
});
it("returns 500 on store update error", async () => {
(store.updateSettings as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Write failed"));

View File

@@ -2,7 +2,7 @@ import { Router, type Request, type Response, type NextFunction } from "express"
import multer from "multer";
import { createReadStream, existsSync } from "node:fs";
import { execSync } from "node:child_process";
import type { TaskStore, Column, MergeResult, ScheduleType, ActivityEventType } from "@kb/core";
import type { TaskStore, Column, MergeResult, ScheduleType, ActivityEventType, ModelPreset } from "@kb/core";
import { COLUMNS, VALID_TRANSITIONS, type BatchStatusEntry, type BatchStatusResponse, type BatchStatusResult, type IssueInfo, type PrInfo, isGhAuthenticated, AUTOMATION_PRESETS, AutomationStore } from "@kb/core";
import type { ServerOptions } from "./server.js";
import { GitHubClient, getCurrentGitHubRepo, parseBadgeUrl } from "./github.js";
@@ -74,6 +74,74 @@ function normalizeModelSelectionPair(provider: string | undefined, modelId: stri
return { provider, modelId };
}
function assertConsistentOptionalPair(
provider: unknown,
modelId: unknown,
pairName: string,
): { provider?: string; modelId?: string } {
const normalizedProvider = validateOptionalModelField(provider, `${pairName} provider`);
const normalizedModelId = validateOptionalModelField(modelId, `${pairName} modelId`);
if ((normalizedProvider && !normalizedModelId) || (!normalizedProvider && normalizedModelId)) {
throw new Error(`${pairName} must include both provider and modelId or neither`);
}
return {
provider: normalizedProvider,
modelId: normalizedModelId,
};
}
function validateModelPresets(value: unknown): ModelPreset[] | undefined {
if (value === undefined) return undefined;
if (!Array.isArray(value)) {
throw new Error("modelPresets must be an array");
}
const seenIds = new Set<string>();
return value.map((preset, index) => {
if (!preset || typeof preset !== "object") {
throw new Error(`modelPresets[${index}] must be an object`);
}
const candidate = preset as Record<string, unknown>;
const id = validateOptionalModelField(candidate.id, `modelPresets[${index}].id`);
const name = validateOptionalModelField(candidate.name, `modelPresets[${index}].name`);
if (!id) {
throw new Error(`modelPresets[${index}].id is required`);
}
if (!name) {
throw new Error(`modelPresets[${index}].name is required`);
}
if (seenIds.has(id)) {
throw new Error(`modelPresets contains duplicate id: ${id}`);
}
seenIds.add(id);
const executor = assertConsistentOptionalPair(
candidate.executorProvider,
candidate.executorModelId,
`modelPresets[${index}].executor`,
);
const validator = assertConsistentOptionalPair(
candidate.validatorProvider,
candidate.validatorModelId,
`modelPresets[${index}].validator`,
);
return {
id,
name,
executorProvider: executor.provider,
executorModelId: executor.modelId,
validatorProvider: validator.provider,
validatorModelId: validator.modelId,
};
});
}
// ── Git Remote Detection ──────────────────────────────────────────
/** Git remote info returned by the remotes endpoint */
@@ -863,10 +931,18 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
// These are computed server-side and injected only on GET /settings.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { githubTokenConfigured, ...clientSettings } = req.body;
if (Object.prototype.hasOwnProperty.call(clientSettings, "modelPresets")) {
clientSettings.modelPresets = validateModelPresets(clientSettings.modelPresets);
}
const settings = await store.updateSettings(clientSettings);
res.json(settings);
} catch (err: any) {
res.status(500).json({ error: err.message });
const status = typeof err?.message === "string" && (
err.message.includes("modelPresets") || err.message.includes("must include both provider and modelId")
) ? 400 : 500;
res.status(status).json({ error: err.message });
}
});
@@ -952,6 +1028,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
column,
dependencies,
breakIntoSubtasks,
modelPresetId,
modelProvider,
modelId,
validatorModelProvider,
@@ -980,6 +1057,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
column,
dependencies,
breakIntoSubtasks,
modelPresetId: validateOptionalModelField(modelPresetId, "modelPresetId"),
modelProvider: executorModel.provider,
modelId: executorModel.modelId,
validatorModelProvider: validatorModel.provider,