import { useStore } from '@nanostores/react' import { useQuery } from '@tanstack/react-query' import type * as React from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useNavigate } from 'react-router' import { ArchiveSkillConfirmDialog } from '@/app/learning/archive-skill-confirm-dialog' import { CodeEditor } from '@/components/chat/code-editor' import { PageLoader } from '@/components/page-loader' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { CountSkeleton } from '@/components/ui/skeleton' import type { DesktopRosterAgent } from '@/global' import { editLearningNode, getLearningNode, getOfficialSkills, getProfiles, getSkillContent, getSkills, getToolsets, getUsageAnalytics, previewSkillHub, type ProfileScope, profileScopeKey, setSkillEnabled, setToolsetEnabled } from '@/hermes' import { type Translations, useI18n } from '@/i18n' import { isDesktopToolsetVisible } from '@/lib/desktop-toolsets' import { compactNumber } from '@/lib/format' import { Loader2 } from '@/lib/icons' import { queryClient } from '@/lib/query-client' import { invalidateSlashCompletions } from '@/lib/slash-completion-cache' import { normalize } from '@/lib/text' import { useStoreSelector } from '@/lib/use-session-slice' import { $gateway, activeGatewayConnectionId } from '@/store/gateway' import { $hubActions, installHubSkill, OFFICIAL_SKILLS_KEY } from '@/store/hub-actions' import { notify, notifyError } from '@/store/notifications' import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile' import type { OfficialSkillInfo, SkillInfo, ToolsetInfo } from '@/types/hermes' import { useOnProfileSwitch } from '../hooks/use-on-profile-switch' import { useRefreshHotkey } from '../hooks/use-refresh-hotkey' import { useRouteEnumParam } from '../hooks/use-route-enum-param' import { CapRow, DetailColumn, DetailPane, ListColumn, ListStrip, ListStripButton, ListStripMenu, type ListStripMenuToggle, MasterDetail, ToolChip } from '../master-detail' import { PanelEmpty, PanelPill } from '../overlays/panel' import { PageSearchShell } from '../page-search-shell' import { SETTINGS_ROUTE } from '../routes' import { BrowserRealProfilePanel } from '../settings/browser-real-profile-panel' import { ComputerUsePanel } from '../settings/computer-use-panel' import { asText, includesQuery, prettyName, toolNames, toolsetDisplayLabel } from '../settings/helpers' import { TerminalBackendPanel } from '../settings/terminal-backend-panel' import { ToolsetConfigPanel } from '../settings/toolset-config-panel' import type { SetStatusbarItemGroup } from '../shell/statusbar-controls' import { EmbeddedHubPicker } from './embedded-hub-picker' import { McpTab } from './mcp-tab' import { $skillsSortDesc, $toolsetsSortDesc } from './store' // 'hub' is gone as a top-level tab — the Skills Hub browser lives inside the // Skills tab now (EmbeddedHubPicker below the installed list). Legacy // `?tab=hub` links fall back to 'skills' via useRouteEnumParam. const SKILLS_MODES = ['skills', 'toolsets', 'mcp'] as const // Skills + toolsets live in the RQ cache so switching tabs/pages paints the // cached lists instantly (no reload flash) and mount only fires a deduped // background refetch. A profile swap globally invalidates (see store/profile), // so these plain keys refetch against the new backend automatically. // Both are extended with the Capabilities scope key at the call sites so every // scoped profile keeps its own cached copy (prefix invalidations still match). const SKILLS_QUERY_KEY = ['skills-list'] as const const TOOLSETS_QUERY_KEY = ['toolsets-list'] as const // Per-tool call counts come from a 365-day message scan — heavy, and purely // cosmetic (Toolsets usage badges). Cache the result module-wide with a TTL so // bouncing between tabs/pages doesn't re-run the scan every time. Keyed by // the Capabilities scope profile: analytics are profile-scoped, so a scope or // app-profile switch must not show the previous profile's counts. // `useRefreshHotkey` still forces a fresh pull. const TOOL_CALLS_TTL_MS = 10 * 60 * 1000 const toolCallsCache = new Map }>() async function loadToolCalls( scopeKey: string, scopeProfile: ProfileScope, force = false ): Promise> { const cached = toolCallsCache.get(scopeKey) if (!force && cached && Date.now() - cached.at < TOOL_CALLS_TTL_MS) { return cached.value } const analytics = await getUsageAnalytics(365, scopeProfile) const value = Object.fromEntries((analytics.tools ?? []).map(e => [e.tool, e.count])) toolCallsCache.set(scopeKey, { at: Date.now(), value }) return value } const usageOf = (skill: SkillInfo): number => (typeof skill.usage === 'number' ? skill.usage : 0) const categoryFor = (skill: SkillInfo): string => asText(skill.category) || 'general' // Row subtitle: category, with non-default origins badged. function skillSubtitle(skill: SkillInfo, copy: Translations['surfaces']): React.ReactNode { const category = prettyName(categoryFor(skill)) const provenance = skill.provenance return ( <> {category} {provenance === 'agent' && ( {copy.learned} )} {provenance === 'hub' && ( {copy.hub} )} ) } function filteredSkills(skills: SkillInfo[], query: string, desc: boolean): SkillInfo[] { const q = normalize(query) const sign = desc ? 1 : -1 return skills .filter( skill => !q || includesQuery(skill.name, q) || includesQuery(skill.description, q) || includesQuery(skill.category, q) ) .sort((a, b) => sign * (usageOf(b) - usageOf(a)) || asText(a.name).localeCompare(asText(b.name))) } // Catalog rows have no usage yet — plain A–Z, same query fields as installed // rows plus tags (the catalog's frontmatter tags are its richest search text). function filteredOfficial(skills: OfficialSkillInfo[], query: string): OfficialSkillInfo[] { const q = normalize(query) return skills .filter( skill => !q || includesQuery(skill.name, q) || includesQuery(skill.description, q) || includesQuery(skill.category, q) || skill.tags.some(tag => includesQuery(tag, q)) ) .sort((a, b) => asText(a.name).localeCompare(asText(b.name))) } const toolsetCalls = (toolset: ToolsetInfo, toolCalls: Record): number => toolNames(toolset).reduce((sum, name) => sum + (toolCalls[name] ?? 0), 0) function filteredToolsets( toolsets: ToolsetInfo[], query: string, toolCalls: Record, desc: boolean ): ToolsetInfo[] { const q = normalize(query) const sign = desc ? 1 : -1 return toolsets .filter(toolset => { if (!isDesktopToolsetVisible(toolset.name)) { return false } if (!q) { return true } return ( includesQuery(toolset.name, q) || includesQuery(toolsetDisplayLabel(toolset), q) || includesQuery(toolset.description, q) || toolNames(toolset).some(name => includesQuery(name, q)) ) }) .sort( (a, b) => sign * (toolsetCalls(b, toolCalls) - toolsetCalls(a, toolCalls)) || toolsetDisplayLabel(a).localeCompare(toolsetDisplayLabel(b)) ) } const visibleToolsetCount = (toolsets: ToolsetInfo[]) => toolsets.filter(ts => isDesktopToolsetVisible(ts.name)).length interface SkillsViewProps extends React.ComponentProps<'section'> { setStatusbarItemGroup?: SetStatusbarItemGroup /** Embedded mode (plugin dialogs — e.g. Bot Mode's Advanced section): tab * state lives in local React state instead of the route's `?tab=` param, * so an embedding dialog never fights the page router. */ embedded?: boolean /** Pin the WHOLE view to one profile: the scope selector is hidden and * every tab reads/writes THAT profile. This is the plugin door — Bot Mode * renders the real Capabilities surface pinned to a bot. */ fixedProfile?: string /** Pin the view to a REGISTERED gateway connection alongside `fixedProfile`: * every read/write routes to that machine's backend instead of the active * gateway. `''`/`'local'` mean the local pool. This is Bot Mode's * remote-target door — a bot living on another registered gateway gets the * live surface pointed at ITS backend. Ignored without `fixedProfile`. */ fixedConnection?: string } export function SkillsView({ embedded = false, fixedConnection, fixedProfile, setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: SkillsViewProps) { const { t } = useI18n() // Both hooks run unconditionally (rules of hooks); embedded picks the local // one so tab clicks inside a dialog don't rewrite the page URL. const routeTab = useRouteEnumParam('tab', SKILLS_MODES, 'skills') const localTab = useState<(typeof SKILLS_MODES)[number]>('skills') const [mode, setMode] = embedded ? localTab : routeTab // $gateway only feeds the MCP tab — gate the subscription so Skills/Toolsets // tabs don't re-render on connect/disconnect/reconnect. const gateway = useStoreSelector($gateway, g => (mode === 'mcp' ? g : null)) const [query, setQuery] = useState('') // The hub picker hosts a full docs-site iframe — the single most expensive // thing on this page. It mounts lazily (first time the Skills tab is shown) // and then STAYS mounted but hidden across tab switches, so bouncing to // Tools/MCP and back never reloads the site. Derived-state pattern: flips // once, during render, never back. const [hubMounted, setHubMounted] = useState(mode === 'skills') if (mode === 'skills' && !hubMounted) { setHubMounted(true) } // Capabilities scope selector: which profile's Skills/Tools/MCP config we're // editing — and on WHICH gateway. A profile belongs to one gateway, so on a // multi-connection desktop the selector offers every (connection, profile) // pair from the union agent roster and an explicit pick routes every // read/write to that machine's backend. Defaults to the app-wide active // profile (unchanged behavior). A `fixedProfile` (+ optional // `fixedConnection`) pins the scope outright (selector hidden). const activeProfile = useStore($activeGatewayProfile) const [scopeOverride, setScopeOverride] = useState(null) const scopeProfile: ProfileScope = useMemo( () => fixedProfile ? fixedConnection ? { connectionId: fixedConnection, profile: fixedProfile } : fixedProfile : (scopeOverride ?? activeProfile ?? null), [activeProfile, fixedConnection, fixedProfile, scopeOverride] ) const scopeKey = profileScopeKey(scopeProfile) // The registry connection an explicit override pins — null for the ambient // active-profile path and for fixed-profile pins without a connection. const scopeConnectionId = scopeProfile && typeof scopeProfile === 'object' ? (scopeProfile.connectionId ?? '').trim() || 'local' : null // Scoped to a DIFFERENT backend than the window's active gateway? The MCP // tab's live-reload RPC rides the active gateway socket, which would reload // the wrong machine — withhold it for cross-backend scopes. const crossBackendScope = scopeConnectionId !== null && scopeConnectionId !== (activeGatewayConnectionId() ?? 'local') const { data: profilesData } = useQuery({ queryKey: ['capabilities-profiles'], queryFn: getProfiles, staleTime: 60_000, // Pinned scope never shows the selector, so don't fetch the roster for it. enabled: !fixedProfile }) const profiles = profilesData?.profiles ?? [] // v2 multi-connection registry: cheap local IPC to learn whether more than // one gateway is registered. Only then is the (heavier) union agent roster // fetched to feed the selector — single-connection setups keep the exact // legacy profiles list. Both feature-detected for older Electron mains. const registryBridge = window.hermesDesktop?.connections const rosterBridge = window.hermesDesktop?.getAgentRoster const { data: registryData } = useQuery({ queryKey: ['capabilities-connections-registry'], queryFn: () => registryBridge!.list(), staleTime: 60_000, enabled: !fixedProfile && Boolean(registryBridge) && Boolean(rosterBridge) }) const multiConnection = (registryData?.connections.length ?? 0) > 1 const { data: rosterData } = useQuery({ queryKey: ['capabilities-agent-roster'], queryFn: () => rosterBridge!(), staleTime: 60_000, enabled: !fixedProfile && multiConnection }) const { data: skills, isError: skillsFailed, error: skillsError } = useQuery({ queryKey: [...SKILLS_QUERY_KEY, scopeKey], queryFn: () => getSkills(scopeProfile), staleTime: 0 }) const { data: toolsets, isError: toolsetsFailed } = useQuery({ queryKey: [...TOOLSETS_QUERY_KEY, scopeKey], queryFn: () => getToolsets(scopeProfile), staleTime: 0 }) // The built-in optional-skills catalog (optional-skills/ shipped with the // repo) — rendered under the installed list with install buttons. Local // checkout scan on the backend, so it's cheap; failures (older backend // without the endpoint) just render no catalog rows. const { data: officialData } = useQuery({ queryKey: [...OFFICIAL_SKILLS_KEY, scopeKey], queryFn: () => getOfficialSkills(scopeProfile), staleTime: 60_000, retry: false }) // Optimistic write-through against the scoped Skills key: toggles/bulk/ // archive repaint instantly; the next background refetch reconciles. const setSkills = useCallback( (fn: (cur: SkillInfo[] | undefined) => SkillInfo[] | undefined) => queryClient.setQueryData([...SKILLS_QUERY_KEY, scopeKey], prev => fn(prev) ?? prev), [scopeKey] ) // tool name -> call count over the analytics window. null = still loading // (badges show skeletons); {} = loaded empty / unavailable backend. const [toolCalls, setToolCalls] = useState | null>(null) // Bumped on profile switch so a slow analytics load from profile A can't set // toolCalls after the user moved to B. const toolCallsEpoch = useRef(0) const skillsSortDesc = useStore($skillsSortDesc) const toolsetsSortDesc = useStore($toolsetsSortDesc) const [bulkBusy, setBulkBusy] = useState(false) const [selectedSkill, setSelectedSkill] = useState(null) // Catalog selection is separate from installed-skill selection: identifiers // (official//) vs names. Non-null wins the detail pane. const [selectedOfficial, setSelectedOfficial] = useState(null) const [selectedToolset, setSelectedToolset] = useState(null) const refreshCapabilities = useCallback(async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: SKILLS_QUERY_KEY }), queryClient.invalidateQueries({ queryKey: TOOLSETS_QUERY_KEY }), queryClient.invalidateQueries({ queryKey: OFFICIAL_SKILLS_KEY }) ]) invalidateSlashCompletions() // An explicit refresh is the one time we bypass the analytics TTL — but // only if the badges are already on screen; otherwise let the lazy load // pick it up when Toolsets is first shown. Guard the async set against a // profile/scope switch landing before it resolves. if (toolCallsCache.size > 0) { const epoch = toolCallsEpoch.current loadToolCalls(scopeKey, scopeProfile, true) .then(value => toolCallsEpoch.current === epoch && setToolCalls(value)) .catch(() => toolCallsEpoch.current === epoch && setToolCalls({})) } }, [scopeKey, scopeProfile]) const refreshToolsets = useCallback(() => { void queryClient.invalidateQueries({ queryKey: TOOLSETS_QUERY_KEY }) }, []) useRefreshHotkey(refreshCapabilities) // Per-tool call counts feed ONLY the Toolsets tab's usage badges/sort, and // the query behind them is a 365-day message scan — heavy. Fetch it lazily // the first time Toolsets is shown, never on Skills or MCP, so it can't // starve the MCP tab's config load. Absent → toolsets sort A–Z until it lands. useEffect(() => { if (mode !== 'toolsets' || toolCalls !== null) { return } let cancelled = false // Guard the setter by epoch too: when toolCalls is already null at switch // time, setToolCalls(null) is a no-op so this effect never re-runs to flip // `cancelled` — the epoch check catches that gap. const epoch = toolCallsEpoch.current const live = () => !cancelled && toolCallsEpoch.current === epoch loadToolCalls(scopeKey, scopeProfile) .then(value => live() && setToolCalls(value)) .catch(() => live() && setToolCalls({})) return () => void (cancelled = true) }, [mode, scopeKey, scopeProfile, toolCalls]) // On an app-wide profile switch the analytics cache is scope-keyed, but our // local toolCalls state isn't — leaving it non-null would keep the lazy // effect from ever re-running, so badges/sort would show the previous // profile's counts. Reset to null so the next Toolsets view reloads for the // active profile. The switch also drops any scope override — the user just // changed what "here" means, and a stale override pointing at the previous // selection would be surprising. useOnProfileSwitch(() => { toolCallsEpoch.current += 1 setToolCalls(null) setScopeOverride(null) setSelectedOfficial(null) }) const visibleSkills = useMemo( () => (skills ? filteredSkills(skills, query, skillsSortDesc) : []), [query, skills, skillsSortDesc] ) // Installed-name set for the hub picker's already-installed guard — the // UNFILTERED list on purpose (search must not make a skill look absent). const installedSkillNames = useMemo(() => new Set((skills ?? []).map(s => s.name)), [skills]) // Catalog rows still available to install: drop entries whose lock says // installed AND entries whose name already appears in the installed list // (covers installs from before the lock existed, or by hand). const visibleOfficial = useMemo(() => { const catalog = (officialData?.skills ?? []).filter(s => !s.installed && !installedSkillNames.has(s.name)) return filteredOfficial(catalog, query) }, [installedSkillNames, officialData, query]) // Identifiers with a hub install currently running — selected as a joined // string so $hubActions' per-log-line churn doesn't re-render the list. const runningInstallKey = useStoreSelector($hubActions, actions => Object.keys(actions) .filter(key => actions[key]?.running) .sort() .join('|') ) const runningInstalls = useMemo(() => new Set(runningInstallKey.split('|').filter(Boolean)), [runningInstallKey]) const visibleToolsets = useMemo( () => (toolsets ? filteredToolsets(toolsets, query, toolCalls ?? {}, toolsetsSortDesc) : []), [query, toolCalls, toolsets, toolsetsSortDesc] ) // Bulk actions ("All" master switch, "Disable unused") and the master-switch // state target the WHOLE tab, never the search-filtered view — a tab-wide // control that silently scoped to the current query would be a lie. const bulkSkills = skills ?? [] const bulkToolsets = useMemo(() => (toolsets ?? []).filter(ts => isDesktopToolsetVisible(ts.name)), [toolsets]) // Rotating placeholder nudges from the user's own data — teach that search // understands categories and tool names, not just titles. const searchHints = useMemo(() => { if (mode === 'skills' && skills?.length) { const counts = new Map() for (const skill of skills) { const key = categoryFor(skill) counts.set(key, (counts.get(key) || 0) + 1) } return [...counts.entries()] .sort(([, a], [, b]) => b - a) .slice(0, 5) .map(([category]) => t.common.tryHint(category.toLowerCase())) } if (mode === 'toolsets' && toolsets?.length) { return toolsets .filter(ts => isDesktopToolsetVisible(ts.name) && toolNames(ts).length > 0) .slice(0, 5) .map(ts => t.common.tryHint(toolNames(ts)[0])) } return undefined }, [mode, skills, toolsets, t]) // Keep a valid selection: fall back to the first visible row when the // current selection is filtered out (or nothing is selected yet). const activeSkill = useMemo( () => visibleSkills.find(s => s.name === selectedSkill) ?? visibleSkills[0] ?? null, [selectedSkill, visibleSkills] ) // A selected catalog row wins the detail pane; it clears when filtered out // (or when its install finishes and the row leaves the catalog list). const activeOfficial = useMemo( () => visibleOfficial.find(s => s.identifier === selectedOfficial) ?? null, [selectedOfficial, visibleOfficial] ) const activeToolset = useMemo( () => visibleToolsets.find(ts => ts.name === selectedToolset) ?? visibleToolsets[0] ?? null, [selectedToolset, visibleToolsets] ) // Single toggles are optimistic and silent on success (the row repaints // immediately — a toast per flip would spam rapid customization). Errors // revert and notify. async function handleToggleSkill(skill: SkillInfo, enabled: boolean) { setSkills(current => current?.map(row => (row.name === skill.name ? { ...row, enabled } : row)) ?? current) try { await setSkillEnabled(skill.name, enabled, scopeProfile) // A disabled skill loses its `/name` command, so the composer's cached // `/` list has to be dropped along with the row repaint. invalidateSlashCompletions() } catch (err) { setSkills( current => current?.map(row => (row.name === skill.name ? { ...row, enabled: !enabled } : row)) ?? current ) notifyError(err, t.skills.failedToUpdate(skill.name)) } } // Catalog install: routes through the standard hub action pipeline // (background action + tailed log + query invalidation), same as the // embedded hub picker — the row's button spins off ITS $hubActions entry // and the finished install refetches both lists, flipping the row from the // catalog section into the installed section with the normal toggle. function handleInstallOfficial(skill: OfficialSkillInfo) { notify({ kind: 'success', title: t.skills.hub.installStarted(skill.name), message: t.skills.hub.actionLog }) void installHubSkill(skill.identifier, scopeProfile).catch(err => notifyError(err, t.skills.hub.actionFailed)) } async function handleToggleToolset(toolset: ToolsetInfo, enabled: boolean) { const scopedToolsetKey = [...TOOLSETS_QUERY_KEY, scopeKey] const writeScoped = (fn: (cur: ToolsetInfo[] | undefined) => ToolsetInfo[] | undefined) => queryClient.setQueryData(scopedToolsetKey, prev => fn(prev) ?? prev) writeScoped( current => current?.map(row => (row.name === toolset.name ? { ...row, enabled, available: enabled } : row)) ?? current ) try { await setToolsetEnabled(toolset.name, enabled, scopeProfile) } catch (err) { writeScoped( current => current?.map(row => (row.name === toolset.name ? { ...row, enabled: !enabled, available: !enabled } : row)) ?? current ) notifyError(err, t.skills.failedToUpdate(toolsetDisplayLabel(toolset))) } } // Sequential on purpose: each toggle is a config read-modify-write on the // backend; parallel calls would race the disabled-list save. async function bulkApply(skillTargets: SkillInfo[], toolsetTargets: ToolsetInfo[], enabled: boolean) { if (bulkBusy || skillTargets.length + toolsetTargets.length === 0) { return } setBulkBusy(true) let done = 0 try { for (const row of skillTargets) { await setSkillEnabled(row.name, enabled, scopeProfile) setSkills(cur => cur?.map(r => (r.name === row.name ? { ...r, enabled } : r)) ?? cur) done += 1 } for (const row of toolsetTargets) { await setToolsetEnabled(row.name, enabled, scopeProfile) queryClient.setQueryData( [...TOOLSETS_QUERY_KEY, scopeKey], cur => cur?.map(r => (r.name === row.name ? { ...r, enabled, available: enabled } : r)) ?? cur ) done += 1 } notify({ kind: 'success', title: t.skills.bulkUpdated(done), message: '' }) } catch (err) { notifyError(err, t.skills.failedToUpdate(mode === 'skills' ? t.skills.tabSkills : t.skills.tabToolsets)) } finally { invalidateSlashCompletions() setBulkBusy(false) } } const bulkToggle = (enabled: boolean) => mode === 'skills' ? bulkApply( bulkSkills.filter(row => row.enabled !== enabled), [], enabled ) : bulkApply( [], bulkToolsets.filter(row => row.enabled !== enabled), enabled ) // "Never used" = zero recorded activity. The pruning move for a 100+ skill // install: keep the workhorses, shed the noise. const disableUnused = () => bulkApply( bulkSkills.filter(skill => skill.enabled && usageOf(skill) === 0), [], false ) // One switch line covering enable-all/disable-all. const bulkSwitch = (allEnabled: boolean): ListStripMenuToggle => ({ checked: allEnabled, disabled: bulkBusy, label: t.skills.all, onToggle: checked => void bulkToggle(checked) }) const allSkillsEnabled = bulkSkills.length > 0 && bulkSkills.every(s => s.enabled) const allToolsetsEnabled = bulkToolsets.length > 0 && bulkToolsets.every(ts => ts.enabled) const sortButton = (desc: boolean, flip: () => void) => ( {desc ? t.skills.sortMostUsedDesc : t.skills.sortLeastUsedAsc} ) // Full-bleed empty state, matching the MCP tab (spans both columns, not a // cramped note in the left rail). Query-aware, and says "tools" not the // internal "toolsets". const capabilityEmpty = (noun: string) => { const q = query.trim() return (
) } // Learned/local skills are editable + archivable, mirroring the memory // graph (same /api/learning/node endpoints — delete archives, restorable // via `hermes curator restore`). const [skillEditor, setSkillEditor] = useState(null) const [skillDraft, setSkillDraft] = useState('') const [skillSaving, setSkillSaving] = useState(false) const [archiveTarget, setArchiveTarget] = useState(null) // Bumped on profile switch so an in-flight openSkillEditor fetch from profile // A can't reopen the editor with A's content after switching to B. const skillEditorEpoch = useRef(0) // A profile switch swaps the backend under the open editor/archive dialog — // their targets belong to profile A, so a save/archive would hit B. Drop them // so nothing edits or archives against the newly active profile. useOnProfileSwitch(() => { skillEditorEpoch.current += 1 setSkillEditor(null) setSkillDraft('') setArchiveTarget(null) }) const openSkillEditor = async (name: string) => { const epoch = skillEditorEpoch.current try { const node = await getLearningNode(name, scopeProfile) if (skillEditorEpoch.current !== epoch) { return } setSkillEditor({ content: node.content, name }) setSkillDraft(node.content) } catch (err) { notifyError(err, name) } } const saveSkillEdit = async () => { if (!skillEditor) { return } setSkillSaving(true) try { await editLearningNode(skillEditor.name, skillDraft, scopeProfile) notify({ kind: 'success', title: t.skills.skillUpdated, message: t.skills.appliesToNewSessions(skillEditor.name) }) setSkillEditor(null) void refreshCapabilities() } catch (err) { notifyError(err, skillEditor.name) } finally { setSkillSaving(false) } } const skillEditorPane = skillEditor && ( void saveSkillEdit()} size="xs"> {skillSaving ? t.common.saving : t.common.save} } id="skill-editor" onClose={() => setSkillEditor(null)} title={{skillEditor.name}/SKILL.md} > setSkillEditor(null)} onChange={setSkillDraft} onSave={() => void saveSkillEdit()} /> ) // Selecting a different scope is the same staleness hazard as an app-wide // profile switch: in-flight analytics belong to the previous scope's cache // key, and an open skill editor / archive dialog targets the PREVIOUS // scope's skill (a save/archive would hit the new one). Reset both here — // this handler is the only way the scope changes besides an app profile // switch, which useOnProfileSwitch already covers. // // Selector option values are `connectionId::profile` on multi-connection // desktops (the roster path) and bare profile names otherwise, so one // handler decodes both. const changeScope = (value: string) => { const sep = value.indexOf('::') // Roster picks (`connectionId::profile`) stay objects — a `local::` pick // must PIN the local pool even while a remote gateway is active. Legacy // bare-name picks stay strings so cache keys and routing are unchanged. const next: ProfileScope = sep >= 0 ? { connectionId: value.slice(0, sep), profile: value.slice(sep + 2) } : value if (profileScopeKey(next) === scopeKey) { return } setScopeOverride(next as string | { connectionId: string; profile: string }) toolCallsEpoch.current += 1 setToolCalls(null) skillEditorEpoch.current += 1 setSkillEditor(null) setSkillDraft('') setArchiveTarget(null) setSelectedOfficial(null) } // Scope-selector rows. Multi-connection desktops list every reachable // (connection, profile) agent from the union roster — the selected profile // is configured ON ITS OWN GATEWAY. Otherwise the legacy per-profile list. const scopeOptions: { key: string; label: string; value: string }[] = useMemo(() => { if (multiConnection && rosterData?.agents?.length) { const activeId = activeGatewayConnectionId() ?? 'local' return rosterData.agents.map((agent: DesktopRosterAgent) => ({ key: `${agent.connectionId}::${agent.profile}`, label: agent.connectionId === activeId ? `${agent.profile} — ${agent.connectionLabel} (current)` : `${agent.profile} — ${agent.connectionLabel}`, value: `${agent.connectionId}::${agent.profile}` })) } return (profilesData?.profiles ?? []).map(p => ({ key: p.name, label: p.is_default ? 'Hermes (default)' : p.name, value: p.name })) }, [multiConnection, profilesData, rosterData]) // The selector's current value must match one option's value exactly. On the // roster path an ambient (non-override) scope is the active gateway's // profile, which lives at `activeConnectionId::profile`. const scopeSelectValue = useMemo(() => { if (scopeProfile && typeof scopeProfile === 'object') { return `${(scopeProfile.connectionId ?? '').trim() || 'local'}::${scopeProfile.profile ?? ''}` } if (multiConnection && rosterData?.agents?.length) { return `${activeGatewayConnectionId() ?? 'local'}::${normalizeProfileKey(scopeProfile)}` } return scopeProfile ?? '' }, [multiConnection, rosterData, scopeProfile]) // Scope selector, shown above EVERY Capabilities tab (Skills, Tools, MCP, // Browse Hub). Lets the user configure ANY profile's capabilities — on any // registered gateway — without switching the whole app. Only meaningful // with >1 option; hidden otherwise to avoid clutter. const profileScopeSelector = scopeOptions.length > 1 ? (
{t.skills.configuringProfile}
) : null return ( setMode(id as (typeof SKILLS_MODES)[number])} // MCP manages a handful of entries with the editor right there — // searching it is noise. searchHidden={mode === 'mcp'} searchHints={searchHints} searchPlaceholder={mode === 'skills' ? t.skills.searchSkills : t.skills.searchToolsets} searchValue={query} tabs={[ { id: 'skills', label: t.skills.tabSkills, meta: skills?.length ?? null }, { id: 'toolsets', label: t.skills.tabToolsets, meta: toolsets ? visibleToolsetCount(toolsets) : null }, { id: 'mcp', label: t.skills.tabMcp } ]} > {/* One shared column: the scope selector sits above whichever tab is active, so Skills / Tools / MCP all read and write the SAME selected profile. */}
{profileScopeSelector}
{mode === 'mcp' ? ( // The gateway instance backs ONLY the live `reload.mcp` RPC, and // it is the ACTIVE gateway's socket — for a scope pinned to a // different backend that RPC would hot-reload the wrong // machine's MCP servers, so it is withheld (config edits still // apply on that backend's next session). ) : (skillsFailed || toolsetsFailed) && (!skills || !toolsets) ? ( void refreshCapabilities()} size="sm"> {t.skills.refresh} } description={skillsError instanceof Error ? skillsError.message : undefined} icon="error" title={t.skills.skillsLoadFailed} /> ) : !skills || !toolsets ? ( ) : mode === 'skills' ? ( // Installed skills on top, the Skills Hub browser underneath — // discovery sits with management. The list region keeps a floor // (min-h-40, on the wrapper above) so a tall hub viewport or a // short window shrinks the HUB, never the list: the sort strip // and "changes apply" footer can no longer be starved to 0px // and painted over by the hub header. visibleSkills.length === 0 && visibleOfficial.length === 0 ? ( capabilityEmpty('skills') ) : ( $skillsSortDesc.set(!$skillsSortDesc.get()))} right={ void disableUnused() } ]} label={t.skills.tabSkills} toggle={bulkSwitch(allSkillsEnabled)} /> } /> } > {visibleSkills.map(skill => ( 0 ? `×${compactNumber(usageOf(skill))}` : undefined} onSelect={() => { setSelectedSkill(skill.name) setSelectedOfficial(null) }} onToggle={enabled => void handleToggleSkill(skill, enabled)} subtitle={skillSubtitle(skill, t.surfaces)} title={skill.name} toggleLabel={skill.name} /> ))} {/* The built-in optional-skills catalog, below the installed list: every official skill Hermes ships but hasn't installed yet, with a one-click install that flips the row into the installed section above. */} {visibleOfficial.length > 0 && (
{t.skills.officialCatalog}
)} {visibleOfficial.map(skill => { const installing = runningInstalls.has(skill.identifier) return ( handleInstallOfficial(skill)} size="xs" variant="text" > {installing && } {installing ? t.skills.hub.installing : t.skills.hub.install} } active={activeOfficial?.identifier === skill.identifier} enabled={false} key={skill.identifier} onSelect={() => setSelectedOfficial(skill.identifier)} subtitle={prettyName(skill.category)} title={skill.name} /> ) })}
{activeOfficial ? ( handleInstallOfficial(activeOfficial)} profile={scopeProfile} skill={activeOfficial} /> ) : ( activeSkill && ( setArchiveTarget(activeSkill.name)} onEdit={() => void openSkillEditor(activeSkill.name)} profile={scopeProfile} skill={activeSkill} /> ) )}
) ) : visibleToolsets.length === 0 ? ( capabilityEmpty('tools') ) : ( $toolsetsSortDesc.set(!$toolsetsSortDesc.get()))} right={} /> } > {visibleToolsets.map(toolset => { const label = toolsetDisplayLabel(toolset) const calls = toolCalls ? toolsetCalls(toolset, toolCalls) : null return ( ) : calls > 0 ? ( `×${compactNumber(calls)}` ) : ( `${toolNames(toolset).length} tools` ) } onSelect={() => setSelectedToolset(toolset.name)} onToggle={checked => void handleToggleToolset(toolset, checked)} subtitle={asText(toolset.description)} title={label} toggleLabel={t.skills.toggleToolset(label, !toolset.enabled)} /> ) })} {activeToolset && ( )} )}
{/* Hub picker OUTSIDE the tab ternary: it lazy-mounts the first time Skills is shown, then stays mounted (hidden) across Tools/MCP so the docs-site iframe never reloads on a tab bounce. No scope key on purpose — the picker fetches nothing; scope rides the `profile` prop into each install call, and remounting on scope change would reload the whole site for no data benefit. */} {hubMounted && (
{archiveTarget && ( { const name = archiveTarget const snapshot = skills setSkills(current => current?.filter(skill => skill.name !== name) ?? current) invalidateSlashCompletions() if (skillEditor?.name === name) { setSkillEditor(null) } return () => setSkills(() => snapshot) }} onClose={() => setArchiveTarget(null)} onFailure={(err, name) => notifyError(err, name)} open profile={scopeProfile} skillId={archiveTarget} skillName={archiveTarget} /> )}
) } // Feature-detection flag for plugins (Bot Mode): TRUE means this build's // SkillsView routes `fixedConnection` to the pinned connection's backend. // Older builds export SkillsView WITHOUT the prop — passing it there would // silently read/write the ACTIVE gateway under the remote bot's profile name, // which is exactly the wrong-machine bug the prop exists to prevent. A static // property is probe-able without rendering. SkillsView.supportsFixedConnection = true as const // Shared inspector header — mirrors Messaging's PlatformDetail so Skills and // Tools share one title/description block and tab switches don't jump. function DetailHeader({ description, pills, title }: { description: React.ReactNode pills?: React.ReactNode title: string }) { return (

{title}

{pills}

{description}

) } // Frontmatter parse for display: the YAML block between the leading `---` // fences, flattened to top-level `key: value` rows (nested blocks render as // their raw indented text). Display-only — never fed back to the backend. function parseFrontmatter(content: string): { body: string; meta: [string, string][] } { const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(content) if (!match) { return { body: content, meta: [] } } const meta: [string, string][] = [] let currentKey: null | string = null let block: string[] = [] const flush = () => { if (currentKey !== null) { meta.push([currentKey, block.join('\n').trim()]) } currentKey = null block = [] } for (const line of match[1].split(/\r?\n/)) { const kv = /^(\w[\w-]*):\s?(.*)$/.exec(line) if (kv) { flush() currentKey = kv[1] block = kv[2] ? [kv[2]] : [] } else if (currentKey !== null) { block.push(line.replace(/^ {2}/, '')) } } flush() return { body: content.slice(match[0].length), meta } } function SkillDetail({ onArchive, onEdit, profile, skill }: { onArchive: () => void onEdit: () => void profile?: ProfileScope skill: SkillInfo }) { const { t } = useI18n() // Only learned/local skills are the user's to rewrite or archive — bundled // and hub skills are managed by their sources. const editable = skill.provenance === 'agent' // The FULL skill — frontmatter metadata + complete SKILL.md body — for any // provenance, scoped to the Capabilities profile selector. The row list only // carries name/description; the pane shows the whole thing. const contentQuery = useQuery({ queryKey: ['skill-content', skill.name, profileScopeKey(profile)], queryFn: () => getSkillContent(skill.name, profile), staleTime: 60_000 }) const parsed = useMemo( () => (contentQuery.data ? parseFrontmatter(contentQuery.data.content) : null), [contentQuery.data] ) return ( <> {prettyName(categoryFor(skill))} {skill.provenance && skill.provenance !== 'bundled' && ( {t.skills.provenance[skill.provenance]} )} } title={skill.name} /> {editable && (
)} {parsed && parsed.meta.length > 0 && (
{parsed.meta.map(([key, value]) => (
{key} {value}
))}
)} {contentQuery.isLoading ? ( ) : parsed ? (
          {parsed.body.trim() || t.skills.noDescription}
        
) : null} ) } // Detail pane for a not-yet-installed catalog skill: metadata + full SKILL.md // via the hub preview endpoint (same resolver an install uses), plus the same // install button as the row. function OfficialSkillDetail({ installing, onInstall, profile, skill }: { installing: boolean onInstall: () => void profile?: ProfileScope skill: OfficialSkillInfo }) { const { t } = useI18n() const previewQuery = useQuery({ queryKey: ['official-skill-preview', skill.identifier, profileScopeKey(profile)], queryFn: () => previewSkillHub(skill.identifier, profile), staleTime: 5 * 60_000, retry: false }) const parsed = useMemo( () => (previewQuery.data?.skill_md ? parseFrontmatter(previewQuery.data.skill_md) : null), [previewQuery.data] ) return ( <> {prettyName(skill.category)} {t.skills.officialPill} } title={skill.name} />
{parsed && parsed.meta.length > 0 && (
{parsed.meta.map(([key, value]) => (
{key} {value}
))}
)} {previewQuery.isLoading ? ( ) : parsed ? (
          {parsed.body.trim() || t.skills.noDescription}
        
) : null} ) } function ToolsetDetail({ toolset, toolCalls, onConfiguredChange, profile }: { toolset: ToolsetInfo toolCalls: Record onConfiguredChange: () => void profile?: ProfileScope }) { const { t } = useI18n() const navigate = useNavigate() const tools = toolNames(toolset) const label = toolsetDisplayLabel(toolset) return ( <> {/* "Configured" as a resting state is noise — only the warn state earns a pill. */} {t.skills.needsKeys}} title={label} /> {tools.length > 0 && (
{tools.map(name => ( {name} {(toolCalls[name] ?? 0) > 0 && ( ×{compactNumber(toolCalls[name])} )} ))}
)} {toolset.name === 'vision' && ( // Vision has no provider matrix — model resolution runs through the // auxiliary model config. Point at the actual home (Settings → Models, // aux "vision" row) via an internal deep link instead of leaving the // detail pane empty.

{t.skills.visionModelHint}

)} {toolset.name === 'computer_use' && } {/* Real-profile consent toggle ABOVE the backend/provider matrix — the config option users kept missing because its only GUI home was the generic Settings → Config editor. */} {toolset.name === 'browser' && } {toolset.name === 'terminal' && } ) }