Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/__tests__/skill-detail-page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ vi.mock("../lib/useAuthStatus", () => ({
useAuthStatus: () => useAuthStatusMock(),
}));

vi.mock("../components/SkillDiffCard", () => ({
SkillDiffCard: () => <div data-testid="skill-diff-card" />,
}));

describe("SkillDetailPage", () => {
const skillId = "skills:1" as Id<"skills">;
const ownerId = "users:1" as Id<"users">;
Expand Down
23 changes: 23 additions & 0 deletions src/__tests__/skills-index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,29 @@ describe("SkillsIndex", () => {
);
});

it("shows and clears the active capability tag filter", async () => {
searchMock = { tag: "crypto" };
render(<SkillsIndex />);
await act(async () => {});

const capabilityChip = screen.getByRole("button", { name: /crypto/i });
expect(capabilityChip).toBeTruthy();

await act(async () => {
fireEvent.click(capabilityChip);
});

expect(navigateMock).toHaveBeenCalled();
const lastCall = navigateMock.mock.calls.at(-1)?.[0] as {
replace?: boolean;
search: (prev: Record<string, unknown>) => Record<string, unknown>;
};
expect(lastCall.replace).toBe(true);
expect(lastCall.search({ tag: "crypto" })).toEqual({
tag: undefined,
});
});

it("shows load-more button when more results are available", async () => {
vi.stubGlobal("IntersectionObserver", undefined);
convexHttpMock.query.mockResolvedValue({
Expand Down
27 changes: 5 additions & 22 deletions src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@ import { useAuthActions } from "@convex-dev/auth/react";
import { Link } from "@tanstack/react-router";
import { Menu, Monitor, Moon, Plus, Search, Sun } from "lucide-react";
import { useMemo, useRef } from "react";
import { getUserFacingAuthError } from "../lib/authErrorMessage";
import { gravatarUrl } from "../lib/gravatar";
import { isModerator } from "../lib/roles";
import { getClawHubSiteUrl, getSiteMode, getSiteName } from "../lib/site";
import { applyTheme, useThemeMode } from "../lib/theme";
import { startThemeTransition } from "../lib/theme-transition";
import { setAuthError, useAuthError } from "../lib/useAuthError";
import { useAuthError } from "../lib/useAuthError";
import { SignInButton } from "./SignInButton";
import { useAuthStatus } from "../lib/useAuthStatus";
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
import { Button } from "./ui/button";
Expand All @@ -24,7 +24,7 @@ import { ToggleGroup, ToggleGroupItem } from "./ui/toggle-group";

export default function Header() {
const { isAuthenticated, isLoading, me } = useAuthStatus();
const { signIn, signOut } = useAuthActions();
const { signOut } = useAuthActions();
const { mode, setMode } = useThemeMode();
const toggleRef = useRef<HTMLDivElement | null>(null);
const siteMode = getSiteMode();
Expand All @@ -37,7 +37,6 @@ export default function Header() {
const initial = (me?.displayName ?? me?.name ?? handle).charAt(0).toUpperCase();
const isStaff = isModerator(me);
const { error: authError, clear: clearAuthError } = useAuthError();
const signInRedirectTo = getCurrentRelativeUrl();

const setTheme = (next: "system" | "light" | "dark") => {
startThemeTransition({
Expand Down Expand Up @@ -311,34 +310,18 @@ export default function Header() {
</button>
</div>
) : null}
<Button
<SignInButton
variant="primary"
size="sm"
disabled={isLoading}
onClick={() => {
clearAuthError();
void signIn(
"github",
signInRedirectTo ? { redirectTo: signInRedirectTo } : undefined,
).catch((error) => {
setAuthError(
getUserFacingAuthError(error, "Sign in failed. Please try again."),
);
});
}}
>
<span>Sign in</span>
<span className="hidden text-white/70 sm:inline">with GitHub</span>
</Button>
</SignInButton>
</>
)}
</div>
</div>
</header>
);
}

function getCurrentRelativeUrl() {
if (typeof window === "undefined") return "/";
return `${window.location.pathname}${window.location.search}${window.location.hash}`;
}
84 changes: 84 additions & 0 deletions src/components/SignInButton.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/* @vitest-environment jsdom */

import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SignInButton } from "./SignInButton";

const signInMock = vi.fn();
const clearAuthErrorMock = vi.fn();
const setAuthErrorMock = vi.fn();
const getUserFacingAuthErrorMock = vi.fn();

vi.mock("@convex-dev/auth/react", () => ({
useAuthActions: () => ({
signIn: signInMock,
}),
}));

vi.mock("../lib/useAuthError", () => ({
clearAuthError: () => clearAuthErrorMock(),
setAuthError: (message: string) => setAuthErrorMock(message),
}));

vi.mock("../lib/authErrorMessage", () => ({
getUserFacingAuthError: (error: unknown, fallback: string) =>
getUserFacingAuthErrorMock(error, fallback),
}));

describe("SignInButton", () => {
beforeEach(() => {
signInMock.mockReset();
clearAuthErrorMock.mockReset();
setAuthErrorMock.mockReset();
getUserFacingAuthErrorMock.mockReset();
getUserFacingAuthErrorMock.mockImplementation((_, fallback) => fallback);
window.history.replaceState(null, "", "/skills?q=test#top");
});

afterEach(() => {
vi.clearAllMocks();
});

it("starts GitHub sign-in with the current relative URL by default", async () => {
signInMock.mockResolvedValue({ signingIn: true });

render(<SignInButton>Sign in with GitHub</SignInButton>);
fireEvent.click(screen.getByRole("button", { name: "Sign in with GitHub" }));

await waitFor(() => {
expect(signInMock).toHaveBeenCalledWith("github", {
redirectTo: "/skills?q=test#top",
});
});
expect(clearAuthErrorMock).toHaveBeenCalledTimes(1);
expect(setAuthErrorMock).not.toHaveBeenCalled();
});

it("surfaces a generic error when sign-in resolves without redirecting", async () => {
signInMock.mockResolvedValue({ signingIn: false });

render(<SignInButton>Sign in with GitHub</SignInButton>);
fireEvent.click(screen.getByRole("button", { name: "Sign in with GitHub" }));

await waitFor(() => {
expect(setAuthErrorMock).toHaveBeenCalledWith("Sign in failed. Please try again.");
});
});

it("surfaces user-facing auth errors when sign-in rejects", async () => {
const failure = new Error("oauth failed");
signInMock.mockRejectedValue(failure);
getUserFacingAuthErrorMock.mockReturnValue("GitHub auth unavailable");

render(<SignInButton>Sign in with GitHub</SignInButton>);
fireEvent.click(screen.getByRole("button", { name: "Sign in with GitHub" }));

await waitFor(() => {
expect(getUserFacingAuthErrorMock).toHaveBeenCalledWith(
failure,
"Sign in failed. Please try again.",
);
expect(setAuthErrorMock).toHaveBeenCalledWith("GitHub auth unavailable");
});
});
});
48 changes: 48 additions & 0 deletions src/components/SignInButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { useAuthActions } from "@convex-dev/auth/react";
import type { ComponentProps } from "react";
import { getUserFacingAuthError } from "../lib/authErrorMessage";
import { clearAuthError, setAuthError } from "../lib/useAuthError";
import { Button } from "./ui/button";

type ButtonProps = ComponentProps<typeof Button>;

type SignInButtonProps = Omit<ButtonProps, "onClick" | "type"> & {
redirectTo?: string;
};

export function SignInButton({
redirectTo,
children = "Sign in with GitHub",
...props
}: SignInButtonProps) {
const { signIn } = useAuthActions();

return (
<Button
type="button"
onClick={() => {
clearAuthError();
const next = redirectTo ?? getCurrentRelativeUrl();
void signIn("github", next ? { redirectTo: next } : undefined)
.then((result) => {
if (result?.signingIn === false) {
setAuthError("Sign in failed. Please try again.");
}
})
.catch((error) => {
setAuthError(
getUserFacingAuthError(error, "Sign in failed. Please try again."),
);
});
}}
{...props}
>
{children}
</Button>
);
}

function getCurrentRelativeUrl() {
if (typeof window === "undefined") return "/";
return `${window.location.pathname}${window.location.search}${window.location.hash}`;
}
7 changes: 3 additions & 4 deletions src/components/SkillDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { toast } from "sonner";
import { api } from "../../convex/_generated/api";
import type { Doc, Id } from "../../convex/_generated/dataModel";
import { canManageSkill, isModerator } from "../lib/roles";
import { hasOwnProperty } from "../lib/hasOwnProperty";
import type { SkillBySlugResult, SkillPageInitialData } from "../lib/skillPage";
import { useAuthStatus } from "../lib/useAuthStatus";
import { ClientOnly } from "./ClientOnly";
Expand Down Expand Up @@ -37,13 +38,11 @@ type SkillDetailPageProps = {
type SkillFile = Doc<"skillVersions">["files"][number];

function formatReportError(error: unknown) {
if (error && typeof error === "object" && "data" in error) {
if (hasOwnProperty(error, "data")) {
const data = (error as { data?: unknown }).data;
if (typeof data === "string" && data.trim()) return data.trim();
if (
data &&
typeof data === "object" &&
"message" in data &&
hasOwnProperty(data, "message") &&
typeof (data as { message?: unknown }).message === "string"
) {
const message = (data as { message?: string }).message?.trim();
Expand Down
7 changes: 5 additions & 2 deletions src/components/UserBadge.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { hasOwnProperty } from "../lib/hasOwnProperty";
import type { PublicPublisher, PublicUser } from "../lib/publicUser";

type UserBadgeProps = {
Expand All @@ -17,11 +18,13 @@ export function UserBadge({
link = true,
showName = false,
}: UserBadgeProps) {
const userName = user && "name" in user ? user.name?.trim() : undefined;
const userName = hasOwnProperty(user, "name") && typeof user.name === "string"
? user.name.trim()
: undefined;
const displayName = user?.displayName?.trim() || userName || null;
const handle = user?.handle ?? fallbackHandle ?? null;
const href =
user?.handle && "kind" in user
user?.handle && hasOwnProperty(user, "kind")
? user.kind === "org"
? `/orgs/${encodeURIComponent(user.handle)}`
: `/u/${encodeURIComponent(user.handle)}`
Expand Down
19 changes: 19 additions & 0 deletions src/lib/categories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export type SkillCategory = {
slug: string;
label: string;
keywords: string[];
};

export const SKILL_CATEGORIES: SkillCategory[] = [
{ slug: "mcp-tools", label: "MCP Tools", keywords: ["mcp", "tool", "server"] },
{ slug: "prompts", label: "Prompts", keywords: ["prompt", "template", "system"] },
{ slug: "workflows", label: "Workflows", keywords: ["workflow", "pipeline", "chain"] },
{ slug: "dev-tools", label: "Dev Tools", keywords: ["dev", "debug", "lint", "test", "build"] },
{ slug: "data", label: "Data & APIs", keywords: ["api", "data", "fetch", "http", "rest", "graphql"] },
{ slug: "security", label: "Security", keywords: ["security", "scan", "auth", "encrypt"] },
{ slug: "automation", label: "Automation", keywords: ["auto", "cron", "schedule", "bot"] },
{ slug: "other", label: "Other", keywords: [] },
];

export const ALL_CATEGORY_KEYWORDS = SKILL_CATEGORIES.flatMap((c) => c.keywords);

9 changes: 7 additions & 2 deletions src/lib/convexError.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { hasOwnProperty } from "./hasOwnProperty";

type ConvexLikeErrorData =
| string
| {
Expand All @@ -24,9 +26,12 @@ export function getUserFacingConvexError(error: unknown, fallback: string) {
const candidates: string[] = [];
const maybe = error as ConvexLikeError;

if (maybe && typeof maybe === "object" && "data" in maybe) {
if (hasOwnProperty(maybe, "data")) {
if (typeof maybe.data === "string") candidates.push(maybe.data);
if (maybe.data && typeof maybe.data === "object" && typeof maybe.data.message === "string") {
if (
hasOwnProperty(maybe.data, "message") &&
typeof maybe.data.message === "string"
) {
candidates.push(maybe.data.message);
}
}
Expand Down
6 changes: 6 additions & 0 deletions src/lib/hasOwnProperty.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export function hasOwnProperty<K extends PropertyKey>(
value: unknown,
key: K,
): value is Record<K, unknown> {
return typeof value === "object" && value !== null && Object.prototype.hasOwnProperty.call(value, key);
}
19 changes: 16 additions & 3 deletions src/lib/packageApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
PackageVerificationSummary,
} from "clawhub-schema";
import { ApiRoutes } from "clawhub-schema/routes";
import { hasOwnProperty } from "./hasOwnProperty";
import { getRequiredRuntimeEnv, getRuntimeEnv } from "./runtimeEnv";

export type PackageListItem = {
Expand Down Expand Up @@ -117,6 +118,11 @@ type PluginCatalogResult = {
nextCursor: string | null;
};

type PackageCatalogBrowseResponse = {
items: PackageListItem[];
nextCursor: string | null;
};

type PackageApiErrorOptions = {
status: number;
retryAfterSeconds?: number | null;
Expand Down Expand Up @@ -302,10 +308,17 @@ export async function fetchPluginCatalog(params: {
executesCode: params.executesCode,
limit: params.limit,
});
if (hasOwnProperty(response, "results") && Array.isArray(response.results)) {
return {
items: response.results.map((entry) => entry.package),
nextCursor: null,
};
}

const browseResponse = response as PackageCatalogBrowseResponse;
return {
items:
"results" in response ? response.results.map((entry) => entry.package) : response.items,
nextCursor: "results" in response ? null : response.nextCursor,
items: browseResponse.items,
nextCursor: browseResponse.nextCursor,
};
Comment on lines +318 to 322
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

response is cast to PackageCatalogBrowseResponse without validating that items is present/array-shaped. If the API ever returns an unexpected shape (or results exists but isn’t an array), this will return undefined fields and likely fail later. Consider using hasOwnProperty(response, "items") + Array.isArray(...) (and validating nextCursor) and throwing a PackageApiError (or a clear fallback) when the shape is unrecognized.

Copilot uses AI. Check for mistakes.
}

Expand Down
Loading