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:
@@ -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
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Task, TaskDetail } from "@kb/core";
|
||||
|
||||
// Mock the API
|
||||
vi.mock("../../api", () => ({
|
||||
fetchModels: vi.fn().mockResolvedValue([]),
|
||||
fetchTaskDetail: vi.fn(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user