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:
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: '隐藏技能中心浏览器',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user