import { useCallback, useLayoutEffect, useMemo, useRef, useState, type RefObject } from "react"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import { nearestTaskId } from "./thread-viewport-geometry"; import { useMobileThreadPosition } from "./use-mobile-thread-position"; type VisibilityEntry = { element: Element; isIntersecting: boolean; }; export type ThreadColumnActivation = { boardRef: RefObject; registerColumn: (taskId: string, element: HTMLElement | null) => void; preloadTaskIds: ReadonlySet; detailTaskIds: ReadonlySet; mobileTaskId: string | null; }; function sameIds(a: ReadonlySet, b: ReadonlySet): boolean { if (a.size !== b.size) return false; for (const id of a) { if (!b.has(id)) return false; } return true; } function addAdjacent(ids: Set, orderedIds: readonly string[], id: string): void { const index = orderedIds.indexOf(id); if (index < 0) return; if (index > 0) ids.add(orderedIds[index - 1]); if (index < orderedIds.length - 1) ids.add(orderedIds[index + 1]); } function columnIdForElement(elements: ReadonlyMap, target: Element) { for (const [id, element] of elements) { if (element === target) return id; } return null; } function measureVisibility( board: HTMLElement, elements: ReadonlyMap, visibility: Map, ): boolean { const root = board.getBoundingClientRect(); if (root.width <= 0 || root.height <= 0) return false; for (const [id, element] of elements) { const rect = element.getBoundingClientRect(); visibility.set(id, { element, isIntersecting: rect.right > root.left && rect.left < root.right && rect.bottom > root.top && rect.top < root.bottom, }); } return true; } function resolveVisibleIds( orderedIds: readonly string[], visibleIds: ReadonlySet, observerReady: boolean, fallbackTaskId: string | null, ): string[] { if (observerReady) return orderedIds.filter((id) => visibleIds.has(id)); return fallbackTaskId ? [fallbackTaskId] : []; } function buildActivationSets({ orderedIds, visibleIds, observerReady, fallbackTaskId, isMobile, board, elements, }: { orderedIds: readonly string[]; visibleIds: ReadonlySet; observerReady: boolean; fallbackTaskId: string | null; isMobile: boolean; board: HTMLElement | null; elements: ReadonlyMap; }) { const visible = resolveVisibleIds(orderedIds, visibleIds, observerReady, fallbackTaskId); const detailTaskIds = new Set(); if (isMobile) { const nearest = nearestTaskId(visible, board, elements); if (nearest) detailTaskIds.add(nearest); } else { visible.forEach((id) => detailTaskIds.add(id)); } const preloadTaskIds = new Set(visible); for (const id of visible) addAdjacent(preloadTaskIds, orderedIds, id); return { preloadTaskIds: new Set(orderedIds.filter((id) => preloadTaskIds.has(id))), detailTaskIds, }; } /** * Owns the expensive part of a Threads column's lifecycle. Every task keeps a * shell, while only visible columns receive session membership data and only * the active detail window mounts a transcript. */ export function useThreadColumnActivation( orderedIds: readonly string[], focusedTaskId?: string | null, layoutKey = "columns", ): ThreadColumnActivation { const { isMobile } = useResponsiveBreakpoint(); const boardRef = useRef(null); const elementsRef = useRef(new Map()); const visibilityRef = useRef(new Map()); const observerRef = useRef(null); const [visibleIds, setVisibleIds] = useState>(() => new Set()); const [observerReady, setObserverReady] = useState(false); const idsKey = orderedIds.join("\u0000"); const hasColumns = orderedIds.length > 0; const updateVisibleIds = useCallback(() => { const next = new Set(); for (const [id, entry] of visibilityRef.current) { if (entry.isIntersecting && elementsRef.current.get(id) === entry.element) next.add(id); } setVisibleIds((previous) => (sameIds(previous, next) ? previous : next)); }, []); const registerColumn = useCallback( (taskId: string, element: HTMLElement | null) => { if (element) { elementsRef.current.set(taskId, element); observerRef.current?.observe(element); return; } const previous = elementsRef.current.get(taskId); if (previous) observerRef.current?.unobserve(previous); elementsRef.current.delete(taskId); visibilityRef.current.delete(taskId); updateVisibleIds(); }, [updateVisibleIds], ); useLayoutEffect(() => { const board = boardRef.current; observerRef.current?.disconnect(); observerRef.current = null; visibilityRef.current.clear(); if (!board || typeof IntersectionObserver === "undefined") { setVisibleIds(new Set()); setObserverReady(false); return; } setObserverReady(measureVisibility(board, elementsRef.current, visibilityRef.current)); updateVisibleIds(); const observer = new IntersectionObserver( (entries) => { if (observerRef.current !== observer) return; for (const entry of entries) { const taskId = columnIdForElement(elementsRef.current, entry.target); if (!taskId) continue; visibilityRef.current.set(taskId, { element: entry.target, isIntersecting: entry.isIntersecting, }); } setObserverReady(true); updateVisibleIds(); }, { root: board, threshold: [0, 0.5, 1] }, ); observerRef.current = observer; for (const element of elementsRef.current.values()) observer.observe(element); return () => { observer.disconnect(); if (observerRef.current === observer) observerRef.current = null; }; // Callback refs reconcile membership without dropping surviving visibility. // Reflow or board replacement rebuilds observation from measured geometry. }, [hasColumns, layoutKey, isMobile, updateVisibleIds]); const fallbackTaskId = focusedTaskId && orderedIds.includes(focusedTaskId) ? focusedTaskId : (orderedIds[0] ?? null); const mobileTaskId = useMobileThreadPosition({ enabled: isMobile, boardRef, elementsRef, orderedIds, fallbackTaskId, }); const sets = useMemo( () => buildActivationSets({ orderedIds, visibleIds, observerReady, fallbackTaskId: isMobile ? mobileTaskId : fallbackTaskId, isMobile, board: boardRef.current, elements: elementsRef.current, }), [fallbackTaskId, idsKey, isMobile, mobileTaskId, observerReady, visibleIds], ); return { boardRef, registerColumn, mobileTaskId, ...sets }; }