feat(KB-218): add workflow steps for post-implementation review
- Add core data model for workflow step definitions with AI-assisted prompt refinement - Create API routes for CRUD operations and prompt refinement via /api/workflow-steps - Add WorkflowStepManager dashboard UI for defining and managing workflow steps - Integrate workflow step selection into NewTaskModal for per-task enablement - Execute workflow steps sequentially in executor after task_done() with readonly tools - Run workflow step agents before moving tasks to in-review, failing on step errors - Add comprehensive tests for store, API routes, components, and executor integration
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch } from "lucide-react";
|
||||
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Workflow } from "lucide-react";
|
||||
|
||||
// GitHub logo icon (Octocat mark) - uses currentColor for theme compatibility
|
||||
function GitHubLogo({ size = 16 }: { size?: number }) {
|
||||
@@ -24,6 +24,7 @@ interface HeaderProps {
|
||||
onOpenActivityLog?: () => void;
|
||||
onOpenSchedules?: () => void;
|
||||
onOpenGitManager?: () => void;
|
||||
onOpenWorkflowSteps?: () => void;
|
||||
onToggleTerminal?: () => void;
|
||||
/** Opens the top-level workspace-aware file browser modal. */
|
||||
onOpenFiles?: () => void;
|
||||
@@ -63,6 +64,7 @@ export function Header({
|
||||
onOpenActivityLog,
|
||||
onOpenSchedules,
|
||||
onOpenGitManager,
|
||||
onOpenWorkflowSteps,
|
||||
onToggleTerminal,
|
||||
onOpenFiles,
|
||||
filesOpen,
|
||||
@@ -351,6 +353,18 @@ export function Header({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Workflow Steps - desktop only */}
|
||||
{!isMobile && onOpenWorkflowSteps && (
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={onOpenWorkflowSteps}
|
||||
title="Workflow Steps"
|
||||
data-testid="workflow-steps-btn"
|
||||
>
|
||||
<Workflow size={16} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Settings - always inline on desktop */}
|
||||
{!isMobile && (
|
||||
<button className="btn-icon" onClick={onOpenSettings} title="Settings">
|
||||
@@ -426,6 +440,17 @@ export function Header({
|
||||
<Clock size={16} />
|
||||
<span>Scheduled Tasks</span>
|
||||
</button>
|
||||
{onOpenWorkflowSteps && (
|
||||
<button
|
||||
className="mobile-overflow-item"
|
||||
onClick={() => handleOverflowAction(onOpenWorkflowSteps)}
|
||||
role="menuitem"
|
||||
data-testid="overflow-workflow-steps-btn"
|
||||
>
|
||||
<Workflow size={16} />
|
||||
<span>Workflow Steps</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="mobile-overflow-item"
|
||||
onClick={() => handleOverflowAction(onOpenSettings)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
|
||||
import type { Task, TaskCreateInput, ModelPreset, Settings } from "@kb/core";
|
||||
import type { Task, TaskCreateInput, ModelPreset, Settings, WorkflowStep } from "@kb/core";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { uploadAttachment, fetchModels, fetchSettings } from "../api";
|
||||
import { uploadAttachment, fetchModels, fetchSettings, fetchWorkflowSteps } from "../api";
|
||||
import type { ModelInfo } from "../api";
|
||||
import { filterModels } from "../utils/modelFilter";
|
||||
import { applyPresetToSelection, getRecommendedPresetForSize } from "../utils/modelPresets";
|
||||
@@ -317,6 +317,8 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
|
||||
const [presetMode, setPresetMode] = useState<"default" | "preset" | "custom">("default");
|
||||
const [enablePlanningMode, setEnablePlanningMode] = useState(false);
|
||||
const [hasDirtyState, setHasDirtyState] = useState(false);
|
||||
const [workflowSteps, setWorkflowSteps] = useState<WorkflowStep[]>([]);
|
||||
const [selectedWorkflowSteps, setSelectedWorkflowSteps] = useState<string[]>([]);
|
||||
|
||||
const depDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const descTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
@@ -333,6 +335,9 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
|
||||
fetchSettings()
|
||||
.then((nextSettings) => setSettings(nextSettings))
|
||||
.catch(() => setSettings(null));
|
||||
fetchWorkflowSteps()
|
||||
.then((steps) => setWorkflowSteps(steps.filter((s) => s.enabled)))
|
||||
.catch(() => setWorkflowSteps([]));
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
@@ -344,9 +349,10 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
|
||||
pendingImages.length > 0 ||
|
||||
executorModel !== "" ||
|
||||
validatorModel !== "" ||
|
||||
enablePlanningMode;
|
||||
enablePlanningMode ||
|
||||
selectedWorkflowSteps.length > 0;
|
||||
setHasDirtyState(isDirty);
|
||||
}, [description, dependencies, pendingImages, executorModel, validatorModel, enablePlanningMode]);
|
||||
}, [description, dependencies, pendingImages, executorModel, validatorModel, enablePlanningMode, selectedWorkflowSteps]);
|
||||
|
||||
const availablePresets = settings?.modelPresets || [];
|
||||
const selectedPreset = availablePresets.find((preset) => preset.id === selectedPresetId);
|
||||
@@ -455,6 +461,7 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
|
||||
setSelectedPresetId("");
|
||||
setPresetMode("default");
|
||||
setEnablePlanningMode(false);
|
||||
setSelectedWorkflowSteps([]);
|
||||
setHasDirtyState(false);
|
||||
onClose();
|
||||
}, [hasDirtyState, onClose, pendingImages]);
|
||||
@@ -479,6 +486,7 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
|
||||
setSelectedPresetId("");
|
||||
setPresetMode("default");
|
||||
setEnablePlanningMode(false);
|
||||
setSelectedWorkflowSteps([]);
|
||||
|
||||
// Close modal and trigger planning mode
|
||||
onClose();
|
||||
@@ -500,6 +508,7 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
|
||||
description: trimmedDesc,
|
||||
column: "triage",
|
||||
dependencies: dependencies.length ? dependencies : undefined,
|
||||
enabledWorkflowSteps: selectedWorkflowSteps.length > 0 ? selectedWorkflowSteps : undefined,
|
||||
modelPresetId: presetMode === "preset" ? selectedPresetId || undefined : undefined,
|
||||
modelProvider: executorModel && executorSlashIdx !== -1 ? executorModel.slice(0, executorSlashIdx) : undefined,
|
||||
modelId: executorModel && executorSlashIdx !== -1 ? executorModel.slice(executorSlashIdx + 1) : undefined,
|
||||
@@ -532,6 +541,7 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
|
||||
setSelectedPresetId("");
|
||||
setPresetMode("default");
|
||||
setEnablePlanningMode(false);
|
||||
setSelectedWorkflowSteps([]);
|
||||
|
||||
addToast(`Created ${task.id}`, "success");
|
||||
onClose();
|
||||
@@ -760,6 +770,46 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Workflow Steps */}
|
||||
{workflowSteps.length > 0 && (
|
||||
<div className="form-group" data-testid="workflow-steps-section">
|
||||
<label>Workflow Steps</label>
|
||||
<small style={{ marginBottom: "8px", display: "block" }}>
|
||||
Select steps to run after task implementation completes
|
||||
</small>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
|
||||
{workflowSteps.map((step) => (
|
||||
<label
|
||||
key={step.id}
|
||||
className="checkbox-label"
|
||||
style={{ display: "flex", alignItems: "flex-start", gap: "8px" }}
|
||||
data-testid={`workflow-step-checkbox-${step.id}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedWorkflowSteps.includes(step.id)}
|
||||
onChange={(e) => {
|
||||
setSelectedWorkflowSteps((prev) =>
|
||||
e.target.checked
|
||||
? [...prev, step.id]
|
||||
: prev.filter((id) => id !== step.id)
|
||||
);
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
style={{ marginTop: "2px" }}
|
||||
/>
|
||||
<div>
|
||||
<span style={{ fontWeight: 500, fontSize: "13px" }}>{step.name}</span>
|
||||
<div style={{ fontSize: "12px", color: "var(--text-secondary)", marginTop: "2px" }}>
|
||||
{step.description}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Planning Mode Toggle */}
|
||||
<div className="form-group">
|
||||
<label className="checkbox-label">
|
||||
|
||||
468
packages/dashboard/app/components/WorkflowStepManager.tsx
Normal file
468
packages/dashboard/app/components/WorkflowStepManager.tsx
Normal file
@@ -0,0 +1,468 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { WorkflowStep, WorkflowStepInput } from "@kb/core";
|
||||
import { fetchWorkflowSteps, createWorkflowStep, updateWorkflowStep, deleteWorkflowStep, refineWorkflowStepPrompt } from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { X, Plus, Pencil, Trash2, Sparkles, Check, Loader2 } from "lucide-react";
|
||||
|
||||
interface WorkflowStepManagerProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
}
|
||||
|
||||
interface StepFormData {
|
||||
name: string;
|
||||
description: string;
|
||||
prompt: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: StepFormData = {
|
||||
name: "",
|
||||
description: "",
|
||||
prompt: "",
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
export function WorkflowStepManager({ isOpen, onClose, addToast }: WorkflowStepManagerProps) {
|
||||
const [steps, setSteps] = useState<WorkflowStep[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [form, setForm] = useState<StepFormData>(EMPTY_FORM);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [refining, setRefining] = useState(false);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
|
||||
const loadSteps = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await fetchWorkflowSteps();
|
||||
setSteps(data);
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to load workflow steps", "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [addToast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadSteps();
|
||||
}
|
||||
}, [isOpen, loadSteps]);
|
||||
|
||||
const handleCreate = useCallback(() => {
|
||||
setIsCreating(true);
|
||||
setEditingId(null);
|
||||
setForm(EMPTY_FORM);
|
||||
}, []);
|
||||
|
||||
const handleEdit = useCallback((step: WorkflowStep) => {
|
||||
setEditingId(step.id);
|
||||
setIsCreating(false);
|
||||
setForm({
|
||||
name: step.name,
|
||||
description: step.description,
|
||||
prompt: step.prompt,
|
||||
enabled: step.enabled,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setEditingId(null);
|
||||
setIsCreating(false);
|
||||
setForm(EMPTY_FORM);
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!form.name.trim() || !form.description.trim()) {
|
||||
addToast("Name and description are required", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
if (isCreating) {
|
||||
const input: WorkflowStepInput = {
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim(),
|
||||
prompt: form.prompt.trim() || undefined,
|
||||
enabled: form.enabled,
|
||||
};
|
||||
await createWorkflowStep(input);
|
||||
addToast("Workflow step created", "success");
|
||||
} else if (editingId) {
|
||||
await updateWorkflowStep(editingId, {
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim(),
|
||||
prompt: form.prompt,
|
||||
enabled: form.enabled,
|
||||
});
|
||||
addToast("Workflow step updated", "success");
|
||||
}
|
||||
|
||||
setIsCreating(false);
|
||||
setEditingId(null);
|
||||
setForm(EMPTY_FORM);
|
||||
await loadSteps();
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to save workflow step", "error");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [form, isCreating, editingId, addToast, loadSteps]);
|
||||
|
||||
const handleDelete = useCallback(async (id: string) => {
|
||||
try {
|
||||
await deleteWorkflowStep(id);
|
||||
addToast("Workflow step deleted", "success");
|
||||
setDeleteConfirmId(null);
|
||||
if (editingId === id) {
|
||||
setEditingId(null);
|
||||
setForm(EMPTY_FORM);
|
||||
}
|
||||
await loadSteps();
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to delete workflow step", "error");
|
||||
}
|
||||
}, [editingId, addToast, loadSteps]);
|
||||
|
||||
const handleRefine = useCallback(async () => {
|
||||
if (!editingId && !isCreating) return;
|
||||
|
||||
// For new steps being created, we need to save first then refine
|
||||
if (isCreating) {
|
||||
if (!form.name.trim() || !form.description.trim()) {
|
||||
addToast("Name and description are required before refining", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const input: WorkflowStepInput = {
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim(),
|
||||
prompt: form.prompt.trim() || undefined,
|
||||
enabled: form.enabled,
|
||||
};
|
||||
const created = await createWorkflowStep(input);
|
||||
setIsCreating(false);
|
||||
setEditingId(created.id);
|
||||
|
||||
// Now refine
|
||||
setRefining(true);
|
||||
const result = await refineWorkflowStepPrompt(created.id);
|
||||
setForm((prev) => ({ ...prev, prompt: result.prompt }));
|
||||
addToast("Prompt refined with AI", "success");
|
||||
await loadSteps();
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to refine prompt", "error");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setRefining(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!editingId) return;
|
||||
|
||||
setRefining(true);
|
||||
try {
|
||||
const result = await refineWorkflowStepPrompt(editingId);
|
||||
setForm((prev) => ({ ...prev, prompt: result.prompt }));
|
||||
addToast("Prompt refined with AI", "success");
|
||||
await loadSteps();
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to refine prompt", "error");
|
||||
} finally {
|
||||
setRefining(false);
|
||||
}
|
||||
}, [editingId, isCreating, form, addToast, loadSteps]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const isEditing = isCreating || editingId !== null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose} data-testid="workflow-step-manager">
|
||||
<div
|
||||
className="modal workflow-step-manager-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-label="Workflow Steps"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="modal-header">
|
||||
<h2>Workflow Steps</h2>
|
||||
<button className="btn-icon" onClick={onClose} aria-label="Close">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body" style={{ padding: "16px", maxHeight: "70vh", overflowY: "auto" }}>
|
||||
{loading ? (
|
||||
<div style={{ textAlign: "center", padding: "32px", color: "var(--text-secondary)" }}>
|
||||
Loading...
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Step list */}
|
||||
{steps.length === 0 && !isEditing && (
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
padding: "32px",
|
||||
color: "var(--text-secondary)",
|
||||
fontSize: "14px",
|
||||
}}
|
||||
data-testid="empty-state"
|
||||
>
|
||||
No workflow steps defined. Create one to get started.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{steps.length > 0 && !isEditing && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
||||
{steps.map((step) => (
|
||||
<div
|
||||
key={step.id}
|
||||
className="workflow-step-card"
|
||||
data-testid={`workflow-step-${step.id}`}
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
border: "1px solid var(--border-primary)",
|
||||
borderRadius: "8px",
|
||||
background: "var(--bg-secondary)",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px", marginBottom: "4px" }}>
|
||||
<span style={{ fontWeight: 600, fontSize: "14px" }}>{step.name}</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "11px",
|
||||
padding: "2px 6px",
|
||||
borderRadius: "4px",
|
||||
background: step.enabled ? "var(--status-success-bg, rgba(34, 197, 94, 0.15))" : "var(--bg-tertiary)",
|
||||
color: step.enabled ? "var(--status-success, #22c55e)" : "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
{step.enabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "var(--text-secondary)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{step.description}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "4px", marginLeft: "8px", flexShrink: 0 }}>
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => handleEdit(step)}
|
||||
title="Edit"
|
||||
aria-label={`Edit ${step.name}`}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
{deleteConfirmId === step.id ? (
|
||||
<div style={{ display: "flex", gap: "4px", alignItems: "center" }}>
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => handleDelete(step.id)}
|
||||
title="Confirm delete"
|
||||
aria-label={`Confirm delete ${step.name}`}
|
||||
style={{ color: "var(--status-error, #ef4444)" }}
|
||||
>
|
||||
<Check size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => setDeleteConfirmId(null)}
|
||||
title="Cancel delete"
|
||||
aria-label="Cancel delete"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => setDeleteConfirmId(step.id)}
|
||||
title="Delete"
|
||||
aria-label={`Delete ${step.name}`}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit / Create form */}
|
||||
{isEditing && (
|
||||
<div
|
||||
style={{
|
||||
padding: "16px",
|
||||
border: "1px solid var(--border-primary)",
|
||||
borderRadius: "8px",
|
||||
background: "var(--bg-secondary)",
|
||||
}}
|
||||
data-testid="workflow-step-form"
|
||||
>
|
||||
<h3 style={{ margin: "0 0 12px", fontSize: "14px", fontWeight: 600 }}>
|
||||
{isCreating ? "New Workflow Step" : "Edit Workflow Step"}
|
||||
</h3>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label style={{ display: "block", fontSize: "12px", color: "var(--text-secondary)", marginBottom: "4px" }}>
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="e.g. Documentation Review"
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid var(--border-primary)",
|
||||
background: "var(--bg-primary)",
|
||||
color: "var(--text-primary)",
|
||||
fontSize: "13px",
|
||||
}}
|
||||
data-testid="workflow-step-name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label style={{ display: "block", fontSize: "12px", color: "var(--text-secondary)", marginBottom: "4px" }}>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={form.description}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, description: e.target.value }))}
|
||||
placeholder="Brief description of what this step does"
|
||||
rows={2}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid var(--border-primary)",
|
||||
background: "var(--bg-primary)",
|
||||
color: "var(--text-primary)",
|
||||
fontSize: "13px",
|
||||
resize: "vertical",
|
||||
}}
|
||||
data-testid="workflow-step-description"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Prompt */}
|
||||
<div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "4px" }}>
|
||||
<label style={{ fontSize: "12px", color: "var(--text-secondary)" }}>
|
||||
Agent Prompt
|
||||
</label>
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={handleRefine}
|
||||
disabled={!form.description.trim() || refining}
|
||||
title="Refine with AI"
|
||||
aria-label="Refine prompt with AI"
|
||||
style={{ fontSize: "12px", display: "flex", alignItems: "center", gap: "4px" }}
|
||||
data-testid="refine-btn"
|
||||
>
|
||||
{refining ? <Loader2 size={12} className="spin" /> : <Sparkles size={12} />}
|
||||
<span style={{ fontSize: "11px" }}>Refine with AI</span>
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={form.prompt}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, prompt: e.target.value }))}
|
||||
placeholder="Leave empty to use AI refinement"
|
||||
rows={6}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid var(--border-primary)",
|
||||
background: "var(--bg-primary)",
|
||||
color: "var(--text-primary)",
|
||||
fontSize: "13px",
|
||||
fontFamily: "monospace",
|
||||
resize: "vertical",
|
||||
}}
|
||||
data-testid="workflow-step-prompt"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Enabled toggle */}
|
||||
<label style={{ display: "flex", alignItems: "center", gap: "8px", fontSize: "13px", cursor: "pointer" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.enabled}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, enabled: e.target.checked }))}
|
||||
data-testid="workflow-step-enabled"
|
||||
/>
|
||||
Enabled (available for selection on new tasks)
|
||||
</label>
|
||||
|
||||
{/* Form actions */}
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", gap: "8px", marginTop: "4px" }}>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={handleCancel}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !form.name.trim() || !form.description.trim()}
|
||||
data-testid="save-workflow-step"
|
||||
>
|
||||
{saving ? "Saving..." : isCreating ? "Create" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{!isEditing && !loading && (
|
||||
<div className="modal-footer" style={{ padding: "12px 16px", borderTop: "1px solid var(--border-primary)" }}>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleCreate}
|
||||
style={{ display: "flex", alignItems: "center", gap: "6px" }}
|
||||
data-testid="add-workflow-step"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Add Workflow Step
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ vi.mock("../../api", () => ({
|
||||
autoSelectModelPreset: false,
|
||||
defaultPresetBySize: {},
|
||||
}),
|
||||
fetchWorkflowSteps: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
function makeTask(id: string): Task {
|
||||
@@ -457,4 +458,83 @@ describe("NewTaskModal", () => {
|
||||
// The overflow-y: auto is applied via CSS in styles.css
|
||||
expect(modal?.contains(modalBody)).toBe(true);
|
||||
});
|
||||
|
||||
it("shows workflow step checkboxes when steps are available", async () => {
|
||||
const { fetchWorkflowSteps } = await import("../../api");
|
||||
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
|
||||
{ id: "WS-001", name: "Docs Review", description: "Check documentation", prompt: "Review docs", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" },
|
||||
{ id: "WS-002", name: "QA Check", description: "Run tests", prompt: "Run tests", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" },
|
||||
]);
|
||||
|
||||
renderNewTaskModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("workflow-steps-section")).toBeInTheDocument();
|
||||
expect(screen.getByText("Docs Review")).toBeInTheDocument();
|
||||
expect(screen.getByText("QA Check")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not show workflow steps section when no steps are available", async () => {
|
||||
renderNewTaskModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("workflow-steps-section")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("toggles workflow step selection", async () => {
|
||||
const { fetchWorkflowSteps } = await import("../../api");
|
||||
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
|
||||
{ id: "WS-001", name: "Docs Review", description: "Check documentation", prompt: "Review docs", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" },
|
||||
]);
|
||||
|
||||
renderNewTaskModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("workflow-step-checkbox-WS-001")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const checkbox = screen.getByTestId("workflow-step-checkbox-WS-001").querySelector("input[type='checkbox']") as HTMLInputElement;
|
||||
expect(checkbox.checked).toBe(false);
|
||||
|
||||
fireEvent.click(checkbox);
|
||||
expect(checkbox.checked).toBe(true);
|
||||
|
||||
fireEvent.click(checkbox);
|
||||
expect(checkbox.checked).toBe(false);
|
||||
});
|
||||
|
||||
it("passes selected workflow steps to onCreateTask", async () => {
|
||||
const { fetchWorkflowSteps } = await import("../../api");
|
||||
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
|
||||
{ id: "WS-001", name: "Docs Review", description: "Check documentation", prompt: "Review docs", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" },
|
||||
]);
|
||||
|
||||
const { props } = renderNewTaskModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("workflow-step-checkbox-WS-001")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Check the workflow step
|
||||
const checkbox = screen.getByTestId("workflow-step-checkbox-WS-001").querySelector("input[type='checkbox']") as HTMLInputElement;
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
// Fill in description
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
fireEvent.change(textarea, { target: { value: "Test task" } });
|
||||
|
||||
// Submit
|
||||
const submitBtn = screen.getByText("Create Task");
|
||||
fireEvent.click(submitBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onCreateTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
enabledWorkflowSteps: ["WS-001"],
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { WorkflowStepManager } from "../WorkflowStepManager";
|
||||
import type { WorkflowStep } from "@kb/core";
|
||||
|
||||
const mockSteps: WorkflowStep[] = [
|
||||
{
|
||||
id: "WS-001",
|
||||
name: "Documentation Review",
|
||||
description: "Verify all public APIs have documentation",
|
||||
prompt: "Review the task changes and verify docs.",
|
||||
enabled: true,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "WS-002",
|
||||
name: "QA Check",
|
||||
description: "Run tests and verify they pass",
|
||||
prompt: "Execute the test suite.",
|
||||
enabled: false,
|
||||
createdAt: "2026-01-02T00:00:00.000Z",
|
||||
updatedAt: "2026-01-02T00:00:00.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchWorkflowSteps: vi.fn(() => Promise.resolve([])),
|
||||
createWorkflowStep: vi.fn(() => Promise.resolve({
|
||||
id: "WS-003",
|
||||
name: "New Step",
|
||||
description: "New description",
|
||||
prompt: "",
|
||||
enabled: true,
|
||||
createdAt: "2026-01-03T00:00:00.000Z",
|
||||
updatedAt: "2026-01-03T00:00:00.000Z",
|
||||
})),
|
||||
updateWorkflowStep: vi.fn((id: string, updates: Record<string, unknown>) => Promise.resolve({
|
||||
...mockSteps.find((s) => s.id === id),
|
||||
...updates,
|
||||
updatedAt: "2026-01-03T00:00:00.000Z",
|
||||
})),
|
||||
deleteWorkflowStep: vi.fn(() => Promise.resolve()),
|
||||
refineWorkflowStepPrompt: vi.fn(() => Promise.resolve({
|
||||
prompt: "AI-generated detailed prompt",
|
||||
workflowStep: { ...mockSteps[0], prompt: "AI-generated detailed prompt" },
|
||||
})),
|
||||
}));
|
||||
|
||||
import {
|
||||
fetchWorkflowSteps,
|
||||
createWorkflowStep,
|
||||
updateWorkflowStep,
|
||||
deleteWorkflowStep,
|
||||
refineWorkflowStepPrompt,
|
||||
} from "../../api";
|
||||
|
||||
const onClose = vi.fn();
|
||||
const addToast = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("WorkflowStepManager", () => {
|
||||
it("does not render when closed", () => {
|
||||
const { container } = render(
|
||||
<WorkflowStepManager isOpen={false} onClose={onClose} addToast={addToast} />
|
||||
);
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
|
||||
it("renders list of workflow steps", async () => {
|
||||
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce(mockSteps);
|
||||
|
||||
render(<WorkflowStepManager isOpen={true} onClose={onClose} addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Documentation Review")).toBeInTheDocument();
|
||||
expect(screen.getByText("QA Check")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows empty state when no steps exist", async () => {
|
||||
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([]);
|
||||
|
||||
render(<WorkflowStepManager isOpen={true} onClose={onClose} addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("empty-state")).toBeInTheDocument();
|
||||
expect(screen.getByText(/No workflow steps defined/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("opens create form when Add button is clicked", async () => {
|
||||
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([]);
|
||||
|
||||
render(<WorkflowStepManager isOpen={true} onClose={onClose} addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("add-workflow-step")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("add-workflow-step"));
|
||||
|
||||
expect(screen.getByTestId("workflow-step-form")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("workflow-step-name")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("workflow-step-description")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("submits new workflow step", async () => {
|
||||
vi.mocked(fetchWorkflowSteps)
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
render(<WorkflowStepManager isOpen={true} onClose={onClose} addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("add-workflow-step")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("add-workflow-step"));
|
||||
|
||||
const nameInput = screen.getByTestId("workflow-step-name");
|
||||
const descInput = screen.getByTestId("workflow-step-description");
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: "New Step" } });
|
||||
fireEvent.change(descInput, { target: { value: "New description" } });
|
||||
|
||||
fireEvent.click(screen.getByTestId("save-workflow-step"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createWorkflowStep).toHaveBeenCalledWith({
|
||||
name: "New Step",
|
||||
description: "New description",
|
||||
prompt: undefined,
|
||||
enabled: true,
|
||||
});
|
||||
expect(addToast).toHaveBeenCalledWith("Workflow step created", "success");
|
||||
});
|
||||
});
|
||||
|
||||
it("edits existing workflow step", async () => {
|
||||
vi.mocked(fetchWorkflowSteps)
|
||||
.mockResolvedValueOnce(mockSteps)
|
||||
.mockResolvedValueOnce(mockSteps);
|
||||
|
||||
render(<WorkflowStepManager isOpen={true} onClose={onClose} addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Documentation Review")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click edit button for the first step
|
||||
const editBtn = screen.getByLabelText("Edit Documentation Review");
|
||||
fireEvent.click(editBtn);
|
||||
|
||||
// Form should be pre-populated
|
||||
expect(screen.getByTestId("workflow-step-form")).toBeInTheDocument();
|
||||
const nameInput = screen.getByTestId("workflow-step-name") as HTMLInputElement;
|
||||
expect(nameInput.value).toBe("Documentation Review");
|
||||
|
||||
// Change the name
|
||||
fireEvent.change(nameInput, { target: { value: "Updated Name" } });
|
||||
fireEvent.click(screen.getByTestId("save-workflow-step"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateWorkflowStep).toHaveBeenCalledWith("WS-001", expect.objectContaining({
|
||||
name: "Updated Name",
|
||||
}));
|
||||
expect(addToast).toHaveBeenCalledWith("Workflow step updated", "success");
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes workflow step with confirmation", async () => {
|
||||
vi.mocked(fetchWorkflowSteps)
|
||||
.mockResolvedValueOnce(mockSteps)
|
||||
.mockResolvedValueOnce([mockSteps[1]]);
|
||||
|
||||
render(<WorkflowStepManager isOpen={true} onClose={onClose} addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Documentation Review")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click delete (first shows confirm dialog)
|
||||
const deleteBtn = screen.getByLabelText("Delete Documentation Review");
|
||||
fireEvent.click(deleteBtn);
|
||||
|
||||
// Confirm delete
|
||||
const confirmBtn = screen.getByLabelText("Confirm delete Documentation Review");
|
||||
fireEvent.click(confirmBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deleteWorkflowStep).toHaveBeenCalledWith("WS-001");
|
||||
expect(addToast).toHaveBeenCalledWith("Workflow step deleted", "success");
|
||||
});
|
||||
});
|
||||
|
||||
it("calls refine API and updates prompt", async () => {
|
||||
vi.mocked(fetchWorkflowSteps)
|
||||
.mockResolvedValueOnce(mockSteps)
|
||||
.mockResolvedValueOnce(mockSteps);
|
||||
|
||||
render(<WorkflowStepManager isOpen={true} onClose={onClose} addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Documentation Review")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Edit first step
|
||||
fireEvent.click(screen.getByLabelText("Edit Documentation Review"));
|
||||
|
||||
// Click refine button
|
||||
const refineBtn = screen.getByTestId("refine-btn");
|
||||
fireEvent.click(refineBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(refineWorkflowStepPrompt).toHaveBeenCalledWith("WS-001");
|
||||
expect(addToast).toHaveBeenCalledWith("Prompt refined with AI", "success");
|
||||
});
|
||||
});
|
||||
|
||||
it("handles API errors gracefully", async () => {
|
||||
vi.mocked(fetchWorkflowSteps).mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
render(<WorkflowStepManager isOpen={true} onClose={onClose} addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith("Network error", "error");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows enabled/disabled badges", async () => {
|
||||
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce(mockSteps);
|
||||
|
||||
render(<WorkflowStepManager isOpen={true} onClose={onClose} addToast={addToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Enabled")).toBeInTheDocument();
|
||||
expect(screen.getByText("Disabled")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user