fix(ui): restore skill downloads and search paging

This commit is contained in:
Vincent Koc
2026-05-02 22:46:30 -07:00
parent f8141bc517
commit f84c894e4e
4 changed files with 134 additions and 18 deletions
+50 -8
View File
@@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const navigateMock = vi.fn();
let searchMock: { q?: string; type?: "all" | "skills" | "plugins" } = {};
const useUnifiedSearchMock = vi.fn();
vi.mock("@tanstack/react-router", () => ({
createFileRoute: () => (config: { component?: unknown; validateSearch?: unknown }) => ({
@@ -16,14 +17,7 @@ vi.mock("@tanstack/react-router", () => ({
}));
vi.mock("../lib/useUnifiedSearch", () => ({
useUnifiedSearch: () => ({
results: [],
skillResults: [],
pluginResults: [],
skillCount: 0,
pluginCount: 0,
isSearching: false,
}),
useUnifiedSearch: (...args: unknown[]) => useUnifiedSearchMock(...args),
}));
vi.mock("../components/PluginListItem", () => ({
@@ -52,6 +46,15 @@ describe("search route", () => {
beforeEach(() => {
searchMock = { q: "first" };
navigateMock.mockReset();
useUnifiedSearchMock.mockReset();
useUnifiedSearchMock.mockReturnValue({
results: [],
skillResults: [],
pluginResults: [],
skillCount: 0,
pluginCount: 0,
isSearching: false,
});
});
it("keeps the input synced with query param changes while mounted", async () => {
@@ -81,4 +84,43 @@ describe("search route", () => {
expect(screen.queryByRole("button", { name: /users/i })).toBeNull();
});
it("can request more results from global search", async () => {
searchMock = { q: "weather", type: "skills" };
useUnifiedSearchMock.mockReturnValue({
results: Array.from({ length: 25 }, (_, index) => ({
type: "skill",
skill: {
_id: `skill-${index}`,
slug: `weather-${index}`,
displayName: `Weather ${index}`,
ownerUserId: "users:1",
stats: { downloads: 0, stars: 0 },
updatedAt: 1,
createdAt: 1,
},
ownerHandle: "clawhub",
score: 1,
})),
skillResults: [],
pluginResults: [],
skillCount: 25,
pluginCount: 0,
isSearching: false,
});
const route = await loadRoute();
const Component = route.__config.component as ComponentType;
render(<Component />);
expect(useUnifiedSearchMock).toHaveBeenLastCalledWith("weather", "skills", {
limits: { skills: 25, plugins: 25 },
});
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
expect(useUnifiedSearchMock).toHaveBeenLastCalledWith("weather", "skills", {
limits: { skills: 50, plugins: 50 },
});
});
});
+17 -1
View File
@@ -7,6 +7,7 @@ import type { Doc, Id } from "../../convex/_generated/dataModel";
import { getSkillBadges } from "../lib/badges";
import { formatCompactStat, formatSkillStatsTriplet } from "../lib/numberFormat";
import type { PublicPublisher, PublicSkill } from "../lib/publicUser";
import { getRuntimeEnv } from "../lib/runtimeEnv";
import { timeAgo } from "../lib/timeAgo";
import { DetailHero } from "./DetailPageShell";
import { SkillInstallCard } from "./SkillInstallCard";
@@ -107,6 +108,13 @@ export function SkillHeader({
}: SkillHeaderProps) {
const formattedStats = formatSkillStatsTriplet(skill.stats);
const installOwnerId = owner?._id ?? skill.ownerPublisherId ?? skill.ownerUserId ?? null;
const convexSiteUrl = getRuntimeEnv("VITE_CONVEX_SITE_URL") ?? "https://clawhub.ai";
const downloadHref =
latestVersion && !nixPlugin
? `${convexSiteUrl}/api/v1/download?slug=${encodeURIComponent(skill.slug)}`
: null;
const hasTitleActions =
Boolean(downloadHref) || isAuthenticated || canManage || isStaff || Boolean(settingsHref);
return (
<>
@@ -171,8 +179,16 @@ export function SkillHeader({
<span className="plugin-version-badge">v{latestVersion.version}</span>
) : null}
{nixPlugin ? <Badge variant="accent">Plugin bundle (nix)</Badge> : null}
{isAuthenticated || canManage || isStaff || settingsHref ? (
{hasTitleActions ? (
<div className="skill-title-actions">
{downloadHref ? (
<Button asChild variant="outline" size="sm" className="skill-settings-link">
<a href={downloadHref}>
<Download size={14} aria-hidden="true" />
Download zip
</a>
</Button>
) : null}
{isAuthenticated ? (
<>
<button
+41 -9
View File
@@ -12,6 +12,8 @@ import {
type UnifiedSkillResult,
} from "../lib/useUnifiedSearch";
const SEARCH_PAGE_SIZE = 25;
type SearchState = {
q?: string;
type?: UnifiedSearchType;
@@ -30,15 +32,32 @@ function UnifiedSearchPage() {
const navigate = useNavigate();
const activeType = search.type ?? "all";
const [query, setQuery] = useState(search.q ?? "");
const [resultLimit, setResultLimit] = useState(SEARCH_PAGE_SIZE);
useEffect(() => {
setQuery(search.q ?? "");
}, [search.q]);
useEffect(() => {
setResultLimit(SEARCH_PAGE_SIZE);
}, [search.q, activeType]);
const { results, skillCount, pluginCount, isSearching } = useUnifiedSearch(
search.q ?? "",
activeType,
{
limits: {
skills: resultLimit,
plugins: resultLimit,
},
},
);
const canLoadMore =
search.q &&
!isSearching &&
((activeType === "all" && (skillCount >= resultLimit || pluginCount >= resultLimit)) ||
(activeType === "skills" && skillCount >= resultLimit) ||
(activeType === "plugins" && pluginCount >= resultLimit));
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
@@ -121,15 +140,28 @@ function UnifiedSearchPage() {
<p className="text-ink-soft">No results found for "{search.q}"</p>
</Card>
) : (
<div className="results-list">
{results.map((item) =>
item.type === "skill" ? (
<SkillResultRow key={`skill-${item.skill._id}`} result={item} />
) : (
<PluginResultRow key={`plugin-${item.plugin.name}`} result={item} />
),
)}
</div>
<>
<div className="results-list">
{results.map((item) =>
item.type === "skill" ? (
<SkillResultRow key={`skill-${item.skill._id}`} result={item} />
) : (
<PluginResultRow key={`plugin-${item.plugin.name}`} result={item} />
),
)}
</div>
{canLoadMore ? (
<div className="search-load-more">
<button
type="button"
className="search-load-more-button"
onClick={() => setResultLimit((limit) => limit + SEARCH_PAGE_SIZE)}
>
Load more
</button>
</div>
) : null}
</>
)}
</main>
);
+26
View File
@@ -8217,6 +8217,32 @@ code {
color: var(--ink-soft);
}
.search-load-more {
display: flex;
justify-content: center;
margin-top: var(--space-4);
}
.search-load-more-button {
min-height: 38px;
border: 1px solid var(--line);
border-radius: var(--radius-pill);
background: var(--surface);
color: var(--ink);
cursor: pointer;
font-size: var(--fs-sm);
font-weight: 700;
padding: 0 var(--space-4);
transition:
background-color 0.15s ease,
border-color 0.15s ease;
}
.search-load-more-button:hover {
border-color: color-mix(in srgb, var(--ink) 18%, var(--line));
background: var(--surface-raised);
}
@media (max-width: 760px) {
.search-page-form {
flex-direction: column;