diff --git a/packages/dashboard/app/components/ActiveAgentsPanel.tsx b/packages/dashboard/app/components/ActiveAgentsPanel.tsx index 8f769651db..57441a1d5d 100644 --- a/packages/dashboard/app/components/ActiveAgentsPanel.tsx +++ b/packages/dashboard/app/components/ActiveAgentsPanel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Activity, FileText } from "lucide-react"; import { useTranslation } from "react-i18next"; import type { Agent } from "../api"; @@ -24,6 +24,31 @@ function LiveAgentCard({ agent, projectId, onSelect, onOpenTaskLogs }: LiveAgent const { t } = useTranslation("app"); const { entries, isConnected } = useLiveTranscript(agent.taskId, projectId); const [task, setTask] = useState(null); + const cardRef = useRef(null); + const [isInViewport, setIsInViewport] = useState(false); + + // Gate the RuntimeFallbackBadge's polling to visible cards only, matching + // TaskCard.tsx's pattern -- without this, every live agent card (including + // ones scrolled off-screen) polls the runtime-fallback endpoint forever. + useEffect(() => { + if (typeof IntersectionObserver === "undefined") { + setIsInViewport(true); + return; + } + + const element = cardRef.current; + if (!element) return; + + const observer = new IntersectionObserver( + ([entry]) => { + setIsInViewport(entry?.isIntersecting ?? true); + }, + { rootMargin: "200px" }, + ); + + observer.observe(element); + return () => observer.disconnect(); + }, [agent.id]); // Poll the agent's task so the empty state can show real run progress // (current step, executor model) instead of just "Connecting..." while the @@ -101,6 +126,7 @@ function LiveAgentCard({ agent, projectId, onSelect, onOpenTaskLogs }: LiveAgent return (
)} {agent.taskId && ( - + )}
diff --git a/packages/dashboard/app/components/AgentsView.tsx b/packages/dashboard/app/components/AgentsView.tsx index 0feabf0f8d..a98ccbe32b 100644 --- a/packages/dashboard/app/components/AgentsView.tsx +++ b/packages/dashboard/app/components/AgentsView.tsx @@ -326,6 +326,85 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin const { t } = useTranslation("app"); const agentRoles = getAgentRoles(t); const [showSystemAgents, setShowSystemAgents] = useState(false); + + // Real IntersectionObserver-backed viewport gating for RuntimeFallbackBadge + // instances rendered per-card (board + list views), matching TaskCard.tsx's + // pattern. Cards are rendered inline inside a .map() rather than as their + // own components, so a single shared observer keyed by a per-card string + // (`board:{agentId}` / `list:{agentId}`, kept distinct so board and list + // never share visibility state for the same agent) replaces the per-card + // useRef/useState/useEffect triplet TaskCard uses for its single card root. + const agentCardElsRef = useRef>(new Map()); + const agentCardKeyByElRef = useRef>(new Map()); + const [visibleAgentCardKeys, setVisibleAgentCardKeys] = useState>(new Set()); + const agentCardObserverRef = useRef(null); + + useEffect(() => { + if (typeof IntersectionObserver === "undefined") { + return; + } + const observer = new IntersectionObserver( + (entries) => { + setVisibleAgentCardKeys((prev) => { + let changed = false; + const next = new Set(prev); + for (const entry of entries) { + const key = agentCardKeyByElRef.current.get(entry.target); + if (!key) continue; + if (entry.isIntersecting) { + if (!next.has(key)) { + next.add(key); + changed = true; + } + } else if (next.has(key)) { + next.delete(key); + changed = true; + } + } + return changed ? next : prev; + }); + }, + { rootMargin: "200px" }, + ); + agentCardObserverRef.current = observer; + agentCardElsRef.current.forEach((el) => observer.observe(el)); + return () => { + observer.disconnect(); + agentCardObserverRef.current = null; + }; + }, []); + + const registerAgentCardRef = useCallback((key: string) => (el: HTMLDivElement | null) => { + const prevEl = agentCardElsRef.current.get(key); + if (prevEl) { + agentCardObserverRef.current?.unobserve(prevEl); + agentCardKeyByElRef.current.delete(prevEl); + } + if (el) { + agentCardElsRef.current.set(key, el); + agentCardKeyByElRef.current.set(el, key); + if (agentCardObserverRef.current) { + agentCardObserverRef.current.observe(el); + } else { + // No IntersectionObserver support: treat as always visible, same + // synchronous-true fallback TaskCard.tsx uses. + setVisibleAgentCardKeys((prev) => (prev.has(key) ? prev : new Set(prev).add(key))); + } + } else { + agentCardElsRef.current.delete(key); + setVisibleAgentCardKeys((prev) => { + if (!prev.has(key)) return prev; + const next = new Set(prev); + next.delete(key); + return next; + }); + } + }, []); + + const isAgentCardInViewport = useCallback( + (key: string) => (typeof IntersectionObserver === "undefined" ? true : visibleAgentCardKeys.has(key)), + [visibleAgentCardKeys], + ); const viewportMode = useViewportMode(); const isMobileViewport = viewportMode === "mobile"; const [sidebarWidth, setSidebarWidth] = useState(() => readAgentsSidebarWidth(projectId)); @@ -1693,6 +1772,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin return (
openAgentDetail(agent.id)} role="button" @@ -1720,7 +1800,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
{agent.name}
{agent.id}
{agent.taskId && ( - + )}
{health.icon}{healthSummary.label ? ` ${healthSummary.label}` : ""} @@ -1760,6 +1840,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin return (
{ // Open detail when the user clicks the card body, but @@ -1892,7 +1973,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
{t("agents.workingOn", "Working on:")} - +
)}
diff --git a/packages/dashboard/app/components/__tests__/RuntimeFallbackBadge.test.tsx b/packages/dashboard/app/components/__tests__/RuntimeFallbackBadge.test.tsx index bf57316d49..349cc74b3a 100644 --- a/packages/dashboard/app/components/__tests__/RuntimeFallbackBadge.test.tsx +++ b/packages/dashboard/app/components/__tests__/RuntimeFallbackBadge.test.tsx @@ -189,6 +189,55 @@ describe("RuntimeFallbackBadge", () => { expect(legacyMocks.fetchTaskRuntimeFallback).not.toHaveBeenCalled(); expect(screen.queryByTestId("runtime-fallback-badge")).toBeNull(); }); + + // ActiveAgentsPanel.tsx and AgentsView.tsx (board + list cards) each wire a + // real IntersectionObserver-backed isInViewport value into this exact same + // call, mirroring TaskCard.tsx's + // pattern -- the shared poll-gating implementation lives here in the hook + // this component consumes, so a *transition* (not just a static isInViewport + // prop) is what actually reproduces "card scrolls off-screen mid-session" + // for all four call sites, not just the initial-render case above. + it("stops polling once isInViewport transitions to false mid-session, and resumes once it transitions back to true", async () => { + legacyMocks.fetchTaskRuntimeFallback.mockResolvedValue(fallbackWithHint); + const { rerender } = render( + + + , + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(legacyMocks.fetchTaskRuntimeFallback).toHaveBeenCalledTimes(1); + expect(screen.getByTestId("runtime-fallback-badge")).toBeInTheDocument(); + + // Card scrolls off-screen: parent flips isInViewport to false (as a real + // IntersectionObserver callback would via setIsInViewport(false)). + rerender( + + + , + ); + legacyMocks.fetchTaskRuntimeFallback.mockClear(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(30_000); + }); + expect(legacyMocks.fetchTaskRuntimeFallback).not.toHaveBeenCalled(); + expect(screen.queryByTestId("runtime-fallback-badge")).toBeNull(); + + // Card scrolls back into view: polling resumes. + rerender( + + + , + ); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(legacyMocks.fetchTaskRuntimeFallback).toHaveBeenCalled(); + expect(screen.getByTestId("runtime-fallback-badge")).toBeInTheDocument(); + }); }); describe("RuntimeFallbackBadge — mobile breakpoint", () => { @@ -233,4 +282,32 @@ describe("RuntimeFallbackBadge — mobile breakpoint", () => { expect(badge.textContent).toContain("hermes"); expect(badge.className).toContain("card-runtime-fallback-badge"); }); + + it("stops polling once isInViewport transitions to false at mobile viewport width (agent-card list rows scroll off-screen too)", async () => { + mockMobileViewport(); + legacyMocks.fetchTaskRuntimeFallback.mockResolvedValue(fallbackWithHint); + const { rerender } = render( + + + , + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(legacyMocks.fetchTaskRuntimeFallback).toHaveBeenCalledTimes(1); + + rerender( + + + , + ); + legacyMocks.fetchTaskRuntimeFallback.mockClear(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(30_000); + }); + expect(legacyMocks.fetchTaskRuntimeFallback).not.toHaveBeenCalled(); + expect(screen.queryByTestId("runtime-fallback-badge")).toBeNull(); + }); });