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:
Ubbe
2025-07-29 20:22:23 +04:00
committed by GitHub
parent 55af487589
commit a60abe5cfe
12 changed files with 491 additions and 150 deletions

View File

@@ -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",

View File

@@ -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

View File

@@ -2,6 +2,8 @@
@tailwind components;
@tailwind utilities;
@plugin 'tailwind-scrollbar';
@layer base {
:root {
--background: 0 0% 98%; /* neutral-50#FAFAFA */

View File

@@ -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"

View File

@@ -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>
</>

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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();
});
}

View File

@@ -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;
}
}

View File

@@ -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,
};
}

View File

@@ -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>
);
}

View File

@@ -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,
};
}