Files
aiturk-hermes-ide/apps/desktop/src/app/skills/index.tsx
T

1375 lines
54 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, { at: number; value: Record<string, number> }>()
async function loadToolCalls(
scopeKey: string,
scopeProfile: ProfileScope,
force = false
): Promise<Record<string, number>> {
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 (
<>
<span className="truncate">{category}</span>
{provenance === 'agent' && (
<Badge className="shrink-0 normal-case" variant="default">
{copy.learned}
</Badge>
)}
{provenance === 'hub' && (
<Badge className="shrink-0 normal-case" variant="muted">
{copy.hub}
</Badge>
)}
</>
)
}
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 AZ, 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<string, number>): number =>
toolNames(toolset).reduce((sum, name) => sum + (toolCalls[name] ?? 0), 0)
function filteredToolsets(
toolsets: ToolsetInfo[],
query: string,
toolCalls: Record<string, number>,
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 | string | { connectionId: string; profile: string }>(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<SkillInfo[]>([...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<Record<string, number> | 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<string | null>(null)
// Catalog selection is separate from installed-skill selection: identifiers
// (official/<category>/<name>) vs names. Non-null wins the detail pane.
const [selectedOfficial, setSelectedOfficial] = useState<string | null>(null)
const [selectedToolset, setSelectedToolset] = useState<string | null>(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 AZ 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<string, number>()
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<ToolsetInfo[]>(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<ToolsetInfo[]>(
[...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) => (
<ListStripButton onClick={flip}>{desc ? t.skills.sortMostUsedDesc : t.skills.sortLeastUsedAsc}</ListStripButton>
)
// 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 (
<div className="flex h-full min-h-0 flex-1">
<PanelEmpty
description={q ? t.skills.emptyNothingMatches(q) : t.skills.emptyNoneAvailable(noun)}
icon="search"
title={t.skills.emptyNoneFound(noun)}
/>
</div>
)
}
// 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 | { content: string; name: string }>(null)
const [skillDraft, setSkillDraft] = useState('')
const [skillSaving, setSkillSaving] = useState(false)
const [archiveTarget, setArchiveTarget] = useState<null | string>(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 && (
<DetailPane
actions={
<Button disabled={skillSaving} onClick={() => void saveSkillEdit()} size="xs">
{skillSaving ? t.common.saving : t.common.save}
</Button>
}
id="skill-editor"
onClose={() => setSkillEditor(null)}
title={<span className="text-[0.68rem] font-normal text-muted-foreground/60">{skillEditor.name}/SKILL.md</span>}
>
<CodeEditor
filePath="SKILL.md"
initialValue={skillEditor.content}
key={skillEditor.name}
onCancel={() => setSkillEditor(null)}
onChange={setSkillDraft}
onSave={() => void saveSkillEdit()}
/>
</DetailPane>
)
// 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 ? (
<div className="flex items-center gap-2 border-b border-(--ui-stroke-secondary) px-3 py-2">
<span className="text-[0.7rem] font-medium text-(--ui-text-tertiary)">{t.skills.configuringProfile}</span>
<Select onValueChange={changeScope} value={scopeSelectValue}>
<SelectTrigger className="h-7 w-56 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{scopeOptions.map(option => (
<SelectItem key={option.key} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null
return (
<PageSearchShell
{...props}
activeTab={mode}
onSearchChange={setQuery}
onTabChange={id => 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. */}
<div className="flex h-full flex-col">
{profileScopeSelector}
<div className="flex min-h-0 flex-1 flex-col">
<div className={mode === 'skills' ? 'min-h-40 flex-1 overflow-hidden' : 'min-h-0 flex-1'}>
{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).
<McpTab gateway={crossBackendScope ? null : gateway} key={`mcp-${scopeKey}`} profile={scopeProfile} />
) : (skillsFailed || toolsetsFailed) && (!skills || !toolsets) ? (
<PanelEmpty
action={
<Button onClick={() => void refreshCapabilities()} size="sm">
{t.skills.refresh}
</Button>
}
description={skillsError instanceof Error ? skillsError.message : undefined}
icon="error"
title={t.skills.skillsLoadFailed}
/>
) : !skills || !toolsets ? (
<PageLoader label={t.skills.loading} />
) : 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')
) : (
<MasterDetail pane={skillEditorPane} resizeId="capabilities-split" split="wide">
<ListColumn
header={
<ListStrip
left={sortButton(skillsSortDesc, () => $skillsSortDesc.set(!$skillsSortDesc.get()))}
right={
<ListStripMenu
items={[
{
disabled: bulkBusy,
label: t.skills.disableUnused,
onSelect: () => void disableUnused()
}
]}
label={t.skills.tabSkills}
toggle={bulkSwitch(allSkillsEnabled)}
/>
}
/>
}
>
{visibleSkills.map(skill => (
<CapRow
active={activeOfficial === null && activeSkill?.name === skill.name}
busy={bulkBusy}
enabled={skill.enabled}
key={skill.name}
meta={usageOf(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 && (
<div className="flex h-7 shrink-0 items-end px-2 pb-1 text-[0.62rem] font-medium uppercase tracking-wide text-(--ui-text-quaternary)">
{t.skills.officialCatalog}
</div>
)}
{visibleOfficial.map(skill => {
const installing = runningInstalls.has(skill.identifier)
return (
<CapRow
action={
<Button
disabled={installing}
onClick={() => handleInstallOfficial(skill)}
size="xs"
variant="text"
>
{installing && <Loader2 className="size-3 animate-spin" />}
{installing ? t.skills.hub.installing : t.skills.hub.install}
</Button>
}
active={activeOfficial?.identifier === skill.identifier}
enabled={false}
key={skill.identifier}
onSelect={() => setSelectedOfficial(skill.identifier)}
subtitle={prettyName(skill.category)}
title={skill.name}
/>
)
})}
</ListColumn>
<DetailColumn footer={t.skills.changesApplyNewSessions}>
{activeOfficial ? (
<OfficialSkillDetail
installing={runningInstalls.has(activeOfficial.identifier)}
onInstall={() => handleInstallOfficial(activeOfficial)}
profile={scopeProfile}
skill={activeOfficial}
/>
) : (
activeSkill && (
<SkillDetail
onArchive={() => setArchiveTarget(activeSkill.name)}
onEdit={() => void openSkillEditor(activeSkill.name)}
profile={scopeProfile}
skill={activeSkill}
/>
)
)}
</DetailColumn>
</MasterDetail>
)
) : visibleToolsets.length === 0 ? (
capabilityEmpty('tools')
) : (
<MasterDetail resizeId="capabilities-split" split="wide">
<ListColumn
header={
<ListStrip
left={sortButton(toolsetsSortDesc, () => $toolsetsSortDesc.set(!$toolsetsSortDesc.get()))}
right={<ListStripMenu label={t.skills.tabToolsets} toggle={bulkSwitch(allToolsetsEnabled)} />}
/>
}
>
{visibleToolsets.map(toolset => {
const label = toolsetDisplayLabel(toolset)
const calls = toolCalls ? toolsetCalls(toolset, toolCalls) : null
return (
<CapRow
active={activeToolset?.name === toolset.name}
busy={bulkBusy}
enabled={toolset.enabled}
key={toolset.name}
meta={
calls === null ? (
<CountSkeleton />
) : 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)}
/>
)
})}
</ListColumn>
<DetailColumn footer={t.skills.changesApplyNewSessions}>
{activeToolset && (
<ToolsetDetail
onConfiguredChange={refreshToolsets}
profile={scopeProfile}
toolCalls={toolCalls ?? {}}
toolset={activeToolset}
/>
)}
</DetailColumn>
</MasterDetail>
)}
</div>
{/* 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 && (
<EmbeddedHubPicker hidden={mode !== 'skills'} installedNames={installedSkillNames} profile={scopeProfile} />
)}
</div>
</div>
{archiveTarget && (
<ArchiveSkillConfirmDialog
onApply={() => {
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}
/>
)}
</PageSearchShell>
)
}
// 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 (
<header>
<div className="flex min-h-6 flex-wrap items-center gap-2">
<h3 className="min-w-0 truncate text-[0.9375rem] font-semibold tracking-tight">{title}</h3>
{pills}
</div>
<p className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{description}
</p>
</header>
)
}
// 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 (
<>
<DetailHeader
description={asText(skill.description) || t.skills.noDescription}
pills={
<>
<PanelPill>{prettyName(categoryFor(skill))}</PanelPill>
{skill.provenance && skill.provenance !== 'bundled' && (
<PanelPill tone={skill.provenance === 'agent' ? 'good' : 'muted'}>
{t.skills.provenance[skill.provenance]}
</PanelPill>
)}
</>
}
title={skill.name}
/>
{editable && (
<div className="flex items-center gap-2">
<Button onClick={onEdit} size="xs" variant="text">
{t.skills.edit}
</Button>
<Button className="text-destructive hover:text-destructive" onClick={onArchive} size="xs" variant="text">
{t.skills.archive}
</Button>
</div>
)}
{parsed && parsed.meta.length > 0 && (
<div className="grid gap-1 rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-3">
{parsed.meta.map(([key, value]) => (
<div className="flex gap-2 text-[0.68rem] leading-4" key={key}>
<span className="w-24 shrink-0 font-medium text-(--ui-text-tertiary)">{key}</span>
<span className="min-w-0 whitespace-pre-wrap break-words text-(--ui-text-secondary)">{value}</span>
</div>
))}
</div>
)}
{contentQuery.isLoading ? (
<CountSkeleton />
) : parsed ? (
<pre
className="overflow-auto whitespace-pre-wrap wrap-break-word rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-3 font-mono text-[0.68rem] leading-relaxed"
data-selectable-text="true"
>
{parsed.body.trim() || t.skills.noDescription}
</pre>
) : 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 (
<>
<DetailHeader
description={asText(skill.description) || t.skills.noDescription}
pills={
<>
<PanelPill>{prettyName(skill.category)}</PanelPill>
<PanelPill tone="muted">{t.skills.officialPill}</PanelPill>
</>
}
title={skill.name}
/>
<div className="flex items-center gap-2">
<Button disabled={installing} onClick={onInstall} size="xs" variant="textStrong">
{installing && <Loader2 className="size-3 animate-spin" />}
{installing ? t.skills.hub.installing : t.skills.hub.install}
</Button>
</div>
{parsed && parsed.meta.length > 0 && (
<div className="grid gap-1 rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-3">
{parsed.meta.map(([key, value]) => (
<div className="flex gap-2 text-[0.68rem] leading-4" key={key}>
<span className="w-24 shrink-0 font-medium text-(--ui-text-tertiary)">{key}</span>
<span className="min-w-0 whitespace-pre-wrap break-words text-(--ui-text-secondary)">{value}</span>
</div>
))}
</div>
)}
{previewQuery.isLoading ? (
<CountSkeleton />
) : parsed ? (
<pre
className="overflow-auto whitespace-pre-wrap wrap-break-word rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-3 font-mono text-[0.68rem] leading-relaxed"
data-selectable-text="true"
>
{parsed.body.trim() || t.skills.noDescription}
</pre>
) : null}
</>
)
}
function ToolsetDetail({
toolset,
toolCalls,
onConfiguredChange,
profile
}: {
toolset: ToolsetInfo
toolCalls: Record<string, number>
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. */}
<DetailHeader
description={asText(toolset.description) || t.skills.noDescription}
pills={!toolset.configured && <PanelPill tone="warn">{t.skills.needsKeys}</PanelPill>}
title={label}
/>
{tools.length > 0 && (
<div className="flex flex-wrap gap-1">
{tools.map(name => (
<ToolChip key={name}>
{name}
{(toolCalls[name] ?? 0) > 0 && (
<span className="ml-1 text-(--ui-text-quaternary)">×{compactNumber(toolCalls[name])}</span>
)}
</ToolChip>
))}
</div>
)}
{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.
<div className="grid gap-1.5">
<p className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{t.skills.visionModelHint}
</p>
<div>
<Button
onClick={() => navigate(`${SETTINGS_ROUTE}?tab=config:model&aux=vision`)}
size="xs"
variant="textStrong"
>
{t.skills.visionModelLink}
</Button>
</div>
</div>
)}
{toolset.name === 'computer_use' && <ComputerUsePanel onConfiguredChange={onConfiguredChange} />}
{/* 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' && <BrowserRealProfilePanel profile={profile} />}
{toolset.name === 'terminal' && <TerminalBackendPanel onConfiguredChange={onConfiguredChange} />}
<ToolsetConfigPanel
key={`${toolset.name}:${profileScopeKey(profile)}`}
onConfiguredChange={onConfiguredChange}
profile={profile}
toolset={toolset.name}
/>
</>
)
}