From 59b982bf37466f462a940283a180d8ff32a66f9e Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 5 Aug 2026 08:38:02 -0700 Subject: [PATCH] [eric] skills: detail page gets claude.ai chrome (byline, enable toggle, kebab, file picker, eye/code); Tools Add menu extracted --- frontend/src/app/pages/Skills/Skills.tsx | 242 +++++++++++------- frontend/src/app/pages/Tools/Tools.tsx | 83 +----- .../app/pages/Tools/dialogs/ToolsAddMenu.tsx | 89 +++++++ frontend/src/shared/state/skillsSlice.ts | 4 + 4 files changed, 253 insertions(+), 165 deletions(-) create mode 100644 frontend/src/app/pages/Tools/dialogs/ToolsAddMenu.tsx diff --git a/frontend/src/app/pages/Skills/Skills.tsx b/frontend/src/app/pages/Skills/Skills.tsx index 4dafbc6a..568f0f14 100644 --- a/frontend/src/app/pages/Skills/Skills.tsx +++ b/frontend/src/app/pages/Skills/Skills.tsx @@ -15,10 +15,11 @@ import Collapse from '@mui/material/Collapse'; import Snackbar from '@mui/material/Snackbar'; 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 Menu from '@mui/material/Menu'; import MenuItem from '@mui/material/MenuItem'; +import Switch from '@mui/material/Switch'; +import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; +import ShareIcon from '@mui/icons-material/Share'; import EditIcon from '@mui/icons-material/Edit'; import DeleteIcon from '@mui/icons-material/Delete'; import TerminalIcon from '@mui/icons-material/Terminal'; @@ -55,7 +56,8 @@ import { RegistrySkillDetail, } from '@/shared/state/skillRegistrySlice'; import { onboardingBus } from '@/app/components/Onboarding/eventBus'; -import ShareButton from '@/app/components/share/ShareButton'; +import { requestShare } from '@/app/components/share/ShareRequestHost'; +import { API_BASE } from '@/shared/config'; import { IMPORT_OPEN_EVENT } from '@/app/components/share/ImportEntryPoint'; import UploadFileIcon from '@mui/icons-material/UploadFile'; import SkillBuilderChat, { SkillPreviewData } from './SkillBuilderChat'; @@ -108,6 +110,7 @@ const Skills: React.FC = () => { const [directoryOpen, setDirectoryOpen] = useState(false); const [uploadOpen, setUploadOpen] = useState(false); const [addMenuAnchor, setAddMenuAnchor] = useState(null); + const [detailMenuAnchor, setDetailMenuAnchor] = useState(null); const handleBuilderPreview = useCallback((data: SkillPreviewData | null) => { setBuilderPreview(data); @@ -246,29 +249,89 @@ const Skills: React.FC = () => { return selection.type === 'local' && selection.id === key; }; - const ContentPreview: React.FC<{ content: string }> = ({ content }) => ( + // claude.ai's content card header: [SKILL.md v] file picker + "N files" + eye/code toggles. Single-file skills hide the picker. + const ContentPreview: React.FC<{ content: string; skillId?: string; multiFile?: boolean }> = ({ content, skillId, multiFile }) => { + const [files, setFiles] = useState<{ path: string; content: string }[]>([]); + const [selectedPath, setSelectedPath] = useState('SKILL.md'); + const [fileMenuAnchor, setFileMenuAnchor] = useState(null); + useEffect(() => { + setFiles([]); + setSelectedPath('SKILL.md'); + if (!skillId || !multiFile) return; + let stale = false; + void fetch(`${API_BASE}/skills/${skillId}/files`) + .then((res) => (res.ok ? res.json() : { files: [] })) + .then((data: { files?: { path: string; content: string }[] }) => { if (!stale) setFiles(data.files ?? []); }) + .catch(() => { /* picker quietly falls back to SKILL.md */ }); + return () => { stale = true; }; + }, [skillId, multiFile]); + const selected = files.find((f) => f.path === selectedPath); + const shownContent = selected ? selected.content : content; + const isMarkdown = selectedPath.toLowerCase().endsWith('.md'); + const view = isMarkdown ? contentView : 'raw'; + const iconBtnSx = (active: boolean) => ({ + color: active ? c.text.primary : c.text.tertiary, + bgcolor: active ? c.bg.secondary : 'transparent', + borderRadius: `${c.radius.sm}px`, + '&:hover': { color: c.text.primary }, + }); + return ( - - { if (v) setContentView(v); }} - size="small" - sx={{ - '& .MuiToggleButton-root': { - color: c.text.tertiary, border: `1px solid ${c.border.medium}`, - textTransform: 'none', fontSize: '0.75rem', py: 0.25, px: 1.2, lineHeight: 1.4, - '&.Mui-selected': { bgcolor: c.bg.secondary, color: c.text.primary, borderColor: c.border.strong }, - '&:hover': { bgcolor: 'rgba(0,0,0,0.03)' }, - }, - }} - > - Preview - Raw - + + + {files.length > 1 ? ( + <> + + setFileMenuAnchor(null)} + PaperProps={{ sx: { bgcolor: c.bg.surface, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, mt: 0.5, minWidth: 200 } }} + > + {files.map((f) => ( + { setSelectedPath(f.path); setFileMenuAnchor(null); }} + sx={{ fontSize: '0.8125rem', fontFamily: c.font.mono, color: f.path === selectedPath ? c.text.primary : c.text.secondary, '&:hover': { bgcolor: c.bg.secondary } }} + > + {f.path} + + ))} + + + {files.length} files + + + ) : null} + + + + + setContentView('preview')} sx={iconBtnSx(view === 'preview')}> + + + + + + setContentView('raw')} sx={iconBtnSx(view === 'raw')}> + + + + - {contentView === 'raw' ? ( + {view === 'raw' ? ( { color: c.text.secondary, fontSize: '0.8125rem', fontFamily: c.font.mono, whiteSpace: 'pre-wrap', wordBreak: 'break-word', m: 0, lineHeight: 1.65, }}> - {content} + {shownContent} ) : ( @@ -305,11 +368,12 @@ const Skills: React.FC = () => { '& a': { color: c.accent.primary, textDecoration: 'none', '&:hover': { textDecoration: 'underline' } }, '& strong': { fontWeight: 600, color: c.text.primary }, }}> - {content} + {shownContent} )} - ); + ); + }; const SidebarRow: React.FC<{ label: string; @@ -656,79 +720,71 @@ const Skills: React.FC = () => { ) : null ) : selectedLocal ? ( - - - - {selectedLocal.name} + {/* claude.ai detail chrome: title + info, byline underneath, enable toggle + kebab on the right. */} + + + + + {selectedLocal.name} + + {selectedLocal.command && ( + + + + )} + {regOutdated.includes(selectedLocal.id) && ( + + )} + + + by {selectedLocal.built_in ? 'OpenSwarm' : /anthropic/i.test(selectedLocal.source || '') ? 'Anthropic' : selectedLocal.source ? selectedLocal.source.split('/')[0] : 'You'} - {selectedLocal.built_in && ( - - )} - {regOutdated.includes(selectedLocal.id) && ( - - )} - - {regOutdated.includes(selectedLocal.id) && ( - - )} - - - openEdit(selectedLocal)} sx={{ color: c.text.tertiary, '&:hover': { color: c.accent.primary } }}> - - + checked={selectedLocal.enabled !== false} + onChange={() => { void dispatch(updateSkill({ id: selectedLocal.id, enabled: selectedLocal.enabled === false })); }} + /> - {!selectedLocal.built_in && ( - - handleDelete(selectedLocal.id)} sx={{ color: c.text.tertiary, '&:hover': { color: c.status.error } }}> - - - - )} + ) => setDetailMenuAnchor(e.currentTarget)} sx={{ color: c.text.tertiary, '&:hover': { color: c.text.primary } }}> + + + setDetailMenuAnchor(null)} + PaperProps={{ sx: { bgcolor: c.bg.surface, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, mt: 0.5, minWidth: 180 } }} + > + { setDetailMenuAnchor(null); openEdit(selectedLocal); }} sx={{ fontSize: '0.875rem', color: c.text.primary, gap: 1.5, '&:hover': { bgcolor: c.bg.secondary } }}> + + Edit + + { setDetailMenuAnchor(null); requestShare({ kind: 'skill', id: selectedLocal.id, name: selectedLocal.name }); }} sx={{ fontSize: '0.875rem', color: c.text.primary, gap: 1.5, '&:hover': { bgcolor: c.bg.secondary } }}> + + Share as .swarm… + + {regOutdated.includes(selectedLocal.id) && ( + { setDetailMenuAnchor(null); void handleUpdate(selectedLocal); }} sx={{ fontSize: '0.875rem', color: c.text.primary, gap: 1.5, '&:hover': { bgcolor: c.bg.secondary } }}> + + {updatingId === selectedLocal.id ? 'Updating…' : 'Update to latest'} + + )} + {!selectedLocal.built_in && ( + { setDetailMenuAnchor(null); void handleDelete(selectedLocal.id); }} sx={{ fontSize: '0.875rem', color: c.status.error, gap: 1.5, '&:hover': { bgcolor: c.bg.secondary } }}> + + Delete + + )} + - {selectedLocal.command && ( - - } - label={`/${selectedLocal.command}`} - size="small" - sx={{ - bgcolor: 'rgba(174,86,48,0.08)', color: c.accent.primary, - fontWeight: 500, fontSize: '0.75rem', height: 26, - }} - /> - - )} - - - Added by You - - {selectedLocal.description && ( @@ -737,7 +793,7 @@ const Skills: React.FC = () => { )} - + ) : null} diff --git a/frontend/src/app/pages/Tools/Tools.tsx b/frontend/src/app/pages/Tools/Tools.tsx index aa7087a9..72889811 100644 --- a/frontend/src/app/pages/Tools/Tools.tsx +++ b/frontend/src/app/pages/Tools/Tools.tsx @@ -2,19 +2,15 @@ import React, { useEffect, useState, useMemo, useCallback } from 'react'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import Button from '@mui/material/Button'; -import MenuItem from '@mui/material/MenuItem'; import Chip from '@mui/material/Chip'; import Collapse from '@mui/material/Collapse'; -import Menu from '@mui/material/Menu'; import Snackbar from '@mui/material/Snackbar'; import Alert from '@mui/material/Alert'; -import AddIcon from '@mui/icons-material/Add'; import BuildIcon from '@mui/icons-material/Build'; import LockIcon from '@mui/icons-material/Lock'; import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight'; import HourglassEmptyIcon from '@mui/icons-material/HourglassEmpty'; -import StorefrontIcon from '@mui/icons-material/Storefront'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { fetchTools, @@ -35,10 +31,8 @@ import ToolSection from './cards/ToolSection'; import BrowserPermissionCard from './cards/BrowserPermissionCard'; import AgentWorkflowsSection from './cards/AgentWorkflowsSection'; import RegistryBrowserDialog from './dialogs/RegistryBrowserDialog'; +import ToolsAddMenu from './dialogs/ToolsAddMenu'; import ToolDialogs from './dialogs/ToolDialogs'; -import DirectoryDialog from '../Directory/DirectoryDialog'; -import AddCustomConnectorDialog from '../Directory/AddCustomConnectorDialog'; -import AddLinkIcon from '@mui/icons-material/AddLink'; import CustomToolCard from './cards/CustomToolCard'; import IntegrationGalleryCard from './cards/IntegrationGalleryCard'; import { useToolsActions } from './hooks/useToolsActions'; @@ -72,9 +66,6 @@ const Tools: React.FC = () => { const [browserSectionOpen, setBrowserSectionOpen] = useState(false); const [browserCollapsed, setBrowserCollapsed] = useState>({ browser_delegation: true, browser_action: true }); const [builtinSectionOpen, setBuiltinSectionOpen] = useState(true); - 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(() => { @@ -82,10 +73,7 @@ const Tools: React.FC = () => { if (connFilter === 'not-connected') return tools.filter((t) => t.enabled === false); return tools; }, [tools, connFilter]); - const visibleGallery = useMemo( - () => (connFilter === 'connected' ? [] : uninstalledIntegrations), - [uninstalledIntegrations, connFilter], - ); + const visibleGallery = useMemo(() => (connFilter === 'connected' ? [] : uninstalledIntegrations), [uninstalledIntegrations, connFilter]); useEffect(() => { dispatch(fetchTools()); @@ -100,11 +88,8 @@ const Tools: React.FC = () => { const toggleCategory = (cat: string) => setCollapsedCategories((p) => ({ ...p, [cat]: !p[cat] })); const toggleBuiltinExpand = (name: string) => setExpandedBuiltin((p) => (p === name ? null : name)); - - const handleMenuOpen = (e: React.MouseEvent) => setMenuAnchor(e.currentTarget); - const handleMenuClose = () => setMenuAnchor(null); - - const a = useToolsActions({ items, allTools, regServersRaw, closeMenu: handleMenuClose }); + // The Add menu closes itself now (ToolsAddMenu), so the hook's closeMenu hook-in is a no-op. + const a = useToolsActions({ items, allTools, regServersRaw, closeMenu: () => {} }); // Curated whitelist matches the MCPSearch alias map in main.py (mcp-meta). const CURATED_MCP_NAMES = useMemo(() => new Set([ @@ -123,43 +108,13 @@ const Tools: React.FC = () => { {/* The pane header already says "Tools"; a slim action row beats a second page title. */} - - - - { handleMenuClose(); setDirectoryOpen(true); }} sx={{ color: c.text.primary, fontSize: '0.875rem', gap: 1.5, '&:hover': { bgcolor: c.bg.secondary } }}> - - Browse connectors - - { handleMenuClose(); setCustomConnectorOpen(true); }} sx={{ color: c.text.primary, fontSize: '0.875rem', gap: 1.5, '&:hover': { bgcolor: c.bg.secondary } }}> - - Add custom connector - - - - Create Custom - - {devMode && ( - - - Browse MCP Registry - - )} - - + a.setExpandedToolId(toolId)} + onSnackbar={(message, severity) => a.setSnackbar({ open: true, message, severity: severity === 'error' ? 'error' : undefined })} + /> @@ -296,22 +251,6 @@ const Tools: React.FC = () => { onCredentialsSave={a.handleCredentialsSave} /> - setDirectoryOpen(false)} - onOpenInstalledConnector={(toolId) => { - setDirectoryOpen(false); - a.setExpandedToolId(toolId); - }} - /> - - setCustomConnectorOpen(false)} - onBrowsePrebuilt={() => setDirectoryOpen(true)} - onAdded={(message, severity) => a.setSnackbar({ open: true, message, severity: severity === 'error' ? 'error' : undefined })} - /> void; + onOpenRegistry: () => void; + onOpenInstalledConnector: (toolId: string) => void; + onSnackbar: (message: string, severity: 'success' | 'error') => void; +} + +// The Tools page's Add button (claude.ai's Connectors-page grammar) plus the Directory and +// custom-connector dialogs it opens. +const ToolsAddMenu: React.FC = ({ devMode, onOpenCreate, onOpenRegistry, onOpenInstalledConnector, onSnackbar }) => { + const c = useClaudeTokens(); + const [menuAnchor, setMenuAnchor] = useState(null); + const [directoryOpen, setDirectoryOpen] = useState(false); + const [customConnectorOpen, setCustomConnectorOpen] = useState(false); + const closeMenu = () => setMenuAnchor(null); + + return ( + <> + + + { closeMenu(); setDirectoryOpen(true); }} sx={{ color: c.text.primary, fontSize: '0.875rem', gap: 1.5, '&:hover': { bgcolor: c.bg.secondary } }}> + + Browse connectors + + { closeMenu(); setCustomConnectorOpen(true); }} sx={{ color: c.text.primary, fontSize: '0.875rem', gap: 1.5, '&:hover': { bgcolor: c.bg.secondary } }}> + + Add custom connector + + { closeMenu(); onOpenCreate(); }} sx={{ color: c.text.primary, fontSize: '0.875rem', gap: 1.5, '&:hover': { bgcolor: c.bg.secondary } }}> + + Create Custom + + {devMode && ( + { closeMenu(); onOpenRegistry(); }} sx={{ color: c.text.primary, fontSize: '0.875rem', gap: 1.5, '&:hover': { bgcolor: c.bg.secondary } }}> + + Browse MCP Registry + + )} + + + setDirectoryOpen(false)} + onOpenInstalledConnector={(toolId) => { + setDirectoryOpen(false); + onOpenInstalledConnector(toolId); + }} + /> + + setCustomConnectorOpen(false)} + onBrowsePrebuilt={() => setDirectoryOpen(true)} + onAdded={onSnackbar} + /> + + ); +}; + +export default ToolsAddMenu; diff --git a/frontend/src/shared/state/skillsSlice.ts b/frontend/src/shared/state/skillsSlice.ts index b2ce6ca0..c86eff95 100644 --- a/frontend/src/shared/state/skillsSlice.ts +++ b/frontend/src/shared/state/skillsSlice.ts @@ -16,6 +16,10 @@ export interface Skill { source?: string; folder?: string; version?: string; + /** Folder skill with files beyond SKILL.md; the detail page offers a file picker for those. */ + has_supporting_files?: boolean; + /** Detail-page toggle: disabled skills stay installed but leave the agent's skill list. */ + enabled?: boolean; } interface SkillsState {