fix(FUX-039): thread real IntersectionObserver viewport state into agent cards
Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
@@ -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<TaskDetail | null>(null);
|
||||
const cardRef = useRef<HTMLDivElement>(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 (
|
||||
<div
|
||||
ref={cardRef}
|
||||
className="live-agent-card"
|
||||
onClick={handleSelect}
|
||||
onKeyDown={handleKeyDown}
|
||||
@@ -120,7 +146,7 @@ function LiveAgentCard({ agent, projectId, onSelect, onOpenTaskLogs }: LiveAgent
|
||||
<span className="live-agent-task badge"><AgentTaskBadge taskId={agent.taskId} taskColumn={agent.taskColumn} /></span>
|
||||
)}
|
||||
{agent.taskId && (
|
||||
<RuntimeFallbackBadge taskId={agent.taskId} isInViewport={true} projectId={projectId} />
|
||||
<RuntimeFallbackBadge taskId={agent.taskId} isInViewport={isInViewport} projectId={projectId} />
|
||||
)}
|
||||
</div>
|
||||
<div className="live-agent-card-transcript">
|
||||
|
||||
@@ -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<Map<string, Element>>(new Map());
|
||||
const agentCardKeyByElRef = useRef<Map<Element, string>>(new Map());
|
||||
const [visibleAgentCardKeys, setVisibleAgentCardKeys] = useState<Set<string>>(new Set());
|
||||
const agentCardObserverRef = useRef<IntersectionObserver | null>(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<number>(() => readAgentsSidebarWidth(projectId));
|
||||
@@ -1693,6 +1772,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
return (
|
||||
<div key={agent.id} className={`agent-board-card ${stateCardClass}${selectedAgentId === agent.id ? " agent-card--selected" : ""}`}>
|
||||
<div
|
||||
ref={registerAgentCardRef(`board:${agent.id}`)}
|
||||
className="agent-board-clickable"
|
||||
onClick={() => openAgentDetail(agent.id)}
|
||||
role="button"
|
||||
@@ -1720,7 +1800,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
<div className="agent-board-name">{agent.name}</div>
|
||||
<div className="agent-board-id">{agent.id}</div>
|
||||
{agent.taskId && (
|
||||
<RuntimeFallbackBadge taskId={agent.taskId} isInViewport={true} projectId={projectId} />
|
||||
<RuntimeFallbackBadge taskId={agent.taskId} isInViewport={isAgentCardInViewport(`board:${agent.id}`)} projectId={projectId} />
|
||||
)}
|
||||
<div className="agent-board-health" style={{ color: health.color }} title={healthSummary.title}>
|
||||
{health.icon}{healthSummary.label ? ` ${healthSummary.label}` : ""}
|
||||
@@ -1760,6 +1840,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
return (
|
||||
<div
|
||||
key={agent.id}
|
||||
ref={registerAgentCardRef(`list:${agent.id}`)}
|
||||
className={`agent-card agent-card--clickable ${stateCardClass}${selectedAgentId === agent.id ? " agent-card--selected" : ""}`}
|
||||
onClick={(e) => {
|
||||
// Open detail when the user clicks the card body, but
|
||||
@@ -1892,7 +1973,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
<div className="agent-task">
|
||||
<span className="text-secondary">{t("agents.workingOn", "Working on:")}</span>
|
||||
<span className="badge"><AgentTaskBadge taskId={agent.taskId} taskColumn={agent.taskColumn} /></span>
|
||||
<RuntimeFallbackBadge taskId={agent.taskId} isInViewport={true} projectId={projectId} />
|
||||
<RuntimeFallbackBadge taskId={agent.taskId} isInViewport={isAgentCardInViewport(`list:${agent.id}`)} projectId={projectId} />
|
||||
</div>
|
||||
)}
|
||||
<div className="agent-heartbeat-control">
|
||||
|
||||
@@ -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
|
||||
// <RuntimeFallbackBadge isInViewport={...} /> 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(
|
||||
<ToastProvider>
|
||||
<RuntimeFallbackBadge taskId="FN-100" isInViewport={true} projectId="proj-1" />
|
||||
</ToastProvider>,
|
||||
);
|
||||
|
||||
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(
|
||||
<ToastProvider>
|
||||
<RuntimeFallbackBadge taskId="FN-100" isInViewport={false} projectId="proj-1" />
|
||||
</ToastProvider>,
|
||||
);
|
||||
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(
|
||||
<ToastProvider>
|
||||
<RuntimeFallbackBadge taskId="FN-100" isInViewport={true} projectId="proj-1" />
|
||||
</ToastProvider>,
|
||||
);
|
||||
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(
|
||||
<ToastProvider>
|
||||
<RuntimeFallbackBadge taskId="FN-100" isInViewport={true} projectId="proj-1" />
|
||||
</ToastProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(legacyMocks.fetchTaskRuntimeFallback).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(
|
||||
<ToastProvider>
|
||||
<RuntimeFallbackBadge taskId="FN-100" isInViewport={false} projectId="proj-1" />
|
||||
</ToastProvider>,
|
||||
);
|
||||
legacyMocks.fetchTaskRuntimeFallback.mockClear();
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
});
|
||||
expect(legacyMocks.fetchTaskRuntimeFallback).not.toHaveBeenCalled();
|
||||
expect(screen.queryByTestId("runtime-fallback-badge")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user