AUDIT / src /hooks /useConversationManager.ts
Arypulka98's picture
feat(audit): deploy full backend cluster node (part 2)
cc11e77 verified
Raw
History Blame Contribute Delete
38.2 kB
/**
* 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<typeof import("@/lib/agentLoop")> | null = null;
const _loadAgentLoop = (): Promise<typeof import("@/lib/agentLoop")> =>
(_agentLoopPromise ??= import("@/lib/agentLoop"));
export interface ConversationManagerResult {
/** S713: streaming content separato — aggiorna solo il componente streaming */
streamingContent: string;
streamingMsgIdRef: React.MutableRefObject<string | null>;
/** S296: SmartSuggestions chips */
suggestions: Suggestion[];
setSuggestions: React.Dispatch<React.SetStateAction<Suggestion[]>>;
/** S352: resume task interrotto */
resumeTask: { id: string; goal: string } | null;
setResumeTask: React.Dispatch<React.SetStateAction<{ id: string; goal: string } | null>>;
/** InlineConfirmation */
pendingConfirm: ConfirmRequest | null;
setPendingConfirm: React.Dispatch<React.SetStateAction<ConfirmRequest | null>>;
confirmResolverRef: React.MutableRefObject<((v: boolean) => void) | null>;
/** Refs esposti per il render di MainWorkspace */
abortRef: React.MutableRefObject<AbortController | null>;
importFileRef: React.MutableRefObject<HTMLInputElement | null>;
/** Handlers */
sendMessage: (text: string, prefixContext?: string, opts?: { resumeTaskId?: string }) => Promise<void>;
handleRetry: () => void;
handleAutoFix: (error: string, code: string, filename: string) => void;
handleExport: () => void;
handleImport: () => void;
handleImportFile: (e: React.ChangeEvent<HTMLInputElement>) => Promise<void>;
handlePreviewRuntimeError: (errors: PreviewRuntimeError[]) => void;
handlePreviewVerified: () => void;
handleRunRequest: (code: string, filename: string) => void;
executeSlashCommand: ReturnType<typeof useSlashCommands>;
}
export function useConversationManager(): ConversationManagerResult {
// ── Streaming display state (S713) ────────────────────────────────────────
const [_streamContent, _setStreamContent] = useState("");
const _streamMsgIdRef = useRef<string | null>(null);
// ── UI state ───────────────────────────────────────────────────────────────
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
const [resumeTask, setResumeTask] = useState<{ id: string; goal: string } | null>(null);
// ── InlineConfirmation ─────────────────────────────────────────────────────
const [pendingConfirm, setPendingConfirm] = useState<ConfirmRequest | null>(null);
const confirmResolverRef = useRef<((v: boolean) => void) | null>(null);
// ── Streaming refs ─────────────────────────────────────────────────────────
const abortRef = useRef<AbortController | null>(null);
const importFileRef = useRef<HTMLInputElement | null>(null);
const streamingConvIdRef = useRef<string | null>(null);
const streamingMsgIdRef = useRef<string | null>(null);
const hasFirstChunkRef = useRef(false);
const agentLiveStatusRef = useRef("");
const _lastSaveRef = useRef(0);
const _sendMsgRef = useRef<((text: string, prefix?: string) => Promise<void>) | null>(null);
const _previewAutoRepairFired = useRef(false);
const _isSafariAbortRef = useRef<boolean>(false);
const _pendingSteerRef = useRef<string | null>(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<void>((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<boolean>((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<HTMLInputElement>) => {
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,
};
}