From 621d9e289c2cd655ec406a43c41e2eff6aa0149d Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 10 Jul 2026 13:59:42 +0000 Subject: [PATCH] Stabilize streamlined agent sidebar Add a 120s linger window for live sidebar agent rows so finished runs do not immediately swap the section to the recent fallback. Scope heartbeat progress invalidation away from hot list queries while preserving direct cache patches and agent detail refreshes. Co-Authored-By: Paperclip --- ui/src/components/SidebarAgents.test.tsx | 62 +++++++++++++++++++ ui/src/components/SidebarAgents.tsx | 69 +++++++++++++++++++--- ui/src/context/LiveUpdatesProvider.test.ts | 22 +++---- ui/src/context/LiveUpdatesProvider.tsx | 7 +-- 4 files changed, 136 insertions(+), 24 deletions(-) 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) }); } }