mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
Compare commits
6 Commits
fix/copilo
...
feat/agent
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3db2a944f7 | ||
|
|
59192102a6 | ||
|
|
65cca9bef8 | ||
|
|
6b32e43d84 | ||
|
|
b73d05c23e | ||
|
|
8277cce835 |
@@ -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,87 @@
|
||||
"use client";
|
||||
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
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="small" 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="small" className="text-zinc-500">
|
||||
{tile.label}
|
||||
</Text>
|
||||
</div>
|
||||
<Text variant="h4" className={tile.color}>
|
||||
{tile.value}
|
||||
</Text>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"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,16 @@ import Avatar, {
|
||||
AvatarImage,
|
||||
} from "@/components/atoms/Avatar/Avatar";
|
||||
import { Link } from "@/components/atoms/Link/Link";
|
||||
import { Progress } from "@/components/atoms/Progress/Progress";
|
||||
import { cn } from "@/lib/utils";
|
||||
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 +31,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 +49,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 +62,12 @@ 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={cn(
|
||||
"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-b-zinc-100 border-l-red-400 border-r-zinc-100 border-t-zinc-100"
|
||||
: "border-zinc-100",
|
||||
)}
|
||||
transition={{
|
||||
type: "spring",
|
||||
damping: 25,
|
||||
@@ -79,6 +94,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,26 +144,65 @@ 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="small" 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="small" className="mt-1 line-clamp-1 text-red-500">
|
||||
{statusInfo.lastError}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<div className="mt-1 flex items-center gap-3">
|
||||
<Text variant="small" className="text-zinc-400">
|
||||
{statusInfo.totalRuns} runs
|
||||
</Text>
|
||||
<Text variant="small" className="text-zinc-400">
|
||||
${statusInfo.monthlySpend}
|
||||
</Text>
|
||||
{statusInfo.lastRunAt && (
|
||||
<Text variant="small" 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
|
||||
@@ -95,7 +102,8 @@ export function LibraryAgentList({
|
||||
const {
|
||||
isFavoritesTab,
|
||||
agentLoading,
|
||||
allAgentsCount,
|
||||
displayedCount,
|
||||
allAgentIDs,
|
||||
favoritesCount,
|
||||
agents,
|
||||
hasNextPage,
|
||||
@@ -116,18 +124,33 @@ export function LibraryAgentList({
|
||||
selectedFolderId,
|
||||
onFolderSelect,
|
||||
activeTab,
|
||||
statusFilter,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{!selectedFolderId && fleetSummary && (
|
||||
<div className="mb-4">
|
||||
<AgentBriefingPanel
|
||||
summary={fleetSummary}
|
||||
agentIDs={allAgentIDs}
|
||||
onFilterChange={onStatusFilterChange}
|
||||
activeFilter={statusFilter}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!selectedFolderId && (
|
||||
<LibrarySubSection
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={onTabChange}
|
||||
allCount={allAgentsCount}
|
||||
allCount={displayedCount}
|
||||
favoritesCount={favoritesCount}
|
||||
setLibrarySort={setLibrarySort}
|
||||
statusFilter={statusFilter}
|
||||
onStatusFilterChange={onStatusFilterChange}
|
||||
fleetSummary={fleetSummary}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -22,6 +22,10 @@ import { useFavoriteAgents } from "../../hooks/useFavoriteAgents";
|
||||
import { getQueryClient } from "@/lib/react-query/queryClient";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { AgentStatusFilter } from "../../types";
|
||||
import { mockStatusForAgent } from "../../hooks/useAgentStatus";
|
||||
|
||||
const FILTER_EXHAUST_THRESHOLD = 3;
|
||||
|
||||
interface Props {
|
||||
searchTerm: string;
|
||||
@@ -29,6 +33,7 @@ interface Props {
|
||||
selectedFolderId: string | null;
|
||||
onFolderSelect: (folderId: string | null) => void;
|
||||
activeTab: string;
|
||||
statusFilter?: AgentStatusFilter;
|
||||
}
|
||||
|
||||
export function useLibraryAgentList({
|
||||
@@ -37,12 +42,15 @@ export function useLibraryAgentList({
|
||||
selectedFolderId,
|
||||
onFolderSelect,
|
||||
activeTab,
|
||||
statusFilter = "all",
|
||||
}: Props) {
|
||||
const isFavoritesTab = activeTab === "favorites";
|
||||
const { toast } = useToast();
|
||||
const stableQueryClient = getQueryClient();
|
||||
const queryClient = useQueryClient();
|
||||
const prevSortRef = useRef<LibraryAgentSort | null>(null);
|
||||
const consecutiveEmptyPagesRef = useRef(0);
|
||||
const prevFilteredLengthRef = useRef(0);
|
||||
|
||||
const [editingFolder, setEditingFolder] = useState<LibraryFolder | null>(
|
||||
null,
|
||||
@@ -199,6 +207,50 @@ export function useLibraryAgentList({
|
||||
|
||||
const showFolders = !isFavoritesTab;
|
||||
|
||||
// All loaded agent IDs (unfiltered) — used by AgentBriefingPanel so the
|
||||
// sitrep always covers the full fleet, not just the currently filtered view.
|
||||
const allAgentIDs = agents.map((a) => a.id);
|
||||
|
||||
// Client-side filter by status using mock data until the real API supports it.
|
||||
const filteredAgents = filterAgentsByStatus(agents, statusFilter);
|
||||
|
||||
// Track consecutive pages that produced no new filtered items
|
||||
useEffect(() => {
|
||||
if (statusFilter === "all") {
|
||||
consecutiveEmptyPagesRef.current = 0;
|
||||
prevFilteredLengthRef.current = filteredAgents.length;
|
||||
return;
|
||||
}
|
||||
|
||||
const newFilteredCount = filteredAgents.length;
|
||||
const previousCount = prevFilteredLengthRef.current;
|
||||
|
||||
if (newFilteredCount > previousCount) {
|
||||
// New filtered items were added, reset counter
|
||||
consecutiveEmptyPagesRef.current = 0;
|
||||
} else if (!isFetchingNextPage && previousCount > 0) {
|
||||
// No new items and not currently fetching means last fetch was empty
|
||||
consecutiveEmptyPagesRef.current++;
|
||||
}
|
||||
|
||||
prevFilteredLengthRef.current = newFilteredCount;
|
||||
}, [filteredAgents.length, statusFilter, isFetchingNextPage]);
|
||||
|
||||
// Reset counter when statusFilter changes
|
||||
useEffect(() => {
|
||||
consecutiveEmptyPagesRef.current = 0;
|
||||
prevFilteredLengthRef.current = 0;
|
||||
}, [statusFilter]);
|
||||
|
||||
// Derive filteredExhausted: stop fetching when threshold reached
|
||||
const filteredExhausted =
|
||||
statusFilter !== "all" &&
|
||||
consecutiveEmptyPagesRef.current >= FILTER_EXHAUST_THRESHOLD;
|
||||
|
||||
// When a filter is active, show the filtered count instead of the API total.
|
||||
const displayedCount =
|
||||
statusFilter === "all" ? allAgentsCount : filteredAgents.length;
|
||||
|
||||
function handleFolderDeleted() {
|
||||
if (selectedFolderId === deletingFolder?.id) {
|
||||
onFolderSelect(null);
|
||||
@@ -210,9 +262,11 @@ export function useLibraryAgentList({
|
||||
agentLoading,
|
||||
agentCount,
|
||||
allAgentsCount,
|
||||
displayedCount,
|
||||
allAgentIDs,
|
||||
favoritesCount: favoriteAgentsData.agentCount,
|
||||
agents,
|
||||
hasNextPage: agentsHasNextPage,
|
||||
agents: filteredAgents,
|
||||
hasNextPage: agentsHasNextPage && !filteredExhausted,
|
||||
isFetchingNextPage: agentsIsFetchingNextPage,
|
||||
fetchNextPage: agentsFetchNextPage,
|
||||
foldersData,
|
||||
@@ -226,3 +280,16 @@ export function useLibraryAgentList({
|
||||
handleFolderDeleted,
|
||||
};
|
||||
}
|
||||
|
||||
function filterAgentsByStatus<T extends { id: string }>(
|
||||
agents: T[],
|
||||
statusFilter: AgentStatusFilter,
|
||||
): T[] {
|
||||
if (statusFilter === "all") return agents;
|
||||
return agents.filter((agent) => {
|
||||
const info = mockStatusForAgent(agent.id);
|
||||
if (statusFilter === "attention") return info.health === "attention";
|
||||
if (statusFilter === "healthy") return info.health === "good";
|
||||
return info.status === statusFilter;
|
||||
});
|
||||
}
|
||||
@@ -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,44 @@
|
||||
"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="small" 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 { useMemo } 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 = useMemo<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);
|
||||
}, [agentIDs, 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,16 @@
|
||||
/**
|
||||
* 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 parsed = new Date(isoDate).getTime();
|
||||
if (Number.isNaN(parsed)) return "unknown";
|
||||
const diff = Date.now() - parsed;
|
||||
if (diff < 0) return "just now";
|
||||
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,113 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, 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 {
|
||||
// NOTE: useState initializer runs once on mount; a new agentID prop will not
|
||||
// re-derive info. Replace with a real API call wired to the agentID param.
|
||||
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 = useMemo<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;
|
||||
}, [agentIDs]);
|
||||
return summary;
|
||||
}
|
||||
|
||||
export { mockStatusForAgent, deriveHealth };
|
||||
@@ -0,0 +1,25 @@
|
||||
"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 {
|
||||
// NOTE: useState initializer runs once on mount; the hardcoded mock values
|
||||
// will not update if the component re-renders. Replace with a real API call
|
||||
// once the backend endpoint is available.
|
||||
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,73 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useCallback, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
const STORAGE_KEY = "autopilot_pending_prompt";
|
||||
|
||||
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 router = useRouter();
|
||||
|
||||
// Hydrate from sessionStorage in case we just navigated here
|
||||
const [pendingPrompt, setPendingPrompt] = useState<string | null>(() => {
|
||||
if (typeof window === "undefined") return null;
|
||||
return sessionStorage.getItem(STORAGE_KEY);
|
||||
});
|
||||
|
||||
const sendPrompt = useCallback(
|
||||
(prompt: string) => {
|
||||
// Persist to sessionStorage so it survives client-side navigation
|
||||
sessionStorage.setItem(STORAGE_KEY, prompt);
|
||||
setPendingPrompt(prompt);
|
||||
// Use Next.js router for client-side navigation (preserves React tree)
|
||||
router.push("/");
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
const consumePrompt = useCallback((): string | null => {
|
||||
const prompt = pendingPrompt ?? sessionStorage.getItem(STORAGE_KEY);
|
||||
if (prompt !== null) {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
setPendingPrompt(null);
|
||||
}
|
||||
return prompt;
|
||||
}, [pendingPrompt]);
|
||||
|
||||
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