From d5773bfc3ad32148f0ff2e1de975fc94e37a0335 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 15 Aug 2026 16:44:03 -0700 Subject: [PATCH] feat(desktop): Skills tab hub browser + full-skill detail pane; drop Browse Hub tab; SkillsView SDK export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/app/skills/embedded-hub-picker.tsx | 60 ++- apps/desktop/src/app/skills/hub.tsx | 478 ------------------ apps/desktop/src/app/skills/index.test.tsx | 67 ++- apps/desktop/src/app/skills/index.tsx | 258 +++++++--- apps/desktop/src/hermes.ts | 12 + apps/desktop/src/i18n/en.ts | 2 +- apps/desktop/src/i18n/types.ts | 2 +- apps/desktop/src/i18n/zh.ts | 2 +- apps/desktop/src/sdk/index.ts | 7 + 9 files changed, 316 insertions(+), 572 deletions(-) delete mode 100644 apps/desktop/src/app/skills/hub.tsx diff --git a/apps/desktop/src/app/skills/embedded-hub-picker.tsx b/apps/desktop/src/app/skills/embedded-hub-picker.tsx index 8e2d2959ed..6f2afea0b0 100644 --- a/apps/desktop/src/app/skills/embedded-hub-picker.tsx +++ b/apps/desktop/src/app/skills/embedded-hub-picker.tsx @@ -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 /** 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 ( -
+
{h.pickerTitle} - +
+ + +
{open && (
- {/* 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. */}
= { 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 ( -
-
-
- {skill.name} - - {h.trust[skill.trust_level] ?? skill.trust_level} - - {installed && {h.installed}} -
-

{skill.description}

-
-
- - {installed ? ( - - ) : ( - - )} -
-
- ) -} - -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) - const [scan, setScan] = useState(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() - 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() - - 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 ( -
- {/* Connected hubs — label on its own line, chips below, roomy padding. */} -
- {h.connectedHubs} -
- {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 ( - 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. */} - {source.label} - {fetching && ( - - - - )} - - ) - })} -
-
- - {/* 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 && ( -
- - {term.length > 0 ? h.resultCount(results.length, null) : h.featured} - {anyFetching && results.length > 0 && ( - {h.searching} - )} - - - {hasInstalled && ( - - )} -
- )} - - {/* Scrollable results. */} -
- {searching ? ( -
- -
- ) : listed.length === 0 ? ( -
-

- {searched ? h.noResults : h.landingHint} -

-
- ) : ( -
- {listed.map(skill => ( - - ))} -
- )} -
- - {/* 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 && ( - - {h.actionLog} - {activeLog?.running && } - - } - > - - - )} - - !open && setDetail(null)} open={detail !== null}> - - {detail && ( - <> - - - {detail.name} - - {h.trust[detail.trust_level] ?? detail.trust_level} - - - {detail.identifier} - - -
- {scan && ( -
-
- {scan.policy === 'allow' ? h.policyAllow : scan.policy === 'block' ? h.policyBlock : h.policyAsk} - {' · '} - {scan.verdict === 'safe' - ? h.verdictSafe - : scan.verdict === 'dangerous' - ? h.verdictDangerous - : h.verdictCaution} -
-
- {scan.findings.length === 0 ? h.noFindings : h.findings(scan.findings.length)} -
- {scan.findings.slice(0, 12).map((finding, index) => ( -
- [{finding.severity}] {finding.file} - {finding.line !== null ? `:${finding.line}` : ''} — {finding.description} -
- ))} -
- )} - - {previewQuery.isLoading ? ( - - ) : previewQuery.data ? ( - <> -
-                      {previewQuery.data.skill_md || h.noReadme}
-                    
- {previewQuery.data.files.length > 0 && ( -
- {h.files}: {previewQuery.data.files.join(', ')} -
- )} - - ) : null} -
- - - - - - - )} -
-
-
- ) -} diff --git a/apps/desktop/src/app/skills/index.test.tsx b/apps/desktop/src/app/skills/index.test.tsx index 7aebb7c7a8..fbbcd8fa04 100644 --- a/apps/desktop/src/app/skills/index.test.tsx +++ b/apps/desktop/src/app/skills/index.test.tsx @@ -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( + + + + + + ) + }) + + // 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() + + // 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 diff --git a/apps/desktop/src/app/skills/index.tsx b/apps/desktop/src/app/skills/index.tsx index efdb4dbd5f..302612d98a 100644 --- a/apps/desktop/src/app/skills/index.tsx +++ b/apps/desktop/src/app/skills/index.tsx @@ -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) - 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. */}
{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' && }
- {mode === 'hub' ? ( - // Keyed per scope so a scope change remounts the hub (drops the - // previous profile's action log / transient state). - - ) : mode === 'mcp' ? ( + {mode === 'mcp' ? ( ) : (skillsFailed || toolsetsFailed) && (!skills || !toolsets) ? ( ) : mode === 'skills' ? ( - visibleSkills.length === 0 ? ( - capabilityEmpty('skills') - ) : ( - - $skillsSortDesc.set(!$skillsSortDesc.get()))} - right={ - 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. +
+
+ {visibleSkills.length === 0 ? ( + capabilityEmpty('skills') + ) : ( + + $skillsSortDesc.set(!$skillsSortDesc.get()))} + right={ + void disableUnused() + } + ]} + label={t.skills.tabSkills} + toggle={bulkSwitch(allSkillsEnabled)} + /> + } /> } - /> - } - > - {visibleSkills.map(skill => ( - 0 ? `×${compactNumber(usageOf(skill))}` : undefined} - onSelect={() => setSelectedSkill(skill.name)} - onToggle={enabled => void handleToggleSkill(skill, enabled)} - subtitle={skillSubtitle(skill)} - title={skill.name} - toggleLabel={skill.name} - /> - ))} - - - {activeSkill && ( - setArchiveTarget(activeSkill.name)} - onEdit={() => void openSkillEditor(activeSkill.name)} - skill={activeSkill} - /> - )} - - - ) + > + {visibleSkills.map(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} + /> + ))} + + + {activeSkill && ( + setArchiveTarget(activeSkill.name)} + onEdit={() => void openSkillEditor(activeSkill.name)} + profile={scopeProfile} + skill={activeSkill} + /> + )} + + + )} +
+ +
) : 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 ( <> void; onEd
)} + {parsed && parsed.meta.length > 0 && ( +
+ {parsed.meta.map(([key, value]) => ( +
+ {key} + {value} +
+ ))} +
+ )} + {contentQuery.isLoading ? ( + + ) : parsed ? ( +
+          {parsed.body.trim() || t.skills.noDescription}
+        
+ ) : null} ) } diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index 974a4da5a3..8234860c9d 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -1070,6 +1070,18 @@ export function getSkills(profile?: null | string): Promise { }) } +/** 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 { return window.hermesDesktop.api({ ...profileScoped(), diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index e01c0994b0..0b7f57d461 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -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', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 0d24573ce9..5ab74d2fa3 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -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 diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 196a73d49c..42deb03e52 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -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: '隐藏技能中心浏览器', diff --git a/apps/desktop/src/sdk/index.ts b/apps/desktop/src/sdk/index.ts index 499b046324..147e71c877 100644 --- a/apps/desktop/src/sdk/index.ts +++ b/apps/desktop/src/sdk/index.ts @@ -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