/** * AgentGhostView.tsx — GAP-4: Mobile Agent Ghost View * * Overlay slide-up dal bottom (≤ 30vh) che appare automaticamente * quando l'agente scrive file nel VFS, mostrando quale file sta modificando * e le prime 8 righe del contenuto + progress bar 4s → chiusura automatica. * * Si sopprime automaticamente se il file modificato è quello aperto nell'editor. * Rispetta safe-area-inset-bottom e prefers-reduced-motion. * * @module AgentGhostView */ import { useState, useEffect, useRef, useCallback, memo } from "react"; import type * as React from "react"; import { X, FileText, Eye } from "lucide-react"; import { eventRuntime } from "@/core/events"; import { vfsAsync } from "@/lib/vfsDb"; import { Z_INDEX } from "@/lib/zindex"; // ── Costanti ────────────────────────────────────────────────────────────────── const AUTO_CLOSE_MS = 4_000; const MAX_DIFF_LINES = 8; const MAX_READ_CHARS = 8_000; const MAX_LINE_WIDTH = 72; // ── Types ───────────────────────────────────────────────────────────────────── interface GhostEntry { path: string; snippet: string; at: number; } // ── Helpers ─────────────────────────────────────────────────────────────────── function _basename(path: string): string { return path.split("/").pop() || path; } function _extColor(path: string): string { const ext = path.split(".").pop()?.toLowerCase() ?? ""; const map: Record = { ts: "#3178c6", tsx: "#3178c6", js: "#f7df1e", jsx: "#61dafb", py: "#3572a5", go: "#00add8", rs: "#dea584", json: "#ffa500", md: "#83a598", css: "#563d7c", html: "#e34c26", sh: "#89d051", }; return map[ext] ?? "#888"; } function _truncateLines(content: string, maxLines: number, maxWidth: number): string { return content.split("\n").slice(0, maxLines) .map(l => l.length > maxWidth ? l.slice(0, maxWidth) + "…" : l) .join("\n"); } // ── Componente ──────────────────────────────────────────────────────────────── interface AgentGhostViewProps { enabled?: boolean; activeFilePath?: string | null; } const AgentGhostView = memo(function AgentGhostView({ enabled = true, activeFilePath = null, }: AgentGhostViewProps) { const [visible, setVisible] = useState(false); const [entry, setEntry] = useState(null); const [batchCount, setBatchCount] = useState(0); const autoCloseTimer = useRef | null>(null); const mountedRef = useRef(true); useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; if (autoCloseTimer.current) clearTimeout(autoCloseTimer.current); }; }, []); const resetAutoClose = useCallback(() => { if (autoCloseTimer.current) clearTimeout(autoCloseTimer.current); autoCloseTimer.current = setTimeout(() => { if (mountedRef.current) { setVisible(false); setBatchCount(0); } }, AUTO_CLOSE_MS); }, []); const handleClose = useCallback(() => { if (autoCloseTimer.current) clearTimeout(autoCloseTimer.current); setVisible(false); setBatchCount(0); }, []); useEffect(() => { if (!enabled) return; const unsub = eventRuntime.on("file_patch", async (evt) => { if (evt.path === "@vfs/sync_commit") return; if (evt.path === activeFilePath) return; if (!mountedRef.current) return; let snippet = ""; try { const content = await vfsAsync.read(evt.path); if (content) snippet = _truncateLines(content.slice(0, MAX_READ_CHARS), MAX_DIFF_LINES, MAX_LINE_WIDTH); } catch { snippet = "(anteprima non disponibile)"; } if (!mountedRef.current) return; setEntry({ path: evt.path, snippet, at: Date.now() }); setBatchCount(c => c + 1); setVisible(true); resetAutoClose(); }); return unsub; }, [enabled, activeFilePath, resetAutoClose]); if (!visible || !entry) return null; const filename = _basename(entry.path); const extColor = _extColor(entry.path); return (
{/* Header */}
AGENTE STA MODIFICANDO {batchCount > 1 && ( {batchCount} file )}
{/* File path */}
{entry.path}
{/* Snippet */} {entry.snippet && (
            {entry.snippet}
          
)} {/* Progress bar */}
); }); export default AgentGhostView;