First Release of Claw3D (#11)
Co-authored-by: iamlukethedev <iamlukethedev@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type HQSidebarTab =
|
||||
| "inbox"
|
||||
| "history"
|
||||
| "playbooks"
|
||||
| "marketplace"
|
||||
| "analytics";
|
||||
|
||||
type HQSidebarProps = {
|
||||
open: boolean;
|
||||
activeTab: HQSidebarTab;
|
||||
inboxCount: number;
|
||||
onToggle: () => void;
|
||||
onTabChange: (tab: HQSidebarTab) => void;
|
||||
inboxPanel: ReactNode;
|
||||
historyPanel: ReactNode;
|
||||
playbooksPanel: ReactNode;
|
||||
marketplacePanel: ReactNode;
|
||||
analyticsPanel: ReactNode;
|
||||
};
|
||||
|
||||
const TAB_LABELS: Record<HQSidebarTab, string> = {
|
||||
inbox: "Inbox",
|
||||
history: "History",
|
||||
playbooks: "Playbooks",
|
||||
marketplace: "Marketplace",
|
||||
analytics: "Analytics",
|
||||
};
|
||||
|
||||
const PRIMARY_TABS: HQSidebarTab[] = ["inbox", "history", "playbooks"];
|
||||
|
||||
export function HQSidebar({
|
||||
open,
|
||||
activeTab,
|
||||
inboxCount,
|
||||
onToggle,
|
||||
onTabChange,
|
||||
inboxPanel,
|
||||
historyPanel,
|
||||
playbooksPanel,
|
||||
marketplacePanel,
|
||||
analyticsPanel,
|
||||
}: HQSidebarProps) {
|
||||
const analyticsOnly = activeTab === "analytics";
|
||||
const marketplaceOnly = activeTab === "marketplace";
|
||||
const railOnly = analyticsOnly || marketplaceOnly;
|
||||
const activePanel =
|
||||
activeTab === "inbox"
|
||||
? inboxPanel
|
||||
: activeTab === "history"
|
||||
? historyPanel
|
||||
: activeTab === "playbooks"
|
||||
? playbooksPanel
|
||||
: activeTab === "marketplace"
|
||||
? marketplacePanel
|
||||
: analyticsPanel;
|
||||
|
||||
return (
|
||||
<aside className="pointer-events-none fixed inset-y-0 right-0 z-20 flex justify-end">
|
||||
<div className="pointer-events-auto mt-14 flex shrink-0 flex-col items-end gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="rounded-l-md border border-r-0 border-cyan-500/30 bg-[#06090d]/90 px-1.5 py-2.5 font-mono text-[10px] font-semibold tracking-[0.2em] text-cyan-300 shadow-xl backdrop-blur transition-colors hover:border-cyan-400/50 hover:text-cyan-100"
|
||||
aria-expanded={open}
|
||||
aria-label={open ? "Collapse headquarters sidebar" : "Open headquarters sidebar"}
|
||||
>
|
||||
<span className="block leading-none [writing-mode:vertical-rl]">
|
||||
{open ? "COLLAPSE HQ" : "OPEN HQ"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onTabChange("marketplace");
|
||||
if (!open) {
|
||||
onToggle();
|
||||
}
|
||||
}}
|
||||
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"
|
||||
>
|
||||
<span className="block leading-none [writing-mode:vertical-rl]">
|
||||
MARKETPLACE
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onTabChange("analytics");
|
||||
if (!open) {
|
||||
onToggle();
|
||||
}
|
||||
}}
|
||||
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 ${
|
||||
analyticsOnly
|
||||
? "border-amber-400/50 bg-[#1a1206]/95 text-amber-200"
|
||||
: "border-amber-500/25 bg-[#120d06]/90 text-amber-300/80 hover:border-amber-400/45 hover:text-amber-100"
|
||||
}`}
|
||||
aria-pressed={analyticsOnly}
|
||||
aria-label="Open analytics sidebar"
|
||||
>
|
||||
<span className="block leading-none [writing-mode:vertical-rl]">
|
||||
ANALYTICS
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{open ? (
|
||||
<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"}
|
||||
</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."}
|
||||
</div>
|
||||
{railOnly ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTabChange("inbox")}
|
||||
className="mt-3 rounded border border-cyan-500/20 bg-cyan-500/10 px-2 py-1 font-mono text-[10px] uppercase tracking-[0.16em] text-cyan-200 transition-colors hover:border-cyan-400/40 hover:text-cyan-100"
|
||||
>
|
||||
Back To HQ
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!railOnly ? (
|
||||
<div className="grid grid-cols-3 border-b border-cyan-500/15">
|
||||
{PRIMARY_TABS.map((tab) => {
|
||||
const isActive = tab === activeTab;
|
||||
const showBadge = tab === "inbox" && inboxCount > 0;
|
||||
return (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
onClick={() => onTabChange(tab)}
|
||||
className={`flex items-center justify-center gap-1 border-r border-cyan-500/10 px-2 py-2.5 font-mono text-[11px] uppercase tracking-[0.18em] transition-colors last:border-r-0 ${
|
||||
isActive
|
||||
? "bg-cyan-500/10 text-cyan-100"
|
||||
: "text-white/45 hover:bg-white/5 hover:text-white/80"
|
||||
}`}
|
||||
>
|
||||
<span>{TAB_LABELS[tab]}</span>
|
||||
{showBadge ? (
|
||||
<span className="rounded bg-cyan-500/15 px-1.5 py-0.5 text-[10px] text-cyan-300">
|
||||
{inboxCount}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden">{activePanel}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { OfficePhaserCanvas } from "@/features/office/components/OfficePhaserCanvas";
|
||||
import { useOfficeBuilderStore } from "@/features/office/state/useOfficeBuilderStore";
|
||||
import type { OfficeMap } from "@/lib/office/schema";
|
||||
|
||||
type OfficeBuilderPanelProps = {
|
||||
initialMap: OfficeMap;
|
||||
workspaceId: string;
|
||||
officeId: string;
|
||||
};
|
||||
|
||||
const nextId = (prefix: string) =>
|
||||
`${prefix}_${Math.random().toString(36).slice(2, 8)}_${Date.now().toString(36)}`;
|
||||
|
||||
export function OfficeBuilderPanel({ initialMap, workspaceId, officeId }: OfficeBuilderPanelProps) {
|
||||
const store = useOfficeBuilderStore(initialMap);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [message, setMessage] = useState<string>("");
|
||||
const [showDebug, setShowDebug] = useState(true);
|
||||
const [lightingEnabled, setLightingEnabled] = useState(true);
|
||||
const [ambienceEnabled, setAmbienceEnabled] = useState(true);
|
||||
const [thoughtEnabled, setThoughtEnabled] = useState(true);
|
||||
|
||||
const saveVersion = async () => {
|
||||
const versionId = `v${Date.now()}`;
|
||||
const response = await fetch("/api/office", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
action: "saveVersion",
|
||||
workspaceId,
|
||||
officeId,
|
||||
versionId,
|
||||
createdBy: "studio",
|
||||
notes: "builder save",
|
||||
map: store.map,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
setMessage("save failed");
|
||||
return;
|
||||
}
|
||||
setMessage(`saved ${versionId}`);
|
||||
};
|
||||
|
||||
const publishLatest = async () => {
|
||||
const response = await fetch("/api/office/publish", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
workspaceId,
|
||||
officeId,
|
||||
publishedBy: "studio",
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
setMessage("publish failed");
|
||||
return;
|
||||
}
|
||||
setMessage("published");
|
||||
};
|
||||
|
||||
const debug = useMemo(
|
||||
() => ({
|
||||
showZones: showDebug,
|
||||
showAnchors: showDebug,
|
||||
showEmitterBounds: showDebug,
|
||||
showLightBounds: showDebug,
|
||||
showMetrics: true,
|
||||
}),
|
||||
[showDebug]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full gap-3">
|
||||
<aside className="ui-panel w-72 shrink-0 overflow-y-auto p-3">
|
||||
<div className="font-mono text-[11px] text-muted-foreground">builder controls</div>
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
<button type="button" className="ui-btn-secondary px-2 py-1 text-left text-xs" onClick={store.undo}>
|
||||
undo
|
||||
</button>
|
||||
<button type="button" className="ui-btn-secondary px-2 py-1 text-left text-xs" onClick={store.redo}>
|
||||
redo
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ui-btn-secondary px-2 py-1 text-left text-xs"
|
||||
onClick={() => store.rotateSelected(90)}
|
||||
>
|
||||
rotate selected
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ui-btn-secondary px-2 py-1 text-left text-xs"
|
||||
onClick={() => store.flipSelected("x")}
|
||||
>
|
||||
flip selected x
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ui-btn-secondary px-2 py-1 text-left text-xs"
|
||||
onClick={() => store.flipSelected("y")}
|
||||
>
|
||||
flip selected y
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ui-btn-secondary px-2 py-1 text-left text-xs"
|
||||
onClick={() =>
|
||||
store.addLight({
|
||||
id: nextId("light"),
|
||||
preset: "ceiling_lamp",
|
||||
animationPreset: "soft_flicker",
|
||||
x: 220,
|
||||
y: 180,
|
||||
radius: 120,
|
||||
baseIntensity: 0.45,
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
add light
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ui-btn-secondary px-2 py-1 text-left text-xs"
|
||||
onClick={() =>
|
||||
store.addEmitter({
|
||||
id: nextId("emitter"),
|
||||
preset: "coffee_steam",
|
||||
zoneId: store.map.zones[0]?.id ?? "",
|
||||
enabled: true,
|
||||
maxParticles: 12,
|
||||
spawnRate: 0.15,
|
||||
})
|
||||
}
|
||||
>
|
||||
add emitter
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ui-btn-secondary px-2 py-1 text-left text-xs"
|
||||
onClick={() =>
|
||||
store.addInteractionPoint({
|
||||
id: nextId("interaction"),
|
||||
kind: "tv_watch",
|
||||
x: 260,
|
||||
y: 220,
|
||||
tags: [],
|
||||
})
|
||||
}
|
||||
>
|
||||
add interaction point
|
||||
</button>
|
||||
<button type="button" className="ui-btn-primary px-2 py-1 text-left text-xs" onClick={saveVersion}>
|
||||
save version
|
||||
</button>
|
||||
<button type="button" className="ui-btn-primary px-2 py-1 text-left text-xs" onClick={publishLatest}>
|
||||
publish active
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-4 border-t border-border/50 pt-3">
|
||||
<div className="font-mono text-[11px] text-muted-foreground">simulation toggles</div>
|
||||
<div className="mt-2 flex flex-col gap-2 text-xs">
|
||||
<label className="flex items-center justify-between">
|
||||
<span>debug</span>
|
||||
<input type="checkbox" checked={showDebug} onChange={(event) => setShowDebug(event.target.checked)} />
|
||||
</label>
|
||||
<label className="flex items-center justify-between">
|
||||
<span>lighting</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={lightingEnabled}
|
||||
onChange={(event) => setLightingEnabled(event.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center justify-between">
|
||||
<span>ambience</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={ambienceEnabled}
|
||||
onChange={(event) => setAmbienceEnabled(event.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center justify-between">
|
||||
<span>thought bubbles</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={thoughtEnabled}
|
||||
onChange={(event) => setThoughtEnabled(event.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 text-xs text-muted-foreground">selected {selectedIds.length}</div>
|
||||
{message ? <div className="mt-2 text-xs text-muted-foreground">{message}</div> : null}
|
||||
</aside>
|
||||
<div className="ui-panel min-h-0 flex-1 overflow-hidden p-2">
|
||||
<OfficePhaserCanvas
|
||||
mode="builder"
|
||||
map={store.map}
|
||||
presence={[]}
|
||||
debug={debug}
|
||||
runtime={{
|
||||
enableLighting: lightingEnabled,
|
||||
enableAmbience: ambienceEnabled,
|
||||
enableThoughtBubbles: thoughtEnabled,
|
||||
}}
|
||||
onObjectMoved={(id, x, y) => {
|
||||
store.moveObject(id, x, y);
|
||||
}}
|
||||
onSelectionChange={(ids) => {
|
||||
store.select(ids);
|
||||
setSelectedIds(ids);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import {
|
||||
createOfficeSceneBridge,
|
||||
type OfficeDebugSettings,
|
||||
type OfficeRuntimeSettings,
|
||||
} from "@/features/office/phaser/OfficeSceneBridge";
|
||||
import { createOfficeBuilderScene } from "@/features/office/phaser/OfficeBuilderScene";
|
||||
import { createOfficeViewerScene } from "@/features/office/phaser/OfficeViewerScene";
|
||||
import type { OfficeAgentPresence } from "@/lib/office/presence";
|
||||
import type { OfficeMap } from "@/lib/office/schema";
|
||||
|
||||
type OfficePhaserCanvasProps = {
|
||||
mode: "viewer" | "builder";
|
||||
map: OfficeMap;
|
||||
presence: OfficeAgentPresence[];
|
||||
debug: OfficeDebugSettings;
|
||||
runtime: OfficeRuntimeSettings;
|
||||
onObjectMoved?: (id: string, x: number, y: number) => void;
|
||||
onSelectionChange?: (ids: string[]) => void;
|
||||
};
|
||||
|
||||
export function OfficePhaserCanvas(props: OfficePhaserCanvasProps) {
|
||||
const { debug, map, mode, onObjectMoved, onSelectionChange, presence, runtime } = props;
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
const gameRef = useRef<import("phaser").Game | null>(null);
|
||||
const bridgeRef = useRef<ReturnType<typeof createOfficeSceneBridge> | null>(null);
|
||||
if (!bridgeRef.current) {
|
||||
bridgeRef.current = createOfficeSceneBridge({
|
||||
map,
|
||||
presence,
|
||||
debug,
|
||||
runtime,
|
||||
});
|
||||
}
|
||||
const bridge = bridgeRef.current;
|
||||
|
||||
useEffect(() => {
|
||||
bridge.setState({
|
||||
map,
|
||||
presence,
|
||||
debug,
|
||||
runtime,
|
||||
});
|
||||
}, [bridge, debug, map, presence, runtime]);
|
||||
|
||||
useEffect(() => {
|
||||
let canceled = false;
|
||||
const setup = async () => {
|
||||
if (!rootRef.current) return;
|
||||
const PhaserLib = await import("phaser");
|
||||
if (canceled || !rootRef.current) return;
|
||||
const scene =
|
||||
mode === "builder"
|
||||
? createOfficeBuilderScene({
|
||||
PhaserLib,
|
||||
bridge,
|
||||
onObjectMoved,
|
||||
onSelectionChange,
|
||||
})
|
||||
: createOfficeViewerScene({ PhaserLib, bridge });
|
||||
const game = new PhaserLib.Game({
|
||||
type: PhaserLib.AUTO,
|
||||
parent: rootRef.current,
|
||||
backgroundColor: "transparent",
|
||||
width: map.canvas.width,
|
||||
height: map.canvas.height,
|
||||
scene: [scene],
|
||||
render: {
|
||||
antialias: true,
|
||||
pixelArt: false,
|
||||
},
|
||||
scale: {
|
||||
mode: PhaserLib.Scale.RESIZE,
|
||||
autoCenter: PhaserLib.Scale.CENTER_BOTH,
|
||||
},
|
||||
});
|
||||
gameRef.current = game;
|
||||
};
|
||||
void setup();
|
||||
return () => {
|
||||
canceled = true;
|
||||
gameRef.current?.destroy(true);
|
||||
gameRef.current = null;
|
||||
};
|
||||
}, [bridge, map.canvas.height, map.canvas.width, mode, onObjectMoved, onSelectionChange]);
|
||||
|
||||
return <div className="h-full w-full overflow-hidden rounded-lg" ref={rootRef} />;
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useRef } from "react";
|
||||
|
||||
import { CalendarDays } from "lucide-react";
|
||||
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import { useApprovalMetrics } from "@/features/office/hooks/useApprovalMetrics";
|
||||
import { useOfficeUsageAnalyticsViewModel } from "@/features/office/hooks/useOfficeUsageAnalyticsViewModel";
|
||||
import { usePerformanceAnalytics } from "@/features/office/hooks/usePerformanceAnalytics";
|
||||
import type { RunRecord } from "@/features/office/hooks/useRunLog";
|
||||
import type { GatewayClient, GatewayStatus } from "@/lib/gateway/GatewayClient";
|
||||
import {
|
||||
formatCurrency,
|
||||
formatNumber,
|
||||
} from "@/lib/office/usageAnalyticsPresentation";
|
||||
import type { StudioSettingsCoordinator } from "@/lib/studio/coordinator";
|
||||
|
||||
const formatPercent = (value: number | null | undefined) => {
|
||||
if (value === null || value === undefined) return "n/a";
|
||||
return `${Math.round(value * 100)}%`;
|
||||
};
|
||||
|
||||
const formatDuration = (valueMs: number | null | undefined) => {
|
||||
if (!valueMs) return "n/a";
|
||||
const seconds = Math.round(valueMs / 1000);
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
if (minutes < 60) {
|
||||
return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`;
|
||||
}
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainingMinutes = minutes % 60;
|
||||
return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
|
||||
};
|
||||
|
||||
const formatBudgetInput = (value: number | null) => (value === null ? "" : String(value));
|
||||
|
||||
const parseBudgetInput = (value: string): number | null => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
const parsed = Number(trimmed);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) return null;
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const StatCard = ({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
hint: string;
|
||||
}) => (
|
||||
<div className="rounded border border-white/8 bg-white/[0.03] px-3 py-3">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-white/35">{label}</div>
|
||||
<div className="mt-2 font-mono text-[18px] font-semibold text-white/90">{value}</div>
|
||||
<div className="mt-1 font-mono text-[10px] text-white/35">{hint}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const openNativeDatePicker = (input: HTMLInputElement | null) => {
|
||||
if (!input) return;
|
||||
if (typeof input.showPicker === "function") {
|
||||
input.showPicker();
|
||||
return;
|
||||
}
|
||||
input.focus();
|
||||
};
|
||||
|
||||
const DatePickerField = ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) => {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
return (
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.16em] text-white/35">
|
||||
{label}
|
||||
</span>
|
||||
<div className="relative">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="date"
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onFocus={() => openNativeDatePicker(inputRef.current)}
|
||||
className="w-full rounded border border-white/10 bg-black/50 px-2 py-2 pr-9 font-mono text-[11px] text-white/80 outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openNativeDatePicker(inputRef.current)}
|
||||
className="absolute inset-y-0 right-0 flex w-8 items-center justify-center text-white/40 transition-colors hover:text-cyan-200"
|
||||
aria-label={`Open ${label.toLowerCase()} calendar`}
|
||||
>
|
||||
<CalendarDays className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
export function AnalyticsPanel({
|
||||
client,
|
||||
status,
|
||||
agents,
|
||||
runLog,
|
||||
gatewayUrl,
|
||||
settingsCoordinator,
|
||||
onSelectAgent,
|
||||
}: {
|
||||
client: GatewayClient;
|
||||
status: GatewayStatus;
|
||||
agents: AgentState[];
|
||||
runLog: RunRecord[];
|
||||
gatewayUrl: string;
|
||||
settingsCoordinator: StudioSettingsCoordinator;
|
||||
onSelectAgent: (agentId: string) => void;
|
||||
}) {
|
||||
const {
|
||||
startDate,
|
||||
setStartDate,
|
||||
endDate,
|
||||
setEndDate,
|
||||
budgets,
|
||||
settingsLoaded,
|
||||
usage,
|
||||
updateBudget,
|
||||
} = useOfficeUsageAnalyticsViewModel({
|
||||
client,
|
||||
status,
|
||||
agents,
|
||||
gatewayUrl,
|
||||
settingsCoordinator,
|
||||
});
|
||||
|
||||
const approvalMetrics = useApprovalMetrics({ client, status, agents });
|
||||
const performance = usePerformanceAnalytics({
|
||||
agents,
|
||||
runLog,
|
||||
approvalByAgent: approvalMetrics.byAgent,
|
||||
});
|
||||
|
||||
const dailyChartMax = useMemo(() => {
|
||||
return usage.costDaily.reduce((max, entry) => Math.max(max, entry.totalCost), 0);
|
||||
}, [usage.costDaily]);
|
||||
|
||||
const alertBannerClass =
|
||||
usage.budgetAlerts.some((alert) => alert.severity === "danger")
|
||||
? "border-rose-500/30 bg-rose-500/10 text-rose-100"
|
||||
: "border-amber-500/30 bg-amber-500/10 text-amber-100";
|
||||
|
||||
return (
|
||||
<section className="flex h-full min-h-0 flex-col">
|
||||
<div className="border-b border-cyan-500/10 px-4 py-3">
|
||||
<div className="font-mono text-[11px] uppercase tracking-[0.22em] text-white/70">
|
||||
Analytics
|
||||
</div>
|
||||
<div className="mt-1 font-mono text-[11px] text-white/40">
|
||||
Real usage, spend, and agent trust metrics for headquarters.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<DatePickerField label="Start" value={startDate} onChange={setStartDate} />
|
||||
<DatePickerField label="End" value={endDate} onChange={setEndDate} />
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center justify-between gap-2">
|
||||
<div className="font-mono text-[10px] text-white/35">
|
||||
{usage.lastRefreshedAt
|
||||
? `Last refresh ${new Date(usage.lastRefreshedAt).toLocaleTimeString()}`
|
||||
: "No analytics snapshot yet"}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void usage.refresh()}
|
||||
className="rounded border border-cyan-500/20 bg-cyan-500/10 px-2 py-1 font-mono text-[10px] uppercase tracking-[0.16em] text-cyan-200 transition-colors hover:border-cyan-400/40 hover:text-cyan-100"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{usage.error ? (
|
||||
<div className="mt-3 rounded border border-rose-500/30 bg-rose-500/10 px-3 py-2 font-mono text-[11px] text-rose-100">
|
||||
{usage.error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{usage.budgetAlerts.length > 0 ? (
|
||||
<div className={`mt-3 rounded border px-3 py-2 font-mono text-[11px] ${alertBannerClass}`}>
|
||||
{usage.budgetAlerts.map((alert) => (
|
||||
<div key={alert.key}>
|
||||
{alert.label}: {formatCurrency(alert.currentUsd)} / {formatCurrency(alert.limitUsd)}.
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : settingsLoaded ? (
|
||||
<div className="mt-3 rounded border border-emerald-500/20 bg-emerald-500/10 px-3 py-2 font-mono text-[11px] text-emerald-100">
|
||||
Budgets are within threshold.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-2">
|
||||
<StatCard
|
||||
label="Total Spend"
|
||||
value={formatCurrency(usage.totals.totalCost)}
|
||||
hint="Selected range."
|
||||
/>
|
||||
<StatCard
|
||||
label="Total Tokens"
|
||||
value={formatNumber(usage.totals.totalTokens)}
|
||||
hint="Input + output + cache."
|
||||
/>
|
||||
<StatCard
|
||||
label="Success Rate"
|
||||
value={formatPercent(performance.fleet.successRate)}
|
||||
hint="Completed runs only."
|
||||
/>
|
||||
<StatCard
|
||||
label="Avg Runtime"
|
||||
value={formatDuration(performance.fleet.avgRuntimeMs)}
|
||||
hint="Session-local run history."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 rounded border border-white/8 bg-white/[0.03] px-3 py-3">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-white/35">
|
||||
Budget Limits
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="font-mono text-[10px] text-white/35">Daily USD</span>
|
||||
<input
|
||||
value={formatBudgetInput(budgets.dailySpendLimitUsd)}
|
||||
onChange={(event) =>
|
||||
updateBudget("dailySpendLimitUsd", parseBudgetInput(event.target.value))
|
||||
}
|
||||
placeholder="No limit"
|
||||
inputMode="decimal"
|
||||
className="rounded border border-white/10 bg-black/50 px-2 py-2 font-mono text-[11px] text-white/80 outline-none placeholder:text-white/20"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="font-mono text-[10px] text-white/35">Monthly USD</span>
|
||||
<input
|
||||
value={formatBudgetInput(budgets.monthlySpendLimitUsd)}
|
||||
onChange={(event) =>
|
||||
updateBudget("monthlySpendLimitUsd", parseBudgetInput(event.target.value))
|
||||
}
|
||||
placeholder="No limit"
|
||||
inputMode="decimal"
|
||||
className="rounded border border-white/10 bg-black/50 px-2 py-2 font-mono text-[11px] text-white/80 outline-none placeholder:text-white/20"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="font-mono text-[10px] text-white/35">Per-agent USD</span>
|
||||
<input
|
||||
value={formatBudgetInput(budgets.perAgentSoftLimitUsd)}
|
||||
onChange={(event) =>
|
||||
updateBudget("perAgentSoftLimitUsd", parseBudgetInput(event.target.value))
|
||||
}
|
||||
placeholder="Soft limit"
|
||||
inputMode="decimal"
|
||||
className="rounded border border-white/10 bg-black/50 px-2 py-2 font-mono text-[11px] text-white/80 outline-none placeholder:text-white/20"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="font-mono text-[10px] text-white/35">Alert threshold %</span>
|
||||
<input
|
||||
value={String(budgets.alertThresholdPct)}
|
||||
onChange={(event) =>
|
||||
updateBudget(
|
||||
"alertThresholdPct",
|
||||
Math.min(100, Math.max(1, parseBudgetInput(event.target.value) ?? 80))
|
||||
)
|
||||
}
|
||||
inputMode="numeric"
|
||||
className="rounded border border-white/10 bg-black/50 px-2 py-2 font-mono text-[11px] text-white/80 outline-none"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 rounded border border-white/8 bg-white/[0.03] px-3 py-3">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-white/35">
|
||||
Daily Cost
|
||||
</div>
|
||||
{usage.loading ? (
|
||||
<div className="mt-3 font-mono text-[11px] text-white/40">Loading usage data.</div>
|
||||
) : usage.costDaily.length === 0 ? (
|
||||
<div className="mt-3 font-mono text-[11px] text-white/35">
|
||||
No cost data in the selected range.
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 flex items-end gap-1">
|
||||
{usage.costDaily.map((entry) => {
|
||||
const heightPct = dailyChartMax > 0 ? (entry.totalCost / dailyChartMax) * 100 : 0;
|
||||
return (
|
||||
<div key={entry.date} className="flex min-w-0 flex-1 flex-col items-center gap-1">
|
||||
<div className="font-mono text-[9px] text-white/35">
|
||||
{formatCurrency(entry.totalCost)}
|
||||
</div>
|
||||
<div className="flex h-28 w-full items-end rounded bg-black/40 px-1">
|
||||
<div
|
||||
className="w-full rounded-t bg-rose-400/80"
|
||||
style={{ height: `${Math.max(4, heightPct)}%` }}
|
||||
title={`${entry.date} · ${formatCurrency(entry.totalCost)}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="font-mono text-[9px] text-white/35">
|
||||
{entry.date.slice(5)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 rounded border border-white/8 bg-black/25 px-3 py-3">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.16em] text-white/35">
|
||||
Cost Breakdown
|
||||
</div>
|
||||
<div className="mt-2 space-y-1 font-mono text-[11px] text-white/70">
|
||||
<div>Input: {formatCurrency(usage.totals.inputCost)}.</div>
|
||||
<div>Output: {formatCurrency(usage.totals.outputCost)}.</div>
|
||||
<div>Cache read: {formatCurrency(usage.totals.cacheReadCost)}.</div>
|
||||
<div>Cache write: {formatCurrency(usage.totals.cacheWriteCost)}.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 rounded border border-white/8 bg-white/[0.03] px-3 py-3">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-white/35">
|
||||
Top Agents By Spend
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{usage.aggregates.byAgent.slice(0, 6).map((entry) => (
|
||||
<button
|
||||
key={entry.agentId}
|
||||
type="button"
|
||||
onClick={() => onSelectAgent(entry.agentId)}
|
||||
className="flex w-full items-center justify-between rounded border border-white/8 bg-black/25 px-3 py-2 text-left transition-colors hover:border-cyan-400/25 hover:bg-cyan-500/[0.04]"
|
||||
>
|
||||
<span className="font-mono text-[11px] text-white/80">{entry.agentName}</span>
|
||||
<span className="font-mono text-[11px] text-white/55">
|
||||
{formatCurrency(entry.totals.totalCost)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{usage.aggregates.byAgent.length === 0 ? (
|
||||
<div className="font-mono text-[11px] text-white/35">No agent spend data yet.</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 rounded border border-white/8 bg-white/[0.03] px-3 py-3">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-white/35">
|
||||
Model Breakdown
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{usage.aggregates.byModel.slice(0, 6).map((entry) => (
|
||||
<div
|
||||
key={`${entry.provider ?? "unknown"}:${entry.model ?? "unknown"}`}
|
||||
className="flex items-center justify-between rounded border border-white/8 bg-black/25 px-3 py-2"
|
||||
>
|
||||
<span className="font-mono text-[11px] text-white/80">
|
||||
{entry.provider ?? "unknown"} / {entry.model ?? "unknown"}
|
||||
</span>
|
||||
<span className="font-mono text-[11px] text-white/55">
|
||||
{formatCurrency(entry.totals.totalCost)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{usage.aggregates.byModel.length === 0 ? (
|
||||
<div className="font-mono text-[11px] text-white/35">No model usage data yet.</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 rounded border border-white/8 bg-white/[0.03] px-3 py-3">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-white/35">
|
||||
Performance
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<StatCard
|
||||
label="Approvals"
|
||||
value={formatNumber(approvalMetrics.totals.requestedCount)}
|
||||
hint="Session-local approval requests."
|
||||
/>
|
||||
<StatCard
|
||||
label="Intervention Rate"
|
||||
value={formatPercent(performance.fleet.interventionRate)}
|
||||
hint="Approvals per observed run."
|
||||
/>
|
||||
<StatCard
|
||||
label="Tool Calls"
|
||||
value={formatNumber(performance.fleet.totalToolCalls)}
|
||||
hint="Current transcript state."
|
||||
/>
|
||||
<StatCard
|
||||
label="Completed Runs"
|
||||
value={formatNumber(performance.fleet.completedRuns)}
|
||||
hint="In-memory office run log."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
{performance.rows.map((row) => (
|
||||
<button
|
||||
key={row.agentId}
|
||||
type="button"
|
||||
onClick={() => onSelectAgent(row.agentId)}
|
||||
className="w-full rounded border border-white/8 bg-black/25 px-3 py-3 text-left transition-colors hover:border-cyan-400/25 hover:bg-cyan-500/[0.04]"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-mono text-[11px] font-semibold uppercase tracking-[0.14em] text-white/85">
|
||||
{row.agentName}
|
||||
</span>
|
||||
<span className="font-mono text-[10px] text-white/40">
|
||||
{row.totalRuns} runs
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2 font-mono text-[10px] text-white/55">
|
||||
<div>Success: {formatPercent(row.successRate)}.</div>
|
||||
<div>Avg runtime: {formatDuration(row.avgRuntimeMs)}.</div>
|
||||
<div>Tool calls: {formatNumber(row.toolCalls)}.</div>
|
||||
<div>Approvals: {formatNumber(row.approvalRequestedCount)}.</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{performance.rows.length === 0 ? (
|
||||
<div className="font-mono text-[11px] text-white/35">
|
||||
No performance data is available yet.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import type { RunRecord, RunTriggerKind } from "@/features/office/hooks/useRunLog";
|
||||
|
||||
const formatClockTime = (timestampMs: number) =>
|
||||
new Date(timestampMs).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
const formatDuration = (startedAt: number, endedAt: number | null) => {
|
||||
const deltaMs = Math.max(0, (endedAt ?? Date.now()) - startedAt);
|
||||
const seconds = Math.floor(deltaMs / 1000);
|
||||
if (!endedAt) return `${Math.max(1, seconds)}s running`;
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
if (minutes < 60) return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainingMinutes = minutes % 60;
|
||||
return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
|
||||
};
|
||||
|
||||
const TRIGGER_LABELS: Record<RunTriggerKind, string> = {
|
||||
user: "USER",
|
||||
heartbeat: "HEARTBEAT",
|
||||
cron: "CRON",
|
||||
};
|
||||
|
||||
export function HistoryPanel({
|
||||
runs,
|
||||
agents,
|
||||
onSelectAgent,
|
||||
}: {
|
||||
runs: RunRecord[];
|
||||
agents: AgentState[];
|
||||
onSelectAgent: (agentId: string) => void;
|
||||
}) {
|
||||
const [agentFilter, setAgentFilter] = useState("all");
|
||||
const [triggerFilter, setTriggerFilter] = useState<"all" | RunTriggerKind>("all");
|
||||
|
||||
const filteredRuns = useMemo(() => {
|
||||
return runs.filter((run) => {
|
||||
if (agentFilter !== "all" && run.agentId !== agentFilter) return false;
|
||||
if (triggerFilter !== "all" && run.trigger !== triggerFilter) return false;
|
||||
return true;
|
||||
});
|
||||
}, [agentFilter, runs, triggerFilter]);
|
||||
|
||||
return (
|
||||
<section className="flex h-full min-h-0 flex-col">
|
||||
<div className="border-b border-cyan-500/10 px-4 py-3">
|
||||
<div className="font-mono text-[11px] uppercase tracking-[0.22em] text-white/70">
|
||||
Audit Log
|
||||
</div>
|
||||
<div className="mt-1 font-mono text-[11px] text-white/40">
|
||||
This session only. Lifecycle events are captured live from HQ.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 border-b border-cyan-500/10 px-4 py-3">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.16em] text-white/35">
|
||||
Agent
|
||||
</span>
|
||||
<select
|
||||
value={agentFilter}
|
||||
onChange={(event) => setAgentFilter(event.target.value)}
|
||||
className="rounded border border-white/10 bg-black/50 px-2 py-2 font-mono text-[11px] text-white/80 outline-none"
|
||||
>
|
||||
<option value="all">All agents</option>
|
||||
{agents.map((agent) => (
|
||||
<option key={agent.agentId} value={agent.agentId}>
|
||||
{agent.name || agent.agentId}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.16em] text-white/35">
|
||||
Trigger
|
||||
</span>
|
||||
<select
|
||||
value={triggerFilter}
|
||||
onChange={(event) => setTriggerFilter(event.target.value as "all" | RunTriggerKind)}
|
||||
className="rounded border border-white/10 bg-black/50 px-2 py-2 font-mono text-[11px] text-white/80 outline-none"
|
||||
>
|
||||
<option value="all">All triggers</option>
|
||||
<option value="user">User</option>
|
||||
<option value="heartbeat">Heartbeat</option>
|
||||
<option value="cron">Cron</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-2 py-2">
|
||||
{filteredRuns.length === 0 ? (
|
||||
<div className="px-2 py-6 font-mono text-[11px] text-white/35">
|
||||
No run records yet for this session.
|
||||
</div>
|
||||
) : (
|
||||
filteredRuns.map((run) => {
|
||||
const isRunning = run.endedAt === null;
|
||||
return (
|
||||
<button
|
||||
key={run.runId}
|
||||
type="button"
|
||||
onClick={() => onSelectAgent(run.agentId)}
|
||||
className="mb-2 flex w-full flex-col rounded border border-white/8 bg-white/[0.03] px-3 py-3 text-left transition-colors hover:border-cyan-400/25 hover:bg-cyan-500/[0.05]"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`h-2 w-2 shrink-0 rounded-full ${
|
||||
isRunning
|
||||
? "bg-amber-400"
|
||||
: run.outcome === "error"
|
||||
? "bg-rose-400"
|
||||
: "bg-emerald-400"
|
||||
}`}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[11px] font-semibold uppercase tracking-[0.16em] text-white/85">
|
||||
{run.agentName}
|
||||
</span>
|
||||
<span className="rounded border border-cyan-500/20 bg-cyan-500/10 px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-[0.16em] text-cyan-200">
|
||||
{TRIGGER_LABELS[run.trigger]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-3 gap-2 font-mono text-[10px] uppercase tracking-[0.16em] text-white/38">
|
||||
<div>
|
||||
<div>Started</div>
|
||||
<div className="mt-1 text-[11px] text-white/75">{formatClockTime(run.startedAt)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div>Duration</div>
|
||||
<div className="mt-1 text-[11px] text-white/75">
|
||||
{formatDuration(run.startedAt, run.endedAt)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div>Outcome</div>
|
||||
<div className="mt-1 text-[11px] text-white/75">
|
||||
{isRunning ? "Running" : run.outcome === "error" ? "Error" : "Completed"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
|
||||
const formatRelativeTime = (timestampMs: number | null) => {
|
||||
if (!timestampMs) return "No output yet";
|
||||
const deltaMs = Date.now() - timestampMs;
|
||||
if (deltaMs < 60_000) return "Just now";
|
||||
if (deltaMs < 3_600_000) return `${Math.max(1, Math.floor(deltaMs / 60_000))}m ago`;
|
||||
if (deltaMs < 86_400_000) return `${Math.max(1, Math.floor(deltaMs / 3_600_000))}h ago`;
|
||||
return `${Math.max(1, Math.floor(deltaMs / 86_400_000))}d ago`;
|
||||
};
|
||||
|
||||
export function InboxPanel({
|
||||
agents,
|
||||
onSelectAgent,
|
||||
}: {
|
||||
agents: AgentState[];
|
||||
onSelectAgent: (agentId: string) => void;
|
||||
}) {
|
||||
const sortedAgents = useMemo(
|
||||
() =>
|
||||
[...agents].sort(
|
||||
(left, right) =>
|
||||
(right.lastAssistantMessageAt ?? 0) - (left.lastAssistantMessageAt ?? 0) ||
|
||||
left.name.localeCompare(right.name)
|
||||
),
|
||||
[agents]
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="flex h-full min-h-0 flex-col">
|
||||
<div className="border-b border-cyan-500/10 px-4 py-3">
|
||||
<div className="font-mono text-[11px] uppercase tracking-[0.22em] text-white/70">
|
||||
Results Center
|
||||
</div>
|
||||
<div className="mt-1 font-mono text-[11px] text-white/40">
|
||||
Latest assistant output from every desk.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-2 py-2">
|
||||
{sortedAgents.length === 0 ? (
|
||||
<div className="px-2 py-6 font-mono text-[11px] text-white/35">
|
||||
No agents are connected yet.
|
||||
</div>
|
||||
) : (
|
||||
sortedAgents.map((agent) => {
|
||||
const preview = agent.latestPreview?.trim() || "No completed assistant output yet.";
|
||||
const isRunning = agent.status === "running";
|
||||
return (
|
||||
<button
|
||||
key={agent.agentId}
|
||||
type="button"
|
||||
onClick={() => onSelectAgent(agent.agentId)}
|
||||
className="mb-2 flex w-full flex-col rounded border border-white/8 bg-white/[0.03] px-3 py-3 text-left transition-colors hover:border-cyan-400/25 hover:bg-cyan-500/[0.05]"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`h-2 w-2 shrink-0 rounded-full ${
|
||||
isRunning ? "bg-emerald-400" : "bg-amber-400/80"
|
||||
}`}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[11px] font-semibold uppercase tracking-[0.16em] text-white/85">
|
||||
{agent.name || agent.agentId}
|
||||
</span>
|
||||
{agent.hasUnseenActivity ? (
|
||||
<span className="rounded bg-cyan-500/15 px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-[0.12em] text-cyan-300">
|
||||
New
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-2 line-clamp-3 font-mono text-[12px] leading-5 text-white/70">
|
||||
{preview}
|
||||
</div>
|
||||
<div className="mt-3 font-mono text-[10px] uppercase tracking-[0.16em] text-white/35">
|
||||
{formatRelativeTime(agent.lastAssistantMessageAt)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,794 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import type { OfficeStandupController } from "@/features/office/hooks/useOfficeStandupController";
|
||||
import {
|
||||
createCronJob,
|
||||
formatCronSchedule,
|
||||
listCronJobs,
|
||||
removeCronJob,
|
||||
runCronJobNow,
|
||||
sortCronJobsByUpdatedAt,
|
||||
type CronJobCreateInput,
|
||||
type CronJobSummary,
|
||||
} from "@/lib/cron/types";
|
||||
import type { GatewayClient, GatewayStatus } from "@/lib/gateway/GatewayClient";
|
||||
import { isGatewayDisconnectLikeError } from "@/lib/gateway/GatewayClient";
|
||||
|
||||
type TemplateDefinition = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
buildInput: (agent: AgentState, customName: string) => CronJobCreateInput;
|
||||
};
|
||||
|
||||
const PLAYBOOK_TEMPLATES: TemplateDefinition[] = [
|
||||
{
|
||||
id: "daily-briefing",
|
||||
name: "Daily Morning Briefing",
|
||||
description: "Every day at 9am. Summarize priorities, blockers, and what changed overnight.",
|
||||
buildInput: (agent, customName) => ({
|
||||
name: customName || "Daily Morning Briefing",
|
||||
agentId: agent.agentId,
|
||||
sessionKey: agent.sessionKey,
|
||||
enabled: true,
|
||||
schedule: { kind: "cron", expr: "0 9 * * *" },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "now",
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
message:
|
||||
"Create a concise morning briefing for headquarters. Summarize current priorities, blocked work, recent notable changes, and the next recommended actions.",
|
||||
thinking: "high",
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "nightly-code-review",
|
||||
name: "Nightly Code Review Digest",
|
||||
description: "Every night at midnight. Review the day and summarize risky changes or regressions.",
|
||||
buildInput: (agent, customName) => ({
|
||||
name: customName || "Nightly Code Review Digest",
|
||||
agentId: agent.agentId,
|
||||
sessionKey: agent.sessionKey,
|
||||
enabled: true,
|
||||
schedule: { kind: "cron", expr: "0 0 * * *" },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "now",
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
message:
|
||||
"Review the latest work available to you and produce a digest of risky changes, unresolved questions, and follow-up recommendations for the team.",
|
||||
thinking: "high",
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "hourly-health-check",
|
||||
name: "Hourly Health Check",
|
||||
description: "Every 60 minutes. Report runtime health, failures, and anything that needs intervention.",
|
||||
buildInput: (agent, customName) => ({
|
||||
name: customName || "Hourly Health Check",
|
||||
agentId: agent.agentId,
|
||||
sessionKey: agent.sessionKey,
|
||||
enabled: true,
|
||||
schedule: { kind: "every", everyMs: 60 * 60 * 1000 },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "now",
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
message:
|
||||
"Run a health check. Summarize your current status, errors, blocked tasks, pending approvals, and whether a human needs to step in.",
|
||||
thinking: "medium",
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "weekly-progress-report",
|
||||
name: "Weekly Progress Report",
|
||||
description: "Every Monday at 8am. Roll up wins, unfinished work, and next steps.",
|
||||
buildInput: (agent, customName) => ({
|
||||
name: customName || "Weekly Progress Report",
|
||||
agentId: agent.agentId,
|
||||
sessionKey: agent.sessionKey,
|
||||
enabled: true,
|
||||
schedule: { kind: "cron", expr: "0 8 * * 1" },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "now",
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
message:
|
||||
"Write a weekly progress report for headquarters. Include completed work, unfinished work, risks, and the most important next steps.",
|
||||
thinking: "high",
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "continuous-monitor",
|
||||
name: "Continuous Monitor",
|
||||
description: "Every 15 minutes. Watch for drift, silent failures, or anything unusual.",
|
||||
buildInput: (agent, customName) => ({
|
||||
name: customName || "Continuous Monitor",
|
||||
agentId: agent.agentId,
|
||||
sessionKey: agent.sessionKey,
|
||||
enabled: true,
|
||||
schedule: { kind: "every", everyMs: 15 * 60 * 1000 },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "now",
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
message:
|
||||
"Monitor your current context and report only if you detect unusual behavior, blocked progress, repeated failures, or opportunities that need attention.",
|
||||
thinking: "medium",
|
||||
},
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
const formatRelativeDateTime = (timestampMs?: number) => {
|
||||
if (!timestampMs || !Number.isFinite(timestampMs)) return "Unknown";
|
||||
return new Date(timestampMs).toLocaleString([], {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
export function PlaybooksPanel({
|
||||
client,
|
||||
status,
|
||||
agents,
|
||||
standup,
|
||||
}: {
|
||||
client: GatewayClient;
|
||||
status: GatewayStatus;
|
||||
agents: AgentState[];
|
||||
standup: OfficeStandupController;
|
||||
}) {
|
||||
const [jobs, setJobs] = useState<CronJobSummary[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null);
|
||||
const [selectedAgentId, setSelectedAgentId] = useState("");
|
||||
const [nameOverride, setNameOverride] = useState("");
|
||||
const [createBusy, setCreateBusy] = useState(false);
|
||||
const [runBusyJobId, setRunBusyJobId] = useState<string | null>(null);
|
||||
const [deleteBusyJobId, setDeleteBusyJobId] = useState<string | null>(null);
|
||||
const [actionMessage, setActionMessage] = useState<string | null>(null);
|
||||
|
||||
const agentById = useMemo(
|
||||
() => new Map(agents.map((agent) => [agent.agentId, agent])),
|
||||
[agents]
|
||||
);
|
||||
|
||||
const activeTemplate = useMemo(
|
||||
() => PLAYBOOK_TEMPLATES.find((template) => template.id === selectedTemplateId) ?? null,
|
||||
[selectedTemplateId]
|
||||
);
|
||||
const [standupAgentId, setStandupAgentId] = useState("");
|
||||
const [standupCronExpr, setStandupCronExpr] = useState("0 9 * * 1-5");
|
||||
const [standupTimezone, setStandupTimezone] = useState("UTC");
|
||||
const [standupSpeakerSeconds, setStandupSpeakerSeconds] = useState("8");
|
||||
const [standupAutoOpenBoard, setStandupAutoOpenBoard] = useState(true);
|
||||
const [standupScheduleEnabled, setStandupScheduleEnabled] = useState(false);
|
||||
const [jiraEnabled, setJiraEnabled] = useState(false);
|
||||
const [jiraBaseUrl, setJiraBaseUrl] = useState("");
|
||||
const [jiraEmail, setJiraEmail] = useState("");
|
||||
const [jiraApiToken, setJiraApiToken] = useState("");
|
||||
const [jiraApiTokenConfigured, setJiraApiTokenConfigured] = useState(false);
|
||||
const [jiraProjectKey, setJiraProjectKey] = useState("");
|
||||
const [jiraJql, setJiraJql] = useState("");
|
||||
const [manualTask, setManualTask] = useState("");
|
||||
const [manualBlockers, setManualBlockers] = useState("");
|
||||
const [manualNote, setManualNote] = useState("");
|
||||
const [manualJiraAssignee, setManualJiraAssignee] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!standup.config) return;
|
||||
setStandupScheduleEnabled(standup.config.schedule.enabled);
|
||||
setStandupCronExpr(standup.config.schedule.cronExpr);
|
||||
setStandupTimezone(standup.config.schedule.timezone);
|
||||
setStandupSpeakerSeconds(String(standup.config.schedule.speakerSeconds));
|
||||
setStandupAutoOpenBoard(standup.config.schedule.autoOpenBoard);
|
||||
setJiraEnabled(standup.config.jira.enabled);
|
||||
setJiraBaseUrl(standup.config.jira.baseUrl);
|
||||
setJiraEmail(standup.config.jira.email);
|
||||
setJiraApiToken(standup.config.jira.apiToken);
|
||||
setJiraApiTokenConfigured(standup.config.jira.apiTokenConfigured);
|
||||
setJiraProjectKey(standup.config.jira.projectKey);
|
||||
setJiraJql(standup.config.jira.jql);
|
||||
}, [standup.config]);
|
||||
|
||||
useEffect(() => {
|
||||
if (standupAgentId || agents.length === 0) return;
|
||||
setStandupAgentId(agents[0]?.agentId ?? "");
|
||||
}, [agents, standupAgentId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!standup.config || !standupAgentId) return;
|
||||
const manual = standup.config.manualByAgentId[standupAgentId];
|
||||
setManualTask(manual?.currentTask ?? "");
|
||||
setManualBlockers(manual?.blockers ?? "");
|
||||
setManualNote(manual?.note ?? "");
|
||||
setManualJiraAssignee(manual?.jiraAssignee ?? "");
|
||||
}, [standup.config, standupAgentId]);
|
||||
|
||||
const loadJobs = useCallback(async () => {
|
||||
if (status !== "connected") {
|
||||
setJobs([]);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await listCronJobs(client, { includeDisabled: true });
|
||||
setJobs(sortCronJobsByUpdatedAt(result.jobs));
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to load playbooks.";
|
||||
setError(message);
|
||||
if (!isGatewayDisconnectLikeError(err)) {
|
||||
console.error(message);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [client, status]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadJobs();
|
||||
}, [loadJobs]);
|
||||
|
||||
const handleCreate = useCallback(async () => {
|
||||
if (!activeTemplate) return;
|
||||
const agent = agentById.get(selectedAgentId);
|
||||
if (!agent) {
|
||||
setError("Pick an agent before launching a playbook.");
|
||||
return;
|
||||
}
|
||||
|
||||
setCreateBusy(true);
|
||||
setError(null);
|
||||
setActionMessage(null);
|
||||
try {
|
||||
await createCronJob(client, activeTemplate.buildInput(agent, nameOverride.trim()));
|
||||
setActionMessage(`Created "${nameOverride.trim() || activeTemplate.name}".`);
|
||||
setSelectedTemplateId(null);
|
||||
setSelectedAgentId("");
|
||||
setNameOverride("");
|
||||
await loadJobs();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to create playbook.";
|
||||
setError(message);
|
||||
} finally {
|
||||
setCreateBusy(false);
|
||||
}
|
||||
}, [activeTemplate, agentById, client, loadJobs, nameOverride, selectedAgentId]);
|
||||
|
||||
const handleRunNow = useCallback(
|
||||
async (jobId: string) => {
|
||||
setRunBusyJobId(jobId);
|
||||
setError(null);
|
||||
setActionMessage(null);
|
||||
try {
|
||||
const result = await runCronJobNow(client, jobId);
|
||||
setActionMessage(result.ok ? "Playbook triggered." : "Playbook trigger failed.");
|
||||
await loadJobs();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to run playbook.");
|
||||
} finally {
|
||||
setRunBusyJobId(null);
|
||||
}
|
||||
},
|
||||
[client, loadJobs]
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (jobId: string) => {
|
||||
setDeleteBusyJobId(jobId);
|
||||
setError(null);
|
||||
setActionMessage(null);
|
||||
try {
|
||||
const result = await removeCronJob(client, jobId);
|
||||
setActionMessage(result.ok && result.removed ? "Playbook removed." : "Playbook was not removed.");
|
||||
await loadJobs();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to delete playbook.");
|
||||
} finally {
|
||||
setDeleteBusyJobId(null);
|
||||
}
|
||||
},
|
||||
[client, loadJobs]
|
||||
);
|
||||
|
||||
const handleSaveStandupConfig = useCallback(async () => {
|
||||
setError(null);
|
||||
setActionMessage(null);
|
||||
try {
|
||||
await standup.saveConfig({
|
||||
schedule: {
|
||||
enabled: standupScheduleEnabled,
|
||||
cronExpr: standupCronExpr.trim() || "0 9 * * 1-5",
|
||||
timezone: standupTimezone.trim() || "UTC",
|
||||
speakerSeconds: Number(standupSpeakerSeconds) || 8,
|
||||
autoOpenBoard: standupAutoOpenBoard,
|
||||
},
|
||||
jira: {
|
||||
enabled: jiraEnabled,
|
||||
baseUrl: jiraBaseUrl.trim(),
|
||||
email: jiraEmail.trim(),
|
||||
apiToken: jiraApiToken.trim(),
|
||||
projectKey: jiraProjectKey.trim().toUpperCase(),
|
||||
jql: jiraJql.trim(),
|
||||
},
|
||||
});
|
||||
setActionMessage("Standup settings saved.");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to save standup settings.");
|
||||
}
|
||||
}, [
|
||||
jiraApiToken,
|
||||
jiraBaseUrl,
|
||||
jiraEmail,
|
||||
jiraEnabled,
|
||||
jiraJql,
|
||||
jiraProjectKey,
|
||||
standup,
|
||||
standupAutoOpenBoard,
|
||||
standupCronExpr,
|
||||
standupScheduleEnabled,
|
||||
standupSpeakerSeconds,
|
||||
standupTimezone,
|
||||
]);
|
||||
|
||||
const handleSaveManualNotes = useCallback(async () => {
|
||||
if (!standupAgentId) {
|
||||
setError("Pick an agent before saving standup notes.");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setActionMessage(null);
|
||||
try {
|
||||
await standup.updateManualEntry(standupAgentId, {
|
||||
jiraAssignee: manualJiraAssignee.trim() || null,
|
||||
currentTask: manualTask.trim(),
|
||||
blockers: manualBlockers.trim(),
|
||||
note: manualNote.trim(),
|
||||
});
|
||||
setActionMessage("Standup notes saved.");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to save standup notes.");
|
||||
}
|
||||
}, [
|
||||
manualBlockers,
|
||||
manualJiraAssignee,
|
||||
manualNote,
|
||||
manualTask,
|
||||
standup,
|
||||
standupAgentId,
|
||||
]);
|
||||
|
||||
return (
|
||||
<section className="flex h-full min-h-0 flex-col">
|
||||
<div className="border-b border-cyan-500/10 px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="font-mono text-[11px] uppercase tracking-[0.22em] text-white/70">
|
||||
Playbooks
|
||||
</div>
|
||||
<div className="mt-1 font-mono text-[11px] text-white/40">
|
||||
Launch reusable schedules for the whole headquarters.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadJobs()}
|
||||
className="rounded border border-cyan-500/20 bg-cyan-500/10 px-2 py-1 font-mono text-[10px] uppercase tracking-[0.16em] text-cyan-200 transition-colors hover:border-cyan-400/40 hover:text-cyan-100"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
{error ? <div className="mt-2 font-mono text-[11px] text-rose-300">{error}</div> : null}
|
||||
{actionMessage ? (
|
||||
<div className="mt-2 font-mono text-[11px] text-emerald-300">{actionMessage}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="border-b border-cyan-500/10 px-4 py-3">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.16em] text-white/35">
|
||||
Active Jobs
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{loading ? (
|
||||
<div className="font-mono text-[11px] text-white/40">Loading scheduled jobs.</div>
|
||||
) : jobs.length === 0 ? (
|
||||
<div className="font-mono text-[11px] text-white/35">No active playbooks yet.</div>
|
||||
) : (
|
||||
jobs.map((job) => {
|
||||
const agentName = agentById.get(job.agentId ?? "")?.name || job.agentId || "Unknown";
|
||||
return (
|
||||
<div
|
||||
key={job.id}
|
||||
className="rounded border border-white/8 bg-white/[0.03] px-3 py-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-mono text-[11px] font-semibold uppercase tracking-[0.16em] text-white/85">
|
||||
{job.name}
|
||||
</div>
|
||||
<div className="mt-1 font-mono text-[11px] text-white/45">{agentName}</div>
|
||||
</div>
|
||||
<div className="shrink-0 rounded border border-cyan-500/20 bg-cyan-500/10 px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-[0.12em] text-cyan-200">
|
||||
{job.state.lastStatus ?? "ready"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 space-y-1 font-mono text-[11px] text-white/65">
|
||||
<div>{formatCronSchedule(job.schedule)}</div>
|
||||
<div>Next run: {formatRelativeDateTime(job.state.nextRunAtMs)}</div>
|
||||
<div>Last run: {formatRelativeDateTime(job.state.lastRunAtMs)}</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRunNow(job.id)}
|
||||
disabled={runBusyJobId === job.id || deleteBusyJobId === job.id}
|
||||
className="rounded border border-amber-500/25 bg-amber-500/10 px-2 py-1 font-mono text-[10px] uppercase tracking-[0.16em] text-amber-200 transition-colors hover:border-amber-400/50 hover:text-amber-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{runBusyJobId === job.id ? "Running" : "Run now"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleDelete(job.id)}
|
||||
disabled={deleteBusyJobId === job.id || runBusyJobId === job.id}
|
||||
className="rounded border border-rose-500/25 bg-rose-500/10 px-2 py-1 font-mono text-[10px] uppercase tracking-[0.16em] text-rose-200 transition-colors hover:border-rose-400/50 hover:text-rose-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{deleteBusyJobId === job.id ? "Deleting" : "Delete"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3">
|
||||
<div className="rounded border border-emerald-500/15 bg-emerald-500/[0.05] px-3 py-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.16em] text-emerald-200/85">
|
||||
Automated Standup
|
||||
</div>
|
||||
<div className="mt-1 font-mono text-[11px] leading-5 text-white/50">
|
||||
Configure the daily meeting, Jira source, and manual notes board.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void standup.startMeeting("manual")}
|
||||
className="rounded border border-emerald-500/25 bg-emerald-500/10 px-2 py-1 font-mono text-[10px] uppercase tracking-[0.16em] text-emerald-100 transition-colors hover:border-emerald-400/50 hover:text-white"
|
||||
>
|
||||
Start now
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-3">
|
||||
<label className="flex items-center gap-2 font-mono text-[11px] text-white/75">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={standupScheduleEnabled}
|
||||
onChange={(event) => setStandupScheduleEnabled(event.target.checked)}
|
||||
/>
|
||||
Enable scheduled standup.
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.16em] text-white/35">
|
||||
Cron expression
|
||||
</span>
|
||||
<input
|
||||
value={standupCronExpr}
|
||||
onChange={(event) => setStandupCronExpr(event.target.value)}
|
||||
className="rounded border border-white/10 bg-black/50 px-2 py-2 font-mono text-[11px] text-white/80 outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.16em] text-white/35">
|
||||
Timezone
|
||||
</span>
|
||||
<input
|
||||
value={standupTimezone}
|
||||
onChange={(event) => setStandupTimezone(event.target.value)}
|
||||
className="rounded border border-white/10 bg-black/50 px-2 py-2 font-mono text-[11px] text-white/80 outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.16em] text-white/35">
|
||||
Seconds per speaker
|
||||
</span>
|
||||
<input
|
||||
value={standupSpeakerSeconds}
|
||||
onChange={(event) => setStandupSpeakerSeconds(event.target.value)}
|
||||
className="rounded border border-white/10 bg-black/50 px-2 py-2 font-mono text-[11px] text-white/80 outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 font-mono text-[11px] text-white/75">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={standupAutoOpenBoard}
|
||||
onChange={(event) => setStandupAutoOpenBoard(event.target.checked)}
|
||||
/>
|
||||
Auto-open the standup board when a meeting starts.
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 font-mono text-[11px] text-white/75">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={jiraEnabled}
|
||||
onChange={(event) => setJiraEnabled(event.target.checked)}
|
||||
/>
|
||||
Enable Jira source.
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.16em] text-white/35">
|
||||
Jira base URL
|
||||
</span>
|
||||
<input
|
||||
value={jiraBaseUrl}
|
||||
onChange={(event) => setJiraBaseUrl(event.target.value)}
|
||||
placeholder="https://company.atlassian.net"
|
||||
className="rounded border border-white/10 bg-black/50 px-2 py-2 font-mono text-[11px] text-white/80 outline-none placeholder:text-white/20"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.16em] text-white/35">
|
||||
Jira email
|
||||
</span>
|
||||
<input
|
||||
value={jiraEmail}
|
||||
onChange={(event) => setJiraEmail(event.target.value)}
|
||||
className="rounded border border-white/10 bg-black/50 px-2 py-2 font-mono text-[11px] text-white/80 outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.16em] text-white/35">
|
||||
Jira API token
|
||||
</span>
|
||||
<input
|
||||
type="password"
|
||||
value={jiraApiToken}
|
||||
onChange={(event) => {
|
||||
setJiraApiToken(event.target.value);
|
||||
setJiraApiTokenConfigured(event.target.value.trim().length > 0);
|
||||
}}
|
||||
placeholder={
|
||||
jiraApiTokenConfigured ? "Stored on Studio host. Enter to replace." : ""
|
||||
}
|
||||
className="rounded border border-white/10 bg-black/50 px-2 py-2 font-mono text-[11px] text-white/80 outline-none"
|
||||
/>
|
||||
{jiraApiTokenConfigured ? (
|
||||
<span className="text-[10px] text-white/45">
|
||||
A Jira API token is already stored on the Studio host.
|
||||
</span>
|
||||
) : null}
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.16em] text-white/35">
|
||||
Jira project key
|
||||
</span>
|
||||
<input
|
||||
value={jiraProjectKey}
|
||||
onChange={(event) => setJiraProjectKey(event.target.value)}
|
||||
className="rounded border border-white/10 bg-black/50 px-2 py-2 font-mono text-[11px] text-white/80 outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.16em] text-white/35">
|
||||
Jira JQL override
|
||||
</span>
|
||||
<textarea
|
||||
value={jiraJql}
|
||||
onChange={(event) => setJiraJql(event.target.value)}
|
||||
rows={3}
|
||||
className="rounded border border-white/10 bg-black/50 px-2 py-2 font-mono text-[11px] text-white/80 outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSaveStandupConfig()}
|
||||
disabled={standup.saving}
|
||||
className="rounded border border-emerald-500/25 bg-emerald-500/10 px-3 py-2 font-mono text-[10px] uppercase tracking-[0.18em] text-emerald-100 transition-colors hover:border-emerald-400/50 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{standup.saving ? "Saving standup settings" : "Save standup settings"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 border-t border-white/10 pt-4">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.16em] text-white/35">
|
||||
Manual board input
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.16em] text-white/35">
|
||||
Agent
|
||||
</span>
|
||||
<select
|
||||
value={standupAgentId}
|
||||
onChange={(event) => setStandupAgentId(event.target.value)}
|
||||
className="rounded border border-white/10 bg-black/50 px-2 py-2 font-mono text-[11px] text-white/80 outline-none"
|
||||
>
|
||||
<option value="">Select an agent</option>
|
||||
{agents.map((agent) => (
|
||||
<option key={agent.agentId} value={agent.agentId}>
|
||||
{agent.name || agent.agentId}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.16em] text-white/35">
|
||||
Jira assignee hint
|
||||
</span>
|
||||
<input
|
||||
value={manualJiraAssignee}
|
||||
onChange={(event) => setManualJiraAssignee(event.target.value)}
|
||||
className="rounded border border-white/10 bg-black/50 px-2 py-2 font-mono text-[11px] text-white/80 outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.16em] text-white/35">
|
||||
Current task
|
||||
</span>
|
||||
<input
|
||||
value={manualTask}
|
||||
onChange={(event) => setManualTask(event.target.value)}
|
||||
className="rounded border border-white/10 bg-black/50 px-2 py-2 font-mono text-[11px] text-white/80 outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.16em] text-white/35">
|
||||
Blockers
|
||||
</span>
|
||||
<textarea
|
||||
value={manualBlockers}
|
||||
onChange={(event) => setManualBlockers(event.target.value)}
|
||||
rows={3}
|
||||
className="rounded border border-white/10 bg-black/50 px-2 py-2 font-mono text-[11px] text-white/80 outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.16em] text-white/35">
|
||||
Manual note
|
||||
</span>
|
||||
<textarea
|
||||
value={manualNote}
|
||||
onChange={(event) => setManualNote(event.target.value)}
|
||||
rows={4}
|
||||
className="rounded border border-white/10 bg-black/50 px-2 py-2 font-mono text-[11px] text-white/80 outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSaveManualNotes()}
|
||||
className="rounded border border-cyan-500/25 bg-cyan-500/10 px-3 py-2 font-mono text-[10px] uppercase tracking-[0.18em] text-cyan-100 transition-colors hover:border-cyan-400/50 hover:text-white"
|
||||
>
|
||||
Save manual notes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{standup.meeting ? (
|
||||
<div className="mt-4 rounded border border-white/8 bg-white/[0.03] px-3 py-3 font-mono text-[11px] text-white/65">
|
||||
<div>Meeting phase: {standup.meeting.phase}</div>
|
||||
<div>Participants: {standup.meeting.participantOrder.length}</div>
|
||||
<div>
|
||||
Current speaker: {standup.meeting.currentSpeakerAgentId ?? "Waiting"}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 font-mono text-[10px] uppercase tracking-[0.16em] text-white/35">
|
||||
Templates
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{PLAYBOOK_TEMPLATES.map((template) => {
|
||||
const isSelected = template.id === selectedTemplateId;
|
||||
return (
|
||||
<div
|
||||
key={template.id}
|
||||
className={`rounded border px-3 py-3 transition-colors ${
|
||||
isSelected
|
||||
? "border-cyan-400/30 bg-cyan-500/[0.06]"
|
||||
: "border-white/8 bg-white/[0.03]"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedTemplateId((current) =>
|
||||
current === template.id ? null : template.id
|
||||
);
|
||||
setError(null);
|
||||
setActionMessage(null);
|
||||
}}
|
||||
className="w-full text-left"
|
||||
>
|
||||
<div className="font-mono text-[11px] font-semibold uppercase tracking-[0.16em] text-white/85">
|
||||
{template.name}
|
||||
</div>
|
||||
<div className="mt-1 font-mono text-[11px] leading-5 text-white/50">
|
||||
{template.description}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isSelected ? (
|
||||
<div className="mt-3 space-y-3 border-t border-cyan-500/10 pt-3">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.16em] text-white/35">
|
||||
Agent
|
||||
</span>
|
||||
<select
|
||||
value={selectedAgentId}
|
||||
onChange={(event) => setSelectedAgentId(event.target.value)}
|
||||
className="rounded border border-white/10 bg-black/50 px-2 py-2 font-mono text-[11px] text-white/80 outline-none"
|
||||
>
|
||||
<option value="">Select an agent</option>
|
||||
{agents.map((agent) => (
|
||||
<option key={agent.agentId} value={agent.agentId}>
|
||||
{agent.name || agent.agentId}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.16em] text-white/35">
|
||||
Name override
|
||||
</span>
|
||||
<input
|
||||
value={nameOverride}
|
||||
onChange={(event) => setNameOverride(event.target.value)}
|
||||
placeholder={template.name}
|
||||
className="rounded border border-white/10 bg-black/50 px-2 py-2 font-mono text-[11px] text-white/80 outline-none placeholder:text-white/20"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCreate()}
|
||||
disabled={createBusy}
|
||||
className="w-full rounded border border-cyan-500/25 bg-cyan-500/10 px-3 py-2 font-mono text-[10px] uppercase tracking-[0.18em] text-cyan-100 transition-colors hover:border-cyan-400/50 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{createBusy ? "Creating playbook" : "Launch playbook"}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import { CURATED_ELEVENLABS_VOICES } from "@/lib/voiceReply/catalog";
|
||||
|
||||
type SettingsPanelProps = {
|
||||
gatewayStatus?: string;
|
||||
gatewayUrl?: string;
|
||||
onGatewayDisconnect?: () => void;
|
||||
voiceRepliesEnabled: boolean;
|
||||
voiceRepliesVoiceId: string | null;
|
||||
voiceRepliesSpeed: number;
|
||||
voiceRepliesLoaded: boolean;
|
||||
onVoiceRepliesToggle: (enabled: boolean) => void;
|
||||
onVoiceRepliesVoiceChange: (voiceId: string | null) => void;
|
||||
onVoiceRepliesSpeedChange: (speed: number) => void;
|
||||
onVoiceRepliesPreview: (voiceId: string | null, voiceName: string) => void;
|
||||
};
|
||||
|
||||
export function SettingsPanel({
|
||||
gatewayStatus,
|
||||
gatewayUrl,
|
||||
onGatewayDisconnect,
|
||||
voiceRepliesEnabled,
|
||||
voiceRepliesVoiceId,
|
||||
voiceRepliesSpeed,
|
||||
voiceRepliesLoaded,
|
||||
onVoiceRepliesToggle,
|
||||
onVoiceRepliesVoiceChange,
|
||||
onVoiceRepliesSpeedChange,
|
||||
onVoiceRepliesPreview,
|
||||
}: SettingsPanelProps) {
|
||||
const normalizedGatewayUrl = gatewayUrl?.trim() ?? "";
|
||||
const gatewayStateLabel = gatewayStatus
|
||||
? gatewayStatus.charAt(0).toUpperCase() + gatewayStatus.slice(1)
|
||||
: "Unknown";
|
||||
const gatewayDisconnectDisabled = gatewayStatus !== "connected";
|
||||
|
||||
return (
|
||||
<div className="px-4 py-4">
|
||||
<div className="rounded-lg border border-cyan-500/10 bg-black/20 px-4 py-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-medium text-white">Gateway</div>
|
||||
<div className="mt-1 text-[10px] text-white/75">
|
||||
Current studio connection and endpoint details.
|
||||
</div>
|
||||
</div>
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.14em] text-cyan-200/70">
|
||||
{gatewayStateLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 rounded-md border border-cyan-500/10 bg-black/25 px-3 py-2 font-mono text-[10px] text-cyan-100/80">
|
||||
{normalizedGatewayUrl || "No gateway URL configured."}
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between gap-3">
|
||||
<div className="text-[10px] text-white/60">
|
||||
Disconnecting returns you to the gateway connect screen.
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onGatewayDisconnect?.()}
|
||||
disabled={gatewayDisconnectDisabled}
|
||||
className="rounded-md border border-rose-500/20 bg-rose-500/10 px-3 py-1.5 text-[10px] font-medium uppercase tracking-[0.14em] text-rose-100 transition-colors hover:border-rose-400/40 hover:bg-rose-500/15 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
Disconnect gateway
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ui-settings-row mt-3 flex min-h-[72px] items-center justify-between gap-6 rounded-lg border border-cyan-500/10 bg-black/20 px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-label="Voice replies"
|
||||
aria-checked={voiceRepliesEnabled}
|
||||
className={`ui-switch self-center ${voiceRepliesEnabled ? "ui-switch--on" : ""}`}
|
||||
onClick={() => onVoiceRepliesToggle(!voiceRepliesEnabled)}
|
||||
disabled={!voiceRepliesLoaded}
|
||||
>
|
||||
<span className="ui-switch-thumb" />
|
||||
</button>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[11px] font-medium text-white">Voice replies</span>
|
||||
<span className="text-[10px] text-white/80">
|
||||
Play finalized assistant replies with a natural voice.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.14em] text-cyan-200/70">
|
||||
{voiceRepliesLoaded ? (voiceRepliesEnabled ? "On" : "Off") : "Loading"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 rounded-lg border border-cyan-500/10 bg-black/20 px-4 py-3">
|
||||
<div className="text-[11px] font-medium text-white">Voice</div>
|
||||
<div className="mt-1 text-[10px] text-white/75">
|
||||
Choose the voice used for spoken agent replies.
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
{CURATED_ELEVENLABS_VOICES.map((voice) => {
|
||||
const selected = voice.id === voiceRepliesVoiceId;
|
||||
return (
|
||||
<button
|
||||
key={voice.id ?? "default"}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onVoiceRepliesVoiceChange(voice.id);
|
||||
onVoiceRepliesPreview(voice.id, voice.label);
|
||||
}}
|
||||
disabled={!voiceRepliesLoaded}
|
||||
className={`rounded-lg border px-3 py-2 text-left transition-colors ${
|
||||
selected
|
||||
? "border-cyan-400/40 bg-cyan-500/12 text-white"
|
||||
: "border-cyan-500/10 bg-black/15 text-white/80 hover:border-cyan-400/20 hover:bg-cyan-500/6"
|
||||
}`}
|
||||
>
|
||||
<div className="text-[11px] font-medium">{voice.label}</div>
|
||||
<div className="mt-1 text-[10px] text-white/65">{voice.description}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 rounded-lg border border-cyan-500/10 bg-black/20 px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-medium text-white">Speed</div>
|
||||
<div className="mt-1 text-[10px] text-white/75">
|
||||
Adjust how fast the selected voice speaks.
|
||||
</div>
|
||||
</div>
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.14em] text-cyan-200/70">
|
||||
{voiceRepliesSpeed.toFixed(2)}x
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0.7"
|
||||
max="1.2"
|
||||
step="0.05"
|
||||
value={voiceRepliesSpeed}
|
||||
disabled={!voiceRepliesLoaded}
|
||||
onChange={(event) =>
|
||||
onVoiceRepliesSpeedChange(Number.parseFloat(event.target.value))
|
||||
}
|
||||
className="mt-3 h-2 w-full cursor-pointer appearance-none rounded-full bg-cyan-500/15 accent-cyan-400"
|
||||
/>
|
||||
<div className="mt-1 flex items-center justify-between text-[10px] text-white/45">
|
||||
<span>Slower</span>
|
||||
<span>Faster</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,636 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
Download,
|
||||
ExternalLink,
|
||||
RefreshCcw,
|
||||
Settings2,
|
||||
Shield,
|
||||
Sparkles,
|
||||
Star,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
import type { OfficeSkillsMarketplaceController } from "@/features/office/hooks/useOfficeSkillsMarketplace";
|
||||
import type { SkillMarketplaceCollectionId, SkillMarketplaceEntry } from "@/lib/skills/marketplace";
|
||||
import { buildSkillMarketplaceCollections } from "@/lib/skills/marketplace";
|
||||
import { buildAgentSkillsAllowlistSet, deriveAgentSkillsAccessMode } from "@/lib/skills/presentation";
|
||||
|
||||
type MarketplaceFilter = "all" | SkillMarketplaceCollectionId;
|
||||
|
||||
const FILTER_LABELS: Record<MarketplaceFilter, string> = {
|
||||
all: "All",
|
||||
featured: "Featured",
|
||||
installed: "Installed",
|
||||
"setup-required": "Needs setup",
|
||||
"built-in": "Built-in",
|
||||
workspace: "Workspace",
|
||||
extra: "Community",
|
||||
other: "Other",
|
||||
};
|
||||
|
||||
const READINESS_LABELS = {
|
||||
ready: "Ready",
|
||||
"needs-setup": "Needs setup",
|
||||
unavailable: "Unavailable",
|
||||
"disabled-globally": "Disabled globally",
|
||||
} as const;
|
||||
|
||||
const READINESS_CLASSES = {
|
||||
ready: "border-emerald-500/30 bg-emerald-500/10 text-emerald-100",
|
||||
"needs-setup": "border-amber-500/30 bg-amber-500/10 text-amber-100",
|
||||
unavailable: "border-rose-500/30 bg-rose-500/10 text-rose-100",
|
||||
"disabled-globally": "border-cyan-500/30 bg-cyan-500/10 text-cyan-100",
|
||||
} as const;
|
||||
|
||||
const formatRating = (value: number | undefined) => {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return "4.7";
|
||||
}
|
||||
return value.toFixed(1);
|
||||
};
|
||||
|
||||
const formatInstalls = (value: number | undefined) => {
|
||||
const installs = value ?? 0;
|
||||
if (installs >= 1000) {
|
||||
return `${(installs / 1000).toFixed(1)}k`;
|
||||
}
|
||||
return new Intl.NumberFormat("en-US").format(installs);
|
||||
};
|
||||
|
||||
const buildSearchBlob = (entry: SkillMarketplaceEntry): string => {
|
||||
return [
|
||||
entry.skill.name,
|
||||
entry.skill.description,
|
||||
entry.skill.skillKey,
|
||||
entry.skill.source,
|
||||
entry.metadata.category,
|
||||
entry.metadata.tagline,
|
||||
entry.metadata.capabilities.join(" "),
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
};
|
||||
|
||||
const getAgentSkillEnabled = (
|
||||
skillName: string,
|
||||
accessMode: ReturnType<typeof deriveAgentSkillsAccessMode>,
|
||||
allowlistSet: Set<string>
|
||||
) => {
|
||||
if (accessMode === "all") {
|
||||
return true;
|
||||
}
|
||||
if (accessMode === "none") {
|
||||
return false;
|
||||
}
|
||||
return allowlistSet.has(skillName.trim());
|
||||
};
|
||||
|
||||
export function SkillsMarketplacePanel({
|
||||
marketplace,
|
||||
onSelectAgent,
|
||||
onOpenAgentSettings,
|
||||
}: {
|
||||
marketplace: OfficeSkillsMarketplaceController;
|
||||
onSelectAgent: (agentId: string) => void;
|
||||
onOpenAgentSettings: (agentId: string) => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeFilter, setActiveFilter] = useState<MarketplaceFilter>("all");
|
||||
const [detailSkillKey, setDetailSkillKey] = useState<string | null>(null);
|
||||
|
||||
const entries = useMemo(() => marketplace.skillsReport?.skills ?? [], [marketplace.skillsReport]);
|
||||
const collections = useMemo(() => buildSkillMarketplaceCollections(entries), [entries]);
|
||||
const accessMode = useMemo(
|
||||
() => deriveAgentSkillsAccessMode(marketplace.skillsAllowlist),
|
||||
[marketplace.skillsAllowlist]
|
||||
);
|
||||
const allowlistSet = useMemo(
|
||||
() => buildAgentSkillsAllowlistSet(marketplace.skillsAllowlist),
|
||||
[marketplace.skillsAllowlist]
|
||||
);
|
||||
|
||||
const filteredCollections = useMemo(() => {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const visibleCollectionIds: SkillMarketplaceCollectionId[] =
|
||||
activeFilter === "all"
|
||||
? ["built-in", "installed", "workspace", "extra", "other"]
|
||||
: [activeFilter];
|
||||
return collections
|
||||
.filter((collection) => visibleCollectionIds.includes(collection.id))
|
||||
.map((collection) => ({
|
||||
...collection,
|
||||
entries: collection.entries.filter((entry) => {
|
||||
if (!normalizedQuery) {
|
||||
return true;
|
||||
}
|
||||
return buildSearchBlob(entry).includes(normalizedQuery);
|
||||
}),
|
||||
}))
|
||||
.filter((collection) => collection.entries.length > 0);
|
||||
}, [activeFilter, collections, query]);
|
||||
|
||||
const featuredEntries = useMemo(() => {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const featuredCollection = collections.find((collection) => collection.id === "featured");
|
||||
if (!featuredCollection) {
|
||||
return [];
|
||||
}
|
||||
return featuredCollection.entries
|
||||
.filter((entry) => {
|
||||
if (!normalizedQuery) {
|
||||
return true;
|
||||
}
|
||||
return buildSearchBlob(entry).includes(normalizedQuery);
|
||||
})
|
||||
.slice(0, 3);
|
||||
}, [collections, query]);
|
||||
|
||||
const filterCounts = useMemo(() => {
|
||||
const counts: Record<MarketplaceFilter, number> = {
|
||||
all: entries.length,
|
||||
featured: 0,
|
||||
installed: 0,
|
||||
"setup-required": 0,
|
||||
"built-in": 0,
|
||||
workspace: 0,
|
||||
extra: 0,
|
||||
other: 0,
|
||||
};
|
||||
for (const collection of collections) {
|
||||
counts[collection.id] = collection.entries.length;
|
||||
}
|
||||
return counts;
|
||||
}, [collections, entries.length]);
|
||||
|
||||
const detailEntry =
|
||||
collections
|
||||
.flatMap((collection) => collection.entries)
|
||||
.find((entry) => entry.skill.skillKey === detailSkillKey) ?? null;
|
||||
|
||||
return (
|
||||
<section className="relative flex h-full min-h-0 flex-col">
|
||||
<div className="border-b border-cyan-500/10 px-4 py-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-mono text-[11px] uppercase tracking-[0.22em] text-white/70">
|
||||
Skills Marketplace
|
||||
</div>
|
||||
<div className="mt-1 font-mono text-[11px] text-white/40">
|
||||
Browse gateway skills like a curated plugin store.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void marketplace.refresh()}
|
||||
className="inline-flex items-center gap-1 rounded border border-cyan-500/20 bg-cyan-500/10 px-2 py-1 font-mono text-[10px] uppercase tracking-[0.16em] text-cyan-200 transition-colors hover:border-cyan-400/40 hover:text-cyan-100"
|
||||
>
|
||||
<RefreshCcw className="h-3.5 w-3.5" />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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.
|
||||
</div>
|
||||
|
||||
<div className="mt-3 rounded border border-cyan-500/15 bg-white/[0.03] px-3 py-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.16em] text-white/40">
|
||||
Agent context
|
||||
</div>
|
||||
<div className="mt-1 font-mono text-[11px] text-white/75">
|
||||
{marketplace.selectedAgent?.name ?? "No agent selected"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="font-mono text-[10px] text-white/35">
|
||||
Access mode: {accessMode === "selected" ? "Selected skills" : accessMode}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex gap-2">
|
||||
<select
|
||||
value={marketplace.selectedAgentId ?? ""}
|
||||
onChange={(event) => marketplace.setSelectedAgentId(event.target.value || null)}
|
||||
className="min-w-0 flex-1 rounded border border-white/10 bg-black/40 px-2 py-2 font-mono text-[11px] text-white/80 outline-none"
|
||||
>
|
||||
{marketplace.agents.length === 0 ? <option value="">No agents available</option> : null}
|
||||
{marketplace.agents.map((agent) => (
|
||||
<option key={agent.agentId} value={agent.agentId}>
|
||||
{agent.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!marketplace.selectedAgentId}
|
||||
onClick={() => {
|
||||
if (marketplace.selectedAgentId) {
|
||||
onSelectAgent(marketplace.selectedAgentId);
|
||||
}
|
||||
}}
|
||||
className="rounded border border-white/10 bg-white/5 px-2 py-2 font-mono text-[10px] uppercase tracking-[0.14em] text-white/75 transition-colors hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Focus chat
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!marketplace.selectedAgentId}
|
||||
onClick={() => {
|
||||
if (marketplace.selectedAgentId) {
|
||||
onOpenAgentSettings(marketplace.selectedAgentId);
|
||||
}
|
||||
}}
|
||||
className="rounded border border-white/10 bg-white/5 px-2 py-2 font-mono text-[10px] uppercase tracking-[0.14em] text-white/75 transition-colors hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Search skills, categories, or sources"
|
||||
className="w-full rounded border border-white/10 bg-black/40 px-3 py-2 font-mono text-[11px] text-white/85 outline-none transition focus:border-cyan-400/35"
|
||||
aria-label="Search marketplace skills"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{(Object.keys(FILTER_LABELS) as MarketplaceFilter[]).map((filterId) => (
|
||||
<button
|
||||
key={filterId}
|
||||
type="button"
|
||||
onClick={() => setActiveFilter(filterId)}
|
||||
className={`rounded border px-2 py-1 font-mono text-[10px] uppercase tracking-[0.14em] transition-colors ${
|
||||
activeFilter === filterId
|
||||
? "border-cyan-400/35 bg-cyan-500/10 text-cyan-100"
|
||||
: "border-white/10 bg-white/[0.03] text-white/45 hover:text-white/80"
|
||||
}`}
|
||||
>
|
||||
{FILTER_LABELS[filterId]} ({filterCounts[filterId]})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{marketplace.message ? (
|
||||
<div
|
||||
className={`mt-3 rounded border px-3 py-2 font-mono text-[11px] ${
|
||||
marketplace.message.kind === "success"
|
||||
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-100"
|
||||
: "border-rose-500/30 bg-rose-500/10 text-rose-100"
|
||||
}`}
|
||||
>
|
||||
{marketplace.message.text}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{marketplace.error && !marketplace.message ? (
|
||||
<div className="mt-3 rounded border border-rose-500/30 bg-rose-500/10 px-3 py-2 font-mono text-[11px] text-rose-100">
|
||||
{marketplace.error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{marketplace.loading ? (
|
||||
<div className="mt-4 font-mono text-[11px] text-white/45">Loading marketplace inventory...</div>
|
||||
) : null}
|
||||
|
||||
{!marketplace.loading && activeFilter === "all" && featuredEntries.length > 0 ? (
|
||||
<div className="mt-4">
|
||||
<div className="mb-2 flex items-center gap-2 font-mono text-[10px] uppercase tracking-[0.18em] text-white/40">
|
||||
<Sparkles className="h-3.5 w-3.5 text-cyan-300" />
|
||||
Featured shelf
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
{featuredEntries.map((entry) => (
|
||||
<button
|
||||
key={`featured:${entry.skill.skillKey}`}
|
||||
type="button"
|
||||
onClick={() => setDetailSkillKey(entry.skill.skillKey)}
|
||||
className="rounded border border-cyan-500/15 bg-gradient-to-br from-cyan-500/10 to-transparent px-3 py-3 text-left transition-colors hover:border-cyan-400/30"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<div className="font-mono text-[11px] font-semibold text-white/90">{entry.skill.name}</div>
|
||||
<div className="mt-1 font-mono text-[10px] text-cyan-100/75">{entry.metadata.tagline}</div>
|
||||
</div>
|
||||
<div className="rounded border border-cyan-500/20 px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-[0.12em] text-cyan-100/85">
|
||||
{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>
|
||||
<span>{entry.metadata.category}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!marketplace.loading && filteredCollections.length === 0 ? (
|
||||
<div className="mt-4 rounded border border-white/10 bg-white/[0.03] px-3 py-4 font-mono text-[11px] text-white/45">
|
||||
No matching skills found for this gateway.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!marketplace.loading &&
|
||||
filteredCollections.map((collection) => (
|
||||
<div key={collection.id} className="mt-4">
|
||||
<div className="mb-2 font-mono text-[10px] uppercase tracking-[0.18em] text-white/40">
|
||||
{collection.label}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{collection.entries.map((entry) => {
|
||||
const isEnabledForAgent = getAgentSkillEnabled(entry.skill.name, accessMode, allowlistSet);
|
||||
const primaryAction =
|
||||
entry.readiness === "needs-setup" && entry.installable
|
||||
? {
|
||||
label: "Install deps",
|
||||
run: () => void marketplace.handleInstallSkill(entry.skill),
|
||||
icon: Download,
|
||||
}
|
||||
: entry.readiness === "disabled-globally"
|
||||
? {
|
||||
label: "Enable gateway",
|
||||
run: () => void marketplace.handleSetSkillGlobalEnabled(entry.skill.skillKey, true),
|
||||
icon: Settings2,
|
||||
}
|
||||
: entry.readiness === "needs-setup"
|
||||
? {
|
||||
label: "Open settings",
|
||||
run: () => {
|
||||
if (marketplace.selectedAgentId) {
|
||||
onOpenAgentSettings(marketplace.selectedAgentId);
|
||||
}
|
||||
},
|
||||
icon: Settings2,
|
||||
}
|
||||
: null;
|
||||
const PrimaryIcon = primaryAction?.icon ?? Settings2;
|
||||
return (
|
||||
<div
|
||||
key={`${collection.id}:${entry.skill.skillKey}`}
|
||||
className="rounded border border-white/8 bg-white/[0.03] px-3 py-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDetailSkillKey(entry.skill.skillKey)}
|
||||
className="truncate font-mono text-[11px] font-semibold text-white/90 transition-colors hover:text-cyan-100"
|
||||
>
|
||||
{entry.skill.name}
|
||||
</button>
|
||||
<span className="rounded bg-white/[0.05] px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-[0.12em] text-white/45">
|
||||
{entry.metadata.category}
|
||||
</span>
|
||||
<span
|
||||
className={`rounded border px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-[0.12em] ${READINESS_CLASSES[entry.readiness]}`}
|
||||
>
|
||||
{READINESS_LABELS[entry.readiness]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 font-mono text-[10px] text-white/65">{entry.metadata.tagline}</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-3 font-mono text-[10px] text-white/45">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<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>
|
||||
<span>{entry.skill.source}</span>
|
||||
</div>
|
||||
{entry.missingDetails.length > 0 ? (
|
||||
<div className="mt-2 font-mono text-[10px] text-amber-100/85">
|
||||
{entry.missingDetails[0]}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void marketplace.handleSetSkillEnabled(entry.skill.name, !isEnabledForAgent)}
|
||||
disabled={
|
||||
entry.readiness === "unavailable" ||
|
||||
!marketplace.selectedAgentId ||
|
||||
marketplace.busySkillKey === entry.skill.skillKey
|
||||
}
|
||||
className={`rounded border px-2 py-1 font-mono text-[10px] uppercase tracking-[0.14em] transition-colors disabled:cursor-not-allowed disabled:opacity-45 ${
|
||||
isEnabledForAgent
|
||||
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-100"
|
||||
: "border-white/10 bg-white/5 text-white/75 hover:bg-white/10"
|
||||
}`}
|
||||
>
|
||||
{isEnabledForAgent ? "Enabled for agent" : "Enable for agent"}
|
||||
</button>
|
||||
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
{primaryAction ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={primaryAction.run}
|
||||
disabled={marketplace.busySkillKey === entry.skill.skillKey}
|
||||
className="inline-flex items-center gap-1 rounded border border-cyan-500/25 bg-cyan-500/10 px-2 py-1 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"
|
||||
>
|
||||
<PrimaryIcon className="h-3.5 w-3.5" />
|
||||
{primaryAction.label}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{entry.removable ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void marketplace.handleRemoveSkill(entry.skill)}
|
||||
disabled={marketplace.busySkillKey === entry.skill.skillKey}
|
||||
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
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDetailSkillKey(entry.skill.skillKey)}
|
||||
className="rounded border border-white/10 bg-white/5 px-2 py-1 font-mono text-[10px] uppercase tracking-[0.14em] text-white/75 transition-colors hover:bg-white/10"
|
||||
>
|
||||
Details
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{detailEntry ? (
|
||||
<div className="absolute inset-0 z-10 flex flex-col bg-[#050607]/96">
|
||||
<div className="flex items-start justify-between border-b border-cyan-500/10 px-4 py-3">
|
||||
<div>
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-white/40">
|
||||
Skill detail
|
||||
</div>
|
||||
<div className="mt-1 font-mono text-[14px] font-semibold text-white/90">
|
||||
{detailEntry.skill.name}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDetailSkillKey(null)}
|
||||
className="rounded border border-white/10 bg-white/5 p-1.5 text-white/70 transition-colors hover:bg-white/10 hover:text-white"
|
||||
aria-label="Close marketplace detail"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
|
||||
<div className="rounded border border-white/8 bg-white/[0.03] px-3 py-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="rounded bg-cyan-500/10 px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-[0.12em] text-cyan-100">
|
||||
{detailEntry.metadata.category}
|
||||
</span>
|
||||
<span className="rounded bg-white/[0.05] px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-[0.12em] text-white/55">
|
||||
{detailEntry.metadata.trustLabel}
|
||||
</span>
|
||||
<span
|
||||
className={`rounded border px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-[0.12em] ${READINESS_CLASSES[detailEntry.readiness]}`}
|
||||
>
|
||||
{READINESS_LABELS[detailEntry.readiness]}
|
||||
</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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-white/40">
|
||||
Capabilities
|
||||
</div>
|
||||
<div className="mt-2 flex flex-col gap-2">
|
||||
{detailEntry.metadata.capabilities.map((capability) => (
|
||||
<div
|
||||
key={capability}
|
||||
className="rounded border border-white/8 bg-white/[0.03] px-3 py-2 font-mono text-[10px] text-white/70"
|
||||
>
|
||||
{capability}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{detailEntry.missingDetails.length > 0 ? (
|
||||
<div className="mt-4">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-white/40">
|
||||
Setup notes
|
||||
</div>
|
||||
<div className="mt-2 flex flex-col gap-2">
|
||||
{detailEntry.missingDetails.map((line) => (
|
||||
<div
|
||||
key={line}
|
||||
className="rounded border border-amber-500/20 bg-amber-500/10 px-3 py-2 font-mono text-[10px] text-amber-100"
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : 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.
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{detailEntry.readiness === "needs-setup" && detailEntry.installable ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void marketplace.handleInstallSkill(detailEntry.skill)}
|
||||
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 dependencies
|
||||
</button>
|
||||
) : null}
|
||||
{detailEntry.readiness === "disabled-globally" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void marketplace.handleSetSkillGlobalEnabled(detailEntry.skill.skillKey, true)
|
||||
}
|
||||
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"
|
||||
>
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
Enable for gateway
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
disabled={!marketplace.selectedAgentId}
|
||||
onClick={() => {
|
||||
if (marketplace.selectedAgentId) {
|
||||
onOpenAgentSettings(marketplace.selectedAgentId);
|
||||
}
|
||||
}}
|
||||
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 disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
Manage in settings
|
||||
</button>
|
||||
{detailEntry.skill.homepage ? (
|
||||
<a
|
||||
href={detailEntry.skill.homepage}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
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"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
Homepage
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user