feat(KB-268): add workflow step templates
- Define 5 built-in workflow step templates (Documentation Review, QA Check, Security Audit, Performance Review, Accessibility Check) - Add API endpoints GET /api/workflow-step-templates and POST /api/workflow-step-templates/:id/create - Add templates tab to Workflow Step Manager with one-click add functionality - Include high-quality agent prompts for each template category - Add changeset for the new feature
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, THINKING_LEVELS, THEME_MODES, COLOR_THEMES } from "./types.js";
|
||||
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, TaskStep, StepStatus, TaskLogEntry, ActivityLogEntry, ActivityEventType, ThinkingLevel, SteeringComment, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepInput } from "./types.js";
|
||||
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES } from "./types.js";
|
||||
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, TaskStep, StepStatus, TaskLogEntry, ActivityLogEntry, ActivityEventType, ThinkingLevel, SteeringComment, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepInput, WorkflowStepTemplate } from "./types.js";
|
||||
export { TaskStore } from "./store.js";
|
||||
export { GlobalSettingsStore } from "./global-settings.js";
|
||||
export { canTransition, getValidTransitions, resolveDependencyOrder } from "./board.js";
|
||||
|
||||
@@ -66,6 +66,154 @@ export interface WorkflowStepInput {
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/** A built-in workflow step template for one-click creation. */
|
||||
export interface WorkflowStepTemplate {
|
||||
/** Unique template identifier (e.g., "documentation-review") */
|
||||
id: string;
|
||||
/** Display name (e.g., "Documentation Review") */
|
||||
name: string;
|
||||
/** Short description for UI */
|
||||
description: string;
|
||||
/** Full agent prompt template */
|
||||
prompt: string;
|
||||
/** Grouping category (e.g., "Quality", "Security") */
|
||||
category: string;
|
||||
/** Optional icon identifier for UI (e.g., "file-text", "shield") */
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
/** Built-in workflow step templates available for one-click creation. */
|
||||
export const WORKFLOW_STEP_TEMPLATES: WorkflowStepTemplate[] = [
|
||||
{
|
||||
id: "documentation-review",
|
||||
name: "Documentation Review",
|
||||
description: "Verify all public APIs, functions, and complex logic have appropriate documentation",
|
||||
category: "Quality",
|
||||
icon: "file-text",
|
||||
prompt: `You are a documentation reviewer. Review the completed task and verify documentation quality.
|
||||
|
||||
Review Criteria:
|
||||
1. All new public functions, classes, and modules have JSDoc comments or equivalent documentation
|
||||
2. Complex logic has inline comments explaining the "why" not just the "what"
|
||||
3. README files are updated if the task changes user-facing behavior
|
||||
4. CHANGELOG or release notes are considered for significant changes
|
||||
5. Type definitions are documented for public APIs
|
||||
|
||||
Files to Review:
|
||||
- Review all files modified in the task worktree
|
||||
- Focus on public API surface area
|
||||
- Check test files for test documentation
|
||||
|
||||
Output Requirements:
|
||||
- If documentation is adequate: call task_done() with success status
|
||||
- If documentation is missing: list specific files and functions that need documentation using task_log()
|
||||
- Provide specific suggestions for what documentation should be added`,
|
||||
},
|
||||
{
|
||||
id: "qa-check",
|
||||
name: "QA Check",
|
||||
description: "Run tests and verify they pass, check for obvious bugs",
|
||||
category: "Quality",
|
||||
icon: "check-circle",
|
||||
prompt: `You are a QA tester. Verify the task implementation by running tests and checking for bugs.
|
||||
|
||||
Test Execution:
|
||||
1. Run the project's test suite (use pnpm test, npm test, or the configured test command)
|
||||
2. Verify all tests pass
|
||||
3. If tests fail, analyze whether failures are related to the task changes
|
||||
|
||||
Code Review:
|
||||
1. Review the changes for obvious bugs or edge cases
|
||||
2. Check error handling is appropriate
|
||||
3. Verify input validation is present where needed
|
||||
4. Look for common issues: null pointer risks, off-by-one errors, race conditions
|
||||
|
||||
Output Requirements:
|
||||
- If all tests pass and no bugs found: call task_done() with success status
|
||||
- If tests fail: provide detailed failure information via task_log()
|
||||
- If bugs are found: describe the bug, affected files, and suggested fix via task_log()`,
|
||||
},
|
||||
{
|
||||
id: "security-audit",
|
||||
name: "Security Audit",
|
||||
description: "Check for common security vulnerabilities and anti-patterns",
|
||||
category: "Security",
|
||||
icon: "shield",
|
||||
prompt: `You are a security auditor. Review the task changes for common security vulnerabilities.
|
||||
|
||||
Security Checklist:
|
||||
1. **Injection vulnerabilities** — Check for SQL injection, command injection, XSS via unsanitized user input
|
||||
2. **Secrets and credentials** — Ensure no hardcoded passwords, API keys, tokens, or private keys
|
||||
3. **Unsafe eval** — Check for eval(), new Function(), or similar dangerous patterns
|
||||
4. **Path traversal** — Verify file path handling prevents directory traversal attacks
|
||||
5. **Insecure deserialization** — Check for unsafe parsing of untrusted data
|
||||
6. **Authentication/Authorization** — Verify access controls are properly implemented
|
||||
7. **Dependency risks** — Note any new dependencies that might have known vulnerabilities
|
||||
|
||||
Files to Review:
|
||||
- All modified files in the task
|
||||
- Configuration files that might contain secrets
|
||||
- Areas handling user input or external data
|
||||
|
||||
Output Requirements:
|
||||
- If no security issues found: call task_done() with success status
|
||||
- If issues found: describe each vulnerability with specific file paths, line numbers, and severity via task_log()
|
||||
- Provide remediation suggestions for each issue`,
|
||||
},
|
||||
{
|
||||
id: "performance-review",
|
||||
name: "Performance Review",
|
||||
description: "Check for performance anti-patterns and optimization opportunities",
|
||||
category: "Quality",
|
||||
icon: "zap",
|
||||
prompt: `You are a performance reviewer. Analyze the task changes for performance implications.
|
||||
|
||||
Performance Checklist:
|
||||
1. **Algorithmic complexity** — Check for O(n²) or worse patterns that could bottleneck
|
||||
2. **N+1 queries** — Look for database queries in loops
|
||||
3. **Memory leaks** — Check for unclosed resources, event listeners, or accumulating caches
|
||||
4. **Unnecessary re-renders** — For UI code, check for inefficient React/Angular/Vue patterns
|
||||
5. **Bundle size** — Note if large dependencies are added unnecessarily
|
||||
6. **Async patterns** — Verify proper use of async/await, Promise.all for parallel work
|
||||
7. **Caching opportunities** — Identify where caching could improve performance
|
||||
|
||||
Files to Review:
|
||||
- All modified files, focusing on hot paths and frequently executed code
|
||||
- Database query files
|
||||
- API endpoints and route handlers
|
||||
|
||||
Output Requirements:
|
||||
- If performance is acceptable: call task_done() with success status
|
||||
- If issues found: describe each issue with specific file paths and suggested optimizations via task_log()`,
|
||||
},
|
||||
{
|
||||
id: "accessibility-check",
|
||||
name: "Accessibility Check",
|
||||
description: "Verify UI changes meet accessibility standards (WCAG 2.1)",
|
||||
category: "Quality",
|
||||
icon: "eye",
|
||||
prompt: `You are an accessibility reviewer. Check UI changes for WCAG 2.1 compliance.
|
||||
|
||||
Accessibility Checklist:
|
||||
1. **Keyboard navigation** — Ensure all interactive elements are keyboard accessible
|
||||
2. **ARIA labels** — Check that screen reader announcements are appropriate
|
||||
3. **Color contrast** — Verify text meets minimum contrast ratios (4.5:1 for normal text)
|
||||
4. **Focus indicators** — Ensure visible focus states for keyboard navigation
|
||||
5. **Alt text** — Check that images have meaningful alternative text
|
||||
6. **Form labels** — Verify all inputs have associated labels
|
||||
7. **Semantic HTML** — Check that proper HTML elements are used (buttons not divs)
|
||||
|
||||
Files to Review:
|
||||
- Modified UI components
|
||||
- CSS/styling changes
|
||||
- New HTML templates or JSX
|
||||
|
||||
Output Requirements:
|
||||
- If accessibility requirements are met: call task_done() with success status
|
||||
- If issues found: describe each issue with specific file paths, WCAG guideline references, and remediation steps via task_log()`,
|
||||
},
|
||||
];
|
||||
|
||||
export interface PrInfo {
|
||||
url: string;
|
||||
number: number;
|
||||
|
||||
@@ -1229,6 +1229,23 @@ export function refineWorkflowStepPrompt(id: string): Promise<{ prompt: string;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Workflow Step Templates ──────────────────────────────────────────────
|
||||
|
||||
/** Re-export WorkflowStepTemplate type from core */
|
||||
export type { WorkflowStepTemplate } from "@kb/core";
|
||||
|
||||
/** Fetch all built-in workflow step templates */
|
||||
export function fetchWorkflowStepTemplates(): Promise<{ templates: import("@kb/core").WorkflowStepTemplate[] }> {
|
||||
return api<{ templates: import("@kb/core").WorkflowStepTemplate[] }>("/workflow-step-templates");
|
||||
}
|
||||
|
||||
/** Create a workflow step from a built-in template */
|
||||
export function createWorkflowStepFromTemplate(templateId: string): Promise<WorkflowStep> {
|
||||
return api<WorkflowStep>(`/workflow-step-templates/${encodeURIComponent(templateId)}/create`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
// ── AI Text Refinement API ────────────────────────────────────────────
|
||||
|
||||
/** Refinement types for AI text refinement */
|
||||
|
||||
@@ -1,8 +1,32 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { WorkflowStep, WorkflowStepInput } from "@kb/core";
|
||||
import { fetchWorkflowSteps, createWorkflowStep, updateWorkflowStep, deleteWorkflowStep, refineWorkflowStepPrompt } from "../api";
|
||||
import {
|
||||
fetchWorkflowSteps,
|
||||
createWorkflowStep,
|
||||
updateWorkflowStep,
|
||||
deleteWorkflowStep,
|
||||
refineWorkflowStepPrompt,
|
||||
fetchWorkflowStepTemplates,
|
||||
createWorkflowStepFromTemplate,
|
||||
type WorkflowStepTemplate,
|
||||
} from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { X, Plus, Pencil, Trash2, Sparkles, Check, Loader2 } from "lucide-react";
|
||||
import {
|
||||
X,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Sparkles,
|
||||
Check,
|
||||
Loader2,
|
||||
FileText,
|
||||
CheckCircle,
|
||||
Shield,
|
||||
Zap,
|
||||
Eye,
|
||||
LayoutGrid,
|
||||
BookOpen,
|
||||
} from "lucide-react";
|
||||
|
||||
interface WorkflowStepManagerProps {
|
||||
isOpen: boolean;
|
||||
@@ -17,6 +41,8 @@ interface StepFormData {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
type TabId = "my-steps" | "templates";
|
||||
|
||||
const EMPTY_FORM: StepFormData = {
|
||||
name: "",
|
||||
description: "",
|
||||
@@ -24,15 +50,49 @@ const EMPTY_FORM: StepFormData = {
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
/** Map template icon names to Lucide components */
|
||||
function getTemplateIcon(iconName: string | undefined) {
|
||||
switch (iconName) {
|
||||
case "file-text":
|
||||
return FileText;
|
||||
case "check-circle":
|
||||
return CheckCircle;
|
||||
case "shield":
|
||||
return Shield;
|
||||
case "zap":
|
||||
return Zap;
|
||||
case "eye":
|
||||
return Eye;
|
||||
default:
|
||||
return CheckCircle;
|
||||
}
|
||||
}
|
||||
|
||||
/** Get category badge colors */
|
||||
function getCategoryColors(category: string): { bg: string; text: string } {
|
||||
switch (category.toLowerCase()) {
|
||||
case "quality":
|
||||
return { bg: "rgba(59, 130, 246, 0.15)", text: "#3b82f6" };
|
||||
case "security":
|
||||
return { bg: "rgba(239, 68, 68, 0.15)", text: "#ef4444" };
|
||||
default:
|
||||
return { bg: "var(--bg-tertiary)", text: "var(--text-secondary)" };
|
||||
}
|
||||
}
|
||||
|
||||
export function WorkflowStepManager({ isOpen, onClose, addToast }: WorkflowStepManagerProps) {
|
||||
const [steps, setSteps] = useState<WorkflowStep[]>([]);
|
||||
const [templates, setTemplates] = useState<WorkflowStepTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [templatesLoading, setTemplatesLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<TabId>("my-steps");
|
||||
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 [addingTemplateId, setAddingTemplateId] = useState<string | null>(null);
|
||||
|
||||
const loadSteps = useCallback(async () => {
|
||||
try {
|
||||
@@ -46,11 +106,24 @@ export function WorkflowStepManager({ isOpen, onClose, addToast }: WorkflowStepM
|
||||
}
|
||||
}, [addToast]);
|
||||
|
||||
const loadTemplates = useCallback(async () => {
|
||||
try {
|
||||
setTemplatesLoading(true);
|
||||
const response = await fetchWorkflowStepTemplates();
|
||||
setTemplates(response.templates);
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to load templates", "error");
|
||||
} finally {
|
||||
setTemplatesLoading(false);
|
||||
}
|
||||
}, [addToast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadSteps();
|
||||
loadTemplates();
|
||||
}
|
||||
}, [isOpen, loadSteps]);
|
||||
}, [isOpen, loadSteps, loadTemplates]);
|
||||
|
||||
const handleCreate = useCallback(() => {
|
||||
setIsCreating(true);
|
||||
@@ -180,6 +253,25 @@ export function WorkflowStepManager({ isOpen, onClose, addToast }: WorkflowStepM
|
||||
}
|
||||
}, [editingId, isCreating, form, addToast, loadSteps]);
|
||||
|
||||
const handleAddTemplate = useCallback(async (template: WorkflowStepTemplate) => {
|
||||
setAddingTemplateId(template.id);
|
||||
try {
|
||||
await createWorkflowStepFromTemplate(template.id);
|
||||
addToast(`Added ${template.name} workflow step`, "success");
|
||||
await loadSteps();
|
||||
// Switch to "My Workflow Steps" tab to show the newly added step
|
||||
setActiveTab("my-steps");
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes("already exists")) {
|
||||
addToast(`A workflow step named '${template.name}' already exists`, "error");
|
||||
} else {
|
||||
addToast(err.message || "Failed to add workflow step from template", "error");
|
||||
}
|
||||
} finally {
|
||||
setAddingTemplateId(null);
|
||||
}
|
||||
}, [addToast, loadSteps]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const isEditing = isCreating || editingId !== null;
|
||||
@@ -207,107 +299,300 @@ export function WorkflowStepManager({ isOpen, onClose, addToast }: WorkflowStepM
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Step list */}
|
||||
{steps.length === 0 && !isEditing && (
|
||||
{/* Tab Navigation */}
|
||||
{!isEditing && (
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
padding: "32px",
|
||||
color: "var(--text-secondary)",
|
||||
fontSize: "14px",
|
||||
display: "flex",
|
||||
gap: "8px",
|
||||
marginBottom: "16px",
|
||||
borderBottom: "1px solid var(--border-primary)",
|
||||
paddingBottom: "8px",
|
||||
}}
|
||||
data-testid="empty-state"
|
||||
>
|
||||
No workflow steps defined. Create one to get started.
|
||||
<button
|
||||
className={`btn ${activeTab === "my-steps" ? "btn-primary" : "btn-secondary"}`}
|
||||
onClick={() => setActiveTab("my-steps")}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
fontSize: "13px",
|
||||
padding: "6px 12px",
|
||||
}}
|
||||
data-testid="tab-my-steps"
|
||||
>
|
||||
<BookOpen size={14} />
|
||||
My Workflow Steps ({steps.length})
|
||||
</button>
|
||||
<button
|
||||
className={`btn ${activeTab === "templates" ? "btn-primary" : "btn-secondary"}`}
|
||||
onClick={() => setActiveTab("templates")}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
fontSize: "13px",
|
||||
padding: "6px 12px",
|
||||
}}
|
||||
data-testid="tab-templates"
|
||||
>
|
||||
<LayoutGrid size={14} />
|
||||
Templates ({templates.length})
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{steps.length > 0 && !isEditing && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
||||
{steps.map((step) => (
|
||||
{/* My Workflow Steps Tab */}
|
||||
{activeTab === "my-steps" && !isEditing && (
|
||||
<>
|
||||
{steps.length === 0 && (
|
||||
<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)",
|
||||
textAlign: "center",
|
||||
padding: "32px",
|
||||
color: "var(--text-secondary)",
|
||||
fontSize: "14px",
|
||||
}}
|
||||
data-testid="empty-state"
|
||||
>
|
||||
<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>
|
||||
No workflow steps defined. Create one to get started, or add one from the Templates tab.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{steps.length > 0 && (
|
||||
<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={{
|
||||
fontSize: "12px",
|
||||
color: "var(--text-secondary)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
}}
|
||||
>
|
||||
{step.description}
|
||||
<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 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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Templates Tab */}
|
||||
{activeTab === "templates" && !isEditing && (
|
||||
<>
|
||||
{templatesLoading ? (
|
||||
<div style={{ textAlign: "center", padding: "32px", color: "var(--text-secondary)" }}>
|
||||
<Loader2 size={24} className="spin" style={{ margin: "0 auto 8px" }} />
|
||||
Loading templates...
|
||||
</div>
|
||||
) : templates.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
padding: "32px",
|
||||
color: "var(--text-secondary)",
|
||||
fontSize: "14px",
|
||||
}}
|
||||
data-testid="no-templates-state"
|
||||
>
|
||||
No templates available.
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
|
||||
{templates.map((template) => {
|
||||
const IconComponent = getTemplateIcon(template.icon);
|
||||
const categoryColors = getCategoryColors(template.category);
|
||||
const isAdding = addingTemplateId === template.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={template.id}
|
||||
data-testid={`template-${template.id}`}
|
||||
style={{
|
||||
padding: "16px",
|
||||
border: "1px solid var(--border-primary)",
|
||||
borderRadius: "8px",
|
||||
background: "var(--bg-secondary)",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", gap: "12px", alignItems: "flex-start" }}>
|
||||
{/* Icon */}
|
||||
<div
|
||||
style={{
|
||||
padding: "8px",
|
||||
borderRadius: "6px",
|
||||
background: "var(--bg-tertiary)",
|
||||
color: "var(--text-primary)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<IconComponent size={20} />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
marginBottom: "4px",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 600, fontSize: "14px" }}>
|
||||
{template.name}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "11px",
|
||||
padding: "2px 6px",
|
||||
borderRadius: "4px",
|
||||
background: categoryColors.bg,
|
||||
color: categoryColors.text,
|
||||
}}
|
||||
>
|
||||
{template.category}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: "8px",
|
||||
}}
|
||||
>
|
||||
{template.description}
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => handleAddTemplate(template)}
|
||||
disabled={isAdding}
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
padding: "4px 12px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
}}
|
||||
data-testid={`add-template-${template.id}`}
|
||||
>
|
||||
{isAdding ? (
|
||||
<>
|
||||
<Loader2 size={12} className="spin" />
|
||||
Adding...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus size={12} />
|
||||
Add
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Edit / Create form */}
|
||||
@@ -328,7 +613,14 @@ export function WorkflowStepManager({ isOpen, onClose, addToast }: WorkflowStepM
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label style={{ display: "block", fontSize: "12px", color: "var(--text-secondary)", marginBottom: "4px" }}>
|
||||
<label
|
||||
style={{
|
||||
display: "block",
|
||||
fontSize: "12px",
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: "4px",
|
||||
}}
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
@@ -351,7 +643,14 @@ export function WorkflowStepManager({ isOpen, onClose, addToast }: WorkflowStepM
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label style={{ display: "block", fontSize: "12px", color: "var(--text-secondary)", marginBottom: "4px" }}>
|
||||
<label
|
||||
style={{
|
||||
display: "block",
|
||||
fontSize: "12px",
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: "4px",
|
||||
}}
|
||||
>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
@@ -375,7 +674,14 @@ export function WorkflowStepManager({ isOpen, onClose, addToast }: WorkflowStepM
|
||||
|
||||
{/* Prompt */}
|
||||
<div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "4px" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: "4px",
|
||||
}}
|
||||
>
|
||||
<label style={{ fontSize: "12px", color: "var(--text-secondary)" }}>
|
||||
Agent Prompt
|
||||
</label>
|
||||
@@ -385,10 +691,19 @@ export function WorkflowStepManager({ isOpen, onClose, addToast }: WorkflowStepM
|
||||
disabled={!form.description.trim() || refining}
|
||||
title="Refine with AI"
|
||||
aria-label="Refine prompt with AI"
|
||||
style={{ fontSize: "12px", display: "flex", alignItems: "center", gap: "4px" }}
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
}}
|
||||
data-testid="refine-btn"
|
||||
>
|
||||
{refining ? <Loader2 size={12} className="spin" /> : <Sparkles size={12} />}
|
||||
{refining ? (
|
||||
<Loader2 size={12} className="spin" />
|
||||
) : (
|
||||
<Sparkles size={12} />
|
||||
)}
|
||||
<span style={{ fontSize: "11px" }}>Refine with AI</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -413,7 +728,15 @@ export function WorkflowStepManager({ isOpen, onClose, addToast }: WorkflowStepM
|
||||
</div>
|
||||
|
||||
{/* Enabled toggle */}
|
||||
<label style={{ display: "flex", alignItems: "center", gap: "8px", fontSize: "13px", cursor: "pointer" }}>
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
fontSize: "13px",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.enabled}
|
||||
@@ -424,12 +747,15 @@ export function WorkflowStepManager({ isOpen, onClose, addToast }: WorkflowStepM
|
||||
</label>
|
||||
|
||||
{/* Form actions */}
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", gap: "8px", marginTop: "4px" }}>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={handleCancel}
|
||||
disabled={saving}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
gap: "8px",
|
||||
marginTop: "4px",
|
||||
}}
|
||||
>
|
||||
<button className="btn btn-secondary" onClick={handleCancel} disabled={saving}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@@ -449,8 +775,11 @@ export function WorkflowStepManager({ isOpen, onClose, addToast }: WorkflowStepM
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{!isEditing && !loading && (
|
||||
<div className="modal-footer" style={{ padding: "12px 16px", borderTop: "1px solid var(--border-primary)" }}>
|
||||
{!isEditing && (
|
||||
<div
|
||||
className="modal-footer"
|
||||
style={{ padding: "12px 16px", borderTop: "1px solid var(--border-primary)" }}
|
||||
>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleCreate}
|
||||
|
||||
@@ -5001,3 +5001,143 @@ describe("POST /workflow-steps/:id/refine", () => {
|
||||
expect(store.updateWorkflowStep).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Workflow Step Template Tests ──────────────────────────────────────────
|
||||
|
||||
describe("GET /workflow-step-templates", () => {
|
||||
let store: TaskStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createMockStore();
|
||||
});
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
return app;
|
||||
}
|
||||
|
||||
it("returns all built-in templates", async () => {
|
||||
const res = await GET(buildApp(), "/api/workflow-step-templates");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.templates).toBeDefined();
|
||||
expect(Array.isArray(res.body.templates)).toBe(true);
|
||||
expect(res.body.templates.length).toBeGreaterThanOrEqual(5);
|
||||
|
||||
// Check that templates have required fields
|
||||
for (const template of res.body.templates) {
|
||||
expect(template.id).toBeDefined();
|
||||
expect(template.name).toBeDefined();
|
||||
expect(template.description).toBeDefined();
|
||||
expect(template.category).toBeDefined();
|
||||
expect(template.prompt).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("includes expected template IDs", async () => {
|
||||
const res = await GET(buildApp(), "/api/workflow-step-templates");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const ids = res.body.templates.map((t: { id: string }) => t.id);
|
||||
expect(ids).toContain("documentation-review");
|
||||
expect(ids).toContain("qa-check");
|
||||
expect(ids).toContain("security-audit");
|
||||
expect(ids).toContain("performance-review");
|
||||
expect(ids).toContain("accessibility-check");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /workflow-step-templates/:id/create", () => {
|
||||
let store: TaskStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createMockStore();
|
||||
});
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
return app;
|
||||
}
|
||||
|
||||
it("creates workflow step from template", async () => {
|
||||
const created = {
|
||||
id: "WS-001",
|
||||
name: "Documentation Review",
|
||||
description: "Verify all public APIs, functions, and complex logic have appropriate documentation",
|
||||
prompt: expect.stringContaining("documentation reviewer"),
|
||||
enabled: true,
|
||||
createdAt: "2026-01-01",
|
||||
updatedAt: "2026-01-01",
|
||||
};
|
||||
(store.listWorkflowSteps as ReturnType<typeof vi.fn>).mockResolvedValueOnce([]);
|
||||
(store.createWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(created);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/workflow-step-templates/documentation-review/create", JSON.stringify({}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.id).toBe("WS-001");
|
||||
expect(res.body.name).toBe("Documentation Review");
|
||||
expect(store.createWorkflowStep).toHaveBeenCalledWith({
|
||||
name: "Documentation Review",
|
||||
description: "Verify all public APIs, functions, and complex logic have appropriate documentation",
|
||||
prompt: expect.stringContaining("documentation reviewer"),
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("creates workflow step from qa-check template", async () => {
|
||||
const created = {
|
||||
id: "WS-002",
|
||||
name: "QA Check",
|
||||
description: "Run tests and verify they pass, check for obvious bugs",
|
||||
prompt: expect.stringContaining("QA tester"),
|
||||
enabled: true,
|
||||
createdAt: "2026-01-01",
|
||||
updatedAt: "2026-01-01",
|
||||
};
|
||||
(store.listWorkflowSteps as ReturnType<typeof vi.fn>).mockResolvedValueOnce([]);
|
||||
(store.createWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(created);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/workflow-step-templates/qa-check/create", JSON.stringify({}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.name).toBe("QA Check");
|
||||
expect(store.createWorkflowStep).toHaveBeenCalledWith({
|
||||
name: "QA Check",
|
||||
description: "Run tests and verify they pass, check for obvious bugs",
|
||||
prompt: expect.stringContaining("QA tester"),
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 404 for non-existent template", async () => {
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/workflow-step-templates/nonexistent/create", JSON.stringify({}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toContain("not found");
|
||||
});
|
||||
|
||||
it("returns 409 when workflow step with same name already exists", async () => {
|
||||
const existingSteps = [
|
||||
{ id: "WS-001", name: "Documentation Review", description: "Check docs", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" },
|
||||
];
|
||||
(store.listWorkflowSteps as ReturnType<typeof vi.fn>).mockResolvedValueOnce(existingSteps);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/workflow-step-templates/documentation-review/create", JSON.stringify({}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.error).toContain("already exists");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4715,6 +4715,57 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Workflow Step Templates ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /api/workflow-step-templates
|
||||
* List all built-in workflow step templates.
|
||||
* Returns: { templates: WorkflowStepTemplate[] }
|
||||
*/
|
||||
router.get("/workflow-step-templates", async (_req, res) => {
|
||||
try {
|
||||
const { WORKFLOW_STEP_TEMPLATES } = await import("@kb/core");
|
||||
res.json({ templates: WORKFLOW_STEP_TEMPLATES });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/workflow-step-templates/:id/create
|
||||
* Create a workflow step from a built-in template.
|
||||
* Returns: WorkflowStep
|
||||
*/
|
||||
router.post("/workflow-step-templates/:id/create", async (req, res) => {
|
||||
try {
|
||||
const { WORKFLOW_STEP_TEMPLATES } = await import("@kb/core");
|
||||
const template = WORKFLOW_STEP_TEMPLATES.find((t) => t.id === req.params.id);
|
||||
|
||||
if (!template) {
|
||||
res.status(404).json({ error: `Template '${req.params.id}' not found` });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for name conflicts with existing workflow steps
|
||||
const existing = await store.listWorkflowSteps();
|
||||
if (existing.some((ws) => ws.name.toLowerCase() === template.name.toLowerCase())) {
|
||||
res.status(409).json({ error: `A workflow step named '${template.name}' already exists` });
|
||||
return;
|
||||
}
|
||||
|
||||
const step = await store.createWorkflowStep({
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
prompt: template.prompt,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
res.status(201).json(step);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user