836 lines · 30.4 KB · TypeScript
RawDownload
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.