/ hooks / usePrStatus.ts
usePrStatus.ts
  1  import { useEffect, useRef, useState } from 'react'
  2  import { getLastInteractionTime } from '../bootstrap/state.js'
  3  import { fetchPrStatus, type PrReviewState } from '../utils/ghPrStatus.js'
  4  
  5  const POLL_INTERVAL_MS = 60_000
  6  const SLOW_GH_THRESHOLD_MS = 4_000
  7  const IDLE_STOP_MS = 60 * 60_000 // stop polling after 60 min idle
  8  
  9  export type PrStatusState = {
 10    number: number | null
 11    url: string | null
 12    reviewState: PrReviewState | null
 13    lastUpdated: number
 14  }
 15  
 16  const INITIAL_STATE: PrStatusState = {
 17    number: null,
 18    url: null,
 19    reviewState: null,
 20    lastUpdated: 0,
 21  }
 22  
 23  /**
 24   * Polls PR review status every 60s while the session is active.
 25   * When no interaction is detected for 60 minutes, the loop stops — no
 26   * timers remain. React re-runs the effect when isLoading changes
 27   * (turn starts/ends), restarting the loop. Effect setup schedules
 28   * the next poll relative to the last fetch time so turn boundaries
 29   * don't spawn `gh` more than once per interval. Disables permanently
 30   * if a fetch exceeds 4s.
 31   *
 32   * Pass `enabled: false` to skip polling entirely (hook still must be
 33   * called unconditionally to satisfy the rules of hooks).
 34   */
 35  export function usePrStatus(isLoading: boolean, enabled = true): PrStatusState {
 36    const [prStatus, setPrStatus] = useState<PrStatusState>(INITIAL_STATE)
 37    const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
 38    const disabledRef = useRef(false)
 39    const lastFetchRef = useRef(0)
 40  
 41    useEffect(() => {
 42      if (!enabled) return
 43      if (disabledRef.current) return
 44  
 45      let cancelled = false
 46      let lastSeenInteractionTime = -1
 47      let lastActivityTimestamp = Date.now()
 48  
 49      async function poll() {
 50        if (cancelled) return
 51  
 52        const currentInteractionTime = getLastInteractionTime()
 53        if (lastSeenInteractionTime !== currentInteractionTime) {
 54          lastSeenInteractionTime = currentInteractionTime
 55          lastActivityTimestamp = Date.now()
 56        } else if (Date.now() - lastActivityTimestamp >= IDLE_STOP_MS) {
 57          return
 58        }
 59  
 60        const start = Date.now()
 61        const result = await fetchPrStatus()
 62        if (cancelled) return
 63        lastFetchRef.current = start
 64  
 65        setPrStatus(prev => {
 66          const newNumber = result?.number ?? null
 67          const newReviewState = result?.reviewState ?? null
 68          if (prev.number === newNumber && prev.reviewState === newReviewState) {
 69            return prev
 70          }
 71          return {
 72            number: newNumber,
 73            url: result?.url ?? null,
 74            reviewState: newReviewState,
 75            lastUpdated: Date.now(),
 76          }
 77        })
 78  
 79        if (Date.now() - start > SLOW_GH_THRESHOLD_MS) {
 80          disabledRef.current = true
 81          return
 82        }
 83  
 84        if (!cancelled) {
 85          timeoutRef.current = setTimeout(poll, POLL_INTERVAL_MS)
 86        }
 87      }
 88  
 89      const elapsed = Date.now() - lastFetchRef.current
 90      if (elapsed >= POLL_INTERVAL_MS) {
 91        void poll()
 92      } else {
 93        timeoutRef.current = setTimeout(poll, POLL_INTERVAL_MS - elapsed)
 94      }
 95  
 96      return () => {
 97        cancelled = true
 98        if (timeoutRef.current) {
 99          clearTimeout(timeoutRef.current)
100          timeoutRef.current = null
101        }
102      }
103    }, [isLoading, enabled])
104  
105    return prStatus
106  }