feat(KB-060): add model selection to task creation

- Extend task creation types, store persistence, and API payloads to accept executor and validator model overrides
- Add inline model selection to the dashboard create card with default normalization, retryable model loading, and focus-safe interactions
- Wire the list-view create flow through InlineCreateCard and style the dropdown to stay usable within the viewport on mobile and desktop
- Add store, route, app, list-view, and inline-create tests covering override submission, partial selections, and inline creation behavior
- Document the new creation-time model selection flow and include a published package changeset
This commit is contained in:
gsxdsm
2026-03-30 05:41:30 -07:00
parent 650b8ccfc3
commit bf789f5c41
15 changed files with 919 additions and 88 deletions

View File

@@ -41,6 +41,7 @@ AI-guided interactive planning for creating well-specified tasks from high-level
- **Kanban Board**: Drag-and-drop task management across columns (Triage, Todo, In Progress, In Review, Done)
- **Inline Editing**: Quick-edit task title and description directly on the board for Triage and Todo columns. Double-click a card or use the pencil icon that appears on hover.
- **List View**: Alternative tabular view for tasks with sorting and filtering. The "Hide Done" toggle hides both Done and Archived tasks for an active-work-only view.
- **Model Selection at Creation**: Choose executor and validator AI models while creating tasks from the board or list view, or leave them unset to use the global defaults.
- **Task Details**: View full task specifications, agent logs, and attachments
- **GitHub Import**: Import issues directly from GitHub repositories
- **PR Management**: Create, monitor, and merge pull requests for in-review tasks

View File

@@ -43,6 +43,7 @@ function AppInner() {
});
const [searchQuery, setSearchQuery] = useState("");
const [githubTokenConfigured, setGithubTokenConfigured] = useState(false);
const [isListInlineCreating, setIsListInlineCreating] = useState(false);
const { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, updateTask, duplicateTask, archiveTask, unarchiveTask } = useTasks();
// Theme management
@@ -88,8 +89,16 @@ function AppInner() {
setView(newView);
}, []);
useEffect(() => {
if (view !== "list") {
setIsListInlineCreating(false);
}
}, [view]);
const handleNewTaskOpen = useCallback(() => setNewTaskModalOpen(true), []);
const handleNewTaskClose = useCallback(() => setNewTaskModalOpen(false), []);
const handleListInlineCreateOpen = useCallback(() => setIsListInlineCreating(true), []);
const handleListInlineCreateCancel = useCallback(() => setIsListInlineCreating(false), []);
const handleQuickCreate = useCallback(
async (description: string): Promise<void> => {
@@ -106,6 +115,15 @@ function AppInner() {
[createTask],
);
const handleListInlineCreate = useCallback(
async (input: TaskCreateInput): Promise<Task> => {
const task = await createTask({ ...input, column: input.column ?? "triage" });
setIsListInlineCreating(false);
return task;
},
[createTask],
);
// Planning mode handlers
const handlePlanningOpen = useCallback(() => setIsPlanningOpen(true), []);
const handlePlanningClose = useCallback(() => setIsPlanningOpen(false), []);
@@ -201,13 +219,18 @@ function AppInner() {
searchQuery={searchQuery}
/>
) : (
// Board view keeps the existing modal-based create flow; list view uses
// InlineCreateCard so model selection is available directly in-row.
<ListView
tasks={tasks}
onMoveTask={moveTask}
onOpenDetail={handleDetailOpen}
addToast={addToast}
globalPaused={globalPaused}
onNewTask={handleNewTaskOpen}
onNewTask={handleListInlineCreateOpen}
isCreating={isListInlineCreating}
onCancelCreate={handleListInlineCreateCancel}
onCreateTask={handleListInlineCreate}
/>
)}
{detailTask && (

View File

@@ -32,7 +32,18 @@ export async function fetchTaskDetail(id: string): Promise<TaskDetail> {
}
export function createTask(input: TaskCreateInput): Promise<Task> {
const { title, description, column, dependencies, breakIntoSubtasks } = input;
const {
title,
description,
column,
dependencies,
breakIntoSubtasks,
modelProvider,
modelId,
validatorModelProvider,
validatorModelId,
} = input;
return api<Task>("/tasks", {
method: "POST",
body: JSON.stringify({
@@ -41,6 +52,10 @@ export function createTask(input: TaskCreateInput): Promise<Task> {
column,
dependencies,
breakIntoSubtasks,
modelProvider,
modelId,
validatorModelProvider,
validatorModelId,
}),
});
}

View File

@@ -1,8 +1,10 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { Link } from "lucide-react";
import { Brain, Link } from "lucide-react";
import type { Task, TaskCreateInput } from "@kb/core";
import type { ToastType } from "../hooks/useToast";
import { uploadAttachment } from "../api";
import { fetchModels, uploadAttachment } from "../api";
import type { ModelInfo } from "../api";
import { CustomModelDropdown } from "./CustomModelDropdown";
const ALLOWED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
@@ -16,19 +18,78 @@ interface InlineCreateCardProps {
onSubmit: (input: TaskCreateInput) => Promise<Task>;
onCancel: () => void;
addToast: (msg: string, type?: ToastType) => void;
/**
* Optional model list from a parent surface. When omitted, InlineCreateCard
* fetches models itself so it can stay reusable in both list and board flows
* without forcing model data to be threaded through every caller.
*/
availableModels?: ModelInfo[];
}
export function InlineCreateCard({ tasks, onSubmit, onCancel, addToast }: InlineCreateCardProps) {
function getModelSelectionValue(provider?: string, modelId?: string): string {
return provider && modelId ? `${provider}/${modelId}` : "";
}
function parseModelSelection(value: string): { provider?: string; modelId?: string } {
if (!value) {
return { provider: undefined, modelId: undefined };
}
const slashIndex = value.indexOf("/");
if (slashIndex === -1) {
return { provider: undefined, modelId: undefined };
}
return {
provider: value.slice(0, slashIndex),
modelId: value.slice(slashIndex + 1),
};
}
export function InlineCreateCard({
tasks,
onSubmit,
onCancel,
addToast,
availableModels,
}: InlineCreateCardProps) {
const [description, setDescription] = useState("");
const [dependencies, setDependencies] = useState<string[]>([]);
const [showDeps, setShowDeps] = useState(false);
const [depSearch, setDepSearch] = useState("");
const [showModels, setShowModels] = useState(false);
const [executorProvider, setExecutorProvider] = useState<string | undefined>(undefined);
const [executorModelId, setExecutorModelId] = useState<string | undefined>(undefined);
const [validatorProvider, setValidatorProvider] = useState<string | undefined>(undefined);
const [validatorModelId, setValidatorModelId] = useState<string | undefined>(undefined);
const [modelsLoading, setModelsLoading] = useState(false);
const [modelsError, setModelsError] = useState<string | null>(null);
const [loadedModels, setLoadedModels] = useState<ModelInfo[]>(availableModels ?? []);
const [breakIntoSubtasks, setBreakIntoSubtasks] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
const inputRef = useRef<HTMLTextAreaElement>(null);
const cardRef = useRef<HTMLDivElement>(null);
const loadModels = useCallback(async () => {
if (availableModels) {
setLoadedModels(availableModels);
setModelsError(null);
setModelsLoading(false);
return;
}
setModelsLoading(true);
setModelsError(null);
try {
setLoadedModels(await fetchModels());
} catch (err: any) {
setModelsError(err?.message || "Failed to load models");
} finally {
setModelsLoading(false);
}
}, [availableModels]);
useEffect(() => {
inputRef.current?.focus();
}, []);
@@ -37,6 +98,56 @@ export function InlineCreateCard({ tasks, onSubmit, onCancel, addToast }: Inline
if (!showDeps) setDepSearch("");
}, [showDeps]);
useEffect(() => {
let cancelled = false;
if (availableModels) {
setLoadedModels(availableModels);
setModelsLoading(false);
setModelsError(null);
return;
}
setModelsLoading(true);
setModelsError(null);
fetchModels()
.then((models) => {
if (!cancelled) {
setLoadedModels(models);
}
})
.catch((err: any) => {
if (!cancelled) {
setModelsError(err?.message || "Failed to load models");
}
})
.finally(() => {
if (!cancelled) {
setModelsLoading(false);
}
});
return () => {
cancelled = true;
};
}, [availableModels]);
const executorSelectionValue = getModelSelectionValue(executorProvider, executorModelId);
const validatorSelectionValue = getModelSelectionValue(validatorProvider, validatorModelId);
const hasExecutorOverride = Boolean(executorProvider && executorModelId);
const hasValidatorOverride = Boolean(validatorProvider && validatorModelId);
const selectedModelCount = Number(hasExecutorOverride) + Number(hasValidatorOverride);
const getModelBadgeLabel = useCallback(
(provider?: string, modelId?: string) => {
if (!provider || !modelId) return "Using default";
const matched = loadedModels.find((model) => model.provider === provider && model.id === modelId);
return matched ? `${matched.provider}/${matched.id}` : `${provider}/${modelId}`;
},
[loadedModels],
);
// Cancel when focus leaves the card entirely and there's no content
useEffect(() => {
const card = cardRef.current;
@@ -44,20 +155,33 @@ export function InlineCreateCard({ tasks, onSubmit, onCancel, addToast }: Inline
const handleFocusOut = (e: FocusEvent) => {
// relatedTarget is the element receiving focus — if it's inside the card, ignore
if (e.relatedTarget instanceof Node && card.contains(e.relatedTarget)) return;
// Only cancel if empty and dropdown is not open
// Only cancel if empty and dropdowns are not open
if (
description.trim() === "" &&
pendingImages.length === 0 &&
dependencies.length === 0 &&
!breakIntoSubtasks &&
!showDeps
!hasExecutorOverride &&
!hasValidatorOverride &&
!showDeps &&
!showModels
) {
onCancel();
}
};
card.addEventListener("focusout", handleFocusOut);
return () => card.removeEventListener("focusout", handleFocusOut);
}, [description, pendingImages, dependencies, breakIntoSubtasks, showDeps, onCancel]);
}, [
description,
pendingImages,
dependencies,
breakIntoSubtasks,
hasExecutorOverride,
hasValidatorOverride,
showDeps,
showModels,
onCancel,
]);
// Clean up object URLs on unmount to prevent memory leaks
useEffect(() => {
@@ -109,6 +233,10 @@ export function InlineCreateCard({ tasks, onSubmit, onCancel, addToast }: Inline
column: "triage",
dependencies: dependencies.length ? dependencies : undefined,
breakIntoSubtasks,
modelProvider: hasExecutorOverride ? executorProvider : undefined,
modelId: hasExecutorOverride ? executorModelId : undefined,
validatorModelProvider: hasValidatorOverride ? validatorProvider : undefined,
validatorModelId: hasValidatorOverride ? validatorModelId : undefined,
});
// Upload pending images as attachments
@@ -136,7 +264,21 @@ export function InlineCreateCard({ tasks, onSubmit, onCancel, addToast }: Inline
} finally {
setSubmitting(false);
}
}, [description, dependencies, breakIntoSubtasks, submitting, pendingImages, onSubmit, addToast]);
}, [
description,
dependencies,
breakIntoSubtasks,
submitting,
pendingImages,
onSubmit,
addToast,
hasExecutorOverride,
executorProvider,
executorModelId,
hasValidatorOverride,
validatorProvider,
validatorModelId,
]);
const handleKeyDown = useCallback(
async (e: React.KeyboardEvent) => {
@@ -159,6 +301,45 @@ export function InlineCreateCard({ tasks, onSubmit, onCancel, addToast }: Inline
);
}, []);
const toggleDepsDropdown = useCallback(() => {
setShowDeps((prev) => {
const next = !prev;
if (next) setShowModels(false);
return next;
});
}, []);
const toggleModelsDropdown = useCallback(() => {
setShowModels((prev) => {
const next = !prev;
if (next) setShowDeps(false);
return next;
});
}, []);
const handleExecutorChange = useCallback((value: string) => {
const next = parseModelSelection(value);
setExecutorProvider(next.provider);
setExecutorModelId(next.modelId);
}, []);
const handleValidatorChange = useCallback((value: string) => {
const next = parseModelSelection(value);
setValidatorProvider(next.provider);
setValidatorModelId(next.modelId);
}, []);
const handleModelDropdownMouseDown = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const target = e.target;
if (
target instanceof HTMLElement &&
(target.closest("button") || target.closest("input"))
) {
return;
}
e.preventDefault();
}, []);
const truncate = (s: string, len: number) =>
s.length > len ? s.slice(0, len) + "…" : s;
@@ -199,16 +380,145 @@ export function InlineCreateCard({ tasks, onSubmit, onCancel, addToast }: Inline
</div>
)}
<div className="inline-create-footer">
<div className="dep-trigger-wrap">
<button
type="button"
className="btn btn-sm dep-trigger"
onClick={() => setShowDeps((v) => !v)}
>
<Link size={12} style={{ verticalAlign: 'middle' }} />{dependencies.length > 0 ? ` ${dependencies.length} deps` : " Deps"}
</button>
<div className="inline-create-controls">
<div className="dep-trigger-wrap">
<button
type="button"
className="btn btn-sm dep-trigger"
onClick={toggleDepsDropdown}
>
<Link size={12} style={{ verticalAlign: "middle" }} />
{dependencies.length > 0 ? ` ${dependencies.length} deps` : " Deps"}
</button>
{showDeps && (() => {
const term = depSearch.toLowerCase();
const filtered = (term
? tasks.filter((t) =>
t.id.toLowerCase().includes(term) ||
(t.title && t.title.toLowerCase().includes(term)) ||
(t.description && t.description.toLowerCase().includes(term))
)
: [...tasks]
).sort((a, b) => {
const cmp = b.createdAt.localeCompare(a.createdAt);
if (cmp !== 0) return cmp;
const aNum = parseInt(a.id.slice(a.id.lastIndexOf("-") + 1), 10) || 0;
const bNum = parseInt(b.id.slice(b.id.lastIndexOf("-") + 1), 10) || 0;
return bNum - aNum;
});
return (
<div className="dep-dropdown" onMouseDown={(e) => e.preventDefault()}>
<input
className="dep-dropdown-search"
placeholder="Search tasks…"
autoFocus
value={depSearch}
onChange={(e) => setDepSearch(e.target.value)}
onClick={(e) => e.stopPropagation()}
/>
{filtered.length === 0 ? (
<div className="dep-dropdown-empty">No existing tasks</div>
) : (
filtered.map((t) => (
<div
key={t.id}
className={`dep-dropdown-item${dependencies.includes(t.id) ? " selected" : ""}`}
onMouseDown={(e) => e.preventDefault()}
onClick={() => toggleDep(t.id)}
>
<span className="dep-dropdown-id">{t.id}</span>
<span className="dep-dropdown-title">{truncate(t.title || t.description || t.id, 30)}</span>
</div>
))
)}
</div>
);
})()}
</div>
<div className="inline-create-model-wrap">
<button
type="button"
className="btn btn-sm inline-create-model-trigger"
onClick={toggleModelsDropdown}
aria-expanded={showModels}
aria-haspopup="dialog"
>
<Brain size={12} style={{ verticalAlign: "middle" }} />
{selectedModelCount > 0
? ` ${selectedModelCount} model${selectedModelCount === 1 ? "" : "s"}`
: " Models"}
</button>
{showModels && (
<div
className="inline-create-model-dropdown"
onMouseDown={handleModelDropdownMouseDown}
>
{modelsLoading ? (
<div className="inline-create-model-empty">Loading models</div>
) : modelsError ? (
<div className="inline-create-model-empty">
<span>Failed to load models.</span>
<button
type="button"
className="btn btn-sm"
onClick={() => void loadModels()}
>
Retry
</button>
</div>
) : loadedModels.length === 0 ? (
<div className="inline-create-model-empty">
No models available. Configure authentication in Settings to enable model selection.
</div>
) : (
<>
<div className="inline-create-model-row">
<label htmlFor="inline-create-executor-model" className="inline-create-model-label">
Executor Model
</label>
<span className={`model-badge ${hasExecutorOverride ? "model-badge-custom" : "model-badge-default"}`}>
{getModelBadgeLabel(executorProvider, executorModelId)}
</span>
<CustomModelDropdown
id="inline-create-executor-model"
label="Executor Model"
value={executorSelectionValue}
onChange={handleExecutorChange}
models={loadedModels}
disabled={submitting}
placeholder="Select executor model…"
/>
</div>
<div className="inline-create-model-row">
<label htmlFor="inline-create-validator-model" className="inline-create-model-label">
Validator Model
</label>
<span className={`model-badge ${hasValidatorOverride ? "model-badge-custom" : "model-badge-default"}`}>
{getModelBadgeLabel(validatorProvider, validatorModelId)}
</span>
<CustomModelDropdown
id="inline-create-validator-model"
label="Validator Model"
value={validatorSelectionValue}
onChange={handleValidatorChange}
models={loadedModels}
disabled={submitting}
placeholder="Select validator model…"
/>
</div>
</>
)}
</div>
)}
</div>
{!submitting && (
<label className="inline-create-hint" style={{ display: "inline-flex", alignItems: "center", gap: 6, marginLeft: 8 }}>
<label
className="inline-create-hint"
style={{ display: "inline-flex", alignItems: "center", gap: 6, marginLeft: 8 }}
>
<input
type="checkbox"
data-testid="break-into-subtasks-toggle"
@@ -218,50 +528,6 @@ export function InlineCreateCard({ tasks, onSubmit, onCancel, addToast }: Inline
Break into subtasks
</label>
)}
{showDeps && (() => {
const term = depSearch.toLowerCase();
const filtered = (term
? tasks.filter((t) =>
t.id.toLowerCase().includes(term) ||
(t.title && t.title.toLowerCase().includes(term)) ||
(t.description && t.description.toLowerCase().includes(term))
)
: [...tasks]
).sort((a, b) => {
const cmp = b.createdAt.localeCompare(a.createdAt);
if (cmp !== 0) return cmp;
const aNum = parseInt(a.id.slice(a.id.lastIndexOf("-") + 1), 10) || 0;
const bNum = parseInt(b.id.slice(b.id.lastIndexOf("-") + 1), 10) || 0;
return bNum - aNum;
});
return (
<div className="dep-dropdown" onMouseDown={(e) => e.preventDefault()}>
<input
className="dep-dropdown-search"
placeholder="Search tasks…"
autoFocus
value={depSearch}
onChange={(e) => setDepSearch(e.target.value)}
onClick={(e) => e.stopPropagation()}
/>
{filtered.length === 0 ? (
<div className="dep-dropdown-empty">No existing tasks</div>
) : (
filtered.map((t) => (
<div
key={t.id}
className={`dep-dropdown-item${dependencies.includes(t.id) ? " selected" : ""}`}
onMouseDown={(e) => e.preventDefault()}
onClick={() => toggleDep(t.id)}
>
<span className="dep-dropdown-id">{t.id}</span>
<span className="dep-dropdown-title">{truncate(t.title || t.description || t.id, 30)}</span>
</div>
))
)}
</div>
);
})()}
</div>
<div className="inline-create-actions">
<span className="inline-create-hint">Enter to create · Esc to cancel</span>

View File

@@ -1,6 +1,6 @@
import { useState, useCallback, useMemo, Fragment, useEffect, useRef } from "react";
import { LayoutGrid, List as ListIcon, ArrowUpDown, ArrowUp, ArrowDown, Search, Link, Columns3, EyeOff, Eye } from "lucide-react";
import type { Task, TaskDetail, Column, TaskStep } from "@kb/core";
import type { Task, TaskDetail, Column, TaskStep, TaskCreateInput } from "@kb/core";
import { COLUMN_LABELS, COLUMNS } from "@kb/core";
import { fetchTaskDetail } from "../api";
import { InlineCreateCard } from "./InlineCreateCard";
@@ -32,7 +32,7 @@ interface ListViewProps {
globalPaused?: boolean;
isCreating?: boolean;
onCancelCreate?: () => void;
onCreateTask?: (input: { description: string; column: Column; dependencies?: string[] }) => Promise<Task>;
onCreateTask?: (input: TaskCreateInput) => Promise<Task>;
onNewTask?: () => void;
}

View File

@@ -318,6 +318,26 @@ describe("App view switching", () => {
});
});
it("opens the inline create card from the list view new-task button", async () => {
render(<App />);
await waitFor(() => {
expect(screen.getByTitle("List view")).toBeTruthy();
});
fireEvent.click(screen.getByTitle("List view"));
await waitFor(() => {
expect(document.querySelector(".list-view")).toBeTruthy();
});
fireEvent.click(screen.getByText("+ New Task"));
await waitFor(() => {
expect(screen.getByPlaceholderText("What needs to be done?")).toBeTruthy();
});
});
it("persists view preference to localStorage", async () => {
// Clear any previous value
localStorage.removeItem("kb-dashboard-view");

View File

@@ -1,29 +1,90 @@
import { describe, it, expect, vi } from "vitest";
import type { ComponentProps } from "react";
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 type { ModelInfo } from "../../api";
// Mock lucide-react
vi.mock("lucide-react", () => ({
Brain: () => null,
Cpu: () => null,
Link: () => null,
Search: () => null,
Sparkles: () => null,
Terminal: () => null,
}));
// Mock the api module
vi.mock("../../api", () => ({
fetchModels: vi.fn().mockResolvedValue([]),
uploadAttachment: vi.fn(),
}));
function renderCard(tasks: Task[] = []) {
const props = {
const MOCK_MODELS: ModelInfo[] = [
{
provider: "anthropic",
id: "claude-sonnet-4-5",
name: "Claude Sonnet 4.5",
reasoning: true,
contextWindow: 200_000,
},
{
provider: "openai",
id: "gpt-4o",
name: "GPT-4o",
reasoning: true,
contextWindow: 128_000,
},
];
function createMockTask(overrides: Partial<Task> = {}): Task {
return {
id: "KB-001",
title: "Test task",
description: "Task description",
column: "todo" as Column,
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
...overrides,
};
}
function renderCard(
tasks: Task[] = [],
overrides: Partial<ComponentProps<typeof InlineCreateCard>> = {},
) {
const props: ComponentProps<typeof InlineCreateCard> = {
tasks,
onSubmit: vi.fn().mockResolvedValue({ id: "KB-001" }),
onSubmit: vi.fn().mockResolvedValue({ id: "KB-001" } as Task),
onCancel: vi.fn(),
addToast: vi.fn(),
availableModels: MOCK_MODELS,
...overrides,
};
const result = render(<InlineCreateCard {...props} />);
return { ...result, props };
}
function openModelPanel() {
fireEvent.click(screen.getByRole("button", { name: /Models/i }));
}
function chooseModel(label: "Executor Model" | "Validator Model", optionText: string) {
fireEvent.click(screen.getByRole("button", { name: label }));
fireEvent.click(screen.getByText(optionText));
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(fetchModels).mockResolvedValue(MOCK_MODELS);
});
describe("InlineCreateCard blur-to-cancel", () => {
it("calls onCancel when focus leaves the card with empty input", () => {
const { props } = renderCard();
@@ -69,19 +130,15 @@ describe("InlineCreateCard blur-to-cancel", () => {
describe("InlineCreateCard dep-dropdown focus retention", () => {
const testTasks: Task[] = [
{ id: "KB-010", title: "Task A", description: "First task", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z" },
createMockTask({ id: "KB-010", title: "Task A", description: "First task" }),
];
it("dep-dropdown-item mouseDown calls preventDefault to retain focus", () => {
renderCard(testTasks);
// Open the dropdown
fireEvent.click(screen.getByText(/Deps/));
const item = document.querySelector(".dep-dropdown-item") as HTMLElement;
expect(item).toBeTruthy();
// Fire mouseDown and verify preventDefault was called —
// this is the mechanism that keeps focus on the search input in
// real browsers and prevents a focusout with relatedTarget: null
const prevented = !fireEvent.mouseDown(item);
expect(prevented).toBe(true);
});
@@ -90,13 +147,11 @@ describe("InlineCreateCard dep-dropdown focus retention", () => {
const { props } = renderCard(testTasks);
const textarea = screen.getByPlaceholderText("What needs to be done?");
// Open dropdown and select a dependency
fireEvent.click(screen.getByText(/Deps/));
const item = document.querySelector(".dep-dropdown-item") as HTMLElement;
expect(item).toBeTruthy();
fireEvent.click(item);
// Focus the textarea then blur out of the card entirely
textarea.focus();
fireEvent.focusOut(textarea, { relatedTarget: null });
@@ -104,11 +159,180 @@ describe("InlineCreateCard dep-dropdown focus retention", () => {
});
});
describe("InlineCreateCard model selector", () => {
it("opens and closes the model disclosure dropdown", () => {
renderCard();
openModelPanel();
expect(screen.getByText("Executor Model")).toBeTruthy();
expect(screen.getByText("Validator Model")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /Models/i }));
expect(screen.queryByText("Executor Model")).toBeNull();
});
it("updates executor selection and shows the selected model badge", () => {
renderCard();
openModelPanel();
chooseModel("Executor Model", "Claude Sonnet 4.5");
expect(screen.getByText("anthropic/claude-sonnet-4-5")).toBeTruthy();
});
it("updates validator selection and shows the selected model badge", () => {
renderCard();
openModelPanel();
chooseModel("Validator Model", "GPT-4o");
expect(screen.getByText("openai/gpt-4o")).toBeTruthy();
});
it("clears the model selection when Use default is chosen", () => {
renderCard();
openModelPanel();
chooseModel("Executor Model", "Claude Sonnet 4.5");
expect(screen.getByText("anthropic/claude-sonnet-4-5")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Executor Model" }));
const defaultOption = document.querySelector(".model-combobox-dropdown .model-combobox-option") as HTMLElement;
expect(defaultOption).toBeTruthy();
fireEvent.click(defaultOption);
expect(screen.getAllByText("Using default")).toHaveLength(2);
});
it("omits model fields from the submit payload after clearing back to default", async () => {
const { props } = renderCard();
const textarea = screen.getByPlaceholderText("What needs to be done?");
fireEvent.change(textarea, { target: { value: "Task using defaults again" } });
openModelPanel();
chooseModel("Executor Model", "Claude Sonnet 4.5");
fireEvent.click(screen.getByRole("button", { name: "Executor Model" }));
const defaultOption = document.querySelector(".model-combobox-dropdown .model-combobox-option") as HTMLElement;
expect(defaultOption).toBeTruthy();
fireEvent.click(defaultOption);
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(props.onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
description: "Task using defaults again",
modelProvider: undefined,
modelId: undefined,
validatorModelProvider: undefined,
validatorModelId: undefined,
}),
);
});
});
it("includes selected models in the submit payload", async () => {
const { props } = renderCard();
const textarea = screen.getByPlaceholderText("What needs to be done?");
fireEvent.change(textarea, { target: { value: "Task with model overrides" } });
openModelPanel();
chooseModel("Executor Model", "Claude Sonnet 4.5");
chooseModel("Validator Model", "GPT-4o");
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(props.onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
description: "Task with model overrides",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
validatorModelProvider: "openai",
validatorModelId: "gpt-4o",
}),
);
});
});
it("does NOT call onCancel when focus leaves while the model dropdown is open", () => {
const { props } = renderCard();
const textarea = screen.getByPlaceholderText("What needs to be done?");
openModelPanel();
textarea.focus();
fireEvent.focusOut(textarea, { relatedTarget: null });
expect(props.onCancel).not.toHaveBeenCalled();
});
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?");
openModelPanel();
chooseModel("Executor Model", "Claude Sonnet 4.5");
fireEvent.click(screen.getByRole("button", { name: /1 model/i }));
textarea.focus();
fireEvent.focusOut(textarea, { relatedTarget: null });
expect(props.onCancel).not.toHaveBeenCalled();
});
it("prevents default on model option mouseDown to retain focus while selecting", () => {
const { props } = renderCard();
const textarea = screen.getByPlaceholderText("What needs to be done?");
textarea.focus();
openModelPanel();
fireEvent.click(screen.getByRole("button", { name: "Executor Model" }));
const option = screen.getByText("Claude Sonnet 4.5");
const prevented = !fireEvent.mouseDown(option);
fireEvent.click(option);
expect(prevented).toBe(true);
expect(props.onCancel).not.toHaveBeenCalled();
expect(screen.getByText("anthropic/claude-sonnet-4-5")).toBeTruthy();
});
it("uses parent-provided models without fetching again", () => {
renderCard();
expect(fetchModels).not.toHaveBeenCalled();
});
it("fetches models when parent-provided models are omitted", async () => {
renderCard([], { availableModels: undefined });
await waitFor(() => {
expect(fetchModels).toHaveBeenCalledTimes(1);
});
});
it("shows an error state and retries model loading", async () => {
vi.mocked(fetchModels)
.mockRejectedValueOnce(new Error("no auth"))
.mockResolvedValueOnce(MOCK_MODELS);
renderCard([], { availableModels: undefined });
openModelPanel();
await waitFor(() => {
expect(screen.getByText("Failed to load models.")).toBeTruthy();
});
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
await waitFor(() => {
expect(screen.queryByText("Failed to load models.")).toBeNull();
});
expect(fetchModels).toHaveBeenCalledTimes(2);
});
});
describe("InlineCreateCard dependency dropdown sort order", () => {
const scrambledTasks: Task[] = [
{ id: "KB-001", title: "Oldest", description: "First", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z" },
{ id: "KB-003", title: "Newest", description: "Third", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-03-01T00:00:00Z", updatedAt: "2026-03-01T00:00:00Z" },
{ id: "KB-002", title: "Middle", description: "Second", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-02-01T00:00:00Z", updatedAt: "2026-02-01T00:00:00Z" },
createMockTask({ id: "KB-001", title: "Oldest", description: "First", createdAt: "2026-01-01T00:00:00Z" }),
createMockTask({ id: "KB-003", title: "Newest", description: "Third", createdAt: "2026-03-01T00:00:00Z" }),
createMockTask({ id: "KB-002", title: "Middle", description: "Second", createdAt: "2026-02-01T00:00:00Z" }),
];
it("renders dependency dropdown items sorted newest-first by createdAt", () => {
@@ -124,7 +348,6 @@ describe("InlineCreateCard dependency dropdown sort order", () => {
renderCard(scrambledTasks);
fireEvent.click(screen.getByText(/Deps/));
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
// All three tasks match "KB-00" so we can verify order with a filter active
fireEvent.change(input, { target: { value: "KB-00" } });
const items = document.querySelectorAll(".dep-dropdown-item");
expect(items).toHaveLength(3);
@@ -135,9 +358,9 @@ describe("InlineCreateCard dependency dropdown sort order", () => {
describe("InlineCreateCard dependency dropdown sort with identical timestamps", () => {
const sameTimeTasks: Task[] = [
{ id: "KB-001", title: "First", description: "First task", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z" },
{ id: "KB-002", title: "Second", description: "Second task", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z" },
{ id: "KB-003", title: "Third", description: "Third task", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z" },
createMockTask({ id: "KB-001", title: "First", description: "First task" }),
createMockTask({ id: "KB-002", title: "Second", description: "Second task" }),
createMockTask({ id: "KB-003", title: "Third", description: "Third task" }),
];
it("renders tasks with identical createdAt sorted newest-ID-first (descending numeric ID)", () => {
@@ -163,9 +386,9 @@ describe("InlineCreateCard dependency dropdown sort with identical timestamps",
describe("InlineCreateCard dependency dropdown search", () => {
const testTasks: Task[] = [
{ id: "KB-001", title: "Fix login", description: "Login page broken", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z" },
{ id: "KB-002", title: "Add dark mode", description: "Theme support", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-02-01T00:00:00Z", updatedAt: "2026-02-01T00:00:00Z" },
{ id: "KB-003", title: "Refactor API", description: "Clean up endpoints", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-03-01T00:00:00Z", updatedAt: "2026-03-01T00:00:00Z" },
createMockTask({ id: "KB-001", title: "Fix login", description: "Login page broken", createdAt: "2026-01-01T00:00:00Z" }),
createMockTask({ id: "KB-002", title: "Add dark mode", description: "Theme support", createdAt: "2026-02-01T00:00:00Z" }),
createMockTask({ id: "KB-003", title: "Refactor API", description: "Clean up endpoints", createdAt: "2026-03-01T00:00:00Z" }),
];
it("shows search input when dropdown is opened", () => {

View File

@@ -5,6 +5,7 @@ import type { Task, TaskDetail } from "@kb/core";
// Mock the API
vi.mock("../../api", () => ({
fetchModels: vi.fn().mockResolvedValue([]),
fetchTaskDetail: vi.fn(),
}));

View File

@@ -1958,9 +1958,27 @@ body {
}
.inline-create-footer {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
}
.inline-create-controls {
display: flex;
align-items: center;
gap: 8px;
flex: 1 1 auto;
flex-wrap: wrap;
min-width: 0;
}
.inline-create-actions {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
.inline-create-hint {
@@ -2018,7 +2036,12 @@ body {
position: relative;
}
.dep-trigger {
.inline-create-model-wrap {
position: relative;
}
.dep-trigger,
.inline-create-model-trigger {
font-size: 12px;
padding: 3px 8px;
}
@@ -2098,6 +2121,86 @@ body {
white-space: nowrap;
}
.inline-create-model-dropdown {
position: absolute;
top: 100%;
left: 0;
margin-top: 4px;
width: min(320px, calc(100vw - 32px));
max-width: calc(100vw - 32px);
padding: 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
display: flex;
flex-direction: column;
gap: 10px;
z-index: 50;
box-shadow: var(--shadow);
}
.inline-create-model-row {
display: flex;
flex-direction: column;
gap: 6px;
}
.inline-create-model-label {
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
}
.inline-create-model-select {
width: 100%;
min-width: 0;
padding: 7px 10px;
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
font-size: 12px;
font-family: inherit;
outline: none;
}
.inline-create-model-select:focus {
border-color: var(--todo);
box-shadow: var(--focus-ring);
}
.inline-create-model-empty {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-size: 12px;
color: var(--text-dim);
}
.inline-create-model-row .model-badge {
width: fit-content;
}
@media (max-width: 640px) {
.inline-create-actions {
width: 100%;
justify-content: space-between;
margin-left: 0;
}
.inline-create-hint {
margin-left: 0;
}
.inline-create-model-dropdown {
left: 0;
right: auto;
width: min(360px, calc(100vw - 48px));
max-width: calc(100vw - 48px);
}
}
/* === Empty state === */
.empty-column {
display: flex;

View File

@@ -194,6 +194,95 @@ describe("POST /tasks", () => {
});
});
it("forwards model overrides when both provider and id are supplied", async () => {
const createdTask = {
...FAKE_TASK_DETAIL,
column: "triage",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
validatorModelProvider: "openai",
validatorModelId: "gpt-4o",
};
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue(createdTask);
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks",
JSON.stringify({
description: "Use explicit models",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
validatorModelProvider: "openai",
validatorModelId: "gpt-4o",
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(store.createTask).toHaveBeenCalledWith({
title: undefined,
description: "Use explicit models",
column: undefined,
dependencies: undefined,
breakIntoSubtasks: undefined,
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
validatorModelProvider: "openai",
validatorModelId: "gpt-4o",
});
});
it("normalizes partial model overrides back to defaults", async () => {
const createdTask = {
...FAKE_TASK_DETAIL,
column: "triage",
};
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue(createdTask);
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks",
JSON.stringify({
description: "Ignore partial model selection",
modelProvider: "anthropic",
validatorModelId: "gpt-4o",
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(store.createTask).toHaveBeenCalledWith({
title: undefined,
description: "Ignore partial model selection",
column: undefined,
dependencies: undefined,
breakIntoSubtasks: undefined,
modelProvider: undefined,
modelId: undefined,
validatorModelProvider: undefined,
validatorModelId: undefined,
});
});
it("returns 400 when model fields are not strings", async () => {
const res = await REQUEST(
buildApp(),
"POST",
"/api/tasks",
JSON.stringify({
description: "Invalid model payload",
modelProvider: ["anthropic"],
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("modelProvider must be a string");
expect(store.createTask).not.toHaveBeenCalled();
});
it("returns 400 when description is missing", async () => {
const res = await REQUEST(
buildApp(),

View File

@@ -48,6 +48,23 @@ const upload = multer({
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
});
function validateOptionalModelField(value: unknown, name: string): string | undefined {
if (value === undefined || value === null) return undefined;
if (typeof value !== "string") {
throw new Error(`${name} must be a string`);
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function normalizeModelSelectionPair(provider: string | undefined, modelId: string | undefined) {
if (!provider || !modelId) {
return { provider: undefined, modelId: undefined };
}
return { provider, modelId };
}
// ── Git Remote Detection ──────────────────────────────────────────
/** Git remote info returned by the remotes endpoint */
@@ -577,7 +594,17 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
// Create task
router.post("/tasks", async (req, res) => {
try {
const { title, description, column, dependencies, breakIntoSubtasks } = req.body;
const {
title,
description,
column,
dependencies,
breakIntoSubtasks,
modelProvider,
modelId,
validatorModelProvider,
validatorModelId,
} = req.body;
if (!description || typeof description !== "string") {
res.status(400).json({ error: "description is required" });
return;
@@ -586,16 +613,30 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
res.status(400).json({ error: "breakIntoSubtasks must be a boolean" });
return;
}
const validatedModelProvider = validateOptionalModelField(modelProvider, "modelProvider");
const validatedModelId = validateOptionalModelField(modelId, "modelId");
const validatedValidatorModelProvider = validateOptionalModelField(validatorModelProvider, "validatorModelProvider");
const validatedValidatorModelId = validateOptionalModelField(validatorModelId, "validatorModelId");
const executorModel = normalizeModelSelectionPair(validatedModelProvider, validatedModelId);
const validatorModel = normalizeModelSelectionPair(validatedValidatorModelProvider, validatedValidatorModelId);
const task = await store.createTask({
title,
description,
column,
dependencies,
breakIntoSubtasks,
modelProvider: executorModel.provider,
modelId: executorModel.modelId,
validatorModelProvider: validatorModel.provider,
validatorModelId: validatorModel.modelId,
});
res.status(201).json(task);
} catch (err: any) {
res.status(500).json({ error: err.message });
const status = err.message?.includes("must be a string") ? 400 : 500;
res.status(status).json({ error: err.message });
}
});