agent-ecosystem/src/renderer/components/chat/ChatHistory.tsx
iliya 16f3fa51a3 refactor: clean up code and improve readability across multiple files
- Removed unnecessary eslint disable comments related to intentional mutations for clarity.
- Updated import statements for consistency and organization.
- Refactored regex patterns and utility functions in various components to enhance performance and maintainability.
- Improved error handling and notifications for API errors in the teams IPC module.
- Streamlined the handling of DOM mutations in the ChatHistory and other components for better readability.
- Enhanced type definitions and added utility functions to improve code structure and type safety.
2026-03-19 14:07:14 +02:00

1016 lines
37 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { isNearBottom, 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 { ChevronRight, ChevronsDown, Users } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { SessionContextPanel } from './SessionContextPanel/index';
/** Pixels from bottom considered "near bottom" for scroll-button visibility and auto-scroll. */
const SCROLL_THRESHOLD = 300;
import {
computeRemainingContext,
formatPercentOfTotal,
sumContextInjectionTokens,
} from '@renderer/utils/contextMath';
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,
openTeamTab,
openSessionReport,
} = 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,
openTeamTab: s.openTeamTab,
openSessionReport: s.openSessionReport,
}))
);
// 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;
// Compute combined subagent cost from process metrics
const subagentCostUsd = useMemo(() => {
const processes = sessionDetail?.processes;
if (!processes || processes.length === 0) return undefined;
const total = processes.reduce((sum, p) => sum + (p.metrics.costUsd ?? 0), 0);
return total > 0 ? total : undefined;
}, [sessionDetail?.processes]);
// 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;
const teamBySessionId = useStore((s) => s.teamBySessionId);
// Look up whether this session belongs to a team
const sessionTeam = useMemo(() => {
const sid = sessionDetail?.session?.id;
if (!sid) return null;
return teamBySessionId[sid] ?? null;
}, [teamBySessionId, sessionDetail?.session?.id]);
// 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 INPUT tokens from the target AI group (excluding output tokens,
// since visible context is part of input only)
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.cache_read_input_tokens ?? 0) +
(usage.cache_creation_input_tokens ?? 0);
break;
}
}
}
return { allContextInjections: injections, lastAiGroupTotalTokens: totalTokens };
}, [sessionContextStats, conversation, selectedContextPhase, sessionPhaseInfo]);
const visibleContextPercentLabel = useMemo(() => {
const visibleTokens = sumContextInjectionTokens(allContextInjections);
return formatPercentOfTotal(visibleTokens, lastAiGroupTotalTokens);
}, [allContextInjections, lastAiGroupTotalTokens]);
const remainingContext = useMemo(
() => computeRemainingContext(lastAiGroupTotalTokens),
[lastAiGroupTotalTokens]
);
// 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]);
// --- New-item animation tracking ---
const knownGroupIdsRef = useRef<Set<string>>(new Set());
const animatedGroupIdsRef = useRef<Set<string>>(new Set());
const isInitialRenderRef = useRef(true);
const prevTabIdRef = useRef(effectiveTabId);
// Reset animation tracking when switching tabs/sessions
if (prevTabIdRef.current !== effectiveTabId) {
prevTabIdRef.current = effectiveTabId;
knownGroupIdsRef.current.clear();
animatedGroupIdsRef.current.clear();
isInitialRenderRef.current = true;
}
const newGroupIds = useMemo(() => {
const items = conversation?.items;
if (!items || items.length === 0) {
knownGroupIdsRef.current.clear();
animatedGroupIdsRef.current.clear();
isInitialRenderRef.current = true;
return new Set<string>();
}
// First render: seed all known IDs, no animations
if (isInitialRenderRef.current) {
isInitialRenderRef.current = false;
for (const item of items) {
knownGroupIdsRef.current.add(item.group.id);
}
return new Set<string>();
}
// Subsequent updates: detect new items
const newIds = new Set<string>();
for (const item of items) {
if (!knownGroupIdsRef.current.has(item.group.id)) {
newIds.add(item.group.id);
knownGroupIdsRef.current.add(item.group.id);
}
}
return newIds;
}, [conversation]);
// Expire animation flags after the CSS animation completes (350ms + buffer).
// This prevents replay when the virtualizer remounts off-screen elements.
useEffect(() => {
if (newGroupIds.size === 0) return;
const timer = setTimeout(() => {
for (const id of newGroupIds) {
animatedGroupIdsRef.current.add(id);
}
}, 400);
return () => clearTimeout(timer);
}, [newGroupIds]);
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,
});
// Scroll-to-bottom button visibility
const [showScrollButton, setShowScrollButton] = useState(false);
const checkScrollButton = useCallback(() => {
const container = scrollContainerRef.current;
if (!container) return;
const { scrollTop, scrollHeight, clientHeight } = container;
setShowScrollButton(!isNearBottom(scrollTop, scrollHeight, clientHeight, SCROLL_THRESHOLD));
}, []);
// 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.
const { scrollToBottom } = useAutoScrollBottom([conversation], {
threshold: SCROLL_THRESHOLD,
smoothDuration: 300,
autoBehavior: 'auto',
disabled: shouldDisableAutoScroll,
externalRef: scrollContainerRef,
resetKey: effectiveTabId,
});
// Re-check button visibility whenever conversation updates
useEffect(() => {
checkScrollButton();
}, [conversation, checkScrollButton]);
// 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 => {
const container = scrollContainerRef.current;
if (container) {
container
.querySelectorAll<HTMLElement>('mark[data-search-result="current"]')
.forEach((prev) => {
prev.setAttribute('data-search-result', 'match');
prev.style.backgroundColor = 'var(--highlight-bg-inactive)';
prev.style.color = 'var(--highlight-text-inactive)';
prev.style.boxShadow = '';
});
}
el.setAttribute('data-search-result', 'current');
el.style.backgroundColor = 'var(--highlight-bg)';
el.style.color = 'var(--highlight-text)';
el.style.boxShadow = '0 0 0 1px var(--highlight-ring)';
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
};
/**
* DOM text-search fallback: walk text nodes inside the group element to find the
* Nth occurrence of the search query, then scroll the enclosing element into view.
* This works even when React hasn't created <mark> elements (ReactMarkdown
* component memoization, render timing, etc.).
*/
const fallbackDOMSearch = (): boolean => {
const groupEl =
chatItemRefs.current.get(currentMatch.itemId) ??
aiGroupRefs.current.get(currentMatch.itemId);
if (!groupEl) return false;
const query = useStore.getState().searchQuery;
if (!query) return false;
const lowerQuery = query.toLowerCase();
let count = 0;
// Scope to [data-search-content] elements to exclude UI chrome
// (timestamps, labels, buttons) from text-node walking
const searchRoots = groupEl.querySelectorAll<HTMLElement>('[data-search-content]');
const roots = searchRoots.length > 0 ? Array.from(searchRoots) : [groupEl];
for (const root of roots) {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
let node: Node | null;
while ((node = walker.nextNode())) {
const text = node.textContent ?? '';
const lowerText = text.toLowerCase();
let pos = 0;
while ((pos = lowerText.indexOf(lowerQuery, pos)) !== -1) {
if (count === currentMatch.matchIndexInItem) {
const parent = node.parentElement;
if (parent) {
parent.scrollIntoView({ behavior: 'smooth', block: 'center' });
return true;
}
}
count++;
pos += lowerQuery.length;
}
}
}
return false;
};
const tryScrollToResult = (): void => {
const container = scrollContainerRef.current;
if (!container) return;
// Primary: find mark by item ID + match index
const el = container.querySelector<HTMLElement>(
`mark[data-search-item-id="${CSS.escape(currentMatch.itemId)}"][data-search-match-index="${currentMatch.matchIndexInItem}"]`
);
if (el) {
promoteAndScroll(el);
return;
}
// Secondary: align by global order (nth rendered mark) as canonical fallback.
if (attempt >= 3) {
const orderedMarks = Array.from(
container.querySelectorAll<HTMLElement>(
'mark[data-search-item-id][data-search-match-index]'
)
);
const byGlobal = orderedMarks[currentSearchIndex];
if (byGlobal) {
promoteAndScroll(byGlobal);
return;
}
}
// After a few frames, try fallback DOM text search
if (attempt >= 6) {
if (fallbackDOMSearch()) return;
}
// Keep retrying (marks may appear after async render)
if (attempt < 60) {
attempt++;
frameId = requestAnimationFrame(tryScrollToResult);
}
};
const run = async (): Promise<void> => {
await ensureGroupVisible(currentMatch.itemId);
if (cancelled) return;
frameId = requestAnimationFrame(tryScrollToResult);
};
void run();
return () => {
cancelled = true;
cancelAnimationFrame(frameId);
};
}, [currentSearchIndex, searchMatches, scrollContainerRef, ensureGroupVisible]);
// Track previous active state to detect when THIS tab becomes active/inactive
const wasActiveRef = useRef(isThisTabActive);
// Save scroll position when THIS tab becomes inactive
useEffect(() => {
const wasActive = wasActiveRef.current;
wasActiveRef.current = isThisTabActive;
// If this tab just became inactive, save its scroll position
if (wasActive && !isThisTabActive && scrollContainerRef.current) {
saveScrollPosition(scrollContainerRef.current.scrollTop);
}
}, [isThisTabActive, saveScrollPosition, scrollContainerRef]);
// Also save on unmount (e.g., when tab is closed)
useEffect(() => {
const scrollContainer = scrollContainerRef.current;
return () => {
if (scrollContainer) {
saveScrollPosition(scrollContainer.scrollTop);
}
};
}, [saveScrollPosition, scrollContainerRef]);
// Restore scroll position when THIS tab becomes active with saved position
// Uses shouldDisableAutoScroll (covers full navigation lifecycle) instead of pendingNavigation
// After navigation completes (transition true→false), save current position to prevent stale restore
useEffect(() => {
const wasDisabled = prevShouldDisableRef.current;
prevShouldDisableRef.current = shouldDisableAutoScroll;
// Navigation just completed — save current scroll position, skip restore
if (wasDisabled && !shouldDisableAutoScroll && scrollContainerRef.current) {
saveScrollPosition(scrollContainerRef.current.scrollTop);
return;
}
if (
isThisTabActive &&
savedScrollTop !== undefined &&
scrollContainerRef.current &&
!conversationLoading &&
!shouldDisableAutoScroll
) {
let frameA = 0;
let frameB = 0;
// Use double RAF so layout + virtual rows settle before restore.
frameA = requestAnimationFrame(() => {
frameB = requestAnimationFrame(() => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTop = savedScrollTop;
}
});
});
return () => {
cancelAnimationFrame(frameA);
cancelAnimationFrame(frameB);
};
}
}, [
isThisTabActive,
savedScrollTop,
conversationLoading,
scrollContainerRef,
shouldDisableAutoScroll,
saveScrollPosition,
]);
useEffect(() => {
return () => {
if (navigationHighlightTimerRef.current) {
clearTimeout(navigationHighlightTimerRef.current);
}
};
}, []);
// Register ref for user/system chat items
const registerChatItemRef = useCallback((groupId: string) => {
return (el: HTMLElement | null) => {
if (el) chatItemRefs.current.set(groupId, el);
else chatItemRefs.current.delete(groupId);
};
}, []);
// Register ref for individual tool items (for precise scroll targeting)
const registerToolRef = useCallback((toolId: string, el: HTMLElement | null) => {
if (el) toolItemRefs.current.set(toolId, el);
else toolItemRefs.current.delete(toolId);
}, []);
// Loading state
if (conversationLoading) return <ChatHistoryLoadingState />;
// Empty state
if (!conversation || conversation.items.length === 0) return <ChatHistoryEmptyState />;
return (
<div
className="flex flex-1 flex-col overflow-hidden"
style={{ backgroundColor: 'var(--color-surface)' }}
>
<div className="relative flex flex-1 overflow-hidden">
{/* Context panel sidebar (left) */}
{isContextPanelVisible && allContextInjections.length > 0 && (
<div className="w-80 shrink-0">
<SessionContextPanel
injections={allContextInjections}
onClose={() => setContextPanelVisible(false)}
projectRoot={sessionDetail?.session?.projectPath}
onNavigateToTurn={handleNavigateToTurn}
onNavigateToTool={handleNavigateToTool}
onNavigateToUserGroup={handleNavigateToUserGroup}
totalSessionTokens={lastAiGroupTotalTokens}
sessionMetrics={sessionDetail?.metrics}
subagentCostUsd={subagentCostUsd}
onViewReport={effectiveTabId ? () => openSessionReport(effectiveTabId) : undefined}
phaseInfo={sessionPhaseInfo ?? undefined}
selectedPhase={selectedContextPhase}
onPhaseChange={setSelectedContextPhase}
side="left"
/>
</div>
)}
{/* Chat content */}
<div
ref={scrollContainerRef}
className="flex-1 overflow-y-auto"
style={{ backgroundColor: 'var(--color-surface)' }}
onScroll={checkScrollButton}
>
{/* Sticky Context button */}
{allContextInjections.length > 0 && (
<div className="pointer-events-none sticky top-0 z-10 flex justify-start px-4 pb-0 pt-3">
<button
onClick={() => setContextPanelVisible(!isContextPanelVisible)}
onMouseEnter={() => setIsContextButtonHovered(true)}
onMouseLeave={() => setIsContextButtonHovered(false)}
className="pointer-events-auto flex items-center gap-1 rounded-md px-2.5 py-1.5 text-xs shadow-lg backdrop-blur-md transition-colors"
style={{
backgroundColor: isContextPanelVisible
? 'var(--context-btn-active-bg)'
: isContextButtonHovered
? 'var(--context-btn-bg-hover)'
: 'var(--context-btn-bg)',
color: isContextPanelVisible
? 'var(--context-btn-active-text)'
: 'var(--color-text-secondary)',
}}
>
{visibleContextPercentLabel ? (
<>
{visibleContextPercentLabel}
{remainingContext && remainingContext.urgency !== 'normal' && (
<span
style={{
color: remainingContext.urgency === 'critical' ? '#ef4444' : '#f59e0b',
}}
>
{' '}
({remainingContext.remainingPct.toFixed(0)}% left)
</span>
)}
</>
) : (
`Context (${allContextInjections.length})`
)}
</button>
</div>
)}
{sessionTeam && (
<div
className="mx-auto max-w-5xl px-6 pt-4"
style={{ marginTop: allContextInjections.length > 0 ? '-1.5rem' : 0 }}
>
<button
onClick={() => openTeamTab(sessionTeam.teamName, sessionTeam.projectPath)}
className="inline-flex items-center gap-2 rounded-md px-3 py-1.5 text-xs transition-colors hover:brightness-110"
style={{
backgroundColor: 'var(--color-surface-raised)',
color: 'var(--color-text-secondary)',
border: '1px solid var(--color-border)',
}}
>
<Users className="size-3.5" />
<span>{sessionTeam.displayName}</span>
<ChevronRight className="size-3 opacity-50" />
</button>
</div>
)}
<div
className="mx-auto max-w-5xl px-6 py-8"
style={{ marginTop: allContextInjections.length > 0 && !sessionTeam ? '-2rem' : 0 }}
>
<div className="space-y-8">
{shouldVirtualize ? (
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const item = conversation.items[virtualRow.index];
if (!item) return null;
return (
<div
key={virtualRow.key}
ref={rowVirtualizer.measureElement}
data-index={virtualRow.index}
className="pb-8"
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualRow.start}px)`,
}}
>
<ChatHistoryItem
item={item}
highlightedGroupId={highlightedGroupId}
highlightToolUseId={effectiveHighlightToolUseId}
isSearchHighlight={isSearchHighlight}
isNavigationHighlight={isNavigationHighlight}
highlightColor={effectiveHighlightColor}
isNew={
newGroupIds.has(item.group.id) &&
!animatedGroupIdsRef.current.has(item.group.id)
}
registerChatItemRef={registerChatItemRef}
registerAIGroupRef={registerAIGroupRefCombined}
registerToolRef={registerToolRef}
/>
</div>
);
})}
</div>
) : (
conversation.items.map((item) => (
<ChatHistoryItem
key={item.group.id}
item={item}
highlightedGroupId={highlightedGroupId}
highlightToolUseId={effectiveHighlightToolUseId}
isSearchHighlight={isSearchHighlight}
isNavigationHighlight={isNavigationHighlight}
highlightColor={effectiveHighlightColor}
isNew={
newGroupIds.has(item.group.id) &&
!animatedGroupIdsRef.current.has(item.group.id)
}
registerChatItemRef={registerChatItemRef}
registerAIGroupRef={registerAIGroupRefCombined}
registerToolRef={registerToolRef}
/>
))
)}
</div>
</div>
</div>
{/* Scroll to bottom button */}
{showScrollButton && (
<button
onClick={() => {
scrollToBottom('smooth');
setShowScrollButton(false);
}}
className="absolute bottom-5 z-20 flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs shadow-lg backdrop-blur-md transition-all"
style={{
right: '1rem',
backgroundColor: 'var(--context-btn-bg)',
color: 'var(--color-text-secondary)',
border: '1px solid var(--color-border-emphasis)',
}}
title="Scroll to bottom"
>
<ChevronsDown className="size-3.5" />
<span>Bottom</span>
</button>
)}
</div>
</div>
);
};