From e2f2dfdc4d9e20425b46dc97e445ad2df16490e0 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 14 Jun 2026 07:22:12 -0700 Subject: [PATCH] [eric] swarm: drop-to-import with GPU-safe pixel digest; modal only for code/action bundles --- .../src/app/components/share/ImportDigest.tsx | 114 +++++++++ .../app/components/share/ImportEntryPoint.tsx | 130 ++++++++-- .../src/app/components/share/ImportModal.tsx | 239 ++++++------------ 3 files changed, 303 insertions(+), 180 deletions(-) create mode 100644 frontend/src/app/components/share/ImportDigest.tsx diff --git a/frontend/src/app/components/share/ImportDigest.tsx b/frontend/src/app/components/share/ImportDigest.tsx new file mode 100644 index 00000000..c08ecee8 --- /dev/null +++ b/frontend/src/app/components/share/ImportDigest.tsx @@ -0,0 +1,114 @@ +// The "digest" flash that plays where you drop a .swarm: an expanding ring of +// brand-tinted dithered pixels, evoking PixelBlast WITHOUT any WebGL. PixelBlast +// is a single shared WebGL2 context (one canvas, reparented) and reusing it here +// would fight an app's loading animation over that one canvas, plus rapid +// WebGL-context churn is the exact thing that crashed the GPU process. So this is +// plain Canvas2D on ONE pooled canvas, and play() refuses to start while a burst +// is already running, so drop-spam can never pile up work. +import React, { forwardRef, useImperativeHandle, useRef } from 'react'; + +export interface DigestHandle { + // Returns false if a burst is already playing (caller should ignore the drop). + play: (x: number, y: number) => boolean; +} + +const SIZE = 240; +const CELL = 6; +const DURATION = 680; +const RADIUS_MAX = 132; + +function dither(gx: number, gy: number): number { + const v = Math.sin(gx * 12.9898 + gy * 78.233) * 43758.5453; + return v - Math.floor(v); +} + +const ImportDigest = forwardRef(({ color = '#c4633a' }, ref) => { + const canvasRef = useRef(null); + const busyRef = useRef(false); + const rafRef = useRef(0); + + useImperativeHandle(ref, () => ({ + play(x: number, y: number): boolean { + if (busyRef.current) return false; + const canvas = canvasRef.current; + if (!canvas) return false; + + const reduce = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; + busyRef.current = true; + canvas.style.left = `${x - SIZE / 2}px`; + canvas.style.top = `${y - SIZE / 2}px`; + canvas.style.opacity = '1'; + + const finish = () => { + busyRef.current = false; + canvas.style.opacity = '0'; + }; + if (reduce) { + // Honor reduced-motion: no flashing pixels, just a brief, calm beat. + window.setTimeout(finish, 200); + return true; + } + + const dpr = Math.min(window.devicePixelRatio || 1, 2); + canvas.width = SIZE * dpr; + canvas.height = SIZE * dpr; + const ctx = canvas.getContext('2d'); + if (!ctx) { + finish(); + return true; + } + ctx.scale(dpr, dpr); + const cells = Math.ceil(SIZE / CELL); + const center = SIZE / 2; + const start = performance.now(); + + const frame = () => { + const t = Math.min(1, (performance.now() - start) / DURATION); + const eased = 1 - Math.pow(1 - t, 3); + const ring = eased * RADIUS_MAX; + ctx.clearRect(0, 0, SIZE, SIZE); + ctx.fillStyle = color; + for (let gy = 0; gy < cells; gy++) { + for (let gx = 0; gx < cells; gx++) { + const px = gx * CELL + CELL / 2; + const py = gy * CELL + CELL / 2; + const dist = Math.hypot(px - center, py - center); + const band = 1 - Math.abs(dist - ring) / 34; // bright at the expanding front + if (band <= 0) continue; + const a = band * (0.35 + 0.65 * dither(gx, gy)) * (1 - t * 0.25); + if (a <= 0) continue; + ctx.globalAlpha = a > 1 ? 1 : a; + ctx.fillRect(gx * CELL, gy * CELL, CELL - 1, CELL - 1); + } + } + if (t < 1) { + rafRef.current = requestAnimationFrame(frame); + } else { + finish(); + } + }; + rafRef.current = requestAnimationFrame(frame); + return true; + }, + })); + + return ( + + ); +}); + +ImportDigest.displayName = 'ImportDigest'; +export default ImportDigest; diff --git a/frontend/src/app/components/share/ImportEntryPoint.tsx b/frontend/src/app/components/share/ImportEntryPoint.tsx index d5b604f6..33a56294 100644 --- a/frontend/src/app/components/share/ImportEntryPoint.tsx +++ b/frontend/src/app/components/share/ImportEntryPoint.tsx @@ -1,36 +1,108 @@ -// The one global import affordance: a hidden file picker plus a window-wide -// drag-and-drop overlay. Mount once near the app root. A sidebar/page button -// opens the picker by dispatching IMPORT_OPEN_EVENT, so there's a single owner -// of the ImportModal (no duplicate modals). +// The one global import affordance. Drop a .swarm anywhere (or pick it): a +// GPU-safe pixel "digest" flash plays where you dropped it WHILE the preflight +// runs underneath, then it resolves straight into the import for safe bundles or +// a short confirm for ones that carry code/actions. Mount once near the app root. import React, { useCallback, useEffect, useRef, useState } from 'react'; import Box from '@mui/material/Box'; import Fade from '@mui/material/Fade'; import Typography from '@mui/material/Typography'; +import Snackbar from '@mui/material/Snackbar'; +import Alert from '@mui/material/Alert'; import FileDownloadIcon from '@mui/icons-material/FileDownload'; +import { useNavigate } from 'react-router-dom'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import ImportDigest, { DigestHandle } from './ImportDigest'; import ImportModal from './ImportModal'; +import { importCommit, importPreflight } from './shareApi'; +import { ImportPreflight } from './shareTypes'; export const IMPORT_OPEN_EVENT = 'openswarm:import-open'; - const ACCEPT = '.swarm,.md,.zip'; +const DIGEST_MS = 700; + +const DEST: Record string | null> = { + app: (id) => `/apps/${id}`, + dashboard: (id) => `/dashboard/${id}`, +}; function looksImportable(name: string): boolean { const n = name.toLowerCase(); return n.endsWith('.swarm') || n.endsWith('.md') || n.endsWith('.zip'); } +// A bundle needs a confirm only if it can run code (an app) or wants actions +// connected; everything else is inert data and imports straight away. +function needsConfirm(pf: ImportPreflight): boolean { + const s = pf.summary; + const hasApp = s.root.type === 'app' || s.includes.some((i) => i.type === 'app'); + const hasAction = s.requirements.some((r) => r.kind === 'mcp_action'); + const risky = !!pf.review && pf.review.verdict !== 'clean'; + return hasApp || hasAction || risky; +} + +const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)); + const ImportEntryPoint: React.FC = () => { const c = useClaudeTokens(); + const navigate = useNavigate(); const inputRef = useRef(null); - const [pending, setPending] = useState(null); - const [dragging, setDragging] = useState(false); + const digestRef = useRef(null); const depth = useRef(0); + const [dragging, setDragging] = useState(false); + const [confirm, setConfirm] = useState(null); + const [committing, setCommitting] = useState(false); + const [toast, setToast] = useState<{ msg: string; sev: 'success' | 'error' } | null>(null); + const confirmRef = useRef(false); // ignore new drops while a confirm is up - const take = useCallback((f: File | null) => { - if (f && looksImportable(f.name)) setPending(f); - }, []); + const finish = useCallback( + (rootType: string, rootId: string, name: string) => { + setToast({ msg: `Added ${name}`, sev: 'success' }); + const to = DEST[rootType]?.(rootId); + if (to) navigate(to); + }, + [navigate], + ); + + const commitAndFinish = useCallback( + async (pf: ImportPreflight) => { + setCommitting(true); + try { + const res = await importCommit(pf.staging_token); + finish(res.root_type, res.root_id, pf.summary.root.name); + setConfirm(null); + confirmRef.current = false; + } catch (e: any) { + setToast({ msg: e?.message || "We couldn't finish the import.", sev: 'error' }); + } finally { + setCommitting(false); + } + }, + [finish], + ); + + const handleFile = useCallback( + async (file: File | null, x: number, y: number) => { + if (!file || !looksImportable(file.name) || confirmRef.current) return; + // The digest doubles as the spam guard: it refuses to start while busy. + if (!digestRef.current?.play(x, y)) return; + let pf: ImportPreflight; + try { + [, pf] = await Promise.all([delay(DIGEST_MS), importPreflight(file)]); + } catch (e: any) { + setToast({ msg: e?.message || "We couldn't read this file.", sev: 'error' }); + return; + } + if (needsConfirm(pf)) { + confirmRef.current = true; + setConfirm(pf); + } else { + commitAndFinish(pf); + } + }, + [commitAndFinish], + ); useEffect(() => { const openPicker = () => inputRef.current?.click(); @@ -40,9 +112,7 @@ const ImportEntryPoint: React.FC = () => { useEffect(() => { const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer?.types || []).includes('Files'); - // Webviews are a separate compositor layer; ignore drops landing on one. const onWebview = (t: EventTarget | null) => (t as HTMLElement)?.tagName === 'WEBVIEW'; - const onEnter = (e: DragEvent) => { if (!hasFiles(e) || onWebview(e.target)) return; depth.current += 1; @@ -62,10 +132,9 @@ const ImportEntryPoint: React.FC = () => { const f = e.dataTransfer?.files?.[0]; if (f) { e.preventDefault(); - take(f); + void handleFile(f, e.clientX, e.clientY); } }; - window.addEventListener('dragenter', onEnter); window.addEventListener('dragleave', onLeave); window.addEventListener('dragover', onOver); @@ -76,7 +145,7 @@ const ImportEntryPoint: React.FC = () => { window.removeEventListener('dragover', onOver); window.removeEventListener('drop', onDrop); }; - }, [take]); + }, [handleFile]); return ( <> @@ -86,10 +155,11 @@ const ImportEntryPoint: React.FC = () => { accept={ACCEPT} style={{ display: 'none' }} onChange={(e) => { - take(e.target.files?.[0] || null); + void handleFile(e.target.files?.[0] || null, window.innerWidth / 2, window.innerHeight / 2); e.target.value = ''; }} /> + { > - Drop to import into OpenSwarm + Drop to add to OpenSwarm - setPending(null)} /> + confirm && commitAndFinish(confirm)} + onClose={() => { + setConfirm(null); + confirmRef.current = false; + }} + /> + setToast(null)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + setToast(null)} + sx={{ bgcolor: c.bg.surface, color: c.text.primary, border: `1px solid ${c.border.medium}` }} + > + {toast?.msg} + + ); }; diff --git a/frontend/src/app/components/share/ImportModal.tsx b/frontend/src/app/components/share/ImportModal.tsx index ef3a1228..9d8ab4f3 100644 --- a/frontend/src/app/components/share/ImportModal.tsx +++ b/frontend/src/app/components/share/ImportModal.tsx @@ -1,187 +1,102 @@ -// Import side of .swarm: preflight shows what's inside (and any environment -// requirements as informational "Needs X" rows), then commit writes the -// entities with fresh ids and we navigate to the imported root. Requirements in -// v1 are informational only; the live "enable this Action" walkthrough lands -// with the app/dashboard slices, so we never imply a grant we don't perform. -import React, { useCallback, useEffect, useState } from 'react'; +// Confirmation surface shown only for bundles that carry something with a +// consequence (an app that runs code, or actions that must be connected). Safe +// bundles never reach here; the entry point auto-imports them. This is purely +// presentational: the entry point owns preflight, commit, and navigation. +import React from 'react'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import Dialog from '@mui/material/Dialog'; import Button from '@mui/material/Button'; import IconButton from '@mui/material/IconButton'; import CircularProgress from '@mui/material/CircularProgress'; -import Snackbar from '@mui/material/Snackbar'; -import Alert from '@mui/material/Alert'; import CloseIcon from '@mui/icons-material/Close'; -import { useNavigate } from 'react-router-dom'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import IncludesList from './IncludesList'; -import { importCommit, importPreflight } from './shareApi'; import { ImportPreflight } from './shareTypes'; interface Props { - file: File | null; + preflight: ImportPreflight | null; open: boolean; + committing: boolean; + onConfirm: () => void; onClose: () => void; } -const DEST: Record string> = { - app: (id) => `/apps/${id}`, - dashboard: (id) => `/dashboard/${id}`, - skill: () => '/skills', -}; - -const ImportModal: React.FC = ({ file, open, onClose }) => { +const ImportModal: React.FC = ({ preflight, open, committing, onConfirm, onClose }) => { const c = useClaudeTokens(); - const navigate = useNavigate(); - const [preflight, setPreflight] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); - const [committing, setCommitting] = useState(false); - - const load = useCallback(() => { - if (!file) return undefined; - setPreflight(null); - setError(''); - setLoading(true); - let alive = true; - importPreflight(file) - .then((pf) => alive && setPreflight(pf)) - .catch((e) => alive && setError(e?.message || "We couldn't read this file.")) - .finally(() => alive && setLoading(false)); - return () => { - alive = false; - }; - }, [file]); - - useEffect(() => { - if (!open) return; - return load(); - }, [open, load]); - - const handleCommit = async () => { - if (!preflight) return; - setCommitting(true); - try { - const result = await importCommit(preflight.staging_token); - const dest = (DEST[result.root_type] || (() => '/skills'))(result.root_id); - onClose(); - navigate(dest); - } catch (e: any) { - setError(e?.message || "We couldn't finish the import."); - } finally { - setCommitting(false); - } - }; - return ( - <> - - - - Import {preflight ? preflight.summary.root.name : ''} - - - - - - - - {loading ? ( - - - - ) : error ? ( - - {error} - + - ) : preflight ? ( - <> - - {preflight.review && preflight.review.findings.length > 0 && ( - - {preflight.review.findings.map((f, i) => ( - - {f} - - ))} - - )} - {preflight.conflicts.length > 0 && ( - - Some items already exist and will be added as copies. - - )} - {preflight.warnings.map((w, i) => ( - - {w} - - ))} - - - - - ) : null} - - - - setError('')} - anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} - > - - {error} - - - + + + )} + ); };