feat(ui): add proceed button to advance task to next workflow step (#486)

This commit is contained in:
Carlos Florêncio
2026-03-29 17:04:37 +01:00
committed by GitHub
parent 5f833dc5fa
commit 06b82f5452
2 changed files with 187 additions and 74 deletions
@@ -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<ChatInputContainerHandle | null>,
) {
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<kandev-system>
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.
</kandev-system>`;
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 (
<div
@@ -319,6 +286,20 @@ function ChatStatusBar({
>
{showTodos && <TodoIndicator todos={todoItems} />}
{taskId && <PRMergedBanner key={taskId} taskId={taskId} />}
{showProceed && (
<Button
type="button"
variant="ghost"
size="sm"
className="ml-auto h-6 gap-1 px-2 text-xs cursor-pointer hover:bg-muted/40"
onClick={onProceed}
disabled={isMoving}
data-testid="proceed-next-step"
>
<span className="text-muted-foreground">{nextStepName}</span>
<IconArrowRight className="h-3.5 w-3.5" />
</Button>
)}
</div>
);
}
@@ -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 (
<div className="bg-card flex-shrink-0 px-2 pb-2 pt-1">
<ChatStatusBar todoItems={todoItems} taskId={taskId} />
<ChatStatusBar
todoItems={todoItems}
taskId={taskId}
nextStepName={proceedStepName}
onProceed={proceed}
isAgentBusy={isAgentBusy}
isMoving={isMoving}
/>
<ChatInputContainer
ref={chatInputRef}
key={clarificationKey}
onSubmit={handleSubmit}
sessionId={resolvedSessionId}
taskId={taskId}
taskTitle={task?.title}
taskDescription={taskDescription ?? ""}
taskTitle={panelState.task?.title}
taskDescription={panelState.taskDescription ?? ""}
planModeEnabled={planModeEnabled}
planModeAvailable={panelState.planModeAvailable}
mcpServers={panelState.mcpServers}
onPlanModeChange={handlePlanModeChange}
isAgentBusy={isAgentBusy}
isStarting={isStarting}
isStarting={panelState.isStarting}
isSending={isSending}
onCancel={handleCancelTurn}
placeholder={placeholder}
@@ -423,20 +399,20 @@ export function ChatInputArea({
onClarificationResolved={onClarificationResolved}
showRequestChangesTooltip={showRequestChangesTooltip}
onRequestChangesTooltipDismiss={onRequestChangesTooltipDismiss}
pendingCommentsByFile={pendingCommentsByFile}
pendingCommentsByFile={panelState.pendingCommentsByFile}
hasContextComments={
panelState.planComments.length > 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}
@@ -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<ChatInputContainerHandle | null>,
) {
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<kandev-system>
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.
</kandev-system>`;
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<ChatInputContainerHandle | null>;
}) {
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 };
}