"use client"; import { useEffect, useCallback, useRef, useState } from "react"; import { deleteTaskPR, listWorkspaceTaskPRs } from "@/lib/api/domains/github-api"; import { getWebSocketClient } from "@/lib/ws/connection"; import { useAppStore } from "@/components/state-provider"; import type { TaskPR } from "@/lib/types/github"; /** Fetch all PR associations for a workspace. */ export function useWorkspacePRs(workspaceId: string | null) { const setTaskPRs = useAppStore((state) => state.setTaskPRs); const fetchedRef = useRef(null); const requestRef = useRef(0); useEffect(() => { if (!workspaceId) { fetchedRef.current = null; return; } if (fetchedRef.current === workspaceId) return; const requestId = ++requestRef.current; fetchedRef.current = workspaceId; listWorkspaceTaskPRs(workspaceId, { cache: "no-store" }) .then((response) => { if (requestRef.current !== requestId) return; setTaskPRs(response?.task_prs ?? {}); }) .catch(() => { if (requestRef.current === requestId) { fetchedRef.current = null; // allow retry on failure } }); }, [workspaceId, setTaskPRs]); } const SYNC_RETRY_DELAY = 5_000; // 5 seconds const SYNC_MAX_RETRIES = 6; // Up to 30 seconds of retries /** * Returns the primary PR (first by created_at) for a task. Multi-repo tasks * may have additional PRs — use `useTaskPRs` to get the full list. */ export function getPrimaryTaskPR(prs: TaskPR[] | undefined): TaskPR | null { return prs && prs.length > 0 ? prs[0] : null; } /** * Normalises the WS sync response into an array of TaskPR rows. Backend * returns `{prs: TaskPR[]}` (current shape) for multi-repo support, but we * accept the legacy bare-TaskPR shape too in case an older backend is * still running. Empty / null / unknown shapes return an empty array. */ function normalizeSyncResponse(result: SyncResponse): TaskPR[] { if (!result) return []; const envelope = result as { prs?: TaskPR[] }; if (Array.isArray(envelope.prs)) return envelope.prs; const single = result as TaskPR; if (single.task_id) return [single]; return []; } /** * Response shape from the `github.task_pr.sync` WS action. `permanent` * is true when every watch on the task points at a repository the * backend has classified as unresolvable (missing, deleted, or * inaccessible). When set, the 5s retry interval stops — without this * the frontend kept hammering a dead repo every 5s for the lifetime of * the task. Older backends omit the field, so it's optional. */ type SyncResponse = { prs?: TaskPR[]; permanent?: boolean } | TaskPR | null | undefined; /** Fetch a single task's PR associations, with on-demand sync via WS. */ export function useTaskPR(taskId: string | null) { const prs = useAppStore((state) => (taskId ? (state.taskPRs.byTaskId[taskId] ?? null) : null)); const pr = getPrimaryTaskPR(prs ?? undefined); const setTaskPR = useAppStore((state) => state.setTaskPR); const removeTaskPR = useAppStore((state) => state.removeTaskPR); const workspaceId = useAppStore((state) => state.workspaces.activeId); const connectionStatus = useAppStore((state) => state.connection.status); const retryRef = useRef(0); const permanentRef = useRef(false); // Tracks the previous connection status so the resync effect below only // fires on the transition edge into `connected`, not on every re-render // while already connected. const prevConnectionStatusRef = useRef(connectionStatus); // Bumped on each reconnect transition so the retry-polling effect below // tears down and recreates its interval (see that effect's dependency list). const [reconnectGeneration, setReconnectGeneration] = useState(0); const [loadedTaskId, setLoadedTaskId] = useState(null); // Monotonic counter incremented before each WS request, snapshotted in // the .then() closure. Mirrors useWorkspacePRs above. Without this, a // stale response from a previous taskId can land after the user // navigates to a new task and flip permanentRef.current = true for the // active task, killing its retry loop. The reset effect below clears // retry/permanent state on taskId change, but a still-in-flight WS // call from the previous task can race that reset. const requestRef = useRef(0); const refresh = useCallback(() => { if (!taskId) return; const client = getWebSocketClient(); if (!client) return; // Backend returns `{prs: TaskPR[], permanent?: boolean}` — multi-repo // tasks have one row per repo. We push each into the store so the // per-repo PR icon stays in sync. Empty array means no watches yet // (the freshness retry below handles the polling cadence). Legacy // single-PR shape (`TaskPR` only) is detected via the absence of // `.prs`. When `permanent` is true (every watch's repo is dead), // exhaust the retry counter so the 5s interval below clears itself. const requestId = ++requestRef.current; const requestedTaskId = taskId; client .request("github.task_pr.sync", { task_id: requestedTaskId }) .then((result) => { // Drop responses that aren't the latest in-flight request for // this hook instance — they'd otherwise corrupt permanentRef / // retryRef for whatever task the user is now viewing. The // taskId-change effect below bumps requestRef.current too, so // requestId alone covers both stale-by-sequence and // stale-by-task-change. if (requestRef.current !== requestId) return; const envelope = (result ?? {}) as { permanent?: boolean }; if (envelope.permanent) { permanentRef.current = true; retryRef.current = SYNC_MAX_RETRIES; } const list = normalizeSyncResponse(result); setLoadedTaskId(requestedTaskId); if (list.length === 0) return; for (const pr of list) { if (pr.task_id) setTaskPR(requestedTaskId, pr); } retryRef.current = 0; }) .catch(() => { if (requestRef.current !== requestId) return; // Keep transient failures pending while retries remain, then settle the // loading state so review-panel cleanup cannot be blocked forever. if (retryRef.current >= SYNC_MAX_RETRIES) { setLoadedTaskId(requestedTaskId); } }); }, [taskId, setTaskPR]); const unlink = useCallback( async (associationId: string) => { if (!taskId || !workspaceId) throw new Error("No active workspace is selected."); await deleteTaskPR(associationId, workspaceId); removeTaskPR(taskId, associationId); }, [removeTaskPR, taskId, workspaceId], ); // Reset retry/permanent state when taskId changes. Bumping requestRef // here invalidates any still-in-flight .then() closure from the prior // taskId so it can't write to the new task's refs. useEffect(() => { retryRef.current = 0; permanentRef.current = false; requestRef.current++; setLoadedTaskId(null); }, [taskId]); // Sync once when the task becomes active (freshness check). // Intentionally excludes `pr` so WS-driven store updates don't re-trigger. useEffect(() => { if (!taskId) return; refresh(); }, [taskId, refresh]); // Retry polling when no PR is in the store yet. permanentRef short-circuits // the interval entirely so a task whose repos are all dead doesn't tie up // the backend's gh throttle on every 5s tick. // // Depends on `reconnectGeneration` so a reconnect (see the effect below, // which zeroes retryRef/permanentRef and bumps the generation on the // transition edge) tears down and recreates the interval, starting a fresh // 30s retry window. Without that dep, once the budget is exhausted the // interval clears itself and merely resetting the refs never recreates it — // a single empty resync would then leave the store empty and the // "Pull Request" tab missing. useEffect(() => { if (!taskId || pr || permanentRef.current) return; const interval = setInterval(() => { if (retryRef.current >= SYNC_MAX_RETRIES || permanentRef.current) { clearInterval(interval); return; } retryRef.current++; refresh(); }, SYNC_RETRY_DELAY); return () => clearInterval(interval); }, [taskId, pr, refresh, reconnectGeneration]); // Reconnect-driven resync. A task can be opened while the WS is down (e.g. a // transient dev HMR disconnect, or a task auto-created by the PR-review // watcher that the user navigates to directly). In that window the freshness // sync and every 5s retry queue/time out with nothing, so the store never // learns about the PR and the auto "Pull Request" tab is never offered. When // the socket comes back up, clear the exhausted retry/permanent state, refire // the sync, and bump reconnectGeneration so the polling effect above restarts // its (possibly already self-cleared) interval and runs a fresh 30s retry // window. Guarded on the transition edge into `connected` so unrelated store // updates don't refire. useEffect(() => { const prev = prevConnectionStatusRef.current; prevConnectionStatusRef.current = connectionStatus; if (!taskId || connectionStatus !== "connected" || prev === "connected") return; retryRef.current = 0; permanentRef.current = false; refresh(); setReconnectGeneration((g) => g + 1); }, [taskId, connectionStatus, refresh]); return { pr, prs: prs ?? [], refresh, unlink, loaded: loadedTaskId === taskId || prs !== null, } as { pr: TaskPR | null; prs: TaskPR[]; refresh: () => void; unlink: (associationId: string) => Promise; loaded: boolean; }; } /** Read the active task's primary PR from the store (no fetching). */ export function useActiveTaskPR(): TaskPR | null { return useAppStore((s) => { const taskId = s.tasks.activeTaskId; if (!taskId) return null; return getPrimaryTaskPR(s.taskPRs.byTaskId[taskId]); }); }