Spaces:
Sleeping
Sleeping
| /** | |
| * 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<AgentRuntimePhase, { label: string; color: string; rgb: string; icon: string }> = { | |
| 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<ReturnType<typeof setInterval> | 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 ( | |
| <div style={{ display: "flex", alignItems: "center", gap: 0, width: "100%" }}> | |
| {PHASE_ORDER.map((p, i) => { | |
| const meta = PHASE_META[p]; | |
| const isDone = activeIdx > i; | |
| const isActive = activeIdx === i; | |
| return ( | |
| <div key={p} style={{ display: "flex", alignItems: "center", flex: 1, minWidth: 0 }}> | |
| <div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 3, flex: 1 }}> | |
| <div style={{ | |
| width: 16, height: 16, borderRadius: "50%", | |
| display: "flex", alignItems: "center", justifyContent: "center", | |
| background: isDone | |
| ? `rgba(${meta.rgb},.18)` | |
| : isActive | |
| ? `rgba(${meta.rgb},.12)` | |
| : "rgba(255,255,255,.04)", | |
| border: isDone | |
| ? `1.5px solid ${meta.color}` | |
| : isActive | |
| ? `1.5px solid rgba(${meta.rgb},.60)` | |
| : "1px solid rgba(255,255,255,.09)", | |
| color: isDone || isActive ? meta.color : "rgba(255,255,255,.18)", | |
| transition: "all .25s ease", | |
| animation: isActive && phase !== "DONE" && phase !== "ERROR" | |
| ? "trp-dot 1.6s ease-in-out infinite" : undefined, | |
| }}> | |
| {isDone ? ( | |
| <svg width={8} height={8} viewBox="0 0 24 24" fill="none" | |
| stroke={meta.color} strokeWidth={3.5} strokeLinecap="round"> | |
| <polyline points="20 6 9 17 4 12"/> | |
| </svg> | |
| ) : ( | |
| <span style={{ fontSize: "0.48rem", fontWeight: 700 }}>{i + 1}</span> | |
| )} | |
| </div> | |
| <span style={{ | |
| fontSize: "0.52rem", fontWeight: isActive ? 600 : 400, | |
| color: isDone | |
| ? `rgba(${meta.rgb},.65)` | |
| : isActive ? meta.color : "rgba(148,163,184,.22)", | |
| letterSpacing: ".03em", whiteSpace: "nowrap", | |
| transition: "color .2s", | |
| }}> | |
| {meta.label} | |
| </span> | |
| </div> | |
| {i < PHASE_ORDER.length - 1 && ( | |
| <div style={{ | |
| height: 1, width: "100%", maxWidth: 18, flexShrink: 0, | |
| background: isDone | |
| ? `rgba(${meta.rgb},.35)` : "rgba(255,255,255,.06)", | |
| transition: "background .3s", marginBottom: 16, | |
| }} /> | |
| )} | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| ); | |
| }); | |
| 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 ( | |
| <button | |
| onClick={onClick} | |
| title="Espandi pannello operativo" | |
| style={{ | |
| all: "unset", cursor: "pointer", | |
| display: "flex", alignItems: "center", gap: 7, | |
| padding: "6px 11px", borderRadius: 20, | |
| background: "rgba(12,14,28,.92)", | |
| border: `1px solid rgba(${meta.rgb},.25)`, | |
| boxShadow: `0 2px 12px rgba(0,0,0,.45), 0 0 0 1px rgba(${meta.rgb},.08)`, | |
| backdropFilter: "blur(8px)", WebkitBackdropFilter: "blur(8px)", | |
| animation: "trp-in .22s ease", | |
| WebkitTapHighlightColor: "transparent", | |
| } as React.CSSProperties} | |
| > | |
| <div style={{ | |
| width: 7, height: 7, borderRadius: "50%", | |
| background: meta.color, | |
| animation: phase !== "DONE" && phase !== "ERROR" | |
| ? "trp-dot 1.4s ease-in-out infinite" : undefined, | |
| flexShrink: 0, | |
| }} /> | |
| <span style={{ fontSize: ".72rem", color: "rgba(226,232,240,.78)", fontWeight: 500, maxWidth: 160, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}> | |
| {toolLabel} | |
| </span> | |
| <span style={{ fontSize: ".63rem", fontFamily: "ui-monospace,monospace", color: `rgba(${meta.rgb},.6)`, flexShrink: 0 }}> | |
| <ElapsedTimer startMs={startMs} /> | |
| </span> | |
| </button> | |
| ); | |
| }); | |
| 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<ReturnType<typeof setTimeout> | 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<string, unknown>); | |
| }, [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 ( | |
| <div style={{ | |
| position: "fixed", | |
| bottom: 84, // sopra la input bar | |
| right: 14, | |
| zIndex: Z_INDEX.NOTIFICATION, | |
| pointerEvents: "auto", | |
| }}> | |
| <CollapsedPill | |
| phase={phase} | |
| toolLabel={toolLabel} | |
| startMs={startMs} | |
| onClick={toggle} | |
| /> | |
| </div> | |
| ); | |
| } | |
| // ── Pannello espanso ────────────────────────────────────────────────────── | |
| return ( | |
| <div | |
| style={{ | |
| position: "fixed", | |
| bottom: 84, | |
| right: 14, | |
| width: 288, | |
| zIndex: Z_INDEX.NOTIFICATION, | |
| background: "rgba(10,12,26,.95)", | |
| border: `1px solid rgba(${phaseMeta.rgb},.20)`, | |
| borderRadius: 14, | |
| boxShadow: `0 8px 28px rgba(0,0,0,.55), 0 0 0 1px rgba(${phaseMeta.rgb},.06)`, | |
| backdropFilter: "blur(12px)", WebkitBackdropFilter: "blur(12px)", | |
| overflow: "hidden", | |
| animation: "trp-in .18s ease", | |
| pointerEvents: "auto", | |
| } as React.CSSProperties} | |
| > | |
| {/* ── Header ── */} | |
| <div style={{ | |
| padding: "9px 12px 7px", | |
| borderBottom: "1px solid rgba(255,255,255,.06)", | |
| display: "flex", alignItems: "center", gap: 7, | |
| }}> | |
| {/* Icona fase */} | |
| <div style={{ | |
| width: 22, height: 22, borderRadius: 7, flexShrink: 0, | |
| display: "flex", alignItems: "center", justifyContent: "center", | |
| background: `rgba(${phaseMeta.rgb},.12)`, | |
| border: `1px solid rgba(${phaseMeta.rgb},.28)`, | |
| animation: isLive && phase !== "DONE" && phase !== "ERROR" | |
| ? "trp-dot 1.6s ease-in-out infinite" : undefined, | |
| }}> | |
| <span style={{ fontSize: ".72rem", color: phaseMeta.color, lineHeight: 1 }}> | |
| {phaseMeta.icon} | |
| </span> | |
| </div> | |
| <div style={{ flex: 1, minWidth: 0 }}> | |
| <div style={{ fontSize: ".72rem", fontWeight: 600, color: "rgba(226,232,240,.85)", lineHeight: 1.3 }}> | |
| {isLive ? phaseMeta.label : "Completato"} | |
| </div> | |
| <div style={{ fontSize: ".60rem", color: `rgba(${phaseMeta.rgb},.55)`, fontFamily: "ui-monospace,monospace", lineHeight: 1.2, marginTop: 1 }}> | |
| <ElapsedTimer startMs={startMs} /> | |
| </div> | |
| </div> | |
| {/* Collapse button */} | |
| <button | |
| onClick={toggle} | |
| title="Comprimi" | |
| style={{ | |
| all: "unset", cursor: "pointer", | |
| width: 20, height: 20, borderRadius: 6, | |
| display: "flex", alignItems: "center", justifyContent: "center", | |
| color: "rgba(148,163,184,.4)", | |
| transition: "color .15s", | |
| WebkitTapHighlightColor: "transparent", | |
| } as React.CSSProperties} | |
| onMouseEnter={e => { (e.currentTarget as HTMLElement).style.color = "rgba(148,163,184,.8)"; }} | |
| onMouseLeave={e => { (e.currentTarget as HTMLElement).style.color = "rgba(148,163,184,.4)"; }} | |
| > | |
| <svg width={11} height={11} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round"> | |
| <polyline points="6 9 12 15 18 9"/> | |
| </svg> | |
| </button> | |
| </div> | |
| {/* ── Stepper fasi ── */} | |
| <div style={{ padding: "8px 12px 4px" }}> | |
| <PhaseStepper phase={phase} /> | |
| </div> | |
| {/* ── Tool attivo ── */} | |
| {isLive && runningStep && ( | |
| <div style={{ | |
| margin: "0 10px 8px", | |
| padding: "7px 10px", | |
| borderRadius: 10, | |
| background: `rgba(${runningRgb},.07)`, | |
| border: `1px solid rgba(${runningRgb},.18)`, | |
| animation: "trp-fade .18s ease", | |
| }}> | |
| <div style={{ display: "flex", alignItems: "center", gap: 7 }}> | |
| {/* Icona tool */} | |
| <div style={{ | |
| width: 20, height: 20, borderRadius: 6, flexShrink: 0, | |
| display: "flex", alignItems: "center", justifyContent: "center", | |
| background: `rgba(${runningRgb},.14)`, | |
| border: `1px solid rgba(${runningRgb},.30)`, | |
| color: runningColor, | |
| }}> | |
| {getToolIcon(runningStep.tool, 11)} | |
| </div> | |
| <div style={{ flex: 1, minWidth: 0 }}> | |
| <div style={{ fontSize: ".70rem", fontWeight: 500, color: "rgba(226,232,240,.80)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}> | |
| {toolLabel} | |
| </div> | |
| {runningStep.explanation && ( | |
| <div style={{ fontSize: ".60rem", color: `rgba(${runningRgb},.45)`, marginTop: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}> | |
| {runningStep.explanation.length > 52 | |
| ? runningStep.explanation.slice(0, 52) + "…" | |
| : runningStep.explanation} | |
| </div> | |
| )} | |
| </div> | |
| {/* Spinner */} | |
| <div style={{ | |
| width: 12, height: 12, flexShrink: 0, | |
| border: `1.5px solid rgba(${runningRgb},.18)`, | |
| borderTopColor: runningColor, | |
| borderRadius: "50%", | |
| animation: "trp-spin .8s linear infinite", | |
| }} /> | |
| </div> | |
| </div> | |
| )} | |
| {/* ── Timeline step completati (max 4) ── */} | |
| {doneSteps.length > 0 && ( | |
| <div style={{ padding: "0 10px 8px", display: "flex", flexDirection: "column", gap: 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 ( | |
| <div key={`${step.tool}-${i}`} style={{ display: "flex", gap: 7, alignItems: "flex-start", minHeight: 22 }}> | |
| {/* Linea verticale + dot */} | |
| <div style={{ display: "flex", flexDirection: "column", alignItems: "center", width: 14, flexShrink: 0, paddingTop: 2 }}> | |
| <div style={{ | |
| width: 12, height: 12, borderRadius: "50%", | |
| display: "flex", alignItems: "center", justifyContent: "center", | |
| background: `rgba(${sr},.08)`, | |
| border: `1px solid rgba(${sr},.22)`, | |
| }}> | |
| <svg width={6} height={6} viewBox="0 0 24 24" fill="none" | |
| stroke="#4ade80" strokeWidth={3.5} strokeLinecap="round"> | |
| <polyline points="20 6 9 17 4 12"/> | |
| </svg> | |
| </div> | |
| {!isLast && ( | |
| <div style={{ width: 1, flex: 1, minHeight: 4, background: "rgba(255,255,255,.06)", marginTop: 2 }} /> | |
| )} | |
| </div> | |
| {/* Label */} | |
| <div style={{ flex: 1, minWidth: 0, paddingBottom: isLast ? 0 : 6 }}> | |
| <span style={{ | |
| fontSize: ".66rem", | |
| color: "rgba(148,163,184,.42)", | |
| overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", | |
| display: "block", | |
| }}> | |
| {narrativeLabel(step.tool, (step.args ?? {}) as Record<string, unknown>) | |
| .replace(/…$/, "")} | |
| </span> | |
| </div> | |
| {/* Durata */} | |
| {step.durationMs != null && ( | |
| <span style={{ fontSize: ".57rem", fontFamily: "ui-monospace,monospace", color: "rgba(100,116,139,.30)", flexShrink: 0, paddingTop: 1 }}> | |
| {step.durationMs < 1000 | |
| ? `${step.durationMs}ms` | |
| : `${(step.durationMs / 1000).toFixed(1)}s`} | |
| </span> | |
| )} | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| )} | |
| {/* ── Reasoning snippet ── */} | |
| {reasoningSnippet && !isLive && ( | |
| <div style={{ | |
| margin: "0 10px 9px", | |
| padding: "6px 9px", | |
| borderRadius: 8, | |
| background: "rgba(255,255,255,.025)", | |
| border: "1px solid rgba(255,255,255,.06)", | |
| }}> | |
| <div style={{ fontSize: ".60rem", color: "#4a4a6a", marginBottom: 3, textTransform: "uppercase", letterSpacing: ".06em", fontWeight: 600 }}> | |
| Risultato | |
| </div> | |
| <div style={{ fontSize: ".66rem", color: "rgba(148,163,184,.55)", lineHeight: 1.55 }}> | |
| {reasoningSnippet} | |
| </div> | |
| </div> | |
| )} | |
| {/* ── Progress bar live ── */} | |
| {isLive && ( | |
| <div style={{ height: 2, background: "rgba(255,255,255,.04)", position: "relative", overflow: "hidden" }}> | |
| <div style={{ | |
| position: "absolute", top: 0, left: 0, height: "100%", | |
| background: `linear-gradient(90deg, rgba(${phaseMeta.rgb},.0), rgba(${phaseMeta.rgb},.7), rgba(${phaseMeta.rgb},.0))`, | |
| width: "40%", | |
| animation: "trp-bar 1.8s ease-in-out infinite alternate", | |
| }} /> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| export default memo(TaskRuntimePanel); | |