1375 lines
54 KiB
TypeScript
1375 lines
54 KiB
TypeScript
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 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<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 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<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}
|
||
/>
|
||
</>
|
||
)
|
||
}
|