AUDIT / src /components /ChatMessage.tsx
Arypulka98's picture
feat(audit): deploy full backend cluster node
aea470f verified
Raw
History Blame Contribute Delete
37.5 kB
/**
* ChatMessage.tsx — Layout stile Replit Agent (S440)
*
* Flusso agente:
* 1. AgentNarration ← status animato live (solo durante streaming)
* 2. AgentStepCards ← live ticker | done: chip row + log panel card
* 3. Divider ← "Risposta" (se ci sono sia step che contenuto)
* 4. MarkdownRender ← risposta finale in streaming
* 5. SuggestChips ← prossimi step suggeriti (dopo completamento)
* 6. Footer ← copia / riprova
*/
import { useState, memo, useMemo, useCallback, useRef, useEffect } from "react";
import { useIsMobile } from "@/hooks/use-mobile";
import { Message } from "@/lib/storage";
import { Z_INDEX } from "@/lib/zindex";
import { normalizeLlmText } from "@/lib/utils/normalizeLlmOutput";
import { useTaskStore, selectActiveTask } from "@/store/taskStore";
import { DownloadProjectButton } from "./chat/DownloadProjectButton";
import { useAgentSSE } from "@/hooks/useAgentSSE";
import SuggestChips from "./chat/SuggestChips";
import TypingIndicator from "./chat/TypingIndicator";
import MarkdownRender, { parseBlocks } from "./chat/MarkdownRender";
import { AgentActionCard } from "./AgentActionCard";
import AgentNarration from "./chat/AgentNarration";
import AgentStepCards from "./chat/AgentStepCards";
import AgentPhaseBar from "./chat/AgentPhaseBar";
import StreamingPlanBubble from "./chat/StreamingPlanBubble";
import PlanReplayBubble, { type PlanReplayItem } from "./chat/PlanReplayBubble";
// ── U2: QuickWins — estrae il marker <!--QWINS:...→ dal contenuto e renderizza collassabile ──
interface _QW { c: string; s: string; e: string; i: string }
const _QWINS_RE = /\n<!--QWINS:([\s\S]*?)-->/;
function _extractQuickWins(content: string): { text: string; wins: _QW[] } {
const m = content?.match(_QWINS_RE);
if (!m) return { text: content ?? "", wins: [] };
try {
const wins = JSON.parse(m[1]) as _QW[];
return { text: content.replace(_QWINS_RE, "").trim(), wins: Array.isArray(wins) ? wins : [] };
} catch { return { text: content.replace(_QWINS_RE, "").trim(), wins: [] }; }
}
const _QW_ICON: Record<string, string> = {
performance: "⚡", reliability: "🛡️", ux: "✨", architecture: "🏗️",
};
const QuickWinsSection = function QuickWinsSection({ wins }: { wins: _QW[] }) {
const [open, setOpen] = useState(false);
if (!wins.length) return null;
return (
<div style={{ marginTop: 10, borderRadius: 8, overflow: "hidden",
border: "1px solid rgba(99,102,241,0.18)", background: "rgba(15,15,35,0.4)" }}>
<button
onClick={() => setOpen(o => !o)}
style={{
all: "unset" as never, cursor: "pointer", width: "100%",
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "6px 10px",
fontSize: "0.70rem", fontWeight: 600, letterSpacing: "0.04em",
color: "rgba(160,160,220,0.75)",
fontFamily: '"JetBrains Mono", ui-monospace, monospace',
}}
>
<span>💡 Quick Wins ({wins.length})</span>
<span style={{ fontSize: "0.65rem", opacity: 0.55 }}>{open ? "▲" : "▼"}</span>
</button>
{open && (
<div style={{ padding: "0 10px 10px", display: "flex", flexDirection: "column", gap: 5 }}>
{wins.map((w, i) => (
<div key={i} style={{
fontSize: "0.73rem", lineHeight: 1.5,
color: "rgba(200,200,230,0.80)",
display: "flex", gap: 6, alignItems: "flex-start",
}}>
<span style={{ flexShrink: 0, fontSize: "0.8rem" }}>{_QW_ICON[w.c] ?? "•"}</span>
<span>{w.s}<span style={{ marginLeft: 6, opacity: 0.4, fontSize: "0.65rem" }}>
effort:{w.e} · impatto:{w.i}
</span></span>
</div>
))}
</div>
)}
</div>
);
};
interface ChatMessageProps {
message: Message;
isStreaming?: boolean;
onRetry?: () => void;
onRegenerate?: () => void;
onCopyMessage?: (content: string) => void;
onExecuteResult?: (result: string) => void;
onSuggest?: (text: string) => void;
onEdit?: () => void;
isEditing?: boolean;
editDraft?: string;
onEditDraftChange?: (v: string) => void;
onEditSave?: () => void;
onEditCancel?: () => void;
}
// S462: ResponseCompletionMarker — "punto fermo" visivo a fine risposta, stile premium
const ResponseCompletionMarker = memo(function ResponseCompletionMarker() {
return (
<div style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
marginTop: 10,
marginBottom: 2,
}}>
<svg width={10} height={10} viewBox="0 0 14 14" fill="none">
<circle cx="7" cy="7" r="5.5"
stroke="rgba(74,222,128,0.35)"
strokeWidth="1.3"
fill="none"
/>
<polyline
points="4.5,7 6.5,9 9.5,5"
stroke="rgba(74,222,128,0.45)"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</svg>
<span style={{
fontSize: "0.60rem",
color: "rgba(255,255,255,0.22)",
fontFamily: '"JetBrains Mono", ui-monospace, monospace',
letterSpacing: "0.04em",
userSelect: "none",
}}>
completato
</span>
</div>
);
});
const ChatMessage = memo(({ message, isStreaming, onRetry, onRegenerate, onCopyMessage, onSuggest }: ChatMessageProps) => {
const [copied, setCopied] = useState(false);
// S-PLAN-REPLAY: carica piano salvato per questo messaggio (da localStorage)
const [replayItems, setReplayItems] = useState<PlanReplayItem[]>(() => {
if (typeof localStorage === "undefined" || !message.id) return [];
try {
const raw = localStorage.getItem(`planReplay_${message.id}`);
if (!raw) return [];
const parsed = JSON.parse(raw) as PlanReplayItem[] | { items: PlanReplayItem[]; savedAt: number };
return Array.isArray(parsed) ? parsed : (parsed.items ?? []);
} catch { return []; }
});
// Cattura piano durante streaming e lo salva per replay futuro
useEffect(() => {
if (!isStreaming || !message.id) return;
const onPlanComplete = (e: Event) => {
const { items } = (e as CustomEvent<{ items: PlanReplayItem[] }>).detail ?? {};
if (!Array.isArray(items) || items.length === 0) return;
try { localStorage.setItem(`planReplay_${message.id}`, JSON.stringify({ items, savedAt: Date.now() })); } catch { /* quota */ }
setReplayItems(items);
};
window.addEventListener("agente-ai:plan-complete", onPlanComplete);
return () => window.removeEventListener("agente-ai:plan-complete", onPlanComplete);
}, [isStreaming, message.id]);
// ── Visual Proof Badge ─────────────────────────────────────────────────────
const ProofBadgePill = memo(function ProofBadgePill({
badge,
}: {
badge: { status: "ok" | "warn"; label: string };
}) {
const isOk = badge.status === "ok";
return (
<div style={{
display: "inline-flex", alignItems: "center", gap: 5,
marginTop: 6,
padding: "2px 10px",
borderRadius: 999,
fontSize: "0.6rem",
fontFamily: '"JetBrains Mono", ui-monospace, monospace',
letterSpacing: "0.04em",
background: isOk ? "rgba(52,211,153,0.08)" : "rgba(251,191,36,0.08)",
border: `1px solid ${isOk ? "rgba(52,211,153,0.3)" : "rgba(251,191,36,0.3)"}`,
color: isOk ? "#34d399" : "#fbbf24",
animation: "fadeIn 0.2s ease both",
}}>
<span style={{ fontSize: "0.65rem" }}>{isOk ? "⬡" : "⚠"}</span>
{badge.label}
</div>
);
});
// S301: footer buttons sempre visibili — no hover gate
const isMobile = useIsMobile();
const isUser = message.role === "user";
const isTyping = message.content === "\u2026";
const isError = message.error === true;
const agentStatus = message.agentStatus ?? "";
const hasThinkingStatus = isStreaming === true && agentStatus.length > 0;
const hasContent = message.content.length > 0 && !isTyping;
// P4.1: step SSE live dall'activeTask (TaskAction[] → AgentStep[])
const { steps: liveSteps, isLive } = useAgentSSE();
// Durante streaming attivo mostra step live; altrimenti quelli salvati nel messaggio
const displaySteps = (isStreaming && isLive && liveSteps.length > 0)
? liveSteps
: (message.steps ?? []);
const hasSteps = displaySteps.length > 0;
// S396-NARR: testo narrativo live dall'ultimo step running → tool name per AgentNarration
// S484: passa tool name (non explanation) → AgentNarration usa ACTION_VERB/TOOL_HUMAN interni
// explanation è ancora su AgentStep per uso futuro (tooltip, step card)
const liveNarrativeStatus = useMemo(() => {
if (!isStreaming) return agentStatus;
if (!isLive || liveSteps.length === 0) {
// Fix #4: usa il primo rigo del testo parziale come narrazione live se nessun status
if (agentStatus) return agentStatus;
const _cnt = message.content;
if (_cnt && _cnt.length > 5 && _cnt !== "\u200B") {
const _line = _cnt.split("\n")[0].slice(0, 80);
if (_line.length > 5) return _line;
}
return "Analisi della richiesta in corso…";
}
const lastRunning = [...liveSteps].reverse().find(s => s.status === "running");
// S-UI4: preferisci explanation se disponibile — più descrittiva del solo tool name
if (lastRunning) return lastRunning.explanation ?? lastRunning.tool ?? "";
const lastDone = [...liveSteps].reverse().find(s => s.status === "done");
if (lastDone) return lastDone.explanation ?? lastDone.tool ?? agentStatus;
return agentStatus;
}, [isStreaming, isLive, liveSteps, agentStatus, message.content]);
// Show suggest chips only when: response done, has content, has steps
const showSuggest = !isStreaming && hasContent && (message.steps?.length ?? 0) > 0 && !!onSuggest;
const _activeTaskId = useTaskStore(s => s.activeTaskId);
const _proofBadge = useTaskStore(s => s.tasks.find(t => t.id === _activeTaskId)?.proofBadge);
const showProofBadge = !isStreaming && (message.steps?.length ?? 0) > 0 && !!_proofBadge;
const activeTask = useTaskStore(selectActiveTask);
// S385: DownloadProjectButton — detect write_file steps, use last task ID as conversation ID
const lastTaskId = useTaskStore(state => state.tasks[state.tasks.length - 1]?.id ?? "");
const vfsFileCount = useMemo(
() => message.steps?.filter(s => s.tool === "write_file").length ?? 0,
[message.steps],
);
// GAP-FIX #4: showActionCard visibile solo quando NON ci sono liveSteps.
// Evita overlap/discontinuità: AgentActionCard → AgentStepCards.
// Viene usato solo nella fase iniziale (prima che SSE popoli liveSteps).
const showActionCard = isStreaming === true
&& activeTask != null
&& (activeTask.status === "RUNNING" || activeTask.status === "QUEUED")
&& liveSteps.length === 0
&& (activeTask.actions.length > 0 || activeTask.status === "RUNNING");
// Steps chips: visibili post-completamento
const showStepsChips = displaySteps.length > 0 && hasContent;
// U2: estrai quickWins dal contenuto (marker <!--QWINS:...-->)
const { text: _qwCleanContent, wins: _quickWins } = useMemo(
() => _extractQuickWins(message.content ?? ""),
[message.content]
);
const copyMsg = useCallback(async () => {
try {
await navigator.clipboard.writeText(_qwCleanContent);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
onCopyMessage?.(_qwCleanContent);
} catch {
try {
const ta = document.createElement("textarea");
ta.value = _qwCleanContent;
ta.style.cssText = "position:fixed;opacity:0;top:0;left:0";
document.body.appendChild(ta); ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch { /* silent */ }
}
}, [message.content, onCopyMessage]);
// S301: strip leaked agent XML artifacts + auto-critique meta-commentary before rendering
const sanitizeContent = (raw: string): string => raw
.replace(/<action>[\w_]+<\/action>\s*<input>[\s\S]*?<\/input>/gi, "")
.replace(/^(?:La mia |)risposta(?:\s+precedente)?(?:\s+era|\s+è)?(?:\s+imprecisa|\s+incompleta|\s+sbagliata|\s+errata|\s+troncata)[^.\n]*\.?\s*/gim, "")
.replace(/^Risposta\s+(?:corretta\s+e\s+)?(?:migliorata|aggiornata)\s*[::]\s*/gim, "")
.replace(/^\[REASONING\s+CORE[^\]]*\][^\n]*/gim, "")
// BUG-FIX risposte-errate: strip meta-commento su tool falliti (specchio di finalTextCleaner)
.replace(/^Ho capito che[^.\n]{0,220}.?\s*/gim, "")
.replace(/^Notizie:[^.\n]{0,320}.?\s*/gim, "")
.replace(/^Calcolo:[^.\n]{0,220}(?:fallito|errore|sintassi)[^.\n]*.?\s*/gim, "")
.replace(/^In sintesi,\s*finora non[^.\n]{0,220}.?\s*/gim, "")
.replace(/^Ecco un riassunto[^.\n]{0,120}(?:finora|ottenuto|disponibili)[^.\n]*.?\s*/gim, "")
.replace(/^Non sono disponibili[^.\n]{0,200}(?:timeout|errore|sintassi)[^.\n]*.?\s*/gim, "")
.trim();
// #11: debounce normalizeLlmText durante streaming — evita 21 regex × 250 chunk = 5250 ops/msg
const _lastBlocksRef = useRef<ReturnType<typeof parseBlocks>>([]);
const _lastLenRef = useRef<number>(0);
const blocks = useMemo(() => {
if (isUser || isTyping) return [];
const currentLen = message.content?.length ?? 0;
// Durante streaming salta la normalizzazione se il contenuto è cresciuto di meno di 150 chars
const hasNewStep = /```step/.test(message.content ?? "") &&
!_lastBlocksRef.current.some(b => b.type === "step");
if (isStreaming && !hasNewStep && currentLen - _lastLenRef.current < 150 && _lastBlocksRef.current.length > 0) {
return _lastBlocksRef.current;
}
const normalized = normalizeLlmText(sanitizeContent(_qwCleanContent));
const parsed = parseBlocks(normalized);
_lastBlocksRef.current = parsed;
_lastLenRef.current = currentLen;
return parsed;
}, [message.content, isUser, isTyping, isStreaming]);
// Meccanismo B live: set dei tool già emessi come ```step inline nel testo streamato
// Passato ad AgentStepCards per escluderli dal live ticker ed evitare duplicati
const inlineStepTools = useMemo(() => {
if (!isStreaming) return undefined;
const tools = blocks.filter(b => b.type === "step").map(b => b.content.trim());
return tools.length > 0 ? new Set(tools) : undefined;
}, [isStreaming, blocks]);
// S456: layout interlacciato stile Replit — paragrafi di testo alternati con chip step
// Attivo solo post-completamento (non streaming) quando ci sono ≥2 paragrafi
type _InterleavedSeg =
| { type: "text"; blk: ReturnType<typeof parseBlocks> }
| { type: "chips"; steps: NonNullable<Message["steps"]> }
| { type: "streaming-tail"; blk: ReturnType<typeof parseBlocks> };
const interleavedSegments = useMemo<_InterleavedSeg[] | null>(() => {
// S-LIVE: condizione senza guard su isStreaming — i chip si rendono anche durante lo streaming
// per effetto "live" identico a Replit/Manus (zero salti al completamento).
if (!showStepsChips || !hasContent || displaySteps.length === 0) return null;
// Meccanismo B: step inline → MarkdownRender li gestisce via parseBlocks (no duplicati)
if (blocks.some(b => b.type === "step")) return null;
const normalized = normalizeLlmText(sanitizeContent(_qwCleanContent));
const allParas = normalized
.split(/\n{2,}/)
.map(p => p.trim())
.filter(p => p.length > 0);
if (allParas.length < 1) return null;
// S480: meta-tool interni nascosti — corrisponde all'INTERNAL set di AgentStepCards
const _CHIP_INTERNAL = new Set(["__plan__","__thinking__","__verify__","__belief_revision__","__plan_tracker__"]);
const visibleSteps = displaySteps.filter(s => !_CHIP_INTERNAL.has(s.tool));
if (visibleSteps.length === 0) return null;
const segs: _InterleavedSeg[] = [];
// S-LIVE2: Distribuzione proporzionale su TUTTI i paragrafi — effetto Replit autentico.
//
// Ogni paragrafo spiega il processo che stava accadendo mentre veniva scritto.
// Ogni processo ha le sue card sotto il paragrafo che lo descrive.
//
// Formula: paragrafo[i] → steps[ stepIdx .. round(N*(i+1)/tot) ]
//
// Esempio: 4 paragrafi, 10 step:
// para[0] → steps[0..2] (3 step — "Ho analizzato il codebase")
// para[1] → steps[3..5] (2 step — "Ho scritto i file")
// para[2] → steps[6..8] (2 step — "Ho fatto il push")
// para[3] → steps[9] (1 step — "Completato")
//
// Durante streaming: para[last] = streaming-tail (testo live) + step in arrivo sotto di esso.
// Zero salti: il layout finale è identico a quello durante lo streaming.
let _stepIdx = 0;
allParas.forEach((para, i) => {
const isLast = i === allParas.length - 1;
const _paraHasInlineSteps = parseBlocks(para).some(b => b.type === "step");
// L'ultimo paragrafo durante streaming → streaming-tail (aggiornamento live del testo)
segs.push(
isStreaming && isLast
? { type: "streaming-tail", blk: parseBlocks(para) } as _InterleavedSeg
: { type: "text", blk: parseBlocks(para) } as _InterleavedSeg
);
if (_paraHasInlineSteps) return; // salta chip: il paragrafo ha già step inline nel testo
// Quota proporzionale di step per questo paragrafo
const targetIdx = isLast
? visibleSteps.length
: Math.round(visibleSteps.length * (i + 1) / allParas.length);
const paraSteps = visibleSteps.slice(_stepIdx, targetIdx);
if (paraSteps.length > 0) {
segs.push({ type: "chips", steps: paraSteps } as _InterleavedSeg);
}
_stepIdx = targetIdx;
});
return segs.length > 0 ? segs : null;
}, [showStepsChips, hasContent, message.content, message.steps, displaySteps, blocks, isStreaming]);
/* ─────────────────────────────────────────────────────────────── */
/* ── User bubble ────────────────────────────────────────────── */
/* ─────────────────────────────────────────────────────────────── */
if (isUser) {
return (
<div data-testid="message-user" style={{ display: "flex", justifyContent: "flex-end" }}>
<div style={{
maxWidth: "min(78%, 520px)",
minWidth: 0,
background: "var(--user-bubble-bg)",
borderRadius: "18px 18px 4px 18px",
padding: isMobile ? "8px 12px" : "10px 16px",
color: "var(--user-bubble-text)",
fontSize: isMobile ? "0.88rem" : "0.92rem",
lineHeight: isMobile ? 1.5 : 1.65,
overflowWrap: "anywhere",
wordBreak: "break-word",
boxShadow: "0 2px 14px rgba(0,0,0,0.18)",
zIndex: Z_INDEX.BASE,
}}>
<span style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere", wordBreak: "break-word" }}>
{message.content}
</span>
</div>
</div>
);
}
/* ─────────────────────────────────────────────────────────────── */
/* ── AI card ─────────────────────────────────────────────────── */
/* ─────────────────────────────────────────────────────────────── */
// S300: background con fallback esplicito — evita sfondo bianco su iOS
// rgba(13,13,31,...) è il fallback neutro per browser senza var() support
const cardBg = isError
? "var(--card-bg-error, rgba(30,8,8,0.90))"
: isStreaming
? "var(--card-bg-streaming, rgba(16,18,40,0.48))"
: "transparent";
const cardBorder = isError
? "1px solid var(--card-border-error)"
: isStreaming
? "1px solid rgba(99,102,241,0.28)"
: "none";
const cardShadow = isError || isStreaming
? "var(--card-shadow-streaming)"
: "none";
return (
<div
className={isStreaming ? "chat-card-streaming" : (!isError ? "chat-card-enter" : undefined)}
style={{
width: "100%",
minWidth: 0,
background: cardBg,
border: cardBorder,
borderRadius: 16,
padding: isMobile ? "10px 12px" : "14px 16px",
paddingTop: isStreaming && !isError ? (isMobile ? "12px" : "16px") : (isMobile ? "10px" : "14px"),
color: "var(--text)",
fontSize: "0.92rem",
lineHeight: isMobile ? 1.5 : 1.65,
position: "relative",
zIndex: Z_INDEX.BASE,
boxShadow: cardShadow,
transition: "border-color 0.35s ease, box-shadow 0.35s ease",
overflowWrap: "anywhere",
wordBreak: "break-word",
overflow: "hidden",
}}
>
{/* ── S443: barra progresso 1px indeterminate in cima durante streaming ── */}
{isStreaming && !isError && (
<div style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
height: 2,
overflow: "hidden",
borderRadius: "16px 16px 0 0",
}}>
<div className="stream-progress-bar" />
</div>
)}
{/* ── Header rimosso S432-UI: avatar/label ridondante ── */}
{/* ── GAP-4: Abort button — ferma il task durante streaming ── */}
{isStreaming && !isError && (
<div style={{ display: "flex", justifyContent: "flex-end", marginBottom: 4 }}>
<button
onClick={() => window.dispatchEvent(new CustomEvent("agente-ai:abort"))}
title="Ferma il task corrente"
style={{
all: "unset",
cursor: "pointer",
fontSize: "0.60rem",
fontFamily: '"JetBrains Mono", ui-monospace, monospace',
fontWeight: 600,
letterSpacing: "0.04em",
color: "rgba(248,113,113,0.55)",
padding: "2px 9px",
borderRadius: 6,
background: "rgba(239,68,68,0.06)",
border: "1px solid rgba(239,68,68,0.16)",
display: "inline-flex",
alignItems: "center",
gap: 4,
transition: "all 0.15s",
}}
onMouseEnter={e => {
e.currentTarget.style.color = "#f87171";
e.currentTarget.style.background = "rgba(239,68,68,0.12)";
e.currentTarget.style.borderColor = "rgba(239,68,68,0.30)";
}}
onMouseLeave={e => {
e.currentTarget.style.color = "rgba(248,113,113,0.55)";
e.currentTarget.style.background = "rgba(239,68,68,0.06)";
e.currentTarget.style.borderColor = "rgba(239,68,68,0.16)";
}}
>
✕ Stop
</button>
</div>
)}
{/* ── S396-NARR: AgentNarration — barra narrativa live (solo durante streaming) ── */}
{isStreaming && !isError && liveNarrativeStatus && (
<AgentNarration status={liveNarrativeStatus} />
)}
{/* ── GAP-5: AgentPhaseBar — stepper macro-fasi (Piano→Ricerca→Azione→Verifica) ── */}
{isStreaming && !isError && displaySteps.length > 0 && (
<AgentPhaseBar steps={displaySteps} isLive={true} />
)}
{/* ── S-UI-PLAN: StreamingPlanBubble — piano subtask rivelati uno per uno prima dell'esecuzione ── */}
{/* Nascosto non appena AgentStepCards prende il controllo (liveSteps.length > 0) */}
{isStreaming && !isError && (
<StreamingPlanBubble visible={true} />
)}
{/* ── S487: AgentStepCards LIVE — fallback finché nessun paragrafo completato (no interleave) ── */}
{isStreaming && !isError && liveSteps.length > 0 && !interleavedSegments && (
<AgentStepCards steps={liveSteps} isLive={true} inlineStepTools={inlineStepTools} />
)}
{/* ── Pure typing (no steps, no status) — #9: skeleton invece di "…" ── */}
{isTyping && !agentStatus && !hasSteps ? (
<div style={{ padding: "4px 0", display: "flex", flexDirection: "column", gap: 8 }}>
<div className="animate-pulse" style={{ height: 11, width: "62%", borderRadius: 6, background: "rgba(96,165,250,0.12)" }} />
<div className="animate-pulse" style={{ height: 11, width: "41%", borderRadius: 6, background: "rgba(96,165,250,0.07)", animationDelay: "0.25s" }} />
</div>
) : isError ? (
/* ── Error state ── */
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<div style={{ display: "flex", alignItems: "flex-start", gap: 8 }}>
<span style={{ fontSize: "0.8rem", flexShrink: 0, marginTop: 1, color: "#f87171", fontWeight: 700 }}>!</span>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: "0.82rem", fontWeight: 600, color: "#f87171" }}>
Qualcosa è andato storto
</div>
<div style={{ fontSize: "0.78rem", color: "rgba(248,113,113,0.55)", marginTop: 3, overflowWrap: "anywhere" }}>
{message.content || "Errore sconosciuto — riprova o cambia provider."}
</div>
</div>
</div>
{onRetry && (
<button
onClick={onRetry}
onMouseEnter={e => { e.currentTarget.style.background = "rgba(239,68,68,0.14)"; }}
onMouseLeave={e => { e.currentTarget.style.background = "rgba(239,68,68,0.07)"; }}
style={{
all: "unset", cursor: "pointer",
fontSize: "0.74rem", fontWeight: 600,
padding: "5px 13px", borderRadius: 8,
background: "rgba(239,68,68,0.07)",
border: "1px solid rgba(239,68,68,0.18)",
color: "#f87171",
display: "inline-flex", alignItems: "center", gap: 5,
alignSelf: "flex-start", transition: "background 0.18s",
}}
>
↻ Riprova
</button>
)}
</div>
) : (
/* ── Normal AI content — Claude/Replit/Manus layout ── */
<>
{/* AgentActionCard — fallback chip-based per fase pending iniziale (no steps, no SSE) */}
{showActionCard && activeTask && (
<AgentActionCard task={activeTask} isStreaming={isStreaming ?? false} liveStatus={agentStatus} />
)}
{/* Streaming "thinking" placeholder (no steps, no status, has nothing yet) */}
{!isTyping && isStreaming && !hasContent && !agentStatus && !showActionCard && !hasSteps && (
<TypingIndicator />
)}
{/* 6+7. S456: layout interlacciato stile Replit — paragrafo → chip → paragrafo → chip … */}
{/* Fix #5: separatore visivo narrazione → risposta quando il primo segmento è chips */}
{interleavedSegments && interleavedSegments[0]?.type === "chips" && (
<div style={{ height: 1, background: "rgba(255,255,255,0.05)", margin: "8px 0 10px" }} />
)}
{interleavedSegments ? (
interleavedSegments.map((seg, i) => {
const isLast = i === interleavedSegments.length - 1;
// S487: streaming-tail = ultimo paragrafo in scrittura durante live
if (seg.type === "streaming-tail") {
return (
<MarkdownRender
key={i}
blocks={seg.blk}
isStreaming={true}
hasThinkingStatus={hasThinkingStatus}
steps={displaySteps}
/>
);
}
if (seg.type === "text") {
return (
<MarkdownRender
key={i}
blocks={seg.blk}
isStreaming={isLast ? (isStreaming ?? false) : false}
hasThinkingStatus={isLast ? hasThinkingStatus : false}
steps={displaySteps}
/>
);
}
return (
<div key={i} style={{ margin: "12px 0 14px" }}>
<AgentStepCards
steps={seg.steps}
isLive={isLast && !!isStreaming}
inlineStepTools={isStreaming ? inlineStepTools : undefined}
/>
</div>
);
})
) : (
/* Layout classico — chip sopra + divider + testo (testo breve / 1 paragrafo) */
<>
{/* S-PLAN-REPLAY: piano salvato — visibile per messaggi completati, collassato di default */}
{!isStreaming && replayItems.length > 0 && (
<PlanReplayBubble items={replayItems} />
)}
{showStepsChips && (
<AgentStepCards
steps={message.steps!}
isLive={false}
inlineStepTools={blocks.some(b => b.type === "step")
? new Set(blocks.filter(b => b.type === "step").map(b => b.content.trim()))
: undefined}
/>
)}
{showStepsChips && hasContent && (
<div style={{ display: "flex", alignItems: "center", gap: 8, margin: "4px 0" }}>
<div style={{ flex: 1, height: 1, background: "rgba(255,255,255,0.06)" }} />
<span style={{ fontSize: 11, color: "rgba(122,122,122,0.40)", letterSpacing: "0.05em", userSelect: "none" }}>
Risposta
</span>
<div style={{ flex: 1, height: 1, background: "rgba(255,255,255,0.06)" }} />
</div>
)}
{!isTyping && hasContent && (
<MarkdownRender
blocks={blocks}
isStreaming={isStreaming}
hasThinkingStatus={hasThinkingStatus}
steps={displaySteps}
/>
)}
</>
)}
{/* S462: ResponseCompletionMarker — punto fermo visivo a fine risposta */}
{!isStreaming && hasContent && (message.steps?.length ?? 0) > 0 && (
<ResponseCompletionMarker />
)}
{/* Visual Proof Badge — ⬡ verde verificato / ⚠ giallo discrepanza */}
{showProofBadge && _proofBadge && (
<ProofBadgePill badge={_proofBadge} />
)}
{/* 8. SuggestChips — prossimo step stile Replit/Manus */}
{showSuggest && (
<SuggestChips
steps={message.steps!}
onSuggest={onSuggest}
/>
)}
</>
)}
{/* S-LIVE: completion marker gestito da ResponseCompletionMarker dentro interleavedSegments */}
{/* U2: QuickWins — insights collassabili post-completamento */}
{!isStreaming && !isError && !isTyping && _quickWins.length > 0 && (
<QuickWinsSection wins={_quickWins} />
)}
{/* ── Footer actions — copia / rigenera / riprova ── */}
{!isTyping && !isError && !isStreaming && (
<div style={{
display: "flex",
gap: 4,
marginTop: showSuggest ? 6 : 10,
paddingTop: 8,
borderTop: showSuggest ? "none" : "1px solid var(--border)",
flexWrap: "wrap",
animation: "fadeIn 0.15s ease both",
}}>
<button
onClick={copyMsg}
style={{
all: "unset", cursor: "pointer",
fontSize: "0.64rem",
fontFamily: '"JetBrains Mono", ui-monospace, monospace',
letterSpacing: "0.04em",
color: copied ? "#60a5fa" : "var(--footer-btn-color)",
padding: isMobile ? "3px 6px" : "3px 10px", borderRadius: 6,
background: copied ? "rgba(59,130,246,0.08)" : "var(--footer-btn-bg)",
border: `1px solid ${copied ? "rgba(59,130,246,0.22)" : "var(--footer-btn-border)"}`,
transition: "all 0.18s ease",
display: "inline-flex", alignItems: "center", gap: 4,
}}
onMouseEnter={e => { if (!copied) { e.currentTarget.style.color = "var(--primary)"; e.currentTarget.style.borderColor = "rgba(99,102,241,0.35)"; } }}
onMouseLeave={e => { if (!copied) { e.currentTarget.style.color = "var(--footer-btn-color)"; e.currentTarget.style.borderColor = "var(--footer-btn-border)"; } }}
>
{copied ? "copiato" : "copia"}
</button>
{/* S393 Priority 3: Preview Runtime — rileva HTML nel contenuto e offre inline preview */}
{message.role === "assistant" && hasContent && (() => {
const _rawContent = message.content ?? "";
const _hasHtml = /<!DOCTYPE\s+html|<html[\s>]/i.test(_rawContent)
|| /<(div|section|article|main|body)\s[^>]*style=/i.test(_rawContent);
if (!_hasHtml) return null;
const _extractHtml = () => {
// Estrae blocco HTML dal markdown (```html ... ```) o raw HTML
const m = _rawContent.match(/```(?:html)?\s*(<!DOCTYPE[\s\S]*?)<\/html>\s*```/i)
|| _rawContent.match(/(<!DOCTYPE[\s\S]*?<\/html>)/i);
return m ? m[1] : _rawContent;
};
return (
<button
onClick={() => {
const _html = _extractHtml();
window.dispatchEvent(new CustomEvent("agente:preview_open", {
detail: { html: _html, name: "preview.html" },
}));
}}
title="Apri anteprima HTML nel pannello preview"
style={{
all: "unset", cursor: "pointer",
fontSize: "0.64rem",
fontFamily: '"JetBrains Mono", ui-monospace, monospace',
letterSpacing: "0.04em",
color: "#34d399",
padding: isMobile ? "3px 6px" : "3px 10px", borderRadius: 6,
background: "rgba(52,211,153,0.07)",
border: "1px solid rgba(52,211,153,0.22)",
transition: "all 0.18s ease",
display: "inline-flex", alignItems: "center", gap: 4,
}}
onMouseEnter={e => { e.currentTarget.style.background = "rgba(52,211,153,0.12)"; e.currentTarget.style.borderColor = "rgba(52,211,153,0.40)"; }}
onMouseLeave={e => { e.currentTarget.style.background = "rgba(52,211,153,0.07)"; e.currentTarget.style.borderColor = "rgba(52,211,153,0.22)"; }}
>
<span style={{ fontSize: "0.8rem", lineHeight: 1 }}></span>
preview
</button>
);
})()}
{/* S385: DownloadProjectButton — scarica tutti i file VFS come ZIP (solo assistant) */}
{message.role === "assistant" && vfsFileCount > 0 && lastTaskId && (
<DownloadProjectButton conversationId={lastTaskId} fileCount={vfsFileCount} />
)}
{onRetry && (
<button
onClick={onRetry}
title="Auto-revisione — rilegge e critica la risposta precedente"
style={{
all: "unset", cursor: "pointer",
fontSize: "0.64rem",
fontFamily: '"JetBrains Mono", ui-monospace, monospace',
letterSpacing: "0.04em",
color: "var(--footer-btn-color)",
padding: isMobile ? "3px 6px" : "3px 10px", borderRadius: 6,
background: "var(--footer-btn-bg)",
border: "1px solid var(--footer-btn-border)",
transition: "all 0.18s ease",
display: "inline-flex", alignItems: "center", gap: 4,
}}
onMouseEnter={e => { e.currentTarget.style.color = "#a78bfa"; e.currentTarget.style.borderColor = "rgba(167,139,250,0.3)"; e.currentTarget.style.background = "rgba(167,139,250,0.06)"; }}
onMouseLeave={e => { e.currentTarget.style.color = "var(--footer-btn-color)"; e.currentTarget.style.borderColor = "var(--footer-btn-border)"; e.currentTarget.style.background = "var(--footer-btn-bg)"; }}
>
<span style={{ fontSize: "0.8rem", lineHeight: 1 }}></span>
riprova
</button>
)}
</div>
)}
</div>
);
});
ChatMessage.displayName = "ChatMessage";
export default ChatMessage;