fix: pause task ticker in hidden tabs

This commit is contained in:
2026-07-22 11:38:51 +08:00
parent 5ee469ff5b
commit d2c278f0ea
2 changed files with 121 additions and 6 deletions
@@ -0,0 +1,75 @@
import { act, cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ScheduledConditionRecord, ScheduledConditionTaskId } from "../types";
import { AgentTaskTicker } from "./agent-task-ticker";
function createRunningCondition(id: string, taskId: ScheduledConditionTaskId): ScheduledConditionRecord {
return {
id,
kind: "condition",
taskId,
scheduledAt: "2026-07-08T10:00:00+08:00",
title: id,
summary: "Running condition",
status: "running",
riskLevel: "normal",
updatedAt: Date.parse("2026-07-08T10:01:00+08:00"),
sessionId: id,
durationMinutes: 10
};
}
function setDocumentVisibility(visibilityState: DocumentVisibilityState) {
Object.defineProperty(document, "visibilityState", {
configurable: true,
value: visibilityState
});
document.dispatchEvent(new Event("visibilitychange"));
}
function getActiveConditionId(container: HTMLElement) {
const activeCards = container.querySelectorAll<HTMLElement>('article[aria-hidden="false"]');
return activeCards.item(activeCards.length - 1).dataset.conditionId;
}
afterEach(() => {
cleanup();
vi.useRealTimers();
setDocumentVisibility("visible");
});
describe("AgentTaskTicker page visibility", () => {
it("does not rotate or retain exiting cards while the page is hidden", () => {
vi.useFakeTimers();
setDocumentVisibility("visible");
const conditions = [
createRunningCondition("condition-a", "scada-diagnosis"),
createRunningCondition("condition-b", "smart-dispatch"),
createRunningCondition("condition-c", "pump-energy"),
createRunningCondition("condition-d", "network-simulation")
];
const { container } = render(<AgentTaskTicker conditions={conditions} />);
const initialConditionId = getActiveConditionId(container);
act(() => {
setDocumentVisibility("hidden");
vi.advanceTimersByTime(18_000);
});
expect(getActiveConditionId(container)).toBe(initialConditionId);
expect(container.querySelectorAll("article")).toHaveLength(3);
act(() => {
setDocumentVisibility("visible");
});
act(() => {
vi.advanceTimersByTime(6_000);
});
expect(getActiveConditionId(container)).not.toBe(initialConditionId);
});
});
@@ -101,6 +101,10 @@ export function AgentTaskTicker({
active: false
}));
const [isHovered, setIsHovered] = useState(false);
const [isDocumentVisible, setIsDocumentVisible] = useState(() => (
typeof document === "undefined" || document.visibilityState === "visible"
));
const [visibilityRevision, setVisibilityRevision] = useState(0);
const runningConditions = conditions;
const visibleTickerCards = useMemo(
() => createVisibleTickerCards(runningConditions, rotationIndex, nowMs),
@@ -181,13 +185,19 @@ export function AgentTaskTicker({
}, [hasMultipleRunningTasks, showNextTask, showPreviousTask]);
useEffect(() => {
setNowMs(Date.now());
if (!isDocumentVisible) {
return;
}
const refreshNow = () => setNowMs(Date.now());
refreshNow();
const intervalId = window.setInterval(() => {
setNowMs(Date.now());
refreshNow();
}, 1_000);
return () => window.clearInterval(intervalId);
}, []);
}, [isDocumentVisible]);
useEffect(() => {
setRotationIndex(0);
@@ -197,6 +207,26 @@ export function AgentTaskTicker({
useEffect(() => releaseTaskSwitchLock, [releaseTaskSwitchLock]);
useEffect(() => {
const handleVisibilityChange = () => {
const isVisible = document.visibilityState === "visible";
releaseTaskSwitchLock();
finishTaskSwitchAnimation();
setIsHovered(false);
setIsDocumentVisible(isVisible);
setVisibilityRevision((current) => current + 1);
if (isVisible) {
setNowMs(Date.now());
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => document.removeEventListener("visibilitychange", handleVisibilityChange);
}, [finishTaskSwitchAnimation, releaseTaskSwitchLock]);
useEffect(() => {
const section = sectionRef.current;
if (!section) {
@@ -209,16 +239,20 @@ export function AgentTaskTicker({
}, [handleWheel]);
useEffect(() => {
if (!hasMultipleRunningTasks || isHovered) {
if (!hasMultipleRunningTasks || isHovered || !isDocumentVisible) {
return;
}
const intervalId = window.setInterval(() => {
if (document.visibilityState !== "visible") {
return;
}
shiftTask(1);
}, 6_000);
return () => window.clearInterval(intervalId);
}, [hasMultipleRunningTasks, isHovered, runningConditions.length, shiftTask]);
}, [hasMultipleRunningTasks, isDocumentVisible, isHovered, runningConditions.length, shiftTask]);
if (visibleTickerCards.length === 0) {
return null;
@@ -237,7 +271,12 @@ export function AgentTaskTicker({
onMouseLeave={() => setIsHovered(false)}
>
<div className="absolute inset-0">
<AnimatePresence custom={transitionDirection} mode="popLayout" initial={false}>
<AnimatePresence
key={`ticker-presence-${visibilityRevision}`}
custom={transitionDirection}
mode="popLayout"
initial={false}
>
{visibleTickerCards
.slice()
.reverse()
@@ -320,6 +359,7 @@ function TickerTaskCard({
}}
exit="exit"
aria-hidden={!isActive}
data-condition-id={condition.id}
>
<div
className={cn(