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:
@@ -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}
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
118
packages/dashboard/app/components/SubtaskBreakdownModal.test.tsx
Normal file
118
packages/dashboard/app/components/SubtaskBreakdownModal.test.tsx
Normal 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());
|
||||
});
|
||||
});
|
||||
367
packages/dashboard/app/components/SubtaskBreakdownModal.tsx
Normal file
367
packages/dashboard/app/components/SubtaskBreakdownModal.tsx
Normal 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 };
|
||||
@@ -8,6 +8,7 @@ import type { TaskStore, TaskAttachment } from "@kb/core";
|
||||
import type { TaskDetail } from "@kb/core";
|
||||
import type { AuthStorageLike, ModelRegistryLike } from "./routes.js";
|
||||
import { __resetPlanningState } from "./planning.js";
|
||||
import { __resetSubtaskBreakdownState } from "./subtask-breakdown.js";
|
||||
import * as terminalServiceModule from "./terminal-service.js";
|
||||
|
||||
// Mock @kb/core for gh CLI auth checks
|
||||
@@ -379,6 +380,90 @@ describe("POST /tasks", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /subtasks/*", () => {
|
||||
let store: TaskStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createMockStore();
|
||||
__resetPlanningState();
|
||||
__resetSubtaskBreakdownState();
|
||||
});
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
return app;
|
||||
}
|
||||
|
||||
it("starts a subtask streaming session and returns sessionId", async () => {
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/subtasks/start-streaming",
|
||||
JSON.stringify({ description: "Break this feature into subtasks" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(typeof res.body.sessionId).toBe("string");
|
||||
});
|
||||
|
||||
it("creates tasks from a breakdown and resolves dependencies", async () => {
|
||||
(store.createTask as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({ ...FAKE_TASK_DETAIL, id: "KB-101", title: "First", column: "triage" })
|
||||
.mockResolvedValueOnce({ ...FAKE_TASK_DETAIL, id: "KB-102", title: "Second", column: "triage" });
|
||||
(store.updateTask as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({ ...FAKE_TASK_DETAIL, id: "KB-101", title: "First", column: "triage", size: "S" })
|
||||
.mockResolvedValueOnce({ ...FAKE_TASK_DETAIL, id: "KB-102", title: "Second", column: "triage", size: "M" })
|
||||
.mockResolvedValueOnce({ ...FAKE_TASK_DETAIL, id: "KB-102", title: "Second", column: "triage", dependencies: ["KB-101"] });
|
||||
|
||||
const start = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/subtasks/start-streaming",
|
||||
JSON.stringify({ description: "Break this feature into subtasks" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
const createRes = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/subtasks/create-tasks",
|
||||
JSON.stringify({
|
||||
sessionId: start.body.sessionId,
|
||||
subtasks: [
|
||||
{ tempId: "subtask-1", title: "First", description: "Do first", size: "S", dependsOn: [] },
|
||||
{ tempId: "subtask-2", title: "Second", description: "Do second", size: "M", dependsOn: ["subtask-1"] },
|
||||
],
|
||||
}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(createRes.status).toBe(201);
|
||||
expect(createRes.body.tasks).toHaveLength(2);
|
||||
expect(store.createTask).toHaveBeenNthCalledWith(1, expect.objectContaining({ title: "First", dependencies: undefined }));
|
||||
expect(store.createTask).toHaveBeenNthCalledWith(2, expect.objectContaining({ title: "Second", dependencies: undefined }));
|
||||
expect(store.updateTask).toHaveBeenCalledWith("KB-102", { dependencies: ["KB-101"] });
|
||||
});
|
||||
|
||||
it("returns 404 for invalid subtask session during batch creation", async () => {
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/subtasks/create-tasks",
|
||||
JSON.stringify({
|
||||
sessionId: "missing-session",
|
||||
subtasks: [{ tempId: "subtask-1", title: "First", description: "Do first" }],
|
||||
}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toContain("not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /tasks/:id/retry", () => {
|
||||
let store: TaskStore;
|
||||
|
||||
|
||||
@@ -3538,6 +3538,194 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
|
||||
// ── Planning Mode Routes ──────────────────────────────────────────────────
|
||||
|
||||
router.post("/subtasks/start-streaming", async (req, res) => {
|
||||
try {
|
||||
const { description } = req.body;
|
||||
|
||||
if (!description || typeof description !== "string") {
|
||||
res.status(400).json({ error: "description is required and must be a string" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (description.length > 1000) {
|
||||
res.status(400).json({ error: "description must be 1000 characters or less" });
|
||||
return;
|
||||
}
|
||||
|
||||
const { createSubtaskSession } = await import("./subtask-breakdown.js");
|
||||
const session = await createSubtaskSession(description, store, store.getRootDir());
|
||||
res.status(201).json({ sessionId: session.sessionId });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message || "Failed to start subtask breakdown" });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/subtasks/:sessionId/stream", async (req, res) => {
|
||||
const { sessionId } = req.params;
|
||||
|
||||
res.setHeader("Content-Type", "text/event-stream");
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
res.setHeader("Connection", "keep-alive");
|
||||
res.setHeader("X-Accel-Buffering", "no");
|
||||
res.flushHeaders();
|
||||
res.write(": connected\n\n");
|
||||
|
||||
try {
|
||||
const { subtaskStreamManager, getSubtaskSession } = await import("./subtask-breakdown.js");
|
||||
const session = getSubtaskSession(sessionId);
|
||||
if (!session) {
|
||||
res.write(`event: error\ndata: ${JSON.stringify("Session not found or expired")}\n\n`);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const unsubscribe = subtaskStreamManager.subscribe(sessionId, (event) => {
|
||||
try {
|
||||
const data = (event as { data?: unknown }).data;
|
||||
res.write(`event: ${event.type}\ndata: ${JSON.stringify(data ?? {})}\n\n`);
|
||||
if (event.type === "complete" || event.type === "error") {
|
||||
unsubscribe();
|
||||
res.end();
|
||||
}
|
||||
} catch {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
if (session.status === "complete") {
|
||||
res.write(`event: subtasks\ndata: ${JSON.stringify(session.subtasks)}\n\n`);
|
||||
res.write("event: complete\ndata: {}\n\n");
|
||||
unsubscribe();
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.status === "error") {
|
||||
res.write(`event: error\ndata: ${JSON.stringify(session.error || "Failed to generate subtasks")}\n\n`);
|
||||
unsubscribe();
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
if (res.writableEnded) {
|
||||
clearInterval(heartbeat);
|
||||
return;
|
||||
}
|
||||
res.write(": heartbeat\n\n");
|
||||
}, 30_000);
|
||||
|
||||
req.on("close", () => {
|
||||
clearInterval(heartbeat);
|
||||
unsubscribe();
|
||||
});
|
||||
} catch (err: any) {
|
||||
res.write(`event: error\ndata: ${JSON.stringify(err.message || "Stream error")}\n\n`);
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/subtasks/create-tasks", async (req, res) => {
|
||||
try {
|
||||
const { sessionId, subtasks, parentTaskId } = req.body as {
|
||||
sessionId?: string;
|
||||
subtasks?: Array<{ tempId: string; title: string; description: string; size?: "S" | "M" | "L"; dependsOn?: string[] }>;
|
||||
parentTaskId?: string;
|
||||
};
|
||||
|
||||
if (!sessionId || typeof sessionId !== "string") {
|
||||
res.status(400).json({ error: "sessionId is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(subtasks) || subtasks.length === 0) {
|
||||
res.status(400).json({ error: "subtasks must be a non-empty array" });
|
||||
return;
|
||||
}
|
||||
|
||||
const { getSubtaskSession, cleanupSubtaskSession } = await import("./subtask-breakdown.js");
|
||||
const session = getSubtaskSession(sessionId);
|
||||
if (!session) {
|
||||
res.status(404).json({ error: `Subtask session ${sessionId} not found or expired` });
|
||||
return;
|
||||
}
|
||||
|
||||
const createdTasks = [] as Awaited<ReturnType<typeof store.createTask>>[];
|
||||
const tempIdToTaskId = new Map<string, string>();
|
||||
|
||||
for (const item of subtasks) {
|
||||
if (!item || typeof item.tempId !== "string" || typeof item.title !== "string" || !item.title.trim()) {
|
||||
res.status(400).json({ error: "Each subtask must include tempId and title" });
|
||||
return;
|
||||
}
|
||||
|
||||
const task = await store.createTask({
|
||||
title: item.title.trim(),
|
||||
description: typeof item.description === "string" ? item.description.trim() : item.title.trim(),
|
||||
column: "triage",
|
||||
dependencies: undefined,
|
||||
});
|
||||
|
||||
tempIdToTaskId.set(item.tempId, task.id);
|
||||
createdTasks.push(task);
|
||||
|
||||
if (item.size === "S" || item.size === "M" || item.size === "L") {
|
||||
await store.updateTask(task.id, { size: item.size });
|
||||
}
|
||||
}
|
||||
|
||||
for (let index = 0; index < subtasks.length; index++) {
|
||||
const item = subtasks[index]!;
|
||||
const created = createdTasks[index]!;
|
||||
const resolvedDependencies = Array.isArray(item.dependsOn)
|
||||
? item.dependsOn.map((dep) => tempIdToTaskId.get(dep)).filter((dep): dep is string => Boolean(dep))
|
||||
: [];
|
||||
|
||||
if (resolvedDependencies.length > 0) {
|
||||
const updated = await store.updateTask(created.id, { dependencies: resolvedDependencies });
|
||||
createdTasks[index] = updated;
|
||||
}
|
||||
|
||||
await store.logEntry(created.id, "Created via subtask breakdown", `Source: ${session.initialDescription.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
let parentTaskClosed = false;
|
||||
if (typeof parentTaskId === "string" && parentTaskId.trim()) {
|
||||
try {
|
||||
await store.deleteTask(parentTaskId);
|
||||
parentTaskClosed = true;
|
||||
} catch {
|
||||
parentTaskClosed = false;
|
||||
}
|
||||
}
|
||||
|
||||
cleanupSubtaskSession(sessionId);
|
||||
res.status(201).json({ tasks: createdTasks, parentTaskClosed });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message || "Failed to create tasks from breakdown" });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/subtasks/cancel", async (req, res) => {
|
||||
try {
|
||||
const { sessionId } = req.body;
|
||||
if (!sessionId || typeof sessionId !== "string") {
|
||||
res.status(400).json({ error: "sessionId is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const { cancelSubtaskSession } = await import("./subtask-breakdown.js");
|
||||
await cancelSubtaskSession(sessionId);
|
||||
res.json({ success: true });
|
||||
} catch (err: any) {
|
||||
if (err.name === "SessionNotFoundError") {
|
||||
res.status(404).json({ error: err.message });
|
||||
} else {
|
||||
res.status(500).json({ error: err.message || "Failed to cancel subtask session" });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/planning/start
|
||||
* Start a new planning session.
|
||||
|
||||
331
packages/dashboard/src/subtask-breakdown.ts
Normal file
331
packages/dashboard/src/subtask-breakdown.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
import type { TaskStore } from "@kb/core";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { EventEmitter } from "node:events";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let createKbAgent: any;
|
||||
const engineModule = "@kb/engine";
|
||||
|
||||
async function initEngine() {
|
||||
if (!createKbAgent) {
|
||||
try {
|
||||
const engine = await import(/* @vite-ignore */ engineModule);
|
||||
createKbAgent = engine.createKbAgent;
|
||||
} catch {
|
||||
createKbAgent = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const engineReady = initEngine();
|
||||
|
||||
export interface SubtaskItem {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
suggestedSize: "S" | "M" | "L";
|
||||
dependsOn: string[];
|
||||
}
|
||||
|
||||
export interface SubtaskSession {
|
||||
sessionId: string;
|
||||
initialDescription: string;
|
||||
subtasks: SubtaskItem[];
|
||||
status: "generating" | "complete" | "error";
|
||||
error?: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export type SubtaskStreamEvent =
|
||||
| { type: "thinking"; data: string }
|
||||
| { type: "subtasks"; data: SubtaskItem[] }
|
||||
| { type: "error"; data: string }
|
||||
| { type: "complete" };
|
||||
|
||||
export type SubtaskStreamCallback = (event: SubtaskStreamEvent) => void;
|
||||
|
||||
const SESSION_TTL_MS = 30 * 60 * 1000;
|
||||
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
|
||||
|
||||
const sessions = new Map<string, SubtaskSession & { updatedAt: Date; agent?: any; thinkingOutput: string }>();
|
||||
|
||||
export const SUBTASK_BREAKDOWN_PROMPT = `You are a task decomposition assistant for the kb task board system.
|
||||
|
||||
Analyze the user's task description and break it down into 2-5 smaller, independently executable subtasks.
|
||||
|
||||
For each subtask, provide:
|
||||
1. Title (short and descriptive)
|
||||
2. Description (1-2 sentences, implementation-focused)
|
||||
3. Size estimate (S: <2h, M: 2-4h, L: 4-8h)
|
||||
4. Dependencies (which other subtask IDs must be completed first)
|
||||
|
||||
Guidelines:
|
||||
- Prefer parallelizable subtasks when possible
|
||||
- Only add dependencies when truly required
|
||||
- Order subtasks so prerequisites appear earlier
|
||||
- Keep the overall scope aligned with the original task
|
||||
- Use IDs like "subtask-1", "subtask-2", etc.
|
||||
|
||||
Return ONLY valid JSON in this format:
|
||||
{
|
||||
"subtasks": [
|
||||
{
|
||||
"id": "subtask-1",
|
||||
"title": "...",
|
||||
"description": "...",
|
||||
"suggestedSize": "S",
|
||||
"dependsOn": []
|
||||
}
|
||||
]
|
||||
}`;
|
||||
|
||||
function cleanupExpiredSessions(): void {
|
||||
const now = Date.now();
|
||||
for (const [id, session] of sessions) {
|
||||
if (now - session.updatedAt.getTime() > SESSION_TTL_MS) {
|
||||
try {
|
||||
session.agent?.session?.dispose?.();
|
||||
} catch {
|
||||
// ignore cleanup failures
|
||||
}
|
||||
sessions.delete(id);
|
||||
subtaskStreamManager.cleanupSession(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cleanupInterval = setInterval(cleanupExpiredSessions, CLEANUP_INTERVAL_MS);
|
||||
process.on("beforeExit", () => {
|
||||
clearInterval(cleanupInterval);
|
||||
});
|
||||
|
||||
export class SubtaskStreamManager extends EventEmitter {
|
||||
private sessions = new Map<string, Set<SubtaskStreamCallback>>();
|
||||
|
||||
subscribe(sessionId: string, callback: SubtaskStreamCallback): () => void {
|
||||
if (!this.sessions.has(sessionId)) {
|
||||
this.sessions.set(sessionId, new Set());
|
||||
}
|
||||
const callbacks = this.sessions.get(sessionId)!;
|
||||
callbacks.add(callback);
|
||||
return () => {
|
||||
callbacks.delete(callback);
|
||||
if (callbacks.size === 0) {
|
||||
this.sessions.delete(sessionId);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
broadcast(sessionId: string, event: SubtaskStreamEvent): void {
|
||||
const callbacks = this.sessions.get(sessionId);
|
||||
if (!callbacks) return;
|
||||
for (const callback of callbacks) {
|
||||
try {
|
||||
callback(event);
|
||||
} catch {
|
||||
// ignore subscriber failures
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cleanupSession(sessionId: string): void {
|
||||
this.sessions.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
export const subtaskStreamManager = new SubtaskStreamManager();
|
||||
|
||||
export async function createSubtaskSession(initialDescription: string, _store?: TaskStore, rootDir?: string): Promise<SubtaskSession> {
|
||||
const sessionId = randomUUID();
|
||||
const session = {
|
||||
sessionId,
|
||||
initialDescription,
|
||||
subtasks: [],
|
||||
status: "generating" as const,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
thinkingOutput: "",
|
||||
};
|
||||
sessions.set(sessionId, session);
|
||||
|
||||
const cwd = rootDir ?? process.cwd();
|
||||
generateSubtasks(sessionId, cwd).catch((err) => {
|
||||
const existing = sessions.get(sessionId);
|
||||
if (!existing) return;
|
||||
existing.status = "error";
|
||||
existing.error = err instanceof Error ? err.message : "Failed to generate subtasks";
|
||||
existing.updatedAt = new Date();
|
||||
subtaskStreamManager.broadcast(sessionId, { type: "error", data: existing.error });
|
||||
});
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
initialDescription,
|
||||
subtasks: [],
|
||||
status: "generating",
|
||||
createdAt: session.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
async function generateSubtasks(sessionId: string, cwd: string): Promise<void> {
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) throw new SessionNotFoundError(`Subtask session ${sessionId} not found`);
|
||||
|
||||
await engineReady;
|
||||
|
||||
if (createKbAgent) {
|
||||
const agent = await createKbAgent({
|
||||
cwd,
|
||||
systemPrompt: SUBTASK_BREAKDOWN_PROMPT,
|
||||
tools: "readonly",
|
||||
onThinking: (delta: string) => {
|
||||
const current = sessions.get(sessionId);
|
||||
if (!current) return;
|
||||
current.thinkingOutput += delta;
|
||||
current.updatedAt = new Date();
|
||||
subtaskStreamManager.broadcast(sessionId, { type: "thinking", data: delta });
|
||||
},
|
||||
onText: (delta: string) => {
|
||||
const current = sessions.get(sessionId);
|
||||
if (!current) return;
|
||||
current.thinkingOutput += delta;
|
||||
},
|
||||
});
|
||||
|
||||
session.agent = agent;
|
||||
await agent.session.prompt(session.initialDescription);
|
||||
|
||||
const messages = agent.session.state.messages as Array<{ role: string; content?: string | Array<{ type: string; text: string }> }>;
|
||||
const lastAssistant = messages.filter((m) => m.role === "assistant").pop();
|
||||
let responseText = session.thinkingOutput;
|
||||
if (typeof lastAssistant?.content === "string") {
|
||||
responseText = lastAssistant.content;
|
||||
} else if (Array.isArray(lastAssistant?.content)) {
|
||||
responseText = lastAssistant.content
|
||||
.filter((item): item is { type: "text"; text: string } => item.type === "text")
|
||||
.map((item) => item.text)
|
||||
.join("");
|
||||
}
|
||||
|
||||
const subtasks = parseSubtasks(responseText);
|
||||
completeSession(sessionId, subtasks);
|
||||
return;
|
||||
}
|
||||
|
||||
const fallback = generateFallbackSubtasks(session.initialDescription);
|
||||
completeSession(sessionId, fallback);
|
||||
}
|
||||
|
||||
function parseSubtasks(text: string): SubtaskItem[] {
|
||||
const jsonMatch = text.match(/```(?:json)?\s*([\s\S]*?)\s*```/) || text.match(/\{[\s\S]*\}/);
|
||||
const jsonText = jsonMatch ? jsonMatch[1] || jsonMatch[0] : text;
|
||||
const parsed = JSON.parse(jsonText.trim()) as { subtasks?: SubtaskItem[] };
|
||||
if (!Array.isArray(parsed.subtasks) || parsed.subtasks.length === 0) {
|
||||
throw new Error("AI did not return a valid subtasks array");
|
||||
}
|
||||
return parsed.subtasks.map(normalizeSubtaskItem);
|
||||
}
|
||||
|
||||
function normalizeSubtaskItem(item: SubtaskItem, index = 0): SubtaskItem {
|
||||
return {
|
||||
id: typeof item.id === "string" && item.id.trim() ? item.id.trim() : `subtask-${index + 1}`,
|
||||
title: typeof item.title === "string" ? item.title.trim() : "",
|
||||
description: typeof item.description === "string" ? item.description.trim() : "",
|
||||
suggestedSize: item.suggestedSize === "S" || item.suggestedSize === "M" || item.suggestedSize === "L" ? item.suggestedSize : "M",
|
||||
dependsOn: Array.isArray(item.dependsOn) ? item.dependsOn.filter((dep): dep is string => typeof dep === "string") : [],
|
||||
};
|
||||
}
|
||||
|
||||
function generateFallbackSubtasks(initialDescription: string): SubtaskItem[] {
|
||||
return [
|
||||
{
|
||||
id: "subtask-1",
|
||||
title: "Define implementation approach",
|
||||
description: `Clarify scope and technical approach for: ${initialDescription}`,
|
||||
suggestedSize: "S",
|
||||
dependsOn: [],
|
||||
},
|
||||
{
|
||||
id: "subtask-2",
|
||||
title: "Implement core changes",
|
||||
description: "Build the main functionality required by the task description.",
|
||||
suggestedSize: "M",
|
||||
dependsOn: ["subtask-1"],
|
||||
},
|
||||
{
|
||||
id: "subtask-3",
|
||||
title: "Verify and polish",
|
||||
description: "Add tests, validation, and any follow-up cleanup needed for delivery.",
|
||||
suggestedSize: "S",
|
||||
dependsOn: ["subtask-2"],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function completeSession(sessionId: string, subtasks: SubtaskItem[]): void {
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
session.subtasks = subtasks.map(normalizeSubtaskItem);
|
||||
session.status = "complete";
|
||||
session.error = undefined;
|
||||
session.updatedAt = new Date();
|
||||
subtaskStreamManager.broadcast(sessionId, { type: "subtasks", data: session.subtasks });
|
||||
subtaskStreamManager.broadcast(sessionId, { type: "complete" });
|
||||
}
|
||||
|
||||
export function getSubtaskSession(sessionId: string): SubtaskSession | undefined {
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) return undefined;
|
||||
return {
|
||||
sessionId: session.sessionId,
|
||||
initialDescription: session.initialDescription,
|
||||
subtasks: session.subtasks,
|
||||
status: session.status,
|
||||
error: session.error,
|
||||
createdAt: session.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function cancelSubtaskSession(sessionId: string): Promise<void> {
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) {
|
||||
throw new SessionNotFoundError(`Subtask session ${sessionId} not found or expired`);
|
||||
}
|
||||
try {
|
||||
session.agent?.session?.dispose?.();
|
||||
} catch {
|
||||
// ignore dispose errors
|
||||
}
|
||||
subtaskStreamManager.cleanupSession(sessionId);
|
||||
sessions.delete(sessionId);
|
||||
}
|
||||
|
||||
export function cleanupSubtaskSession(sessionId: string): void {
|
||||
const session = sessions.get(sessionId);
|
||||
try {
|
||||
session?.agent?.session?.dispose?.();
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
subtaskStreamManager.cleanupSession(sessionId);
|
||||
sessions.delete(sessionId);
|
||||
}
|
||||
|
||||
export function __resetSubtaskBreakdownState(): void {
|
||||
for (const [, session] of sessions) {
|
||||
try {
|
||||
session.agent?.session?.dispose?.();
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
}
|
||||
sessions.clear();
|
||||
subtaskStreamManager.removeAllListeners();
|
||||
}
|
||||
|
||||
export class SessionNotFoundError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "SessionNotFoundError";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user