import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type FocusEvent, type JSX, type MouseEvent, type WheelEvent } from "react"; import type { AgentMessageLocatorItem } from "./agentTranscriptModel"; import type { AgentConversationFollowEndMode } from "../agentConversationFollowEndController"; import { findMessageLocatorScrollParent, useAgentMessageLocatorSelection, type AgentMessageLocatorVirtualSelectionSource } from "./useAgentMessageLocatorSelection"; export { findMessageLocatorScrollParent } from "./useAgentMessageLocatorSelection"; const AGENT_MESSAGE_LOCATOR_PANEL_FADE_MS = 160; const AGENT_MESSAGE_LOCATOR_ITEM_SPACING_PX = 30; const AGENT_MESSAGE_LOCATOR_HIT_SIZE_PX = 36; const AGENT_MESSAGE_LOCATOR_BOTTOM_SAFE_INSET_MAX_PX = 48; const AGENT_MESSAGE_LOCATOR_SCROLL_DURATION_MS = 160; const agentMessageLocatorScrollAnimations = new WeakMap(); interface AgentMessageLocatorVisibleFrame { heightPx: number; topOffsetPx: number; } export function AgentMessageLocatorRail({ followEndMode, items, isVisible = true, label, onLocate, virtualSelectionSource }: { followEndMode?: AgentConversationFollowEndMode; items: readonly AgentMessageLocatorItem[]; isVisible?: boolean; label?: string; onLocate: (item: AgentMessageLocatorItem) => void; virtualSelectionSource?: AgentMessageLocatorVirtualSelectionSource; }): JSX.Element | null { const locatorRef = useRef(null); const locatorViewportRef = useRef(null); const closePanelTimeoutRef = useRef(null); const [isPanelOpen, setIsPanelOpen] = useState(false); const [shouldRenderPanel, setShouldRenderPanel] = useState(false); const [activeKey, setActiveKey] = useState(null); const { selectItem, selectedKey } = useAgentMessageLocatorSelection({ followEndMode, items, isVisible, locatorRef, virtualSelectionSource }); const previousAgentResponseByKeyRef = useRef | null>(null); const [unreadAgentResponseKeys, setUnreadAgentResponseKeys] = useState< ReadonlySet >(new Set()); const [visibleFrame, setVisibleFrame] = useState(null); useEffect(() => { if (isPanelOpen) { setShouldRenderPanel(true); return; } // timing: keep the panel mounted through its close-fade transition const timeout = window.setTimeout( () => setShouldRenderPanel(false), AGENT_MESSAGE_LOCATOR_PANEL_FADE_MS ); return () => window.clearTimeout(timeout); }, [isPanelOpen]); useEffect( () => () => { if (closePanelTimeoutRef.current !== null) { window.clearTimeout(closePanelTimeoutRef.current); } }, [] ); useEffect(() => { const previousAgentResponseByKey = previousAgentResponseByKeyRef.current; const currentKeys = new Set(items.map((item) => item.key)); setUnreadAgentResponseKeys((currentUnreadKeys) => { let nextUnreadKeys: Set | null = null; const ensureNextUnreadKeys = (): Set => { if (!nextUnreadKeys) { nextUnreadKeys = new Set(currentUnreadKeys); } return nextUnreadKeys; }; for (const key of currentUnreadKeys) { if (!currentKeys.has(key)) { ensureNextUnreadKeys().delete(key); } } if (previousAgentResponseByKey) { for (const item of items) { const hadAgentResponse = previousAgentResponseByKey.get(item.key) ?? false; if ( previousAgentResponseByKey.has(item.key) && item.hasAgentResponse && !hadAgentResponse && item.key !== selectedKey ) { ensureNextUnreadKeys().add(item.key); } } } return nextUnreadKeys ?? currentUnreadKeys; }); previousAgentResponseByKeyRef.current = new Map( items.map((item) => [item.key, item.hasAgentResponse]) ); }, [items, selectedKey]); useEffect(() => { if (!selectedKey) { return; } setUnreadAgentResponseKeys((currentUnreadKeys) => { if (!currentUnreadKeys.has(selectedKey)) { return currentUnreadKeys; } const nextUnreadKeys = new Set(currentUnreadKeys); nextUnreadKeys.delete(selectedKey); return nextUnreadKeys; }); }, [selectedKey]); useLayoutEffect(() => { if (!isVisible) { return; } const locator = locatorRef.current; const scrollParent = locator ? findMessageLocatorScrollParent(locator) : null; if (!scrollParent) { return; } let animationFrame: number | null = null; const updateVisibleFrame = (): void => { animationFrame = null; const nextFrame = readMessageLocatorVisibleFrame(scrollParent); setVisibleFrame((current) => current?.heightPx === nextFrame.heightPx && current.topOffsetPx === nextFrame.topOffsetPx ? current : nextFrame ); }; const scheduleUpdate = (): void => { if (animationFrame !== null) { return; } animationFrame = window.requestAnimationFrame(updateVisibleFrame); }; scheduleUpdate(); const resizeObserver = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(scheduleUpdate); resizeObserver?.observe(scrollParent); window.addEventListener("resize", scheduleUpdate); return () => { resizeObserver?.disconnect(); window.removeEventListener("resize", scheduleUpdate); if (animationFrame !== null) { window.cancelAnimationFrame(animationFrame); } }; }, [isVisible, items.length]); useLayoutEffect(() => { if (!isVisible) { return; } const selectedIndex = selectedKey ? items.findIndex((item) => item.key === selectedKey) : -1; const viewport = locatorViewportRef.current; if (selectedIndex < 0 || !viewport) { return; } const railHeight = (items.length - 1) * AGENT_MESSAGE_LOCATOR_ITEM_SPACING_PX + AGENT_MESSAGE_LOCATOR_HIT_SIZE_PX; const viewportHeight = Math.min( railHeight, visibleFrame?.heightPx ?? railHeight ); scrollMessageLocatorViewportToIndex( viewport, selectedIndex, viewportHeight ); }, [isVisible, items, selectedKey, visibleFrame]); if (items.length < 2) { return null; } const railHeight = (items.length - 1) * AGENT_MESSAGE_LOCATOR_ITEM_SPACING_PX + AGENT_MESSAGE_LOCATOR_HIT_SIZE_PX; const viewportHeight = visibleFrame === null ? railHeight : Math.min(railHeight, visibleFrame.heightPx); const activeOrSelectedKey = activeKey ?? selectedKey; const markItemRead = (itemKey: string): void => { setUnreadAgentResponseKeys((currentUnreadKeys) => { if (!currentUnreadKeys.has(itemKey)) { return currentUnreadKeys; } const nextUnreadKeys = new Set(currentUnreadKeys); nextUnreadKeys.delete(itemKey); return nextUnreadKeys; }); }; const handleLocateItem = (item: AgentMessageLocatorItem): void => { selectItem(item.key); setActiveKey(item.key); markItemRead(item.key); onLocate(item); }; const itemFromPointerEvent = ( event: MouseEvent ): AgentMessageLocatorItem | null => { const viewport = locatorViewportRef.current; if (!viewport) { return null; } const rect = viewport.getBoundingClientRect(); const pointerY = event.clientY - rect.top + viewport.scrollTop; const index = Math.round( (pointerY - AGENT_MESSAGE_LOCATOR_HIT_SIZE_PX / 2) / AGENT_MESSAGE_LOCATOR_ITEM_SPACING_PX ); return items[Math.max(0, Math.min(items.length - 1, index))] ?? null; }; const handleViewportClick = (event: MouseEvent): void => { if ((event.target as HTMLElement | null)?.closest("button")) { return; } const item = itemFromPointerEvent(event); if (item) { handleLocateItem(item); } }; const handleViewportMouseMove = (event: MouseEvent): void => { const item = itemFromPointerEvent(event); if (item) { setActiveKey(item.key); } }; const openPanel = (): void => { if (closePanelTimeoutRef.current !== null) { window.clearTimeout(closePanelTimeoutRef.current); closePanelTimeoutRef.current = null; } setIsPanelOpen(true); }; const closePanelSoon = (): void => { if (closePanelTimeoutRef.current !== null) { window.clearTimeout(closePanelTimeoutRef.current); } // timing: delay closing so pointer can move from trigger into panel content closePanelTimeoutRef.current = window.setTimeout(() => { closePanelTimeoutRef.current = null; setIsPanelOpen(false); setActiveKey(null); }, 120); }; const closePanelNow = (): void => { if (closePanelTimeoutRef.current !== null) { window.clearTimeout(closePanelTimeoutRef.current); closePanelTimeoutRef.current = null; } setIsPanelOpen(false); setActiveKey(null); }; const handleBlurCapture = (event: FocusEvent): void => { const nextTarget = event.relatedTarget; if ( nextTarget instanceof Node && event.currentTarget.contains(nextTarget) ) { return; } closePanelNow(); }; return ( ); } function scrollMessageLocatorViewportToIndex( viewport: HTMLElement, selectedIndex: number, viewportHeight: number ): void { const selectedTop = selectedIndex * AGENT_MESSAGE_LOCATOR_ITEM_SPACING_PX - AGENT_MESSAGE_LOCATOR_HIT_SIZE_PX / 2; const selectedBottom = selectedTop + AGENT_MESSAGE_LOCATOR_HIT_SIZE_PX; const padding = AGENT_MESSAGE_LOCATOR_HIT_SIZE_PX; const currentTop = viewport.scrollTop; const currentBottom = currentTop + viewportHeight; if (selectedTop < currentTop + padding) { viewport.scrollTop = Math.max(0, selectedTop - padding); return; } if (selectedBottom > currentBottom - padding) { viewport.scrollTop = Math.max(0, selectedBottom - viewportHeight + padding); } } export function scrollTranscriptRowIntoView( row: HTMLElement, scrollParent: HTMLElement | null ): void { if (!scrollParent) { row.scrollIntoView({ block: "center", behavior: "smooth" }); return; } const targetScrollTop = targetScrollTopForTranscriptRow(row, scrollParent); animateMessageLocatorScroll(scrollParent, targetScrollTop); } function targetScrollTopForTranscriptRow( row: HTMLElement, scrollParent: HTMLElement ): number { const rowRect = row.getBoundingClientRect(); const scrollParentRect = scrollParent.getBoundingClientRect(); const visibleFrame = readMessageLocatorVisibleFrame(scrollParent); const rowCenter = rowRect.top - scrollParentRect.top + scrollParent.scrollTop + rowRect.height / 2; const maxScrollTop = Math.max( 0, scrollParent.scrollHeight - scrollParent.clientHeight ); const targetScrollTop = rowCenter - visibleFrame.topOffsetPx - visibleFrame.heightPx / 2; return Math.min(maxScrollTop, Math.max(0, targetScrollTop)); } function animateMessageLocatorScroll( scrollParent: HTMLElement, targetScrollTop: number ): void { const previousAnimation = agentMessageLocatorScrollAnimations.get(scrollParent); if (previousAnimation !== undefined) { window.cancelAnimationFrame(previousAnimation); } const startScrollTop = scrollParent.scrollTop; const delta = targetScrollTop - startScrollTop; const startedAt = performance.now(); const step = (now: number): void => { const progress = Math.min( 1, (now - startedAt) / AGENT_MESSAGE_LOCATOR_SCROLL_DURATION_MS ); scrollParent.scrollTop = startScrollTop + delta * progress; if (progress >= 1) { scrollParent.scrollTop = targetScrollTop; agentMessageLocatorScrollAnimations.delete(scrollParent); return; } agentMessageLocatorScrollAnimations.set( scrollParent, window.requestAnimationFrame(step) ); }; agentMessageLocatorScrollAnimations.set( scrollParent, window.requestAnimationFrame(step) ); } function readMessageLocatorVisibleFrame( scrollParent: HTMLElement ): AgentMessageLocatorVisibleFrame { const style = window.getComputedStyle(scrollParent); const topOffsetPx = parseCssPx(style.scrollPaddingTop); const bottomOffsetPx = Math.min( parseCssPx(style.scrollPaddingBottom), AGENT_MESSAGE_LOCATOR_BOTTOM_SAFE_INSET_MAX_PX ); return { heightPx: Math.max( 0, scrollParent.clientHeight - topOffsetPx - bottomOffsetPx ), topOffsetPx }; } function parseCssPx(value: string): number { const parsed = Number.parseFloat(value); return Number.isFinite(parsed) ? Math.max(0, parsed) : 0; } function containMessageLocatorPanelWheel( event: WheelEvent ): void { event.stopPropagation(); if (event.deltaY === 0) { return; } event.preventDefault(); event.currentTarget.scrollTop += event.deltaY; }