/** * TaskRuntimePanel.tsx — GAP-4: Visualizzazione Operativa * * Mostra in tempo reale COSA sta facendo l'agente: * - Fase corrente (PLANNING → EXECUTING → VERIFYING → DONE) * - Tool/azione live con label narrativa contestuale * - Timer elapsed * - Timeline compatta step completati (max 4 visibili) * - Reasoning sintetico dell'ultimo step * * Design: * - Fixed bottom-right, compatto, non invasivo, non overlay sulla chat * - Visibile solo quando task RUNNING o QUEUED — sparisce altrimenti * - Collassabile con click: mostra solo pill di stato * - Mobile-first, Safari iOS safe (AbortController + setTimeout, no AbortSignal.timeout) * - Zero nuove dipendenze: usa useAgentSSE + useTaskStore + selectActiveTask * * Nessun side effect globale. Cleanup obbligatorio su useEffect. */ import { memo, useState, useEffect, useRef, useMemo, useCallback } from "react"; import type * as React from "react"; import { useTaskStore, selectActiveTask } from "@/store/taskStore"; import type { AgentRuntimePhase } from "@/store/taskStore"; import { useAgentSSE } from "@/hooks/useAgentSSE"; import { TOOL_COLORS, getToolIcon } from "@/components/chat/toolIcons"; import { narrativeLabel } from "@/lib/agentLoop/toolStatusLabels"; import { Z_INDEX } from "@/lib/zindex"; // ─── Keyframes (una volta sola) ─────────────────────────────────────────────── let _kfInjected = false; function _injectKf() { if (_kfInjected || typeof document === "undefined") return; _kfInjected = true; const el = document.createElement("style"); el.dataset.id = "trp-kf"; el.textContent = ` @keyframes trp-in { from{opacity:0;transform:translateY(8px)} to{opacity:1;transform:none} } @keyframes trp-out { from{opacity:1;transform:none} to{opacity:0;transform:translateY(8px)} } @keyframes trp-dot { 0%,100%{opacity:1;transform:scale(1)} 50%{opacity:.3;transform:scale(.5)} } @keyframes trp-spin { from{transform:rotate(0deg)} to{transform:rotate(360deg)} } @keyframes trp-bar { from{width:0%} to{width:100%} } @keyframes trp-fade { from{opacity:0;transform:translateX(-4px)} to{opacity:1;transform:none} } `; document.head.appendChild(el); } // ─── Costanti ───────────────────────────────────────────────────────────────── const PHASE_META: Record = { IDLE: { label: "In attesa", color: "#6b7280", rgb: "107,114,128", icon: "○" }, PLANNING: { label: "Pianifica", color: "#818cf8", rgb: "129,140,248", icon: "◈" }, EXECUTING: { label: "In esecuzione", color: "#34d399", rgb: "52,211,153", icon: "▶" }, VERIFYING: { label: "Verifica", color: "#fbbf24", rgb: "251,191,36", icon: "◎" }, DONE: { label: "Completato", color: "#4ade80", rgb: "74,222,128", icon: "✓" }, ERROR: { label: "Errore", color: "#f87171", rgb: "248,113,113", icon: "✕" }, }; const INTERNAL_TOOLS = new Set([ "__plan__", "__thinking__", "__verify__", "__belief_revision__", "__plan_tracker__", ]); // ─── Elapsed timer (Safari-safe, nessun AbortSignal.timeout) ───────────────── const ElapsedTimer = memo(({ startMs }: { startMs: number }) => { const [elapsed, setElapsed] = useState(0); const timerRef = useRef | null>(null); useEffect(() => { setElapsed(Date.now() - startMs); timerRef.current = setInterval(() => { setElapsed(Date.now() - startMs); }, 200); return () => { if (timerRef.current !== null) { clearInterval(timerRef.current); timerRef.current = null; } }; }, [startMs]); if (elapsed < 1000) return <>{elapsed}ms; if (elapsed < 60_000) return <>{(elapsed / 1000).toFixed(1)}s; const m = Math.floor(elapsed / 60_000); const s = Math.floor((elapsed % 60_000) / 1000); return <>{m}m{s > 0 ? ` ${s}s` : ""}; }); ElapsedTimer.displayName = "ElapsedTimer"; // ─── Barra di fase compatta ─────────────────────────────────────────────────── const PHASE_ORDER: AgentRuntimePhase[] = ["PLANNING", "EXECUTING", "VERIFYING", "DONE"]; const PhaseStepper = memo(({ phase }: { phase: AgentRuntimePhase }) => { const activeIdx = PHASE_ORDER.indexOf(phase); return (
{PHASE_ORDER.map((p, i) => { const meta = PHASE_META[p]; const isDone = activeIdx > i; const isActive = activeIdx === i; return (
{isDone ? ( ) : ( {i + 1} )}
{meta.label}
{i < PHASE_ORDER.length - 1 && (
)}
); })}
); }); PhaseStepper.displayName = "PhaseStepper"; // ─── Pill compatta (stato collassato) ───────────────────────────────────────── const CollapsedPill = memo(({ phase, toolLabel, startMs, onClick }: { phase: AgentRuntimePhase; toolLabel: string; startMs: number; onClick: () => void; }) => { const meta = PHASE_META[phase]; return ( ); }); CollapsedPill.displayName = "CollapsedPill"; // ─── Componente principale ──────────────────────────────────────────────────── function TaskRuntimePanel() { _injectKf(); const activeTask = useTaskStore(selectActiveTask); const { steps, isLive } = useAgentSSE(activeTask?.id ?? undefined); const [collapsed, setCollapsed] = useState(true); const [visible, setVisible] = useState(false); const fadeTimerRef = useRef | null>(null); // Apri/chiudi pannello in base a isLive useEffect(() => { if (fadeTimerRef.current !== null) { clearTimeout(fadeTimerRef.current); fadeTimerRef.current = null; } if (isLive) { setVisible(true); } else { // Ritardo sparizione: mostra "Completato" per 2.5s fadeTimerRef.current = setTimeout(() => { setVisible(false); fadeTimerRef.current = null; }, 2500); } return () => { if (fadeTimerRef.current !== null) { clearTimeout(fadeTimerRef.current); fadeTimerRef.current = null; } }; }, [isLive]); const toggle = useCallback(() => setCollapsed(c => !c), []); // Step visibili (no internal) ordinati const visibleSteps = useMemo( () => steps.filter(s => !INTERNAL_TOOLS.has(s.tool)), [steps], ); const runningStep = useMemo( () => visibleSteps.find(s => s.status === "running") ?? null, [visibleSteps], ); const doneSteps = useMemo( () => visibleSteps.filter(s => s.status === "done").slice(-4), [visibleSteps], ); // Fase corrente const phase: AgentRuntimePhase = useMemo(() => { if (!activeTask) return "IDLE"; if (activeTask.runtimePhase) return activeTask.runtimePhase; if (!isLive) return "DONE"; if (runningStep) { const t = runningStep.tool; if (["__plan__", "planning", "plan", "__thinking__", "__belief_revision__"].includes(t)) return "PLANNING"; if (["__verify__", "goal_verifier", "_runtime_verifier", "_build_validator"].includes(t)) return "VERIFYING"; return "EXECUTING"; } return "EXECUTING"; }, [activeTask, isLive, runningStep]); const phaseMeta = PHASE_META[phase]; // Label strumento attivo con narrativa contestuale const toolLabel = useMemo(() => { if (!isLive) return "Completato"; if (!runningStep) return phaseMeta.label; return narrativeLabel(runningStep.tool, (runningStep.args ?? {}) as Record); }, [isLive, runningStep, phaseMeta.label]); // Color dello step running const runningColor = runningStep ? (TOOL_COLORS[runningStep.tool] ?? phaseMeta.color) : phaseMeta.color; const runningRgb = runningColor.startsWith("#") ? `${parseInt(runningColor.slice(1,3),16)},${parseInt(runningColor.slice(3,5),16)},${parseInt(runningColor.slice(5,7),16)}` : phaseMeta.rgb; // Snippet reasoning dell'ultimo step done const reasoningSnippet = useMemo(() => { const last = doneSteps[doneSteps.length - 1]; if (!last) return null; const r = typeof last.result === "string" ? last.result : ""; const clean = r.replace(/^(✅|⚠️|❌)\s*(VERIFICATO|VERIFICA|ERRORE):\s*/i, "").trim(); return clean.length > 6 ? (clean.length > 70 ? clean.slice(0, 70) + "…" : clean) : null; }, [doneSteps]); const startMs = activeTask?.startedAt ?? Date.now(); if (!visible || !activeTask) return null; // ── Pill collassata ────────────────────────────────────────────────────── if (collapsed) { return (
); } // ── Pannello espanso ────────────────────────────────────────────────────── return (
{/* ── Header ── */}
{/* Icona fase */}
{phaseMeta.icon}
{isLive ? phaseMeta.label : "Completato"}
{/* Collapse button */}
{/* ── Stepper fasi ── */}
{/* ── Tool attivo ── */} {isLive && runningStep && (
{/* Icona tool */}
{getToolIcon(runningStep.tool, 11)}
{toolLabel}
{runningStep.explanation && (
{runningStep.explanation.length > 52 ? runningStep.explanation.slice(0, 52) + "…" : runningStep.explanation}
)}
{/* Spinner */}
)} {/* ── Timeline step completati (max 4) ── */} {doneSteps.length > 0 && (
{doneSteps.map((step, i) => { const isLast = i === doneSteps.length - 1; const sc = TOOL_COLORS[step.tool] ?? "#818cf8"; const sr = sc.startsWith("#") ? `${parseInt(sc.slice(1,3),16)},${parseInt(sc.slice(3,5),16)},${parseInt(sc.slice(5,7),16)}` : "129,140,248"; return (
{/* Linea verticale + dot */}
{!isLast && (
)}
{/* Label */}
{narrativeLabel(step.tool, (step.args ?? {}) as Record) .replace(/…$/, "")}
{/* Durata */} {step.durationMs != null && ( {step.durationMs < 1000 ? `${step.durationMs}ms` : `${(step.durationMs / 1000).toFixed(1)}s`} )}
); })}
)} {/* ── Reasoning snippet ── */} {reasoningSnippet && !isLive && (
Risultato
{reasoningSnippet}
)} {/* ── Progress bar live ── */} {isLive && (
)}
); } export default memo(TaskRuntimePanel);