diff --git a/apps/desktop/src/app/skills/hub.tsx b/apps/desktop/src/app/skills/hub.tsx
index 75edbd429..4b6abe20c 100644
--- a/apps/desktop/src/app/skills/hub.tsx
+++ b/apps/desktop/src/app/skills/hub.tsx
@@ -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 (
+
+
+
+ {skill.name}
+
+ {h.trust[skill.trust_level] ?? skill.trust_level}
+
+ {installed && {h.installed} }
+
+
{skill.description}
+
+
+ onPreview(skill)} size="xs" variant="text">
+ {h.preview}
+
+ {installed ? (
+
+ {running && }
+ {running ? h.uninstalling : h.uninstall}
+
+ ) : (
+
+ {running && }
+ {running ? h.installing : h.install}
+
+ )}
+
+
+ )
+}
+
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([])
- const [featured, setFeatured] = useState([])
- const [installed, setInstalled] = useState>({})
- 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([])
- const [searching, setSearching] = useState(false)
- const [searched, setSearched] = useState(false)
- const [timedOut, setTimedOut] = useState([])
- const [searchMs, setSearchMs] = useState(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)
- const [actionLog, setActionLog] = useState([])
- 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)
- const [preview, setPreview] = useState(null)
- const [previewLoading, setPreviewLoading] = useState(false)
const [scan, setScan] = useState(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 (
-
-
-
- {sourcesLoading ? (
-
{h.connectingHubs}
- ) : (
- <>
-
{h.connectedHubs}
- {sources.map(source => {
+
+ {/* Connected hubs — label on its own line, chips below, roomy padding. */}
+
+
{h.connectedHubs}
+
+ {sourcesQuery.isLoading
+ ? null
+ : sources.map(source => {
const degraded = source.available === false || source.rate_limited === true
return (
-
{source.label}
-
+
)
})}
- >
+
+
+
+ {/* 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 && (
+
+
+ {searched ? h.resultCount(results.length, null) : h.featured}
+ {timedOut.length > 0 && {h.timedOut(timedOut.join(', '))} }
+
+
+ {hasInstalled && (
+
+ {actions[UPDATE_ALL_KEY]?.running && }
+ {actions[UPDATE_ALL_KEY]?.running ? h.updating : h.updateAll}
+
)}
- {hasInstalled && (
-
void updateAll()} size="xs" variant="text">
- {actionRunning ? h.updating : h.updateAll}
-
+ )}
+
+ {/* Scrollable results. */}
+
+ {searching ? (
+
+ ) : listed.length === 0 ? (
+
+
+ {searched ? h.noResults : h.landingHint}
+
+
+ ) : (
+
+ {listed.map(skill => (
+
+ ))}
+
)}
- {searched && !searching && (
-
- {h.resultCount(results.length, searchMs)}
- {timedOut.length > 0 && {h.timedOut(timedOut.join(', '))} }
-
- )}
-
- {searching ? (
-
- ) : listed.length === 0 ? (
-
-
{searched ? h.noResults : h.landingHint}
-
- ) : (
-
- {showLanding && (
-
- {h.featured}
-
- )}
-
- {listed.map(skill => (
-
-
-
- {skill.name}
-
- {h.trust[skill.trust_level] ?? skill.trust_level}
-
- {isInstalled(skill.identifier) && (
- {h.installed}
- )}
-
-
{skill.description}
-
-
- openDetail(skill)} size="xs" variant="text">
- {h.preview}
-
- void install(skill.identifier, skill.name)}
- size="xs"
- variant="textStrong"
- >
- {isInstalled(skill.identifier) ? h.installed : h.install}
-
-
-
- ))}
-
-
- )}
-
- {action && actionLog.length > 0 && (
-
-
- {h.actionLog}
- {actionRunning && }
-
-
- {actionLog.join('\n')}
-
-
+ {/* 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}>
@@ -421,19 +365,19 @@ export function SkillsHub({ onInstalledChange, query }: SkillsHubProps) {
)}
- {previewLoading ? (
+ {previewQuery.isLoading ? (
- ) : preview ? (
+ ) : previewQuery.data ? (
<>
- {preview.skill_md || h.noReadme}
+ {previewQuery.data.skill_md || h.noReadme}
- {preview.files.length > 0 && (
+ {previewQuery.data.files.length > 0 && (
- {h.files}: {preview.files.join(', ')}
+ {h.files}: {previewQuery.data.files.join(', ')}
)}
>
@@ -445,8 +389,8 @@ export function SkillsHub({ onInstalledChange, query }: SkillsHubProps) {
{scanning ? h.scanning : h.scan}
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}
diff --git a/apps/desktop/src/store/hub-actions.ts b/apps/desktop/src/store/hub-actions.ts
new file mode 100644
index 000000000..e3dcaf3e0
--- /dev/null
+++ b/apps/desktop/src/store/hub-actions.ts
@@ -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>({})
+
+// 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>({})
+
+// The key whose log the bottom pane currently tails (the latest-started action).
+export const $hubActiveLog = atom(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 {
+ $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 {
+ return runHubAction(identifier, 'install', () => installSkillFromHub(identifier))
+}
+
+export function uninstallHubSkill(identifier: string, name: string): Promise {
+ return runHubAction(identifier, 'uninstall', () => uninstallSkillFromHub(name))
+}
+
+export function updateHubSkills(): Promise {
+ return runHubAction(UPDATE_ALL_KEY, 'update', () => updateSkillsFromHub())
+}
+
+export function closeHubLog(): void {
+ $hubActiveLog.set(null)
+}