From 82eb59d91ea570ba2a1c52b92b06b4826fcf96ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Fri, 3 Apr 2026 15:07:46 +0100 Subject: [PATCH] fix: disable plan mode when moving to next workflow step (#525) --- .../task/chat/use-chat-panel-state.ts | 108 +++++---- .../task/chat/use-plan-mode-helpers.ts | 155 +++++++++++++ apps/web/components/task/workflow-stepper.tsx | 40 +++- apps/web/e2e/pages/session-page.ts | 5 + .../workflow/workflow-step-proceed.spec.ts | 206 ++++++++++++++++++ .../hooks/domains/kanban/use-plan-actions.ts | 65 +++++- 6 files changed, 510 insertions(+), 69 deletions(-) create mode 100644 apps/web/components/task/chat/use-plan-mode-helpers.ts create mode 100644 apps/web/e2e/tests/workflow/workflow-step-proceed.spec.ts diff --git a/apps/web/components/task/chat/use-chat-panel-state.ts b/apps/web/components/task/chat/use-chat-panel-state.ts index c9d78914b4..79db21873d 100644 --- a/apps/web/components/task/chat/use-chat-panel-state.ts +++ b/apps/web/components/task/chat/use-chat-panel-state.ts @@ -21,6 +21,7 @@ import { usePendingPRFeedback, } from "@/hooks/domains/comments/use-pending-comments"; import { buildContextItems } from "../chat-context-items"; +import { useAutoDisablePlanMode, usePlanLayoutHandlers } from "./use-plan-mode-helpers"; import type { ContextItem } from "@/lib/types/context"; import type { DiffComment } from "@/lib/diff/types"; import type { PlanComment, PRFeedbackComment } from "@/lib/state/slices/comments"; @@ -60,18 +61,22 @@ type AutoApplyPlanLayoutOpts = { resolvedSessionId: string | null; taskId: string | null; sessionMetaPlanMode: boolean; + currentStepHasPlanMode: boolean; setActiveDocument: (sid: string, doc: ActiveDocument | null) => void; applyBuiltInPreset: (preset: BuiltInPreset) => void; setPlanMode: (sid: string, enabled: boolean) => void; addContextFile: (sid: string, file: { path: string; name: string }) => void; }; -/** Auto-apply plan layout when session metadata has plan_mode enabled. */ +/** Auto-apply plan layout when session metadata has plan_mode enabled AND the current + * step actually configures enable_plan_mode. The step check guards against stale + * sessionMetaPlanMode from deepMerge hydration preserving deleted metadata keys. */ function useAutoApplyPlanLayout(opts: AutoApplyPlanLayoutOpts) { const { resolvedSessionId, taskId, sessionMetaPlanMode, + currentStepHasPlanMode, setActiveDocument, applyBuiltInPreset, setPlanMode, @@ -80,8 +85,17 @@ function useAutoApplyPlanLayout(opts: AutoApplyPlanLayoutOpts) { const autoAppliedPlanSessionRef = useRef(null); useEffect(() => { if (!resolvedSessionId || !taskId) return; + // Reset the guard when plan mode is disabled so future plan-mode steps + // in the same session can be auto-applied (e.g. after proceeding away and back). + if (!sessionMetaPlanMode && autoAppliedPlanSessionRef.current === resolvedSessionId) { + autoAppliedPlanSessionRef.current = null; + return; + } if (autoAppliedPlanSessionRef.current === resolvedSessionId) return; - if (sessionMetaPlanMode) { + // Only auto-apply if both the session metadata AND the current step agree on plan mode. + // sessionMetaPlanMode can be stale (deepMerge hydration preserves deleted keys), + // so we cross-check with the step's actual configuration. + if (sessionMetaPlanMode && currentStepHasPlanMode) { autoAppliedPlanSessionRef.current = resolvedSessionId; setActiveDocument(resolvedSessionId, { type: "plan", taskId }); applyBuiltInPreset("plan"); @@ -92,6 +106,7 @@ function useAutoApplyPlanLayout(opts: AutoApplyPlanLayoutOpts) { resolvedSessionId, taskId, sessionMetaPlanMode, + currentStepHasPlanMode, setActiveDocument, applyBuiltInPreset, setPlanMode, @@ -115,13 +130,19 @@ export function usePlanMode(resolvedSessionId: string | null, taskId: string | n const planModeFromStore = useAppStore((state) => resolvedSessionId ? (state.chatInput.planModeBySessionId[resolvedSessionId] ?? false) : false, ); - const sessionMetaPlanMode = useAppStore((state) => resolvedSessionId ? state.taskSessions.items[resolvedSessionId]?.metadata?.plan_mode === true : false, ); - + const currentStepHasPlanMode = useAppStore((s) => { + if (!taskId) return false; + const task = s.kanban.tasks.find((t) => t.id === taskId); + const stepId = task?.workflowStepId; + if (!stepId) return false; + const step = s.kanban.steps.find((st) => st.id === stepId); + return step?.events?.on_enter?.some((a) => a.type === "enable_plan_mode") ?? false; + }); const planModeEnabled = planModeFromStore; const planLayoutVisible = activeDocument?.type === "plan"; @@ -138,68 +159,37 @@ export function usePlanMode(resolvedSessionId: string | null, taskId: string | n resolvedSessionId, taskId, sessionMetaPlanMode, + currentStepHasPlanMode, setActiveDocument, applyBuiltInPreset, setPlanMode, addContextFile, }); + useAutoDisablePlanMode({ + resolvedSessionId, + taskId, + sessionMetaPlanMode, + planModeFromStore, + applyBuiltInPreset, + closeDocument, + setActiveDocument, + setPlanMode, + removeContextFile, + }); + const refocusChatAfterLayout = useRefocusChatAfterLayout(); - - // Toggle only the plan layout (plan panel + preset) without changing chat input plan mode state - const togglePlanLayout = useCallback( - (show: boolean) => { - if (!resolvedSessionId || !taskId) return; - if (show) { - setActiveDocument(resolvedSessionId, { type: "plan", taskId }); - applyBuiltInPreset("plan"); - } else { - applyBuiltInPreset("default"); - closeDocument(resolvedSessionId); - setActiveDocument(resolvedSessionId, null); - } - refocusChatAfterLayout(); - }, - [ - resolvedSessionId, - taskId, - setActiveDocument, - applyBuiltInPreset, - closeDocument, - refocusChatAfterLayout, - ], - ); - - // Full plan mode toggle: layout + chat input state (context file, border, submit style) - const handlePlanModeChange = useCallback( - (enabled: boolean) => { - if (!resolvedSessionId || !taskId) return; - if (enabled) { - setActiveDocument(resolvedSessionId, { type: "plan", taskId }); - applyBuiltInPreset("plan"); - setPlanMode(resolvedSessionId, true); - addContextFile(resolvedSessionId, { path: PLAN_CONTEXT_PATH, name: "Plan" }); - } else { - applyBuiltInPreset("default"); - closeDocument(resolvedSessionId); - setActiveDocument(resolvedSessionId, null); - setPlanMode(resolvedSessionId, false); - removeContextFile(resolvedSessionId, PLAN_CONTEXT_PATH); - } - refocusChatAfterLayout(); - }, - [ - resolvedSessionId, - taskId, - setActiveDocument, - applyBuiltInPreset, - closeDocument, - setPlanMode, - addContextFile, - removeContextFile, - refocusChatAfterLayout, - ], - ); + const { togglePlanLayout, handlePlanModeChange } = usePlanLayoutHandlers({ + resolvedSessionId, + taskId, + setActiveDocument, + applyBuiltInPreset, + closeDocument, + setPlanMode, + addContextFile, + removeContextFile, + refocusChatAfterLayout, + }); return { planModeEnabled, diff --git a/apps/web/components/task/chat/use-plan-mode-helpers.ts b/apps/web/components/task/chat/use-plan-mode-helpers.ts new file mode 100644 index 0000000000..e822119be4 --- /dev/null +++ b/apps/web/components/task/chat/use-plan-mode-helpers.ts @@ -0,0 +1,155 @@ +"use client"; + +import { useCallback, useEffect, useRef } from "react"; +import type { ActiveDocument } from "@/lib/state/slices/ui/types"; +import type { BuiltInPreset } from "@/lib/state/layout-manager/presets"; + +const PLAN_CONTEXT_PATH = "plan:context"; + +// --- Auto-disable plan mode --- + +type AutoDisablePlanOpts = { + resolvedSessionId: string | null; + taskId: string | null; + sessionMetaPlanMode: boolean; + planModeFromStore: boolean; + applyBuiltInPreset: (preset: BuiltInPreset) => void; + closeDocument: (sid: string) => void; + setActiveDocument: (sid: string, doc: ActiveDocument | null) => void; + setPlanMode: (sid: string, enabled: boolean) => void; + removeContextFile: (sid: string, path: string) => void; +}; + +/** + * Auto-disable plan mode when the backend clears plan_mode from session metadata. + * + * Triggers when `sessionMetaPlanMode` transitions from true to false — the backend explicitly + * cleared plan_mode (e.g., on_exit: disable_plan_mode, or entering a non-plan-mode step). + * + * User-initiated step moves (proceed button, stepper) disable plan mode directly in their + * click handlers rather than relying on this effect. + */ +export function useAutoDisablePlanMode(opts: AutoDisablePlanOpts) { + const { + resolvedSessionId, + taskId, + sessionMetaPlanMode, + planModeFromStore, + applyBuiltInPreset, + closeDocument, + setActiveDocument, + setPlanMode, + removeContextFile, + } = opts; + + const prevSessionMetaPlanRef = useRef(sessionMetaPlanMode); + + useEffect(() => { + const wasPlanMode = prevSessionMetaPlanRef.current; + prevSessionMetaPlanRef.current = sessionMetaPlanMode; + + if (!resolvedSessionId || !taskId) return; + + if (wasPlanMode && !sessionMetaPlanMode && planModeFromStore) { + applyBuiltInPreset("default"); + closeDocument(resolvedSessionId); + setActiveDocument(resolvedSessionId, null); + setPlanMode(resolvedSessionId, false); + removeContextFile(resolvedSessionId, PLAN_CONTEXT_PATH); + } + }, [ + resolvedSessionId, + taskId, + sessionMetaPlanMode, + planModeFromStore, + applyBuiltInPreset, + closeDocument, + setActiveDocument, + setPlanMode, + removeContextFile, + ]); +} + +// --- Plan layout handlers --- + +type PlanLayoutHandlersOpts = { + resolvedSessionId: string | null; + taskId: string | null; + setActiveDocument: (sid: string, doc: ActiveDocument | null) => void; + applyBuiltInPreset: (preset: BuiltInPreset) => void; + closeDocument: (sid: string) => void; + setPlanMode: (sid: string, enabled: boolean) => void; + addContextFile: (sid: string, file: { path: string; name: string }) => void; + removeContextFile: (sid: string, path: string) => void; + refocusChatAfterLayout: () => void; +}; + +/** Returns togglePlanLayout and handlePlanModeChange callbacks. */ +export function usePlanLayoutHandlers(opts: PlanLayoutHandlersOpts) { + const { + resolvedSessionId, + taskId, + setActiveDocument, + applyBuiltInPreset, + closeDocument, + setPlanMode, + addContextFile, + removeContextFile, + refocusChatAfterLayout, + } = opts; + + const togglePlanLayout = useCallback( + (show: boolean) => { + if (!resolvedSessionId || !taskId) return; + if (show) { + setActiveDocument(resolvedSessionId, { type: "plan", taskId }); + applyBuiltInPreset("plan"); + } else { + applyBuiltInPreset("default"); + closeDocument(resolvedSessionId); + setActiveDocument(resolvedSessionId, null); + } + refocusChatAfterLayout(); + }, + [ + resolvedSessionId, + taskId, + setActiveDocument, + applyBuiltInPreset, + closeDocument, + refocusChatAfterLayout, + ], + ); + + const handlePlanModeChange = useCallback( + (enabled: boolean) => { + if (!resolvedSessionId || !taskId) return; + if (enabled) { + setActiveDocument(resolvedSessionId, { type: "plan", taskId }); + applyBuiltInPreset("plan"); + setPlanMode(resolvedSessionId, true); + addContextFile(resolvedSessionId, { path: PLAN_CONTEXT_PATH, name: "Plan" }); + } else { + applyBuiltInPreset("default"); + closeDocument(resolvedSessionId); + setActiveDocument(resolvedSessionId, null); + setPlanMode(resolvedSessionId, false); + removeContextFile(resolvedSessionId, PLAN_CONTEXT_PATH); + } + refocusChatAfterLayout(); + }, + [ + resolvedSessionId, + taskId, + setActiveDocument, + applyBuiltInPreset, + closeDocument, + setPlanMode, + addContextFile, + removeContextFile, + refocusChatAfterLayout, + ], + ); + + return { togglePlanLayout, handlePlanModeChange }; +} diff --git a/apps/web/components/task/workflow-stepper.tsx b/apps/web/components/task/workflow-stepper.tsx index ac1c9a0c18..e784623cae 100644 --- a/apps/web/components/task/workflow-stepper.tsx +++ b/apps/web/components/task/workflow-stepper.tsx @@ -7,6 +7,10 @@ import { Button } from "@kandev/ui/button"; import { IconArrowRight } from "@tabler/icons-react"; import { moveTask } from "@/lib/api"; import { StepCapabilityIcons } from "@/components/step-capability-icons"; +import { useAppStore } from "@/components/state-provider"; +import { useContextFilesStore } from "@/lib/state/context-files-store"; +import { useLayoutStore } from "@/lib/state/layout-store"; +import { useDockviewStore } from "@/lib/state/dockview-store"; import type { KanbanStepEvents } from "@/lib/state/slices/kanban/types"; type Step = { @@ -20,6 +24,38 @@ type Step = { is_start_step?: boolean; }; +const PLAN_CONTEXT_PATH = "plan:context"; + +/** Returns a callback that disables plan mode for the active session of a task. */ +function useDisablePlanMode() { + const activeSessionId = useAppStore((s) => s.tasks.activeSessionId); + const planModeEnabled = useAppStore((s) => + activeSessionId ? (s.chatInput.planModeBySessionId[activeSessionId] ?? false) : false, + ); + const setPlanMode = useAppStore((s) => s.setPlanMode); + const setActiveDocument = useAppStore((s) => s.setActiveDocument); + const closeDocument = useLayoutStore((s) => s.closeDocument); + const removeContextFile = useContextFilesStore((s) => s.removeFile); + const applyBuiltInPreset = useDockviewStore((s) => s.applyBuiltInPreset); + + return useCallback(() => { + if (!activeSessionId || !planModeEnabled) return; + applyBuiltInPreset("default"); + closeDocument(activeSessionId); + setActiveDocument(activeSessionId, null); + setPlanMode(activeSessionId, false); + removeContextFile(activeSessionId, PLAN_CONTEXT_PATH); + }, [ + activeSessionId, + planModeEnabled, + setPlanMode, + setActiveDocument, + closeDocument, + removeContextFile, + applyBuiltInPreset, + ]); +} + type WorkflowStepperProps = { steps: Step[]; currentStepId: string | null; @@ -36,6 +72,7 @@ const WorkflowStepper = memo(function WorkflowStepper({ isArchived, }: WorkflowStepperProps) { const [movingToStepId, setMovingToStepId] = useState(null); + const disablePlanMode = useDisablePlanMode(); const sortedSteps = useMemo(() => [...steps].sort((a, b) => a.position - b.position), [steps]); @@ -47,6 +84,7 @@ const WorkflowStepper = memo(function WorkflowStepper({ const handleMove = useCallback( async (stepId: string) => { if (!taskId || !workflowId) return; + disablePlanMode(); setMovingToStepId(stepId); try { await moveTask(taskId, { @@ -60,7 +98,7 @@ const WorkflowStepper = memo(function WorkflowStepper({ setMovingToStepId(null); } }, - [taskId, workflowId], + [taskId, workflowId, disablePlanMode], ); if (sortedSteps.length === 0) return null; diff --git a/apps/web/e2e/pages/session-page.ts b/apps/web/e2e/pages/session-page.ts index 81ea6b93ca..593de65ba1 100644 --- a/apps/web/e2e/pages/session-page.ts +++ b/apps/web/e2e/pages/session-page.ts @@ -551,6 +551,11 @@ export class SessionPage { return tab.locator(".tabler-icon-star").first(); } + /** "Move to next step" button in the chat status bar. */ + proceedNextStepButton(): Locator { + return this.page.getByTestId("proceed-next-step"); + } + /** Click a task in the sidebar by title. */ async clickTaskInSidebar(title: string): Promise { const taskRow = this.sidebar.locator("[role='button']").filter({ hasText: title }); diff --git a/apps/web/e2e/tests/workflow/workflow-step-proceed.spec.ts b/apps/web/e2e/tests/workflow/workflow-step-proceed.spec.ts new file mode 100644 index 0000000000..89989d54f6 --- /dev/null +++ b/apps/web/e2e/tests/workflow/workflow-step-proceed.spec.ts @@ -0,0 +1,206 @@ +import { test, expect } from "../../fixtures/test-base"; +import { KanbanPage } from "../../pages/kanban-page"; +import { SessionPage } from "../../pages/session-page"; + +test.describe("Manual proceed to next workflow step", () => { + /** + * Regression test: moving a task out of a plan-mode step must disable plan mode + * and show the next step's auto-start prompt in chat. + * + * Workflow: Spec (auto_start_agent) -> Work (auto_start_agent) -> Done + * + * 1. Create task via API in Spec step — agent auto-starts and completes + * 2. Navigate to session page, manually enable plan mode + * 3. Click "proceed to next step" button (Work) + * 4. Assert: plan mode is disabled (plan panel hidden, default input placeholder) + * 5. Assert: stepper shows Work as current step + * 6. Assert: Work step's auto-start prompt response is visible in chat + */ + test("proceed from plan-mode step disables plan mode and shows next step prompt", async ({ + testPage, + apiClient, + seedData, + }) => { + const workflow = await apiClient.createWorkflow( + seedData.workspaceId, + "Proceed Plan Step Workflow", + ); + + const specStep = await apiClient.createWorkflowStep(workflow.id, "Spec", 0); + const workStep = await apiClient.createWorkflowStep(workflow.id, "Work", 1); + await apiClient.createWorkflowStep(workflow.id, "Done", 2); + + // Spec: auto-start agent (completes quickly so we can proceed) + await apiClient.updateWorkflowStep(specStep.id, { + prompt: 'e2e:message("spec done")\n{{task_prompt}}', + events: { + on_enter: [{ type: "auto_start_agent" }], + }, + }); + + // Work: auto-start agent with a response we can assert on + await apiClient.updateWorkflowStep(workStep.id, { + prompt: 'e2e:delay(2000)\ne2e:message("work step response")\n{{task_prompt}}', + events: { + on_enter: [{ type: "auto_start_agent" }], + }, + }); + + await apiClient.saveUserSettings({ + workspace_id: seedData.workspaceId, + workflow_filter_id: workflow.id, + enable_preview_on_click: false, + }); + + // Create task via API in Spec step — triggers auto_start_agent + await apiClient.createTask(seedData.workspaceId, "Plan Proceed Task", { + workflow_id: workflow.id, + workflow_step_id: specStep.id, + agent_profile_id: seedData.agentProfileId, + repository_ids: [seedData.repositoryId], + }); + + // Navigate to task session page + const kanban = new KanbanPage(testPage); + await kanban.goto(); + + const card = kanban.taskCardInColumn("Plan Proceed Task", specStep.id); + await expect(card).toBeVisible({ timeout: 15_000 }); + await card.click(); + await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 }); + + const session = new SessionPage(testPage); + await session.waitForLoad(); + + // Wait for agent to complete its turn (idle input visible) + await expect(session.idleInput()).toBeVisible({ timeout: 30_000 }); + + // Stepper shows Spec as current step + await expect(session.stepperStep("Spec")).toHaveAttribute("aria-current", "step", { + timeout: 10_000, + }); + + // The "proceed to next step" button should be visible (showing "Work") + const proceedBtn = session.proceedNextStepButton(); + await expect(proceedBtn).toBeVisible({ timeout: 10_000 }); + + // --- Enable plan mode manually (simulates being in a plan-mode workflow step) --- + await testPage.waitForTimeout(1_000); + await session.togglePlanMode(); + await expect(session.planPanel).toBeVisible({ timeout: 15_000 }); + await expect(session.planModeInput()).toBeVisible({ timeout: 10_000 }); + + // --- Click proceed to move to Work step --- + await proceedBtn.click(); + + // Plan mode should be disabled: plan panel hidden and input shows default placeholder + await expect(session.planPanel).not.toBeVisible({ timeout: 15_000 }); + await expect(session.planModeInput()).not.toBeVisible({ timeout: 10_000 }); + + // Stepper shows Work as current step + await expect(session.stepperStep("Work")).toHaveAttribute("aria-current", "step", { + timeout: 15_000, + }); + + // Work step auto-start prompt should be visible in chat (user message bubble) + await expect( + session.chat.getByText("work step response", { exact: false }).first(), + ).toBeVisible({ timeout: 30_000 }); + + // Session returns to idle in default (non-plan) mode + await expect(session.idleInput()).toBeVisible({ timeout: 15_000 }); + }); + + /** + * Regression test: the proceed button must re-enable after each step transition. + * Previously, isMoving stayed true after clicking proceed because the button + * reappeared (for the next step) before isMoving was reset. + * + * Workflow: Spec -> Work -> Review -> QA -> Done (all with auto_start_agent) + */ + test("proceed button re-enables across multiple step transitions", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(120_000); + + const workflow = await apiClient.createWorkflow( + seedData.workspaceId, + "Multi Step Proceed Workflow", + ); + + const stepNames = ["Spec", "Work", "Review", "QA", "Done"]; + const steps: { id: string; name: string }[] = []; + for (let i = 0; i < stepNames.length; i++) { + const step = await apiClient.createWorkflowStep(workflow.id, stepNames[i], i); + steps.push({ id: step.id, name: stepNames[i] }); + } + + // Configure all steps except Done with auto_start_agent and a fast prompt + for (const step of steps.slice(0, -1)) { + await apiClient.updateWorkflowStep(step.id, { + prompt: `e2e:message("${step.name} done")\n{{task_prompt}}`, + events: { + on_enter: [{ type: "auto_start_agent" }], + }, + }); + } + + await apiClient.saveUserSettings({ + workspace_id: seedData.workspaceId, + workflow_filter_id: workflow.id, + enable_preview_on_click: false, + }); + + // Create task in Spec step + await apiClient.createTask(seedData.workspaceId, "Multi Step Task", { + workflow_id: workflow.id, + workflow_step_id: steps[0].id, + agent_profile_id: seedData.agentProfileId, + repository_ids: [seedData.repositoryId], + }); + + const kanban = new KanbanPage(testPage); + await kanban.goto(); + + const card = kanban.taskCardInColumn("Multi Step Task", steps[0].id); + await expect(card).toBeVisible({ timeout: 15_000 }); + await card.click(); + await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 }); + + const session = new SessionPage(testPage); + await session.waitForLoad(); + + // Walk through each step transition: Spec -> Work -> Review -> QA -> Done + for (let i = 0; i < steps.length - 1; i++) { + const currentName = steps[i].name; + const nextName = steps[i + 1].name; + + // Wait for agent to complete in current step + await expect(session.idleInput()).toBeVisible({ timeout: 30_000 }); + + // Stepper shows current step + await expect(session.stepperStep(currentName)).toHaveAttribute("aria-current", "step", { + timeout: 10_000, + }); + + // Proceed button is visible AND enabled (not disabled by stale isMoving) + const proceedBtn = session.proceedNextStepButton(); + await expect(proceedBtn).toBeVisible({ timeout: 10_000 }); + await expect(proceedBtn).toBeEnabled({ timeout: 5_000 }); + + // Click proceed + await proceedBtn.click(); + + // Stepper updates to next step + await expect(session.stepperStep(nextName)).toHaveAttribute("aria-current", "step", { + timeout: 15_000, + }); + } + + // On Done step: proceed button should NOT be visible (final step) + await expect(session.idleInput()).toBeVisible({ timeout: 30_000 }); + await expect(session.proceedNextStepButton()).not.toBeVisible({ timeout: 5_000 }); + }); +}); diff --git a/apps/web/hooks/domains/kanban/use-plan-actions.ts b/apps/web/hooks/domains/kanban/use-plan-actions.ts index a07163de46..3bea15efa5 100644 --- a/apps/web/hooks/domains/kanban/use-plan-actions.ts +++ b/apps/web/hooks/domains/kanban/use-plan-actions.ts @@ -4,8 +4,13 @@ import { useToast } from "@/components/toast-provider"; import { getWebSocketClient } from "@/lib/ws/connection"; import { setChatDraftContent } from "@/lib/local-storage"; import { moveTask } from "@/lib/api/domains/kanban-api"; +import { useContextFilesStore } from "@/lib/state/context-files-store"; +import { useLayoutStore } from "@/lib/state/layout-store"; +import { useDockviewStore } from "@/lib/state/dockview-store"; import type { ChatInputContainerHandle } from "@/components/task/chat/chat-input-container"; +const PLAN_CONTEXT_PATH = "plan:context"; + const AUTO_TRANSITION_ACTIONS = ["move_to_next", "move_to_previous", "move_to_step"]; function useNextWorkflowStep(taskId: string | null) { @@ -18,7 +23,11 @@ function useNextWorkflowStep(taskId: string | null) { return task?.workflowStepId ?? null; }); - const [isMoving, setIsMoving] = useState(false); + // Track which step the user was on when they clicked proceed. isMoving is true + // while taskStepId still matches the step we're moving FROM. Once the WS event + // updates taskStepId, isMoving becomes false and the button is clickable again. + const [moveFromStepId, setMoveFromStepId] = useState(null); + const isMoving = moveFromStepId != null && taskStepId === moveFromStepId; const sortedSteps = useMemo(() => [...steps].sort((a, b) => a.position - b.position), [steps]); @@ -51,21 +60,19 @@ function useNextWorkflowStep(taskId: string | null) { const proceed = useCallback(async () => { if (!taskId || !workflowId || !nextStep) return; - setIsMoving(true); + setMoveFromStepId(taskStepId); try { await moveTask(taskId, { workflow_id: workflowId, workflow_step_id: nextStep.id, position: 0, }); - // Keep isMoving=true until the WS event updates taskStepId and - // proceedStepName becomes null, hiding the button entirely. } catch (err) { console.error("Failed to proceed to next step:", err); toast({ description: "Failed to proceed to next step", variant: "error" }); - setIsMoving(false); + setMoveFromStepId(null); } - }, [taskId, workflowId, nextStep, toast]); + }, [taskId, workflowId, nextStep, taskStepId, toast]); const proceedStepName = nextStep && !currentStepAutoTransitions ? nextStep.title : null; @@ -115,6 +122,31 @@ After completing the implementation, provide a summary of what was done. }, [resolvedSessionId, taskId, handlePlanModeChange, chatInputRef]); } +/** Directly disable plan mode state + layout, bypassing the MCP availability guard. */ +function useDirectDisablePlanMode(resolvedSessionId: string | null) { + const setPlanMode = useAppStore((s) => s.setPlanMode); + const setActiveDocument = useAppStore((s) => s.setActiveDocument); + const closeDocument = useLayoutStore((s) => s.closeDocument); + const removeContextFile = useContextFilesStore((s) => s.removeFile); + const applyBuiltInPreset = useDockviewStore((s) => s.applyBuiltInPreset); + + return useCallback(() => { + if (!resolvedSessionId) return; + applyBuiltInPreset("default"); + closeDocument(resolvedSessionId); + setActiveDocument(resolvedSessionId, null); + setPlanMode(resolvedSessionId, false); + removeContextFile(resolvedSessionId, PLAN_CONTEXT_PATH); + }, [ + resolvedSessionId, + applyBuiltInPreset, + closeDocument, + setActiveDocument, + setPlanMode, + removeContextFile, + ]); +} + export function usePlanActions(opts: { resolvedSessionId: string | null; taskId: string | null; @@ -128,9 +160,24 @@ export function usePlanActions(opts: { opts.handlePlanModeChange, opts.chatInputRef, ); - const { proceedStepName, nextStepIsWorkStep, proceed, isMoving } = useNextWorkflowStep( - opts.taskId, - ); + const { + proceedStepName, + nextStepIsWorkStep, + proceed: rawProceed, + isMoving, + } = useNextWorkflowStep(opts.taskId); + + const disablePlanMode = useDirectDisablePlanMode(opts.resolvedSessionId); + const { planModeEnabled } = opts; + // Disable plan mode before proceeding so the layout switches back to default + // and the next step's auto-start prompt is visible in chat. + const proceed = useCallback(() => { + if (planModeEnabled) { + disablePlanMode(); + } + rawProceed(); + }, [planModeEnabled, disablePlanMode, rawProceed]); + const implementPlanHandler = opts.planModeEnabled && !nextStepIsWorkStep ? handleImplementPlan : undefined; return { implementPlanHandler, proceedStepName, proceed, isMoving };