"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { StoreApi } from "zustand"; import { useAppStore, useAppStoreApi } from "@/components/state-provider"; import { getTaskCIAutomationOptions, listTaskPRs } from "@/lib/api/domains/github-api"; import type { AppState } from "@/lib/state/store"; import { isCurrentWorkspaceContext } from "@/lib/state/workspace-context"; import type { TaskPRScope } from "@/lib/state/slices/github/types"; import type { TaskCIAutomationOptions, TaskPR } from "@/lib/types/github"; export type TaskPRTooltipHydrationStatus = "idle" | "loading" | "unavailable"; type TaskPRRequestRegistry = Map>; type TaskAutomationRequestRegistry = Map>; const requestRegistryByStore = new WeakMap, TaskPRRequestRegistry>(); const automationRequestRegistryByStore = new WeakMap< StoreApi, TaskAutomationRequestRegistry >(); function hasTaskPRs(value: unknown): value is TaskPR[] { return Array.isArray(value) && value.length > 0; } function requestKey( workspaceId: string, workspaceContextGeneration: number, taskId: string, ): string { // The NUL delimiter is only an in-memory scope-key boundary; API IDs are // opaque stable values and are not expected to contain it. return `${workspaceId}\u0000${workspaceContextGeneration}\u0000${taskId}`; } function getRequestRegistry(store: StoreApi): TaskPRRequestRegistry { const existing = requestRegistryByStore.get(store); if (existing) return existing; const registry: TaskPRRequestRegistry = new Map(); requestRegistryByStore.set(store, registry); return registry; } function getAutomationRequestRegistry(store: StoreApi): TaskAutomationRequestRegistry { const existing = automationRequestRegistryByStore.get(store); if (existing) return existing; const registry: TaskAutomationRequestRegistry = new Map(); automationRequestRegistryByStore.set(store, registry); return registry; } function isSamePR(left: TaskPR, right: TaskPR): boolean { if (left.id && right.id && left.id === right.id) return true; return ( (left.repository_id ?? "") === (right.repository_id ?? "") && left.pr_number === right.pr_number ); } /** Merge only missing identities so a newer WebSocket row remains authoritative. */ function getTaskPRsForScope(state: AppState, taskId: string, scope: TaskPRScope): TaskPR[] | null { if ( state.taskPRs.workspaceId === scope.workspaceId && state.taskPRs.workspaceContextGeneration === scope.workspaceContextGeneration && hasTaskPRs(state.taskPRs.byTaskId[taskId]) ) { return state.taskPRs.byTaskId[taskId]; } return null; } export function getTaskPRsForCurrentWorkspace(state: AppState, taskId: string): TaskPR[] | null { return getTaskPRsForScope(state, taskId, { workspaceId: state.workspaces.activeId, workspaceContextGeneration: state.workspaceContextGeneration, }); } function mergeMissingTaskPRs( store: StoreApi, taskId: string, prs: TaskPR[], scope: TaskPRScope, ): void { for (const pr of prs) { const taskPRs = store.getState().taskPRs; if (taskPRs.deletedAssociationIdsByTaskId?.[taskId]?.[pr.id]) continue; const current = taskPRs.byTaskId[taskId]; const currentPRs = Array.isArray(current) ? current : []; if (currentPRs.some((candidate) => isSamePR(candidate, pr))) continue; store.getState().setTaskPR(taskId, pr, scope); } } function requestTaskPRs( store: StoreApi, scope: TaskPRScope & { workspaceId: string }, taskId: string, ): Promise { const registry = getRequestRegistry(store); const key = requestKey(scope.workspaceId, scope.workspaceContextGeneration, taskId); const existing = registry.get(key); if (existing) return existing; const request = listTaskPRs([taskId], { cache: "no-store" }).then( (response) => response.task_prs?.[taskId] ?? [], ); registry.set(key, request); request.then( () => { if (registry.get(key) === request) registry.delete(key); }, () => { if (registry.get(key) === request) registry.delete(key); }, ); return request; } function requestTaskAutomationOptions( store: StoreApi, scope: TaskPRScope & { workspaceId: string }, taskId: string, ): Promise { if (typeof getTaskCIAutomationOptions !== "function") return Promise.resolve(null); const registry = getAutomationRequestRegistry(store); const key = requestKey(scope.workspaceId, scope.workspaceContextGeneration, taskId); const existing = registry.get(key); if (existing) return existing; const request = Promise.resolve(getTaskCIAutomationOptions(taskId, { cache: "no-store" })); registry.set(key, request); request.then( () => { if (registry.get(key) === request) registry.delete(key); }, () => { if (registry.get(key) === request) registry.delete(key); }, ); return request; } function automationOptionsForScope( options: TaskCIAutomationOptions | null | undefined, workspaceId: string | null, ): TaskCIAutomationOptions | null { if (!options) return null; if (options.workspace_id && workspaceId && options.workspace_id !== workspaceId) return null; return options; } export function useTaskPRTooltipHydration( taskId: string, hydrationOptions: { includeAutomation?: boolean } = {}, ): { status: TaskPRTooltipHydrationStatus; hydrate: () => Promise; automationOptions: TaskCIAutomationOptions | null; } { const store = useAppStoreApi(); const workspaceId = useAppStore((state) => state.workspaces.activeId); const workspaceContextGeneration = useAppStore((state) => state.workspaceContextGeneration); const storedAutomationOptions = useAppStore((state) => automationOptionsForScope(state.taskCIAutomation?.byTaskId?.[taskId], workspaceId), ); const [status, setStatus] = useState("idle"); const [hydratedAutomationOptions, setHydratedAutomationOptions] = useState(null); const includeAutomation = hydrationOptions.includeAutomation === true; const scopeKey = `${workspaceId ?? ""}\u0000${workspaceContextGeneration}\u0000${taskId}`; const scopeRef = useRef({ key: scopeKey, generation: 0 }); if (scopeRef.current.key !== scopeKey) { scopeRef.current = { key: scopeKey, generation: scopeRef.current.generation + 1, }; } useEffect(() => { setStatus("idle"); setHydratedAutomationOptions(null); }, [scopeKey]); const hydrate = useCallback(() => { const generation = scopeRef.current.generation; const scope = workspaceId ? { workspaceId, workspaceContextGeneration } : null; const isCurrentScope = () => scopeRef.current.generation === generation && scope !== null && isCurrentWorkspaceContext( store.getState(), scope.workspaceId, scope.workspaceContextGeneration, ); if (!scope) { if (scopeRef.current.generation === generation) setStatus("unavailable"); return Promise.resolve([]); } const current = store.getState(); const cached = getTaskPRsForScope(current, taskId, scope); const cachedAutomation = automationOptionsForScope( current.taskCIAutomation?.byTaskId?.[taskId], scope.workspaceId, ); if (cached && (!includeAutomation || cachedAutomation)) { if (isCurrentScope()) { setHydratedAutomationOptions(cachedAutomation); setStatus("idle"); } return Promise.resolve(cached); } if (isCurrentScope()) setStatus("loading"); const prRequest = cached ? Promise.resolve(cached) : requestTaskPRs(store, scope, taskId); let automationRequest: Promise = Promise.resolve(null); if (includeAutomation) { automationRequest = cachedAutomation ? Promise.resolve(cachedAutomation) : requestTaskAutomationOptions(store, scope, taskId); } return Promise.allSettled([prRequest, automationRequest]).then((results) => { const prResult = results[0]; const automationResult = results[1]; const prs = prResult.status === "fulfilled" ? prResult.value : []; const automation = automationResult.status === "fulfilled" ? automationResult.value : null; if (isCurrentScope()) { if (prs.length > 0) mergeMissingTaskPRs(store, taskId, prs, scope); if (automation) { store.getState().setTaskCIAutomationOptions(taskId, automation); setHydratedAutomationOptions(automation); } setStatus(getTaskPRsForScope(store.getState(), taskId, scope) ? "idle" : "unavailable"); } return prs; }); }, [includeAutomation, store, taskId, workspaceContextGeneration, workspaceId]); return useMemo( () => ({ status, hydrate, // Prefer the store entry because WebSocket updates replace the fetched // snapshot while the disclosure remains mounted. Fall back to the local // snapshot for compatibility with callers/tests that provide no store // entry (for example, a request that completed before state hydration). automationOptions: storedAutomationOptions ?? hydratedAutomationOptions, }), [status, hydrate, storedAutomationOptions, hydratedAutomationOptions], ); }