feat(FN-1875): merge fusion/fn-1875

This commit is contained in:
gsxdsm
2026-04-15 09:45:57 -07:00
parent 85f6bf1b5e
commit 72076c65d1
6 changed files with 455 additions and 12 deletions

View File

@@ -25,12 +25,31 @@ function generateStepId(): string {
}
function createEmptyStep(type: AutomationStepType): AutomationStep {
if (type === "command") {
return {
id: generateStepId(),
type,
name: "New Command Step",
command: "",
continueOnFailure: false,
};
}
if (type === "ai-prompt") {
return {
id: generateStepId(),
type,
name: "New AI Prompt Step",
prompt: "",
continueOnFailure: false,
};
}
// create-task
return {
id: generateStepId(),
type,
name: type === "command" ? "New Command Step" : "New AI Prompt Step",
command: type === "command" ? "" : undefined,
prompt: type === "ai-prompt" ? "" : undefined,
name: "New Create Task Step",
taskDescription: "",
taskColumn: "triage",
continueOnFailure: false,
};
}
@@ -48,6 +67,9 @@ function StepEditor({ step, onSave, onCancel }: StepEditorProps) {
const [prompt, setPrompt] = useState(step.prompt ?? "");
const [modelProvider, setModelProvider] = useState(step.modelProvider ?? "");
const [modelId, setModelId] = useState(step.modelId ?? "");
const [taskTitle, setTaskTitle] = useState(step.taskTitle ?? "");
const [taskDescription, setTaskDescription] = useState(step.taskDescription ?? "");
const [taskColumn, setTaskColumn] = useState(step.taskColumn ?? "triage");
const [timeoutMs, setTimeoutMs] = useState<number | undefined>(step.timeoutMs);
const [continueOnFailure, setContinueOnFailure] = useState(step.continueOnFailure ?? false);
const [errors, setErrors] = useState<Record<string, string>>({});
@@ -88,12 +110,19 @@ function StepEditor({ step, onSave, onCancel }: StepEditorProps) {
if (!name.trim()) e.name = "Step name is required";
if (type === "command" && !command.trim()) e.command = "Command is required";
if (type === "ai-prompt" && !prompt.trim()) e.prompt = "Prompt is required";
if (type === "create-task" && !taskDescription.trim()) e.taskDescription = "Task description is required";
// Model pairing validation for ai-prompt and create-task
if ((type === "ai-prompt" || type === "create-task") && (modelProvider || modelId)) {
if (!modelProvider || !modelId) {
e.modelProvider = "Both model provider and model ID must be set together";
}
}
if (timeoutMs !== undefined && timeoutMs < 1000) {
e.timeoutMs = "Timeout must be at least 1 second (1000ms)";
}
setErrors(e);
return Object.keys(e).length === 0;
}, [name, type, command, prompt, timeoutMs]);
}, [name, type, command, prompt, taskDescription, modelProvider, modelId, timeoutMs]);
// Compute combined model value from separate fields
const modelValue = (modelProvider && modelId) ? `${modelProvider}/${modelId}` : "";
@@ -114,18 +143,35 @@ function StepEditor({ step, onSave, onCancel }: StepEditorProps) {
const handleSave = useCallback(() => {
if (!validate()) return;
onSave({
// Clear fields that don't apply to this step type
const baseStep = {
...step,
name: name.trim(),
type,
command: type === "command" ? command.trim() : undefined,
prompt: type === "ai-prompt" ? prompt.trim() : undefined,
modelProvider: type === "ai-prompt" && modelProvider ? modelProvider.trim() : undefined,
modelId: type === "ai-prompt" && modelId ? modelId.trim() : undefined,
taskTitle: type === "create-task" && taskTitle.trim() ? taskTitle.trim() : undefined,
taskDescription: type === "create-task" && taskDescription.trim() ? taskDescription.trim() : undefined,
taskColumn: type === "create-task" ? taskColumn : undefined,
modelProvider: (type === "ai-prompt" || type === "create-task") && modelProvider.trim() ? modelProvider.trim() : undefined,
modelId: (type === "ai-prompt" || type === "create-task") && modelId.trim() ? modelId.trim() : undefined,
timeoutMs: timeoutMs || undefined,
continueOnFailure,
});
}, [validate, onSave, step, name, type, command, prompt, modelProvider, modelId, timeoutMs, continueOnFailure]);
};
// Clear ai-prompt and create-task specific fields when switching to command
if (type !== "ai-prompt") {
delete baseStep.prompt;
}
if (type !== "create-task") {
delete baseStep.taskTitle;
delete baseStep.taskDescription;
delete baseStep.taskColumn;
}
onSave(baseStep as AutomationStep);
}, [validate, onSave, step, name, type, command, prompt, taskTitle, taskDescription, taskColumn, modelProvider, modelId, timeoutMs, continueOnFailure]);
return (
<div className="step-editor">
@@ -151,6 +197,7 @@ function StepEditor({ step, onSave, onCancel }: StepEditorProps) {
>
<option value="command">Command</option>
<option value="ai-prompt">AI Prompt</option>
<option value="create-task">Create Task</option>
</select>
</div>
@@ -201,6 +248,64 @@ function StepEditor({ step, onSave, onCancel }: StepEditorProps) {
</>
)}
{type === "create-task" && (
<>
<div className="form-group">
<label htmlFor={`step-task-title-${step.id}`}>Task Title (optional)</label>
<input
id={`step-task-title-${step.id}`}
type="text"
placeholder="e.g. Review weekly dependencies"
value={taskTitle}
onChange={(e) => setTaskTitle(e.target.value)}
/>
<small>Leave blank to auto-summarize from description</small>
</div>
<div className="form-group">
<label htmlFor={`step-task-description-${step.id}`}>Task Description *</label>
<textarea
id={`step-task-description-${step.id}`}
placeholder="e.g. Check all npm dependencies for security vulnerabilities and update outdated packages"
value={taskDescription}
onChange={(e) => setTaskDescription(e.target.value)}
rows={4}
aria-invalid={!!errors.taskDescription}
/>
{errors.taskDescription && <small className="field-error">{errors.taskDescription}</small>}
</div>
<div className="form-group">
<label htmlFor={`step-task-column-${step.id}`}>Target Column</label>
<select
id={`step-task-column-${step.id}`}
value={taskColumn}
onChange={(e) => setTaskColumn(e.target.value)}
>
<option value="triage">Triage</option>
<option value="todo">To Do</option>
</select>
<small>Column where the new task will be created</small>
</div>
<div className="form-group">
<label htmlFor={`step-task-model-${step.id}`}>Executor Model (optional)</label>
<CustomModelDropdown
id={`step-task-model-${step.id}`}
label="Model"
models={models}
value={modelValue}
onChange={handleModelChange}
placeholder="Use default"
disabled={modelsLoading}
/>
{modelsError && <small className="field-error">{modelsError}</small>}
{errors.modelProvider && <small className="field-error">{errors.modelProvider}</small>}
<small>AI model for executing the created task. Uses default if not selected.</small>
</div>
</>
)}
<div className="form-group">
<label htmlFor={`step-timeout-${step.id}`}>Timeout (ms, optional)</label>
<input
@@ -370,6 +475,14 @@ export function ScheduleStepsEditor({ steps, onChange, onEditingChange }: Schedu
<Plus size={14} />
Add AI Prompt Step
</button>
<button
type="button"
className="btn btn-sm"
onClick={() => handleAddStep("create-task")}
>
<Plus size={14} />
Add Create Task Step
</button>
</div>
</div>
);

View File

@@ -1,4 +1,4 @@
import { Terminal, Sparkles } from "lucide-react";
import { Terminal, Sparkles, ListPlus } from "lucide-react";
import type { AutomationStepType } from "@fusion/core";
interface StepTypeBadgeProps {
@@ -16,6 +16,15 @@ export function StepTypeBadge({ type, size = 12 }: StepTypeBadgeProps) {
);
}
if (type === "create-task") {
return (
<span className="step-type-badge step-type-create-task" title="Create Task step">
<ListPlus size={size} />
<span>Create Task</span>
</span>
);
}
return (
<span className="step-type-badge step-type-ai-prompt" title="AI Prompt step">
<Sparkles size={size} />

View File

@@ -18,6 +18,7 @@ vi.mock("lucide-react", () => ({
GripVertical: () => <span data-testid="icon-grip">≡</span>,
Terminal: () => <span data-testid="icon-terminal">$</span>,
Sparkles: () => <span data-testid="icon-sparkles">✨</span>,
ListPlus: () => <span data-testid="icon-list-plus">📋</span>,
}));
// Mock api - provide models synchronously for immediate availability
@@ -87,6 +88,7 @@ describe("ScheduleStepsEditor", () => {
render(<ScheduleStepsEditor steps={[]} onChange={onChange} />);
expect(screen.getByText("Add Command Step")).toBeDefined();
expect(screen.getByText("Add AI Prompt Step")).toBeDefined();
expect(screen.getByText("Add Create Task Step")).toBeDefined();
});
it("adds a command step when clicking Add Command Step", () => {
@@ -109,6 +111,18 @@ describe("ScheduleStepsEditor", () => {
expect(newSteps[0].name).toBe("New AI Prompt Step");
});
it("adds a create-task step when clicking Add Create Task Step", () => {
render(<ScheduleStepsEditor steps={[]} onChange={onChange} />);
fireEvent.click(screen.getByText("Add Create Task Step"));
expect(onChange).toHaveBeenCalledTimes(1);
const newSteps = onChange.mock.calls[0][0] as AutomationStep[];
expect(newSteps).toHaveLength(1);
expect(newSteps[0].type).toBe("create-task");
expect(newSteps[0].name).toBe("New Create Task Step");
expect(newSteps[0].taskDescription).toBe("");
expect(newSteps[0].taskColumn).toBe("triage");
});
it("appends to existing steps", () => {
const existing = [makeStep({ name: "Existing" })];
render(<ScheduleStepsEditor steps={existing} onChange={onChange} />);
@@ -249,6 +263,36 @@ describe("ScheduleStepsEditor", () => {
fireEvent.click(screen.getByText("Save Step"));
expect(screen.getByText("Command is required")).toBeDefined();
});
it("shows error when create-task step has no task description", () => {
const steps = [makeStep({ id: "s1", type: "create-task", name: "Create Task", taskDescription: "Some description" })];
render(<ScheduleStepsEditor steps={steps} onChange={onChange} />);
fireEvent.click(screen.getByLabelText("Edit Create Task"));
// Clear the task description
const descField = screen.getByLabelText("Task Description *");
fireEvent.change(descField, { target: { value: "" } });
fireEvent.click(screen.getByText("Save Step"));
expect(screen.getByText("Task description is required")).toBeDefined();
expect(onChange).not.toHaveBeenCalled();
});
it("allows saving create-task step with all fields filled", async () => {
const steps = [makeStep({ id: "s1", type: "create-task", name: "Create Task", taskDescription: "", taskColumn: "triage" })];
render(<ScheduleStepsEditor steps={steps} onChange={onChange} />);
fireEvent.click(screen.getByLabelText("Edit Create Task"));
// Fill in all fields
fireEvent.change(screen.getByLabelText("Task Title (optional)"), { target: { value: "Weekly Review" } });
fireEvent.change(screen.getByLabelText("Task Description *"), { target: { value: "Check dependencies" } });
fireEvent.click(screen.getByText("Save Step"));
expect(onChange).toHaveBeenCalledTimes(1);
const savedStep = onChange.mock.calls[0][0][0] as AutomationStep;
expect(savedStep.taskTitle).toBe("Weekly Review");
expect(savedStep.taskDescription).toBe("Check dependencies");
expect(savedStep.taskColumn).toBe("triage");
});
});
describe("empty state", () => {
@@ -357,6 +401,38 @@ describe("ScheduleStepsEditor", () => {
// We can't fully test the React state update in this mock setup,
// but we can verify the callback is properly wired
});
it("shows model dropdown for create-task step type", async () => {
const steps = [makeStep({ id: "s1", type: "create-task", name: "Create Task", taskDescription: "Test description" })];
render(<ScheduleStepsEditor steps={steps} onChange={onChange} />);
fireEvent.click(screen.getByLabelText("Edit Create Task"));
// Wait for models to load and dropdown to appear
await waitFor(() => expect(screen.getByTestId("model-dropdown")).toBeDefined());
expect(screen.getByTestId("model-dropdown")).toBeDefined();
});
it("pre-populates model dropdown when editing create-task step with existing model", async () => {
const steps = [makeStep({
id: "s1",
type: "create-task",
name: "Create Task",
taskDescription: "Test description",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5"
})];
render(<ScheduleStepsEditor steps={steps} onChange={onChange} />);
fireEvent.click(screen.getByLabelText("Edit Create Task"));
// Wait for models to load and dropdown to appear
await waitFor(() => expect(screen.getByTestId("model-dropdown")).toBeDefined());
const dropdown = screen.getByTestId("model-dropdown") as HTMLSelectElement;
expect(dropdown.getAttribute("data-value")).toBe("anthropic/claude-sonnet-4-5");
});
});
describe("ID generation fallback", () => {