agent-ecosystem/src/renderer/hooks/useViewportCommentRead.ts
iliya 46355d87df refactor(session): improve session label formatting and enhance session item display
- Replaced direct access to session.firstMessage with formatSessionLabel for consistent label formatting across components.
- Updated SessionItem, TeamSessionsSection, and KanbanFilterPopover to utilize the new formatting function.
- Enhanced display logic in SessionItem to differentiate between regular and team sessions, improving user experience.
- Added new icons for team sessions and adjusted metadata display for better clarity.
2026-03-29 01:16:04 +02:00

147 lines
4.4 KiB
TypeScript

import { useCallback, useEffect, useRef } from 'react';
import { markCommentsRead } from '@renderer/services/commentReadStorage';
import { useViewportObserver } from './useViewportObserver';
interface UseViewportCommentReadOptions {
teamName: string;
taskId: string;
/**
* Scrollable ancestor DOM element (e.g. DialogContent) used as IO root.
* Pass the actual element (not a RefObject) so that the observer is
* recreated when the portal mounts. Use useState + callback ref:
* const [rootEl, setRootEl] = useState<HTMLDivElement | null>(null);
* <DialogContent ref={setRootEl}>
*/
scrollContainer: HTMLElement | null;
}
const VISIBILITY_THRESHOLD = 0.1;
export function getVisibleCommentIdsFallback(
scrollContainer: HTMLElement | null,
elementsById: ReadonlyMap<string, HTMLElement>
): string[] {
if (!scrollContainer || elementsById.size === 0) return [];
const rootRect = scrollContainer.getBoundingClientRect();
const visibleIds: string[] = [];
for (const [commentId, element] of elementsById) {
const rect = element.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) continue;
const visibleWidth = Math.min(rect.right, rootRect.right) - Math.max(rect.left, rootRect.left);
const visibleHeight = Math.min(rect.bottom, rootRect.bottom) - Math.max(rect.top, rootRect.top);
if (visibleWidth <= 0 || visibleHeight <= 0) continue;
if (visibleHeight / rect.height < VISIBILITY_THRESHOLD) continue;
visibleIds.push(commentId);
}
return visibleIds;
}
/**
* Marks task comments as read based on viewport visibility.
*
* Uses IntersectionObserver (via useViewportObserver) to detect which
* comment elements are visible in the scroll container and records
* their individual IDs as read via per-comment ID tracking.
*
* Each comment element should be registered via the returned
* `registerComment(commentId)` ref callback.
*
* Only comments that have actually been scrolled into view are marked
* as read — fixes the bug where DESC-sorted comments caused all
* comments to be marked read when the newest was visible at the top.
*/
export function useViewportCommentRead({
teamName,
taskId,
scrollContainer,
}: UseViewportCommentReadOptions): {
/** Ref callback factory. Call with the comment's unique ID. */
registerComment: (commentId: string) => (el: HTMLElement | null) => void;
/**
* Flush all observed comment IDs now. Call on dialog close
* as a safety fallback (e.g. if IO did not fire for portal reasons).
*/
flush: () => void;
} {
const seenIdsRef = useRef<Set<string>>(new Set());
const commentElementsRef = useRef<Map<string, HTMLElement>>(new Map());
const teamNameRef = useRef(teamName);
const taskIdRef = useRef(taskId);
useEffect(() => {
teamNameRef.current = teamName;
taskIdRef.current = taskId;
}, [teamName, taskId]);
// Reset tracked state when team/task changes
useEffect(() => {
seenIdsRef.current = new Set();
commentElementsRef.current.clear();
}, [teamName, taskId]);
const persistSeen = useCallback(() => {
if (seenIdsRef.current.size > 0) {
markCommentsRead(teamNameRef.current, taskIdRef.current, Array.from(seenIdsRef.current));
}
}, []);
const handleVisibleChange = useCallback(
(visibleValues: string[]) => {
let changed = false;
for (const id of visibleValues) {
if (id && !seenIdsRef.current.has(id)) {
seenIdsRef.current.add(id);
changed = true;
}
}
if (changed) {
persistSeen();
}
},
[persistSeen]
);
const { registerElement } = useViewportObserver({
root: scrollContainer,
threshold: VISIBILITY_THRESHOLD,
onVisibleChange: handleVisibleChange,
});
const registerComment = useCallback(
(commentId: string) => {
const registerObservedElement = registerElement(commentId);
return (el: HTMLElement | null) => {
if (el) {
commentElementsRef.current.set(commentId, el);
} else {
commentElementsRef.current.delete(commentId);
}
registerObservedElement(el);
};
},
[registerElement]
);
const flush = useCallback(() => {
const fallbackVisibleIds = getVisibleCommentIdsFallback(
scrollContainer,
commentElementsRef.current
);
for (const commentId of fallbackVisibleIds) {
seenIdsRef.current.add(commentId);
}
persistSeen();
}, [persistSeen, scrollContainer]);
return { registerComment, flush };
}