First Release of Claw3D (#11)

Co-authored-by: iamlukethedev <iamlukethedev@users.noreply.github.com>
This commit is contained in:
Luke The Dev
2026-03-19 23:14:04 -05:00
committed by GitHub
parent 5ea96b2650
commit 4fa4f13558
431 changed files with 105438 additions and 14 deletions
@@ -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&apos;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>
);
}