feat(FN-1044): add thinkingLevel and planning model support to task creation

- Add thinkingLevel field to createTask and updateTask in core store
- Accept thinkingLevel and planningModel fields in dashboard task routes
- Send thinkingLevel and planningModel in frontend API calls from task forms
- Add ThinkingLevel selector to ModelSelectorTab component with tests
- Wire up planning model and thinking level in QuickEntryBox, TaskForm, and NewTaskModal
- Add unit tests for store, routes, and ModelSelectorTab coverage
- Add changeset and update AGENTS.md documentation
This commit is contained in:
gsxdsm
2026-04-07 12:18:49 -07:00
parent acae0e7aa0
commit 8d6a44d1b6
13 changed files with 463 additions and 12 deletions

View File

@@ -91,7 +91,9 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
const [savedValidator, setSavedValidator] = useState<ModelSelection>(() => getValidatorSelection(task));
const [selectedPlanning, setSelectedPlanning] = useState<ModelSelection>(() => getPlanningSelection(task));
const [savedPlanning, setSavedPlanning] = useState<ModelSelection>(() => getPlanningSelection(task));
const [savingTarget, setSavingTarget] = useState<"executor" | "validator" | "planning" | null>(null);
const [selectedThinking, setSelectedThinking] = useState<string>(() => task.thinkingLevel ?? "off");
const [savedThinking, setSavedThinking] = useState<string>(() => task.thinkingLevel ?? "off");
const [savingTarget, setSavingTarget] = useState<"executor" | "validator" | "planning" | "thinking" | null>(null);
const activeTaskIdRef = useRef(task.id);
@@ -164,8 +166,11 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
setSavedValidator(nextValidator);
setSelectedPlanning(nextPlanning);
setSavedPlanning(nextPlanning);
const nextThinking = task.thinkingLevel ?? "off";
setSelectedThinking(nextThinking);
setSavedThinking(nextThinking);
setSavingTarget(null);
}, [task.id, task.modelProvider, task.modelId, task.validatorModelProvider, task.validatorModelId, task.planningModelProvider, task.planningModelId]);
}, [task.id, task.modelProvider, task.modelId, task.validatorModelProvider, task.validatorModelId, task.planningModelProvider, task.planningModelId, task.thinkingLevel]);
const executorValue = useMemo(() => getDropdownValue(selectedExecutor), [selectedExecutor]);
const validatorValue = useMemo(() => getDropdownValue(selectedValidator), [selectedValidator]);
@@ -293,6 +298,49 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
[savedPlanning, saveSelection],
);
const handleThinkingChange = useCallback(
async (value: string) => {
const requestTaskId = task.id;
const previousThinking = savedThinking;
setSelectedThinking(value);
setSavingTarget("thinking");
try {
const updatedTask = await updateTask(requestTaskId, {
thinkingLevel: value === "off" ? null : value,
});
if (activeTaskIdRef.current !== requestTaskId) {
return;
}
const nextThinking = updatedTask.thinkingLevel ?? "off";
setSavedThinking(nextThinking);
setSelectedThinking(nextThinking);
addToast(
nextThinking === "off"
? "Thinking level set to default (off)"
: `Thinking level set to ${nextThinking}`,
"success",
);
} catch (err: any) {
if (activeTaskIdRef.current !== requestTaskId) {
return;
}
setSelectedThinking(previousThinking);
addToast(err.message || "Failed to save thinking level", "error");
} finally {
if (activeTaskIdRef.current === requestTaskId) {
setSavingTarget(null);
}
}
},
[task.id, savedThinking, addToast],
);
const executorUsingDefault = !savedExecutor.provider && !savedExecutor.modelId;
const validatorUsingDefault = !savedValidator.provider && !savedValidator.modelId;
const planningUsingDefault = !savedPlanning.provider && !savedPlanning.modelId;
@@ -418,8 +466,35 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
<small>The AI model used for task specification (triage phase).</small>
</div>
<div className="form-group">
<label htmlFor="thinkingLevel">Thinking Level</label>
<div className="model-selector-current">
{savedThinking === "off" ? (
<span className="model-badge model-badge-default">Using default (off)</span>
) : (
<span className="model-badge model-badge-custom">
{savedThinking}
</span>
)}
</div>
<select
id="thinkingLevel"
value={selectedThinking}
onChange={(e) => handleThinkingChange(e.target.value)}
disabled={isSaving}
className="thinking-level-select"
>
<option value="off">Off (default)</option>
<option value="minimal">Minimal</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
<small>Controls the reasoning effort for the AI agent. Higher levels use more tokens.</small>
</div>
<div className="model-selector-status">
{executorUsingDefault && validatorUsingDefault && planningUsingDefault
{executorUsingDefault && validatorUsingDefault && planningUsingDefault && savedThinking === "off"
? "Using global default models."
: "Model settings are up to date."}
</div>

View File

@@ -22,6 +22,8 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
const [isSubmitting, setIsSubmitting] = useState(false);
const [executorModel, setExecutorModel] = useState("");
const [validatorModel, setValidatorModel] = useState("");
const [planningModel, setPlanningModel] = useState("");
const [thinkingLevel, setThinkingLevel] = useState<string>("off");
const [selectedPresetId, setSelectedPresetId] = useState<string>("");
const [presetMode, setPresetMode] = useState<"default" | "preset" | "custom">("default");
const [hasDirtyState, setHasDirtyState] = useState(false);
@@ -42,15 +44,17 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
// Track dirty state
useEffect(() => {
const isDirty =
const isDirty =
description.trim() !== "" ||
dependencies.length > 0 ||
pendingImages.length > 0 ||
executorModel !== "" ||
validatorModel !== "" ||
planningModel !== "" ||
thinkingLevel !== "off" ||
selectedWorkflowSteps.length > 0;
setHasDirtyState(isDirty);
}, [description, dependencies, pendingImages, executorModel, validatorModel, selectedWorkflowSteps]);
}, [description, dependencies, pendingImages, executorModel, validatorModel, planningModel, thinkingLevel, selectedWorkflowSteps]);
const handleClose = useCallback(() => {
if (hasDirtyState) {
@@ -64,6 +68,8 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
setDependencies([]);
setExecutorModel("");
setValidatorModel("");
setPlanningModel("");
setThinkingLevel("off");
setSelectedPresetId("");
setPresetMode("default");
setSelectedWorkflowSteps([]);
@@ -80,6 +86,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
try {
const executorSlashIdx = executorModel.indexOf("/");
const validatorSlashIdx = validatorModel.indexOf("/");
const planningSlashIdx = planningModel.indexOf("/");
const task = await onCreateTask({
title: undefined,
@@ -94,6 +101,9 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
modelId: executorModel && executorSlashIdx !== -1 ? executorModel.slice(executorSlashIdx + 1) : undefined,
validatorModelProvider: validatorModel && validatorSlashIdx !== -1 ? validatorModel.slice(0, validatorSlashIdx) : undefined,
validatorModelId: validatorModel && validatorSlashIdx !== -1 ? validatorModel.slice(validatorSlashIdx + 1) : undefined,
planningModelProvider: planningModel && planningSlashIdx !== -1 ? planningModel.slice(0, planningSlashIdx) : undefined,
planningModelId: planningModel && planningSlashIdx !== -1 ? planningModel.slice(planningSlashIdx + 1) : undefined,
thinkingLevel: thinkingLevel !== "off" ? thinkingLevel as "minimal" | "low" | "medium" | "high" : undefined,
});
// Upload pending images as attachments
@@ -118,6 +128,8 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
setDependencies([]);
setExecutorModel("");
setValidatorModel("");
setPlanningModel("");
setThinkingLevel("off");
setSelectedPresetId("");
setPresetMode("default");
setSelectedWorkflowSteps([]);
@@ -130,7 +142,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
} finally {
setIsSubmitting(false);
}
}, [description, dependencies, pendingImages, executorModel, validatorModel, isSubmitting, onCreateTask, addToast, onClose, projectId, presetMode, selectedPresetId, selectedWorkflowSteps, workflowStepsExplicitlySet]);
}, [description, dependencies, pendingImages, executorModel, validatorModel, planningModel, thinkingLevel, isSubmitting, onCreateTask, addToast, onClose, projectId, presetMode, selectedPresetId, selectedWorkflowSteps, workflowStepsExplicitlySet]);
// Handle keyboard shortcuts
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
@@ -183,6 +195,10 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
onPlanningMode={onPlanningMode}
onSubtaskBreakdown={onSubtaskBreakdown}
onClose={handleClose}
planningModel={planningModel}
onPlanningModelChange={setPlanningModel}
thinkingLevel={thinkingLevel}
onThinkingLevelChange={setThinkingLevel}
/>
</div>

View File

@@ -342,6 +342,8 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
modelId: hasExecutorOverride ? executorModelId : undefined,
validatorModelProvider: hasValidatorOverride ? validatorProvider : undefined,
validatorModelId: hasValidatorOverride ? validatorModelId : undefined,
planningModelProvider: hasPlanningOverride ? planningProvider : undefined,
planningModelId: hasPlanningOverride ? planningModelId : undefined,
});
// Clear input for rapid entry
resetForm();
@@ -363,6 +365,9 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
hasValidatorOverride,
validatorProvider,
validatorModelId,
hasPlanningOverride,
planningProvider,
planningModelId,
addToast,
resetForm,
]);

View File

@@ -33,6 +33,8 @@ export interface TaskFormProps {
onValidatorModelChange: (value: string) => void;
planningModel?: string;
onPlanningModelChange?: (value: string) => void;
thinkingLevel?: string;
onThinkingLevelChange?: (value: string) => void;
presetMode: "default" | "preset" | "custom";
onPresetModeChange: (mode: "default" | "preset" | "custom") => void;
selectedPresetId: string;
@@ -75,6 +77,8 @@ export function TaskForm({
onValidatorModelChange,
planningModel,
onPlanningModelChange,
thinkingLevel,
onThinkingLevelChange,
presetMode,
onPresetModeChange,
selectedPresetId,
@@ -666,6 +670,23 @@ export function TaskForm({
/>
</div>
)}
{onThinkingLevelChange && (
<div className="model-select-row">
<label htmlFor="thinking-level" className="model-select-label">Thinking</label>
<select
id="thinking-level"
value={thinkingLevel || "off"}
onChange={(e) => onThinkingLevelChange(e.target.value)}
disabled={disabled || presetMode === "preset"}
>
<option value="off">Off (default)</option>
<option value="minimal">Minimal</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</div>
)}
</>
)}
</div>

View File

@@ -867,4 +867,90 @@ describe("ModelSelectorTab", () => {
});
});
});
describe("thinkingLevel selector", () => {
it("renders thinking level selector with default 'off'", async () => {
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
await waitForSelectors();
const select = screen.getByLabelText("Thinking Level");
expect(select).toBeInTheDocument();
expect((select as HTMLSelectElement).value).toBe("off");
});
it("renders current thinking level from task", async () => {
const taskWithThinking = { ...FAKE_TASK, thinkingLevel: "high" as const };
render(<ModelSelectorTab task={taskWithThinking} addToast={mockAddToast} />);
await waitForSelectors();
const select = screen.getByLabelText("Thinking Level");
expect((select as HTMLSelectElement).value).toBe("high");
});
it("saves thinking level when changed", async () => {
mockUpdateTask.mockImplementation(async (_id: string, updates: Record<string, unknown>) => ({
...FAKE_TASK,
...updates,
}));
const user = userEvent.setup();
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
await waitForSelectors();
await user.selectOptions(screen.getByLabelText("Thinking Level"), "high");
await waitFor(() => {
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", {
thinkingLevel: "high",
});
});
await waitFor(() => {
expect(mockAddToast).toHaveBeenCalledWith(
"Thinking level set to high",
"success",
);
});
});
it("shows 'set to default' toast when clearing thinking level", async () => {
const taskWithThinking = { ...FAKE_TASK, thinkingLevel: "high" as const };
mockUpdateTask.mockImplementation(async (_id: string, updates: Record<string, unknown>) => ({
...FAKE_TASK,
...updates,
}));
const user = userEvent.setup();
render(<ModelSelectorTab task={taskWithThinking} addToast={mockAddToast} />);
await waitForSelectors();
await user.selectOptions(screen.getByLabelText("Thinking Level"), "off");
await waitFor(() => {
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", {
thinkingLevel: null,
});
});
await waitFor(() => {
expect(mockAddToast).toHaveBeenCalledWith(
"Thinking level set to default (off)",
"success",
);
});
});
it("shows thinking level badge for non-default values", async () => {
const taskWithThinking = { ...FAKE_TASK, thinkingLevel: "medium" as const };
render(<ModelSelectorTab task={taskWithThinking} addToast={mockAddToast} />);
await waitForSelectors();
expect(screen.getByText("medium")).toBeInTheDocument();
});
});
});