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
394 changes: 394 additions & 0 deletions apps/web/src/components/repo-import-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,394 @@
import { convexQuery, useConvexAction } from "@convex-dev/react-query";
import { api } from "@harness/convex-backend/convex/_generated/api";
import { useQuery } from "@tanstack/react-query";
import { Check, FileText, Folder, Github, Loader2, Search } from "lucide-react";
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import type { SkillEntry } from "../lib/skills";
import { MarkdownMessage } from "./markdown-message";
import { RoseCurveSpinner } from "./rose-curve-spinner";
import { Button } from "./ui/button";
import { Checkbox } from "./ui/checkbox";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog";
import { Input } from "./ui/input";

type RepoSkill = {
skillId: string;
fullId: string;
category: string;
description: string;
};

type BrowseResult = {
source: string;
skills: RepoSkill[];
agentsMd: string;
claudeMd: string;
discovered: number;
truncated: boolean;
};

export function RepoImportDialog({
open,
onOpenChange,
initialSource,
existingNames,
onImport,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
initialSource: string;
/** Skill fullIds already in the pack — shown as added, not re-selectable. */
existingNames: Set<string>;
onImport: (
skills: SkillEntry[],
meta: { agentsMd: string; claudeMd: string; source: string },
) => void;
}) {
const listRepoSkills = useConvexAction(api.skills.listRepoSkills);
const [source, setSource] = useState(initialSource);
const [browsing, setBrowsing] = useState(false);
const [result, setResult] = useState<BrowseResult | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [preview, setPreview] = useState<string | null>(null);
const [importAgents, setImportAgents] = useState(true);
const [importClaude, setImportClaude] = useState(true);

const browse = async (raw?: string) => {
const repo = (raw ?? source).trim();
if (!repo || browsing) return;
setSource(repo);
setBrowsing(true);
setResult(null);
setSelected(new Set());
setPreview(null);
try {
const res = (await listRepoSkills({ source: repo })) as BrowseResult;
setResult(res);
// Pre-select everything not already in the pack — one click to add all.
setSelected(
new Set(
res.skills
.filter((s) => !existingNames.has(s.fullId))
.map((s) => s.fullId),
),
);
setPreview(res.skills[0]?.fullId ?? null);
setImportAgents(!!res.agentsMd);
setImportClaude(!!res.claudeMd);
} catch (e) {
const data =
e && typeof e === "object" && "data" in e
? (e as { data: unknown }).data
: undefined;
toast.error(
typeof data === "string"
? data
: e instanceof Error
? e.message
: "Couldn't read that repo",
);
} finally {
setBrowsing(false);
}
};

// Group skills by category, preserving the server's sorted order.
const groups = useMemo(() => {
const map = new Map<string, RepoSkill[]>();
for (const s of result?.skills ?? []) {
const key = s.category || "";
const arr = map.get(key);
if (arr) arr.push(s);
else map.set(key, [s]);
}
return [...map.entries()];
}, [result]);

const selectableCount =
result?.skills.filter((s) => !existingNames.has(s.fullId)).length ?? 0;
const allSelected = selectableCount > 0 && selected.size === selectableCount;

const toggle = (fullId: string) =>
setSelected((prev) => {
const next = new Set(prev);
if (next.has(fullId)) next.delete(fullId);
else next.add(fullId);
return next;
});

const toggleGroup = (skills: RepoSkill[], on: boolean) =>
setSelected((prev) => {
const next = new Set(prev);
for (const s of skills) {
if (existingNames.has(s.fullId)) continue;
if (on) next.add(s.fullId);
else next.delete(s.fullId);
}
return next;
});

const toggleAll = () =>
setSelected(
allSelected
? new Set()
: new Set(
(result?.skills ?? [])
.filter((s) => !existingNames.has(s.fullId))
.map((s) => s.fullId),
),
);

const addSelected = () => {
if (!result || selected.size === 0) return;
const skills: SkillEntry[] = result.skills
.filter((s) => selected.has(s.fullId))
.map((s) => ({ name: s.fullId, description: s.description }));
onImport(skills, {
agentsMd: importAgents ? result.agentsMd : "",
claudeMd: importClaude ? result.claudeMd : "",
source: result.source,
});
onOpenChange(false);
};

const previewQuery = useQuery({
...convexQuery(api.skills.getByName, { name: preview ?? "" }),
enabled: !!preview,
});

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex h-[85vh] max-h-[85vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-4xl">
<DialogHeader className="border-b border-border px-5 py-3.5">
<DialogTitle className="flex items-center gap-2 text-sm">
<Github size={15} />
Import skills from a GitHub repo
</DialogTitle>
</DialogHeader>

<div className="flex items-center gap-2 border-b border-border px-5 py-3">
<div className="relative flex-1">
<Search
size={13}
className="-translate-y-1/2 pointer-events-none absolute top-1/2 left-2.5 text-muted-foreground"
/>
<Input
autoFocus
value={source}
onChange={(e) => setSource(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") browse();
}}
placeholder="owner/repo (e.g. mattpocock/skills)"
className="pl-7"
/>
</div>
<Button
size="sm"
disabled={!source.trim() || browsing}
onClick={() => browse()}
>
{browsing ? (
<Loader2 size={14} className="animate-spin" />
) : (
"Browse"
)}
</Button>
</div>

{/* Body */}
<div className="flex min-h-0 flex-1 flex-col lg:flex-row">
{/* Left: selectable, grouped list */}
<div className="flex min-h-0 flex-1 flex-col border-border lg:w-1/2 lg:border-r">
{result ? (
<>
<div className="flex items-center justify-between gap-2 border-b border-border px-4 py-2 text-[11px] text-muted-foreground">
<button
type="button"
onClick={toggleAll}
className="flex items-center gap-1.5 font-medium text-foreground hover:text-foreground/80"
>
<Checkbox checked={allSelected} className="size-3.5" />
{allSelected ? "Deselect all" : "Select all"}
</button>
<span>
{result.discovered} skill
{result.discovered === 1 ? "" : "s"} found
{result.truncated && " (truncated)"}
</span>
</div>
<div className="min-h-0 flex-1 overflow-y-auto py-1">
{groups.map(([category, skills]) => {
const groupAll = skills.every(
(s) =>
existingNames.has(s.fullId) || selected.has(s.fullId),
);
return (
<div key={category || "_root"} className="mb-1">
{category && (
<button
type="button"
onClick={() => toggleGroup(skills, !groupAll)}
className="flex w-full items-center gap-1.5 px-4 py-1.5 text-[10px] font-medium uppercase tracking-wider text-muted-foreground hover:text-foreground"
>
<Folder size={11} />
{category}
</button>
)}
{skills.map((s) => {
const added = existingNames.has(s.fullId);
const isChecked = added || selected.has(s.fullId);
const isPreview = preview === s.fullId;
return (
<div
key={s.fullId}
className={`flex items-start gap-2.5 px-4 py-1.5 ${
isPreview
? "bg-foreground/5"
: "hover:bg-foreground/3"
}`}
>
<Checkbox
checked={isChecked}
disabled={added}
onCheckedChange={() =>
!added && toggle(s.fullId)
}
className="mt-0.5 shrink-0"
aria-label={`Select ${s.skillId}`}
/>
<button
type="button"
onClick={() => setPreview(s.fullId)}
className="min-w-0 flex-1 cursor-pointer text-left"
>
<div className="flex items-center gap-1.5">
<span className="truncate text-xs font-medium text-foreground">
{s.skillId}
</span>
{added && (
<span className="shrink-0 text-[10px] text-muted-foreground">
· added
</span>
)}
</div>
{s.description && (
<p className="line-clamp-2 text-[11px] text-muted-foreground">
{s.description}
</p>
)}
</button>
</div>
);
})}
</div>
);
})}
</div>
</>
) : (
<div className="flex flex-1 flex-col items-center justify-center gap-2 p-8 text-center">
{browsing ? (
<RoseCurveSpinner
size={22}
className="text-muted-foreground"
label="Reading repository"
/>
) : (
<>
<Github size={22} className="text-muted-foreground/40" />
<p className="text-xs text-muted-foreground">
Enter a repo and press Browse to see its skills. Nested
folders (e.g.{" "}
<code className="text-[10px]">
skills/&lt;category&gt;/&lt;name&gt;
</code>
) are supported.
</p>
</>
)}
</div>
)}
</div>

{/* Right: live SKILL.md preview */}
<div className="hidden min-h-0 flex-1 flex-col lg:flex lg:w-1/2">
{preview ? (
<>
<div className="flex items-center gap-2 border-b border-border px-4 py-2 text-[11px] text-muted-foreground">
<FileText size={12} />
<span className="truncate font-medium text-foreground">
{preview.split("/").pop()}
</span>
<span className="truncate">/ SKILL.md</span>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-3">
{previewQuery.data?.detail ? (
<MarkdownMessage
content={previewQuery.data.detail.replace(
/^---\s*\n[\s\S]*?\n---\s*\n?/,
"",
)}
/>
) : (
<div className="flex items-center justify-center py-12 text-xs text-muted-foreground">
Loading…
</div>
)}
</div>
</>
) : (
<div className="flex flex-1 items-center justify-center p-8 text-center text-xs text-muted-foreground">
Select a skill to preview its SKILL.md.
</div>
)}
</div>
</div>

{/* Footer */}
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border px-5 py-3">
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-[11px] text-foreground">
{result?.agentsMd && (
<label
htmlFor="repo-import-agents"
className="flex items-center gap-1.5"
>
<Checkbox
id="repo-import-agents"
checked={importAgents}
onCheckedChange={(c) => setImportAgents(c === true)}
className="size-3.5"
/>
Import AGENTS.md
</label>
)}
{result?.claudeMd && (
<label
htmlFor="repo-import-claude"
className="flex items-center gap-1.5"
>
<Checkbox
id="repo-import-claude"
checked={importClaude}
onCheckedChange={(c) => setImportClaude(c === true)}
className="size-3.5"
/>
Import CLAUDE.md
</label>
)}
</div>
<Button
size="sm"
disabled={selected.size === 0}
onClick={addSelected}
>
<Check size={14} />
Add {selected.size > 0 ? selected.size : ""} skill
{selected.size === 1 ? "" : "s"}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
Loading
Loading