/
alexefan136
/
flowstack
Обзор
Документация
Войти
/
alexefan136
/
flowstack
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
main
ui/src/components/tasks/CreateTaskModal.tsx
1 028 строк
34 KB
Alexander Efanov
upd fix
29 июл 2026, 23:26
29 июл 2026, 23:26
d8ac8ee
Код
Авторство
О чём код?
// src/components/tasks/CreateTaskModal.tsx import { AlertCircle, Bot, Calendar, Check, CheckCircle2, CheckSquare, ChevronDown, ChevronUp, Clock, Loader2, Plus, Repeat, Sparkles, Tag, Trash2, Workflow, X, Zap, } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useParams } from "react-router-dom"; import { useAgentStore } from "../../lib/stores/agent-store"; import { useAppStore } from "../../lib/stores/app-store"; import { useFlowStore } from "../../lib/stores/flow-store"; import { useSkillStore } from "../../lib/stores/skill-store"; import { useTaskStore } from "../../lib/stores/task-store"; import type { CreateTaskRequest, Subtask, Task, TaskPriority, TaskType, UpdateTaskRequest, } from "../../lib/types/task-types"; import { cn } from "../../lib/utils"; // ============================================================================ // Constants // ============================================================================ const PRIORITIES: { value: TaskPriority; label: string; color: string; bgClass: string; }[] = [ { value: "low", label: "Низкий", color: "text-text-muted", bgClass: "bg-(--glass-bg-default)", }, { value: "medium", label: "Средний", color: "text-accent-amber", bgClass: "bg-accent-amber/10", }, { value: "high", label: "Высокий", color: "text-accent-lime", bgClass: "bg-accent-lime-soft", }, { value: "urgent", label: "Срочный", color: "text-status-error", bgClass: "bg-status-error-soft", }, ]; const TASK_TYPES: { value: TaskType; label: string; description: string; icon: typeof Bot; }[] = [ { value: "simple", label: "Простая", description: "Ручная задача без автоматизации", icon: CheckCircle2, }, { value: "agent", label: "С агентом", description: "Автоматическое выполнение через AI-агента", icon: Bot, }, { value: "recurring", label: "Повторяющаяся", description: "Запускается по расписанию", icon: Repeat, }, ]; const RECURRENCE_PRESETS = [ { label: "Каждый день", pattern: "0 9 * * *" }, { label: "Каждую неделю (Пн)", pattern: "0 9 * * 1" }, { label: "Каждый месяц (1-е число)", pattern: "0 9 1 * *" }, { label: "Каждый час", pattern: "0 * * * *" }, ]; // ============================================================================ // Helpers // ============================================================================ /** Конвертирует ISO-дату в формат datetime-local */ function toDatetimeLocal(iso: string): string { const d = new Date(iso); const pad = (n: number) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; } /** Генерация id для подзадачи */ function genSubtaskId(): string { return `st-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; } // ============================================================================ // Sub-components // ============================================================================ /** * Type selector — segmented control для выбора типа задачи. */ function TypeSelector({ value, onChange, }: { value: TaskType; onChange: (type: TaskType) => void; }) { return ( <div className="grid grid-cols-3 gap-2"> {TASK_TYPES.map((type) => { const Icon = type.icon; const isSelected = value === type.value; return ( <button key={type.value} type="button" onClick={() => onChange(type.value)} className={cn( "relative flex flex-col items-center gap-2 p-3 rounded-xl border transition-all", isSelected ? "border-border-accent bg-accent-lime-soft shadow-lime" : "border-border-subtle bg-(--glass-bg-default) hover:bg-(--glass-bg-strong)", )} aria-pressed={isSelected} > <div className={cn( "w-10 h-10 rounded-lg flex items-center justify-center transition-transform", isSelected ? "gradient-primary shadow-lime" : "bg-(--glass-bg-strong)", )} > <Icon className={cn( "w-5 h-5", isSelected ? "text-text-on-accent" : "text-text-muted", )} /> </div> <div className="text-center"> <div className="text-xs font-medium text-text-primary"> {type.label} </div> <div className="text-[10px] text-text-muted leading-tight mt-0.5"> {type.description} </div> </div> {isSelected && ( <div className="absolute top-2 right-2 w-4 h-4 rounded-full gradient-primary flex items-center justify-center"> <CheckCircle2 className="w-3 h-3 text-text-on-accent" /> </div> )} </button> ); })} </div> ); } /** * Priority selector — compact buttons для выбора приоритета. */ function PrioritySelector({ value, onChange, }: { value: TaskPriority; onChange: (priority: TaskPriority) => void; }) { return ( <div className="flex gap-2"> {PRIORITIES.map((p) => ( <button key={p.value} type="button" onClick={() => onChange(p.value)} className={cn( "flex-1 btn btn-sm transition-all", value === p.value ? `${p.bgClass} ${p.color} border-border-accent` : "btn-ghost", )} aria-pressed={value === p.value} > {p.label} </button> ))} </div> ); } /** * Tags input — chip-based input для добавления тегов. */ function TagsInput({ tags, onChange, }: { tags: string[]; onChange: (tags: string[]) => void; }) { const [inputValue, setInputValue] = useState(""); const inputRef = useRef<HTMLInputElement>(null); const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { if (e.key === "Enter" && inputValue.trim()) { e.preventDefault(); if (!tags.includes(inputValue.trim())) { onChange([...tags, inputValue.trim()]); } setInputValue(""); } else if (e.key === "Backspace" && !inputValue && tags.length > 0) { onChange(tags.slice(0, -1)); } }; const removeTag = (tag: string) => { onChange(tags.filter((t) => t !== tag)); }; return ( <div className="flex flex-wrap gap-1.5 p-2 min-h-[42px] rounded-xl bg-(--glass-bg-default) border border-border-subtle focus-within:border-accent-lime focus-within:ring-2 focus-within:ring-accent-lime-soft transition cursor-text" onClick={() => inputRef.current?.focus()} > {tags.map((tag) => ( <span key={tag} className="inline-flex items-center gap-1 px-2 py-0.5 bg-accent-lime-soft text-accent-lime text-xs rounded-full" > {tag} <button type="button" onClick={(e) => { e.stopPropagation(); removeTag(tag); }} className="hover:bg-accent-lime/20 rounded-full p-0.5 transition" aria-label={`Удалить тег ${tag}`} > <X className="w-3 h-3" /> </button> </span> ))} <input ref={inputRef} value={inputValue} onChange={(e) => setInputValue(e.target.value)} onKeyDown={handleKeyDown} placeholder={tags.length === 0 ? "Добавить тег и Enter..." : ""} className="flex-1 min-w-[100px] bg-transparent outline-none text-sm text-text-primary placeholder:text-text-faint" /> </div> ); } /** * Collapsible section — для опциональных полей. */ function CollapsibleSection({ title, icon: Icon, isOpen, onToggle, badge, children, }: { title: string; icon: typeof Bot; isOpen: boolean; onToggle: () => void; badge?: string; children: React.ReactNode; }) { return ( <div className="border border-border-subtle rounded-xl overflow-hidden"> <button type="button" onClick={onToggle} className="w-full flex items-center justify-between p-3 hover:bg-(--glass-bg-default) transition-colors" aria-expanded={isOpen} > <div className="flex items-center gap-2"> <Icon className="w-4 h-4 text-text-accent" /> <span className="text-sm font-medium text-text-primary">{title}</span> {badge && ( <span className="text-[10px] px-1.5 py-0.5 bg-accent-lime-soft text-accent-lime rounded-full"> {badge} </span> )} </div> {isOpen ? ( <ChevronUp className="w-4 h-4 text-text-muted" /> ) : ( <ChevronDown className="w-4 h-4 text-text-muted" /> )} </button> {isOpen && ( <div className="p-3 pt-0 border-t border-border-subtle space-y-3"> {children} </div> )} </div> ); } // ============================================================================ // Main Component // ============================================================================ export function CreateTaskModal({ isOpen, onClose, initialText = "", chatId, task, }: { isOpen: boolean; onClose: () => void; initialText?: string; chatId?: string; task?: Task; }) { const { workspace = "default" } = useParams(); const { toast } = useAppStore(); const { createTask, updateTask } = useTaskStore(); const { agents, loadAgents } = useAgentStore(); const { skills, loadSkills } = useSkillStore(); const { flows, loadFlows } = useFlowStore(); const isEditMode = !!task; // ========================================================================== // Form state — инициализация из task (edit) или дефолты (create) // ========================================================================== const [title, setTitle] = useState(task?.title ?? initialText); const [description, setDescription] = useState(task?.description ?? ""); const [taskType, setTaskType] = useState<TaskType>(task?.type ?? "simple"); const [priority, setPriority] = useState<TaskPriority>( task?.priority ?? "medium", ); const [assignedAgentId, setAssignedAgentId] = useState<string | null>( task?.assigned_agent_id ?? null, ); const [skillId, setSkillId] = useState<string | null>(task?.skill_id ?? null); const [flowId, setFlowId] = useState<string | null>( task?.workflow_id ?? null, ); const [deadline, setDeadline] = useState( task?.deadline ? toDatetimeLocal(task.deadline) : "", ); const [tags, setTags] = useState<string[]>(task?.tags ?? []); const [estimatedMinutes, setEstimatedMinutes] = useState<number | null>( task?.estimated_duration_minutes ?? null, ); const [recurrencePattern, setRecurrencePattern] = useState( task?.recurrence_pattern ?? "", ); // Подзадачи / чеклист const [subtasks, setSubtasks] = useState<Subtask[]>(task?.subtasks ?? []); const [newSubtask, setNewSubtask] = useState(""); // UI state const [isSubmitting, setIsSubmitting] = useState(false); const [showAdvanced, setShowAdvanced] = useState( !!( task?.deadline || (task?.tags && task.tags.length > 0) || task?.estimated_duration_minutes ), ); const [showExecutor, setShowExecutor] = useState(true); const [showSchedule, setShowSchedule] = useState(true); const [showChecklist, setShowChecklist] = useState( (task?.subtasks?.length ?? 0) > 0, ); // ========================================================================== // Effects — загрузка данных + Escape // ========================================================================== useEffect(() => { if (isOpen) { loadAgents(workspace); loadSkills(workspace); loadFlows(); } }, [isOpen, workspace, loadAgents, loadSkills, loadFlows]); useEffect(() => { if (!isOpen) return; const handleEscape = (e: KeyboardEvent) => { if (e.key === "Escape" && !isSubmitting) onClose(); }; document.addEventListener("keydown", handleEscape); return () => document.removeEventListener("keydown", handleEscape); }, [isOpen, onClose, isSubmitting]); // ========================================================================== // Derived state // ========================================================================== const selectedAgent = useMemo( () => agents.find((a) => a.id === assignedAgentId), [agents, assignedAgentId], ); const selectedSkill = useMemo( () => skills.find((s) => s.id === skillId), [skills, skillId], ); const selectedFlow = useMemo( () => flows.find((f) => f.id === flowId), [flows, flowId], ); const completedSubtasks = useMemo( () => subtasks.filter((s) => s.completed).length, [subtasks], ); const canSubmit = useMemo(() => { if (!title.trim() || isSubmitting) return false; if (taskType === "agent" && !assignedAgentId) return false; if (taskType === "recurring" && !recurrencePattern) return false; return true; }, [title, isSubmitting, taskType, assignedAgentId, recurrencePattern]); const validationErrors = useMemo(() => { const errors: string[] = []; if (!title.trim()) errors.push("Введите название задачи"); if (taskType === "agent" && !assignedAgentId) errors.push("Выберите агента для выполнения"); if (taskType === "recurring" && !recurrencePattern) errors.push("Выберите расписание повторения"); return errors; }, [title, taskType, assignedAgentId, recurrencePattern]); // ========================================================================== // Subtask handlers // ========================================================================== const handleAddSubtask = useCallback(() => { const t = newSubtask.trim(); if (!t) return; setSubtasks((prev) => [ ...prev, { id: genSubtaskId(), title: t, completed: false }, ]); setNewSubtask(""); }, [newSubtask]); const handleToggleSubtask = useCallback((id: string) => { setSubtasks((prev) => prev.map((st) => st.id === id ? { ...st, completed: !st.completed } : st, ), ); }, []); const handleRemoveSubtask = useCallback((id: string) => { setSubtasks((prev) => prev.filter((st) => st.id !== id)); }, []); // ========================================================================== // Submit / Reset // ========================================================================== const handleSubmit = useCallback(async () => { if (!canSubmit) return; setIsSubmitting(true); try { const baseData = { title: title.trim(), description: description.trim(), priority, type: taskType, assigned_agent_id: taskType === "agent" ? (assignedAgentId ?? null) : null, skill_id: skillId, workflow_id: flowId, deadline: deadline ? new Date(deadline).toISOString() : null, tags: tags.length > 0 ? tags : [], estimated_duration_minutes: estimatedMinutes, recurrence_pattern: taskType === "recurring" ? recurrencePattern : null, subtasks: subtasks.length > 0 ? subtasks : [], }; if (isEditMode && task) { await updateTask(task.id, workspace, baseData as UpdateTaskRequest); toast("Задача обновлена", "success"); } else { await createTask(workspace, { ...baseData, chat_id: chatId, } as CreateTaskRequest); toast("Задача создана", "success"); } onClose(); } catch { toast( isEditMode ? "Не удалось обновить задачу" : "Не удалось создать задачу", "error", ); } finally { setIsSubmitting(false); } }, [ canSubmit, title, description, priority, taskType, assignedAgentId, skillId, flowId, deadline, tags, estimatedMinutes, recurrencePattern, subtasks, isEditMode, task, chatId, createTask, updateTask, workspace, toast, onClose, ]); const handleReset = useCallback(() => { setTitle(""); setDescription(""); setTaskType("simple"); setPriority("medium"); setAssignedAgentId(null); setSkillId(null); setFlowId(null); setDeadline(""); setTags([]); setEstimatedMinutes(null); setRecurrencePattern(""); setSubtasks([]); setNewSubtask(""); }, []); // ========================================================================== // Render // ========================================================================== if (!isOpen) return null; return ( <div className="fixed inset-0 bg-black/80 backdrop-blur-md z-50 flex items-center justify-center p-4 fadein"> <div className="glass-frosted rounded-2xl w-full max-w-2xl max-h-[90vh] shadow-2xl border border-border-strong overflow-hidden slide-up flex flex-col"> {/* Header */} <div className="flex items-center justify-between p-5 border-b border-border-subtle shrink-0"> <div className="flex items-center gap-3"> <div className="w-10 h-10 rounded-xl gradient-primary flex items-center justify-center shadow-lime"> {isEditMode ? ( <CheckSquare className="w-5 h-5 text-text-on-accent" aria-hidden="true" /> ) : ( <Plus className="w-5 h-5 text-text-on-accent" aria-hidden="true" /> )} </div> <div> <h3 className="text-lg font-semibold text-text-primary"> {isEditMode ? "Редактировать задачу" : "Создать задачу"} </h3> <p className="text-xs text-text-muted"> {taskType === "agent" ? "Автоматическое выполнение через AI" : taskType === "recurring" ? "Повторяющаяся задача" : "Простая ручная задача"} </p> </div> </div> <button onClick={onClose} disabled={isSubmitting} className="btn btn-ghost btn-icon btn-sm" aria-label="Закрыть" > <X className="w-5 h-5" /> </button> </div> {/* Body */} <div className="flex-1 overflow-y-auto p-5 space-y-5"> {/* Type selector */} <div> <label className="block text-xs text-text-secondary mb-2 font-medium"> Тип задачи </label> <TypeSelector value={taskType} onChange={setTaskType} /> </div> {/* Title */} <div> <label className="block text-xs text-text-secondary mb-1.5 font-medium"> Название * </label> <input type="text" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Например: Проанализировать код авторизации" className="input" autoFocus maxLength={200} /> {title.length > 150 && ( <p className="text-[10px] text-text-muted mt-1"> {title.length}/200 символов </p> )} </div> {/* Description */} <div> <label className="block text-xs text-text-secondary mb-1.5 font-medium"> Описание </label> <textarea value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Детали задачи, контекст, ожидаемый результат..." rows={4} className="input resize-none" maxLength={5000} /> </div> {/* Priority */} <div> <label className="block text-xs text-text-secondary mb-1.5 font-medium"> Приоритет </label> <PrioritySelector value={priority} onChange={setPriority} /> </div> {/* Executor — только для agent задач */} {taskType === "agent" && ( <CollapsibleSection title="Исполнитель" icon={Bot} isOpen={showExecutor} onToggle={() => setShowExecutor(!showExecutor)} badge={selectedAgent?.name} > <div> <label className="block text-xs text-text-secondary mb-1.5 font-medium"> Агент * </label> <select value={assignedAgentId || ""} onChange={(e) => setAssignedAgentId(e.target.value || null)} className="input" > <option value="">Выберите агента...</option> {agents.map((agent) => ( <option key={agent.id} value={agent.id}> {agent.name} — {agent.description || "Без описания"} </option> ))} </select> {selectedAgent && ( <p className="text-xs text-accent-lime mt-1.5 flex items-center gap-1"> <Zap className="w-3 h-3" /> Будет использован {selectedAgent.model} </p> )} </div> {assignedAgentId && ( <> <div> <label className="block text-xs text-text-secondary mb-1.5 font-medium"> Скилл (опционально) </label> <select value={skillId || ""} onChange={(e) => setSkillId(e.target.value || null)} className="input" > <option value="">Без скилла</option> {skills.map((skill) => ( <option key={skill.id} value={skill.id}> {skill.name} — {skill.description} </option> ))} </select> {selectedSkill && ( <p className="text-xs text-text-muted mt-1 flex items-center gap-1"> <Sparkles className="w-3 h-3" /> {selectedSkill.category} </p> )} </div> <div> <label className="block text-xs text-text-secondary mb-1.5 font-medium"> Воркфлоу (опционально) </label> <select value={flowId || ""} onChange={(e) => setFlowId(e.target.value || null)} className="input" > <option value="">Без воркфлоу</option> {flows.map((flow) => ( <option key={flow.id} value={flow.id}> {flow.name} — {flow.description || "Без описания"} </option> ))} </select> {selectedFlow && ( <p className="text-xs text-text-muted mt-1 flex items-center gap-1"> <Workflow className="w-3 h-3" /> {flowNodesCount(selectedFlow)} шагов </p> )} </div> </> )} </CollapsibleSection> )} {/* Scheduling — для recurring задач */} {taskType === "recurring" && ( <CollapsibleSection title="Расписание" icon={Repeat} isOpen={showSchedule} onToggle={() => setShowSchedule(!showSchedule)} > <div> <label className="block text-xs text-text-secondary mb-1.5 font-medium"> Быстрый выбор </label> <div className="grid grid-cols-2 gap-2"> {RECURRENCE_PRESETS.map((preset) => ( <button key={preset.pattern} type="button" onClick={() => setRecurrencePattern(preset.pattern)} className={cn( "btn btn-sm text-xs", recurrencePattern === preset.pattern ? "btn-glass-lime" : "btn-ghost", )} > {preset.label} </button> ))} </div> </div> <div> <label className="block text-xs text-text-secondary mb-1.5 font-medium"> Cron выражение (продвинутое) </label> <input type="text" value={recurrencePattern} onChange={(e) => setRecurrencePattern(e.target.value)} placeholder="0 9 * * *" className="input font-mono text-xs" /> <p className="text-[10px] text-text-muted mt-1"> Формат: минута час день месяц день_недели </p> </div> </CollapsibleSection> )} {/* Checklist / подзадачи */} <CollapsibleSection title="Чеклист" icon={CheckSquare} isOpen={showChecklist} onToggle={() => setShowChecklist(!showChecklist)} badge={ subtasks.length > 0 ? `${completedSubtasks}/${subtasks.length}` : undefined } > {subtasks.length > 0 && ( <div className="space-y-1.5"> {subtasks.map((st) => ( <div key={st.id} className="flex items-center gap-2 group"> <button type="button" onClick={() => handleToggleSubtask(st.id)} className={cn( "w-4 h-4 rounded border flex items-center justify-center shrink-0 transition", st.completed ? "bg-accent-lime border-accent-lime" : "border-border-strong hover:border-accent-lime", )} aria-label={st.completed ? "Снять отметку" : "Отметить"} > {st.completed && ( <Check className="w-3 h-3 text-text-on-accent" /> )} </button> <span className={cn( "flex-1 text-sm", st.completed && "line-through text-text-muted", )} > {st.title} </span> <button type="button" onClick={() => handleRemoveSubtask(st.id)} className="p-1 rounded text-text-muted hover:text-status-error opacity-0 group-hover:opacity-100 transition" aria-label="Удалить подзадачу" > <Trash2 className="w-3.5 h-3.5" /> </button> </div> ))} </div> )} <div className="flex items-center gap-2"> <input type="text" value={newSubtask} onChange={(e) => setNewSubtask(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); handleAddSubtask(); } }} placeholder="Новый пункт чеклиста... (Enter)" className="input flex-1" /> <button type="button" onClick={handleAddSubtask} disabled={!newSubtask.trim()} className="btn btn-ghost btn-icon btn-sm disabled:opacity-40" aria-label="Добавить пункт" > <Plus className="w-4 h-4" /> </button> </div> </CollapsibleSection> {/* Advanced section */} <CollapsibleSection title="Дополнительно" icon={ChevronDown} isOpen={showAdvanced} onToggle={() => setShowAdvanced(!showAdvanced)} badge={ tags.length > 0 || deadline || estimatedMinutes ? "заполнено" : undefined } > <div> <label className="block text-xs text-text-secondary mb-1.5 font-medium flex items-center gap-1"> <Tag className="w-3 h-3" /> Теги </label> <TagsInput tags={tags} onChange={setTags} /> </div> <div> <label className="block text-xs text-text-secondary mb-1.5 font-medium flex items-center gap-1"> <Calendar className="w-3 h-3" /> Дедлайн </label> <input type="datetime-local" value={deadline} onChange={(e) => setDeadline(e.target.value)} className="input" /> </div> <div> <label className="block text-xs text-text-secondary mb-1.5 font-medium flex items-center gap-1"> <Clock className="w-3 h-3" /> Оценка времени (минуты) </label> <input type="number" min={1} max={10080} value={estimatedMinutes ?? ""} onChange={(e) => setEstimatedMinutes( e.target.value ? parseInt(e.target.value) : null, ) } placeholder="Например: 60" className="input" /> {estimatedMinutes && estimatedMinutes >= 60 && ( <p className="text-[10px] text-text-muted mt-1"> ≈ {(estimatedMinutes / 60).toFixed(1)} ч </p> )} </div> </CollapsibleSection> {/* Validation errors */} {!canSubmit && validationErrors.length > 0 && title.trim() && ( <div className="flex items-start gap-2 p-3 bg-status-error-soft border border-status-error/30 rounded-xl"> <AlertCircle className="w-4 h-4 text-status-error shrink-0 mt-0.5" /> <ul className="text-xs text-status-error space-y-0.5"> {validationErrors.map((err, i) => ( <li key={i}>{err}</li> ))} </ul> </div> )} </div> {/* Footer */} <div className="flex items-center justify-between p-5 border-t border-border-subtle shrink-0 gap-3"> <button onClick={handleReset} disabled={isSubmitting} className="btn btn-ghost text-xs" > Очистить </button> <div className="flex items-center gap-2"> <button onClick={onClose} disabled={isSubmitting} className="btn btn-ghost" > Отмена </button> <button onClick={handleSubmit} disabled={!canSubmit} className="btn btn-primary" > {isSubmitting ? ( <> <Loader2 className="w-4 h-4 animate-spin" aria-hidden="true" /> {isEditMode ? "Сохраняем..." : "Создаём..."} </> ) : ( <> {isEditMode ? ( <Check className="w-4 h-4" aria-hidden="true" /> ) : ( <Plus className="w-4 h-4" aria-hidden="true" /> )} {isEditMode ? "Сохранить" : "Создать задачу"} </> )} </button> </div> </div> </div> </div> ); } // ============================================================================ // Local helper (используется в render) // ============================================================================ function flowNodesCount(flow: { definition?: { nodes?: unknown[] }; nodes?: unknown[]; }): number { return flow.definition?.nodes?.length || flow.nodes?.length || 0; }