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}

+
+
+ + {installed ? ( + + ) : ( + + )} +
+
+ ) +} + 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 && ( + )}
- {hasInstalled && ( - + )} + + {/* 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}

-
-
- - -
-
- ))} -
-
- )} - - {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}