feat(desktop): Skills tab hub browser + full-skill detail pane; drop Browse Hub tab; SkillsView SDK export

Consolidates the Capabilities view around the Skills tab and opens the
whole surface to plugins:

- Skills tab: the embedded Skills Hub picker now renders BELOW the
  installed-skills list, expanded by default, with the update-all action
  in its header. "+ Add to this Agent" picks are refused with a toast
  when the skill is already installed in the scoped profile (name and
  identifier both checked against the unfiltered list).
- Detail pane: shows the ENTIRE skill for any provenance — frontmatter
  metadata rendered as key/value rows plus the full SKILL.md body —
  via the existing GET /api/skills/content (new getSkillContent fetcher,
  profile-scoped, cached per skill+scope).
- Browse Hub top-level tab removed (hub.tsx deleted, tabHub i18n keys
  dropped); the hub lives inside Skills now. Legacy ?tab=hub URLs fall
  back to Skills via useRouteEnumParam. Hub actions/store and all hub
  REST fetchers are unchanged.
- SkillsView gains `embedded` (tab state local to the component instead
  of the route ?tab= param) and `fixedProfile` (pins every tab to one
  profile; the scope selector hides and the profiles roster fetch is
  skipped) and is exported from @hermes/plugin-sdk — so Hermes-Bot-Mode
  can render the real Capabilities surface inside its create/edit agent
  Advanced section pinned to a bot.
- i18n: hub.alreadyInstalled (en + zh; others fall back).

Tests: index.test.tsx 8/8 (new: full-skill detail pane renders
frontmatter+body; picker refuses already-installed picks and is expanded
by default); toolset-config-panel 28/28; hermes-parity 11/11. Typecheck
(3 tsconfigs) + eslint clean.
This commit is contained in:
Teknium
2026-08-15 16:44:03 -07:00
parent 95aa709606
commit d5773bfc3a
9 changed files with 316 additions and 572 deletions
@@ -1,8 +1,10 @@
import { useStore } from '@nanostores/react'
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { useI18n } from '@/i18n'
import { installHubSkill } from '@/store/hub-actions'
import { Loader2 } from '@/lib/icons'
import { $hubActions, installHubSkill, UPDATE_ALL_KEY, updateHubSkills } from '@/store/hub-actions'
import { notify, notifyError } from '@/store/notifications'
// The REAL Skills Hub page (docs site) embedded as a one-click picker — the
@@ -24,20 +26,25 @@ interface SkillPickMessage {
}
interface EmbeddedHubPickerProps {
/** Names of skills already installed in the scoped profile — a pick that
* matches is refused with a toast instead of re-running the install. */
installedNames: ReadonlySet<string>
/** Capabilities profile-scope override — installs land in THIS profile;
* undefined/null targets the app-wide active profile. */
profile?: null | string
}
/** Collapsible "browse the full hub" strip for the Skills tab: a resizable
* iframe of the live Skills Hub where every card installs with one click. */
export function EmbeddedHubPicker({ profile }: EmbeddedHubPickerProps) {
/** The Skills Hub browser for the Skills tab: a resizable iframe of the live
* hub where every card installs with one click. Expanded by default —
* discovery IS the point — with a collapse toggle and an update-all action. */
export function EmbeddedHubPicker({ installedNames, profile }: EmbeddedHubPickerProps) {
const { t } = useI18n()
const h = t.skills.hub
const [open, setOpen] = useState(false)
const [open, setOpen] = useState(true)
const updating = useStore($hubActions)[UPDATE_ALL_KEY]?.running ?? false
// Picker messages from the embedded hub page. Origin-checked; installs route
// through the same store pipeline the Browse Hub tab uses, so the action log,
// through the same store pipeline the hub rows use, so the action log,
// optimistic flips, and Skills-list refresh all come for free.
useEffect(() => {
if (!open) {
@@ -58,6 +65,13 @@ export function EmbeddedHubPicker({ profile }: EmbeddedHubPickerProps) {
const target = String(data.identifier || data.name)
const label = String(data.name)
// Already installed in this scope → tell the user, don't reinstall.
if (installedNames.has(label) || installedNames.has(target)) {
notify({ kind: 'success', title: h.alreadyInstalled(label), message: '' })
return
}
notify({ kind: 'success', title: h.installStarted(label), message: h.actionLog })
void installHubSkill(target, profile).catch(err => notifyError(err, h.actionFailed))
}
@@ -65,30 +79,40 @@ export function EmbeddedHubPicker({ profile }: EmbeddedHubPickerProps) {
window.addEventListener('message', onMessage)
return () => window.removeEventListener('message', onMessage)
}, [h, open, profile])
}, [h, installedNames, open, profile])
const updateAll = () => {
notify({ kind: 'success', title: h.updateStarted, message: h.actionLog })
void updateHubSkills(profile).catch(err => notifyError(err, h.actionFailed))
}
return (
<div className="border-b border-(--ui-stroke-secondary)">
<div className="border-t border-(--ui-stroke-secondary)">
<div className="flex items-center justify-between px-3 py-1.5">
<span className="text-[0.7rem] font-medium text-(--ui-text-tertiary)">{h.pickerTitle}</span>
<Button onClick={() => setOpen(v => !v)} size="xs" variant="text">
{open ? h.pickerHide : h.pickerBrowse}
</Button>
<div className="flex items-center gap-1">
<Button disabled={updating} onClick={updateAll} size="xs" variant="text">
{updating && <Loader2 className="size-3 animate-spin" />}
{updating ? h.updating : h.updateAll}
</Button>
<Button onClick={() => setOpen(v => !v)} size="xs" variant="text">
{open ? h.pickerHide : h.pickerBrowse}
</Button>
</div>
</div>
{open && (
<div className="grid gap-1 px-3 pb-2">
{/* Resizable viewport: native CSS resize handle (bottom-right corner)
lets the user drag it larger/smaller. The iframe is rendered
oversized and scaled DOWN (133% × 0.75) so the hub page starts
zoomed out — the cross-origin page itself can't be styled, but
scaling the frame is ours. */}
{/* Resizable viewport: native CSS resize handle lets the user drag it
larger/smaller. The iframe is rendered oversized and scaled DOWN
(133% × 0.75) so the hub page starts zoomed out — the cross-origin
page itself can't be styled, but scaling the frame is ours. */}
<div
style={{
border: '1px solid var(--ui-stroke-secondary)',
borderRadius: 8,
height: 480,
height: 380,
maxWidth: '100%',
minHeight: 240,
minHeight: 200,
minWidth: 320,
overflow: 'hidden',
position: 'relative',
-478
View File
@@ -1,478 +0,0 @@
import { useStore } from '@nanostores/react'
import { useQueries, useQuery } from '@tanstack/react-query'
import { useCallback, useMemo, useState } from 'react'
import { useDebounced } from '@/app/hooks/use-debounced'
import { DetailPane } from '@/app/master-detail'
import { LogTail } from '@/components/chat/log-tail'
import { PageLoader } from '@/components/page-loader'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import {
getSkillHubSources,
previewSkillHub,
scanSkillHub,
searchSkillsHub,
type SkillHubResult,
type SkillHubScanResult
} from '@/hermes'
import { useI18n } from '@/i18n'
import { stripAnsi } from '@/lib/ansi'
import { Loader2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import {
$hubActions,
$hubActiveLog,
$hubInstalledOverride,
closeHubLog,
HUB_SOURCES_KEY,
installHubSkill,
uninstallHubSkill,
UPDATE_ALL_KEY,
updateHubSkills
} from '@/store/hub-actions'
import { notify, notifyError } from '@/store/notifications'
import { normalizeProfileKey } from '@/store/profile'
// Dedup rank when the same skill surfaces from multiple sources — higher trust
// wins. Mirrors the backend's unified_search `_TRUST_RANK`.
const TRUST_RANK: Record<string, number> = { builtin: 2, trusted: 1, community: 0 }
function trustTone(level: string): string {
switch (level) {
case 'builtin':
return 'bg-(--ui-bg-tertiary) text-(--ui-text-secondary)'
case 'trusted':
return 'bg-emerald-500/15 text-emerald-400'
default:
return 'bg-amber-500/15 text-amber-400'
}
}
function verdictTone(policy: string): string {
switch (policy) {
case 'allow':
return 'text-emerald-400'
case 'block':
return 'text-destructive'
default:
return 'text-amber-400'
}
}
// One hub result — a self-contained row that installs/uninstalls ITSELF and
// reads its own action status from the store, so parallel installs never desync.
// `rawInstalled` is the sources/search truth; the store's optimistic override
// wins so the row flips the instant its own action resolves.
function HubSkillRow({
installedName,
onPreview,
profile,
rawInstalled,
skill
}: {
installedName: null | string
onPreview: (skill: SkillHubResult) => void
profile?: null | string
rawInstalled: boolean
skill: SkillHubResult
}) {
const { t } = useI18n()
const h = t.skills.hub
const action = useStore($hubActions)[skill.identifier]
const override = useStore($hubInstalledOverride)[skill.identifier]
const installed = override ?? rawInstalled
const running = action?.running ?? false
const doInstall = () => {
notify({ kind: 'success', title: h.installStarted(skill.name), message: h.actionLog })
void installHubSkill(skill.identifier, profile).catch(err => notifyError(err, h.actionFailed))
}
const doUninstall = () => {
notify({ kind: 'success', title: h.uninstallStarted(skill.name), message: h.actionLog })
void uninstallHubSkill(skill.identifier, installedName || skill.name, profile).catch(err =>
notifyError(err, h.actionFailed)
)
}
return (
<div className="row-hover flex items-start gap-3 rounded-md px-2 py-2.5">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<span className="truncate text-[0.78rem] font-medium text-foreground/85">{skill.name}</span>
<span className={cn('rounded px-1.5 py-0.5 text-[0.6rem]', trustTone(skill.trust_level))}>
{h.trust[skill.trust_level] ?? skill.trust_level}
</span>
{installed && <span className="text-[0.6rem] text-emerald-400">{h.installed}</span>}
</div>
<p className="mt-0.5 line-clamp-2 text-[0.68rem] text-muted-foreground/70">{skill.description}</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button onClick={() => onPreview(skill)} size="xs" variant="text">
{h.preview}
</Button>
{installed ? (
<Button className="hover:text-destructive" disabled={running} onClick={doUninstall} size="xs" variant="text">
{running && <Loader2 className="size-3 animate-spin" />}
{running ? h.uninstalling : h.uninstall}
</Button>
) : (
<Button disabled={running} onClick={doInstall} size="xs" variant="textStrong">
{running && <Loader2 className="size-3 animate-spin" />}
{running ? h.installing : h.install}
</Button>
)}
</div>
</div>
)
}
interface SkillsHubProps {
/** Capabilities profile-scope override — hub browsing/installs target THIS
* profile's backend; undefined/null keeps the app-wide active profile. */
profile?: null | string
query: string
}
export function SkillsHub({ profile, query }: SkillsHubProps) {
const { t } = useI18n()
const h = t.skills.hub
const profileKey = normalizeProfileKey(profile)
// Sources + featured + the installed map — one cached fetch, revalidated on
// mount and re-fetched (from the store) after an action lands.
const sourcesQuery = useQuery({
queryKey: [...HUB_SOURCES_KEY, profileKey],
queryFn: () => getSkillHubSources(profile),
staleTime: 5 * 60_000
})
// Debounced hub search, keyed on the settled query so RQ dedupes/caches per
// term and abandons stale terms for us (no hand-rolled sequence guard).
const term = useDebounced(query.trim(), 350)
// Progressive per-source search: one query per source the backend says is
// worth hitting individually (it marks index-covered API sources unsearchable
// so we don't re-hammer ~70 GitHub calls). Each resolves independently, so the
// list fills in as sources return instead of blocking on the slowest one, and
// each source shows its own spinner. Stale terms key out and are abandoned.
const searchableSources = useMemo(
() => (sourcesQuery.data?.sources ?? []).filter(source => source.searchable !== false),
[sourcesQuery.data]
)
const sourceSearches = useQueries({
queries: searchableSources.map(source => ({
queryKey: ['skill-hub-search', term, source.id, profileKey],
queryFn: () => searchSkillsHub(term, source.id, 20, profile),
enabled: term.length > 0,
staleTime: 60_000
}))
})
// Per-item action lifecycle + log live in the store (store/hub-actions): each
// row reads ITS own entry, so concurrent installs never desync each other,
// and an optimistic installed-override flips a row the instant its own action
// resolves rather than racing the sources refetch.
const actions = useStore($hubActions)
const overrides = useStore($hubInstalledOverride)
const activeLogKey = useStore($hubActiveLog)
const activeLog = activeLogKey ? actions[activeLogKey] : undefined
// Preview/scan dialog. Preview is cache-worthy (keyed by identifier); scan is
// an explicit, on-demand security pass so it stays imperative.
const [detail, setDetail] = useState<null | SkillHubResult>(null)
const [scan, setScan] = useState<null | SkillHubScanResult>(null)
const [scanning, setScanning] = useState(false)
const previewQuery = useQuery({
queryKey: ['skill-hub-preview', detail?.identifier, profileKey],
queryFn: () => previewSkillHub(detail!.identifier, profile),
enabled: detail !== null,
staleTime: 5 * 60_000
})
const install = useCallback(
(identifier: string, name: string) => {
setDetail(null)
notify({ kind: 'success', title: h.installStarted(name), message: h.actionLog })
void installHubSkill(identifier, profile).catch(err => notifyError(err, h.actionFailed))
},
[h, profile]
)
const updateAll = useCallback(() => {
notify({ kind: 'success', title: h.updateStarted, message: h.actionLog })
void updateHubSkills(profile).catch(err => notifyError(err, h.actionFailed))
}, [h, profile])
const runScan = useCallback(
(identifier: string) => {
setScanning(true)
scanSkillHub(identifier, profile)
.then(setScan)
.catch(err => notifyError(err, h.scanFailed))
.finally(() => setScanning(false))
},
[h, profile]
)
const openDetail = useCallback((skill: SkillHubResult) => {
setDetail(skill)
setScan(null)
}, [])
// Per-source progress, keyed by source id (drives the connected-hub chips'
// spinner/degraded tint while a search is streaming in).
const searchStateById = new Map<string, { failed: boolean; fetching: boolean }>()
searchableSources.forEach((source, i) => {
const q = sourceSearches[i]
searchStateById.set(source.id, { failed: q.isError, fetching: term.length > 0 && q.isFetching })
})
// Merge every source's results, deduped by identifier preferring higher trust
// (mirrors the backend's unified_search rank). Recomputes as each source lands.
const results = useMemo(() => {
const seen = new Map<string, SkillHubResult>()
for (const q of sourceSearches) {
for (const r of q.data?.results ?? []) {
const prev = seen.get(r.identifier)
if (!prev || (TRUST_RANK[r.trust_level] ?? 0) > (TRUST_RANK[prev.trust_level] ?? 0)) {
seen.set(r.identifier, r)
}
}
}
return [...seen.values()].sort(
(a, b) => (TRUST_RANK[b.trust_level] ?? 0) - (TRUST_RANK[a.trust_level] ?? 0) || a.name.localeCompare(b.name)
)
}, [sourceSearches])
// Installed map: sources seeds it, search results patch it (a term can surface
// installs the sources list didn't feature); the optimistic override wins so a
// just-(un)installed row reflects its own outcome without the refetch race.
const installed = { ...(sourcesQuery.data?.installed ?? {}) }
for (const q of sourceSearches) {
Object.assign(installed, q.data?.installed ?? {})
}
const isInstalled = (identifier: string) => overrides[identifier] ?? Boolean(installed[identifier])
const sources = sourcesQuery.data?.sources ?? []
const featured = sourcesQuery.data?.featured ?? []
// Still fetching from at least one source; "done" only once every source has
// settled (so "No results" doesn't flash while slower sources are still in).
const anyFetching = term.length > 0 && sourceSearches.some(q => q.isFetching)
const searched = term.length > 0 && sourceSearches.length > 0 && sourceSearches.every(q => !q.isFetching)
const showLanding = term.length === 0
const listed = showLanding ? featured : results
// Only block the whole pane on the first sources landing; after that results
// stream in progressively while a subtle footer shows more are coming.
const searching = anyFetching && results.length === 0
const hasInstalled = Object.keys(installed).length > 0
return (
<div className="flex h-full min-h-0 flex-col">
{/* Connected hubs — label on its own line, chips below, roomy padding. */}
<div className="shrink-0 px-4 pt-5 pb-8 text-[0.68rem] text-(--ui-text-tertiary)">
<span className="mb-1.5 block">{h.connectedHubs}</span>
<div className="flex flex-wrap items-center gap-1.5">
{sourcesQuery.isLoading
? null
: sources.map(source => {
const state = searchStateById.get(source.id)
const degraded = source.available === false || source.rate_limited === true || state?.failed
const fetching = state?.fetching ?? false
return (
<span
className={cn(
'relative rounded px-1.5 py-0.5 text-[0.6rem] transition-opacity',
degraded ? 'bg-amber-500/15 text-amber-400' : 'bg-(--ui-bg-tertiary) text-(--ui-text-secondary)',
// While searching, un-hit sources dim so the active ones read clearly.
term.length > 0 && !fetching && !state?.failed && 'opacity-55'
)}
key={source.id}
>
{/* Spinner overlays the (dimmed) label rather than pushing it,
so a chip never resizes as its search starts/finishes. */}
<span className={cn(fetching && 'opacity-30')}>{source.label}</span>
{fetching && (
<span className="absolute inset-0 grid place-items-center">
<Loader2 className="size-2.5 animate-spin" />
</span>
)}
</span>
)
})}
</div>
</div>
{/* Result summary (left) + Update installed (right) — only when a results
table is actually on screen, and update only if something's installed. */}
{listed.length > 0 && (
<div className="flex shrink-0 items-center justify-between gap-3 px-4 pb-1.5 text-[0.68rem] text-(--ui-text-tertiary)">
<span className="min-w-0 truncate">
{term.length > 0 ? h.resultCount(results.length, null) : h.featured}
{anyFetching && results.length > 0 && (
<span className="ml-2 text-(--ui-text-quaternary)">{h.searching}</span>
)}
</span>
{hasInstalled && (
<Button
className="shrink-0"
disabled={actions[UPDATE_ALL_KEY]?.running}
onClick={updateAll}
size="xs"
variant="text"
>
{actions[UPDATE_ALL_KEY]?.running && <Loader2 className="size-3 animate-spin" />}
{actions[UPDATE_ALL_KEY]?.running ? h.updating : h.updateAll}
</Button>
)}
</div>
)}
{/* Scrollable results. */}
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-4 [scrollbar-gutter:stable]">
{searching ? (
<div className="grid min-h-40 place-items-center">
<PageLoader label={h.searching} />
</div>
) : listed.length === 0 ? (
<div className="grid min-h-40 place-items-center px-6 text-center">
<p className="max-w-md text-[0.72rem] text-(--ui-text-tertiary)">
{searched ? h.noResults : h.landingHint}
</p>
</div>
) : (
<div className="flex flex-col">
{listed.map(skill => (
<HubSkillRow
installedName={installed[skill.identifier]?.name ?? null}
key={skill.identifier}
onPreview={openDetail}
profile={profile}
rawInstalled={Boolean(installed[skill.identifier])}
skill={skill}
/>
))}
</div>
)}
</div>
{/* Action log — same resizable, flush-width bottom pane + LogTail surface
as the MCP logs. ANSI stripped so spawn output reads clean. Tails the
latest-started action ($hubActiveLog). */}
{activeLogKey && (
<DetailPane
defaultCollapsed
defaultHeight={176}
id="hub-action-log"
onClose={closeHubLog}
title={
<span className="flex items-center gap-1.5 text-[0.68rem] font-normal text-muted-foreground/60">
{h.actionLog}
{activeLog?.running && <Codicon name="loading" size="0.75rem" spinning />}
</span>
}
>
<LogTail emptyLabel={h.searching} lines={activeLog?.lines.length ? activeLog.lines.map(stripAnsi) : null} />
</DetailPane>
)}
<Dialog onOpenChange={open => !open && setDetail(null)} open={detail !== null}>
<DialogContent bodyClassName="overflow-hidden" className="max-h-[80vh] max-w-2xl">
{detail && (
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<span className="truncate">{detail.name}</span>
<Badge className={trustTone(detail.trust_level)}>
{h.trust[detail.trust_level] ?? detail.trust_level}
</Badge>
</DialogTitle>
<DialogDescription className="truncate">{detail.identifier}</DialogDescription>
</DialogHeader>
<div className="min-h-0 space-y-3 overflow-y-auto">
{scan && (
<div className="rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-3 text-xs">
<div className={cn('font-medium', verdictTone(scan.policy))}>
{scan.policy === 'allow' ? h.policyAllow : scan.policy === 'block' ? h.policyBlock : h.policyAsk}
{' · '}
{scan.verdict === 'safe'
? h.verdictSafe
: scan.verdict === 'dangerous'
? h.verdictDangerous
: h.verdictCaution}
</div>
<div className="mt-1 text-muted-foreground">
{scan.findings.length === 0 ? h.noFindings : h.findings(scan.findings.length)}
</div>
{scan.findings.slice(0, 12).map((finding, index) => (
<div className="mt-1.5 font-mono text-[0.65rem] text-(--ui-text-tertiary)" key={index}>
[{finding.severity}] {finding.file}
{finding.line !== null ? `:${finding.line}` : ''} — {finding.description}
</div>
))}
</div>
)}
{previewQuery.isLoading ? (
<PageLoader className="min-h-32" label={h.searching} />
) : previewQuery.data ? (
<>
<pre
className="max-h-72 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"
>
{previewQuery.data.skill_md || h.noReadme}
</pre>
{previewQuery.data.files.length > 0 && (
<div className="text-xs text-muted-foreground">
<span className="font-medium">{h.files}:</span> {previewQuery.data.files.join(', ')}
</div>
)}
</>
) : null}
</div>
<DialogFooter>
<Button disabled={scanning} onClick={() => runScan(detail.identifier)} size="sm" variant="text">
{scanning ? h.scanning : h.scan}
</Button>
<Button
disabled={actions[detail.identifier]?.running || isInstalled(detail.identifier)}
onClick={() => install(detail.identifier, detail.name)}
size="sm"
>
{isInstalled(detail.identifier) ? h.installed : h.install}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
</div>
)
}
+66 -1
View File
@@ -16,6 +16,7 @@ const getToolsetConfig = vi.fn()
const selectToolsetProvider = vi.fn()
const getUsageAnalytics = vi.fn()
const getProfiles = vi.fn()
const getSkillContent = vi.fn()
// Partial mock: keep the real module (SkillsView pulls in @/store/profile,
// whose import-time subscription calls setApiRequestProfile) and stub only the
@@ -31,7 +32,8 @@ vi.mock('@/hermes', async importOriginal => ({
getToolsetConfig: (name: string, profile?: null | string) => getToolsetConfig(name, profile),
selectToolsetProvider: (toolset: string, provider: string) => selectToolsetProvider(toolset, provider),
getUsageAnalytics: (days: number, profile?: null | string) => getUsageAnalytics(days, profile),
getProfiles: () => getProfiles()
getProfiles: () => getProfiles(),
getSkillContent: (name: string, profile?: null | string) => getSkillContent(name, profile)
}))
// Notifications hit nanostores/timers we don't care about here.
@@ -85,6 +87,11 @@ beforeEach(() => {
setToolsetEnabled.mockResolvedValue({ ok: true, name: 'web', enabled: false })
getToolsetConfig.mockResolvedValue({ has_category: true, active_provider: null, providers: [] })
getUsageAnalytics.mockResolvedValue({ tools: [] })
getSkillContent.mockResolvedValue({
name: 'web-research',
path: '/skills/web-research/SKILL.md',
content: '---\nname: web-research\nversion: 1.2.0\nauthor: Nous\n---\n\n# Web Research\n\nDeep research steps.'
})
// Single profile by default → the scope selector stays hidden (>1 gate),
// so existing tests see unchanged single-profile behavior.
getProfiles.mockResolvedValue({ profiles: [{ name: 'default', is_default: true }] })
@@ -227,6 +234,64 @@ describe('SkillsView toolset management', () => {
await waitFor(() => expect(setSkillEnabled).toHaveBeenCalledWith('web-research', false, 'researcher'))
})
it('shows the FULL skill in the detail pane — frontmatter metadata + body', async () => {
getSkills.mockResolvedValue([
{
name: 'web-research',
description: 'Research the web',
category: 'research',
enabled: true,
usage: 3,
provenance: 'bundled'
}
])
const { SkillsView } = await import('./index')
await act(async () => {
render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={['/skills?tab=skills']}>
<SkillsView />
</MemoryRouter>
</QueryClientProvider>
)
})
// Frontmatter renders as metadata rows, the body as full text — not just
// the one-line description.
await waitFor(() => expect(getSkillContent).toHaveBeenCalled())
expect(getSkillContent.mock.calls[0][0]).toBe('web-research')
expect(await screen.findByText('version')).toBeTruthy()
expect(await screen.findByText('1.2.0')).toBeTruthy()
expect(await screen.findByText(/Deep research steps/)).toBeTruthy()
})
it('hub picker refuses to reinstall an already-installed skill', async () => {
const { notify } = await import('@/store/notifications')
const { EmbeddedHubPicker } = await import('./embedded-hub-picker')
render(<EmbeddedHubPicker installedNames={new Set(['web-research'])} profile={null} />)
// The picker is expanded by default — the hub iframe is live on mount.
expect(document.querySelector('iframe')).toBeTruthy()
await act(async () => {
window.dispatchEvent(
new MessageEvent('message', {
data: { type: 'hermes-skill-pick', name: 'web-research', identifier: 'web-research' },
origin: 'https://hermes-agent.nousresearch.com'
})
)
})
// Refused with an informational toast, no install action spawned.
await waitFor(() =>
expect(vi.mocked(notify)).toHaveBeenCalledWith(
expect.objectContaining({ title: '"web-research" is already installed' })
)
)
})
it('shows a vision explainer that deep-links to Settings → Models', async () => {
// Vision has no TOOL_CATEGORIES provider matrix — its model lives in the
// auxiliary model config, so the detail pane must point there instead of
+186 -72
View File
@@ -15,6 +15,7 @@ import {
editLearningNode,
getLearningNode,
getProfiles,
getSkillContent,
getSkills,
getToolsets,
getUsageAnalytics,
@@ -58,11 +59,13 @@ import { ToolsetConfigPanel } from '../settings/toolset-config-panel'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
import { EmbeddedHubPicker } from './embedded-hub-picker'
import { SkillsHub } from './hub'
import { McpTab } from './mcp-tab'
import { $skillsSortDesc, $toolsetsSortDesc } from './store'
const SKILLS_MODES = ['skills', 'toolsets', 'mcp', 'hub'] as const
// '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
@@ -180,12 +183,29 @@ const visibleToolsetCount = (toolsets: ToolsetInfo[]) => toolsets.filter(ts => i
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
}
export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: SkillsViewProps) {
export function SkillsView({
embedded = false,
fixedProfile,
setStatusbarItemGroup: _setStatusbarItemGroup,
...props
}: SkillsViewProps) {
const { t } = useI18n()
const [mode, setMode] = useRouteEnumParam('tab', SKILLS_MODES, 'skills')
// $gateway only feeds the MCP tab — gate the subscription so Skills/Toolsets/Hub
// 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))
@@ -195,15 +215,18 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
// editing. Defaults to the app-wide active profile; overriding it here lets
// the user configure ANY profile's toolsets/MCP without switching the whole
// app into that profile. null = the active profile (unchanged behavior).
// A `fixedProfile` pins the scope outright (selector hidden).
const activeProfile = useStore($activeGatewayProfile)
const [scopeOverride, setScopeOverride] = useState<null | string>(null)
const scopeProfile = scopeOverride ?? activeProfile ?? null
const scopeProfile = fixedProfile ?? scopeOverride ?? activeProfile ?? null
const scopeKey = normalizeProfileKey(scopeProfile)
const { data: profilesData } = useQuery({
queryKey: ['capabilities-profiles'],
queryFn: getProfiles,
staleTime: 60_000
staleTime: 60_000,
// Pinned scope never shows the selector, so don't fetch the roster for it.
enabled: !fixedProfile
})
const profiles = profilesData?.profiles ?? []
@@ -323,6 +346,10 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
const bulkSkills = skills ?? []
const bulkToolsets = useMemo(() => (toolsets ?? []).filter(ts => isDesktopToolsetVisible(ts.name)), [toolsets])
// 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])
// Rotating placeholder nudges from the user's own data — teach that search
// understands categories and tool names, not just titles.
const searchHints = useMemo(() => {
@@ -630,36 +657,21 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
// searching it is noise.
searchHidden={mode === 'mcp'}
searchHints={searchHints}
searchPlaceholder={
mode === 'skills'
? t.skills.searchSkills
: mode === 'hub'
? t.skills.hub.searchPlaceholder
: t.skills.searchToolsets
}
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 },
{ id: 'hub', label: t.skills.tabHub }
{ id: 'mcp', label: t.skills.tabMcp }
]}
>
{/* One shared column: the scope selector sits above whichever tab is
active, so Skills / Tools / MCP / Browse Hub all read and write the
SAME selected profile. */}
active, so Skills / Tools / MCP all read and write the SAME selected
profile. */}
<div className="flex h-full flex-col">
{profileScopeSelector}
{/* One-click hub installs live right on the Skills tab: an embedded
picker of the live Skills Hub (same postMessage trick Bot Mode
uses), scoped to the selected profile. Collapsed by default. */}
{mode === 'skills' && <EmbeddedHubPicker key={`picker-${scopeKey}`} profile={scopeProfile} />}
<div className="min-h-0 flex-1">
{mode === 'hub' ? (
// Keyed per scope so a scope change remounts the hub (drops the
// previous profile's action log / transient state).
<SkillsHub key={`hub-${scopeKey}`} profile={scopeProfile} query={query} />
) : mode === 'mcp' ? (
{mode === 'mcp' ? (
<McpTab gateway={gateway} key={`mcp-${scopeKey}`} profile={scopeProfile} />
) : (skillsFailed || toolsetsFailed) && (!skills || !toolsets) ? (
<PanelEmpty
@@ -675,52 +687,70 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
) : !skills || !toolsets ? (
<PageLoader label={t.skills.loading} />
) : mode === 'skills' ? (
visibleSkills.length === 0 ? (
capabilityEmpty('skills')
) : (
<MasterDetail pane={skillEditorPane} 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)}
// Installed skills on top, the Skills Hub browser underneath —
// discovery sits with management, expanded by default. The picker
// renders even when the (filtered) list is empty: a fresh install
// with zero skills is exactly when browsing the hub matters most.
<div className="flex h-full min-h-0 flex-col">
<div className="min-h-0 flex-1">
{visibleSkills.length === 0 ? (
capabilityEmpty('skills')
) : (
<MasterDetail pane={skillEditorPane} 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={activeSkill?.name === skill.name}
busy={bulkBusy}
enabled={skill.enabled}
key={skill.name}
meta={usageOf(skill) > 0 ? `×${compactNumber(usageOf(skill))}` : undefined}
onSelect={() => setSelectedSkill(skill.name)}
onToggle={enabled => void handleToggleSkill(skill, enabled)}
subtitle={skillSubtitle(skill)}
title={skill.name}
toggleLabel={skill.name}
/>
))}
</ListColumn>
<DetailColumn footer={t.skills.changesApplyNewSessions}>
{activeSkill && (
<SkillDetail
onArchive={() => setArchiveTarget(activeSkill.name)}
onEdit={() => void openSkillEditor(activeSkill.name)}
skill={activeSkill}
/>
)}
</DetailColumn>
</MasterDetail>
)
>
{visibleSkills.map(skill => (
<CapRow
active={activeSkill?.name === skill.name}
busy={bulkBusy}
enabled={skill.enabled}
key={skill.name}
meta={usageOf(skill) > 0 ? `×${compactNumber(usageOf(skill))}` : undefined}
onSelect={() => setSelectedSkill(skill.name)}
onToggle={enabled => void handleToggleSkill(skill, enabled)}
subtitle={skillSubtitle(skill)}
title={skill.name}
toggleLabel={skill.name}
/>
))}
</ListColumn>
<DetailColumn footer={t.skills.changesApplyNewSessions}>
{activeSkill && (
<SkillDetail
onArchive={() => setArchiveTarget(activeSkill.name)}
onEdit={() => void openSkillEditor(activeSkill.name)}
profile={scopeProfile}
skill={activeSkill}
/>
)}
</DetailColumn>
</MasterDetail>
)}
</div>
<EmbeddedHubPicker
installedNames={installedSkillNames}
key={`picker-${scopeKey}`}
profile={scopeProfile}
/>
</div>
) : visibleToolsets.length === 0 ? (
capabilityEmpty('tools')
) : (
@@ -826,12 +856,76 @@ function DetailHeader({
)
}
function SkillDetail({ onArchive, onEdit, skill }: { onArchive: () => void; onEdit: () => void; skill: SkillInfo }) {
// 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?: null | string
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, normalizeProfileKey(profile)],
queryFn: () => getSkillContent(skill.name, profile),
staleTime: 60_000
})
const parsed = useMemo(
() => (contentQuery.data ? parseFrontmatter(contentQuery.data.content) : null),
[contentQuery.data]
)
return (
<>
<DetailHeader
@@ -858,6 +952,26 @@ function SkillDetail({ onArchive, onEdit, skill }: { onArchive: () => void; onEd
</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}
</>
)
}
+12
View File
@@ -1070,6 +1070,18 @@ export function getSkills(profile?: null | string): Promise<SkillInfo[]> {
})
}
/** Raw SKILL.md text (frontmatter included) for ANY skill — bundled, hub, or
* learned — backing the Capabilities detail pane's full-skill view. */
export function getSkillContent(
name: string,
profile?: null | string
): Promise<{ content: string; name: string; path: string }> {
return window.hermesDesktop.api<{ content: string; name: string; path: string }>({
...profileScoped(profile),
path: `/api/skills/content?name=${encodeURIComponent(name)}`
})
}
export function getStarmapGraph(): Promise<StarmapGraph> {
return window.hermesDesktop.api<StarmapGraph>({
...profileScoped(),
+1 -1
View File
@@ -1076,7 +1076,6 @@ export const en: Translations = {
tabToolsets: 'Tools',
configuringProfile: 'Configuring:',
tabMcp: 'MCP',
tabHub: 'Browse Hub',
all: 'All',
searchSkills: 'Search skills...',
searchToolsets: 'Search tools...',
@@ -1171,6 +1170,7 @@ export const en: Translations = {
updateStarted: 'Updating installed skills...',
actionFailed: 'Skill action failed',
actionLog: 'Action log',
alreadyInstalled: (name: string) => `"${name}" is already installed`,
pickerTitle: 'Skills Hub',
pickerBrowse: 'Browse the full hub',
pickerHide: 'Hide the hub browser',
+1 -1
View File
@@ -934,7 +934,6 @@ export interface Translations {
tabToolsets: string
configuringProfile: string
tabMcp: string
tabHub: string
all: string
searchSkills: string
searchToolsets: string
@@ -1019,6 +1018,7 @@ export interface Translations {
updateStarted: string
actionFailed: string
actionLog: string
alreadyInstalled: (name: string) => string
pickerTitle: string
pickerBrowse: string
pickerHide: string
+1 -1
View File
@@ -1271,7 +1271,6 @@ export const zh: Translations = {
tabToolsets: '工具集',
configuringProfile: '正在配置:',
tabMcp: 'MCP',
tabHub: '浏览技能中心',
all: '全部',
searchSkills: '搜索技能…',
searchToolsets: '搜索工具集…',
@@ -1364,6 +1363,7 @@ export const zh: Translations = {
updateStarted: '正在更新已安装技能…',
actionFailed: '技能操作失败',
actionLog: '操作日志',
alreadyInstalled: name => `“${name}”已安装`,
pickerTitle: '技能中心',
pickerBrowse: '浏览完整技能中心',
pickerHide: '隐藏技能中心浏览器',
+7
View File
@@ -253,6 +253,13 @@ export {
export type { StatusbarItem } from '@/app/shell/statusbar-controls'
export type { TitlebarTool } from '@/app/shell/titlebar-controls'
/** THE whole Capabilities surface (Skills / Tools / MCP tabs, installed
* lists, full-skill detail pane, embedded hub picker with one-click
* installs). For plugin dialogs pass `embedded` (tab state stays local —
* never touches the page router) and `fixedProfile` to pin every tab to one
* bot's backend; the internal profile selector hides itself. Bot Mode's
* Advanced section is the reference consumer. */
export { SkillsView } from '@/app/skills'
/** THE full MCP tab core Settings renders — per-server enable + OAuth sign-in
* + API-key setup + live probes, not a checkbox list. Route-decoupled so it
* renders anywhere (a plugin dialog); pass a live `gateway` (see