[eric] swarm: drop-to-import with GPU-safe pixel digest; modal only for code/action bundles

This commit is contained in:
ciregenz
2026-06-14 07:22:12 -07:00
parent cff334a02b
commit e2f2dfdc4d
3 changed files with 303 additions and 180 deletions
@@ -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<DigestHandle, { color?: string }>(({ color = '#c4633a' }, ref) => {
const canvasRef = useRef<HTMLCanvasElement>(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 (
<canvas
ref={canvasRef}
width={SIZE}
height={SIZE}
style={{
position: 'fixed',
width: SIZE,
height: SIZE,
pointerEvents: 'none',
zIndex: 2100,
opacity: 0,
transition: 'opacity 160ms ease',
}}
/>
);
});
ImportDigest.displayName = 'ImportDigest';
export default ImportDigest;
@@ -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, (id: string) => 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<void>((r) => setTimeout(r, ms));
const ImportEntryPoint: React.FC = () => {
const c = useClaudeTokens();
const navigate = useNavigate();
const inputRef = useRef<HTMLInputElement | null>(null);
const [pending, setPending] = useState<File | null>(null);
const [dragging, setDragging] = useState(false);
const digestRef = useRef<DigestHandle | null>(null);
const depth = useRef(0);
const [dragging, setDragging] = useState(false);
const [confirm, setConfirm] = useState<ImportPreflight | null>(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 = '';
}}
/>
<ImportDigest ref={digestRef} color={c.accent.primary} />
<Fade in={dragging} timeout={{ enter: 200, exit: 220 }} unmountOnExit>
<Box
sx={{
@@ -108,11 +178,35 @@ const ImportEntryPoint: React.FC = () => {
>
<FileDownloadIcon sx={{ fontSize: 40, color: c.accent.primary }} />
<Typography sx={{ fontSize: '1rem', fontWeight: 600, color: c.text.primary }}>
Drop to import into OpenSwarm
Drop to add to OpenSwarm
</Typography>
</Box>
</Fade>
<ImportModal file={pending} open={!!pending} onClose={() => setPending(null)} />
<ImportModal
preflight={confirm}
open={!!confirm}
committing={committing}
onConfirm={() => confirm && commitAndFinish(confirm)}
onClose={() => {
setConfirm(null);
confirmRef.current = false;
}}
/>
<Snackbar
open={!!toast}
autoHideDuration={3500}
onClose={() => setToast(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert
severity={toast?.sev || 'success'}
variant="outlined"
onClose={() => setToast(null)}
sx={{ bgcolor: c.bg.surface, color: c.text.primary, border: `1px solid ${c.border.medium}` }}
>
{toast?.msg}
</Alert>
</Snackbar>
</>
);
};
+77 -162
View File
@@ -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, (id: string) => string> = {
app: (id) => `/apps/${id}`,
dashboard: (id) => `/dashboard/${id}`,
skill: () => '/skills',
};
const ImportModal: React.FC<Props> = ({ file, open, onClose }) => {
const ImportModal: React.FC<Props> = ({ preflight, open, committing, onConfirm, onClose }) => {
const c = useClaudeTokens();
const navigate = useNavigate();
const [preflight, setPreflight] = useState<ImportPreflight | null>(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 (
<>
<Dialog
open={open}
onClose={onClose}
maxWidth={false}
PaperProps={{
sx: {
width: 440,
maxWidth: '92vw',
bgcolor: c.bg.page,
borderRadius: `${c.radius.xl}px`,
border: `1px solid ${c.border.subtle}`,
boxShadow: c.shadow.lg,
},
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', px: 3, pt: 2.5, pb: 1 }}>
<Typography sx={{ fontSize: '1.05rem', fontWeight: 700, color: c.text.primary }}>
Import {preflight ? preflight.summary.root.name : ''}
</Typography>
<IconButton size="small" onClick={onClose} sx={{ color: c.text.tertiary }}>
<CloseIcon sx={{ fontSize: 18 }} />
</IconButton>
</Box>
<Box sx={{ px: 3, pb: 3 }}>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 3 }}>
<CircularProgress size={20} sx={{ color: c.accent.primary }} />
</Box>
) : error ? (
<Box sx={{ py: 1 }}>
<Typography sx={{ fontSize: '0.85rem', color: c.text.secondary, mb: 1 }}>{error}</Typography>
<Button size="small" onClick={load} sx={{ textTransform: 'none', color: c.accent.primary }}>
Try again
<Dialog
open={open && !!preflight}
onClose={onClose}
maxWidth={false}
PaperProps={{
sx: {
width: 440,
maxWidth: '92vw',
bgcolor: c.bg.page,
borderRadius: `${c.radius.xl}px`,
border: `1px solid ${c.border.subtle}`,
boxShadow: c.shadow.lg,
},
}}
>
{preflight && (
<>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', px: 3, pt: 2.5, pb: 1 }}>
<Typography sx={{ fontSize: '1.05rem', fontWeight: 700, color: c.text.primary }}>
Add {preflight.summary.root.name}?
</Typography>
<IconButton size="small" onClick={onClose} sx={{ color: c.text.tertiary }}>
<CloseIcon sx={{ fontSize: 18 }} />
</IconButton>
</Box>
<Box sx={{ px: 3, pb: 3 }}>
<IncludesList summary={preflight.summary} />
{preflight.review && preflight.review.findings.length > 0 && (
<Box sx={{ mt: 1.5, p: 1.5, borderRadius: `${c.radius.md}px`, border: `1px solid ${c.status.warning}55`, bgcolor: c.status.warningBg }}>
{preflight.review.findings.map((f, i) => (
<Typography key={`rv-${i}`} sx={{ fontSize: '0.78rem', color: c.text.secondary, lineHeight: 1.5 }}>
{f}
</Typography>
))}
</Box>
)}
{preflight.conflicts.length > 0 && (
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, mt: 1.5 }}>
Some items already exist and will be added as copies.
</Typography>
)}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, mt: 2 }}>
<Button onClick={onClose} sx={{ textTransform: 'none', color: c.text.secondary }}>
Cancel
</Button>
<Button
variant="contained"
onClick={onConfirm}
disabled={committing}
startIcon={committing ? <CircularProgress size={14} sx={{ color: c.text.inverse }} /> : undefined}
sx={{
bgcolor: c.accent.primary,
'&:hover': { bgcolor: c.accent.pressed },
'&.Mui-disabled': { bgcolor: c.border.medium, color: c.text.muted },
textTransform: 'none',
borderRadius: `${c.radius.md}px`,
px: 2.5,
py: 0.6,
fontSize: '0.85rem',
fontWeight: 600,
boxShadow: 'none',
}}
>
Add to OpenSwarm
</Button>
</Box>
) : preflight ? (
<>
<IncludesList summary={preflight.summary} />
{preflight.review && preflight.review.findings.length > 0 && (
<Box
sx={{
mt: 1.5,
p: 1.5,
borderRadius: `${c.radius.md}px`,
border: `1px solid ${c.status.warning}55`,
bgcolor: c.status.warningBg,
}}
>
{preflight.review.findings.map((f, i) => (
<Typography key={`rv-${i}`} sx={{ fontSize: '0.78rem', color: c.text.secondary, lineHeight: 1.5 }}>
{f}
</Typography>
))}
</Box>
)}
{preflight.conflicts.length > 0 && (
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, mt: 1.5 }}>
Some items already exist and will be added as copies.
</Typography>
)}
{preflight.warnings.map((w, i) => (
<Typography key={`w-${i}`} sx={{ fontSize: '0.78rem', color: c.text.muted, mt: 0.5 }}>
{w}
</Typography>
))}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 2 }}>
<Button
variant="contained"
onClick={handleCommit}
disabled={committing}
startIcon={committing ? <CircularProgress size={14} sx={{ color: c.text.inverse }} /> : undefined}
sx={{
bgcolor: c.accent.primary,
'&:hover': { bgcolor: c.accent.pressed },
'&.Mui-disabled': { bgcolor: c.border.medium, color: c.text.muted },
textTransform: 'none',
borderRadius: `${c.radius.md}px`,
px: 2.5,
py: 0.6,
fontSize: '0.85rem',
fontWeight: 600,
boxShadow: 'none',
}}
>
Add to OpenSwarm
</Button>
</Box>
</>
) : null}
</Box>
</Dialog>
<Snackbar
open={!!error && !open}
autoHideDuration={4000}
onClose={() => setError('')}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert severity="error" variant="outlined" sx={{ bgcolor: c.bg.surface, color: c.text.primary }}>
{error}
</Alert>
</Snackbar>
</>
</Box>
</>
)}
</Dialog>
);
};