diff --git a/frontend/src/app/components/share/IncludesList.tsx b/frontend/src/app/components/share/IncludesList.tsx new file mode 100644 index 00000000..2cd6f290 --- /dev/null +++ b/frontend/src/app/components/share/IncludesList.tsx @@ -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 = { + 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, + }) => ( + + + {tag} + + + {name} + + {detail && ( + {detail} + )} + + ); + + return ( + + + {summary.includes.map((it, i) => ( + + ))} + {summary.requirements.length > 0 && ( + + {summary.requirements.map((r, i) => ( + + ))} + + )} + + ); +}; + +export default IncludesList; diff --git a/frontend/src/app/components/share/ShareButton.tsx b/frontend/src/app/components/share/ShareButton.tsx new file mode 100644 index 00000000..90d369e5 --- /dev/null +++ b/frontend/src/app/components/share/ShareButton.tsx @@ -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 = ({ 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' ? ( + + + + + Share + + ) : ( + + + + + + )} + {open && setOpen(false)} />} + + ); +}; + +export default ShareButton; diff --git a/frontend/src/app/components/share/ShareModal.tsx b/frontend/src/app/components/share/ShareModal.tsx new file mode 100644 index 00000000..5a8568a1 --- /dev/null +++ b/frontend/src/app/components/share/ShareModal.tsx @@ -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 = ({ target, open, onClose }) => { + const c = useClaudeTokens(); + const [preflight, setPreflight] = useState(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, + ) => ( + + {icon} + + + {title} + {chip && ( + + )} + + {subtitle} + + + ); + + return ( + <> + + + + Share {target.name} + + + + + + + + + {loading ? ( + + + + ) : error ? ( + + {error} + + + ) : preflight ? ( + + ) : null} + + + {optionRow(true, , 'Download .swarm file', 'Save a file you can send to anyone.')} + {optionRow( + false, + , + 'Create share link', + 'A link that opens straight in OpenSwarm.', + true, + 'Coming soon', + )} + + + + + + + + setToast('')} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + setToast('')} + sx={{ bgcolor: c.bg.surface, color: c.text.primary, border: `1px solid ${c.border.medium}`, fontSize: '0.82rem' }} + > + {toast} + + + + ); +}; + +export default ShareModal; diff --git a/frontend/src/app/components/share/shareApi.ts b/frontend/src/app/components/share/shareApi.ts new file mode 100644 index 00000000..daa244b1 --- /dev/null +++ b/frontend/src/app/components/share/shareApi.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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(); +} diff --git a/frontend/src/app/components/share/shareTypes.ts b/frontend/src/app/components/share/shareTypes.ts new file mode 100644 index 00000000..65ed258c --- /dev/null +++ b/frontend/src/app/components/share/shareTypes.ts @@ -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; +} + +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; + unresolved_requirements: RequirementView[]; +} diff --git a/frontend/src/app/pages/Skills/Skills.tsx b/frontend/src/app/pages/Skills/Skills.tsx index 3ee42800..482208e8 100644 --- a/frontend/src/app/pages/Skills/Skills.tsx +++ b/frontend/src/app/pages/Skills/Skills.tsx @@ -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 = () => { )} + openEdit(selectedLocal)} sx={{ color: c.text.tertiary, '&:hover': { color: c.accent.primary } }}>