[eric] marketplace: Get on every card and sheet, spinner, then Open; installs remembered by the backend and Open launches the app, workflow or skill

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C9zwUaHucUgrdxvK8FvjYT
This commit is contained in:
ciregenz
2026-09-03 09:14:31 -07:00
co-authored by Claude Fable 5.1
parent 2afbdc5b05
commit bc1e7acac9
12 changed files with 338 additions and 50 deletions
@@ -11,6 +11,7 @@ import { fetchMarketplaceListings } from '@/shared/state/marketplaceCatalogSlice
import { fetchSkills } from '@/shared/state/skillsSlice';
import { fetchOutputs } from '@/shared/state/outputsSlice';
import { fetchWorkflows } from '@/shared/state/workflowsSlice';
import { addViewCard, openWorkflowsApp } from '@/shared/state/dashboardLayoutSlice';
import ImportModal from '@/app/components/share/ImportModal';
import { importNeedsConfirm } from '@/app/components/share/importNeedsConfirm';
import { importCommit } from '@/app/components/share/shareApi';
@@ -21,6 +22,7 @@ import PackageDialog from './packages/detail/PackageDialog';
import PackageBundleCard from './packages/PackageBundleCard';
import PackageBundleDialog from './packages/detail/PackageBundleDialog';
import { stagePackageInstall } from './packages/installPackage';
import { fetchInstalls, installState, recordFor, recordInstall, type InstallRecord, type PillState } from './packages/installs';
import { KIND_LABELS, isBundle, resolveBundleMembers, type Listing } from './packages/catalog';
type Toast = { message: string; severity: 'success' | 'error' } | null;
@@ -28,25 +30,47 @@ type Toast = { message: string; severity: 'success' | 'error' } | null;
// The store tab: packages published to the marketplace sheet. Install downloads the .swarm and hands
// it to the ordinary bundle import, so what a package can do to this machine is reviewed the same way
// a dropped file is.
const DirectoryPackagesTab: React.FC<{ onInstalled?: (rootType: string) => void }> = ({ onInstalled }) => {
const DirectoryPackagesTab: React.FC<{ onOpenSkill?: (skillId: string) => void }> = ({ onOpenSkill }) => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const { listings, loading, loaded, source, error } = useAppSelector((s) => s.marketplaceCatalog);
const outputs = useAppSelector((s) => s.outputs.items);
const skills = useAppSelector((s) => s.skills.items);
const workflows = useAppSelector((s) => s.workflows.items);
const [query, setQuery] = useState('');
const [kinds, setKinds] = useState<string[]>([]);
const [sort, setSort] = useState('newest');
const [openListing, setOpenListing] = useState<Listing | null>(null);
const [openBundle, setOpenBundle] = useState<Listing | null>(null);
const [installingId, setInstallingId] = useState<string | null>(null);
const [installedIds, setInstalledIds] = useState<string[]>([]);
const [installs, setInstalls] = useState<Record<string, InstallRecord>>({});
const [confirm, setConfirm] = useState<{ preflight: ImportPreflight; listingId: string } | null>(null);
const [committing, setCommitting] = useState(false);
const [toast, setToast] = useState<Toast>(null);
useEffect(() => {
dispatch(fetchMarketplaceListings(false));
// The live lists decide whether Open still has something to open; the record alone only says Get is done.
dispatch(fetchOutputs());
dispatch(fetchSkills());
dispatch(fetchWorkflows(undefined));
let alive = true;
fetchInstalls().then((m) => { if (alive) setInstalls(m); }).catch(() => {});
return () => { alive = false; };
}, [dispatch]);
const stateFor = (listing: Listing): PillState => (
installingId === listing.id ? 'installing' : installState(listing, installs[listing.id], { outputs, skills, workflows })
);
const openInstalled = (listing: Listing) => {
const rec = installs[listing.id];
if (!rec) return;
if (rec.output_id) dispatch(addViewCard({ outputId: rec.output_id }));
else if (rec.workflow_id) dispatch(openWorkflowsApp({ workflowId: rec.workflow_id }));
else if (rec.skill_id) onOpenSkill?.(rec.skill_id);
};
const packages = useMemo(() => listings.filter((l) => !isBundle(l)), [listings]);
const bundles = useMemo(() => listings.filter(isBundle), [listings]);
const kindOptions = useMemo(
@@ -75,21 +99,26 @@ const DirectoryPackagesTab: React.FC<{ onInstalled?: (rootType: string) => void
return bundles.filter((b) => matchesQuery(b, q));
}, [bundles, query, kinds]);
const finish = (preflight: ImportPreflight, listingId: string, rootType: string) => {
setInstalledIds((prev) => (prev.includes(listingId) ? prev : [...prev, listingId]));
setToast({ message: `Added ${preflight.summary.root.name}`, severity: 'success' });
// The pill turning into Open is the feedback, the way the App Store does it: no toast, no tab jump.
const finish = async (listingId: string, rootType: string, rootId: string) => {
const version = listings.find((l) => l.id === listingId)?.version ?? '';
// Nothing else refetches these on import, so an installed package would otherwise stay invisible.
if (rootType === 'skill') dispatch(fetchSkills());
if (rootType === 'app') dispatch(fetchOutputs());
if (rootType === 'workflow') dispatch(fetchWorkflows(undefined));
onInstalled?.(rootType);
try {
setInstalls(await recordInstall(recordFor(listingId, rootType, rootId, version)));
} catch (e: unknown) {
setInstalls((prev) => ({ ...prev, [listingId]: { ...recordFor(listingId, rootType, rootId, version), installed_at: Date.now() / 1000 } }));
setToast({ message: e instanceof Error ? e.message : "Installed, but the store couldn't remember it.", severity: 'error' });
}
};
const commit = async (preflight: ImportPreflight, listingId: string) => {
setCommitting(true);
try {
const res = await importCommit(preflight.staging_token);
finish(preflight, listingId, res.root_type);
await finish(listingId, res.root_type, res.root_id);
setConfirm(null);
} catch (e: unknown) {
setToast({ message: e instanceof Error ? e.message : "We couldn't finish the install.", severity: 'error' });
@@ -183,7 +212,10 @@ const DirectoryPackagesTab: React.FC<{ onInstalled?: (rootType: string) => void
<PackageCard
key={listing.id}
listing={listing}
state={stateFor(listing)}
onOpen={() => setOpenListing(listing)}
onGet={() => { void install(listing.id); }}
onOpenInstalled={() => openInstalled(listing)}
onTag={(tag) => setQuery(tag)}
/>
))}
@@ -224,15 +256,16 @@ const DirectoryPackagesTab: React.FC<{ onInstalled?: (rootType: string) => void
<Box sx={{ flex: 1, minHeight: 0, overflowY: 'auto', pr: 0.5 }}>{body()}</Box>
<PackageDialog
listing={openListing}
installing={!!openListing && installingId === openListing.id}
installed={!!openListing && installedIds.includes(openListing.id)}
state={openListing ? stateFor(openListing) : 'get'}
onInstall={() => { if (openListing) void install(openListing.id); }}
onOpen={() => { if (openListing) openInstalled(openListing); }}
onClose={() => setOpenListing(null)}
/>
<PackageBundleDialog
bundle={openBundle}
members={openBundle ? resolveBundleMembers(openBundle, listings) : []}
installedIds={installedIds}
stateOf={(id) => { const l = listings.find((x) => x.id === id); return l ? stateFor(l) : 'get'; }}
onOpenInstalled={(member) => openInstalled(member)}
installing={installingId !== null}
onInstallAll={() => { if (openBundle) void installBundle(openBundle); }}
onInstallMember={(id) => { void install(id); }}
@@ -66,7 +66,7 @@ const MarketplaceBody: React.FC = () => {
const content = (): React.ReactElement => {
switch (view) {
case 'packages':
return <DirectoryPackagesTab onInstalled={(rootType) => { if (rootType === 'skill') { setFocusSkillId(null); setView('my-skills'); } }} />;
return <DirectoryPackagesTab onOpenSkill={(id) => { setFocusSkillId(id); setView('my-skills'); }} />;
case 'connectors':
return <DirectoryConnectorsTab onOpenInstalled={(id) => { setFocusToolId(id); setView('my-connectors'); }} />;
case 'my-skills':
@@ -0,0 +1,57 @@
import React from 'react';
import Button from '@mui/material/Button';
import CircularProgress from '@mui/material/CircularProgress';
import CheckRoundedIcon from '@mui/icons-material/CheckRounded';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import type { PillState } from './installs';
interface Props {
state: PillState;
disabled?: boolean;
onGet: () => void;
onOpen: () => void;
size?: 'sm' | 'md';
}
// The one action a package has, the way the App Store draws it: Get, a spinner while it lands, then Open. It swallows the click so a card underneath never opens its sheet.
export default function InstallPill({ state, disabled, onGet, onOpen, size = 'md' }: Props) {
const c = useClaudeTokens();
const sm = size === 'sm';
const base = {
borderRadius: `${c.radius.full}px`,
textTransform: 'none' as const,
fontWeight: 650,
fontSize: sm ? '0.75rem' : '0.8125rem',
px: sm ? 1.75 : 2.25,
py: sm ? 0.35 : 0.6,
minWidth: sm ? 58 : 72,
lineHeight: 1.5,
flexShrink: 0,
};
const stop = (e: React.MouseEvent) => { e.stopPropagation(); };
if (state === 'installed') {
return (
<Button onClick={stop} disabled variant="outlined" startIcon={<CheckRoundedIcon sx={{ fontSize: 15 }} />} sx={{ ...base, '&.Mui-disabled': { color: c.text.muted, borderColor: c.border.subtle } }}>
Installed
</Button>
);
}
if (state === 'open') {
return (
<Button onClick={(e) => { stop(e); onOpen(); }} variant="outlined" sx={{ ...base, color: c.accent.primary, borderColor: c.accent.primary, bgcolor: c.bg.surface, '&:hover': { bgcolor: c.bg.elevated, borderColor: c.accent.hover } }}>
Open
</Button>
);
}
return (
<Button
onClick={(e) => { stop(e); if (state === 'get') onGet(); }}
disabled={disabled || state === 'installing'}
variant="contained"
disableElevation
sx={{ ...base, bgcolor: c.accent.primary, color: c.text.inverse, '&:hover': { bgcolor: c.accent.hover }, '&.Mui-disabled': { bgcolor: c.accent.primary, color: c.text.inverse, opacity: state === 'installing' ? 1 : 0.5 } }}
>
{state === 'installing' ? <CircularProgress size={14} thickness={5} sx={{ color: 'inherit' }} /> : 'Get'}
</Button>
);
}
@@ -6,14 +6,19 @@ import ExtensionIcon from '@mui/icons-material/Extension';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { parseTags, KIND_LABELS, type Listing } from './catalog';
import PackageTagRow from './detail/PackageTagRow';
import InstallPill from './InstallPill';
import type { PillState } from './installs';
interface Props {
listing: Listing;
state: PillState;
onOpen: () => void;
onGet: () => void;
onOpenInstalled: () => void;
onTag: (tag: string) => void;
}
export default function PackageCard({ listing, onOpen, onTag }: Props) {
export default function PackageCard({ listing, state, onOpen, onGet, onOpenInstalled, onTag }: Props) {
const c = useClaudeTokens();
const tags = parseTags(listing.tags).slice(0, 3);
@@ -73,6 +78,7 @@ export default function PackageCard({ listing, onOpen, onTag }: Props) {
{listing.author ? ` · ${listing.author}` : ''}
</Typography>
</Box>
<InstallPill state={state} disabled={!listing.download_url} onGet={onGet} onOpen={onOpenInstalled} size="sm" />
</Stack>
<Typography
@@ -7,18 +7,20 @@ import DialogContent from '@mui/material/DialogContent';
import IconButton from '@mui/material/IconButton';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import CheckRoundedIcon from '@mui/icons-material/CheckRounded';
import CloseIcon from '@mui/icons-material/Close';
import ExtensionIcon from '@mui/icons-material/Extension';
import Inventory2Icon from '@mui/icons-material/Inventory2Outlined';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { KIND_LABELS, parseTags, type Listing } from '../catalog';
import PackageTagRow from './PackageTagRow';
import InstallPill from '../InstallPill';
import type { PillState } from '../installs';
interface Props {
bundle: Listing | null;
members: Listing[];
installedIds: string[];
stateOf: (listingId: string) => PillState;
onOpenInstalled: (listing: Listing) => void;
onClose: () => void;
onOpenMember: (member: Listing) => void;
onInstallAll: () => void;
@@ -28,11 +30,11 @@ interface Props {
// A bundle has no package of its own: the sheet lists it, the dialog installs its members. There is no
// file to download here on purpose; a store page installs, it does not hand out archives.
export default function PackageBundleDialog({ bundle, members, installedIds, onClose, onOpenMember, onInstallAll, onInstallMember, installing }: Props) {
export default function PackageBundleDialog({ bundle, members, stateOf, onOpenInstalled, onClose, onOpenMember, onInstallAll, onInstallMember, installing }: Props) {
const c = useClaudeTokens();
if (!bundle) return null;
const installable = members.filter((m) => m.download_url);
const allInstalled = installable.length > 0 && installable.every((m) => installedIds.includes(m.id));
const allInstalled = installable.length > 0 && installable.every((m) => stateOf(m.id) !== 'get');
const pill = { borderRadius: `${c.radius.full}px`, textTransform: 'none' as const, fontWeight: 600, fontSize: '0.8125rem', px: 2, py: 0.6, minWidth: 0, whiteSpace: 'nowrap' as const };
return (
@@ -75,7 +77,7 @@ export default function PackageBundleDialog({ bundle, members, installedIds, onC
disableElevation
sx={{ ...pill, bgcolor: c.accent.primary, color: c.text.inverse, '&:hover': { bgcolor: c.accent.hover }, '&.Mui-disabled': { bgcolor: c.bg.secondary, color: c.text.muted } }}
>
{installing ? <CircularProgress size={14} thickness={5} sx={{ color: 'inherit' }} /> : allInstalled ? 'Installed' : 'Install all'}
{installing ? <CircularProgress size={14} thickness={5} sx={{ color: 'inherit' }} /> : allInstalled ? 'Installed' : 'Get all'}
</Button>
</Stack>
@@ -94,7 +96,6 @@ export default function PackageBundleDialog({ bundle, members, installedIds, onC
) : (
<Stack spacing={0.75}>
{members.map((m) => {
const done = installedIds.includes(m.id);
return (
<Box
key={m.id}
@@ -115,22 +116,7 @@ export default function PackageBundleDialog({ bundle, members, installedIds, onC
{KIND_LABELS[m.kind] || m.kind || 'Package'}{m.version ? ` · v${m.version}` : ''}
</Typography>
</Box>
{done ? (
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ color: c.text.muted, fontSize: '0.8125rem', flexShrink: 0 }}>
<CheckRoundedIcon sx={{ fontSize: 16 }} />
<span>Installed</span>
</Stack>
) : (
<Button
onClick={(e) => { e.stopPropagation(); onInstallMember(m.id); }}
disabled={installing || !m.download_url}
variant="outlined"
size="small"
sx={{ ...pill, borderColor: c.border.strong, color: c.text.primary, '&:hover': { borderColor: c.text.primary, bgcolor: c.bg.elevated } }}
>
Install
</Button>
)}
<InstallPill state={stateOf(m.id)} disabled={!m.download_url} onGet={() => onInstallMember(m.id)} onOpen={() => onOpenInstalled(m)} size="sm" />
</Box>
);
})}
@@ -1,13 +1,10 @@
import React from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import CircularProgress from '@mui/material/CircularProgress';
import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent';
import IconButton from '@mui/material/IconButton';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import CheckRoundedIcon from '@mui/icons-material/CheckRounded';
import CloseIcon from '@mui/icons-material/Close';
import ExtensionIcon from '@mui/icons-material/Extension';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
@@ -15,19 +12,21 @@ import { KIND_LABELS, parseTags, type Listing } from '../catalog';
import { detailsForListing } from '../notionDetails';
import PackageDetails from './PackageDetails';
import PackageTagRow from './PackageTagRow';
import InstallPill from '../InstallPill';
import type { PillState } from '../installs';
import PackageVideoSection from './PackageVideoSection';
interface Props {
listing: Listing | null;
onClose: () => void;
state: PillState;
onInstall: () => void;
installing: boolean;
installed?: boolean;
onOpen: () => void;
}
// The package sheet: one Install action, no file to download. The bundle is fetched and reviewed by the
// same import path a dropped .swarm takes, so the raw archive has no job on a store page.
export default function PackageDialog({ listing, onClose, onInstall, installing, installed }: Props) {
export default function PackageDialog({ listing, onClose, state, onInstall, onOpen }: Props) {
const c = useClaudeTokens();
if (!listing) return null;
const details = detailsForListing(listing);
@@ -70,16 +69,7 @@ export default function PackageDialog({ listing, onClose, onInstall, installing,
</Typography>
<Typography sx={{ mt: 0.4, fontSize: '0.8125rem', color: c.text.muted }}>{meta}</Typography>
</Box>
<Button
onClick={onInstall}
variant="contained"
disableElevation
disabled={installing || !!installed || !listing.download_url}
startIcon={installed ? <CheckRoundedIcon sx={{ fontSize: 16 }} /> : undefined}
sx={{ borderRadius: `${c.radius.full}px`, textTransform: 'none', fontWeight: 600, fontSize: '0.8125rem', px: 2.25, py: 0.6, minWidth: 0, whiteSpace: 'nowrap', bgcolor: c.accent.primary, color: c.text.inverse, '&:hover': { bgcolor: c.accent.hover }, '&.Mui-disabled': { bgcolor: c.bg.secondary, color: c.text.muted } }}
>
{installing ? <CircularProgress size={14} thickness={5} sx={{ color: 'inherit' }} /> : installed ? 'Installed' : 'Install'}
</Button>
<InstallPill state={state} disabled={!listing.download_url} onGet={onInstall} onOpen={onOpen} />
</Stack>
{listing.description && (
@@ -0,0 +1,24 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { installState } from './installs';
import type { Listing } from './catalog';
// The store's one button: Get until the machine remembers an install, Open while the thing it became still exists.
const listing = { id: 'git-graph', kind: 'app' } as Listing;
const rec = { listing_id: 'git-graph', root_type: 'app', output_id: 'out-1', version: '1.0.0', installed_at: 1 };
const none = { outputs: {}, skills: {}, workflows: {} };
test('no record means Get', () => {
assert.equal(installState(listing, undefined, none), 'get');
});
test('a recorded app is Open while its output exists, and Get again once it is gone', () => {
assert.equal(installState(listing, rec, { ...none, outputs: { 'out-1': {} } }), 'open');
assert.equal(installState(listing, rec, none), 'get');
});
test('skills and workflows open through their own lists; other kinds just read Installed', () => {
assert.equal(installState(listing, { listing_id: 'x', root_type: 'skill', skill_id: 'sk', version: '', installed_at: 1 }, { ...none, skills: { sk: {} } }), 'open');
assert.equal(installState(listing, { listing_id: 'x', root_type: 'workflow', workflow_id: 'wf', version: '', installed_at: 1 }, { ...none, workflows: { wf: {} } }), 'open');
assert.equal(installState(listing, { listing_id: 'x', root_type: 'mode', session_id: 'm', version: '', installed_at: 1 }, none), 'installed');
});
@@ -0,0 +1,60 @@
import { API_BASE } from '@/shared/config';
import type { Listing } from './catalog';
// One typed pointer per importable root, mirroring the backend record.
export interface InstallRecord {
listing_id: string;
root_type: string;
output_id?: string | null;
skill_id?: string | null;
workflow_id?: string | null;
dashboard_id?: string | null;
session_id?: string | null;
version: string;
installed_at: number;
}
export function recordFor(listingId: string, rootType: string, rootId: string, version: string): Omit<InstallRecord, 'installed_at'> {
const rec: Omit<InstallRecord, 'installed_at'> = { listing_id: listingId, root_type: rootType, version };
if (rootType === 'app') rec.output_id = rootId;
else if (rootType === 'skill') rec.skill_id = rootId;
else if (rootType === 'workflow') rec.workflow_id = rootId;
else if (rootType === 'dashboard') rec.dashboard_id = rootId;
else if (rootType === 'session' || rootType === 'mode') rec.session_id = rootId;
return rec;
}
export type PillState = 'get' | 'installing' | 'open' | 'installed';
interface LiveEntities {
outputs: Record<string, unknown>;
skills: Record<string, unknown>;
workflows: Record<string, unknown>;
}
// Get until the record says otherwise; Open only while the thing it became still exists, so a deleted app offers Get again instead of an Open that goes nowhere.
export function installState(listing: Listing, record: InstallRecord | undefined, live: LiveEntities): PillState {
if (!record) return 'get';
if (record.output_id) return record.output_id in live.outputs ? 'open' : 'get';
if (record.skill_id) return record.skill_id in live.skills ? 'open' : 'get';
if (record.workflow_id) return record.workflow_id in live.workflows ? 'open' : 'get';
return 'installed';
}
export async function fetchInstalls(): Promise<Record<string, InstallRecord>> {
const res = await fetch(`${API_BASE}/marketplace/installed`);
if (!res.ok) return {};
const body = (await res.json()) as { installs: Record<string, InstallRecord> };
return body.installs || {};
}
export async function recordInstall(rec: Omit<InstallRecord, 'installed_at'>): Promise<Record<string, InstallRecord>> {
const res = await fetch(`${API_BASE}/marketplace/installed`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...rec, installed_at: 0 }),
});
if (!res.ok) throw new Error("Installed, but the store couldn't remember it.");
const body = (await res.json()) as { installs: Record<string, InstallRecord> };
return body.installs || {};
}