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.
This commit is contained in:
matt 2026-02-16 20:36:18 +09:00
parent 9915cf5a03
commit fb2d56e23f
9 changed files with 214 additions and 49 deletions

View file

@ -252,7 +252,11 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
selectSearchMatch, selectSearchMatch,
}); });
const effectiveHighlightToolUseId = controllerToolUseId ?? undefined; // 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. // Keep search match indices aligned with this tab's rendered conversation.
// This avoids stale/global match lists after tab switches or in-place refreshes. // 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] [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<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 // Scroll to current search result when it changes
useEffect(() => { useEffect(() => {
const currentMatch = currentSearchIndex >= 0 ? searchMatches[currentSearchIndex] : null; const currentMatch = currentSearchIndex >= 0 ? searchMatches[currentSearchIndex] : null;
@ -695,7 +780,7 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
highlightToolUseId={effectiveHighlightToolUseId} highlightToolUseId={effectiveHighlightToolUseId}
isSearchHighlight={isSearchHighlight} isSearchHighlight={isSearchHighlight}
isNavigationHighlight={isNavigationHighlight} isNavigationHighlight={isNavigationHighlight}
highlightColor={highlightColor} highlightColor={effectiveHighlightColor}
registerChatItemRef={registerChatItemRef} registerChatItemRef={registerChatItemRef}
registerAIGroupRef={registerAIGroupRefCombined} registerAIGroupRef={registerAIGroupRefCombined}
registerToolRef={registerToolRef} registerToolRef={registerToolRef}
@ -713,7 +798,7 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
highlightToolUseId={effectiveHighlightToolUseId} highlightToolUseId={effectiveHighlightToolUseId}
isSearchHighlight={isSearchHighlight} isSearchHighlight={isSearchHighlight}
isNavigationHighlight={isNavigationHighlight} isNavigationHighlight={isNavigationHighlight}
highlightColor={highlightColor} highlightColor={effectiveHighlightColor}
registerChatItemRef={registerChatItemRef} registerChatItemRef={registerChatItemRef}
registerAIGroupRef={registerAIGroupRefCombined} registerAIGroupRef={registerAIGroupRefCombined}
registerToolRef={registerToolRef} registerToolRef={registerToolRef}
@ -732,6 +817,8 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
onClose={() => setContextPanelVisible(false)} onClose={() => setContextPanelVisible(false)}
projectRoot={sessionDetail?.session?.projectPath} projectRoot={sessionDetail?.session?.projectPath}
onNavigateToTurn={handleNavigateToTurn} onNavigateToTurn={handleNavigateToTurn}
onNavigateToTool={handleNavigateToTool}
onNavigateToUserGroup={handleNavigateToUserGroup}
totalSessionTokens={lastAiGroupTotalTokens} totalSessionTokens={lastAiGroupTotalTokens}
phaseInfo={sessionPhaseInfo ?? undefined} phaseInfo={sessionPhaseInfo ?? undefined}
selectedPhase={selectedContextPhase} selectedPhase={selectedContextPhase}

View file

@ -97,11 +97,10 @@ const ChatHistoryItemInner = ({
} }
case 'ai': { case 'ai': {
const isHighlighted = highlightedGroupId === item.group.id; 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 // 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 // Allowed during navigation highlights so context panel tool deep-linking works
const toolUseIdForGroup = const toolUseIdForGroup = !isSearchHighlight ? highlightToolUseId : undefined;
!isSearchHighlight && !isNavigationHighlight ? highlightToolUseId : undefined;
const hl = getHighlight( const hl = getHighlight(
isHighlighted, isHighlighted,
isSearchHighlight, isSearchHighlight,

View file

@ -2,10 +2,13 @@
* RankedInjectionList - All context injections sorted by token size descending. * RankedInjectionList - All context injections sorted by token size descending.
* Injections are shown as grouped rows (e.g., "Tool output in Turn N"). * 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. * 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 React, { useMemo, useState } from 'react';
import { CopyButton } from '@renderer/components/common/CopyButton';
import { COLOR_TEXT_MUTED, COLOR_TEXT_SECONDARY } from '@renderer/constants/cssVariables'; import { COLOR_TEXT_MUTED, COLOR_TEXT_SECONDARY } from '@renderer/constants/cssVariables';
import { ChevronRight } from 'lucide-react'; import { ChevronRight } from 'lucide-react';
@ -34,6 +37,8 @@ const CATEGORY_COLORS: Record<string, { bg: string; text: string; label: string
interface RankedInjectionListProps { interface RankedInjectionListProps {
injections: ContextInjection[]; injections: ContextInjection[];
onNavigateToTurn?: (turnIndex: number) => void; onNavigateToTurn?: (turnIndex: number) => 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 // Sub-components
// ============================================================================= // =============================================================================
@ -79,9 +91,11 @@ function getInjectionTurnIndex(injection: ContextInjection): number {
const ToolOutputRankedItem = ({ const ToolOutputRankedItem = ({
injection, injection,
onNavigateToTurn, onNavigateToTurn,
onNavigateToTool,
}: Readonly<{ }: Readonly<{
injection: ToolOutputInjection; injection: ToolOutputInjection;
onNavigateToTurn?: (turnIndex: number) => void; onNavigateToTurn?: (turnIndex: number) => void;
onNavigateToTool?: (turnIndex: number, toolUseId: string) => void;
}>): React.ReactElement => { }>): React.ReactElement => {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const hasBreakdown = injection.toolBreakdown.length > 0; const hasBreakdown = injection.toolBreakdown.length > 0;
@ -139,7 +153,9 @@ const ToolOutputRankedItem = ({
<button <button
key={`${tool.toolName}-${idx}`} key={`${tool.toolName}-${idx}`}
onClick={() => { onClick={() => {
if (onNavigateToTurn) { if (tool.toolUseId && onNavigateToTool) {
onNavigateToTool(injection.turnIndex, tool.toolUseId);
} else if (onNavigateToTurn) {
onNavigateToTurn(injection.turnIndex); onNavigateToTurn(injection.turnIndex);
} }
}} }}
@ -185,6 +201,8 @@ const ToolOutputRankedItem = ({
export const RankedInjectionList = ({ export const RankedInjectionList = ({
injections, injections,
onNavigateToTurn, onNavigateToTurn,
onNavigateToTool,
onNavigateToUserGroup,
}: Readonly<RankedInjectionListProps>): React.ReactElement => { }: Readonly<RankedInjectionListProps>): React.ReactElement => {
const sortedInjections = useMemo( const sortedInjections = useMemo(
() => [...injections].sort((a, b) => b.estimatedTokens - a.estimatedTokens), () => [...injections].sort((a, b) => b.estimatedTokens - a.estimatedTokens),
@ -201,50 +219,64 @@ export const RankedInjectionList = ({
key={inj.id} key={inj.id}
injection={inj} injection={inj}
onNavigateToTurn={onNavigateToTurn} onNavigateToTurn={onNavigateToTurn}
onNavigateToTool={onNavigateToTool}
/> />
); );
} }
// All other categories: simple row
const categoryInfo = CATEGORY_COLORS[inj.category] ?? { const categoryInfo = CATEGORY_COLORS[inj.category] ?? {
bg: 'rgba(161, 161, 170, 0.15)', bg: 'rgba(161, 161, 170, 0.15)',
text: '#a1a1aa', text: '#a1a1aa',
label: inj.category, label: inj.category,
}; };
const copyPath = getCopyablePath(inj);
const handleClick = (): void => {
const turnIndex = getInjectionTurnIndex(inj);
if (turnIndex < 0) return;
// User messages → navigate to user group; others → navigate to AI group
if (inj.category === 'user-message' && onNavigateToUserGroup) {
onNavigateToUserGroup(turnIndex);
} else if (onNavigateToTurn) {
onNavigateToTurn(turnIndex);
}
};
return ( return (
<button <div key={inj.id} className="flex items-center gap-0.5">
key={inj.id} <button
onClick={() => { onClick={handleClick}
if (onNavigateToTurn) { className="flex min-w-0 flex-1 items-center gap-2 rounded px-2 py-1.5 text-left transition-colors hover:bg-white/5"
const turnIndex = getInjectionTurnIndex(inj);
if (turnIndex >= 0) onNavigateToTurn(turnIndex);
}
}}
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left transition-colors hover:bg-white/5"
>
{/* Category pill */}
<span
className="shrink-0 rounded px-1.5 py-0.5 text-[9px] font-medium"
style={{ backgroundColor: categoryInfo.bg, color: categoryInfo.text }}
> >
{categoryInfo.label} {/* Category pill */}
</span> <span
{/* Description */} className="shrink-0 rounded px-1.5 py-0.5 text-[9px] font-medium"
<span style={{ backgroundColor: categoryInfo.bg, color: categoryInfo.text }}
className="min-w-0 flex-1 truncate text-xs" >
style={{ color: COLOR_TEXT_SECONDARY }} {categoryInfo.label}
> </span>
{getInjectionDescription(inj)} {/* Description */}
</span> <span
{/* Token count */} className="min-w-0 flex-1 truncate text-xs"
<span style={{ color: COLOR_TEXT_SECONDARY }}
className="shrink-0 text-xs font-medium tabular-nums" >
style={{ color: COLOR_TEXT_MUTED }} {getInjectionDescription(inj)}
> </span>
{formatTokens(inj.estimatedTokens)} {/* Token count */}
</span> <span
</button> className="shrink-0 text-xs font-medium tabular-nums"
style={{ color: COLOR_TEXT_MUTED }}
>
{formatTokens(inj.estimatedTokens)}
</span>
</button>
{/* Copy path button for CLAUDE.md and File items */}
{copyPath && (
<span className="shrink-0" onClick={(e) => e.stopPropagation()}>
<CopyButton text={copyPath} inline />
</span>
)}
</div>
); );
})} })}
</div> </div>

View file

@ -39,6 +39,8 @@ export const SessionContextPanel = ({
onClose, onClose,
projectRoot, projectRoot,
onNavigateToTurn, onNavigateToTurn,
onNavigateToTool,
onNavigateToUserGroup,
totalSessionTokens, totalSessionTokens,
phaseInfo, phaseInfo,
selectedPhase, selectedPhase,
@ -250,7 +252,12 @@ export const SessionContextPanel = ({
/> />
</> </>
) : ( ) : (
<RankedInjectionList injections={injections} onNavigateToTurn={onNavigateToTurn} /> <RankedInjectionList
injections={injections}
onNavigateToTurn={onNavigateToTurn}
onNavigateToTool={onNavigateToTool}
onNavigateToUserGroup={onNavigateToUserGroup}
/>
)} )}
</div> </div>
</div> </div>

View file

@ -18,6 +18,10 @@ export interface SessionContextPanelProps {
projectRoot?: string; projectRoot?: string;
/** Click Turn N to navigate to that turn */ /** Click Turn N to navigate to that turn */
onNavigateToTurn?: (turnIndex: number) => void; 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 */ /** Total session tokens (input + output + cache) for comparison */
totalSessionTokens?: number; totalSessionTokens?: number;
/** Phase information for phase selector */ /** Phase information for phase selector */

View file

@ -3,7 +3,7 @@
* Provides UI for managing notifications, display settings, and advanced options. * 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 { useStore } from '@renderer/store';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
@ -23,12 +23,15 @@ export const SettingsView = (): React.JSX.Element | null => {
const pendingSettingsSection = useStore((s) => s.pendingSettingsSection); const pendingSettingsSection = useStore((s) => s.pendingSettingsSection);
const clearPendingSettingsSection = useStore((s) => s.clearPendingSettingsSection); 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<string | null>(null);
if (pendingSettingsSection !== prevPending) {
setPrevPending(pendingSettingsSection);
if (pendingSettingsSection) { if (pendingSettingsSection) {
setActiveSection(pendingSettingsSection as SettingsSection); setActiveSection(pendingSettingsSection as SettingsSection);
clearPendingSettingsSection(); clearPendingSettingsSection();
} }
}, [pendingSettingsSection, clearPendingSettingsSection]); }
const { const {
config, config,

View file

@ -3,7 +3,8 @@
* Uses @tanstack/react-virtual for efficient DOM rendering with infinite scroll. * 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 { useStore } from '@renderer/store';
import { import {
@ -47,7 +48,6 @@ export const DateGroupedSessions = (): React.JSX.Element => {
sessionsError, sessionsError,
sessionsHasMore, sessionsHasMore,
sessionsLoadingMore, sessionsLoadingMore,
sessionsTotalCount,
fetchSessionsMore, fetchSessionsMore,
pinnedSessionIds, pinnedSessionIds,
sessionSortMode, sessionSortMode,
@ -61,7 +61,6 @@ export const DateGroupedSessions = (): React.JSX.Element => {
sessionsError: s.sessionsError, sessionsError: s.sessionsError,
sessionsHasMore: s.sessionsHasMore, sessionsHasMore: s.sessionsHasMore,
sessionsLoadingMore: s.sessionsLoadingMore, sessionsLoadingMore: s.sessionsLoadingMore,
sessionsTotalCount: s.sessionsTotalCount,
fetchSessionsMore: s.fetchSessionsMore, fetchSessionsMore: s.fetchSessionsMore,
pinnedSessionIds: s.pinnedSessionIds, pinnedSessionIds: s.pinnedSessionIds,
sessionSortMode: s.sessionSortMode, sessionSortMode: s.sessionSortMode,
@ -70,6 +69,8 @@ export const DateGroupedSessions = (): React.JSX.Element => {
); );
const parentRef = useRef<HTMLDivElement>(null); const parentRef = useRef<HTMLDivElement>(null);
const countRef = useRef<HTMLSpanElement>(null);
const [showCountTooltip, setShowCountTooltip] = useState(false);
// Separate pinned sessions from unpinned // Separate pinned sessions from unpinned
const { pinned: pinnedSessions, unpinned: unpinnedSessions } = useMemo( const { pinned: pinnedSessions, unpinned: unpinnedSessions } = useMemo(
@ -303,10 +304,39 @@ export const DateGroupedSessions = (): React.JSX.Element => {
> >
{sessionSortMode === 'most-context' ? 'By Context' : 'Sessions'} {sessionSortMode === 'most-context' ? 'By Context' : 'Sessions'}
</h2> </h2>
<span className="text-xs" style={{ color: 'var(--color-text-muted)', opacity: 0.6 }}> {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- tooltip trigger via hover, not interactive */}
<span
ref={countRef}
className="text-xs"
style={{ color: 'var(--color-text-muted)', opacity: 0.6 }}
onMouseEnter={() => setShowCountTooltip(true)}
onMouseLeave={() => setShowCountTooltip(false)}
>
({sessions.length} ({sessions.length}
{sessionsTotalCount > sessions.length ? ` of ${sessionsTotalCount}` : ''}) {sessionsHasMore ? '+' : ''})
</span> </span>
{showCountTooltip &&
sessionsHasMore &&
countRef.current &&
createPortal(
<div
className="pointer-events-none fixed z-50 w-48 rounded-md px-2.5 py-1.5 text-[11px] leading-snug shadow-lg"
style={{
top: countRef.current.getBoundingClientRect().bottom + 6,
left:
countRef.current.getBoundingClientRect().left +
countRef.current.getBoundingClientRect().width / 2 -
96,
backgroundColor: 'var(--color-surface-overlay)',
border: '1px solid var(--color-border-emphasis)',
color: 'var(--color-text-secondary)',
}}
>
{sessions.length} loaded so far scroll down to load more. Context sorting only ranks
loaded sessions.
</div>,
document.body
)}
<button <button
onClick={() => onClick={() =>
setSessionSortMode(sessionSortMode === 'recent' ? 'most-context' : 'recent') setSessionSortMode(sessionSortMode === 'recent' ? 'most-context' : 'recent')

View file

@ -72,6 +72,8 @@ export interface ToolTokenBreakdown {
tokenCount: number; tokenCount: number;
/** Whether the tool execution resulted in an error */ /** Whether the tool execution resulted in an error */
isError: boolean; isError: boolean;
/** Tool use ID for deep-link navigation to specific tool in chat */
toolUseId?: string;
} }
/** /**

View file

@ -214,6 +214,7 @@ function aggregateToolOutputs(
toolName: displayName, toolName: displayName,
tokenCount: toolTokenCount, tokenCount: toolTokenCount,
isError: linkedTool.result?.isError ?? false, isError: linkedTool.result?.isError ?? false,
toolUseId: linkedTool.id,
}); });
totalTokens += toolTokenCount; totalTokens += toolTokenCount;
} }