diff --git a/frontend/src/app/activity/page.tsx b/frontend/src/app/activity/page.tsx index 2b2c30a..73257eb 100644 --- a/frontend/src/app/activity/page.tsx +++ b/frontend/src/app/activity/page.tsx @@ -20,6 +20,7 @@ import { Button } from "@/components/ui/button"; import { createExponentialBackoff } from "@/lib/backoff"; import { apiDatetimeToMs, parseApiDatetime } from "@/lib/datetime"; import { cn } from "@/lib/utils"; +import { usePageActive } from "@/hooks/usePageActive"; const SSE_RECONNECT_BACKOFF = { baseMs: 1_000, @@ -132,6 +133,7 @@ FeedCard.displayName = "FeedCard"; export default function ActivityPage() { const { isSignedIn } = useAuth(); + const isPageActive = usePageActive(); const feedQuery = useListTaskCommentFeedApiV1ActivityTaskCommentsGet< listTaskCommentFeedApiV1ActivityTaskCommentsGetResponse, @@ -189,6 +191,7 @@ export default function ActivityPage() { }, []); useEffect(() => { + if (!isPageActive) return; if (!isSignedIn) return; let isCancelled = false; const abortController = new AbortController(); @@ -278,7 +281,7 @@ export default function ActivityPage() { window.clearTimeout(reconnectTimeout); } }; - }, [isSignedIn, pushFeedItem]); + }, [isPageActive, isSignedIn, pushFeedItem]); const orderedFeed = useMemo(() => { return [...feedItems].sort((a, b) => { diff --git a/frontend/src/app/boards/[boardId]/page.tsx b/frontend/src/app/boards/[boardId]/page.tsx index 1725560..b0e5f1f 100644 --- a/frontend/src/app/boards/[boardId]/page.tsx +++ b/frontend/src/app/boards/[boardId]/page.tsx @@ -72,6 +72,7 @@ import type { import { createExponentialBackoff } from "@/lib/backoff"; import { apiDatetimeToMs, parseApiDatetime } from "@/lib/datetime"; import { cn } from "@/lib/utils"; +import { usePageActive } from "@/hooks/usePageActive"; type Board = BoardRead; @@ -298,6 +299,7 @@ export default function BoardDetailPage() { const boardIdParam = params?.boardId; const boardId = Array.isArray(boardIdParam) ? boardIdParam[0] : boardIdParam; const { isSignedIn } = useAuth(); + const isPageActive = usePageActive(); const taskIdFromUrl = searchParams.get("taskId"); const [board, setBoard] = useState(null); @@ -580,7 +582,9 @@ export default function BoardDetailPage() { }; useEffect(() => { + if (!isPageActive) return; if (!isSignedIn || !boardId || !board) return; + if (!isChatOpen) return; let isCancelled = false; const abortController = new AbortController(); const backoff = createExponentialBackoff(SSE_RECONNECT_BACKOFF); @@ -685,9 +689,10 @@ export default function BoardDetailPage() { window.clearTimeout(reconnectTimeout); } }; - }, [board, boardId, isSignedIn]); + }, [board, boardId, isChatOpen, isPageActive, isSignedIn]); useEffect(() => { + if (!isPageActive) return; if (!isSignedIn || !boardId || !board) return; let isCancelled = false; const abortController = new AbortController(); @@ -817,7 +822,7 @@ export default function BoardDetailPage() { window.clearTimeout(reconnectTimeout); } }; - }, [board, boardId, isSignedIn]); + }, [board, boardId, isPageActive, isSignedIn]); useEffect(() => { if (!selectedTask) { @@ -840,6 +845,7 @@ export default function BoardDetailPage() { }, [selectedTask]); useEffect(() => { + if (!isPageActive) return; if (!isSignedIn || !boardId || !board) return; let isCancelled = false; const abortController = new AbortController(); @@ -1003,9 +1009,10 @@ export default function BoardDetailPage() { window.clearTimeout(reconnectTimeout); } }; - }, [board, boardId, isSignedIn, pushLiveFeed]); + }, [board, boardId, isPageActive, isSignedIn, pushLiveFeed]); useEffect(() => { + if (!isPageActive) return; if (!isSignedIn || !boardId) return; let isCancelled = false; const abortController = new AbortController(); @@ -1109,7 +1116,7 @@ export default function BoardDetailPage() { window.clearTimeout(reconnectTimeout); } }; - }, [board, boardId, isSignedIn]); + }, [board, boardId, isPageActive, isSignedIn]); const resetForm = () => { setTitle(""); diff --git a/frontend/src/components/BoardOnboardingChat.tsx b/frontend/src/components/BoardOnboardingChat.tsx index 5d4e006..512cbc2 100644 --- a/frontend/src/components/BoardOnboardingChat.tsx +++ b/frontend/src/components/BoardOnboardingChat.tsx @@ -9,6 +9,7 @@ import { } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; +import { usePageActive } from "@/hooks/usePageActive"; import { answerOnboardingApiV1BoardsBoardIdOnboardingAnswerPost, @@ -116,6 +117,7 @@ export function BoardOnboardingChat({ boardId: string; onConfirmed: (board: BoardRead) => void; }) { + const isPageActive = usePageActive(); const [session, setSession] = useState(null); const [loading, setLoading] = useState(false); const [otherText, setOtherText] = useState(""); @@ -180,10 +182,15 @@ export function BoardOnboardingChat({ }, [boardId]); useEffect(() => { - startSession(); + void startSession(); + }, [startSession]); + + useEffect(() => { + if (!isPageActive) return; + void refreshSession(); const interval = setInterval(refreshSession, 2000); return () => clearInterval(interval); - }, [startSession, refreshSession]); + }, [isPageActive, refreshSession]); const handleAnswer = useCallback( async (value: string, freeText?: string) => { diff --git a/frontend/src/hooks/usePageActive.ts b/frontend/src/hooks/usePageActive.ts new file mode 100644 index 0000000..1c7da12 --- /dev/null +++ b/frontend/src/hooks/usePageActive.ts @@ -0,0 +1,42 @@ +"use client"; + +import { useEffect, useState } from "react"; + +const computeIsActive = () => { + if (typeof document === "undefined") return true; + const visible = + document.visibilityState === "visible" && + // `hidden` is a more widely-supported signal; keep both for safety. + !document.hidden; + const focused = typeof document.hasFocus === "function" ? document.hasFocus() : true; + return visible && focused; +}; + +/** + * Returns true when this tab/window is both visible and focused. + * + * Rationale: background tabs/windows should not keep long-lived connections + * (SSE/polling), otherwise opening multiple tabs can exhaust per-origin + * connection limits and make the app feel "hung". + */ +export function usePageActive(): boolean { + const [active, setActive] = useState(() => computeIsActive()); + + useEffect(() => { + const update = () => setActive(computeIsActive()); + + update(); + document.addEventListener("visibilitychange", update); + window.addEventListener("focus", update); + window.addEventListener("blur", update); + + return () => { + document.removeEventListener("visibilitychange", update); + window.removeEventListener("focus", update); + window.removeEventListener("blur", update); + }; + }, []); + + return active; +} +