feat(FN-734): extract reusable TaskForm component and integrate into modals
- Extract shared TaskForm component from NewTaskModal with all form fields and validation logic - Integrate TaskForm into NewTaskModal, replacing duplicated form code - Integrate TaskForm into TaskDetailModal edit mode for consistent editing experience - Add comprehensive tests for TaskForm, Header, and updated modal components - Clean up removed/obsolete tests and fix SSE, settings, and comment components
This commit is contained in:
@@ -133,7 +133,7 @@ export function createTask(input: TaskCreateInput, projectId?: string): Promise<
|
||||
});
|
||||
}
|
||||
|
||||
export function updateTask(id: string, updates: { title?: string; description?: string; prompt?: string; dependencies?: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null }, projectId?: string): Promise<Task> {
|
||||
export function updateTask(id: string, updates: { title?: string; description?: string; prompt?: string; dependencies?: string[]; enabledWorkflowSteps?: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null }, projectId?: string): Promise<Task> {
|
||||
return api<Task>(withProjectId(`/tasks/${id}`, projectId), {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(updates),
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import type { Task, TaskCreateInput, ModelPreset, Settings, WorkflowStep } from "@fusion/core";
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import type { Task, TaskCreateInput } from "@fusion/core";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { uploadAttachment, fetchModels, fetchSettings, fetchWorkflowSteps, refineText, getRefineErrorMessage, updateGlobalSettings, type RefinementType } from "../api";
|
||||
import type { ModelInfo } from "../api";
|
||||
import { applyPresetToSelection, getRecommendedPresetForSize } from "../utils/modelPresets";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { Sparkles, Globe } from "lucide-react";
|
||||
|
||||
const ALLOWED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
||||
|
||||
interface PendingImage {
|
||||
file: File;
|
||||
previewUrl: string;
|
||||
}
|
||||
import { uploadAttachment } from "../api";
|
||||
import { TaskForm, type PendingImage } from "./TaskForm";
|
||||
|
||||
interface NewTaskModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -28,51 +18,15 @@ interface NewTaskModalProps {
|
||||
export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, addToast, onPlanningMode, onSubtaskBreakdown }: NewTaskModalProps) {
|
||||
const [description, setDescription] = useState("");
|
||||
const [dependencies, setDependencies] = useState<string[]>([]);
|
||||
const [showDepDropdown, setShowDepDropdown] = useState(false);
|
||||
const [depSearch, setDepSearch] = useState("");
|
||||
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
|
||||
const [favoriteProviders, setFavoriteProviders] = useState<string[]>([]);
|
||||
const [modelsLoading, setModelsLoading] = useState(false);
|
||||
const [executorModel, setExecutorModel] = useState("");
|
||||
const [validatorModel, setValidatorModel] = useState("");
|
||||
const [settings, setSettings] = useState<Settings | null>(null);
|
||||
const [selectedPresetId, setSelectedPresetId] = useState<string>("");
|
||||
const [presetMode, setPresetMode] = useState<"default" | "preset" | "custom">("default");
|
||||
const [hasDirtyState, setHasDirtyState] = useState(false);
|
||||
const [workflowSteps, setWorkflowSteps] = useState<WorkflowStep[]>([]);
|
||||
const [selectedWorkflowSteps, setSelectedWorkflowSteps] = useState<string[]>([]);
|
||||
|
||||
// AI Refinement state
|
||||
const [isRefineMenuOpen, setIsRefineMenuOpen] = useState(false);
|
||||
const [isRefining, setIsRefining] = useState(false);
|
||||
const refineMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const depDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const descTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Load available models when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setModelsLoading(true);
|
||||
fetchModels()
|
||||
.then((response) => {
|
||||
setAvailableModels(response.models);
|
||||
setFavoriteProviders(response.favoriteProviders);
|
||||
})
|
||||
.catch(() => {/* silently fail - models just won't be available */})
|
||||
.finally(() => setModelsLoading(false));
|
||||
fetchSettings(projectId)
|
||||
.then((nextSettings) => setSettings(nextSettings))
|
||||
.catch(() => setSettings(null));
|
||||
fetchWorkflowSteps(projectId)
|
||||
.then((steps) => setWorkflowSteps(steps.filter((s) => s.enabled)))
|
||||
.catch(() => setWorkflowSteps([]));
|
||||
}
|
||||
}, [isOpen, projectId]);
|
||||
|
||||
// Track dirty state
|
||||
useEffect(() => {
|
||||
const isDirty =
|
||||
@@ -85,116 +39,12 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
setHasDirtyState(isDirty);
|
||||
}, [description, dependencies, pendingImages, executorModel, validatorModel, selectedWorkflowSteps]);
|
||||
|
||||
const availablePresets = settings?.modelPresets || [];
|
||||
const selectedPreset = availablePresets.find((preset) => preset.id === selectedPresetId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !settings?.autoSelectModelPreset) return;
|
||||
const recommended = getRecommendedPresetForSize(undefined, settings.defaultPresetBySize || {}, availablePresets);
|
||||
if (recommended) {
|
||||
const selection = applyPresetToSelection(recommended);
|
||||
setSelectedPresetId(recommended.id);
|
||||
setPresetMode("preset");
|
||||
setExecutorModel(selection.executorValue);
|
||||
setValidatorModel(selection.validatorValue);
|
||||
}
|
||||
}, [isOpen, settings, availablePresets]);
|
||||
|
||||
// Auto-focus description textarea when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// Small delay to ensure modal is fully rendered
|
||||
const timeoutId = setTimeout(() => {
|
||||
descTextareaRef.current?.focus();
|
||||
}, 0);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
if (!showDepDropdown) return;
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (depDropdownRef.current && !depDropdownRef.current.contains(e.target as Node)) {
|
||||
setShowDepDropdown(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [showDepDropdown]);
|
||||
|
||||
// Close refine menu when clicking outside
|
||||
useEffect(() => {
|
||||
if (!isRefineMenuOpen) return;
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (refineMenuRef.current && !refineMenuRef.current.contains(e.target as Node)) {
|
||||
setIsRefineMenuOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [isRefineMenuOpen]);
|
||||
|
||||
// Handle paste for images
|
||||
const handlePaste = useCallback((e: React.ClipboardEvent) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item.type.startsWith("image/")) {
|
||||
const file = item.getAsFile();
|
||||
if (file && ALLOWED_IMAGE_TYPES.includes(file.type)) {
|
||||
e.preventDefault();
|
||||
setPendingImages((prev) => [
|
||||
...prev,
|
||||
{ file, previewUrl: URL.createObjectURL(file) },
|
||||
]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle file drop for images
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const files = e.dataTransfer.files;
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
if (ALLOWED_IMAGE_TYPES.includes(file.type)) {
|
||||
setPendingImages((prev) => [
|
||||
...prev,
|
||||
{ file, previewUrl: URL.createObjectURL(file) },
|
||||
]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const removeImage = useCallback((index: number) => {
|
||||
setPendingImages((prev) => {
|
||||
const removed = prev[index];
|
||||
if (removed) URL.revokeObjectURL(removed.previewUrl);
|
||||
return prev.filter((_, i) => i !== index);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleDep = useCallback((id: string) => {
|
||||
setDependencies((prev) =>
|
||||
prev.includes(id) ? prev.filter((d) => d !== id) : [...prev, id],
|
||||
);
|
||||
}, []);
|
||||
|
||||
const truncate = (s: string, len: number) =>
|
||||
s.length > len ? s.slice(0, len) + "…" : s;
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (hasDirtyState) {
|
||||
if (!confirm("You have unsaved changes. Discard them?")) return;
|
||||
}
|
||||
// Clean up object URLs
|
||||
pendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl));
|
||||
setPendingImages([]);
|
||||
// Reset form
|
||||
setPendingImages([]);
|
||||
setDescription("");
|
||||
@@ -204,8 +54,6 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
setSelectedPresetId("");
|
||||
setPresetMode("default");
|
||||
setSelectedWorkflowSteps([]);
|
||||
setIsRefineMenuOpen(false);
|
||||
setIsRefining(false);
|
||||
setHasDirtyState(false);
|
||||
onClose();
|
||||
}, [hasDirtyState, onClose, pendingImages]);
|
||||
@@ -216,7 +64,6 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
// Create the base task
|
||||
const executorSlashIdx = executorModel.indexOf("/");
|
||||
const validatorSlashIdx = validatorModel.indexOf("/");
|
||||
|
||||
@@ -266,93 +113,23 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [description, dependencies, pendingImages, executorModel, validatorModel, isSubmitting, onCreateTask, addToast, onClose]);
|
||||
}, [description, dependencies, pendingImages, executorModel, validatorModel, isSubmitting, onCreateTask, addToast, onClose, projectId, presetMode, selectedPresetId, selectedWorkflowSteps]);
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape" && !showDepDropdown) {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
handleClose();
|
||||
}
|
||||
}, [handleClose, showDepDropdown]);
|
||||
|
||||
// Auto-resize textarea
|
||||
const handleDescriptionChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setDescription(e.target.value);
|
||||
const el = e.target;
|
||||
el.style.height = "auto";
|
||||
el.style.height = el.scrollHeight + "px";
|
||||
}, []);
|
||||
|
||||
// AI Refinement handler
|
||||
const handleRefine = useCallback(async (type: RefinementType) => {
|
||||
const trimmed = description.trim();
|
||||
if (!trimmed || isRefining) return;
|
||||
|
||||
setIsRefining(true);
|
||||
try {
|
||||
const refined = await refineText(trimmed, type);
|
||||
setDescription(refined);
|
||||
setIsRefineMenuOpen(false);
|
||||
addToast("Description refined with AI", "success");
|
||||
// Auto-resize textarea after content update
|
||||
if (descTextareaRef.current) {
|
||||
descTextareaRef.current.style.height = "auto";
|
||||
descTextareaRef.current.style.height = descTextareaRef.current.scrollHeight + "px";
|
||||
}
|
||||
} catch (err: any) {
|
||||
const errorMessage = getRefineErrorMessage(err);
|
||||
addToast(errorMessage, "error");
|
||||
} finally {
|
||||
setIsRefining(false);
|
||||
}
|
||||
}, [description, isRefining, addToast]);
|
||||
|
||||
const handleToggleFavorite = useCallback(async (provider: string) => {
|
||||
const currentFavorites = favoriteProviders;
|
||||
const isFavorite = currentFavorites.includes(provider);
|
||||
const newFavorites = isFavorite
|
||||
? currentFavorites.filter((p) => p !== provider)
|
||||
: [provider, ...currentFavorites];
|
||||
|
||||
setFavoriteProviders(newFavorites);
|
||||
|
||||
try {
|
||||
await updateGlobalSettings({ favoriteProviders: newFavorites });
|
||||
} catch {
|
||||
// Revert on error
|
||||
setFavoriteProviders(currentFavorites);
|
||||
}
|
||||
}, [favoriteProviders]);
|
||||
}, [handleClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const availableDeps = tasks
|
||||
.filter((t) => !dependencies.includes(t.id))
|
||||
.sort((a, b) => {
|
||||
const cmp = b.createdAt.localeCompare(a.createdAt);
|
||||
if (cmp !== 0) return cmp;
|
||||
const aNum = parseInt(a.id.slice(a.id.lastIndexOf("-") + 1), 10) || 0;
|
||||
const bNum = parseInt(b.id.slice(b.id.lastIndexOf("-") + 1), 10) || 0;
|
||||
return bNum - aNum;
|
||||
});
|
||||
|
||||
const filteredDeps = depSearch
|
||||
? availableDeps.filter((t) =>
|
||||
t.id.toLowerCase().includes(depSearch.toLowerCase()) ||
|
||||
(t.title && t.title.toLowerCase().includes(depSearch.toLowerCase())) ||
|
||||
(t.description && t.description.toLowerCase().includes(depSearch.toLowerCase()))
|
||||
)
|
||||
: availableDeps;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open" onClick={handleClose} onKeyDown={handleKeyDown}>
|
||||
<div
|
||||
className="modal modal-lg new-task-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onPaste={handlePaste}
|
||||
>
|
||||
<div className="modal-header">
|
||||
<h3>New Task</h3>
|
||||
@@ -362,382 +139,33 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
{/* Description field */}
|
||||
<div className="form-group">
|
||||
<label htmlFor="new-task-description">Description</label>
|
||||
<div className="description-with-refine" ref={refineMenuRef}>
|
||||
<textarea
|
||||
ref={descTextareaRef}
|
||||
id="new-task-description"
|
||||
value={description}
|
||||
onChange={handleDescriptionChange}
|
||||
placeholder="What needs to be done?"
|
||||
rows={3}
|
||||
disabled={isSubmitting || isRefining}
|
||||
/>
|
||||
{description.trim() && !isSubmitting && (
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-sm refine-button ${isRefining ? "refine-button--loading" : ""}`}
|
||||
onClick={() => setIsRefineMenuOpen((prev) => !prev)}
|
||||
disabled={isRefining}
|
||||
data-testid="refine-button"
|
||||
title="Refine description with AI"
|
||||
>
|
||||
<Sparkles size={12} style={{ verticalAlign: "middle" }} />
|
||||
{isRefining ? "Refining..." : "Refine"}
|
||||
</button>
|
||||
)}
|
||||
{isRefineMenuOpen && (
|
||||
<div
|
||||
className="refine-menu refine-menu--modal"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<div
|
||||
className="refine-menu-item"
|
||||
onClick={() => handleRefine("clarify")}
|
||||
data-testid="refine-clarify"
|
||||
>
|
||||
<div className="refine-menu-item-title">Clarify</div>
|
||||
<div className="refine-menu-item-desc">Make the description clearer and more specific</div>
|
||||
</div>
|
||||
<div
|
||||
className="refine-menu-item"
|
||||
onClick={() => handleRefine("add-details")}
|
||||
data-testid="refine-add-details"
|
||||
>
|
||||
<div className="refine-menu-item-title">Add details</div>
|
||||
<div className="refine-menu-item-desc">Add implementation details and context</div>
|
||||
</div>
|
||||
<div
|
||||
className="refine-menu-item"
|
||||
onClick={() => handleRefine("expand")}
|
||||
data-testid="refine-expand"
|
||||
>
|
||||
<div className="refine-menu-item-title">Expand</div>
|
||||
<div className="refine-menu-item-desc">Expand into a more comprehensive description</div>
|
||||
</div>
|
||||
<div
|
||||
className="refine-menu-item"
|
||||
onClick={() => handleRefine("simplify")}
|
||||
data-testid="refine-simplify"
|
||||
>
|
||||
<div className="refine-menu-item-title">Simplify</div>
|
||||
<div className="refine-menu-item-desc">Simplify and make more concise</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dependencies */}
|
||||
<div className="form-group">
|
||||
<label>Dependencies</label>
|
||||
<div className="dep-trigger-wrap" ref={depDropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm dep-trigger"
|
||||
onClick={() => setShowDepDropdown((v) => !v)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{dependencies.length > 0 ? `${dependencies.length} selected` : "Add dependencies"}
|
||||
</button>
|
||||
{showDepDropdown && (
|
||||
<div className="dep-dropdown">
|
||||
<input
|
||||
className="dep-dropdown-search"
|
||||
placeholder="Search tasks…"
|
||||
autoFocus
|
||||
value={depSearch}
|
||||
onChange={(e) => setDepSearch(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
{filteredDeps.length === 0 ? (
|
||||
<div className="dep-dropdown-empty">No available tasks</div>
|
||||
) : (
|
||||
filteredDeps.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`dep-dropdown-item${dependencies.includes(t.id) ? " selected" : ""}`}
|
||||
onClick={() => toggleDep(t.id)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<span className="dep-dropdown-id">{t.id}</span>
|
||||
<span className="dep-dropdown-title">{truncate(t.title || t.description || t.id, 30)}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{dependencies.length > 0 && (
|
||||
<div className="selected-deps">
|
||||
{dependencies.map((depId) => (
|
||||
<span key={depId} className="dep-chip">
|
||||
{depId}
|
||||
<button
|
||||
type="button"
|
||||
className="dep-chip-remove"
|
||||
onClick={() => toggleDep(depId)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Model Selection */}
|
||||
<div className="form-group">
|
||||
<label>Model Configuration</label>
|
||||
{modelsLoading ? (
|
||||
<div className="model-selector-loading">Loading models…</div>
|
||||
) : availableModels.length === 0 ? (
|
||||
<small>No models available. Configure authentication in Settings.</small>
|
||||
) : (
|
||||
<>
|
||||
<div className="model-select-row">
|
||||
<label htmlFor="model-preset" className="model-select-label">Preset</label>
|
||||
<select
|
||||
id="model-preset"
|
||||
value={presetMode === "preset" ? selectedPresetId : presetMode}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "default") {
|
||||
setPresetMode("default");
|
||||
setSelectedPresetId("");
|
||||
setExecutorModel("");
|
||||
setValidatorModel("");
|
||||
return;
|
||||
}
|
||||
if (value === "custom") {
|
||||
setPresetMode("custom");
|
||||
setSelectedPresetId("");
|
||||
return;
|
||||
}
|
||||
const preset = availablePresets.find((entry) => entry.id === value);
|
||||
const selection = applyPresetToSelection(preset);
|
||||
setPresetMode("preset");
|
||||
setSelectedPresetId(value);
|
||||
setExecutorModel(selection.executorValue);
|
||||
setValidatorModel(selection.validatorValue);
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<option value="default">Use default</option>
|
||||
{availablePresets.length > 0 ? <option disabled>──────────</option> : null}
|
||||
{availablePresets.map((preset) => (
|
||||
<option key={preset.id} value={preset.id}>{preset.name}</option>
|
||||
))}
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
</div>
|
||||
{presetMode === "preset" && selectedPreset ? (
|
||||
<small>Using preset: {selectedPreset.name}</small>
|
||||
) : null}
|
||||
{presetMode === "preset" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => setPresetMode("custom")}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Override
|
||||
</button>
|
||||
) : null}
|
||||
<div className="model-select-row">
|
||||
<label htmlFor="executor-model" className="model-select-label">Executor</label>
|
||||
<CustomModelDropdown
|
||||
id="executor-model"
|
||||
label="Executor Model"
|
||||
value={executorModel}
|
||||
onChange={(value) => {
|
||||
setPresetMode("custom");
|
||||
setSelectedPresetId("");
|
||||
setExecutorModel(value);
|
||||
}}
|
||||
models={availableModels}
|
||||
disabled={isSubmitting || presetMode === "preset"}
|
||||
favoriteProviders={favoriteProviders}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
/>
|
||||
</div>
|
||||
<div className="model-select-row">
|
||||
<label htmlFor="validator-model" className="model-select-label">Validator</label>
|
||||
<CustomModelDropdown
|
||||
id="validator-model"
|
||||
label="Validator Model"
|
||||
value={validatorModel}
|
||||
onChange={(value) => {
|
||||
setPresetMode("custom");
|
||||
setSelectedPresetId("");
|
||||
setValidatorModel(value);
|
||||
}}
|
||||
models={availableModels}
|
||||
disabled={isSubmitting || presetMode === "preset"}
|
||||
favoriteProviders={favoriteProviders}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Workflow Steps */}
|
||||
<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.length > 0 && 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>
|
||||
))}
|
||||
<label
|
||||
key="browser-verification"
|
||||
className="checkbox-label"
|
||||
style={{ display: "flex", alignItems: "flex-start", gap: "8px" }}
|
||||
data-testid="browser-verification-checkbox"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedWorkflowSteps.includes("browser-verification")}
|
||||
onChange={(e) => {
|
||||
setSelectedWorkflowSteps((prev) =>
|
||||
e.target.checked
|
||||
? [...prev, "browser-verification"]
|
||||
: prev.filter((id) => id !== "browser-verification")
|
||||
);
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
style={{ marginTop: "2px" }}
|
||||
/>
|
||||
<div>
|
||||
<span style={{ fontWeight: 500, fontSize: "13px" }}>
|
||||
<Globe size={14} style={{ verticalAlign: "middle", marginRight: "4px" }} />
|
||||
Browser Verification
|
||||
</span>
|
||||
<div style={{ fontSize: "12px", color: "var(--text-secondary)", marginTop: "2px" }}>
|
||||
Verify web application functionality using browser automation (agent-browser)
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>AI-assisted creation</label>
|
||||
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => {
|
||||
const trimmed = description.trim();
|
||||
if (!trimmed) {
|
||||
addToast("Enter a description first", "error");
|
||||
return;
|
||||
}
|
||||
handleClose();
|
||||
onPlanningMode?.(trimmed);
|
||||
}}
|
||||
disabled={isSubmitting || !description.trim()}
|
||||
>
|
||||
Plan
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => {
|
||||
const trimmed = description.trim();
|
||||
if (!trimmed) {
|
||||
addToast("Enter a description first", "error");
|
||||
return;
|
||||
}
|
||||
handleClose();
|
||||
onSubtaskBreakdown?.(trimmed);
|
||||
}}
|
||||
disabled={isSubmitting || !description.trim()}
|
||||
>
|
||||
Subtask
|
||||
</button>
|
||||
</div>
|
||||
<small>Use Plan for clarifying questions or Subtask to split the work into editable child tasks.</small>
|
||||
</div>
|
||||
|
||||
{/* Attachments */}
|
||||
<div className="form-group">
|
||||
<label>Attachments</label>
|
||||
{pendingImages.length > 0 && (
|
||||
<div className="inline-create-previews">
|
||||
{pendingImages.map((img, i) => (
|
||||
<div key={img.previewUrl} className="inline-create-preview">
|
||||
<img src={img.previewUrl} alt={img.file.name} />
|
||||
<button
|
||||
type="button"
|
||||
className="inline-create-preview-remove"
|
||||
onClick={() => removeImage(i)}
|
||||
disabled={isSubmitting}
|
||||
title="Remove image"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setPendingImages((prev) => [
|
||||
...prev,
|
||||
{ file, previewUrl: URL.createObjectURL(file) },
|
||||
]);
|
||||
e.target.value = "";
|
||||
}
|
||||
}}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Attach Screenshot
|
||||
</button>
|
||||
<small>You can also paste images or drag & drop</small>
|
||||
</div>
|
||||
<TaskForm
|
||||
mode="create"
|
||||
description={description}
|
||||
onDescriptionChange={setDescription}
|
||||
dependencies={dependencies}
|
||||
onDependenciesChange={setDependencies}
|
||||
executorModel={executorModel}
|
||||
onExecutorModelChange={setExecutorModel}
|
||||
validatorModel={validatorModel}
|
||||
onValidatorModelChange={setValidatorModel}
|
||||
presetMode={presetMode}
|
||||
onPresetModeChange={setPresetMode}
|
||||
selectedPresetId={selectedPresetId}
|
||||
onSelectedPresetIdChange={setSelectedPresetId}
|
||||
selectedWorkflowSteps={selectedWorkflowSteps}
|
||||
onWorkflowStepsChange={setSelectedWorkflowSteps}
|
||||
pendingImages={pendingImages}
|
||||
onImagesChange={setPendingImages}
|
||||
tasks={tasks}
|
||||
projectId={projectId}
|
||||
disabled={isSubmitting}
|
||||
addToast={addToast}
|
||||
isActive={isOpen}
|
||||
onPlanningMode={onPlanningMode}
|
||||
onSubtaskBreakdown={onSubtaskBreakdown}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="modal-actions">
|
||||
|
||||
@@ -13,6 +13,7 @@ import { PrSection } from "./PrSection";
|
||||
import { TaskComments } from "./TaskComments";
|
||||
import { MergeDetails } from "./MergeDetails";
|
||||
import { TaskChangesTab } from "./TaskChangesTab";
|
||||
import { TaskForm, type PendingImage } from "./TaskForm";
|
||||
|
||||
interface ModelSelection {
|
||||
provider?: string;
|
||||
@@ -126,8 +127,14 @@ export function TaskDetailModal({
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editTitle, setEditTitle] = useState(task.title || "");
|
||||
const [editDescription, setEditDescription] = useState(task.description || "");
|
||||
const [editDependencies, setEditDependencies] = useState<string[]>(task.dependencies || []);
|
||||
const [editExecutorModel, setEditExecutorModel] = useState("");
|
||||
const [editValidatorModel, setEditValidatorModel] = useState("");
|
||||
const [editPresetMode, setEditPresetMode] = useState<"default" | "preset" | "custom">("default");
|
||||
const [editSelectedPresetId, setEditSelectedPresetId] = useState("");
|
||||
const [editSelectedWorkflowSteps, setEditSelectedWorkflowSteps] = useState<string[]>(task.enabledWorkflowSteps || []);
|
||||
const [editPendingImages, setEditPendingImages] = useState<PendingImage[]>([]);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const titleInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Reset edit state when task changes
|
||||
useEffect(() => {
|
||||
@@ -150,82 +157,102 @@ export function TaskDetailModal({
|
||||
setSpecFeedback("");
|
||||
}, [task.id, task.prompt]);
|
||||
|
||||
// Auto-focus title when entering edit mode
|
||||
useEffect(() => {
|
||||
if (isEditing) {
|
||||
titleInputRef.current?.focus();
|
||||
titleInputRef.current?.select();
|
||||
}
|
||||
}, [isEditing]);
|
||||
// Note: TaskForm handles auto-focus internally via isActive prop
|
||||
|
||||
// Check if task can be edited
|
||||
const canEdit = EDITABLE_COLUMNS.has(task.column) && !isSaving;
|
||||
const hasChanges = editTitle !== (task.title || "") || editDescription !== (task.description || "");
|
||||
|
||||
const enterEditMode = useCallback(() => {
|
||||
if (!canEdit) return;
|
||||
setIsEditing(true);
|
||||
setEditTitle(task.title || "");
|
||||
setEditDescription(task.description || "");
|
||||
}, [canEdit, task.title, task.description]);
|
||||
setEditDependencies(task.dependencies || []);
|
||||
// Populate model overrides from task
|
||||
const execModel = task.modelProvider && task.modelId ? `${task.modelProvider}/${task.modelId}` : "";
|
||||
const valModel = task.validatorModelProvider && task.validatorModelId ? `${task.validatorModelProvider}/${task.validatorModelId}` : "";
|
||||
setEditExecutorModel(execModel);
|
||||
setEditValidatorModel(valModel);
|
||||
setEditPresetMode(execModel || valModel ? "custom" : "default");
|
||||
setEditSelectedPresetId("");
|
||||
setEditSelectedWorkflowSteps(task.enabledWorkflowSteps || []);
|
||||
setEditPendingImages([]);
|
||||
}, [canEdit, task]);
|
||||
|
||||
const exitEditMode = useCallback(() => {
|
||||
setIsEditing(false);
|
||||
setEditTitle(task.title || "");
|
||||
setEditDescription(task.description || "");
|
||||
}, [task.title, task.description]);
|
||||
setEditDependencies(task.dependencies || []);
|
||||
editPendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl));
|
||||
setEditPendingImages([]);
|
||||
}, [task.title, task.description, task.dependencies, editPendingImages]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!hasChanges) {
|
||||
exitEditMode();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await updateTask(task.id, {
|
||||
// Build update payload with all changed fields
|
||||
const executorSlashIdx = editExecutorModel.indexOf("/");
|
||||
const validatorSlashIdx = editValidatorModel.indexOf("/");
|
||||
|
||||
const updates: Parameters<typeof updateTask>[1] = {
|
||||
title: editTitle.trim() || undefined,
|
||||
description: editDescription.trim() || undefined,
|
||||
}, projectId);
|
||||
dependencies: editDependencies,
|
||||
enabledWorkflowSteps: editSelectedWorkflowSteps,
|
||||
modelProvider: editExecutorModel && executorSlashIdx !== -1 ? editExecutorModel.slice(0, executorSlashIdx) : null,
|
||||
modelId: editExecutorModel && executorSlashIdx !== -1 ? editExecutorModel.slice(executorSlashIdx + 1) : null,
|
||||
validatorModelProvider: editValidatorModel && validatorSlashIdx !== -1 ? editValidatorModel.slice(0, validatorSlashIdx) : null,
|
||||
validatorModelId: editValidatorModel && validatorSlashIdx !== -1 ? editValidatorModel.slice(validatorSlashIdx + 1) : null,
|
||||
};
|
||||
|
||||
await updateTask(task.id, updates, projectId);
|
||||
|
||||
// Upload pending images as attachments
|
||||
if (editPendingImages.length > 0) {
|
||||
const failures: string[] = [];
|
||||
for (const img of editPendingImages) {
|
||||
try {
|
||||
const attachment = await uploadAttachment(task.id, img.file, projectId);
|
||||
setAttachments((prev) => [...prev, attachment]);
|
||||
} catch {
|
||||
failures.push(img.file.name);
|
||||
}
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
addToast(`Failed to upload: ${failures.join(", ")}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up
|
||||
editPendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl));
|
||||
setEditPendingImages([]);
|
||||
addToast(`Updated ${task.id}`, "success");
|
||||
setIsEditing(false);
|
||||
} catch (err: any) {
|
||||
addToast(`Failed to update ${task.id}: ${err.message}`, "error");
|
||||
// Stay in edit mode on error so user can retry
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [task.id, editTitle, editDescription, hasChanges, exitEditMode, addToast]);
|
||||
}, [task.id, editTitle, editDescription, editDependencies, editExecutorModel, editValidatorModel, editSelectedWorkflowSteps, editPendingImages, addToast, projectId]);
|
||||
|
||||
const handleTitleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
// Move focus to description textarea
|
||||
const textarea = document.querySelector('.modal-edit-textarea') as HTMLTextAreaElement;
|
||||
textarea?.focus();
|
||||
} else if (e.key === "Escape") {
|
||||
// Handle keyboard shortcuts for edit mode
|
||||
const handleEditKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (!isEditing) return;
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
exitEditMode();
|
||||
}
|
||||
}, [exitEditMode]);
|
||||
|
||||
const handleDescKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||
} else if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
exitEditMode();
|
||||
}
|
||||
}, [handleSave, exitEditMode]);
|
||||
}, [isEditing, exitEditMode, handleSave]);
|
||||
|
||||
const handleDescChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setEditDescription(e.target.value);
|
||||
// Auto-resize textarea
|
||||
const el = e.target;
|
||||
el.style.height = "auto";
|
||||
el.style.height = el.scrollHeight + "px";
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!isEditing) return;
|
||||
document.addEventListener("keydown", handleEditKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleEditKeyDown);
|
||||
}, [isEditing, handleEditKeyDown]);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { entries: agentLogEntries, loading: agentLogLoading } = useAgentLogs(
|
||||
@@ -596,24 +623,31 @@ export function TaskDetailModal({
|
||||
<div className="detail-body">
|
||||
{isEditing ? (
|
||||
<div className="modal-edit-form">
|
||||
<input
|
||||
ref={titleInputRef}
|
||||
type="text"
|
||||
className="modal-edit-input"
|
||||
placeholder="Task title"
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
onKeyDown={handleTitleKeyDown}
|
||||
<TaskForm
|
||||
mode="edit"
|
||||
title={editTitle}
|
||||
onTitleChange={setEditTitle}
|
||||
description={editDescription}
|
||||
onDescriptionChange={setEditDescription}
|
||||
dependencies={editDependencies}
|
||||
onDependenciesChange={setEditDependencies}
|
||||
executorModel={editExecutorModel}
|
||||
onExecutorModelChange={setEditExecutorModel}
|
||||
validatorModel={editValidatorModel}
|
||||
onValidatorModelChange={setEditValidatorModel}
|
||||
presetMode={editPresetMode}
|
||||
onPresetModeChange={setEditPresetMode}
|
||||
selectedPresetId={editSelectedPresetId}
|
||||
onSelectedPresetIdChange={setEditSelectedPresetId}
|
||||
selectedWorkflowSteps={editSelectedWorkflowSteps}
|
||||
onWorkflowStepsChange={setEditSelectedWorkflowSteps}
|
||||
pendingImages={editPendingImages}
|
||||
onImagesChange={setEditPendingImages}
|
||||
tasks={tasks.filter((t) => t.id !== task.id)}
|
||||
projectId={projectId}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
<textarea
|
||||
className="modal-edit-textarea"
|
||||
placeholder="Task description"
|
||||
value={editDescription}
|
||||
onChange={handleDescChange}
|
||||
onKeyDown={handleDescKeyDown}
|
||||
disabled={isSaving}
|
||||
rows={3}
|
||||
addToast={addToast}
|
||||
isActive={isEditing}
|
||||
/>
|
||||
<div className="modal-edit-actions">
|
||||
<button
|
||||
@@ -626,7 +660,7 @@ export function TaskDetailModal({
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || isSaving}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
|
||||
689
packages/dashboard/app/components/TaskForm.tsx
Normal file
689
packages/dashboard/app/components/TaskForm.tsx
Normal file
@@ -0,0 +1,689 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import type { Task, ModelPreset, Settings, WorkflowStep } from "@fusion/core";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { fetchModels, fetchSettings, fetchWorkflowSteps, refineText, getRefineErrorMessage, updateGlobalSettings, type RefinementType, type ModelInfo } from "../api";
|
||||
import { applyPresetToSelection, getRecommendedPresetForSize } from "../utils/modelPresets";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { Sparkles, Globe } from "lucide-react";
|
||||
|
||||
const ALLOWED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
||||
|
||||
export interface PendingImage {
|
||||
file: File;
|
||||
previewUrl: string;
|
||||
}
|
||||
|
||||
export interface TaskFormProps {
|
||||
mode: "create" | "edit";
|
||||
|
||||
// Core fields
|
||||
description: string;
|
||||
onDescriptionChange: (value: string) => void;
|
||||
title?: string;
|
||||
onTitleChange?: (value: string) => void;
|
||||
|
||||
// Dependencies
|
||||
dependencies: string[];
|
||||
onDependenciesChange: (deps: string[]) => void;
|
||||
|
||||
// Model configuration
|
||||
executorModel: string;
|
||||
onExecutorModelChange: (value: string) => void;
|
||||
validatorModel: string;
|
||||
onValidatorModelChange: (value: string) => void;
|
||||
presetMode: "default" | "preset" | "custom";
|
||||
onPresetModeChange: (mode: "default" | "preset" | "custom") => void;
|
||||
selectedPresetId: string;
|
||||
onSelectedPresetIdChange: (id: string) => void;
|
||||
|
||||
// Workflow steps
|
||||
selectedWorkflowSteps: string[];
|
||||
onWorkflowStepsChange: (steps: string[]) => void;
|
||||
|
||||
// Attachments
|
||||
pendingImages: PendingImage[];
|
||||
onImagesChange: (images: PendingImage[]) => void;
|
||||
|
||||
// Context
|
||||
tasks: Task[];
|
||||
projectId?: string;
|
||||
disabled?: boolean;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
isActive?: boolean;
|
||||
|
||||
// AI-assisted creation callbacks (create mode only)
|
||||
onPlanningMode?: (initialPlan: string) => void;
|
||||
onSubtaskBreakdown?: (description: string) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export function TaskForm({
|
||||
mode,
|
||||
description,
|
||||
onDescriptionChange,
|
||||
title,
|
||||
onTitleChange,
|
||||
dependencies,
|
||||
onDependenciesChange,
|
||||
executorModel,
|
||||
onExecutorModelChange,
|
||||
validatorModel,
|
||||
onValidatorModelChange,
|
||||
presetMode,
|
||||
onPresetModeChange,
|
||||
selectedPresetId,
|
||||
onSelectedPresetIdChange,
|
||||
selectedWorkflowSteps,
|
||||
onWorkflowStepsChange,
|
||||
pendingImages,
|
||||
onImagesChange,
|
||||
tasks,
|
||||
projectId,
|
||||
disabled = false,
|
||||
addToast,
|
||||
isActive = true,
|
||||
onPlanningMode,
|
||||
onSubtaskBreakdown,
|
||||
onClose,
|
||||
}: TaskFormProps) {
|
||||
const [showDepDropdown, setShowDepDropdown] = useState(false);
|
||||
const [depSearch, setDepSearch] = useState("");
|
||||
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
|
||||
const [favoriteProviders, setFavoriteProviders] = useState<string[]>([]);
|
||||
const [modelsLoading, setModelsLoading] = useState(false);
|
||||
const [settings, setSettings] = useState<Settings | null>(null);
|
||||
const [workflowSteps, setWorkflowSteps] = useState<WorkflowStep[]>([]);
|
||||
|
||||
// AI Refinement state
|
||||
const [isRefineMenuOpen, setIsRefineMenuOpen] = useState(false);
|
||||
const [isRefining, setIsRefining] = useState(false);
|
||||
const refineMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const depDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const descTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const titleInputRef = useRef<HTMLInputElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Load available models, settings, workflow steps when active
|
||||
useEffect(() => {
|
||||
if (!isActive) return;
|
||||
setModelsLoading(true);
|
||||
fetchModels()
|
||||
.then((response) => {
|
||||
setAvailableModels(response.models);
|
||||
setFavoriteProviders(response.favoriteProviders);
|
||||
})
|
||||
.catch(() => {/* silently fail */})
|
||||
.finally(() => setModelsLoading(false));
|
||||
fetchSettings(projectId)
|
||||
.then((nextSettings) => setSettings(nextSettings))
|
||||
.catch(() => setSettings(null));
|
||||
fetchWorkflowSteps(projectId)
|
||||
.then((steps) => setWorkflowSteps(steps.filter((s) => s.enabled)))
|
||||
.catch(() => setWorkflowSteps([]));
|
||||
}, [isActive, projectId]);
|
||||
|
||||
const availablePresets = settings?.modelPresets || [];
|
||||
const selectedPreset = availablePresets.find((preset) => preset.id === selectedPresetId);
|
||||
|
||||
// Auto-select preset by size (create mode only)
|
||||
useEffect(() => {
|
||||
if (mode !== "create" || !isActive || !settings?.autoSelectModelPreset) return;
|
||||
const recommended = getRecommendedPresetForSize(undefined, settings.defaultPresetBySize || {}, availablePresets);
|
||||
if (recommended) {
|
||||
const selection = applyPresetToSelection(recommended);
|
||||
onSelectedPresetIdChange(recommended.id);
|
||||
onPresetModeChange("preset");
|
||||
onExecutorModelChange(selection.executorValue);
|
||||
onValidatorModelChange(selection.validatorValue);
|
||||
}
|
||||
}, [isActive, settings, availablePresets, mode]);
|
||||
|
||||
// Auto-focus description (create) or title (edit) when active
|
||||
useEffect(() => {
|
||||
if (!isActive) return;
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (mode === "edit" && titleInputRef.current) {
|
||||
titleInputRef.current.focus();
|
||||
titleInputRef.current.select();
|
||||
} else if (mode === "create" && descTextareaRef.current) {
|
||||
descTextareaRef.current.focus();
|
||||
}
|
||||
}, 0);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [isActive, mode]);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
if (!showDepDropdown) return;
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (depDropdownRef.current && !depDropdownRef.current.contains(e.target as Node)) {
|
||||
setShowDepDropdown(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [showDepDropdown]);
|
||||
|
||||
// Close refine menu when clicking outside
|
||||
useEffect(() => {
|
||||
if (!isRefineMenuOpen) return;
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (refineMenuRef.current && !refineMenuRef.current.contains(e.target as Node)) {
|
||||
setIsRefineMenuOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [isRefineMenuOpen]);
|
||||
|
||||
// Handle paste for images
|
||||
const handlePaste = useCallback((e: React.ClipboardEvent) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item.type.startsWith("image/")) {
|
||||
const file = item.getAsFile();
|
||||
if (file && ALLOWED_IMAGE_TYPES.includes(file.type)) {
|
||||
e.preventDefault();
|
||||
onImagesChange([
|
||||
...pendingImages,
|
||||
{ file, previewUrl: URL.createObjectURL(file) },
|
||||
]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [pendingImages, onImagesChange]);
|
||||
|
||||
// Handle file drop for images
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const files = e.dataTransfer.files;
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
if (ALLOWED_IMAGE_TYPES.includes(file.type)) {
|
||||
onImagesChange([
|
||||
...pendingImages,
|
||||
{ file, previewUrl: URL.createObjectURL(file) },
|
||||
]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, [pendingImages, onImagesChange]);
|
||||
|
||||
const removeImage = useCallback((index: number) => {
|
||||
const removed = pendingImages[index];
|
||||
if (removed) URL.revokeObjectURL(removed.previewUrl);
|
||||
onImagesChange(pendingImages.filter((_, i) => i !== index));
|
||||
}, [pendingImages, onImagesChange]);
|
||||
|
||||
const toggleDep = useCallback((id: string) => {
|
||||
onDependenciesChange(
|
||||
dependencies.includes(id) ? dependencies.filter((d) => d !== id) : [...dependencies, id],
|
||||
);
|
||||
}, [dependencies, onDependenciesChange]);
|
||||
|
||||
const truncate = (s: string, len: number) =>
|
||||
s.length > len ? s.slice(0, len) + "…" : s;
|
||||
|
||||
// Auto-resize textarea
|
||||
const handleDescriptionInput = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
onDescriptionChange(e.target.value);
|
||||
const el = e.target;
|
||||
el.style.height = "auto";
|
||||
el.style.height = el.scrollHeight + "px";
|
||||
}, [onDescriptionChange]);
|
||||
|
||||
// AI Refinement handler
|
||||
const handleRefine = useCallback(async (type: RefinementType) => {
|
||||
const trimmed = description.trim();
|
||||
if (!trimmed || isRefining) return;
|
||||
|
||||
setIsRefining(true);
|
||||
try {
|
||||
const refined = await refineText(trimmed, type);
|
||||
onDescriptionChange(refined);
|
||||
setIsRefineMenuOpen(false);
|
||||
addToast("Description refined with AI", "success");
|
||||
if (descTextareaRef.current) {
|
||||
descTextareaRef.current.style.height = "auto";
|
||||
descTextareaRef.current.style.height = descTextareaRef.current.scrollHeight + "px";
|
||||
}
|
||||
} catch (err: any) {
|
||||
const errorMessage = getRefineErrorMessage(err);
|
||||
addToast(errorMessage, "error");
|
||||
} finally {
|
||||
setIsRefining(false);
|
||||
}
|
||||
}, [description, isRefining, addToast, onDescriptionChange]);
|
||||
|
||||
const handleToggleFavorite = useCallback(async (provider: string) => {
|
||||
const currentFavorites = favoriteProviders;
|
||||
const isFavorite = currentFavorites.includes(provider);
|
||||
const newFavorites = isFavorite
|
||||
? currentFavorites.filter((p) => p !== provider)
|
||||
: [provider, ...currentFavorites];
|
||||
|
||||
setFavoriteProviders(newFavorites);
|
||||
|
||||
try {
|
||||
await updateGlobalSettings({ favoriteProviders: newFavorites });
|
||||
} catch {
|
||||
setFavoriteProviders(currentFavorites);
|
||||
}
|
||||
}, [favoriteProviders]);
|
||||
|
||||
const availableDeps = tasks
|
||||
.filter((t) => !dependencies.includes(t.id))
|
||||
.sort((a, b) => {
|
||||
const cmp = b.createdAt.localeCompare(a.createdAt);
|
||||
if (cmp !== 0) return cmp;
|
||||
const aNum = parseInt(a.id.slice(a.id.lastIndexOf("-") + 1), 10) || 0;
|
||||
const bNum = parseInt(b.id.slice(b.id.lastIndexOf("-") + 1), 10) || 0;
|
||||
return bNum - aNum;
|
||||
});
|
||||
|
||||
const filteredDeps = depSearch
|
||||
? availableDeps.filter((t) =>
|
||||
t.id.toLowerCase().includes(depSearch.toLowerCase()) ||
|
||||
(t.title && t.title.toLowerCase().includes(depSearch.toLowerCase())) ||
|
||||
(t.description && t.description.toLowerCase().includes(depSearch.toLowerCase()))
|
||||
)
|
||||
: availableDeps;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="task-form"
|
||||
onDrop={handleDrop}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onPaste={handlePaste}
|
||||
>
|
||||
{/* Title field (edit mode only) */}
|
||||
{mode === "edit" && onTitleChange && (
|
||||
<div className="form-group">
|
||||
<label htmlFor="task-form-title">Title</label>
|
||||
<input
|
||||
ref={titleInputRef}
|
||||
id="task-form-title"
|
||||
type="text"
|
||||
className="modal-edit-input"
|
||||
placeholder="Task title"
|
||||
value={title || ""}
|
||||
onChange={(e) => onTitleChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description field */}
|
||||
<div className="form-group">
|
||||
<label htmlFor="task-form-description">Description</label>
|
||||
<div className="description-with-refine" ref={refineMenuRef}>
|
||||
<textarea
|
||||
ref={descTextareaRef}
|
||||
id="task-form-description"
|
||||
value={description}
|
||||
onChange={handleDescriptionInput}
|
||||
placeholder="What needs to be done?"
|
||||
rows={3}
|
||||
disabled={disabled || isRefining}
|
||||
/>
|
||||
{description.trim() && !disabled && (
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-sm refine-button ${isRefining ? "refine-button--loading" : ""}`}
|
||||
onClick={() => setIsRefineMenuOpen((prev) => !prev)}
|
||||
disabled={isRefining}
|
||||
data-testid="refine-button"
|
||||
title="Refine description with AI"
|
||||
>
|
||||
<Sparkles size={12} style={{ verticalAlign: "middle" }} />
|
||||
{isRefining ? "Refining..." : "Refine"}
|
||||
</button>
|
||||
)}
|
||||
{isRefineMenuOpen && (
|
||||
<div
|
||||
className="refine-menu refine-menu--modal"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="refine-menu-item" onClick={() => handleRefine("clarify")} data-testid="refine-clarify">
|
||||
<div className="refine-menu-item-title">Clarify</div>
|
||||
<div className="refine-menu-item-desc">Make the description clearer and more specific</div>
|
||||
</div>
|
||||
<div className="refine-menu-item" onClick={() => handleRefine("add-details")} data-testid="refine-add-details">
|
||||
<div className="refine-menu-item-title">Add details</div>
|
||||
<div className="refine-menu-item-desc">Add implementation details and context</div>
|
||||
</div>
|
||||
<div className="refine-menu-item" onClick={() => handleRefine("expand")} data-testid="refine-expand">
|
||||
<div className="refine-menu-item-title">Expand</div>
|
||||
<div className="refine-menu-item-desc">Expand into a more comprehensive description</div>
|
||||
</div>
|
||||
<div className="refine-menu-item" onClick={() => handleRefine("simplify")} data-testid="refine-simplify">
|
||||
<div className="refine-menu-item-title">Simplify</div>
|
||||
<div className="refine-menu-item-desc">Simplify and make more concise</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dependencies */}
|
||||
<div className="form-group">
|
||||
<label>Dependencies</label>
|
||||
<div className="dep-trigger-wrap" ref={depDropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm dep-trigger"
|
||||
onClick={() => setShowDepDropdown((v) => !v)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{dependencies.length > 0 ? `${dependencies.length} selected` : "Add dependencies"}
|
||||
</button>
|
||||
{showDepDropdown && (
|
||||
<div className="dep-dropdown">
|
||||
<input
|
||||
className="dep-dropdown-search"
|
||||
placeholder="Search tasks…"
|
||||
autoFocus
|
||||
value={depSearch}
|
||||
onChange={(e) => setDepSearch(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
{filteredDeps.length === 0 ? (
|
||||
<div className="dep-dropdown-empty">No available tasks</div>
|
||||
) : (
|
||||
filteredDeps.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`dep-dropdown-item${dependencies.includes(t.id) ? " selected" : ""}`}
|
||||
onClick={() => toggleDep(t.id)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<span className="dep-dropdown-id">{t.id}</span>
|
||||
<span className="dep-dropdown-title">{truncate(t.title || t.description || t.id, 30)}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{dependencies.length > 0 && (
|
||||
<div className="selected-deps">
|
||||
{dependencies.map((depId) => (
|
||||
<span key={depId} className="dep-chip">
|
||||
{depId}
|
||||
<button
|
||||
type="button"
|
||||
className="dep-chip-remove"
|
||||
onClick={() => toggleDep(depId)}
|
||||
disabled={disabled}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Model Selection */}
|
||||
<div className="form-group">
|
||||
<label>Model Configuration</label>
|
||||
{modelsLoading ? (
|
||||
<div className="model-selector-loading">Loading models…</div>
|
||||
) : availableModels.length === 0 ? (
|
||||
<small>No models available. Configure authentication in Settings.</small>
|
||||
) : (
|
||||
<>
|
||||
<div className="model-select-row">
|
||||
<label htmlFor="model-preset" className="model-select-label">Preset</label>
|
||||
<select
|
||||
id="model-preset"
|
||||
value={presetMode === "preset" ? selectedPresetId : presetMode}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "default") {
|
||||
onPresetModeChange("default");
|
||||
onSelectedPresetIdChange("");
|
||||
onExecutorModelChange("");
|
||||
onValidatorModelChange("");
|
||||
return;
|
||||
}
|
||||
if (value === "custom") {
|
||||
onPresetModeChange("custom");
|
||||
onSelectedPresetIdChange("");
|
||||
return;
|
||||
}
|
||||
const preset = availablePresets.find((entry) => entry.id === value);
|
||||
const selection = applyPresetToSelection(preset);
|
||||
onPresetModeChange("preset");
|
||||
onSelectedPresetIdChange(value);
|
||||
onExecutorModelChange(selection.executorValue);
|
||||
onValidatorModelChange(selection.validatorValue);
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
<option value="default">Use default</option>
|
||||
{availablePresets.length > 0 ? <option disabled>──────────</option> : null}
|
||||
{availablePresets.map((preset) => (
|
||||
<option key={preset.id} value={preset.id}>{preset.name}</option>
|
||||
))}
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
</div>
|
||||
{presetMode === "preset" && selectedPreset ? (
|
||||
<small>Using preset: {selectedPreset.name}</small>
|
||||
) : null}
|
||||
{presetMode === "preset" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => onPresetModeChange("custom")}
|
||||
disabled={disabled}
|
||||
>
|
||||
Override
|
||||
</button>
|
||||
) : null}
|
||||
<div className="model-select-row">
|
||||
<label htmlFor="executor-model" className="model-select-label">Executor</label>
|
||||
<CustomModelDropdown
|
||||
id="executor-model"
|
||||
label="Executor Model"
|
||||
value={executorModel}
|
||||
onChange={(value) => {
|
||||
onPresetModeChange("custom");
|
||||
onSelectedPresetIdChange("");
|
||||
onExecutorModelChange(value);
|
||||
}}
|
||||
models={availableModels}
|
||||
disabled={disabled || presetMode === "preset"}
|
||||
favoriteProviders={favoriteProviders}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
/>
|
||||
</div>
|
||||
<div className="model-select-row">
|
||||
<label htmlFor="validator-model" className="model-select-label">Validator</label>
|
||||
<CustomModelDropdown
|
||||
id="validator-model"
|
||||
label="Validator Model"
|
||||
value={validatorModel}
|
||||
onChange={(value) => {
|
||||
onPresetModeChange("custom");
|
||||
onSelectedPresetIdChange("");
|
||||
onValidatorModelChange(value);
|
||||
}}
|
||||
models={availableModels}
|
||||
disabled={disabled || presetMode === "preset"}
|
||||
favoriteProviders={favoriteProviders}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Workflow Steps */}
|
||||
<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.length > 0 && 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) => {
|
||||
onWorkflowStepsChange(
|
||||
e.target.checked
|
||||
? [...selectedWorkflowSteps, step.id]
|
||||
: selectedWorkflowSteps.filter((id) => id !== step.id)
|
||||
);
|
||||
}}
|
||||
disabled={disabled}
|
||||
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>
|
||||
))}
|
||||
<label
|
||||
key="browser-verification"
|
||||
className="checkbox-label"
|
||||
style={{ display: "flex", alignItems: "flex-start", gap: "8px" }}
|
||||
data-testid="browser-verification-checkbox"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedWorkflowSteps.includes("browser-verification")}
|
||||
onChange={(e) => {
|
||||
onWorkflowStepsChange(
|
||||
e.target.checked
|
||||
? [...selectedWorkflowSteps, "browser-verification"]
|
||||
: selectedWorkflowSteps.filter((id) => id !== "browser-verification")
|
||||
);
|
||||
}}
|
||||
disabled={disabled}
|
||||
style={{ marginTop: "2px" }}
|
||||
/>
|
||||
<div>
|
||||
<span style={{ fontWeight: 500, fontSize: "13px" }}>
|
||||
<Globe size={14} style={{ verticalAlign: "middle", marginRight: "4px" }} />
|
||||
Browser Verification
|
||||
</span>
|
||||
<div style={{ fontSize: "12px", color: "var(--text-secondary)", marginTop: "2px" }}>
|
||||
Verify web application functionality using browser automation (agent-browser)
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI-assisted creation (create mode only) */}
|
||||
{mode === "create" && (onPlanningMode || onSubtaskBreakdown) && (
|
||||
<div className="form-group">
|
||||
<label>AI-assisted creation</label>
|
||||
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
|
||||
{onPlanningMode && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => {
|
||||
const trimmed = description.trim();
|
||||
if (!trimmed) {
|
||||
addToast("Enter a description first", "error");
|
||||
return;
|
||||
}
|
||||
onClose?.();
|
||||
onPlanningMode(trimmed);
|
||||
}}
|
||||
disabled={disabled || !description.trim()}
|
||||
>
|
||||
Plan
|
||||
</button>
|
||||
)}
|
||||
{onSubtaskBreakdown && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => {
|
||||
const trimmed = description.trim();
|
||||
if (!trimmed) {
|
||||
addToast("Enter a description first", "error");
|
||||
return;
|
||||
}
|
||||
onClose?.();
|
||||
onSubtaskBreakdown(trimmed);
|
||||
}}
|
||||
disabled={disabled || !description.trim()}
|
||||
>
|
||||
Subtask
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<small>Use Plan for clarifying questions or Subtask to split the work into editable child tasks.</small>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Attachments */}
|
||||
<div className="form-group">
|
||||
<label>Attachments</label>
|
||||
{pendingImages.length > 0 && (
|
||||
<div className="inline-create-previews">
|
||||
{pendingImages.map((img, i) => (
|
||||
<div key={img.previewUrl} className="inline-create-preview">
|
||||
<img src={img.previewUrl} alt={img.file.name} />
|
||||
<button
|
||||
type="button"
|
||||
className="inline-create-preview-remove"
|
||||
onClick={() => removeImage(i)}
|
||||
disabled={disabled}
|
||||
title="Remove image"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
onImagesChange([
|
||||
...pendingImages,
|
||||
{ file, previewUrl: URL.createObjectURL(file) },
|
||||
]);
|
||||
e.target.value = "";
|
||||
}
|
||||
}}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={disabled}
|
||||
>
|
||||
Attach Screenshot
|
||||
</button>
|
||||
<small>You can also paste images or drag & drop</small>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ vi.mock("../../api", () => ({
|
||||
fetchWorkflowSteps: vi.fn().mockResolvedValue([]),
|
||||
refineText: vi.fn(),
|
||||
getRefineErrorMessage: vi.fn((err) => err?.message || "Failed to refine text. Please try again."),
|
||||
updateGlobalSettings: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
function makeTask(id: string): Task {
|
||||
@@ -49,6 +50,8 @@ function renderNewTaskModal(props = {}) {
|
||||
tasks: [] as Task[],
|
||||
onCreateTask: vi.fn().mockResolvedValue({ id: "FN-001" }),
|
||||
addToast: vi.fn(),
|
||||
onPlanningMode: vi.fn(),
|
||||
onSubtaskBreakdown: vi.fn(),
|
||||
};
|
||||
const mergedProps = { ...defaultProps, ...props };
|
||||
const result = render(<NewTaskModal {...mergedProps} />);
|
||||
|
||||
@@ -14,6 +14,27 @@ vi.mock("../../api", () => ({
|
||||
rejectPlan: vi.fn().mockResolvedValue({}),
|
||||
duplicateTask: vi.fn().mockResolvedValue({}),
|
||||
refineTask: vi.fn().mockResolvedValue({}),
|
||||
// TaskForm dependencies
|
||||
fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [] }),
|
||||
fetchSettings: vi.fn().mockResolvedValue({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {} }),
|
||||
fetchWorkflowSteps: vi.fn().mockResolvedValue([]),
|
||||
refineText: vi.fn(),
|
||||
getRefineErrorMessage: vi.fn((err: any) => err?.message || "Failed to refine"),
|
||||
updateGlobalSettings: vi.fn().mockResolvedValue({}),
|
||||
pauseTask: vi.fn().mockResolvedValue({}),
|
||||
unpauseTask: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
// Mock lucide-react icons used by TaskDetailModal, TaskForm, PrSection, CustomModelDropdown
|
||||
vi.mock("lucide-react", () => ({
|
||||
Pencil: () => null,
|
||||
Sparkles: () => null,
|
||||
Globe: () => null,
|
||||
GitPullRequest: () => null,
|
||||
ExternalLink: () => null,
|
||||
RefreshCw: () => null,
|
||||
Plus: () => null,
|
||||
MessageSquare: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useAgentLogs", () => ({
|
||||
@@ -2506,8 +2527,8 @@ describe("TaskDetailModal", () => {
|
||||
|
||||
// Edit button should be hidden now
|
||||
expect(container.querySelector(".modal-edit-btn")).toBeNull();
|
||||
// But input should be visible
|
||||
expect(container.querySelector(".modal-edit-input")).toBeTruthy();
|
||||
// But TaskForm title input should be visible
|
||||
expect(container.querySelector("#task-form-title")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("entering edit mode shows title input and description textarea", () => {
|
||||
@@ -2525,15 +2546,15 @@ describe("TaskDetailModal", () => {
|
||||
|
||||
// Initially shows title as h2
|
||||
expect(container.querySelector("h2.detail-title")).toBeTruthy();
|
||||
expect(container.querySelector(".modal-edit-input")).toBeNull();
|
||||
expect(container.querySelector("#task-form-title")).toBeNull();
|
||||
|
||||
// Enter edit mode
|
||||
fireEvent.click(container.querySelector(".modal-edit-btn")!);
|
||||
|
||||
// Now shows edit form
|
||||
// Now shows edit form with TaskForm fields
|
||||
expect(container.querySelector("h2.detail-title")).toBeNull();
|
||||
expect(container.querySelector(".modal-edit-input")).toBeTruthy();
|
||||
expect(container.querySelector(".modal-edit-textarea")).toBeTruthy();
|
||||
expect(container.querySelector("#task-form-title")).toBeTruthy();
|
||||
expect(container.querySelector("#task-form-description")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("clicking Cancel exits edit mode without saving", () => {
|
||||
@@ -2553,14 +2574,14 @@ describe("TaskDetailModal", () => {
|
||||
fireEvent.click(container.querySelector(".modal-edit-btn")!);
|
||||
|
||||
// Change values
|
||||
const titleInput = container.querySelector(".modal-edit-input") as HTMLInputElement;
|
||||
const titleInput = container.querySelector("#task-form-title") as HTMLInputElement;
|
||||
fireEvent.change(titleInput, { target: { value: "Modified title" } });
|
||||
|
||||
// Click Cancel
|
||||
fireEvent.click(screen.getByText("Cancel"));
|
||||
|
||||
// Should exit edit mode without saving
|
||||
expect(container.querySelector(".modal-edit-input")).toBeNull();
|
||||
expect(container.querySelector("#task-form-title")).toBeNull();
|
||||
expect(container.querySelector("h2.detail-title")?.textContent).toBe("Original title");
|
||||
});
|
||||
|
||||
@@ -2585,8 +2606,8 @@ describe("TaskDetailModal", () => {
|
||||
fireEvent.click(container.querySelector(".modal-edit-btn")!);
|
||||
|
||||
// Change values
|
||||
const titleInput = container.querySelector(".modal-edit-input") as HTMLInputElement;
|
||||
const descTextarea = container.querySelector(".modal-edit-textarea") as HTMLTextAreaElement;
|
||||
const titleInput = container.querySelector("#task-form-title") as HTMLInputElement;
|
||||
const descTextarea = container.querySelector("#task-form-description") as HTMLTextAreaElement;
|
||||
fireEvent.change(titleInput, { target: { value: "New title" } });
|
||||
fireEvent.change(descTextarea, { target: { value: "New description" } });
|
||||
|
||||
@@ -2594,14 +2615,14 @@ describe("TaskDetailModal", () => {
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdate).toHaveBeenCalledWith("FN-001", {
|
||||
expect(mockUpdate).toHaveBeenCalledWith("FN-001", expect.objectContaining({
|
||||
title: "New title",
|
||||
description: "New description",
|
||||
}, undefined);
|
||||
}), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it("Save button is disabled when no changes made", () => {
|
||||
it("Save button is enabled in edit mode", () => {
|
||||
const { container } = render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ id: "FN-001", column: "triage", title: "Test title", description: "Test description" })}
|
||||
@@ -2618,7 +2639,7 @@ describe("TaskDetailModal", () => {
|
||||
fireEvent.click(container.querySelector(".modal-edit-btn")!);
|
||||
|
||||
const saveButton = screen.getByText("Save");
|
||||
expect(saveButton.hasAttribute("disabled")).toBe(true);
|
||||
expect(saveButton.hasAttribute("disabled")).toBe(false);
|
||||
});
|
||||
|
||||
it("Save button shows 'Saving…' during save operation", async () => {
|
||||
@@ -2642,10 +2663,6 @@ describe("TaskDetailModal", () => {
|
||||
// Enter edit mode
|
||||
fireEvent.click(container.querySelector(".modal-edit-btn")!);
|
||||
|
||||
// Change value
|
||||
const titleInput = container.querySelector(".modal-edit-input") as HTMLInputElement;
|
||||
fireEvent.change(titleInput, { target: { value: "New title" } });
|
||||
|
||||
// Click Save
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
|
||||
@@ -2675,10 +2692,6 @@ describe("TaskDetailModal", () => {
|
||||
// Enter edit mode
|
||||
fireEvent.click(container.querySelector(".modal-edit-btn")!);
|
||||
|
||||
// Change value
|
||||
const titleInput = container.querySelector(".modal-edit-input") as HTMLInputElement;
|
||||
fireEvent.change(titleInput, { target: { value: "New title" } });
|
||||
|
||||
// Click Save
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
|
||||
@@ -2687,7 +2700,7 @@ describe("TaskDetailModal", () => {
|
||||
});
|
||||
|
||||
// Should exit edit mode
|
||||
expect(container.querySelector(".modal-edit-input")).toBeNull();
|
||||
expect(container.querySelector("#task-form-title")).toBeNull();
|
||||
});
|
||||
|
||||
it("failed save shows toast with error and stays in edit mode", async () => {
|
||||
@@ -2712,10 +2725,6 @@ describe("TaskDetailModal", () => {
|
||||
// Enter edit mode
|
||||
fireEvent.click(container.querySelector(".modal-edit-btn")!);
|
||||
|
||||
// Change value
|
||||
const titleInput = container.querySelector(".modal-edit-input") as HTMLInputElement;
|
||||
fireEvent.change(titleInput, { target: { value: "New title" } });
|
||||
|
||||
// Click Save
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
|
||||
@@ -2724,10 +2733,10 @@ describe("TaskDetailModal", () => {
|
||||
});
|
||||
|
||||
// Should stay in edit mode
|
||||
expect(container.querySelector(".modal-edit-input")).toBeTruthy();
|
||||
expect(container.querySelector("#task-form-title")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Escape key exits edit mode", () => {
|
||||
it("Escape key exits edit mode", async () => {
|
||||
const { container } = render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ id: "FN-001", column: "triage", title: "Test title" })}
|
||||
@@ -2742,17 +2751,19 @@ describe("TaskDetailModal", () => {
|
||||
|
||||
// Enter edit mode
|
||||
fireEvent.click(container.querySelector(".modal-edit-btn")!);
|
||||
expect(container.querySelector(".modal-edit-input")).toBeTruthy();
|
||||
expect(container.querySelector("#task-form-title")).toBeTruthy();
|
||||
|
||||
// Press Escape
|
||||
const titleInput = container.querySelector(".modal-edit-input") as HTMLInputElement;
|
||||
fireEvent.keyDown(titleInput, { key: "Escape" });
|
||||
// Press Escape (handled via document-level keydown listener)
|
||||
await act(async () => {
|
||||
const event = new KeyboardEvent("keydown", { key: "Escape", bubbles: true });
|
||||
document.dispatchEvent(event);
|
||||
});
|
||||
|
||||
// Should exit edit mode
|
||||
expect(container.querySelector(".modal-edit-input")).toBeNull();
|
||||
expect(container.querySelector("#task-form-title")).toBeNull();
|
||||
});
|
||||
|
||||
it("Enter in title input moves focus to description textarea", () => {
|
||||
it("edit mode shows both title and description fields", () => {
|
||||
const { container } = render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ id: "FN-001", column: "triage", title: "Test title", description: "Test description" })}
|
||||
@@ -2768,13 +2779,86 @@ describe("TaskDetailModal", () => {
|
||||
// Enter edit mode
|
||||
fireEvent.click(container.querySelector(".modal-edit-btn")!);
|
||||
|
||||
// Press Enter in title input
|
||||
const titleInput = container.querySelector(".modal-edit-input") as HTMLInputElement;
|
||||
fireEvent.keyDown(titleInput, { key: "Enter" });
|
||||
// Both title and description should be present in TaskForm
|
||||
expect(container.querySelector("#task-form-title")).toBeTruthy();
|
||||
expect(container.querySelector("#task-form-description")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Description textarea should be focused (we can check by seeing if the textarea exists)
|
||||
// Note: focus testing is limited in JSDOM, but we verify the handler doesn't error
|
||||
expect(container.querySelector(".modal-edit-textarea")).toBeTruthy();
|
||||
it("edit mode renders model configuration and workflow steps", () => {
|
||||
const { container } = render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ id: "FN-001", column: "triage", title: "Test task" })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Enter edit mode
|
||||
fireEvent.click(container.querySelector(".modal-edit-btn")!);
|
||||
|
||||
// Model configuration and workflow steps should be present via TaskForm
|
||||
expect(screen.getByText(/Model Configuration/i)).toBeTruthy();
|
||||
expect(screen.getByText(/Workflow Steps/i)).toBeTruthy();
|
||||
expect(screen.getByTestId("browser-verification-checkbox")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("save sends all changed fields via updateTask", async () => {
|
||||
const { updateTask } = await import("../../api");
|
||||
const mockUpdate = vi.mocked(updateTask);
|
||||
mockUpdate.mockResolvedValueOnce({ id: "FN-001" } as Task);
|
||||
|
||||
const { container } = render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ id: "FN-001", column: "triage", title: "Test", description: "Desc", dependencies: ["FN-002"] })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Enter edit mode
|
||||
fireEvent.click(container.querySelector(".modal-edit-btn")!);
|
||||
|
||||
// Click Save
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdate).toHaveBeenCalledWith("FN-001", expect.objectContaining({
|
||||
title: "Test",
|
||||
description: "Desc",
|
||||
dependencies: ["FN-002"],
|
||||
enabledWorkflowSteps: [],
|
||||
}), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it("pre-populates form with existing task values", () => {
|
||||
const { container } = render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ id: "FN-001", column: "todo", title: "My Task", description: "My Description" })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Enter edit mode
|
||||
fireEvent.click(container.querySelector(".modal-edit-btn")!);
|
||||
|
||||
const titleInput = container.querySelector("#task-form-title") as HTMLInputElement;
|
||||
const descTextarea = container.querySelector("#task-form-description") as HTMLTextAreaElement;
|
||||
expect(titleInput.value).toBe("My Task");
|
||||
expect(descTextarea.value).toBe("My Description");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
236
packages/dashboard/app/components/__tests__/TaskForm.test.tsx
Normal file
236
packages/dashboard/app/components/__tests__/TaskForm.test.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { TaskForm } from "../TaskForm";
|
||||
import type { Task, Column } from "@fusion/core";
|
||||
|
||||
// Mock lucide-react
|
||||
vi.mock("lucide-react", () => ({
|
||||
Sparkles: () => null,
|
||||
Globe: () => null,
|
||||
}));
|
||||
|
||||
// Mock the api module
|
||||
vi.mock("../../api", () => ({
|
||||
fetchModels: vi.fn().mockResolvedValue({ models: [
|
||||
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 },
|
||||
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
|
||||
], favoriteProviders: [] }),
|
||||
fetchSettings: vi.fn().mockResolvedValue({
|
||||
modelPresets: [],
|
||||
autoSelectModelPreset: false,
|
||||
defaultPresetBySize: {},
|
||||
}),
|
||||
fetchWorkflowSteps: vi.fn().mockResolvedValue([]),
|
||||
refineText: vi.fn().mockResolvedValue("Refined text"),
|
||||
getRefineErrorMessage: vi.fn((err) => err?.message || "Failed to refine text. Please try again."),
|
||||
updateGlobalSettings: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
function makeTask(id: string): Task {
|
||||
return {
|
||||
id,
|
||||
title: `Task ${id}`,
|
||||
description: `Description for ${id}`,
|
||||
column: "todo" as Column,
|
||||
status: undefined as any,
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
dependencies: [],
|
||||
log: [],
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
function renderTaskForm(props: Partial<React.ComponentProps<typeof TaskForm>> = {}) {
|
||||
const defaultProps: React.ComponentProps<typeof TaskForm> = {
|
||||
mode: "create",
|
||||
description: "",
|
||||
onDescriptionChange: vi.fn(),
|
||||
dependencies: [],
|
||||
onDependenciesChange: vi.fn(),
|
||||
executorModel: "",
|
||||
onExecutorModelChange: vi.fn(),
|
||||
validatorModel: "",
|
||||
onValidatorModelChange: vi.fn(),
|
||||
presetMode: "default" as const,
|
||||
onPresetModeChange: vi.fn(),
|
||||
selectedPresetId: "",
|
||||
onSelectedPresetIdChange: vi.fn(),
|
||||
selectedWorkflowSteps: [],
|
||||
onWorkflowStepsChange: vi.fn(),
|
||||
pendingImages: [],
|
||||
onImagesChange: vi.fn(),
|
||||
tasks: [],
|
||||
addToast: vi.fn(),
|
||||
isActive: true,
|
||||
};
|
||||
const mergedProps = { ...defaultProps, ...props };
|
||||
const result = render(<TaskForm {...mergedProps} />);
|
||||
return { ...result, props: mergedProps };
|
||||
}
|
||||
|
||||
// Mock URL.createObjectURL / revokeObjectURL
|
||||
globalThis.URL.createObjectURL = vi.fn(() => "blob:mock-url");
|
||||
globalThis.URL.revokeObjectURL = vi.fn();
|
||||
|
||||
describe("TaskForm", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders description field with AI refine button when text is present", () => {
|
||||
renderTaskForm({ description: "Some text" });
|
||||
|
||||
expect(screen.getByLabelText(/Description/i)).toBeTruthy();
|
||||
expect(screen.getByTestId("refine-button")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not show refine button when description is empty", () => {
|
||||
renderTaskForm({ description: "" });
|
||||
|
||||
expect(screen.getByLabelText(/Description/i)).toBeTruthy();
|
||||
expect(screen.queryByTestId("refine-button")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders dependency selector and can toggle dependencies", () => {
|
||||
const onDependenciesChange = vi.fn();
|
||||
const tasks = [makeTask("FN-001"), makeTask("FN-002")];
|
||||
|
||||
renderTaskForm({ tasks, onDependenciesChange });
|
||||
|
||||
const depButton = screen.getByRole("button", { name: "Add dependencies" });
|
||||
expect(depButton).toBeTruthy();
|
||||
|
||||
fireEvent.click(depButton);
|
||||
expect(screen.getByPlaceholderText("Search tasks…")).toBeTruthy();
|
||||
|
||||
// Click to select a task
|
||||
fireEvent.click(screen.getByText("FN-001"));
|
||||
expect(onDependenciesChange).toHaveBeenCalledWith(["FN-001"]);
|
||||
});
|
||||
|
||||
it("renders model configuration section", () => {
|
||||
renderTaskForm();
|
||||
|
||||
expect(screen.getByText(/Model Configuration/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders workflow step checkboxes with browser verification", () => {
|
||||
renderTaskForm();
|
||||
|
||||
expect(screen.getByTestId("browser-verification-checkbox")).toBeTruthy();
|
||||
expect(screen.getByText("Browser Verification")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("in create mode: shows Plan and Subtask buttons", () => {
|
||||
renderTaskForm({
|
||||
mode: "create",
|
||||
onPlanningMode: vi.fn(),
|
||||
onSubtaskBreakdown: vi.fn(),
|
||||
});
|
||||
|
||||
expect(screen.getByRole("button", { name: "Plan" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Subtask" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("in edit mode: hides Plan/Subtask buttons, shows title field", () => {
|
||||
renderTaskForm({
|
||||
mode: "edit",
|
||||
title: "My task",
|
||||
onTitleChange: vi.fn(),
|
||||
onPlanningMode: vi.fn(),
|
||||
onSubtaskBreakdown: vi.fn(),
|
||||
});
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Plan" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Subtask" })).toBeNull();
|
||||
expect(screen.getByLabelText(/Title/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("image paste adds to pending images", () => {
|
||||
const onImagesChange = vi.fn();
|
||||
const { container } = renderTaskForm({ onImagesChange });
|
||||
|
||||
const taskForm = container.querySelector(".task-form")!;
|
||||
const imageFile = new File(["fake"], "test.png", { type: "image/png" });
|
||||
|
||||
fireEvent.paste(taskForm, {
|
||||
clipboardData: {
|
||||
items: [
|
||||
{
|
||||
type: "image/png",
|
||||
getAsFile: () => imageFile,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(onImagesChange).toHaveBeenCalled();
|
||||
const newImages = onImagesChange.mock.calls[0][0];
|
||||
expect(newImages).toHaveLength(1);
|
||||
expect(newImages[0].file).toBe(imageFile);
|
||||
});
|
||||
|
||||
it("renders selected dependencies as chips", () => {
|
||||
renderTaskForm({ dependencies: ["FN-001", "FN-002"] });
|
||||
|
||||
expect(screen.getByText("FN-001")).toBeTruthy();
|
||||
expect(screen.getByText("FN-002")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows pending image previews", () => {
|
||||
const images = [
|
||||
{ file: new File(["fake"], "test.png", { type: "image/png" }), previewUrl: "blob:test" },
|
||||
];
|
||||
const { container } = renderTaskForm({ pendingImages: images });
|
||||
|
||||
expect(container.querySelector(".inline-create-previews")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("calls onWorkflowStepsChange when browser verification is toggled", () => {
|
||||
const onWorkflowStepsChange = vi.fn();
|
||||
renderTaskForm({ onWorkflowStepsChange });
|
||||
|
||||
const checkbox = screen.getByTestId("browser-verification-checkbox").querySelector('input[type="checkbox"]') as HTMLInputElement;
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
expect(onWorkflowStepsChange).toHaveBeenCalledWith(["browser-verification"]);
|
||||
});
|
||||
|
||||
it("disables all inputs when disabled prop is true", () => {
|
||||
renderTaskForm({
|
||||
disabled: true,
|
||||
description: "Some text",
|
||||
dependencies: ["FN-001"],
|
||||
});
|
||||
|
||||
const textarea = screen.getByLabelText(/Description/i) as HTMLTextAreaElement;
|
||||
expect(textarea.disabled).toBe(true);
|
||||
|
||||
// The dep button should be disabled
|
||||
const depButton = screen.getByRole("button", { name: "1 selected" });
|
||||
expect(depButton).toHaveProperty("disabled", true);
|
||||
});
|
||||
|
||||
it("calls AI refine when menu item is clicked", async () => {
|
||||
const { refineText } = await import("../../api");
|
||||
const onDescriptionChange = vi.fn();
|
||||
|
||||
renderTaskForm({
|
||||
description: "Some text to refine",
|
||||
onDescriptionChange,
|
||||
});
|
||||
|
||||
// Open refine menu
|
||||
fireEvent.click(screen.getByTestId("refine-button"));
|
||||
|
||||
// Click clarify
|
||||
fireEvent.click(screen.getByTestId("refine-clarify"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(refineText).toHaveBeenCalledWith("Some text to refine", "clarify");
|
||||
expect(onDescriptionChange).toHaveBeenCalledWith("Refined text");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user