Skills (#50)
Co-authored-by: iamlukethedev <iamlukethedev@users.noreply.github.com>
This commit is contained in:
@@ -6,7 +6,6 @@ export type HQSidebarTab =
|
||||
| "inbox"
|
||||
| "history"
|
||||
| "playbooks"
|
||||
| "marketplace"
|
||||
| "analytics";
|
||||
|
||||
type HQSidebarProps = {
|
||||
@@ -15,10 +14,10 @@ type HQSidebarProps = {
|
||||
inboxCount: number;
|
||||
onToggle: () => void;
|
||||
onTabChange: (tab: HQSidebarTab) => void;
|
||||
onOpenMarketplace: () => void;
|
||||
inboxPanel: ReactNode;
|
||||
historyPanel: ReactNode;
|
||||
playbooksPanel: ReactNode;
|
||||
marketplacePanel: ReactNode;
|
||||
analyticsPanel: ReactNode;
|
||||
};
|
||||
|
||||
@@ -26,7 +25,6 @@ const TAB_LABELS: Record<HQSidebarTab, string> = {
|
||||
inbox: "Inbox",
|
||||
history: "History",
|
||||
playbooks: "Playbooks",
|
||||
marketplace: "Marketplace",
|
||||
analytics: "Analytics",
|
||||
};
|
||||
|
||||
@@ -38,15 +36,14 @@ export function HQSidebar({
|
||||
inboxCount,
|
||||
onToggle,
|
||||
onTabChange,
|
||||
onOpenMarketplace,
|
||||
inboxPanel,
|
||||
historyPanel,
|
||||
playbooksPanel,
|
||||
marketplacePanel,
|
||||
analyticsPanel,
|
||||
}: HQSidebarProps) {
|
||||
const analyticsOnly = activeTab === "analytics";
|
||||
const marketplaceOnly = activeTab === "marketplace";
|
||||
const railOnly = analyticsOnly || marketplaceOnly;
|
||||
const railOnly = analyticsOnly;
|
||||
const activePanel =
|
||||
activeTab === "inbox"
|
||||
? inboxPanel
|
||||
@@ -54,9 +51,7 @@ export function HQSidebar({
|
||||
? historyPanel
|
||||
: activeTab === "playbooks"
|
||||
? playbooksPanel
|
||||
: activeTab === "marketplace"
|
||||
? marketplacePanel
|
||||
: analyticsPanel;
|
||||
: analyticsPanel;
|
||||
|
||||
return (
|
||||
<aside className="pointer-events-none fixed inset-y-0 right-0 z-20 flex justify-end">
|
||||
@@ -76,18 +71,10 @@ export function HQSidebar({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onTabChange("marketplace");
|
||||
if (!open) {
|
||||
onToggle();
|
||||
}
|
||||
onOpenMarketplace();
|
||||
}}
|
||||
className={`rounded-l-md border border-r-0 px-1.5 py-2.5 font-mono text-[10px] font-semibold tracking-[0.2em] shadow-xl backdrop-blur transition-colors ${
|
||||
marketplaceOnly
|
||||
? "border-fuchsia-400/50 bg-[#16081b]/95 text-fuchsia-100"
|
||||
: "border-fuchsia-500/25 bg-[#100611]/90 text-fuchsia-300/80 hover:border-fuchsia-400/45 hover:text-fuchsia-100"
|
||||
}`}
|
||||
aria-pressed={marketplaceOnly}
|
||||
aria-label="Open marketplace sidebar"
|
||||
className="rounded-l-md border border-r-0 border-fuchsia-500/25 bg-[#100611]/90 px-1.5 py-2.5 font-mono text-[10px] font-semibold tracking-[0.2em] text-fuchsia-300/80 shadow-xl backdrop-blur transition-colors hover:border-fuchsia-400/45 hover:text-fuchsia-100"
|
||||
aria-label="Open marketplace"
|
||||
>
|
||||
<span className="block leading-none [writing-mode:vertical-rl]">
|
||||
MARKETPLACE
|
||||
@@ -120,14 +107,12 @@ export function HQSidebar({
|
||||
<div className="pointer-events-auto flex h-full w-56 flex-col border-l border-cyan-500/20 bg-black/85 shadow-2xl backdrop-blur">
|
||||
<div className="border-b border-cyan-500/15 px-4 py-3">
|
||||
<div className="font-mono text-[10px] font-semibold tracking-[0.32em] text-cyan-300/80">
|
||||
{analyticsOnly ? "ANALYTICS" : marketplaceOnly ? "MARKETPLACE" : "HEADQUARTERS"}
|
||||
{analyticsOnly ? "ANALYTICS" : "HEADQUARTERS"}
|
||||
</div>
|
||||
<div className="mt-1 font-mono text-[11px] text-white/45">
|
||||
{analyticsOnly
|
||||
? "Cost, budgets, and performance intelligence."
|
||||
: marketplaceOnly
|
||||
? "Discover, install, and enable new skills."
|
||||
: "Monitor outputs, runs, and schedules."}
|
||||
: "Monitor outputs, runs, and schedules."}
|
||||
</div>
|
||||
{railOnly ? (
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import type { OfficeSkillsMarketplaceController } from "@/features/office/hooks/useOfficeSkillsMarketplace";
|
||||
|
||||
import { SkillsMarketplacePanel } from "./SkillsMarketplacePanel";
|
||||
|
||||
type SkillsMarketplaceModalProps = {
|
||||
open: boolean;
|
||||
marketplace: OfficeSkillsMarketplaceController;
|
||||
onClose: () => void;
|
||||
onSelectAgent: (agentId: string) => void;
|
||||
onOpenAgentSettings: (agentId: string) => void;
|
||||
};
|
||||
|
||||
export function SkillsMarketplaceModal({
|
||||
open,
|
||||
marketplace,
|
||||
onClose,
|
||||
onSelectAgent,
|
||||
onOpenAgentSettings,
|
||||
}: SkillsMarketplaceModalProps) {
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [onClose, open]);
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[125] flex items-center justify-center bg-black/80 p-4 backdrop-blur-sm"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Skills marketplace"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="flex h-[min(90vh,960px)] w-full max-w-6xl flex-col overflow-hidden rounded-xl border border-cyan-500/20 bg-[#050607]/95 shadow-2xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 border-b border-cyan-500/10 px-5 py-4">
|
||||
<div>
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.22em] text-cyan-300/80">
|
||||
Skills Marketplace
|
||||
</div>
|
||||
<div className="mt-1 font-mono text-[11px] text-white/45">
|
||||
Discover, install, and enable gateway skills in a wider workspace.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="inline-flex items-center gap-1 rounded border border-white/10 bg-white/5 px-2 py-1.5 font-mono text-[10px] uppercase tracking-[0.14em] text-white/75 transition-colors hover:bg-white/10"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
<SkillsMarketplacePanel
|
||||
marketplace={marketplace}
|
||||
onSelectAgent={onSelectAgent}
|
||||
onOpenAgentSettings={onOpenAgentSettings}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { buildAgentSkillsAllowlistSet, deriveAgentSkillsAccessMode } from "@/lib
|
||||
type MarketplaceFilter = "all" | SkillMarketplaceCollectionId;
|
||||
|
||||
const FILTER_LABELS: Record<MarketplaceFilter, string> = {
|
||||
claw3d: "Claw3D",
|
||||
all: "All",
|
||||
featured: "Featured",
|
||||
installed: "Installed",
|
||||
@@ -99,10 +100,13 @@ export function SkillsMarketplacePanel({
|
||||
onOpenAgentSettings: (agentId: string) => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeFilter, setActiveFilter] = useState<MarketplaceFilter>("all");
|
||||
const [activeFilter, setActiveFilter] = useState<MarketplaceFilter>("claw3d");
|
||||
const [detailSkillKey, setDetailSkillKey] = useState<string | null>(null);
|
||||
|
||||
const entries = useMemo(() => marketplace.skillsReport?.skills ?? [], [marketplace.skillsReport]);
|
||||
const entries = useMemo(
|
||||
() => marketplace.marketplaceSkills ?? marketplace.skillsReport?.skills ?? [],
|
||||
[marketplace.marketplaceSkills, marketplace.skillsReport]
|
||||
);
|
||||
const collections = useMemo(() => buildSkillMarketplaceCollections(entries), [entries]);
|
||||
const accessMode = useMemo(
|
||||
() => deriveAgentSkillsAccessMode(marketplace.skillsAllowlist),
|
||||
@@ -117,7 +121,7 @@ export function SkillsMarketplacePanel({
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const visibleCollectionIds: SkillMarketplaceCollectionId[] =
|
||||
activeFilter === "all"
|
||||
? ["built-in", "installed", "workspace", "extra", "other"]
|
||||
? ["claw3d", "built-in", "installed", "workspace", "extra", "other"]
|
||||
: [activeFilter];
|
||||
return collections
|
||||
.filter((collection) => visibleCollectionIds.includes(collection.id))
|
||||
@@ -151,6 +155,7 @@ export function SkillsMarketplacePanel({
|
||||
|
||||
const filterCounts = useMemo(() => {
|
||||
const counts: Record<MarketplaceFilter, number> = {
|
||||
claw3d: 0,
|
||||
all: entries.length,
|
||||
featured: 0,
|
||||
installed: 0,
|
||||
@@ -196,8 +201,8 @@ export function SkillsMarketplacePanel({
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-3">
|
||||
<div className="rounded border border-amber-500/20 bg-amber-500/10 px-3 py-2 font-mono text-[10px] text-amber-100">
|
||||
Install and global setup actions affect the whole gateway. Agent access controls below apply only
|
||||
to the selected agent.
|
||||
Packaged skill installs target the selected agent workspace. Global setup actions still affect
|
||||
the whole gateway. Agent access controls below apply only to the selected agent.
|
||||
</div>
|
||||
|
||||
<div className="mt-3 rounded border border-cyan-500/15 bg-white/[0.03] px-3 py-3">
|
||||
@@ -291,6 +296,11 @@ export function SkillsMarketplacePanel({
|
||||
}`}
|
||||
>
|
||||
{marketplace.message.text}
|
||||
{marketplace.message.kind === "success" ? (
|
||||
<div className="mt-1 font-mono text-[10px] text-emerald-100/80">
|
||||
Check the `CLAW3D` filter below to find the installed skill quickly.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -327,14 +337,32 @@ export function SkillsMarketplacePanel({
|
||||
{entry.metadata.editorBadge ?? "Featured"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-3 font-mono text-[10px] text-white/55">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Star className="h-3 w-3 text-amber-300" />
|
||||
{formatRating(entry.metadata.rating)}
|
||||
</span>
|
||||
<span>{formatInstalls(entry.metadata.installs)} installs</span>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3 font-mono text-[10px] text-white/55">
|
||||
{!entry.metadata.hideStats ? (
|
||||
<>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Star className="h-3 w-3 text-amber-300" />
|
||||
{formatRating(entry.metadata.rating)}
|
||||
</span>
|
||||
<span>{formatInstalls(entry.metadata.installs)} installs</span>
|
||||
</>
|
||||
) : null}
|
||||
<span>{entry.metadata.category}</span>
|
||||
</div>
|
||||
{entry.metadata.poweredByName && entry.metadata.poweredByUrl ? (
|
||||
<div className="mt-2 font-mono text-[10px] text-white/55">
|
||||
Powered by{" "}
|
||||
<a
|
||||
href={entry.metadata.poweredByUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-cyan-200 underline decoration-cyan-500/40 underline-offset-2 transition-colors hover:text-cyan-100"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{entry.metadata.poweredByName}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -355,9 +383,17 @@ export function SkillsMarketplacePanel({
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{collection.entries.map((entry) => {
|
||||
const packagedSkill = marketplace.packagedSkillsByKey.get(entry.skill.skillKey);
|
||||
const packageOnly = Boolean(packagedSkill && !entry.skill.baseDir.trim());
|
||||
const isEnabledForAgent = getAgentSkillEnabled(entry.skill.name, accessMode, allowlistSet);
|
||||
const primaryAction =
|
||||
entry.readiness === "needs-setup" && entry.installable
|
||||
packageOnly
|
||||
? {
|
||||
label: "Install skill",
|
||||
run: () => void marketplace.handleInstallPackagedSkill(entry.skill.skillKey),
|
||||
icon: Download,
|
||||
}
|
||||
: entry.readiness === "needs-setup" && entry.installable
|
||||
? {
|
||||
label: "Install deps",
|
||||
run: () => void marketplace.handleInstallSkill(entry.skill),
|
||||
@@ -411,13 +447,30 @@ export function SkillsMarketplacePanel({
|
||||
<Shield className="h-3 w-3 text-cyan-300" />
|
||||
{entry.metadata.trustLabel}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Star className="h-3 w-3 text-amber-300" />
|
||||
{formatRating(entry.metadata.rating)}
|
||||
</span>
|
||||
<span>{formatInstalls(entry.metadata.installs)} installs</span>
|
||||
{!entry.metadata.hideStats ? (
|
||||
<>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Star className="h-3 w-3 text-amber-300" />
|
||||
{formatRating(entry.metadata.rating)}
|
||||
</span>
|
||||
<span>{formatInstalls(entry.metadata.installs)} installs</span>
|
||||
</>
|
||||
) : null}
|
||||
<span>{entry.skill.source}</span>
|
||||
</div>
|
||||
{entry.metadata.poweredByName && entry.metadata.poweredByUrl ? (
|
||||
<div className="mt-2 font-mono text-[10px] text-white/55">
|
||||
Powered by{" "}
|
||||
<a
|
||||
href={entry.metadata.poweredByUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-cyan-200 underline decoration-cyan-500/40 underline-offset-2 transition-colors hover:text-cyan-100"
|
||||
>
|
||||
{entry.metadata.poweredByName}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
{entry.missingDetails.length > 0 ? (
|
||||
<div className="mt-2 font-mono text-[10px] text-amber-100/85">
|
||||
{entry.missingDetails[0]}
|
||||
@@ -430,6 +483,7 @@ export function SkillsMarketplacePanel({
|
||||
type="button"
|
||||
onClick={() => void marketplace.handleSetSkillEnabled(entry.skill.name, !isEnabledForAgent)}
|
||||
disabled={
|
||||
packageOnly ||
|
||||
entry.readiness === "unavailable" ||
|
||||
!marketplace.selectedAgentId ||
|
||||
marketplace.busySkillKey === entry.skill.skillKey
|
||||
@@ -440,7 +494,7 @@ export function SkillsMarketplacePanel({
|
||||
: "border-white/10 bg-white/5 text-white/75 hover:bg-white/10"
|
||||
}`}
|
||||
>
|
||||
{isEnabledForAgent ? "Enabled for agent" : "Enable for agent"}
|
||||
{isEnabledForAgent ? "Disable for agent" : "Enable for agent"}
|
||||
</button>
|
||||
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
@@ -464,7 +518,7 @@ export function SkillsMarketplacePanel({
|
||||
className="inline-flex items-center gap-1 rounded border border-rose-500/25 bg-rose-500/10 px-2 py-1 font-mono text-[10px] uppercase tracking-[0.14em] text-rose-100 transition-colors hover:border-rose-400/40 disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Remove
|
||||
Remove for all agents
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
@@ -478,6 +532,16 @@ export function SkillsMarketplacePanel({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center justify-between gap-2 font-mono text-[10px] text-white/35">
|
||||
<div>
|
||||
{isEnabledForAgent
|
||||
? "This skill is currently enabled for the selected agent."
|
||||
: "This skill is currently disabled for the selected agent."}
|
||||
</div>
|
||||
{entry.removable ? (
|
||||
<div>Removing from the gateway deletes the installed skill for every agent.</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -523,15 +587,36 @@ export function SkillsMarketplacePanel({
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 font-mono text-[11px] text-white/75">{detailEntry.metadata.tagline}</div>
|
||||
<div className="mt-3 grid grid-cols-3 gap-2 font-mono text-[10px] text-white/55">
|
||||
<div className="rounded border border-white/8 bg-black/30 px-2 py-2">
|
||||
<div className="text-white/35">Rating</div>
|
||||
<div className="mt-1 text-white/90">{formatRating(detailEntry.metadata.rating)}</div>
|
||||
</div>
|
||||
<div className="rounded border border-white/8 bg-black/30 px-2 py-2">
|
||||
<div className="text-white/35">Installs</div>
|
||||
<div className="mt-1 text-white/90">{formatInstalls(detailEntry.metadata.installs)}</div>
|
||||
{detailEntry.metadata.poweredByName && detailEntry.metadata.poweredByUrl ? (
|
||||
<div className="mt-3 font-mono text-[10px] text-white/60">
|
||||
Powered by{" "}
|
||||
<a
|
||||
href={detailEntry.metadata.poweredByUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-cyan-200 underline decoration-cyan-500/40 underline-offset-2 transition-colors hover:text-cyan-100"
|
||||
>
|
||||
{detailEntry.metadata.poweredByName}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className={`mt-3 grid gap-2 font-mono text-[10px] text-white/55 ${
|
||||
detailEntry.metadata.hideStats ? "grid-cols-1" : "grid-cols-3"
|
||||
}`}
|
||||
>
|
||||
{!detailEntry.metadata.hideStats ? (
|
||||
<>
|
||||
<div className="rounded border border-white/8 bg-black/30 px-2 py-2">
|
||||
<div className="text-white/35">Rating</div>
|
||||
<div className="mt-1 text-white/90">{formatRating(detailEntry.metadata.rating)}</div>
|
||||
</div>
|
||||
<div className="rounded border border-white/8 bg-black/30 px-2 py-2">
|
||||
<div className="text-white/35">Installs</div>
|
||||
<div className="mt-1 text-white/90">{formatInstalls(detailEntry.metadata.installs)}</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<div className="rounded border border-white/8 bg-black/30 px-2 py-2">
|
||||
<div className="text-white/35">Source</div>
|
||||
<div className="mt-1 text-white/90">{detailEntry.skill.source}</div>
|
||||
@@ -574,11 +659,23 @@ export function SkillsMarketplacePanel({
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 rounded border border-cyan-500/15 bg-cyan-500/10 px-3 py-3 font-mono text-[10px] text-cyan-100">
|
||||
Gateway setup changes apply to every agent. Agent enablement still depends on the selected
|
||||
agent's allowlist.
|
||||
Packaged installs land in the selected workspace. Gateway setup changes still apply to every
|
||||
agent, and agent enablement depends on the selected agent's allowlist.
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{marketplace.packagedSkillsByKey.get(detailEntry.skill.skillKey) &&
|
||||
!detailEntry.skill.baseDir.trim() ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void marketplace.handleInstallPackagedSkill(detailEntry.skill.skillKey)}
|
||||
disabled={marketplace.busySkillKey === detailEntry.skill.skillKey}
|
||||
className="inline-flex items-center gap-1 rounded border border-cyan-500/25 bg-cyan-500/10 px-2 py-1.5 font-mono text-[10px] uppercase tracking-[0.14em] text-cyan-100 transition-colors hover:border-cyan-400/40 disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
Install skill
|
||||
</button>
|
||||
) : null}
|
||||
{detailEntry.readiness === "needs-setup" && detailEntry.installable ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -628,6 +725,10 @@ export function SkillsMarketplacePanel({
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-4 rounded border border-white/8 bg-white/[0.03] px-3 py-3 font-mono text-[10px] text-white/60">
|
||||
`Enable/Disable for agent` only changes access for the selected agent. `Remove for all agents`
|
||||
deletes the installed skill from the gateway workspace.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
Reference in New Issue
Block a user