feat(KB-040): add break-into-subtasks toggle for task creation

- Add breakIntoSubtasks flag to core Task model and persist to store
- Plumb subtask flag through dashboard API routes with validation
- Add subtask toggle UI to inline task creation card
- Update triage agent to handle automatic subtask breakdown when enabled
- Add comprehensive tests for store, API routes, and UI components
- Include changeset for patch release documenting the new feature
This commit is contained in:
gsxdsm
2026-03-29 22:23:47 -07:00
parent 5aa464e5fa
commit 86efb14947
11 changed files with 327 additions and 13 deletions

View File

@@ -32,9 +32,16 @@ export async function fetchTaskDetail(id: string): Promise<TaskDetail> {
}
export function createTask(input: TaskCreateInput): Promise<Task> {
const { title, description, column, dependencies, breakIntoSubtasks } = input;
return api<Task>("/tasks", {
method: "POST",
body: JSON.stringify(input),
body: JSON.stringify({
title,
description,
column,
dependencies,
breakIntoSubtasks,
}),
});
}

View File

@@ -23,6 +23,7 @@ export function InlineCreateCard({ tasks, onSubmit, onCancel, addToast }: Inline
const [dependencies, setDependencies] = useState<string[]>([]);
const [showDeps, setShowDeps] = useState(false);
const [depSearch, setDepSearch] = useState("");
const [breakIntoSubtasks, setBreakIntoSubtasks] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
const inputRef = useRef<HTMLTextAreaElement>(null);
@@ -44,13 +45,19 @@ export function InlineCreateCard({ tasks, onSubmit, onCancel, addToast }: Inline
// relatedTarget is the element receiving focus — if it's inside the card, ignore
if (e.relatedTarget instanceof Node && card.contains(e.relatedTarget)) return;
// Only cancel if empty and dropdown is not open
if (description.trim() === "" && pendingImages.length === 0 && dependencies.length === 0 && !showDeps) {
if (
description.trim() === "" &&
pendingImages.length === 0 &&
dependencies.length === 0 &&
!breakIntoSubtasks &&
!showDeps
) {
onCancel();
}
};
card.addEventListener("focusout", handleFocusOut);
return () => card.removeEventListener("focusout", handleFocusOut);
}, [description, pendingImages, dependencies, showDeps, onCancel]);
}, [description, pendingImages, dependencies, breakIntoSubtasks, showDeps, onCancel]);
// Clean up object URLs on unmount to prevent memory leaks
useEffect(() => {
@@ -101,6 +108,7 @@ export function InlineCreateCard({ tasks, onSubmit, onCancel, addToast }: Inline
description: description.trim(),
column: "triage",
dependencies: dependencies.length ? dependencies : undefined,
breakIntoSubtasks,
});
// Upload pending images as attachments
@@ -128,7 +136,7 @@ export function InlineCreateCard({ tasks, onSubmit, onCancel, addToast }: Inline
} finally {
setSubmitting(false);
}
}, [description, dependencies, submitting, pendingImages, onSubmit, addToast]);
}, [description, dependencies, breakIntoSubtasks, submitting, pendingImages, onSubmit, addToast]);
const handleKeyDown = useCallback(
async (e: React.KeyboardEvent) => {
@@ -199,6 +207,17 @@ export function InlineCreateCard({ tasks, onSubmit, onCancel, addToast }: Inline
>
<Link size={12} style={{ verticalAlign: 'middle' }} />{dependencies.length > 0 ? ` ${dependencies.length} deps` : " Deps"}
</button>
{!submitting && (
<label className="inline-create-hint" style={{ display: "inline-flex", alignItems: "center", gap: 6, marginLeft: 8 }}>
<input
type="checkbox"
data-testid="break-into-subtasks-toggle"
checked={breakIntoSubtasks}
onChange={(e) => setBreakIntoSubtasks(e.target.checked)}
/>
Break into subtasks
</label>
)}
{showDeps && (() => {
const term = depSearch.toLowerCase();
const filtered = (term

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { InlineCreateCard } from "../InlineCreateCard";
import type { Task, Column } from "@kb/core";
@@ -187,3 +187,54 @@ describe("InlineCreateCard dependency dropdown search", () => {
expect(items[0].querySelector(".dep-dropdown-id")?.textContent).toBe("KB-002");
});
});
describe("InlineCreateCard breakIntoSubtasks toggle", () => {
it("renders toggle defaulted to off", () => {
renderCard();
const checkbox = screen.getByTestId("break-into-subtasks-toggle") as HTMLInputElement;
expect(checkbox.checked).toBe(false);
});
it("can be toggled on", () => {
renderCard();
const checkbox = screen.getByTestId("break-into-subtasks-toggle") as HTMLInputElement;
fireEvent.click(checkbox);
expect(checkbox.checked).toBe(true);
});
it("passes breakIntoSubtasks in submit payload", async () => {
const { props } = renderCard();
const textarea = screen.getByPlaceholderText("What needs to be done?");
const checkbox = screen.getByTestId("break-into-subtasks-toggle") as HTMLInputElement;
fireEvent.change(textarea, { target: { value: "Split this work" } });
fireEvent.click(checkbox);
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(props.onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
description: "Split this work",
breakIntoSubtasks: true,
}),
);
});
});
it("passes breakIntoSubtasks=false by default", async () => {
const { props } = renderCard();
const textarea = screen.getByPlaceholderText("What needs to be done?");
fireEvent.change(textarea, { target: { value: "Simple task" } });
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(props.onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
description: "Simple task",
breakIntoSubtasks: false,
}),
);
});
});
});

View File

@@ -1357,6 +1357,7 @@ describe("ListView Inline Create Card", () => {
expect(mockOnCreateTask).toHaveBeenCalledWith({
description: "New task description",
column: "triage",
breakIntoSubtasks: false,
});
});
});