From 06b82f5452331ebeead1e388874828632a84aa67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Sun, 29 Mar 2026 17:04:37 +0100 Subject: [PATCH] feat(ui): add proceed button to advance task to next workflow step (#486) --- .../components/task/chat/chat-input-area.tsx | 124 +++++++--------- .../hooks/domains/kanban/use-plan-actions.ts | 137 ++++++++++++++++++ 2 files changed, 187 insertions(+), 74 deletions(-) create mode 100644 apps/web/hooks/domains/kanban/use-plan-actions.ts diff --git a/apps/web/components/task/chat/chat-input-area.tsx b/apps/web/components/task/chat/chat-input-area.tsx index 8b04e6d4cf..6c381b4086 100644 --- a/apps/web/components/task/chat/chat-input-area.tsx +++ b/apps/web/components/task/chat/chat-input-area.tsx @@ -1,7 +1,8 @@ "use client"; import { useCallback, useState } from "react"; -import { IconGitMerge, IconX } from "@tabler/icons-react"; +import { IconArrowRight, IconGitMerge, IconX } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; import { TodoIndicator } from "./todo-indicator"; import { getWebSocketClient } from "@/lib/ws/connection"; import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut"; @@ -19,7 +20,7 @@ import { formatPRFeedbackAsMarkdown, formatPlanCommentsAsMarkdown, } from "@/lib/state/slices/comments/format"; -import { setChatDraftContent } from "@/lib/local-storage"; +import { usePlanActions } from "@/hooks/domains/kanban/use-plan-actions"; import { useArchiveAndSwitchTask } from "@/hooks/use-task-actions"; import { useToast } from "@/components/toast-provider"; import type { DiffComment } from "@/lib/diff/types"; @@ -208,49 +209,6 @@ export function useChatPanelHandlers( return { handleCancelTurn, handleCancelQueue, handleQueueEditComplete }; } -function useImplementPlan( - resolvedSessionId: string | null, - taskId: string | null, - handlePlanModeChange: (enabled: boolean) => void, - chatInputRef: React.RefObject, -) { - return useCallback(() => { - if (!resolvedSessionId || !taskId) return; - - const client = getWebSocketClient(); - if (!client) return; - - const userText = chatInputRef.current?.getValue() ?? ""; - chatInputRef.current?.clear(); - if (resolvedSessionId) { - setChatDraftContent(resolvedSessionId, null); - } - - const visibleText = userText.trim() || "Implement the plan"; - const content = `${visibleText}\n\n -IMPLEMENT PLAN: The user has approved the plan and wants you to implement it now. -Read the current plan using the get_task_plan_kandev MCP tool. -Implement all changes described in the plan step by step. -After completing the implementation, provide a summary of what was done. -`; - - client - .request( - "message.add", - { - task_id: taskId, - session_id: resolvedSessionId, - content, - plan_mode: false, - }, - 10000, - ) - .catch((err: unknown) => console.error("Failed to send implement plan message:", err)); - - handlePlanModeChange(false); - }, [resolvedSessionId, taskId, handlePlanModeChange, chatInputRef]); -} - function PRMergedBanner({ taskId }: { taskId: string }) { const taskPR = useAppStore((state) => state.taskPRs.byTaskId[taskId]); const [dismissed, setDismissed] = useState(false); @@ -306,11 +264,20 @@ type TodoDisplayItem = { function ChatStatusBar({ todoItems, taskId, + nextStepName, + onProceed, + isAgentBusy, + isMoving, }: { todoItems: TodoDisplayItem[]; taskId: string | null; + nextStepName: string | null; + onProceed: () => void; + isAgentBusy: boolean; + isMoving: boolean; }) { const showTodos = todoItems.length > 0; + const showProceed = !!nextStepName && !isAgentBusy; // PRMergedBanner returns null internally when not applicable return (
{showTodos && } {taskId && } + {showProceed && ( + + )}
); } @@ -362,33 +343,21 @@ export function ChatInputArea({ }: ChatInputAreaProps) { const { resolvedSessionId, - task, taskId, - taskDescription, - isStarting, isAgentBusy, - isFailed, needsRecovery, planModeEnabled, activeDocument, handlePlanModeChange, - contextFiles, - handleToggleContextFile, - handleAddContextFile, - pendingCommentsByFile, - chatSubmitKey, - agentCommands, - contextItems, - planContextEnabled, todoItems, } = panelState; - const handleImplementPlan = useImplementPlan( + const { implementPlanHandler, proceedStepName, proceed, isMoving } = usePlanActions({ resolvedSessionId, taskId, + planModeEnabled, handlePlanModeChange, chatInputRef, - ); - + }); const hasClarification = !!panelState.pendingClarification; const placeholder = placeholderOverride ?? @@ -401,21 +370,28 @@ export function ChatInputArea({ ); return (
- + 0 || panelState.pendingPRFeedback.length > 0 } - submitKey={chatSubmitKey} - hasAgentCommands={!!(agentCommands && agentCommands.length > 0)} - isFailed={isFailed} + submitKey={panelState.chatSubmitKey} + hasAgentCommands={!!(panelState.agentCommands && panelState.agentCommands.length > 0)} + isFailed={panelState.isFailed} needsRecovery={needsRecovery} - contextItems={contextItems} - planContextEnabled={planContextEnabled} - contextFiles={contextFiles} - onToggleContextFile={handleToggleContextFile} - onAddContextFile={handleAddContextFile} - onImplementPlan={handleImplementPlan} + contextItems={panelState.contextItems} + planContextEnabled={panelState.planContextEnabled} + contextFiles={panelState.contextFiles} + onToggleContextFile={panelState.handleToggleContextFile} + onAddContextFile={panelState.handleAddContextFile} + onImplementPlan={implementPlanHandler} hideSessionsDropdown={hideSessionsDropdown} minimalToolbar={minimalToolbar} hidePlanMode={hidePlanMode} diff --git a/apps/web/hooks/domains/kanban/use-plan-actions.ts b/apps/web/hooks/domains/kanban/use-plan-actions.ts new file mode 100644 index 0000000000..a07163de46 --- /dev/null +++ b/apps/web/hooks/domains/kanban/use-plan-actions.ts @@ -0,0 +1,137 @@ +import React, { useCallback, useMemo, useState } from "react"; +import { useAppStore } from "@/components/state-provider"; +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 type { ChatInputContainerHandle } from "@/components/task/chat/chat-input-container"; + +const AUTO_TRANSITION_ACTIONS = ["move_to_next", "move_to_previous", "move_to_step"]; + +function useNextWorkflowStep(taskId: string | null) { + const { toast } = useToast(); + const workflowId = useAppStore((s) => s.kanban.workflowId); + const steps = useAppStore((s) => s.kanban.steps); + const taskStepId = useAppStore((s) => { + if (!taskId) return null; + const task = s.kanban.tasks.find((t) => t.id === taskId); + return task?.workflowStepId ?? null; + }); + + const [isMoving, setIsMoving] = useState(false); + + const sortedSteps = useMemo(() => [...steps].sort((a, b) => a.position - b.position), [steps]); + + const { currentStep, nextStep } = useMemo(() => { + const currentIndex = sortedSteps.findIndex((s) => s.id === taskStepId); + const current = currentIndex >= 0 ? sortedSteps[currentIndex] : null; + const next = + currentIndex >= 0 && currentIndex < sortedSteps.length - 1 + ? sortedSteps[currentIndex + 1] + : null; + return { currentStep: current, nextStep: next }; + }, [sortedSteps, taskStepId]); + + const currentStepAutoTransitions = useMemo( + () => + currentStep?.events?.on_turn_complete?.some((a) => + AUTO_TRANSITION_ACTIONS.includes(a.type), + ) ?? false, + [currentStep], + ); + + const nextStepIsWorkStep = useMemo(() => { + if (!nextStep) return false; + const hasAutoStart = + nextStep.events?.on_enter?.some((a) => a.type === "auto_start_agent") ?? false; + const hasPlanMode = + nextStep.events?.on_enter?.some((a) => a.type === "enable_plan_mode") ?? false; + return hasAutoStart && !hasPlanMode; + }, [nextStep]); + + const proceed = useCallback(async () => { + if (!taskId || !workflowId || !nextStep) return; + setIsMoving(true); + 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); + } + }, [taskId, workflowId, nextStep, toast]); + + const proceedStepName = nextStep && !currentStepAutoTransitions ? nextStep.title : null; + + return { proceedStepName, nextStepIsWorkStep, proceed, isMoving }; +} + +function useImplementPlan( + resolvedSessionId: string | null, + taskId: string | null, + handlePlanModeChange: (enabled: boolean) => void, + chatInputRef: React.RefObject, +) { + return useCallback(() => { + if (!resolvedSessionId || !taskId) return; + + const client = getWebSocketClient(); + if (!client) return; + + const userText = chatInputRef.current?.getValue() ?? ""; + chatInputRef.current?.clear(); + if (resolvedSessionId) { + setChatDraftContent(resolvedSessionId, null); + } + + const visibleText = userText.trim() || "Implement the plan"; + const content = `${visibleText}\n\n +IMPLEMENT PLAN: The user has approved the plan and wants you to implement it now. +Read the current plan using the get_task_plan_kandev MCP tool. +Implement all changes described in the plan step by step. +After completing the implementation, provide a summary of what was done. +`; + + client + .request( + "message.add", + { + task_id: taskId, + session_id: resolvedSessionId, + content, + plan_mode: false, + }, + 10000, + ) + .catch((err: unknown) => console.error("Failed to send implement plan message:", err)); + + handlePlanModeChange(false); + }, [resolvedSessionId, taskId, handlePlanModeChange, chatInputRef]); +} + +export function usePlanActions(opts: { + resolvedSessionId: string | null; + taskId: string | null; + planModeEnabled: boolean; + handlePlanModeChange: (enabled: boolean) => void; + chatInputRef: React.RefObject; +}) { + const handleImplementPlan = useImplementPlan( + opts.resolvedSessionId, + opts.taskId, + opts.handlePlanModeChange, + opts.chatInputRef, + ); + const { proceedStepName, nextStepIsWorkStep, proceed, isMoving } = useNextWorkflowStep( + opts.taskId, + ); + const implementPlanHandler = + opts.planModeEnabled && !nextStepIsWorkStep ? handleImplementPlan : undefined; + return { implementPlanHandler, proceedStepName, proceed, isMoving }; +}