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>
);
}
@@ -0,0 +1,155 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import type { AgentState } from "@/features/agents/state/store";
import {
parseExecApprovalRequested,
parseExecApprovalResolved,
resolveExecApprovalAgentId,
} from "@/features/agents/approvals/execApprovalEvents";
import type { ExecApprovalDecision } from "@/features/agents/approvals/types";
import type { GatewayClient, GatewayStatus } from "@/lib/gateway/GatewayClient";
export type ApprovalRecord = {
id: string;
agentId: string | null;
sessionKey: string | null;
command: string;
createdAtMs: number;
expiresAtMs: number;
resolvedAtMs: number | null;
decision: ExecApprovalDecision | null;
resolvedBy: string | null;
};
export type ApprovalAgentMetrics = {
agentId: string;
requestedCount: number;
resolvedCount: number;
deniedCount: number;
allowOnceCount: number;
allowAlwaysCount: number;
};
const MAX_APPROVAL_RECORDS = 300;
export const useApprovalMetrics = ({
client,
status,
agents,
}: {
client: GatewayClient;
status: GatewayStatus;
agents: AgentState[];
}) => {
const [records, setRecords] = useState<ApprovalRecord[]>([]);
const agentsRef = useRef(agents);
useEffect(() => {
agentsRef.current = agents;
}, [agents]);
useEffect(() => {
if (status !== "connected") return;
return client.onEvent((event) => {
const requested = parseExecApprovalRequested(event);
if (requested) {
const agentId = resolveExecApprovalAgentId({
requested,
agents: agentsRef.current,
});
const nextRecord: ApprovalRecord = {
id: requested.id,
agentId,
sessionKey: requested.request.sessionKey,
command: requested.request.command,
createdAtMs: requested.createdAtMs,
expiresAtMs: requested.expiresAtMs,
resolvedAtMs: null,
decision: null,
resolvedBy: null,
};
setRecords((current) => {
const withoutExisting = current.filter((record) => record.id !== requested.id);
return [nextRecord, ...withoutExisting].slice(0, MAX_APPROVAL_RECORDS);
});
return;
}
const resolved = parseExecApprovalResolved(event);
if (!resolved) return;
setRecords((current) => {
let updated = false;
const next = current.map((record) => {
if (record.id !== resolved.id) return record;
updated = true;
return {
...record,
resolvedAtMs: resolved.ts,
decision: resolved.decision,
resolvedBy: resolved.resolvedBy,
};
});
if (updated) return next;
const fallbackRecord: ApprovalRecord = {
id: resolved.id,
agentId: null,
sessionKey: null,
command: "Unknown command",
createdAtMs: resolved.ts,
expiresAtMs: resolved.ts,
resolvedAtMs: resolved.ts,
decision: resolved.decision,
resolvedBy: resolved.resolvedBy,
};
return [fallbackRecord, ...current].slice(0, MAX_APPROVAL_RECORDS);
});
});
}, [client, status]);
const byAgent = useMemo(() => {
const metrics = new Map<string, ApprovalAgentMetrics>();
for (const record of records) {
const agentId = record.agentId?.trim() ?? "";
if (!agentId) continue;
const current = metrics.get(agentId) ?? {
agentId,
requestedCount: 0,
resolvedCount: 0,
deniedCount: 0,
allowOnceCount: 0,
allowAlwaysCount: 0,
};
current.requestedCount += 1;
if (record.decision) {
current.resolvedCount += 1;
if (record.decision === "deny") current.deniedCount += 1;
if (record.decision === "allow-once") current.allowOnceCount += 1;
if (record.decision === "allow-always") current.allowAlwaysCount += 1;
}
metrics.set(agentId, current);
}
return Array.from(metrics.values()).sort((left, right) => {
if (right.requestedCount !== left.requestedCount) {
return right.requestedCount - left.requestedCount;
}
return left.agentId.localeCompare(right.agentId);
});
}, [records]);
const totals = useMemo(() => {
return {
requestedCount: records.length,
resolvedCount: records.filter((record) => record.decision !== null).length,
deniedCount: records.filter((record) => record.decision === "deny").length,
allowOnceCount: records.filter((record) => record.decision === "allow-once").length,
allowAlwaysCount: records.filter((record) => record.decision === "allow-always").length,
};
}, [records]);
return {
records,
byAgent,
totals,
};
};
@@ -0,0 +1,300 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { AgentState } from "@/features/agents/state/store";
import type { GatewayClient, GatewayStatus } from "@/lib/gateway/GatewayClient";
import { readGatewayAgentSkillsAllowlist } from "@/lib/gateway/agentConfig";
import { isGatewayDisconnectLikeError } from "@/lib/gateway/GatewayClient";
import { setAgentSkillEnabled } from "@/lib/skills/agentAccess";
import { resolvePreferredInstallOption } from "@/lib/skills/presentation";
import { removeSkillFromGateway } from "@/lib/skills/remove";
import {
installSkill,
loadAgentSkillStatus,
updateSkill,
type SkillStatusEntry,
type SkillStatusReport,
} from "@/lib/skills/types";
type MarketplaceMessage = {
kind: "success" | "error";
text: string;
};
export const useOfficeSkillsMarketplace = ({
client,
status,
agents,
preferredAgentId,
onSkillActivityStart,
onSkillActivityEnd,
}: {
client: GatewayClient;
status: GatewayStatus;
agents: AgentState[];
preferredAgentId?: string | null;
onSkillActivityStart?: (agentId: string) => void;
onSkillActivityEnd?: (agentId: string) => void;
}) => {
const requestIdRef = useRef(0);
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(
preferredAgentId ?? null,
);
const [skillsReport, setSkillsReport] = useState<SkillStatusReport | null>(
null,
);
const [skillsAllowlist, setSkillsAllowlist] = useState<string[] | undefined>(
undefined,
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [busySkillKey, setBusySkillKey] = useState<string | null>(null);
const [message, setMessage] = useState<MarketplaceMessage | null>(null);
const selectedAgent = useMemo(
() => agents.find((agent) => agent.agentId === selectedAgentId) ?? null,
[agents, selectedAgentId],
);
useEffect(() => {
const preferred = (preferredAgentId ?? "").trim();
const current = (selectedAgentId ?? "").trim();
const hasCurrent =
current.length > 0 && agents.some((agent) => agent.agentId === current);
if (hasCurrent) {
return;
}
if (preferred && agents.some((agent) => agent.agentId === preferred)) {
setSelectedAgentId(preferred);
return;
}
setSelectedAgentId(agents[0]?.agentId ?? null);
}, [agents, preferredAgentId, selectedAgentId]);
const loadMarketplace = useCallback(
async (agentId: string) => {
const resolvedAgentId = agentId.trim();
if (!resolvedAgentId || status !== "connected") {
setSkillsReport(null);
setSkillsAllowlist(undefined);
setLoading(false);
return;
}
const requestId = requestIdRef.current + 1;
requestIdRef.current = requestId;
setLoading(true);
setError(null);
try {
const [report, allowlist] = await Promise.all([
loadAgentSkillStatus(client, resolvedAgentId),
readGatewayAgentSkillsAllowlist({
client,
agentId: resolvedAgentId,
}),
]);
if (requestId !== requestIdRef.current) {
return;
}
setSkillsReport(report);
setSkillsAllowlist(allowlist);
} catch (err) {
if (requestId !== requestIdRef.current) {
return;
}
const nextMessage =
err instanceof Error
? err.message
: "Failed to load skills marketplace data.";
setSkillsReport(null);
setSkillsAllowlist(undefined);
setError(nextMessage);
if (!isGatewayDisconnectLikeError(err)) {
console.error(nextMessage);
}
} finally {
if (requestId === requestIdRef.current) {
setLoading(false);
}
}
},
[client, status],
);
useEffect(() => {
if (!selectedAgentId || status !== "connected") {
requestIdRef.current += 1;
setSkillsReport(null);
setSkillsAllowlist(undefined);
setLoading(false);
return;
}
void loadMarketplace(selectedAgentId);
}, [loadMarketplace, selectedAgentId, status]);
const refresh = useCallback(async () => {
if (!selectedAgentId) {
return;
}
await loadMarketplace(selectedAgentId);
}, [loadMarketplace, selectedAgentId]);
const runSkillMutation = useCallback(
async (params: {
skillKey: string;
successMessage: string;
run: (agentId: string, report: SkillStatusReport) => Promise<void>;
}) => {
const agentId = selectedAgentId?.trim() ?? "";
const report = skillsReport;
const normalizedSkillKey = params.skillKey.trim();
if (!agentId || !report) {
setMessage({
kind: "error",
text: "Select an agent before managing marketplace skills.",
});
return;
}
setBusySkillKey(normalizedSkillKey);
setError(null);
setMessage(null);
onSkillActivityStart?.(agentId);
try {
await params.run(agentId, report);
await loadMarketplace(agentId);
setMessage({
kind: "success",
text: params.successMessage,
});
} catch (err) {
const nextMessage =
err instanceof Error
? err.message
: "Failed to update the skill.";
setError(nextMessage);
setMessage({
kind: "error",
text: nextMessage,
});
if (!isGatewayDisconnectLikeError(err)) {
console.error(nextMessage);
}
} finally {
onSkillActivityEnd?.(agentId);
setBusySkillKey((current) =>
current === normalizedSkillKey ? null : current,
);
}
},
[loadMarketplace, onSkillActivityEnd, onSkillActivityStart, selectedAgentId, skillsReport],
);
const handleSetSkillEnabled = useCallback(
async (skillName: string, enabled: boolean) => {
const entry =
skillsReport?.skills.find(
(skill) => skill.name.trim() === skillName.trim(),
) ?? null;
await runSkillMutation({
skillKey: entry?.skillKey ?? skillName,
successMessage: enabled
? `Enabled ${skillName.trim()} for ${selectedAgent?.name ?? "the selected agent"}.`
: `Removed ${skillName.trim()} from ${selectedAgent?.name ?? "the selected agent"}.`,
run: async (agentId, report) => {
await setAgentSkillEnabled({
client,
agentId,
skillName,
enabled,
visibleSkills: report.skills,
});
},
});
},
[client, runSkillMutation, selectedAgent?.name, skillsReport],
);
const handleInstallSkill = useCallback(
async (skill: SkillStatusEntry) => {
const installOption = resolvePreferredInstallOption(skill);
if (!installOption) {
setMessage({
kind: "error",
text: `No guided install is available for ${skill.name.trim()}.`,
});
return;
}
await runSkillMutation({
skillKey: skill.skillKey,
successMessage: `Installed dependencies for ${skill.name.trim()}.`,
run: async () => {
await installSkill(client, {
name: skill.name,
installId: installOption.id,
timeoutMs: 120_000,
});
},
});
},
[client, runSkillMutation],
);
const handleSetSkillGlobalEnabled = useCallback(
async (skillKey: string, enabled: boolean) => {
await runSkillMutation({
skillKey,
successMessage: enabled
? "Skill enabled for this gateway."
: "Skill disabled for this gateway.",
run: async () => {
await updateSkill(client, { skillKey, enabled });
},
});
},
[client, runSkillMutation],
);
const handleRemoveSkill = useCallback(
async (skill: SkillStatusEntry) => {
await runSkillMutation({
skillKey: skill.skillKey,
successMessage: `${skill.name.trim()} removed from gateway files.`,
run: async (_agentId, report) => {
await removeSkillFromGateway({
skillKey: skill.skillKey,
source: skill.source as
| "openclaw-managed"
| "openclaw-workspace",
baseDir: skill.baseDir,
workspaceDir: report.workspaceDir,
managedSkillsDir: report.managedSkillsDir,
});
},
});
},
[runSkillMutation],
);
return {
agents,
selectedAgent,
selectedAgentId,
setSelectedAgentId,
skillsReport,
skillsAllowlist,
loading,
error,
busySkillKey,
message,
refresh,
handleSetSkillEnabled,
handleInstallSkill,
handleSetSkillGlobalEnabled,
handleRemoveSkill,
};
};
export type OfficeSkillsMarketplaceController = ReturnType<
typeof useOfficeSkillsMarketplace
>;
@@ -0,0 +1,429 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { fetchJson } from "@/lib/http";
import type {
StandupAgentSnapshot,
StandupMeeting,
} from "@/lib/office/standup/types";
import type {
StudioStandupPreferencePatch,
StudioStandupPreferencePublic,
} from "@/lib/studio/settings";
type StandupConfigResponse = {
gatewayUrl: string;
config: StudioStandupPreferencePublic;
};
type StandupMeetingResponse = {
meeting: StandupMeeting | null;
};
const splitCronExpr = (expr: string) => expr.trim().split(/\s+/);
const expandRange = (segment: string): number[] => {
if (segment.includes("-")) {
const [startRaw, endRaw] = segment.split("-");
const start = Number(startRaw);
const end = Number(endRaw);
if (!Number.isFinite(start) || !Number.isFinite(end)) return [];
const values: number[] = [];
for (let value = start; value <= end; value += 1) values.push(value);
return values;
}
const numeric = Number(segment);
return Number.isFinite(numeric) ? [numeric] : [];
};
const matchesCronPart = (part: string, value: number) => {
if (part === "*") return true;
return part
.split(",")
.some((segment) => expandRange(segment.trim()).includes(value));
};
const getZonedParts = (date: Date, timeZone: string) => {
const formatter = new Intl.DateTimeFormat("en-US", {
timeZone: timeZone || "UTC",
minute: "numeric",
hour: "numeric",
day: "numeric",
month: "numeric",
weekday: "short",
hour12: false,
});
const parts = formatter.formatToParts(date);
const get = (type: Intl.DateTimeFormatPartTypes) =>
parts.find((part) => part.type === type)?.value ?? "";
const weekdayMap: Record<string, number> = {
Sun: 0,
Mon: 1,
Tue: 2,
Wed: 3,
Thu: 4,
Fri: 5,
Sat: 6,
};
return {
minute: Number(get("minute")),
hour: Number(get("hour")),
day: Number(get("day")),
month: Number(get("month")),
weekday: weekdayMap[get("weekday")] ?? -1,
};
};
const shouldRunNow = (config: StudioStandupPreferencePublic, now: Date) => {
if (!config.schedule.enabled) return false;
const parts = splitCronExpr(config.schedule.cronExpr);
if (parts.length !== 5) return false;
const zoned = getZonedParts(now, config.schedule.timezone || "UTC");
return (
matchesCronPart(parts[0] ?? "*", zoned.minute) &&
matchesCronPart(parts[1] ?? "*", zoned.hour) &&
matchesCronPart(parts[2] ?? "*", zoned.day) &&
matchesCronPart(parts[3] ?? "*", zoned.month) &&
matchesCronPart(parts[4] ?? "*", zoned.weekday)
);
};
const sameScheduledMinute = (leftIso: string | null, right: Date, timeZone: string) => {
if (!leftIso) return false;
const leftDate = new Date(leftIso);
if (Number.isNaN(leftDate.getTime())) return false;
const left = getZonedParts(leftDate, timeZone || "UTC");
const next = getZonedParts(right, timeZone || "UTC");
return (
left.minute === next.minute &&
left.hour === next.hour &&
left.day === next.day &&
left.month === next.month
);
};
const everyoneArrived = (meeting: StandupMeeting | null) => {
if (!meeting) return false;
return meeting.participantOrder.every((agentId) =>
meeting.arrivedAgentIds.includes(agentId)
);
};
const isMeetingActive = (meeting: StandupMeeting | null) =>
meeting?.phase === "gathering" || meeting?.phase === "in_progress";
export type OfficeStandupController = {
config: StudioStandupPreferencePublic | null;
meeting: StandupMeeting | null;
loading: boolean;
saving: boolean;
error: string | null;
saveConfig: (patch: StudioStandupPreferencePatch) => Promise<void>;
updateManualEntry: (
agentId: string,
patch: Partial<StudioStandupPreferencePublic["manualByAgentId"][string]>
) => Promise<void>;
startMeeting: (trigger?: "manual" | "scheduled") => Promise<void>;
reportArrivals: (arrivedAgentIds: string[]) => Promise<void>;
openBoardByDefault: boolean;
refreshMeeting: () => Promise<void>;
};
export const useOfficeStandupController = (params: {
gatewayUrl: string;
agents: StandupAgentSnapshot[];
}): OfficeStandupController => {
const { gatewayUrl, agents } = params;
const [config, setConfig] = useState<StudioStandupPreferencePublic | null>(null);
const [meeting, setMeeting] = useState<StandupMeeting | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const lastArrivalsRef = useRef("");
const meetingRef = useRef<StandupMeeting | null>(null);
const configRefreshInFlightRef = useRef<Promise<void> | null>(null);
const meetingRefreshInFlightRef = useRef<Promise<void> | null>(null);
const pageVisible = () =>
typeof document === "undefined" || document.visibilityState === "visible";
useEffect(() => {
meetingRef.current = meeting;
}, [meeting]);
const refreshConfig = useCallback(async (options?: { allowHidden?: boolean }) => {
if (!gatewayUrl.trim()) return;
if (!options?.allowHidden && !pageVisible()) return;
if (configRefreshInFlightRef.current) return configRefreshInFlightRef.current;
const task = (async () => {
const payload = await fetchJson<StandupConfigResponse>(
`/api/office/standup/config?gatewayUrl=${encodeURIComponent(gatewayUrl)}`,
{ cache: "no-store" }
);
setConfig(payload.config);
})();
configRefreshInFlightRef.current = task.finally(() => {
configRefreshInFlightRef.current = null;
});
return configRefreshInFlightRef.current;
}, [gatewayUrl]);
const refreshMeeting = useCallback(async (options?: { allowHidden?: boolean }) => {
if (!options?.allowHidden && !pageVisible()) return;
if (meetingRefreshInFlightRef.current) return meetingRefreshInFlightRef.current;
const task = (async () => {
const payload = await fetchJson<StandupMeetingResponse>(
"/api/office/standup/meeting",
{ cache: "no-store" }
);
setMeeting(payload.meeting);
})();
meetingRefreshInFlightRef.current = task.finally(() => {
meetingRefreshInFlightRef.current = null;
});
return meetingRefreshInFlightRef.current;
}, []);
useEffect(() => {
let cancelled = false;
setLoading(true);
Promise.all([refreshConfig({ allowHidden: true }), refreshMeeting({ allowHidden: true })])
.catch((err) => {
if (!cancelled) {
setError(
err instanceof Error ? err.message : "Failed to load standup state."
);
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [refreshConfig, refreshMeeting]);
useEffect(() => {
const intervalId = window.setInterval(() => {
if (!pageVisible()) return;
void refreshMeeting().catch((err) => {
setError(
err instanceof Error ? err.message : "Failed to refresh standup meeting."
);
});
}, isMeetingActive(meeting) ? 8000 : 60000);
return () => {
window.clearInterval(intervalId);
};
}, [meeting, refreshMeeting]);
useEffect(() => {
const handleVisibilityOrFocus = () => {
if (!pageVisible()) return;
void Promise.all([refreshConfig(), refreshMeeting()]).catch((err) => {
setError(
err instanceof Error ? err.message : "Failed to refresh standup state."
);
});
};
window.addEventListener("focus", handleVisibilityOrFocus);
document.addEventListener("visibilitychange", handleVisibilityOrFocus);
return () => {
window.removeEventListener("focus", handleVisibilityOrFocus);
document.removeEventListener("visibilitychange", handleVisibilityOrFocus);
};
}, [refreshConfig, refreshMeeting]);
const saveConfig = useCallback(
async (patch: StudioStandupPreferencePatch) => {
if (!gatewayUrl.trim()) return;
setSaving(true);
try {
const payload = await fetchJson<StandupConfigResponse>(
"/api/office/standup/config",
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
gatewayUrl,
config: patch,
}),
}
);
setConfig(payload.config);
} finally {
setSaving(false);
}
},
[gatewayUrl]
);
const updateManualEntry = useCallback(
async (
agentId: string,
patch: Partial<StudioStandupPreferencePublic["manualByAgentId"][string]>
) => {
await saveConfig({
manualByAgentId: {
[agentId]: {
...patch,
updatedAt: new Date().toISOString(),
},
},
});
},
[saveConfig]
);
const startMeeting = useCallback(
async (trigger: "manual" | "scheduled" = "manual") => {
if (!gatewayUrl.trim()) return;
const payload = await fetchJson<StandupMeetingResponse>(
"/api/office/standup/run",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
gatewayUrl,
trigger,
agents,
}),
}
);
setMeeting(payload.meeting);
if (trigger === "scheduled") {
setConfig((current) =>
current
? {
...current,
schedule: {
...current.schedule,
lastAutoRunAt: new Date().toISOString(),
},
}
: current
);
}
lastArrivalsRef.current = "";
},
[agents, gatewayUrl]
);
const reportArrivals = useCallback(async (arrivedAgentIds: string[]) => {
const deduped = Array.from(new Set(arrivedAgentIds)).sort();
const nextKey = deduped.join("|");
if (nextKey === lastArrivalsRef.current) return;
lastArrivalsRef.current = nextKey;
const payload = await fetchJson<StandupMeetingResponse>(
"/api/office/standup/meeting",
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "arrivals",
arrivedAgentIds: deduped,
}),
}
);
setMeeting(payload.meeting);
}, []);
useEffect(() => {
if (!config) return;
const intervalId = window.setInterval(() => {
if (!pageVisible()) return;
const now = new Date();
if (isMeetingActive(meetingRef.current)) return;
if (!shouldRunNow(config, now)) return;
if (
sameScheduledMinute(
config.schedule.lastAutoRunAt,
now,
config.schedule.timezone || "UTC"
)
) {
return;
}
void startMeeting("scheduled").catch((err) => {
setError(
err instanceof Error ? err.message : "Failed to start the scheduled standup."
);
});
}, 60000);
return () => {
window.clearInterval(intervalId);
};
}, [config, startMeeting]);
useEffect(() => {
if (!meeting || meeting.phase !== "gathering") return;
if (!everyoneArrived(meeting)) return;
const firstSpeaker = meeting.participantOrder[0] ?? null;
if (!firstSpeaker) return;
void fetchJson<StandupMeetingResponse>("/api/office/standup/meeting", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "start",
speakerAgentId: firstSpeaker,
}),
})
.then((payload) => setMeeting(payload.meeting))
.catch((err) => {
setError(
err instanceof Error ? err.message : "Failed to start standup speaking."
);
});
}, [meeting]);
useEffect(() => {
if (!meeting || meeting.phase !== "in_progress") return;
const startedAt = meeting.speakerStartedAt
? Date.parse(meeting.speakerStartedAt)
: Number.NaN;
if (!Number.isFinite(startedAt)) return;
const elapsed = Date.now() - startedAt;
const remaining = Math.max(0, meeting.speakerDurationMs - elapsed);
const timerId = window.setTimeout(() => {
const currentIndex = meeting.currentSpeakerAgentId
? meeting.participantOrder.indexOf(meeting.currentSpeakerAgentId)
: -1;
const isLastSpeaker = currentIndex >= meeting.participantOrder.length - 1;
const action = isLastSpeaker ? "complete" : "advance";
void fetchJson<StandupMeetingResponse>("/api/office/standup/meeting", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action }),
})
.then((payload) => setMeeting(payload.meeting))
.catch((err) => {
setError(
err instanceof Error ? err.message : "Failed to advance standup progress."
);
});
}, remaining + 50);
return () => {
window.clearTimeout(timerId);
};
}, [meeting]);
const openBoardByDefault = useMemo(
() => Boolean(config?.schedule.autoOpenBoard),
[config?.schedule.autoOpenBoard]
);
return {
config,
meeting,
loading,
saving,
error,
saveConfig,
updateManualEntry,
startMeeting,
reportArrivals,
openBoardByDefault,
refreshMeeting,
};
};
@@ -0,0 +1,111 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import type { AgentState } from "@/features/agents/state/store";
import type { StudioSettingsCoordinator } from "@/lib/studio/coordinator";
import {
defaultStudioAnalyticsPreference,
resolveAnalyticsPreference,
type StudioAnalyticsBudgetSettings,
} from "@/lib/studio/settings";
import type { GatewayClient, GatewayStatus } from "@/lib/gateway/GatewayClient";
import { useUsageAnalytics } from "@/features/office/hooks/useUsageAnalytics";
import { getDefaultUsageAnalyticsRange } from "@/lib/office/usageAnalyticsPresentation";
export type OfficeUsageAnalyticsParams = {
client: GatewayClient;
status: GatewayStatus;
agents: AgentState[];
gatewayUrl: string;
settingsCoordinator: StudioSettingsCoordinator;
};
export const useOfficeUsageAnalyticsViewModel = ({
client,
status,
agents,
gatewayUrl,
settingsCoordinator,
}: OfficeUsageAnalyticsParams) => {
const defaultRange = getDefaultUsageAnalyticsRange();
const [startDate, setStartDate] = useState(defaultRange.startDate);
const [endDate, setEndDate] = useState(defaultRange.endDate);
const [budgets, setBudgets] = useState<StudioAnalyticsBudgetSettings>(
defaultStudioAnalyticsPreference().budgets
);
const [settingsLoaded, setSettingsLoaded] = useState(false);
useEffect(() => {
let cancelled = false;
const gatewayKey = gatewayUrl.trim();
if (!gatewayKey) {
setBudgets(defaultStudioAnalyticsPreference().budgets);
setSettingsLoaded(true);
return;
}
void (async () => {
try {
const settings = await settingsCoordinator.loadSettings({ maxAgeMs: 30_000 });
if (cancelled || !settings) return;
const analyticsPreference = resolveAnalyticsPreference(settings, gatewayKey);
setBudgets(analyticsPreference.budgets);
} finally {
if (!cancelled) {
setSettingsLoaded(true);
}
}
})();
return () => {
cancelled = true;
};
}, [gatewayUrl, settingsCoordinator]);
const usage = useUsageAnalytics({
client,
status,
agents,
startDate,
endDate,
budgets,
});
const saveBudgets = useCallback(
(nextBudgets: StudioAnalyticsBudgetSettings) => {
const key = gatewayUrl.trim();
if (!key) return;
settingsCoordinator.schedulePatch(
{
analytics: {
[key]: {
budgets: nextBudgets,
},
},
},
150
);
},
[gatewayUrl, settingsCoordinator]
);
const updateBudget = useCallback(
(key: keyof StudioAnalyticsBudgetSettings, value: number | null) => {
setBudgets((current) => {
const next = { ...current, [key]: value };
saveBudgets(next);
return next;
});
},
[saveBudgets]
);
return {
startDate,
setStartDate,
endDate,
setEndDate,
budgets,
settingsLoaded,
usage,
updateBudget,
};
};
@@ -0,0 +1,113 @@
"use client";
import { useMemo } from "react";
import type { AgentState } from "@/features/agents/state/store";
import type { ApprovalAgentMetrics } from "@/features/office/hooks/useApprovalMetrics";
import type { RunRecord } from "@/features/office/hooks/useRunLog";
export type AgentPerformanceRow = {
agentId: string;
agentName: string;
totalRuns: number;
completedRuns: number;
successRate: number | null;
avgRuntimeMs: number | null;
toolCalls: number;
approvalRequestedCount: number;
interventionRate: number | null;
};
const average = (values: number[]): number | null => {
if (values.length === 0) return null;
return values.reduce((sum, value) => sum + value, 0) / values.length;
};
export const usePerformanceAnalytics = ({
agents,
runLog,
approvalByAgent,
}: {
agents: AgentState[];
runLog: RunRecord[];
approvalByAgent: ApprovalAgentMetrics[];
}) => {
return useMemo(() => {
const approvalsByAgentId = new Map(
approvalByAgent.map((entry) => [entry.agentId, entry])
);
const rows: AgentPerformanceRow[] = agents.map((agent) => {
const runs = runLog.filter((record) => record.agentId === agent.agentId);
const completedRuns = runs.filter((record) => record.endedAt !== null);
const successfulRuns = completedRuns.filter((record) => record.outcome === "ok");
const runtimes = completedRuns
.map((record) =>
record.endedAt === null ? null : Math.max(0, record.endedAt - record.startedAt)
)
.filter((value): value is number => value !== null);
const approvalMetrics = approvalsByAgentId.get(agent.agentId);
const toolCalls =
agent.transcriptEntries?.filter((entry) => entry.kind === "tool").length ?? 0;
const successRate =
completedRuns.length > 0 ? successfulRuns.length / completedRuns.length : null;
const approvalRequestedCount = approvalMetrics?.requestedCount ?? 0;
const interventionRate =
runs.length > 0
? Math.min(1, approvalRequestedCount / Math.max(1, runs.length))
: null;
return {
agentId: agent.agentId,
agentName: agent.name || agent.agentId,
totalRuns: runs.length,
completedRuns: completedRuns.length,
successRate,
avgRuntimeMs: average(runtimes),
toolCalls,
approvalRequestedCount,
interventionRate,
};
});
const allCompletedRuns = runLog.filter((record) => record.endedAt !== null);
const allSuccessfulRuns = allCompletedRuns.filter((record) => record.outcome === "ok");
const allRuntimes = allCompletedRuns
.map((record) =>
record.endedAt === null ? null : Math.max(0, record.endedAt - record.startedAt)
)
.filter((value): value is number => value !== null);
const totalApprovalsRequested = approvalByAgent.reduce(
(sum, entry) => sum + entry.requestedCount,
0
);
const totalToolCalls = rows.reduce((sum, row) => sum + row.toolCalls, 0);
return {
fleet: {
totalRuns: runLog.length,
completedRuns: allCompletedRuns.length,
successRate:
allCompletedRuns.length > 0
? allSuccessfulRuns.length / allCompletedRuns.length
: null,
avgRuntimeMs: average(allRuntimes),
totalToolCalls,
totalApprovalsRequested,
interventionRate:
runLog.length > 0
? Math.min(1, totalApprovalsRequested / Math.max(1, runLog.length))
: null,
},
rows: rows.sort((left, right) => {
const leftCost = left.totalRuns;
const rightCost = right.totalRuns;
if (rightCost !== leftCost) return rightCost - leftCost;
return left.agentName.localeCompare(right.agentName);
}),
topToolUsers: [...rows]
.filter((row) => row.toolCalls > 0)
.sort((left, right) => right.toolCalls - left.toolCalls)
.slice(0, 5),
};
}, [agents, approvalByAgent, runLog]);
};
+132
View File
@@ -0,0 +1,132 @@
"use client";
import { useEffect, useRef, useState } from "react";
import type { AgentState } from "@/features/agents/state/store";
import type { AgentEventPayload } from "@/features/agents/state/runtimeEventBridge";
import type { GatewayClient, GatewayStatus } from "@/lib/gateway/GatewayClient";
import { isSameSessionKey } from "@/lib/gateway/GatewayClient";
import { isHeartbeatPrompt } from "@/lib/text/message-extract";
export type RunTriggerKind = "user" | "heartbeat" | "cron";
export type RunOutcomeKind = "ok" | "error" | null;
export type RunRecord = {
runId: string;
agentId: string;
agentName: string;
startedAt: number;
endedAt: number | null;
outcome: RunOutcomeKind;
trigger: RunTriggerKind;
};
const MAX_RUN_RECORDS = 200;
const resolveRunTrigger = (agent: AgentState): RunTriggerKind => {
if (agent.latestOverrideKind === "heartbeat" || agent.latestOverrideKind === "cron") {
return agent.latestOverrideKind;
}
const lastUserMessage = agent.lastUserMessage?.trim() ?? "";
if (lastUserMessage && isHeartbeatPrompt(lastUserMessage)) {
return "heartbeat";
}
return "user";
};
const resolveLifecyclePhase = (payload: AgentEventPayload): "start" | "end" | "error" | null => {
if (payload.stream !== "lifecycle") return null;
const phase = typeof payload.data?.phase === "string" ? payload.data.phase.trim() : "";
if (phase === "start" || phase === "end" || phase === "error") {
return phase;
}
return null;
};
const findAgentForRunEvent = (
agents: AgentState[],
payload: AgentEventPayload
): AgentState | null => {
const sessionKey = payload.sessionKey?.trim() ?? "";
if (sessionKey) {
const bySession = agents.find((agent) => isSameSessionKey(agent.sessionKey, sessionKey));
if (bySession) return bySession;
}
return agents.find((agent) => agent.runId === payload.runId) ?? null;
};
export const useRunLog = ({
client,
status,
agents,
maxRecords = MAX_RUN_RECORDS,
}: {
client: GatewayClient;
status: GatewayStatus;
agents: AgentState[];
maxRecords?: number;
}) => {
const [records, setRecords] = useState<RunRecord[]>([]);
const agentsRef = useRef(agents);
useEffect(() => {
agentsRef.current = agents;
}, [agents]);
useEffect(() => {
if (status !== "connected") return;
return client.onEvent((event) => {
if (event.event !== "agent") return;
const payload = event.payload as AgentEventPayload | undefined;
if (!payload?.runId) return;
const phase = resolveLifecyclePhase(payload);
if (!phase) return;
const agent = findAgentForRunEvent(agentsRef.current, payload);
if (!agent) return;
const timestamp = Date.now();
setRecords((current) => {
if (phase === "start") {
const nextRecord: RunRecord = {
runId: payload.runId,
agentId: agent.agentId,
agentName: agent.name || agent.agentId,
startedAt: timestamp,
endedAt: null,
outcome: null,
trigger: resolveRunTrigger(agent),
};
const withoutExisting = current.filter((record) => record.runId !== payload.runId);
return [nextRecord, ...withoutExisting].slice(0, Math.max(1, maxRecords));
}
let updated = false;
const next = current.map((record) => {
if (record.runId !== payload.runId) return record;
updated = true;
const outcome: RunOutcomeKind = phase === "error" ? "error" : "ok";
return {
...record,
endedAt: timestamp,
outcome,
};
});
if (updated) return next;
const fallbackOutcome: RunOutcomeKind = phase === "error" ? "error" : "ok";
const fallbackRecord: RunRecord = {
runId: payload.runId,
agentId: agent.agentId,
agentName: agent.name || agent.agentId,
startedAt: timestamp,
endedAt: timestamp,
outcome: fallbackOutcome,
trigger: resolveRunTrigger(agent),
};
return [fallbackRecord, ...current].slice(0, Math.max(1, maxRecords));
});
});
}, [client, maxRecords, status]);
return records;
};
@@ -0,0 +1,587 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { AgentState } from "@/features/agents/state/store";
import type {
StudioAnalyticsBudgetSettings,
} from "@/lib/studio/settings";
import type { GatewayClient, GatewayStatus } from "@/lib/gateway/GatewayClient";
type UsageMessageCounts = {
total: number;
user: number;
assistant: number;
toolCalls: number;
toolResults: number;
errors: number;
};
export type UsageTotals = {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
totalTokens: number;
totalCost: number;
inputCost: number;
outputCost: number;
cacheReadCost: number;
cacheWriteCost: number;
durationMs: number;
};
type UsageToolRecord = {
name: string;
count: number;
};
type UsageModelRecord = {
provider: string | null;
model: string | null;
count: number;
totals: UsageTotals;
};
type UsageDailyBreakdown = {
date: string;
tokens: number;
cost: number;
};
type UsageDailyMessageBreakdown = {
date: string;
total: number;
toolCalls: number;
errors: number;
};
type NormalizedSessionUsage = {
totals: UsageTotals;
messageCounts: UsageMessageCounts;
toolUsage: {
totalCalls: number;
tools: UsageToolRecord[];
};
modelUsage: UsageModelRecord[];
dailyBreakdown: UsageDailyBreakdown[];
dailyMessageCounts: UsageDailyMessageBreakdown[];
};
export type UsageSessionRow = {
key: string;
label: string | null;
agentId: string | null;
agentName: string | null;
channel: string | null;
model: string | null;
provider: string | null;
updatedAt: number | null;
usage: NormalizedSessionUsage;
};
export type CostDailyRow = {
date: string;
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
totalTokens: number;
inputCost: number;
outputCost: number;
cacheReadCost: number;
cacheWriteCost: number;
totalCost: number;
};
export type UsageAgentAggregate = {
agentId: string;
agentName: string;
totals: UsageTotals;
};
export type UsageDailyAggregate = {
date: string;
tokens: number;
cost: number;
messages: number;
toolCalls: number;
errors: number;
};
export type UsageBudgetAlert = {
key: "daily" | "monthly" | "per-agent";
severity: "warning" | "danger";
label: string;
currentUsd: number;
limitUsd: number;
};
type UsageSessionsResult = {
sessions?: unknown[];
totals?: unknown;
aggregates?: unknown;
};
type UsageCostResult = {
daily?: unknown[];
};
const EMPTY_TOTALS: UsageTotals = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
totalCost: 0,
inputCost: 0,
outputCost: 0,
cacheReadCost: 0,
cacheWriteCost: 0,
durationMs: 0,
};
const EMPTY_MESSAGE_COUNTS: UsageMessageCounts = {
total: 0,
user: 0,
assistant: 0,
toolCalls: 0,
toolResults: 0,
errors: 0,
};
const asRecord = (value: unknown): Record<string, unknown> | null =>
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
const asArray = (value: unknown): unknown[] => (Array.isArray(value) ? value : []);
const asNumber = (value: unknown): number => {
return typeof value === "number" && Number.isFinite(value) ? value : 0;
};
const asString = (value: unknown): string | null => {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed ? trimmed : null;
};
const normalizeTotals = (value: unknown): UsageTotals => {
const record = asRecord(value);
if (!record) return { ...EMPTY_TOTALS };
return {
input: asNumber(record.input),
output: asNumber(record.output),
cacheRead: asNumber(record.cacheRead),
cacheWrite: asNumber(record.cacheWrite),
totalTokens: asNumber(record.totalTokens),
totalCost: asNumber(record.totalCost),
inputCost: asNumber(record.inputCost),
outputCost: asNumber(record.outputCost),
cacheReadCost: asNumber(record.cacheReadCost),
cacheWriteCost: asNumber(record.cacheWriteCost),
durationMs: asNumber(record.durationMs),
};
};
const addTotals = (left: UsageTotals, right: UsageTotals): UsageTotals => {
return {
input: left.input + right.input,
output: left.output + right.output,
cacheRead: left.cacheRead + right.cacheRead,
cacheWrite: left.cacheWrite + right.cacheWrite,
totalTokens: left.totalTokens + right.totalTokens,
totalCost: left.totalCost + right.totalCost,
inputCost: left.inputCost + right.inputCost,
outputCost: left.outputCost + right.outputCost,
cacheReadCost: left.cacheReadCost + right.cacheReadCost,
cacheWriteCost: left.cacheWriteCost + right.cacheWriteCost,
durationMs: left.durationMs + right.durationMs,
};
};
const normalizeMessageCounts = (value: unknown): UsageMessageCounts => {
const record = asRecord(value);
if (!record) return { ...EMPTY_MESSAGE_COUNTS };
return {
total: asNumber(record.total),
user: asNumber(record.user),
assistant: asNumber(record.assistant),
toolCalls: asNumber(record.toolCalls),
toolResults: asNumber(record.toolResults),
errors: asNumber(record.errors),
};
};
const normalizeToolUsage = (value: unknown) => {
const record = asRecord(value);
const tools = asArray(record?.tools).map((entry) => {
const parsed = asRecord(entry);
return {
name: asString(parsed?.name) ?? "Unknown",
count: asNumber(parsed?.count),
};
});
return {
totalCalls: asNumber(record?.totalCalls),
tools: tools.filter((entry) => entry.count > 0),
};
};
const normalizeModelUsage = (value: unknown): UsageModelRecord[] => {
return asArray(value)
.map((entry) => {
const parsed = asRecord(entry);
return {
provider: asString(parsed?.provider),
model: asString(parsed?.model),
count: asNumber(parsed?.count),
totals: normalizeTotals(parsed?.totals),
};
})
.filter((entry) => entry.count > 0 || entry.totals.totalCost > 0 || entry.totals.totalTokens > 0);
};
const normalizeDailyBreakdown = (value: unknown): UsageDailyBreakdown[] => {
return asArray(value)
.map((entry) => {
const parsed = asRecord(entry);
return {
date: asString(parsed?.date) ?? "",
tokens: asNumber(parsed?.tokens),
cost: asNumber(parsed?.cost),
};
})
.filter((entry) => entry.date);
};
const normalizeDailyMessageCounts = (value: unknown): UsageDailyMessageBreakdown[] => {
return asArray(value)
.map((entry) => {
const parsed = asRecord(entry);
return {
date: asString(parsed?.date) ?? "",
total: asNumber(parsed?.total),
toolCalls: asNumber(parsed?.toolCalls),
errors: asNumber(parsed?.errors),
};
})
.filter((entry) => entry.date);
};
const normalizeSessionUsage = (value: unknown): NormalizedSessionUsage => {
const record = asRecord(value);
return {
totals: normalizeTotals(record),
messageCounts: normalizeMessageCounts(record?.messageCounts),
toolUsage: normalizeToolUsage(record?.toolUsage),
modelUsage: normalizeModelUsage(record?.modelUsage),
dailyBreakdown: normalizeDailyBreakdown(record?.dailyBreakdown),
dailyMessageCounts: normalizeDailyMessageCounts(record?.dailyMessageCounts),
};
};
const normalizeDateString = (value: string) => {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return "";
return parsed.toISOString().slice(0, 10);
};
const normalizeCostDaily = (value: unknown): CostDailyRow[] => {
return asArray(value)
.map((entry) => {
const parsed = asRecord(entry);
return {
date: normalizeDateString(asString(parsed?.date) ?? ""),
input: asNumber(parsed?.input),
output: asNumber(parsed?.output),
cacheRead: asNumber(parsed?.cacheRead),
cacheWrite: asNumber(parsed?.cacheWrite),
totalTokens: asNumber(parsed?.totalTokens),
inputCost: asNumber(parsed?.inputCost),
outputCost: asNumber(parsed?.outputCost),
cacheReadCost: asNumber(parsed?.cacheReadCost),
cacheWriteCost: asNumber(parsed?.cacheWriteCost),
totalCost: asNumber(parsed?.totalCost),
};
})
.filter((entry) => entry.date);
};
const startOfToday = () => new Date().toISOString().slice(0, 10);
const startOfCurrentMonth = () => {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
return `${year}-${month}`;
};
const calculateBudgetAlerts = (params: {
budgets: StudioAnalyticsBudgetSettings;
costDaily: CostDailyRow[];
byAgent: UsageAgentAggregate[];
}): UsageBudgetAlert[] => {
const alerts: UsageBudgetAlert[] = [];
const thresholdRatio = params.budgets.alertThresholdPct / 100;
const today = startOfToday();
const monthPrefix = startOfCurrentMonth();
const todayCost = params.costDaily
.filter((entry) => entry.date === today)
.reduce((sum, entry) => sum + entry.totalCost, 0);
const monthlyCost = params.costDaily
.filter((entry) => entry.date.startsWith(monthPrefix))
.reduce((sum, entry) => sum + entry.totalCost, 0);
const maxAgentCost = params.byAgent[0]?.totals.totalCost ?? 0;
const addAlert = (
key: UsageBudgetAlert["key"],
label: string,
currentUsd: number,
limitUsd: number | null
) => {
if (limitUsd === null || limitUsd <= 0) return;
if (currentUsd >= limitUsd) {
alerts.push({ key, label, currentUsd, limitUsd, severity: "danger" });
return;
}
if (currentUsd >= limitUsd * thresholdRatio) {
alerts.push({ key, label, currentUsd, limitUsd, severity: "warning" });
}
};
addAlert("daily", "Daily budget", todayCost, params.budgets.dailySpendLimitUsd);
addAlert("monthly", "Monthly budget", monthlyCost, params.budgets.monthlySpendLimitUsd);
addAlert(
"per-agent",
"Per-agent soft limit",
maxAgentCost,
params.budgets.perAgentSoftLimitUsd
);
return alerts;
};
export const useUsageAnalytics = ({
client,
status,
agents,
startDate,
endDate,
budgets,
}: {
client: GatewayClient;
status: GatewayStatus;
agents: AgentState[];
startDate: string;
endDate: string;
budgets: StudioAnalyticsBudgetSettings;
}) => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [sessions, setSessions] = useState<UsageSessionRow[]>([]);
const [costDaily, setCostDaily] = useState<CostDailyRow[]>([]);
const [serverTotals, setServerTotals] = useState<UsageTotals | null>(null);
const [lastRefreshedAt, setLastRefreshedAt] = useState<number | null>(null);
const agentsRef = useRef(agents);
useEffect(() => {
agentsRef.current = agents;
}, [agents]);
const refresh = useCallback(async () => {
if (status !== "connected") {
setSessions([]);
setCostDaily([]);
setServerTotals(null);
return;
}
setLoading(true);
setError(null);
try {
const [usageResult, costResult] = await Promise.all([
client.call<UsageSessionsResult>("sessions.usage", {
startDate,
endDate,
limit: 1000,
includeContextWeight: true,
}),
client.call<UsageCostResult>("usage.cost", {
startDate,
endDate,
}),
]);
const agentNameById = new Map(
agentsRef.current.map((agent) => [agent.agentId, agent.name || agent.agentId])
);
const normalizedSessions = asArray(usageResult.sessions).map((entry) => {
const parsed = asRecord(entry);
const agentId = asString(parsed?.agentId);
const provider =
asString(parsed?.modelProvider) ??
asString(parsed?.providerOverride) ??
asString(asRecord(parsed?.origin)?.provider);
const model = asString(parsed?.model) ?? asString(parsed?.modelOverride);
return {
key: asString(parsed?.key) ?? "unknown",
label: asString(parsed?.label),
agentId,
agentName: agentId ? agentNameById.get(agentId) ?? agentId : null,
channel: asString(parsed?.channel),
model,
provider,
updatedAt: asNumber(parsed?.updatedAt) || null,
usage: normalizeSessionUsage(parsed?.usage),
} satisfies UsageSessionRow;
});
setSessions(normalizedSessions);
setCostDaily(normalizeCostDaily(costResult.daily));
setServerTotals(asRecord(usageResult.totals) ? normalizeTotals(usageResult.totals) : null);
setLastRefreshedAt(Date.now());
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load usage analytics.");
setSessions([]);
setCostDaily([]);
setServerTotals(null);
} finally {
setLoading(false);
}
}, [client, endDate, startDate, status]);
useEffect(() => {
void refresh();
}, [refresh]);
const aggregates = useMemo(() => {
const messages: UsageMessageCounts = { ...EMPTY_MESSAGE_COUNTS };
const tools = new Map<string, number>();
const models = new Map<string, UsageModelRecord>();
const byAgent = new Map<string, UsageAgentAggregate>();
const daily = new Map<string, UsageDailyAggregate>();
let totals: UsageTotals = { ...EMPTY_TOTALS };
for (const session of sessions) {
totals = addTotals(totals, session.usage.totals);
messages.total += session.usage.messageCounts.total;
messages.user += session.usage.messageCounts.user;
messages.assistant += session.usage.messageCounts.assistant;
messages.toolCalls += session.usage.messageCounts.toolCalls;
messages.toolResults += session.usage.messageCounts.toolResults;
messages.errors += session.usage.messageCounts.errors;
for (const tool of session.usage.toolUsage.tools) {
tools.set(tool.name, (tools.get(tool.name) ?? 0) + tool.count);
}
for (const model of session.usage.modelUsage) {
const key = `${model.provider ?? "unknown"}::${model.model ?? "unknown"}`;
const existing = models.get(key) ?? {
provider: model.provider,
model: model.model,
count: 0,
totals: { ...EMPTY_TOTALS },
};
existing.count += model.count;
existing.totals = addTotals(existing.totals, model.totals);
models.set(key, existing);
}
if (session.agentId) {
const existing = byAgent.get(session.agentId) ?? {
agentId: session.agentId,
agentName: session.agentName ?? session.agentId,
totals: { ...EMPTY_TOTALS },
};
existing.totals = addTotals(existing.totals, session.usage.totals);
byAgent.set(session.agentId, existing);
}
for (const entry of session.usage.dailyBreakdown) {
const existing = daily.get(entry.date) ?? {
date: entry.date,
tokens: 0,
cost: 0,
messages: 0,
toolCalls: 0,
errors: 0,
};
existing.tokens += entry.tokens;
existing.cost += entry.cost;
daily.set(entry.date, existing);
}
for (const entry of session.usage.dailyMessageCounts) {
const existing = daily.get(entry.date) ?? {
date: entry.date,
tokens: 0,
cost: 0,
messages: 0,
toolCalls: 0,
errors: 0,
};
existing.messages += entry.total;
existing.toolCalls += entry.toolCalls;
existing.errors += entry.errors;
daily.set(entry.date, existing);
}
}
if (serverTotals) {
totals = {
...totals,
...serverTotals,
};
} else if (costDaily.length > 0) {
totals = {
...totals,
totalCost: costDaily.reduce((sum, entry) => sum + entry.totalCost, 0),
totalTokens: costDaily.reduce((sum, entry) => sum + entry.totalTokens, 0),
inputCost: costDaily.reduce((sum, entry) => sum + entry.inputCost, 0),
outputCost: costDaily.reduce((sum, entry) => sum + entry.outputCost, 0),
cacheReadCost: costDaily.reduce((sum, entry) => sum + entry.cacheReadCost, 0),
cacheWriteCost: costDaily.reduce((sum, entry) => sum + entry.cacheWriteCost, 0),
};
}
const byAgentRows = Array.from(byAgent.values()).sort(
(left, right) => right.totals.totalCost - left.totals.totalCost
);
return {
totals,
messages,
tools: {
totalCalls: Array.from(tools.values()).reduce((sum, value) => sum + value, 0),
uniqueTools: tools.size,
tools: Array.from(tools.entries())
.map(([name, count]) => ({ name, count }))
.sort((left, right) => right.count - left.count),
},
byModel: Array.from(models.values()).sort(
(left, right) => right.totals.totalCost - left.totals.totalCost
),
byAgent: byAgentRows,
daily: Array.from(daily.values()).sort((left, right) => left.date.localeCompare(right.date)),
};
}, [costDaily, serverTotals, sessions]);
const budgetAlerts = useMemo(() => {
return calculateBudgetAlerts({
budgets,
costDaily,
byAgent: aggregates.byAgent,
});
}, [aggregates.byAgent, budgets, costDaily]);
return {
loading,
error,
refresh,
sessions,
costDaily,
lastRefreshedAt,
totals: aggregates.totals,
aggregates,
budgetAlerts,
};
};
@@ -0,0 +1,149 @@
import type Phaser from "phaser";
import type { OfficeSceneBridge } from "@/features/office/phaser/OfficeSceneBridge";
type OfficeBuilderRenderable =
| Phaser.GameObjects.Rectangle
| Phaser.GameObjects.Image;
export const createOfficeBuilderScene = (params: {
PhaserLib: typeof import("phaser");
bridge: OfficeSceneBridge;
onObjectMoved?: (id: string, x: number, y: number) => void;
onSelectionChange?: (ids: string[]) => void;
}): Phaser.Scene => {
const { PhaserLib, bridge, onObjectMoved, onSelectionChange } = params;
class BuilderScene extends PhaserLib.Scene {
private unsubscribe: (() => void) | null = null;
private layer = new Map<string, OfficeBuilderRenderable>();
private selected = new Set<string>();
private dragId: string | null = null;
constructor() {
super("office-builder-scene");
}
create() {
this.cameras.main.setBackgroundColor("#0f1a26");
this.unsubscribe = bridge.subscribe(() => {
this.renderMap();
});
this.renderMap();
this.input.on("pointerdown", (pointer: Phaser.Input.Pointer) => {
const target = this.pickObject(pointer.worldX, pointer.worldY);
const shiftPressed = Boolean(
pointer.event &&
typeof pointer.event === "object" &&
"shiftKey" in pointer.event &&
(pointer.event as { shiftKey?: unknown }).shiftKey === true,
);
if (!target) {
this.selected.clear();
onSelectionChange?.([]);
return;
}
if (shiftPressed) {
if (this.selected.has(target.id)) {
this.selected.delete(target.id);
} else {
this.selected.add(target.id);
}
} else {
this.selected.clear();
this.selected.add(target.id);
}
this.dragId = target.id;
onSelectionChange?.([...this.selected]);
this.renderMap();
});
this.input.on("pointermove", (pointer: Phaser.Input.Pointer) => {
if (!pointer.isDown || !this.dragId) return;
const nextX = Math.round(pointer.worldX / 16) * 16;
const nextY = Math.round(pointer.worldY / 16) * 16;
onObjectMoved?.(this.dragId, nextX, nextY);
});
this.input.on("pointerup", () => {
this.dragId = null;
});
}
shutdown() {
this.unsubscribe?.();
this.unsubscribe = null;
for (const item of this.layer.values()) {
item.destroy();
}
this.layer.clear();
this.selected.clear();
}
private renderMap() {
const state = bridge.getState();
const keep = new Set<string>();
for (const object of state.map.objects) {
keep.add(object.id);
const existing = this.layer.get(object.id);
if (existing) {
// Cast to common interface for transform
const transform =
existing as unknown as Phaser.GameObjects.Components.Transform;
transform.setPosition(object.x, object.y);
transform.setAngle(object.rotation);
if (existing instanceof PhaserLib.GameObjects.Rectangle) {
existing.setSize(32, 32);
existing.setStrokeStyle(
this.selected.has(object.id) ? 2 : 0,
0x79e5ff,
1,
);
existing.setFillStyle(0x4f80af, 0.95);
} else if (existing instanceof PhaserLib.GameObjects.Image) {
existing.setAlpha(this.selected.has(object.id) ? 0.8 : 1);
}
continue;
}
let sprite: OfficeBuilderRenderable;
if (object.assetId === "office_bg") {
const img = this.add.image(object.x, object.y, "office_bg");
img.setOrigin(0.5, 0.5);
sprite = img;
} else {
const rect = this.add.rectangle(
object.x,
object.y,
32,
32,
0x4f80af,
0.95,
);
rect.setOrigin(0.5, 0.5);
sprite = rect;
}
sprite.setDepth(object.zIndex);
this.layer.set(object.id, sprite);
}
for (const [id, item] of this.layer) {
if (keep.has(id)) continue;
item.destroy();
this.layer.delete(id);
}
}
private pickObject(x: number, y: number) {
const map = bridge.getState().map;
for (let index = map.objects.length - 1; index >= 0; index -= 1) {
const object = map.objects[index];
if (Math.abs(x - object.x) <= 16 && Math.abs(y - object.y) <= 16) {
return object;
}
}
return null;
}
}
return new BuilderScene();
};
@@ -0,0 +1,63 @@
import type { OfficeAgentPresence } from "@/lib/office/presence";
import type { OfficeMap } from "@/lib/office/schema";
export type OfficeDebugSettings = {
showZones: boolean;
showAnchors: boolean;
showEmitterBounds: boolean;
showLightBounds: boolean;
showMetrics: boolean;
};
export type OfficeRuntimeSettings = {
enableAmbience: boolean;
enableThoughtBubbles: boolean;
enableLighting: boolean;
};
export type OfficeSceneBridgeState = {
map: OfficeMap;
presence: OfficeAgentPresence[];
debug: OfficeDebugSettings;
runtime: OfficeRuntimeSettings;
};
export type OfficeSceneBridge = {
getState: () => OfficeSceneBridgeState;
setState: (next: Partial<OfficeSceneBridgeState>) => void;
subscribe: (listener: () => void) => () => void;
};
export const createOfficeSceneBridge = (
initialState: OfficeSceneBridgeState
): OfficeSceneBridge => {
let state = initialState;
const listeners = new Set<() => void>();
return {
getState: () => state,
setState: (next) => {
state = {
...state,
...next,
debug: {
...state.debug,
...(next.debug ?? {}),
},
runtime: {
...state.runtime,
...(next.runtime ?? {}),
},
};
for (const listener of listeners) {
listener();
}
},
subscribe: (listener) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
};
};
@@ -0,0 +1,308 @@
import type Phaser from "phaser";
import type { OfficeSceneBridge } from "@/features/office/phaser/OfficeSceneBridge";
import { AgentEffectsSystem } from "@/features/office/phaser/systems/AgentEffectsSystem";
import { AmbienceSystem } from "@/features/office/phaser/systems/AmbienceSystem";
import { LightingSystem } from "@/features/office/phaser/systems/LightingSystem";
export const createOfficeViewerScene = (params: {
PhaserLib: typeof import("phaser");
bridge: OfficeSceneBridge;
}): Phaser.Scene => {
const { PhaserLib, bridge } = params;
class ViewerScene extends PhaserLib.Scene {
private unsubscribe: (() => void) | null = null;
private lighting: LightingSystem | null = null;
private ambience: AmbienceSystem | null = null;
private agents: AgentEffectsSystem | null = null;
private floor: Phaser.GameObjects.Graphics | null = null;
private staticLayer: Phaser.GameObjects.Group | null = null;
private debugGfx: Phaser.GameObjects.Graphics | null = null;
private metricsText: Phaser.GameObjects.Text | null = null;
private startedAt = 0;
private createTextureGraphics() {
const gfx = this.add.graphics();
gfx.setVisible(false);
return gfx;
}
constructor() {
super("office-viewer-scene");
}
preload() {
this.load.image("office_bg", "/office-assets/backgrounds/office-bg.png");
}
create() {
this.startedAt = this.time.now;
// Generate procedural textures
this.generateTextures();
this.floor = this.add.graphics();
this.floor.setDepth(100);
this.staticLayer = this.add.group();
this.debugGfx = this.add.graphics();
this.debugGfx.setDepth(20_000);
this.lighting = new LightingSystem({ scene: this });
this.ambience = new AmbienceSystem({ scene: this });
this.agents = new AgentEffectsSystem({ scene: this });
this.metricsText = this.add.text(12, 12, "", {
fontFamily: "var(--font-mono)",
fontSize: "10px",
color: "#d8e9ff",
backgroundColor: "rgba(0,0,0,0.35)",
padding: { left: 6, right: 6, top: 4, bottom: 4 },
});
this.metricsText.setScrollFactor(0);
this.metricsText.setDepth(60_000);
this.unsubscribe = bridge.subscribe(() => {
this.renderStatic();
});
this.renderStatic();
}
update(_: number, delta: number) {
const state = bridge.getState();
const elapsedS = (this.time.now - this.startedAt) / 1000;
this.ambience?.update(
state.map.ambienceEmitters ?? [],
state.map.zones,
state.runtime.enableAmbience,
);
this.lighting?.update(
state.runtime.enableLighting ? (state.map.lights ?? []) : [],
state.map.lightingOverlay?.baseDarkness ?? 0,
elapsedS,
);
this.agents?.update({
map: state.map,
agents: state.presence,
elapsedMs: delta,
thoughtBubblesEnabled:
state.runtime.enableThoughtBubbles &&
(state.map.theme?.enableThoughtBubbles ?? true),
});
if (state.debug.showMetrics && this.metricsText) {
const fps = this.game.loop.actualFps;
const textureCount = this.textures.list
? Object.keys(this.textures.list).length
: 0;
const memory = (
performance as Performance & { memory?: { usedJSHeapSize: number } }
).memory;
const usedMb = memory
? Math.round(memory.usedJSHeapSize / 1024 / 1024)
: null;
this.metricsText.setVisible(true);
this.metricsText.setText(
usedMb === null
? `fps ${fps.toFixed(1)} textures ${textureCount}`
: `fps ${fps.toFixed(1)} textures ${textureCount} heapMb ${usedMb}`,
);
} else if (this.metricsText) {
this.metricsText.setVisible(false);
}
}
shutdown() {
this.unsubscribe?.();
this.unsubscribe = null;
this.floor?.destroy();
this.floor = null;
this.staticLayer?.destroy(true, true);
this.staticLayer = null;
this.debugGfx?.destroy();
this.debugGfx = null;
this.metricsText?.destroy();
this.metricsText = null;
this.lighting?.destroy();
this.lighting = null;
this.ambience?.destroy();
this.ambience = null;
this.agents?.destroy();
this.agents = null;
}
private generateTextures() {
// Desk texture (brown rectangle with wood grain hint)
if (!this.textures.exists("desk_modern")) {
const gfx = this.createTextureGraphics();
gfx.fillStyle(0x8b5a2b, 1);
gfx.fillRect(0, 0, 64, 32);
gfx.fillStyle(0x6b4226, 1);
gfx.fillRect(0, 30, 64, 2); // shadow/edge
gfx.generateTexture("desk_modern", 64, 32);
gfx.destroy();
}
// Meeting table texture (large oval/rect)
if (!this.textures.exists("meeting_table")) {
const gfx = this.createTextureGraphics();
gfx.fillStyle(0x3e2723, 1);
gfx.fillRoundedRect(0, 0, 160, 80, 10);
gfx.fillStyle(0x5d4037, 1);
gfx.fillRoundedRect(4, 4, 152, 72, 6); // inset
gfx.generateTexture("meeting_table", 160, 80);
gfx.destroy();
}
// Floor tile (subtle grid)
if (!this.textures.exists("floor_tile")) {
const gfx = this.createTextureGraphics();
gfx.fillStyle(0x2a2a2a, 1);
gfx.fillRect(0, 0, 32, 32);
gfx.lineStyle(1, 0x333333, 1);
gfx.strokeRect(0, 0, 32, 32);
gfx.generateTexture("floor_tile", 32, 32);
gfx.destroy();
}
// Plant (green circle/cluster)
if (!this.textures.exists("plant_potted")) {
const gfx = this.createTextureGraphics();
gfx.fillStyle(0x2e7d32, 1);
gfx.fillCircle(16, 16, 12);
gfx.fillStyle(0x4caf50, 1);
gfx.fillCircle(14, 14, 8);
gfx.generateTexture("plant_potted", 32, 32);
gfx.destroy();
}
// Wall (solid block)
if (!this.textures.exists("wall_block")) {
const gfx = this.createTextureGraphics();
gfx.fillStyle(0x546e7a, 1);
gfx.fillRect(0, 0, 32, 32);
gfx.lineStyle(2, 0x37474f, 1);
gfx.strokeRect(0, 0, 32, 32);
gfx.generateTexture("wall_block", 32, 32);
gfx.destroy();
}
// Arcade machine (glowy screen)
if (!this.textures.exists("arcade_machine")) {
const gfx = this.createTextureGraphics();
gfx.fillStyle(0x212121, 1);
gfx.fillRect(0, 0, 32, 48);
gfx.fillStyle(0x00e5ff, 1); // Screen
gfx.fillRect(4, 8, 24, 20);
gfx.fillStyle(0xff1744, 1); // Buttons
gfx.fillCircle(8, 36, 2);
gfx.fillCircle(16, 36, 2);
gfx.generateTexture("arcade_machine", 32, 48);
gfx.destroy();
}
// Coffee station (counter with machine)
if (!this.textures.exists("coffee_station")) {
const gfx = this.createTextureGraphics();
gfx.fillStyle(0x616161, 1); // Counter
gfx.fillRect(0, 0, 64, 32);
gfx.fillStyle(0x212121, 1); // Machine
gfx.fillRect(8, 4, 16, 20);
gfx.fillStyle(0xffeb3b, 1); // Light
gfx.fillCircle(12, 8, 1);
gfx.generateTexture("coffee_station", 64, 32);
gfx.destroy();
}
// TV Wall
if (!this.textures.exists("tv_wall")) {
const gfx = this.createTextureGraphics();
gfx.fillStyle(0x000000, 1);
gfx.fillRect(0, 0, 80, 10);
gfx.generateTexture("tv_wall", 80, 10);
gfx.destroy();
}
}
private renderStatic() {
const state = bridge.getState();
if (!this.floor || !this.debugGfx || !this.staticLayer) return;
this.floor.clear();
this.debugGfx.clear();
this.staticLayer.clear(true, true); // Clear existing sprites
this.cameras.main.setBackgroundColor(state.map.canvas.backgroundColor);
for (const layer of state.map.layers) {
if (!layer.visible) continue;
const objects = state.map.objects
.filter((entry) => entry.layerId === layer.id)
.sort((left, right) => left.zIndex - right.zIndex);
for (const object of objects) {
if (this.textures.exists(object.assetId)) {
const sprite = this.add.sprite(object.x, object.y, object.assetId);
sprite.setDepth(object.zIndex);
sprite.setAngle(object.rotation);
sprite.setFlip(object.flipX, object.flipY);
// If the sprite is on the floor layer, tint it slightly to recede
if (layer.id === "floor") {
sprite.setTint(0xdddddd);
}
} else {
// Fallback for missing assets
this.floor.fillStyle(0x4679ab, layer.opacity);
this.floor.fillRect(object.x - 16, object.y - 16, 32, 32);
}
}
}
if (state.debug.showZones) {
this.debugGfx.lineStyle(1, 0x63f2ce, 0.7);
for (const zone of state.map.zones) {
const points = zone.shape.points;
if (points.length < 2) continue;
this.debugGfx.beginPath();
this.debugGfx.moveTo(points[0].x, points[0].y);
for (let index = 1; index < points.length; index += 1) {
this.debugGfx.lineTo(points[index].x, points[index].y);
}
this.debugGfx.closePath();
this.debugGfx.strokePath();
}
}
if (state.debug.showAnchors) {
this.debugGfx.fillStyle(0xe9d875, 0.9);
for (const point of state.map.interactionPoints ?? []) {
this.debugGfx.fillCircle(point.x, point.y, 4);
}
}
if (state.debug.showEmitterBounds) {
this.debugGfx.lineStyle(1, 0xe9b7ff, 0.7);
for (const emitter of state.map.ambienceEmitters ?? []) {
const zone = state.map.zones.find(
(entry) => entry.id === emitter.zoneId,
);
if (!zone || zone.shape.points.length < 2) continue;
this.debugGfx.beginPath();
this.debugGfx.moveTo(zone.shape.points[0].x, zone.shape.points[0].y);
for (let index = 1; index < zone.shape.points.length; index += 1) {
this.debugGfx.lineTo(
zone.shape.points[index].x,
zone.shape.points[index].y,
);
}
this.debugGfx.closePath();
this.debugGfx.strokePath();
}
}
if (state.debug.showLightBounds) {
this.debugGfx.lineStyle(1, 0xffdb6d, 0.6);
for (const light of state.map.lights ?? []) {
this.debugGfx.strokeCircle(light.x, light.y, light.radius);
}
}
}
}
return new ViewerScene();
};
@@ -0,0 +1,180 @@
import type Phaser from "phaser";
import type { OfficeAgentPresence } from "@/lib/office/presence";
import type { OfficeMap } from "@/lib/office/schema";
type AgentEffectsSystemParams = {
scene: Phaser.Scene;
};
type AvatarState = {
sprite: Phaser.GameObjects.Arc;
label: Phaser.GameObjects.Text;
stateIcon: Phaser.GameObjects.Text;
thoughtIcon: Phaser.GameObjects.Text;
vx: number;
vy: number;
lastThoughtAt: number;
};
const THOUGHTS = ["coffee", "gamepad", "zzz", "idea", "music"] as const;
const stateColor = (state: OfficeAgentPresence["state"]) => {
if (state === "working") return 0x47c773;
if (state === "meeting") return 0x58c6ff;
if (state === "error") return 0xff6e6e;
return 0xe8d58a;
};
const thoughtFromSeed = (seed: number) => THOUGHTS[Math.abs(seed) % THOUGHTS.length] ?? "idea";
const hash = (value: string) => {
let h = 0;
for (let index = 0; index < value.length; index += 1) {
h = (h * 33 + value.charCodeAt(index)) >>> 0;
}
return h;
};
export class AgentEffectsSystem {
private readonly scene: Phaser.Scene;
private readonly avatars = new Map<string, AvatarState>();
constructor(params: AgentEffectsSystemParams) {
this.scene = params.scene;
}
update(params: {
map: OfficeMap;
agents: OfficeAgentPresence[];
elapsedMs: number;
thoughtBubblesEnabled: boolean;
}) {
const keep = new Set<string>();
const zonesByType = new Map(
params.map.zones.map((zone) => [zone.type, zone])
);
for (const agent of params.agents) {
keep.add(agent.agentId);
const entry = this.getOrCreate(agent.agentId, agent.name, agent.state);
entry.sprite.fillColor = stateColor(agent.state);
entry.stateIcon.setText(agent.state === "error" ? "!" : "");
const target = this.resolveTarget(agent.state, zonesByType);
const dx = target.x - entry.sprite.x;
const dy = target.y - entry.sprite.y;
const distance = Math.hypot(dx, dy);
const maxSpeed = 0.05 * params.elapsedMs;
if (distance > 0.1) {
const step = Math.min(maxSpeed, distance);
entry.sprite.x += (dx / distance) * step;
entry.sprite.y += (dy / distance) * step;
}
entry.label.setPosition(entry.sprite.x, entry.sprite.y + 15);
entry.stateIcon.setPosition(entry.sprite.x + 12, entry.sprite.y - 12);
entry.thoughtIcon.setPosition(entry.sprite.x, entry.sprite.y - 20);
if (
params.thoughtBubblesEnabled &&
agent.state === "idle" &&
params.elapsedMs + entry.lastThoughtAt > 7_000
) {
const now = this.scene.time.now;
if (now - entry.lastThoughtAt > 7000) {
const seed = hash(`${agent.agentId}:${Math.floor(now / 2000)}`);
if (seed % 7 === 0) {
entry.lastThoughtAt = now;
entry.thoughtIcon.setAlpha(0.9);
entry.thoughtIcon.setText(thoughtFromSeed(seed));
}
}
}
if (entry.thoughtIcon.alpha > 0) {
entry.thoughtIcon.setAlpha(Math.max(0, entry.thoughtIcon.alpha - 0.0075 * params.elapsedMs));
}
}
for (const [agentId, entry] of this.avatars) {
if (keep.has(agentId)) continue;
entry.sprite.destroy();
entry.label.destroy();
entry.stateIcon.destroy();
entry.thoughtIcon.destroy();
this.avatars.delete(agentId);
}
}
destroy() {
for (const entry of this.avatars.values()) {
entry.sprite.destroy();
entry.label.destroy();
entry.stateIcon.destroy();
entry.thoughtIcon.destroy();
}
this.avatars.clear();
}
private getOrCreate(agentId: string, name: string, state: OfficeAgentPresence["state"]) {
const existing = this.avatars.get(agentId);
if (existing) {
existing.label.setText(name);
return existing;
}
const sprite = this.scene.add.circle(80, 80, 8, stateColor(state));
sprite.setDepth(8_500);
const label = this.scene.add.text(80, 95, name, {
fontFamily: "var(--font-mono)",
fontSize: "10px",
color: "#d7e7ff",
});
label.setDepth(8_500);
label.setOrigin(0.5, 0);
const stateIcon = this.scene.add.text(92, 68, "", {
fontFamily: "var(--font-mono)",
fontSize: "12px",
color: "#ff7171",
});
stateIcon.setDepth(9_000);
stateIcon.setOrigin(0.5, 0.5);
const thoughtIcon = this.scene.add.text(80, 58, "", {
fontFamily: "var(--font-mono)",
fontSize: "10px",
color: "#f4e8bb",
backgroundColor: "rgba(20,30,40,0.55)",
padding: { left: 4, right: 4, top: 2, bottom: 2 },
});
thoughtIcon.setDepth(9_000);
thoughtIcon.setOrigin(0.5, 0.5);
thoughtIcon.setAlpha(0);
const created: AvatarState = {
sprite,
label,
stateIcon,
thoughtIcon,
vx: 0,
vy: 0,
lastThoughtAt: this.scene.time.now,
};
this.avatars.set(agentId, created);
return created;
}
private resolveTarget(
state: OfficeAgentPresence["state"],
zonesByType: Map<string, OfficeMap["zones"][number]>
) {
const fallback = { x: 120, y: 120 };
const pickFrom = (zoneType: string) => {
const zone = zonesByType.get(zoneType);
if (!zone || zone.shape.points.length === 0) return fallback;
const point = zone.shape.points[0];
return { x: point.x, y: point.y };
};
if (state === "working") return pickFrom("desk_zone");
if (state === "meeting") return pickFrom("meeting_room");
if (state === "error") return pickFrom("desk_zone");
return pickFrom("hallway");
}
}
@@ -0,0 +1,100 @@
import type Phaser from "phaser";
import type { OfficeAmbienceEmitter, OfficeZone } from "@/lib/office/schema";
type AmbienceSystemParams = {
scene: Phaser.Scene;
};
type ZoneBounds = {
x: number;
y: number;
w: number;
h: number;
};
const resolveZoneBounds = (zone: OfficeZone): ZoneBounds => {
const points = zone.shape.points;
if (points.length === 0) return { x: 0, y: 0, w: 0, h: 0 };
const xs = points.map((point) => point.x);
const ys = points.map((point) => point.y);
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
const minY = Math.min(...ys);
const maxY = Math.max(...ys);
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
};
export class AmbienceSystem {
private readonly scene: Phaser.Scene;
private readonly particles = new Map<string, Phaser.GameObjects.Particles.ParticleEmitter>();
constructor(params: AmbienceSystemParams) {
this.scene = params.scene;
}
update(emitters: OfficeAmbienceEmitter[], zones: OfficeZone[], enabled: boolean) {
const keep = new Set<string>();
const zonesById = new Map(zones.map((zone) => [zone.id, zone]));
for (const emitterDef of emitters) {
if (!enabled || !emitterDef.enabled) continue;
const zone = zonesById.get(emitterDef.zoneId);
if (!zone) continue;
keep.add(emitterDef.id);
if (this.particles.has(emitterDef.id)) continue;
const texture = this.resolveTexture(emitterDef.preset);
if (!this.scene.textures.exists(texture)) {
const gfx = this.scene.add.graphics();
gfx.fillStyle(0xffffff, 0.8);
gfx.fillCircle(4, 4, 4);
gfx.generateTexture(texture, 8, 8);
gfx.destroy();
}
const bounds = resolveZoneBounds(zone);
const emitter = this.scene.add.particles(0, 0, texture, {
x: { min: bounds.x, max: bounds.x + Math.max(bounds.w, 1) },
y: { min: bounds.y, max: bounds.y + Math.max(bounds.h, 1) },
quantity: 1,
frequency: Math.max(30, Math.floor(1000 / Math.max(emitterDef.spawnRate, 0.01))),
lifespan: 3000,
alpha: { start: 0.0, end: 0.35 },
speedY: { min: -8, max: -2 },
speedX: { min: -3, max: 3 },
scale: { start: 0.2, end: 0.05 },
maxParticles: Math.max(1, emitterDef.maxParticles),
});
emitter.setDepth(3_000);
this.particles.set(emitterDef.id, emitter);
}
for (const [key, emitter] of this.particles) {
if (keep.has(key)) continue;
this.destroyEmitter(emitter);
this.particles.delete(key);
}
}
destroy() {
for (const emitter of this.particles.values()) {
this.destroyEmitter(emitter);
}
this.particles.clear();
}
private destroyEmitter(emitter: Phaser.GameObjects.Particles.ParticleEmitter) {
const unsafe = emitter as unknown as {
stop: () => void;
manager?: { destroy: () => void };
};
unsafe.stop();
unsafe.manager?.destroy();
}
private resolveTexture(preset: OfficeAmbienceEmitter["preset"]) {
if (preset === "coffee_steam") return "office-ambience-steam";
if (preset === "window_dust") return "office-ambience-dust";
if (preset === "game_sparkle") return "office-ambience-sparkle";
return "office-ambience-pollen";
}
}
@@ -0,0 +1,70 @@
import type Phaser from "phaser";
import type { OfficeLightObject } from "@/lib/office/schema";
type LightingSystemParams = {
scene: Phaser.Scene;
};
export class LightingSystem {
private readonly scene: Phaser.Scene;
private readonly darknessGraphics: Phaser.GameObjects.Graphics;
private readonly glowGraphics: Phaser.GameObjects.Graphics;
private readonly lightIntensity = new Map<string, number>();
constructor(params: LightingSystemParams) {
this.scene = params.scene;
this.darknessGraphics = this.scene.add.graphics();
this.darknessGraphics.setDepth(50_000);
this.darknessGraphics.setScrollFactor(0);
this.glowGraphics = this.scene.add.graphics();
this.glowGraphics.setDepth(49_900);
this.glowGraphics.setScrollFactor(0);
}
update(lights: OfficeLightObject[], overlayDarkness: number, elapsedS: number) {
this.darknessGraphics.clear();
this.glowGraphics.clear();
const darkness = Math.min(Math.max(overlayDarkness, 0), 0.65);
this.darknessGraphics.setBlendMode("NORMAL");
this.darknessGraphics.fillStyle(0x000000, darkness);
this.darknessGraphics.fillRect(
0,
0,
this.scene.scale.width,
this.scene.scale.height
);
this.glowGraphics.setBlendMode("ADD");
for (const light of lights) {
const intensity = this.resolveAnimatedIntensity(light, elapsedS);
this.lightIntensity.set(light.id, intensity);
const alpha = Math.min(Math.max(intensity, 0), 1) * 0.18;
this.glowGraphics.fillStyle(0xf8f2ce, alpha);
this.glowGraphics.fillCircle(light.x, light.y, light.radius);
}
}
getIntensity(lightId: string) {
return this.lightIntensity.get(lightId) ?? 0;
}
destroy() {
this.darknessGraphics.destroy();
this.glowGraphics.destroy();
}
private resolveAnimatedIntensity(light: OfficeLightObject, elapsedS: number) {
const base = light.baseIntensity;
if (light.animationPreset === "steady") return base;
if (light.animationPreset === "soft_flicker") {
const speed = light.flicker?.speed ?? 1.2;
const amplitude = light.flicker?.amplitude ?? 0.08;
return base + Math.sin(elapsedS * speed * 3.2) * amplitude;
}
if (light.animationPreset === "breathing_pulse") {
return base + Math.sin(elapsedS * 1.7) * 0.1;
}
return base + (Math.sin(elapsedS * 7.5) > 0 ? 0.09 : -0.07);
}
}
@@ -0,0 +1,560 @@
"use client";
import { Check, Landmark, Lock, RefreshCw, Wallet } from "lucide-react";
import { type ReactNode, useMemo, useState } from "react";
import {
type OfficeUsageAnalyticsParams,
useOfficeUsageAnalyticsViewModel,
} from "@/features/office/hooks/useOfficeUsageAnalyticsViewModel";
import {
formatCurrency,
formatNumber,
toDateInputValue,
} from "@/lib/office/usageAnalyticsPresentation";
const PIN_STORAGE_KEY = "openclaw_atm_pin_code";
const resolveInitialPinMode = (): "setup" | "verify" => {
if (typeof window === "undefined") {
return "verify";
}
return window.localStorage.getItem(PIN_STORAGE_KEY) ? "verify" : "setup";
};
export function AtmImmersiveScreen(props: OfficeUsageAnalyticsParams) {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [pinMode] = useState<"setup" | "verify">(resolveInitialPinMode);
const [inputPin, setInputPin] = useState("");
const [error, setError] = useState<string | null>(null);
const { usage, settingsLoaded, startDate, endDate, setStartDate, setEndDate } =
useOfficeUsageAnalyticsViewModel(props);
const handlePinSubmit = () => {
if (inputPin.length < 4) {
setError("PIN must be at least 4 digits");
return;
}
if (pinMode === "setup") {
localStorage.setItem(PIN_STORAGE_KEY, inputPin);
setIsAuthenticated(true);
setError(null);
} else {
const stored = localStorage.getItem(PIN_STORAGE_KEY);
if (inputPin === stored) {
setIsAuthenticated(true);
setError(null);
} else {
setError("Incorrect PIN");
setInputPin("");
}
}
};
const handleKeyPad = (key: string) => {
setError(null);
if (key === "clear") {
setInputPin("");
} else if (key === "backspace") {
setInputPin((prev) => prev.slice(0, -1));
} else if (key === "submit") {
handlePinSubmit();
} else {
if (inputPin.length < 6) {
setInputPin((prev) => prev + key);
}
}
};
const recentCostDaily = useMemo(() => usage.costDaily.slice(-7), [usage.costDaily]);
const chartMax = useMemo(
() => recentCostDaily.reduce((max, entry) => Math.max(max, entry.totalCost), 0),
[recentCostDaily],
);
const overviewCards = useMemo(
() => [
{ label: "Total Spend", value: formatCurrency(usage.totals.totalCost) },
{ label: "Total Tokens", value: formatNumber(usage.totals.totalTokens) },
{ label: "Sessions", value: formatNumber(usage.sessions.length) },
{ label: "Messages", value: formatNumber(usage.aggregates.messages.total) },
{ label: "Tool Calls", value: formatNumber(usage.aggregates.tools.totalCalls) },
{ label: "Unique Tools", value: formatNumber(usage.aggregates.tools.uniqueTools) },
{ label: "Errors", value: formatNumber(usage.aggregates.messages.errors) },
{
label: "Avg Session Cost",
value:
usage.sessions.length > 0
? formatCurrency(usage.totals.totalCost / usage.sessions.length)
: formatCurrency(0),
},
],
[usage],
);
const recentSessions = useMemo(
() =>
[...usage.sessions]
.sort((left, right) => (right.updatedAt ?? 0) - (left.updatedAt ?? 0))
.slice(0, 18),
[usage.sessions],
);
const selectedRangeLabel = useMemo(() => {
const now = new Date();
const end = toDateInputValue(now);
const lastWeek = new Date(now);
lastWeek.setDate(lastWeek.getDate() - 6);
const lastMonth = new Date(now);
lastMonth.setDate(lastMonth.getDate() - 29);
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
if (startDate === toDateInputValue(lastWeek) && endDate === end) return "7D";
if (startDate === toDateInputValue(lastMonth) && endDate === end) return "30D";
if (startDate === toDateInputValue(monthStart) && endDate === end) return "MTD";
return "Custom";
}, [endDate, startDate]);
const setQuickRange = (days: number | "mtd") => {
const end = new Date();
const start = new Date(end);
if (days === "mtd") {
start.setDate(1);
} else {
start.setDate(start.getDate() - (days - 1));
}
setStartDate(toDateInputValue(start));
setEndDate(toDateInputValue(end));
};
if (!isAuthenticated) {
return (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-[radial-gradient(circle_at_center,#113a3d_0%,#071719_65%,#020607_100%)] text-[#d6fff7]">
<div className="pointer-events-none absolute inset-0 opacity-20 [background-image:linear-gradient(rgba(130,255,228,0.08)_1px,transparent_1px),linear-gradient(90deg,rgba(130,255,228,0.08)_1px,transparent_1px)] [background-size:22px_22px]" />
<div className="relative z-10 flex flex-col items-center">
<div className="mb-8 flex h-20 w-20 items-center justify-center rounded-full border border-[#7dfff0]/30 bg-[#0d3034] shadow-[0_0_40px_rgba(125,255,240,0.15)]">
<Lock className="h-8 w-8 text-[#7dfff0]" />
</div>
<h2 className="text-[24px] font-medium tracking-[0.1em] text-[#dbfff6]">
{pinMode === "setup" ? "CREATE ACCESS PIN" : "ENTER PIN CODE"}
</h2>
<p className="mt-2 text-[13px] uppercase tracking-[0.15em] text-[#83fff0]/60">
{pinMode === "setup"
? "Set a secure code for your treasury ledger"
: "Authentication required to view ledger"}
</p>
<div className="mb-8 mt-10 flex gap-4">
{[...Array(4)].map((_, i) => (
<div
key={i}
className={`h-4 w-4 rounded-full border border-[#7dfff0]/40 transition-all duration-200 ${
i < inputPin.length
? "bg-[#7dfff0] shadow-[0_0_15px_rgba(125,255,240,0.6)]"
: "bg-transparent"
}`}
/>
))}
</div>
{error ? (
<div className="animate-in slide-in-from-top-2 mb-6 rounded-lg border border-rose-500/20 bg-rose-500/10 px-4 py-2 text-[13px] font-medium text-rose-200 fade-in">
{error}
</div>
) : null}
<div className="grid grid-cols-3 gap-4">
{[1, 2, 3, 4, 5, 6, 7, 8, 9].map((num) => (
<button
key={num}
onClick={() => handleKeyPad(num.toString())}
className="flex h-16 w-24 items-center justify-center rounded-xl border border-[#7dfff0]/10 bg-[#041315]/80 text-[24px] font-light text-[#d6fff7] transition-all hover:bg-[#0d3034] hover:border-[#7dfff0]/30 active:scale-95"
>
{num}
</button>
))}
<button
onClick={() => handleKeyPad("clear")}
className="flex h-16 w-24 items-center justify-center rounded-xl border border-rose-500/20 bg-[#1a0505]/60 text-[14px] font-medium uppercase tracking-wider text-rose-200 transition-all hover:bg-rose-900/40 active:scale-95"
>
Clear
</button>
<button
onClick={() => handleKeyPad("0")}
className="flex h-16 w-24 items-center justify-center rounded-xl border border-[#7dfff0]/10 bg-[#041315]/80 text-[24px] font-light text-[#d6fff7] transition-all hover:bg-[#0d3034] hover:border-[#7dfff0]/30 active:scale-95"
>
0
</button>
<button
onClick={() => handleKeyPad("submit")}
className="flex h-16 w-24 items-center justify-center rounded-xl border border-emerald-500/20 bg-[#051a10]/60 text-emerald-200 transition-all hover:bg-emerald-900/40 active:scale-95"
>
<Check className="h-6 w-6" />
</button>
</div>
</div>
</div>
);
}
return (
<div className="absolute inset-0 overflow-y-auto bg-[radial-gradient(circle_at_top,#113a3d_0%,#071719_45%,#020607_100%)] text-[#d6fff7]">
<div className="pointer-events-none absolute inset-0 opacity-20 [background-image:linear-gradient(rgba(130,255,228,0.08)_1px,transparent_1px),linear-gradient(90deg,rgba(130,255,228,0.08)_1px,transparent_1px)] [background-size:22px_22px]" />
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_center,transparent_55%,rgba(0,0,0,0.34)_100%)]" />
<div className="relative flex min-h-full flex-col px-10 py-8 pb-14">
<div className="flex items-start justify-between gap-6">
<div>
<div className="flex items-center gap-3 text-[12px] uppercase tracking-[0.32em] text-[#83fff0]/70">
<Landmark className="h-4 w-4" />
OpenClaw Treasury ATM
</div>
<div className="mt-3 text-[13px] uppercase tracking-[0.24em] text-[#7ddfd2]/62">
Token Usage Ledger
</div>
<div className="mt-2 text-[44px] font-semibold tracking-[0.08em] text-[#dbfff6]">
{formatNumber(usage.totals.totalTokens)}
</div>
<div className="mt-2 text-[15px] uppercase tracking-[0.28em] text-[#89fff1]/72">
Total tokens used
</div>
<div className="mt-4 inline-flex items-center rounded-full border border-[#7cffef]/20 bg-black/20 px-4 py-2 text-[13px] uppercase tracking-[0.24em] text-[#bafff7]/85">
USD equivalent {formatCurrency(usage.totals.totalCost)}
</div>
</div>
<div className="w-[320px] rounded-[24px] border border-[#7dfff0]/18 bg-black/22 p-5 shadow-[0_18px_50px_rgba(0,0,0,0.34)]">
<div className="flex items-center gap-2 text-[11px] uppercase tracking-[0.24em] text-[#88fff1]/62">
<Wallet className="h-4 w-4" />
Account summary
</div>
<div className="mt-4 flex flex-wrap gap-2">
{[
{ label: "7D", value: 7 },
{ label: "30D", value: 30 },
{ label: "MTD", value: "mtd" as const },
].map((range) => (
<button
key={range.label}
type="button"
onClick={() => setQuickRange(range.value)}
className={`rounded-full border px-3 py-1.5 text-[10px] uppercase tracking-[0.22em] transition-colors ${
selectedRangeLabel === range.label
? "border-[#8efff2]/40 bg-[#0d3034] text-[#dffff8]"
: "border-[#7dfff0]/16 bg-[#041315] text-[#8ffff3]/68 hover:border-[#7dfff0]/30 hover:text-[#dffff8]"
}`}
>
{range.label}
</button>
))}
</div>
<div className="mt-4 grid grid-cols-2 gap-3">
<SummaryCard label="Input" value={formatCurrency(usage.totals.inputCost)} />
<SummaryCard label="Output" value={formatCurrency(usage.totals.outputCost)} />
<SummaryCard label="Cache read" value={formatCurrency(usage.totals.cacheReadCost)} />
<SummaryCard label="Cache write" value={formatCurrency(usage.totals.cacheWriteCost)} />
</div>
<div className="mt-4 rounded-2xl border border-[#7dfff0]/12 bg-[#031314]/80 px-4 py-3 text-[12px] uppercase tracking-[0.18em] text-[#9ffef0]/76">
{usage.lastRefreshedAt
? `Last refresh ${new Date(usage.lastRefreshedAt).toLocaleTimeString()}`
: settingsLoaded
? "Awaiting first usage snapshot"
: "Loading account preferences"}
</div>
</div>
</div>
<div className="mt-7 space-y-6">
<SectionCard
title="Usage Overview"
subtitle="Expanded OpenClaw expense data for the selected ledger window."
action={
<button
type="button"
onClick={() => void usage.refresh()}
className="inline-flex items-center gap-2 rounded-full border border-[#7dfff0]/24 bg-[#072528] px-4 py-2 text-[11px] uppercase tracking-[0.22em] text-[#b7fff8] transition-colors hover:border-[#7dfff0]/40 hover:bg-[#0a3035]"
>
<RefreshCw className={`h-3.5 w-3.5 ${usage.loading ? "animate-spin" : ""}`} />
Refresh
</button>
}
>
{usage.error ? <EmptyPanelState message={usage.error} tone="danger" /> : null}
<div className="grid grid-cols-2 gap-3 xl:grid-cols-4">
{overviewCards.map((card) => (
<SummaryCard key={card.label} label={card.label} value={card.value} />
))}
</div>
</SectionCard>
<SectionCard
title="Daily Withdrawals"
subtitle="Recent cost movement across the last seven days."
>
{usage.loading && recentCostDaily.length === 0 ? (
<EmptyPanelState message="Loading ATM ledger." />
) : recentCostDaily.length === 0 ? (
<EmptyPanelState message="No token spend recorded for the current ledger window." />
) : (
<div className="grid grid-cols-7 gap-3">
{recentCostDaily.map((entry) => {
const heightPct = chartMax > 0 ? (entry.totalCost / chartMax) * 100 : 0;
return (
<div key={entry.date} className="flex min-w-0 flex-col items-center gap-3">
<div className="text-center text-[11px] uppercase tracking-[0.12em] text-[#9dfef0]/68">
{formatCurrency(entry.totalCost)}
</div>
<div className="flex h-[230px] w-full items-end rounded-[20px] border border-[#7dfff0]/10 bg-[#041315]/86 p-2">
<div
className="w-full rounded-[14px] bg-[linear-gradient(180deg,#7effef_0%,#2cd3bf_100%)] shadow-[0_0_18px_rgba(85,255,231,0.32)]"
style={{ height: `${Math.max(8, heightPct)}%` }}
title={`${entry.date} ${formatCurrency(entry.totalCost)}`}
/>
</div>
<div className="text-[11px] uppercase tracking-[0.16em] text-[#7bd9cd]/72">
{entry.date.slice(5)}
</div>
</div>
);
})}
</div>
)}
</SectionCard>
<div className="grid grid-cols-1 gap-6 xl:grid-cols-2">
<SectionCard
title="Activity By Day"
subtitle="Daily tokens, cost, messages, tool calls, and errors."
>
<div className="space-y-3">
{usage.aggregates.daily.map((entry) => (
<ListRow
key={entry.date}
title={entry.date}
primary={`${formatCurrency(entry.cost)} · ${formatNumber(entry.tokens)} tokens`}
secondary={`${formatNumber(entry.messages)} messages · ${formatNumber(entry.toolCalls)} tool calls · ${formatNumber(entry.errors)} errors`}
/>
))}
{usage.aggregates.daily.length === 0 ? (
<EmptyPanelState message="No daily activity rows available yet." />
) : null}
</div>
</SectionCard>
<SectionCard
title="Budget Alerts"
subtitle="Threshold warnings for daily, monthly, and per-agent spend."
>
<div className="space-y-3">
{usage.budgetAlerts.map((alert) => (
<div
key={alert.key}
className={`rounded-2xl border px-4 py-4 text-[13px] ${
alert.severity === "danger"
? "border-rose-400/35 bg-rose-500/12 text-rose-100"
: "border-amber-300/30 bg-amber-400/12 text-amber-50"
}`}
>
<div className="text-[11px] uppercase tracking-[0.18em] opacity-70">
{alert.label}
</div>
<div className="mt-2 text-[16px]">
{formatCurrency(alert.currentUsd)} / {formatCurrency(alert.limitUsd)}.
</div>
</div>
))}
{usage.budgetAlerts.length === 0 ? (
<EmptyPanelState
message="Budget thresholds are healthy for the current ATM ledger window."
tone="success"
/>
) : null}
</div>
</SectionCard>
</div>
<div className="grid grid-cols-1 gap-6 xl:grid-cols-2">
<SectionCard title="Agent Expenses" subtitle="All agents ranked by total spend.">
<div className="space-y-3">
{usage.aggregates.byAgent.map((entry, index) => (
<ListRow
key={entry.agentId}
title={`Account ${String(index + 1).padStart(2, "0")} · ${entry.agentName}`}
primary={formatCurrency(entry.totals.totalCost)}
secondary={`${formatNumber(entry.totals.totalTokens)} tokens`}
/>
))}
{usage.aggregates.byAgent.length === 0 ? (
<EmptyPanelState message="No agent token activity yet." />
) : null}
</div>
</SectionCard>
<SectionCard
title="Model Expenses"
subtitle="Provider and model spend breakdown."
>
<div className="space-y-3">
{usage.aggregates.byModel.map((entry, index) => (
<ListRow
key={`${entry.provider ?? "unknown"}:${entry.model ?? "unknown"}`}
title={`Route ${String(index + 1).padStart(2, "0")} · ${entry.provider ?? "unknown"} / ${entry.model ?? "unknown"}`}
primary={formatCurrency(entry.totals.totalCost)}
secondary={`${formatNumber(entry.totals.totalTokens)} tokens`}
/>
))}
{usage.aggregates.byModel.length === 0 ? (
<EmptyPanelState message="No model cost routes recorded yet." />
) : null}
</div>
</SectionCard>
</div>
<div className="grid grid-cols-1 gap-6 xl:grid-cols-2">
<SectionCard title="Tool Usage" subtitle="All tools observed in the selected sessions.">
<div className="space-y-3">
{usage.aggregates.tools.tools.map((tool, index) => (
<ListRow
key={tool.name}
title={`Tool ${String(index + 1).padStart(2, "0")} · ${tool.name}`}
primary={formatNumber(tool.count)}
secondary="total invocations"
/>
))}
{usage.aggregates.tools.tools.length === 0 ? (
<EmptyPanelState message="No tool usage has been recorded yet." />
) : null}
</div>
</SectionCard>
<SectionCard
title="Message Totals"
subtitle="Conversation activity across all selected sessions."
>
<div className="grid grid-cols-2 gap-3">
<SummaryCard
label="All Messages"
value={formatNumber(usage.aggregates.messages.total)}
/>
<SummaryCard
label="User"
value={formatNumber(usage.aggregates.messages.user)}
/>
<SummaryCard
label="Assistant"
value={formatNumber(usage.aggregates.messages.assistant)}
/>
<SummaryCard
label="Tool Results"
value={formatNumber(usage.aggregates.messages.toolResults)}
/>
</div>
</SectionCard>
</div>
<SectionCard
title="Recent Sessions"
subtitle="Latest sessions with cost and token totals."
>
<div className="space-y-3">
{recentSessions.map((session) => (
<ListRow
key={session.key}
title={session.label ?? session.agentName ?? session.key}
primary={`${formatCurrency(session.usage.totals.totalCost)} · ${formatNumber(
session.usage.totals.totalTokens,
)} tokens`}
secondary={`${session.provider ?? "unknown"} / ${session.model ?? "unknown"} · ${
session.updatedAt
? new Date(session.updatedAt).toLocaleString()
: "no timestamp"
}`}
/>
))}
{recentSessions.length === 0 ? (
<EmptyPanelState message="No sessions available for the selected range." />
) : null}
</div>
</SectionCard>
</div>
</div>
</div>
);
}
function SectionCard({
title,
subtitle,
action,
children,
}: {
title: string;
subtitle: string;
action?: ReactNode;
children: ReactNode;
}) {
return (
<section className="rounded-[28px] border border-[#7dfff0]/16 bg-black/20 p-6">
<div className="flex items-start justify-between gap-4">
<div>
<div className="text-[12px] uppercase tracking-[0.24em] text-[#8cfff3]/64">
{title}
</div>
<div className="mt-2 text-[14px] text-[#d8fff7]/74">{subtitle}</div>
</div>
{action}
</div>
<div className="mt-5">{children}</div>
</section>
);
}
function SummaryCard({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-2xl border border-[#7dfff0]/10 bg-[#031314]/78 px-4 py-3">
<div className="text-[11px] uppercase tracking-[0.18em] text-[#7addcf]/58">
{label}
</div>
<div className="mt-2 text-[16px] text-[#e4fff9]">{value}</div>
</div>
);
}
function ListRow({
title,
primary,
secondary,
}: {
title: string;
primary: string;
secondary: string;
}) {
return (
<div className="flex items-center justify-between gap-4 rounded-2xl border border-[#7dfff0]/10 bg-[#031314]/78 px-4 py-3">
<div className="min-w-0">
<div className="truncate text-[13px] uppercase tracking-[0.12em] text-[#dffef8]">
{title}
</div>
<div className="mt-1 text-[11px] text-[#8cdcd1]/66">{secondary}</div>
</div>
<div className="shrink-0 text-right text-[15px] text-[#d9fff8]">{primary}</div>
</div>
);
}
function EmptyPanelState({
message,
tone = "neutral",
}: {
message: string;
tone?: "neutral" | "success" | "danger";
}) {
const toneClass =
tone === "danger"
? "border-rose-400/30 bg-rose-500/12 text-rose-100"
: tone === "success"
? "border-emerald-400/25 bg-emerald-500/10 text-emerald-100"
: "border-[#7dfff0]/10 bg-[#031314]/78 text-[#b6fff7]/70";
return (
<div className={`rounded-2xl border px-4 py-4 text-[13px] ${toneClass}`}>
{message}
</div>
);
}
@@ -0,0 +1,904 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Image from "next/image";
import {
ExternalLink,
Github,
RefreshCw,
ShieldCheck,
ShieldX,
MessageSquare,
} from "lucide-react";
import type {
GitHubDashboardResponse,
GitHubDetailResponse,
GitHubInlineCommentSide,
GitHubPullRequestDetail,
GitHubPullRequestSummary,
GitHubReviewAction,
} from "@/lib/office/github";
import { resolveSkillMarketplaceMetadata } from "@/lib/skills/marketplace";
import {
buildSkillMissingDetails,
deriveSkillReadinessState,
type SkillReadinessState,
} from "@/lib/skills/presentation";
import type { SkillStatusEntry } from "@/lib/skills/types";
import { FileDiffModal } from "./github/FileDiffModal";
import { useBrowserPreview } from "./github/useBrowserPreview";
import {
GITHUB_RECORDING_PRIVACY_MASK_ACTIVE,
formatRelativeTime,
maskGitHubRecordingText,
summarizeChecksTone,
} from "./github/utils";
type GithubImmersiveScreenProps = {
agentName?: string | null;
githubSkill?: SkillStatusEntry | null;
onOpenSetup?: () => void;
};
export function GithubImmersiveScreen({
agentName,
githubSkill = null,
onOpenSetup,
}: GithubImmersiveScreenProps) {
const [dashboard, setDashboard] = useState<GitHubDashboardResponse | null>(
null,
);
const [detail, setDetail] = useState<GitHubPullRequestDetail | null>(null);
const [loading, setLoading] = useState(true);
const [detailLoading, setDetailLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<"queue" | "repo">("queue");
const [selectedPr, setSelectedPr] = useState<GitHubPullRequestSummary | null>(
null,
);
const [reviewBody, setReviewBody] = useState("");
const [reviewBusyAction, setReviewBusyAction] =
useState<GitHubReviewAction | null>(null);
const [reviewMessage, setReviewMessage] = useState<string | null>(null);
const [detailMode, setDetailMode] = useState<"summary" | "browser">(
"summary",
);
const [selectedFilePath, setSelectedFilePath] = useState<string | null>(null);
const requestIdRef = useRef(0);
const detailRequestIdRef = useRef(0);
const skillReadiness = useMemo<SkillReadinessState | null>(
() => (githubSkill ? deriveSkillReadinessState(githubSkill) : null),
[githubSkill],
);
const skillMissingDetails = useMemo(
() => (githubSkill ? buildSkillMissingDetails(githubSkill) : []),
[githubSkill],
);
const skillMetadata = useMemo(
() => (githubSkill ? resolveSkillMarketplaceMetadata(githubSkill) : null),
[githubSkill],
);
const refreshDashboard = useCallback(async () => {
const requestId = requestIdRef.current + 1;
requestIdRef.current = requestId;
setLoading(true);
setError(null);
try {
const response = await fetch("/api/office/github", { cache: "no-store" });
const payload = (await response.json()) as GitHubDashboardResponse & {
error?: string;
};
if (!response.ok) {
throw new Error(
payload.error?.trim() || "Unable to load GitHub dashboard.",
);
}
if (requestIdRef.current !== requestId) return;
setDashboard(payload);
setSelectedPr((current) => {
if (!current) {
return (
payload.reviewRequests[0] ??
payload.currentRepoPullRequests[0] ??
payload.authoredPullRequests[0] ??
null
);
}
return (
[
...payload.reviewRequests,
...payload.currentRepoPullRequests,
...payload.authoredPullRequests,
].find(
(entry) =>
entry.repo === current.repo && entry.number === current.number,
) ?? current
);
});
} catch (error) {
if (requestIdRef.current !== requestId) return;
setDashboard(null);
setError(
error instanceof Error
? error.message
: "Unable to load GitHub dashboard.",
);
} finally {
if (requestIdRef.current === requestId) {
setLoading(false);
}
}
}, []);
useEffect(() => {
void refreshDashboard();
}, [refreshDashboard]);
const loadDetail = useCallback(
async (summary: GitHubPullRequestSummary | null) => {
if (!summary) {
detailRequestIdRef.current += 1;
setDetail(null);
setSelectedFilePath(null);
return;
}
const requestId = detailRequestIdRef.current + 1;
detailRequestIdRef.current = requestId;
setDetailLoading(true);
try {
const params = new URLSearchParams({
repo: summary.repo,
number: String(summary.number),
});
const response = await fetch(
`/api/office/github?${params.toString()}`,
{
cache: "no-store",
},
);
const payload = (await response.json()) as GitHubDetailResponse & {
error?: string;
};
if (!response.ok) {
throw new Error(
payload.error?.trim() || "Unable to load pull request details.",
);
}
if (detailRequestIdRef.current !== requestId) return;
setDetail(payload.pullRequest);
setSelectedFilePath(null);
} catch (error) {
if (detailRequestIdRef.current !== requestId) return;
setDetail(null);
setSelectedFilePath(null);
setError(
error instanceof Error
? error.message
: "Unable to load pull request details.",
);
} finally {
if (detailRequestIdRef.current === requestId) {
setDetailLoading(false);
}
}
},
[],
);
useEffect(() => {
void loadDetail(selectedPr);
}, [loadDetail, selectedPr]);
const browserPreview = useBrowserPreview(
detail?.url ?? null,
detailMode === "browser" && !GITHUB_RECORDING_PRIVACY_MASK_ACTIVE,
);
const queueEntries = useMemo(
() =>
dashboard
? [
...dashboard.reviewRequests,
...dashboard.authoredPullRequests,
].filter(
(entry, index, list) =>
list.findIndex(
(candidate) =>
candidate.repo === entry.repo &&
candidate.number === entry.number,
) === index,
)
: [],
[dashboard],
);
const activeList =
activeTab === "queue"
? queueEntries
: (dashboard?.currentRepoPullRequests ?? []);
const currentRepoLabel = useMemo(() => {
const slug = dashboard?.currentRepoSlug?.trim();
if (!slug) return "No git remote";
const segments = slug.split("/").filter(Boolean);
return maskGitHubRecordingText(segments.at(-1) ?? slug);
}, [dashboard?.currentRepoSlug]);
const isInitialLoading = loading && dashboard === null && !error;
const selectedFile = useMemo(
() => detail?.files.find((file) => file.path === selectedFilePath) ?? null,
[detail?.files, selectedFilePath],
);
useEffect(() => {
if (!selectedFile) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setSelectedFilePath(null);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [selectedFile]);
const handleSelectPr = useCallback(
(summary: GitHubPullRequestSummary, tab: "queue" | "repo") => {
setActiveTab(tab);
setSelectedPr(summary);
setSelectedFilePath(null);
setReviewBody("");
setReviewMessage(null);
},
[],
);
const handleSubmitReview = useCallback(
async (action: GitHubReviewAction) => {
if (!detail) return;
setReviewBusyAction(action);
setReviewMessage(null);
setError(null);
try {
const response = await fetch("/api/office/github", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
repo: detail.repo,
number: detail.number,
action,
body: reviewBody,
}),
});
const payload = (await response.json()) as {
error?: string;
message?: string;
};
if (!response.ok) {
throw new Error(
payload.error?.trim() || "Unable to submit GitHub review.",
);
}
setReviewMessage(payload.message?.trim() || "Review submitted.");
setReviewBody("");
await Promise.all([refreshDashboard(), loadDetail(selectedPr)]);
} catch (error) {
setError(
error instanceof Error
? error.message
: "Unable to submit GitHub review.",
);
} finally {
setReviewBusyAction(null);
}
},
[detail, loadDetail, refreshDashboard, reviewBody, selectedPr],
);
const handleSubmitInlineComment = useCallback(
async (input: {
repo: string;
pullNumber: number;
commitId: string | null;
path: string;
line: number;
side: GitHubInlineCommentSide;
body: string;
}) => {
const response = await fetch("/api/office/github", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
repo: input.repo,
number: input.pullNumber,
commitId: input.commitId,
path: input.path,
line: input.line,
side: input.side,
body: input.body,
}),
});
const payload = (await response.json()) as { error?: string; message?: string };
if (!response.ok) {
const message =
payload.error?.trim() || "Unable to submit the GitHub inline comment.";
throw new Error(message);
}
},
[],
);
const shouldBlockForSkillSetup =
githubSkill !== null &&
skillReadiness !== null &&
skillReadiness !== "ready";
if (shouldBlockForSkillSetup) {
return (
<div className="flex h-full flex-col bg-[#050816] text-white">
<div className="border-b border-cyan-500/10 bg-[#071122] px-8 py-6">
<div className="flex items-center gap-3 text-cyan-200">
<Github className="h-6 w-6" />
<div>
<div className="text-[11px] uppercase tracking-[0.28em] text-cyan-200/70">
Code Review Room
</div>
<div className="text-xl font-semibold">
GitHub skill setup required.
</div>
</div>
</div>
</div>
<div className="flex flex-1 items-center justify-center px-8">
<div className="max-w-xl rounded-3xl border border-cyan-400/15 bg-[#081427] p-8 shadow-[0_30px_120px_rgba(0,0,0,0.55)]">
<div className="mb-4 flex items-center gap-3 text-cyan-100">
<ShieldX className="h-5 w-5 text-amber-300" />
<span className="text-sm uppercase tracking-[0.24em] text-cyan-100/70">
{skillMetadata?.tagline ??
"GitHub access is not ready for this agent."}
</span>
</div>
<div className="text-2xl font-semibold text-white">
{skillReadiness === "disabled-globally"
? "GitHub is disabled for this gateway."
: skillReadiness === "unavailable"
? "This agent cannot use the GitHub skill yet."
: "The GitHub skill still needs setup."}
</div>
<p className="mt-3 text-sm leading-6 text-cyan-100/72">
Open the Skills panel to install or enable the bundled GitHub
skill so agents can walk here and review pull requests through
OpenClaw.
</p>
<div className="mt-5 space-y-2">
{skillMissingDetails.length > 0 ? (
skillMissingDetails.map((line) => (
<div
key={line}
className="rounded-2xl border border-white/6 bg-black/20 px-4 py-3 text-sm text-white/72"
>
{line}
</div>
))
) : (
<div className="rounded-2xl border border-white/6 bg-black/20 px-4 py-3 text-sm text-white/72">
Enable the GitHub skill for the selected agent, then come back
to the code review room.
</div>
)}
</div>
{onOpenSetup ? (
<button
type="button"
onClick={onOpenSetup}
className="mt-6 inline-flex items-center gap-2 rounded-full border border-cyan-300/30 bg-cyan-300/10 px-5 py-2.5 text-sm font-medium text-cyan-100 transition-colors hover:border-cyan-200/50 hover:bg-cyan-300/18"
>
<ShieldCheck className="h-4 w-4" />
Open Skills Setup
</button>
) : null}
</div>
</div>
</div>
);
}
return (
<div className="relative flex h-full flex-col overflow-hidden bg-[radial-gradient(circle_at_top,#0f1b3d_0%,#060916_42%,#020409_100%)] text-white">
<div className="border-b border-cyan-400/12 bg-[#06101f]/82 px-6 py-4 backdrop-blur-sm">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-2xl border border-cyan-300/18 bg-cyan-300/8">
<Github className="h-5 w-5 text-cyan-100" />
</div>
<div>
<div className="text-[11px] uppercase tracking-[0.28em] text-cyan-200/65">
Code Review Room
</div>
<div className="text-lg font-semibold text-white">
{agentName
? `${agentName} is reviewing GitHub.`
: "GitHub review station."}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
void refreshDashboard();
void loadDetail(selectedPr);
}}
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-[12px] text-white/72 transition-colors hover:border-white/20 hover:text-white"
>
<RefreshCw
className={`h-3.5 w-3.5 ${loading || detailLoading ? "animate-spin" : ""}`}
/>
Refresh
</button>
{dashboard?.viewerLogin ? (
<div className="rounded-full border border-cyan-300/16 bg-cyan-300/8 px-3 py-1.5 text-[12px] text-cyan-100/90">
@{maskGitHubRecordingText(dashboard.viewerLogin)}
</div>
) : null}
</div>
</div>
</div>
{error ? (
<div className="mx-6 mt-4 rounded-2xl border border-rose-400/16 bg-rose-400/8 px-4 py-3 text-sm text-rose-100">
{error}
</div>
) : null}
{reviewMessage ? (
<div className="mx-6 mt-4 rounded-2xl border border-emerald-400/16 bg-emerald-400/8 px-4 py-3 text-sm text-emerald-100">
{reviewMessage}
</div>
) : null}
{dashboard && !dashboard.ready && dashboard.message ? (
<div className="mx-6 mt-4 rounded-2xl border border-amber-300/16 bg-amber-300/10 px-4 py-3 text-sm text-amber-100">
{dashboard.message}
</div>
) : null}
{isInitialLoading ? (
<div className="flex min-h-0 flex-1 items-center justify-center px-8 py-10">
<div className="flex max-w-md flex-col items-center rounded-3xl border border-cyan-300/12 bg-[#081122]/78 px-8 py-10 text-center shadow-[0_20px_80px_rgba(0,0,0,0.38)]">
<div className="flex h-14 w-14 items-center justify-center rounded-full border border-cyan-300/18 bg-cyan-300/8">
<RefreshCw className="h-6 w-6 animate-spin text-cyan-100" />
</div>
<div className="mt-5 text-[11px] uppercase tracking-[0.28em] text-cyan-100/55">
Loading GitHub
</div>
<div className="mt-2 text-lg font-semibold text-white">
Fetching your review queue.
</div>
<div className="mt-2 text-sm text-white/58">
Pull requests, repo metadata, and review details are loading now.
</div>
</div>
</div>
) : (
<div className="grid min-h-0 flex-1 grid-cols-[340px_minmax(0,1fr)] gap-0">
<div className="flex min-h-0 flex-col border-r border-white/6 bg-[#081122]/72">
<div className="grid grid-cols-2 gap-2 p-3">
<button
type="button"
onClick={() => setActiveTab("queue")}
className={`min-w-0 rounded-2xl px-4 py-2.5 text-left transition-colors ${
activeTab === "queue"
? "border border-cyan-300/20 bg-cyan-300/12 text-white"
: "border border-white/6 bg-white/4 text-white/65 hover:text-white"
}`}
>
<div className="text-[11px] uppercase tracking-[0.24em] text-white/50">
My Queue
</div>
<div className="mt-1 text-lg font-semibold leading-none">
{queueEntries.length}
</div>
</button>
<button
type="button"
onClick={() => setActiveTab("repo")}
className={`min-w-0 rounded-2xl px-4 py-2.5 text-left transition-colors ${
activeTab === "repo"
? "border border-cyan-300/20 bg-cyan-300/12 text-white"
: "border border-white/6 bg-white/4 text-white/65 hover:text-white"
}`}
>
<div className="text-[11px] uppercase tracking-[0.24em] text-white/50">
Current Repo
</div>
<div className="mt-1 break-words text-sm font-medium leading-5 text-white/85">
{currentRepoLabel}
</div>
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-4">
{loading ? (
<div className="rounded-2xl border border-white/6 bg-white/4 px-4 py-4 text-sm text-white/55">
Loading pull requests.
</div>
) : activeList.length === 0 ? (
<div className="rounded-2xl border border-white/6 bg-white/4 px-4 py-4 text-sm text-white/55">
{activeTab === "queue"
? "No review requests or authored pull requests found."
: "No open pull requests found for this repository."}
</div>
) : (
<div className="space-y-3">
{activeList.map((entry) => {
const isSelected =
selectedPr?.repo === entry.repo &&
selectedPr?.number === entry.number;
return (
<button
key={`${entry.repo}#${entry.number}`}
type="button"
onClick={() => handleSelectPr(entry, activeTab)}
className={`w-full rounded-2xl border px-4 py-4 text-left transition-colors ${
isSelected
? "border-cyan-300/24 bg-cyan-300/10"
: "border-white/6 bg-white/4 hover:border-white/12 hover:bg-white/6"
}`}
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-[11px] uppercase tracking-[0.24em] text-white/45">
{maskGitHubRecordingText(entry.repo)}
</div>
<div className="mt-1 text-sm font-semibold text-white">
#{entry.number} {maskGitHubRecordingText(entry.title)}
</div>
</div>
{entry.isDraft ? (
<span className="rounded-full border border-white/10 px-2 py-1 text-[10px] uppercase tracking-[0.2em] text-white/55">
Draft
</span>
) : null}
</div>
<div className="mt-3 flex flex-wrap items-center gap-2 text-[11px] text-white/56">
<span>@{maskGitHubRecordingText(entry.author)}</span>
<span>{formatRelativeTime(entry.updatedAt)}</span>
{entry.statusSummary ? (
<span
className={summarizeChecksTone(
entry.statusSummary,
)}
>
{entry.statusSummary}
</span>
) : null}
</div>
</button>
);
})}
</div>
)}
</div>
</div>
<div className="min-h-0 overflow-hidden">
{detailLoading ? (
<div className="flex h-full items-center justify-center text-sm text-white/55">
Loading pull request details.
</div>
) : detail ? (
<div className="grid h-full grid-cols-[minmax(0,1fr)_320px]">
<div className="min-h-0 overflow-y-auto px-6 py-5">
<div className="flex items-start justify-between gap-4">
<div>
<div className="text-[11px] uppercase tracking-[0.26em] text-cyan-200/55">
{maskGitHubRecordingText(detail.repo)}
</div>
<div className="mt-1 text-2xl font-semibold text-white">
#{detail.number} {maskGitHubRecordingText(detail.title)}
</div>
<div className="mt-3 flex flex-wrap items-center gap-3 text-sm text-white/62">
<span>@{maskGitHubRecordingText(detail.author)}</span>
<span>{formatRelativeTime(detail.updatedAt)}</span>
{detail.reviewDecision ? (
<span>{detail.reviewDecision}</span>
) : null}
{detail.mergeable ? (
<span>{detail.mergeable}</span>
) : null}
</div>
</div>
<a
href={detail.url}
target="_blank"
rel="noreferrer"
className="inline-flex min-w-[96px] shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-full border border-white/10 bg-white/5 px-3 py-2 text-[12px] text-white/75 transition-colors hover:border-white/20 hover:text-white"
>
<ExternalLink className="h-3.5 w-3.5" />
Open PR
</a>
</div>
<div className="mt-5 grid gap-4 md:grid-cols-3">
<div className="rounded-2xl border border-white/6 bg-white/4 px-4 py-4">
<div className="text-[11px] uppercase tracking-[0.24em] text-white/45">
Checks
</div>
<div className="mt-2 text-lg font-semibold text-white">
{detail.statusChecks.length}
</div>
</div>
<div className="rounded-2xl border border-white/6 bg-white/4 px-4 py-4">
<div className="text-[11px] uppercase tracking-[0.24em] text-white/45">
Files Changed
</div>
<div className="mt-2 text-lg font-semibold text-white">
{detail.files.length}
</div>
</div>
<div className="rounded-2xl border border-white/6 bg-white/4 px-4 py-4">
<div className="text-[11px] uppercase tracking-[0.24em] text-white/45">
Reviews
</div>
<div className="mt-2 text-lg font-semibold text-white">
{detail.reviews.length}
</div>
</div>
</div>
<div className="mt-5 rounded-3xl border border-cyan-400/10 bg-[#071223]/88 p-5">
<div className="space-y-4">
<div>
<div className="text-[11px] uppercase tracking-[0.24em] text-cyan-100/52">
Review Actions
</div>
<div className="mt-1 text-sm text-white/68">
Submit the review directly from the server room.
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => void handleSubmitReview("APPROVE")}
disabled={Boolean(reviewBusyAction)}
className="inline-flex h-11 items-center justify-center whitespace-nowrap rounded-full border border-emerald-300/24 bg-emerald-300/10 px-4 text-sm text-emerald-100 transition-colors hover:border-emerald-200/40 hover:bg-emerald-300/16 disabled:cursor-not-allowed disabled:opacity-60"
>
{reviewBusyAction === "APPROVE"
? "Approving..."
: "Approve"}
</button>
<button
type="button"
onClick={() =>
void handleSubmitReview("REQUEST_CHANGES")
}
disabled={Boolean(reviewBusyAction)}
className="inline-flex h-11 items-center justify-center whitespace-nowrap rounded-full border border-amber-300/24 bg-amber-300/10 px-4 text-xs text-amber-100 transition-colors hover:border-amber-200/40 hover:bg-amber-300/16 disabled:cursor-not-allowed disabled:opacity-60"
>
{reviewBusyAction === "REQUEST_CHANGES"
? "Requesting..."
: "Request Changes"}
</button>
<button
type="button"
onClick={() => void handleSubmitReview("COMMENT")}
disabled={Boolean(reviewBusyAction)}
className="inline-flex h-11 items-center justify-center whitespace-nowrap rounded-full border border-cyan-300/24 bg-cyan-300/10 px-4 text-sm text-cyan-100 transition-colors hover:border-cyan-200/40 hover:bg-cyan-300/16 disabled:cursor-not-allowed disabled:opacity-60"
>
{reviewBusyAction === "COMMENT"
? "Sending..."
: "Comment"}
</button>
</div>
</div>
<textarea
value={reviewBody}
onChange={(event) => setReviewBody(event.target.value)}
placeholder="Add an approval note or request changes summary."
className="mt-4 h-28 w-full resize-none rounded-2xl border border-white/8 bg-black/22 px-4 py-3 text-sm text-white outline-none placeholder:text-white/28"
/>
</div>
<div className="mt-5 flex items-center gap-2">
<button
type="button"
onClick={() => setDetailMode("summary")}
className={`rounded-full px-3 py-1.5 text-[12px] transition-colors ${
detailMode === "summary"
? "border border-white/10 bg-white/10 text-white"
: "border border-white/6 bg-white/4 text-white/58 hover:text-white"
}`}
>
Summary
</button>
<button
type="button"
onClick={() => setDetailMode("browser")}
className={`rounded-full px-3 py-1.5 text-[12px] transition-colors ${
detailMode === "browser"
? "border border-white/10 bg-white/10 text-white"
: "border border-white/6 bg-white/4 text-white/58 hover:text-white"
}`}
>
Browser Preview
</button>
</div>
{detailMode === "summary" ? (
<>
<div className="mt-5 rounded-3xl border border-white/6 bg-white/4 p-5">
<div className="text-[11px] uppercase tracking-[0.24em] text-white/45">
Description
</div>
<div className="mt-3 whitespace-pre-wrap text-sm leading-6 text-white/78">
{maskGitHubRecordingText(detail.body) ||
"No pull request description."}
</div>
</div>
<div className="mt-5 rounded-3xl border border-white/6 bg-white/4 p-5">
<div className="text-[11px] uppercase tracking-[0.24em] text-white/45">
Diff Preview
</div>
<div className="mt-1 text-sm text-white/72">
Full pull request diff preview.
</div>
<pre className="mt-3 max-h-[320px] overflow-auto rounded-2xl border border-white/6 bg-black/28 p-4 text-[12px] leading-5 text-cyan-100/86">
{maskGitHubRecordingText(detail.diff) ||
"Diff preview unavailable."}
</pre>
{detail.diffTruncated ? (
<div className="mt-2 text-[11px] text-white/45">
Diff preview truncated for performance.
</div>
) : null}
</div>
</>
) : (
<div className="mt-5 rounded-3xl border border-white/6 bg-white/4 p-5">
<div className="mb-3 text-[11px] uppercase tracking-[0.24em] text-white/45">
Browser Preview
</div>
{GITHUB_RECORDING_PRIVACY_MASK_ACTIVE ? (
<div className="rounded-2xl border border-white/6 bg-black/20 px-4 py-5 text-sm text-white/55">
Browser preview is temporarily disabled so the screen
recording does not reveal the real GitHub username.
</div>
) : browserPreview.loading ? (
<div className="rounded-2xl border border-white/6 bg-black/20 px-4 py-5 text-sm text-white/55">
Capturing GitHub preview.
</div>
) : browserPreview.mediaUrl ? (
<Image
src={browserPreview.mediaUrl}
alt={`Preview of ${detail.url}`}
width={1280}
height={720}
unoptimized
className="h-auto w-full rounded-2xl border border-white/8 object-cover"
/>
) : (
<div className="rounded-2xl border border-white/6 bg-black/20 px-4 py-5 text-sm text-white/55">
{browserPreview.error ??
"Browser preview unavailable on this setup."}
</div>
)}
</div>
)}
</div>
<div className="min-h-0 overflow-y-auto border-l border-white/6 bg-[#060d19]/86 px-4 py-5">
<div className="text-[11px] uppercase tracking-[0.24em] text-white/42">
Checks
</div>
<div className="mt-3 space-y-2">
{detail.statusChecks.length > 0 ? (
detail.statusChecks.map((check) => (
<div
key={`${check.name}-${check.detailsUrl ?? "local"}`}
className="rounded-2xl border border-white/6 bg-white/4 px-3 py-3"
>
<div className="text-sm font-medium text-white">
{check.name}
</div>
<div className="mt-1 text-[12px] text-white/55">
{[check.status, check.conclusion, check.workflow]
.filter(Boolean)
.join(" · ") || "No status"}
</div>
</div>
))
) : (
<div className="rounded-2xl border border-white/6 bg-white/4 px-3 py-3 text-sm text-white/55">
No checks reported.
</div>
)}
</div>
<div className="mt-6 text-[11px] uppercase tracking-[0.24em] text-white/42">
Files
</div>
<div className="mt-3 space-y-2">
{detail.files.slice(0, 12).map((file) => (
<button
key={file.path}
type="button"
onClick={() => setSelectedFilePath(file.path)}
className={`w-full rounded-2xl border px-3 py-3 text-left transition-colors ${
selectedFile?.path === file.path
? "border-cyan-300/24 bg-cyan-300/10"
: "border-white/6 bg-white/4 hover:border-white/12 hover:bg-white/6"
}`}
>
<div className="truncate text-sm text-white">
{file.path}
</div>
<div className="mt-1 text-[12px] text-white/55">
+{file.additions} / -{file.deletions}
</div>
{file.status ? (
<div className="mt-1 text-[11px] uppercase tracking-[0.16em] text-white/40">
{file.status}
</div>
) : null}
</button>
))}
</div>
<div className="mt-6 text-[11px] uppercase tracking-[0.24em] text-white/42">
Recent Reviews
</div>
<div className="mt-3 space-y-2">
{detail.reviews.slice(0, 6).length > 0 ? (
detail.reviews.slice(0, 6).map((review, index) => (
<div
key={`${review.author}-${review.submittedAt ?? index}`}
className="rounded-2xl border border-white/6 bg-white/4 px-3 py-3"
>
<div className="flex items-center gap-2 text-sm text-white">
<MessageSquare className="h-3.5 w-3.5 text-cyan-200/70" />
<span>@{maskGitHubRecordingText(review.author)}</span>
</div>
<div className="mt-1 text-[12px] text-white/55">
{[review.state, review.submittedAt]
.filter(Boolean)
.join(" · ")}
</div>
{review.body ? (
<div className="mt-2 text-[12px] leading-5 text-white/68">
{maskGitHubRecordingText(review.body)}
</div>
) : null}
</div>
))
) : (
<div className="rounded-2xl border border-white/6 bg-white/4 px-3 py-3 text-sm text-white/55">
No reviews yet.
</div>
)}
</div>
</div>
</div>
) : (
<div className="flex h-full items-center justify-center px-8 text-center text-sm text-white/55">
Select a pull request to inspect its checks, diff, and review
actions.
</div>
)}
</div>
</div>
)}
{selectedFile && detail ? (
<FileDiffModal
key={selectedFile.path}
file={selectedFile}
repo={detail.repo}
pullNumber={detail.number}
commitId={detail.headRefOid}
onSubmitInlineComment={handleSubmitInlineComment}
onClose={() => setSelectedFilePath(null)}
/>
) : null}
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,165 @@
"use client";
import { AudioLines, PhoneCall, Smartphone } from "lucide-react";
import type { MockPhoneCallScenario } from "@/lib/office/call/types";
export type PhoneCallStep =
| "dialing"
| "ringing"
| "speaking"
| "reply"
| "complete";
export function PhoneBoothImmersiveScreen({
scenario,
step,
typedDigits,
}: {
scenario: MockPhoneCallScenario;
step: PhoneCallStep;
typedDigits: string;
}) {
const statusLabel =
step === "dialing"
? "Dialing"
: step === "ringing"
? "Waiting for answer"
: step === "speaking"
? "Connected"
: step === "reply"
? "On the line"
: "Call complete";
return (
<div className="absolute inset-0 overflow-hidden bg-[radial-gradient(circle_at_top,#0f172a_0%,#050816_46%,#02030a_100%)] text-white">
<div className="pointer-events-none absolute inset-0 opacity-25 [background-image:linear-gradient(rgba(56,189,248,0.08)_1px,transparent_1px),linear-gradient(90deg,rgba(56,189,248,0.08)_1px,transparent_1px)] [background-size:22px_22px]" />
<div className="relative flex h-full items-center justify-center px-8 py-10">
<div className="grid w-full max-w-5xl grid-cols-[1.05fr_0.95fr] gap-10">
<div className="rounded-[32px] border border-sky-300/18 bg-slate-950/65 p-8 shadow-[0_24px_90px_rgba(2,8,23,0.75)]">
<div className="flex items-center gap-3 text-[11px] uppercase tracking-[0.28em] text-sky-200/70">
<PhoneCall className="h-4 w-4" />
Phone Booth Call
</div>
<div className="mt-4 text-4xl font-semibold tracking-[0.08em] text-sky-50">
{scenario.callee}
</div>
<div className="mt-2 text-sm uppercase tracking-[0.24em] text-sky-200/55">
{statusLabel}
</div>
<div className="mt-8 rounded-[28px] border border-sky-300/16 bg-slate-900/90 p-6">
<div className="flex items-center justify-between text-[11px] uppercase tracking-[0.24em] text-sky-200/60">
<span>Calling from booth</span>
<span>{scenario.voiceAvailable ? "ElevenLabs ready" : "Text fallback"}</span>
</div>
<div className="mt-5 text-3xl font-medium tracking-[0.24em] text-sky-50">
{typedDigits || scenario.dialNumber}
</div>
<div className="mt-6 grid grid-cols-3 gap-3">
{["1", "2", "3", "4", "5", "6", "7", "8", "9", "*", "0", "#"].map((digit) => (
<div
key={digit}
className={`flex h-14 items-center justify-center rounded-2xl border text-xl ${
typedDigits.includes(digit)
? "border-sky-300/40 bg-sky-400/16 text-sky-50"
: "border-slate-700 bg-slate-900/75 text-slate-300"
}`}
>
{digit}
</div>
))}
</div>
<div className="mt-5 flex items-center justify-end">
<div
className={`inline-flex items-center gap-3 rounded-2xl border px-5 py-3 text-sm uppercase tracking-[0.22em] transition-all ${
step === "dialing"
? "border-emerald-300/18 bg-emerald-400/8 text-emerald-100/72"
: "border-emerald-300/45 bg-emerald-400/18 text-emerald-50 shadow-[0_0_24px_rgba(74,222,128,0.22)]"
}`}
>
<PhoneCall className="h-4 w-4" />
{step === "dialing" ? "Ready to call" : "Calling"}
</div>
</div>
</div>
</div>
<div className="flex items-center justify-center">
<div className="relative h-[74vh] max-h-[720px] w-[360px] rounded-[44px] border border-sky-200/20 bg-[#020617] p-3 shadow-[0_30px_120px_rgba(0,0,0,0.78)]">
<div className="absolute left-1/2 top-3 h-1.5 w-28 -translate-x-1/2 rounded-full bg-slate-700" />
<div className="relative flex h-full flex-col overflow-hidden rounded-[34px] border border-sky-300/12 bg-[linear-gradient(180deg,#081225_0%,#020617_100%)] px-6 py-8">
<div className="flex items-center justify-between text-[11px] uppercase tracking-[0.24em] text-sky-200/65">
<span>Cellular relay</span>
<Smartphone className="h-4 w-4" />
</div>
<div className="mt-8 flex h-28 w-28 items-center justify-center self-center rounded-full border border-sky-300/22 bg-sky-400/10 text-sky-100">
{step === "speaking" || step === "reply" ? (
<AudioLines className="h-12 w-12" />
) : (
<PhoneCall className="h-12 w-12" />
)}
</div>
<div className="mt-6 text-center">
<div className="text-[13px] uppercase tracking-[0.26em] text-sky-200/55">
{statusLabel}
</div>
<div className="mt-2 text-2xl font-semibold text-sky-50">
{scenario.callee}
</div>
<div className="mt-2 text-sm tracking-[0.22em] text-sky-200/60">
{scenario.dialNumber}
</div>
</div>
<div className="mt-8 flex-1 space-y-4">
<Bubble
label="Agent"
text={
step === "dialing"
? `Typing ${typedDigits || scenario.dialNumber}.`
: step === "ringing"
? `Pressed call and waiting for ${scenario.callee} to answer.`
: scenario.spokenText ?? "Preparing the line."
}
tone="primary"
/>
{step === "reply" || step === "complete" ? (
<Bubble
label={scenario.callee}
text={scenario.recipientReply ?? "The line is quiet."}
tone="secondary"
/>
) : null}
</div>
<div className="rounded-[24px] border border-sky-300/14 bg-slate-950/70 px-4 py-3 text-sm text-sky-100/78">
{scenario.statusLine}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
function Bubble({
label,
text,
tone,
}: {
label: string;
text: string;
tone: "primary" | "secondary";
}) {
return (
<div
className={`rounded-[24px] border px-4 py-4 ${
tone === "primary"
? "border-sky-300/18 bg-sky-400/10 text-sky-50"
: "border-slate-700 bg-slate-900/90 text-slate-100"
}`}
>
<div className="text-[10px] uppercase tracking-[0.22em] opacity-60">{label}</div>
<div className="mt-2 text-sm leading-6">{text}</div>
</div>
);
}
@@ -0,0 +1,264 @@
"use client";
import { CheckCheck, MessageSquareText, Send, Smartphone } from "lucide-react";
import type { MockTextMessageScenario } from "@/lib/office/text/types";
export type TextMessageStep =
| "selecting_contact"
| "composing"
| "sending"
| "delivered"
| "reply"
| "complete";
export function SmsBoothImmersiveScreen({
scenario,
step,
typedMessage,
activeKey,
contacts,
activeContactIndex,
}: {
scenario: MockTextMessageScenario;
step: TextMessageStep;
typedMessage: string;
activeKey: string | null;
contacts: string[];
activeContactIndex: number | null;
}) {
const statusLabel =
step === "selecting_contact"
? "Selecting contact"
: step === "composing"
? "Composing"
: step === "sending"
? "Sending"
: step === "delivered"
? "Delivered"
: step === "reply"
? "Reply received"
: "Message complete";
const messageBody = typedMessage || scenario.messageText || "";
return (
<div className="absolute inset-0 overflow-hidden bg-[radial-gradient(circle_at_top,#0f172a_0%,#050816_48%,#02030a_100%)] text-white">
<div className="pointer-events-none absolute inset-0 opacity-20 [background-image:linear-gradient(rgba(56,189,248,0.08)_1px,transparent_1px),linear-gradient(90deg,rgba(56,189,248,0.08)_1px,transparent_1px)] [background-size:22px_22px]" />
<div className="relative flex h-full items-center justify-center px-8 py-10">
<div className="grid w-full max-w-5xl grid-cols-[1fr_0.92fr] gap-10">
<div className="rounded-[32px] border border-sky-300/18 bg-slate-950/65 p-8 shadow-[0_24px_90px_rgba(2,8,23,0.75)]">
<div className="flex items-center gap-3 text-[11px] uppercase tracking-[0.28em] text-sky-200/70">
<MessageSquareText className="h-4 w-4" />
Messaging Booth
</div>
<div className="mt-4 text-4xl font-semibold tracking-[0.08em] text-sky-50">
{scenario.recipient}
</div>
<div className="mt-2 text-sm uppercase tracking-[0.24em] text-sky-200/55">
{statusLabel}
</div>
<div className="mt-8 rounded-[28px] border border-sky-300/16 bg-slate-900/90 p-6">
<div className="flex items-center justify-between text-[11px] uppercase tracking-[0.24em] text-sky-200/60">
<span>Typing from booth</span>
<span>iPhone relay</span>
</div>
<div className="mt-5 rounded-[24px] border border-slate-700 bg-slate-950/80 px-5 py-4 text-base leading-7 text-sky-50">
{messageBody || "Waiting for the first characters."}
{step === "composing" ? <span className="ml-1 inline-block animate-pulse">|</span> : null}
</div>
<div className="mt-5 flex items-center justify-end gap-3 text-sm uppercase tracking-[0.22em]">
<div className="inline-flex items-center gap-2 rounded-2xl border border-sky-300/22 bg-sky-400/10 px-4 py-2 text-sky-100/80">
<Smartphone className="h-4 w-4" />
Active
</div>
<div className="inline-flex items-center gap-2 rounded-2xl border border-emerald-300/24 bg-emerald-400/10 px-4 py-2 text-emerald-100/80">
{step === "sending" ? <Send className="h-4 w-4" /> : <CheckCheck className="h-4 w-4" />}
{step === "composing" ? "Drafting" : statusLabel}
</div>
</div>
</div>
</div>
<div className="flex items-center justify-center">
<div className="relative h-[74vh] max-h-[720px] w-[360px] rounded-[44px] border border-sky-200/20 bg-[#020617] p-3 shadow-[0_30px_120px_rgba(0,0,0,0.78)]">
<div className="absolute left-1/2 top-3 h-1.5 w-28 -translate-x-1/2 rounded-full bg-slate-700" />
<div className="relative flex h-full flex-col overflow-hidden rounded-[34px] border border-sky-300/12 bg-[linear-gradient(180deg,#081225_0%,#020617_100%)] px-5 py-6">
<div className="flex items-center justify-between text-[11px] uppercase tracking-[0.24em] text-sky-200/65">
<span>Messages</span>
<Smartphone className="h-4 w-4" />
</div>
<div className="mt-5 text-center">
<div className="text-[13px] uppercase tracking-[0.26em] text-sky-200/55">
{statusLabel}
</div>
<div className="mt-2 text-2xl font-semibold text-sky-50">
{scenario.recipient}
</div>
</div>
<div className="mt-6 flex-1">
{step === "selecting_contact" ? (
<ContactList
contacts={contacts}
activeContactIndex={activeContactIndex}
/>
) : (
<div className="space-y-4">
<Bubble
align="right"
label="Agent"
text={messageBody || "Starting draft."}
tone="primary"
/>
{step === "delivered" || step === "reply" || step === "complete" ? (
<div className="text-right text-[11px] uppercase tracking-[0.2em] text-sky-200/45">
Delivered
</div>
) : null}
{step === "reply" || step === "complete" ? (
<Bubble
align="left"
label={scenario.recipient}
text={scenario.confirmationText ?? "Delivered."}
tone="secondary"
/>
) : null}
</div>
)}
</div>
<div className="mt-4 rounded-[24px] border border-sky-300/14 bg-slate-950/75 p-3">
<PhoneKeyboard activeKey={activeKey} />
</div>
<div className="rounded-[24px] border border-sky-300/14 bg-slate-950/70 px-4 py-3 text-sm text-sky-100/78">
{scenario.statusLine}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
function ContactList({
contacts,
activeContactIndex,
}: {
contacts: string[];
activeContactIndex: number | null;
}) {
const selectedIndex = activeContactIndex ?? 0;
const windowStart = Math.max(
0,
Math.min(selectedIndex - 2, Math.max(contacts.length - 5, 0)),
);
const visibleContacts = contacts.slice(windowStart, windowStart + 5);
return (
<div className="relative h-full overflow-hidden rounded-[24px] border border-sky-300/14 bg-slate-950/55 px-3 py-3">
<div className="pointer-events-none absolute inset-x-0 top-0 h-8 bg-[linear-gradient(180deg,rgba(2,6,23,0.94),rgba(2,6,23,0))]" />
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-8 bg-[linear-gradient(0deg,rgba(2,6,23,0.94),rgba(2,6,23,0))]" />
<div className="space-y-2 pt-3">
{visibleContacts.map((contact, index) => {
const absoluteIndex = windowStart + index;
const active = absoluteIndex === selectedIndex;
return (
<div
key={`${contact}-${absoluteIndex}`}
className={`rounded-[22px] border px-4 py-3 transition-all duration-150 ${
active
? "scale-[0.98] border-sky-200/70 bg-sky-300/20 text-sky-50 shadow-[0_0_20px_rgba(56,189,248,0.2)]"
: "border-slate-700/80 bg-slate-900/80 text-slate-200"
}`}
>
<div className="text-sm font-medium">{contact}</div>
<div className="mt-1 text-[11px] uppercase tracking-[0.18em] opacity-60">
{active ? "Opening conversation" : "Recent thread"}
</div>
</div>
);
})}
</div>
</div>
);
}
const KEYBOARD_ROWS = [
["q", "w", "e", "r", "t", "y", "u", "i", "o", "p"],
["a", "s", "d", "f", "g", "h", "j", "k", "l"],
["z", "x", "c", "v", "b", "n", "m", ",", ".", "?"],
] as const;
function PhoneKeyboard({ activeKey }: { activeKey: string | null }) {
return (
<div className="space-y-2">
{KEYBOARD_ROWS.map((row, rowIndex) => (
<div
key={row.join("")}
className={`flex gap-2 ${rowIndex === 1 ? "px-3" : rowIndex === 2 ? "px-6" : ""}`}
>
{row.map((keyValue) => (
<KeyboardKey
key={keyValue}
label={keyValue}
active={activeKey === keyValue}
/>
))}
</div>
))}
<div className="flex items-center gap-2">
<KeyboardKey label="123" active={false} className="w-[18%]" />
<KeyboardKey label="space" active={activeKey === "space"} className="flex-1" />
<KeyboardKey label="return" active={activeKey === "return"} className="w-[22%]" />
</div>
</div>
);
}
function KeyboardKey({
label,
active,
className = "",
}: {
label: string;
active: boolean;
className?: string;
}) {
return (
<div
className={`flex h-9 min-w-0 flex-1 items-center justify-center rounded-2xl border text-[12px] font-medium uppercase tracking-[0.12em] transition-all duration-100 ${
active
? "scale-[0.96] border-sky-200/70 bg-sky-300/30 text-sky-50 shadow-[0_0_20px_rgba(56,189,248,0.25)]"
: "border-slate-700/90 bg-slate-800/90 text-slate-200"
} ${className}`}
>
{label}
</div>
);
}
function Bubble({
align,
label,
text,
tone,
}: {
align: "left" | "right";
label: string;
text: string;
tone: "primary" | "secondary";
}) {
return (
<div className={align === "right" ? "ml-10" : "mr-10"}>
<div
className={`rounded-[24px] border px-4 py-4 ${
tone === "primary"
? "border-sky-300/18 bg-sky-400/10 text-sky-50"
: "border-slate-700 bg-slate-900/90 text-slate-100"
}`}
>
<div className="text-[10px] uppercase tracking-[0.22em] opacity-60">{label}</div>
<div className="mt-2 text-sm leading-6">{text}</div>
</div>
</div>
);
}
@@ -0,0 +1,231 @@
"use client";
import { ExternalLink, X } from "lucide-react";
import type { StandupMeeting } from "@/lib/office/standup/types";
const sourceTone = (ready: boolean, stale: boolean) => {
if (!ready) return stale ? "text-amber-200 border-amber-400/25" : "text-rose-200 border-rose-400/25";
return "text-emerald-200 border-emerald-400/25";
};
export function StandupImmersiveScreen({
meeting,
onClose,
}: {
meeting: StandupMeeting;
onClose: () => void;
}) {
return (
<div className="fixed inset-0 z-50 bg-[#05070b]/96 text-white">
<div className="flex h-full flex-col">
<div className="flex items-center justify-between border-b border-cyan-500/15 px-6 py-4">
<div>
<div className="font-mono text-[11px] uppercase tracking-[0.28em] text-cyan-200/85">
Standup Board
</div>
<div className="mt-1 font-mono text-[12px] text-white/50">
{meeting.phase === "gathering"
? "Everyone is walking to the meeting room."
: meeting.phase === "in_progress"
? "Team updates are being presented."
: "Last standup snapshot."}
</div>
</div>
<button
type="button"
onClick={onClose}
className="inline-flex items-center gap-2 rounded border border-white/10 bg-white/5 px-3 py-2 font-mono text-[11px] uppercase tracking-[0.16em] text-white/70 transition-colors hover:border-white/20 hover:text-white"
>
<X className="h-4 w-4" />
Close
</button>
</div>
<div className="grid gap-4 border-b border-cyan-500/10 px-6 py-4 font-mono text-[11px] text-white/60 md:grid-cols-3">
<div>Phase: {meeting.phase}</div>
<div>Speaker: {meeting.currentSpeakerAgentId ?? "Waiting"}</div>
<div>
Progress: {meeting.arrivedAgentIds.length}/{meeting.participantOrder.length} arrived
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-5">
<div className="grid gap-4 xl:grid-cols-3">
{meeting.cards.map((card) => {
const isSpeaking = card.agentId === meeting.currentSpeakerAgentId;
return (
<section
key={card.agentId}
className={`rounded-2xl border px-4 py-4 ${
isSpeaking
? "border-cyan-400/35 bg-cyan-500/[0.08]"
: "border-white/10 bg-white/[0.03]"
}`}
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="font-mono text-[11px] uppercase tracking-[0.18em] text-white/45">
Participant
</div>
<div className="mt-1 text-lg font-semibold text-white">
{card.agentName}
</div>
</div>
{isSpeaking ? (
<div className="rounded border border-cyan-400/30 bg-cyan-500/10 px-2 py-1 font-mono text-[10px] uppercase tracking-[0.16em] text-cyan-100">
Speaking
</div>
) : null}
</div>
<div className="mt-4 space-y-4">
<div>
<div className="font-mono text-[10px] uppercase tracking-[0.16em] text-white/35">
Current task
</div>
<div className="mt-1 text-sm leading-6 text-white/85">
{card.currentTask}
</div>
</div>
<div>
<div className="font-mono text-[10px] uppercase tracking-[0.16em] text-white/35">
Recent commits
</div>
<div className="mt-2 space-y-2">
{card.recentCommits.length === 0 ? (
<div className="font-mono text-[11px] text-white/35">
No recent GitHub activity.
</div>
) : (
card.recentCommits.map((commit) => (
<div
key={commit.id}
className="rounded border border-white/8 bg-black/20 px-3 py-2"
>
<div className="text-sm text-white/82">{commit.title}</div>
{commit.subtitle ? (
<div className="mt-1 font-mono text-[10px] text-white/40">
{commit.subtitle}
</div>
) : null}
</div>
))
)}
</div>
</div>
<div>
<div className="font-mono text-[10px] uppercase tracking-[0.16em] text-white/35">
Active tickets
</div>
<div className="mt-2 space-y-2">
{card.activeTickets.length === 0 ? (
<div className="font-mono text-[11px] text-white/35">
No active Jira tickets.
</div>
) : (
card.activeTickets.map((ticket) => (
<div
key={ticket.id}
className="rounded border border-white/8 bg-black/20 px-3 py-2"
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="font-mono text-[10px] uppercase tracking-[0.14em] text-cyan-200/80">
{ticket.key}
</div>
<div className="mt-1 text-sm text-white/82">
{ticket.title}
</div>
<div className="mt-1 font-mono text-[10px] text-white/40">
{ticket.status}
</div>
</div>
{ticket.url ? (
<a
href={ticket.url}
target="_blank"
rel="noreferrer"
className="text-white/45 transition-colors hover:text-white"
>
<ExternalLink className="h-4 w-4" />
</a>
) : null}
</div>
</div>
))
)}
</div>
</div>
<div>
<div className="font-mono text-[10px] uppercase tracking-[0.16em] text-white/35">
Blockers
</div>
<div className="mt-2 space-y-2">
{card.blockers.length === 0 ? (
<div className="font-mono text-[11px] text-emerald-200/75">
No blockers reported.
</div>
) : (
card.blockers.map((blocker, index) => (
<div
key={`${card.agentId}-blocker-${index}`}
className="rounded border border-rose-400/20 bg-rose-500/[0.06] px-3 py-2 text-sm text-rose-100/90"
>
{blocker}
</div>
))
)}
</div>
</div>
{card.manualNotes.length > 0 ? (
<div>
<div className="font-mono text-[10px] uppercase tracking-[0.16em] text-white/35">
Manual notes
</div>
<div className="mt-2 space-y-2">
{card.manualNotes.map((note, index) => (
<div
key={`${card.agentId}-note-${index}`}
className="rounded border border-white/8 bg-black/20 px-3 py-2 text-sm text-white/75"
>
{note}
</div>
))}
</div>
</div>
) : null}
<div>
<div className="font-mono text-[10px] uppercase tracking-[0.16em] text-white/35">
Sources
</div>
<div className="mt-2 flex flex-wrap gap-2">
{card.sourceStates.map((source) => (
<div
key={`${card.agentId}-${source.kind}`}
className={`rounded border px-2 py-1 font-mono text-[10px] uppercase tracking-[0.12em] ${sourceTone(
source.ready,
source.stale
)}`}
>
{source.kind}
{source.error ? ` · ${source.error}` : source.stale ? " · stale" : ""}
</div>
))}
</div>
</div>
</div>
</section>
);
})}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,254 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import { X } from "lucide-react";
import type { GitHubInlineCommentSide } from "@/lib/office/github";
import {
getDiffLineTone,
parseDiffPatch,
type GitHubDiffFile,
} from "./diff";
import { maskGitHubRecordingText } from "./utils";
type FileDiffModalProps = {
file: GitHubDiffFile;
repo: string;
pullNumber: number;
commitId: string | null;
onSubmitInlineComment: (input: {
repo: string;
pullNumber: number;
commitId: string | null;
path: string;
line: number;
side: GitHubInlineCommentSide;
body: string;
}) => Promise<void>;
onClose: () => void;
};
export function FileDiffModal({
file,
repo,
pullNumber,
commitId,
onSubmitInlineComment,
onClose,
}: FileDiffModalProps) {
const diffLines = useMemo(() => parseDiffPatch(file.patch), [file.patch]);
const [selectedLineId, setSelectedLineId] = useState<string | null>(null);
const [commentBody, setCommentBody] = useState("");
const [commentBusy, setCommentBusy] = useState(false);
const [commentError, setCommentError] = useState<string | null>(null);
const [submissionMessage, setSubmissionMessage] = useState<string | null>(null);
const [submissionTone, setSubmissionTone] = useState<"info" | "success" | "error">(
"info",
);
const selectedLine = useMemo(
() =>
diffLines.find((line) => line.id === selectedLineId && line.commentable) ??
null,
[diffLines, selectedLineId],
);
const handleSubmitComment = useCallback(() => {
if (!selectedLine?.side || !selectedLine.lineNumber) return;
const nextCommentBody = commentBody;
const nextLineNumber = selectedLine.lineNumber;
const nextSide = selectedLine.side;
setCommentBusy(true);
setCommentError(null);
setSubmissionTone("info");
setSubmissionMessage("Submitting inline comment...");
setCommentBody("");
setSelectedLineId(null);
void onSubmitInlineComment({
repo,
pullNumber,
commitId,
path: file.path,
line: nextLineNumber,
side: nextSide,
body: nextCommentBody,
})
.then(() => {
setSubmissionTone("success");
setSubmissionMessage("Inline comment submitted.");
})
.catch((error) => {
setSubmissionTone("error");
setSubmissionMessage(
error instanceof Error
? error.message
: "Unable to submit the inline comment.",
);
})
.finally(() => {
setCommentBusy(false);
});
}, [
commentBody,
commitId,
file.path,
onSubmitInlineComment,
pullNumber,
repo,
selectedLine,
]);
return (
<div
className="absolute inset-0 z-40 flex items-center justify-center bg-black/76 p-6 backdrop-blur-sm"
onClick={onClose}
>
<div
className="flex h-full max-h-[92%] w-full max-w-6xl flex-col overflow-hidden rounded-[28px] border border-cyan-300/16 bg-[#081223] shadow-[0_28px_120px_rgba(0,0,0,0.66)]"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-start justify-between gap-4 border-b border-white/8 px-6 py-5">
<div className="min-w-0">
<div className="text-[11px] uppercase tracking-[0.24em] text-cyan-100/48">
File Diff
</div>
<div className="mt-2 break-all text-lg font-semibold text-white">
{file.path}
</div>
<div className="mt-3 flex flex-wrap items-center gap-2 text-[11px] uppercase tracking-[0.18em] text-white/50">
{file.status ? (
<span className="rounded-full border border-white/10 bg-white/5 px-2.5 py-1">
{file.status}
</span>
) : null}
<span className="rounded-full border border-emerald-400/18 bg-emerald-400/10 px-2.5 py-1 text-emerald-100/88">
+{file.additions}
</span>
<span className="rounded-full border border-rose-400/18 bg-rose-400/10 px-2.5 py-1 text-rose-100/88">
-{file.deletions}
</span>
</div>
{file.previousPath ? (
<div className="mt-3 text-sm text-white/54">
Renamed from {file.previousPath}
</div>
) : null}
</div>
<button
type="button"
onClick={onClose}
className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/10 bg-white/5 text-white/70 transition-colors hover:border-white/18 hover:text-white"
aria-label="Close file diff"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="min-h-0 flex-1 overflow-auto p-4">
<div className="overflow-hidden rounded-2xl border border-white/8 bg-[#050b15]">
<div className="border-b border-white/8 px-4 py-3 text-[11px] uppercase tracking-[0.22em] text-white/40">
Patch
</div>
{submissionMessage ? (
<div
className={`border-b px-4 py-3 text-sm ${
submissionTone === "success"
? "border-emerald-400/18 bg-emerald-400/10 text-emerald-100"
: submissionTone === "error"
? "border-rose-400/18 bg-rose-400/10 text-rose-100"
: "border-cyan-300/14 bg-cyan-300/10 text-cyan-100"
}`}
>
{submissionMessage}
</div>
) : null}
<div className="overflow-auto p-2">
{diffLines.map((line) => (
<div key={line.id}>
{line.commentable ? (
<button
type="button"
onClick={() => {
setSelectedLineId(line.id);
setCommentError(null);
}}
className={`grid w-full grid-cols-[44px_44px_minmax(0,1fr)] gap-3 rounded-md px-3 py-1 text-left transition-colors hover:bg-white/6 ${
selectedLine?.id === line.id ? "ring-1 ring-cyan-300/28" : ""
} ${getDiffLineTone(line.text)}`}
>
<span className="select-none text-right font-mono text-[11px] text-white/28">
{line.oldNumber ?? ""}
</span>
<span className="select-none text-right font-mono text-[11px] text-white/28">
{line.newNumber ?? ""}
</span>
<span className="block whitespace-pre-wrap break-all font-mono text-[12px] leading-5">
{maskGitHubRecordingText(line.text) || " "}
</span>
</button>
) : (
<div
className={`grid grid-cols-[44px_44px_minmax(0,1fr)] gap-3 rounded-md px-3 py-1 ${getDiffLineTone(line.text)}`}
>
<span className="select-none text-right font-mono text-[11px] text-white/22" />
<span className="select-none text-right font-mono text-[11px] text-white/22" />
<span className="block whitespace-pre-wrap break-all font-mono text-[12px] leading-5">
{maskGitHubRecordingText(line.text) || " "}
</span>
</div>
)}
{selectedLine?.id === line.id ? (
<div className="mx-3 my-2 rounded-2xl border border-cyan-300/14 bg-[#0b172c] p-4">
<div className="text-[11px] uppercase tracking-[0.18em] text-cyan-100/58">
Comment on {(selectedLine.side ?? "RIGHT").toLowerCase()} side
line {selectedLine.lineNumber}
</div>
<textarea
value={commentBody}
onChange={(event) => setCommentBody(event.target.value)}
placeholder="Add an inline comment."
className="mt-3 h-28 w-full resize-none rounded-2xl border border-white/8 bg-black/22 px-4 py-3 text-sm text-white outline-none placeholder:text-white/28"
/>
{commentError ? (
<div className="mt-3 rounded-xl border border-rose-400/18 bg-rose-400/10 px-3 py-2 text-sm text-rose-100">
{commentError}
</div>
) : null}
<div className="mt-3 flex items-center justify-between gap-3">
<div className="text-[12px] text-white/45">
This posts directly to GitHub on the selected diff line.
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
setSelectedLineId(null);
setCommentBody("");
setCommentError(null);
}}
className="inline-flex items-center rounded-full border border-white/10 bg-white/5 px-3 py-2 text-[12px] text-white/68 transition-colors hover:border-white/18 hover:text-white"
>
Cancel
</button>
<button
type="button"
disabled={commentBusy || !commentBody.trim()}
onClick={() => void handleSubmitComment()}
className="inline-flex items-center rounded-full border border-cyan-300/20 bg-cyan-300/10 px-3 py-2 text-[12px] text-cyan-100 transition-colors hover:border-cyan-200/38 hover:bg-cyan-300/16 disabled:cursor-not-allowed disabled:opacity-60"
>
{commentBusy ? "Submitting..." : "Add Comment"}
</button>
</div>
</div>
</div>
) : null}
</div>
))}
</div>
</div>
</div>
</div>
</div>
);
}
+143
View File
@@ -0,0 +1,143 @@
"use client";
import type {
GitHubInlineCommentSide,
GitHubPullRequestDetail,
} from "@/lib/office/github";
export type GitHubDiffFile = GitHubPullRequestDetail["files"][number];
export type ParsedDiffLine = {
id: string;
text: string;
kind: "meta" | "hunk" | "context" | "add" | "del";
oldNumber: number | null;
newNumber: number | null;
lineNumber: number | null;
side: GitHubInlineCommentSide | null;
commentable: boolean;
};
const HUNK_HEADER_PATTERN = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/;
export const getDiffLineTone = (line: string): string => {
if (line.startsWith("@@")) {
return "bg-cyan-400/10 text-cyan-100";
}
if (line.startsWith("+") && !line.startsWith("+++")) {
return "bg-emerald-500/12 text-emerald-100";
}
if (line.startsWith("-") && !line.startsWith("---")) {
return "bg-rose-500/12 text-rose-100";
}
if (
line.startsWith("diff --git") ||
line.startsWith("index ") ||
line.startsWith("---") ||
line.startsWith("+++")
) {
return "bg-white/4 text-white/72";
}
return "text-white/78";
};
export const parseDiffPatch = (patch: string | null): ParsedDiffLine[] => {
const lines = (patch ?? "Diff preview unavailable for this file.").split("\n");
const parsed: ParsedDiffLine[] = [];
let oldLine = 0;
let newLine = 0;
let inHunk = false;
lines.forEach((line, index) => {
const hunkMatch = line.match(HUNK_HEADER_PATTERN);
if (hunkMatch) {
oldLine = Number(hunkMatch[1]);
newLine = Number(hunkMatch[2]);
inHunk = true;
parsed.push({
id: `hunk-${index}`,
text: line,
kind: "hunk",
oldNumber: null,
newNumber: null,
lineNumber: null,
side: null,
commentable: false,
});
return;
}
if (!inHunk) {
parsed.push({
id: `meta-${index}`,
text: line,
kind: "meta",
oldNumber: null,
newNumber: null,
lineNumber: null,
side: null,
commentable: false,
});
return;
}
if (line.startsWith("+") && !line.startsWith("+++")) {
parsed.push({
id: `add-${index}`,
text: line,
kind: "add",
oldNumber: null,
newNumber: newLine,
lineNumber: newLine,
side: "RIGHT",
commentable: true,
});
newLine += 1;
return;
}
if (line.startsWith("-") && !line.startsWith("---")) {
parsed.push({
id: `del-${index}`,
text: line,
kind: "del",
oldNumber: oldLine,
newNumber: null,
lineNumber: oldLine,
side: "LEFT",
commentable: true,
});
oldLine += 1;
return;
}
if (line.startsWith("\\")) {
parsed.push({
id: `meta-${index}`,
text: line,
kind: "meta",
oldNumber: null,
newNumber: null,
lineNumber: null,
side: null,
commentable: false,
});
return;
}
parsed.push({
id: `ctx-${index}`,
text: line,
kind: "context",
oldNumber: oldLine,
newNumber: newLine,
lineNumber: newLine,
side: "RIGHT",
commentable: true,
});
oldLine += 1;
newLine += 1;
});
return parsed;
};
@@ -0,0 +1,87 @@
"use client";
import { useEffect, useRef, useState } from "react";
type BrowserPreviewState = {
mediaUrl: string | null;
browserUrl: string | null;
loading: boolean;
error: string | null;
};
export function useBrowserPreview(url: string | null, enabled: boolean) {
const [state, setState] = useState<BrowserPreviewState>({
mediaUrl: null,
browserUrl: url,
loading: false,
error: null,
});
const requestIdRef = useRef(0);
useEffect(() => {
if (!enabled || !url) {
requestIdRef.current += 1;
setState({
mediaUrl: null,
browserUrl: url,
loading: false,
error: null,
});
return;
}
const requestId = requestIdRef.current + 1;
requestIdRef.current = requestId;
setState((current) => ({
...current,
browserUrl: url,
loading: true,
error: null,
}));
void (async () => {
try {
const params = new URLSearchParams({
url,
ts: String(Date.now()),
});
const response = await fetch(
`/api/office/browser-preview?${params.toString()}`,
{
cache: "no-store",
},
);
const payload = (await response.json()) as {
error?: string;
mediaUrl?: string;
browserUrl?: string;
};
if (!response.ok) {
throw new Error(
payload.error?.trim() || "Unable to capture GitHub browser preview.",
);
}
if (requestIdRef.current !== requestId) return;
setState({
mediaUrl: payload.mediaUrl?.trim() || null,
browserUrl: payload.browserUrl?.trim() || url,
loading: false,
error: null,
});
} catch (error) {
if (requestIdRef.current !== requestId) return;
setState({
mediaUrl: null,
browserUrl: url,
loading: false,
error:
error instanceof Error
? error.message
: "Unable to capture GitHub browser preview.",
});
}
})();
}, [enabled, url]);
return state;
}
@@ -0,0 +1,29 @@
"use client";
export const GITHUB_RECORDING_PRIVACY_MASK_ACTIVE = false;
export const maskGitHubRecordingText = (
value: string | null | undefined,
): string => {
return value ?? "";
};
export const formatRelativeTime = (value: string | null): string => {
if (!value) return "Unknown update";
const timestamp = Date.parse(value);
if (!Number.isFinite(timestamp)) return value;
const deltaMinutes = Math.max(0, Math.round((Date.now() - timestamp) / 60_000));
if (deltaMinutes < 1) return "Updated just now";
if (deltaMinutes < 60) return `Updated ${deltaMinutes}m ago`;
const deltaHours = Math.round(deltaMinutes / 60);
if (deltaHours < 24) return `Updated ${deltaHours}h ago`;
const deltaDays = Math.round(deltaHours / 24);
return `Updated ${deltaDays}d ago`;
};
export const summarizeChecksTone = (summary: string | null): string => {
if (!summary) return "text-white/45";
if (summary.includes("failing")) return "text-rose-300";
if (summary.includes("pending")) return "text-amber-200";
return "text-emerald-200";
};
@@ -0,0 +1,230 @@
"use client";
import { useMemo, useReducer } from "react";
import type {
OfficeMap,
OfficeMapObject,
OfficeLightObject,
OfficeAmbienceEmitter,
OfficeInteractionPoint,
} from "@/lib/office/schema";
type BuilderHistory = {
past: OfficeMap[];
present: OfficeMap;
future: OfficeMap[];
};
type BuilderSelection = {
objectIds: string[];
};
type BuilderUi = {
zoom: number;
panX: number;
panY: number;
snapToGrid: boolean;
};
type BuilderState = {
history: BuilderHistory;
selection: BuilderSelection;
ui: BuilderUi;
};
type BuilderAction =
| { type: "select"; ids: string[] }
| { type: "moveObject"; id: string; x: number; y: number }
| { type: "rotateSelected"; step: number }
| { type: "flipSelected"; axis: "x" | "y" }
| { type: "setLayerOrder"; id: string; zIndex: number }
| { type: "toggleSnap" }
| { type: "setZoom"; zoom: number }
| { type: "setPan"; x: number; y: number }
| { type: "undo" }
| { type: "redo" }
| { type: "replaceMap"; map: OfficeMap }
| { type: "addLight"; light: OfficeLightObject }
| { type: "addEmitter"; emitter: OfficeAmbienceEmitter }
| { type: "addInteractionPoint"; point: OfficeInteractionPoint };
const cloneMap = (map: OfficeMap): OfficeMap => structuredClone(map);
const pushHistory = (history: BuilderHistory, present: OfficeMap): BuilderHistory => ({
past: [...history.past.slice(-49), cloneMap(history.present)],
present,
future: [],
});
const updateObjects = (map: OfficeMap, updater: (entry: OfficeMapObject) => OfficeMapObject): OfficeMap => {
return {
...map,
objects: map.objects.map(updater),
};
};
const reducer = (state: BuilderState, action: BuilderAction): BuilderState => {
if (action.type === "select") {
return {
...state,
selection: {
objectIds: action.ids,
},
};
}
if (action.type === "replaceMap") {
return {
...state,
history: pushHistory(state.history, cloneMap(action.map)),
};
}
if (action.type === "moveObject") {
const map = updateObjects(state.history.present, (entry) =>
entry.id === action.id ? { ...entry, x: action.x, y: action.y } : entry
);
return { ...state, history: pushHistory(state.history, map) };
}
if (action.type === "rotateSelected") {
const selected = new Set(state.selection.objectIds);
const map = updateObjects(state.history.present, (entry) =>
selected.has(entry.id)
? { ...entry, rotation: (((entry.rotation + action.step) % 360) + 360) % 360 }
: entry
);
return { ...state, history: pushHistory(state.history, map) };
}
if (action.type === "flipSelected") {
const selected = new Set(state.selection.objectIds);
const map = updateObjects(state.history.present, (entry) => {
if (!selected.has(entry.id)) return entry;
return action.axis === "x" ? { ...entry, flipX: !entry.flipX } : { ...entry, flipY: !entry.flipY };
});
return { ...state, history: pushHistory(state.history, map) };
}
if (action.type === "setLayerOrder") {
const map = updateObjects(state.history.present, (entry) =>
entry.id === action.id ? { ...entry, zIndex: action.zIndex } : entry
);
return { ...state, history: pushHistory(state.history, map) };
}
if (action.type === "toggleSnap") {
return {
...state,
ui: {
...state.ui,
snapToGrid: !state.ui.snapToGrid,
},
};
}
if (action.type === "setZoom") {
return {
...state,
ui: {
...state.ui,
zoom: action.zoom,
},
};
}
if (action.type === "setPan") {
return {
...state,
ui: {
...state.ui,
panX: action.x,
panY: action.y,
},
};
}
if (action.type === "addLight") {
const map: OfficeMap = {
...state.history.present,
lights: [...(state.history.present.lights ?? []), action.light],
};
return { ...state, history: pushHistory(state.history, map) };
}
if (action.type === "addEmitter") {
const map: OfficeMap = {
...state.history.present,
ambienceEmitters: [...(state.history.present.ambienceEmitters ?? []), action.emitter],
};
return { ...state, history: pushHistory(state.history, map) };
}
if (action.type === "addInteractionPoint") {
const map: OfficeMap = {
...state.history.present,
interactionPoints: [...(state.history.present.interactionPoints ?? []), action.point],
};
return { ...state, history: pushHistory(state.history, map) };
}
if (action.type === "undo") {
const previous = state.history.past[state.history.past.length - 1];
if (!previous) return state;
return {
...state,
history: {
past: state.history.past.slice(0, -1),
present: previous,
future: [cloneMap(state.history.present), ...state.history.future],
},
};
}
if (action.type === "redo") {
const next = state.history.future[0];
if (!next) return state;
return {
...state,
history: {
past: [...state.history.past, cloneMap(state.history.present)],
present: next,
future: state.history.future.slice(1),
},
};
}
return state;
};
export const useOfficeBuilderStore = (initialMap: OfficeMap) => {
const [state, dispatch] = useReducer(reducer, {
history: {
past: [],
present: cloneMap(initialMap),
future: [],
},
selection: {
objectIds: [],
},
ui: {
zoom: 1,
panX: 0,
panY: 0,
snapToGrid: true,
},
});
return useMemo(
() => ({
map: state.history.present,
selection: state.selection,
ui: state.ui,
canUndo: state.history.past.length > 0,
canRedo: state.history.future.length > 0,
select: (ids: string[]) => dispatch({ type: "select", ids }),
moveObject: (id: string, x: number, y: number) => dispatch({ type: "moveObject", id, x, y }),
rotateSelected: (step: number) => dispatch({ type: "rotateSelected", step }),
flipSelected: (axis: "x" | "y") => dispatch({ type: "flipSelected", axis }),
setLayerOrder: (id: string, zIndex: number) => dispatch({ type: "setLayerOrder", id, zIndex }),
toggleSnap: () => dispatch({ type: "toggleSnap" }),
setZoom: (zoom: number) => dispatch({ type: "setZoom", zoom }),
setPan: (x: number, y: number) => dispatch({ type: "setPan", x, y }),
undo: () => dispatch({ type: "undo" }),
redo: () => dispatch({ type: "redo" }),
replaceMap: (map: OfficeMap) => dispatch({ type: "replaceMap", map }),
addLight: (light: OfficeLightObject) => dispatch({ type: "addLight", light }),
addEmitter: (emitter: OfficeAmbienceEmitter) => dispatch({ type: "addEmitter", emitter }),
addInteractionPoint: (point: OfficeInteractionPoint) =>
dispatch({ type: "addInteractionPoint", point }),
}),
[state.history.future.length, state.history.past.length, state.history.present, state.selection, state.ui]
);
};