feat(frontend): use websockets on new library page + fixes (#11526)

## Changes 🏗️

<img width="800" height="614" alt="Screenshot 2025-12-03 at 14 52 46"
src="https://github.com/user-attachments/assets/c7012d7a-96d4-4268-a53b-27f2f7322a39"
/>

- Create a new `useExecutionEvents()` hook that can be used to subscribe
to agent executions
- now both the **Activity Dropdown** and the new **Library Agent Page**
use that
  - so subscribing to executions is centralised on a single place
- Apply a couple of design fixes
- Fix not being able to select the new templates tab 

## 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] Run the app locally and verify the above
This commit is contained in:
Ubbe
2025-12-03 16:24:58 +07:00
committed by GitHub
parent e62a8fb572
commit b4a69c49a1
8 changed files with 142 additions and 78 deletions

View File

@@ -69,7 +69,7 @@ export function SelectedRunView({
if (isLoading && !run) {
return (
<div className="flex-1 space-y-4">
<div className="flex-1 space-y-4 px-4">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-64 w-full" />

View File

@@ -13,6 +13,7 @@ import {
} from "@/components/molecules/TabsLine/TabsLine";
import { humanizeCronExpression } from "@/lib/cron-expression-utils";
import { formatInTimezone, getTimezoneDisplayName } from "@/lib/timezone-utils";
import { AGENT_LIBRARY_SECTION_PADDING_X } from "../../../helpers";
import { AgentInputsReadOnly } from "../../modals/AgentInputsReadOnly/AgentInputsReadOnly";
import { RunDetailCard } from "../RunDetailCard/RunDetailCard";
import { RunDetailHeader } from "../RunDetailHeader/RunDetailHeader";
@@ -68,7 +69,7 @@ export function SelectedScheduleView({
if (isLoading && !schedule) {
return (
<div className="flex-1 space-y-4">
<div className="flex-1 space-y-4 px-4">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-64 w-full" />
@@ -103,7 +104,7 @@ export function SelectedScheduleView({
</div>
<TabsLine defaultValue="input">
<TabsLineList>
<TabsLineList className={AGENT_LIBRARY_SECTION_PADDING_X}>
<TabsLineTrigger value="input">Your input</TabsLineTrigger>
<TabsLineTrigger value="schedule">Schedule</TabsLineTrigger>
</TabsLineList>

View File

@@ -23,7 +23,7 @@ interface Props {
selectedRunId?: string;
onSelectRun: (id: string, tab?: "runs" | "scheduled") => void;
onClearSelectedRun?: () => void;
onTabChange?: (tab: "runs" | "scheduled") => void;
onTabChange?: (tab: "runs" | "scheduled" | "templates") => void;
onCountsChange?: (info: {
runsCount: number;
schedulesCount: number;
@@ -74,7 +74,7 @@ export function SidebarRunsList({
<TabsLine
value={tabValue}
onValueChange={(v) => {
const value = v as "runs" | "scheduled";
const value = v as "runs" | "scheduled" | "templates";
onTabChange?.(value);
if (value === "runs") {
if (runs && runs.length) {
@@ -82,12 +82,14 @@ export function SidebarRunsList({
} else {
onClearSelectedRun?.();
}
} else {
} else if (value === "scheduled") {
if (schedules && schedules.length) {
onSelectRun(schedules[0].id, "scheduled");
} else {
onClearSelectedRun?.();
}
} else if (value === "templates") {
onClearSelectedRun?.();
}
}}
className="flex min-h-0 flex-col overflow-hidden"
@@ -134,7 +136,7 @@ export function SidebarRunsList({
<TabsLineContent
value="scheduled"
className={cn(
"flex min-h-0 flex-1 flex-col",
"mt-0 flex min-h-0 flex-1 flex-col",
AGENT_LIBRARY_SECTION_PADDING_X,
)}
>

View File

@@ -1,8 +1,6 @@
import type { GraphExecutionsPaginated } from "@/app/api/__generated__/models/graphExecutionsPaginated";
import type { InfiniteData } from "@tanstack/react-query";
const AGENT_RUNNING_POLL_INTERVAL = 1500;
function hasValidExecutionsData(
page: unknown,
): page is { data: GraphExecutionsPaginated } {
@@ -16,26 +14,6 @@ function hasValidExecutionsData(
);
}
export function getRunsPollingInterval(
pages: Array<unknown> | undefined,
isRunsTab: boolean,
): number | false {
if (!isRunsTab || !pages?.length) return false;
try {
const executions = pages.flatMap((page) => {
if (!hasValidExecutionsData(page)) return [];
return page.data.executions || [];
});
const hasActive = executions.some(
(e) => e.status === "RUNNING" || e.status === "QUEUED",
);
return hasActive ? AGENT_RUNNING_POLL_INTERVAL : false;
} catch {
return false;
}
}
export function computeRunsCount(
infiniteData: InfiniteData<unknown> | undefined,
runsLength: number,

View File

@@ -6,12 +6,13 @@ import { useGetV1ListGraphExecutionsInfinite } from "@/app/api/__generated__/end
import { useGetV1ListExecutionSchedulesForAGraph } from "@/app/api/__generated__/endpoints/schedules/schedules";
import type { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo";
import { okData } from "@/app/api/helpers";
import { useExecutionEvents } from "@/hooks/useExecutionEvents";
import { useQueryClient } from "@tanstack/react-query";
import { parseAsString, useQueryStates } from "nuqs";
import {
computeRunsCount,
extractRunsFromPages,
getNextRunsPageParam,
getRunsPollingInterval,
} from "./helpers";
function parseTab(value: string | null): "runs" | "scheduled" | "templates" {
@@ -42,6 +43,7 @@ export function useSidebarRunsList({
});
const tabValue = useMemo(() => parseTab(activeTabRaw), [activeTabRaw]);
const queryClient = useQueryClient();
const runsQuery = useGetV1ListGraphExecutionsInfinite(
graphId || "",
@@ -49,9 +51,6 @@ export function useSidebarRunsList({
{
query: {
enabled: !!graphId,
refetchInterval: (q) =>
getRunsPollingInterval(q.state.data?.pages, tabValue === "runs"),
refetchIntervalInBackground: true,
refetchOnWindowFocus: false,
getNextPageParam: getNextRunsPageParam,
},
@@ -79,6 +78,19 @@ export function useSidebarRunsList({
const schedulesCount = schedules.length;
const loading = !schedulesQuery.isSuccess || !runsQuery.isSuccess;
// Update query cache when execution events arrive via websocket
useExecutionEvents({
graphId: graphId || undefined,
enabled: !!graphId && tabValue === "runs",
onExecutionUpdate: (_execution) => {
// Invalidate and refetch the query to ensure we have the latest data
// This is simpler and more reliable than manually updating the cache
// The queryKey is stable and includes the graphId, so this only invalidates
// queries for this specific graph's executions
queryClient.invalidateQueries({ queryKey: runsQuery.queryKey });
},
});
// Notify parent about counts and loading state
useEffect(() => {
if (onCountsChange) {

View File

@@ -77,7 +77,7 @@ export function useNewAgentLibraryView() {
});
}
function handleSetActiveTab(tab: "runs" | "scheduled") {
function handleSetActiveTab(tab: "runs" | "scheduled" | "templates") {
setQueryStates({
activeTab: tab,
});

View File

@@ -1,19 +1,17 @@
import { useGetV1ListAllExecutions } from "@/app/api/__generated__/endpoints/graphs/graphs";
import BackendAPI from "@/lib/autogpt-server-api/client";
import type { GraphExecution, GraphID } from "@/lib/autogpt-server-api/types";
import { useCallback, useEffect, useState } from "react";
import * as Sentry from "@sentry/nextjs";
import { useExecutionEvents } from "@/hooks/useExecutionEvents";
import { useLibraryAgents } from "@/hooks/useLibraryAgents/useLibraryAgents";
import type { GraphExecution } from "@/lib/autogpt-server-api/types";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
NotificationState,
categorizeExecutions,
handleExecutionUpdate,
} from "./helpers";
import { useLibraryAgents } from "@/hooks/useLibraryAgents/useLibraryAgents";
export function useAgentActivityDropdown() {
const [isOpen, setIsOpen] = useState(false);
const [api] = useState(() => new BackendAPI());
const { agentInfoMap } = useLibraryAgents();
const [notifications, setNotifications] = useState<NotificationState>({
@@ -23,8 +21,6 @@ export function useAgentActivityDropdown() {
totalCount: 0,
});
const [isConnected, setIsConnected] = useState(false);
const {
data: executions,
isSuccess: executionsSuccess,
@@ -33,6 +29,12 @@ export function useAgentActivityDropdown() {
query: { select: (res) => (res.status === 200 ? res.data : null) },
});
// Get all graph IDs from agentInfoMap
const graphIds = useMemo(
() => Array.from(agentInfoMap.keys()),
[agentInfoMap],
);
// Handle real-time execution updates
const handleExecutionEvent = useCallback(
(execution: GraphExecution) => {
@@ -51,45 +53,15 @@ export function useAgentActivityDropdown() {
}
}, [executions, executionsSuccess, agentInfoMap]);
// Initialize WebSocket connection for real-time updates
useEffect(() => {
if (!agentInfoMap.size) return;
const connectHandler = api.onWebSocketConnect(() => {
setIsConnected(true);
agentInfoMap.forEach((_, graphId) => {
api.subscribeToGraphExecutions(graphId as GraphID).catch((error) => {
Sentry.captureException(error, {
tags: {
graphId,
},
});
});
});
});
const disconnectHandler = api.onWebSocketDisconnect(() => {
setIsConnected(false);
});
const messageHandler = api.onWebSocketMessage(
"graph_execution_event",
handleExecutionEvent,
);
api.connectWebSocket();
return () => {
connectHandler();
disconnectHandler();
messageHandler();
api.disconnectWebSocket();
};
}, [api, handleExecutionEvent, agentInfoMap]);
// Subscribe to execution events for all graphs
useExecutionEvents({
graphIds: graphIds.length > 0 ? graphIds : undefined,
enabled: graphIds.length > 0,
onExecutionUpdate: handleExecutionEvent,
});
return {
...notifications,
isConnected,
isReady: executionsSuccess,
error: executionsError,
isOpen,

View File

@@ -0,0 +1,99 @@
"use client";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import type { GraphExecution, GraphID } from "@/lib/autogpt-server-api/types";
import * as Sentry from "@sentry/nextjs";
import { useEffect, useRef } from "react";
type ExecutionEventHandler = (execution: GraphExecution) => void;
interface UseExecutionEventsOptions {
graphId?: GraphID | string | null;
graphIds?: (GraphID | string)[];
enabled?: boolean;
onExecutionUpdate?: ExecutionEventHandler;
}
/**
* Generic hook to subscribe to graph execution events via WebSocket.
* Automatically handles subscription/unsubscription and reconnection.
*
* @param options - Configuration options
* @param options.graphId - The graph ID to subscribe to (single graph)
* @param options.graphIds - Array of graph IDs to subscribe to (multiple graphs)
* @param options.enabled - Whether the subscription is enabled (default: true)
* @param options.onExecutionUpdate - Callback invoked when an execution is updated
*/
export function useExecutionEvents({
graphId,
graphIds,
enabled = true,
onExecutionUpdate,
}: UseExecutionEventsOptions) {
const api = useBackendAPI();
const onExecutionUpdateRef = useRef(onExecutionUpdate);
useEffect(() => {
onExecutionUpdateRef.current = onExecutionUpdate;
}, [onExecutionUpdate]);
useEffect(() => {
if (!enabled) return;
const idsToSubscribe = graphIds || (graphId ? [graphId] : []);
if (idsToSubscribe.length === 0) return;
// Normalize IDs to strings for consistent comparison
const normalizedIds = idsToSubscribe.map((id) => String(id));
const subscribedIds = new Set<string>();
const handleExecutionEvent = (execution: GraphExecution) => {
// Filter by graphIds if provided, using normalized string comparison
if (normalizedIds.length > 0) {
const executionGraphId = String(execution.graph_id);
if (!normalizedIds.includes(executionGraphId)) return;
}
onExecutionUpdateRef.current?.(execution);
};
const connectHandler = api.onWebSocketConnect(() => {
normalizedIds.forEach((id) => {
// Track subscriptions to avoid duplicate subscriptions
if (subscribedIds.has(id)) return;
subscribedIds.add(id);
api
.subscribeToGraphExecutions(id as GraphID)
.then(() => {
console.debug(`Subscribed to execution updates for graph ${id}`);
})
.catch((error) => {
console.error(
`Failed to subscribe to execution updates for graph ${id}:`,
error,
);
Sentry.captureException(error, {
tags: { graphId: id },
});
subscribedIds.delete(id);
});
});
});
const messageHandler = api.onWebSocketMessage(
"graph_execution_event",
handleExecutionEvent,
);
api.connectWebSocket();
return () => {
connectHandler();
messageHandler();
// Note: Backend automatically cleans up subscriptions on websocket disconnect
// If IDs change while connected, old subscriptions remain but are filtered client-side
subscribedIds.clear();
};
}, [api, graphId, graphIds, enabled]);
}