feat(KB-247): add subtask breakdown dialog for AI-assisted task creation

- Add backend subtask breakdown session management and API endpoints
- Create SubtaskBreakdownModal component for generating AI-suggested subtasks
- Integrate subtask breakdown into QuickEntryBox and NewTaskModal flows
- Add frontend API client functions for breakdown sessions and subtask creation
- Add tests for SubtaskBreakdownModal and backend routes
- Create changeset documenting the subtask breakdown feature
This commit is contained in:
gsxdsm
2026-03-31 05:48:30 -07:00
parent 243da605b1
commit 34d47eddfd
10 changed files with 1279 additions and 49 deletions

View File

@@ -10,6 +10,7 @@ import { TerminalModal } from "./components/TerminalModal";
import { FileBrowserModal } from "./components/FileBrowserModal";
import { SettingsModal } from "./components/SettingsModal";
import { PlanningModeModal } from "./components/PlanningModeModal";
import { SubtaskBreakdownModal } from "./components/SubtaskBreakdownModal";
import type { SectionId } from "./components/SettingsModal";
import { ToastContainer } from "./components/ToastContainer";
import { GitHubImportModal } from "./components/GitHubImportModal";
@@ -27,6 +28,8 @@ function AppInner() {
const [newTaskModalOpen, setNewTaskModalOpen] = useState(false);
const [isPlanningOpen, setIsPlanningOpen] = useState(false);
const [planningInitialPlan, setPlanningInitialPlan] = useState<string | null>(null);
const [isSubtaskOpen, setIsSubtaskOpen] = useState(false);
const [subtaskInitialDescription, setSubtaskInitialDescription] = useState<string | null>(null);
const [detailTask, setDetailTask] = useState<TaskDetail | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const [schedulesOpen, setSchedulesOpen] = useState(false);
@@ -150,9 +153,20 @@ function AppInner() {
// Handle subtask breakdown from inline/quick create
const handleSubtaskBreakdown = useCallback((description: string) => {
// Placeholder for KB-247 integration
// For now, show a toast indicating this feature is coming
addToast("Subtask breakdown coming soon! Description: " + description.slice(0, 30) + "...", "info");
setSubtaskInitialDescription(description);
setIsSubtaskOpen(true);
}, []);
const handleSubtaskClose = useCallback(() => {
setIsSubtaskOpen(false);
setSubtaskInitialDescription(null);
}, []);
const handleSubtaskTasksCreated = useCallback((createdTasks: Task[]) => {
const ids = createdTasks.map((task) => task.id).join(", ");
addToast(`Created ${ids} from subtask breakdown`, "success");
setIsSubtaskOpen(false);
setSubtaskInitialDescription(null);
}, [addToast]);
// Usage indicator handlers
@@ -334,6 +348,12 @@ function AppInner() {
tasks={tasks}
initialPlan={planningInitialPlan ?? undefined}
/>
<SubtaskBreakdownModal
isOpen={isSubtaskOpen}
onClose={handleSubtaskClose}
initialDescription={subtaskInitialDescription ?? ""}
onTasksCreated={handleSubtaskTasksCreated}
/>
<TerminalModal
isOpen={terminalOpen}
onClose={handleTerminalClose}
@@ -363,6 +383,7 @@ function AppInner() {
onCreateTask={handleModalCreate}
addToast={addToast}
onPlanningMode={handleNewTaskPlanningMode}
onSubtaskBreakdown={handleSubtaskBreakdown}
/>
<ActivityLogModal
isOpen={activityLogOpen}

View File

@@ -922,6 +922,14 @@ export interface PlanningSession {
summary: PlanningSummary | null;
}
export interface SubtaskItem {
id: string;
title: string;
description: string;
suggestedSize: "S" | "M" | "L";
dependsOn: string[];
}
/** SSE event types for planning session streaming */
export type PlanningStreamEvent =
| { type: "thinking"; data: string }
@@ -1261,3 +1269,110 @@ export function getRefineErrorMessage(error: unknown): string {
return REFINE_ERROR_MESSAGES.NETWORK;
}
export function startSubtaskBreakdown(description: string): Promise<{ sessionId: string }> {
return api<{ sessionId: string }>("/subtasks/start-streaming", {
method: "POST",
body: JSON.stringify({ description }),
});
}
export function getSubtaskStreamUrl(sessionId: string): string {
return `/api/subtasks/${encodeURIComponent(sessionId)}/stream`;
}
export function connectSubtaskStream(
sessionId: string,
handlers: {
onThinking?: (data: string) => void;
onSubtasks?: (data: SubtaskItem[]) => void;
onError?: (data: string) => void;
onComplete?: () => void;
}
): { close: () => void; isConnected: () => boolean } {
const eventSource = new EventSource(getSubtaskStreamUrl(sessionId));
let isClosed = false;
eventSource.onopen = () => {
isClosed = false;
};
eventSource.addEventListener("thinking", (event: Event) => {
const messageEvent = event as MessageEvent;
try {
handlers.onThinking?.(JSON.parse(messageEvent.data));
} catch {
handlers.onThinking?.(messageEvent.data);
}
});
eventSource.addEventListener("subtasks", (event: Event) => {
try {
const messageEvent = event as MessageEvent;
handlers.onSubtasks?.(JSON.parse(messageEvent.data) as SubtaskItem[]);
} catch (err) {
console.error("[subtasks] Failed to parse subtasks event:", err);
}
});
eventSource.addEventListener("error", (event: Event) => {
try {
const messageEvent = event as MessageEvent;
handlers.onError?.(JSON.parse(messageEvent.data) as string);
} catch {
handlers.onError?.("Stream error");
}
isClosed = true;
eventSource.close();
});
eventSource.addEventListener("complete", () => {
handlers.onComplete?.();
isClosed = true;
eventSource.close();
});
eventSource.onerror = () => {
if (!isClosed) {
handlers.onError?.("Connection lost");
}
isClosed = true;
eventSource.close();
};
return {
close: () => {
isClosed = true;
eventSource.close();
},
isConnected: () => !isClosed,
};
}
export function createTasksFromBreakdown(
sessionId: string,
subtasks: SubtaskItem[],
parentTaskId?: string,
): Promise<{ tasks: Task[]; parentTaskClosed?: boolean }> {
return api<{ tasks: Task[]; parentTaskClosed?: boolean }>("/subtasks/create-tasks", {
method: "POST",
body: JSON.stringify({
sessionId,
parentTaskId,
subtasks: subtasks.map((subtask) => ({
tempId: subtask.id,
title: subtask.title,
description: subtask.description,
size: subtask.suggestedSize,
dependsOn: subtask.dependsOn,
})),
}),
});
}
export function cancelSubtaskBreakdown(sessionId: string): Promise<void> {
return api<void>("/subtasks/cancel", {
method: "POST",
body: JSON.stringify({ sessionId }),
});
}

View File

@@ -22,6 +22,7 @@ interface NewTaskModalProps {
onCreateTask: (input: TaskCreateInput) => Promise<Task>;
addToast: (message: string, type?: ToastType) => void;
onPlanningMode?: (initialPlan: string) => void;
onSubtaskBreakdown?: (description: string) => void;
}
/**
@@ -302,7 +303,7 @@ function ModelCombobox({
);
}
export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, onPlanningMode }: NewTaskModalProps) {
export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, onPlanningMode, onSubtaskBreakdown }: NewTaskModalProps) {
const [description, setDescription] = useState("");
const [dependencies, setDependencies] = useState<string[]>([]);
const [showDepDropdown, setShowDepDropdown] = useState(false);
@@ -316,7 +317,6 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
const [settings, setSettings] = useState<Settings | null>(null);
const [selectedPresetId, setSelectedPresetId] = useState<string>("");
const [presetMode, setPresetMode] = useState<"default" | "preset" | "custom">("default");
const [enablePlanningMode, setEnablePlanningMode] = useState(false);
const [hasDirtyState, setHasDirtyState] = useState(false);
const [workflowSteps, setWorkflowSteps] = useState<WorkflowStep[]>([]);
const [selectedWorkflowSteps, setSelectedWorkflowSteps] = useState<string[]>([]);
@@ -355,10 +355,9 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
pendingImages.length > 0 ||
executorModel !== "" ||
validatorModel !== "" ||
enablePlanningMode ||
selectedWorkflowSteps.length > 0;
setHasDirtyState(isDirty);
}, [description, dependencies, pendingImages, executorModel, validatorModel, enablePlanningMode, selectedWorkflowSteps]);
}, [description, dependencies, pendingImages, executorModel, validatorModel, selectedWorkflowSteps]);
const availablePresets = settings?.modelPresets || [];
const selectedPreset = availablePresets.find((preset) => preset.id === selectedPresetId);
@@ -478,7 +477,6 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
setValidatorModel("");
setSelectedPresetId("");
setPresetMode("default");
setEnablePlanningMode(false);
setSelectedWorkflowSteps([]);
setIsRefineMenuOpen(false);
setIsRefining(false);
@@ -490,33 +488,6 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
const trimmedDesc = description.trim();
if (!trimmedDesc || isSubmitting) return;
// Planning mode flow: skip task creation, open planning modal instead
if (enablePlanningMode && onPlanningMode) {
setIsSubmitting(true);
try {
// Clean up object URLs before closing
pendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl));
// Clear form state
setPendingImages([]);
setDescription("");
setDependencies([]);
setExecutorModel("");
setValidatorModel("");
setSelectedPresetId("");
setPresetMode("default");
setEnablePlanningMode(false);
setSelectedWorkflowSteps([]);
// Close modal and trigger planning mode
onClose();
onPlanningMode(trimmedDesc);
} finally {
setIsSubmitting(false);
}
return;
}
setIsSubmitting(true);
try {
// Create the base task
@@ -560,7 +531,6 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
setValidatorModel("");
setSelectedPresetId("");
setPresetMode("default");
setEnablePlanningMode(false);
setSelectedWorkflowSteps([]);
addToast(`Created ${task.id}`, "success");
@@ -570,7 +540,7 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
} finally {
setIsSubmitting(false);
}
}, [description, dependencies, pendingImages, executorModel, validatorModel, enablePlanningMode, isSubmitting, onCreateTask, addToast, onClose, onPlanningMode]);
}, [description, dependencies, pendingImages, executorModel, validatorModel, isSubmitting, onCreateTask, addToast, onClose]);
// Handle keyboard shortcuts
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
@@ -908,18 +878,43 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
</div>
)}
{/* Planning Mode Toggle */}
<div className="form-group">
<label className="checkbox-label">
<input
type="checkbox"
checked={enablePlanningMode}
onChange={(e) => setEnablePlanningMode(e.target.checked)}
disabled={isSubmitting}
/>
Enable planning mode
</label>
<small>AI will ask clarifying questions before creating the task specification</small>
<label>AI-assisted creation</label>
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
<button
type="button"
className="btn btn-sm"
onClick={() => {
const trimmed = description.trim();
if (!trimmed) {
addToast("Enter a description first", "error");
return;
}
handleClose();
onPlanningMode?.(trimmed);
}}
disabled={isSubmitting || !description.trim()}
>
Plan
</button>
<button
type="button"
className="btn btn-sm"
onClick={() => {
const trimmed = description.trim();
if (!trimmed) {
addToast("Enter a description first", "error");
return;
}
handleClose();
onSubtaskBreakdown?.(trimmed);
}}
disabled={isSubmitting || !description.trim()}
>
Subtask
</button>
</div>
<small>Use Plan for clarifying questions or Subtask to split the work into editable child tasks.</small>
</div>
{/* Attachments */}

View File

@@ -0,0 +1,118 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { SubtaskBreakdownModal } from "./SubtaskBreakdownModal";
const mockStartSubtaskBreakdown = vi.fn();
const mockConnectSubtaskStream = vi.fn();
const mockCreateTasksFromBreakdown = vi.fn();
const mockCancelSubtaskBreakdown = vi.fn();
vi.mock("../api", () => ({
startSubtaskBreakdown: (...args: any[]) => mockStartSubtaskBreakdown(...args),
connectSubtaskStream: (...args: any[]) => mockConnectSubtaskStream(...args),
createTasksFromBreakdown: (...args: any[]) => mockCreateTasksFromBreakdown(...args),
cancelSubtaskBreakdown: (...args: any[]) => mockCancelSubtaskBreakdown(...args),
}));
const SAMPLE_SUBTASKS = [
{ id: "subtask-1", title: "First", description: "Do first", suggestedSize: "S" as const, dependsOn: [] },
{ id: "subtask-2", title: "Second", description: "Do second", suggestedSize: "M" as const, dependsOn: ["subtask-1"] },
];
describe("SubtaskBreakdownModal", () => {
const onClose = vi.fn();
const onTasksCreated = vi.fn();
let streamHandlers: any;
beforeEach(() => {
vi.clearAllMocks();
streamHandlers = undefined;
mockStartSubtaskBreakdown.mockResolvedValue({ sessionId: "session-123" });
mockConnectSubtaskStream.mockImplementation((_sessionId, handlers) => {
streamHandlers = handlers;
return { close: vi.fn(), isConnected: () => true };
});
mockCreateTasksFromBreakdown.mockResolvedValue({ tasks: [{ id: "KB-101" }, { id: "KB-102" }] });
mockCancelSubtaskBreakdown.mockResolvedValue(undefined);
vi.stubGlobal("confirm", vi.fn(() => true));
});
afterEach(() => {
vi.unstubAllGlobals();
});
function renderModal() {
return render(
<SubtaskBreakdownModal
isOpen={true}
onClose={onClose}
initialDescription="Build a complex feature"
onTasksCreated={onTasksCreated}
/>,
);
}
it("shows generating state after auto-start", async () => {
renderModal();
await waitFor(() => expect(mockStartSubtaskBreakdown).toHaveBeenCalledWith("Build a complex feature"));
expect(await screen.findByText("AI is generating subtasks...")).toBeInTheDocument();
});
it("renders editable subtasks when stream returns items", async () => {
renderModal();
await waitFor(() => expect(streamHandlers).toBeDefined());
streamHandlers.onSubtasks(SAMPLE_SUBTASKS);
expect(await screen.findByDisplayValue("First")).toBeInTheDocument();
expect(screen.getByDisplayValue("Do second")).toBeInTheDocument();
});
it("adds and removes subtasks", async () => {
renderModal();
await waitFor(() => expect(streamHandlers).toBeDefined());
streamHandlers.onSubtasks([SAMPLE_SUBTASKS[0]]);
fireEvent.click(await screen.findByText("Add subtask"));
expect(screen.getAllByText(/subtask-/i).length).toBeGreaterThan(1);
fireEvent.click(screen.getByText(/Remove/));
await waitFor(() => expect(screen.queryByDisplayValue("First")).not.toBeInTheDocument());
});
it("changes size and dependency selection", async () => {
renderModal();
await waitFor(() => expect(streamHandlers).toBeDefined());
streamHandlers.onSubtasks(SAMPLE_SUBTASKS);
fireEvent.click(await screen.findAllByText("L").then((buttons) => buttons[0]!));
fireEvent.click(screen.getByLabelText(/subtask-1/i, { selector: 'input[type="checkbox"]' }));
expect(screen.getByText("subtask-1")).toBeInTheDocument();
});
it("saves via API with edited data", async () => {
renderModal();
await waitFor(() => expect(streamHandlers).toBeDefined());
streamHandlers.onSubtasks(SAMPLE_SUBTASKS);
const titleInputs = await screen.findAllByRole("textbox");
fireEvent.change(titleInputs[0], { target: { value: "Updated first" } });
fireEvent.click(screen.getByText("Create Tasks"));
await waitFor(() => expect(mockCreateTasksFromBreakdown).toHaveBeenCalled());
expect(onTasksCreated).toHaveBeenCalled();
expect(onClose).toHaveBeenCalled();
});
it("cancel closes modal", async () => {
renderModal();
await waitFor(() => expect(mockStartSubtaskBreakdown).toHaveBeenCalled());
fireEvent.click(await screen.findByLabelText("Close"));
await waitFor(() => expect(onClose).toHaveBeenCalled());
});
it("escape closes modal", async () => {
renderModal();
await waitFor(() => expect(mockStartSubtaskBreakdown).toHaveBeenCalled());
fireEvent.keyDown(document, { key: "Escape" });
await waitFor(() => expect(onClose).toHaveBeenCalled());
});
});

View File

@@ -0,0 +1,367 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { Task } from "@kb/core";
import {
startSubtaskBreakdown,
connectSubtaskStream,
createTasksFromBreakdown,
cancelSubtaskBreakdown,
type SubtaskItem,
} from "../api";
import { CheckCircle, Loader2, ListTree, Plus, Trash2, X } from "lucide-react";
interface SubtaskBreakdownModalProps {
isOpen: boolean;
onClose: () => void;
initialDescription: string;
onTasksCreated: (tasks: Task[]) => void;
parentTaskId?: string;
}
type ViewState =
| { type: "initial" }
| { type: "generating"; sessionId: string }
| { type: "editing"; sessionId: string }
| { type: "creating"; sessionId: string };
function createEmptySubtask(index: number): SubtaskItem {
return {
id: `subtask-${index}`,
title: "",
description: "",
suggestedSize: "M",
dependsOn: [],
};
}
function hasDependencyCycle(subtasks: SubtaskItem[]): boolean {
const graph = new Map(subtasks.map((item) => [item.id, item.dependsOn]));
const visiting = new Set<string>();
const visited = new Set<string>();
const visit = (id: string): boolean => {
if (visiting.has(id)) return true;
if (visited.has(id)) return false;
visiting.add(id);
for (const dep of graph.get(id) ?? []) {
if (graph.has(dep) && visit(dep)) return true;
}
visiting.delete(id);
visited.add(id);
return false;
};
return subtasks.some((item) => visit(item.id));
}
export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onTasksCreated, parentTaskId }: SubtaskBreakdownModalProps) {
const [view, setView] = useState<ViewState>({ type: "initial" });
const [subtasks, setSubtasks] = useState<SubtaskItem[]>([]);
const [thinkingOutput, setThinkingOutput] = useState("");
const [showThinking, setShowThinking] = useState(true);
const [error, setError] = useState<string | null>(null);
const [dirty, setDirty] = useState(false);
const streamRef = useRef<{ close: () => void; isConnected: () => boolean } | null>(null);
const titleRefs = useRef<Array<HTMLInputElement | null>>([]);
const autoStartedRef = useRef(false);
const sessionId = view.type === "generating" || view.type === "editing" || view.type === "creating"
? view.sessionId
: null;
const isInvalid = useMemo(() => {
if (subtasks.length === 0) return true;
if (subtasks.some((subtask) => !subtask.title.trim())) return true;
return hasDependencyCycle(subtasks);
}, [subtasks]);
const resetState = useCallback(() => {
streamRef.current?.close();
streamRef.current = null;
setView({ type: "initial" });
setSubtasks([]);
setThinkingOutput("");
setShowThinking(true);
setError(null);
setDirty(false);
autoStartedRef.current = false;
}, []);
const handleClose = useCallback(async () => {
if ((dirty || view.type === "editing" || view.type === "creating") && !confirm("Close subtask breakdown? Unsaved changes will be lost.")) {
return;
}
if (sessionId) {
try {
await cancelSubtaskBreakdown(sessionId);
} catch {
// ignore cancel errors
}
}
resetState();
onClose();
}, [dirty, onClose, resetState, sessionId, view.type]);
const beginBreakdown = useCallback(async () => {
if (!initialDescription.trim()) return;
setError(null);
setThinkingOutput("");
try {
const { sessionId } = await startSubtaskBreakdown(initialDescription.trim());
setView({ type: "generating", sessionId });
streamRef.current?.close();
streamRef.current = connectSubtaskStream(sessionId, {
onThinking: (data) => setThinkingOutput((prev) => prev + data),
onSubtasks: (items) => {
setSubtasks(items);
setView({ type: "editing", sessionId });
setDirty(false);
},
onError: (message) => {
setError(message);
setView({ type: "initial" });
},
});
} catch (err: any) {
setError(err.message || "Failed to start subtask breakdown");
setView({ type: "initial" });
}
}, [initialDescription]);
useEffect(() => {
if (!isOpen) {
resetState();
return;
}
if (isOpen && initialDescription && !autoStartedRef.current) {
autoStartedRef.current = true;
void beginBreakdown();
}
}, [isOpen, initialDescription, beginBreakdown, resetState]);
useEffect(() => {
return () => {
streamRef.current?.close();
};
}, []);
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
void handleClose();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, handleClose]);
const updateSubtask = useCallback((id: string, patch: Partial<SubtaskItem>) => {
setSubtasks((current) => current.map((item) => item.id === id ? { ...item, ...patch } : item));
setDirty(true);
}, []);
const addSubtask = useCallback(() => {
setSubtasks((current) => [...current, createEmptySubtask(current.length + 1)]);
setDirty(true);
}, []);
const removeSubtask = useCallback((id: string) => {
setSubtasks((current) => current
.filter((item) => item.id !== id)
.map((item) => ({ ...item, dependsOn: item.dependsOn.filter((dep) => dep !== id) })));
setDirty(true);
}, []);
const moveFocusToNext = useCallback((index: number) => {
titleRefs.current[index + 1]?.focus();
}, []);
const handleCreateTasks = useCallback(async () => {
if (!sessionId || isInvalid) return;
setError(null);
setView({ type: "creating", sessionId });
try {
const result = await createTasksFromBreakdown(sessionId, subtasks, parentTaskId);
onTasksCreated(result.tasks);
resetState();
onClose();
} catch (err: any) {
setError(err.message || "Failed to create tasks");
setView({ type: "editing", sessionId });
}
}, [isInvalid, onClose, onTasksCreated, parentTaskId, resetState, sessionId, subtasks]);
if (!isOpen) return null;
return (
<div className="modal-overlay open" onClick={(event) => event.target === event.currentTarget && void handleClose()}>
<div className="modal modal-lg planning-modal">
<div className="modal-header">
<div className="detail-title-row">
<ListTree size={20} style={{ color: "var(--triage)" }} />
<h3>Subtask Breakdown</h3>
</div>
<button className="modal-close" onClick={() => void handleClose()} aria-label="Close">
<X size={20} />
</button>
</div>
<div className="planning-modal-body">
{error && <div className="form-error planning-error">{error}</div>}
{view.type === "initial" && (
<div className="planning-initial">
<div className="planning-view-scroll">
<p className="text-muted">Preparing to break this task into subtasks.</p>
<pre className="planning-thinking-output">{initialDescription}</pre>
</div>
</div>
)}
{view.type === "generating" && (
<div className="planning-loading">
<Loader2 size={40} className="spin" style={{ color: "var(--todo)" }} />
<p>AI is generating subtasks...</p>
<div className="planning-thinking-container">
<button className="planning-thinking-toggle" onClick={() => setShowThinking(!showThinking)} type="button">
{showThinking ? "Hide thinking" : "Show thinking"}
</button>
{showThinking && thinkingOutput && (
<div className="planning-thinking-output">
<pre>{thinkingOutput}</pre>
</div>
)}
</div>
</div>
)}
{(view.type === "editing" || view.type === "creating") && (
<div className="planning-summary">
<div className="planning-view-scroll planning-summary-scroll">
<div className="planning-summary-header">
<CheckCircle size={24} style={{ color: "var(--color-success)" }} />
<h4>Review your subtasks</h4>
<p className="text-muted">Edit titles, descriptions, sizes, and dependencies before creating all tasks at once.</p>
</div>
<div className="planning-summary-form">
{subtasks.map((subtask, index) => (
<div key={subtask.id} className="task-detail-section" data-testid={`subtask-item-${index}`}>
<div className="detail-title-row" style={{ justifyContent: "space-between" }}>
<strong>{subtask.id}</strong>
<button type="button" className="btn btn-sm" onClick={() => removeSubtask(subtask.id)} disabled={view.type === "creating"}>
<Trash2 size={14} /> Remove
</button>
</div>
<div className="form-group">
<label>Title</label>
<input
ref={(element) => { titleRefs.current[index] = element; }}
value={subtask.title}
onChange={(event) => updateSubtask(subtask.id, { title: event.target.value })}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
moveFocusToNext(index);
}
}}
disabled={view.type === "creating"}
/>
</div>
<div className="form-group">
<label>Description</label>
<textarea
rows={3}
value={subtask.description}
onChange={(event) => updateSubtask(subtask.id, { description: event.target.value })}
disabled={view.type === "creating"}
/>
</div>
<div className="form-group">
<label>Size</label>
<div className="planning-size-selector">
{(["S", "M", "L"] as const).map((size) => (
<button
key={size}
type="button"
className={`planning-size-btn ${subtask.suggestedSize === size ? "selected" : ""}`}
onClick={() => updateSubtask(subtask.id, { suggestedSize: size })}
disabled={view.type === "creating"}
>
{size}
</button>
))}
</div>
</div>
<div className="form-group">
<label>Dependencies</label>
<div className="planning-deps-list">
{subtasks.filter((item) => item.id !== subtask.id).map((candidate) => {
const selected = subtask.dependsOn.includes(candidate.id);
return (
<label key={candidate.id} className={`planning-dep-chip ${selected ? "selected" : ""}`}>
<input
type="checkbox"
checked={selected}
onChange={() => {
const nextDeps = selected
? subtask.dependsOn.filter((dep) => dep !== candidate.id)
: [...subtask.dependsOn, candidate.id];
updateSubtask(subtask.id, { dependsOn: nextDeps });
}}
disabled={view.type === "creating"}
/>
<span className="planning-dep-id">{candidate.id}</span>
<span className="planning-dep-title">{candidate.title || "Untitled"}</span>
</label>
);
})}
{subtasks.filter((item) => item.id !== subtask.id).length === 0 && (
<div className="text-muted">No other subtasks available yet.</div>
)}
</div>
</div>
</div>
))}
<button type="button" className="btn" onClick={addSubtask} disabled={view.type === "creating"}>
<Plus size={16} style={{ marginRight: 6 }} /> Add subtask
</button>
{hasDependencyCycle(subtasks) && (
<div className="form-error planning-error">Dependencies contain a cycle. Remove circular references before creating tasks.</div>
)}
</div>
</div>
<div className="planning-actions planning-summary-actions">
<button className="btn" onClick={() => void handleClose()} disabled={view.type === "creating"}>
Cancel
</button>
<button className="btn btn-primary" onClick={() => void handleCreateTasks()} disabled={view.type === "creating" || isInvalid}>
{view.type === "creating" ? (
<>
<Loader2 size={16} className="spin" style={{ marginRight: 8 }} />
Creating...
</>
) : (
<>Create Tasks</>
)}
</button>
</div>
</div>
)}
</div>
</div>
</div>
);
}
export type { SubtaskBreakdownModalProps };