Spaces:
Sleeping
Sleeping
| /** | |
| * 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 = / | |
| { | |
| // 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 ( | |
| <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; | |