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:
Fusion
2026-04-24 15:00:43 -07:00
committed by gsxdsm
parent 269b13ecb1
commit a6dd98957c
13 changed files with 364 additions and 17 deletions

View File

@@ -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),

View File

@@ -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"

View File

@@ -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}
/>

View File

@@ -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"

View File

@@ -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>
</>
)}

View File

@@ -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 ? (

View File

@@ -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 });

View File

@@ -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", () => {

View File

@@ -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(),

View File

@@ -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

View File

@@ -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({

View File

@@ -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

View File

@@ -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;