From 4ca33625ed5985bad8727a97c57be17793474425 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 30 Mar 2026 07:57:53 -0700 Subject: [PATCH] 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 --- .changeset/fix-planning-mode-checkbox.md | 5 + packages/dashboard/app/App.tsx | 15 ++- .../dashboard/app/components/NewTaskModal.tsx | 35 +++++- .../app/components/PlanningModeModal.test.tsx | 51 ++++++++ .../app/components/PlanningModeModal.tsx | 46 +++++++- .../__tests__/NewTaskModal.test.tsx | 111 ++++++++++++++++++ 6 files changed, 255 insertions(+), 8 deletions(-) create mode 100644 .changeset/fix-planning-mode-checkbox.md diff --git a/.changeset/fix-planning-mode-checkbox.md b/.changeset/fix-planning-mode-checkbox.md new file mode 100644 index 000000000..d7dd44a7d --- /dev/null +++ b/.changeset/fix-planning-mode-checkbox.md @@ -0,0 +1,5 @@ +--- +"@dustinbyrne/kb": patch +--- + +Fix "Enable planning mode" checkbox to actually trigger the planning flow before task creation. diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index a53fb4018..d135dcd8f 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -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(null); const [detailTask, setDetailTask] = useState(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} /> diff --git a/packages/dashboard/app/components/NewTaskModal.tsx b/packages/dashboard/app/components/NewTaskModal.tsx index ba3857042..15a711954 100644 --- a/packages/dashboard/app/components/NewTaskModal.tsx +++ b/packages/dashboard/app/components/NewTaskModal.tsx @@ -18,6 +18,7 @@ interface NewTaskModalProps { tasks: Task[]; // for dependency selection onCreateTask: (input: TaskCreateInput) => Promise; 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([]); @@ -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[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) => { diff --git a/packages/dashboard/app/components/PlanningModeModal.test.tsx b/packages/dashboard/app/components/PlanningModeModal.test.tsx index 198a07b65..0c0613643 100644 --- a/packages/dashboard/app/components/PlanningModeModal.test.tsx +++ b/packages/dashboard/app/components/PlanningModeModal.test.tsx @@ -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( + + ); + + // 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( + + ); + + // The auto-start should happen with the initial plan + await waitFor(() => { + expect(mockStartPlanning).toHaveBeenCalledWith("Pre-filled plan from new task"); + }); + }); }); describe("Planning flow", () => { diff --git a/packages/dashboard/app/components/PlanningModeModal.tsx b/packages/dashboard/app/components/PlanningModeModal.tsx index e9be45451..caa634dc7 100644 --- a/packages/dashboard/app/components/PlanningModeModal.tsx +++ b/packages/dashboard/app/components/PlanningModeModal.tsx @@ -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({ type: "initial" }); const [error, setError] = useState(null); const [responseHistory, setResponseHistory] = useState([]); const [editedSummary, setEditedSummary] = useState(null); + const [hasAutoStarted, setHasAutoStarted] = useState(false); const textareaRef = useRef(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; diff --git a/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx b/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx index fea76e762..a267fafb9 100644 --- a/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx @@ -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(""); + }); + }); });