diff --git a/ui/src/components/SidebarAgents.test.tsx b/ui/src/components/SidebarAgents.test.tsx index 1630a66f128..0e38989b2f6 100644 --- a/ui/src/components/SidebarAgents.test.tsx +++ b/ui/src/components/SidebarAgents.test.tsx @@ -7,6 +7,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { Agent, ResourceMemberships } from "@paperclipai/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { SidebarAgents } from "./SidebarAgents"; +import { queryKeys } from "../lib/queryKeys"; import { TooltipProvider } from "@/components/ui/tooltip"; const mockAgentsApi = vi.hoisted(() => ({ @@ -267,6 +268,7 @@ describe("SidebarAgents", () => { currentRoot.unmount(); }); } + vi.useRealTimers(); queryClient.clear(); container.remove(); document.body.innerHTML = ""; @@ -302,6 +304,23 @@ describe("SidebarAgents", () => { await flushReact(); } + async function renderSidebarAgentsWithFakeTimers() { + const currentRoot = createRoot(container); + root = currentRoot; + + await act(async () => { + currentRoot.render( + + + , + ); + }); + await act(async () => { + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(0); + }); + } + async function renderRailSidebarAgents() { mockSidebarState.collapsed = true; const currentRoot = createRoot(container); @@ -645,6 +664,49 @@ describe("SidebarAgents", () => { expect(seeAllAgentsLink(container)?.getAttribute("href")).toBe("/agents/all"); }); + it("keeps formerly live agents visible for the streamlined linger window", async () => { + vi.useFakeTimers({ now: new Date("2026-01-01T00:00:00Z") }); + mockAgentsApi.list.mockResolvedValue([ + makeAgent({ id: "agent-a", name: "Alpha", urlKey: "alpha" }), + makeAgent({ id: "agent-b", name: "Bravo", urlKey: "bravo" }), + makeAgent({ id: "agent-c", name: "Charlie", urlKey: "charlie" }), + makeAgent({ id: "agent-d", name: "Delta", urlKey: "delta" }), + ]); + mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([ + { id: "run-1", agentId: "agent-a", status: "running" }, + ]); + + await renderSidebarAgentsWithFakeTimers(); + + let labels = agentLinkLabels(container); + expect(labels).toHaveLength(1); + expect(labels[0]).toContain("Alpha"); + expect(labels[0]).toContain("1 live"); + + mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([]); + await act(async () => { + queryClient.setQueryData(queryKeys.liveRuns("company-1"), []); + }); + await act(async () => { + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(0); + }); + + labels = agentLinkLabels(container); + expect(labels).toEqual(["Alpha"]); + expect(labels.join(" ")).not.toContain("live"); + + await act(async () => { + await vi.advanceTimersByTimeAsync(120_000); + }); + expect(agentLinkLabels(container)).toEqual(["Alpha"]); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + }); + expect(agentLinkLabels(container)).toEqual(["Alpha", "Bravo", "Charlie"]); + }); + it("shows up to 3 recently-active agents plus a See all link when none are running", async () => { mockAgentsApi.list.mockResolvedValue( Array.from({ length: 7 }, (_, index) => diff --git a/ui/src/components/SidebarAgents.tsx b/ui/src/components/SidebarAgents.tsx index 7e681da39b5..8df41e7419b 100644 --- a/ui/src/components/SidebarAgents.tsx +++ b/ui/src/components/SidebarAgents.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link, useLocation } from "@/lib/router"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { @@ -63,6 +63,7 @@ import type { Agent } from "@paperclipai/shared"; * recently-active agents plus a "See all agents" link (IA Phase 5). */ const RECENT_AGENT_LIMIT = 3; +const LIVE_AGENT_LINGER_MS = 120_000; const AGENT_SORT_CHOICES: SidebarSectionRadioChoice[] = [ { value: "top", label: "Top" }, @@ -298,6 +299,8 @@ function SidebarAgentItem({ export function SidebarAgents({ streamlined = false }: { streamlined?: boolean } = {}) { const [open, setOpen] = useState(true); const [pendingAgentIds, setPendingAgentIds] = useState>(() => new Set()); + const [, setLiveLingerVersion] = useState(0); + const lastSeenLiveAtRef = useRef>(new Map()); const queryClient = useQueryClient(); const { selectedCompanyId } = useCompany(); const { openNewAgent } = useDialogActions(); @@ -354,6 +357,13 @@ export function SidebarAgents({ streamlined = false }: { streamlined?: boolean } } return counts; }, [liveRuns]); + const liveAgentIds = useMemo(() => { + const ids = new Set(); + for (const [agentId, count] of liveCountByAgent) { + if (count > 0) ids.add(agentId); + } + return ids; + }, [liveCountByAgent]); const visibleAgents = useMemo(() => { const filtered = (agents ?? []).filter( @@ -384,15 +394,36 @@ export function SidebarAgents({ streamlined = false }: { streamlined?: boolean } () => sortAgents(orderedAgents, sortMode), [orderedAgents, sortMode], ); + const sortedAgentIdSet = useMemo( + () => new Set(sortedAgents.map((agent: Agent) => agent.id)), + [sortedAgents], + ); + + useEffect(() => { + const now = Date.now(); + for (const agentId of liveAgentIds) { + lastSeenLiveAtRef.current.set(agentId, now); + } + for (const agentId of lastSeenLiveAtRef.current.keys()) { + if (!sortedAgentIdSet.has(agentId)) { + lastSeenLiveAtRef.current.delete(agentId); + } + } + }, [liveAgentIds, sortedAgentIdSet]); // IA Phase 5 (streamlined): if any agent has a live run, show only those - // active agents. Otherwise fall back to up to RECENT_AGENT_LIMIT agents. Either - // way a "See all agents" link is shown so the full list is always reachable. + // active agents. Agents that just stopped running linger briefly so clustered + // run boundaries do not make rows pop out and the section does not immediately + // swap to the recent fallback during short all-idle gaps. Otherwise fall back + // to up to RECENT_AGENT_LIMIT agents. Either way a "See all agents" link is + // shown so the full list is always reachable. // Classic mode (PAP-89, flag OFF) restores the show-all behavior. - const runningAgents = useMemo( - () => sortedAgents.filter((agent: Agent) => (liveCountByAgent.get(agent.id) ?? 0) > 0), - [sortedAgents, liveCountByAgent], - ); + const nowForLiveLinger = Date.now(); + const runningAgents = sortedAgents.filter((agent: Agent) => { + if ((liveCountByAgent.get(agent.id) ?? 0) > 0) return true; + const lastSeenLiveAt = lastSeenLiveAtRef.current.get(agent.id); + return lastSeenLiveAt !== undefined && nowForLiveLinger - lastSeenLiveAt <= LIVE_AGENT_LINGER_MS; + }); const hasActiveAgents = runningAgents.length > 0; const displayedAgents = !streamlined ? sortedAgents @@ -437,6 +468,30 @@ export function SidebarAgents({ streamlined = false }: { streamlined?: boolean } }; }, [sortModeStorageKey]); + useEffect(() => { + if (!streamlined) return; + + const now = Date.now(); + let nextExpiryAt: number | null = null; + for (const agent of sortedAgents) { + if ((liveCountByAgent.get(agent.id) ?? 0) > 0) continue; + const lastSeenLiveAt = lastSeenLiveAtRef.current.get(agent.id); + if (lastSeenLiveAt === undefined) continue; + const expiresAt = lastSeenLiveAt + LIVE_AGENT_LINGER_MS; + if (expiresAt < now) continue; + nextExpiryAt = nextExpiryAt === null ? expiresAt : Math.min(nextExpiryAt, expiresAt); + } + if (nextExpiryAt === null) return; + + const timeoutId = window.setTimeout(() => { + setLiveLingerVersion((version) => version + 1); + }, Math.max(0, nextExpiryAt - now + 1)); + + return () => { + window.clearTimeout(timeoutId); + }; + }, [streamlined, sortedAgents, liveCountByAgent]); + const persistSortMode = useCallback( (value: string) => { const nextSortMode: AgentSidebarSortMode = diff --git a/ui/src/context/LiveUpdatesProvider.test.ts b/ui/src/context/LiveUpdatesProvider.test.ts index 422046a52c4..9d13bfe50a0 100644 --- a/ui/src/context/LiveUpdatesProvider.test.ts +++ b/ui/src/context/LiveUpdatesProvider.test.ts @@ -100,7 +100,7 @@ describe("LiveUpdatesProvider issue invalidation", () => { }); }); - it("keeps heartbeat progress invalidation scoped to live run data", () => { + it("keeps heartbeat progress invalidation scoped away from hot list queries", () => { const invalidations: unknown[] = []; const queryClient = { invalidateQueries: (input: unknown) => { @@ -117,21 +117,21 @@ describe("LiveUpdatesProvider issue invalidation", () => { }, ); - expect(invalidations).toContainEqual({ - queryKey: queryKeys.liveRuns("company-1"), - }); - expect(invalidations).toContainEqual({ - queryKey: queryKeys.heartbeats("company-1"), - }); - expect(invalidations).toContainEqual({ - queryKey: queryKeys.agents.list("company-1"), - }); expect(invalidations).toContainEqual({ queryKey: queryKeys.agents.detail("agent-1"), }); - expect(invalidations).toContainEqual({ + expect(invalidations).not.toContainEqual({ + queryKey: queryKeys.liveRuns("company-1"), + }); + expect(invalidations).not.toContainEqual({ + queryKey: queryKeys.heartbeats("company-1"), + }); + expect(invalidations).not.toContainEqual({ queryKey: queryKeys.heartbeats("company-1", "agent-1"), }); + expect(invalidations).not.toContainEqual({ + queryKey: queryKeys.agents.list("company-1"), + }); expect(invalidations).not.toContainEqual({ queryKey: queryKeys.dashboard("company-1"), }); diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index cef24b1f13f..19eb9ce7f75 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -817,17 +817,12 @@ function invalidateHeartbeatQueries( function invalidateHeartbeatProgressQueries( queryClient: ReturnType, - companyId: string, + _companyId: string, payload: Record, ) { - queryClient.invalidateQueries({ queryKey: queryKeys.liveRuns(companyId) }); - queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(companyId) }); - queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(companyId) }); - const agentId = readString(payload.agentId); if (agentId) { queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentId) }); - queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(companyId, agentId) }); } }