feat(frontend): new library agent view setup (#10652)

## Changes 🏗️

Setup for the new Agent Runs page:

<img width="900" height="521" alt="Screenshot 2025-08-15 at 14 36 34"
src="https://github.com/user-attachments/assets/460d6611-4b15-4878-92d3-b477dc4453a9"
/>

It is behind a feature flag in Launch Darkly, `new-agent-runs`, so we
can progressively enable in staging and later on production.

### Other improvements

<img width="350" height="291" alt="Screenshot_2025-08-15_at_14 28 08"
src="https://github.com/user-attachments/assets/972d2a1a-a4cd-4e92-b6d7-2dcf7f57c2db"
/>

- Added a new `<ErrorCard />` component to paint gracefully API errors
when fetching data
- Moved some sub-components of the old library page to a nested
`/components` folder 📁

Behind a feature flag

## Checklist 📋

### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  - [x] Tested with the feature flag ON and OFF

### For configuration changes:

None

---------

Co-authored-by: Abhimanyu Yadav <122007096+Abhi1992002@users.noreply.github.com>
This commit is contained in:
Ubbe
2025-08-19 13:11:39 +01:00
committed by GitHub
parent 38610d1e7a
commit c6247f265e
25 changed files with 2065 additions and 1336 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,74 @@
"use client";
import { Breadcrumbs } from "@/components/molecules/Breadcrumbs/Breadcrumbs";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { useAgentRunsView } from "./useAgentRunsView";
import { AgentRunsLoading } from "./components/AgentRunsLoading";
import { Button } from "@/components/atoms/Button/Button";
import { Plus } from "@phosphor-icons/react";
export function AgentRunsView() {
const { response, ready, error, agentId } = useAgentRunsView();
// Handle loading state
if (!ready) {
return <AgentRunsLoading />;
}
// Handle errors - check for query error first, then response errors
if (error || (response && response.status !== 200)) {
return (
<ErrorCard
isSuccess={false}
responseError={error || undefined}
httpError={
response?.status !== 200
? {
status: response?.status,
statusText: "Request failed",
}
: undefined
}
context="agent"
onRetry={() => window.location.reload()}
/>
);
}
// Handle missing data
if (!response?.data) {
return (
<ErrorCard
isSuccess={false}
responseError={{ message: "No agent data found" }}
context="agent"
onRetry={() => window.location.reload()}
/>
);
}
const agent = response.data;
return (
<div className="grid h-screen grid-cols-[25%_85%] gap-4 pt-8">
{/* Left Sidebar - 30% */}
<div className="bg-gray-50 p-4">
<Button variant="primary" size="large" className="w-full">
<Plus size={20} /> New Run
</Button>
</div>
{/* Main Content - 70% */}
<div className="p-4">
<Breadcrumbs
items={[
{ name: "My Library", link: "/library" },
{ name: agent.name, link: `/library/agents/${agentId}` },
]}
/>
{/* Main content will go here */}
<div className="mt-4 text-gray-600">Main content area</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,25 @@
import React from "react";
import { Skeleton } from "@/components/ui/skeleton";
export function AgentRunsLoading() {
return (
<div className="px-6 py-6">
<div className="flex h-screen w-full gap-4">
{/* Left Sidebar */}
<div className="w-80 space-y-4">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-32 w-full" />
<Skeleton className="h-24 w-full" />
</div>
{/* Main Content */}
<div className="flex-1 space-y-4">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-64 w-full" />
<Skeleton className="h-32 w-full" />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,15 @@
import { useGetV2GetLibraryAgent } from "@/app/api/__generated__/endpoints/library/library";
import { useParams } from "next/navigation";
export function useAgentRunsView() {
const { id } = useParams();
const agentId = id as string;
const { data: response, isSuccess, error } = useGetV2GetLibraryAgent(agentId);
return {
agentId: id,
ready: isSuccess,
error,
response,
};
}

View File

@@ -0,0 +1,622 @@
"use client";
import { useParams, useRouter } from "next/navigation";
import { useQueryState } from "nuqs";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
Graph,
GraphExecution,
GraphExecutionID,
GraphExecutionMeta,
GraphID,
LibraryAgent,
LibraryAgentID,
LibraryAgentPreset,
LibraryAgentPresetID,
Schedule,
ScheduleID,
} from "@/lib/autogpt-server-api";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import { exportAsJSONFile } from "@/lib/utils";
import DeleteConfirmDialog from "@/components/agptui/delete-confirm-dialog";
import type { ButtonAction } from "@/components/agptui/types";
import { useOnboarding } from "@/components/onboarding/onboarding-provider";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import LoadingBox, { LoadingSpinner } from "@/components/ui/loading";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { AgentRunDetailsView } from "./components/agent-run-details-view";
import { AgentRunDraftView } from "./components/agent-run-draft-view";
import { AgentRunsSelectorList } from "./components/agent-runs-selector-list";
import { AgentScheduleDetailsView } from "./components/agent-schedule-details-view";
export function OldAgentLibraryView() {
const { id: agentID }: { id: LibraryAgentID } = useParams();
const [executionId, setExecutionId] = useQueryState("executionId");
const { toast } = useToast();
const router = useRouter();
const api = useBackendAPI();
// ============================ STATE =============================
const [graph, setGraph] = useState<Graph | null>(null); // Graph version corresponding to LibraryAgent
const [agent, setAgent] = useState<LibraryAgent | null>(null);
const [agentRuns, setAgentRuns] = useState<GraphExecutionMeta[]>([]);
const [agentPresets, setAgentPresets] = useState<LibraryAgentPreset[]>([]);
const [schedules, setSchedules] = useState<Schedule[]>([]);
const [selectedView, selectView] = useState<
| { type: "run"; id?: GraphExecutionID }
| { type: "preset"; id: LibraryAgentPresetID }
| { type: "schedule"; id: ScheduleID }
>({ type: "run" });
const [selectedRun, setSelectedRun] = useState<
GraphExecution | GraphExecutionMeta | null
>(null);
const selectedSchedule =
selectedView.type == "schedule"
? schedules.find((s) => s.id == selectedView.id)
: null;
const [isFirstLoad, setIsFirstLoad] = useState<boolean>(true);
const [agentDeleteDialogOpen, setAgentDeleteDialogOpen] =
useState<boolean>(false);
const [confirmingDeleteAgentRun, setConfirmingDeleteAgentRun] =
useState<GraphExecutionMeta | null>(null);
const [confirmingDeleteAgentPreset, setConfirmingDeleteAgentPreset] =
useState<LibraryAgentPresetID | null>(null);
const {
state: onboardingState,
updateState: updateOnboardingState,
incrementRuns,
} = useOnboarding();
const [copyAgentDialogOpen, setCopyAgentDialogOpen] = useState(false);
// Set page title with agent name
useEffect(() => {
if (agent) {
document.title = `${agent.name} - Library - AutoGPT Platform`;
}
}, [agent]);
const openRunDraftView = useCallback(() => {
selectView({ type: "run" });
}, []);
const selectRun = useCallback((id: GraphExecutionID) => {
selectView({ type: "run", id });
}, []);
const selectPreset = useCallback((id: LibraryAgentPresetID) => {
selectView({ type: "preset", id });
}, []);
const selectSchedule = useCallback((id: ScheduleID) => {
selectView({ type: "schedule", id });
}, []);
const graphVersions = useRef<Record<number, Graph>>({});
const loadingGraphVersions = useRef<Record<number, Promise<Graph>>>({});
const getGraphVersion = useCallback(
async (graphID: GraphID, version: number) => {
if (version in graphVersions.current)
return graphVersions.current[version];
if (version in loadingGraphVersions.current)
return loadingGraphVersions.current[version];
const pendingGraph = api.getGraph(graphID, version).then((graph) => {
graphVersions.current[version] = graph;
return graph;
});
// Cache promise as well to avoid duplicate requests
loadingGraphVersions.current[version] = pendingGraph;
return pendingGraph;
},
[api, graphVersions, loadingGraphVersions],
);
// Reward user for viewing results of their onboarding agent
useEffect(() => {
if (
!onboardingState ||
!selectedRun ||
onboardingState.completedSteps.includes("GET_RESULTS")
)
return;
if (selectedRun.id === onboardingState.onboardingAgentExecutionId) {
updateOnboardingState({
completedSteps: [...onboardingState.completedSteps, "GET_RESULTS"],
});
}
}, [selectedRun, onboardingState, updateOnboardingState]);
const lastRefresh = useRef<number>(0);
const refreshPageData = useCallback(() => {
if (Date.now() - lastRefresh.current < 2e3) return; // 2 second debounce
lastRefresh.current = Date.now();
api.getLibraryAgent(agentID).then((agent) => {
setAgent(agent);
getGraphVersion(agent.graph_id, agent.graph_version).then(
(_graph) =>
(graph && graph.version == _graph.version) || setGraph(_graph),
);
Promise.all([
api.getGraphExecutions(agent.graph_id),
api.listLibraryAgentPresets({
graph_id: agent.graph_id,
page_size: 100,
}),
]).then(([runs, presets]) => {
setAgentRuns(runs);
setAgentPresets(presets.presets);
// Preload the corresponding graph versions for the latest 10 runs
new Set(runs.slice(0, 10).map((run) => run.graph_version)).forEach(
(version) => getGraphVersion(agent.graph_id, version),
);
});
});
}, [api, agentID, getGraphVersion, graph]);
// On first load: select the latest run
useEffect(() => {
// Only for first load or first execution
if (selectedView.id || !isFirstLoad) return;
if (agentRuns.length == 0 && agentPresets.length == 0) return;
setIsFirstLoad(false);
if (agentRuns.length > 0) {
// select latest run
const latestRun = agentRuns.reduce((latest, current) => {
if (latest.started_at && !current.started_at) return current;
else if (!latest.started_at) return latest;
return latest.started_at > current.started_at ? latest : current;
}, agentRuns[0]);
selectRun(latestRun.id);
} else {
// select top preset
const latestPreset = agentPresets.toSorted(
(a, b) => b.updated_at.getTime() - a.updated_at.getTime(),
)[0];
selectPreset(latestPreset.id);
}
}, [
isFirstLoad,
selectedView.id,
agentRuns,
agentPresets,
selectRun,
selectPreset,
]);
useEffect(() => {
if (executionId) {
selectRun(executionId as GraphExecutionID);
setExecutionId(null);
}
}, [executionId, selectRun, setExecutionId]);
// Initial load
useEffect(() => {
refreshPageData();
// Show a toast when the WebSocket connection disconnects
let connectionToast: ReturnType<typeof toast> | null = null;
const cancelDisconnectHandler = api.onWebSocketDisconnect(() => {
connectionToast ??= toast({
title: "Connection to server was lost",
variant: "destructive",
description: (
<div className="flex items-center">
Trying to reconnect...
<LoadingSpinner className="ml-1.5 size-3.5" />
</div>
),
duration: Infinity,
dismissable: true,
});
});
const cancelConnectHandler = api.onWebSocketConnect(() => {
if (connectionToast)
connectionToast.update({
id: connectionToast.id,
title: "✅ Connection re-established",
variant: "default",
description: (
<div className="flex items-center">
Refreshing data...
<LoadingSpinner className="ml-1.5 size-3.5" />
</div>
),
duration: 2000,
dismissable: true,
});
connectionToast = null;
});
return () => {
cancelDisconnectHandler();
cancelConnectHandler();
};
}, []);
// Subscribe to WebSocket updates for agent runs
useEffect(() => {
if (!agent?.graph_id) return;
return api.onWebSocketConnect(() => {
refreshPageData(); // Sync up on (re)connect
// Subscribe to all executions for this agent
api.subscribeToGraphExecutions(agent.graph_id);
});
}, [api, agent?.graph_id, refreshPageData]);
// Handle execution updates
useEffect(() => {
const detachExecUpdateHandler = api.onWebSocketMessage(
"graph_execution_event",
(data) => {
if (data.graph_id != agent?.graph_id) return;
if (data.status == "COMPLETED") {
incrementRuns();
}
setAgentRuns((prev) => {
const index = prev.findIndex((run) => run.id === data.id);
if (index === -1) {
return [...prev, data];
}
const newRuns = [...prev];
newRuns[index] = { ...newRuns[index], ...data };
return newRuns;
});
if (data.id === selectedView.id) {
setSelectedRun((prev) => ({ ...prev, ...data }));
}
},
);
return () => {
detachExecUpdateHandler();
};
}, [api, agent?.graph_id, selectedView.id, incrementRuns]);
// Pre-load selectedRun based on selectedView
useEffect(() => {
if (selectedView.type != "run" || !selectedView.id) return;
const newSelectedRun = agentRuns.find((run) => run.id == selectedView.id);
if (selectedView.id !== selectedRun?.id) {
// Pull partial data from "cache" while waiting for the rest to load
setSelectedRun(newSelectedRun ?? null);
}
}, [api, selectedView, agentRuns, selectedRun?.id]);
// Load selectedRun based on selectedView; refresh on agent refresh
useEffect(() => {
if (selectedView.type != "run" || !selectedView.id || !agent) return;
api
.getGraphExecutionInfo(agent.graph_id, selectedView.id)
.then(async (run) => {
// Ensure corresponding graph version is available before rendering I/O
await getGraphVersion(run.graph_id, run.graph_version);
setSelectedRun(run);
});
}, [api, selectedView, agent, getGraphVersion]);
const fetchSchedules = useCallback(async () => {
if (!agent) return;
setSchedules(await api.listGraphExecutionSchedules(agent.graph_id));
}, [api, agent?.graph_id]);
useEffect(() => {
fetchSchedules();
}, [fetchSchedules]);
// =========================== ACTIONS ============================
const deleteRun = useCallback(
async (run: GraphExecutionMeta) => {
if (run.status == "RUNNING" || run.status == "QUEUED") {
await api.stopGraphExecution(run.graph_id, run.id);
}
await api.deleteGraphExecution(run.id);
setConfirmingDeleteAgentRun(null);
if (selectedView.type == "run" && selectedView.id == run.id) {
openRunDraftView();
}
setAgentRuns((runs) => runs.filter((r) => r.id !== run.id));
},
[api, selectedView, openRunDraftView],
);
const deletePreset = useCallback(
async (presetID: LibraryAgentPresetID) => {
await api.deleteLibraryAgentPreset(presetID);
setConfirmingDeleteAgentPreset(null);
if (selectedView.type == "preset" && selectedView.id == presetID) {
openRunDraftView();
}
setAgentPresets((presets) => presets.filter((p) => p.id !== presetID));
},
[api, selectedView, openRunDraftView],
);
const deleteSchedule = useCallback(
async (scheduleID: ScheduleID) => {
const removedSchedule =
await api.deleteGraphExecutionSchedule(scheduleID);
setSchedules((schedules) => {
const newSchedules = schedules.filter(
(s) => s.id !== removedSchedule.id,
);
if (
selectedView.type == "schedule" &&
selectedView.id == removedSchedule.id
) {
if (newSchedules.length > 0) {
// Select next schedule if available
selectSchedule(newSchedules[0].id);
} else {
// Reset to draft view if current schedule was deleted
openRunDraftView();
}
}
return newSchedules;
});
openRunDraftView();
},
[schedules, api],
);
const downloadGraph = useCallback(
async () =>
agent &&
// Export sanitized graph from backend
api
.getGraph(agent.graph_id, agent.graph_version, true)
.then((graph) =>
exportAsJSONFile(graph, `${graph.name}_v${graph.version}.json`),
),
[api, agent],
);
const copyAgent = useCallback(async () => {
setCopyAgentDialogOpen(false);
api
.forkLibraryAgent(agentID)
.then((newAgent) => {
router.push(`/library/agents/${newAgent.id}`);
})
.catch((error) => {
console.error("Error copying agent:", error);
toast({
title: "Error copying agent",
description: `An error occurred while copying the agent: ${error.message}`,
variant: "destructive",
});
});
}, [agentID, api, router, toast]);
const agentActions: ButtonAction[] = useMemo(
() => [
{
label: "Customize agent",
href: `/build?flowID=${agent?.graph_id}&flowVersion=${agent?.graph_version}`,
disabled: !agent?.can_access_graph,
},
{ label: "Export agent to file", callback: downloadGraph },
...(!agent?.can_access_graph
? [
{
label: "Edit a copy",
callback: () => setCopyAgentDialogOpen(true),
},
]
: []),
{
label: "Delete agent",
callback: () => setAgentDeleteDialogOpen(true),
},
],
[agent, downloadGraph],
);
const runGraph =
graphVersions.current[selectedRun?.graph_version ?? 0] ?? graph;
const onCreateSchedule = useCallback(
(schedule: Schedule) => {
setSchedules((prev) => [...prev, schedule]);
selectSchedule(schedule.id);
},
[selectView],
);
const onCreatePreset = useCallback(
(preset: LibraryAgentPreset) => {
setAgentPresets((prev) => [...prev, preset]);
selectPreset(preset.id);
},
[selectPreset],
);
const onUpdatePreset = useCallback(
(updated: LibraryAgentPreset) => {
setAgentPresets((prev) =>
prev.map((p) => (p.id === updated.id ? updated : p)),
);
selectPreset(updated.id);
},
[selectPreset],
);
if (!agent || !graph) {
return <LoadingBox className="h-[90vh]" />;
}
return (
<div className="container justify-stretch p-0 pt-16 lg:flex">
{/* Sidebar w/ list of runs */}
{/* TODO: render this below header in sm and md layouts */}
<AgentRunsSelectorList
className="agpt-div w-full border-b lg:w-auto lg:border-b-0 lg:border-r"
agent={agent}
agentRuns={agentRuns}
agentPresets={agentPresets}
schedules={schedules}
selectedView={selectedView}
onSelectRun={selectRun}
onSelectPreset={selectPreset}
onSelectSchedule={selectSchedule}
onSelectDraftNewRun={openRunDraftView}
doDeleteRun={setConfirmingDeleteAgentRun}
doDeletePreset={setConfirmingDeleteAgentPreset}
doDeleteSchedule={deleteSchedule}
/>
<div className="flex-1">
{/* Header */}
<div className="agpt-div w-full border-b">
<h1
data-testid="agent-title"
className="font-poppins text-3xl font-medium"
>
{
agent.name /* TODO: use dynamic/custom run title - https://github.com/Significant-Gravitas/AutoGPT/issues/9184 */
}
</h1>
</div>
{/* Run / Schedule views */}
{(selectedView.type == "run" && selectedView.id ? (
selectedRun && runGraph ? (
<AgentRunDetailsView
agent={agent}
graph={runGraph}
run={selectedRun}
agentActions={agentActions}
onRun={selectRun}
deleteRun={() => setConfirmingDeleteAgentRun(selectedRun)}
/>
) : null
) : selectedView.type == "run" ? (
/* Draft new runs / Create new presets */
<AgentRunDraftView
graph={graph}
triggerSetupInfo={agent.trigger_setup_info}
onRun={selectRun}
onCreateSchedule={onCreateSchedule}
onCreatePreset={onCreatePreset}
agentActions={agentActions}
/>
) : selectedView.type == "preset" ? (
/* Edit & update presets */
<AgentRunDraftView
graph={graph}
triggerSetupInfo={agent.trigger_setup_info}
agentPreset={
agentPresets.find((preset) => preset.id == selectedView.id)!
}
onRun={selectRun}
onCreateSchedule={onCreateSchedule}
onUpdatePreset={onUpdatePreset}
doDeletePreset={setConfirmingDeleteAgentPreset}
agentActions={agentActions}
/>
) : selectedView.type == "schedule" ? (
selectedSchedule &&
graph && (
<AgentScheduleDetailsView
graph={graph}
schedule={selectedSchedule}
// agent={agent}
agentActions={agentActions}
onForcedRun={selectRun}
doDeleteSchedule={deleteSchedule}
/>
)
) : null) || <LoadingBox className="h-[70vh]" />}
<DeleteConfirmDialog
entityType="agent"
open={agentDeleteDialogOpen}
onOpenChange={setAgentDeleteDialogOpen}
onDoDelete={() =>
agent &&
api.deleteLibraryAgent(agent.id).then(() => router.push("/library"))
}
/>
<DeleteConfirmDialog
entityType="agent run"
open={!!confirmingDeleteAgentRun}
onOpenChange={(open) => !open && setConfirmingDeleteAgentRun(null)}
onDoDelete={() =>
confirmingDeleteAgentRun && deleteRun(confirmingDeleteAgentRun)
}
/>
<DeleteConfirmDialog
entityType={agent.has_external_trigger ? "trigger" : "agent preset"}
open={!!confirmingDeleteAgentPreset}
onOpenChange={(open) => !open && setConfirmingDeleteAgentPreset(null)}
onDoDelete={() =>
confirmingDeleteAgentPreset &&
deletePreset(confirmingDeleteAgentPreset)
}
/>
{/* Copy agent confirmation dialog */}
<Dialog
onOpenChange={setCopyAgentDialogOpen}
open={copyAgentDialogOpen}
>
<DialogContent>
<DialogHeader>
<DialogTitle>You&apos;re making an editable copy</DialogTitle>
<DialogDescription className="pt-2">
The original Marketplace agent stays the same and cannot be
edited. We&apos;ll save a new version of this agent to your
Library. From there, you can customize it however you&apos;d
like by clicking &quot;Customize agent&quot; this will open
the builder where you can see and modify the inner workings.
</DialogDescription>
</DialogHeader>
<DialogFooter className="justify-end">
<Button
type="button"
variant="outline"
onClick={() => setCopyAgentDialogOpen(false)}
>
Cancel
</Button>
<Button type="button" onClick={copyAgent}>
Continue
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
);
}

View File

@@ -36,7 +36,7 @@ import {
} from "@/components/agents/agent-run-status-chip";
import useCredits from "@/hooks/useCredits";
export default function AgentRunDetailsView({
export function AgentRunDetailsView({
agent,
graph,
run,

View File

@@ -30,7 +30,7 @@ import {
useToastOnFail,
} from "@/components/molecules/Toast/use-toast";
export default function AgentRunDraftView({
export function AgentRunDraftView({
graph,
agentPreset,
triggerSetupInfo,

View File

@@ -19,7 +19,7 @@ import { Separator } from "@/components/ui/separator";
import { agentRunStatusMap } from "@/components/agents/agent-run-status-chip";
import AgentRunSummaryCard from "@/components/agents/agent-run-summary-card";
import { Button } from "../atoms/Button/Button";
import { Button } from "../../../../../../../../components/atoms/Button/Button";
interface AgentRunsSelectorListProps {
agent: LibraryAgent;
@@ -38,7 +38,7 @@ interface AgentRunsSelectorListProps {
className?: string;
}
export default function AgentRunsSelectorList({
export function AgentRunsSelectorList({
agent,
agentRuns,
agentPresets,

View File

@@ -20,7 +20,7 @@ import { useToastOnFail } from "@/components/molecules/Toast/use-toast";
import { humanizeCronExpression } from "@/lib/cron-expression-utils";
import { PlayIcon } from "lucide-react";
export default function AgentScheduleDetailsView({
export function AgentScheduleDetailsView({
graph,
schedule,
agentActions,

View File

@@ -1,21 +1,3 @@
import AgentFlowListSkeleton from "@/components/monitor/skeletons/AgentFlowListSkeleton";
import React from "react";
import FlowRunsListSkeleton from "@/components/monitor/skeletons/FlowRunsListSkeleton";
import FlowRunsStatusSkeleton from "@/components/monitor/skeletons/FlowRunsStatusSkeleton";
import { AgentRunsLoading } from "./components/AgentRunsView/components/AgentRunsLoading";
export default function MonitorLoadingSkeleton() {
return (
<div className="space-y-4 p-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{/* Agents Section */}
<AgentFlowListSkeleton />
{/* Runs Section */}
<FlowRunsListSkeleton />
{/* Stats Section */}
<FlowRunsStatusSkeleton />
</div>
</div>
);
}
export default AgentRunsLoading;

View File

@@ -1,622 +1,16 @@
"use client";
import { useParams, useRouter } from "next/navigation";
import { useQueryState } from "nuqs";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
Graph,
GraphExecution,
GraphExecutionID,
GraphExecutionMeta,
GraphID,
LibraryAgent,
LibraryAgentID,
LibraryAgentPreset,
LibraryAgentPresetID,
Schedule,
ScheduleID,
} from "@/lib/autogpt-server-api";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import { exportAsJSONFile } from "@/lib/utils";
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
import AgentRunDetailsView from "@/components/agents/agent-run-details-view";
import AgentRunDraftView from "@/components/agents/agent-run-draft-view";
import AgentRunsSelectorList from "@/components/agents/agent-runs-selector-list";
import AgentScheduleDetailsView from "@/components/agents/agent-schedule-details-view";
import DeleteConfirmDialog from "@/components/agptui/delete-confirm-dialog";
import type { ButtonAction } from "@/components/agptui/types";
import { useOnboarding } from "@/components/onboarding/onboarding-provider";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import LoadingBox, { LoadingSpinner } from "@/components/ui/loading";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { OldAgentLibraryView } from "./components/OldAgentLibraryView/OldAgentLibraryView";
import { AgentRunsView } from "./components/AgentRunsView/AgentRunsView";
export default function AgentRunsPage(): React.ReactElement {
const { id: agentID }: { id: LibraryAgentID } = useParams();
const [executionId, setExecutionId] = useQueryState("executionId");
const { toast } = useToast();
const router = useRouter();
const api = useBackendAPI();
export default function AgentLibraryPage() {
const isNewAgentRunsEnabled = useGetFlag(Flag.NEW_AGENT_RUNS);
// ============================ STATE =============================
const [graph, setGraph] = useState<Graph | null>(null); // Graph version corresponding to LibraryAgent
const [agent, setAgent] = useState<LibraryAgent | null>(null);
const [agentRuns, setAgentRuns] = useState<GraphExecutionMeta[]>([]);
const [agentPresets, setAgentPresets] = useState<LibraryAgentPreset[]>([]);
const [schedules, setSchedules] = useState<Schedule[]>([]);
const [selectedView, selectView] = useState<
| { type: "run"; id?: GraphExecutionID }
| { type: "preset"; id: LibraryAgentPresetID }
| { type: "schedule"; id: ScheduleID }
>({ type: "run" });
const [selectedRun, setSelectedRun] = useState<
GraphExecution | GraphExecutionMeta | null
>(null);
const selectedSchedule =
selectedView.type == "schedule"
? schedules.find((s) => s.id == selectedView.id)
: null;
const [isFirstLoad, setIsFirstLoad] = useState<boolean>(true);
const [agentDeleteDialogOpen, setAgentDeleteDialogOpen] =
useState<boolean>(false);
const [confirmingDeleteAgentRun, setConfirmingDeleteAgentRun] =
useState<GraphExecutionMeta | null>(null);
const [confirmingDeleteAgentPreset, setConfirmingDeleteAgentPreset] =
useState<LibraryAgentPresetID | null>(null);
const {
state: onboardingState,
updateState: updateOnboardingState,
incrementRuns,
} = useOnboarding();
const [copyAgentDialogOpen, setCopyAgentDialogOpen] = useState(false);
// Set page title with agent name
useEffect(() => {
if (agent) {
document.title = `${agent.name} - Library - AutoGPT Platform`;
}
}, [agent]);
const openRunDraftView = useCallback(() => {
selectView({ type: "run" });
}, []);
const selectRun = useCallback((id: GraphExecutionID) => {
selectView({ type: "run", id });
}, []);
const selectPreset = useCallback((id: LibraryAgentPresetID) => {
selectView({ type: "preset", id });
}, []);
const selectSchedule = useCallback((id: ScheduleID) => {
selectView({ type: "schedule", id });
}, []);
const graphVersions = useRef<Record<number, Graph>>({});
const loadingGraphVersions = useRef<Record<number, Promise<Graph>>>({});
const getGraphVersion = useCallback(
async (graphID: GraphID, version: number) => {
if (version in graphVersions.current)
return graphVersions.current[version];
if (version in loadingGraphVersions.current)
return loadingGraphVersions.current[version];
const pendingGraph = api.getGraph(graphID, version).then((graph) => {
graphVersions.current[version] = graph;
return graph;
});
// Cache promise as well to avoid duplicate requests
loadingGraphVersions.current[version] = pendingGraph;
return pendingGraph;
},
[api, graphVersions, loadingGraphVersions],
);
// Reward user for viewing results of their onboarding agent
useEffect(() => {
if (
!onboardingState ||
!selectedRun ||
onboardingState.completedSteps.includes("GET_RESULTS")
)
return;
if (selectedRun.id === onboardingState.onboardingAgentExecutionId) {
updateOnboardingState({
completedSteps: [...onboardingState.completedSteps, "GET_RESULTS"],
});
}
}, [selectedRun, onboardingState, updateOnboardingState]);
const lastRefresh = useRef<number>(0);
const refreshPageData = useCallback(() => {
if (Date.now() - lastRefresh.current < 2e3) return; // 2 second debounce
lastRefresh.current = Date.now();
api.getLibraryAgent(agentID).then((agent) => {
setAgent(agent);
getGraphVersion(agent.graph_id, agent.graph_version).then(
(_graph) =>
(graph && graph.version == _graph.version) || setGraph(_graph),
);
Promise.all([
api.getGraphExecutions(agent.graph_id),
api.listLibraryAgentPresets({
graph_id: agent.graph_id,
page_size: 100,
}),
]).then(([runs, presets]) => {
setAgentRuns(runs);
setAgentPresets(presets.presets);
// Preload the corresponding graph versions for the latest 10 runs
new Set(runs.slice(0, 10).map((run) => run.graph_version)).forEach(
(version) => getGraphVersion(agent.graph_id, version),
);
});
});
}, [api, agentID, getGraphVersion, graph]);
// On first load: select the latest run
useEffect(() => {
// Only for first load or first execution
if (selectedView.id || !isFirstLoad) return;
if (agentRuns.length == 0 && agentPresets.length == 0) return;
setIsFirstLoad(false);
if (agentRuns.length > 0) {
// select latest run
const latestRun = agentRuns.reduce((latest, current) => {
if (latest.started_at && !current.started_at) return current;
else if (!latest.started_at) return latest;
return latest.started_at > current.started_at ? latest : current;
}, agentRuns[0]);
selectRun(latestRun.id);
} else {
// select top preset
const latestPreset = agentPresets.toSorted(
(a, b) => b.updated_at.getTime() - a.updated_at.getTime(),
)[0];
selectPreset(latestPreset.id);
}
}, [
isFirstLoad,
selectedView.id,
agentRuns,
agentPresets,
selectRun,
selectPreset,
]);
useEffect(() => {
if (executionId) {
selectRun(executionId as GraphExecutionID);
setExecutionId(null);
}
}, [executionId, selectRun, setExecutionId]);
// Initial load
useEffect(() => {
refreshPageData();
// Show a toast when the WebSocket connection disconnects
let connectionToast: ReturnType<typeof toast> | null = null;
const cancelDisconnectHandler = api.onWebSocketDisconnect(() => {
connectionToast ??= toast({
title: "Connection to server was lost",
variant: "destructive",
description: (
<div className="flex items-center">
Trying to reconnect...
<LoadingSpinner className="ml-1.5 size-3.5" />
</div>
),
duration: Infinity,
dismissable: true,
});
});
const cancelConnectHandler = api.onWebSocketConnect(() => {
if (connectionToast)
connectionToast.update({
id: connectionToast.id,
title: "✅ Connection re-established",
variant: "default",
description: (
<div className="flex items-center">
Refreshing data...
<LoadingSpinner className="ml-1.5 size-3.5" />
</div>
),
duration: 2000,
dismissable: true,
});
connectionToast = null;
});
return () => {
cancelDisconnectHandler();
cancelConnectHandler();
};
}, []);
// Subscribe to WebSocket updates for agent runs
useEffect(() => {
if (!agent?.graph_id) return;
return api.onWebSocketConnect(() => {
refreshPageData(); // Sync up on (re)connect
// Subscribe to all executions for this agent
api.subscribeToGraphExecutions(agent.graph_id);
});
}, [api, agent?.graph_id, refreshPageData]);
// Handle execution updates
useEffect(() => {
const detachExecUpdateHandler = api.onWebSocketMessage(
"graph_execution_event",
(data) => {
if (data.graph_id != agent?.graph_id) return;
if (data.status == "COMPLETED") {
incrementRuns();
}
setAgentRuns((prev) => {
const index = prev.findIndex((run) => run.id === data.id);
if (index === -1) {
return [...prev, data];
}
const newRuns = [...prev];
newRuns[index] = { ...newRuns[index], ...data };
return newRuns;
});
if (data.id === selectedView.id) {
setSelectedRun((prev) => ({ ...prev, ...data }));
}
},
);
return () => {
detachExecUpdateHandler();
};
}, [api, agent?.graph_id, selectedView.id, incrementRuns]);
// Pre-load selectedRun based on selectedView
useEffect(() => {
if (selectedView.type != "run" || !selectedView.id) return;
const newSelectedRun = agentRuns.find((run) => run.id == selectedView.id);
if (selectedView.id !== selectedRun?.id) {
// Pull partial data from "cache" while waiting for the rest to load
setSelectedRun(newSelectedRun ?? null);
}
}, [api, selectedView, agentRuns, selectedRun?.id]);
// Load selectedRun based on selectedView; refresh on agent refresh
useEffect(() => {
if (selectedView.type != "run" || !selectedView.id || !agent) return;
api
.getGraphExecutionInfo(agent.graph_id, selectedView.id)
.then(async (run) => {
// Ensure corresponding graph version is available before rendering I/O
await getGraphVersion(run.graph_id, run.graph_version);
setSelectedRun(run);
});
}, [api, selectedView, agent, getGraphVersion]);
const fetchSchedules = useCallback(async () => {
if (!agent) return;
setSchedules(await api.listGraphExecutionSchedules(agent.graph_id));
}, [api, agent?.graph_id]);
useEffect(() => {
fetchSchedules();
}, [fetchSchedules]);
// =========================== ACTIONS ============================
const deleteRun = useCallback(
async (run: GraphExecutionMeta) => {
if (run.status == "RUNNING" || run.status == "QUEUED") {
await api.stopGraphExecution(run.graph_id, run.id);
}
await api.deleteGraphExecution(run.id);
setConfirmingDeleteAgentRun(null);
if (selectedView.type == "run" && selectedView.id == run.id) {
openRunDraftView();
}
setAgentRuns((runs) => runs.filter((r) => r.id !== run.id));
},
[api, selectedView, openRunDraftView],
);
const deletePreset = useCallback(
async (presetID: LibraryAgentPresetID) => {
await api.deleteLibraryAgentPreset(presetID);
setConfirmingDeleteAgentPreset(null);
if (selectedView.type == "preset" && selectedView.id == presetID) {
openRunDraftView();
}
setAgentPresets((presets) => presets.filter((p) => p.id !== presetID));
},
[api, selectedView, openRunDraftView],
);
const deleteSchedule = useCallback(
async (scheduleID: ScheduleID) => {
const removedSchedule =
await api.deleteGraphExecutionSchedule(scheduleID);
setSchedules((schedules) => {
const newSchedules = schedules.filter(
(s) => s.id !== removedSchedule.id,
);
if (
selectedView.type == "schedule" &&
selectedView.id == removedSchedule.id
) {
if (newSchedules.length > 0) {
// Select next schedule if available
selectSchedule(newSchedules[0].id);
} else {
// Reset to draft view if current schedule was deleted
openRunDraftView();
}
}
return newSchedules;
});
openRunDraftView();
},
[schedules, api],
);
const downloadGraph = useCallback(
async () =>
agent &&
// Export sanitized graph from backend
api
.getGraph(agent.graph_id, agent.graph_version, true)
.then((graph) =>
exportAsJSONFile(graph, `${graph.name}_v${graph.version}.json`),
),
[api, agent],
);
const copyAgent = useCallback(async () => {
setCopyAgentDialogOpen(false);
api
.forkLibraryAgent(agentID)
.then((newAgent) => {
router.push(`/library/agents/${newAgent.id}`);
})
.catch((error) => {
console.error("Error copying agent:", error);
toast({
title: "Error copying agent",
description: `An error occurred while copying the agent: ${error.message}`,
variant: "destructive",
});
});
}, [agentID, api, router, toast]);
const agentActions: ButtonAction[] = useMemo(
() => [
{
label: "Customize agent",
href: `/build?flowID=${agent?.graph_id}&flowVersion=${agent?.graph_version}`,
disabled: !agent?.can_access_graph,
},
{ label: "Export agent to file", callback: downloadGraph },
...(!agent?.can_access_graph
? [
{
label: "Edit a copy",
callback: () => setCopyAgentDialogOpen(true),
},
]
: []),
{
label: "Delete agent",
callback: () => setAgentDeleteDialogOpen(true),
},
],
[agent, downloadGraph],
);
const runGraph =
graphVersions.current[selectedRun?.graph_version ?? 0] ?? graph;
const onCreateSchedule = useCallback(
(schedule: Schedule) => {
setSchedules((prev) => [...prev, schedule]);
selectSchedule(schedule.id);
},
[selectView],
);
const onCreatePreset = useCallback(
(preset: LibraryAgentPreset) => {
setAgentPresets((prev) => [...prev, preset]);
selectPreset(preset.id);
},
[selectPreset],
);
const onUpdatePreset = useCallback(
(updated: LibraryAgentPreset) => {
setAgentPresets((prev) =>
prev.map((p) => (p.id === updated.id ? updated : p)),
);
selectPreset(updated.id);
},
[selectPreset],
);
if (!agent || !graph) {
return <LoadingBox className="h-[90vh]" />;
if (isNewAgentRunsEnabled) {
return <AgentRunsView />;
}
return (
<div className="container justify-stretch p-0 pt-16 lg:flex">
{/* Sidebar w/ list of runs */}
{/* TODO: render this below header in sm and md layouts */}
<AgentRunsSelectorList
className="agpt-div w-full border-b lg:w-auto lg:border-b-0 lg:border-r"
agent={agent}
agentRuns={agentRuns}
agentPresets={agentPresets}
schedules={schedules}
selectedView={selectedView}
onSelectRun={selectRun}
onSelectPreset={selectPreset}
onSelectSchedule={selectSchedule}
onSelectDraftNewRun={openRunDraftView}
doDeleteRun={setConfirmingDeleteAgentRun}
doDeletePreset={setConfirmingDeleteAgentPreset}
doDeleteSchedule={deleteSchedule}
/>
<div className="flex-1">
{/* Header */}
<div className="agpt-div w-full border-b">
<h1
data-testid="agent-title"
className="font-poppins text-3xl font-medium"
>
{
agent.name /* TODO: use dynamic/custom run title - https://github.com/Significant-Gravitas/AutoGPT/issues/9184 */
}
</h1>
</div>
{/* Run / Schedule views */}
{(selectedView.type == "run" && selectedView.id ? (
selectedRun && runGraph ? (
<AgentRunDetailsView
agent={agent}
graph={runGraph}
run={selectedRun}
agentActions={agentActions}
onRun={selectRun}
deleteRun={() => setConfirmingDeleteAgentRun(selectedRun)}
/>
) : null
) : selectedView.type == "run" ? (
/* Draft new runs / Create new presets */
<AgentRunDraftView
graph={graph}
triggerSetupInfo={agent.trigger_setup_info}
onRun={selectRun}
onCreateSchedule={onCreateSchedule}
onCreatePreset={onCreatePreset}
agentActions={agentActions}
/>
) : selectedView.type == "preset" ? (
/* Edit & update presets */
<AgentRunDraftView
graph={graph}
triggerSetupInfo={agent.trigger_setup_info}
agentPreset={
agentPresets.find((preset) => preset.id == selectedView.id)!
}
onRun={selectRun}
onCreateSchedule={onCreateSchedule}
onUpdatePreset={onUpdatePreset}
doDeletePreset={setConfirmingDeleteAgentPreset}
agentActions={agentActions}
/>
) : selectedView.type == "schedule" ? (
selectedSchedule &&
graph && (
<AgentScheduleDetailsView
graph={graph}
schedule={selectedSchedule}
// agent={agent}
agentActions={agentActions}
onForcedRun={selectRun}
doDeleteSchedule={deleteSchedule}
/>
)
) : null) || <LoadingBox className="h-[70vh]" />}
<DeleteConfirmDialog
entityType="agent"
open={agentDeleteDialogOpen}
onOpenChange={setAgentDeleteDialogOpen}
onDoDelete={() =>
agent &&
api.deleteLibraryAgent(agent.id).then(() => router.push("/library"))
}
/>
<DeleteConfirmDialog
entityType="agent run"
open={!!confirmingDeleteAgentRun}
onOpenChange={(open) => !open && setConfirmingDeleteAgentRun(null)}
onDoDelete={() =>
confirmingDeleteAgentRun && deleteRun(confirmingDeleteAgentRun)
}
/>
<DeleteConfirmDialog
entityType={agent.has_external_trigger ? "trigger" : "agent preset"}
open={!!confirmingDeleteAgentPreset}
onOpenChange={(open) => !open && setConfirmingDeleteAgentPreset(null)}
onDoDelete={() =>
confirmingDeleteAgentPreset &&
deletePreset(confirmingDeleteAgentPreset)
}
/>
{/* Copy agent confirmation dialog */}
<Dialog
onOpenChange={setCopyAgentDialogOpen}
open={copyAgentDialogOpen}
>
<DialogContent>
<DialogHeader>
<DialogTitle>You&apos;re making an editable copy</DialogTitle>
<DialogDescription className="pt-2">
The original Marketplace agent stays the same and cannot be
edited. We&apos;ll save a new version of this agent to your
Library. From there, you can customize it however you&apos;d
like by clicking &quot;Customize agent&quot; this will open
the builder where you can see and modify the inner workings.
</DialogDescription>
</DialogHeader>
<DialogFooter className="justify-end">
<Button
type="button"
variant="outline"
onClick={() => setCopyAgentDialogOpen(false)}
>
Cancel
</Button>
<Button type="button" onClick={copyAgent}>
Continue
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
);
return <OldAgentLibraryView />;
}

View File

@@ -1,5 +1,5 @@
import BackendAPI from "@/lib/autogpt-server-api";
import { BreadCrumbs } from "@/components/agptui/BreadCrumbs";
import { Breadcrumbs } from "@/components/molecules/Breadcrumbs/Breadcrumbs";
import { AgentInfo } from "@/components/agptui/AgentInfo";
import { AgentImages } from "@/components/agptui/AgentImages";
import { AgentsSection } from "@/components/agptui/composite/AgentsSection";
@@ -73,7 +73,7 @@ export default async function MarketplaceAgentPage({
return (
<div className="mx-auto w-screen max-w-[1360px]">
<main className="mt-5 px-4">
<BreadCrumbs items={breadcrumbs} />
<Breadcrumbs items={breadcrumbs} />
<div className="mt-4 flex flex-col items-start gap-4 sm:mt-6 sm:gap-6 md:mt-8 md:flex-row md:gap-8">
<div className="w-full md:w-auto md:shrink-0">

View File

@@ -1,6 +1,6 @@
import BackendAPI from "@/lib/autogpt-server-api";
import { AgentsSection } from "@/components/agptui/composite/AgentsSection";
import { BreadCrumbs } from "@/components/agptui/BreadCrumbs";
import { Breadcrumbs } from "@/components/molecules/Breadcrumbs/Breadcrumbs";
import { Metadata } from "next";
import { CreatorInfoCard } from "@/components/agptui/CreatorInfoCard";
import { CreatorLinks } from "@/components/agptui/CreatorLinks";
@@ -49,7 +49,7 @@ export default async function Page({
return (
<div className="mx-auto w-screen max-w-[1360px]">
<main className="mt-5 px-4">
<BreadCrumbs
<Breadcrumbs
items={[
{ name: "Store", link: "/marketplace" },
{ name: creator.name, link: "#" },

View File

@@ -1,42 +0,0 @@
import * as React from "react";
import Link from "next/link";
interface BreadcrumbItem {
name: string;
link: string;
}
interface BreadCrumbsProps {
items: BreadcrumbItem[];
}
export const BreadCrumbs: React.FC<BreadCrumbsProps> = ({ items }) => {
return (
<div className="flex items-center gap-4">
{/*
Commented out for now, but keeping until we have approval to remove
<button className="flex h-12 w-12 items-center justify-center rounded-full border border-neutral-200 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-800">
<IconLeftArrow className="h-5 w-5 text-neutral-900 dark:text-neutral-100" />
</button>
<button className="flex h-12 w-12 items-center justify-center rounded-full border border-neutral-200 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-800">
<IconRightArrow className="h-5 w-5 text-neutral-900 dark:text-neutral-100" />
</button> */}
<div className="flex h-auto flex-wrap items-center justify-start gap-4 rounded-[5rem] dark:bg-transparent">
{items.map((item, index) => (
<React.Fragment key={index}>
<Link href={item.link}>
<span className="rounded py-1 pr-2 text-xl font-medium leading-9 tracking-tight text-[#272727] transition-colors duration-200 hover:text-gray-400 dark:text-neutral-100 dark:hover:text-gray-500">
{item.name}
</span>
</Link>
{index < items.length - 1 && (
<span className="text-center text-2xl font-normal text-black dark:text-neutral-100">
/
</span>
)}
</React.Fragment>
))}
</div>
</div>
);
};

View File

@@ -0,0 +1,36 @@
import * as React from "react";
import { Link } from "@/components/atoms/Link/Link";
import { Text } from "@/components/atoms/Text/Text";
interface BreadcrumbItem {
name: string;
link: string;
}
interface Props {
items: BreadcrumbItem[];
}
export function Breadcrumbs({ items }: Props) {
return (
<div className="flex items-center gap-4">
<div className="flex h-auto flex-wrap items-center justify-start gap-3 rounded-[5rem] dark:bg-transparent">
{items.map((item, index) => (
<React.Fragment key={index}>
<Link
href={item.link}
className="text-zinc-700 transition-colors hover:text-zinc-900 hover:no-underline"
>
{item.name}
</Link>
{index < items.length - 1 && (
<Text variant="body-medium" className="text-zinc-700">
/
</Text>
)}
</React.Fragment>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,293 @@
import type { Meta, StoryObj } from "@storybook/nextjs";
import { ErrorCard } from "./ErrorCard";
import { ArrowClockwise } from "@phosphor-icons/react";
const meta: Meta<typeof ErrorCard> = {
title: "Molecules/ErrorCard",
component: ErrorCard,
parameters: {
layout: "centered",
docs: {
description: {
component: `
## ErrorCard Component
A reusable error card component that handles API query responses gracefully with elegant styling and user-friendly messaging.
### ✨ Features
- **Purple gradient border** - Elegant gradient from purple to light pastel purple
- **Smart error handling** - Automatically detects HTTP errors vs response errors
- **User-friendly messages** - Non-technical, funny error messages for HTTP errors
- **Loading state** - Supports custom loading slot or default spinner
- **Action buttons** - Sentry error reporting with toast notifications and Discord help link
- **Phosphor icons** - Uses phosphor icons throughout
- **TypeScript support** - Full TypeScript interface support
### 🎯 Magic Usage Pattern
Just pass your API hook results directly:
\`\`\`tsx
<ErrorCard
isSuccess={isSuccess}
responseError={error || undefined}
httpError={response?.status !== 200 ? {
status: response?.status,
statusText: "Request failed"
} : undefined}
context="agent data"
onRetry={refetch}
/>
\`\`\`
The component will automatically:
1. Show loading spinner if not successful and no errors
2. Show custom loading slot if provided
3. Handle HTTP errors with friendly messages
4. Handle response errors with technical details
5. Report errors to Sentry with comprehensive context
6. Show toast notifications for error reporting feedback
7. Provide retry and Discord help options
8. Hide itself if successful and no errors
### 🎭 User-Friendly HTTP Error Messages
- **500+**: "Our servers are having a bit of a moment 🤖"
- **404**: "We couldn't find what you're looking for. It might have wandered off somewhere! 🔍"
- **403**: "You don't have permission to access this. Maybe you need to sign in again? 🔐"
- **429**: "Whoa there, speed racer! You're making requests too quickly. Take a breather and try again. ⏱️"
- **400+**: "Something's not quite right with your request. Double-check and try again! ✨"
`,
},
},
},
tags: ["autodocs"],
argTypes: {
isSuccess: {
control: "boolean",
description: "Whether the API request was successful",
table: {
defaultValue: { summary: "false" },
},
},
responseError: {
control: "object",
description: "Error object from API response (validation errors, etc.)",
},
httpError: {
control: "object",
description: "HTTP error object with status code and message",
},
context: {
control: "text",
description: "Context for the error message (e.g., 'user data', 'agent')",
table: {
defaultValue: { summary: '"data"' },
},
},
loadingSlot: {
control: false,
description:
"Custom loading component to show instead of default spinner",
},
onRetry: {
control: false,
description:
"Callback function for retry button (button only shows if provided)",
},
className: {
control: "text",
description: "Additional CSS classes to apply",
},
},
};
export default meta;
type Story = StoryObj<typeof meta>;
/**
* Response errors are typically validation errors from the API.
* They show the technical error message in a code block for debugging.
*/
export const ResponseError: Story = {
args: {
isSuccess: false,
responseError: {
detail: [{ msg: "Invalid authentication credentials provided" }],
},
context: "user data",
onRetry: () => alert("Retry clicked!"),
},
};
/**
* HTTP 500+ errors get a friendly, non-technical message about server issues.
*/
export const HttpError500: Story = {
args: {
isSuccess: false,
httpError: {
status: 500,
statusText: "Internal Server Error",
},
context: "agent data",
onRetry: () => alert("Retry clicked!"),
},
};
/**
* HTTP 404 errors get a playful message about missing resources.
*/
export const HttpError404: Story = {
args: {
isSuccess: false,
httpError: {
status: 404,
statusText: "Not Found",
},
context: "agent data",
onRetry: () => alert("Retry clicked!"),
},
};
/**
* HTTP 429 errors get a humorous rate limiting message.
*/
export const HttpError429: Story = {
args: {
isSuccess: false,
httpError: {
status: 429,
statusText: "Too Many Requests",
},
context: "API data",
onRetry: () => alert("Retry clicked!"),
},
};
/**
* HTTP 403 errors suggest re-authentication.
*/
export const HttpError403: Story = {
args: {
isSuccess: false,
httpError: {
status: 403,
statusText: "Forbidden",
},
context: "user profile",
onRetry: () => alert("Retry clicked!"),
},
};
/**
* Default loading state shows a spinning phosphor icon.
*/
export const LoadingState: Story = {
args: {
isSuccess: false,
},
};
/**
* You can provide a custom loading component via the loadingSlot prop.
*/
export const CustomLoadingSlot: Story = {
args: {
loadingSlot: (
<div className="flex items-center gap-3">
<ArrowClockwise
size={20}
weight="bold"
className="animate-spin text-purple-500"
/>
<span className="text-zinc-600">Loading your awesome data...</span>
</div>
),
},
};
/**
* Response errors can also have string error details instead of arrays.
*/
export const StringErrorDetail: Story = {
args: {
isSuccess: false,
responseError: {
detail: "Something went wrong with the database connection",
},
context: "database",
onRetry: () => alert("Retry clicked!"),
},
};
/**
* If no onRetry callback is provided, the retry button won't appear.
*/
export const NoRetryButton: Story = {
args: {
isSuccess: false,
responseError: {
message: "This error cannot be retried",
},
context: "configuration",
// No onRetry prop - button won't show
},
};
/**
* Response errors with just a message property.
*/
export const SimpleMessage: Story = {
args: {
isSuccess: false,
responseError: {
message: "User session has expired",
},
context: "authentication",
onRetry: () => alert("Retry clicked!"),
},
};
/**
* Typical usage pattern with React Query or similar data fetching hooks.
*/
export const TypicalUsage: Story = {
args: {
isSuccess: false,
responseError: {
detail: [{ msg: "Agent not found in library" }],
},
context: "agent",
onRetry: () => alert("This would typically call refetch() or similar"),
},
parameters: {
docs: {
description: {
story: `
This shows how you'd typically use ErrorCard with a data fetching hook:
\`\`\`tsx
function MyComponent() {
const { data: response, isSuccess, error } = useApiHook();
return (
<ErrorCard
isSuccess={isSuccess}
responseError={error || undefined}
httpError={response?.status !== 200 ? {
status: response?.status,
statusText: "Request failed"
} : undefined}
context="agent"
onRetry={() => window.location.reload()}
/>
);
}
\`\`\`
`,
},
},
},
};

View File

@@ -0,0 +1,61 @@
import React from "react";
import { getErrorMessage, getHttpErrorMessage, isHttpError } from "./helpers";
import { CardWrapper } from "./components/CardWrapper";
import { ErrorHeader } from "./components/ErrorHeader";
import { ErrorMessage } from "./components/ErrorMessage";
import { ActionButtons } from "./components/ActionButtons";
export interface ErrorCardProps {
isSuccess?: boolean;
responseError?: {
detail?: Array<{ msg: string }> | string;
message?: string;
};
httpError?: {
status?: number;
statusText?: string;
message?: string;
};
context?: string;
loadingSlot?: React.ReactNode;
onRetry?: () => void;
className?: string;
}
export function ErrorCard({
isSuccess = false,
responseError,
httpError,
context = "data",
onRetry,
className = "",
}: ErrorCardProps) {
if (isSuccess && !responseError && !httpError) {
return null;
}
const isHttp = isHttpError(httpError);
const errorMessage = isHttp
? getHttpErrorMessage(httpError)
: getErrorMessage(responseError);
return (
<CardWrapper className={className}>
<div className="relative space-y-4 p-6">
<ErrorHeader />
<ErrorMessage
isHttpError={isHttp}
errorMessage={errorMessage}
context={context}
/>
<ActionButtons
onRetry={onRetry}
responseError={responseError}
httpError={httpError}
context={context}
/>
</div>
</CardWrapper>
);
}

View File

@@ -0,0 +1,51 @@
import React from "react";
import { ArrowClockwise, Bug, DiscordLogo } from "@phosphor-icons/react";
import { handleReportError } from "../helpers";
import { ErrorCardProps } from "../ErrorCard";
import { Button } from "@/components/atoms/Button/Button";
interface ActionButtonsProps {
onRetry?: () => void;
responseError?: ErrorCardProps["responseError"];
httpError?: ErrorCardProps["httpError"];
context: string;
}
export function ActionButtons({
onRetry,
responseError,
httpError,
context,
}: ActionButtonsProps) {
return (
<div className="flex flex-col gap-3 pt-2 sm:flex-row">
{onRetry && (
<Button onClick={onRetry} variant="outline" size="small">
<ArrowClockwise size={16} weight="bold" />
Try Again
</Button>
)}
<Button
onClick={() => handleReportError(responseError, httpError, context)}
variant="ghost"
size="small"
>
<Bug size={16} weight="bold" />
Report Error
</Button>
<Button
as="NextLink"
variant="ghost"
size="small"
href="https://discord.gg/autogpt"
target="_blank"
rel="noopener noreferrer"
>
<DiscordLogo size={16} weight="fill" />
Get Help
</Button>
</div>
);
}

View File

@@ -0,0 +1,24 @@
import React from "react";
import { colors } from "@/components/styles/colors";
interface CardWrapperProps {
children: React.ReactNode;
className?: string;
}
export function CardWrapper({ children, className = "" }: CardWrapperProps) {
return (
<div className={`relative overflow-hidden rounded-xl ${className}`}>
{/* Purple gradient border */}
<div
className="absolute inset-0 rounded-xl p-[1px]"
style={{
background: `linear-gradient(135deg, ${colors.zinc[500]}, ${colors.zinc[200]}, ${colors.zinc[100]})`,
}}
>
<div className="h-full w-full rounded-xl bg-white" />
</div>
{children}
</div>
);
}

View File

@@ -0,0 +1,18 @@
import React from "react";
import { Text } from "@/components/atoms/Text/Text";
import { Warning } from "@phosphor-icons/react";
export function ErrorHeader() {
return (
<div className="flex items-center gap-3">
<div className="flex-shrink-0">
<Warning size={24} weight="fill" className="text-red-400" />
</div>
<div>
<Text variant="large-semibold" className="text-zinc-800">
Something went wrong
</Text>
</div>
</div>
);
}

View File

@@ -0,0 +1,35 @@
import React from "react";
import { Text } from "@/components/atoms/Text/Text";
interface ErrorMessageProps {
isHttpError: boolean;
errorMessage: string;
context: string;
}
export function ErrorMessage({
isHttpError,
errorMessage,
context,
}: ErrorMessageProps) {
return (
<div className="space-y-2">
{isHttpError ? (
<Text variant="body" className="text-zinc-700">
{errorMessage}
</Text>
) : (
<>
<Text variant="body" className="text-zinc-700">
We had the following error when retrieving {context ?? "your data"}:
</Text>
<div className="rounded-lg border border-zinc-100 bg-zinc-50 p-3">
<Text variant="body" className="!text-red-700">
{errorMessage}
</Text>
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,26 @@
import React from "react";
import { Text } from "@/components/atoms/Text/Text";
import { ArrowClockwise } from "@phosphor-icons/react";
interface LoadingStateProps {
loadingSlot?: React.ReactNode;
}
export function LoadingState({ loadingSlot }: LoadingStateProps) {
return (
<div className="relative flex items-center justify-center gap-3 p-6">
{loadingSlot || (
<>
<ArrowClockwise
size={20}
weight="bold"
className="animate-spin text-purple-500"
/>
<Text variant="body" className="text-zinc-600">
Loading...
</Text>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,124 @@
import { ErrorCardProps } from "./ErrorCard";
export function getErrorMessage(
error: ErrorCardProps["responseError"],
): string {
if (!error) return "Unknown error occurred";
if (typeof error.detail === "string") return error.detail;
if (Array.isArray(error.detail) && error.detail.length > 0) {
return error.detail[0].msg;
}
if (error.message) return error.message;
return "Unknown error occurred";
}
export function getHttpErrorMessage(
httpError: ErrorCardProps["httpError"],
): string {
if (!httpError) return "";
const status = httpError.status || 0;
if (status >= 500) {
return "An internal server error has occurred. Please try again in a few minutes.";
}
if (status === 404) {
return "The requested resource could not be found. Please verify the URL and try again.";
}
if (status === 403) {
return "Access to this resource is forbidden. Please check your permissions or sign in again.";
}
if (status === 429) {
return "Too many requests have been made. Please wait a moment before trying again.";
}
if (status >= 400) {
return "The request could not be processed. Please review your input and try again.";
}
return "An unexpected error has occurred. Our team has been notified and is working to resolve the issue.";
}
export function shouldShowError(
isSuccess: boolean,
responseError?: ErrorCardProps["responseError"],
httpError?: ErrorCardProps["httpError"],
): boolean {
return !isSuccess || !!responseError || !!httpError;
}
export function isHttpError(httpError?: ErrorCardProps["httpError"]): boolean {
return !!httpError;
}
export function handleReportError(
responseError?: ErrorCardProps["responseError"],
httpError?: ErrorCardProps["httpError"],
context?: string,
): void {
try {
// Import Sentry dynamically to avoid SSR issues
import("@sentry/nextjs").then((Sentry) => {
// Create a comprehensive error object for Sentry
const errorData = {
responseError,
httpError,
context,
timestamp: new Date().toISOString(),
userAgent:
typeof window !== "undefined" ? window.navigator.userAgent : "server",
url: typeof window !== "undefined" ? window.location.href : "unknown",
};
// Create an error object that Sentry can capture
const errorMessage = httpError
? `HTTP ${httpError.status} - ${httpError.statusText || "Error"}`
: responseError
? `Response Error - ${getErrorMessage(responseError)}`
: "Unknown Error";
const error = new Error(
`ErrorCard: ${context ? `${context} ` : ""}${errorMessage}`,
);
Sentry.withScope((scope) => {
scope.setTag("component", "ErrorCard");
scope.setTag("errorType", httpError ? "http" : "response");
scope.setContext("errorDetails", errorData);
if (context) {
scope.setTag("context", context);
}
if (httpError?.status) {
scope.setTag("httpStatus", httpError.status.toString());
}
Sentry.captureException(error);
});
// Show success toast notification after pressing the report error button
import("sonner").then(({ toast }) => {
toast.success("Error reported successfully", {
description:
"Thank you for helping us improve! Our team has been notified.",
duration: 4000,
});
});
});
} catch (error) {
console.error("Failed to report error to Sentry:", error);
// Fallback toast notification
import("sonner").then(({ toast }) => {
toast.error("Failed to report error", {
description: "Please try again or contact support directly.",
duration: 4000,
});
});
}
}

View File

@@ -11,7 +11,7 @@ import {
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import AgentRunDraftView from "@/components/agents/agent-run-draft-view";
import { AgentRunDraftView } from "@/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/components/agent-run-draft-view";
interface RunInputDialogProps {
isOpen: boolean;

View File

@@ -1,15 +1,19 @@
"use client";
import { useFlags } from "launchdarkly-react-client-sdk";
export enum Flag {
BETA_BLOCKS = "beta-blocks",
AGENT_ACTIVITY = "agent-activity",
NEW_BLOCK_MENU = "new-block-menu",
NEW_AGENT_RUNS = "new-agent-runs",
}
export type FlagValues = {
[Flag.BETA_BLOCKS]: string[];
[Flag.AGENT_ACTIVITY]: boolean;
[Flag.NEW_BLOCK_MENU]: boolean;
[Flag.NEW_AGENT_RUNS]: boolean;
};
const isTest = process.env.NEXT_PUBLIC_PW_TEST === "true";
@@ -17,7 +21,8 @@ const isTest = process.env.NEXT_PUBLIC_PW_TEST === "true";
const mockFlags = {
[Flag.BETA_BLOCKS]: [],
[Flag.AGENT_ACTIVITY]: true,
[Flag.NEW_BLOCK_MENU]: false, // TODO: change to true when new block menu is ready
[Flag.NEW_BLOCK_MENU]: false,
[Flag.NEW_AGENT_RUNS]: false,
};
export function useGetFlag<T extends Flag>(flag: T): FlagValues[T] | null {