// ─── DownloadQueue.tsx ──────────────────────────────────────────────────────── // S725-XLSX1: Intercetta l'evento agent:download-ready da triggerDownload(). // Su Safari iOS, a.click() in contesto async è bloccato silenziosamente. // Questa coda mostra un bottone nel user-gesture context garantito. // Auto-dismiss dopo 30s. Dedup: non aggiunge se url già in coda. // FILE-6: Web Share API — bottone "Condividi" nativo iOS (AirDrop, Note, Messaggi). // FILE-5: ascolta agent:share-file per condivisione file VFS da tool agente. // AUT-3: ascolta agent:open-url per chip azione (Working Copy pull, ecc.). // ───────────────────────────────────────────────────────────────────────────── import { useEffect, useState, useCallback } from "react"; import { Z_INDEX } from "@/lib/zindex"; interface QueuedDownload { id: number; url: string; filename: string; } interface QueuedUrl { id: number; url: string; label: string; } const _hasShare = typeof navigator !== "undefined" && "share" in navigator; async function _tryWebShare(url: string, filename: string): Promise { if (!_hasShare) return false; try { const res = await fetch(url); const blob = await res.blob(); const file = new File([blob], filename, { type: blob.type || "application/octet-stream" }); const nav = navigator as Navigator & { canShare?: (d: unknown) => boolean }; if (nav.canShare?.({ files: [file] })) { await navigator.share({ files: [file], title: filename }); return true; } await navigator.share({ title: filename, text: filename }); return true; } catch (e) { if ((e as Error).name === "AbortError") return true; return false; } } export default function DownloadQueue() { const [queue, setQueue] = useState([]); const [urlQueue, setUrlQueue] = useState([]); // agent:download-ready — da fileConverter.downloadBlob, export.ts, write_file, ecc. useEffect(() => { const handler = (e: Event) => { const { url, filename } = (e as CustomEvent<{ url: string; filename: string }>).detail ?? {}; if (!url || !filename) return; setQueue(prev => prev.some(q => q.url === url) ? prev : [...prev, { id: Date.now() + Math.random(), url, filename }]); }; window.addEventListener("agent:download-ready", handler); return () => window.removeEventListener("agent:download-ready", handler); }, []); // AUT-3: agent:open-url — deep link Working Copy, GitHub, ecc. useEffect(() => { const handler = (e: Event) => { const { url, label } = (e as CustomEvent<{ url: string; label?: string }>).detail ?? {}; if (!url) return; setUrlQueue(prev => prev.some(q => q.url === url) ? prev : [...prev, { id: Date.now() + Math.random(), url, label: label || "Apri" }]); }; window.addEventListener("agent:open-url", handler); return () => window.removeEventListener("agent:open-url", handler); }, []); // FILE-5: agent:share-file — dall'agente via tool share_file useEffect(() => { const handler = async (e: Event) => { const { path, title } = (e as CustomEvent<{ path: string; title?: string }>).detail ?? {}; if (!path) return; try { const { vfsAsync } = await import("@/lib/vfsDb"); const content = await vfsAsync.read(path); if (!content) { console.warn("share_file: file non trovato", path); return; } const filename = title || path.split("/").pop() || "file"; const mimeMap: Record = { ".md": "text/markdown", ".txt": "text/plain", ".json": "application/json", ".html": "text/html", ".css": "text/css", ".ts": "text/typescript", ".js": "text/javascript", ".zip": "application/zip", }; const ext = filename.includes(".") ? "." + filename.split(".").pop()! : ""; const mime = mimeMap[ext] ?? "text/plain"; const blob = new Blob([content], { type: mime }); const url = URL.createObjectURL(blob); if (_hasShare) { const ok = await _tryWebShare(url, filename); if (ok) { URL.revokeObjectURL(url); return; } } setQueue(prev => [...prev, { id: Date.now() + Math.random(), url, filename }]); } catch (err) { console.error("agent:share-file handler:", err); } }; window.addEventListener("agent:share-file", handler as EventListener); return () => window.removeEventListener("agent:share-file", handler as EventListener); }, []); const dismiss = useCallback((id: number) => setQueue(prev => prev.filter(q => q.id !== id)), []); const dismissUrl = useCallback((id: number) => setUrlQueue(prev => prev.filter(q => q.id !== id)), []); // Auto-dismiss useEffect(() => { if (!queue.length) return; const t = setTimeout(() => setQueue([]), 30_000); return () => clearTimeout(t); }, [queue.length]); useEffect(() => { if (!urlQueue.length) return; const t = setTimeout(() => setUrlQueue([]), 60_000); return () => clearTimeout(t); }, [urlQueue.length]); if (!queue.length && !urlQueue.length) return null; return (
{/* AUT-3: URL action chips (Working Copy pull, ecc.) */} {urlQueue.map(item => (
📂
))} {/* Download / Share chips */} {queue.map(item => (
📥 {item.filename} {_hasShare && ( )} dismiss(item.id)} style={{ all: "unset" as never, cursor: "pointer", background: "rgba(124,77,255,0.25)", border: "1px solid rgba(124,77,255,0.5)", color: "#c5b8f7", fontSize: "0.72rem", fontWeight: 700, padding: "3px 10px", borderRadius: 6, whiteSpace: "nowrap", }} >⬇ Salva
))}
); }