feat(FN-2389): add task priority controls across dashboard flows
- Wire priority through task creation and update API payloads for dashboard clients - Add priority selection to TaskForm, New Task modal, and inline create card with default/reset behavior - Support priority editing and display in Task Detail modal plus non-default priority badges on task cards - Extend dashboard styles and component tests to cover priority selectors, rendering, and mobile behavior
This commit is contained in:
@@ -59,6 +59,7 @@ import type {
|
||||
InsightStatus,
|
||||
InsightRun,
|
||||
InsightRunTrigger,
|
||||
TaskPriority,
|
||||
} from "@fusion/core";
|
||||
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
|
||||
import type { ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult, Routine, RoutineCreateInput, RoutineUpdateInput, RoutineExecutionResult } from "@fusion/core";
|
||||
@@ -227,6 +228,7 @@ export function createTask(input: TaskCreateInput, projectId?: string): Promise<
|
||||
summarize,
|
||||
reviewLevel,
|
||||
executionMode,
|
||||
priority,
|
||||
} = input;
|
||||
|
||||
return api<Task>(withProjectId("/tasks", projectId), {
|
||||
@@ -250,11 +252,12 @@ export function createTask(input: TaskCreateInput, projectId?: string): Promise<
|
||||
summarize,
|
||||
reviewLevel,
|
||||
executionMode,
|
||||
priority,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
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; executionMode?: "standard" | "fast" | 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; executionMode?: "standard" | "fast" | null; priority?: TaskPriority | null }, projectId?: string): Promise<Task> {
|
||||
return api<Task>(withProjectId(`/tasks/${id}`, projectId), {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(updates),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Brain, Link, Lightbulb, ListTree, Zap, ChevronDown, ChevronUp, Bot, Maximize2, Minimize2 } from "lucide-react";
|
||||
import type { Task, TaskCreateInput, Settings } from "@fusion/core";
|
||||
import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, type Task, type TaskCreateInput, type TaskPriority, type Settings } from "@fusion/core";
|
||||
import { getErrorMessage } from "@fusion/core";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { fetchModels, uploadAttachment, fetchSettings, updateGlobalSettings, fetchAgents } from "../api";
|
||||
@@ -92,6 +92,7 @@ export function InlineCreateCard({
|
||||
const [validatorProvider, setValidatorProvider] = useState<string | undefined>(undefined);
|
||||
const [validatorModelId, setValidatorModelId] = useState<string | undefined>(undefined);
|
||||
const [browserVerification, setBrowserVerification] = useState(false);
|
||||
const [priority, setPriority] = useState<TaskPriority>(DEFAULT_TASK_PRIORITY);
|
||||
const [modelsLoading, setModelsLoading] = useState(false);
|
||||
const [modelsError, setModelsError] = useState<string | null>(null);
|
||||
const [loadedModels, setLoadedModels] = useState<ModelInfo[]>(availableModels ?? []);
|
||||
@@ -317,6 +318,7 @@ export function InlineCreateCard({
|
||||
validatorModelProvider: hasValidatorOverride ? validatorProvider : undefined,
|
||||
validatorModelId: hasValidatorOverride ? validatorModelId : undefined,
|
||||
enabledWorkflowSteps: browserVerification ? ["browser-verification"] : undefined,
|
||||
priority,
|
||||
});
|
||||
|
||||
// Upload pending images as attachments
|
||||
@@ -348,6 +350,7 @@ export function InlineCreateCard({
|
||||
setValidatorProvider(undefined);
|
||||
setValidatorModelId(undefined);
|
||||
setBrowserVerification(false);
|
||||
setPriority(DEFAULT_TASK_PRIORITY);
|
||||
setDependencies([]);
|
||||
setSelectedAgentId(null);
|
||||
setShowDeps(false);
|
||||
@@ -379,6 +382,7 @@ export function InlineCreateCard({
|
||||
validatorProvider,
|
||||
validatorModelId,
|
||||
browserVerification,
|
||||
priority,
|
||||
submitting,
|
||||
pendingImages,
|
||||
onSubmit,
|
||||
@@ -873,6 +877,23 @@ export function InlineCreateCard({
|
||||
{browserVerification ? "Browser Verify ✓" : "Browser Verify"}
|
||||
</button>
|
||||
|
||||
<label className="inline-create-priority-wrap" htmlFor="inline-create-priority-select">
|
||||
<span className="visually-hidden">Priority</span>
|
||||
<select
|
||||
id="inline-create-priority-select"
|
||||
className="select inline-create-priority-select"
|
||||
data-testid="inline-create-priority-select"
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value as TaskPriority)}
|
||||
>
|
||||
{TASK_PRIORITIES.map((taskPriority) => (
|
||||
<option key={taskPriority} value={taskPriority}>
|
||||
{`Priority: ${taskPriority[0].toUpperCase()}${taskPriority.slice(1)}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="inline-create-model-wrap">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import type { Task, TaskCreateInput } from "@fusion/core";
|
||||
import { DEFAULT_TASK_PRIORITY, type Task, type TaskCreateInput, type TaskPriority } from "@fusion/core";
|
||||
import { getErrorMessage } from "@fusion/core";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { uploadAttachment, fetchAgents } from "../api";
|
||||
@@ -35,6 +35,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
const [selectedWorkflowSteps, setSelectedWorkflowSteps] = useState<string[]>([]);
|
||||
const [workflowStepsExplicitlySet, setWorkflowStepsExplicitlySet] = useState(false);
|
||||
const [reviewLevel, setReviewLevel] = useState<number | undefined>(undefined);
|
||||
const [priority, setPriority] = useState<TaskPriority>(DEFAULT_TASK_PRIORITY);
|
||||
|
||||
// Agent assignment state
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
||||
@@ -146,9 +147,10 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
thinkingLevel !== "" ||
|
||||
selectedWorkflowSteps.length > 0 ||
|
||||
selectedAgentId !== null ||
|
||||
reviewLevel !== undefined;
|
||||
reviewLevel !== undefined ||
|
||||
priority !== DEFAULT_TASK_PRIORITY;
|
||||
setHasDirtyState(isDirty);
|
||||
}, [description, dependencies, pendingImages, executorModel, validatorModel, planningModel, thinkingLevel, selectedWorkflowSteps, selectedAgentId, reviewLevel]);
|
||||
}, [description, dependencies, pendingImages, executorModel, validatorModel, planningModel, thinkingLevel, selectedWorkflowSteps, selectedAgentId, reviewLevel, priority]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (hasDirtyState) {
|
||||
@@ -171,6 +173,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
setSelectedAgentId(null);
|
||||
setShowAgentPicker(false);
|
||||
setReviewLevel(undefined);
|
||||
setPriority(DEFAULT_TASK_PRIORITY);
|
||||
setHasDirtyState(false);
|
||||
onClose();
|
||||
}, [hasDirtyState, onClose, pendingImages]);
|
||||
@@ -203,6 +206,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
planningModelId: planningModel && planningSlashIdx !== -1 ? planningModel.slice(planningSlashIdx + 1) : undefined,
|
||||
thinkingLevel: thinkingLevel !== "" ? thinkingLevel as "minimal" | "low" | "medium" | "high" : undefined,
|
||||
reviewLevel,
|
||||
priority,
|
||||
});
|
||||
|
||||
// Upload pending images as attachments
|
||||
@@ -236,6 +240,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
setSelectedAgentId(null);
|
||||
setShowAgentPicker(false);
|
||||
setReviewLevel(undefined);
|
||||
setPriority(DEFAULT_TASK_PRIORITY);
|
||||
|
||||
addToast(`Created ${task.id}`, "success");
|
||||
onClose();
|
||||
@@ -244,7 +249,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [description, dependencies, pendingImages, executorModel, validatorModel, planningModel, thinkingLevel, isSubmitting, onCreateTask, addToast, onClose, projectId, presetMode, selectedPresetId, selectedWorkflowSteps, workflowStepsExplicitlySet, selectedAgentId]);
|
||||
}, [description, dependencies, pendingImages, executorModel, validatorModel, planningModel, thinkingLevel, isSubmitting, onCreateTask, addToast, onClose, projectId, presetMode, selectedPresetId, selectedWorkflowSteps, workflowStepsExplicitlySet, selectedAgentId, reviewLevel, priority]);
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
@@ -445,6 +450,8 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
onThinkingLevelChange={setThinkingLevel}
|
||||
reviewLevel={reviewLevel}
|
||||
onReviewLevelChange={setReviewLevel}
|
||||
priority={priority}
|
||||
onPriorityChange={setPriority}
|
||||
renderBelowPrimary={quickFields}
|
||||
hideDependencies={true}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { memo, useCallback, useState, useRef, useEffect, useMemo } from "react";
|
||||
import { Link, Clock, Layers, Pencil, ChevronDown, Folder, Target, Bot, Trash2 } from "lucide-react";
|
||||
import type { Task, TaskDetail, Column, PrInfo, IssueInfo } from "@fusion/core";
|
||||
import { COLUMN_LABELS, VALID_TRANSITIONS, getErrorMessage } from "@fusion/core";
|
||||
import type { Task, TaskDetail, Column, PrInfo, IssueInfo, TaskPriority } from "@fusion/core";
|
||||
import { COLUMN_LABELS, DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, VALID_TRANSITIONS, getErrorMessage } from "@fusion/core";
|
||||
import { fetchTaskDetail, uploadAttachment, fetchMission, fetchAgent } from "../api";
|
||||
import { GitHubBadge } from "./GitHubBadge";
|
||||
import { pickPreferredBadge } from "./TaskCardBadge";
|
||||
@@ -64,6 +64,12 @@ async function getAgentName(agentId: string, projectId?: string): Promise<string
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTaskPriorityValue(priority: Task["priority"]): TaskPriority {
|
||||
return typeof priority === "string" && (TASK_PRIORITIES as readonly string[]).includes(priority)
|
||||
? (priority as TaskPriority)
|
||||
: DEFAULT_TASK_PRIORITY;
|
||||
}
|
||||
|
||||
function abbreviateBadge(text: string, max: number): string {
|
||||
if (text.length <= max) return text;
|
||||
return text.slice(0, max - 3) + "...";
|
||||
@@ -252,6 +258,7 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
|
||||
previousTask.updatedAt === nextTask.updatedAt &&
|
||||
previousTask.createdAt === nextTask.createdAt &&
|
||||
previousTask.status === nextTask.status &&
|
||||
previousTask.priority === nextTask.priority &&
|
||||
previousTask.paused === nextTask.paused &&
|
||||
previousTask.error === nextTask.error &&
|
||||
previousTask.size === nextTask.size &&
|
||||
@@ -536,6 +543,8 @@ function TaskCardComponent({
|
||||
|
||||
const isFailed = task.status === "failed";
|
||||
const isPaused = task.paused === true;
|
||||
const normalizedPriority = normalizeTaskPriorityValue(task.priority);
|
||||
const showPriorityBadge = normalizedPriority !== DEFAULT_TASK_PRIORITY;
|
||||
const isStuck = isTaskStuck(task, taskStuckTimeoutMs, lastFetchTimeMs);
|
||||
const isAwaitingApproval = task.column === "triage" && task.status === "awaiting-approval";
|
||||
const isArchived = task.column === "archived";
|
||||
@@ -885,6 +894,11 @@ function TaskCardComponent({
|
||||
issueInfo={liveIssueInfo}
|
||||
/>
|
||||
)}
|
||||
{showPriorityBadge && (
|
||||
<span className={`card-priority-badge card-priority-badge--${normalizedPriority}`}>
|
||||
{normalizedPriority}
|
||||
</span>
|
||||
)}
|
||||
{task.missionId && (
|
||||
<span
|
||||
className="card-mission-badge"
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Pencil, Bot, X, ChevronDown } from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { Task, TaskDetail, TaskAttachment, Column, MergeResult, Settings, AgentLogEntry, Agent } from "@fusion/core";
|
||||
import { COLUMN_LABELS, VALID_TRANSITIONS, getErrorMessage } from "@fusion/core";
|
||||
import type { Task, TaskDetail, TaskAttachment, Column, MergeResult, Settings, AgentLogEntry, Agent, TaskPriority } from "@fusion/core";
|
||||
import { COLUMN_LABELS, DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, VALID_TRANSITIONS, getErrorMessage } from "@fusion/core";
|
||||
import { uploadAttachment, deleteAttachment, updateTask, pauseTask, unpauseTask, fetchTaskDetail, fetchSettings, requestSpecRevision, rebuildTaskSpec, approvePlan, rejectPlan, refineTask, fetchWorkflowResults, assignTask, fetchAgents, fetchAgent } from "../api";
|
||||
import type { WorkflowStepResult } from "@fusion/core";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
@@ -210,6 +210,12 @@ function splitModelSelection(value: string): { provider: string; modelId: string
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTaskPriorityValue(priority: Task["priority"]): TaskPriority {
|
||||
return typeof priority === "string" && (TASK_PRIORITIES as readonly string[]).includes(priority)
|
||||
? (priority as TaskPriority)
|
||||
: DEFAULT_TASK_PRIORITY;
|
||||
}
|
||||
|
||||
const DESCRIPTION_TRUNCATE_LENGTH = 200;
|
||||
|
||||
const EDITABLE_COLUMNS: Set<Column> = new Set(["triage", "todo"]);
|
||||
@@ -324,6 +330,7 @@ export function TaskDetailModal({
|
||||
const [editThinkingLevel, setEditThinkingLevel] = useState("");
|
||||
const [editPresetMode, setEditPresetMode] = useState<"default" | "preset" | "custom">("default");
|
||||
const [editReviewLevel, setEditReviewLevel] = useState<number | undefined>(undefined);
|
||||
const [editPriority, setEditPriority] = useState<TaskPriority>(DEFAULT_TASK_PRIORITY);
|
||||
const [editSelectedPresetId, setEditSelectedPresetId] = useState("");
|
||||
const [editSelectedWorkflowSteps, setEditSelectedWorkflowSteps] = useState<string[]>(task.enabledWorkflowSteps || []);
|
||||
const [editPendingImages, setEditPendingImages] = useState<PendingImage[]>([]);
|
||||
@@ -530,6 +537,7 @@ export function TaskDetailModal({
|
||||
setEditSelectedWorkflowSteps(task.enabledWorkflowSteps || []);
|
||||
setEditPendingImages([]);
|
||||
setEditReviewLevel(task.reviewLevel);
|
||||
setEditPriority(normalizeTaskPriorityValue(task.priority));
|
||||
}, [canEdit, task]);
|
||||
|
||||
const exitEditMode = useCallback(() => {
|
||||
@@ -537,9 +545,10 @@ export function TaskDetailModal({
|
||||
setEditTitle(task.title || "");
|
||||
setEditDescription(task.description || "");
|
||||
setEditDependencies(task.dependencies || []);
|
||||
setEditPriority(normalizeTaskPriorityValue(task.priority));
|
||||
editPendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl));
|
||||
setEditPendingImages([]);
|
||||
}, [task.title, task.description, task.dependencies, editPendingImages]);
|
||||
}, [task.title, task.description, task.dependencies, task.priority, editPendingImages]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setIsSaving(true);
|
||||
@@ -592,6 +601,11 @@ export function TaskDetailModal({
|
||||
updates.reviewLevel = editReviewLevel;
|
||||
}
|
||||
|
||||
const currentPriority = normalizeTaskPriorityValue(task.priority);
|
||||
if (editPriority !== currentPriority) {
|
||||
updates.priority = editPriority;
|
||||
}
|
||||
|
||||
const hasTaskUpdates = Object.keys(updates).length > 0;
|
||||
if (hasTaskUpdates) {
|
||||
const updatedTask = await updateTask(task.id, updates, projectId);
|
||||
@@ -626,7 +640,7 @@ export function TaskDetailModal({
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
}, [task, editTitle, editDescription, editDependencies, editExecutorModel, editValidatorModel, editPlanningModel, editThinkingLevel, editReviewLevel, editSelectedWorkflowSteps, editPendingImages, addToast, projectId, onTaskUpdated]);
|
||||
}, [task, editTitle, editDescription, editDependencies, editExecutorModel, editValidatorModel, editPlanningModel, editThinkingLevel, editReviewLevel, editPriority, editSelectedWorkflowSteps, editPendingImages, addToast, projectId, onTaskUpdated]);
|
||||
|
||||
const handleAutoSaveDescription = useCallback(async (description: string) => {
|
||||
try {
|
||||
@@ -1180,6 +1194,8 @@ export function TaskDetailModal({
|
||||
onAutoSaveDescription={handleAutoSaveDescription}
|
||||
reviewLevel={editReviewLevel}
|
||||
onReviewLevelChange={setEditReviewLevel}
|
||||
priority={editPriority}
|
||||
onPriorityChange={setEditPriority}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
@@ -1205,7 +1221,10 @@ export function TaskDetailModal({
|
||||
})()}
|
||||
<div className="detail-meta">
|
||||
Created {new Date(task.createdAt).toLocaleDateString()} · Updated{" "}
|
||||
{new Date(task.updatedAt).toLocaleDateString()}
|
||||
{new Date(task.updatedAt).toLocaleDateString()} ·
|
||||
<span className={`detail-priority-chip detail-priority-chip--${normalizeTaskPriorityValue(task.priority)}`}>
|
||||
Priority: {normalizeTaskPriorityValue(task.priority)}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useCallback, useEffect, useRef, type ReactNode } from "react";
|
||||
import type { Task, Settings, WorkflowStep } from "@fusion/core";
|
||||
import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, type Task, type TaskPriority, type Settings, type WorkflowStep } from "@fusion/core";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { fetchModels, fetchSettings, fetchWorkflowSteps, refineText, getRefineErrorMessage, updateGlobalSettings, type RefinementType, type ModelInfo } from "../api";
|
||||
import { applyPresetToSelection, getRecommendedPresetForSize } from "../utils/modelPresets";
|
||||
@@ -40,6 +40,8 @@ export interface TaskFormProps {
|
||||
onDependenciesChange: (deps: string[]) => void;
|
||||
|
||||
// Model configuration
|
||||
priority?: TaskPriority;
|
||||
onPriorityChange?: (value: TaskPriority) => void;
|
||||
executorModel: string;
|
||||
onExecutorModelChange: (value: string) => void;
|
||||
validatorModel: string;
|
||||
@@ -96,6 +98,8 @@ export function TaskForm({
|
||||
onTitleChange,
|
||||
dependencies,
|
||||
onDependenciesChange,
|
||||
priority,
|
||||
onPriorityChange,
|
||||
executorModel,
|
||||
onExecutorModelChange,
|
||||
validatorModel,
|
||||
@@ -132,6 +136,7 @@ export function TaskForm({
|
||||
pendingImages.length > 0 ||
|
||||
selectedWorkflowSteps.length > 0 ||
|
||||
presetMode !== "default" ||
|
||||
(priority ?? DEFAULT_TASK_PRIORITY) !== DEFAULT_TASK_PRIORITY ||
|
||||
executorModel !== "" ||
|
||||
validatorModel !== "" ||
|
||||
(planningModel || "") !== "" ||
|
||||
@@ -193,6 +198,7 @@ export function TaskForm({
|
||||
pendingImages.length > 0 ||
|
||||
selectedWorkflowSteps.length > 0 ||
|
||||
presetMode !== "default" ||
|
||||
(priority ?? DEFAULT_TASK_PRIORITY) !== DEFAULT_TASK_PRIORITY ||
|
||||
executorModel !== "" ||
|
||||
validatorModel !== "" ||
|
||||
(planningModel || "") !== "" ||
|
||||
@@ -841,6 +847,24 @@ export function TaskForm({
|
||||
{/* Model Selection */}
|
||||
<div className="form-group">
|
||||
<label>Model Configuration</label>
|
||||
{onPriorityChange && (
|
||||
<div className="model-select-row">
|
||||
<label htmlFor="task-priority" className="model-select-label">Priority</label>
|
||||
<select
|
||||
id="task-priority"
|
||||
data-testid="task-priority-select"
|
||||
value={priority ?? DEFAULT_TASK_PRIORITY}
|
||||
onChange={(e) => onPriorityChange(e.target.value as TaskPriority)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{TASK_PRIORITIES.map((taskPriority) => (
|
||||
<option key={taskPriority} value={taskPriority}>
|
||||
{taskPriority[0].toUpperCase() + taskPriority.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{modelsLoading ? (
|
||||
<div className="model-selector-loading">Loading models…</div>
|
||||
) : availableModels.length === 0 ? (
|
||||
|
||||
@@ -878,6 +878,29 @@ describe("InlineCreateCard button visibility when collapsed", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("includes priority in submit payload and resets to normal after successful create", async () => {
|
||||
const mockOnSubmit = vi.fn().mockResolvedValue(createMockTask());
|
||||
renderCard([], { onSubmit: mockOnSubmit });
|
||||
expandCard();
|
||||
|
||||
fireEvent.change(screen.getByTestId("inline-create-priority-select"), { target: { value: "urgent" } });
|
||||
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), {
|
||||
target: { value: "Task with urgent priority" },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId("save-button"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
priority: "urgent",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expandCard();
|
||||
expect(screen.getByTestId("inline-create-priority-select")).toHaveValue("normal");
|
||||
});
|
||||
|
||||
describe("Consolidated controls layout (FN-781, FN-1292)", () => {
|
||||
it("renders Plan, Subtask, Deps, Agent, and Models together in footer controls when expanded", () => {
|
||||
renderCard();
|
||||
@@ -887,10 +910,11 @@ describe("InlineCreateCard button visibility when collapsed", () => {
|
||||
const controlsRow = document.querySelector(".inline-create-controls");
|
||||
expect(controlsRow).toBeTruthy();
|
||||
|
||||
// Plan, Subtask, Deps, Agent, Browser Verify, Preset, Models all in one row
|
||||
// Plan, Subtask, Deps, Agent, Browser Verify, Priority, Preset, Models all in one row
|
||||
expect(controlsRow!.contains(screen.getByTestId("plan-button"))).toBe(true);
|
||||
expect(controlsRow!.contains(screen.getByTestId("subtask-button"))).toBe(true);
|
||||
expect(controlsRow!.contains(screen.getByTestId("inline-create-agent-button"))).toBe(true);
|
||||
expect(controlsRow!.contains(screen.getByTestId("inline-create-priority-select"))).toBe(true);
|
||||
const depsButton = screen.getByText(/Deps/);
|
||||
expect(controlsRow!.contains(depsButton)).toBe(true);
|
||||
const modelsButton = screen.getByRole("button", { name: /Models/i });
|
||||
|
||||
@@ -706,6 +706,59 @@ describe("NewTaskModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("priority selection payload", () => {
|
||||
it("includes default normal priority in create payload", async () => {
|
||||
const { props } = renderNewTaskModal();
|
||||
|
||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Task with default priority" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onCreateTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
priority: "normal",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("includes selected priority and resets back to normal after submit", async () => {
|
||||
const { props } = renderNewTaskModal();
|
||||
|
||||
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
|
||||
fireEvent.change(screen.getByTestId("task-priority-select"), { target: { value: "urgent" } });
|
||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Task with urgent priority" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onCreateTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
priority: "urgent",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("task-priority-select")).toHaveValue("normal");
|
||||
});
|
||||
});
|
||||
|
||||
it("treats non-default priority as dirty state on cancel", () => {
|
||||
renderNewTaskModal();
|
||||
|
||||
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
|
||||
fireEvent.change(screen.getByTestId("task-priority-select"), { target: { value: "high" } });
|
||||
|
||||
const originalConfirm = window.confirm;
|
||||
window.confirm = vi.fn().mockReturnValue(false);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
|
||||
expect(window.confirm).toHaveBeenCalledWith("You have unsaved changes. Discard them?");
|
||||
window.confirm = originalConfirm;
|
||||
});
|
||||
});
|
||||
|
||||
// Agent assignment tests (FN-1483)
|
||||
describe("agent assignment", () => {
|
||||
it("renders agent picker button", () => {
|
||||
|
||||
@@ -571,6 +571,37 @@ describe("TaskCard size badge", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskCard priority badge", () => {
|
||||
const noopToast = vi.fn();
|
||||
|
||||
it("renders a badge for non-default priorities", () => {
|
||||
render(<TaskCard task={makeTask({ priority: "urgent" })} onOpenDetail={vi.fn()} addToast={noopToast} />);
|
||||
|
||||
const badge = screen.getByText("urgent");
|
||||
expect(badge.classList.contains("card-priority-badge")).toBe(true);
|
||||
expect(badge.classList.contains("card-priority-badge--urgent")).toBe(true);
|
||||
});
|
||||
|
||||
it("hides the priority badge for default and missing priority", () => {
|
||||
const { rerender } = render(<TaskCard task={makeTask({ priority: "normal" })} onOpenDetail={vi.fn()} addToast={noopToast} />);
|
||||
expect(screen.queryByText("normal")).toBeNull();
|
||||
|
||||
rerender(<TaskCard task={makeTask({ priority: undefined })} onOpenDetail={vi.fn()} addToast={noopToast} />);
|
||||
expect(screen.queryByText("normal")).toBeNull();
|
||||
});
|
||||
|
||||
it("re-renders when priority changes", () => {
|
||||
const task = makeTask({ id: "FN-PRIORITY", priority: "normal" });
|
||||
const { rerender } = render(<TaskCard task={task} onOpenDetail={vi.fn()} addToast={noopToast} />);
|
||||
|
||||
expect(screen.queryByText("high")).toBeNull();
|
||||
|
||||
rerender(<TaskCard task={{ ...task, priority: "high" }} onOpenDetail={vi.fn()} addToast={noopToast} />);
|
||||
|
||||
expect(screen.getByText("high")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Tests for inline editing functionality in TaskCard.
|
||||
*/
|
||||
@@ -3852,7 +3883,7 @@ describe("TaskCard PluginSlot integration", () => {
|
||||
description: "Test description",
|
||||
column: "todo",
|
||||
status: "todo",
|
||||
priority: 0,
|
||||
priority: "normal",
|
||||
size: "M",
|
||||
dependencies: ["FN-001"],
|
||||
steps: [],
|
||||
@@ -3893,7 +3924,7 @@ describe("TaskCard PluginSlot integration", () => {
|
||||
title: "Task without deps",
|
||||
column: "todo",
|
||||
status: "todo",
|
||||
priority: 0,
|
||||
priority: "normal",
|
||||
size: "M",
|
||||
steps: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
|
||||
@@ -4523,6 +4523,57 @@ describe("TaskDetailModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("includes priority in update payload only when changed", async () => {
|
||||
const { updateTask } = await import("../../api");
|
||||
const mockUpdate = vi.mocked(updateTask);
|
||||
mockUpdate.mockResolvedValue({ id: "FN-001" } as Task);
|
||||
|
||||
const { container } = render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ id: "FN-001", column: "triage", title: "Test", description: "Desc", priority: "normal" })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(container.querySelector(".modal-edit-btn")!);
|
||||
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
fireEvent.click(container.querySelector(".modal-edit-btn")!);
|
||||
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
|
||||
fireEvent.change(container.querySelector("#task-priority") as HTMLSelectElement, { target: { value: "urgent" } });
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdate).toHaveBeenCalledWith("FN-001", { priority: "urgent" }, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it("renders normalized priority in detail metadata", () => {
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ id: "FN-001", column: "triage", description: "Priority metadata", priority: undefined })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Priority: normal")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("pre-populates form with existing task values", () => {
|
||||
const { container } = render(
|
||||
<TaskDetailModal
|
||||
|
||||
@@ -169,6 +169,29 @@ describe("TaskForm", () => {
|
||||
expect(screen.getByText(/Model Configuration/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders priority select with default normal value when enabled", () => {
|
||||
renderTaskForm({ onPriorityChange: vi.fn() });
|
||||
|
||||
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
|
||||
expect(screen.getByTestId("task-priority-select")).toHaveValue("normal");
|
||||
});
|
||||
|
||||
it("calls onPriorityChange when priority selection changes", () => {
|
||||
const onPriorityChange = vi.fn();
|
||||
renderTaskForm({ onPriorityChange });
|
||||
|
||||
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
|
||||
fireEvent.change(screen.getByTestId("task-priority-select"), { target: { value: "urgent" } });
|
||||
|
||||
expect(onPriorityChange).toHaveBeenCalledWith("urgent");
|
||||
});
|
||||
|
||||
it("auto-expands more options when priority is non-default", () => {
|
||||
renderTaskForm({ priority: "high", onPriorityChange: vi.fn() });
|
||||
|
||||
expect(screen.getByTestId("task-form-more-options-toggle")).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
|
||||
it("fetches and stores favoriteModels from fetchModels response", async () => {
|
||||
const { fetchModels } = await import("../../api");
|
||||
vi.mocked(fetchModels).mockResolvedValueOnce({
|
||||
|
||||
@@ -410,6 +410,13 @@ describe("InlineCreateCard mobile", () => {
|
||||
expectRuleToContain(mobileSection, ".inline-create-controls .btn", "min-height: 36px;");
|
||||
});
|
||||
|
||||
it("contains .inline-create-priority-select min-height: 36px in the mobile media block", () => {
|
||||
const css = fs.readFileSync(stylesPath, "utf-8");
|
||||
const mobileSection = getMainMobileSection(css);
|
||||
|
||||
expectRuleToContain(mobileSection, ".inline-create-priority-select", "min-height: 36px;");
|
||||
});
|
||||
|
||||
it("renders Plan and Subtask buttons when expanded", () => {
|
||||
render(
|
||||
<InlineCreateCard
|
||||
|
||||
@@ -1610,6 +1610,32 @@ body {
|
||||
color: var(--triage);
|
||||
}
|
||||
|
||||
.card-priority-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
.card-priority-badge--low {
|
||||
background: color-mix(in srgb, var(--color-info) 15%, transparent);
|
||||
color: var(--color-info);
|
||||
}
|
||||
|
||||
.card-priority-badge--high {
|
||||
background: color-mix(in srgb, var(--color-warning) 18%, transparent);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.card-priority-badge--urgent {
|
||||
background: color-mix(in srgb, var(--color-error) 20%, transparent);
|
||||
color: var(--color-error-dark);
|
||||
}
|
||||
|
||||
.card.failed {
|
||||
border-left: 3px solid var(--color-error-dark);
|
||||
}
|
||||
@@ -4399,6 +4425,37 @@ input[type="range"]:focus-visible {
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.detail-priority-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: var(--space-xs);
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius-pill);
|
||||
text-transform: capitalize;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.detail-priority-chip--low {
|
||||
background: color-mix(in srgb, var(--color-info) 14%, transparent);
|
||||
color: var(--color-info);
|
||||
}
|
||||
|
||||
.detail-priority-chip--normal {
|
||||
background: color-mix(in srgb, var(--text-muted) 18%, transparent);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.detail-priority-chip--high {
|
||||
background: color-mix(in srgb, var(--color-warning) 18%, transparent);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.detail-priority-chip--urgent {
|
||||
background: color-mix(in srgb, var(--color-error) 20%, transparent);
|
||||
color: var(--color-error-dark);
|
||||
}
|
||||
|
||||
/* Error alert in task detail modal */
|
||||
.detail-error-alert {
|
||||
display: flex;
|
||||
@@ -6526,6 +6583,15 @@ input[type="range"]:focus-visible {
|
||||
padding: 3px 8px;
|
||||
}
|
||||
|
||||
.inline-create-priority-wrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.inline-create-priority-select {
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
.dep-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
@@ -7668,6 +7734,10 @@ input[type="range"]:focus-visible {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.inline-create-priority-select {
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
/* Inline create: constrain dependency dropdown to card width */
|
||||
.dep-dropdown {
|
||||
left: 0;
|
||||
|
||||
Reference in New Issue
Block a user