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,
});
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.
// 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<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;
@ -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}

View file

@ -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,

View file

@ -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<string, { bg: string; text: string; label: string
interface RankedInjectionListProps {
injections: ContextInjection[];
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
// =============================================================================
@ -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 = ({
<button
key={`${tool.toolName}-${idx}`}
onClick={() => {
if (onNavigateToTurn) {
if (tool.toolUseId && onNavigateToTool) {
onNavigateToTool(injection.turnIndex, tool.toolUseId);
} else if (onNavigateToTurn) {
onNavigateToTurn(injection.turnIndex);
}
}}
@ -185,6 +201,8 @@ const ToolOutputRankedItem = ({
export const RankedInjectionList = ({
injections,
onNavigateToTurn,
onNavigateToTool,
onNavigateToUserGroup,
}: Readonly<RankedInjectionListProps>): React.ReactElement => {
const sortedInjections = useMemo(
() => [...injections].sort((a, b) => b.estimatedTokens - a.estimatedTokens),
@ -201,50 +219,64 @@ export const RankedInjectionList = ({
key={inj.id}
injection={inj}
onNavigateToTurn={onNavigateToTurn}
onNavigateToTool={onNavigateToTool}
/>
);
}
// All other categories: simple row
const categoryInfo = CATEGORY_COLORS[inj.category] ?? {
bg: 'rgba(161, 161, 170, 0.15)',
text: '#a1a1aa',
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 (
<button
key={inj.id}
onClick={() => {
if (onNavigateToTurn) {
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 }}
<div key={inj.id} className="flex items-center gap-0.5">
<button
onClick={handleClick}
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"
>
{categoryInfo.label}
</span>
{/* Description */}
<span
className="min-w-0 flex-1 truncate text-xs"
style={{ color: COLOR_TEXT_SECONDARY }}
>
{getInjectionDescription(inj)}
</span>
{/* Token count */}
<span
className="shrink-0 text-xs font-medium tabular-nums"
style={{ color: COLOR_TEXT_MUTED }}
>
{formatTokens(inj.estimatedTokens)}
</span>
</button>
{/* 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}
</span>
{/* Description */}
<span
className="min-w-0 flex-1 truncate text-xs"
style={{ color: COLOR_TEXT_SECONDARY }}
>
{getInjectionDescription(inj)}
</span>
{/* Token count */}
<span
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>

View file

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

View file

@ -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 */

View file

@ -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<string | null>(null);
if (pendingSettingsSection !== prevPending) {
setPrevPending(pendingSettingsSection);
if (pendingSettingsSection) {
setActiveSection(pendingSettingsSection as SettingsSection);
clearPendingSettingsSection();
}
}, [pendingSettingsSection, clearPendingSettingsSection]);
}
const {
config,

View file

@ -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<HTMLDivElement>(null);
const countRef = useRef<HTMLSpanElement>(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'}
</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}
{sessionsTotalCount > sessions.length ? ` of ${sessionsTotalCount}` : ''})
{sessionsHasMore ? '+' : ''})
</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
onClick={() =>
setSessionSortMode(sessionSortMode === 'recent' ? 'most-context' : 'recent')

View file

@ -72,6 +72,8 @@ export interface ToolTokenBreakdown {
tokenCount: number;
/** Whether the tool execution resulted in an error */
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,
tokenCount: toolTokenCount,
isError: linkedTool.result?.isError ?? false,
toolUseId: linkedTool.id,
});
totalTokens += toolTokenCount;
}