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:
gsxdsm
2026-03-30 07:57:53 -07:00
parent ea5a34f7cf
commit 3c9901b2cf
6 changed files with 255 additions and 8 deletions

View File

@@ -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} />
</>

View File

@@ -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) => {

View File

@@ -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", () => {

View File

@@ -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;

View File

@@ -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("");
});
});
});