AUDIT / src /components /task /TaskRuntimePanel.tsx
Arypulka98's picture
feat(audit): deploy full backend cluster node (part 2)
cc11e77 verified
Raw
History Blame Contribute Delete
22.1 kB
/**
* 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);