62b4520507
- A Claude Docs `batch` that creates a doc is captured like an `Artifact` publish, titled by the doc's name and linked by its own frame URL. - At Stop/SessionEnd, each pptx/docx/xlsx/pdf/html a workspace session created or changed is snapshotted into its owner's library (`auto`); an unchanged file is skipped, a changed one refreshes its snapshot. - Server mode: a page with no owner carries a suggested owner (the workspace or home its session ran in). The suggested user claims or declines it; an admin claims or assigns any. New tab Unowned / Is this yours? on the Artifacts page; all three actions audited.
1883 lines
66 KiB
TypeScript
1883 lines
66 KiB
TypeScript
/**
|
|
* @file Artifacts.tsx
|
|
* @description `/artifacts` — the Artifacts hub (`/api/artifacts`,
|
|
* server/lib/artifacts.js), in the same shapes as the other pages (a plain
|
|
* title row, Sessions' filter bar, `.card` tiles, no decorative icons): a
|
|
* search and category filter, the unfiltered front page shelved by kind
|
|
* (Skills, Plugins, Pages, Files — "See all" picks the category), each
|
|
* artifact a card with one primary action (Install / Installed / Open /
|
|
* Preview / Download), text actions in its footer, the rest in its ⋯ menu,
|
|
* and a detail sheet a click on the card opens. It holds claude.ai pages sessions published, pinned
|
|
* workspace files (or one uploaded from the computer), skills (picked,
|
|
* uploaded as a file/folder/zip, or written — `ArtifactAddPanels.tsx`) and
|
|
* plugins (read from a marketplace link, or typed). One list holds the
|
|
* caller's own rows and, in server mode, what every user shared; a small
|
|
* **All · My library · Hub** filter narrows it. In server mode every tab is
|
|
* split by who it is from, others' first (All: others' shared rows / yours;
|
|
* Hub: shared by others / by you; My library: installed from others / your
|
|
* own), and every section pages through 12 cards at a time. A shared skill or plugin is
|
|
* installed into the viewer's own account (after a confirmation that lists
|
|
* what will be written or run); one already installed there shows
|
|
* **Installed** instead of the Install button; **Uninstall** (a skill's
|
|
* copies moved aside, a plugin `claude plugin uninstall`) is offered only in
|
|
* the card's ⋯ menu. There is no Reinstall: to take a newer version, uninstall
|
|
* and install again. Editing a skill
|
|
* edits its SKILL.md (name, description, instructions — a new snapshot);
|
|
* its preview opens with what it installs as and when Claude uses it. Deleting a shared
|
|
* row of your own only takes it out of your library — it stays in the Hub
|
|
* (**Hub only**) until you remove it from there. A card's "N installs" opens
|
|
* who installed it, when, and — for your own installs — the folders written. An
|
|
* admin's switch in Settings → AI tools (`ArtifactsHubSettings`) turns Hub
|
|
* installs off for everyone. A text file (Markdown, code, logs) is previewed
|
|
* by the dashboard itself — Markdown rendered, raw HTML in it shown as text —
|
|
* while an HTML/SVG file is the server's sandboxed `raw` route in a sandboxed
|
|
* iframe, so nothing a shared HTML file carries runs on the dashboard's origin. Right-clicking a
|
|
* card offers the same actions as its buttons, plus copying a page's link.
|
|
* Preview is offered only for what a browser shows inside that sandbox (text,
|
|
* images, HTML, audio/video); an Office file, a zip or a PDF is downloaded —
|
|
* the sandbox blocks both a download and the PDF viewer, so it would be blank.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { MoreHorizontal, Search, X } from "lucide-react";
|
|
import {
|
|
api,
|
|
type Artifact,
|
|
type ArtifactCandidate,
|
|
type ArtifactInstalls,
|
|
type ArtifactKind,
|
|
type ArtifactSettings,
|
|
type ServerUserUsage,
|
|
} from "../lib/api";
|
|
import { usePermissions } from "../components/AuthGate";
|
|
import type { Lane } from "../lib/types";
|
|
import { eventBus } from "../lib/eventBus";
|
|
import { toast } from "../lib/toast";
|
|
import { confirmDialog } from "../lib/confirm";
|
|
import { useDialog } from "../hooks/useDialog";
|
|
import { copyText } from "../lib/clipboard";
|
|
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../components/ContextMenu";
|
|
import {
|
|
FileUploadPanel,
|
|
PluginAddPanel,
|
|
SkillAddPanel,
|
|
SkillNameField,
|
|
skillSlug,
|
|
splitSkillMd,
|
|
} from "../components/artifacts/ArtifactAddPanels";
|
|
import { MarkdownPreview, isMarkdown } from "../components/MarkdownPreview";
|
|
|
|
// The storefront's shelf order: what installs first, what is read after.
|
|
const SHELVES: ArtifactKind[] = ["skill", "plugin", "page", "file"];
|
|
|
|
function fmtBytes(n: number | null): string {
|
|
if (!n) return "";
|
|
if (n < 1024) return `${n} B`;
|
|
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
|
|
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
|
}
|
|
|
|
const fmtDate = (iso: string) =>
|
|
new Date(iso).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
|
|
|
|
const IMAGE = /\.(png|jpe?g|gif|webp|svg)$/i;
|
|
// What the sandboxed preview can show: the server's typed list minus PDF.
|
|
const PREVIEWABLE =
|
|
/\.(html?|md|markdown|txt|log|csv|tsv|json|ya?ml|sh|py|js|ts|png|jpe?g|gif|webp|svg|mp4|webm|mp3|wav)$/i;
|
|
|
|
/** What a row can do, worked out once for its card, menu and detail sheet. */
|
|
function capabilities(a: Artifact) {
|
|
const previewPath = a.kind === "skill" ? "SKILL.md" : (a.files[0]?.path ?? "");
|
|
return {
|
|
previewPath,
|
|
canPreview:
|
|
a.files.length > 0 &&
|
|
(a.kind === "skill" || (a.kind === "file" && PREVIEWABLE.test(previewPath))),
|
|
canDownload: a.kind === "file" && a.files.length > 0,
|
|
installable: a.kind === "skill" || a.kind === "plugin",
|
|
// Its owner removed it from their library; it lives on in the Hub only.
|
|
hubOnly: a.mine && a.shared && !a.inLibrary,
|
|
};
|
|
}
|
|
|
|
// The page's controls, in the shapes the other pages use (Sessions' filter bar).
|
|
const segment = (on: boolean) =>
|
|
`rounded-md px-3 py-1.5 text-xs font-medium whitespace-nowrap transition-colors ${
|
|
on ? "bg-surface-4 text-fg-primary" : "text-fg-muted hover:text-fg-secondary"
|
|
}`;
|
|
const smallPrimary = "btn-primary shrink-0 px-3 py-1 text-xs";
|
|
const smallSecondary = "btn-secondary shrink-0 px-2.5 py-1 text-xs";
|
|
// A text action in a card's footer.
|
|
const footAction =
|
|
"rounded-md px-2 py-1 text-xs font-medium text-fg-secondary transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-50";
|
|
|
|
export function Artifacts() {
|
|
const { t } = useTranslation("artifacts");
|
|
const [settings, setSettings] = useState<ArtifactSettings | null>(null);
|
|
const [tab, setTab] = useState<"all" | "mine" | "hub" | "unowned">("all");
|
|
const { isAdmin } = usePermissions();
|
|
// Server mode: rows nobody owns — every one for an admin, those offered to you otherwise.
|
|
const [unownedCount, setUnownedCount] = useState(0);
|
|
const [users, setUsers] = useState<ServerUserUsage[]>([]);
|
|
const [kind, setKind] = useState<ArtifactKind | "">("");
|
|
const [q, setQ] = useState("");
|
|
const [items, setItems] = useState<Artifact[] | null>(null);
|
|
const [adding, setAdding] = useState<ArtifactKind | null>(null);
|
|
const [addMenu, setAddMenu] = useState(false);
|
|
const addMenuRef = useRef<HTMLDivElement>(null);
|
|
const [preview, setPreview] = useState<{ item: Artifact; path: string } | null>(null);
|
|
// By id, so the open sheet shows the row as the latest list has it.
|
|
const [detailId, setDetailId] = useState<number | null>(null);
|
|
|
|
useEffect(() => {
|
|
api.artifacts
|
|
.settings()
|
|
.then(setSettings)
|
|
.catch(() => setSettings(null));
|
|
}, []);
|
|
|
|
const load = useCallback(() => {
|
|
api.artifacts
|
|
.list({ scope: tab, kind: kind || undefined, q: q.trim() || undefined })
|
|
.then((r) => setItems(r.items))
|
|
.catch((err) => {
|
|
setItems([]);
|
|
toast.error(err);
|
|
});
|
|
}, [tab, kind, q]);
|
|
|
|
useEffect(() => {
|
|
const id = window.setTimeout(load, q ? 250 : 0);
|
|
return () => window.clearTimeout(id);
|
|
}, [load, q]);
|
|
|
|
const serverMode = !!settings?.serverMode;
|
|
const countUnowned = useCallback(() => {
|
|
if (!serverMode) return;
|
|
api.artifacts
|
|
.list({ scope: "unowned" })
|
|
.then((r) => setUnownedCount(r.items.length))
|
|
.catch(() => setUnownedCount(0));
|
|
}, [serverMode]);
|
|
useEffect(countUnowned, [countUnowned]);
|
|
useEffect(() => {
|
|
if (tab !== "unowned" || !isAdmin || users.length) return;
|
|
api.users
|
|
.list()
|
|
.then((r) => setUsers(r.users.filter((u) => !u.disabled)))
|
|
.catch(() => setUsers([]));
|
|
}, [tab, isAdmin, users.length]);
|
|
|
|
useEffect(
|
|
() =>
|
|
eventBus.subscribe((msg) => {
|
|
if (msg.type === "artifact_changed") {
|
|
load();
|
|
countUnowned();
|
|
}
|
|
}),
|
|
[load, countUnowned]
|
|
);
|
|
|
|
const settle = async (run: () => Promise<unknown>, done: string) => {
|
|
try {
|
|
await run();
|
|
toast.success(t(done));
|
|
load();
|
|
countUnowned();
|
|
} catch (err) {
|
|
toast.error(err);
|
|
}
|
|
};
|
|
|
|
// The Add menu closes on Escape or a click anywhere outside it.
|
|
useEffect(() => {
|
|
if (!addMenu) return;
|
|
const onDown = (e: MouseEvent) => {
|
|
if (!addMenuRef.current?.contains(e.target as Node)) setAddMenu(false);
|
|
};
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") setAddMenu(false);
|
|
};
|
|
document.addEventListener("mousedown", onDown);
|
|
document.addEventListener("keydown", onKey);
|
|
return () => {
|
|
document.removeEventListener("mousedown", onDown);
|
|
document.removeEventListener("keydown", onKey);
|
|
};
|
|
}, [addMenu]);
|
|
|
|
const server = !!settings?.serverMode;
|
|
|
|
const share = async (a: Artifact) => {
|
|
// A Hub-only row (its owner already removed it from their library) is
|
|
// deleted by unsharing it, so that asks first.
|
|
if (
|
|
a.shared &&
|
|
!a.inLibrary &&
|
|
!(await confirmDialog({
|
|
title: t("hubRemoveTitle", { title: a.title }),
|
|
message: t("hubRemoveBody"),
|
|
confirmLabel: t("hubRemove"),
|
|
}))
|
|
)
|
|
return;
|
|
try {
|
|
await api.artifacts.update(a.id, { shared: !a.shared });
|
|
toast.success(t(a.shared ? "unshareDone" : "shareDone"));
|
|
load();
|
|
} catch (err) {
|
|
toast.error(err);
|
|
}
|
|
};
|
|
|
|
const [renaming, setRenaming] = useState<Artifact | null>(null);
|
|
|
|
const remove = async (a: Artifact) => {
|
|
if (
|
|
!(await confirmDialog({
|
|
title: t("deleteTitle", { title: a.title }),
|
|
message: t(!a.shared ? "deleteBody" : a.mine ? "deleteBodyKeep" : "deleteBodyShared"),
|
|
confirmLabel: t("deleteConfirm"),
|
|
}))
|
|
)
|
|
return;
|
|
try {
|
|
const r = await api.artifacts.remove(a.id);
|
|
if (r.kept) toast.success(t("deleteKept"));
|
|
setDetailId((id) => (id === a.id ? null : id));
|
|
load();
|
|
} catch (err) {
|
|
toast.error(err);
|
|
}
|
|
};
|
|
|
|
const install = async (a: Artifact) => {
|
|
const message =
|
|
a.kind === "skill"
|
|
? `${t("installSkillBody", { name: a.source })} ${a.files.map((f) => f.path).join(", ")}`
|
|
: t("installPluginBody", { market: a.market, name: a.source });
|
|
if (
|
|
!(await confirmDialog({
|
|
title: t("installTitle", { title: a.title }),
|
|
message,
|
|
confirmLabel: t("installConfirm"),
|
|
destructive: false,
|
|
}))
|
|
)
|
|
return;
|
|
const done = (r: { installed: string }) =>
|
|
toast.success(
|
|
a.kind === "skill"
|
|
? t("installed", { path: r.installed })
|
|
: t("installedPlugin", { name: r.installed })
|
|
);
|
|
try {
|
|
done(await api.artifacts.install(a.id));
|
|
} catch (err) {
|
|
const code = (err as { code?: string }).code;
|
|
if (code !== "EEXISTS") return toast.error(err);
|
|
if (
|
|
!(await confirmDialog({
|
|
title: t("replaceTitle", { name: a.source }),
|
|
message: t("replaceBody"),
|
|
confirmLabel: t("replaceConfirm"),
|
|
}))
|
|
)
|
|
return;
|
|
try {
|
|
done(await api.artifacts.install(a.id, true));
|
|
} catch (e) {
|
|
toast.error(e);
|
|
}
|
|
}
|
|
load();
|
|
};
|
|
|
|
// Taken out of the user's own accounts again; a skill's copies are moved to
|
|
// ~/.claude/agenthub-replaced-skills/, never deleted.
|
|
const uninstall = async (a: Artifact) => {
|
|
if (
|
|
!(await confirmDialog({
|
|
title: t("uninstallTitle", { title: a.title }),
|
|
message: t(a.kind === "skill" ? "uninstallSkillBody" : "uninstallPluginBody", {
|
|
name: a.source,
|
|
}),
|
|
confirmLabel: t("uninstall"),
|
|
}))
|
|
)
|
|
return;
|
|
try {
|
|
const r = await api.artifacts.uninstall(a.id);
|
|
toast.success(t("uninstalled", { name: r.uninstalled }));
|
|
} catch (err) {
|
|
toast.error(err);
|
|
}
|
|
load();
|
|
};
|
|
|
|
const actionsFor = (a: Artifact): CardActions => ({
|
|
onOpen: () => setDetailId(a.id),
|
|
onShare: () => void share(a),
|
|
onRename: () => {
|
|
setDetailId(null);
|
|
setRenaming(a);
|
|
},
|
|
onDelete: () => void remove(a),
|
|
onInstall: () => void install(a),
|
|
onUninstall: () => void uninstall(a),
|
|
onPreview: (path) => {
|
|
setDetailId(null);
|
|
setPreview({ item: a, path });
|
|
},
|
|
});
|
|
|
|
const card = (a: Artifact) =>
|
|
tab === "unowned" ? (
|
|
<div key={a.id} className="flex flex-col gap-2" data-testid={`unowned-${a.id}`}>
|
|
<ArtifactCard
|
|
item={a}
|
|
server={server}
|
|
canInstall={!!settings?.installsEnabled}
|
|
{...actionsFor(a)}
|
|
/>
|
|
<div className="flex flex-wrap items-center gap-2 text-xs">
|
|
{(a.suggestedForMe || isAdmin) && (
|
|
<button
|
|
type="button"
|
|
className="btn-primary px-2 py-1 text-xs"
|
|
onClick={() => settle(() => api.artifacts.claim(a.id), "unowned.claimed")}
|
|
>
|
|
{t(a.suggestedForMe ? "unowned.mine" : "unowned.takeIt")}
|
|
</button>
|
|
)}
|
|
{a.suggestedForMe && (
|
|
<button
|
|
type="button"
|
|
className={footAction}
|
|
onClick={() => settle(() => api.artifacts.decline(a.id), "unowned.declined")}
|
|
>
|
|
{t("unowned.notMine")}
|
|
</button>
|
|
)}
|
|
{isAdmin && users.length > 0 && (
|
|
<select
|
|
className="input h-7 w-auto py-0 text-xs"
|
|
aria-label={t("unowned.assign")}
|
|
value=""
|
|
onChange={(e) => {
|
|
const userId = Number(e.target.value);
|
|
if (userId) settle(() => api.artifacts.assign(a.id, userId), "unowned.assigned");
|
|
}}
|
|
>
|
|
<option value="">{t("unowned.assign")}</option>
|
|
{users.map((u) => (
|
|
<option key={u.id} value={u.id}>
|
|
{u.displayName || u.username}
|
|
</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<ArtifactCard
|
|
key={a.id}
|
|
item={a}
|
|
server={server}
|
|
canInstall={!!settings?.installsEnabled}
|
|
{...actionsFor(a)}
|
|
/>
|
|
);
|
|
|
|
// Local mode (everything is yours): the unfiltered page is shelved by
|
|
// kind; a category or a search is one flat grid of results.
|
|
const shelves =
|
|
items && !kind && !q.trim()
|
|
? SHELVES.map((k) => ({ kind: k, rows: items.filter((a) => a.kind === k) })).filter(
|
|
(s) => s.rows.length > 0
|
|
)
|
|
: null;
|
|
// In server mode every tab is split by who it is from, others' first: All
|
|
// into others' shared rows and yours, the Hub into what others shared and
|
|
// what you shared, My library into what you installed from others (the
|
|
// server adds those to `mine`) and your own.
|
|
const halves =
|
|
server && items && tab !== "unowned"
|
|
? (["others", "own"] as const).map((who) => ({
|
|
key: `${tab}.${who}`,
|
|
rows: items.filter((a) => (who === "own" ? a.mine : !a.mine)),
|
|
}))
|
|
: null;
|
|
const detail = detailId === null ? null : (items?.find((a) => a.id === detailId) ?? null);
|
|
|
|
return (
|
|
<div className="animate-fade-in space-y-6" data-testid="artifacts-page">
|
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
|
|
<p className="max-w-2xl text-xs text-fg-muted">
|
|
{t(server ? "subtitleServer" : "subtitle")}
|
|
</p>
|
|
</div>
|
|
<div className="relative shrink-0" ref={addMenuRef}>
|
|
<button
|
|
type="button"
|
|
className="btn-primary"
|
|
data-testid="artifacts-add"
|
|
aria-haspopup="menu"
|
|
aria-expanded={addMenu}
|
|
onClick={() => setAddMenu((v) => !v)}
|
|
>
|
|
{t("add")}
|
|
</button>
|
|
{addMenu && (
|
|
<div className="absolute right-0 z-20 mt-1 w-72 rounded-lg border border-border bg-surface-1 p-1 shadow-xl">
|
|
{SHELVES.map((k) => (
|
|
<button
|
|
key={k}
|
|
type="button"
|
|
aria-label={t(`add.${k}`)}
|
|
onClick={() => {
|
|
setAdding(k);
|
|
setAddMenu(false);
|
|
}}
|
|
className="block w-full rounded-md px-3 py-2 text-left hover:bg-surface-4"
|
|
>
|
|
<span className="block text-sm text-fg-primary">{t(`add.${k}`)}</span>
|
|
<span className="block truncate text-xs text-fg-muted">{t(`blurb.${k}`)}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex w-full flex-wrap items-center gap-3 rounded-xl border border-border bg-surface-2/40 p-2">
|
|
<div className="relative min-w-[180px] max-w-[340px] flex-1">
|
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-fg-muted" />
|
|
<input
|
|
value={q}
|
|
onChange={(e) => setQ(e.target.value)}
|
|
placeholder={t("search")}
|
|
aria-label={t("search")}
|
|
className="input w-full pl-10 pr-9"
|
|
/>
|
|
{q && (
|
|
<button
|
|
type="button"
|
|
aria-label={t("clearSearch")}
|
|
onClick={() => setQ("")}
|
|
className="btn-icon absolute right-1.5 top-1/2 -translate-y-1/2"
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div
|
|
className="flex flex-wrap gap-1 rounded-lg border border-border bg-surface-1 p-1"
|
|
role="group"
|
|
aria-label={t("categories")}
|
|
>
|
|
{(["", ...SHELVES] as const).map((k) => (
|
|
<button
|
|
key={k || "all"}
|
|
type="button"
|
|
aria-pressed={kind === k}
|
|
onClick={() => setKind(k)}
|
|
className={segment(kind === k)}
|
|
>
|
|
{t(k ? `kind.${k}` : "kind.all")}
|
|
</button>
|
|
))}
|
|
</div>
|
|
{server && (
|
|
<div
|
|
className="ml-auto flex gap-1 rounded-lg border border-border bg-surface-1 p-1"
|
|
data-testid="artifacts-scope"
|
|
>
|
|
{(["all", "mine", "hub"] as const).map((v) => (
|
|
<button
|
|
key={v}
|
|
type="button"
|
|
aria-pressed={tab === v}
|
|
onClick={() => setTab(v)}
|
|
className={segment(tab === v)}
|
|
>
|
|
{t(`tab.${v}`)}
|
|
</button>
|
|
))}
|
|
{(isAdmin || unownedCount > 0 || tab === "unowned") && (
|
|
<button
|
|
type="button"
|
|
aria-pressed={tab === "unowned"}
|
|
onClick={() => setTab("unowned")}
|
|
className={segment(tab === "unowned")}
|
|
data-testid="artifacts-tab-unowned"
|
|
title={t(isAdmin ? "unowned.hintAdmin" : "unowned.hint")}
|
|
>
|
|
{t(isAdmin ? "tab.unowned" : "tab.suggested")}
|
|
{unownedCount > 0 && <span className="ml-1 text-fg-muted">{unownedCount}</span>}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{items === null ? (
|
|
<SkeletonGrid label={t("loading")} />
|
|
) : items.length === 0 && tab === "unowned" ? (
|
|
<p className="rounded-lg border border-dashed border-border px-4 py-6 text-center text-xs text-fg-muted">
|
|
{t("unowned.empty")}
|
|
</p>
|
|
) : items.length === 0 ? (
|
|
<EmptyState
|
|
hub={tab === "hub"}
|
|
searching={!!q.trim() || !!kind}
|
|
onAdd={(k) => setAdding(k)}
|
|
/>
|
|
) : halves ? (
|
|
<div className="space-y-8" data-testid="artifacts-list">
|
|
{halves.map((h) => (
|
|
<section key={h.key} aria-labelledby={`half-${h.key}`} data-testid={`half-${h.key}`}>
|
|
<div className="mb-3 min-w-0">
|
|
<h2 id={`half-${h.key}`} className="text-sm font-semibold text-fg-primary">
|
|
{t(`section.${h.key}`)}
|
|
<span className="ml-2 text-xs font-normal text-fg-muted">{h.rows.length}</span>
|
|
</h2>
|
|
<p className="truncate text-xs text-fg-muted">{t(`section.${h.key}Hint`)}</p>
|
|
</div>
|
|
{h.rows.length ? (
|
|
<PagedGrid key={`${h.key}|${kind}|${q}`} rows={h.rows} card={card} />
|
|
) : (
|
|
<p className="rounded-lg border border-dashed border-border px-4 py-6 text-center text-xs text-fg-muted">
|
|
{t("section.empty")}
|
|
</p>
|
|
)}
|
|
</section>
|
|
))}
|
|
</div>
|
|
) : shelves ? (
|
|
<div className="space-y-8" data-testid="artifacts-list">
|
|
{shelves.map((s) => (
|
|
<section key={s.kind} aria-labelledby={`shelf-${s.kind}`}>
|
|
<div className="mb-3 flex items-end justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<h2 id={`shelf-${s.kind}`} className="text-sm font-semibold text-fg-primary">
|
|
{t(`kind.${s.kind}`)}
|
|
<span className="ml-2 text-xs font-normal text-fg-muted">{s.rows.length}</span>
|
|
</h2>
|
|
<p className="truncate text-xs text-fg-muted">{t(`blurb.${s.kind}`)}</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setKind(s.kind)}
|
|
className="btn-ghost shrink-0 px-2 py-1 text-xs"
|
|
>
|
|
{t("seeAll")}
|
|
</button>
|
|
</div>
|
|
<PagedGrid key={s.kind} rows={s.rows} card={card} />
|
|
</section>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div data-testid="artifacts-list">
|
|
<p className="mb-2 text-xs text-fg-muted">{t("results", { count: items.length })}</p>
|
|
<PagedGrid key={`${tab}|${kind}|${q}`} rows={items} card={card} />
|
|
</div>
|
|
)}
|
|
|
|
{detail && (
|
|
<DetailDialog
|
|
item={detail}
|
|
server={server}
|
|
canInstall={!!settings?.installsEnabled}
|
|
onClose={() => setDetailId(null)}
|
|
{...actionsFor(detail)}
|
|
/>
|
|
)}
|
|
{adding && <AddDialog kind={adding} onClose={() => setAdding(null)} onAdded={load} />}
|
|
{preview && <PreviewDialog {...preview} onClose={() => setPreview(null)} />}
|
|
{renaming && (
|
|
<RenameDialog
|
|
item={renaming}
|
|
onClose={() => setRenaming(null)}
|
|
onSaved={() => {
|
|
setRenaming(null);
|
|
load();
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Cards per page of a section: four rows of the three-column grid.
|
|
const PAGE_SIZE = 12;
|
|
|
|
/** A section's cards, a page at a time once there are more than `PAGE_SIZE`
|
|
* (the Sessions table's Previous · n / m · Next). Keyed by the caller on the
|
|
* filters, so a new tab, kind or search starts again at page one. */
|
|
function PagedGrid({ rows, card }: { rows: Artifact[]; card: (a: Artifact) => React.ReactNode }) {
|
|
const { t } = useTranslation("artifacts");
|
|
const [page, setPage] = useState(0);
|
|
const pages = Math.max(1, Math.ceil(rows.length / PAGE_SIZE));
|
|
const at = Math.min(page, pages - 1);
|
|
const pager =
|
|
"rounded-md bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40";
|
|
return (
|
|
<>
|
|
<ul className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
|
{rows.slice(at * PAGE_SIZE, (at + 1) * PAGE_SIZE).map(card)}
|
|
</ul>
|
|
{pages > 1 && (
|
|
<nav className="mt-3 flex items-center justify-between px-1" aria-label={t("pages")}>
|
|
<span className="text-xs text-fg-muted">
|
|
{t("common:pagination.showing", {
|
|
from: at * PAGE_SIZE + 1,
|
|
to: Math.min((at + 1) * PAGE_SIZE, rows.length),
|
|
total: rows.length,
|
|
})}
|
|
</span>
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
type="button"
|
|
className={pager}
|
|
disabled={at === 0}
|
|
onClick={() => setPage(at - 1)}
|
|
>
|
|
{t("common:pagination.previous")}
|
|
</button>
|
|
<span className="px-3 py-1.5 text-xs text-fg-muted">
|
|
{at + 1} / {pages}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
className={pager}
|
|
disabled={at >= pages - 1}
|
|
onClick={() => setPage(at + 1)}
|
|
>
|
|
{t("common:pagination.next")}
|
|
</button>
|
|
</div>
|
|
</nav>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function SkeletonGrid({ label }: { label: string }) {
|
|
return (
|
|
<div role="status" aria-label={label}>
|
|
<span className="sr-only">{label}</span>
|
|
<ul className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3" aria-hidden>
|
|
{Array.from({ length: 6 }, (_, i) => (
|
|
<li key={i} className="card flex animate-pulse flex-col gap-3 p-4">
|
|
<span className="block h-3 w-2/3 rounded bg-surface-4" />
|
|
<span className="block h-2.5 w-1/3 rounded bg-surface-2" />
|
|
<span className="block h-2.5 w-full rounded bg-surface-2" />
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function EmptyState({
|
|
hub,
|
|
searching,
|
|
onAdd,
|
|
}: {
|
|
hub: boolean;
|
|
searching: boolean;
|
|
onAdd: (k: ArtifactKind) => void;
|
|
}) {
|
|
const { t } = useTranslation("artifacts");
|
|
return (
|
|
<div className="card flex flex-col items-center px-6 py-12 text-center">
|
|
<p className="text-sm font-semibold text-fg-primary">
|
|
{t(searching ? "empty.searchTitle" : hub ? "empty.hubTitle" : "empty.title")}
|
|
</p>
|
|
<p className="mt-1 max-w-md text-xs text-fg-muted">
|
|
{searching ? t("empty.search") : t(hub ? "empty.hub" : "empty.mine")}
|
|
</p>
|
|
{!hub && !searching && (
|
|
<div className="mt-6 grid w-full max-w-2xl gap-2 sm:grid-cols-2">
|
|
{SHELVES.map((k) => (
|
|
<button
|
|
key={k}
|
|
type="button"
|
|
onClick={() => onAdd(k)}
|
|
className="rounded-lg border border-border bg-surface-2 px-3 py-2.5 text-left transition-colors hover:bg-surface-4"
|
|
>
|
|
<span className="block text-sm font-medium text-fg-primary">{t(`add.${k}`)}</span>
|
|
<span className="block text-xs text-fg-muted">{t(`blurb.${k}`)}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type CardActions = {
|
|
onOpen: () => void;
|
|
onShare: () => void;
|
|
onRename: () => void;
|
|
onDelete: () => void;
|
|
onInstall: () => void;
|
|
onUninstall: () => void;
|
|
onPreview: (path: string) => void;
|
|
};
|
|
|
|
/** The one primary action on a card / detail sheet. */
|
|
function PrimaryAction({
|
|
item: a,
|
|
canInstall,
|
|
onInstall,
|
|
onPreview,
|
|
}: {
|
|
item: Artifact;
|
|
canInstall: boolean;
|
|
onInstall: () => void;
|
|
onPreview: (path: string) => void;
|
|
}) {
|
|
const { t } = useTranslation("artifacts");
|
|
const c = capabilities(a);
|
|
if (a.installed)
|
|
return (
|
|
<span
|
|
className="badge border-status-success/30 bg-status-success/10 text-status-success"
|
|
data-testid="artifact-installed"
|
|
>
|
|
{t("installedBadge")}
|
|
</span>
|
|
);
|
|
if (c.installable)
|
|
return (
|
|
<button
|
|
type="button"
|
|
className={smallPrimary}
|
|
disabled={!canInstall}
|
|
title={canInstall ? undefined : t("installsOff")}
|
|
onClick={onInstall}
|
|
>
|
|
{t("install")}
|
|
</button>
|
|
);
|
|
if (a.kind === "page" && a.url)
|
|
return (
|
|
<a href={a.url} target="_blank" rel="noopener noreferrer" className={smallPrimary}>
|
|
{t("open")}
|
|
</a>
|
|
);
|
|
if (c.canPreview)
|
|
return (
|
|
<button type="button" className={smallSecondary} onClick={() => onPreview(c.previewPath)}>
|
|
{t("preview")}
|
|
</button>
|
|
);
|
|
if (c.canDownload)
|
|
return (
|
|
<a href={api.artifacts.rawUrl(a.id, a.files[0]?.path, true)} className={smallSecondary}>
|
|
{t("download")}
|
|
</a>
|
|
);
|
|
return null;
|
|
}
|
|
|
|
function ArtifactCard({
|
|
item: a,
|
|
server,
|
|
canInstall,
|
|
onOpen,
|
|
onShare,
|
|
onRename,
|
|
onDelete,
|
|
onInstall,
|
|
onUninstall,
|
|
onPreview,
|
|
}: CardActions & {
|
|
item: Artifact;
|
|
server: boolean;
|
|
canInstall: boolean;
|
|
}) {
|
|
const { t } = useTranslation("artifacts");
|
|
const menu = useContextMenu<null>();
|
|
const pick = (fn: () => void) => () => {
|
|
menu.close();
|
|
fn();
|
|
};
|
|
const { previewPath, canPreview, canDownload, installable, hubOnly } = capabilities(a);
|
|
// The primary action already offers Preview/Download for a plain file.
|
|
const primaryIsPreview = !installable && a.kind !== "page" && canPreview;
|
|
return (
|
|
<li
|
|
className="card flex flex-col gap-3 p-4 transition-colors hover:border-border-light"
|
|
data-testid={`artifact-${a.id}`}
|
|
onContextMenu={(e) => menu.open(e, null)}
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={onOpen}
|
|
title={t("details")}
|
|
className="min-w-0 flex-1 rounded-md text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
|
|
>
|
|
<span
|
|
className="block truncate text-sm font-medium text-fg-primary hover:text-accent"
|
|
title={a.title}
|
|
>
|
|
{a.title}
|
|
</span>
|
|
<span
|
|
className="mt-0.5 block truncate text-xs text-fg-muted"
|
|
title={a.source || a.url || ""}
|
|
>
|
|
{t(`kindOne.${a.kind}`)}
|
|
{a.source ? ` · ${a.source}` : ""}
|
|
{a.size ? ` · ${fmtBytes(a.size)}` : ""}
|
|
</span>
|
|
</button>
|
|
<PrimaryAction
|
|
item={a}
|
|
canInstall={canInstall}
|
|
onInstall={onInstall}
|
|
onPreview={onPreview}
|
|
/>
|
|
</div>
|
|
{a.shared && (
|
|
<span
|
|
className="badge self-start border-accent/30 bg-accent-muted text-accent"
|
|
title={hubOnly ? t("hubOnlyHint") : undefined}
|
|
>
|
|
{t(hubOnly ? "hubOnly" : "shared")}
|
|
</span>
|
|
)}
|
|
{a.auto && (
|
|
<span className="badge self-start" title={t("autoHint")}>
|
|
{t("auto")}
|
|
</span>
|
|
)}
|
|
{a.description && (
|
|
<p className="line-clamp-2 text-xs leading-relaxed text-fg-secondary">{a.description}</p>
|
|
)}
|
|
<div className="mt-auto flex flex-wrap items-center gap-x-2 gap-y-1 border-t border-border pt-2.5 text-xs text-fg-muted">
|
|
<span className="flex min-w-0 flex-1 items-center gap-1.5">
|
|
{server && a.owner && !a.mine && (
|
|
<span className="truncate">{t("by", { name: a.owner })} ·</span>
|
|
)}
|
|
<span className="shrink-0" title={new Date(a.createdAt).toLocaleString()}>
|
|
{fmtDate(a.createdAt)}
|
|
</span>
|
|
{a.installs > 0 && (
|
|
<>
|
|
<span>·</span>
|
|
<InstallsButton item={a} />
|
|
</>
|
|
)}
|
|
</span>
|
|
<span className="flex shrink-0 items-center">
|
|
{canPreview && !primaryIsPreview && (
|
|
<button type="button" className={footAction} onClick={() => onPreview(previewPath)}>
|
|
{t("preview")}
|
|
</button>
|
|
)}
|
|
{canDownload && primaryIsPreview && (
|
|
<a href={api.artifacts.rawUrl(a.id, a.files[0]?.path, true)} className={footAction}>
|
|
{t("download")}
|
|
</a>
|
|
)}
|
|
{server && a.canChange && (
|
|
<button type="button" className={footAction} onClick={onShare}>
|
|
{t(hubOnly ? "hubRemove" : a.shared ? "unshare" : "share")}
|
|
</button>
|
|
)}
|
|
{a.canChange && (
|
|
<button type="button" className={footAction} onClick={onRename}>
|
|
{t("rename")}
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
className="btn-icon"
|
|
aria-label={t("more")}
|
|
title={t("more")}
|
|
aria-haspopup="menu"
|
|
onClick={(e) => menu.open(e, null)}
|
|
>
|
|
<MoreHorizontal className="h-4 w-4" />
|
|
</button>
|
|
</span>
|
|
</div>
|
|
{menu.menu && (
|
|
<ContextMenu x={menu.menu.x} y={menu.menu.y} onClose={menu.close} testId="artifact-menu">
|
|
<MenuItem onSelect={pick(onOpen)}>{t("details")}</MenuItem>
|
|
{a.kind === "page" && a.url && (
|
|
<>
|
|
<MenuItem onSelect={pick(() => window.open(a.url!, "_blank", "noopener,noreferrer"))}>
|
|
{t("open")}
|
|
</MenuItem>
|
|
<MenuItem onSelect={pick(() => void copyText(a.url!))}>{t("copyLink")}</MenuItem>
|
|
</>
|
|
)}
|
|
{canPreview && (
|
|
<MenuItem onSelect={pick(() => onPreview(previewPath))}>{t("preview")}</MenuItem>
|
|
)}
|
|
{canDownload && (
|
|
<MenuItem
|
|
onSelect={pick(() => {
|
|
window.location.href = api.artifacts.rawUrl(a.id, a.files[0]?.path, true);
|
|
})}
|
|
>
|
|
{t("download")}
|
|
</MenuItem>
|
|
)}
|
|
{installable && !a.installed && (
|
|
<MenuItem disabled={!canInstall} onSelect={pick(onInstall)}>
|
|
{t("install")}
|
|
</MenuItem>
|
|
)}
|
|
{a.installed && (
|
|
<MenuItem danger onSelect={pick(onUninstall)}>
|
|
{t("uninstall")}
|
|
</MenuItem>
|
|
)}
|
|
{a.canChange && (
|
|
<>
|
|
<MenuSeparator />
|
|
{server && (
|
|
<MenuItem danger={hubOnly} onSelect={pick(onShare)}>
|
|
{t(hubOnly ? "hubRemove" : a.shared ? "unshare" : "share")}
|
|
</MenuItem>
|
|
)}
|
|
<MenuItem onSelect={pick(onRename)}>{t("rename")}</MenuItem>
|
|
{!hubOnly && (
|
|
<MenuItem danger onSelect={pick(onDelete)}>
|
|
{t("delete")}
|
|
</MenuItem>
|
|
)}
|
|
</>
|
|
)}
|
|
</ContextMenu>
|
|
)}
|
|
</li>
|
|
);
|
|
}
|
|
|
|
/** An artifact's own page: title and main action up top, a strip of facts,
|
|
* what it is, how it installs, and what it carries. */
|
|
function DetailDialog({
|
|
item: a,
|
|
server,
|
|
canInstall,
|
|
onClose,
|
|
onShare,
|
|
onRename,
|
|
onDelete,
|
|
onInstall,
|
|
onPreview,
|
|
}: CardActions & {
|
|
item: Artifact;
|
|
server: boolean;
|
|
canInstall: boolean;
|
|
onClose: () => void;
|
|
}) {
|
|
const { t } = useTranslation("artifacts");
|
|
const { canPreview, canDownload, hubOnly } = capabilities(a);
|
|
const secondary = smallSecondary;
|
|
const stats: { label: string; value: React.ReactNode }[] = [
|
|
{ label: t("stat.kind"), value: t(`kindOne.${a.kind}`) },
|
|
...(a.kind === "skill" || a.kind === "plugin"
|
|
? [{ label: t("stat.installs"), value: a.installs > 0 ? <InstallsButton item={a} /> : "0" }]
|
|
: []),
|
|
...(a.files.length ? [{ label: t("stat.files"), value: String(a.files.length) }] : []),
|
|
...(a.size ? [{ label: t("stat.size"), value: fmtBytes(a.size) }] : []),
|
|
{ label: t("stat.updated"), value: fmtDate(a.updatedAt) },
|
|
];
|
|
return (
|
|
<Modal title={a.title} onClose={onClose} size="md">
|
|
<div className="space-y-5" data-testid="artifact-detail">
|
|
<div>
|
|
<div className="min-w-0">
|
|
<p className="text-base font-semibold leading-tight text-fg-primary">{a.title}</p>
|
|
<p className="mt-0.5 truncate text-sm text-fg-muted">
|
|
{server && a.owner && !a.mine ? t("by", { name: a.owner }) : t(`kindOne.${a.kind}`)}
|
|
{a.source ? ` · ${a.source}` : ""}
|
|
</p>
|
|
{a.shared && (
|
|
<span className="badge mt-2 border-accent/30 bg-accent-muted text-accent">
|
|
{t(hubOnly ? "hubOnly" : "shared")}
|
|
</span>
|
|
)}
|
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
|
<PrimaryAction
|
|
item={a}
|
|
canInstall={canInstall}
|
|
onInstall={onInstall}
|
|
onPreview={onPreview}
|
|
/>
|
|
{canPreview && a.kind !== "file" && (
|
|
<button
|
|
type="button"
|
|
className={secondary}
|
|
onClick={() => onPreview(capabilities(a).previewPath)}
|
|
>
|
|
{t("preview")}
|
|
</button>
|
|
)}
|
|
{canDownload && canPreview && (
|
|
<a href={api.artifacts.rawUrl(a.id, a.files[0]?.path, true)} className={secondary}>
|
|
{t("download")}
|
|
</a>
|
|
)}
|
|
{a.kind === "page" && a.url && (
|
|
<button type="button" className={secondary} onClick={() => void copyText(a.url!)}>
|
|
{t("copyLink")}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<dl className="grid grid-cols-2 divide-border rounded-lg border border-border bg-surface-2/50 sm:flex sm:divide-x">
|
|
{stats.map((s) => (
|
|
<div key={s.label} className="flex-1 px-3 py-2.5 text-center">
|
|
<dt className="text-[10px] font-medium uppercase tracking-wide text-fg-muted">
|
|
{s.label}
|
|
</dt>
|
|
<dd className="mt-0.5 text-sm font-semibold text-fg-primary">{s.value}</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
|
|
<section>
|
|
<h3 className="mb-1 text-sm font-semibold text-fg-primary">
|
|
{t(a.kind === "skill" ? "preview.whenUsed" : "about")}
|
|
</h3>
|
|
<p className="whitespace-pre-wrap text-sm leading-relaxed text-fg-secondary">
|
|
{a.description || <span className="text-fg-muted">{t("preview.noDescription")}</span>}
|
|
</p>
|
|
</section>
|
|
|
|
{a.kind === "skill" && a.source && (
|
|
<section className="rounded-lg border border-border p-3">
|
|
<p className="flex items-center gap-2 text-xs text-fg-secondary">
|
|
{t("detail.runAs")}
|
|
<code className="rounded-md bg-surface-3 px-1.5 py-0.5 font-mono text-xs text-fg-primary">
|
|
/{a.source}
|
|
</code>
|
|
</p>
|
|
<p className="mt-1 font-mono text-[11px] text-fg-muted">
|
|
{t("preview.installsAs", { slug: a.source })}
|
|
</p>
|
|
</section>
|
|
)}
|
|
{a.kind === "plugin" && (
|
|
<section className="space-y-1 rounded-lg border border-border p-3 text-xs">
|
|
<p className="text-fg-secondary">
|
|
{t("form.pluginKey")}: <code className="font-mono text-fg-primary">{a.source}</code>
|
|
</p>
|
|
{a.market && (
|
|
<p className="truncate text-fg-secondary" title={a.market}>
|
|
{t("detail.market")}: <code className="font-mono text-fg-primary">{a.market}</code>
|
|
</p>
|
|
)}
|
|
</section>
|
|
)}
|
|
|
|
{a.files.length > 0 && a.kind !== "page" && (
|
|
<section>
|
|
<h3 className="mb-1.5 text-sm font-semibold text-fg-primary">
|
|
{t("files", { count: a.files.length })}
|
|
</h3>
|
|
<ul className="max-h-48 divide-y divide-border overflow-auto rounded-lg border border-border">
|
|
{a.files.map((f) => (
|
|
<li key={f.path} className="flex items-center gap-2 px-3 py-1.5 text-xs">
|
|
<span
|
|
className="min-w-0 flex-1 truncate font-mono text-fg-secondary"
|
|
title={f.path}
|
|
>
|
|
{f.path}
|
|
</span>
|
|
<span className="shrink-0 text-[11px] text-fg-muted">{fmtBytes(f.size)}</span>
|
|
{PREVIEWABLE.test(f.path) && (
|
|
<button
|
|
type="button"
|
|
className={footAction}
|
|
aria-label={`${t("preview")} ${f.path}`}
|
|
onClick={() => onPreview(f.path)}
|
|
>
|
|
{t("preview")}
|
|
</button>
|
|
)}
|
|
<a
|
|
href={api.artifacts.rawUrl(a.id, f.path, true)}
|
|
className={footAction}
|
|
aria-label={`${t("download")} ${f.path}`}
|
|
>
|
|
{t("download")}
|
|
</a>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
)}
|
|
|
|
<p className="text-[11px] text-fg-muted">
|
|
{t("detail.added", { date: new Date(a.createdAt).toLocaleString() })}
|
|
</p>
|
|
|
|
{a.canChange && (
|
|
<div className="flex flex-wrap items-center gap-2 border-t border-border pt-4">
|
|
{server && (
|
|
<button type="button" className={secondary} onClick={onShare}>
|
|
{t(hubOnly ? "hubRemove" : a.shared ? "unshare" : "share")}
|
|
</button>
|
|
)}
|
|
<button type="button" className={secondary} onClick={onRename}>
|
|
{t("rename")}
|
|
</button>
|
|
{!hubOnly && (
|
|
<button
|
|
type="button"
|
|
className="btn-danger ml-auto px-2.5 py-1 text-xs"
|
|
onClick={onDelete}
|
|
>
|
|
{t("delete")}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
/** "N installs" on a card; a click lists who installed it, when, and where. */
|
|
function InstallsButton({ item }: { item: Artifact }) {
|
|
const { t } = useTranslation("artifacts");
|
|
const [open, setOpen] = useState(false);
|
|
const [data, setData] = useState<ArtifactInstalls | null>(null);
|
|
const box = useRef<HTMLSpanElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
setData(null);
|
|
api.artifacts
|
|
.installs(item.id)
|
|
.then(setData)
|
|
.catch((err) => {
|
|
setOpen(false);
|
|
toast.error(err);
|
|
});
|
|
const onDown = (e: MouseEvent) => {
|
|
if (!box.current?.contains(e.target as Node)) setOpen(false);
|
|
};
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") setOpen(false);
|
|
};
|
|
document.addEventListener("mousedown", onDown);
|
|
document.addEventListener("keydown", onKey);
|
|
return () => {
|
|
document.removeEventListener("mousedown", onDown);
|
|
document.removeEventListener("keydown", onKey);
|
|
};
|
|
}, [open, item.id]);
|
|
|
|
return (
|
|
<span className="relative" ref={box}>
|
|
<button
|
|
type="button"
|
|
aria-expanded={open}
|
|
onClick={() => setOpen((v) => !v)}
|
|
className="underline decoration-dotted underline-offset-2 hover:text-fg-primary"
|
|
>
|
|
{t("installs", { count: item.installs })}
|
|
</button>
|
|
{open && (
|
|
<div
|
|
role="dialog"
|
|
aria-label={t("installsTitle")}
|
|
data-testid="artifact-installs"
|
|
className="absolute left-0 z-30 mt-1 w-80 max-w-[80vw] rounded-lg border border-border bg-surface-1 p-2 shadow-xl"
|
|
>
|
|
<p className="mb-1.5 px-1 text-xs font-medium text-fg-primary">{t("installsTitle")}</p>
|
|
{data === null ? (
|
|
<p className="px-1 text-xs text-fg-muted">{t("loading")}</p>
|
|
) : (
|
|
<ul className="max-h-64 space-y-1 overflow-auto">
|
|
{data.items.map((i, n) => (
|
|
<li key={n} className="rounded px-1.5 py-1 text-xs hover:bg-surface-2">
|
|
<div className="flex items-center gap-2">
|
|
<span className="min-w-0 flex-1 truncate text-fg-primary">
|
|
{i.mine ? t("installsYou") : (i.user ?? t("installsUnknown"))}
|
|
{i.replaced && (
|
|
<span className="ml-1 text-fg-muted">· {t("installsReplaced")}</span>
|
|
)}
|
|
</span>
|
|
<span className="shrink-0 text-[11px] text-fg-muted">
|
|
{new Date(i.at).toLocaleString()}
|
|
</span>
|
|
</div>
|
|
{i.paths && (
|
|
<div className="mt-0.5 space-y-0.5">
|
|
{i.paths.length ? (
|
|
i.paths.map((p) => (
|
|
<p
|
|
key={p}
|
|
className="truncate font-mono text-[10px] text-fg-muted"
|
|
title={p}
|
|
>
|
|
{p}
|
|
</p>
|
|
))
|
|
) : (
|
|
<p className="font-mono text-[10px] text-fg-muted">
|
|
{t("installsPluginWhere")}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</li>
|
|
))}
|
|
{data.unrecorded > 0 && (
|
|
<li className="px-1.5 py-1 text-[11px] text-fg-muted">
|
|
{t("installsUnrecorded", { count: data.unrecorded })}
|
|
</li>
|
|
)}
|
|
{data.items.length === 0 && data.unrecorded === 0 && (
|
|
<li className="px-1.5 py-1 text-[11px] text-fg-muted">
|
|
{t("installsNoneOfYours")}
|
|
</li>
|
|
)}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
)}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function Modal({
|
|
title,
|
|
onClose,
|
|
children,
|
|
wide = false,
|
|
size,
|
|
flush = false,
|
|
}: {
|
|
title: string;
|
|
onClose: () => void;
|
|
children: React.ReactNode;
|
|
wide?: boolean;
|
|
/** A detail sheet: wider than a form, narrower than a preview. */
|
|
size?: "md";
|
|
/** The body draws its own padding (a preview fills the panel edge to edge). */
|
|
flush?: boolean;
|
|
}) {
|
|
const { t } = useTranslation("artifacts");
|
|
const panel = useRef<HTMLDivElement>(null);
|
|
useDialog(true, panel, onClose);
|
|
return (
|
|
<div
|
|
className="fixed inset-0 z-40 flex items-center justify-center bg-black/60 p-3"
|
|
role="presentation"
|
|
onClick={onClose}
|
|
>
|
|
<div
|
|
ref={panel}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label={title}
|
|
onClick={(e) => e.stopPropagation()}
|
|
className={`flex max-h-[90vh] w-full ${wide ? "max-w-6xl" : size === "md" ? "max-w-2xl" : "max-w-lg"} flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-2xl shadow-black/50`}
|
|
>
|
|
<div className="flex shrink-0 items-center justify-between gap-2 border-b border-border px-4 py-3">
|
|
<h2 className="truncate text-sm font-semibold text-fg-primary">{title}</h2>
|
|
<button type="button" aria-label={t("close")} onClick={onClose} className="btn-icon">
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
<div className={`min-h-0 flex-1 ${flush ? "flex flex-col" : "overflow-auto p-4"}`}>
|
|
{children}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const fieldLabel = "mb-1 block text-xs text-fg-secondary";
|
|
|
|
function RenameDialog({
|
|
item,
|
|
onClose,
|
|
onSaved,
|
|
}: {
|
|
item: Artifact;
|
|
onClose: () => void;
|
|
onSaved: () => void;
|
|
}) {
|
|
const { t } = useTranslation("artifacts");
|
|
const plugin = item.kind === "plugin";
|
|
// A skill edits its SKILL.md too: name, description and instructions,
|
|
// read from the snapshot and saved as a new one.
|
|
const skill = item.kind === "skill" && item.files.some((f) => f.path === "SKILL.md");
|
|
const [title, setTitle] = useState(item.title);
|
|
const [description, setDescription] = useState(item.description);
|
|
const [source, setSource] = useState(item.source ?? "");
|
|
const [market, setMarket] = useState(item.market ?? "");
|
|
const [orig, setOrig] = useState<{ name: string; description: string; body: string } | null>(
|
|
null
|
|
);
|
|
const [skillName, setSkillName] = useState("");
|
|
const [content, setContent] = useState("");
|
|
const [loadFailed, setLoadFailed] = useState(false);
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!skill) return;
|
|
let live = true;
|
|
fetch(api.artifacts.rawUrl(item.id, "SKILL.md"), { credentials: "same-origin" })
|
|
.then((r) => (r.ok ? r.text() : Promise.reject(new Error(String(r.status)))))
|
|
.then((text) => {
|
|
if (!live) return;
|
|
const parts = splitSkillMd(text);
|
|
const o = { ...parts, name: parts.name || item.source || "" };
|
|
setOrig(o);
|
|
setSkillName(o.name);
|
|
setDescription(o.description || item.description);
|
|
setContent(o.body);
|
|
})
|
|
.catch(() => live && setLoadFailed(true));
|
|
return () => {
|
|
live = false;
|
|
};
|
|
}, [skill, item.id, item.source, item.description]);
|
|
|
|
const skillChanged =
|
|
!!orig &&
|
|
(skillName.trim() !== orig.name ||
|
|
description.trim() !== orig.description ||
|
|
content !== orig.body);
|
|
const blocked =
|
|
busy ||
|
|
!title.trim() ||
|
|
(plugin && (!source.trim() || !market.trim())) ||
|
|
(skill && (!orig || !skillSlug(skillName) || !description.trim() || !content.trim()));
|
|
|
|
return (
|
|
<Modal title={t("editTitle", { title: item.title })} onClose={onClose} wide={skill}>
|
|
<form
|
|
className="space-y-3"
|
|
onSubmit={async (e) => {
|
|
e.preventDefault();
|
|
setBusy(true);
|
|
try {
|
|
await api.artifacts.update(item.id, {
|
|
title,
|
|
...(skill
|
|
? skillChanged
|
|
? {
|
|
skill: {
|
|
name: skillName.trim(),
|
|
description: description.trim(),
|
|
content,
|
|
},
|
|
}
|
|
: {}
|
|
: { description }),
|
|
...(plugin && source.trim() !== item.source ? { source: source.trim() } : {}),
|
|
...(plugin && market.trim() !== item.market ? { market: market.trim() } : {}),
|
|
});
|
|
onSaved();
|
|
} catch (err) {
|
|
toast.error(err);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}}
|
|
>
|
|
<label className="block">
|
|
<span className={fieldLabel}>{t("edit.title")}</span>
|
|
<input
|
|
className="input w-full"
|
|
value={title}
|
|
autoFocus
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
/>
|
|
</label>
|
|
{skill ? (
|
|
loadFailed ? (
|
|
<p className="text-sm text-status-danger">{t("previewFailed")}</p>
|
|
) : !orig ? (
|
|
<p className="text-sm text-fg-muted">{t("loading")}</p>
|
|
) : (
|
|
<>
|
|
<SkillNameField value={skillName} onChange={setSkillName} required />
|
|
<label className="block">
|
|
<span className={fieldLabel}>{t("skill.description")} *</span>
|
|
<textarea
|
|
className="input w-full resize-y"
|
|
rows={2}
|
|
value={description}
|
|
placeholder={t("skill.descriptionHint")}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
/>
|
|
</label>
|
|
<label className="block">
|
|
<span className={fieldLabel}>{t("skill.content")} *</span>
|
|
<textarea
|
|
className="input min-h-[16rem] w-full resize-y font-mono text-xs"
|
|
rows={16}
|
|
value={content}
|
|
onChange={(e) => setContent(e.target.value)}
|
|
/>
|
|
</label>
|
|
{item.files.length > 1 && (
|
|
<p className="text-[11px] text-fg-muted">
|
|
{t("edit.otherFiles", { count: item.files.length - 1 })}
|
|
</p>
|
|
)}
|
|
{skillChanged && item.installed && (
|
|
<p className="text-[11px] text-fg-muted">{t("edit.reinstallNote")}</p>
|
|
)}
|
|
</>
|
|
)
|
|
) : (
|
|
<label className="block">
|
|
<span className={fieldLabel}>{t("edit.description")}</span>
|
|
<textarea
|
|
className="input w-full resize-y"
|
|
rows={4}
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
/>
|
|
</label>
|
|
)}
|
|
{plugin && (
|
|
<>
|
|
<label className="block">
|
|
<span className={fieldLabel}>{t("form.pluginKey")}</span>
|
|
<input
|
|
className="input w-full font-mono text-xs"
|
|
placeholder="my-plugin@my-marketplace"
|
|
value={source}
|
|
onChange={(e) => setSource(e.target.value)}
|
|
/>
|
|
</label>
|
|
<label className="block">
|
|
<span className={fieldLabel}>{t("form.market")}</span>
|
|
<input
|
|
className="input w-full font-mono text-xs"
|
|
placeholder="owner/repo · https://…"
|
|
value={market}
|
|
onChange={(e) => setMarket(e.target.value)}
|
|
/>
|
|
</label>
|
|
</>
|
|
)}
|
|
<div className="flex justify-end gap-2 pt-1">
|
|
<button type="button" className="btn-ghost text-sm" onClick={onClose} disabled={busy}>
|
|
{t("form.cancel")}
|
|
</button>
|
|
<button type="submit" className="btn-primary text-sm" disabled={blocked}>
|
|
{t("form.saveEdit")}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
// Text the dashboard renders itself: Markdown through `MarkdownPreview`, the
|
|
// rest as code. A sandboxed iframe would draw a text/plain body with the
|
|
// browser's own colours — light text on our white frame in a dark theme.
|
|
const TEXT = /\.(md|markdown|txt|log|csv|tsv|json|ya?ml|sh|py|js|ts)$/i;
|
|
const MEDIA = /\.(mp4|webm|mp3|wav)$/i;
|
|
const TEXT_MAX = 1024 * 1024;
|
|
|
|
function TextPreview({
|
|
url,
|
|
path,
|
|
skill = false,
|
|
}: {
|
|
url: string;
|
|
path: string;
|
|
/** A skill's SKILL.md: its name/description are in the dialog's header, so
|
|
* only the instructions are rendered (the raw frontmatter folds away). */
|
|
skill?: boolean;
|
|
}) {
|
|
const { t } = useTranslation("artifacts");
|
|
const [text, setText] = useState<string | null>(null);
|
|
const [failed, setFailed] = useState(false);
|
|
useEffect(() => {
|
|
let live = true;
|
|
setText(null);
|
|
setFailed(false);
|
|
fetch(url, { credentials: "same-origin" })
|
|
.then((r) => (r.ok ? r.text() : Promise.reject(new Error(String(r.status)))))
|
|
.then((body) => live && setText(body))
|
|
.catch(() => live && setFailed(true));
|
|
return () => {
|
|
live = false;
|
|
};
|
|
}, [url]);
|
|
if (failed) return <p className="p-4 text-sm text-status-danger">{t("previewFailed")}</p>;
|
|
if (text === null) return <p className="p-4 text-sm text-fg-muted">{t("loading")}</p>;
|
|
const shown = text.length > TEXT_MAX ? text.slice(0, TEXT_MAX) : text;
|
|
const front = skill ? /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---/.exec(shown) : null;
|
|
return (
|
|
<div className="h-full overflow-auto" data-testid="artifact-text-preview">
|
|
{skill ? (
|
|
<div className="px-5 py-4">
|
|
<p className="mb-2 text-[11px] font-medium uppercase tracking-wide text-fg-muted">
|
|
{t("preview.instructions")}
|
|
</p>
|
|
<MarkdownPreview text={splitSkillMd(shown).body} />
|
|
{front && (
|
|
<details className="mt-4 rounded-lg border border-border px-3 py-2">
|
|
<summary className="cursor-pointer text-xs text-fg-secondary">
|
|
{t("preview.frontmatter")}
|
|
</summary>
|
|
<pre className="mt-2 whitespace-pre-wrap break-words font-mono text-[11px] text-fg-secondary">
|
|
{front[1]}
|
|
</pre>
|
|
</details>
|
|
)}
|
|
</div>
|
|
) : isMarkdown(path) ? (
|
|
<MarkdownPreview text={shown} className="px-5 py-4" />
|
|
) : (
|
|
<pre className="whitespace-pre-wrap break-words p-4 font-mono text-xs text-fg-primary">
|
|
{shown}
|
|
</pre>
|
|
)}
|
|
{shown !== text && <p className="px-4 pb-4 text-[11px] text-fg-muted">{t("truncated")}</p>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** A skill preview's header: what it installs as, when Claude uses it, its files. */
|
|
function SkillSummary({ item }: { item: Artifact }) {
|
|
const { t } = useTranslation("artifacts");
|
|
return (
|
|
<div
|
|
className="shrink-0 space-y-1.5 border-b border-border px-4 py-3"
|
|
data-testid="skill-summary"
|
|
>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<code className="rounded bg-surface-3 px-1.5 py-0.5 font-mono text-xs text-fg-primary">
|
|
/{item.source}
|
|
</code>
|
|
{item.installed && (
|
|
<span className="text-[11px] text-status-success">{t("installedBadge")}</span>
|
|
)}
|
|
<span className="ml-auto text-[11px] text-fg-muted">
|
|
{t("files", { count: item.files.length })}
|
|
{item.size ? ` · ${fmtBytes(item.size)}` : ""}
|
|
{item.owner && !item.mine ? ` · ${t("by", { name: item.owner })}` : ""}
|
|
{` · ${new Date(item.updatedAt).toLocaleString()}`}
|
|
</span>
|
|
</div>
|
|
<div>
|
|
<p className="text-[11px] font-medium uppercase tracking-wide text-fg-muted">
|
|
{t("preview.whenUsed")}
|
|
</p>
|
|
<p className="text-sm text-fg-primary">
|
|
{item.description || <span className="text-fg-muted">{t("preview.noDescription")}</span>}
|
|
</p>
|
|
</div>
|
|
<p className="font-mono text-[11px] text-fg-muted">
|
|
{t("preview.installsAs", { slug: item.source })}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PreviewDialog({
|
|
item,
|
|
path,
|
|
onClose,
|
|
}: {
|
|
item: Artifact;
|
|
path: string;
|
|
onClose: () => void;
|
|
}) {
|
|
const { t } = useTranslation("artifacts");
|
|
const [current, setCurrent] = useState(path);
|
|
const url = api.artifacts.rawUrl(item.id, current);
|
|
const html = /\.(html?|svg)$/i.test(current);
|
|
return (
|
|
<Modal title={item.title} onClose={onClose} wide flush>
|
|
{item.kind === "skill" && <SkillSummary item={item} />}
|
|
<div className="flex h-[70vh] min-h-0">
|
|
{item.files.length > 1 && (
|
|
<ul className="w-56 shrink-0 space-y-0.5 overflow-auto border-r border-border p-2">
|
|
{item.files.map((f) => (
|
|
<li key={f.path}>
|
|
<button
|
|
type="button"
|
|
onClick={() => setCurrent(f.path)}
|
|
aria-current={f.path === current ? "true" : undefined}
|
|
className={`w-full truncate rounded px-2 py-1 text-left font-mono text-[11px] ${
|
|
f.path === current
|
|
? "bg-surface-3 text-fg-primary"
|
|
: "text-fg-secondary hover:bg-surface-2 hover:text-fg-primary"
|
|
}`}
|
|
title={f.path}
|
|
>
|
|
{f.path}
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
<div className="flex min-w-0 flex-1 flex-col">
|
|
<div className="flex shrink-0 items-center gap-2 border-b border-border px-4 py-2">
|
|
<span className="min-w-0 flex-1 truncate font-mono text-xs text-fg-secondary">
|
|
{current}
|
|
</span>
|
|
<a
|
|
href={api.artifacts.rawUrl(item.id, current, true)}
|
|
className="btn-ghost px-2 py-1 text-xs"
|
|
>
|
|
{t("download")}
|
|
</a>
|
|
</div>
|
|
<div className="min-h-0 flex-1 bg-surface-0">
|
|
{!PREVIEWABLE.test(current) ? (
|
|
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
|
|
<p className="text-sm text-fg-secondary">{t("noPreview")}</p>
|
|
<a
|
|
href={api.artifacts.rawUrl(item.id, current, true)}
|
|
className="btn-primary text-sm"
|
|
>
|
|
{t("download")}
|
|
</a>
|
|
</div>
|
|
) : TEXT.test(current) ? (
|
|
<TextPreview
|
|
url={url}
|
|
path={current}
|
|
skill={item.kind === "skill" && current === "SKILL.md"}
|
|
/>
|
|
) : MEDIA.test(current) ? (
|
|
<div className="flex h-full items-center justify-center p-4">
|
|
{/\.(mp3|wav)$/i.test(current) ? (
|
|
<audio src={url} controls className="w-full max-w-lg" />
|
|
) : (
|
|
<video src={url} controls className="max-h-full max-w-full" />
|
|
)}
|
|
</div>
|
|
) : IMAGE.test(current) && !html ? (
|
|
<img src={url} alt={current} className="h-full w-full object-contain p-4" />
|
|
) : (
|
|
// No allow-* tokens: a shared page renders, nothing in it runs.
|
|
// HTML and SVG draw on white, the colour a page assumes.
|
|
<iframe
|
|
title={current}
|
|
src={url}
|
|
sandbox=""
|
|
className="h-full w-full bg-white"
|
|
data-testid="artifact-preview"
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
function AddDialog({
|
|
kind,
|
|
onClose,
|
|
onAdded,
|
|
}: {
|
|
kind: ArtifactKind;
|
|
onClose: () => void;
|
|
onAdded: () => void;
|
|
}) {
|
|
const { t } = useTranslation("artifacts");
|
|
const [busy, setBusy] = useState(false);
|
|
const [title, setTitle] = useState("");
|
|
const [description, setDescription] = useState("");
|
|
const [url, setUrl] = useState("");
|
|
const [lanes, setLanes] = useState<Lane[]>([]);
|
|
const [laneId, setLaneId] = useState<number | null>(null);
|
|
const [candidates, setCandidates] = useState<ArtifactCandidate[] | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (kind !== "file" && kind !== "skill") return;
|
|
api.lanes
|
|
.list()
|
|
.then((r) => setLanes(r.lanes))
|
|
.catch(() => setLanes([]));
|
|
}, [kind]);
|
|
|
|
useEffect(() => {
|
|
if (kind === "file") {
|
|
setCandidates(null);
|
|
if (laneId)
|
|
api.artifacts
|
|
.candidates(laneId)
|
|
.then((r) => setCandidates(r.items))
|
|
.catch((err) => {
|
|
setCandidates([]);
|
|
toast.error(err);
|
|
});
|
|
}
|
|
}, [kind, laneId]);
|
|
|
|
const run = async (fn: () => Promise<unknown>, close = true) => {
|
|
setBusy(true);
|
|
try {
|
|
await fn();
|
|
toast.success(t("added"));
|
|
onAdded();
|
|
if (close) onClose();
|
|
} catch (err) {
|
|
toast.error(err);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const meta = (
|
|
<>
|
|
<label className="block">
|
|
<span className={fieldLabel}>{t("form.title")}</span>
|
|
<input className="input w-full" value={title} onChange={(e) => setTitle(e.target.value)} />
|
|
</label>
|
|
<label className="block">
|
|
<span className={fieldLabel}>{t("form.description")}</span>
|
|
<textarea
|
|
className="input w-full resize-y"
|
|
rows={3}
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
/>
|
|
</label>
|
|
</>
|
|
);
|
|
const workspacePicker = (
|
|
<select
|
|
className="select w-full"
|
|
aria-label={t("form.workspace")}
|
|
value={laneId ?? ""}
|
|
onChange={(e) => setLaneId(e.target.value ? Number(e.target.value) : null)}
|
|
>
|
|
<option value="">{t("form.pickWorkspace")}</option>
|
|
{lanes.map((l) => (
|
|
<option key={l.id} value={l.id}>
|
|
{l.title || l.cwd}
|
|
</option>
|
|
))}
|
|
</select>
|
|
);
|
|
|
|
return (
|
|
<Modal title={t(`add.${kind}`)} onClose={onClose}>
|
|
<div className="space-y-3" data-testid={`artifacts-add-${kind}`}>
|
|
{kind === "page" && (
|
|
<form
|
|
className="space-y-3"
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
void run(() => api.artifacts.addPage({ url, title, description }));
|
|
}}
|
|
>
|
|
<label className="block">
|
|
<span className={fieldLabel}>{t("form.url")}</span>
|
|
<input
|
|
className="input w-full font-mono text-xs"
|
|
placeholder="https://claude.ai/…"
|
|
value={url}
|
|
autoFocus
|
|
onChange={(e) => setUrl(e.target.value)}
|
|
/>
|
|
</label>
|
|
{meta}
|
|
<div className="flex justify-end gap-2 pt-1">
|
|
<button type="button" className="btn-ghost text-sm" onClick={onClose} disabled={busy}>
|
|
{t("form.cancel")}
|
|
</button>
|
|
<button type="submit" className="btn-primary text-sm" disabled={busy || !url.trim()}>
|
|
{t("form.save")}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
)}
|
|
|
|
{kind === "file" && (
|
|
<>
|
|
<FileUploadPanel onAdded={onAdded} />
|
|
<p className="border-t border-border-light pt-3 text-xs font-medium text-fg-secondary">
|
|
{t("file.fromWorkspace")}
|
|
</p>
|
|
{workspacePicker}
|
|
{laneId && candidates === null && (
|
|
<p className="text-xs text-fg-muted">{t("loading")}</p>
|
|
)}
|
|
{candidates && candidates.length === 0 && (
|
|
<p className="text-xs text-fg-muted">{t("form.noCandidates")}</p>
|
|
)}
|
|
{candidates && candidates.length > 0 && (
|
|
<ul className="max-h-80 space-y-0.5 overflow-auto rounded-lg border border-border p-1">
|
|
{candidates.map((c) => (
|
|
<li
|
|
key={c.path}
|
|
className="flex items-center gap-2 rounded px-1.5 py-1 text-xs hover:bg-surface-2"
|
|
>
|
|
<span
|
|
className="min-w-0 flex-1 truncate font-mono text-fg-secondary"
|
|
title={c.path}
|
|
>
|
|
{c.path}
|
|
</span>
|
|
{!c.exists ? (
|
|
<span className="text-fg-muted">{t("form.missing")}</span>
|
|
) : c.pinned ? (
|
|
<span className="text-fg-muted">{t("form.pinned")}</span>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
className="btn-ghost text-xs"
|
|
disabled={busy}
|
|
onClick={() =>
|
|
void run(async () => {
|
|
await api.artifacts.pinFile({
|
|
laneId: laneId!,
|
|
path: c.path,
|
|
sessionId: c.sessionId,
|
|
});
|
|
setCandidates((list) =>
|
|
(list ?? []).map((x) =>
|
|
x.path === c.path ? { ...x, pinned: true } : x
|
|
)
|
|
);
|
|
}, false)
|
|
}
|
|
>
|
|
{t("form.pin")}
|
|
</button>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{kind === "skill" && <SkillAddPanel lanes={lanes} onAdded={onAdded} onClose={onClose} />}
|
|
|
|
{kind === "plugin" && <PluginAddPanel onAdded={onAdded} onClose={onClose} />}
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|