import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { Plus, Trash2, ChevronUp, ChevronDown, AlertTriangle, Bot } from "lucide-react"; import type { WorkflowIrColumn, WorkflowColumnAgent, TraitViolation } from "@fusion/core"; import { fetchTraits, fetchAgents, type TraitCatalogEntry } from "../api"; import type { Agent } from "../api"; import { getErrorMessage } from "@fusion/core"; import type { ToastType } from "../hooks/useToast"; interface WorkflowColumnPanelProps { columns: WorkflowIrColumn[]; onChange: (next: WorkflowIrColumn[]) => void; /** Column-level composition violations (from validateColumnTraits) to surface * on the offending column band. Keyed by column id; workflow-wide violations * (columnId === null) are shown at the panel head. */ violations: TraitViolation[]; readOnly: boolean; projectId?: string; addToast: (message: string, type?: ToastType) => void; /** Always true for the graduated workflow-column runtime. Retained as a prop * while older call sites/tests converge on the always-on model. */ columnAgentsEnabled: boolean; } let columnSeq = 0; function newColumnId(): string { columnSeq += 1; return `col-${Date.now().toString(36)}-${columnSeq}`; } export function WorkflowColumnPanel({ columns, onChange, violations, readOnly, projectId, addToast, columnAgentsEnabled, }: WorkflowColumnPanelProps) { const { t } = useTranslation("app"); const [catalog, setCatalog] = useState([]); const [agents, setAgents] = useState([]); const [agentsLoading, setAgentsLoading] = useState(true); const [agentsError, setAgentsError] = useState(null); useEffect(() => { let cancelled = false; fetchTraits(projectId) .then((catalog) => { if (!cancelled) setCatalog(catalog); }) .catch((err) => { if (!cancelled) addToast(getErrorMessage(err) || t("workflowColumns.traitsLoadFailed", "Failed to load traits"), "error"); }); return () => { cancelled = true; }; }, [projectId, addToast, t]); // Eagerly load the agent registry for the per-column picker (R11). Mirrors the // fetchTraits-on-mount pattern above (cancelled guard + toast), but ALSO keeps // an inline error near the picker rather than only a toast, so a failed fetch // is visible at the point of use. useEffect(() => { let cancelled = false; setAgentsLoading(true); setAgentsError(null); // Promise.resolve guards against test mocks that return undefined. Promise.resolve(fetchAgents(undefined, projectId)) .then((list) => { if (cancelled) return; setAgents(list ?? []); setAgentsLoading(false); }) .catch((err) => { if (cancelled) return; const message = getErrorMessage(err) || t("workflowColumns.agentsLoadFailed", "Failed to load agents"); setAgentsError(message); setAgentsLoading(false); addToast(message, "error"); }); return () => { cancelled = true; }; }, [projectId, addToast, t]); // Key derived agent lookups on the joined id string, never on array identity — // SWR/dedupe can hand back a fresh array with identical ids and we must not // churn selection/derived state on that (skill-autocomplete SWR learning). const agentIdsKey = useMemo(() => agents.map((a) => a.id).join(","), [agents]); const agentById = useMemo(() => { const map = new Map(); for (const a of agents) map.set(a.id, a); return map; // Keyed on the joined id string (not array identity) per the SWR-identity // learning: a fresh array with identical ids must not churn derived state. // (exhaustive-deps is not enforced in this package; the omission of `agents` // from the dep array is intentional — agentIdsKey is the stable identity.) }, [agentIdsKey]); const setColumnAgent = useCallback( (id: string, agent: WorkflowColumnAgent | undefined) => { onChange( columns.map((c) => { if (c.id !== id) return c; if (!agent) { // Clearing to "(none)" REMOVES the key entirely — never write // `agent: null` (R9 parity: omitted-when-unset). const { agent: _omit, ...rest } = c; return rest; } return { ...c, agent }; }), ); }, [columns, onChange], ); const selectColumnAgentId = useCallback( (id: string, agentId: string) => { if (!agentId) { setColumnAgent(id, undefined); return; } const existing = columns.find((c) => c.id === id)?.agent; // Preserve an existing mode; default new selections to "defer" (the less // surprising mode). setColumnAgent(id, { agentId, mode: existing?.mode ?? "defer" }); }, [columns, setColumnAgent], ); const setColumnAgentMode = useCallback( (id: string, mode: "defer" | "override") => { const existing = columns.find((c) => c.id === id)?.agent; if (!existing) return; setColumnAgent(id, { ...existing, mode }); }, [columns, setColumnAgent], ); // `!!agentsError` (PR #1432 review): when the registry fetch failed, the select // would render enabled with only "(none)" while the bound id has no matching // option — interacting with it could silently clear a binding. Disabled while // the registry is unavailable, consistent with the loading guard. const agentPickerDisabled = readOnly || !columnAgentsEnabled || agentsLoading || !!agentsError; const workflowWide = violations.filter((v) => v.columnId === null); const violationsFor = useCallback( (columnId: string) => violations.filter((v) => v.columnId === columnId), [violations], ); const addColumn = useCallback(() => { const id = newColumnId(); onChange([...columns, { id, name: t("workflowColumns.newColumnName", "New column"), traits: [] }]); }, [columns, onChange, t]); const renameColumn = useCallback( (id: string, name: string) => { onChange(columns.map((c) => (c.id === id ? { ...c, name } : c))); }, [columns, onChange], ); const removeColumn = useCallback( (id: string) => { onChange(columns.filter((c) => c.id !== id)); }, [columns, onChange], ); const moveColumn = useCallback( (index: number, dir: -1 | 1) => { const target = index + dir; if (target < 0 || target >= columns.length) return; const next = [...columns]; [next[index], next[target]] = [next[target], next[index]]; onChange(next); }, [columns, onChange], ); const toggleTrait = useCallback( (columnId: string, traitId: string) => { onChange( columns.map((c) => { if (c.id !== columnId) return c; const has = c.traits.some((tr) => tr.trait === traitId); return { ...c, traits: has ? c.traits.filter((tr) => tr.trait !== traitId) : [...c.traits, { trait: traitId }], }; }), ); }, [columns, onChange], ); return ( ); }