From fb2d56e23f10d01e1efa4f4a35057f2457cc5c53 Mon Sep 17 00:00:00 2001 From: matt Date: Mon, 16 Feb 2026 20:36:18 +0900 Subject: [PATCH] feat(chat): enhance navigation and tool highlighting in chat history - Introduced context panel navigation for user message groups and specific tools within turns, improving user experience in navigating chat history. - Added state management for context navigation tool use ID and effective highlight color, allowing distinct visual cues for context panel interactions. - Updated `ChatHistory` and `SessionContextPanel` components to support new navigation handlers and integrate deep-linking functionality for tools. - Enhanced `RankedInjectionList` to facilitate navigation to user groups and tools, providing a more interactive and user-friendly interface. --- src/renderer/components/chat/ChatHistory.tsx | 93 +++++++++++++++++- .../components/chat/ChatHistoryItem.tsx | 7 +- .../components/RankedInjectionList.tsx | 98 ++++++++++++------- .../chat/SessionContextPanel/index.tsx | 9 +- .../chat/SessionContextPanel/types.ts | 4 + .../components/settings/SettingsView.tsx | 9 +- .../sidebar/DateGroupedSessions.tsx | 40 +++++++- src/renderer/types/contextInjection.ts | 2 + src/renderer/utils/contextTracker.ts | 1 + 9 files changed, 214 insertions(+), 49 deletions(-) diff --git a/src/renderer/components/chat/ChatHistory.tsx b/src/renderer/components/chat/ChatHistory.tsx index 5b60dd30..49023fea 100644 --- a/src/renderer/components/chat/ChatHistory.tsx +++ b/src/renderer/components/chat/ChatHistory.tsx @@ -252,7 +252,11 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => { selectSearchMatch, }); - const effectiveHighlightToolUseId = controllerToolUseId ?? undefined; + // Local tool highlight for context panel navigation (separate from controller) + const [contextNavToolUseId, setContextNavToolUseId] = useState(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. @@ -396,6 +400,87 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => { [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 groupId = prevItem.group.id; + 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); + }, + [conversation, 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 => { + 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; @@ -695,7 +780,7 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => { highlightToolUseId={effectiveHighlightToolUseId} isSearchHighlight={isSearchHighlight} isNavigationHighlight={isNavigationHighlight} - highlightColor={highlightColor} + highlightColor={effectiveHighlightColor} registerChatItemRef={registerChatItemRef} registerAIGroupRef={registerAIGroupRefCombined} registerToolRef={registerToolRef} @@ -713,7 +798,7 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => { highlightToolUseId={effectiveHighlightToolUseId} isSearchHighlight={isSearchHighlight} isNavigationHighlight={isNavigationHighlight} - highlightColor={highlightColor} + highlightColor={effectiveHighlightColor} registerChatItemRef={registerChatItemRef} registerAIGroupRef={registerAIGroupRefCombined} registerToolRef={registerToolRef} @@ -732,6 +817,8 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => { onClose={() => setContextPanelVisible(false)} projectRoot={sessionDetail?.session?.projectPath} onNavigateToTurn={handleNavigateToTurn} + onNavigateToTool={handleNavigateToTool} + onNavigateToUserGroup={handleNavigateToUserGroup} totalSessionTokens={lastAiGroupTotalTokens} phaseInfo={sessionPhaseInfo ?? undefined} selectedPhase={selectedContextPhase} diff --git a/src/renderer/components/chat/ChatHistoryItem.tsx b/src/renderer/components/chat/ChatHistoryItem.tsx index d611fea1..b3b589dd 100644 --- a/src/renderer/components/chat/ChatHistoryItem.tsx +++ b/src/renderer/components/chat/ChatHistoryItem.tsx @@ -97,11 +97,10 @@ const ChatHistoryItemInner = ({ } case 'ai': { const isHighlighted = highlightedGroupId === item.group.id; - // Pass highlightToolUseId to ALL AI groups (when not search/navigation) + // Pass highlightToolUseId to ALL AI groups (when not search highlight) // Each group will check if it contains the tool and expand accordingly - // This fixes issues where timestamp matching might fail to find the correct group - const toolUseIdForGroup = - !isSearchHighlight && !isNavigationHighlight ? highlightToolUseId : undefined; + // Allowed during navigation highlights so context panel tool deep-linking works + const toolUseIdForGroup = !isSearchHighlight ? highlightToolUseId : undefined; const hl = getHighlight( isHighlighted, isSearchHighlight, diff --git a/src/renderer/components/chat/SessionContextPanel/components/RankedInjectionList.tsx b/src/renderer/components/chat/SessionContextPanel/components/RankedInjectionList.tsx index e7101937..ebbe6d5f 100644 --- a/src/renderer/components/chat/SessionContextPanel/components/RankedInjectionList.tsx +++ b/src/renderer/components/chat/SessionContextPanel/components/RankedInjectionList.tsx @@ -2,10 +2,13 @@ * RankedInjectionList - All context injections sorted by token size descending. * Injections are shown as grouped rows (e.g., "Tool output in Turn N"). * Tool-output rows are expandable to reveal individual tool breakdowns sorted desc. + * Individual tools support deep-link navigation to the exact tool in chat. + * CLAUDE.md and File items show a copy-path button. */ import React, { useMemo, useState } from 'react'; +import { CopyButton } from '@renderer/components/common/CopyButton'; import { COLOR_TEXT_MUTED, COLOR_TEXT_SECONDARY } from '@renderer/constants/cssVariables'; import { ChevronRight } from 'lucide-react'; @@ -34,6 +37,8 @@ const CATEGORY_COLORS: Record void; + onNavigateToTool?: (turnIndex: number, toolUseId: string) => void; + onNavigateToUserGroup?: (turnIndex: number) => void; } // ============================================================================= @@ -71,6 +76,13 @@ function getInjectionTurnIndex(injection: ContextInjection): number { } } +/** Get copyable path for path-based injections. */ +function getCopyablePath(injection: ContextInjection): string | null { + if (injection.category === 'claude-md') return injection.path; + if (injection.category === 'mentioned-file') return injection.path; + return null; +} + // ============================================================================= // Sub-components // ============================================================================= @@ -79,9 +91,11 @@ function getInjectionTurnIndex(injection: ContextInjection): number { const ToolOutputRankedItem = ({ injection, onNavigateToTurn, + onNavigateToTool, }: Readonly<{ injection: ToolOutputInjection; onNavigateToTurn?: (turnIndex: number) => void; + onNavigateToTool?: (turnIndex: number, toolUseId: string) => void; }>): React.ReactElement => { const [expanded, setExpanded] = useState(false); const hasBreakdown = injection.toolBreakdown.length > 0; @@ -139,7 +153,9 @@ const ToolOutputRankedItem = ({ + {/* Category pill */} + + {categoryInfo.label} + + {/* Description */} + + {getInjectionDescription(inj)} + + {/* Token count */} + + {formatTokens(inj.estimatedTokens)} + + + {/* Copy path button for CLAUDE.md and File items */} + {copyPath && ( + e.stopPropagation()}> + + + )} + ); })} diff --git a/src/renderer/components/chat/SessionContextPanel/index.tsx b/src/renderer/components/chat/SessionContextPanel/index.tsx index b03f5ad5..28c540e2 100644 --- a/src/renderer/components/chat/SessionContextPanel/index.tsx +++ b/src/renderer/components/chat/SessionContextPanel/index.tsx @@ -39,6 +39,8 @@ export const SessionContextPanel = ({ onClose, projectRoot, onNavigateToTurn, + onNavigateToTool, + onNavigateToUserGroup, totalSessionTokens, phaseInfo, selectedPhase, @@ -250,7 +252,12 @@ export const SessionContextPanel = ({ /> ) : ( - + )} diff --git a/src/renderer/components/chat/SessionContextPanel/types.ts b/src/renderer/components/chat/SessionContextPanel/types.ts index 0c2162f2..b5222683 100644 --- a/src/renderer/components/chat/SessionContextPanel/types.ts +++ b/src/renderer/components/chat/SessionContextPanel/types.ts @@ -18,6 +18,10 @@ export interface SessionContextPanelProps { projectRoot?: string; /** Click Turn N to navigate to that turn */ onNavigateToTurn?: (turnIndex: number) => void; + /** Navigate to a specific tool within a turn by toolUseId */ + onNavigateToTool?: (turnIndex: number, toolUseId: string) => void; + /** Navigate to the user message group preceding the AI group at turnIndex */ + onNavigateToUserGroup?: (turnIndex: number) => void; /** Total session tokens (input + output + cache) for comparison */ totalSessionTokens?: number; /** Phase information for phase selector */ diff --git a/src/renderer/components/settings/SettingsView.tsx b/src/renderer/components/settings/SettingsView.tsx index 330e5c07..ee4f0400 100644 --- a/src/renderer/components/settings/SettingsView.tsx +++ b/src/renderer/components/settings/SettingsView.tsx @@ -3,7 +3,7 @@ * Provides UI for managing notifications, display settings, and advanced options. */ -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { useStore } from '@renderer/store'; import { Loader2 } from 'lucide-react'; @@ -23,12 +23,15 @@ export const SettingsView = (): React.JSX.Element | null => { const pendingSettingsSection = useStore((s) => s.pendingSettingsSection); const clearPendingSettingsSection = useStore((s) => s.clearPendingSettingsSection); - useEffect(() => { + // Consume pending section during render (React-recommended pattern for adjusting state on prop change) + const [prevPending, setPrevPending] = useState(null); + if (pendingSettingsSection !== prevPending) { + setPrevPending(pendingSettingsSection); if (pendingSettingsSection) { setActiveSection(pendingSettingsSection as SettingsSection); clearPendingSettingsSection(); } - }, [pendingSettingsSection, clearPendingSettingsSection]); + } const { config, diff --git a/src/renderer/components/sidebar/DateGroupedSessions.tsx b/src/renderer/components/sidebar/DateGroupedSessions.tsx index f3412135..45aa32e1 100644 --- a/src/renderer/components/sidebar/DateGroupedSessions.tsx +++ b/src/renderer/components/sidebar/DateGroupedSessions.tsx @@ -3,7 +3,8 @@ * Uses @tanstack/react-virtual for efficient DOM rendering with infinite scroll. */ -import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; import { useStore } from '@renderer/store'; import { @@ -47,7 +48,6 @@ export const DateGroupedSessions = (): React.JSX.Element => { sessionsError, sessionsHasMore, sessionsLoadingMore, - sessionsTotalCount, fetchSessionsMore, pinnedSessionIds, sessionSortMode, @@ -61,7 +61,6 @@ export const DateGroupedSessions = (): React.JSX.Element => { sessionsError: s.sessionsError, sessionsHasMore: s.sessionsHasMore, sessionsLoadingMore: s.sessionsLoadingMore, - sessionsTotalCount: s.sessionsTotalCount, fetchSessionsMore: s.fetchSessionsMore, pinnedSessionIds: s.pinnedSessionIds, sessionSortMode: s.sessionSortMode, @@ -70,6 +69,8 @@ export const DateGroupedSessions = (): React.JSX.Element => { ); const parentRef = useRef(null); + const countRef = useRef(null); + const [showCountTooltip, setShowCountTooltip] = useState(false); // Separate pinned sessions from unpinned const { pinned: pinnedSessions, unpinned: unpinnedSessions } = useMemo( @@ -303,10 +304,39 @@ export const DateGroupedSessions = (): React.JSX.Element => { > {sessionSortMode === 'most-context' ? 'By Context' : 'Sessions'} - + {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- tooltip trigger via hover, not interactive */} + setShowCountTooltip(true)} + onMouseLeave={() => setShowCountTooltip(false)} + > ({sessions.length} - {sessionsTotalCount > sessions.length ? ` of ${sessionsTotalCount}` : ''}) + {sessionsHasMore ? '+' : ''}) + {showCountTooltip && + sessionsHasMore && + countRef.current && + createPortal( +
+ {sessions.length} loaded so far — scroll down to load more. Context sorting only ranks + loaded sessions. +
, + document.body + )}