Spaces:
Configuration error
Configuration error
| /** | |
| * 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<string, string> = { | |
| 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<GhostEntry | null>(null); | |
| const [batchCount, setBatchCount] = useState(0); | |
| const autoCloseTimer = useRef<ReturnType<typeof setTimeout> | 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 ( | |
| <div | |
| role="status" | |
| aria-live="polite" | |
| aria-label={`Agente sta modificando ${filename}`} | |
| style={{ | |
| position: "fixed", left: 0, right: 0, bottom: 0, | |
| zIndex: Z_INDEX.MODAL - 1, | |
| animation: "ghostSlideUp 250ms ease-out", | |
| paddingBottom: "env(safe-area-inset-bottom, 0px)", | |
| }} | |
| > | |
| <div style={{ | |
| position: "absolute", inset: 0, | |
| background: "rgba(6,6,18,0.80)", | |
| backdropFilter: "blur(12px)", | |
| WebkitBackdropFilter: "blur(12px)", | |
| pointerEvents: "none", | |
| }} /> | |
| <div style={{ | |
| position: "relative", margin: "0 0", padding: "12px 16px 14px", | |
| borderTop: "1px solid rgba(99,102,241,0.30)", | |
| maxHeight: "30vh", overflow: "hidden", | |
| display: "flex", flexDirection: "column", gap: 8, | |
| }}> | |
| {/* Header */} | |
| <div style={{ display: "flex", alignItems: "center", gap: 8, flexShrink: 0 }}> | |
| <Eye size={14} color="#818cf8" /> | |
| <span style={{ fontSize: "0.72rem", color: "#818cf8", fontWeight: 700, letterSpacing: "0.05em" }}> | |
| AGENTE STA MODIFICANDO | |
| </span> | |
| {batchCount > 1 && ( | |
| <span style={{ | |
| fontSize: "0.65rem", fontWeight: 700, | |
| background: "rgba(99,102,241,0.20)", color: "#818cf8", | |
| padding: "1px 7px", borderRadius: 10, | |
| border: "1px solid rgba(99,102,241,0.30)", | |
| }}> | |
| {batchCount} file | |
| </span> | |
| )} | |
| <div style={{ flex: 1 }} /> | |
| <button | |
| onClick={handleClose} | |
| aria-label="Chiudi Ghost View" | |
| style={{ all: "unset", cursor: "pointer", padding: 6, color: "#4a4a78", touchAction: "manipulation" }} | |
| > | |
| <X size={16} /> | |
| </button> | |
| </div> | |
| {/* File path */} | |
| <div style={{ display: "flex", alignItems: "center", gap: 6, flexShrink: 0 }}> | |
| <FileText size={13} color={extColor} /> | |
| <span style={{ | |
| fontSize: "0.78rem", fontWeight: 600, color: "#d0d0f0", | |
| fontFamily: "monospace", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", | |
| }}> | |
| {entry.path} | |
| </span> | |
| </div> | |
| {/* Snippet */} | |
| {entry.snippet && ( | |
| <pre style={{ | |
| margin: 0, padding: "8px 10px", | |
| background: "rgba(255,255,255,0.03)", | |
| border: "1px solid rgba(99,102,241,0.12)", | |
| borderLeft: `3px solid ${extColor}`, | |
| borderRadius: 6, fontSize: "0.68rem", color: "#a0a0c0", | |
| fontFamily: "monospace", lineHeight: 1.5, | |
| overflow: "hidden", whiteSpace: "pre", flex: 1, minHeight: 0, | |
| }}> | |
| {entry.snippet} | |
| </pre> | |
| )} | |
| {/* Progress bar */} | |
| <div style={{ position: "absolute", bottom: 0, left: 0, right: 0, height: 2, background: "rgba(99,102,241,0.15)" }}> | |
| <div style={{ | |
| height: "100%", background: "#6366f1", | |
| animation: `ghostProgress ${AUTO_CLOSE_MS}ms linear forwards`, | |
| transformOrigin: "left center", | |
| }} /> | |
| </div> | |
| </div> | |
| <style>{` | |
| @keyframes ghostSlideUp { | |
| from { transform: translateY(100%); opacity: 0; } | |
| to { transform: translateY(0); opacity: 1; } | |
| } | |
| @keyframes ghostProgress { | |
| from { transform: scaleX(1); } | |
| to { transform: scaleX(0); } | |
| } | |
| @media (prefers-reduced-motion: reduce) { | |
| @keyframes ghostSlideUp { from { opacity: 0; } to { opacity: 1; } } | |
| @keyframes ghostProgress { from { opacity: 1; } to { opacity: 0; } } | |
| } | |
| `}</style> | |
| </div> | |
| ); | |
| }); | |
| export default AgentGhostView; | |