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:
parent
9915cf5a03
commit
fb2d56e23f
9 changed files with 214 additions and 49 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -214,6 +214,7 @@ function aggregateToolOutputs(
|
|||
toolName: displayName,
|
||||
tokenCount: toolTokenCount,
|
||||
isError: linkedTool.result?.isError ?? false,
|
||||
toolUseId: linkedTool.id,
|
||||
});
|
||||
totalTokens += toolTokenCount;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue