feat(desktop): skill Hub in Capabilities — React Query + per-item store

Fold the skill hub (search/preview/scan/install, from #57441) into Capabilities
as a fourth "Browse Hub" tab, rebuilt on our stack:
- sources + debounced term search + preview are useQuery-driven (RQ dedupes/
  caches per term, cancels stale terms — no hand-rolled sequence guard).
- Each result is a self-contained HubSkillRow that installs/uninstalls ITSELF,
  reading its own status from a nanostore (store/hub-actions). Concurrent
  installs never desync; an optimistic installed-override flips a row the instant
  its own action resolves instead of racing the sources refetch.
- Action log bubbles through a $hubActiveLog atom into the shared LogTail in a
  collapsed-by-default, persistent bottom DetailPane (ANSI stripped).
This commit is contained in:
Brooklyn Nicholson 2026-07-03 05:08:36 -05:00
parent 16aa09aca5
commit 929ba007bb
2 changed files with 326 additions and 287 deletions

View file

@ -1,5 +1,10 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import { useCallback, 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'
@ -13,26 +18,30 @@ import {
DialogTitle
} from '@/components/ui/dialog'
import {
getActionStatus,
getSkillHubSources,
installSkillFromHub,
previewSkillHub,
scanSkillHub,
searchSkillsHub,
type SkillHubInstalledEntry,
type SkillHubPreview,
type SkillHubResult,
type SkillHubScanResult,
type SkillHubSource,
updateSkillsFromHub
type SkillHubScanResult
} from '@/hermes'
import { useI18n } from '@/i18n'
import { stripAnsi } from '@/lib/ansi'
import { Loader2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { upsertDesktopActionTask } from '@/store/activity'
import {
$hubActions,
$hubActiveLog,
$hubInstalledOverride,
closeHubLog,
HUB_SOURCES_KEY,
installHubSkill,
uninstallHubSkill,
UPDATE_ALL_KEY,
updateHubSkills
} from '@/store/hub-actions'
import { notify, notifyError } from '@/store/notifications'
const ACTION_POLL_MS = 1200
function trustTone(level: string): string {
switch (level) {
case 'builtin':
@ -59,208 +68,135 @@ function verdictTone(policy: string): string {
}
}
// 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,
rawInstalled,
skill
}: {
installedName: null | string
onPreview: (skill: SkillHubResult) => void
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).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).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 {
/** Called after an install/uninstall/update finishes so the parent can refresh the installed-skills list. */
onInstalledChange?: () => void
query: string
}
export function SkillsHub({ onInstalledChange, query }: SkillsHubProps) {
export function SkillsHub({ query }: SkillsHubProps) {
const { t } = useI18n()
const h = t.skills.hub
const [sources, setSources] = useState<SkillHubSource[]>([])
const [featured, setFeatured] = useState<SkillHubResult[]>([])
const [installed, setInstalled] = useState<Record<string, SkillHubInstalledEntry>>({})
const [sourcesLoading, setSourcesLoading] = useState(true)
// 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,
queryFn: getSkillHubSources,
staleTime: 5 * 60_000
})
const [results, setResults] = useState<SkillHubResult[]>([])
const [searching, setSearching] = useState(false)
const [searched, setSearched] = useState(false)
const [timedOut, setTimedOut] = useState<string[]>([])
const [searchMs, setSearchMs] = useState<null | number>(null)
// Debounced hub search, keyed on the settled query so RQ dedupes/caches per
// term and cancels stale terms for us (no hand-rolled sequence guard).
const term = useDebounced(query.trim(), 350)
// Live log tail for the most recent install/uninstall/update action.
const [action, setAction] = useState<null | string>(null)
const [actionLog, setActionLog] = useState<string[]>([])
const [actionRunning, setActionRunning] = useState(false)
const searchQuery = useQuery({
queryKey: ['skill-hub-search', term],
queryFn: () => searchSkillsHub(term),
enabled: term.length > 0,
staleTime: 60_000
})
// Preview/scan dialog state.
// 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 [preview, setPreview] = useState<null | SkillHubPreview>(null)
const [previewLoading, setPreviewLoading] = useState(false)
const [scan, setScan] = useState<null | SkillHubScanResult>(null)
const [scanning, setScanning] = useState(false)
const searchSeq = useRef(0)
useEffect(() => {
let cancelled = false
getSkillHubSources()
.then(response => {
if (cancelled) {
return
}
setSources(response.sources)
setFeatured(response.featured)
setInstalled(response.installed)
})
.catch(err => notifyError(err, h.loadFailed))
.finally(() => {
if (!cancelled) {
setSourcesLoading(false)
}
})
return () => void (cancelled = true)
// eslint-disable-next-line react-hooks/exhaustive-deps -- load once on mount
}, [])
// Debounced hub search driven by the shared page search field.
useEffect(() => {
const trimmed = query.trim()
if (!trimmed) {
setResults([])
setSearched(false)
setSearching(false)
setTimedOut([])
setSearchMs(null)
return
}
const seq = searchSeq.current + 1
searchSeq.current = seq
setSearching(true)
const timer = window.setTimeout(() => {
const started = performance.now()
searchSkillsHub(trimmed)
.then(response => {
if (searchSeq.current !== seq) {
return
}
setResults(response.results)
setTimedOut(response.timed_out || [])
setInstalled(prev => ({ ...prev, ...(response.installed || {}) }))
setSearchMs(Math.round(performance.now() - started))
setSearched(true)
})
.catch(err => {
if (searchSeq.current === seq) {
notifyError(err, h.searchFailed)
setResults([])
setSearched(true)
}
})
.finally(() => {
if (searchSeq.current === seq) {
setSearching(false)
}
})
}, 350)
return () => window.clearTimeout(timer)
}, [h, query])
// Poll a spawned hub action's log until it exits, then refresh installed state.
useEffect(() => {
if (!action) {
return
}
let cancelled = false
let timer: null | number = null
const poll = async () => {
try {
const status = await getActionStatus(action, 200)
if (cancelled) {
return
}
setActionLog(status.lines)
setActionRunning(status.running)
upsertDesktopActionTask(status)
if (status.running) {
timer = window.setTimeout(() => void poll(), ACTION_POLL_MS)
} else {
getSkillHubSources()
.then(response => {
if (!cancelled) {
setInstalled(response.installed)
}
})
.catch(() => {})
onInstalledChange?.()
}
} catch {
if (!cancelled) {
setActionRunning(false)
}
}
}
void poll()
return () => {
cancelled = true
if (timer !== null) {
window.clearTimeout(timer)
}
}
}, [action, onInstalledChange])
const previewQuery = useQuery({
queryKey: ['skill-hub-preview', detail?.identifier],
queryFn: () => previewSkillHub(detail!.identifier),
enabled: detail !== null,
staleTime: 5 * 60_000
})
const install = useCallback(
async (identifier: string, name: string) => {
try {
const started = await installSkillFromHub(identifier)
notify({ kind: 'success', title: h.installStarted(name), message: h.actionLog })
setActionLog([])
setActionRunning(true)
setAction(started.name)
setDetail(null)
} catch (err) {
notifyError(err, h.actionFailed)
}
(identifier: string, name: string) => {
setDetail(null)
notify({ kind: 'success', title: h.installStarted(name), message: h.actionLog })
void installHubSkill(identifier).catch(err => notifyError(err, h.actionFailed))
},
[h]
)
const updateAll = useCallback(async () => {
try {
const started = await updateSkillsFromHub()
notify({ kind: 'success', title: h.updateStarted, message: h.actionLog })
setActionLog([])
setActionRunning(true)
setAction(started.name)
} catch (err) {
notifyError(err, h.actionFailed)
}
const updateAll = useCallback(() => {
notify({ kind: 'success', title: h.updateStarted, message: h.actionLog })
void updateHubSkills().catch(err => notifyError(err, h.actionFailed))
}, [h])
const openDetail = useCallback(
(skill: SkillHubResult) => {
setDetail(skill)
setPreview(null)
setScan(null)
setPreviewLoading(true)
previewSkillHub(skill.identifier)
.then(setPreview)
.catch(err => notifyError(err, h.previewFailed))
.finally(() => setPreviewLoading(false))
},
[h]
)
const runScan = useCallback(
(identifier: string) => {
setScanning(true)
@ -272,115 +208,123 @@ export function SkillsHub({ onInstalledChange, query }: SkillsHubProps) {
[h]
)
const isInstalled = useCallback((identifier: string) => Boolean(installed[identifier]), [installed])
const openDetail = useCallback((skill: SkillHubResult) => {
setDetail(skill)
setScan(null)
}, [])
const hasInstalled = Object.keys(installed).length > 0
const showLanding = !searched && !searching
// 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 ?? {}), ...(searchQuery.data?.installed ?? {}) }
const isInstalled = (identifier: string) => overrides[identifier] ?? Boolean(installed[identifier])
const sources = sourcesQuery.data?.sources ?? []
const featured = sourcesQuery.data?.featured ?? []
const results = searchQuery.data?.results ?? []
const timedOut = searchQuery.data?.timed_out ?? []
const searching = term.length > 0 && searchQuery.isFetching
const searched = term.length > 0 && searchQuery.isSuccess
const showLanding = term.length === 0
const listed = showLanding ? featured : results
const hasInstalled = Object.keys(installed).length > 0
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
{sourcesLoading ? (
<span>{h.connectingHubs}</span>
) : (
<>
<span>{h.connectedHubs}</span>
{sources.map(source => {
<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 degraded = source.available === false || source.rate_limited === true
return (
<Badge
<span
className={cn(
'rounded px-1.5 py-0.5 text-[0.6rem]',
degraded ? 'bg-amber-500/15 text-amber-400' : 'bg-(--ui-bg-tertiary) text-(--ui-text-secondary)'
)}
key={source.id}
>
{source.label}
</Badge>
</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">
{searched ? h.resultCount(results.length, null) : h.featured}
{timedOut.length > 0 && <span className="ml-2 text-amber-400">{h.timedOut(timedOut.join(', '))}</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>
{hasInstalled && (
<Button disabled={actionRunning} onClick={() => void updateAll()} size="xs" variant="text">
{actionRunning ? h.updating : h.updateAll}
</Button>
)}
{/* 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}
rawInstalled={Boolean(installed[skill.identifier])}
skill={skill}
/>
))}
</div>
)}
</div>
{searched && !searching && (
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span>{h.resultCount(results.length, searchMs)}</span>
{timedOut.length > 0 && <span className="text-amber-400">{h.timedOut(timedOut.join(', '))}</span>}
</div>
)}
{searching ? (
<PageLoader className="min-h-40" label={h.searching} />
) : listed.length === 0 ? (
<div className="grid min-h-40 place-items-center text-center">
<div className="max-w-md text-xs text-muted-foreground">{searched ? h.noResults : h.landingHint}</div>
</div>
) : (
<div className="space-y-1">
{showLanding && (
<div className="text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
{h.featured}
</div>
)}
<div>
{listed.map(skill => (
<div
className="grid gap-3 px-0 py-2.5 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center"
key={skill.identifier}
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">{skill.name}</span>
<Badge className={trustTone(skill.trust_level)}>
{h.trust[skill.trust_level] ?? skill.trust_level}
</Badge>
{isInstalled(skill.identifier) && (
<Badge className="bg-emerald-500/15 text-emerald-400">{h.installed}</Badge>
)}
</div>
<p className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">{skill.description}</p>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<Button onClick={() => openDetail(skill)} size="xs" variant="text">
{h.preview}
</Button>
<Button
disabled={actionRunning || isInstalled(skill.identifier)}
onClick={() => void install(skill.identifier, skill.name)}
size="xs"
variant="textStrong"
>
{isInstalled(skill.identifier) ? h.installed : h.install}
</Button>
</div>
</div>
))}
</div>
</div>
)}
{action && actionLog.length > 0 && (
<div>
<div className="mb-1.5 flex items-center gap-2 text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
{h.actionLog}
{actionRunning && <Codicon name="loading" size="0.75rem" spinning />}
</div>
<pre
className="max-h-48 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.65rem] leading-relaxed text-(--ui-text-tertiary)"
data-selectable-text="true"
>
{actionLog.join('\n')}
</pre>
</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}>
@ -421,19 +365,19 @@ export function SkillsHub({ onInstalledChange, query }: SkillsHubProps) {
</div>
)}
{previewLoading ? (
{previewQuery.isLoading ? (
<PageLoader className="min-h-32" label={h.searching} />
) : preview ? (
) : 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"
>
{preview.skill_md || h.noReadme}
{previewQuery.data.skill_md || h.noReadme}
</pre>
{preview.files.length > 0 && (
{previewQuery.data.files.length > 0 && (
<div className="text-xs text-muted-foreground">
<span className="font-medium">{h.files}:</span> {preview.files.join(', ')}
<span className="font-medium">{h.files}:</span> {previewQuery.data.files.join(', ')}
</div>
)}
</>
@ -445,8 +389,8 @@ export function SkillsHub({ onInstalledChange, query }: SkillsHubProps) {
{scanning ? h.scanning : h.scan}
</Button>
<Button
disabled={actionRunning || isInstalled(detail.identifier)}
onClick={() => void install(detail.identifier, detail.name)}
disabled={actions[detail.identifier]?.running || isInstalled(detail.identifier)}
onClick={() => install(detail.identifier, detail.name)}
size="sm"
>
{isInstalled(detail.identifier) ? h.installed : h.install}

View file

@ -0,0 +1,95 @@
import { atom, map } from 'nanostores'
import { getActionStatus, installSkillFromHub, uninstallSkillFromHub, updateSkillsFromHub } from '@/hermes'
import { queryClient } from '@/lib/query-client'
import { upsertDesktopActionTask } from '@/store/activity'
const POLL_MS = 1200
// Shared with hub.tsx's sources useQuery so a finished action refreshes the
// installed map.
export const HUB_SOURCES_KEY = ['skill-hub-sources'] as const
// The Capabilities Skills-list query key (see app/skills/index.tsx) — kept in
// sync here so a hub (un)install updates the Skills tab, not just the hub.
const SKILLS_LIST_KEY = ['skills-list'] as const
// Non-identifier key for the fleet-wide "Update installed" action.
export const UPDATE_ALL_KEY = '__update_all__'
export type HubActionKind = 'install' | 'uninstall' | 'update'
export interface HubAction {
kind: HubActionKind
running: boolean
lines: string[]
}
// Per-item action status, keyed by skill identifier (or UPDATE_ALL_KEY). Each
// row drives its own button off ITS entry — one install never touches another.
export const $hubActions = map<Record<string, HubAction | undefined>>({})
// Optimistic installed overrides so a row flips to its resolved state the instant
// its own action finishes, instead of waiting on (and racing) the sources
// refetch. install/update → true, uninstall → false; sources reconciles after.
export const $hubInstalledOverride = map<Record<string, boolean | undefined>>({})
// The key whose log the bottom pane currently tails (the latest-started action).
export const $hubActiveLog = atom<null | string>(null)
// One self-contained task: spawn → tail its own action log into the store →
// mark resolved. Concurrency-safe: state is per-key, so parallel installs never
// stomp each other, and the sources query is invalidated once at the end.
async function runHubAction(
key: string,
kind: HubActionKind,
spawn: () => Promise<{ name: string }>
): Promise<void> {
$hubActions.setKey(key, { kind, running: true, lines: [] })
$hubActiveLog.set(key)
try {
const started = await spawn()
for (;;) {
const status = await getActionStatus(started.name, 200)
upsertDesktopActionTask(status)
$hubActions.setKey(key, { kind, running: status.running, lines: status.lines })
if (!status.running) {
break
}
await new Promise(resolve => setTimeout(resolve, POLL_MS))
}
if (key !== UPDATE_ALL_KEY) {
$hubInstalledOverride.setKey(key, kind !== 'uninstall')
}
// Refresh the hub's installed map AND the Capabilities Skills list — a hub
// (un)install adds/removes a skill, so its count/rows must update too.
void queryClient.invalidateQueries({ queryKey: HUB_SOURCES_KEY })
void queryClient.invalidateQueries({ queryKey: SKILLS_LIST_KEY })
} finally {
const current = $hubActions.get()[key]
if (current) {
$hubActions.setKey(key, { ...current, running: false })
}
}
}
export function installHubSkill(identifier: string): Promise<void> {
return runHubAction(identifier, 'install', () => installSkillFromHub(identifier))
}
export function uninstallHubSkill(identifier: string, name: string): Promise<void> {
return runHubAction(identifier, 'uninstall', () => uninstallSkillFromHub(name))
}
export function updateHubSkills(): Promise<void> {
return runHubAction(UPDATE_ALL_KEY, 'update', () => updateSkillsFromHub())
}
export function closeHubLog(): void {
$hubActiveLog.set(null)
}