feat(ui): add proceed button to advance task to next workflow step (#486)
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user