[eric] marketplace: pixel pass against live claude.ai, sectioned Filter by checks, exact sort labels, Add menus, connection tabs

This commit is contained in:
ciregenz
2026-08-05 07:43:51 -07:00
parent 55749f54d0
commit e1b8d4b17d
5 changed files with 182 additions and 141 deletions
@@ -28,8 +28,9 @@ const DirectoryConnectorsTab: React.FC<Props> = ({ onOpenInstalled }) => {
const dispatch = useAppDispatch();
const tools = useAppSelector((s) => s.tools.items);
const [query, setQuery] = useState('');
const [filter, setFilter] = useState('all');
const [filterSelected, setFilterSelected] = useState<string[]>(['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<string | null>(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<Props> = ({ 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<Props> = ({ 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<Props> = ({ onOpenInstalled }) => {
'&::-webkit-scrollbar': { width: 6 },
'&::-webkit-scrollbar-thumb': { background: c.border.medium, borderRadius: 3 },
}}>
{!query.trim() && filter === 'all' && (
{!query.trim() && filterSelected.length === 2 && (
<>
<Typography sx={{ fontSize: '0.75rem', fontWeight: 600, letterSpacing: '0.06em', color: c.text.tertiary, textTransform: 'uppercase', mb: 1.25 }}>
Popular
@@ -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<PickerProps> = ({ label, options, value, onChange }) => {
const pillSx = (c: ReturnType<typeof useClaudeTokens>) => ({
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 | HTMLElement>(null);
const active = options.find((o) => o.value === value);
return (
<>
<Box
role="button"
onClick={(e: React.MouseEvent<HTMLElement>) => 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 },
}}
>
<Typography sx={{ fontSize: '0.9375rem', color: c.text.primary }}>
{active && active.value !== options[0].value ? active.label : label}
</Typography>
<Box role="button" onClick={(e: React.MouseEvent<HTMLElement>) => setAnchor(e.currentTarget)} sx={pillSx(c)}>
<Typography sx={{ fontSize: '0.9375rem', color: c.text.primary }}>Filter by</Typography>
<KeyboardArrowDownIcon sx={{ fontSize: 18, color: c.text.tertiary }} />
</Box>
<Menu
anchorEl={anchor}
open={!!anchor}
onClose={() => 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) => [
<Typography key={`h-${section.label}`} sx={{ px: 2, pt: i === 0 ? 0.75 : 1.25, pb: 0.25, fontSize: '0.75rem', color: c.text.tertiary }}>
{section.label}
</Typography>,
...section.options.map((o) => (
<MenuItem
key={o.value}
onClick={() => 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}
<CheckIcon sx={{ fontSize: 16, color: '#3b82f6', visibility: selected.includes(o.value) ? 'visible' : 'hidden' }} />
</MenuItem>
)),
])}
</Menu>
</>
);
};
const SortPill: React.FC<{ options: PickerOption[]; value: string; onChange: (value: string) => void }> = ({ options, value, onChange }) => {
const c = useClaudeTokens();
const [anchor, setAnchor] = useState<null | HTMLElement>(null);
return (
<>
<Box role="button" onClick={(e: React.MouseEvent<HTMLElement>) => setAnchor(e.currentTarget)} sx={pillSx(c)}>
<Typography sx={{ fontSize: '0.9375rem', color: c.text.primary }}>Sort by</Typography>
<KeyboardArrowDownIcon sx={{ fontSize: 18, color: c.text.tertiary }} />
</Box>
<Menu
anchorEl={anchor}
open={!!anchor}
onClose={() => 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) => (
<MenuItem
key={o.value}
onClick={() => { 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 } }}
>
<Box sx={{ width: 18, display: 'flex' }}>{o.value === value && <CheckIcon sx={{ fontSize: 16, color: c.text.secondary }} />}</Box>
{o.label}
<CheckIcon sx={{ fontSize: 16, color: '#3b82f6', visibility: o.value === value ? 'visible' : 'hidden' }} />
</MenuItem>
))}
</Menu>
@@ -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<Props> = ({
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<Props> = ({
<Typography sx={{ fontSize: '0.9375rem', fontWeight: 500, color: c.text.primary, whiteSpace: 'nowrap' }}>{chipLabel}</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<DropdownPill label="Filter by" options={filterOptions} value={filterValue} onChange={onFilter} />
<DropdownPill label="Sort by" options={sortOptions} value={sortValue} onChange={onSort} />
<FilterPill sections={filterSections} selected={filterSelected} onToggle={onToggleFilter} />
<SortPill options={sortOptions} value={sortValue} onChange={onSort} />
</Box>
</Box>
</Box>
@@ -49,9 +49,10 @@ const DirectorySkillsTab: React.FC<Props> = ({ 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<string[]>(['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<CommunitySkill[]>([]);
const [communityLoading, setCommunityLoading] = useState(false);
const [installingKey, setInstallingKey] = useState<string | null>(null);
@@ -98,7 +99,7 @@ const DirectorySkillsTab: React.FC<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ onOpenInstalled }) => {
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, height: '100%', minHeight: 0 }}>
<DirectoryFilterBar
searchPlaceholder="Search skills..."
chipLabel={filter === 'community' ? 'Community' : filter === 'all' ? 'All skills' : 'Anthropic'}
chipLabel={filterSelected.includes('community') ? (filterSelected.includes('anthropic') ? 'Anthropic & Community' : 'Community') : 'Anthropic'}
query={query}
onQuery={setQuery}
filterOptions={[
{ value: 'all', label: 'All skills' },
{ value: 'anthropic', label: 'Anthropic' },
{ value: 'community', label: 'Community' },
filterSections={[
{ label: 'Status', options: [{ value: 'installed', label: 'Installed' }, { value: 'not-installed', label: 'Not installed' }] },
{ label: 'Source', options: [{ value: 'anthropic', label: 'Anthropic' }, { value: 'community', label: 'Community' }] },
]}
filterValue={filter}
onFilter={setFilter}
filterSelected={filterSelected}
onToggleFilter={toggleFilter}
sortOptions={[
{ value: 'popular', label: 'Popular' },
{ value: 'name', label: 'Name' },
{ value: 'popular', label: 'Most popular' },
{ value: 'name', label: 'Name A-Z' },
]}
sortValue={sort}
onSort={setSort}
+60 -81
View File
@@ -17,7 +17,8 @@ import Alert from '@mui/material/Alert';
import InputAdornment from '@mui/material/InputAdornment';
import ToggleButton from '@mui/material/ToggleButton';
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
import AddIcon from '@mui/icons-material/Add';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
import TerminalIcon from '@mui/icons-material/Terminal';
@@ -60,7 +61,6 @@ import UploadFileIcon from '@mui/icons-material/UploadFile';
import SkillBuilderChat, { SkillPreviewData } from './SkillBuilderChat';
import DirectoryDialog from '../Directory/DirectoryDialog';
import UploadSkillDialog from '../Directory/UploadSkillDialog';
import StorefrontOutlinedIcon from '@mui/icons-material/StorefrontOutlined';
import DriveFolderUploadOutlinedIcon from '@mui/icons-material/DriveFolderUploadOutlined';
interface SkillForm {
@@ -107,6 +107,7 @@ const Skills: React.FC = () => {
const [builderOpen, setBuilderOpen] = useState(false);
const [directoryOpen, setDirectoryOpen] = useState(false);
const [uploadOpen, setUploadOpen] = useState(false);
const [addMenuAnchor, setAddMenuAnchor] = useState<null | HTMLElement>(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. */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', px: 2, pt: 1.5, pb: 0.5 }}>
<Box sx={{ display: 'flex', gap: 0.25 }}>
<Tooltip title="Import .swarm">
<IconButton
size="small"
onClick={() => window.dispatchEvent(new CustomEvent(IMPORT_OPEN_EVENT))}
sx={{ color: c.text.tertiary, '&:hover': { color: c.text.primary } }}
>
<UploadFileIcon sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
<Tooltip title="Search">
<IconButton
size="small"
onClick={() => setSearchFilter((p) => (p === '' ? ' ' : ''))}
sx={{ color: c.text.tertiary, '&:hover': { color: c.text.primary } }}
>
<SearchIcon sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
<Tooltip title="Upload skill">
<IconButton
size="small"
onClick={() => setUploadOpen(true)}
sx={{ color: c.text.tertiary, '&:hover': { color: c.text.primary } }}
>
<DriveFolderUploadOutlinedIcon sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
<Tooltip title="Create skill">
<IconButton size="small" onClick={openCreate} sx={{ color: c.text.tertiary, '&:hover': { color: c.text.primary } }}>
<AddIcon sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
</Box>
</Box>
<Box sx={{ px: 1.5, pb: 0.5 }}>
{/* 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. */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 0.75, px: 1.5, pt: 1.5, pb: 1 }}>
<Tooltip title="Search">
<IconButton
size="small"
onClick={() => setSearchFilter((p) => (p === '' ? ' ' : ''))}
sx={{ color: c.text.tertiary, '&:hover': { color: c.text.primary } }}
>
<SearchIcon sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
<Button
size="small"
startIcon={<AutoFixHighIcon sx={{ fontSize: 14 }} />}
onClick={() => setBuilderOpen(true)}
fullWidth
sx={{
textTransform: 'none',
fontSize: '0.875rem',
fontWeight: 500,
color: c.accent.primary,
justifyContent: 'center',
gap: 0.5,
py: 0.8,
px: 1.5,
borderRadius: 999,
border: `1px solid ${c.accent.primary}40`,
'&:hover': { bgcolor: `${c.accent.primary}10`, borderColor: c.accent.primary },
}}
>
Build with AI
</Button>
</Box>
<Box sx={{ px: 1.5, pb: 0.5 }}>
<Button
size="small"
startIcon={<StorefrontOutlinedIcon sx={{ fontSize: 14 }} />}
onClick={() => setDirectoryOpen(true)}
fullWidth
sx={{
textTransform: 'none',
fontSize: '0.875rem',
fontWeight: 600,
color: c.bg.surface,
bgcolor: c.text.primary,
justifyContent: 'center',
gap: 0.5,
py: 0.8,
px: 1.5,
borderRadius: 999,
'&:hover': { bgcolor: c.text.secondary },
textTransform: 'none', fontSize: '0.8125rem', fontWeight: 600, px: 1.5, py: 0.4,
color: c.text.primary, bgcolor: c.bg.secondary, borderRadius: `${c.radius.md}px`,
'&:hover': { bgcolor: c.bg.elevated },
}}
>
Browse directory
Browse
</Button>
<Button
size="small"
endIcon={<KeyboardArrowDownIcon sx={{ fontSize: 16 }} />}
onClick={(e: React.MouseEvent<HTMLElement>) => setAddMenuAnchor(e.currentTarget)}
sx={{
textTransform: 'none', fontSize: '0.8125rem', fontWeight: 600, px: 1.5, py: 0.4,
color: c.text.primary, bgcolor: c.bg.secondary, borderRadius: `${c.radius.md}px`,
'&:hover': { bgcolor: c.bg.elevated },
}}
>
Add
</Button>
<Menu
anchorEl={addMenuAnchor}
open={!!addMenuAnchor}
onClose={() => setAddMenuAnchor(null)}
PaperProps={{ sx: { bgcolor: c.bg.surface, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, mt: 0.5, minWidth: 220 } }}
>
<MenuItem onClick={() => { setAddMenuAnchor(null); setBuilderOpen(true); }} sx={{ fontSize: '0.875rem', color: c.text.primary, gap: 1.5, '&:hover': { bgcolor: c.bg.secondary } }}>
<AutoFixHighIcon sx={{ fontSize: 16, color: c.text.tertiary }} />
Create with Claude
</MenuItem>
<MenuItem onClick={() => { setAddMenuAnchor(null); openCreate(); }} sx={{ fontSize: '0.875rem', color: c.text.primary, gap: 1.5, '&:hover': { bgcolor: c.bg.secondary } }}>
<DescriptionIcon sx={{ fontSize: 16, color: c.text.tertiary }} />
Write skill instructions
</MenuItem>
<MenuItem onClick={() => { setAddMenuAnchor(null); setUploadOpen(true); }} sx={{ fontSize: '0.875rem', color: c.text.primary, gap: 1.5, '&:hover': { bgcolor: c.bg.secondary } }}>
<DriveFolderUploadOutlinedIcon sx={{ fontSize: 16, color: c.text.tertiary }} />
Upload a skill
</MenuItem>
<MenuItem onClick={() => { 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 } }}>
<UploadFileIcon sx={{ fontSize: 16, color: c.text.tertiary }} />
Import .swarm
</MenuItem>
</Menu>
</Box>
<Collapse in={searchFilter !== ''} timeout={0} unmountOnExit>
@@ -776,15 +752,16 @@ const Skills: React.FC = () => {
}}
>
<DialogTitle sx={{ color: c.text.primary, fontWeight: 600, fontFamily: c.font.sans }}>
{editingId ? 'Edit Skill' : 'New Skill'}
{editingId ? 'Edit skill' : 'Write skill instructions'}
</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '8px !important' }}>
<TextField
label="Name"
label="Skill name"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
fullWidth
size="small"
placeholder="weekly-status-report"
sx={{ '& .MuiOutlinedInput-root': { bgcolor: c.bg.secondary } }}
/>
<TextField
@@ -793,6 +770,7 @@ const Skills: React.FC = () => {
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 } }}
/>
<TextField
@@ -805,13 +783,14 @@ const Skills: React.FC = () => {
sx={{ '& .MuiOutlinedInput-root': { bgcolor: c.bg.secondary } }}
/>
<TextField
label="Content (Markdown)"
label="Instructions"
value={form.content}
onChange={(e) => 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'}
</Button>
</DialogActions>
</Dialog>
+32 -3
View File
@@ -75,6 +75,17 @@ const Tools: React.FC = () => {
const [menuAnchor, setMenuAnchor] = useState<null | HTMLElement>(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
</Button>
<Menu
anchorEl={menuAnchor}
@@ -202,6 +213,24 @@ const Tools: React.FC = () => {
</Typography>
<Typography sx={{ color: c.text.ghost, fontSize: '0.6875rem', fontWeight: 600 }}>{tools.length + uninstalledIntegrations.length}</Typography>
{customSectionOpen ? <KeyboardArrowDownIcon className="section-arrow" sx={{ fontSize: 15, color: c.text.ghost, transition: 'color 0.15s' }} /> : <KeyboardArrowRightIcon className="section-arrow" sx={{ fontSize: 15, color: c.text.ghost, transition: 'color 0.15s' }} />}
<Box sx={{ display: 'flex', gap: 0.5, ml: 1 }} onClick={(e: React.MouseEvent) => e.stopPropagation()}>
{([['all', 'All'], ['connected', 'Connected'], ['not-connected', 'Not connected']] as const).map(([value, label]) => (
<Box
key={value}
role="button"
onClick={() => 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}
</Box>
))}
</Box>
</Box>
<Collapse in={customSectionOpen} timeout={0} unmountOnExit>
{loading ? (
@@ -217,7 +246,7 @@ const Tools: React.FC = () => {
</Box>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, pl: 1 }}>
{uninstalledIntegrations.map((ig) => (
{visibleGallery.map((ig) => (
<IntegrationGalleryCard
key={ig.id}
integration={ig}
@@ -225,7 +254,7 @@ const Tools: React.FC = () => {
onToggle={a.handleIntegrationToggle}
/>
))}
{tools.map((tool) => (
{visibleTools.map((tool) => (
<CustomToolCard
key={tool.id}
tool={tool}