import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useAutoScrollBottom } from '@renderer/hooks/useAutoScrollBottom';import { useTabNavigationController } from '@renderer/hooks/useTabNavigationController';import { useTabUI } from '@renderer/hooks/useTabUI';import { useVisibleAIGroup } from '@renderer/hooks/useVisibleAIGroup';import { useStore } from '@renderer/store';import { useVirtualizer } from '@tanstack/react-virtual';import { useShallow } from 'zustand/react/shallow'; import { SessionContextPanel } from './SessionContextPanel/index';import { ChatHistoryEmptyState } from './ChatHistoryEmptyState';import { ChatHistoryItem } from './ChatHistoryItem';import { ChatHistoryLoadingState } from './ChatHistoryLoadingState'; import type { ContextInjection } from '@renderer/types/contextInjection'; /** * Waits for two requestAnimationFrame cycles, allowing the virtualizer to render. */function waitForDoubleRaf(): Promise<void> { return new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())) );} interface ChatHistoryProps { /** Tab ID for per-tab state isolation (scroll position, deep links) */ tabId?: string;} export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => { const VIRTUALIZATION_THRESHOLD = 120; const ESTIMATED_CHAT_ITEM_HEIGHT = 260; // Per-tab UI state (context panel, scroll position, expansion) from useTabUI const { isContextPanelVisible, setContextPanelVisible, savedScrollTop, saveScrollPosition, expandAIGroup, expandSubagentTrace, selectedContextPhase, setSelectedContextPhase, } = useTabUI(); // Global store subscriptions (shared data) const { searchQuery, currentSearchIndex, searchMatches, openTabs, activeTabId, consumeTabNavigation, setSearchQuery, syncSearchMatchesWithRendered, selectSearchMatch, setTabVisibleAIGroup, } = useStore( useShallow((s) => ({ searchQuery: s.searchQuery, currentSearchIndex: s.currentSearchIndex, searchMatches: s.searchMatches, openTabs: s.openTabs, activeTabId: s.activeTabId, consumeTabNavigation: s.consumeTabNavigation, setSearchQuery: s.setSearchQuery, syncSearchMatchesWithRendered: s.syncSearchMatchesWithRendered, selectSearchMatch: s.selectSearchMatch, setTabVisibleAIGroup: s.setTabVisibleAIGroup, })) ); // Per-tab session data (each tab renders its own session independently) const tabData = useStore( useShallow((s) => { const td = tabId ? s.tabSessionData[tabId] : null; return { conversation: td?.conversation ?? s.conversation, conversationLoading: td?.conversationLoading ?? s.conversationLoading, sessionContextStats: td?.sessionContextStats ?? s.sessionContextStats, sessionPhaseInfo: td?.sessionPhaseInfo ?? s.sessionPhaseInfo, sessionDetail: td?.sessionDetail ?? s.sessionDetail, }; }) ); const { conversation, conversationLoading, sessionContextStats, sessionPhaseInfo, sessionDetail, } = tabData; // State for Context button hover (local state OK - doesn't need per-tab isolation) const [isContextButtonHovered, setIsContextButtonHovered] = useState(false); // Determine if this tab instance is currently active // Use tabId prop if provided, otherwise fall back to activeTabId (for backwards compatibility) const effectiveTabId = tabId ?? activeTabId; const isThisTabActive = effectiveTabId === activeTabId; // Get THIS tab's pending navigation request const thisTab = effectiveTabId ? openTabs.find((t) => t.id === effectiveTabId) : null; const pendingNavigation = thisTab?.pendingNavigation; // Compute all accumulated context injections (phase-aware) const { allContextInjections, lastAiGroupTotalTokens } = useMemo(() => { if (!sessionContextStats || !conversation?.items.length) { return { allContextInjections: [] as ContextInjection[], lastAiGroupTotalTokens: undefined }; } // Determine which phase to show const effectivePhase = selectedContextPhase; // If a specific phase is selected, find the last AI group in that phase let targetAiGroupId: string | undefined; if (effectivePhase !== null && sessionPhaseInfo) { const phase = sessionPhaseInfo.phases.find((p) => p.phaseNumber === effectivePhase); if (phase) { targetAiGroupId = phase.lastAIGroupId; } } // Default: use the last AI group overall if (!targetAiGroupId) { const lastAiItem = [...conversation.items].reverse().find((item) => item.type === 'ai'); if (lastAiItem?.type !== 'ai') { return { allContextInjections: [] as ContextInjection[], lastAiGroupTotalTokens: undefined, }; } targetAiGroupId = lastAiItem.group.id; } const stats = sessionContextStats.get(targetAiGroupId); const injections = stats?.accumulatedInjections ?? []; // Get total tokens from the target AI group let totalTokens: number | undefined; const targetItem = conversation.items.find( (item) => item.type === 'ai' && item.group.id === targetAiGroupId ); if (targetItem?.type === 'ai') { const responses = targetItem.group.responses || []; for (let i = responses.length - 1; i >= 0; i--) { const msg = responses[i]; if (msg.type === 'assistant' && msg.usage) { const usage = msg.usage; totalTokens = (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0); break; } } } return { allContextInjections: injections, lastAiGroupTotalTokens: totalTokens }; }, [sessionContextStats, conversation, selectedContextPhase, sessionPhaseInfo]); // State for navigation highlight (blue, used for Turn navigation from CLAUDE.md panel) const [isNavigationHighlight, setIsNavigationHighlight] = useState(false); const navigationHighlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); // Refs map for AI groups, chat items, and individual tool items (for scrolling) const aiGroupRefs = useRef<Map<string, HTMLElement>>(new Map()); const chatItemRefs = useRef<Map<string, HTMLElement>>(new Map()); const toolItemRefs = useRef<Map<string, HTMLElement>>(new Map()); // Shared scroll container ref - used by both auto-scroll and navigation coordinator const scrollContainerRef = useRef<HTMLDivElement>(null); const isSearchActive = searchQuery.trim().length > 0; const shouldVirtualize = (conversation?.items.length ?? 0) >= VIRTUALIZATION_THRESHOLD; const emptyRenderedSyncCountRef = useRef(0); const setSearchQueryForTab = useCallback( (query: string): void => { setSearchQuery(query, conversation); }, [setSearchQuery, conversation] ); const groupIndexMap = useMemo(() => { const map = new Map<string, number>(); if (!conversation?.items) { return map; } conversation.items.forEach((item, index) => { map.set(item.group.id, index); }); return map; }, [conversation]); const rowVirtualizer = useVirtualizer({ count: shouldVirtualize ? (conversation?.items.length ?? 0) : 0, getScrollElement: () => scrollContainerRef.current, estimateSize: () => ESTIMATED_CHAT_ITEM_HEIGHT, overscan: 8, measureElement: (element) => element.getBoundingClientRect().height, }); const ensureGroupVisible = useCallback( async (groupId: string) => { if (!shouldVirtualize) { return; } const index = groupIndexMap.get(groupId); if (index === undefined) { return; } rowVirtualizer.scrollToIndex(index, { align: 'center' }); // Wait 2 RAF frames so the virtualizer has time to render the target row await waitForDoubleRaf(); }, [groupIndexMap, rowVirtualizer, shouldVirtualize] ); // Sticky context button height (py-3 = 12px padding * 2 + button height ~28px + pt-3 = 12px) // Total: approximately 52px, round up to 60px for safety const STICKY_BUTTON_OFFSET = allContextInjections.length > 0 ? 60 : 0; // Unified navigation controller - replaces useNavigationCoordinator + useSearchContextNavigation // Must be created before useAutoScrollBottom so we can pass shouldDisableAutoScroll const { highlightedGroupId, setHighlightedGroupId, highlightToolUseId: controllerToolUseId, isSearchHighlight, highlightColor, shouldDisableAutoScroll, } = useTabNavigationController({ isActiveTab: isThisTabActive, pendingNavigation, conversation, conversationLoading, consumeTabNavigation, tabId: effectiveTabId ?? '', aiGroupRefs, chatItemRefs, toolItemRefs, expandAIGroup, expandSubagentTrace, scrollContainerRef, stickyOffset: STICKY_BUTTON_OFFSET, ensureGroupVisible, setSearchQuery: setSearchQueryForTab, selectSearchMatch, }); // Local tool highlight for context panel navigation (separate from controller) const [contextNavToolUseId, setContextNavToolUseId] = useState<string | null>(null); const effectiveHighlightToolUseId = controllerToolUseId ?? contextNavToolUseId ?? undefined; // Use blue for context panel tool navigation, otherwise use controller's color const effectiveHighlightColor = contextNavToolUseId ? ('blue' as const) : highlightColor; // Keep search match indices aligned with this tab's rendered conversation. // This avoids stale/global match lists after tab switches or in-place refreshes. useEffect(() => { if (!isThisTabActive || !searchQuery.trim()) { return; } setSearchQuery(searchQuery, conversation); }, [isThisTabActive, searchQuery, conversation, setSearchQuery]); // Canonicalize matches from rendered mark elements (DOM order). // This guarantees that nth navigation follows the exact nth visible highlight. // Skip when virtualizing: only a subset of items are rendered, so DOM-based sync // would produce an incomplete match list. The store-level matches are already correct. useEffect(() => { if (!isThisTabActive || !isSearchActive || !conversation || shouldVirtualize) { emptyRenderedSyncCountRef.current = 0; return; } let frameA = 0; let frameB = 0; let cancelled = false; const run = (): void => { const container = scrollContainerRef.current; if (!container || cancelled) return; const renderedMatches: { itemId: string; matchIndexInItem: number }[] = []; const marks = container.querySelectorAll<HTMLElement>( 'mark[data-search-item-id][data-search-match-index]' ); for (const mark of marks) { const itemId = mark.dataset.searchItemId; const matchIndexRaw = mark.dataset.searchMatchIndex; const matchIndex = matchIndexRaw !== undefined ? Number(matchIndexRaw) : Number.NaN; if (!itemId || !Number.isFinite(matchIndex)) continue; renderedMatches.push({ itemId, matchIndexInItem: matchIndex }); } // Prevent transient "0 marks" snapshots during mount from wiping results. if (renderedMatches.length === 0 && searchMatches.length > 0) { emptyRenderedSyncCountRef.current += 1; if (emptyRenderedSyncCountRef.current < 3) { return; } } else { emptyRenderedSyncCountRef.current = 0; } syncSearchMatchesWithRendered(renderedMatches); }; // Wait for highlight marks to be mounted and stabilized. frameA = requestAnimationFrame(() => { frameB = requestAnimationFrame(run); }); return () => { cancelled = true; cancelAnimationFrame(frameA); cancelAnimationFrame(frameB); }; }, [ isThisTabActive, isSearchActive, shouldVirtualize, conversation, currentSearchIndex, searchMatches, syncSearchMatchesWithRendered, ]); // Track shouldDisableAutoScroll transitions for scroll restore coordination const prevShouldDisableRef = useRef(shouldDisableAutoScroll); const { registerAIGroupRef } = useVisibleAIGroup({ onVisibleChange: (aiGroupId) => { if (effectiveTabId) { setTabVisibleAIGroup(effectiveTabId, aiGroupId); } }, threshold: 0.5, rootRef: scrollContainerRef, }); // Auto-follow when conversation updates, but only if the user was already near bottom. // This preserves manual reading position when the user scrolls up. // Disabled during navigation to prevent conflicts with deep-link/search scrolling. useAutoScrollBottom([conversation], { threshold: 150, smoothDuration: 300, autoBehavior: 'auto', disabled: shouldDisableAutoScroll, externalRef: scrollContainerRef, resetKey: effectiveTabId, }); // Callback to register AI group refs (combines with visibility hook) const registerAIGroupRefCombined = useCallback( (groupId: string) => { const visibilityRef = registerAIGroupRef(groupId); return (el: HTMLElement | null) => { if (typeof visibilityRef === 'function') visibilityRef(el); if (el) aiGroupRefs.current.set(groupId, el); else aiGroupRefs.current.delete(groupId); }; }, [registerAIGroupRef] ); // Handler to navigate to a specific turn (AI group) from CLAUDE.md panel const handleNavigateToTurn = useCallback( (turnIndex: number) => { if (!conversation) return; const targetItem = conversation.items.find( (item) => item.type === 'ai' && item.group.turnIndex === turnIndex ); if (targetItem?.type !== 'ai') return; const run = async (): Promise<void> => { const groupId = targetItem.group.id; await ensureGroupVisible(groupId); const element = aiGroupRefs.current.get(groupId); if (!element) return; element.scrollIntoView({ behavior: 'smooth', block: 'center' }); setHighlightedGroupId(groupId); setIsNavigationHighlight(true); if (navigationHighlightTimerRef.current) { clearTimeout(navigationHighlightTimerRef.current); } navigationHighlightTimerRef.current = setTimeout(() => { setHighlightedGroupId(null); setIsNavigationHighlight(false); navigationHighlightTimerRef.current = null; }, 2000); }; void run(); }, [conversation, ensureGroupVisible, setHighlightedGroupId] ); // Handler to navigate to a user message group (preceding the AI group at turnIndex) const handleNavigateToUserGroup = useCallback( (turnIndex: number) => { if (!conversation) return; const aiItemIndex = conversation.items.findIndex( (item) => item.type === 'ai' && item.group.turnIndex === turnIndex ); if (aiItemIndex < 0) return; // Find the user item preceding this AI group const prevItem = aiItemIndex > 0 ? conversation.items[aiItemIndex - 1] : null; if (prevItem?.type !== 'user') return; const run = async (): Promise<void> => { const groupId = prevItem.group.id; await ensureGroupVisible(groupId); const element = chatItemRefs.current.get(groupId); if (!element) return; element.scrollIntoView({ behavior: 'smooth', block: 'center' }); setHighlightedGroupId(groupId); setIsNavigationHighlight(true); if (navigationHighlightTimerRef.current) { clearTimeout(navigationHighlightTimerRef.current); } navigationHighlightTimerRef.current = setTimeout(() => { setHighlightedGroupId(null); setIsNavigationHighlight(false); navigationHighlightTimerRef.current = null; }, 2000); }; void run(); }, [conversation, ensureGroupVisible, setHighlightedGroupId] ); // Handler to navigate to a specific tool within a turn from context panel const handleNavigateToTool = useCallback( (turnIndex: number, toolUseId: string) => { if (!conversation) return; const targetItem = conversation.items.find( (item) => item.type === 'ai' && item.group.turnIndex === turnIndex ); if (targetItem?.type !== 'ai') return; const run = async (): Promise<void> => { const groupId = targetItem.group.id; await ensureGroupVisible(groupId); // Set group + tool highlight immediately setHighlightedGroupId(groupId); setIsNavigationHighlight(true); setContextNavToolUseId(toolUseId); // Wait for tool element to appear in DOM (up to 500ms) let toolElement: HTMLElement | undefined; const startTime = Date.now(); while (Date.now() - startTime < 500) { toolElement = toolItemRefs.current.get(toolUseId); if (toolElement) break; await new Promise((resolve) => setTimeout(resolve, 50)); } // Scroll to tool element, or fall back to AI group const scrollTarget = toolElement ?? aiGroupRefs.current.get(groupId); if (scrollTarget) { scrollTarget.scrollIntoView({ behavior: 'smooth', block: 'center' }); } // Clear highlight after 2s if (navigationHighlightTimerRef.current) { clearTimeout(navigationHighlightTimerRef.current); } navigationHighlightTimerRef.current = setTimeout(() => { setHighlightedGroupId(null); setIsNavigationHighlight(false); setContextNavToolUseId(null); navigationHighlightTimerRef.current = null; }, 2000); }; void run(); }, [conversation, ensureGroupVisible, setHighlightedGroupId] ); // Scroll to current search result when it changes useEffect(() => { const currentMatch = currentSearchIndex >= 0 ? searchMatches[currentSearchIndex] : null; if (!currentMatch) return; let frameId = 0; let attempt = 0; let cancelled = false; /** * Promote a mark element to "current" (demote any previous) and scroll to it. */ const promoteAndScroll = (el: HTMLElement): void => {Lines 1–500 of 836. Load more
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useAutoScrollBottom } from '@renderer/hooks/useAutoScrollBottom'; import { useTabNavigationController } from '@renderer/hooks/useTabNavigationController'; import { useTabUI } from '@renderer/hooks/useTabUI'; import { useVisibleAIGroup } from '@renderer/hooks/useVisibleAIGroup'; import { useStore } from '@renderer/store'; import { useVirtualizer } from '@tanstack/react-virtual'; import { useShallow } from 'zustand/react/shallow'; import { SessionContextPanel } from './SessionContextPanel/index'; import { ChatHistoryEmptyState } from './ChatHistoryEmptyState'; import { ChatHistoryItem } from './ChatHistoryItem'; import { ChatHistoryLoadingState } from './ChatHistoryLoadingState'; import type { ContextInjection } from '@renderer/types/contextInjection'; /** * Waits for two requestAnimationFrame cycles, allowing the virtualizer to render. */ function waitForDoubleRaf(): Promise<void> { return new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())) ); } interface ChatHistoryProps { /** Tab ID for per-tab state isolation (scroll position, deep links) */ tabId?: string; } export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => { const VIRTUALIZATION_THRESHOLD = 120; const ESTIMATED_CHAT_ITEM_HEIGHT = 260; // Per-tab UI state (context panel, scroll position, expansion) from useTabUI const { isContextPanelVisible, setContextPanelVisible, savedScrollTop, saveScrollPosition, expandAIGroup, expandSubagentTrace, selectedContextPhase, setSelectedContextPhase, } = useTabUI(); // Global store subscriptions (shared data) const { searchQuery, currentSearchIndex, searchMatches, openTabs, activeTabId, consumeTabNavigation, setSearchQuery, syncSearchMatchesWithRendered, selectSearchMatch, setTabVisibleAIGroup, } = useStore( useShallow((s) => ({ searchQuery: s.searchQuery, currentSearchIndex: s.currentSearchIndex, searchMatches: s.searchMatches, openTabs: s.openTabs, activeTabId: s.activeTabId, consumeTabNavigation: s.consumeTabNavigation, setSearchQuery: s.setSearchQuery, syncSearchMatchesWithRendered: s.syncSearchMatchesWithRendered, selectSearchMatch: s.selectSearchMatch, setTabVisibleAIGroup: s.setTabVisibleAIGroup, })) ); // Per-tab session data (each tab renders its own session independently) const tabData = useStore( useShallow((s) => { const td = tabId ? s.tabSessionData[tabId] : null; return { conversation: td?.conversation ?? s.conversation, conversationLoading: td?.conversationLoading ?? s.conversationLoading, sessionContextStats: td?.sessionContextStats ?? s.sessionContextStats, sessionPhaseInfo: td?.sessionPhaseInfo ?? s.sessionPhaseInfo, sessionDetail: td?.sessionDetail ?? s.sessionDetail, }; }) ); const { conversation, conversationLoading, sessionContextStats, sessionPhaseInfo, sessionDetail, } = tabData; // State for Context button hover (local state OK - doesn't need per-tab isolation) const [isContextButtonHovered, setIsContextButtonHovered] = useState(false); // Determine if this tab instance is currently active // Use tabId prop if provided, otherwise fall back to activeTabId (for backwards compatibility) const effectiveTabId = tabId ?? activeTabId; const isThisTabActive = effectiveTabId === activeTabId; // Get THIS tab's pending navigation request const thisTab = effectiveTabId ? openTabs.find((t) => t.id === effectiveTabId) : null; const pendingNavigation = thisTab?.pendingNavigation; // Compute all accumulated context injections (phase-aware) const { allContextInjections, lastAiGroupTotalTokens } = useMemo(() => { if (!sessionContextStats || !conversation?.items.length) { return { allContextInjections: [] as ContextInjection[], lastAiGroupTotalTokens: undefined }; } // Determine which phase to show const effectivePhase = selectedContextPhase; // If a specific phase is selected, find the last AI group in that phase let targetAiGroupId: string | undefined; if (effectivePhase !== null && sessionPhaseInfo) { const phase = sessionPhaseInfo.phases.find((p) => p.phaseNumber === effectivePhase); if (phase) { targetAiGroupId = phase.lastAIGroupId; } } // Default: use the last AI group overall if (!targetAiGroupId) { const lastAiItem = [...conversation.items].reverse().find((item) => item.type === 'ai'); if (lastAiItem?.type !== 'ai') { return { allContextInjections: [] as ContextInjection[], lastAiGroupTotalTokens: undefined, }; } targetAiGroupId = lastAiItem.group.id; } const stats = sessionContextStats.get(targetAiGroupId); const injections = stats?.accumulatedInjections ?? []; // Get total tokens from the target AI group let totalTokens: number | undefined; const targetItem = conversation.items.find( (item) => item.type === 'ai' && item.group.id === targetAiGroupId ); if (targetItem?.type === 'ai') { const responses = targetItem.group.responses || []; for (let i = responses.length - 1; i >= 0; i--) { const msg = responses[i]; if (msg.type === 'assistant' && msg.usage) { const usage = msg.usage; totalTokens = (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0); break; } } } return { allContextInjections: injections, lastAiGroupTotalTokens: totalTokens }; }, [sessionContextStats, conversation, selectedContextPhase, sessionPhaseInfo]); // State for navigation highlight (blue, used for Turn navigation from CLAUDE.md panel) const [isNavigationHighlight, setIsNavigationHighlight] = useState(false); const navigationHighlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); // Refs map for AI groups, chat items, and individual tool items (for scrolling) const aiGroupRefs = useRef<Map<string, HTMLElement>>(new Map()); const chatItemRefs = useRef<Map<string, HTMLElement>>(new Map()); const toolItemRefs = useRef<Map<string, HTMLElement>>(new Map()); // Shared scroll container ref - used by both auto-scroll and navigation coordinator const scrollContainerRef = useRef<HTMLDivElement>(null); const isSearchActive = searchQuery.trim().length > 0; const shouldVirtualize = (conversation?.items.length ?? 0) >= VIRTUALIZATION_THRESHOLD; const emptyRenderedSyncCountRef = useRef(0); const setSearchQueryForTab = useCallback( (query: string): void => { setSearchQuery(query, conversation); }, [setSearchQuery, conversation] ); const groupIndexMap = useMemo(() => { const map = new Map<string, number>(); if (!conversation?.items) { return map; } conversation.items.forEach((item, index) => { map.set(item.group.id, index); }); return map; }, [conversation]); const rowVirtualizer = useVirtualizer({ count: shouldVirtualize ? (conversation?.items.length ?? 0) : 0, getScrollElement: () => scrollContainerRef.current, estimateSize: () => ESTIMATED_CHAT_ITEM_HEIGHT, overscan: 8, measureElement: (element) => element.getBoundingClientRect().height, }); const ensureGroupVisible = useCallback( async (groupId: string) => { if (!shouldVirtualize) { return; } const index = groupIndexMap.get(groupId); if (index === undefined) { return; } rowVirtualizer.scrollToIndex(index, { align: 'center' }); // Wait 2 RAF frames so the virtualizer has time to render the target row await waitForDoubleRaf(); }, [groupIndexMap, rowVirtualizer, shouldVirtualize] ); // Sticky context button height (py-3 = 12px padding * 2 + button height ~28px + pt-3 = 12px) // Total: approximately 52px, round up to 60px for safety const STICKY_BUTTON_OFFSET = allContextInjections.length > 0 ? 60 : 0; // Unified navigation controller - replaces useNavigationCoordinator + useSearchContextNavigation // Must be created before useAutoScrollBottom so we can pass shouldDisableAutoScroll const { highlightedGroupId, setHighlightedGroupId, highlightToolUseId: controllerToolUseId, isSearchHighlight, highlightColor, shouldDisableAutoScroll, } = useTabNavigationController({ isActiveTab: isThisTabActive, pendingNavigation, conversation, conversationLoading, consumeTabNavigation, tabId: effectiveTabId ?? '', aiGroupRefs, chatItemRefs, toolItemRefs, expandAIGroup, expandSubagentTrace, scrollContainerRef, stickyOffset: STICKY_BUTTON_OFFSET, ensureGroupVisible, setSearchQuery: setSearchQueryForTab, selectSearchMatch, }); // Local tool highlight for context panel navigation (separate from controller) const [contextNavToolUseId, setContextNavToolUseId] = useState<string | null>(null); const effectiveHighlightToolUseId = controllerToolUseId ?? contextNavToolUseId ?? undefined; // Use blue for context panel tool navigation, otherwise use controller's color const effectiveHighlightColor = contextNavToolUseId ? ('blue' as const) : highlightColor; // Keep search match indices aligned with this tab's rendered conversation. // This avoids stale/global match lists after tab switches or in-place refreshes. useEffect(() => { if (!isThisTabActive || !searchQuery.trim()) { return; } setSearchQuery(searchQuery, conversation); }, [isThisTabActive, searchQuery, conversation, setSearchQuery]); // Canonicalize matches from rendered mark elements (DOM order). // This guarantees that nth navigation follows the exact nth visible highlight. // Skip when virtualizing: only a subset of items are rendered, so DOM-based sync // would produce an incomplete match list. The store-level matches are already correct. useEffect(() => { if (!isThisTabActive || !isSearchActive || !conversation || shouldVirtualize) { emptyRenderedSyncCountRef.current = 0; return; } let frameA = 0; let frameB = 0; let cancelled = false; const run = (): void => { const container = scrollContainerRef.current; if (!container || cancelled) return; const renderedMatches: { itemId: string; matchIndexInItem: number }[] = []; const marks = container.querySelectorAll<HTMLElement>( 'mark[data-search-item-id][data-search-match-index]' ); for (const mark of marks) { const itemId = mark.dataset.searchItemId; const matchIndexRaw = mark.dataset.searchMatchIndex; const matchIndex = matchIndexRaw !== undefined ? Number(matchIndexRaw) : Number.NaN; if (!itemId || !Number.isFinite(matchIndex)) continue; renderedMatches.push({ itemId, matchIndexInItem: matchIndex }); } // Prevent transient "0 marks" snapshots during mount from wiping results. if (renderedMatches.length === 0 && searchMatches.length > 0) { emptyRenderedSyncCountRef.current += 1; if (emptyRenderedSyncCountRef.current < 3) { return; } } else { emptyRenderedSyncCountRef.current = 0; } syncSearchMatchesWithRendered(renderedMatches); }; // Wait for highlight marks to be mounted and stabilized. frameA = requestAnimationFrame(() => { frameB = requestAnimationFrame(run); }); return () => { cancelled = true; cancelAnimationFrame(frameA); cancelAnimationFrame(frameB); }; }, [ isThisTabActive, isSearchActive, shouldVirtualize, conversation, currentSearchIndex, searchMatches, syncSearchMatchesWithRendered, ]); // Track shouldDisableAutoScroll transitions for scroll restore coordination const prevShouldDisableRef = useRef(shouldDisableAutoScroll); const { registerAIGroupRef } = useVisibleAIGroup({ onVisibleChange: (aiGroupId) => { if (effectiveTabId) { setTabVisibleAIGroup(effectiveTabId, aiGroupId); } }, threshold: 0.5, rootRef: scrollContainerRef, }); // Auto-follow when conversation updates, but only if the user was already near bottom. // This preserves manual reading position when the user scrolls up. // Disabled during navigation to prevent conflicts with deep-link/search scrolling. useAutoScrollBottom([conversation], { threshold: 150, smoothDuration: 300, autoBehavior: 'auto', disabled: shouldDisableAutoScroll, externalRef: scrollContainerRef, resetKey: effectiveTabId, }); // Callback to register AI group refs (combines with visibility hook) const registerAIGroupRefCombined = useCallback( (groupId: string) => { const visibilityRef = registerAIGroupRef(groupId); return (el: HTMLElement | null) => { if (typeof visibilityRef === 'function') visibilityRef(el); if (el) aiGroupRefs.current.set(groupId, el); else aiGroupRefs.current.delete(groupId); }; }, [registerAIGroupRef] ); // Handler to navigate to a specific turn (AI group) from CLAUDE.md panel const handleNavigateToTurn = useCallback( (turnIndex: number) => { if (!conversation) return; const targetItem = conversation.items.find( (item) => item.type === 'ai' && item.group.turnIndex === turnIndex ); if (targetItem?.type !== 'ai') return; const run = async (): Promise<void> => { const groupId = targetItem.group.id; await ensureGroupVisible(groupId); const element = aiGroupRefs.current.get(groupId); if (!element) return; element.scrollIntoView({ behavior: 'smooth', block: 'center' }); setHighlightedGroupId(groupId); setIsNavigationHighlight(true); if (navigationHighlightTimerRef.current) { clearTimeout(navigationHighlightTimerRef.current); } navigationHighlightTimerRef.current = setTimeout(() => { setHighlightedGroupId(null); setIsNavigationHighlight(false); navigationHighlightTimerRef.current = null; }, 2000); }; void run(); }, [conversation, ensureGroupVisible, setHighlightedGroupId] ); // Handler to navigate to a user message group (preceding the AI group at turnIndex) const handleNavigateToUserGroup = useCallback( (turnIndex: number) => { if (!conversation) return; const aiItemIndex = conversation.items.findIndex( (item) => item.type === 'ai' && item.group.turnIndex === turnIndex ); if (aiItemIndex < 0) return; // Find the user item preceding this AI group const prevItem = aiItemIndex > 0 ? conversation.items[aiItemIndex - 1] : null; if (prevItem?.type !== 'user') return; const run = async (): Promise<void> => { const groupId = prevItem.group.id; await ensureGroupVisible(groupId); const element = chatItemRefs.current.get(groupId); if (!element) return; element.scrollIntoView({ behavior: 'smooth', block: 'center' }); setHighlightedGroupId(groupId); setIsNavigationHighlight(true); if (navigationHighlightTimerRef.current) { clearTimeout(navigationHighlightTimerRef.current); } navigationHighlightTimerRef.current = setTimeout(() => { setHighlightedGroupId(null); setIsNavigationHighlight(false); navigationHighlightTimerRef.current = null; }, 2000); }; void run(); }, [conversation, ensureGroupVisible, setHighlightedGroupId] ); // Handler to navigate to a specific tool within a turn from context panel const handleNavigateToTool = useCallback( (turnIndex: number, toolUseId: string) => { if (!conversation) return; const targetItem = conversation.items.find( (item) => item.type === 'ai' && item.group.turnIndex === turnIndex ); if (targetItem?.type !== 'ai') return; const run = async (): Promise<void> => { const groupId = targetItem.group.id; await ensureGroupVisible(groupId); // Set group + tool highlight immediately setHighlightedGroupId(groupId); setIsNavigationHighlight(true); setContextNavToolUseId(toolUseId); // Wait for tool element to appear in DOM (up to 500ms) let toolElement: HTMLElement | undefined; const startTime = Date.now(); while (Date.now() - startTime < 500) { toolElement = toolItemRefs.current.get(toolUseId); if (toolElement) break; await new Promise((resolve) => setTimeout(resolve, 50)); } // Scroll to tool element, or fall back to AI group const scrollTarget = toolElement ?? aiGroupRefs.current.get(groupId); if (scrollTarget) { scrollTarget.scrollIntoView({ behavior: 'smooth', block: 'center' }); } // Clear highlight after 2s if (navigationHighlightTimerRef.current) { clearTimeout(navigationHighlightTimerRef.current); } navigationHighlightTimerRef.current = setTimeout(() => { setHighlightedGroupId(null); setIsNavigationHighlight(false); setContextNavToolUseId(null); navigationHighlightTimerRef.current = null; }, 2000); }; void run(); }, [conversation, ensureGroupVisible, setHighlightedGroupId] ); // Scroll to current search result when it changes useEffect(() => { const currentMatch = currentSearchIndex >= 0 ? searchMatches[currentSearchIndex] : null; if (!currentMatch) return; let frameId = 0; let attempt = 0; let cancelled = false; /** * Promote a mark element to "current" (demote any previous) and scroll to it. */ const promoteAndScroll = (el: HTMLElement): void => {