feat(KB-125): add planning mode checkbox to new task flow
- Add planning mode checkbox to NewTaskModal with onPlanGenerated callback - Wire up planning mode flow in App.tsx to open modal after task creation - Add initialPlan prop to PlanningModeModal with auto-start logic - Add comprehensive tests for planning mode checkbox flow in both modals - Include changeset for the planning mode checkbox fix
This commit is contained in:
@@ -21,6 +21,7 @@ import { useTheme } from "./hooks/useTheme";
|
||||
function AppInner() {
|
||||
const [newTaskModalOpen, setNewTaskModalOpen] = useState(false);
|
||||
const [isPlanningOpen, setIsPlanningOpen] = useState(false);
|
||||
const [planningInitialPlan, setPlanningInitialPlan] = useState<string | null>(null);
|
||||
const [detailTask, setDetailTask] = useState<TaskDetail | null>(null);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [githubImportOpen, setGitHubImportOpen] = useState(false);
|
||||
@@ -126,12 +127,22 @@ function AppInner() {
|
||||
|
||||
// Planning mode handlers
|
||||
const handlePlanningOpen = useCallback(() => setIsPlanningOpen(true), []);
|
||||
const handlePlanningClose = useCallback(() => setIsPlanningOpen(false), []);
|
||||
const handlePlanningClose = useCallback(() => {
|
||||
setIsPlanningOpen(false);
|
||||
setPlanningInitialPlan(null);
|
||||
}, []);
|
||||
const handlePlanningTaskCreated = useCallback((task: Task) => {
|
||||
addToast(`Created ${task.id} from planning mode`, "success");
|
||||
setIsPlanningOpen(false);
|
||||
setPlanningInitialPlan(null);
|
||||
}, [addToast]);
|
||||
|
||||
// Handle planning mode from new task dialog
|
||||
const handleNewTaskPlanningMode = useCallback((initialPlan: string) => {
|
||||
setPlanningInitialPlan(initialPlan);
|
||||
setIsPlanningOpen(true);
|
||||
}, []);
|
||||
|
||||
// Usage indicator handlers
|
||||
const handleOpenUsage = useCallback(() => setUsageOpen(true), []);
|
||||
const handleCloseUsage = useCallback(() => setUsageOpen(false), []);
|
||||
@@ -273,6 +284,7 @@ function AppInner() {
|
||||
onClose={handlePlanningClose}
|
||||
onTaskCreated={handlePlanningTaskCreated}
|
||||
tasks={tasks}
|
||||
initialPlan={planningInitialPlan ?? undefined}
|
||||
/>
|
||||
<TerminalModal
|
||||
isOpen={terminalOpen}
|
||||
@@ -288,6 +300,7 @@ function AppInner() {
|
||||
tasks={tasks}
|
||||
onCreateTask={handleModalCreate}
|
||||
addToast={addToast}
|
||||
onPlanningMode={handleNewTaskPlanningMode}
|
||||
/>
|
||||
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||
</>
|
||||
|
||||
@@ -18,6 +18,7 @@ interface NewTaskModalProps {
|
||||
tasks: Task[]; // for dependency selection
|
||||
onCreateTask: (input: TaskCreateInput) => Promise<Task>;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
onPlanningMode?: (initialPlan: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -285,7 +286,7 @@ function ModelCombobox({
|
||||
);
|
||||
}
|
||||
|
||||
export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast }: NewTaskModalProps) {
|
||||
export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, onPlanningMode }: NewTaskModalProps) {
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [dependencies, setDependencies] = useState<string[]>([]);
|
||||
@@ -415,6 +416,31 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast }:
|
||||
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([]);
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setDependencies([]);
|
||||
setExecutorModel("");
|
||||
setValidatorModel("");
|
||||
setEnablePlanningMode(false);
|
||||
|
||||
// Close modal and trigger planning mode
|
||||
onClose();
|
||||
onPlanningMode(trimmedDesc);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
// Create the base task
|
||||
@@ -444,7 +470,7 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast }:
|
||||
const executorSlashIdx = executorModel.indexOf("/");
|
||||
const validatorSlashIdx = validatorModel.indexOf("/");
|
||||
|
||||
if (executorModel || validatorModel || enablePlanningMode) {
|
||||
if (executorModel || validatorModel) {
|
||||
const updates: Parameters<typeof updateTask>[1] = {};
|
||||
|
||||
if (executorModel && executorSlashIdx !== -1) {
|
||||
@@ -457,9 +483,6 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast }:
|
||||
updates.validatorModelId = validatorModel.slice(validatorSlashIdx + 1);
|
||||
}
|
||||
|
||||
// Note: enablePlanningMode would need backend support
|
||||
// TODO: Add backend support for per-task planning mode
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await updateTask(task.id, updates);
|
||||
}
|
||||
@@ -482,7 +505,7 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast }:
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [description, title, dependencies, pendingImages, executorModel, validatorModel, enablePlanningMode, isSubmitting, onCreateTask, addToast, onClose]);
|
||||
}, [description, title, dependencies, pendingImages, executorModel, validatorModel, enablePlanningMode, isSubmitting, onCreateTask, addToast, onClose, onPlanningMode]);
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
|
||||
@@ -158,6 +158,57 @@ describe("PlanningModeModal", () => {
|
||||
|
||||
expect(screen.getByText(/Build a user authentication/)).toBeDefined();
|
||||
});
|
||||
|
||||
it("auto-starts planning when initialPlan prop is provided", async () => {
|
||||
mockStartPlanning.mockResolvedValue({
|
||||
sessionId: "session-123",
|
||||
currentQuestion: mockQuestion,
|
||||
summary: null,
|
||||
});
|
||||
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
tasks={mockTasks}
|
||||
initialPlan="Build a login system from new task dialog"
|
||||
/>
|
||||
);
|
||||
|
||||
// Wait for startPlanning to be called
|
||||
await waitFor(() => {
|
||||
expect(mockStartPlanning).toHaveBeenCalledWith("Build a login system from new task dialog");
|
||||
});
|
||||
|
||||
// Should transition to question view
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("What is the scope?")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("sets initial plan text in textarea when initialPlan prop is provided", async () => {
|
||||
mockStartPlanning.mockResolvedValue({
|
||||
sessionId: "session-123",
|
||||
currentQuestion: mockQuestion,
|
||||
summary: null,
|
||||
});
|
||||
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
tasks={mockTasks}
|
||||
initialPlan="Pre-filled plan from new task"
|
||||
/>
|
||||
);
|
||||
|
||||
// The auto-start should happen with the initial plan
|
||||
await waitFor(() => {
|
||||
expect(mockStartPlanning).toHaveBeenCalledWith("Pre-filled plan from new task");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Planning flow", () => {
|
||||
|
||||
@@ -14,6 +14,7 @@ interface PlanningModeModalProps {
|
||||
onClose: () => void;
|
||||
onTaskCreated: (task: Task) => void;
|
||||
tasks: Task[];
|
||||
initialPlan?: string;
|
||||
}
|
||||
|
||||
interface QuestionResponse {
|
||||
@@ -33,12 +34,13 @@ const EXAMPLE_PLANS = [
|
||||
"Refactor the task card component for better performance",
|
||||
];
|
||||
|
||||
export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks }: PlanningModeModalProps) {
|
||||
export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initialPlan: initialPlanProp }: PlanningModeModalProps) {
|
||||
const [initialPlan, setInitialPlan] = useState("");
|
||||
const [view, setView] = useState<ViewState>({ type: "initial" });
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [responseHistory, setResponseHistory] = useState<QuestionResponse[]>([]);
|
||||
const [editedSummary, setEditedSummary] = useState<PlanningSummary | null>(null);
|
||||
const [hasAutoStarted, setHasAutoStarted] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Focus textarea when opening
|
||||
@@ -48,6 +50,26 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks }: Pla
|
||||
}
|
||||
}, [isOpen, view.type]);
|
||||
|
||||
// Auto-start planning when initialPlan prop is provided
|
||||
useEffect(() => {
|
||||
if (isOpen && initialPlanProp && !hasAutoStarted && view.type === "initial") {
|
||||
setInitialPlan(initialPlanProp);
|
||||
setHasAutoStarted(true);
|
||||
// Use a small timeout to allow state update to propagate before starting
|
||||
const timer = setTimeout(() => {
|
||||
handleStartPlanningWithPlan(initialPlanProp);
|
||||
}, 0);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isOpen, initialPlanProp, hasAutoStarted, view.type]);
|
||||
|
||||
// Reset hasAutoStarted when modal closes
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setHasAutoStarted(false);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Handle browser unload during active session
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
@@ -104,6 +126,28 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks }: Pla
|
||||
}
|
||||
}, [initialPlan]);
|
||||
|
||||
// Helper for auto-start with a specific plan (from prop)
|
||||
const handleStartPlanningWithPlan = useCallback(async (plan: string) => {
|
||||
if (!plan.trim()) return;
|
||||
|
||||
setError(null);
|
||||
setView({ type: "loading" });
|
||||
|
||||
try {
|
||||
const session = await startPlanning(plan.trim());
|
||||
if (session.currentQuestion) {
|
||||
setView({ type: "question", session });
|
||||
} else if (session.summary) {
|
||||
setView({ type: "summary", session, summary: session.summary });
|
||||
setEditedSummary(session.summary);
|
||||
}
|
||||
setResponseHistory([]);
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to start planning session");
|
||||
setView({ type: "initial" });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSubmitResponse = useCallback(
|
||||
async (responses: QuestionResponse) => {
|
||||
if (view.type !== "question") return;
|
||||
|
||||
@@ -262,4 +262,115 @@ describe("NewTaskModal", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Planning mode tests
|
||||
it("calls onPlanningMode when planning mode is checked and form is submitted", async () => {
|
||||
const onPlanningMode = vi.fn();
|
||||
const { props } = renderNewTaskModal({ onPlanningMode });
|
||||
|
||||
const titleInput = screen.getByLabelText(/Title/i);
|
||||
const descTextarea = screen.getByLabelText(/Description/i);
|
||||
const checkbox = screen.getByLabelText(/Enable planning mode/i);
|
||||
|
||||
fireEvent.change(titleInput, { target: { value: "My Task" } });
|
||||
fireEvent.change(descTextarea, { target: { value: "Build a login system" } });
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onPlanningMode).toHaveBeenCalledWith("Build a login system");
|
||||
});
|
||||
});
|
||||
|
||||
it("does NOT call onCreateTask when planning mode is checked", async () => {
|
||||
const onPlanningMode = vi.fn();
|
||||
const { props } = renderNewTaskModal({ onPlanningMode });
|
||||
|
||||
const descTextarea = screen.getByLabelText(/Description/i);
|
||||
const checkbox = screen.getByLabelText(/Enable planning mode/i);
|
||||
|
||||
fireEvent.change(descTextarea, { target: { value: "Build a login system" } });
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onPlanningMode).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(props.onCreateTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onCreateTask normally when planning mode is unchecked", async () => {
|
||||
const onPlanningMode = vi.fn();
|
||||
const { props } = renderNewTaskModal({ onPlanningMode });
|
||||
|
||||
const descTextarea = screen.getByLabelText(/Description/i);
|
||||
fireEvent.change(descTextarea, { target: { value: "Normal task" } });
|
||||
|
||||
// Ensure planning mode is unchecked
|
||||
const checkbox = screen.getByLabelText(/Enable planning mode/i) as HTMLInputElement;
|
||||
expect(checkbox.checked).toBe(false);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onCreateTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
description: "Normal task",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(onPlanningMode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes modal after triggering planning mode", async () => {
|
||||
const onPlanningMode = vi.fn();
|
||||
const { props } = renderNewTaskModal({ onPlanningMode });
|
||||
|
||||
const descTextarea = screen.getByLabelText(/Description/i);
|
||||
const checkbox = screen.getByLabelText(/Enable planning mode/i);
|
||||
|
||||
fireEvent.change(descTextarea, { target: { value: "Build a login system" } });
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("clears form state after triggering planning mode", async () => {
|
||||
const onPlanningMode = vi.fn();
|
||||
renderNewTaskModal({ onPlanningMode });
|
||||
|
||||
const titleInput = screen.getByLabelText(/Title/i);
|
||||
const descTextarea = screen.getByLabelText(/Description/i);
|
||||
const checkbox = screen.getByLabelText(/Enable planning mode/i);
|
||||
|
||||
fireEvent.change(titleInput, { target: { value: "My Task" } });
|
||||
fireEvent.change(descTextarea, { target: { value: "Build a login system" } });
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onPlanningMode).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Re-open the modal and check that state is cleared
|
||||
renderNewTaskModal({
|
||||
isOpen: true,
|
||||
onPlanningMode,
|
||||
onClose: vi.fn(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const newDescTextarea = screen.getAllByLabelText(/Description/i)[0];
|
||||
expect(newDescTextarea).toHaveValue("");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user