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:
parent
16aa09aca5
commit
929ba007bb
2 changed files with 326 additions and 287 deletions
|
|
@ -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}
|
||||
|
|
|
|||
95
apps/desktop/src/store/hub-actions.ts
Normal file
95
apps/desktop/src/store/hub-actions.ts
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue