From e1b8d4b17ded63ffc393c54691f757b283d3b24b Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 5 Aug 2026 07:43:51 -0700 Subject: [PATCH] [eric] marketplace: pixel pass against live claude.ai, sectioned Filter by checks, exact sort labels, Add menus, connection tabs --- .../Directory/DirectoryConnectorsTab.tsx | 24 ++- .../pages/Directory/DirectoryFilterBar.tsx | 83 +++++++---- .../pages/Directory/DirectorySkillsTab.tsx | 40 ++--- frontend/src/app/pages/Skills/Skills.tsx | 141 ++++++++---------- frontend/src/app/pages/Tools/Tools.tsx | 35 ++++- 5 files changed, 182 insertions(+), 141 deletions(-) diff --git a/frontend/src/app/pages/Directory/DirectoryConnectorsTab.tsx b/frontend/src/app/pages/Directory/DirectoryConnectorsTab.tsx index ddedebdc..4be2e5ca 100644 --- a/frontend/src/app/pages/Directory/DirectoryConnectorsTab.tsx +++ b/frontend/src/app/pages/Directory/DirectoryConnectorsTab.tsx @@ -28,8 +28,9 @@ const DirectoryConnectorsTab: React.FC = ({ onOpenInstalled }) => { const dispatch = useAppDispatch(); const tools = useAppSelector((s) => s.tools.items); const [query, setQuery] = useState(''); - const [filter, setFilter] = useState('all'); + const [filterSelected, setFilterSelected] = useState(['installed', 'not-installed']); const [sort, setSort] = useState('popular'); + const toggleFilter = (value: string) => setFilterSelected((p) => (p.includes(value) ? p.filter((v) => v !== value) : [...p, value])); const [installingId, setInstallingId] = useState(null); const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity: 'success' | 'error' }>({ open: false, message: '', severity: 'success' }); @@ -45,11 +46,10 @@ const DirectoryConnectorsTab: React.FC = ({ onOpenInstalled }) => { const q = query.trim().toLowerCase(); let out = INTEGRATIONS.filter((ig) => !q || ig.name.toLowerCase().includes(q) || ig.description.toLowerCase().includes(q)); - if (filter === 'installed') out = out.filter((ig) => !!installedToolByName[ig.name]); - else if (filter === 'not-installed') out = out.filter((ig) => !installedToolByName[ig.name]); + out = out.filter((ig) => (installedToolByName[ig.name] ? filterSelected.includes('installed') : filterSelected.includes('not-installed'))); if (sort === 'name') out = [...out].sort((a, b) => a.name.localeCompare(b.name)); return out; - }, [query, filter, sort, installedToolByName]); + }, [query, filterSelected, sort, installedToolByName]); const popular = useMemo( () => POPULAR_IDS.map((id) => INTEGRATIONS.find((ig) => ig.id === id)).filter((ig): ig is Integration => !!ig), @@ -100,16 +100,14 @@ const DirectoryConnectorsTab: React.FC = ({ onOpenInstalled }) => { chipLabel="Anthropic & Partners" query={query} onQuery={setQuery} - filterOptions={[ - { value: 'all', label: 'All connectors' }, - { value: 'installed', label: 'Installed' }, - { value: 'not-installed', label: 'Not installed' }, + filterSections={[ + { label: 'Status', options: [{ value: 'installed', label: 'Installed' }, { value: 'not-installed', label: 'Not installed' }] }, ]} - filterValue={filter} - onFilter={setFilter} + filterSelected={filterSelected} + onToggleFilter={toggleFilter} sortOptions={[ - { value: 'popular', label: 'Popular' }, - { value: 'name', label: 'Name' }, + { value: 'popular', label: 'Default' }, + { value: 'name', label: 'Alphabetical' }, ]} sortValue={sort} onSort={setSort} @@ -120,7 +118,7 @@ const DirectoryConnectorsTab: React.FC = ({ onOpenInstalled }) => { '&::-webkit-scrollbar': { width: 6 }, '&::-webkit-scrollbar-thumb': { background: c.border.medium, borderRadius: 3 }, }}> - {!query.trim() && filter === 'all' && ( + {!query.trim() && filterSelected.length === 2 && ( <> Popular diff --git a/frontend/src/app/pages/Directory/DirectoryFilterBar.tsx b/frontend/src/app/pages/Directory/DirectoryFilterBar.tsx index a7fd3f9b..bc949806 100644 --- a/frontend/src/app/pages/Directory/DirectoryFilterBar.tsx +++ b/frontend/src/app/pages/Directory/DirectoryFilterBar.tsx @@ -15,48 +15,77 @@ export interface PickerOption { label: string; } -interface PickerProps { +export interface FilterSection { label: string; options: PickerOption[]; - value: string; - onChange: (value: string) => void; } -const DropdownPill: React.FC = ({ label, options, value, onChange }) => { +const pillSx = (c: ReturnType) => ({ + display: 'flex', alignItems: 'center', gap: 0.75, px: 1.75, py: 0.9, + borderRadius: `${c.radius.md}px`, border: `1px solid ${c.border.medium}`, + cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap', + '&:hover': { borderColor: c.border.strong, bgcolor: c.bg.elevated }, +}); + +// claude.ai's Filter pill: always reads "Filter by"; state lives in the menu as checkable rows under section headers. +const FilterPill: React.FC<{ sections: FilterSection[]; selected: string[]; onToggle: (value: string) => void }> = ({ sections, selected, onToggle }) => { const c = useClaudeTokens(); const [anchor, setAnchor] = useState(null); - const active = options.find((o) => o.value === value); return ( <> - ) => setAnchor(e.currentTarget)} - sx={{ - display: 'flex', alignItems: 'center', gap: 0.75, px: 1.75, py: 0.9, - borderRadius: `${c.radius.md}px`, border: `1px solid ${c.border.medium}`, - cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap', - '&:hover': { borderColor: c.border.strong, bgcolor: c.bg.elevated }, - }} - > - - {active && active.value !== options[0].value ? active.label : label} - + ) => setAnchor(e.currentTarget)} sx={pillSx(c)}> + Filter by setAnchor(null)} - PaperProps={{ sx: { bgcolor: c.bg.surface, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, mt: 0.5, minWidth: 170 } }} + PaperProps={{ sx: { bgcolor: c.bg.surface, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, mt: 0.5, minWidth: 190 } }} + > + {sections.flatMap((section, i) => [ + + {section.label} + , + ...section.options.map((o) => ( + onToggle(o.value)} + sx={{ fontSize: '0.875rem', color: c.text.primary, display: 'flex', justifyContent: 'space-between', gap: 2, '&:hover': { bgcolor: c.bg.secondary } }} + > + {o.label} + + + )), + ])} + + + ); +}; + +const SortPill: React.FC<{ options: PickerOption[]; value: string; onChange: (value: string) => void }> = ({ options, value, onChange }) => { + const c = useClaudeTokens(); + const [anchor, setAnchor] = useState(null); + return ( + <> + ) => setAnchor(e.currentTarget)} sx={pillSx(c)}> + Sort by + + + setAnchor(null)} + PaperProps={{ sx: { bgcolor: c.bg.surface, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, mt: 0.5, minWidth: 190 } }} > {options.map((o) => ( { onChange(o.value); setAnchor(null); }} - sx={{ fontSize: '0.875rem', color: c.text.primary, gap: 1, '&:hover': { bgcolor: c.bg.secondary } }} + sx={{ fontSize: '0.875rem', color: c.text.primary, display: 'flex', justifyContent: 'space-between', gap: 2, '&:hover': { bgcolor: c.bg.secondary } }} > - {o.value === value && } {o.label} + ))} @@ -69,9 +98,9 @@ interface Props { chipLabel: string; query: string; onQuery: (q: string) => void; - filterOptions: PickerOption[]; - filterValue: string; - onFilter: (v: string) => void; + filterSections: FilterSection[]; + filterSelected: string[]; + onToggleFilter: (value: string) => void; sortOptions: PickerOption[]; sortValue: string; onSort: (v: string) => void; @@ -80,7 +109,7 @@ interface Props { // The Directory's search row + chip/filter row, shared by both tabs (same chrome on claude.ai). const DirectoryFilterBar: React.FC = ({ searchPlaceholder, chipLabel, query, onQuery, - filterOptions, filterValue, onFilter, sortOptions, sortValue, onSort, + filterSections, filterSelected, onToggleFilter, sortOptions, sortValue, onSort, }) => { const c = useClaudeTokens(); return ( @@ -112,8 +141,8 @@ const DirectoryFilterBar: React.FC = ({ {chipLabel} - - + + diff --git a/frontend/src/app/pages/Directory/DirectorySkillsTab.tsx b/frontend/src/app/pages/Directory/DirectorySkillsTab.tsx index 6e29314d..b1d479f3 100644 --- a/frontend/src/app/pages/Directory/DirectorySkillsTab.tsx +++ b/frontend/src/app/pages/Directory/DirectorySkillsTab.tsx @@ -49,9 +49,10 @@ const DirectorySkillsTab: React.FC = ({ onOpenInstalled }) => { const localSkills = useAppSelector((s) => s.skills.items); const [query, setQuery] = useState(''); - // Defaults to the Anthropic set, mirroring claude.ai's Directory landing view. - const [filter, setFilter] = useState('anthropic'); + // claude.ai grammar: Filter by is checkable sections. Community starts unchecked so the landing view is the Anthropic set, same as claude.ai's. + const [filterSelected, setFilterSelected] = useState(['installed', 'not-installed', 'anthropic']); const [sort, setSort] = useState('popular'); + const toggleFilter = (value: string) => setFilterSelected((p) => (p.includes(value) ? p.filter((v) => v !== value) : [...p, value])); const [community, setCommunity] = useState([]); const [communityLoading, setCommunityLoading] = useState(false); const [installingKey, setInstallingKey] = useState(null); @@ -98,7 +99,7 @@ const DirectorySkillsTab: React.FC = ({ onOpenInstalled }) => { // Join curated skills to their skills.sh twin (source anthropics/skills) so Anthropic cards get real install counts. const communityByName = new Map(community.map((cs) => [cs.name.trim().toLowerCase(), cs])); const out: SkillCardModel[] = []; - if (filter !== 'community') { + if (filterSelected.includes('anthropic')) { for (const sk of curated) { if (q && !sk.name.toLowerCase().includes(q) && !sk.description.toLowerCase().includes(q)) continue; const twin = communityByName.get(sk.name.trim().toLowerCase()); @@ -113,7 +114,7 @@ const DirectorySkillsTab: React.FC = ({ onOpenInstalled }) => { }); } } - if (filter !== 'anthropic') { + if (filterSelected.includes('community')) { const curatedNames = new Set(curated.map((sk) => sk.name.trim().toLowerCase())); for (const cs of community) { // Anthropic-sourced twins already render as curated cards; listing them twice reads as duplicates. @@ -131,10 +132,16 @@ const DirectorySkillsTab: React.FC = ({ onOpenInstalled }) => { }); } } - if (sort === 'name') out.sort((a, b) => a.slug.localeCompare(b.slug)); - else out.sort((a, b) => (b.installs ?? -1) - (a.installs ?? -1)); - return out; - }, [curated, community, query, filter, sort]); + const statusOk = (installed: boolean): boolean => (installed ? filterSelected.includes('installed') : filterSelected.includes('not-installed')); + const withStatus = out.filter((card) => statusOk(installedNames.has((card.curated?.name ?? card.community?.name ?? '').trim().toLowerCase()))); + if (sort === 'name') withStatus.sort((a, b) => a.slug.localeCompare(b.slug)); + else { + // claude.ai's landing order: installed first, then popularity descending. + const rank = (card: SkillCardModel): number => (installedNames.has((card.curated?.name ?? card.community?.name ?? '').trim().toLowerCase()) ? 1 : 0); + withStatus.sort((a, b) => rank(b) - rank(a) || (b.installs ?? -1) - (a.installs ?? -1)); + } + return withStatus; + }, [curated, community, query, filterSelected, sort, installedNames]); const handleInstallCurated = async (card: SkillCardModel) => { if (!card.curated) return; @@ -160,19 +167,18 @@ const DirectorySkillsTab: React.FC = ({ onOpenInstalled }) => { { const [builderOpen, setBuilderOpen] = useState(false); const [directoryOpen, setDirectoryOpen] = useState(false); const [uploadOpen, setUploadOpen] = useState(false); + const [addMenuAnchor, setAddMenuAnchor] = useState(null); const handleBuilderPreview = useCallback((data: SkillPreviewData | null) => { setBuilderPreview(data); @@ -350,88 +351,63 @@ const Skills: React.FC = () => { bgcolor: c.bg.secondary, }} > - {/* The Settings pane header already says Skills; this row is just the action strip. */} - - - - window.dispatchEvent(new CustomEvent(IMPORT_OPEN_EVENT))} - sx={{ color: c.text.tertiary, '&:hover': { color: c.text.primary } }} - > - - - - - setSearchFilter((p) => (p === '' ? ' ' : ''))} - sx={{ color: c.text.tertiary, '&:hover': { color: c.text.primary } }} - > - - - - - setUploadOpen(true)} - sx={{ color: c.text.tertiary, '&:hover': { color: c.text.primary } }} - > - - - - - - - - - - - + {/* claude.ai's Skills header grammar: search icon, Browse, Add menu (Create with Claude / Write skill instructions / Upload a skill), plus our Import .swarm row. */} + + + setSearchFilter((p) => (p === '' ? ' ' : ''))} + sx={{ color: c.text.tertiary, '&:hover': { color: c.text.primary } }} + > + + + - - - + + setAddMenuAnchor(null)} + PaperProps={{ sx: { bgcolor: c.bg.surface, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, mt: 0.5, minWidth: 220 } }} + > + { setAddMenuAnchor(null); setBuilderOpen(true); }} sx={{ fontSize: '0.875rem', color: c.text.primary, gap: 1.5, '&:hover': { bgcolor: c.bg.secondary } }}> + + Create with Claude + + { setAddMenuAnchor(null); openCreate(); }} sx={{ fontSize: '0.875rem', color: c.text.primary, gap: 1.5, '&:hover': { bgcolor: c.bg.secondary } }}> + + Write skill instructions + + { setAddMenuAnchor(null); setUploadOpen(true); }} sx={{ fontSize: '0.875rem', color: c.text.primary, gap: 1.5, '&:hover': { bgcolor: c.bg.secondary } }}> + + Upload a skill + + { setAddMenuAnchor(null); window.dispatchEvent(new CustomEvent(IMPORT_OPEN_EVENT)); }} sx={{ fontSize: '0.875rem', color: c.text.primary, gap: 1.5, '&:hover': { bgcolor: c.bg.secondary } }}> + + Import .swarm + + @@ -776,15 +752,16 @@ const Skills: React.FC = () => { }} > - {editingId ? 'Edit Skill' : 'New Skill'} + {editingId ? 'Edit skill' : 'Write skill instructions'} setForm({ ...form, name: e.target.value })} fullWidth size="small" + placeholder="weekly-status-report" sx={{ '& .MuiOutlinedInput-root': { bgcolor: c.bg.secondary } }} /> { onChange={(e) => setForm({ ...form, description: e.target.value })} fullWidth size="small" + placeholder="Generate weekly status reports from recent work. Use when asked for updates or progress summaries." sx={{ '& .MuiOutlinedInput-root': { bgcolor: c.bg.secondary } }} /> { sx={{ '& .MuiOutlinedInput-root': { bgcolor: c.bg.secondary } }} /> setForm({ ...form, content: e.target.value })} fullWidth multiline minRows={12} maxRows={24} + placeholder="Summarize my recent work in three sections: wins, blockers, and next steps. Keep the tone professional but not stiff..." sx={{ '& .MuiOutlinedInput-root': { bgcolor: c.bg.secondary, fontFamily: c.font.mono, fontSize: '0.875rem', @@ -832,7 +811,7 @@ const Skills: React.FC = () => { textTransform: 'none', borderRadius: `${c.radius.md}px`, }} > - {editingId ? 'Save Changes' : 'Create Skill'} + {editingId ? 'Save Changes' : 'Create'} diff --git a/frontend/src/app/pages/Tools/Tools.tsx b/frontend/src/app/pages/Tools/Tools.tsx index 59fb6734..aa7087a9 100644 --- a/frontend/src/app/pages/Tools/Tools.tsx +++ b/frontend/src/app/pages/Tools/Tools.tsx @@ -75,6 +75,17 @@ const Tools: React.FC = () => { const [menuAnchor, setMenuAnchor] = useState(null); const [directoryOpen, setDirectoryOpen] = useState(false); const [customConnectorOpen, setCustomConnectorOpen] = useState(false); + // claude.ai's Connectors page tabs: All / Connected / Not connected. + const [connFilter, setConnFilter] = useState<'all' | 'connected' | 'not-connected'>('all'); + const visibleTools = useMemo(() => { + if (connFilter === 'connected') return tools.filter((t) => t.enabled !== false); + if (connFilter === 'not-connected') return tools.filter((t) => t.enabled === false); + return tools; + }, [tools, connFilter]); + const visibleGallery = useMemo( + () => (connFilter === 'connected' ? [] : uninstalledIntegrations), + [uninstalledIntegrations, connFilter], + ); useEffect(() => { dispatch(fetchTools()); @@ -121,7 +132,7 @@ const Tools: React.FC = () => { onClick={handleMenuOpen} sx={{ bgcolor: c.accent.primary, '&:hover': { bgcolor: c.accent.pressed }, textTransform: 'none', borderRadius: 2, fontSize: '0.8125rem' }} > - New Tool + Add { {tools.length + uninstalledIntegrations.length} {customSectionOpen ? : } + e.stopPropagation()}> + {([['all', 'All'], ['connected', 'Connected'], ['not-connected', 'Not connected']] as const).map(([value, label]) => ( + setConnFilter(value)} + sx={{ + px: 1.25, py: 0.3, borderRadius: 999, cursor: 'pointer', userSelect: 'none', + fontSize: '0.75rem', fontWeight: 600, lineHeight: 1.6, + color: connFilter === value ? c.text.primary : c.text.tertiary, + bgcolor: connFilter === value ? c.bg.secondary : 'transparent', + '&:hover': { color: c.text.primary }, + }} + > + {label} + + ))} + {loading ? ( @@ -217,7 +246,7 @@ const Tools: React.FC = () => { ) : ( - {uninstalledIntegrations.map((ig) => ( + {visibleGallery.map((ig) => ( { onToggle={a.handleIntegrationToggle} /> ))} - {tools.map((tool) => ( + {visibleTools.map((tool) => (