[eric] swarm: Share button + modal, wired into Skills detail header

This commit is contained in:
ciregenz
2026-06-14 05:55:33 -07:00
parent d7b2dfce7b
commit 9b6e0f818f
6 changed files with 487 additions and 0 deletions
@@ -0,0 +1,87 @@
// The "what's inside this bundle" panel, shared by the Share and Import modals:
// the root entity, the dependencies pulled in with it, and any environment
// requirements (an Action the importer must enable themselves).
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { BundleSummary } from './shareTypes';
const KIND_LABEL: Record<string, string> = {
skill: 'Skill',
app: 'App',
dashboard: 'Dashboard',
mode: 'Mode',
workflow: 'Workflow',
session: 'Agent',
};
const IncludesList: React.FC<{ summary: BundleSummary }> = ({ summary }) => {
const c = useClaudeTokens();
const Row: React.FC<{ tag: string; name: string; detail?: string; faded?: boolean }> = ({
tag,
name,
detail,
faded,
}) => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.5 }}>
<Typography
sx={{
fontSize: '0.6rem',
fontWeight: 700,
letterSpacing: '0.04em',
textTransform: 'uppercase',
color: c.text.tertiary,
minWidth: 60,
flexShrink: 0,
}}
>
{tag}
</Typography>
<Typography
sx={{
fontSize: '0.85rem',
color: faded ? c.text.muted : c.text.primary,
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{name}
</Typography>
{detail && (
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted, flexShrink: 0 }}>{detail}</Typography>
)}
</Box>
);
return (
<Box
sx={{
border: `1px solid ${c.border.subtle}`,
borderRadius: `${c.radius.md}px`,
bgcolor: c.bg.surface,
px: 1.5,
py: 0.5,
}}
>
<Row tag={KIND_LABEL[summary.root.type] || summary.root.type} name={summary.root.name} />
{summary.includes.map((it, i) => (
<Row key={`inc-${i}`} tag={KIND_LABEL[it.type] || it.type} name={it.name} detail={it.detail} />
))}
{summary.requirements.length > 0 && (
<Box sx={{ mt: 0.5, pt: 0.5, borderTop: `1px solid ${c.border.subtle}` }}>
{summary.requirements.map((r, i) => (
<Row key={`req-${i}`} tag="Needs" name={r.label} detail={r.detail} faded />
))}
</Box>
)}
</Box>
);
};
export default IncludesList;
@@ -0,0 +1,60 @@
// The reusable top-right Share affordance. Drop it on any modality's surface.
// 'icon' is the Anthropic-style header icon; 'menuItem' is for a sidebar "..."
// overflow menu. Click always stops propagation so card/header parents that own
// their own onClick don't also fire.
import React, { useState } from 'react';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import MenuItem from '@mui/material/MenuItem';
import ListItemIcon from '@mui/material/ListItemIcon';
import IosShareIcon from '@mui/icons-material/IosShare';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import ShareModal from './ShareModal';
import { ShareTarget } from './shareTypes';
interface Props {
target: ShareTarget;
size?: 'small' | 'medium';
variant?: 'icon' | 'menuItem';
iconFontSize?: number;
onOpen?: () => void; // let a parent close its overflow menu when we take over
}
const ShareButton: React.FC<Props> = ({ target, size = 'small', variant = 'icon', iconFontSize = 18, onOpen }) => {
const c = useClaudeTokens();
const [open, setOpen] = useState(false);
const start = (e: React.MouseEvent) => {
e.stopPropagation();
onOpen?.();
setOpen(true);
};
return (
<>
{variant === 'menuItem' ? (
<MenuItem onClick={start} sx={{ fontSize: '0.85rem', color: c.text.primary, gap: 1 }}>
<ListItemIcon sx={{ minWidth: 0, color: c.text.tertiary }}>
<IosShareIcon sx={{ fontSize: 16 }} />
</ListItemIcon>
Share
</MenuItem>
) : (
<Tooltip title="Share">
<IconButton
size={size}
onClick={start}
sx={{ color: c.text.tertiary, '&:hover': { color: c.accent.primary } }}
>
<IosShareIcon sx={{ fontSize: iconFontSize }} />
</IconButton>
</Tooltip>
)}
{open && <ShareModal target={target} open={open} onClose={() => setOpen(false)} />}
</>
);
};
export default ShareButton;
@@ -0,0 +1,213 @@
// Anthropic-style Share modal. v1 ships one real action, Download .swarm; the
// "Create share link" row is shown but disabled (that hosted-link flow is v2).
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 Chip from '@mui/material/Chip';
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 DownloadIcon from '@mui/icons-material/Download';
import LinkIcon from '@mui/icons-material/Link';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import IncludesList from './IncludesList';
import { downloadSwarm, exportPreflight } from './shareApi';
import { ExportPreflight, ShareTarget } from './shareTypes';
interface Props {
target: ShareTarget;
open: boolean;
onClose: () => void;
}
const ShareModal: React.FC<Props> = ({ target, open, onClose }) => {
const c = useClaudeTokens();
const [preflight, setPreflight] = useState<ExportPreflight | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [downloading, setDownloading] = useState(false);
const [toast, setToast] = useState('');
const load = useCallback(() => {
setPreflight(null);
setError('');
setLoading(true);
let alive = true;
exportPreflight(target)
.then((pf) => alive && setPreflight(pf))
.catch((e) => alive && setError(e?.message || "We couldn't read this for sharing."))
.finally(() => alive && setLoading(false));
return () => {
alive = false;
};
}, [target.kind, target.id]);
useEffect(() => {
if (!open) return;
return load();
}, [open, load]);
const handleDownload = async () => {
if (!preflight) return;
setDownloading(true);
try {
await downloadSwarm(target, preflight.filename);
setToast(`Saved ${preflight.filename}`);
onClose();
} catch (e: any) {
setError(e?.message || "We couldn't build the file.");
} finally {
setDownloading(false);
}
};
const optionRow = (
selected: boolean,
icon: React.ReactNode,
title: string,
subtitle: string,
disabled?: boolean,
chip?: string,
) => (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
p: 1.5,
mb: 1,
borderRadius: `${c.radius.md}px`,
border: `1px solid ${selected ? c.accent.primary : c.border.subtle}`,
bgcolor: selected ? `${c.accent.primary}0d` : 'transparent',
opacity: disabled ? 0.5 : 1,
cursor: disabled ? 'default' : 'default',
}}
>
<Box sx={{ color: selected ? c.accent.primary : c.text.tertiary, display: 'flex' }}>{icon}</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography sx={{ fontSize: '0.88rem', fontWeight: 600, color: c.text.primary }}>{title}</Typography>
{chip && (
<Chip
label={chip}
size="small"
sx={{ height: 18, fontSize: '0.62rem', bgcolor: c.bg.secondary, color: c.text.muted }}
/>
)}
</Box>
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted }}>{subtitle}</Typography>
</Box>
</Box>
);
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 }}>
Share {target.name}
</Typography>
<IconButton size="small" onClick={onClose} sx={{ color: c.text.tertiary }}>
<CloseIcon sx={{ fontSize: 18 }} />
</IconButton>
</Box>
<Box sx={{ px: 3, pb: 3 }}>
<Box sx={{ mb: 2 }}>
{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} />
) : null}
</Box>
{optionRow(true, <DownloadIcon sx={{ fontSize: 20 }} />, 'Download .swarm file', 'Save a file you can send to anyone.')}
{optionRow(
false,
<LinkIcon sx={{ fontSize: 20 }} />,
'Create share link',
'A link that opens straight in OpenSwarm.',
true,
'Coming soon',
)}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 1 }}>
<Button
variant="contained"
onClick={handleDownload}
disabled={!preflight || downloading}
startIcon={
downloading ? (
<CircularProgress size={14} sx={{ color: c.text.inverse }} />
) : (
<DownloadIcon sx={{ fontSize: 16 }} />
)
}
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',
}}
>
Download .swarm
</Button>
</Box>
</Box>
</Dialog>
<Snackbar
open={!!toast}
autoHideDuration={3000}
onClose={() => setToast('')}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert
severity="success"
variant="outlined"
onClose={() => setToast('')}
sx={{ bgcolor: c.bg.surface, color: c.text.primary, border: `1px solid ${c.border.medium}`, fontSize: '0.82rem' }}
>
{toast}
</Alert>
</Snackbar>
</>
);
};
export default ShareModal;
@@ -0,0 +1,72 @@
// Thin fetch helpers for the .swarm endpoints. The global interceptor in
// shared/config.ts attaches the bearer token, so we never set it here. Errors
// surface the backend's short detail message (those are already user-facing) or
// a friendly fallback; callers translate to a toast.
import { API_BASE } from '@/shared/config';
import {
ExportPreflight,
ImportCommitResult,
ImportPreflight,
ShareTarget,
} from './shareTypes';
async function _detail(res: Response, fallback: string): Promise<string> {
try {
const data = await res.json();
if (data && typeof data.detail === 'string' && data.detail) return data.detail;
} catch {
/* non-JSON error body */
}
return fallback;
}
export async function exportPreflight(target: ShareTarget): Promise<ExportPreflight> {
const res = await fetch(`${API_BASE}/swarm/export/preflight`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: target.kind, id: target.id }),
});
if (!res.ok) throw new Error(await _detail(res, "We couldn't read this for sharing."));
return res.json();
}
export async function downloadSwarm(target: ShareTarget, filename: string): Promise<void> {
const res = await fetch(`${API_BASE}/swarm/export`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: target.kind, id: target.id }),
});
if (!res.ok) throw new Error(await _detail(res, "We couldn't build the file."));
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
export async function importPreflight(file: File): Promise<ImportPreflight> {
const form = new FormData();
form.append('file', file);
// No Content-Type header: the browser sets the multipart boundary itself.
const res = await fetch(`${API_BASE}/swarm/import/preflight`, { method: 'POST', body: form });
if (!res.ok) throw new Error(await _detail(res, "We couldn't read this file."));
return res.json();
}
export async function importCommit(
stagingToken: string,
acceptRequirements: string[] = [],
): Promise<ImportCommitResult> {
const res = await fetch(`${API_BASE}/swarm/import/commit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ staging_token: stagingToken, accept_requirements: acceptRequirements }),
});
if (!res.ok) throw new Error(await _detail(res, "We couldn't finish the import."));
return res.json();
}
@@ -0,0 +1,53 @@
// Shared types for the .swarm share/import UI. The *Response shapes mirror the
// backend pydantic models in backend/apps/swarm/models.py; keep them in sync.
export type ShareKind = 'skill' | 'app' | 'workflow' | 'dashboard';
export interface ShareTarget {
kind: ShareKind;
id: string;
name: string;
}
export interface IncludeItem {
type: string;
name: string;
detail?: string;
}
export interface RequirementView {
kind: string;
key: string;
label: string;
detail?: string;
}
export interface BundleSummary {
root: IncludeItem;
includes: IncludeItem[];
requirements: RequirementView[];
counts: Record<string, number>;
}
export interface ExportPreflight {
ok: boolean;
summary: BundleSummary;
filename: string;
link_supported: boolean;
}
export interface ImportPreflight {
ok: boolean;
summary: BundleSummary;
staging_token: string;
conflicts: IncludeItem[];
warnings: string[];
}
export interface ImportCommitResult {
ok: boolean;
root_type: ShareKind;
root_id: string;
created: Record<string, string[]>;
unresolved_requirements: RequirementView[];
}
+2
View File
@@ -51,6 +51,7 @@ import {
RegistrySkillDetail,
} from '@/shared/state/skillRegistrySlice';
import { onboardingBus } from '@/app/components/Onboarding/eventBus';
import ShareButton from '@/app/components/share/ShareButton';
import SkillBuilderChat, { SkillPreviewData } from './SkillBuilderChat';
interface SkillForm {
@@ -619,6 +620,7 @@ const Skills: React.FC = () => {
)}
</Box>
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'center' }}>
<ShareButton target={{ kind: 'skill', id: selectedLocal.id, name: selectedLocal.name }} />
<Tooltip title="Edit">
<IconButton size="small" onClick={() => openEdit(selectedLocal)} sx={{ color: c.text.tertiary, '&:hover': { color: c.accent.primary } }}>
<EditIcon sx={{ fontSize: 18 }} />