/** * useConversationManager.ts — S-CM1 * * Estratto da MainWorkspace.tsx (S761-CM1). * Gestisce il ciclo vita della conversazione: sendMessage, streaming, handlers. * * Riduzione MainWorkspace: ~1267 → ~720 righe (-43%). * Invariante PR2: solo frontend, zero side-effect backend. */ import { useState, useRef, useCallback, useEffect } from "react"; import type * as React from "react"; import { toast } from "sonner"; import type { ConfirmRequest } from "@/lib/agentLoop"; import type { Suggestion } from "@/lib/agent/SmartSuggestions"; import type { PreviewRuntimeError } from "@/components/PreviewSandbox"; import { getMessages, getConversations, saveConversations, saveMessages, createConversation, updateConversationTitle, touchConversation, type Message, } from "@/lib/storage"; import { getAnyToken, buildMessages, buildRawMessages, guessCategory, } from "@/lib/messageBuilders"; import { streamWithFallback } from "@/lib/providerChain"; import { getCurrentSegment } from "@/lib/topicBoundary"; import { buildFileContext } from "@/lib/fileReader"; import { exportAsJSON, importFromJSON } from "@/lib/export"; import { saveSession } from "@/lib/sessionMemory"; import { classifyTask, getTopInterpretations } from "@/lib/taskClassifier"; import { detectAmbiguity } from "@/lib/ambiguityDetector"; import { useUIStore } from "@/store/uiStore"; import { useConversationStore } from "@/store/conversationStore"; import { useWorkspaceStore } from "@/store/workspaceStore"; import { useChatStore } from "@/store/chatStore"; import { useTaskStore, RESUME_WINDOW_MS } from "@/store/taskStore"; import { useSlashCommands } from "@/hooks/useSlashCommands"; // S439: lazy singleton — identico al pattern MainWorkspace (module-level cache) let _agentLoopPromise: Promise | null = null; const _loadAgentLoop = (): Promise => (_agentLoopPromise ??= import("@/lib/agentLoop")); export interface ConversationManagerResult { /** S713: streaming content separato — aggiorna solo il componente streaming */ streamingContent: string; streamingMsgIdRef: React.MutableRefObject; /** S296: SmartSuggestions chips */ suggestions: Suggestion[]; setSuggestions: React.Dispatch>; /** S352: resume task interrotto */ resumeTask: { id: string; goal: string } | null; setResumeTask: React.Dispatch>; /** InlineConfirmation */ pendingConfirm: ConfirmRequest | null; setPendingConfirm: React.Dispatch>; confirmResolverRef: React.MutableRefObject<((v: boolean) => void) | null>; /** Refs esposti per il render di MainWorkspace */ abortRef: React.MutableRefObject; importFileRef: React.MutableRefObject; /** Handlers */ sendMessage: (text: string, prefixContext?: string, opts?: { resumeTaskId?: string }) => Promise; handleRetry: () => void; handleAutoFix: (error: string, code: string, filename: string) => void; handleExport: () => void; handleImport: () => void; handleImportFile: (e: React.ChangeEvent) => Promise; handlePreviewRuntimeError: (errors: PreviewRuntimeError[]) => void; handlePreviewVerified: () => void; handleRunRequest: (code: string, filename: string) => void; executeSlashCommand: ReturnType; } export function useConversationManager(): ConversationManagerResult { // ── Streaming display state (S713) ──────────────────────────────────────── const [_streamContent, _setStreamContent] = useState(""); const _streamMsgIdRef = useRef(null); // ── UI state ─────────────────────────────────────────────────────────────── const [suggestions, setSuggestions] = useState([]); const [resumeTask, setResumeTask] = useState<{ id: string; goal: string } | null>(null); // ── InlineConfirmation ───────────────────────────────────────────────────── const [pendingConfirm, setPendingConfirm] = useState(null); const confirmResolverRef = useRef<((v: boolean) => void) | null>(null); // ── Streaming refs ───────────────────────────────────────────────────────── const abortRef = useRef(null); const importFileRef = useRef(null); const streamingConvIdRef = useRef(null); const streamingMsgIdRef = useRef(null); const hasFirstChunkRef = useRef(false); const agentLiveStatusRef = useRef(""); const _lastSaveRef = useRef(0); const _sendMsgRef = useRef<((text: string, prefix?: string) => Promise) | null>(null); const _previewAutoRepairFired = useRef(false); const _isSafariAbortRef = useRef(false); const _pendingSteerRef = useRef(null); // V7-5 // ── Store subscriptions ──────────────────────────────────────────────────── const isStreaming = useChatStore(s => s.isStreaming); const setIsStreaming = useChatStore(s => s.setIsStreaming); const setAgentMode = useChatStore(s => s.setAgentMode); const setThinkMode = useChatStore(s => s.setThinkMode); const setPendingDisambig = useChatStore(s => s.setPendingDisambig); const setAgentLiveStatus = useChatStore(s => s.setAgentLiveStatus); const setInput = useChatStore(s => s.setInput); const setAttachedFiles = useChatStore(s => s.setAttachedFiles); const activeId = useConversationStore(s => s.activeId); const messages = useConversationStore(s => s.messages); const setMessages = useConversationStore(s => s.setMessages); const setConversations = useConversationStore(s => s.setConversations); const setActiveId = useConversationStore(s => s.setActiveId); const previewHtml = useWorkspaceStore(s => s.previewHtml); const setRunCode = useWorkspaceStore(s => s.setRunCode); const setTab = useUIStore(s => s.setActiveTab); const addAlert = useUIStore(s => s.addAlert); // ── _persistMsgs — stable callback ──────────────────────────────────────── const _persistMsgs = useCallback( (id: string, msgs: Message[], opts?: { skipLocalStorage?: boolean }) => saveMessages(id, msgs, opts), [], ); // ── executeSlashCommand ──────────────────────────────────────────────────── const executeSlashCommand = useSlashCommands({ setAgentMode, persistMsgs: _persistMsgs, sendMsgRef: _sendMsgRef, }); // ── sendMessage ──────────────────────────────────────────────────────────── const sendMessage = useCallback(async ( text: string, prefixContext?: string, opts?: { resumeTaskId?: string }, ) => { const _chat = useChatStore.getState(); const _conv = useConversationStore.getState(); const _ws = useWorkspaceStore.getState(); const fileCtx = buildFileContext(_chat.attachedFiles); // V7-5: prepend pending steer hint se non streaming const _sh = _pendingSteerRef.current; if (_sh) _pendingSteerRef.current = null; const userText = (prefixContext ? prefixContext + "\n\n" : "") + (_sh ? `[Contesto: ${_sh}]\n` : "") + text.trim() + fileCtx; if (!userText) return; // S400-MANUS: live guidance — inietta istruzione nel loop in esecuzione if (_chat.isStreaming) { if (_chat.agentMode && text.trim()) { const _cid = _conv.activeId; if (_cid) { const _guidanceMsg: Message = { id: crypto.randomUUID(), role: "user", content: text.trim(), createdAt: Date.now(), isGuidance: true, }; const _withGuidance = [..._conv.messages, _guidanceMsg]; setMessages(_withGuidance); _persistMsgs(_cid, _withGuidance); import("@/lib/liveGuidanceStore").then(m => m.liveGuidanceStore.push(text.trim())).catch(() => {}); setInput(""); setAttachedFiles([]); } } return; } const _isPending = !!_chat.pendingDisambig; const _disambigOrig = _chat.pendingDisambig?.originalQuery ?? ""; if (_isPending) setPendingDisambig(null); setInput(""); setAttachedFiles([]); let convId = _conv.activeId; let currentConvs = _conv.conversations; if (!convId) { const conv = createConversation(); currentConvs = [conv, ..._conv.conversations]; setConversations(currentConvs); saveConversations(currentConvs); setActiveId(conv.id); convId = conv.id; } const userMsg: Message = { id: crypto.randomUUID(), role: "user", content: userText, createdAt: Date.now() }; const currentMsgs = _conv.activeId === convId ? _conv.messages : getMessages(convId); const withUser = [...currentMsgs, userMsg]; // S97-Bug2: topic boundary cut const _prevSeg = getCurrentSegment(); const _newCat = guessCategory(userText); const INCOMPAT = new Set(["realtime:coding","coding:realtime","news:coding","coding:news"]); const _isNewTopic = _prevSeg && INCOMPAT.has(`${_prevSeg.category}:${_newCat}`); let msgsForLLM = _isNewTopic ? [userMsg] : withUser; if (_isPending && _disambigOrig) { const enriched = `[Query originale]: ${_disambigOrig}\n\n[Chiarimento]: ${userText}`; msgsForLLM = msgsForLLM.map(m => m.id === userMsg.id ? { ...m, content: enriched } : m); } setMessages(withUser); _persistMsgs(convId, withUser); // S264: smart title sincrono if (currentMsgs.length === 0) { const _FILLER = /^(ciao|hey|hei|salve|ok|okay|allora|quindi|comunque|per favore|per piacere|grazie|perfavore|puoi|potresti|riesci|dimmi|spiegami|vorrei|voglio|ho bisogno|aiutami|aiuto|please|can you|could you|tell me|explain|help me|i need|i want|what is|what are|how do|how to|come|cosa|chi|quando|dove|perch[eé]|qual[ei]|in che|in modo|fammi|fai|crea|scrivi|genera|fatti|devo|mi serv[ei]|ho un|ho una|[ée] possibile|sai|hai|conosci|sai dirmi|c['']s*[eè]|come funziona|come si|mi puoi|mi potresti)\s+/i; const _raw = text.trim().replace(/^[\s\u2022\u2019""''«»]+/, ''); const _clean = _raw.replace(_FILLER, '').trim(); const _src = (_clean.length > 4 ? _clean : _raw); const _words = _src.split(/\s+/).filter(w => w.length > 1).slice(0, 6).join(' '); const _smartTitle = _words.length > 0 ? (_words.charAt(0).toUpperCase() + _words.slice(1)).slice(0, 54) : 'Nuova conversazione'; const updated = updateConversationTitle(currentConvs, convId, _smartTitle); setConversations(updated); saveConversations(updated); } // S435: Auto-detection modalità const _AUTO_AGENT_TYPES = new Set([ "full_app","architecture","search_and_report","code_generation", "debug","refactor","data_analysis","realtime_query", ]); const _AUTO_THINK_TYPES = new Set(["explanation","math_calculation"]); const _isAutoMode = !_chat.agentMode && !_chat.thinkMode; let _effectiveAgentMode = _chat.agentMode; let _effectiveThinkMode = _chat.thinkMode; if (_isAutoMode) { const _autoCls = classifyTask(text); _effectiveAgentMode = _autoCls.complexity >= 6 || _autoCls.needsPlan || _AUTO_AGENT_TYPES.has(_autoCls.type); _effectiveThinkMode = !_effectiveAgentMode && ( _AUTO_THINK_TYPES.has(_autoCls.type) || _autoCls.complexity >= 7 ); if (_effectiveAgentMode) setAgentMode(true); else if (_effectiveThinkMode) setThinkMode(true); } // S99: Ambiguity Detector if (_effectiveAgentMode && !_isPending) { const _topInterps = getTopInterpretations(text, 3); const _primaryCls = classifyTask(text); const _amb = detectAmbiguity(text, _primaryCls, _topInterps); if (_amb.isAmbiguous && _amb.question) { const qaMsg: Message = { id: crypto.randomUUID(), role: "assistant", content: `🤔 ${_amb.question}`, createdAt: Date.now() }; const withQA = [...withUser, qaMsg]; setMessages(withQA); _persistMsgs(convId, withQA); setPendingDisambig({ originalQuery: text }); return; } } const assistantMsg: Message = { id: crypto.randomUUID(), role: "assistant", content: "…", createdAt: Date.now() }; setMessages([...withUser, assistantMsg]); setIsStreaming(true); setTab("chat"); streamingConvIdRef.current = convId; streamingMsgIdRef.current = assistantMsg.id; _streamMsgIdRef.current = assistantMsg.id; hasFirstChunkRef.current = false; const abort = new AbortController(); abortRef.current = abort; let accumulated = ""; let rafId: number | null = null; let _lastPersistedLen = 0; const PERSIST_CHUNK = 500; let _taskId: string | null = null; const flushChunk = () => { rafId = null; if (streamingConvIdRef.current !== convId) return; _setStreamContent(accumulated); if (accumulated.length - _lastPersistedLen >= PERSIST_CHUNK) { _lastPersistedLen = accumulated.length; _persistMsgs(convId, withUser.concat({ ...assistantMsg, content: accumulated }), { skipLocalStorage: true }); } }; try { const _onChunk = (chunk: string) => { accumulated += chunk; if (!hasFirstChunkRef.current) { hasFirstChunkRef.current = true; const ws = "Scrittura risposta in tempo reale..."; agentLiveStatusRef.current = ws; setAgentLiveStatus(ws); } if (rafId !== null) return; rafId = requestAnimationFrame(flushChunk); }; const _onAgentStatus = (t: string) => { agentLiveStatusRef.current = t; setAgentLiveStatus(t); if (streamingMsgIdRef.current) setMessages(prev => prev.map(m => m.id === streamingMsgIdRef.current ? { ...m, agentStatus: t } : m)); }; if (_effectiveAgentMode) { const ts0 = useTaskStore.getState(); // P4-1: se VITE_BACKEND_URL è configurato e il backend è healthy → SSE Railway const _backendUrl = (import.meta.env.VITE_BACKEND_URL ?? "").replace(/\/$/, ""); const { isBackendHealthy: _isBH } = await import("@/lib/backendClient"); const _useBackendSSE = !!_backendUrl && _isBH() && !opts?.resumeTaskId; if (_useBackendSSE) { // ── P4-1: Backend SSE path ──────────────────────────────────────── const { startAgentTaskSSE } = await import("@/lib/agentSSE"); const _sseCtx = buildRawMessages(msgsForLLM, _ws.openFile, _ws.settings) as unknown as import('@/lib/apiTypes').ApiMsg[]; // P17-F3-FIX: userText include fileCtx con visionDescription — il backend SSE // riceve la descrizione AI delle immagini allegate (buildFileContext già formatta // visionDescription/visionTags/visionTextInImage come testo strutturato). // Prima: text (grezzo) → backend cieco sulle immagini. Ora: userText → contesto pieno. const sseHandle = startAgentTaskSSE(userText, _sseCtx, 8); // P16-F1: S399-GUARD ha rifiutato il task (SSE già attivo) — fallback locale if (sseHandle.rejected) { /* [UCM] SSE rejected fallback */ // Re-route verso il path locale senza SSE (agentLoop in-browser) throw new Error("SSE_REJECTED"); } _taskId = sseHandle.taskId; // Quando l'utente clicca "Stop" → cancella anche il task SSE abort.signal.addEventListener("abort", () => sseHandle.cancel(), { once: true }); // Token streaming dal backend → stessa pipeline UI del local agent const _sseOnChunk = (e: Event) => { const d = (e as CustomEvent<{ taskId: string; token: string; text: string }>).detail; if (d.taskId !== sseHandle.taskId) return; _onChunk(d.token); }; window.addEventListener("agent:stream-chunk", _sseOnChunk); const _sseMsgId = streamingMsgIdRef.current; const { eventRuntime: _er } = await import("@/core/events"); try { await new Promise((resolve, reject) => { if (abort.signal.aborted) { resolve(); return; } let _resolved = false; const _done = (err?: Error) => { if (_resolved) return; _resolved = true; _u1?.(); _u2?.(); _u3?.(); _unsubStore(); if (err) reject(err); else resolve(); }; // Stop utente → risolvi (non è un errore) abort.signal.addEventListener("abort", () => _done(), { once: true }); let _u1: (() => void) | undefined; let _u2: (() => void) | undefined; let _u3: (() => void) | undefined; _u1 = _er.on("task_complete", (ev) => { if (ev.taskId !== sseHandle.taskId) return; _done(); }); _u2 = _er.on("task_error", (ev) => { if (ev.taskId !== sseHandle.taskId) return; _done(abort.signal.aborted ? undefined : new Error(ev.message)); }); _u3 = _er.on("task_cancel", (ev) => { if (ev.taskId !== sseHandle.taskId) return; _done(); }); // Fallback: osserva taskStore per stati terminali (USR timeout, network drop) const _unsubStore = useTaskStore.subscribe((state) => { const _t = state.tasks.find(t => t.id === sseHandle.taskId); if (!_t) return; const _TERMINAL = ["SUCCESS", "ERROR", "TIMEOUT", "CANCELLED"]; if (!_TERMINAL.includes(_t.status)) return; const _isErr = _t.status === "ERROR" || _t.status === "TIMEOUT"; _done(_isErr ? new Error(_t.error ?? "Task terminato con errore") : undefined); }); }); } finally { window.removeEventListener("agent:stream-chunk", _sseOnChunk); } // Converti TaskAction[] → AgentStep[] e salva nel messaggio per display post-stream if (_sseMsgId) { const _ft = useTaskStore.getState().tasks.find(t => t.id === sseHandle.taskId); if (_ft?.actions.length) { const _steps = _ft.actions .filter(a => a.type !== "plan") .map(a => ({ tool: a.toolName ?? String(a.type), status: (a.done ? "done" : "running") as "done" | "running" | "error", output: a.detail, explanation: a.explanation, timestamp: a.at, })); if (_steps.length > 0) { setMessages(prev => (prev.map(m => m.id === _sseMsgId ? { ...m, steps: _steps } : m) as Message[])); } } } // taskStore già aggiornato a SUCCESS da agentSSEDispatch } else { // ── Local agent path (fallback: backend non disponibile / resume task) ── if (opts?.resumeTaskId) { _taskId = opts.resumeTaskId; ts0.retryTask(_taskId); ts0.updateStatus(_taskId, "RUNNING"); } else { _taskId = ts0.startTask(text); ts0.updateStatus(_taskId, "RUNNING"); } const { runAgentLoop } = await _loadAgentLoop(); await runAgentLoop( "", buildRawMessages(msgsForLLM, _ws.openFile, _ws.settings) as Array<{ role: string; content: string }>, { model: _effectiveThinkMode ? "deepseek-ai/DeepSeek-R1" : _ws.settings.model, temperature: _ws.settings.temperature, maxTokens: _ws.settings.maxTokens, keepThink: _effectiveThinkMode, taskId: _taskId!, conversationId: convId }, { onChunk: _onChunk, onStatus: _onAgentStatus, onNeedConfirm: (req) => new Promise((resolve) => { confirmResolverRef.current = resolve; setPendingConfirm(req); }), onSteps: (steps) => { if (!streamingMsgIdRef.current) return; setMessages(prev => prev.map(m => m.id === streamingMsgIdRef.current ? { ...m, steps } : m)); }, onSuggestions: (sugs) => setSuggestions(sugs), }, abort.signal, ); useTaskStore.getState().updateStatus(_taskId, "SUCCESS"); } } else { await streamWithFallback( getAnyToken() || "", buildMessages(msgsForLLM, _ws.openFile, _ws.settings), _onChunk, abort.signal, { model: _effectiveThinkMode ? "deepseek-ai/DeepSeek-R1" : _ws.settings.model, temperature: _ws.settings.temperature, maxTokens: _ws.settings.maxTokens, keepThink: _effectiveThinkMode }, ); } if (_isAutoMode) { setAgentMode(false); setThinkMode(false); } if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null; } if (streamingConvIdRef.current === convId) { const cleanContent = accumulated.replace(/\u200B/g, ""); setMessages(prev => { const final = prev.map(m => m.id === assistantMsg.id ? { ...m, content: cleanContent } : m); _persistMsgs(convId, final); return final; }); _setStreamContent(""); _streamMsgIdRef.current = null; const touched = touchConversation(currentConvs, convId); setConversations(touched); saveConversations(touched); // S264: async AI title — dopo il primo scambio if (withUser.length === 1) { void (async () => { try { const _token = getAnyToken(); if (!_token) return; let _aiTitle = ''; await streamWithFallback( _token, [ { role: 'system', content: 'Sei un assistente che genera titoli brevi. Rispondi SOLO con il titolo, niente altro. Massimo 5 parole, niente punteggiatura finale.' }, { role: 'user', content: `Genera un titolo brevissimo (3-5 parole, in italiano) per una conversazione che inizia con: "${text.slice(0, 200)}"` }, ], (chunk) => { _aiTitle += chunk; }, new AbortController().signal, { model: 'llama-3.1-8b-instant', maxTokens: 20, temperature: 0.3 }, ); const _t = _aiTitle.replace(/^["']|["']$|^Titolo:\s*/gi, '').trim().slice(0, 54); if (_t.length >= 4) { const _convs = useConversationStore.getState().conversations; const _upd = updateConversationTitle(_convs, convId, _t.charAt(0).toUpperCase() + _t.slice(1)); setConversations(_upd); saveConversations(_upd); } } catch { /* non-fatal */ } })(); } } } catch (err: unknown) { if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null; } if ((err as Error)?.name === "AbortError") { const partial = accumulated.replace(/\u200B/g, "").trim(); const stopped = withUser.concat({ ...assistantMsg, content: partial || "_(risposta interrotta)_" }); setMessages(stopped); if (streamingConvIdRef.current === convId) _persistMsgs(convId, stopped); _setStreamContent(""); _streamMsgIdRef.current = null; return; } const rawMsg = (err as Error).message ?? ""; if (_taskId) useTaskStore.getState().updateStatus(_taskId, "ERROR", rawMsg || "Errore sconosciuto"); const friendlyMsg = rawMsg.includes("All providers exhausted") || rawMsg.includes("503") ? "⚠️ Tutti i provider AI sono temporaneamente esauriti. Riprova tra qualche minuto, oppure apri le **Impostazioni** per configurare un'API key alternativa." : rawMsg.includes("401") || rawMsg.includes("403") ? "🔑 API key non valida o scaduta. Apri le **Impostazioni** per aggiornare la chiave." : rawMsg.includes("429") ? "⏱️ Rate limit raggiunto. Attendi qualche secondo e riprova, oppure apri le **Impostazioni** per aggiungere un'API key alternativa." : `Errore: ${rawMsg}`; const errMsg = withUser.concat({ ...assistantMsg, content: friendlyMsg, error: true }); setMessages(errMsg); _persistMsgs(convId, errMsg); } finally { import("@/lib/liveGuidanceStore").then(m => m.liveGuidanceStore.clear()).catch(() => {}); setIsStreaming(false); if (typeof requestIdleCallback !== "undefined") { requestIdleCallback(() => { import("@/lib/sessionAnalyzer").then(m => m.runSessionAnalysis()).catch(() => {}); }, { timeout: 10_000 }); } abortRef.current = null; streamingConvIdRef.current = null; streamingMsgIdRef.current = null; } // eslint-disable-next-line react-hooks/exhaustive-deps }, [_persistMsgs, setInput, setAttachedFiles, setPendingDisambig, setConversations, setActiveId, setMessages, setIsStreaming, setTab, setAgentLiveStatus, setSuggestions, setPendingConfirm, setAgentMode, setThinkMode]); // ── sendMessage ref sync ────────────────────────────────────────────────── useEffect(() => { _sendMsgRef.current = sendMessage; }, [sendMessage]); // ── agente-ai:send listener (GAP-C) ────────────────────────────────────── useEffect(() => { const _h = (e: Event) => { const text = (e as CustomEvent<{ text: string }>).detail?.text; if (text) { setSuggestions([]); void sendMessage(text); } }; window.addEventListener("agente-ai:send", _h); return () => window.removeEventListener("agente-ai:send", _h); }, [sendMessage]); // ── V7-5: agente:steer listener — soft-steer hint ────────────────────────── useEffect(() => { const _steerH = (e: Event) => { const d = (e as CustomEvent<{ tool: string; hint: string }>).detail; if (!d?.hint) return; if (useChatStore.getState().isStreaming) { import("@/lib/liveGuidanceStore") .then(m => m.liveGuidanceStore.push(`[STEER ${d.tool}] ${d.hint}`)) .catch(() => {}); } else { _pendingSteerRef.current = d.hint; } }; window.addEventListener("agente:steer", _steerH); return () => window.removeEventListener("agente:steer", _steerH); }, []); // eslint-disable-line react-hooks/exhaustive-deps // ── Session save debounced 5min ─────────────────────────────────────────── useEffect(() => { if (!isStreaming && messages.length > 2) { const now = Date.now(); if (now - _lastSaveRef.current > 300_000) { _lastSaveRef.current = now; saveSession(messages.map(m => ({ role: m.role, content: m.content }))); } const { streamingTaskId } = useChatStore.getState(); if (!streamingTaskId) useChatStore.getState().clearStreaming(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isStreaming]); // ── Auto-save ogni 30s durante streaming (S258) ─────────────────────────── useEffect(() => { if (!isStreaming) return; const id = activeId; if (!id) return; const interval = setInterval(() => { const { messages: liveMessages, persistMsgs: _p } = useConversationStore.getState(); if (liveMessages.length > 0) _p(id, liveMessages); }, 30_000); return () => clearInterval(interval); // eslint-disable-next-line react-hooks/exhaustive-deps }, [isStreaming, activeId]); // ── Agent live status cycling ───────────────────────────────────────────── useEffect(() => { if (!isStreaming) { agentLiveStatusRef.current = ""; setAgentLiveStatus(""); return; } hasFirstChunkRef.current = false; const push = (text: string) => { if (hasFirstChunkRef.current) return; agentLiveStatusRef.current = text; setAgentLiveStatus(text); if (streamingMsgIdRef.current) setMessages(prev => prev.map(m => m.id === streamingMsgIdRef.current ? { ...m, agentStatus: text } : m)); }; const timers = [ setTimeout(() => push("Comprendo la richiesta..."), 0), setTimeout(() => push("Pianificazione risposta..."), 1400), setTimeout(() => push("Elaborazione contesto..."), 3800), setTimeout(() => push("Strutturazione output..."), 8000), setTimeout(() => push("Generazione risposta..."), 15000), ]; return () => timers.forEach(clearTimeout); // eslint-disable-next-line react-hooks/exhaustive-deps }, [isStreaming]); // ── Hydrate messages on activeId change ────────────────────────────────── useEffect(() => { if (activeId) setMessages(getMessages(activeId)); else setMessages([]); // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeId]); // ── S352/S621: Banner "riprendi task interrotto" ────────────────────────── useEffect(() => { const tasks = useTaskStore.getState().tasks; const interrupted = tasks.find(t => (t.status === "RUNNING" || t.status === "QUEUED") && Date.now() - t.startedAt < RESUME_WINDOW_MS ); if (interrupted) setResumeTask({ id: interrupted.id, goal: interrupted.goal.slice(0, 80) }); }, []); // eslint-disable-line react-hooks/exhaustive-deps // ── S697: Safari stream abort-on-hide ──────────────────────────────────── useEffect(() => { const onStreamBg = () => { _isSafariAbortRef.current = true; setTimeout(() => { if (_isSafariAbortRef.current && abortRef.current) abortRef.current.abort(); }, 8000); }; const onStreamFg = () => { _isSafariAbortRef.current = false; }; window.addEventListener('safari:stream-background', onStreamBg); window.addEventListener('safari:stream-foreground', onStreamFg); return () => { window.removeEventListener('safari:stream-background', onStreamBg); window.removeEventListener('safari:stream-foreground', onStreamFg); }; }, []); // ── S402: reset repair guard su nuova versione HTML ─────────────────────── useEffect(() => { _previewAutoRepairFired.current = false; }, [previewHtml?.content]); // ── OPFS fallback toast ─────────────────────────────────────────────────── useEffect(() => { const handler = (e: Event) => { const path = (e as CustomEvent<{ path: string }>).detail?.path ?? ""; toast.warning( `File salvato in memoria alternativa${path ? ` (${path.split("/").pop()})` : ""} — OPFS non disponibile su questo browser.`, { duration: 6000 }, ); }; window.addEventListener("vfs:opfs-fallback", handler); return () => window.removeEventListener("vfs:opfs-fallback", handler); }, []); // ── handleRetry ────────────────────────────────────────────────────────── const handleRetry = useCallback(() => { const msgs = useConversationStore.getState().messages; const lastIdx = msgs.length - 1; if (lastIdx < 1) return; const prev = msgs[lastIdx - 1]; if (prev?.role === "user") { setMessages(m => m.slice(0, lastIdx - 1)); void sendMessage(prev.content); } }, [sendMessage, setMessages]); // ── handleAutoFix ──────────────────────────────────────────────────────── const handleAutoFix = useCallback((error: string, code: string, filename: string) => void sendMessage( `Fix questo errore e restituisci il file completo corretto.\n\n**File:** \`${filename}\`\n**Errore:**\n\`\`\`\n${error}\n\`\`\`\n**Codice:**\n\`\`\`\n${code}\n\`\`\`` ), [sendMessage]); // ── handleExport ───────────────────────────────────────────────────────── const handleExport = useCallback(() => { const { conversations, activeId: aid, messages: msgs } = useConversationStore.getState(); const conv = conversations.find(c => c.id === aid); if (!conv) return; exportAsJSON(conv, msgs); }, []); // ── handleImport ───────────────────────────────────────────────────────── const handleImport = useCallback(() => { importFileRef.current?.click(); }, []); // ── handleImportFile ───────────────────────────────────────────────────── const handleImportFile = useCallback(async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; e.target.value = ""; try { const { conv, messages: msgs } = await importFromJSON(file); const convs = [conv, ...getConversations()]; saveConversations(convs); saveMessages(conv.id, msgs); setConversations(convs); setActiveId(conv.id); setMessages(msgs); try { sessionStorage.setItem("agente_active_id", conv.id); } catch { /**/ } } catch (err) { addAlert({ severity: "error", title: "Import fallito", message: String(err instanceof Error ? err.message : err), source: "import" }); } }, [setConversations, setActiveId, setMessages, addAlert]); // ── handlePreviewRuntimeError (S402) ───────────────────────────────────── const handlePreviewRuntimeError = useCallback((errors: PreviewRuntimeError[]) => { if (_previewAutoRepairFired.current) return; const _cs = useChatStore.getState(); if (_cs.isStreaming) return; _previewAutoRepairFired.current = true; const _pHtml = useWorkspaceStore.getState().previewHtml; if (_pHtml?.name) { const _firstErr = errors[0]?.message ?? "errore runtime"; import("@/lib/projectStateGraph").then(m => m.markFailed(_pHtml.name!, _firstErr)).catch(() => {}); } const errorLines = errors.slice(0, 3) .map((e, i) => `${i + 1}. ${e.message}${e.line ? ` (riga ${e.line})` : ""}`) .join("\n"); void sendMessage( `🔴 **Errore runtime rilevato nel preview HTML** (auto-repair S402)\n\n\`\`\`\n${errorLines}\n\`\`\`\n\nCorreggi il file HTML/JS nel VFS e riscrivilo completo. Non spiegare — scrivi direttamente il codice corretto.`, ); }, [sendMessage]); // ── handlePreviewVerified (S403) ───────────────────────────────────────── const handlePreviewVerified = useCallback(() => { const _pHtml = useWorkspaceStore.getState().previewHtml; if (_pHtml?.name) import("@/lib/projectStateGraph").then(m => m.markVerified(_pHtml.name!)).catch(() => {}); }, []); // ── handleRunRequest ────────────────────────────────────────────────────── const handleRunRequest = useCallback((code: string, filename: string) => { setRunCode({ code, filename }); setTab("chat"); const ext = filename.split(".").pop()?.toLowerCase() ?? ""; if (ext === "py") { void sendMessage(`Esegui questo script Python:\n\`\`\`python\n${code}\n\`\`\``); } else if (ext === "js" || ext === "ts" || ext === "mjs") { void sendMessage(`Esegui questo codice:\n\`\`\`${ext}\n${code}\n\`\`\``); } else { setTab("run"); } }, [sendMessage, setRunCode, setTab]); return { streamingContent: _streamContent, streamingMsgIdRef: _streamMsgIdRef, suggestions, setSuggestions, resumeTask, setResumeTask, pendingConfirm, setPendingConfirm, confirmResolverRef, abortRef, importFileRef, sendMessage, handleRetry, handleAutoFix, handleExport, handleImport, handleImportFile, handlePreviewRuntimeError, handlePreviewVerified, handleRunRequest, executeSlashCommand, }; }