mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
feat(frontend): agent activity improvements (#10462)
## Changes 🏗️ There is a bug where the agent activity dropdown bubble only shows up to `6` even if there are `50 running agents`. We only display the last 6 runs in the dropdown, but the bubble badge count should show the correct all running agents. On top of that we added the option to search runs by agent name when you have more than 6 recent runs: https://github.com/user-attachments/assets/931e3db7-5715-48d1-b4df-22490fae9de0 - Also make the dropdown items a link ( `a` ) so that you can command click them to open runs in new tabs. - Keep up to `400` executions on the state ( worse case load test ) - Each execution object is relatively small (ID, status, timestamps, agent info) - 400 objects × ~`1KB` each = negligible memory footprint `400kb` - Always display running agents at the top - Only display runs from the last week on the dropdown - the agent library page contains the historical runs, this is just to show the recent ones ### Code changes - **Added count tracking** - the `NotificationState` interface now includes separate count fields (`activeCount`, `recentCompletionsCount`, `recentFailuresCount`) to track the actual numbers independent of display limits. - **Dual array system:** - the `categorizeExecutions` function now creates: - unlimited arrays for counting all executions - limited arrays (sliced to 6 items) for dropdown display - Updated all helper functions to properly maintain both the display arrays and the count fields. - Component uses actual counts - `<AgentActivityDropdown />` component now uses `activeCount` for the badge and hover hint instead of `activeExecutions.length` ## 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] Login and navigate to library or build - [x] Start running agents like there is no tomorrow - [x] The badge shows the correct agent execution count ( .i.e 10 ) - [x] The dropdown only displays the 6 most recent - [x] You can command click on the runs and they open in new tabs ### For configuration changes None
This commit is contained in:
@@ -86,6 +86,7 @@
|
||||
"react-markdown": "9.0.3",
|
||||
"react-modal": "3.16.3",
|
||||
"react-shepherd": "6.1.9",
|
||||
"react-window": "1.8.11",
|
||||
"recharts": "2.15.3",
|
||||
"shepherd.js": "14.5.1",
|
||||
"sonner": "2.0.6",
|
||||
@@ -112,6 +113,7 @@
|
||||
"@types/react": "18.3.17",
|
||||
"@types/react-dom": "18.3.5",
|
||||
"@types/react-modal": "3.16.3",
|
||||
"@types/react-window": "1.8.8",
|
||||
"axe-playwright": "2.1.0",
|
||||
"chromatic": "13.1.2",
|
||||
"concurrently": "9.2.0",
|
||||
|
||||
36
autogpt_platform/frontend/pnpm-lock.yaml
generated
36
autogpt_platform/frontend/pnpm-lock.yaml
generated
@@ -188,6 +188,9 @@ importers:
|
||||
react-shepherd:
|
||||
specifier: 6.1.9
|
||||
version: 6.1.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3)
|
||||
react-window:
|
||||
specifier: 1.8.11
|
||||
version: 1.8.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
recharts:
|
||||
specifier: 2.15.3
|
||||
version: 2.15.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
@@ -261,6 +264,9 @@ importers:
|
||||
'@types/react-modal':
|
||||
specifier: 3.16.3
|
||||
version: 3.16.3
|
||||
'@types/react-window':
|
||||
specifier: 1.8.8
|
||||
version: 1.8.8
|
||||
axe-playwright:
|
||||
specifier: 2.1.0
|
||||
version: 2.1.0(playwright@1.54.1)
|
||||
@@ -2956,6 +2962,9 @@ packages:
|
||||
'@types/react-modal@3.16.3':
|
||||
resolution: {integrity: sha512-xXuGavyEGaFQDgBv4UVm8/ZsG+qxeQ7f77yNrW3n+1J6XAstUy5rYHeIHPh1KzsGc6IkCIdu6lQ2xWzu1jBTLg==}
|
||||
|
||||
'@types/react-window@1.8.8':
|
||||
resolution: {integrity: sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==}
|
||||
|
||||
'@types/react@18.3.17':
|
||||
resolution: {integrity: sha512-opAQ5no6LqJNo9TqnxBKsgnkIYHozW9KSTlFVoSUJYh1Fl/sswkEoqIugRSm7tbh6pABtYjGAjW+GOS23j8qbw==}
|
||||
|
||||
@@ -5238,6 +5247,9 @@ packages:
|
||||
resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==}
|
||||
engines: {node: '>= 4.0.0'}
|
||||
|
||||
memoize-one@5.2.1:
|
||||
resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==}
|
||||
|
||||
merge-stream@2.0.0:
|
||||
resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
|
||||
|
||||
@@ -6125,6 +6137,13 @@ packages:
|
||||
react: '>=16.6.0'
|
||||
react-dom: '>=16.6.0'
|
||||
|
||||
react-window@1.8.11:
|
||||
resolution: {integrity: sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==}
|
||||
engines: {node: '>8.0.0'}
|
||||
peerDependencies:
|
||||
react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
react@18.3.1:
|
||||
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -10272,6 +10291,10 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/react': 18.3.17
|
||||
|
||||
'@types/react-window@1.8.8':
|
||||
dependencies:
|
||||
'@types/react': 18.3.17
|
||||
|
||||
'@types/react@18.3.17':
|
||||
dependencies:
|
||||
'@types/prop-types': 15.7.15
|
||||
@@ -11468,7 +11491,7 @@ snapshots:
|
||||
|
||||
dom-helpers@5.2.1:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.27.6
|
||||
'@babel/runtime': 7.28.2
|
||||
csstype: 3.1.3
|
||||
|
||||
dom-serializer@1.4.1:
|
||||
@@ -12905,6 +12928,8 @@ snapshots:
|
||||
dependencies:
|
||||
fs-monkey: 1.1.0
|
||||
|
||||
memoize-one@5.2.1: {}
|
||||
|
||||
merge-stream@2.0.0: {}
|
||||
|
||||
merge2@1.4.1: {}
|
||||
@@ -13859,13 +13884,20 @@ snapshots:
|
||||
|
||||
react-transition-group@4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.27.6
|
||||
'@babel/runtime': 7.28.2
|
||||
dom-helpers: 5.2.1
|
||||
loose-envify: 1.4.0
|
||||
prop-types: 15.8.1
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
react-window@1.8.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.2
|
||||
memoize-one: 5.2.1
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
react@18.3.1:
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@plugin 'tailwind-scrollbar';
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 98%; /* neutral-50#FAFAFA */
|
||||
|
||||
@@ -5,13 +5,15 @@ import { ReactNode, useState } from "react";
|
||||
import { Text } from "../Text/Text";
|
||||
import { useInput } from "./useInput";
|
||||
|
||||
export interface TextFieldProps extends InputProps {
|
||||
export interface TextFieldProps extends Omit<InputProps, "size"> {
|
||||
label: string;
|
||||
id: string;
|
||||
hideLabel?: boolean;
|
||||
decimalCount?: number; // Only used for type="amount"
|
||||
error?: string;
|
||||
hint?: ReactNode;
|
||||
size?: "small" | "medium";
|
||||
wrapperClassName?: string;
|
||||
}
|
||||
|
||||
export function Input({
|
||||
@@ -22,6 +24,8 @@ export function Input({
|
||||
decimalCount,
|
||||
hint,
|
||||
error,
|
||||
size = "medium",
|
||||
wrapperClassName,
|
||||
...props
|
||||
}: TextFieldProps) {
|
||||
const { handleInputChange } = useInput({ ...props, decimalCount });
|
||||
@@ -43,11 +47,11 @@ export function Input({
|
||||
}
|
||||
|
||||
const input = (
|
||||
<div className="relative">
|
||||
<div className={cn("relative", wrapperClassName)}>
|
||||
<BaseInput
|
||||
className={cn(
|
||||
// Override the default input styles with Figma design
|
||||
"h-[2.875rem] rounded-3xl border border-zinc-200 bg-white px-4 py-2.5 shadow-none",
|
||||
// Base styles
|
||||
"rounded-3xl border border-zinc-200 bg-white px-4 shadow-none",
|
||||
"font-normal text-black",
|
||||
"placeholder:font-normal placeholder:text-zinc-400",
|
||||
// Focus and hover states
|
||||
@@ -57,6 +61,17 @@ export function Input({
|
||||
"border-1.5 border-red-500 focus:border-red-500 focus:ring-red-500",
|
||||
// Add padding for password toggle button
|
||||
isPasswordType && "pr-12",
|
||||
// Size variants
|
||||
size === "small" && [
|
||||
"h-[2.25rem]", // 36px
|
||||
"py-2",
|
||||
"text-sm leading-[22px]", // 14px font, 22px line height
|
||||
"placeholder:text-sm placeholder:leading-[22px]",
|
||||
],
|
||||
size === "medium" && [
|
||||
"h-[2.875rem]", // 46px (current default)
|
||||
"py-2.5",
|
||||
],
|
||||
className,
|
||||
)}
|
||||
placeholder={placeholder || label}
|
||||
@@ -81,7 +96,7 @@ export function Input({
|
||||
);
|
||||
|
||||
const inputWithError = (
|
||||
<div className="relative mb-6">
|
||||
<div className={cn("relative mb-6", wrapperClassName)}>
|
||||
{input}
|
||||
<Text
|
||||
variant="small-medium"
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "@/components/ui/popover";
|
||||
import { Bell } from "@phosphor-icons/react";
|
||||
import { useState } from "react";
|
||||
import { ActivityDropdown } from "./components/ActivityDropdown";
|
||||
import { ActivityDropdown } from "./components/ActivityDropdown/ActivityDropdown";
|
||||
import { formatNotificationCount } from "./helpers";
|
||||
import { useAgentActivityDropdown } from "./useAgentActivityDropdown";
|
||||
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||
@@ -19,6 +19,8 @@ export function AgentActivityDropdown() {
|
||||
const { activeExecutions, recentCompletions, recentFailures } =
|
||||
useAgentActivityDropdown();
|
||||
|
||||
const activeCount = activeExecutions.length;
|
||||
|
||||
if (!isAgentActivityEnabled) {
|
||||
return null;
|
||||
}
|
||||
@@ -33,14 +35,14 @@ export function AgentActivityDropdown() {
|
||||
>
|
||||
<Bell size={22} className="text-black" />
|
||||
|
||||
{activeExecutions.length > 0 && (
|
||||
{activeCount > 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)}
|
||||
{formatNotificationCount(activeCount)}
|
||||
<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 */}
|
||||
@@ -49,8 +51,8 @@ export function AgentActivityDropdown() {
|
||||
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" : ""}
|
||||
{activeCount} running agent
|
||||
{activeCount > 1 ? "s" : ""}
|
||||
</Text>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
"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,162 @@
|
||||
"use client";
|
||||
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { Input } from "@/components/atoms/Input/Input";
|
||||
import { Bell, MagnifyingGlass, X } from "@phosphor-icons/react";
|
||||
import { FixedSizeList as List } from "react-window";
|
||||
import { AgentExecutionWithInfo } from "../../helpers";
|
||||
import { ActivityItem } from "../ActivityItem";
|
||||
import {
|
||||
EXECUTION_DISPLAY_WITH_SEARCH,
|
||||
useActivityDropdown,
|
||||
} from "./useActivityDropdown";
|
||||
import styles from "./styles.module.css";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
|
||||
interface Props {
|
||||
activeExecutions: AgentExecutionWithInfo[];
|
||||
recentCompletions: AgentExecutionWithInfo[];
|
||||
recentFailures: AgentExecutionWithInfo[];
|
||||
}
|
||||
|
||||
interface VirtualizedItemProps {
|
||||
index: number;
|
||||
style: React.CSSProperties;
|
||||
data: AgentExecutionWithInfo[];
|
||||
}
|
||||
|
||||
function VirtualizedActivityItem({ index, style, data }: VirtualizedItemProps) {
|
||||
const execution = data[index];
|
||||
return (
|
||||
<div style={style}>
|
||||
<ActivityItem execution={execution} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ActivityDropdown({
|
||||
activeExecutions,
|
||||
recentCompletions,
|
||||
recentFailures,
|
||||
}: Props) {
|
||||
const {
|
||||
isSearchVisible,
|
||||
searchQuery,
|
||||
filteredExecutions,
|
||||
toggleSearch,
|
||||
totalExecutions,
|
||||
handleSearchChange,
|
||||
handleClearSearch,
|
||||
} = useActivityDropdown({
|
||||
activeExecutions,
|
||||
recentCompletions,
|
||||
recentFailures,
|
||||
});
|
||||
|
||||
// Static height for the virtualised list (react-window)
|
||||
const itemHeight = 72; // Height of each ActivityItem in pixels
|
||||
const maxHeight = 400; // Maximum height of the dropdown
|
||||
|
||||
const listHeight = Math.min(
|
||||
maxHeight,
|
||||
filteredExecutions.length * itemHeight,
|
||||
);
|
||||
|
||||
const withSearch = totalExecutions > EXECUTION_DISPLAY_WITH_SEARCH;
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 z-10 px-4 pb-1 pt-0">
|
||||
<div className="flex h-[60px] items-center justify-between">
|
||||
{isSearchVisible && withSearch ? (
|
||||
<div
|
||||
className={`${styles.searchContainer} ${
|
||||
isSearchVisible ? styles.searchEnter : styles.searchExit
|
||||
}`}
|
||||
>
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
id="agent-search"
|
||||
label="Search agents"
|
||||
placeholder="Search runs by agent name..."
|
||||
hideLabel
|
||||
size="small"
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
className="!focus:border-1 w-full pr-10"
|
||||
wrapperClassName="!mb-0"
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={handleClearSearch}
|
||||
className="absolute right-1 top-1/2 flex h-6 w-6 -translate-y-1/2 items-center justify-center"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X size={16} className="text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.headerContainer}>
|
||||
<Text variant="large-semibold" className="!text-black">
|
||||
Agent Activity
|
||||
</Text>
|
||||
{withSearch ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={toggleSearch}
|
||||
aria-label="Search agents"
|
||||
className="relative left-3 hover:border-transparent hover:bg-transparent"
|
||||
>
|
||||
<MagnifyingGlass
|
||||
size={16}
|
||||
className="h-4 w-4 text-gray-600"
|
||||
/>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
className={styles.scrollContainer}
|
||||
data-testid="agent-activity-dropdown"
|
||||
>
|
||||
{filteredExecutions.length > 0 ? (
|
||||
<List
|
||||
height={listHeight}
|
||||
width={300} // Match dropdown width (w-80 = 20rem = 320px)
|
||||
itemCount={filteredExecutions.length}
|
||||
itemSize={itemHeight}
|
||||
itemData={filteredExecutions}
|
||||
>
|
||||
{VirtualizedActivityItem}
|
||||
</List>
|
||||
) : (
|
||||
<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">
|
||||
{searchQuery
|
||||
? "No matching agents found"
|
||||
: "Nothing to show yet"}
|
||||
</Text>
|
||||
<Text variant="body" className="!text-zinc-500">
|
||||
{searchQuery
|
||||
? "Try another search term"
|
||||
: "Start an agent to get updates"}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
|
||||
import { AgentExecutionWithInfo } from "../../helpers";
|
||||
|
||||
type Args = {
|
||||
activeExecutions: AgentExecutionWithInfo[];
|
||||
recentCompletions: AgentExecutionWithInfo[];
|
||||
recentFailures: AgentExecutionWithInfo[];
|
||||
};
|
||||
|
||||
export function getSortedExecutions({
|
||||
activeExecutions,
|
||||
recentCompletions,
|
||||
recentFailures,
|
||||
}: Args) {
|
||||
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) => {
|
||||
// Priority order: RUNNING > QUEUED > everything else
|
||||
const aIsRunning = a.status === AgentExecutionStatus.RUNNING;
|
||||
const bIsRunning = b.status === AgentExecutionStatus.RUNNING;
|
||||
const aIsQueued = a.status === AgentExecutionStatus.QUEUED;
|
||||
const bIsQueued = b.status === AgentExecutionStatus.QUEUED;
|
||||
|
||||
// RUNNING agents always at the very top
|
||||
if (aIsRunning && !bIsRunning) return -1;
|
||||
if (!aIsRunning && bIsRunning) return 1;
|
||||
|
||||
// If both are running, sort by most recent start time
|
||||
if (aIsRunning && bIsRunning) {
|
||||
if (!a.started_at || !b.started_at) return 0;
|
||||
return (
|
||||
new Date(b.started_at).getTime() - new Date(a.started_at).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
// QUEUED agents come second
|
||||
if (aIsQueued && !bIsQueued) return -1;
|
||||
if (!aIsQueued && bIsQueued) return 1;
|
||||
|
||||
// If both are queued, sort by most recent start time
|
||||
if (aIsQueued && bIsQueued) {
|
||||
if (!a.started_at || !b.started_at) return 0;
|
||||
return (
|
||||
new Date(b.started_at).getTime() - new Date(a.started_at).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
// For everything else (completed, failed, etc.), sort by most recent end time
|
||||
const aTime = a.ended_at;
|
||||
const bTime = b.ended_at;
|
||||
|
||||
if (!aTime || !bTime) return 0;
|
||||
return new Date(bTime).getTime() - new Date(aTime).getTime();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
.scrollContainer {
|
||||
min-height: 10rem;
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* Target the scrollable div created by react-window */
|
||||
.scrollContainer div[style*="overflow"] {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgb(209, 213, 219) white;
|
||||
}
|
||||
|
||||
/* Webkit scrollbar styles */
|
||||
.scrollContainer div[style*="overflow"]::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.scrollContainer div[style*="overflow"]::-webkit-scrollbar-track {
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.scrollContainer div[style*="overflow"]::-webkit-scrollbar-thumb {
|
||||
background: rgb(209, 213, 219); /* gray-300 */
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.scrollContainer div[style*="overflow"]::-webkit-scrollbar-thumb:hover {
|
||||
background: rgb(156, 163, 175); /* gray-400 */
|
||||
}
|
||||
|
||||
/* Search Animation Styles */
|
||||
.searchContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
will-change: opacity;
|
||||
}
|
||||
|
||||
.searchEnter {
|
||||
animation: searchFadeIn 0.2s ease-in-out forwards;
|
||||
}
|
||||
|
||||
.searchExit {
|
||||
animation: searchFadeOut 0.2s ease-in-out forwards;
|
||||
}
|
||||
|
||||
.headerContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@keyframes searchFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes searchFadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { AgentExecutionWithInfo } from "../../helpers";
|
||||
import { getSortedExecutions } from "./helpers";
|
||||
|
||||
export const EXECUTION_DISPLAY_WITH_SEARCH = 6;
|
||||
|
||||
interface UseActivityDropdownProps {
|
||||
activeExecutions: AgentExecutionWithInfo[];
|
||||
recentCompletions: AgentExecutionWithInfo[];
|
||||
recentFailures: AgentExecutionWithInfo[];
|
||||
}
|
||||
|
||||
export function useActivityDropdown({
|
||||
activeExecutions,
|
||||
recentCompletions,
|
||||
recentFailures,
|
||||
}: UseActivityDropdownProps) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [isSearchVisible, setIsSearchVisible] = useState(false);
|
||||
|
||||
const sortedExecutions = getSortedExecutions({
|
||||
activeExecutions,
|
||||
recentCompletions,
|
||||
recentFailures,
|
||||
});
|
||||
|
||||
// Filter executions based on search query
|
||||
const filteredExecutions = useMemo(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
return sortedExecutions;
|
||||
}
|
||||
|
||||
const query = searchQuery.toLowerCase().trim();
|
||||
return sortedExecutions.filter((execution) =>
|
||||
execution.agent_name.toLowerCase().includes(query),
|
||||
);
|
||||
}, [sortedExecutions, searchQuery]);
|
||||
|
||||
function toggleSearch() {
|
||||
setIsSearchVisible(!isSearchVisible);
|
||||
if (searchQuery) {
|
||||
setSearchQuery("");
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearchChange(value: string) {
|
||||
setSearchQuery(value);
|
||||
}
|
||||
|
||||
function handleClearSearch() {
|
||||
handleSearchChange("");
|
||||
toggleSearch();
|
||||
}
|
||||
|
||||
function handleShowSearch() {
|
||||
setIsSearchVisible(true);
|
||||
}
|
||||
|
||||
return {
|
||||
isSearchVisible,
|
||||
searchQuery,
|
||||
filteredExecutions,
|
||||
totalExecutions: sortedExecutions.length,
|
||||
toggleSearch,
|
||||
handleSearchChange,
|
||||
handleClearSearch,
|
||||
handleShowSearch,
|
||||
};
|
||||
}
|
||||
@@ -10,17 +10,15 @@ import {
|
||||
StopCircle,
|
||||
CircleDashed,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { AgentExecutionWithInfo } from "../helpers";
|
||||
import { formatTimeAgo, getExecutionDuration } from "../helpers";
|
||||
import Link from "next/link";
|
||||
|
||||
interface Props {
|
||||
execution: AgentExecutionWithInfo;
|
||||
}
|
||||
|
||||
export function ActivityItem({ execution }: Props) {
|
||||
const router = useRouter();
|
||||
|
||||
function getStatusIcon() {
|
||||
switch (execution.status) {
|
||||
case AgentExecutionStatus.QUEUED:
|
||||
@@ -81,13 +79,12 @@ export function ActivityItem({ execution }: Props) {
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
const agentId = execution.library_agent_id || execution.graph_id;
|
||||
|
||||
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}`);
|
||||
}}
|
||||
<Link
|
||||
className="block cursor-pointer border-b border-slate-50 px-2 py-3 transition-colors last:border-b-0 hover:bg-lightGrey"
|
||||
href={`/library/agents/${agentId}?executionId=${execution.id}`}
|
||||
role="button"
|
||||
>
|
||||
{/* Icon + Agent Name */}
|
||||
@@ -108,6 +105,6 @@ export function ActivityItem({ execution }: Props) {
|
||||
{getTimeDisplay()}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,14 +8,20 @@ const MILLISECONDS_PER_SECOND = 1000;
|
||||
const SECONDS_PER_MINUTE = 60;
|
||||
const MINUTES_PER_HOUR = 60;
|
||||
const HOURS_PER_DAY = 24;
|
||||
const DAYS_PER_WEEK = 7;
|
||||
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;
|
||||
const MILLISECONDS_PER_WEEK = DAYS_PER_WEEK * MILLISECONDS_PER_DAY;
|
||||
|
||||
// Display constants
|
||||
export const EXECUTION_DISPLAY_LIMIT = 6;
|
||||
const SHORT_DURATION_THRESHOLD_SECONDS = 5;
|
||||
|
||||
// State sanity limits - keep only most recent executions to prevent unbounded growth
|
||||
const MAX_ACTIVE_EXECUTIONS_IN_STATE = 200; // Most important - these are running
|
||||
const MAX_RECENT_COMPLETIONS_IN_STATE = 100;
|
||||
const MAX_RECENT_FAILURES_IN_STATE = 100;
|
||||
|
||||
export function formatTimeAgo(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
@@ -134,9 +140,10 @@ export function enrichExecutionWithAgentInfo(
|
||||
>,
|
||||
): AgentExecutionWithInfo {
|
||||
const agentInfo = agentInfoMap.get(execution.graph_id);
|
||||
|
||||
return {
|
||||
...execution,
|
||||
agent_name: agentInfo?.name || `Graph ${execution.graph_id.slice(0, 8)}...`,
|
||||
agent_name: agentInfo?.name ?? "Unknown Agent",
|
||||
agent_description: agentInfo?.description ?? "",
|
||||
library_agent_id: agentInfo?.library_agent_id,
|
||||
};
|
||||
@@ -154,36 +161,34 @@ export function isActiveExecution(
|
||||
|
||||
export function isRecentCompletion(
|
||||
execution: GeneratedGraphExecutionMeta,
|
||||
thirtyMinutesAgo: Date,
|
||||
oneWeekAgo: Date,
|
||||
): boolean {
|
||||
const status = execution.status;
|
||||
return (
|
||||
status === AgentExecutionStatus.COMPLETED &&
|
||||
!!execution.ended_at &&
|
||||
new Date(execution.ended_at) > thirtyMinutesAgo
|
||||
new Date(execution.ended_at) > oneWeekAgo
|
||||
);
|
||||
}
|
||||
|
||||
export function isRecentFailure(
|
||||
execution: GeneratedGraphExecutionMeta,
|
||||
thirtyMinutesAgo: Date,
|
||||
oneWeekAgo: Date,
|
||||
): boolean {
|
||||
const status = execution.status;
|
||||
return (
|
||||
(status === AgentExecutionStatus.FAILED ||
|
||||
status === AgentExecutionStatus.TERMINATED) &&
|
||||
!!execution.ended_at &&
|
||||
new Date(execution.ended_at) > thirtyMinutesAgo
|
||||
new Date(execution.ended_at) > oneWeekAgo
|
||||
);
|
||||
}
|
||||
|
||||
export function isRecentNotification(
|
||||
execution: AgentExecutionWithInfo,
|
||||
thirtyMinutesAgo: Date,
|
||||
oneWeekAgo: Date,
|
||||
): boolean {
|
||||
return execution.ended_at
|
||||
? new Date(execution.ended_at) > thirtyMinutesAgo
|
||||
: false;
|
||||
return execution.ended_at ? new Date(execution.ended_at) > oneWeekAgo : false;
|
||||
}
|
||||
|
||||
export function categorizeExecutions(
|
||||
@@ -193,23 +198,24 @@ export function categorizeExecutions(
|
||||
{ name: string; description: string; library_agent_id?: string }
|
||||
>,
|
||||
): NotificationState {
|
||||
const twentyFourHoursAgo = new Date(Date.now() - MILLISECONDS_PER_DAY);
|
||||
const oneWeekAgo = new Date(Date.now() - MILLISECONDS_PER_WEEK);
|
||||
|
||||
const enrichedExecutions = executions.map((execution) =>
|
||||
enrichExecutionWithAgentInfo(execution, agentInfoMap),
|
||||
);
|
||||
|
||||
// Filter and limit each category to prevent unbounded state growth
|
||||
const activeExecutions = enrichedExecutions
|
||||
.filter(isActiveExecution)
|
||||
.slice(0, EXECUTION_DISPLAY_LIMIT);
|
||||
.slice(0, MAX_ACTIVE_EXECUTIONS_IN_STATE);
|
||||
|
||||
const recentCompletions = enrichedExecutions
|
||||
.filter((execution) => isRecentCompletion(execution, twentyFourHoursAgo))
|
||||
.slice(0, EXECUTION_DISPLAY_LIMIT);
|
||||
.filter((execution) => isRecentCompletion(execution, oneWeekAgo))
|
||||
.slice(0, MAX_RECENT_COMPLETIONS_IN_STATE);
|
||||
|
||||
const recentFailures = enrichedExecutions
|
||||
.filter((execution) => isRecentFailure(execution, twentyFourHoursAgo))
|
||||
.slice(0, EXECUTION_DISPLAY_LIMIT);
|
||||
.filter((execution) => isRecentFailure(execution, oneWeekAgo))
|
||||
.slice(0, MAX_RECENT_FAILURES_IN_STATE);
|
||||
|
||||
return {
|
||||
activeExecutions,
|
||||
@@ -226,14 +232,20 @@ export function removeExecutionFromAllCategories(
|
||||
state: NotificationState,
|
||||
executionId: string,
|
||||
): NotificationState {
|
||||
const filteredActiveExecutions = state.activeExecutions.filter(
|
||||
(e) => e.id !== executionId,
|
||||
);
|
||||
const filteredRecentCompletions = state.recentCompletions.filter(
|
||||
(e) => e.id !== executionId,
|
||||
);
|
||||
const filteredRecentFailures = state.recentFailures.filter(
|
||||
(e) => e.id !== executionId,
|
||||
);
|
||||
|
||||
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),
|
||||
activeExecutions: filteredActiveExecutions,
|
||||
recentCompletions: filteredRecentCompletions,
|
||||
recentFailures: filteredRecentFailures,
|
||||
totalCount: state.totalCount, // Will be recalculated later
|
||||
};
|
||||
}
|
||||
@@ -242,23 +254,23 @@ export function addExecutionToCategory(
|
||||
state: NotificationState,
|
||||
execution: AgentExecutionWithInfo,
|
||||
): NotificationState {
|
||||
const twentyFourHoursAgo = new Date(Date.now() - MILLISECONDS_PER_DAY);
|
||||
const oneWeekAgo = new Date(Date.now() - MILLISECONDS_PER_WEEK);
|
||||
const newState = { ...state };
|
||||
|
||||
if (isActiveExecution(execution)) {
|
||||
newState.activeExecutions = [execution, ...newState.activeExecutions].slice(
|
||||
0,
|
||||
EXECUTION_DISPLAY_LIMIT,
|
||||
MAX_ACTIVE_EXECUTIONS_IN_STATE,
|
||||
);
|
||||
} else if (isRecentCompletion(execution, twentyFourHoursAgo)) {
|
||||
} else if (isRecentCompletion(execution, oneWeekAgo)) {
|
||||
newState.recentCompletions = [
|
||||
execution,
|
||||
...newState.recentCompletions,
|
||||
].slice(0, EXECUTION_DISPLAY_LIMIT);
|
||||
} else if (isRecentFailure(execution, twentyFourHoursAgo)) {
|
||||
].slice(0, MAX_RECENT_COMPLETIONS_IN_STATE);
|
||||
} else if (isRecentFailure(execution, oneWeekAgo)) {
|
||||
newState.recentFailures = [execution, ...newState.recentFailures].slice(
|
||||
0,
|
||||
EXECUTION_DISPLAY_LIMIT,
|
||||
MAX_RECENT_FAILURES_IN_STATE,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -268,16 +280,19 @@ export function addExecutionToCategory(
|
||||
export function cleanupOldNotifications(
|
||||
state: NotificationState,
|
||||
): NotificationState {
|
||||
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
const oneWeekAgo = new Date(Date.now() - MILLISECONDS_PER_WEEK);
|
||||
|
||||
const filteredRecentCompletions = state.recentCompletions.filter((e) =>
|
||||
isRecentNotification(e, oneWeekAgo),
|
||||
);
|
||||
const filteredRecentFailures = state.recentFailures.filter((e) =>
|
||||
isRecentNotification(e, oneWeekAgo),
|
||||
);
|
||||
|
||||
return {
|
||||
...state,
|
||||
recentCompletions: state.recentCompletions.filter((e) =>
|
||||
isRecentNotification(e, twentyFourHoursAgo),
|
||||
),
|
||||
recentFailures: state.recentFailures.filter((e) =>
|
||||
isRecentNotification(e, twentyFourHoursAgo),
|
||||
),
|
||||
recentCompletions: filteredRecentCompletions,
|
||||
recentFailures: filteredRecentFailures,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user