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