- forward refs through ScrollArea so logs can access the viewport - keep logs pinned to the bottom only when the user is already near it - apply import and className ordering cleanup across frontend components
211 lines
5.7 KiB
TypeScript
211 lines
5.7 KiB
TypeScript
import {
|
|
useInfiniteQuery,
|
|
useMutation,
|
|
useQuery,
|
|
useQueryClient,
|
|
} from "@tanstack/react-query"
|
|
import { useNavigate } from "@tanstack/react-router"
|
|
import { type UIEvent, useEffect, useRef, useState } from "react"
|
|
import { useTranslation } from "react-i18next"
|
|
import { toast } from "sonner"
|
|
|
|
import {
|
|
type SkillRegistrySearchResult,
|
|
type SkillSearchResponse,
|
|
type SkillSupportItem,
|
|
getSkills,
|
|
installSkill,
|
|
searchSkills,
|
|
} from "@/api/skills"
|
|
import { getTools } from "@/api/tools"
|
|
|
|
import { buildUnavailableToolMessages } from "./tool-support"
|
|
|
|
const MARKET_SEARCH_LIMIT = 20
|
|
|
|
export function useHubMarketplace() {
|
|
const { t } = useTranslation()
|
|
const navigate = useNavigate()
|
|
const queryClient = useQueryClient()
|
|
const isLoadMoreLockedRef = useRef(false)
|
|
|
|
const [marketQuery, setMarketQuery] = useState("")
|
|
const [submittedMarketQuery, setSubmittedMarketQuery] = useState("")
|
|
|
|
const { data: skillsData } = useQuery({
|
|
queryKey: ["skills"],
|
|
queryFn: getSkills,
|
|
})
|
|
const { data: toolsData } = useQuery({
|
|
queryKey: ["tools"],
|
|
queryFn: getTools,
|
|
})
|
|
|
|
const findSkillsTool = toolsData?.tools.find(
|
|
(tool) => tool.name === "find_skills",
|
|
)
|
|
const installSkillTool = toolsData?.tools.find(
|
|
(tool) => tool.name === "install_skill",
|
|
)
|
|
const canSearchMarketplace = findSkillsTool?.status === "enabled"
|
|
const canInstallFromMarketplace = installSkillTool?.status === "enabled"
|
|
const hasSubmittedQuery = submittedMarketQuery.trim() !== ""
|
|
const isMarketSearchActive = canSearchMarketplace && hasSubmittedQuery
|
|
|
|
const {
|
|
data: marketSearchData,
|
|
isPending: isMarketSearchPending,
|
|
isFetching: isMarketSearchFetching,
|
|
isFetchingNextPage,
|
|
error: marketSearchError,
|
|
hasNextPage,
|
|
fetchNextPage,
|
|
refetch: refetchMarketSearch,
|
|
} = useInfiniteQuery({
|
|
queryKey: ["skills-marketplace", submittedMarketQuery],
|
|
initialPageParam: 0,
|
|
queryFn: ({ pageParam }) =>
|
|
searchSkills(
|
|
submittedMarketQuery,
|
|
MARKET_SEARCH_LIMIT,
|
|
Number(pageParam) || 0,
|
|
),
|
|
getNextPageParam: (lastPage: SkillSearchResponse) =>
|
|
lastPage.has_more ? (lastPage.next_offset ?? undefined) : undefined,
|
|
enabled: isMarketSearchActive,
|
|
staleTime: 5 * 60 * 1000,
|
|
refetchOnMount: false,
|
|
refetchOnWindowFocus: false,
|
|
})
|
|
|
|
const installMutation = useMutation({
|
|
mutationFn: installSkill,
|
|
onSuccess: (response) => {
|
|
toast.success(
|
|
t("pages.agent.skills.install_success", {
|
|
name: response.skill?.name ?? response.slug,
|
|
}),
|
|
)
|
|
void queryClient.invalidateQueries({ queryKey: ["skills"] })
|
|
void queryClient.invalidateQueries({ queryKey: ["skills-marketplace"] })
|
|
},
|
|
onError: (err) => {
|
|
toast.error(
|
|
err instanceof Error
|
|
? err.message
|
|
: t("pages.agent.skills.install_error"),
|
|
)
|
|
},
|
|
})
|
|
|
|
const allSkills = skillsData?.skills ?? []
|
|
const workspaceSkillsByName = new Map(
|
|
allSkills
|
|
.filter((skill) => skill.source === "workspace")
|
|
.map((skill) => [skill.name, skill] as const),
|
|
)
|
|
const marketResults =
|
|
marketSearchData?.pages.flatMap((page) => page.results) ?? []
|
|
const hasMoreMarketResults = hasNextPage ?? false
|
|
const isMarketSearchInitialLoading =
|
|
isMarketSearchActive &&
|
|
!marketSearchData &&
|
|
(isMarketSearchPending || isMarketSearchFetching)
|
|
const isMarketSearchLoadingMore =
|
|
isMarketSearchActive && Boolean(marketSearchData) && isFetchingNextPage
|
|
const installPendingKey =
|
|
installMutation.isPending && installMutation.variables
|
|
? `${installMutation.variables.registry}:${installMutation.variables.slug}`
|
|
: null
|
|
|
|
const unavailableToolMessages = buildUnavailableToolMessages({
|
|
searchTool: findSkillsTool,
|
|
installTool: installSkillTool,
|
|
t,
|
|
})
|
|
|
|
useEffect(() => {
|
|
if (!isFetchingNextPage) {
|
|
isLoadMoreLockedRef.current = false
|
|
}
|
|
}, [isFetchingNextPage])
|
|
|
|
const handleSearchSubmit = () => {
|
|
const nextQuery = marketQuery.trim()
|
|
if (!canSearchMarketplace || nextQuery === "") {
|
|
return
|
|
}
|
|
|
|
isLoadMoreLockedRef.current = false
|
|
if (nextQuery === submittedMarketQuery) {
|
|
void refetchMarketSearch()
|
|
return
|
|
}
|
|
|
|
setSubmittedMarketQuery(nextQuery)
|
|
}
|
|
|
|
const handleInstall = (result: SkillRegistrySearchResult) => {
|
|
installMutation.mutate({
|
|
slug: result.slug,
|
|
registry: result.registry_name,
|
|
version: result.version || undefined,
|
|
})
|
|
}
|
|
|
|
const handleViewInstalled = () => {
|
|
void navigate({ to: "/agent/skills" })
|
|
}
|
|
|
|
const handleScroll = (event: UIEvent<HTMLDivElement>) => {
|
|
if (
|
|
!isMarketSearchActive ||
|
|
!hasMoreMarketResults ||
|
|
isFetchingNextPage ||
|
|
isLoadMoreLockedRef.current
|
|
) {
|
|
return
|
|
}
|
|
|
|
const node = event.currentTarget
|
|
const remaining = node.scrollHeight - node.scrollTop - node.clientHeight
|
|
if (remaining > 240) {
|
|
return
|
|
}
|
|
|
|
isLoadMoreLockedRef.current = true
|
|
void fetchNextPage()
|
|
}
|
|
|
|
const getInstalledSkill = (
|
|
installedName?: string,
|
|
): SkillSupportItem | null => {
|
|
if (!installedName) {
|
|
return null
|
|
}
|
|
return workspaceSkillsByName.get(installedName) ?? null
|
|
}
|
|
|
|
const isInstallPending = (result: SkillRegistrySearchResult) =>
|
|
installPendingKey === `${result.registry_name}:${result.slug}`
|
|
|
|
return {
|
|
marketQuery,
|
|
submittedMarketQuery,
|
|
canSearchMarketplace,
|
|
canInstallFromMarketplace,
|
|
marketResults,
|
|
marketSearchError,
|
|
unavailableToolMessages,
|
|
hasSubmittedQuery,
|
|
isMarketSearchInitialLoading,
|
|
isMarketSearchLoadingMore,
|
|
setMarketQuery,
|
|
handleSearchSubmit,
|
|
handleInstall,
|
|
handleViewInstalled,
|
|
handleScroll,
|
|
getInstalledSkill,
|
|
isInstallPending,
|
|
}
|
|
}
|