mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
33
autogpt_platform/frontend/pnpm-lock.yaml
generated
33
autogpt_platform/frontend/pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -180,6 +180,7 @@ export default function AgentRunDetailsView({
|
||||
</>
|
||||
),
|
||||
callback: runAgain,
|
||||
dataTestId: "run-again-button",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NavbarView } from "./components/NavbarMainPage";
|
||||
import { NavbarView } from "./components/NavbarView";
|
||||
import { getNavbarAccountData } from "./data";
|
||||
|
||||
export async function Navbar() {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}</>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
105
autogpt_platform/frontend/src/tests/agent-activity.spec.ts
Normal file
105
autogpt_platform/frontend/src/tests/agent-activity.spec.ts
Normal 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 });
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user