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

@@ -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} />);