fix: pause task ticker in hidden tabs
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user