feat(FN-3183): add inline priority picker to quick-entry box

Adds an inline priority picker to the Quick Entry Box and Task Detail Modal with shared badge styling and proper mobile tap targets (FN-3183, FN-3186), updates agent tools tests and documentation for layered memory access patterns (FN-3179), and fixes styling alignment between the modal and inline c

Fusion-Task-Id: FN-3183
This commit is contained in:
Fusion
2026-05-02 03:38:12 -07:00
committed by gsxdsm
parent 99b8183f01
commit 19142cfc98
4 changed files with 184 additions and 24 deletions

View File

@@ -156,36 +156,46 @@
}
.detail-priority-chip {
display: inline-flex;
align-items: center;
--detail-priority-control-min-height: calc(var(--space-2xl) + var(--space-xs));
gap: var(--space-xs);
margin-left: var(--space-xs);
padding: 1px 6px;
border-radius: var(--radius-pill);
text-transform: capitalize;
font-weight: 600;
letter-spacing: 0.2px;
min-height: var(--detail-priority-control-min-height);
}
.detail-priority-chip--low {
background: color-mix(in srgb, var(--color-info) 14%, transparent);
color: var(--color-info);
.detail-priority-chip--saving {
opacity: 0.75;
}
.detail-priority-chip--normal {
background: color-mix(in srgb, var(--text-muted) 18%, transparent);
color: var(--text-muted);
.detail-priority-select {
border: 0;
background: transparent;
color: inherit;
font: inherit;
text-transform: uppercase;
letter-spacing: inherit;
cursor: pointer;
padding: 0;
min-height: inherit;
}
.detail-priority-chip--high {
background: color-mix(in srgb, var(--color-warning) 18%, transparent);
color: var(--color-warning);
.detail-priority-select:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
border-radius: var(--radius-sm);
}
.detail-priority-chip--urgent {
background: color-mix(in srgb, var(--color-error) 20%, transparent);
color: var(--color-error-dark);
.detail-priority-select:disabled {
cursor: wait;
}
.detail-priority-select option {
color: var(--text);
background: var(--surface);
text-transform: uppercase;
}
/* Error alert in task detail modal */
.detail-error-alert {
display: flex;
@@ -1375,6 +1385,15 @@
grid-template-columns: 1fr;
}
.detail-priority-chip {
margin-left: 0;
margin-top: var(--space-xs);
}
.detail-priority-select {
min-height: var(--detail-priority-control-min-height);
}
.detail-source-header {
align-items: flex-start;
}

View File

@@ -429,6 +429,8 @@ export function TaskDetailModal({
const [editSourceIssueUrl, setEditSourceIssueUrl] = useState(task.sourceIssue?.url ?? "");
const [editPendingImages, setEditPendingImages] = useState<PendingImage[]>([]);
const [isSaving, setIsSaving] = useState(false);
const [inlinePriority, setInlinePriority] = useState<TaskPriority>(normalizeTaskPriorityValue(task.priority));
const [isSavingInlinePriority, setIsSavingInlinePriority] = useState(false);
const mountedRef = useRef(false);
// Split-menu dropdown state for footer actions
@@ -487,6 +489,10 @@ export function TaskDetailModal({
setWorkflowEnabledSteps(task.enabledWorkflowSteps || []);
}, [task.id, task.enabledWorkflowSteps]);
useEffect(() => {
setInlinePriority(normalizeTaskPriorityValue(task.priority));
}, [task.id, task.priority]);
// Load merged settings for effective model resolution
useEffect(() => {
let cancelled = false;
@@ -862,6 +868,34 @@ export function TaskDetailModal({
}
}, [task.id, addToast, projectId, onTaskUpdated]);
const handleInlinePriorityChange = useCallback(async (nextValue: string) => {
const normalizedNextPriority = normalizeTaskPriorityValue(nextValue as Task["priority"]);
const currentPriority = normalizeTaskPriorityValue(task.priority);
if (normalizedNextPriority === currentPriority) {
setInlinePriority(currentPriority);
return;
}
const previousPriority = inlinePriority;
setInlinePriority(normalizedNextPriority);
setIsSavingInlinePriority(true);
try {
const updatedTask = await updateTask(task.id, { priority: normalizedNextPriority }, projectId);
setInlinePriority(normalizeTaskPriorityValue(updatedTask.priority));
onTaskUpdated?.(updatedTask);
addToast(`Priority updated to ${normalizeTaskPriorityValue(updatedTask.priority)}`, "success");
} catch (err) {
setInlinePriority(previousPriority);
addToast(`Failed to update ${task.id}: ${getErrorMessage(err)}`, "error");
} finally {
if (mountedRef.current) {
setIsSavingInlinePriority(false);
}
}
}, [task.id, task.priority, projectId, inlinePriority, onTaskUpdated, addToast]);
// Handle keyboard shortcuts for edit mode
const handleEditKeyDown = useCallback((e: KeyboardEvent) => {
if (!isEditing) return;
@@ -1608,9 +1642,26 @@ export function TaskDetailModal({
<div className="detail-meta">
Created {new Date(task.createdAt).toLocaleDateString()} · Updated{" "}
{new Date(task.updatedAt).toLocaleDateString()} ·
<span className={`detail-priority-chip detail-priority-chip--${normalizeTaskPriorityValue(task.priority)}`}>
Priority: {normalizeTaskPriorityValue(task.priority)}
</span>
<label
className={`card-priority-badge card-priority-badge--${inlinePriority} detail-priority-chip ${isSavingInlinePriority ? "detail-priority-chip--saving" : ""}`}
>
<span>Priority:</span>
<select
className="detail-priority-select"
value={inlinePriority}
onChange={(event) => {
void handleInlinePriorityChange(event.target.value);
}}
disabled={isSavingInlinePriority}
aria-label="Task priority"
>
{TASK_PRIORITIES.map((priorityOption) => (
<option key={priorityOption} value={priorityOption}>
{priorityOption}
</option>
))}
</select>
</label>
{provenanceDisplay && (
<div className="detail-provenance">
<GitBranch aria-hidden="true" />

View File

@@ -5141,7 +5141,7 @@ describe("TaskDetailModal", () => {
});
});
it("renders normalized priority in detail metadata", () => {
it("renders normalized priority in detail metadata", async () => {
render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "triage", description: "Priority metadata", priority: undefined })}
@@ -5154,7 +5154,95 @@ describe("TaskDetailModal", () => {
/>,
);
expect(screen.getByText("Priority: normal")).toBeTruthy();
const prioritySelect = screen.getByRole("combobox", { name: "Task priority" }) as HTMLSelectElement;
expect(prioritySelect.value).toBe("normal");
});
it("updates priority inline and propagates successful save", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
const onTaskUpdated = vi.fn();
const addToast = vi.fn();
const updatedTask = makeTask({ id: "FN-001", column: "triage", priority: "urgent" });
mockUpdate.mockResolvedValueOnce(updatedTask as Task);
render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "triage", description: "Priority metadata", priority: "normal" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
onTaskUpdated={onTaskUpdated}
addToast={addToast}
/>,
);
fireEvent.change(screen.getByRole("combobox", { name: "Task priority" }), {
target: { value: "urgent" },
});
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith("FN-001", { priority: "urgent" }, undefined);
});
expect(onTaskUpdated).toHaveBeenCalledWith(updatedTask);
expect(addToast).toHaveBeenCalledWith("Priority updated to urgent", "success");
});
it("does not call updateTask when inline priority is unchanged", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "triage", description: "Priority metadata", priority: "high" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.change(screen.getByRole("combobox", { name: "Task priority" }), {
target: { value: "high" },
});
await waitFor(() => {
expect(mockUpdate).not.toHaveBeenCalled();
});
});
it("reverts inline priority when save fails", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
const addToast = vi.fn();
mockUpdate.mockRejectedValueOnce(new Error("Request failed"));
render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "triage", description: "Priority metadata", priority: "low" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={addToast}
/>,
);
const prioritySelect = screen.getByRole("combobox", { name: "Task priority" }) as HTMLSelectElement;
fireEvent.change(prioritySelect, { target: { value: "urgent" } });
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith("FN-001", { priority: "urgent" }, undefined);
});
await waitFor(() => {
expect(prioritySelect.value).toBe("low");
});
expect(addToast).toHaveBeenCalledWith("Failed to update FN-001: Request failed", "error");
});
it("pre-populates form with existing task values", () => {