feat(FN-2241): add review level selection in task creation and editing
- Extend core task types/store and dashboard API route handling to persist task reviewLevel - Add review level controls to TaskForm, NewTaskModal, and TaskDetailModal flows - Improve workflow step selector presentation in WorkflowResultsTab and styles for clearer review settings UX - Document the new review level behavior and add route/form/modal tests to cover create and edit scenarios
This commit is contained in:
@@ -184,6 +184,7 @@ export function createTask(input: TaskCreateInput, projectId?: string): Promise<
|
||||
planningModelId,
|
||||
thinkingLevel,
|
||||
summarize,
|
||||
reviewLevel,
|
||||
} = input;
|
||||
|
||||
return api<Task>(withProjectId("/tasks", projectId), {
|
||||
@@ -205,11 +206,12 @@ export function createTask(input: TaskCreateInput, projectId?: string): Promise<
|
||||
planningModelId,
|
||||
thinkingLevel,
|
||||
summarize,
|
||||
reviewLevel,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateTask(id: string, updates: { title?: string; description?: string; prompt?: string; dependencies?: string[]; enabledWorkflowSteps?: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null }, projectId?: string): Promise<Task> {
|
||||
export function updateTask(id: string, updates: { title?: string; description?: string; prompt?: string; dependencies?: string[]; enabledWorkflowSteps?: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; reviewLevel?: number | null }, projectId?: string): Promise<Task> {
|
||||
return api<Task>(withProjectId(`/tasks/${id}`, projectId), {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(updates),
|
||||
|
||||
@@ -33,6 +33,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
const [hasDirtyState, setHasDirtyState] = useState(false);
|
||||
const [selectedWorkflowSteps, setSelectedWorkflowSteps] = useState<string[]>([]);
|
||||
const [workflowStepsExplicitlySet, setWorkflowStepsExplicitlySet] = useState(false);
|
||||
const [reviewLevel, setReviewLevel] = useState<number | undefined>(undefined);
|
||||
|
||||
// Agent assignment state
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
||||
@@ -142,9 +143,10 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
planningModel !== "" ||
|
||||
thinkingLevel !== "" ||
|
||||
selectedWorkflowSteps.length > 0 ||
|
||||
selectedAgentId !== null;
|
||||
selectedAgentId !== null ||
|
||||
reviewLevel !== undefined;
|
||||
setHasDirtyState(isDirty);
|
||||
}, [description, dependencies, pendingImages, executorModel, validatorModel, planningModel, thinkingLevel, selectedWorkflowSteps, selectedAgentId]);
|
||||
}, [description, dependencies, pendingImages, executorModel, validatorModel, planningModel, thinkingLevel, selectedWorkflowSteps, selectedAgentId, reviewLevel]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (hasDirtyState) {
|
||||
@@ -166,6 +168,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
setWorkflowStepsExplicitlySet(false);
|
||||
setSelectedAgentId(null);
|
||||
setShowAgentPicker(false);
|
||||
setReviewLevel(undefined);
|
||||
setHasDirtyState(false);
|
||||
onClose();
|
||||
}, [hasDirtyState, onClose, pendingImages]);
|
||||
@@ -197,6 +200,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
planningModelProvider: planningModel && planningSlashIdx !== -1 ? planningModel.slice(0, planningSlashIdx) : undefined,
|
||||
planningModelId: planningModel && planningSlashIdx !== -1 ? planningModel.slice(planningSlashIdx + 1) : undefined,
|
||||
thinkingLevel: thinkingLevel !== "" ? thinkingLevel as "minimal" | "low" | "medium" | "high" : undefined,
|
||||
reviewLevel,
|
||||
});
|
||||
|
||||
// Upload pending images as attachments
|
||||
@@ -229,6 +233,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
setWorkflowStepsExplicitlySet(false);
|
||||
setSelectedAgentId(null);
|
||||
setShowAgentPicker(false);
|
||||
setReviewLevel(undefined);
|
||||
|
||||
addToast(`Created ${task.id}`, "success");
|
||||
onClose();
|
||||
@@ -436,6 +441,8 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
onPlanningModelChange={setPlanningModel}
|
||||
thinkingLevel={thinkingLevel}
|
||||
onThinkingLevelChange={setThinkingLevel}
|
||||
reviewLevel={reviewLevel}
|
||||
onReviewLevelChange={setReviewLevel}
|
||||
renderBelowPrimary={quickFields}
|
||||
hideDependencies={true}
|
||||
/>
|
||||
|
||||
@@ -321,6 +321,7 @@ export function TaskDetailModal({
|
||||
const [editPlanningModel, setEditPlanningModel] = useState("");
|
||||
const [editThinkingLevel, setEditThinkingLevel] = useState("");
|
||||
const [editPresetMode, setEditPresetMode] = useState<"default" | "preset" | "custom">("default");
|
||||
const [editReviewLevel, setEditReviewLevel] = useState<number | undefined>(undefined);
|
||||
const [editSelectedPresetId, setEditSelectedPresetId] = useState("");
|
||||
const [editSelectedWorkflowSteps, setEditSelectedWorkflowSteps] = useState<string[]>(task.enabledWorkflowSteps || []);
|
||||
const [editPendingImages, setEditPendingImages] = useState<PendingImage[]>([]);
|
||||
@@ -526,6 +527,7 @@ export function TaskDetailModal({
|
||||
setEditSelectedPresetId("");
|
||||
setEditSelectedWorkflowSteps(task.enabledWorkflowSteps || []);
|
||||
setEditPendingImages([]);
|
||||
setEditReviewLevel(task.reviewLevel);
|
||||
}, [canEdit, task]);
|
||||
|
||||
const exitEditMode = useCallback(() => {
|
||||
@@ -583,6 +585,11 @@ export function TaskDetailModal({
|
||||
updates.thinkingLevel = editThinkingLevel !== "" ? (editThinkingLevel as "minimal" | "low" | "medium" | "high") : null;
|
||||
}
|
||||
|
||||
const currentReviewLevel = task.reviewLevel;
|
||||
if (editReviewLevel !== currentReviewLevel) {
|
||||
updates.reviewLevel = editReviewLevel;
|
||||
}
|
||||
|
||||
const hasTaskUpdates = Object.keys(updates).length > 0;
|
||||
if (hasTaskUpdates) {
|
||||
const updatedTask = await updateTask(task.id, updates, projectId);
|
||||
@@ -617,7 +624,7 @@ export function TaskDetailModal({
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
}, [task, editTitle, editDescription, editDependencies, editExecutorModel, editValidatorModel, editPlanningModel, editThinkingLevel, editSelectedWorkflowSteps, editPendingImages, addToast, projectId, onTaskUpdated]);
|
||||
}, [task, editTitle, editDescription, editDependencies, editExecutorModel, editValidatorModel, editPlanningModel, editThinkingLevel, editReviewLevel, editSelectedWorkflowSteps, editPendingImages, addToast, projectId, onTaskUpdated]);
|
||||
|
||||
const handleAutoSaveDescription = useCallback(async (description: string) => {
|
||||
try {
|
||||
@@ -1147,6 +1154,8 @@ export function TaskDetailModal({
|
||||
addToast={addToast}
|
||||
isActive={isEditing}
|
||||
onAutoSaveDescription={handleAutoSaveDescription}
|
||||
reviewLevel={editReviewLevel}
|
||||
onReviewLevelChange={setEditReviewLevel}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { useState, useCallback, useEffect, useRef, type ReactNode } from "react";
|
||||
import type { Task, ModelPreset, Settings, WorkflowStep } from "@fusion/core";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { fetchModels, fetchSettings, fetchWorkflowSteps, refineText, getRefineErrorMessage, updateGlobalSettings, type RefinementType, type ModelInfo } from "../api";
|
||||
@@ -8,6 +8,19 @@ import { Sparkles, ChevronUp, ChevronDown, X, Maximize2, Minimize2 } from "lucid
|
||||
|
||||
const ALLOWED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
||||
|
||||
/** Renders a phase badge using shared .phase-badge classes for consistency */
|
||||
function phaseBadge(phase: "pre-merge" | "post-merge", id: string, prefix: string): ReactNode {
|
||||
const phaseClass = phase === "post-merge" ? "phase-badge--post-merge" : "phase-badge--pre-merge";
|
||||
return (
|
||||
<span
|
||||
className={`phase-badge ${phaseClass}`}
|
||||
data-testid={`${prefix}-${id}`}
|
||||
>
|
||||
{phase === "post-merge" ? "Post-merge" : "Pre-merge"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export interface PendingImage {
|
||||
file: File;
|
||||
previewUrl: string;
|
||||
@@ -60,6 +73,10 @@ export interface TaskFormProps {
|
||||
// Auto-save callback (edit mode)
|
||||
onAutoSaveDescription?: (description: string) => Promise<void>;
|
||||
|
||||
// Review level (0=None, 1=Plan Only, 2=Plan and Code, 3=Full)
|
||||
reviewLevel?: number;
|
||||
onReviewLevelChange?: (value: number) => void;
|
||||
|
||||
// AI-assisted creation callbacks (create mode only)
|
||||
onPlanningMode?: (initialPlan: string) => void;
|
||||
onSubtaskBreakdown?: (description: string) => void;
|
||||
@@ -107,6 +124,8 @@ export function TaskForm({
|
||||
onClose,
|
||||
renderBelowPrimary,
|
||||
hideDependencies,
|
||||
reviewLevel,
|
||||
onReviewLevelChange,
|
||||
}: TaskFormProps) {
|
||||
const hasInitialMoreOptions =
|
||||
(hideDependencies ? false : dependencies.length > 0) ||
|
||||
@@ -116,7 +135,8 @@ export function TaskForm({
|
||||
executorModel !== "" ||
|
||||
validatorModel !== "" ||
|
||||
(planningModel || "") !== "" ||
|
||||
(thinkingLevel || "") !== "";
|
||||
(thinkingLevel || "") !== "" ||
|
||||
reviewLevel !== undefined;
|
||||
|
||||
const [showDepDropdown, setShowDepDropdown] = useState(false);
|
||||
const [showMoreOptions, setShowMoreOptions] = useState(hasInitialMoreOptions);
|
||||
@@ -176,7 +196,8 @@ export function TaskForm({
|
||||
executorModel !== "" ||
|
||||
validatorModel !== "" ||
|
||||
(planningModel || "") !== "" ||
|
||||
(thinkingLevel || "") !== "";
|
||||
(thinkingLevel || "") !== "" ||
|
||||
reviewLevel !== undefined;
|
||||
|
||||
// Auto-select preset by size (create mode only)
|
||||
useEffect(() => {
|
||||
@@ -952,6 +973,23 @@ export function TaskForm({
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{onReviewLevelChange && (
|
||||
<div className="model-select-row">
|
||||
<label htmlFor="review-level" className="model-select-label">Review</label>
|
||||
<select
|
||||
id="review-level"
|
||||
value={reviewLevel ?? ""}
|
||||
onChange={(e) => onReviewLevelChange(e.target.value === "" ? 0 : parseInt(e.target.value, 10))}
|
||||
disabled={disabled}
|
||||
>
|
||||
<option value="">Default (None)</option>
|
||||
<option value="0">0 — None</option>
|
||||
<option value="1">1 — Plan Only</option>
|
||||
<option value="2">2 — Plan and Code</option>
|
||||
<option value="3">3 — Full</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -959,57 +997,41 @@ export function TaskForm({
|
||||
{/* Workflow Steps */}
|
||||
<div className="form-group" data-testid="workflow-steps-section">
|
||||
<label>Workflow Steps</label>
|
||||
<small style={{ marginBottom: "8px", display: "block" }}>
|
||||
Select steps to run after task implementation completes
|
||||
</small>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
|
||||
{workflowSteps.length > 0 && workflowSteps.map((step) => (
|
||||
<label
|
||||
key={step.id}
|
||||
className="checkbox-label"
|
||||
style={{ display: "flex", alignItems: "flex-start", gap: "8px" }}
|
||||
data-testid={`workflow-step-checkbox-${step.id}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedWorkflowSteps.includes(step.id)}
|
||||
onChange={(e) => {
|
||||
onWorkflowStepsChange(
|
||||
e.target.checked
|
||||
? [...selectedWorkflowSteps, step.id]
|
||||
: selectedWorkflowSteps.filter((id) => id !== step.id)
|
||||
);
|
||||
}}
|
||||
disabled={disabled}
|
||||
style={{ marginTop: "2px" }}
|
||||
/>
|
||||
<div>
|
||||
<span style={{ fontWeight: 500, fontSize: "13px" }}>
|
||||
{step.name}
|
||||
<span
|
||||
style={{
|
||||
marginLeft: "6px",
|
||||
fontSize: "11px",
|
||||
padding: "1px 6px",
|
||||
borderRadius: "4px",
|
||||
background: (step.phase || "pre-merge") === "post-merge"
|
||||
? "rgba(139, 92, 246, 0.15)"
|
||||
: "rgba(59, 130, 246, 0.15)",
|
||||
color: (step.phase || "pre-merge") === "post-merge"
|
||||
? "#8b5cf6"
|
||||
: "#3b82f6",
|
||||
}}
|
||||
data-testid={`workflow-step-phase-${step.id}`}
|
||||
>
|
||||
{(step.phase || "pre-merge") === "post-merge" ? "Post-merge" : "Pre-merge"}
|
||||
<div className="workflow-steps-section">
|
||||
<small className="workflow-steps-description">
|
||||
Select steps to run after task implementation completes
|
||||
</small>
|
||||
<div className="workflow-steps-list">
|
||||
{workflowSteps.length > 0 && workflowSteps.map((step) => (
|
||||
<label
|
||||
key={step.id}
|
||||
className="checkbox-label workflow-step-item"
|
||||
data-testid={`workflow-step-checkbox-${step.id}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedWorkflowSteps.includes(step.id)}
|
||||
onChange={(e) => {
|
||||
onWorkflowStepsChange(
|
||||
e.target.checked
|
||||
? [...selectedWorkflowSteps, step.id]
|
||||
: selectedWorkflowSteps.filter((id) => id !== step.id)
|
||||
);
|
||||
}}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<div>
|
||||
<span className="workflow-step-name">
|
||||
{step.name}
|
||||
{phaseBadge(step.phase || "pre-merge", step.id, "workflow-step-phase")}
|
||||
</span>
|
||||
</span>
|
||||
<div style={{ fontSize: "12px", color: "var(--text-secondary)", marginTop: "2px" }}>
|
||||
{step.description}
|
||||
<div className="workflow-step-description">
|
||||
{step.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selected steps — execution order with reorder controls */}
|
||||
|
||||
@@ -361,37 +361,36 @@ export function WorkflowResultsTab({
|
||||
|
||||
return (
|
||||
<div className="workflow-results-editor" data-testid="workflow-steps-editor">
|
||||
<small style={{ marginBottom: "8px", display: "block" }}>
|
||||
Select steps to run after task implementation completes
|
||||
</small>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
|
||||
{workflowStepOptions.map((step) => (
|
||||
<label
|
||||
key={step.id}
|
||||
className="checkbox-label"
|
||||
style={{ display: "flex", alignItems: "flex-start", gap: "8px" }}
|
||||
data-testid={step.id === "browser-verification"
|
||||
? "browser-verification-checkbox"
|
||||
: `workflow-step-checkbox-${step.id}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedWorkflowSteps.includes(step.id)}
|
||||
onChange={(event) => toggleStep(step.id, event.target.checked)}
|
||||
style={{ marginTop: "2px" }}
|
||||
/>
|
||||
<div>
|
||||
<span style={{ fontWeight: 500, fontSize: "13px" }}>
|
||||
{step.name}
|
||||
{phaseBadge(step.phase, step.id, "workflow-step-phase")}
|
||||
</span>
|
||||
<div style={{ fontSize: "12px", color: "var(--text-secondary)", marginTop: "2px" }}>
|
||||
{step.description}
|
||||
<div className="workflow-steps-section">
|
||||
<small className="workflow-steps-description">
|
||||
Select steps to run after task implementation completes
|
||||
</small>
|
||||
<div className="workflow-steps-list">
|
||||
{workflowStepOptions.map((step) => (
|
||||
<label
|
||||
key={step.id}
|
||||
className="checkbox-label workflow-step-item"
|
||||
data-testid={step.id === "browser-verification"
|
||||
? "browser-verification-checkbox"
|
||||
: `workflow-step-checkbox-${step.id}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedWorkflowSteps.includes(step.id)}
|
||||
onChange={(event) => toggleStep(step.id, event.target.checked)}
|
||||
/>
|
||||
<div>
|
||||
<span className="workflow-step-name">
|
||||
{step.name}
|
||||
{phaseBadge(step.phase, step.id, "workflow-step-phase")}
|
||||
</span>
|
||||
<div className="workflow-step-description">
|
||||
{step.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedWorkflowSteps.length > 1 && (
|
||||
|
||||
@@ -630,6 +630,82 @@ describe("NewTaskModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Review level tests (FN-2241)
|
||||
describe("review level selection payload", () => {
|
||||
it("omits reviewLevel from payload when not selected", async () => {
|
||||
const { props } = renderNewTaskModal();
|
||||
|
||||
const descTextarea = screen.getByRole('textbox');
|
||||
fireEvent.change(descTextarea, { target: { value: "Task without review level" } });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onCreateTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
reviewLevel: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("includes reviewLevel in payload when selected", async () => {
|
||||
const { props } = renderNewTaskModal();
|
||||
|
||||
// Open more options to access the review level selector
|
||||
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Review")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select review level 2 (Plan and Code)
|
||||
const select = document.getElementById("review-level") as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: "2" } });
|
||||
|
||||
const descTextarea = screen.getByRole('textbox');
|
||||
fireEvent.change(descTextarea, { target: { value: "Task with review level" } });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onCreateTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
reviewLevel: 2,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("includes reviewLevel 3 in payload when Full review is selected", async () => {
|
||||
const { props } = renderNewTaskModal();
|
||||
|
||||
// Open more options to access the review level selector
|
||||
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Review")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select review level 3 (Full)
|
||||
const select = document.getElementById("review-level") as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: "3" } });
|
||||
|
||||
const descTextarea = screen.getByRole('textbox');
|
||||
fireEvent.change(descTextarea, { target: { value: "Task with full review" } });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onCreateTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
reviewLevel: 3,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Agent assignment tests (FN-1483)
|
||||
describe("agent assignment", () => {
|
||||
it("renders agent picker button", () => {
|
||||
|
||||
@@ -69,6 +69,8 @@ function renderTaskForm(props: Partial<React.ComponentProps<typeof TaskForm>> =
|
||||
tasks: [],
|
||||
addToast: vi.fn(),
|
||||
isActive: true,
|
||||
reviewLevel: undefined,
|
||||
onReviewLevelChange: vi.fn(),
|
||||
};
|
||||
const mergedProps = { ...defaultProps, ...props };
|
||||
const result = render(<TaskForm {...mergedProps} />);
|
||||
@@ -99,6 +101,8 @@ function renderTaskFormWithDescriptionState(props: Partial<React.ComponentProps<
|
||||
tasks: [],
|
||||
addToast: vi.fn(),
|
||||
isActive: true,
|
||||
reviewLevel: undefined,
|
||||
onReviewLevelChange: vi.fn(),
|
||||
};
|
||||
|
||||
const mergedProps = { ...defaultProps, ...props };
|
||||
|
||||
@@ -15364,6 +15364,45 @@ html .column.drag-over * {
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
/* === Workflow Steps Section (TaskForm & WorkflowResultsTab) === */
|
||||
.workflow-steps-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.workflow-steps-description {
|
||||
margin-bottom: var(--space-xs);
|
||||
display: block;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--space-xs);
|
||||
}
|
||||
|
||||
.workflow-steps-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.workflow-step-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.workflow-step-name {
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.workflow-step-description {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Workflow step reorder controls */
|
||||
.workflow-step-order {
|
||||
margin-top: var(--space-sm);
|
||||
@@ -15459,6 +15498,13 @@ html .column.drag-over * {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.workflow-step-order-actions .btn-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
/* === New Task Modal Quick Fields === */
|
||||
.new-task-quick-fields {
|
||||
display: flex;
|
||||
|
||||
@@ -758,6 +758,72 @@ describe("POST /tasks", () => {
|
||||
expect(store.createTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forwards reviewLevel when provided", 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: "Test task", reviewLevel: 2 }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(store.createTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ reviewLevel: 2 }),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts reviewLevel 0 (None) via POST", async () => {
|
||||
const createdTask = { ...FAKE_TASK_DETAIL, column: "triage", reviewLevel: 0 };
|
||||
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue(createdTask);
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/tasks",
|
||||
JSON.stringify({ description: "Test task", reviewLevel: 0 }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(store.createTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ reviewLevel: 0 }),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns 400 for invalid reviewLevel value via POST", async () => {
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/tasks",
|
||||
JSON.stringify({ description: "Test task", reviewLevel: 5 }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("reviewLevel must be an integer between 0 and 3");
|
||||
expect(store.createTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 400 for non-integer reviewLevel via POST", async () => {
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/tasks",
|
||||
JSON.stringify({ description: "Test task", reviewLevel: 1.5 }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("reviewLevel must be an integer between 0 and 3");
|
||||
expect(store.createTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forwards planningModelProvider and planningModelId when provided", async () => {
|
||||
const createdTask = {
|
||||
...FAKE_TASK_DETAIL,
|
||||
@@ -2674,6 +2740,64 @@ describe("PATCH /tasks/:id", () => {
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("thinkingLevel must be one of");
|
||||
});
|
||||
|
||||
it("forwards reviewLevel to store.updateTask", async () => {
|
||||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...FAKE_TASK_DETAIL,
|
||||
reviewLevel: 2,
|
||||
});
|
||||
|
||||
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({
|
||||
reviewLevel: 2,
|
||||
}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
|
||||
reviewLevel: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts null to clear reviewLevel via PATCH", async () => {
|
||||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...FAKE_TASK_DETAIL,
|
||||
reviewLevel: undefined,
|
||||
});
|
||||
|
||||
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({
|
||||
reviewLevel: null,
|
||||
}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
|
||||
reviewLevel: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 400 for invalid reviewLevel value via PATCH", async () => {
|
||||
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({
|
||||
reviewLevel: 5,
|
||||
}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("reviewLevel must be an integer between 0 and 3");
|
||||
});
|
||||
|
||||
it("returns 400 for non-integer reviewLevel via PATCH", async () => {
|
||||
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({
|
||||
reviewLevel: 1.5,
|
||||
}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("reviewLevel must be an integer between 0 and 3");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -3805,6 +3805,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
planningModelProvider,
|
||||
planningModelId,
|
||||
thinkingLevel,
|
||||
reviewLevel,
|
||||
} = req.body;
|
||||
if (!description || typeof description !== "string") {
|
||||
throw badRequest("description is required");
|
||||
@@ -3826,6 +3827,13 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
throw badRequest(`thinkingLevel must be one of: ${validThinkingLevels.join(", ")}`);
|
||||
}
|
||||
|
||||
// Validate reviewLevel if provided (must be integer 0-3)
|
||||
if (reviewLevel !== undefined && reviewLevel !== null) {
|
||||
if (typeof reviewLevel !== "number" || !Number.isInteger(reviewLevel) || reviewLevel < 0 || reviewLevel > 3) {
|
||||
throw badRequest("reviewLevel must be an integer between 0 and 3");
|
||||
}
|
||||
}
|
||||
|
||||
const executorModel = normalizeModelSelectionPair(validatedModelProvider, validatedModelId);
|
||||
const validatorModel = normalizeModelSelectionPair(validatedValidatorModelProvider, validatedValidatorModelId);
|
||||
const planningModel = normalizeModelSelectionPair(validatedPlanningModelProvider, validatedPlanningModelId);
|
||||
@@ -3891,6 +3899,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
planningModelId: planningModel.modelId,
|
||||
thinkingLevel: thinkingLevel || undefined,
|
||||
summarize,
|
||||
reviewLevel: reviewLevel ?? undefined,
|
||||
},
|
||||
{ onSummarize, settings: { autoSummarizeTitles: settings.autoSummarizeTitles } }
|
||||
);
|
||||
@@ -5086,7 +5095,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
router.patch("/tasks/:id", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const { title, description, prompt, dependencies, enabledWorkflowSteps, modelProvider, modelId, validatorModelProvider, validatorModelId, planningModelProvider, planningModelId, thinkingLevel, assigneeUserId } = req.body;
|
||||
const { title, description, prompt, dependencies, enabledWorkflowSteps, modelProvider, modelId, validatorModelProvider, validatorModelId, planningModelProvider, planningModelId, thinkingLevel, assigneeUserId, reviewLevel } = req.body;
|
||||
const hasBodyField = (field: string) => Object.prototype.hasOwnProperty.call(req.body, field);
|
||||
|
||||
// Validate model fields are strings or undefined/null
|
||||
@@ -5113,6 +5122,13 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
throw new Error(`thinkingLevel must be one of: ${validThinkingLevels.join(", ")}`);
|
||||
}
|
||||
|
||||
// Validate reviewLevel if provided (must be integer 0-3)
|
||||
if (reviewLevel !== undefined && reviewLevel !== null) {
|
||||
if (typeof reviewLevel !== "number" || !Number.isInteger(reviewLevel) || reviewLevel < 0 || reviewLevel > 3) {
|
||||
throw new Error("reviewLevel must be an integer between 0 and 3");
|
||||
}
|
||||
}
|
||||
|
||||
if (enabledWorkflowSteps !== undefined) {
|
||||
if (!Array.isArray(enabledWorkflowSteps) || !enabledWorkflowSteps.every((id: unknown) => typeof id === "string")) {
|
||||
throw new Error("enabledWorkflowSteps must be an array of strings");
|
||||
@@ -5133,6 +5149,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
if (hasBodyField("planningModelId")) updates.planningModelId = validatedPlanningModelId;
|
||||
if (hasBodyField("thinkingLevel")) updates.thinkingLevel = thinkingLevel === null ? null : thinkingLevel;
|
||||
if (hasBodyField("assigneeUserId")) updates.assigneeUserId = validatedAssigneeUserId;
|
||||
if (hasBodyField("reviewLevel")) updates.reviewLevel = reviewLevel;
|
||||
|
||||
const task = await scopedStore.updateTask(req.params.id, updates);
|
||||
res.json(task);
|
||||
@@ -5140,7 +5157,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
const status = (err instanceof Error ? err.message : String(err)).includes("must be a string") || (err instanceof Error ? err.message : String(err)).includes("must be an array of strings") || (err instanceof Error ? err.message : String(err)).includes("thinkingLevel must be one of") ? 400 : 500;
|
||||
const status = (err instanceof Error ? err.message : String(err)).includes("must be a string") || (err instanceof Error ? err.message : String(err)).includes("must be an array of strings") || (err instanceof Error ? err.message : String(err)).includes("thinkingLevel must be one of") || (err instanceof Error ? err.message : String(err)).includes("reviewLevel must be an integer") ? 400 : 500;
|
||||
throw new ApiError(status, err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user