[eric] swarm: .swarm import flow (drag-drop + file picker, preflight/commit modal)

This commit is contained in:
ciregenz
2026-06-14 06:00:12 -07:00
parent 9b6e0f818f
commit 7ea461fc52
4 changed files with 304 additions and 0 deletions
+2
View File
@@ -20,6 +20,7 @@ import {
setUpdateError,
} from '@/shared/state/updateSlice';
import AppShell from './components/Layout/AppShell';
import ImportEntryPoint from './components/share/ImportEntryPoint';
import DashboardSelection from './pages/DashboardSelection/DashboardSelection';
import ErrorBoundary from './components/feedback/ErrorBoundary';
import { setPanelMode, disableOnboardingAfterCrash } from '@/shared/state/onboardingProgressSlice';
@@ -468,6 +469,7 @@ const ThemedApp: React.FC = () => {
<DefaultModelGuard>
<UpdateListener>
<CrashRecoveryChip />
<ImportEntryPoint />
<DeepLinkListener>
<ErrorBoundary scope="routes">
<Suspense fallback={null}>
@@ -0,0 +1,120 @@
// 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).
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 FileDownloadIcon from '@mui/icons-material/FileDownload';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import ImportModal from './ImportModal';
export const IMPORT_OPEN_EVENT = 'openswarm:import-open';
const ACCEPT = '.swarm,.md,.zip';
function looksImportable(name: string): boolean {
const n = name.toLowerCase();
return n.endsWith('.swarm') || n.endsWith('.md') || n.endsWith('.zip');
}
const ImportEntryPoint: React.FC = () => {
const c = useClaudeTokens();
const inputRef = useRef<HTMLInputElement | null>(null);
const [pending, setPending] = useState<File | null>(null);
const [dragging, setDragging] = useState(false);
const depth = useRef(0);
const take = useCallback((f: File | null) => {
if (f && looksImportable(f.name)) setPending(f);
}, []);
useEffect(() => {
const openPicker = () => inputRef.current?.click();
window.addEventListener(IMPORT_OPEN_EVENT, openPicker);
return () => window.removeEventListener(IMPORT_OPEN_EVENT, openPicker);
}, []);
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;
setDragging(true);
};
const onLeave = () => {
depth.current = Math.max(0, depth.current - 1);
if (depth.current === 0) setDragging(false);
};
const onOver = (e: DragEvent) => {
if (hasFiles(e)) e.preventDefault();
};
const onDrop = (e: DragEvent) => {
depth.current = 0;
setDragging(false);
if (onWebview(e.target)) return;
const f = e.dataTransfer?.files?.[0];
if (f) {
e.preventDefault();
take(f);
}
};
window.addEventListener('dragenter', onEnter);
window.addEventListener('dragleave', onLeave);
window.addEventListener('dragover', onOver);
window.addEventListener('drop', onDrop);
return () => {
window.removeEventListener('dragenter', onEnter);
window.removeEventListener('dragleave', onLeave);
window.removeEventListener('dragover', onOver);
window.removeEventListener('drop', onDrop);
};
}, [take]);
return (
<>
<input
ref={inputRef}
type="file"
accept={ACCEPT}
style={{ display: 'none' }}
onChange={(e) => {
take(e.target.files?.[0] || null);
e.target.value = '';
}}
/>
<Fade in={dragging} timeout={{ enter: 200, exit: 220 }} unmountOnExit>
<Box
sx={{
position: 'fixed',
inset: 0,
zIndex: 2000,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 1.5,
bgcolor: `${c.bg.page}e6`,
border: `2px dashed ${c.accent.primary}`,
pointerEvents: 'none',
}}
>
<FileDownloadIcon sx={{ fontSize: 40, color: c.accent.primary }} />
<Typography sx={{ fontSize: '1rem', fontWeight: 600, color: c.text.primary }}>
Drop to import into OpenSwarm
</Typography>
</Box>
</Fade>
<ImportModal file={pending} open={!!pending} onClose={() => setPending(null)} />
</>
);
};
export default ImportEntryPoint;
@@ -0,0 +1,171 @@
// 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';
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;
open: boolean;
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 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
</Button>
</Box>
) : preflight ? (
<>
<IncludesList summary={preflight.summary} />
{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>
</>
);
};
export default ImportModal;
+11
View File
@@ -52,6 +52,8 @@ import {
} from '@/shared/state/skillRegistrySlice';
import { onboardingBus } from '@/app/components/Onboarding/eventBus';
import ShareButton from '@/app/components/share/ShareButton';
import { IMPORT_OPEN_EVENT } from '@/app/components/share/ImportEntryPoint';
import UploadFileIcon from '@mui/icons-material/UploadFile';
import SkillBuilderChat, { SkillPreviewData } from './SkillBuilderChat';
interface SkillForm {
@@ -315,6 +317,15 @@ const Skills: React.FC = () => {
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', px: 2, pt: 2, pb: 1 }}>
<Typography sx={{ fontSize: '0.92rem', fontWeight: 700, color: c.text.primary }}>Skills</Typography>
<Box sx={{ display: 'flex', gap: 0.25 }}>
<Tooltip title="Import .swarm">
<IconButton
size="small"
onClick={() => window.dispatchEvent(new CustomEvent(IMPORT_OPEN_EVENT))}
sx={{ color: c.text.tertiary, '&:hover': { color: c.text.primary } }}
>
<UploadFileIcon sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
<Tooltip title="Search">
<IconButton
size="small"