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
|
active: false
|
||||||
}));
|
}));
|
||||||
const [isHovered, setIsHovered] = useState(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 runningConditions = conditions;
|
||||||
const visibleTickerCards = useMemo(
|
const visibleTickerCards = useMemo(
|
||||||
() => createVisibleTickerCards(runningConditions, rotationIndex, nowMs),
|
() => createVisibleTickerCards(runningConditions, rotationIndex, nowMs),
|
||||||
@@ -181,13 +185,19 @@ export function AgentTaskTicker({
|
|||||||
}, [hasMultipleRunningTasks, showNextTask, showPreviousTask]);
|
}, [hasMultipleRunningTasks, showNextTask, showPreviousTask]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setNowMs(Date.now());
|
if (!isDocumentVisible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshNow = () => setNowMs(Date.now());
|
||||||
|
|
||||||
|
refreshNow();
|
||||||
const intervalId = window.setInterval(() => {
|
const intervalId = window.setInterval(() => {
|
||||||
setNowMs(Date.now());
|
refreshNow();
|
||||||
}, 1_000);
|
}, 1_000);
|
||||||
|
|
||||||
return () => window.clearInterval(intervalId);
|
return () => window.clearInterval(intervalId);
|
||||||
}, []);
|
}, [isDocumentVisible]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setRotationIndex(0);
|
setRotationIndex(0);
|
||||||
@@ -197,6 +207,26 @@ export function AgentTaskTicker({
|
|||||||
|
|
||||||
useEffect(() => releaseTaskSwitchLock, [releaseTaskSwitchLock]);
|
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(() => {
|
useEffect(() => {
|
||||||
const section = sectionRef.current;
|
const section = sectionRef.current;
|
||||||
if (!section) {
|
if (!section) {
|
||||||
@@ -209,16 +239,20 @@ export function AgentTaskTicker({
|
|||||||
}, [handleWheel]);
|
}, [handleWheel]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasMultipleRunningTasks || isHovered) {
|
if (!hasMultipleRunningTasks || isHovered || !isDocumentVisible) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const intervalId = window.setInterval(() => {
|
const intervalId = window.setInterval(() => {
|
||||||
|
if (document.visibilityState !== "visible") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
shiftTask(1);
|
shiftTask(1);
|
||||||
}, 6_000);
|
}, 6_000);
|
||||||
|
|
||||||
return () => window.clearInterval(intervalId);
|
return () => window.clearInterval(intervalId);
|
||||||
}, [hasMultipleRunningTasks, isHovered, runningConditions.length, shiftTask]);
|
}, [hasMultipleRunningTasks, isDocumentVisible, isHovered, runningConditions.length, shiftTask]);
|
||||||
|
|
||||||
if (visibleTickerCards.length === 0) {
|
if (visibleTickerCards.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
@@ -237,7 +271,12 @@ export function AgentTaskTicker({
|
|||||||
onMouseLeave={() => setIsHovered(false)}
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
>
|
>
|
||||||
<div className="absolute inset-0">
|
<div className="absolute inset-0">
|
||||||
<AnimatePresence custom={transitionDirection} mode="popLayout" initial={false}>
|
<AnimatePresence
|
||||||
|
key={`ticker-presence-${visibilityRevision}`}
|
||||||
|
custom={transitionDirection}
|
||||||
|
mode="popLayout"
|
||||||
|
initial={false}
|
||||||
|
>
|
||||||
{visibleTickerCards
|
{visibleTickerCards
|
||||||
.slice()
|
.slice()
|
||||||
.reverse()
|
.reverse()
|
||||||
@@ -320,6 +359,7 @@ function TickerTaskCard({
|
|||||||
}}
|
}}
|
||||||
exit="exit"
|
exit="exit"
|
||||||
aria-hidden={!isActive}
|
aria-hidden={!isActive}
|
||||||
|
data-condition-id={condition.id}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
Reference in New Issue
Block a user