Skip to content
Merged
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
54 changes: 54 additions & 0 deletions __tests__/next-best-action-panel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/** @jest-environment jsdom */

import React from "react";
import { render, screen } from "@testing-library/react";

import { NextBestActionPanel } from "@/components/next-best-action-panel";

describe("NextBestActionPanel", () => {
test("shows copy install + report issue when install command + github repo exist", () => {
render(
<NextBestActionPanel
installCmd="openclaw install foo"
repoUrl="https://github.com/acme/foo"
issueTitle="Issue title"
issueBody="Issue body"
/>
);

expect(screen.getByRole("button", { name: /copy install command/i })).toBeInTheDocument();

const report = screen.getByRole("link", { name: /report issue/i });
expect(report).toBeInTheDocument();
expect(report.getAttribute("href")).toMatch(/github\.com\/acme\/foo\/issues\/new/);
});

test("shows copy install when install command begins with comment lines", () => {
render(
<NextBestActionPanel
installCmd="# via registry\n\nopenclaw install foo"
repoUrl="https://github.com/acme/foo"
issueTitle="Issue title"
issueBody="Issue body"
/>
);

expect(screen.getByRole("button", { name: /copy install command/i })).toBeInTheDocument();
});

test("falls back to view repository when no install command", () => {
render(
<NextBestActionPanel
installCmd=""
repoUrl="https://github.com/acme/foo"
issueTitle="Issue title"
issueBody="Issue body"
/>
);

expect(screen.queryByRole("button", { name: /copy install command/i })).toBeNull();

const view = screen.getByRole("link", { name: /view on github|view repository/i });
expect(view.getAttribute("href")).toBe("https://github.com/acme/foo");
});
});
49 changes: 49 additions & 0 deletions __tests__/reportIssue.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { parseGitHubRepo } from "@/lib/reportIssue";

describe("parseGitHubRepo", () => {
test("parses https://github.com/owner/repo", () => {
expect(parseGitHubRepo("https://github.com/acme/foo")).toEqual({
owner: "acme",
repo: "foo",
});
});

test("parses https://www.github.com/owner/repo", () => {
expect(parseGitHubRepo("https://www.github.com/acme/foo")).toEqual({
owner: "acme",
repo: "foo",
});
});

test("parses git+https://github.com/owner/repo.git", () => {
expect(parseGitHubRepo("git+https://github.com/acme/foo.git")).toEqual({
owner: "acme",
repo: "foo",
});
});

test("parses ssh://git@github.com/owner/repo.git", () => {
expect(parseGitHubRepo("ssh://git@github.com/acme/foo.git")).toEqual({
owner: "acme",
repo: "foo",
});
});

test("parses git+ssh://git@github.com/owner/repo.git", () => {
expect(parseGitHubRepo("git+ssh://git@github.com/acme/foo.git")).toEqual({
owner: "acme",
repo: "foo",
});
});

test("parses git@github.com:owner/repo.git", () => {
expect(parseGitHubRepo("git@github.com:acme/foo.git")).toEqual({
owner: "acme",
repo: "foo",
});
});

test("returns null for non-github urls", () => {
expect(parseGitHubRepo("https://gitlab.com/acme/foo")).toBeNull();
});
});
19 changes: 11 additions & 8 deletions src/app/skills/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { notFound } from "next/navigation";
import { getSkills, getSkillBySlug } from "@/lib/data";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { ReportIssueButton } from "@/components/report-issue-button";
import { NextBestActionPanel } from "@/components/next-best-action-panel";
import { buildSkillIssueBodyTemplate } from "@/lib/reportIssue";
import Link from "next/link";

Expand Down Expand Up @@ -95,6 +95,16 @@ export default async function SkillPage({
</p>
</section>

{/* Next Best Action */}
<section className="mb-8">
<NextBestActionPanel
installCmd={skill.install_cmd}
repoUrl={skill.repo_url}
issueTitle={`forAgents.dev: ${skill.name} (${skill.slug})`}
issueBody={issueBody}
/>
</section>

<Separator className="opacity-10 my-8" />

{/* Install */}
Expand Down Expand Up @@ -129,13 +139,6 @@ export default async function SkillPage({
>
📄 GET /api/skill/{skill.slug}
</Link>

{/* Report issue actions */}
<ReportIssueButton
repoUrl={skill.repo_url}
issueTitle={`forAgents.dev: ${skill.name} (${skill.slug})`}
issueBody={issueBody}
/>
</div>
</section>

Expand Down
150 changes: 150 additions & 0 deletions src/components/next-best-action-panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
"use client";

import * as React from "react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { buildGitHubNewIssueUrl, parseGitHubRepo } from "@/lib/reportIssue";

type Props = {
installCmd?: string | null;
repoUrl?: string | null;
issueTitle: string;
issueBody: string;
};

async function copyToClipboard(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
try {
const el = document.createElement("textarea");
el.value = text;
el.setAttribute("readonly", "");
el.style.position = "fixed";
el.style.top = "-1000px";
el.style.left = "-1000px";
document.body.appendChild(el);
el.focus();
el.select();
const ok = document.execCommand("copy");
document.body.removeChild(el);
return ok;
} catch {
return false;
}
}
}

function extractPrimaryInstallCmd(cmd: string | null | undefined): string | null {
const raw0 = (cmd ?? "").trim();
if (!raw0) return null;

// Accept both real newlines and literal "\\n" sequences.
const raw = raw0.replace(/\\r\\n/g, "\n").replace(/\\n/g, "\n");

// Support install_cmd strings that start with one or more comment lines.
// We copy the first non-empty, non-comment line.
const lines = raw.split(/\r?\n/);
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
if (trimmed.startsWith("#")) continue;
return trimmed;
}

return null;
}

export function NextBestActionPanel({
installCmd,
repoUrl,
issueTitle,
issueBody,
}: Props) {
const repo = React.useMemo(() => parseGitHubRepo(repoUrl), [repoUrl]);

const issueUrl = React.useMemo(() => {
if (!repo) return null;
return buildGitHubNewIssueUrl({ repo, title: issueTitle, body: issueBody });
}, [repo, issueTitle, issueBody]);

const primaryInstallCmd = extractPrimaryInstallCmd(installCmd);

const hasInstall = Boolean(primaryInstallCmd);
const primaryHref = !hasInstall ? repoUrl?.trim() : null;
const primaryLabel = repo ? "View on GitHub ↗" : "View repository ↗";

const [copyLabel, setCopyLabel] = React.useState("Copy install command");

const onCopyInstall = async () => {
if (!primaryInstallCmd) return;
const ok = await copyToClipboard(primaryInstallCmd);
setCopyLabel(ok ? "Copied" : "Copy failed");
window.setTimeout(() => setCopyLabel("Copy install command"), 1500);
if (!ok) window.prompt("Copy the install command:", primaryInstallCmd);
};

return (
<Card className="border-white/10 bg-white/5">
<CardHeader className="pb-4">
<CardTitle className="text-sm text-[#F8FAFC]">Next best action</CardTitle>
<CardDescription>
Do the most useful thing in one click.
</CardDescription>
</CardHeader>

<CardContent>
<div className="flex flex-wrap items-center gap-2">
{hasInstall ? (
<Button
type="button"
size="sm"
className="bg-cyan text-black hover:bg-cyan/90"
onClick={onCopyInstall}
title="Copies the install command to your clipboard"
>
{copyLabel}
</Button>
) : primaryHref ? (
<Button asChild size="sm" className="bg-cyan text-black hover:bg-cyan/90">
<a href={primaryHref} target="_blank" rel="noopener noreferrer">
{primaryLabel}
</a>
</Button>
) : null}

{issueUrl ? (
<Button
asChild
variant="outline"
size="sm"
className="border-white/10 bg-white/5"
>
<a href={issueUrl} target="_blank" rel="noopener noreferrer">
Report issue ↗
</a>
</Button>
) : repoUrl?.trim() && !hasInstall ? null : repoUrl?.trim() ? (
<Button
asChild
variant="outline"
size="sm"
className="border-white/10 bg-white/5"
>
<a href={repoUrl.trim()} target="_blank" rel="noopener noreferrer">
View repository ↗
</a>
</Button>
) : null}
</div>
</CardContent>
</Card>
);
}
99 changes: 0 additions & 99 deletions src/components/report-issue-button.tsx

This file was deleted.

Loading
Loading