agent-ecosystem/src/renderer/hooks/useVisibleAIGroup.ts
iliya 47dac2e8b5 feat: migrate to React 19
Upgrade React 18.3.1 → 19.2.4 with full type compatibility.

Changes:
- react, react-dom → ^19.0.0
- @types/react, @types/react-dom → ^19.0.0
- lucide-react → ^0.577.0 (React 19 type fixes)
- @tiptap/* → ^3.20.4 (React 19 support)
- useRef calls now require explicit initial value (undefined)
- RefObject types updated for React 19 (includes null)
- MutableRefObject → RefObject (deprecated in 19)
- act() import moved from react-dom/test-utils to react
- Scoped JSX namespace imports added where needed
2026-03-24 17:11:55 +02:00

122 lines
3.7 KiB
TypeScript

import { type RefObject, useCallback, useEffect, useRef } from 'react';
interface UseVisibleAIGroupOptions {
onVisibleChange: (aiGroupId: string) => void;
threshold?: number; // Default 0.5
/** Optional scroll container to observe against (important for nested scroll areas). */
rootRef?: RefObject<HTMLElement | null>;
}
interface UseVisibleAIGroupReturn {
registerAIGroupRef: (aiGroupId: string) => (element: HTMLElement | null) => void;
}
export function useVisibleAIGroup(options: UseVisibleAIGroupOptions): UseVisibleAIGroupReturn {
const { onVisibleChange, threshold = 0.5, rootRef } = options;
// Track which AI Groups are currently visible (above threshold)
const visibleAIGroupIds = useRef<Set<string>>(new Set());
// Track element references by AI Group ID
const elementRefs = useRef<Map<string, HTMLElement>>(new Map());
// IntersectionObserver instance
const observerRef = useRef<IntersectionObserver | null>(null);
// Calculate and report the topmost visible AI Group
const updateTopmostVisible = useCallback(() => {
if (visibleAIGroupIds.current.size === 0) {
return;
}
let topmostId: string | null = null;
let minTop = Infinity;
// Find the AI Group with the smallest top position (closest to top of viewport)
visibleAIGroupIds.current.forEach((id) => {
const element = elementRefs.current.get(id);
if (element) {
const rect = element.getBoundingClientRect();
if (rect.top < minTop) {
minTop = rect.top;
topmostId = id;
}
}
});
if (topmostId) {
onVisibleChange(topmostId);
}
}, [onVisibleChange]);
// Set up IntersectionObserver
useEffect(() => {
observerRef.current = new IntersectionObserver(
(entries) => {
let changed = false;
entries.forEach((entry) => {
const aiGroupId = entry.target.getAttribute('data-aigroup-id');
if (!aiGroupId) return;
if (entry.isIntersecting && entry.intersectionRatio >= threshold) {
// Element is visible above threshold
if (!visibleAIGroupIds.current.has(aiGroupId)) {
visibleAIGroupIds.current.add(aiGroupId);
changed = true;
}
} else {
// Element is not visible or below threshold
if (visibleAIGroupIds.current.has(aiGroupId)) {
visibleAIGroupIds.current.delete(aiGroupId);
changed = true;
}
}
});
// Recalculate topmost visible AI Group if visibility changed
if (changed) {
updateTopmostVisible();
}
},
{
root: rootRef?.current ?? null,
threshold,
// Use root margin to start detection slightly before element enters viewport
rootMargin: '0px',
}
);
return () => {
observerRef.current?.disconnect();
observerRef.current = null;
};
}, [threshold, updateTopmostVisible, rootRef]);
// Register an AI Group element for observation
const registerAIGroupRef = useCallback((aiGroupId: string) => {
return (element: HTMLElement | null) => {
const observer = observerRef.current;
if (!observer) return;
// Clean up previous element if it exists
const prevElement = elementRefs.current.get(aiGroupId);
if (prevElement) {
observer.unobserve(prevElement);
elementRefs.current.delete(aiGroupId);
visibleAIGroupIds.current.delete(aiGroupId);
}
// Register new element
if (element) {
element.setAttribute('data-aigroup-id', aiGroupId);
elementRefs.current.set(aiGroupId, element);
observer.observe(element);
}
};
}, []);
return {
registerAIGroupRef,
};
}