diff --git a/src/features/workbench/components/agent-task-ticker-lifecycle.test.tsx b/src/features/workbench/components/agent-task-ticker-lifecycle.test.tsx new file mode 100644 index 0000000..3f3a663 --- /dev/null +++ b/src/features/workbench/components/agent-task-ticker-lifecycle.test.tsx @@ -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('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(); + 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); + }); +}); diff --git a/src/features/workbench/components/agent-task-ticker.tsx b/src/features/workbench/components/agent-task-ticker.tsx index 7839184..727250a 100644 --- a/src/features/workbench/components/agent-task-ticker.tsx +++ b/src/features/workbench/components/agent-task-ticker.tsx @@ -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)} >
- + {visibleTickerCards .slice() .reverse() @@ -320,6 +359,7 @@ function TickerTaskCard({ }} exit="exit" aria-hidden={!isActive} + data-condition-id={condition.id} >