feat(frontend): agent activity dropdown (#10416)

## Changes 🏗️


https://github.com/user-attachments/assets/42e1c896-5f3b-447c-aee9-4f5963c217d9

There is now a 🔔 icon on the Navigation bar that shows previous agent
runs and displays real-time agent running status.

If you run an agent, the bell will show on a badge how many agents are
running. If you hover over it, a hint appears. If you click on it, it
opens a dropdown and displays the executions with their status ( _which
should match what we have in library functionality, not design-wise_ ).

I leveraged the existing APIs for this purpose. Most of the run logic is
[encapsulated on this
hook](https://github.com/Significant-Gravitas/AutoGPT/compare/dev...feat/agent-notifications?expand=1#diff-a9e7f2904d6283b094aca19b64c7168e8c66be1d5e0bb454be8978cb98526617)
and is also an independent `<AgentActivityDropdown />` component.

Clicking on an agent run opens that run in the library page.

This new functionality is covered by E2E tests 💆🏽 ✔️ 

## 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] The navigation bar layout looks good when logged out
  - [x] The navigation bar layout looks good when logged in
  - [x] Open an agent in the library and click `Run`
- [x] See the real-time activity of the agent running on the navigation
bar bell icon

### For configuration changes:

_No configuration changes needed._
This commit is contained in:
Ubbe
2025-07-22 21:29:09 +04:00
committed by GitHub
parent a58613a84c
commit 41363b1cbe
27 changed files with 1112 additions and 100 deletions

View File

@@ -38,3 +38,7 @@ NEXT_PUBLIC_TURNSTILE=disabled
# Devtools
NEXT_PUBLIC_REACT_QUERY_DEVTOOL=true
# In case you are running Playwright locally
# NEXT_PUBLIC_PW_TEST=true

View File

@@ -207,6 +207,47 @@ The Orval configuration is located in `autogpt_platform/frontend/orval.config.ts
For more details, see the [Orval documentation](https://orval.dev/) or check the configuration file.
## 🚩 Feature Flags
This project uses [LaunchDarkly](https://launchdarkly.com/) for feature flags, allowing us to control feature rollouts and A/B testing.
### Using Feature Flags
#### Check if a feature is enabled
```typescript
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
function MyComponent() {
const isAgentActivityEnabled = useGetFlag(Flag.AGENT_ACTIVITY);
if (!isAgentActivityEnabled) {
return null; // Hide feature
}
return <div>Feature is enabled!</div>;
}
```
#### Protect entire components
```typescript
import { withFeatureFlag } from "@/services/feature-flags/with-feature-flag";
const MyFeaturePage = withFeatureFlag(MyPageComponent, "my-feature-flag");
```
### Testing with Feature Flags
For local development or running Playwright tests locally, use mocked feature flags by setting `NEXT_PUBLIC_PW_TEST=true` in your `.env` file. This bypasses LaunchDarkly and uses the mock values defined in the code.
### Adding New Flags
1. Add the flag to the `Flag` enum in `use-get-flag.ts`
2. Add the flag type to `FlagValues` type
3. Add mock value to `mockFlags` for testing
4. Configure the flag in LaunchDarkly dashboard
## 🚚 Deploy
TODO

View File

@@ -75,6 +75,7 @@
"moment": "2.30.1",
"next": "15.3.5",
"next-themes": "0.4.6",
"nuqs": "2.4.3",
"party-js": "2.2.0",
"react": "18.3.1",
"react-day-picker": "9.8.0",

View File

@@ -24,23 +24,26 @@ export default defineConfig({
/* use more workers on CI. */
workers: process.env.CI ? 4 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: [["html"], ["line"]],
reporter: [["list"], ["html", { open: "never" }]],
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: "http://localhost:3000/",
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
screenshot: "only-on-failure",
bypassCSP: true,
/* Helps debugging failures */
trace: "retain-on-failure",
video: "retain-on-failure",
},
/* Maximum time one test can run for */
timeout: 25000,
/* Configure web server to start automatically */
webServer: {
command: "NEXT_PUBLIC_PW_TEST=true pnpm start",
command: "pnpm start",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
},

View File

@@ -155,6 +155,9 @@ importers:
next-themes:
specifier: 0.4.6
version: 0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
nuqs:
specifier: 2.4.3
version: 2.4.3(next@15.3.5(@babel/core@7.28.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.54.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
party-js:
specifier: 2.2.0
version: 2.2.0
@@ -5333,6 +5336,9 @@ packages:
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
engines: {node: '>=16 || 14 >=14.17'}
mitt@3.0.1:
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
module-details-from-path@1.0.4:
resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==}
@@ -5463,6 +5469,24 @@ packages:
nth-check@2.1.1:
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
nuqs@2.4.3:
resolution: {integrity: sha512-BgtlYpvRwLYiJuWzxt34q2bXu/AIS66sLU1QePIMr2LWkb+XH0vKXdbLSgn9t6p7QKzwI7f38rX3Wl9llTXQ8Q==}
peerDependencies:
'@remix-run/react': '>=2'
next: '>=14.2.0'
react: '>=18.2.0 || ^19.0.0-0'
react-router: ^6 || ^7
react-router-dom: ^6 || ^7
peerDependenciesMeta:
'@remix-run/react':
optional: true
next:
optional: true
react-router:
optional: true
react-router-dom:
optional: true
oas-kit-common@1.0.8:
resolution: {integrity: sha512-pJTS2+T0oGIwgjGpw7sIRU8RQMcUoKCDWFLdBqKB2BNmGpbBMH2sdqAaOXUg8OzonZHU0L7vfJu1mJFEiYDWOQ==}
@@ -13010,6 +13034,8 @@ snapshots:
minipass@7.1.2: {}
mitt@3.0.1: {}
module-details-from-path@1.0.4: {}
moment@2.30.1: {}
@@ -13172,6 +13198,13 @@ snapshots:
dependencies:
boolbase: 1.0.0
nuqs@2.4.3(next@15.3.5(@babel/core@7.28.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.54.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1):
dependencies:
mitt: 3.0.1
react: 18.3.1
optionalDependencies:
next: 15.3.5(@babel/core@7.28.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.54.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
oas-kit-common@1.0.8:
dependencies:
fast-safe-stringify: 2.1.1

View File

@@ -1,5 +1,6 @@
"use client";
import { useParams, useRouter } from "next/navigation";
import { useQueryState } from "nuqs";
import React, {
useCallback,
useEffect,
@@ -45,6 +46,7 @@ import { useToast } from "@/components/molecules/Toast/use-toast";
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();
@@ -202,6 +204,13 @@ export default function AgentRunsPage(): React.ReactElement {
selectPreset,
]);
useEffect(() => {
if (executionId) {
selectRun(executionId as GraphExecutionID);
setExecutionId(null);
}
}, [executionId, selectRun, setExecutionId]);
// Initial load
useEffect(() => {
refreshPageData();
@@ -468,7 +477,7 @@ export default function AgentRunsPage(): React.ReactElement {
}
return (
<div className="container justify-stretch p-0 lg:flex">
<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

View File

@@ -1,31 +1,35 @@
"use client";
import * as React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import { ThemeProviderProps } from "next-themes";
import { BackendAPIProvider } from "@/lib/autogpt-server-api/context";
import { TooltipProvider } from "@/components/ui/tooltip";
import CredentialsProvider from "@/components/integrations/credentials-provider";
import { LaunchDarklyProvider } from "@/services/feature-flags/feature-flag-provider";
import CredentialsProvider from "@/components/integrations/credentials-provider";
import OnboardingProvider from "@/components/onboarding/onboarding-provider";
import { QueryClientProvider } from "@tanstack/react-query";
import { TooltipProvider } from "@/components/ui/tooltip";
import { BackendAPIProvider } from "@/lib/autogpt-server-api/context";
import { getQueryClient } from "@/lib/react-query/queryClient";
import { QueryClientProvider } from "@tanstack/react-query";
import {
ThemeProvider as NextThemesProvider,
ThemeProviderProps,
} from "next-themes";
import { NuqsAdapter } from "nuqs/adapters/next/app";
export function Providers({ children, ...props }: ThemeProviderProps) {
const queryClient = getQueryClient();
return (
<QueryClientProvider client={queryClient}>
<NextThemesProvider {...props}>
<BackendAPIProvider>
<CredentialsProvider>
<LaunchDarklyProvider>
<OnboardingProvider>
<TooltipProvider>{children}</TooltipProvider>
</OnboardingProvider>
</LaunchDarklyProvider>
</CredentialsProvider>
</BackendAPIProvider>
</NextThemesProvider>
<NuqsAdapter>
<NextThemesProvider {...props}>
<BackendAPIProvider>
<CredentialsProvider>
<LaunchDarklyProvider>
<OnboardingProvider>
<TooltipProvider>{children}</TooltipProvider>
</OnboardingProvider>
</LaunchDarklyProvider>
</CredentialsProvider>
</BackendAPIProvider>
</NextThemesProvider>
</NuqsAdapter>
</QueryClientProvider>
);
}

View File

@@ -59,7 +59,8 @@ const PrimaryActionBar: React.FC<PrimaryActionBarProps> = ({
onClick={onClickRunAgent}
disabled={!onClickRunAgent}
title="Run the agent"
data-id="primary-action-run-agent"
aria-label="Run the agent"
data-testid="primary-action-run-agent"
>
<IconPlay /> Run
</Button>

View File

@@ -180,6 +180,7 @@ export default function AgentRunDetailsView({
</>
),
callback: runAgain,
dataTestId: "run-again-button",
},
]
: []),

View File

@@ -1,8 +1,7 @@
"use client";
import React, { useEffect, useState } from "react";
import { Plus } from "lucide-react";
import React, { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
import {
GraphExecutionID,
GraphExecutionMeta,
@@ -12,14 +11,15 @@ import {
Schedule,
ScheduleID,
} from "@/lib/autogpt-server-api";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/agptui/Button";
import { Badge } from "@/components/ui/badge";
import { agentRunStatusMap } from "@/components/agents/agent-run-status-chip";
import AgentRunSummaryCard from "@/components/agents/agent-run-summary-card";
import { Button } from "../atoms/Button/Button";
interface AgentRunsSelectorListProps {
agent: LibraryAgent;
@@ -72,17 +72,11 @@ export default function AgentRunsSelectorList({
<aside className={cn("flex flex-col gap-4", className)}>
{allowDraftNewRun && (
<Button
size="card"
className={
"mb-4 hidden h-16 w-72 items-center gap-2 py-6 lg:flex xl:w-80 " +
(selectedView.type == "run" && !selectedView.id
? "agpt-card-selected text-accent"
: "")
}
className={"mb-4 hidden lg:flex"}
onClick={onSelectDraftNewRun}
leftIcon={<Plus className="h-6 w-6" />}
>
<Plus className="h-6 w-6" />
<span>New {agent.has_external_trigger ? "trigger" : "run"}</span>
New {agent.has_external_trigger ? "trigger" : "run"}
</Button>
)}
@@ -112,7 +106,7 @@ export default function AgentRunsSelectorList({
{/* New Run button - only in small layouts */}
{allowDraftNewRun && (
<Button
size="card"
size="large"
className={
"flex h-28 w-40 items-center gap-2 py-6 lg:hidden " +
(selectedView.type == "run" && !selectedView.id
@@ -120,9 +114,9 @@ export default function AgentRunsSelectorList({
: "")
}
onClick={onSelectDraftNewRun}
leftIcon={<Plus className="h-6 w-6" />}
>
<Plus className="h-6 w-6" />
<span>New {agent.has_external_trigger ? "trigger" : "run"}</span>
New {agent.has_external_trigger ? "trigger" : "run"}
</Button>
)}

View File

@@ -1,4 +1,4 @@
import { NavbarView } from "./components/NavbarMainPage";
import { NavbarView } from "./components/NavbarView";
import { getNavbarAccountData } from "./data";
export async function Navbar() {

View File

@@ -0,0 +1,70 @@
"use client";
import { Text } from "@/components/atoms/Text/Text";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Bell } from "@phosphor-icons/react";
import { useState } from "react";
import { ActivityDropdown } from "./components/ActivityDropdown";
import { formatNotificationCount } from "./helpers";
import { useAgentActivityDropdown } from "./useAgentActivityDropdown";
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
export function AgentActivityDropdown() {
const isAgentActivityEnabled = useGetFlag(Flag.AGENT_ACTIVITY);
const [isOpen, setIsOpen] = useState(false);
const { activeExecutions, recentCompletions, recentFailures } =
useAgentActivityDropdown();
if (!isAgentActivityEnabled) {
return null;
}
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<button
className={`group relative h-[2.5rem] w-[2.5rem] rounded-full p-2 transition-colors hover:bg-white ${isOpen ? "bg-white" : ""}`}
data-testid="agent-activity-button"
aria-label="View Agent Activity"
>
<Bell size={22} className="text-black" />
{activeExecutions.length > 0 && (
<>
{/* Running Agents Rotating Badge */}
<div
data-testid="agent-activity-badge"
className="absolute right-[1px] top-[0.5px] flex h-5 w-5 items-center justify-center rounded-full bg-purple-600 text-[10px] font-medium text-white"
>
{formatNotificationCount(activeExecutions.length)}
<div className="absolute -inset-0.5 animate-spin rounded-full border-[3px] border-transparent border-r-purple-200 border-t-purple-200" />
</div>
{/* Running Agent Hover Hint */}
<div
data-testid="agent-activity-hover-hint"
className="absolute bottom-[-2.5rem] left-1/2 z-50 hidden -translate-x-1/2 transform whitespace-nowrap rounded-small bg-white px-4 py-2 shadow-md group-hover:block"
>
<Text variant="body-medium">
{activeExecutions.length} running agent
{activeExecutions.length > 1 ? "s" : ""}
</Text>
</div>
</>
)}
</button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="center" sideOffset={8}>
<ActivityDropdown
activeExecutions={activeExecutions}
recentCompletions={recentCompletions}
recentFailures={recentFailures}
/>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,92 @@
"use client";
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
import { Text } from "@/components/atoms/Text/Text";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Bell } from "@phosphor-icons/react";
import { AgentExecutionWithInfo, EXECUTION_DISPLAY_LIMIT } from "../helpers";
import { ActivityItem } from "./ActivityItem";
interface Props {
activeExecutions: AgentExecutionWithInfo[];
recentCompletions: AgentExecutionWithInfo[];
recentFailures: AgentExecutionWithInfo[];
}
export function ActivityDropdown({
activeExecutions,
recentCompletions,
recentFailures,
}: Props) {
// Combine and sort all executions - running/queued at top, then by most recent
function getSortedExecutions() {
const allExecutions = [
...activeExecutions.map((e) => ({ ...e, type: "running" as const })),
...recentCompletions.map((e) => ({ ...e, type: "completed" as const })),
...recentFailures.map((e) => ({ ...e, type: "failed" as const })),
];
return allExecutions
.sort((a, b) => {
// Running/queued always at top
const aIsActive =
a.status === AgentExecutionStatus.RUNNING ||
a.status === AgentExecutionStatus.QUEUED;
const bIsActive =
b.status === AgentExecutionStatus.RUNNING ||
b.status === AgentExecutionStatus.QUEUED;
if (aIsActive && !bIsActive) return -1;
if (!aIsActive && bIsActive) return 1;
// Within same category, sort by most recent
const aTime = aIsActive ? a.started_at : a.ended_at;
const bTime = bIsActive ? b.started_at : b.ended_at;
if (!aTime || !bTime) return 0;
return new Date(bTime).getTime() - new Date(aTime).getTime();
})
.slice(0, EXECUTION_DISPLAY_LIMIT);
}
const sortedExecutions = getSortedExecutions();
return (
<div>
{/* Header */}
<div className="sticky top-0 z-10 px-4 pb-1 pt-4">
<Text variant="large-semibold" className="!text-black">
Agent Activity
</Text>
</div>
{/* Content */}
<ScrollArea
className="min-h-[10rem]"
data-testid="agent-activity-dropdown"
>
{sortedExecutions.length > 0 ? (
<div className="p-2">
{sortedExecutions.map((execution) => (
<ActivityItem key={execution.id} execution={execution} />
))}
</div>
) : (
<div className="flex h-full flex-col items-center justify-center gap-5 pb-8 pt-6">
<div className="mx-auto inline-flex flex-col items-center justify-center rounded-full bg-lightGrey p-6">
<Bell className="h-6 w-6 text-zinc-300" />
</div>
<div className="flex flex-col items-center justify-center">
<Text variant="body-medium" className="!text-black">
Nothing to show yet
</Text>
<Text variant="body" className="!text-zinc-500">
Start an agent to get updates
</Text>
</div>
</div>
)}
</ScrollArea>
</div>
);
}

View File

@@ -0,0 +1,113 @@
"use client";
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
import { Text } from "@/components/atoms/Text/Text";
import {
CheckCircle,
CircleNotchIcon,
Clock,
WarningOctagonIcon,
StopCircle,
CircleDashed,
} from "@phosphor-icons/react";
import { useRouter } from "next/navigation";
import type { AgentExecutionWithInfo } from "../helpers";
import { formatTimeAgo, getExecutionDuration } from "../helpers";
interface Props {
execution: AgentExecutionWithInfo;
}
export function ActivityItem({ execution }: Props) {
const router = useRouter();
function getStatusIcon() {
switch (execution.status) {
case AgentExecutionStatus.QUEUED:
return <Clock size={18} className="text-purple-500" />;
case AgentExecutionStatus.RUNNING:
return (
<CircleNotchIcon
size={18}
className="animate-spin text-purple-500"
weight="bold"
/>
);
case AgentExecutionStatus.COMPLETED:
return (
<CheckCircle size={18} weight="fill" className="text-purple-500" />
);
case AgentExecutionStatus.FAILED:
return <WarningOctagonIcon size={18} className="text-purple-500" />;
case AgentExecutionStatus.TERMINATED:
return (
<StopCircle size={18} className="text-purple-500" weight="fill" />
);
case AgentExecutionStatus.INCOMPLETE:
return <CircleDashed size={18} className="text-purple-500" />;
default:
return null;
}
}
function getTimeDisplay() {
const isActiveStatus =
execution.status === AgentExecutionStatus.RUNNING ||
execution.status === AgentExecutionStatus.QUEUED;
if (isActiveStatus) {
const timeAgo = formatTimeAgo(execution.started_at.toString());
const statusText =
execution.status === AgentExecutionStatus.QUEUED ? "queued" : "running";
return `Started ${timeAgo}, ${getExecutionDuration(execution)} ${statusText}`;
}
if (execution.ended_at) {
const timeAgo = formatTimeAgo(execution.ended_at.toString());
switch (execution.status) {
case AgentExecutionStatus.COMPLETED:
return `Completed ${timeAgo}`;
case AgentExecutionStatus.FAILED:
return `Failed ${timeAgo}`;
case AgentExecutionStatus.TERMINATED:
return `Stopped ${timeAgo}`;
case AgentExecutionStatus.INCOMPLETE:
return `Incomplete ${timeAgo}`;
default:
return `Ended ${timeAgo}`;
}
}
return "Unknown";
}
return (
<div
className="cursor-pointer border-b border-slate-50 px-2 py-3 transition-colors last:border-b-0 hover:bg-lightGrey"
onClick={() => {
const agentId = execution.library_agent_id || execution.graph_id;
router.push(`/library/agents/${agentId}?executionId=${execution.id}`);
}}
role="button"
>
{/* Icon + Agent Name */}
<div className="flex items-center space-x-2">
{getStatusIcon()}
<Text
variant="body-medium"
className="max-w-[16rem] truncate text-gray-900"
>
{execution.agent_name}
</Text>
</div>
{/* Agent Message - Indented */}
<div className="ml-7 pt-1">
{/* Time - Indented */}
<Text variant="small" className="!text-zinc-500">
{getTimeDisplay()}
</Text>
</div>
</div>
);
}

View File

@@ -0,0 +1,324 @@
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
import { GraphExecutionMeta as GeneratedGraphExecutionMeta } from "@/app/api/__generated__/models/graphExecutionMeta";
import { MyAgent } from "@/app/api/__generated__/models/myAgent";
import type { GraphExecution } from "@/lib/autogpt-server-api/types";
// Time constants
const MILLISECONDS_PER_SECOND = 1000;
const SECONDS_PER_MINUTE = 60;
const MINUTES_PER_HOUR = 60;
const HOURS_PER_DAY = 24;
const MILLISECONDS_PER_MINUTE = SECONDS_PER_MINUTE * MILLISECONDS_PER_SECOND;
const MILLISECONDS_PER_HOUR = MINUTES_PER_HOUR * MILLISECONDS_PER_MINUTE;
const MILLISECONDS_PER_DAY = HOURS_PER_DAY * MILLISECONDS_PER_HOUR;
// Display constants
export const EXECUTION_DISPLAY_LIMIT = 6;
const SHORT_DURATION_THRESHOLD_SECONDS = 5;
export function formatTimeAgo(dateStr: string): string {
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / MILLISECONDS_PER_MINUTE);
if (diffMins < 1) return "just now";
if (diffMins < SECONDS_PER_MINUTE) return `${diffMins}m ago`;
const diffHours = Math.floor(diffMins / MINUTES_PER_HOUR);
if (diffHours < HOURS_PER_DAY) return `${diffHours}h ago`;
const diffDays = Math.floor(diffHours / HOURS_PER_DAY);
return `${diffDays}d ago`;
}
export function getExecutionDuration(
execution: GeneratedGraphExecutionMeta,
): string {
if (!execution.started_at) return "Unknown";
const start = new Date(execution.started_at);
const end = execution.ended_at ? new Date(execution.ended_at) : new Date();
// Check if dates are valid
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
return "Unknown";
}
const durationMs = end.getTime() - start.getTime();
// Handle negative durations (shouldn't happen but just in case)
if (durationMs < 0) return "Unknown";
const durationSec = Math.floor(durationMs / MILLISECONDS_PER_SECOND);
// For short durations (< 5 seconds), show "a few seconds"
if (durationSec < SHORT_DURATION_THRESHOLD_SECONDS) {
return "a few seconds";
}
if (durationSec < SECONDS_PER_MINUTE) return `${durationSec}s`;
const durationMin = Math.floor(durationSec / SECONDS_PER_MINUTE);
if (durationMin < MINUTES_PER_HOUR)
return `${durationMin}m ${durationSec % SECONDS_PER_MINUTE}s`;
const durationHr = Math.floor(durationMin / MINUTES_PER_HOUR);
return `${durationHr}h ${durationMin % MINUTES_PER_HOUR}m`;
}
export function formatNotificationCount(count: number): string {
if (count > 99) return "+99";
return count.toString();
}
export interface AgentExecutionWithInfo extends GeneratedGraphExecutionMeta {
agent_name: string;
agent_description: string;
library_agent_id?: string;
}
export interface NotificationState {
activeExecutions: AgentExecutionWithInfo[];
recentCompletions: AgentExecutionWithInfo[];
recentFailures: AgentExecutionWithInfo[];
totalCount: number;
}
export function createAgentInfoMap(
agents: MyAgent[],
): Map<
string,
{ name: string; description: string; library_agent_id?: string }
> {
const agentMap = new Map<
string,
{ name: string; description: string; library_agent_id?: string }
>();
agents.forEach((agent) => {
agentMap.set(agent.agent_id, {
name: agent.agent_name,
description: agent.description,
library_agent_id: undefined, // MyAgent doesn't have library_agent_id
});
});
return agentMap;
}
export function convertLegacyExecutionToGenerated(
execution: GraphExecution,
): GeneratedGraphExecutionMeta {
return {
id: execution.id,
user_id: execution.user_id,
graph_id: execution.graph_id,
graph_version: execution.graph_version,
preset_id: execution.preset_id,
status: execution.status as AgentExecutionStatus,
started_at: execution.started_at.toISOString(),
ended_at: execution.ended_at.toISOString(),
stats: execution.stats || {
cost: 0,
duration: 0,
duration_cpu_only: 0,
node_exec_time: 0,
node_exec_time_cpu_only: 0,
node_exec_count: 0,
},
};
}
export function enrichExecutionWithAgentInfo(
execution: GeneratedGraphExecutionMeta,
agentInfoMap: Map<
string,
{ name: string; description: string; library_agent_id?: string }
>,
): AgentExecutionWithInfo {
const agentInfo = agentInfoMap.get(execution.graph_id);
return {
...execution,
agent_name: agentInfo?.name || `Graph ${execution.graph_id.slice(0, 8)}...`,
agent_description: agentInfo?.description ?? "",
library_agent_id: agentInfo?.library_agent_id,
};
}
export function isActiveExecution(
execution: GeneratedGraphExecutionMeta,
): boolean {
const status = execution.status;
return (
status === AgentExecutionStatus.RUNNING ||
status === AgentExecutionStatus.QUEUED
);
}
export function isRecentCompletion(
execution: GeneratedGraphExecutionMeta,
thirtyMinutesAgo: Date,
): boolean {
const status = execution.status;
return (
status === AgentExecutionStatus.COMPLETED &&
!!execution.ended_at &&
new Date(execution.ended_at) > thirtyMinutesAgo
);
}
export function isRecentFailure(
execution: GeneratedGraphExecutionMeta,
thirtyMinutesAgo: Date,
): boolean {
const status = execution.status;
return (
(status === AgentExecutionStatus.FAILED ||
status === AgentExecutionStatus.TERMINATED) &&
!!execution.ended_at &&
new Date(execution.ended_at) > thirtyMinutesAgo
);
}
export function isRecentNotification(
execution: AgentExecutionWithInfo,
thirtyMinutesAgo: Date,
): boolean {
return execution.ended_at
? new Date(execution.ended_at) > thirtyMinutesAgo
: false;
}
export function categorizeExecutions(
executions: GeneratedGraphExecutionMeta[],
agentInfoMap: Map<
string,
{ name: string; description: string; library_agent_id?: string }
>,
): NotificationState {
const twentyFourHoursAgo = new Date(Date.now() - MILLISECONDS_PER_DAY);
const enrichedExecutions = executions.map((execution) =>
enrichExecutionWithAgentInfo(execution, agentInfoMap),
);
const activeExecutions = enrichedExecutions
.filter(isActiveExecution)
.slice(0, EXECUTION_DISPLAY_LIMIT);
const recentCompletions = enrichedExecutions
.filter((execution) => isRecentCompletion(execution, twentyFourHoursAgo))
.slice(0, EXECUTION_DISPLAY_LIMIT);
const recentFailures = enrichedExecutions
.filter((execution) => isRecentFailure(execution, twentyFourHoursAgo))
.slice(0, EXECUTION_DISPLAY_LIMIT);
return {
activeExecutions,
recentCompletions,
recentFailures,
totalCount:
activeExecutions.length +
recentCompletions.length +
recentFailures.length,
};
}
export function removeExecutionFromAllCategories(
state: NotificationState,
executionId: string,
): NotificationState {
return {
activeExecutions: state.activeExecutions.filter(
(e) => e.id !== executionId,
),
recentCompletions: state.recentCompletions.filter(
(e) => e.id !== executionId,
),
recentFailures: state.recentFailures.filter((e) => e.id !== executionId),
totalCount: state.totalCount, // Will be recalculated later
};
}
export function addExecutionToCategory(
state: NotificationState,
execution: AgentExecutionWithInfo,
): NotificationState {
const twentyFourHoursAgo = new Date(Date.now() - MILLISECONDS_PER_DAY);
const newState = { ...state };
if (isActiveExecution(execution)) {
newState.activeExecutions = [execution, ...newState.activeExecutions].slice(
0,
EXECUTION_DISPLAY_LIMIT,
);
} else if (isRecentCompletion(execution, twentyFourHoursAgo)) {
newState.recentCompletions = [
execution,
...newState.recentCompletions,
].slice(0, EXECUTION_DISPLAY_LIMIT);
} else if (isRecentFailure(execution, twentyFourHoursAgo)) {
newState.recentFailures = [execution, ...newState.recentFailures].slice(
0,
EXECUTION_DISPLAY_LIMIT,
);
}
return newState;
}
export function cleanupOldNotifications(
state: NotificationState,
): NotificationState {
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
return {
...state,
recentCompletions: state.recentCompletions.filter((e) =>
isRecentNotification(e, twentyFourHoursAgo),
),
recentFailures: state.recentFailures.filter((e) =>
isRecentNotification(e, twentyFourHoursAgo),
),
};
}
export function calculateTotalCount(
state: NotificationState,
): NotificationState {
return {
...state,
totalCount:
state.activeExecutions.length +
state.recentCompletions.length +
state.recentFailures.length,
};
}
export function handleExecutionUpdate(
currentState: NotificationState,
execution: GraphExecution,
agentInfoMap: Map<
string,
{ name: string; description: string; library_agent_id?: string }
>,
): NotificationState {
// Convert and enrich the execution
const convertedExecution = convertLegacyExecutionToGenerated(execution);
const enrichedExecution = enrichExecutionWithAgentInfo(
convertedExecution,
agentInfoMap,
);
// Remove from all categories first
let newState = removeExecutionFromAllCategories(currentState, execution.id);
// Add to appropriate category
newState = addExecutionToCategory(newState, enrichedExecution);
// Clean up old notifications
newState = cleanupOldNotifications(newState);
// Recalculate total count
newState = calculateTotalCount(newState);
return newState;
}

View File

@@ -0,0 +1,164 @@
import { useGetV1GetAllExecutions } from "@/app/api/__generated__/endpoints/graphs/graphs";
import { useGetV2GetMyAgents } from "@/app/api/__generated__/endpoints/store/store";
import { useGetV2ListLibraryAgents } from "@/app/api/__generated__/endpoints/library/library";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import BackendAPI from "@/lib/autogpt-server-api/client";
import type { GraphExecution } from "@/lib/autogpt-server-api/types";
import { useCallback, useEffect, useState } from "react";
import {
NotificationState,
categorizeExecutions,
createAgentInfoMap,
handleExecutionUpdate,
} from "./helpers";
type AgentInfoMap = Map<
string,
{ name: string; description: string; library_agent_id?: string }
>;
export function useAgentActivityDropdown() {
const [api] = useState(() => new BackendAPI());
const [notifications, setNotifications] = useState<NotificationState>({
activeExecutions: [],
recentCompletions: [],
recentFailures: [],
totalCount: 0,
});
const [isConnected, setIsConnected] = useState(false);
const [agentInfoMap, setAgentInfoMap] = useState<AgentInfoMap>(new Map());
// Get library agents using generated hook
const {
data: myAgentsResponse,
isLoading: isAgentsLoading,
error: agentsError,
} = useGetV2GetMyAgents({
query: {
enabled: true,
},
});
// Get library agents data to map graph_id to library_agent_id
const {
data: libraryAgentsResponse,
isLoading: isLibraryAgentsLoading,
error: libraryAgentsError,
} = useGetV2ListLibraryAgents(
{},
{
query: {
enabled: true,
},
},
);
// Get all executions using generated hook
const {
data: executionsResponse,
isLoading: isExecutionsLoading,
error: executionsError,
} = useGetV1GetAllExecutions({
query: {
enabled: true,
},
});
// Update agent info map when both agent data sources change
useEffect(() => {
if (
myAgentsResponse?.data?.agents &&
libraryAgentsResponse?.data &&
"agents" in libraryAgentsResponse.data
) {
const agentMap = createAgentInfoMap(myAgentsResponse.data.agents);
// Add library agent ID mapping
libraryAgentsResponse.data.agents.forEach(
(libraryAgent: LibraryAgent) => {
const existingInfo = agentMap.get(libraryAgent.graph_id);
if (existingInfo) {
agentMap.set(libraryAgent.graph_id, {
...existingInfo,
library_agent_id: libraryAgent.id,
});
}
},
);
setAgentInfoMap(agentMap);
}
}, [myAgentsResponse, libraryAgentsResponse]);
// Handle real-time execution updates
const handleExecutionEvent = useCallback(
(execution: GraphExecution) => {
setNotifications((currentState) =>
handleExecutionUpdate(currentState, execution, agentInfoMap),
);
},
[agentInfoMap],
);
// Process initial execution state when data loads
useEffect(() => {
if (
executionsResponse?.data &&
!isExecutionsLoading &&
agentInfoMap.size > 0
) {
const newNotifications = categorizeExecutions(
executionsResponse.data,
agentInfoMap,
);
setNotifications(newNotifications);
}
}, [executionsResponse, isExecutionsLoading, agentInfoMap]);
// Initialize WebSocket connection for real-time updates
useEffect(() => {
const connectHandler = api.onWebSocketConnect(() => {
setIsConnected(true);
// Subscribe to graph executions for all user agents
if (myAgentsResponse?.data?.agents) {
myAgentsResponse.data.agents.forEach((agent) => {
api
.subscribeToGraphExecutions(agent.agent_id as any)
.catch((error) => {
console.error(
`[AgentNotifications] Failed to subscribe to graph ${agent.agent_id}:`,
error,
);
});
});
}
});
const disconnectHandler = api.onWebSocketDisconnect(() => {
setIsConnected(false);
});
const messageHandler = api.onWebSocketMessage(
"graph_execution_event",
handleExecutionEvent,
);
api.connectWebSocket();
return () => {
connectHandler();
disconnectHandler();
messageHandler();
api.disconnectWebSocket();
};
}, [api, handleExecutionEvent, myAgentsResponse]);
return {
...notifications,
isConnected,
isLoading: isAgentsLoading || isExecutionsLoading || isLibraryAgentsLoading,
error: agentsError || executionsError || libraryAgentsError,
};
}

View File

@@ -1,12 +1,13 @@
"use client";
import { IconAutoGPTLogo, IconType } from "@/components/ui/icons";
import Wallet from "../../../agptui/Wallet";
import { AccountMenu } from "../components/AccountMenu/AccountMenu";
import { LoginButton } from "../components/LoginButton";
import { MobileNavBar } from "../components/MobileNavbar/MobileNavBar";
import { NavbarLink } from "../components/NavbarLink";
import { AccountMenu } from "./AccountMenu/AccountMenu";
import { LoginButton } from "./LoginButton";
import { MobileNavBar } from "./MobileNavbar/MobileNavBar";
import { NavbarLink } from "./NavbarLink";
import { accountMenuItems, loggedInLinks, loggedOutLinks } from "../helpers";
import { useGetV2GetUserProfile } from "@/app/api/__generated__/endpoints/store/store";
import { AgentActivityDropdown } from "./AgentActivityDropdown/AgentActivityDropdown";
interface NavbarViewProps {
isLoggedIn: boolean;
@@ -45,6 +46,7 @@ export const NavbarView = ({ isLoggedIn }: NavbarViewProps) => {
<div className="flex flex-1 items-center justify-end gap-4">
{isLoggedIn ? (
<div className="flex items-center gap-4">
<AgentActivityDropdown />
{profile && <Wallet />}
<AccountMenu
userName={profile?.username}

View File

@@ -0,0 +1,28 @@
import { useGetV2GetUserProfile } from "@/app/api/__generated__/endpoints/store/store";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
export function useNavbar() {
const { isLoggedIn, isUserLoading } = useSupabase();
console.log("isLoggedIn", isLoggedIn);
const {
data: profileResponse,
isLoading: isProfileLoading,
error: profileError,
} = useGetV2GetUserProfile({
query: {
enabled: isLoggedIn === true,
},
});
const profile = profileResponse?.data || null;
const isLoading = isUserLoading || (isLoggedIn && isProfileLoading);
return {
isLoggedIn,
profile,
isLoading,
profileError,
};
}

View File

@@ -326,14 +326,17 @@ export const startTutorial = (
id: "press-run",
title: "Press Run",
text: "Start your first flow by pressing the Run button!",
attachTo: { element: '[data-id="primary-action-run-agent"]', on: "top" },
attachTo: {
element: '[data-testid="primary-action-run-agent"]',
on: "top",
},
advanceOn: {
selector: '[data-id="primary-action-run-agent"]',
selector: '[data-testid="primary-action-run-agent"]',
event: "click",
},
buttons: [],
beforeShowPromise: () =>
waitForElement('[data-id="primary-action-run-agent"]'),
waitForElement('[data-testid="primary-action-run-agent"]'),
when: {
hide: () => {
setTimeout(() => {
@@ -503,14 +506,17 @@ export const startTutorial = (
id: "press-run-again",
title: "Press Run Again",
text: "Now, press the Run button again to execute the flow with the new Calculator Block added!",
attachTo: { element: '[data-id="primary-action-run-agent"]', on: "top" },
attachTo: {
element: '[data-testid="primary-action-run-agent"]',
on: "top",
},
advanceOn: {
selector: '[data-id="primary-action-run-agent"]',
selector: '[data-testid="primary-action-run-agent"]',
event: "click",
},
buttons: [],
beforeShowPromise: () =>
waitForElement('[data-id="primary-action-run-agent"]'),
waitForElement('[data-testid="primary-action-run-agent"]'),
when: {
hide: () => {
setTimeout(() => {

View File

@@ -1,7 +1,7 @@
"use client";
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import * as React from "react";
import { cn } from "@/lib/utils";
@@ -23,7 +23,7 @@ const PopoverContent = React.forwardRef<
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border border-neutral-200 bg-white p-4 text-neutral-950 shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-50",
"z-50 w-72 rounded-medium border border-neutral-200 bg-white p-4 text-neutral-950 shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-50",
className,
)}
{...props}
@@ -34,8 +34,8 @@ PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export {
Popover,
PopoverTrigger,
PopoverContent,
PopoverAnchor,
PopoverContent,
PopoverPortal,
PopoverTrigger,
};

View File

@@ -1,9 +1,6 @@
import { type CookieOptions } from "@supabase/ssr";
import { SupabaseClient } from "@supabase/supabase-js";
// Detect if we're in a Playwright test environment
const isTest = process.env.NEXT_PUBLIC_PW_TEST === "true";
export const PROTECTED_PAGES = [
"/monitor",
"/build",
@@ -20,12 +17,6 @@ export const STORAGE_KEYS = {
} as const;
export function getCookieSettings(): Partial<CookieOptions> {
if (isTest)
return {
secure: false,
sameSite: "lax",
};
return {
secure: process.env.NODE_ENV === "production",
sameSite: "lax",

View File

@@ -16,10 +16,6 @@ export function LaunchDarklyProvider({ children }: { children: ReactNode }) {
const context = useMemo(() => {
if (isUserLoading || !user) {
console.debug("[LaunchDarklyProvider] Using anonymous context", {
isUserLoading,
hasUser: !!user,
});
return {
kind: "user" as const,
key: "anonymous",
@@ -27,11 +23,6 @@ export function LaunchDarklyProvider({ children }: { children: ReactNode }) {
};
}
console.debug("[LaunchDarklyProvider] Using authenticated context", {
userId: user.id,
email: user.email,
role: user.role,
});
return {
kind: "user" as const,
key: user.id,
@@ -44,14 +35,6 @@ export function LaunchDarklyProvider({ children }: { children: ReactNode }) {
}, [user, isUserLoading]);
if (!isLaunchDarklyConfigured) {
console.debug(
"[LaunchDarklyProvider] Not configured for this environment",
{
isCloud,
envEnabled,
hasClientId: !!clientId,
},
);
return <>{children}</>;
}

View File

@@ -2,15 +2,27 @@ import { useFlags } from "launchdarkly-react-client-sdk";
export enum Flag {
BETA_BLOCKS = "beta-blocks",
AGENT_ACTIVITY = "agent-activity",
}
export type FlagValues = {
[Flag.BETA_BLOCKS]: string[];
[Flag.AGENT_ACTIVITY]: boolean;
};
export function useGetFlag(flag: Flag) {
const isTest = process.env.NEXT_PUBLIC_PW_TEST === "true";
const mockFlags = {
[Flag.BETA_BLOCKS]: [],
[Flag.AGENT_ACTIVITY]: true,
};
export function useGetFlag<T extends Flag>(flag: T): FlagValues[T] | null {
const currentFlags = useFlags<FlagValues>();
const flagValue = currentFlags[flag];
if (isTest) return mockFlags[flag];
if (!flagValue) return null;
return flagValue;
}

View File

@@ -0,0 +1,105 @@
import test, { expect } from "@playwright/test";
import { hasTextContent, hasUrl, isVisible } from "./utils/assertion";
import { getSelectors } from "./utils/selectors";
import { getTestUser } from "./utils/auth";
import { LoginPage } from "./pages/login.page";
import { BuildPage } from "./pages/build.page";
import * as LibraryPage from "./pages/library.page";
test.beforeEach(async ({ page }) => {
const loginPage = new LoginPage(page);
const buildPage = new BuildPage(page);
const testUser = await getTestUser();
const { getId, getText } = getSelectors(page);
await page.goto("/login");
await loginPage.login(testUser.email, testUser.password);
await hasUrl(page, "/marketplace");
await page.goto("/build");
await buildPage.closeTutorial();
await buildPage.openBlocksPanel();
const [dictionaryBlock] = await buildPage.getFilteredBlocksFromAPI(
(block) => block.name === "AddToDictionaryBlock",
);
const blockCard = getId(`block-name-${dictionaryBlock.id}`);
await blockCard.click();
const blockInEditor = getId(dictionaryBlock.id).first();
expect(blockInEditor).toBeAttached();
await buildPage.saveAgent("Test Agent", "Test Description");
await test
.expect(page)
.toHaveURL(({ searchParams }) => !!searchParams.get("flowID"));
// Wait for save to complete
await page.waitForTimeout(1000);
await page.goto("/library");
await LibraryPage.clickFirstAgent(page);
await LibraryPage.waitForAgentPageLoad(page);
await isVisible(getText("Test Agent"), 8000);
});
test("shows badge with count when agent is running", async ({ page }) => {
const { getId } = getSelectors(page);
// Start the agent run
await LibraryPage.clickRunButton(page);
// Wait for the badge to appear and check it has a valid count
const badge = getId("agent-activity-badge");
await isVisible(badge);
// Check that badge shows a positive number (more flexible than exact count)
await expect(async () => {
const badgeText = await badge.textContent();
const count = parseInt(badgeText || "0");
if (count < 1) {
throw new Error(`Expected badge count >= 1, got: ${badgeText}`);
}
}).toPass({ timeout: 10000 });
});
test("displays the runs on the activity dropdown", async ({ page }) => {
const { getId } = getSelectors(page);
const activityBtn = getId("agent-activity-button");
await isVisible(activityBtn);
// Start the agent run
await LibraryPage.clickRunButton(page);
// Wait for the activity badge to appear (indicating execution started)
const badge = getId("agent-activity-badge");
await isVisible(badge);
// Click to open the dropdown
await activityBtn.click();
const dropdown = getId("agent-activity-dropdown");
await isVisible(dropdown);
// Check that the agent name appears in the dropdown
await hasTextContent(dropdown, "Test Agent");
// Check for execution status - be more flexible with text matching
await expect(async () => {
const dropdownText = await dropdown.textContent();
const hasAgentName = dropdownText?.includes("Test Agent");
const hasExecutionStatus =
dropdownText?.includes("queued") ||
dropdownText?.includes("running") ||
dropdownText?.includes("Started");
if (!hasAgentName || !hasExecutionStatus) {
throw new Error(
`Expected agent name and execution status, got: ${dropdownText}`,
);
}
}).toPass({ timeout: 8000 });
});

View File

@@ -2,6 +2,7 @@ import { expect, Locator, Page } from "@playwright/test";
import { BasePage } from "./base.page";
import { Block as APIBlock } from "../../lib/autogpt-server-api/types";
import { beautifyString } from "../../lib/utils";
import { isVisible } from "../utils/assertion";
export interface Block {
id: string;
@@ -341,13 +342,13 @@ export class BuildPage extends BasePage {
async isRunButtonEnabled(): Promise<boolean> {
console.log(`checking if run button is enabled`);
const runButton = this.page.locator('[data-id="primary-action-run-agent"]');
const runButton = this.page.getByTestId("primary-action-run-agent");
return await runButton.isEnabled();
}
async runAgent(): Promise<void> {
console.log(`clicking run button`);
const runButton = this.page.locator('[data-id="primary-action-run-agent"]');
const runButton = this.page.getByTestId("primary-action-run-agent");
await runButton.click();
}
@@ -433,6 +434,25 @@ export class BuildPage extends BasePage {
async createDummyAgent() {
await this.closeTutorial();
await this.openBlocksPanel();
const dictionaryBlock = await this.getDictionaryBlockDetails();
const searchInput = this.page.locator(
'[data-id="blocks-control-search-input"]',
);
const displayName = this.getDisplayName(dictionaryBlock.name);
await searchInput.clear();
await isVisible(this.page.getByText("Output"));
await searchInput.fill(displayName);
const blockCard = this.page.getByTestId(`block-name-${dictionaryBlock.id}`);
if (await blockCard.isVisible()) {
await blockCard.click();
const blockInEditor = this.page.getByTestId(dictionaryBlock.id).first();
expect(blockInEditor).toBeAttached();
}
await this.saveAgent("Test Agent", "Test Description");
await expect(this.isRunButtonEnabled()).resolves.toBeTruthy();

View File

@@ -38,9 +38,9 @@ export async function navigateToAgentByName(
}
export async function clickRunButton(page: Page): Promise<void> {
const { getId, getButton } = getSelectors(page);
const { getId } = getSelectors(page);
const runButton = getId("agent-run-button");
const runAgainButton = getButton("Run again");
const runAgainButton = getId("run-again-button");
if (await runButton.isVisible()) {
await runButton.click();
@@ -61,7 +61,7 @@ export async function runAgent(page: Page): Promise<void> {
export async function waitForAgentPageLoad(page: Page): Promise<void> {
await page.waitForURL(/.*\/library\/agents\/[^/]+/);
await page.getByTestId("Run actions").isVisible();
await page.getByTestId("Run actions").isVisible({ timeout: 10000 });
}
export async function getAgentName(page: Page): Promise<string> {