/** * 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 /; 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 = { performance: "⚡", reliability: "🛡️", ux: "✨", architecture: "🏗️", }; const QuickWinsSection = function QuickWinsSection({ wins }: { wins: _QW[] }) { const [open, setOpen] = useState(false); if (!wins.length) return null; return (
{open && (
{wins.map((w, i) => (
{_QW_ICON[w.c] ?? "•"} {w.s} effort:{w.e} · impatto:{w.i}
))}
)}
); }; 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 (
completato
); }); 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(() => { 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 (
{isOk ? "⬡" : "⚠"} {badge.label}
); }); // 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 ) 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(/[\w_]+<\/action>\s*[\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>([]); const _lastLenRef = useRef(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 } | { type: "chips"; steps: NonNullable } | { type: "streaming-tail"; blk: ReturnType }; 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 (
{message.content}
); } /* ─────────────────────────────────────────────────────────────── */ /* ── 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 (
{/* ── S443: barra progresso 1px indeterminate in cima durante streaming ── */} {isStreaming && !isError && (
)} {/* ── Header rimosso S432-UI: avatar/label ridondante ── */} {/* ── GAP-4: Abort button — ferma il task durante streaming ── */} {isStreaming && !isError && (
)} {/* ── S396-NARR: AgentNarration — barra narrativa live (solo durante streaming) ── */} {isStreaming && !isError && liveNarrativeStatus && ( )} {/* ── GAP-5: AgentPhaseBar — stepper macro-fasi (Piano→Ricerca→Azione→Verifica) ── */} {isStreaming && !isError && displaySteps.length > 0 && ( )} {/* ── 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 && ( )} {/* ── S487: AgentStepCards LIVE — fallback finché nessun paragrafo completato (no interleave) ── */} {isStreaming && !isError && liveSteps.length > 0 && !interleavedSegments && ( )} {/* ── Pure typing (no steps, no status) — #9: skeleton invece di "…" ── */} {isTyping && !agentStatus && !hasSteps ? (
) : isError ? ( /* ── Error state ── */
!
Qualcosa è andato storto
{message.content || "Errore sconosciuto — riprova o cambia provider."}
{onRetry && ( )}
) : ( /* ── Normal AI content — Claude/Replit/Manus layout ── */ <> {/* AgentActionCard — fallback chip-based per fase pending iniziale (no steps, no SSE) */} {showActionCard && activeTask && ( )} {/* Streaming "thinking" placeholder (no steps, no status, has nothing yet) */} {!isTyping && isStreaming && !hasContent && !agentStatus && !showActionCard && !hasSteps && ( )} {/* 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" && (
)} {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 ( ); } if (seg.type === "text") { return ( ); } return (
); }) ) : ( /* 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 && ( )} {showStepsChips && ( b.type === "step") ? new Set(blocks.filter(b => b.type === "step").map(b => b.content.trim())) : undefined} /> )} {showStepsChips && hasContent && (
Risposta
)} {!isTyping && hasContent && ( )} )} {/* S462: ResponseCompletionMarker — punto fermo visivo a fine risposta */} {!isStreaming && hasContent && (message.steps?.length ?? 0) > 0 && ( )} {/* Visual Proof Badge — ⬡ verde verificato / ⚠ giallo discrepanza */} {showProofBadge && _proofBadge && ( )} {/* 8. SuggestChips — prossimo step stile Replit/Manus */} {showSuggest && ( )} )} {/* S-LIVE: completion marker gestito da ResponseCompletionMarker dentro interleavedSegments */} {/* U2: QuickWins — insights collassabili post-completamento */} {!isStreaming && !isError && !isTyping && _quickWins.length > 0 && ( )} {/* ── Footer actions — copia / rigenera / riprova ── */} {!isTyping && !isError && !isStreaming && (
{/* S393 Priority 3: Preview Runtime — rileva HTML nel contenuto e offre inline preview */} {message.role === "assistant" && hasContent && (() => { const _rawContent = message.content ?? ""; const _hasHtml = /]/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*(\s*```/i) || _rawContent.match(/()/i); return m ? m[1] : _rawContent; }; return ( ); })()} {/* S385: DownloadProjectButton — scarica tutti i file VFS come ZIP (solo assistant) */} {message.role === "assistant" && vfsFileCount > 0 && lastTaskId && ( )} {onRetry && ( )}
)}
); }); ChatMessage.displayName = "ChatMessage"; export default ChatMessage;