mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
feat(frontend): add Agent Intelligence Layer to library and home
Implements 7 new features for agent awareness across the platform: 1. Agent Briefing Panel — collapsible stats grid showing fleet-wide counts (running, error, listening, scheduled, idle, monthly spend) 2. Enhanced Library Cards — StatusBadge, progress bar, error messages, run counts, spend, and time-ago on every agent card 3. Expanded Filter & Sort — new AgentFilterMenu dropdown with status- based filtering (running, attention, listening, scheduled, idle) 4. AI Summary / Situation Report — prioritized SitrepList with error- first ranking and contextual action buttons 5. Ask AutoPilot Bridge — shared context that lets sitrep items and pulse chips pre-populate the Home chat with agent-specific prompts 6. Home Pulse Chips — lightweight agent status chips on the empty Home/Copilot screen linking back to the library 7. Contextual Action Buttons — status-aware actions (View error, Reconnect, Watch live, Run now, Retry) on cards and sitrep items All features use deterministic mock data via useAgentStatus hook, marked with TODO comments for backend API integration. Follows existing component patterns (atoms/molecules/organisms), reuses shadcn/ui primitives, Phosphor Icons, and platform design tokens. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,8 @@ import {
|
||||
getSuggestionThemes,
|
||||
} from "./helpers";
|
||||
import { SuggestionThemes } from "./components/SuggestionThemes/SuggestionThemes";
|
||||
import { PulseChips } from "../PulseChips/PulseChips";
|
||||
import { usePulseChips } from "../PulseChips/usePulseChips";
|
||||
|
||||
interface Props {
|
||||
inputLayoutId: string;
|
||||
@@ -34,6 +36,7 @@ export function EmptySession({
|
||||
}: Props) {
|
||||
const { user } = useSupabase();
|
||||
const greetingName = getGreetingName(user);
|
||||
const pulseChips = usePulseChips();
|
||||
|
||||
const { data: suggestedPromptsResponse, isLoading: isLoadingPrompts } =
|
||||
useGetV2GetSuggestedPrompts({
|
||||
@@ -80,6 +83,8 @@ export function EmptySession({
|
||||
Tell me about your work — I'll find what to automate.
|
||||
</Text>
|
||||
|
||||
<PulseChips chips={pulseChips} onChipClick={onSend} />
|
||||
|
||||
<div className="mb-6">
|
||||
<motion.div
|
||||
layoutId={inputLayoutId}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { ArrowRightIcon } from "@phosphor-icons/react";
|
||||
import NextLink from "next/link";
|
||||
import { StatusBadge } from "@/app/(platform)/library/components/StatusBadge/StatusBadge";
|
||||
import type { AgentStatus } from "@/app/(platform)/library/types";
|
||||
|
||||
export interface PulseChipData {
|
||||
id: string;
|
||||
name: string;
|
||||
status: AgentStatus;
|
||||
shortMessage: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
chips: PulseChipData[];
|
||||
onChipClick?: (prompt: string) => void;
|
||||
}
|
||||
|
||||
export function PulseChips({ chips, onChipClick }: Props) {
|
||||
if (chips.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<Text variant="small-medium" className="text-zinc-600">
|
||||
What's happening with your agents
|
||||
</Text>
|
||||
<NextLink
|
||||
href="/library"
|
||||
className="flex items-center gap-1 text-xs text-zinc-500 hover:text-zinc-700"
|
||||
>
|
||||
View all <ArrowRightIcon size={12} />
|
||||
</NextLink>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{chips.map((chip) => (
|
||||
<PulseChip key={chip.id} chip={chip} onClick={onChipClick} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ChipProps {
|
||||
chip: PulseChipData;
|
||||
onClick?: (prompt: string) => void;
|
||||
}
|
||||
|
||||
function PulseChip({ chip, onClick }: ChipProps) {
|
||||
function handleClick() {
|
||||
const prompt = buildChipPrompt(chip);
|
||||
onClick?.(prompt);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
className="flex items-center gap-2 rounded-medium border border-zinc-100 bg-white px-3 py-2 text-left transition-all hover:border-zinc-200 hover:shadow-sm"
|
||||
>
|
||||
<StatusBadge status={chip.status} />
|
||||
<div className="min-w-0">
|
||||
<Text variant="small-medium" className="truncate text-zinc-900">
|
||||
{chip.name}
|
||||
</Text>
|
||||
<Text variant="xsmall" className="truncate text-zinc-500">
|
||||
{chip.shortMessage}
|
||||
</Text>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function buildChipPrompt(chip: PulseChipData): string {
|
||||
switch (chip.status) {
|
||||
case "error":
|
||||
return `What happened with ${chip.name}? It has an error — can you check?`;
|
||||
case "running":
|
||||
return `Give me a status update on ${chip.name} — what has it done so far?`;
|
||||
case "idle":
|
||||
return `${chip.name} hasn't run recently. Should I keep it or update and re-run it?`;
|
||||
default:
|
||||
return `Tell me about ${chip.name} — what's its current status?`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { PulseChipData } from "./PulseChips";
|
||||
import type { AgentStatus } from "@/app/(platform)/library/types";
|
||||
|
||||
/**
|
||||
* Provides a prioritised list of pulse chips for the Home empty state.
|
||||
* Errors → running → stale, max 5 chips.
|
||||
*
|
||||
* TODO: Replace with real API data from `GET /agents/summary` or similar.
|
||||
*/
|
||||
export function usePulseChips(): PulseChipData[] {
|
||||
const [chips] = useState<PulseChipData[]>(() => MOCK_CHIPS);
|
||||
return chips;
|
||||
}
|
||||
|
||||
const MOCK_CHIPS: PulseChipData[] = [
|
||||
{
|
||||
id: "chip-1",
|
||||
name: "Lead Finder",
|
||||
status: "error" as AgentStatus,
|
||||
shortMessage: "API rate limit hit",
|
||||
},
|
||||
{
|
||||
id: "chip-2",
|
||||
name: "CEO Finder",
|
||||
status: "running" as AgentStatus,
|
||||
shortMessage: "72% complete",
|
||||
},
|
||||
{
|
||||
id: "chip-3",
|
||||
name: "Cart Recovery",
|
||||
status: "idle" as AgentStatus,
|
||||
shortMessage: "No runs in 3 weeks",
|
||||
},
|
||||
{
|
||||
id: "chip-4",
|
||||
name: "Social Collector",
|
||||
status: "listening" as AgentStatus,
|
||||
shortMessage: "Waiting for trigger",
|
||||
},
|
||||
];
|
||||
@@ -2,14 +2,17 @@ import { Navbar } from "@/components/layout/Navbar/Navbar";
|
||||
import { NetworkStatusMonitor } from "@/services/network-status/NetworkStatusMonitor";
|
||||
import { ReactNode } from "react";
|
||||
import { AdminImpersonationBanner } from "./admin/components/AdminImpersonationBanner";
|
||||
import { AutoPilotBridgeProvider } from "@/contexts/AutoPilotBridgeContext";
|
||||
|
||||
export default function PlatformLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<main className="flex h-screen w-full flex-col">
|
||||
<NetworkStatusMonitor />
|
||||
<Navbar />
|
||||
<AdminImpersonationBanner />
|
||||
<section className="flex-1">{children}</section>
|
||||
</main>
|
||||
<AutoPilotBridgeProvider>
|
||||
<main className="flex h-screen w-full flex-col">
|
||||
<NetworkStatusMonitor />
|
||||
<Navbar />
|
||||
<AdminImpersonationBanner />
|
||||
<section className="flex-1">{children}</section>
|
||||
</main>
|
||||
</AutoPilotBridgeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { CaretUpIcon, CaretDownIcon } from "@phosphor-icons/react";
|
||||
import { useState } from "react";
|
||||
import type { FleetSummary, AgentStatusFilter } from "../../types";
|
||||
import { SitrepList } from "../SitrepItem/SitrepList";
|
||||
import { StatsGrid } from "./StatsGrid";
|
||||
|
||||
interface Props {
|
||||
summary: FleetSummary;
|
||||
agentIDs: string[];
|
||||
onFilterChange?: (filter: AgentStatusFilter) => void;
|
||||
activeFilter?: AgentStatusFilter;
|
||||
}
|
||||
|
||||
export function AgentBriefingPanel({
|
||||
summary,
|
||||
agentIDs,
|
||||
onFilterChange,
|
||||
activeFilter = "all",
|
||||
}: Props) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
|
||||
const totalAttention = summary.error;
|
||||
|
||||
const headerSummary = [
|
||||
summary.running > 0 && `${summary.running} running`,
|
||||
totalAttention > 0 && `${totalAttention} need attention`,
|
||||
summary.listening > 0 && `${summary.listening} listening`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
|
||||
return (
|
||||
<div className="rounded-large border border-zinc-100 bg-white p-5 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Text variant="h5">Agent Briefing</Text>
|
||||
{headerSummary && (
|
||||
<Text variant="small" className="text-zinc-500">
|
||||
{headerSummary}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
aria-label={isCollapsed ? "Expand briefing" : "Collapse briefing"}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<CaretDownIcon size={16} />
|
||||
) : (
|
||||
<CaretUpIcon size={16} />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!isCollapsed && (
|
||||
<div className="mt-4 space-y-5">
|
||||
<StatsGrid
|
||||
summary={summary}
|
||||
activeFilter={activeFilter}
|
||||
onFilterChange={onFilterChange}
|
||||
/>
|
||||
<SitrepList agentIDs={agentIDs} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import {
|
||||
CurrencyDollarIcon,
|
||||
PlayCircleIcon,
|
||||
WarningCircleIcon,
|
||||
EarIcon,
|
||||
ClockIcon,
|
||||
PauseCircleIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { FleetSummary, AgentStatusFilter } from "../../types";
|
||||
|
||||
interface Props {
|
||||
summary: FleetSummary;
|
||||
activeFilter: AgentStatusFilter;
|
||||
onFilterChange?: (filter: AgentStatusFilter) => void;
|
||||
}
|
||||
|
||||
export function StatsGrid({ summary, activeFilter, onFilterChange }: Props) {
|
||||
const tiles = [
|
||||
{
|
||||
label: "Spend this month",
|
||||
value: `$${summary.monthlySpend.toLocaleString()}`,
|
||||
filter: "all" as AgentStatusFilter,
|
||||
icon: CurrencyDollarIcon,
|
||||
color: "text-zinc-700",
|
||||
},
|
||||
{
|
||||
label: "Running now",
|
||||
value: summary.running,
|
||||
filter: "running" as AgentStatusFilter,
|
||||
icon: PlayCircleIcon,
|
||||
color: "text-blue-600",
|
||||
},
|
||||
{
|
||||
label: "Needs attention",
|
||||
value: summary.error,
|
||||
filter: "attention" as AgentStatusFilter,
|
||||
icon: WarningCircleIcon,
|
||||
color: "text-red-500",
|
||||
},
|
||||
{
|
||||
label: "Listening",
|
||||
value: summary.listening,
|
||||
filter: "listening" as AgentStatusFilter,
|
||||
icon: EarIcon,
|
||||
color: "text-purple-500",
|
||||
},
|
||||
{
|
||||
label: "Scheduled",
|
||||
value: summary.scheduled,
|
||||
filter: "scheduled" as AgentStatusFilter,
|
||||
icon: ClockIcon,
|
||||
color: "text-yellow-600",
|
||||
},
|
||||
{
|
||||
label: "Idle",
|
||||
value: summary.idle,
|
||||
filter: "idle" as AgentStatusFilter,
|
||||
icon: PauseCircleIcon,
|
||||
color: "text-zinc-400",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||
{tiles.map((tile) => {
|
||||
const Icon = tile.icon;
|
||||
const isActive = activeFilter === tile.filter;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tile.label}
|
||||
type="button"
|
||||
onClick={() => onFilterChange?.(tile.filter)}
|
||||
className={cn(
|
||||
"flex flex-col gap-1 rounded-medium border p-3 text-left transition-all hover:shadow-sm",
|
||||
isActive
|
||||
? "border-zinc-900 bg-zinc-50"
|
||||
: "border-zinc-100 bg-white",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Icon size={14} className={tile.color} />
|
||||
<Text variant="xsmall" className="text-zinc-500">
|
||||
{tile.label}
|
||||
</Text>
|
||||
</div>
|
||||
<Text variant="h4" className={tile.color}>
|
||||
{tile.value}
|
||||
</Text>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/__legacy__/ui/select";
|
||||
import { FunnelIcon } from "@phosphor-icons/react";
|
||||
import type { AgentStatusFilter, FleetSummary } from "../../types";
|
||||
|
||||
interface Props {
|
||||
value: AgentStatusFilter;
|
||||
onChange: (value: AgentStatusFilter) => void;
|
||||
summary: FleetSummary;
|
||||
}
|
||||
|
||||
export function AgentFilterMenu({ value, onChange, summary }: Props) {
|
||||
function handleChange(val: string) {
|
||||
onChange(val as AgentStatusFilter);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center" data-testid="agent-filter-dropdown">
|
||||
<span className="hidden whitespace-nowrap text-sm sm:inline">
|
||||
filter
|
||||
</span>
|
||||
<Select value={value} onValueChange={handleChange}>
|
||||
<SelectTrigger className="ml-1 w-fit space-x-1 border-none px-0 text-sm underline underline-offset-4 shadow-none">
|
||||
<FunnelIcon className="h-4 w-4 sm:hidden" />
|
||||
<SelectValue placeholder="All Agents" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="all">All Agents</SelectItem>
|
||||
<SelectItem value="running">
|
||||
Running ({summary.running})
|
||||
</SelectItem>
|
||||
<SelectItem value="attention">
|
||||
Needs Attention ({summary.error})
|
||||
</SelectItem>
|
||||
<SelectItem value="listening">
|
||||
Listening ({summary.listening})
|
||||
</SelectItem>
|
||||
<SelectItem value="scheduled">
|
||||
Scheduled ({summary.scheduled})
|
||||
</SelectItem>
|
||||
<SelectItem value="idle">
|
||||
Idle / Stale ({summary.idle})
|
||||
</SelectItem>
|
||||
<SelectItem value="healthy">Healthy</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import {
|
||||
EyeIcon,
|
||||
ArrowsClockwiseIcon,
|
||||
MonitorPlayIcon,
|
||||
PlayIcon,
|
||||
ArrowCounterClockwiseIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import type { AgentStatus } from "../../types";
|
||||
|
||||
interface Props {
|
||||
status: AgentStatus;
|
||||
agentID: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the single most relevant action for an agent based on its status.
|
||||
*
|
||||
* | Status | Action | Behaviour (TODO: wire to real endpoints) |
|
||||
* |-----------|-----------------|------------------------------------------|
|
||||
* | error | View error | Opens error detail / run log |
|
||||
* | listening | Reconnect | Opens reconnection flow |
|
||||
* | running | Watch live | Opens real-time execution view |
|
||||
* | idle | Run now | Triggers immediate new run |
|
||||
* | scheduled | Run now | Triggers immediate new run |
|
||||
*/
|
||||
export function ContextualActionButton({ status, agentID, className }: Props) {
|
||||
const { toast } = useToast();
|
||||
|
||||
const config = ACTION_CONFIG[status];
|
||||
if (!config) return null;
|
||||
|
||||
const Icon = config.icon;
|
||||
|
||||
function handleClick(e: React.MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// TODO: Replace with real API calls
|
||||
toast({
|
||||
title: config.label,
|
||||
description: `${config.label} triggered for agent ${agentID.slice(0, 8)}…`,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="small"
|
||||
onClick={handleClick}
|
||||
leftIcon={<Icon size={14} />}
|
||||
className={className}
|
||||
>
|
||||
{config.label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const ACTION_CONFIG: Record<
|
||||
AgentStatus,
|
||||
{ label: string; icon: typeof EyeIcon }
|
||||
> = {
|
||||
error: { label: "View error", icon: EyeIcon },
|
||||
listening: { label: "Reconnect", icon: ArrowsClockwiseIcon },
|
||||
running: { label: "Watch live", icon: MonitorPlayIcon },
|
||||
idle: { label: "Run now", icon: PlayIcon },
|
||||
scheduled: { label: "Run now", icon: ArrowCounterClockwiseIcon },
|
||||
};
|
||||
@@ -12,10 +12,15 @@ import Avatar, {
|
||||
AvatarImage,
|
||||
} from "@/components/atoms/Avatar/Avatar";
|
||||
import { Link } from "@/components/atoms/Link/Link";
|
||||
import { Progress } from "@/components/atoms/Progress/Progress";
|
||||
import { AgentCardMenu } from "./components/AgentCardMenu";
|
||||
import { FavoriteButton } from "./components/FavoriteButton";
|
||||
import { useLibraryAgentCard } from "./useLibraryAgentCard";
|
||||
import { useFavoriteAnimation } from "../../context/FavoriteAnimationContext";
|
||||
import { StatusBadge } from "../StatusBadge/StatusBadge";
|
||||
import { ContextualActionButton } from "../ContextualActionButton/ContextualActionButton";
|
||||
import { useAgentStatus } from "../../hooks/useAgentStatus";
|
||||
import { formatTimeAgo } from "../../helpers";
|
||||
|
||||
interface Props {
|
||||
agent: LibraryAgent;
|
||||
@@ -25,6 +30,7 @@ interface Props {
|
||||
export function LibraryAgentCard({ agent, draggable = true }: Props) {
|
||||
const { id, name, graph_id, can_access_graph, image_url } = agent;
|
||||
const { triggerFavoriteAnimation } = useFavoriteAnimation();
|
||||
const statusInfo = useAgentStatus(id);
|
||||
|
||||
function handleDragStart(e: React.DragEvent<HTMLDivElement>) {
|
||||
e.dataTransfer.setData("application/agent-id", id);
|
||||
@@ -42,6 +48,9 @@ export function LibraryAgentCard({ agent, draggable = true }: Props) {
|
||||
onFavoriteAdd: triggerFavoriteAnimation,
|
||||
});
|
||||
|
||||
const hasError = statusInfo.status === "error";
|
||||
const isRunning = statusInfo.status === "running";
|
||||
|
||||
return (
|
||||
<div
|
||||
draggable={draggable}
|
||||
@@ -52,7 +61,11 @@ export function LibraryAgentCard({ agent, draggable = true }: Props) {
|
||||
layoutId={`agent-card-${id}`}
|
||||
data-testid="library-agent-card"
|
||||
data-agent-id={id}
|
||||
className="group relative inline-flex h-[10.625rem] w-full max-w-[25rem] flex-col items-start justify-start gap-2.5 rounded-medium border border-zinc-100 bg-white hover:shadow-md"
|
||||
className={`group relative inline-flex h-auto min-h-[10.625rem] w-full max-w-[25rem] flex-col items-start justify-start gap-2.5 rounded-medium border bg-white hover:shadow-md ${
|
||||
hasError
|
||||
? "border-l-2 border-l-red-400 border-t-zinc-100 border-r-zinc-100 border-b-zinc-100"
|
||||
: "border-zinc-100"
|
||||
}`}
|
||||
transition={{
|
||||
type: "spring",
|
||||
damping: 25,
|
||||
@@ -79,6 +92,7 @@ export function LibraryAgentCard({ agent, draggable = true }: Props) {
|
||||
>
|
||||
{isFromMarketplace ? "FROM MARKETPLACE" : "Built by you"}
|
||||
</Text>
|
||||
<StatusBadge status={statusInfo.status} className="ml-auto" />
|
||||
</div>
|
||||
</NextLink>
|
||||
<FavoriteButton
|
||||
@@ -128,28 +142,68 @@ export function LibraryAgentCard({ agent, draggable = true }: Props) {
|
||||
)}
|
||||
</Link>
|
||||
|
||||
<div className="mt-auto flex w-full justify-start gap-6 border-t border-zinc-100 pb-1 pt-3">
|
||||
<Link
|
||||
href={`/library/agents/${id}`}
|
||||
data-testid="library-agent-card-see-runs-link"
|
||||
className="flex items-center gap-1 text-[13px]"
|
||||
>
|
||||
See runs <CaretCircleRightIcon size={20} />
|
||||
</Link>
|
||||
{/* Status details: progress bar, error message, stats */}
|
||||
{isRunning && statusInfo.progress !== null && (
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<Progress value={statusInfo.progress} className="h-1.5 flex-1" />
|
||||
<Text variant="xsmall" className="text-blue-600">
|
||||
{statusInfo.progress}%
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{can_access_graph && (
|
||||
<Link
|
||||
href={`/build?flowID=${graph_id}`}
|
||||
data-testid="library-agent-card-open-in-builder-link"
|
||||
className="flex items-center gap-1 text-[13px]"
|
||||
isExternal
|
||||
>
|
||||
Open in builder <CaretCircleRightIcon size={20} />
|
||||
</Link>
|
||||
{hasError && statusInfo.lastError && (
|
||||
<Text variant="xsmall" className="mt-1 line-clamp-1 text-red-500">
|
||||
{statusInfo.lastError}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<div className="mt-1 flex items-center gap-3">
|
||||
<Text variant="xsmall" className="text-zinc-400">
|
||||
{statusInfo.totalRuns} runs
|
||||
</Text>
|
||||
<Text variant="xsmall" className="text-zinc-400">
|
||||
${statusInfo.monthlySpend}
|
||||
</Text>
|
||||
{statusInfo.lastRunAt && (
|
||||
<Text variant="xsmall" className="text-zinc-400">
|
||||
{formatTimeAgo(statusInfo.lastRunAt)}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex w-full items-center justify-between gap-2 border-t border-zinc-100 pb-1 pt-3">
|
||||
<div className="flex gap-6">
|
||||
<Link
|
||||
href={`/library/agents/${id}`}
|
||||
data-testid="library-agent-card-see-runs-link"
|
||||
className="flex items-center gap-1 text-[13px]"
|
||||
>
|
||||
See runs <CaretCircleRightIcon size={20} />
|
||||
</Link>
|
||||
|
||||
{can_access_graph && (
|
||||
<Link
|
||||
href={`/build?flowID=${graph_id}`}
|
||||
data-testid="library-agent-card-open-in-builder-link"
|
||||
className="flex items-center gap-1 text-[13px]"
|
||||
isExternal
|
||||
>
|
||||
Open in builder <CaretCircleRightIcon size={20} />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<div className="opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<ContextualActionButton
|
||||
status={statusInfo.status}
|
||||
agentID={id}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,8 +16,9 @@ import {
|
||||
} from "framer-motion";
|
||||
import { LibraryFolderEditDialog } from "../LibraryFolderEditDialog/LibraryFolderEditDialog";
|
||||
import { LibraryFolderDeleteDialog } from "../LibraryFolderDeleteDialog/LibraryFolderDeleteDialog";
|
||||
import { LibraryTab } from "../../types";
|
||||
import type { LibraryTab, AgentStatusFilter, FleetSummary } from "../../types";
|
||||
import { useLibraryAgentList } from "./useLibraryAgentList";
|
||||
import { AgentBriefingPanel } from "../AgentBriefingPanel/AgentBriefingPanel";
|
||||
|
||||
// cancels the current spring and starts a new one from current state.
|
||||
const containerVariants = {
|
||||
@@ -70,6 +71,9 @@ interface Props {
|
||||
tabs: LibraryTab[];
|
||||
activeTab: string;
|
||||
onTabChange: (tabId: string) => void;
|
||||
statusFilter?: AgentStatusFilter;
|
||||
onStatusFilterChange?: (filter: AgentStatusFilter) => void;
|
||||
fleetSummary?: FleetSummary;
|
||||
}
|
||||
|
||||
export function LibraryAgentList({
|
||||
@@ -81,6 +85,9 @@ export function LibraryAgentList({
|
||||
tabs,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
statusFilter = "all",
|
||||
onStatusFilterChange,
|
||||
fleetSummary,
|
||||
}: Props) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const activeContainerVariants = shouldReduceMotion
|
||||
@@ -118,8 +125,21 @@ export function LibraryAgentList({
|
||||
activeTab,
|
||||
});
|
||||
|
||||
const agentIDs = agents.map((a) => a.id);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!selectedFolderId && fleetSummary && (
|
||||
<div className="mb-4">
|
||||
<AgentBriefingPanel
|
||||
summary={fleetSummary}
|
||||
agentIDs={agentIDs}
|
||||
onFilterChange={onStatusFilterChange}
|
||||
activeFilter={statusFilter}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!selectedFolderId && (
|
||||
<LibrarySubSection
|
||||
tabs={tabs}
|
||||
@@ -128,6 +148,9 @@ export function LibraryAgentList({
|
||||
allCount={allAgentsCount}
|
||||
favoritesCount={favoritesCount}
|
||||
setLibrarySort={setLibrarySort}
|
||||
statusFilter={statusFilter}
|
||||
onStatusFilterChange={onStatusFilterChange}
|
||||
fleetSummary={fleetSummary}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
} from "./FolderIcon";
|
||||
import { useState } from "react";
|
||||
import { PencilSimpleIcon, TrashIcon } from "@phosphor-icons/react";
|
||||
import type { AgentStatus } from "../../types";
|
||||
import { StatusBadge } from "../StatusBadge/StatusBadge";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
@@ -21,6 +23,8 @@ interface Props {
|
||||
onDelete?: () => void;
|
||||
onAgentDrop?: (agentId: string, folderId: string) => void;
|
||||
onClick?: () => void;
|
||||
/** Worst status among child agents (optional, for status aggregation). */
|
||||
worstStatus?: AgentStatus;
|
||||
}
|
||||
|
||||
export function LibraryFolder({
|
||||
@@ -33,6 +37,7 @@ export function LibraryFolder({
|
||||
onDelete,
|
||||
onAgentDrop,
|
||||
onClick,
|
||||
worstStatus,
|
||||
}: Props) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
@@ -86,13 +91,18 @@ export function LibraryFolder({
|
||||
>
|
||||
{name}
|
||||
</Text>
|
||||
<Text
|
||||
variant="small"
|
||||
className="text-zinc-500"
|
||||
data-testid="library-folder-agent-count"
|
||||
>
|
||||
{agentCount} {agentCount === 1 ? "agent" : "agents"}
|
||||
</Text>
|
||||
<div className="flex items-center gap-2">
|
||||
<Text
|
||||
variant="small"
|
||||
className="text-zinc-500"
|
||||
data-testid="library-folder-agent-count"
|
||||
>
|
||||
{agentCount} {agentCount === 1 ? "agent" : "agents"}
|
||||
</Text>
|
||||
{worstStatus && worstStatus !== "idle" && (
|
||||
<StatusBadge status={worstStatus} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side - Custom folder icon */}
|
||||
|
||||
@@ -6,9 +6,10 @@ import {
|
||||
} from "@/components/molecules/TabsLine/TabsLine";
|
||||
import { LibraryAgentSort } from "@/app/api/__generated__/models/libraryAgentSort";
|
||||
import { useFavoriteAnimation } from "../../context/FavoriteAnimationContext";
|
||||
import { LibraryTab } from "../../types";
|
||||
import type { LibraryTab, AgentStatusFilter, FleetSummary } from "../../types";
|
||||
import LibraryFolderCreationDialog from "../LibraryFolderCreationDialog/LibraryFolderCreationDialog";
|
||||
import { LibrarySortMenu } from "../LibrarySortMenu/LibrarySortMenu";
|
||||
import { AgentFilterMenu } from "../AgentFilterMenu/AgentFilterMenu";
|
||||
|
||||
interface Props {
|
||||
tabs: LibraryTab[];
|
||||
@@ -17,6 +18,9 @@ interface Props {
|
||||
allCount: number;
|
||||
favoritesCount: number;
|
||||
setLibrarySort: (value: LibraryAgentSort) => void;
|
||||
statusFilter?: AgentStatusFilter;
|
||||
onStatusFilterChange?: (filter: AgentStatusFilter) => void;
|
||||
fleetSummary?: FleetSummary;
|
||||
}
|
||||
|
||||
export function LibrarySubSection({
|
||||
@@ -26,6 +30,9 @@ export function LibrarySubSection({
|
||||
allCount,
|
||||
favoritesCount,
|
||||
setLibrarySort,
|
||||
statusFilter = "all",
|
||||
onStatusFilterChange,
|
||||
fleetSummary,
|
||||
}: Props) {
|
||||
const { registerFavoritesTabRef } = useFavoriteAnimation();
|
||||
const favoritesRef = useRef<HTMLButtonElement>(null);
|
||||
@@ -70,6 +77,13 @@ export function LibrarySubSection({
|
||||
</TabsLine>
|
||||
<div className="hidden items-center gap-6 md:flex">
|
||||
<LibraryFolderCreationDialog />
|
||||
{fleetSummary && onStatusFilterChange && (
|
||||
<AgentFilterMenu
|
||||
value={statusFilter}
|
||||
onChange={onStatusFilterChange}
|
||||
summary={fleetSummary}
|
||||
/>
|
||||
)}
|
||||
<LibrarySortMenu setLibrarySort={setLibrarySort} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import {
|
||||
WarningCircleIcon,
|
||||
PlayIcon,
|
||||
ClockCountdownIcon,
|
||||
CheckCircleIcon,
|
||||
ChatCircleDotsIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AgentStatus } from "../../types";
|
||||
import { ContextualActionButton } from "../ContextualActionButton/ContextualActionButton";
|
||||
|
||||
export type SitrepPriority = "error" | "running" | "stale" | "success";
|
||||
|
||||
export interface SitrepItemData {
|
||||
id: string;
|
||||
agentID: string;
|
||||
agentName: string;
|
||||
priority: SitrepPriority;
|
||||
message: string;
|
||||
status: AgentStatus;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
item: SitrepItemData;
|
||||
onAskAutoPilot?: (prompt: string) => void;
|
||||
}
|
||||
|
||||
const PRIORITY_CONFIG: Record<
|
||||
SitrepPriority,
|
||||
{ icon: typeof WarningCircleIcon; color: string; bg: string }
|
||||
> = {
|
||||
error: {
|
||||
icon: WarningCircleIcon,
|
||||
color: "text-red-500",
|
||||
bg: "bg-red-50",
|
||||
},
|
||||
running: {
|
||||
icon: PlayIcon,
|
||||
color: "text-blue-600",
|
||||
bg: "bg-blue-50",
|
||||
},
|
||||
stale: {
|
||||
icon: ClockCountdownIcon,
|
||||
color: "text-yellow-600",
|
||||
bg: "bg-yellow-50",
|
||||
},
|
||||
success: {
|
||||
icon: CheckCircleIcon,
|
||||
color: "text-green-600",
|
||||
bg: "bg-green-50",
|
||||
},
|
||||
};
|
||||
|
||||
export function SitrepItem({ item, onAskAutoPilot }: Props) {
|
||||
const config = PRIORITY_CONFIG[item.priority];
|
||||
const Icon = config.icon;
|
||||
|
||||
function handleAskAutoPilot() {
|
||||
const prompt = buildAutoPilotPrompt(item);
|
||||
onAskAutoPilot?.(prompt);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group flex items-start gap-3 rounded-medium border border-transparent p-3 transition-colors hover:border-zinc-100 hover:bg-zinc-50/50",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"mt-0.5 flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full",
|
||||
config.bg,
|
||||
)}
|
||||
>
|
||||
<Icon size={14} className={config.color} weight="fill" />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<Text variant="small-medium" className="text-zinc-900">
|
||||
{item.agentName}
|
||||
</Text>
|
||||
<Text variant="small" className="mt-0.5 text-zinc-500">
|
||||
{item.message}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-shrink-0 items-center gap-1.5 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<ContextualActionButton status={item.status} agentID={item.agentID} />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={handleAskAutoPilot}
|
||||
leftIcon={<ChatCircleDotsIcon size={14} />}
|
||||
className="text-xs"
|
||||
>
|
||||
Ask AutoPilot
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildAutoPilotPrompt(item: SitrepItemData): string {
|
||||
switch (item.priority) {
|
||||
case "error":
|
||||
return `What happened with ${item.agentName}? It says "${item.message}" — can you check the logs and tell me what to fix?`;
|
||||
case "running":
|
||||
return `Give me a status update on the ${item.agentName} run — what has it found so far?`;
|
||||
case "stale":
|
||||
return `${item.agentName} hasn't run recently. Should I keep it or update and re-run it?`;
|
||||
case "success":
|
||||
return `How has ${item.agentName} been performing? Give me a quick summary of recent results.`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { useSitrepItems } from "./useSitrepItems";
|
||||
import { SitrepItem } from "./SitrepItem";
|
||||
import { useAutoPilotBridge } from "@/contexts/AutoPilotBridgeContext";
|
||||
|
||||
interface Props {
|
||||
agentIDs: string[];
|
||||
maxItems?: number;
|
||||
}
|
||||
|
||||
export function SitrepList({ agentIDs, maxItems = 10 }: Props) {
|
||||
const items = useSitrepItems(agentIDs, maxItems);
|
||||
const { sendPrompt } = useAutoPilotBridge();
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="py-4 text-center">
|
||||
<Text variant="small" className="text-zinc-400">
|
||||
All agents are healthy — nothing to report.
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<Text variant="small-medium" className="text-zinc-700">
|
||||
AI Summary
|
||||
</Text>
|
||||
<Text variant="xsmall" className="text-zinc-400">
|
||||
Updated just now
|
||||
</Text>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{items.map((item) => (
|
||||
<SitrepItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
onAskAutoPilot={sendPrompt}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { mockStatusForAgent } from "../../hooks/useAgentStatus";
|
||||
import type { SitrepItemData, SitrepPriority } from "./SitrepItem";
|
||||
import type { AgentStatus } from "../../types";
|
||||
|
||||
/**
|
||||
* Produce a prioritised list of sitrep items from agent IDs.
|
||||
* Priority order: error → running → stale → success.
|
||||
*
|
||||
* TODO: Replace with `GET /agents/sitrep` once the backend endpoint exists.
|
||||
*/
|
||||
export function useSitrepItems(
|
||||
agentIDs: string[],
|
||||
maxItems: number,
|
||||
): SitrepItemData[] {
|
||||
const [items] = useState<SitrepItemData[]>(() => {
|
||||
const raw: SitrepItemData[] = agentIDs.map((id) => {
|
||||
const info = mockStatusForAgent(id);
|
||||
return {
|
||||
id,
|
||||
agentID: id,
|
||||
agentName: `Agent ${id.slice(0, 6)}`,
|
||||
priority: toPriority(info.status, info.health === "stale"),
|
||||
message: buildMessage(info.status, info.lastError, info.progress),
|
||||
status: info.status,
|
||||
};
|
||||
});
|
||||
|
||||
const order: Record<SitrepPriority, number> = {
|
||||
error: 0,
|
||||
running: 1,
|
||||
stale: 2,
|
||||
success: 3,
|
||||
};
|
||||
raw.sort((a, b) => order[a.priority] - order[b.priority]);
|
||||
|
||||
return raw.slice(0, maxItems);
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function toPriority(status: AgentStatus, isStale: boolean): SitrepPriority {
|
||||
if (status === "error") return "error";
|
||||
if (status === "running") return "running";
|
||||
if (isStale || status === "idle") return "stale";
|
||||
return "success";
|
||||
}
|
||||
|
||||
function buildMessage(
|
||||
status: AgentStatus,
|
||||
lastError: string | null,
|
||||
progress: number | null,
|
||||
): string {
|
||||
switch (status) {
|
||||
case "error":
|
||||
return lastError ?? "Unknown error occurred";
|
||||
case "running":
|
||||
return progress !== null
|
||||
? `${progress}% complete`
|
||||
: "Currently executing";
|
||||
case "idle":
|
||||
return "Hasn't run recently — still relevant?";
|
||||
case "listening":
|
||||
return "Waiting for trigger event";
|
||||
case "scheduled":
|
||||
return "Next run scheduled";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AgentStatus } from "../../types";
|
||||
|
||||
const STATUS_CONFIG: Record<
|
||||
AgentStatus,
|
||||
{ label: string; bg: string; text: string; pulse: boolean }
|
||||
> = {
|
||||
running: {
|
||||
label: "Running",
|
||||
bg: "bg-blue-50",
|
||||
text: "text-blue-600",
|
||||
pulse: true,
|
||||
},
|
||||
error: {
|
||||
label: "Error",
|
||||
bg: "bg-red-50",
|
||||
text: "text-red-500",
|
||||
pulse: false,
|
||||
},
|
||||
listening: {
|
||||
label: "Listening",
|
||||
bg: "bg-purple-50",
|
||||
text: "text-purple-500",
|
||||
pulse: true,
|
||||
},
|
||||
scheduled: {
|
||||
label: "Scheduled",
|
||||
bg: "bg-yellow-50",
|
||||
text: "text-yellow-600",
|
||||
pulse: false,
|
||||
},
|
||||
idle: {
|
||||
label: "Idle",
|
||||
bg: "bg-zinc-100",
|
||||
text: "text-zinc-500",
|
||||
pulse: false,
|
||||
},
|
||||
};
|
||||
|
||||
interface Props {
|
||||
status: AgentStatus;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function StatusBadge({ status, className }: Props) {
|
||||
const config = STATUS_CONFIG[status];
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium",
|
||||
config.bg,
|
||||
config.text,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-1.5 w-1.5 rounded-full",
|
||||
config.pulse && "animate-pulse",
|
||||
statusDotColor(status),
|
||||
)}
|
||||
/>
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function statusDotColor(status: AgentStatus): string {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return "bg-blue-500";
|
||||
case "error":
|
||||
return "bg-red-500";
|
||||
case "listening":
|
||||
return "bg-purple-500";
|
||||
case "scheduled":
|
||||
return "bg-yellow-500";
|
||||
case "idle":
|
||||
return "bg-zinc-400";
|
||||
}
|
||||
}
|
||||
|
||||
export { STATUS_CONFIG };
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Formats an ISO date string into a human-readable relative time string.
|
||||
* e.g. "3m ago", "2h ago", "5d ago".
|
||||
*/
|
||||
export function formatTimeAgo(isoDate: string): string {
|
||||
const diff = Date.now() - new Date(isoDate).getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type {
|
||||
AgentStatus,
|
||||
AgentHealth,
|
||||
AgentStatusInfo,
|
||||
FleetSummary,
|
||||
} from "../types";
|
||||
|
||||
/**
|
||||
* Derive health from status and recency.
|
||||
* TODO: Replace with real computation once backend provides the data.
|
||||
*/
|
||||
function deriveHealth(status: AgentStatus, lastRunAt: string | null): AgentHealth {
|
||||
if (status === "error") return "attention";
|
||||
if (status === "idle" && lastRunAt) {
|
||||
const daysSince =
|
||||
(Date.now() - new Date(lastRunAt).getTime()) / (1000 * 60 * 60 * 24);
|
||||
if (daysSince > 14) return "stale";
|
||||
}
|
||||
return "good";
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate deterministic mock status for an agent based on its ID.
|
||||
* This allows the UI to render realistic data before the real API is built.
|
||||
* TODO: Replace with real API call `GET /agents/:id/status`.
|
||||
*/
|
||||
function mockStatusForAgent(agentID: string): AgentStatusInfo {
|
||||
const hash = simpleHash(agentID);
|
||||
const statuses: AgentStatus[] = [
|
||||
"running",
|
||||
"error",
|
||||
"listening",
|
||||
"scheduled",
|
||||
"idle",
|
||||
];
|
||||
const status = statuses[hash % statuses.length];
|
||||
const progress = status === "running" ? ((hash * 17) % 100) : null;
|
||||
const totalRuns = (hash % 200) + 1;
|
||||
const daysAgo = (hash % 30) + 1;
|
||||
const lastRunAt = new Date(
|
||||
Date.now() - daysAgo * 24 * 60 * 60 * 1000,
|
||||
).toISOString();
|
||||
const lastError =
|
||||
status === "error" ? "API rate limit exceeded — paused" : null;
|
||||
const monthlySpend = Number(((hash % 5000) / 100).toFixed(2));
|
||||
|
||||
return {
|
||||
status,
|
||||
health: deriveHealth(status, lastRunAt),
|
||||
progress,
|
||||
totalRuns,
|
||||
lastRunAt,
|
||||
lastError,
|
||||
monthlySpend,
|
||||
nextScheduledRun:
|
||||
status === "scheduled"
|
||||
? new Date(Date.now() + 3600_000).toISOString()
|
||||
: null,
|
||||
triggerType: status === "listening" ? "webhook" : null,
|
||||
};
|
||||
}
|
||||
|
||||
function simpleHash(str: string): number {
|
||||
let h = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
h = (h * 31 + str.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook returning status info for a single agent.
|
||||
* TODO: Wire to `GET /agents/:id/status` + WebSocket `/agents/live`.
|
||||
*/
|
||||
export function useAgentStatus(agentID: string): AgentStatusInfo {
|
||||
const [info] = useState(() => mockStatusForAgent(agentID));
|
||||
return info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook returning fleet-wide summary counts.
|
||||
* TODO: Wire to `GET /agents/summary`.
|
||||
*/
|
||||
export function useFleetSummary(agentIDs: string[]): FleetSummary {
|
||||
const [summary] = useState<FleetSummary>(() => {
|
||||
const counts: FleetSummary = {
|
||||
running: 0,
|
||||
error: 0,
|
||||
listening: 0,
|
||||
scheduled: 0,
|
||||
idle: 0,
|
||||
monthlySpend: 0,
|
||||
};
|
||||
for (const id of agentIDs) {
|
||||
const info = mockStatusForAgent(id);
|
||||
counts[info.status] += 1;
|
||||
counts.monthlySpend += info.monthlySpend;
|
||||
}
|
||||
counts.monthlySpend = Number(counts.monthlySpend.toFixed(2));
|
||||
return counts;
|
||||
});
|
||||
return summary;
|
||||
}
|
||||
|
||||
export { mockStatusForAgent, deriveHealth };
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { FleetSummary } from "../types";
|
||||
|
||||
/**
|
||||
* Returns fleet-wide summary counts for the Agent Briefing Panel.
|
||||
*
|
||||
* TODO: Replace with a real `GET /agents/summary` API call once available.
|
||||
* For now, returns deterministic mock data so the UI renders correctly.
|
||||
*/
|
||||
export function useLibraryFleetSummary(): FleetSummary {
|
||||
const [summary] = useState<FleetSummary>(() => ({
|
||||
running: 3,
|
||||
error: 2,
|
||||
listening: 4,
|
||||
scheduled: 5,
|
||||
idle: 8,
|
||||
monthlySpend: 127.45,
|
||||
}));
|
||||
return summary;
|
||||
}
|
||||
@@ -7,7 +7,8 @@ import { LibraryActionHeader } from "./components/LibraryActionHeader/LibraryAct
|
||||
import { LibraryAgentList } from "./components/LibraryAgentList/LibraryAgentList";
|
||||
import { useLibraryListPage } from "./components/useLibraryListPage";
|
||||
import { FavoriteAnimationProvider } from "./context/FavoriteAnimationContext";
|
||||
import { LibraryTab } from "./types";
|
||||
import type { LibraryTab, AgentStatusFilter } from "./types";
|
||||
import { useLibraryFleetSummary } from "./hooks/useLibraryFleetSummary";
|
||||
|
||||
const LIBRARY_TABS: LibraryTab[] = [
|
||||
{ id: "all", title: "All", icon: ListIcon },
|
||||
@@ -19,6 +20,8 @@ export default function LibraryPage() {
|
||||
useLibraryListPage();
|
||||
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState(LIBRARY_TABS[0].id);
|
||||
const [statusFilter, setStatusFilter] = useState<AgentStatusFilter>("all");
|
||||
const fleetSummary = useLibraryFleetSummary();
|
||||
|
||||
useEffect(() => {
|
||||
document.title = "Library – AutoGPT Platform";
|
||||
@@ -50,6 +53,9 @@ export default function LibraryPage() {
|
||||
tabs={LIBRARY_TABS}
|
||||
activeTab={activeTab}
|
||||
onTabChange={handleTabChange}
|
||||
statusFilter={statusFilter}
|
||||
onStatusFilterChange={setStatusFilter}
|
||||
fleetSummary={fleetSummary}
|
||||
/>
|
||||
</main>
|
||||
</FavoriteAnimationProvider>
|
||||
|
||||
@@ -1,7 +1,52 @@
|
||||
import { Icon } from "@phosphor-icons/react";
|
||||
import type { Icon } from "@phosphor-icons/react";
|
||||
|
||||
export interface LibraryTab {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: Icon;
|
||||
}
|
||||
|
||||
/** Agent execution status — drives StatusBadge visuals & filtering. */
|
||||
export type AgentStatus =
|
||||
| "running"
|
||||
| "error"
|
||||
| "listening"
|
||||
| "scheduled"
|
||||
| "idle";
|
||||
|
||||
/** Derived health bucket for quick triage. */
|
||||
export type AgentHealth = "good" | "attention" | "stale";
|
||||
|
||||
/** Real-time metadata that powers the Intelligence Layer features. */
|
||||
export interface AgentStatusInfo {
|
||||
status: AgentStatus;
|
||||
health: AgentHealth;
|
||||
/** 0-100 progress for currently running agents. */
|
||||
progress: number | null;
|
||||
totalRuns: number;
|
||||
lastRunAt: string | null;
|
||||
lastError: string | null;
|
||||
monthlySpend: number;
|
||||
nextScheduledRun: string | null;
|
||||
triggerType: string | null;
|
||||
}
|
||||
|
||||
/** Fleet-wide aggregate counts used by the Briefing Panel stats grid. */
|
||||
export interface FleetSummary {
|
||||
running: number;
|
||||
error: number;
|
||||
listening: number;
|
||||
scheduled: number;
|
||||
idle: number;
|
||||
monthlySpend: number;
|
||||
}
|
||||
|
||||
/** Filter options for the agent filter dropdown. */
|
||||
export type AgentStatusFilter =
|
||||
| "all"
|
||||
| "running"
|
||||
| "attention"
|
||||
| "listening"
|
||||
| "scheduled"
|
||||
| "idle"
|
||||
| "healthy";
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useState } from "react";
|
||||
|
||||
interface AutoPilotBridgeState {
|
||||
/** Pending prompt to be injected into AutoPilot chat. */
|
||||
pendingPrompt: string | null;
|
||||
/** Queue a prompt that the Home/Copilot tab will pick up. */
|
||||
sendPrompt: (prompt: string) => void;
|
||||
/** Consume and clear the pending prompt (called by the chat page). */
|
||||
consumePrompt: () => string | null;
|
||||
}
|
||||
|
||||
const AutoPilotBridgeContext = createContext<AutoPilotBridgeState | null>(null);
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AutoPilotBridgeProvider({ children }: Props) {
|
||||
const [pendingPrompt, setPendingPrompt] = useState<string | null>(null);
|
||||
|
||||
function sendPrompt(prompt: string) {
|
||||
setPendingPrompt(prompt);
|
||||
// Navigate to the Home / Copilot tab.
|
||||
// Using window.location is the simplest approach that works across the
|
||||
// Next.js app router without coupling to a specific router instance.
|
||||
window.location.href = "/";
|
||||
}
|
||||
|
||||
function consumePrompt(): string | null {
|
||||
const prompt = pendingPrompt;
|
||||
setPendingPrompt(null);
|
||||
return prompt;
|
||||
}
|
||||
|
||||
return (
|
||||
<AutoPilotBridgeContext.Provider
|
||||
value={{ pendingPrompt, sendPrompt, consumePrompt }}
|
||||
>
|
||||
{children}
|
||||
</AutoPilotBridgeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAutoPilotBridge(): AutoPilotBridgeState {
|
||||
const context = useContext(AutoPilotBridgeContext);
|
||||
if (!context) {
|
||||
// Return a no-op implementation when used outside the provider
|
||||
// (e.g. in tests or isolated component renders).
|
||||
return {
|
||||
pendingPrompt: null,
|
||||
sendPrompt: () => {},
|
||||
consumePrompt: () => null,
|
||||
};
|
||||
}
|
||||
return context;
|
||||
}
|
||||
Reference in New Issue
Block a user