mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 20:27:44 +02:00
[eric] swarm: Share button + modal, wired into Skills detail header
This commit is contained in:
@@ -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[];
|
||||
}
|
||||
@@ -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 }} />
|
||||
|
||||
Reference in New Issue
Block a user