fix(FUX-039): thread real IntersectionObserver viewport state into agent cards

Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
ddonaldson130
2026-07-08 12:41:16 -04:00
parent c3c726cff4
commit 8a35b0bc4c
3 changed files with 188 additions and 4 deletions

View File

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

View File

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

View File

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