feat(FN-4371): complete remaining decision-only task handling

Fusion-Task-Id: FN-4371
Fusion-Task-Lineage: 4175ba4c-b7cb-4f3a-87c5-75e51f06c0ff
This commit is contained in:
Fusion
2026-05-14 09:52:19 -07:00
committed by gsxdsm
parent c8a2caf948
commit 77308601e5
9 changed files with 188 additions and 1 deletions

View File

@@ -132,7 +132,8 @@
.card-status-badge,
.card-priority-badge,
.card-size-badge {
.card-size-badge,
.card-no-commits-expected-badge {
display: inline-flex;
align-items: center;
font-size: 0.625rem;
@@ -148,6 +149,13 @@
letter-spacing: 0.5px;
}
.card-no-commits-expected-badge {
background: color-mix(in srgb, var(--text-muted) 18%, transparent);
border-color: color-mix(in srgb, var(--text-muted) 35%, transparent);
color: var(--text-muted);
text-transform: lowercase;
}
.card-status-badge.stalled-review {
background: color-mix(in srgb, var(--color-warning) 18%, transparent);
color: var(--color-warning);

View File

@@ -1519,6 +1519,9 @@ function TaskCardComponent({
<span className="visually-hidden">Fast mode</span>
</span>
)}
{task.noCommitsExpected === true && (
<span className="card-no-commits-expected-badge" title="Decision-only task">decision-only</span>
)}
{task.missionId && (
<span
className="card-mission-badge"

View File

@@ -612,6 +612,8 @@ export function TaskDetailContent({
const [isSavingInlinePriority, setIsSavingInlinePriority] = useState(false);
const [inlineExecutionMode, setInlineExecutionMode] = useState<"standard" | "fast">(normalizeExecutionModeValue(task.executionMode));
const [isSavingInlineExecutionMode, setIsSavingInlineExecutionMode] = useState(false);
const [inlineNoCommitsExpected, setInlineNoCommitsExpected] = useState<boolean>(task.noCommitsExpected === true);
const [isSavingInlineNoCommitsExpected, setIsSavingInlineNoCommitsExpected] = useState(false);
const mountedRef = useRef(false);
// Split-menu dropdown state for footer actions
@@ -689,6 +691,10 @@ export function TaskDetailContent({
setInlineExecutionMode(normalizeExecutionModeValue(task.executionMode));
}, [task.id, task.executionMode]);
useEffect(() => {
setInlineNoCommitsExpected(task.noCommitsExpected === true);
}, [task.id, task.noCommitsExpected]);
useEffect(() => {
if (githubTrackingEnabledDraft === null) return;
if ((workingTask.githubTracking?.enabled === true) === githubTrackingEnabledDraft) {
@@ -1231,6 +1237,29 @@ export function TaskDetailContent({
}
}, [task.id, task.executionMode, projectId, inlineExecutionMode, onTaskUpdated, addToast]);
const handleInlineNoCommitsExpectedToggle = useCallback(async () => {
const nextValue = !inlineNoCommitsExpected;
const previousValue = inlineNoCommitsExpected;
setInlineNoCommitsExpected(nextValue);
setIsSavingInlineNoCommitsExpected(true);
try {
const updatedTask = await updateTask(task.id, { noCommitsExpected: nextValue }, projectId);
const normalizedUpdatedValue = updatedTask.noCommitsExpected === true;
setInlineNoCommitsExpected(normalizedUpdatedValue);
onTaskUpdated?.(updatedTask);
addToast(`No-commits expectation ${normalizedUpdatedValue ? "enabled" : "disabled"}`, "success");
} catch (err) {
setInlineNoCommitsExpected(previousValue);
addToast(`Failed to update ${task.id}: ${getErrorMessage(err)}`, "error");
} finally {
if (mountedRef.current) {
setIsSavingInlineNoCommitsExpected(false);
}
}
}, [task.id, projectId, inlineNoCommitsExpected, onTaskUpdated, addToast]);
// Handle keyboard shortcuts for edit mode
const handleEditKeyDown = useCallback((e: KeyboardEvent) => {
if (!isEditing) return;
@@ -2147,6 +2176,21 @@ export function TaskDetailContent({
<span>{inlineExecutionMode === "fast" ? "Fast" : "Standard"}</span>
</button>
</div>
<div className="form-group">
<label className="checkbox-label" htmlFor="detail-no-commits-expected-toggle">
<input
id="detail-no-commits-expected-toggle"
type="checkbox"
checked={inlineNoCommitsExpected}
disabled={isSavingInlineNoCommitsExpected}
onChange={() => {
void handleInlineNoCommitsExpectedToggle();
}}
/>
No commits expected (decision-only task)
</label>
<small>Allows the task to complete without producing git commits. Use for evaluation, verification, or audit tasks where the deliverable is the recorded decision.</small>
</div>
{provenanceDisplay && (
<div className="detail-provenance">
<GitBranch aria-hidden="true" />

View File

@@ -578,6 +578,16 @@ describe("TaskCard", () => {
expect(screen.queryByText("paused by agent")).toBeNull();
});
it("renders decision-only badge when noCommitsExpected is true", () => {
render(<TaskCard task={makeTask({ noCommitsExpected: true })} onOpenDetail={noop} addToast={noop} />);
expect(screen.getByText("decision-only")).toBeTruthy();
});
it("hides decision-only badge when noCommitsExpected is false", () => {
render(<TaskCard task={makeTask({ noCommitsExpected: false })} onOpenDetail={noop} addToast={noop} />);
expect(screen.queryByText("decision-only")).toBeNull();
});
it("does not render fan-out badge when fanout is missing or zero", () => {
const { container, rerender } = render(
<TaskCard task={makeTask({ column: "todo" })} onOpenDetail={noop} addToast={noop} />,

View File

@@ -1159,6 +1159,30 @@ describe("TaskDetailModal", () => {
});
});
it("toggles no-commits-expected checkbox and patches task", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
mockUpdate.mockResolvedValueOnce(makeTask({ id: "FN-001", column: "todo", noCommitsExpected: true }) as Task);
render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "todo", noCommitsExpected: false })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(screen.getByLabelText("No commits expected (decision-only task)"));
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith("FN-001", { noCommitsExpected: true }, undefined);
});
});
it("pre-populates form with existing task values", () => {
const { container } = render(
<TaskDetailModal