AUDIT / src /components /AgentGhostView.tsx
Arypulka98's picture
feat(audit): deploy full backend cluster node
aea470f verified
Raw
History Blame Contribute Delete
8.39 kB
/**
* 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;