Spaces:
Sleeping
Sleeping
| // ─── 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 <a download> 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<boolean> { | |
| 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<QueuedDownload[]>([]); | |
| const [urlQueue, setUrlQueue] = useState<QueuedUrl[]>([]); | |
| // 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<string, string> = { | |
| ".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 ( | |
| <div style={{ | |
| position: "fixed", bottom: 72, right: 12, zIndex: Z_INDEX.CRITICAL_OVERLAY, | |
| display: "flex", flexDirection: "column-reverse", gap: 8, | |
| maxWidth: 320, pointerEvents: "none", | |
| }}> | |
| {/* AUT-3: URL action chips (Working Copy pull, ecc.) */} | |
| {urlQueue.map(item => ( | |
| <div key={item.id} style={{ | |
| background: "rgba(17,24,39,0.97)", backdropFilter: "blur(10px)", | |
| WebkitBackdropFilter: "blur(10px)", // B4: Safari iOS | |
| border: "1px solid rgba(52,211,153,0.35)", borderRadius: 10, | |
| padding: "0.5rem 0.7rem", | |
| display: "flex", alignItems: "center", gap: 6, | |
| boxShadow: "0 4px 24px rgba(0,0,0,0.6)", | |
| pointerEvents: "auto", | |
| }}> | |
| <span style={{ fontSize: "0.75rem" }}>📂</span> | |
| <button | |
| onClick={() => { window.location.href = item.url; dismissUrl(item.id); }} | |
| style={{ | |
| all: "unset" as never, cursor: "pointer", flex: 1, | |
| fontSize: "0.72rem", color: "#6ee7b7", fontWeight: 700, | |
| overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", | |
| }} | |
| title={item.url} | |
| >{item.label}</button> | |
| <button | |
| onClick={() => dismissUrl(item.id)} | |
| style={{ all: "unset" as never, cursor: "pointer", color: "#5a5a72", fontSize: "0.8rem", padding: "0 2px", lineHeight: 1 }} | |
| aria-label="Chiudi" | |
| >✕</button> | |
| </div> | |
| ))} | |
| {/* Download / Share chips */} | |
| {queue.map(item => ( | |
| <div key={item.id} style={{ | |
| background: "rgba(17,17,30,0.97)", backdropFilter: "blur(10px)", | |
| WebkitBackdropFilter: "blur(10px)", // B4: Safari iOS | |
| border: "1px solid rgba(124,77,255,0.4)", borderRadius: 10, | |
| padding: "0.5rem 0.7rem", | |
| display: "flex", alignItems: "center", gap: 6, | |
| boxShadow: "0 4px 24px rgba(0,0,0,0.6)", | |
| pointerEvents: "auto", | |
| }}> | |
| <span style={{ fontSize: "0.72rem", color: "#b8adde", flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}> | |
| 📥 {item.filename} | |
| </span> | |
| {_hasShare && ( | |
| <button | |
| onClick={async () => { | |
| const ok = await _tryWebShare(item.url, item.filename); | |
| if (ok) dismiss(item.id); | |
| }} | |
| style={{ | |
| all: "unset" as never, cursor: "pointer", | |
| background: "rgba(52,211,153,0.15)", | |
| border: "1px solid rgba(52,211,153,0.4)", | |
| color: "#6ee7b7", fontSize: "0.72rem", fontWeight: 700, | |
| padding: "3px 8px", borderRadius: 6, whiteSpace: "nowrap", | |
| }} | |
| title="Condividi via iOS share sheet" | |
| >↗ Share</button> | |
| )} | |
| <a | |
| href={item.url} | |
| download={item.filename} | |
| onClick={() => 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</a> | |
| <button | |
| onClick={() => dismiss(item.id)} | |
| style={{ all: "unset" as never, cursor: "pointer", color: "#5a5a72", fontSize: "0.8rem", padding: "0 2px", lineHeight: 1 }} | |
| aria-label="Chiudi" | |
| >✕</button> | |
| </div> | |
| ))} | |
| </div> | |
| ); | |
| } | |