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}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import { readGatewayAgentSkillsAllowlist } from "@/lib/gateway/agentConfig";
|
||||
import type { GatewayClient, GatewayStatus } from "@/lib/gateway/GatewayClient";
|
||||
import type { OfficeSkillTriggerMovementTarget } from "@/lib/office/places";
|
||||
import {
|
||||
buildAgentSkillsAllowlistSet,
|
||||
deriveAgentSkillsAccessMode,
|
||||
deriveSkillReadinessState,
|
||||
} from "@/lib/skills/presentation";
|
||||
import {
|
||||
listPackagedSkillTriggerDefinitions,
|
||||
resolveTriggeredSkillDefinition,
|
||||
type SkillTriggerDefinition,
|
||||
} from "@/lib/skills/triggers";
|
||||
import { loadAgentSkillStatus } from "@/lib/skills/types";
|
||||
|
||||
const isSkillEnabledForAgent = (params: {
|
||||
allowlist: string[] | undefined;
|
||||
skillName: string;
|
||||
}): boolean => {
|
||||
const accessMode = deriveAgentSkillsAccessMode(params.allowlist);
|
||||
if (accessMode === "all") {
|
||||
return true;
|
||||
}
|
||||
if (accessMode === "none") {
|
||||
return false;
|
||||
}
|
||||
return buildAgentSkillsAllowlistSet(params.allowlist).has(params.skillName.trim());
|
||||
};
|
||||
|
||||
export const useOfficeSkillTriggers = ({
|
||||
client,
|
||||
status,
|
||||
agents,
|
||||
}: {
|
||||
client: GatewayClient;
|
||||
status: GatewayStatus;
|
||||
agents: AgentState[];
|
||||
}) => {
|
||||
const requestIdRef = useRef(0);
|
||||
const [enabledTriggersByAgentId, setEnabledTriggersByAgentId] = useState<
|
||||
Record<string, SkillTriggerDefinition[]>
|
||||
>({});
|
||||
const packagedTriggers = useMemo(() => listPackagedSkillTriggerDefinitions(), []);
|
||||
const agentIdsKey = useMemo(
|
||||
() => agents.map((agent) => agent.agentId).sort().join("|"),
|
||||
[agents],
|
||||
);
|
||||
const stableAgentIds = useMemo(
|
||||
() => (agentIdsKey ? agentIdsKey.split("|").filter((value) => value.length > 0) : []),
|
||||
[agentIdsKey],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== "connected" || agents.length === 0 || packagedTriggers.length === 0) {
|
||||
setEnabledTriggersByAgentId({});
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const load = async () => {
|
||||
const requestId = requestIdRef.current + 1;
|
||||
requestIdRef.current = requestId;
|
||||
try {
|
||||
const triggerBySkillKey = new Map(
|
||||
packagedTriggers.map((trigger) => [trigger.skillKey, trigger]),
|
||||
);
|
||||
const results = await Promise.all(
|
||||
stableAgentIds.map(async (agentId) => {
|
||||
const [report, allowlist] = await Promise.all([
|
||||
loadAgentSkillStatus(client, agentId),
|
||||
readGatewayAgentSkillsAllowlist({ client, agentId }),
|
||||
]);
|
||||
const enabledTriggers = report.skills
|
||||
.filter((skill) => deriveSkillReadinessState(skill) === "ready")
|
||||
.filter((skill) => isSkillEnabledForAgent({ allowlist, skillName: skill.name }))
|
||||
.map((skill) => triggerBySkillKey.get(skill.skillKey))
|
||||
.filter((trigger): trigger is SkillTriggerDefinition => Boolean(trigger));
|
||||
return [agentId, enabledTriggers] as const;
|
||||
}),
|
||||
);
|
||||
|
||||
if (cancelled || requestId !== requestIdRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEnabledTriggersByAgentId(Object.fromEntries(results));
|
||||
} catch {
|
||||
if (cancelled || requestId !== requestIdRef.current) {
|
||||
return;
|
||||
}
|
||||
setEnabledTriggersByAgentId({});
|
||||
}
|
||||
};
|
||||
|
||||
void load();
|
||||
const intervalId = window.setInterval(() => {
|
||||
void load();
|
||||
}, 30_000);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, [agentIdsKey, client, packagedTriggers, stableAgentIds, status]);
|
||||
|
||||
const movementTargetByAgentId = useMemo<Record<string, OfficeSkillTriggerMovementTarget>>(() => {
|
||||
const next: Record<string, OfficeSkillTriggerMovementTarget> = {};
|
||||
for (const agent of agents) {
|
||||
const trigger = resolveTriggeredSkillDefinition({
|
||||
isAgentRunning: agent.status === "running" || Boolean(agent.runId),
|
||||
lastUserMessage: agent.lastUserMessage,
|
||||
transcriptEntries: agent.transcriptEntries,
|
||||
triggers: enabledTriggersByAgentId[agent.agentId] ?? [],
|
||||
});
|
||||
if (trigger) {
|
||||
next[agent.agentId] = trigger.movementTarget;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}, [agents, enabledTriggersByAgentId]);
|
||||
|
||||
return {
|
||||
enabledTriggersByAgentId,
|
||||
movementTargetByAgentId,
|
||||
};
|
||||
};
|
||||
@@ -6,6 +6,12 @@ import type { GatewayClient, GatewayStatus } from "@/lib/gateway/GatewayClient";
|
||||
import { readGatewayAgentSkillsAllowlist } from "@/lib/gateway/agentConfig";
|
||||
import { isGatewayDisconnectLikeError } from "@/lib/gateway/GatewayClient";
|
||||
import { setAgentSkillEnabled } from "@/lib/skills/agentAccess";
|
||||
import {
|
||||
appendPackagedSkillsToMarketplace,
|
||||
getPackagedSkillBySkillKey,
|
||||
listPackagedSkills,
|
||||
} from "@/lib/skills/catalog";
|
||||
import { installPackagedSkillViaGatewayAgent } from "@/lib/skills/install-gateway";
|
||||
import { resolvePreferredInstallOption } from "@/lib/skills/presentation";
|
||||
import { removeSkillFromGateway } from "@/lib/skills/remove";
|
||||
import {
|
||||
@@ -50,11 +56,19 @@ export const useOfficeSkillsMarketplace = ({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busySkillKey, setBusySkillKey] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<MarketplaceMessage | null>(null);
|
||||
const packagedSkillsByKey = useMemo(
|
||||
() => new Map(listPackagedSkills().map((skill) => [skill.skillKey, skill])),
|
||||
[]
|
||||
);
|
||||
|
||||
const selectedAgent = useMemo(
|
||||
() => agents.find((agent) => agent.agentId === selectedAgentId) ?? null,
|
||||
[agents, selectedAgentId],
|
||||
);
|
||||
const marketplaceSkills = useMemo(
|
||||
() => appendPackagedSkillsToMarketplace(skillsReport?.skills ?? []),
|
||||
[skillsReport]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const preferred = (preferredAgentId ?? "").trim();
|
||||
@@ -240,6 +254,36 @@ export const useOfficeSkillsMarketplace = ({
|
||||
[client, runSkillMutation],
|
||||
);
|
||||
|
||||
const handleInstallPackagedSkill = useCallback(
|
||||
async (skillKey: string) => {
|
||||
const packagedSkill = getPackagedSkillBySkillKey(skillKey);
|
||||
if (!packagedSkill) {
|
||||
setMessage({
|
||||
kind: "error",
|
||||
text: `No packaged marketplace skill was found for ${skillKey.trim() || "that entry"}.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await runSkillMutation({
|
||||
skillKey: packagedSkill.skillKey,
|
||||
successMessage: `Successfully installed ${packagedSkill.name.trim()} in the selected workspace. Enable it for the agent from the CLAW3D tab.`,
|
||||
run: async (_agentId, report) => {
|
||||
await installPackagedSkillViaGatewayAgent({
|
||||
client,
|
||||
request: {
|
||||
packageId: packagedSkill.packageId,
|
||||
source: packagedSkill.installSource,
|
||||
workspaceDir: report.workspaceDir,
|
||||
managedSkillsDir: report.managedSkillsDir,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
[client, runSkillMutation]
|
||||
);
|
||||
|
||||
const handleSetSkillGlobalEnabled = useCallback(
|
||||
async (skillKey: string, enabled: boolean) => {
|
||||
await runSkillMutation({
|
||||
@@ -262,6 +306,7 @@ export const useOfficeSkillsMarketplace = ({
|
||||
successMessage: `${skill.name.trim()} removed from gateway files.`,
|
||||
run: async (_agentId, report) => {
|
||||
await removeSkillFromGateway({
|
||||
client,
|
||||
skillKey: skill.skillKey,
|
||||
source: skill.source as
|
||||
| "openclaw-managed"
|
||||
@@ -282,6 +327,8 @@ export const useOfficeSkillsMarketplace = ({
|
||||
selectedAgentId,
|
||||
setSelectedAgentId,
|
||||
skillsReport,
|
||||
marketplaceSkills,
|
||||
packagedSkillsByKey,
|
||||
skillsAllowlist,
|
||||
loading,
|
||||
error,
|
||||
@@ -290,6 +337,7 @@ export const useOfficeSkillsMarketplace = ({
|
||||
refresh,
|
||||
handleSetSkillEnabled,
|
||||
handleInstallSkill,
|
||||
handleInstallPackagedSkill,
|
||||
handleSetSkillGlobalEnabled,
|
||||
handleRemoveSkill,
|
||||
};
|
||||
|
||||
@@ -81,7 +81,8 @@ import { AnalyticsPanel } from "@/features/office/components/panels/AnalyticsPan
|
||||
import { HistoryPanel } from "@/features/office/components/panels/HistoryPanel";
|
||||
import { InboxPanel } from "@/features/office/components/panels/InboxPanel";
|
||||
import { PlaybooksPanel } from "@/features/office/components/panels/PlaybooksPanel";
|
||||
import { SkillsMarketplacePanel } from "@/features/office/components/panels/SkillsMarketplacePanel";
|
||||
import { SkillsMarketplaceModal } from "@/features/office/components/panels/SkillsMarketplaceModal";
|
||||
import { useOfficeSkillTriggers } from "@/features/office/hooks/useOfficeSkillTriggers";
|
||||
import { useOfficeSkillsMarketplace } from "@/features/office/hooks/useOfficeSkillsMarketplace";
|
||||
import { useOfficeStandupController } from "@/features/office/hooks/useOfficeStandupController";
|
||||
import { useRunLog } from "@/features/office/hooks/useRunLog";
|
||||
@@ -106,6 +107,7 @@ import {
|
||||
type OfficePhoneCallRequest,
|
||||
type OfficeTextMessageRequest,
|
||||
} from "@/lib/office/eventTriggers";
|
||||
import { buildOfficeSkillTriggerHoldMaps } from "@/lib/office/places";
|
||||
import type { MockPhoneCallScenario } from "@/lib/office/call/types";
|
||||
import type { MockTextMessageScenario } from "@/lib/office/text/types";
|
||||
import {
|
||||
@@ -759,6 +761,7 @@ export function OfficeScreen({
|
||||
>({});
|
||||
const [gatewayModels, setGatewayModels] = useState<GatewayModelChoice[]>([]);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [marketplaceOpen, setMarketplaceOpen] = useState(false);
|
||||
const [activeSidebarTab, setActiveSidebarTab] =
|
||||
useState<HQSidebarTab>("inbox");
|
||||
const router = useRouter();
|
||||
@@ -1699,22 +1702,53 @@ export function OfficeScreen({
|
||||
onSkillActivityStart: handleMarketplaceGymStart,
|
||||
onSkillActivityEnd: handleMarketplaceGymEnd,
|
||||
});
|
||||
const skillTriggers = useOfficeSkillTriggers({
|
||||
client,
|
||||
status,
|
||||
agents: state.agents,
|
||||
});
|
||||
const animationNowMs = Date.now();
|
||||
const officeAnimationState = useMemo(
|
||||
() =>
|
||||
buildOfficeAnimationState({
|
||||
state: officeTriggerState,
|
||||
agents: state.agents,
|
||||
marketplaceGymHoldByAgentId,
|
||||
nowMs: animationNowMs,
|
||||
}),
|
||||
[
|
||||
animationNowMs,
|
||||
const officeAnimationState = useMemo(() => {
|
||||
const base = buildOfficeAnimationState({
|
||||
state: officeTriggerState,
|
||||
agents: state.agents,
|
||||
marketplaceGymHoldByAgentId,
|
||||
officeTriggerState,
|
||||
state.agents,
|
||||
],
|
||||
);
|
||||
nowMs: animationNowMs,
|
||||
});
|
||||
const skillTriggerHoldMaps = buildOfficeSkillTriggerHoldMaps(
|
||||
skillTriggers.movementTargetByAgentId,
|
||||
);
|
||||
|
||||
return {
|
||||
...base,
|
||||
deskHoldByAgentId: {
|
||||
...base.deskHoldByAgentId,
|
||||
...skillTriggerHoldMaps.deskHoldByAgentId,
|
||||
},
|
||||
githubHoldByAgentId: {
|
||||
...base.githubHoldByAgentId,
|
||||
...skillTriggerHoldMaps.githubHoldByAgentId,
|
||||
},
|
||||
gymHoldByAgentId: {
|
||||
...base.gymHoldByAgentId,
|
||||
...skillTriggerHoldMaps.gymHoldByAgentId,
|
||||
},
|
||||
qaHoldByAgentId: {
|
||||
...base.qaHoldByAgentId,
|
||||
...skillTriggerHoldMaps.qaHoldByAgentId,
|
||||
},
|
||||
skillGymHoldByAgentId: {
|
||||
...base.skillGymHoldByAgentId,
|
||||
...skillTriggerHoldMaps.skillGymHoldByAgentId,
|
||||
},
|
||||
};
|
||||
}, [
|
||||
animationNowMs,
|
||||
marketplaceGymHoldByAgentId,
|
||||
officeTriggerState,
|
||||
skillTriggers.movementTargetByAgentId,
|
||||
state.agents,
|
||||
]);
|
||||
const {
|
||||
deskHoldByAgentId,
|
||||
githubHoldByAgentId,
|
||||
@@ -2851,8 +2885,7 @@ export function OfficeScreen({
|
||||
onPhoneCallComplete={handlePhoneCallComplete}
|
||||
onTextMessageComplete={handleTextMessageComplete}
|
||||
onOpenGithubSkillSetup={() => {
|
||||
setSidebarOpen(true);
|
||||
setActiveSidebarTab("marketplace");
|
||||
setMarketplaceOpen(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -2887,6 +2920,7 @@ export function OfficeScreen({
|
||||
inboxCount={unseenInboxCount}
|
||||
onToggle={() => setSidebarOpen((prev) => !prev)}
|
||||
onTabChange={setActiveSidebarTab}
|
||||
onOpenMarketplace={() => setMarketplaceOpen(true)}
|
||||
inboxPanel={
|
||||
<InboxPanel
|
||||
agents={state.agents}
|
||||
@@ -2914,16 +2948,6 @@ export function OfficeScreen({
|
||||
standup={standupController}
|
||||
/>
|
||||
}
|
||||
marketplacePanel={
|
||||
<SkillsMarketplacePanel
|
||||
marketplace={marketplace}
|
||||
onSelectAgent={handleOpenAgentChat}
|
||||
onOpenAgentSettings={(agentId) => {
|
||||
handleOpenAgentChat(agentId);
|
||||
router.push("/office");
|
||||
}}
|
||||
/>
|
||||
}
|
||||
analyticsPanel={
|
||||
<AnalyticsPanel
|
||||
client={client}
|
||||
@@ -2941,6 +2965,21 @@ export function OfficeScreen({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<SkillsMarketplaceModal
|
||||
open={marketplaceOpen}
|
||||
marketplace={marketplace}
|
||||
onClose={() => setMarketplaceOpen(false)}
|
||||
onSelectAgent={(agentId) => {
|
||||
handleOpenAgentChat(agentId);
|
||||
setMarketplaceOpen(false);
|
||||
}}
|
||||
onOpenAgentSettings={(agentId) => {
|
||||
handleOpenAgentChat(agentId);
|
||||
setMarketplaceOpen(false);
|
||||
router.push("/office");
|
||||
}}
|
||||
/>
|
||||
|
||||
{showOnboardingWizard ? (
|
||||
<OnboardingWizard
|
||||
gatewayConnected={status === "connected"}
|
||||
|
||||
Reference in New Issue
Block a user