From 44a499e62cf7d51fcdfe9307d26f39d378090cfe Mon Sep 17 00:00:00 2001 From: matt Date: Sun, 15 Feb 2026 14:32:32 +0900 Subject: [PATCH 1/4] feat(jsonl): enhance message counting logic for AIGroup interactions - Added logic to await the first main-thread assistant message after a UserGroup to accurately count AIGroup messages. - Updated tests to reflect the new message counting behavior, ensuring correct results in session file analysis. --- src/main/utils/jsonl.ts | 11 +++++++++++ test/main/utils/jsonl.test.ts | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/main/utils/jsonl.ts b/src/main/utils/jsonl.ts index 15b2cc1f..e09a922a 100644 --- a/src/main/utils/jsonl.ts +++ b/src/main/utils/jsonl.ts @@ -328,6 +328,8 @@ export async function analyzeSessionFileMetadata( let firstUserMessage: { text: string; timestamp: string } | null = null; let firstCommandMessage: { text: string; timestamp: string } | null = null; let messageCount = 0; + // After a UserGroup, await the first main-thread assistant message to count the AIGroup + let awaitingAIGroup = false; let gitBranch: string | null = null; let activityIndex = 0; @@ -357,6 +359,15 @@ export async function analyzeSessionFileMetadata( if (isParsedUserChunkMessage(parsed)) { messageCount++; + awaitingAIGroup = true; + } else if ( + awaitingAIGroup && + parsed.type === 'assistant' && + parsed.model !== '' && + !parsed.isSidechain + ) { + messageCount++; + awaitingAIGroup = false; } if (!gitBranch && 'gitBranch' in entry && entry.gitBranch) { diff --git a/test/main/utils/jsonl.test.ts b/test/main/utils/jsonl.test.ts index e748b821..c7a1cf05 100644 --- a/test/main/utils/jsonl.test.ts +++ b/test/main/utils/jsonl.test.ts @@ -166,7 +166,7 @@ describe('jsonl', () => { expect(result.firstUserMessage?.text).toBe('hello world'); expect(result.firstUserMessage?.timestamp).toBe('2026-01-01T00:00:00.000Z'); - expect(result.messageCount).toBe(1); + expect(result.messageCount).toBe(2); expect(result.isOngoing).toBe(true); expect(result.gitBranch).toBe('feature/test'); } finally { From 8b2dbf3bcbe6cb79dc897383c558cf2ee998143e Mon Sep 17 00:00:00 2001 From: matt Date: Sun, 15 Feb 2026 14:49:29 +0900 Subject: [PATCH 2/4] feat(context): enhance session context tracking and display - Added context consumption tracking, including total context consumed and compaction events, to the session metadata. - Introduced a new `PhaseTokenBreakdown` interface for detailed per-phase token contributions. - Updated the `SessionContextPanel` to support a ranked view of context injections, allowing users to toggle between category and ranked displays. - Implemented a `ConsumptionBadge` in the `SessionItem` component to show context consumption with a hover popover for phase breakdown details. - Enhanced session sorting options in the sidebar to allow sorting by context consumption. --- src/main/services/discovery/ProjectScanner.ts | 3 + src/main/types/domain.ts | 20 +++ src/main/utils/jsonl.ts | 90 ++++++++++++ .../components/RankedInjectionList.tsx | 138 ++++++++++++++++++ .../components/SessionContextHeader.tsx | 41 +++++- .../chat/SessionContextPanel/index.tsx | 12 +- .../chat/SessionContextPanel/types.ts | 3 + .../sidebar/DateGroupedSessions.tsx | 96 ++++++++---- .../components/sidebar/SessionItem.tsx | 73 ++++++++- src/renderer/store/slices/sessionSlice.ts | 13 +- src/renderer/types/data.ts | 8 + 11 files changed, 462 insertions(+), 35 deletions(-) create mode 100644 src/renderer/components/chat/SessionContextPanel/components/RankedInjectionList.tsx diff --git a/src/main/services/discovery/ProjectScanner.ts b/src/main/services/discovery/ProjectScanner.ts index eb6dfab6..90884236 100644 --- a/src/main/services/discovery/ProjectScanner.ts +++ b/src/main/services/discovery/ProjectScanner.ts @@ -758,6 +758,9 @@ export class ProjectScanner { isOngoing: metadata.isOngoing, gitBranch: metadata.gitBranch ?? undefined, metadataLevel, + contextConsumption: metadata.contextConsumption, + compactionCount: metadata.compactionCount, + phaseBreakdown: metadata.phaseBreakdown, }; } diff --git a/src/main/types/domain.ts b/src/main/types/domain.ts index 287c8b1b..a14b87fd 100644 --- a/src/main/types/domain.ts +++ b/src/main/types/domain.ts @@ -64,6 +64,20 @@ export interface Project { */ export type SessionMetadataLevel = 'light' | 'deep'; +/** + * Per-phase token breakdown for compaction-aware context consumption. + */ +export interface PhaseTokenBreakdown { + /** 1-based phase number */ + phaseNumber: number; + /** Tokens added during this phase */ + contribution: number; + /** Context window at peak (pre-compaction or final) */ + peakTokens: number; + /** Tokens after compaction (undefined for the last/current phase) */ + postCompaction?: number; +} + export interface Session { /** Session UUID (JSONL filename without extension) */ id: string; @@ -89,6 +103,12 @@ export interface Session { gitBranch?: string; /** Metadata completeness level */ metadataLevel?: SessionMetadataLevel; + /** Total context consumed (compaction-aware sum of all phases) */ + contextConsumption?: number; + /** Number of compaction events */ + compactionCount?: number; + /** Per-phase token breakdown for tooltip display */ + phaseBreakdown?: PhaseTokenBreakdown[]; } /** diff --git a/src/main/utils/jsonl.ts b/src/main/utils/jsonl.ts index e09a922a..9e5f7ef8 100644 --- a/src/main/utils/jsonl.ts +++ b/src/main/utils/jsonl.ts @@ -30,6 +30,7 @@ import { import { extractToolCalls, extractToolResults } from './toolExtraction'; import type { FileSystemProvider } from '../services/infrastructure/FileSystemProvider'; +import type { PhaseTokenBreakdown } from '../types/domain'; const logger = createLogger('Util:jsonl'); @@ -300,6 +301,12 @@ export interface SessionFileMetadata { messageCount: number; isOngoing: boolean; gitBranch: string | null; + /** Total context consumed (compaction-aware) */ + contextConsumption?: number; + /** Number of compaction events */ + compactionCount?: number; + /** Per-phase token breakdown */ + phaseBreakdown?: PhaseTokenBreakdown[]; } /** @@ -339,6 +346,13 @@ export async function analyzeSessionFileMetadata( // Track tool_use IDs that are shutdown responses so their tool_results are also ending events const shutdownToolIds = new Set(); + // Context consumption tracking + + let lastMainAssistantInputTokens = 0; + const compactionPhases: { pre: number; post: number }[] = []; + + let awaitingPostCompaction = false; + for await (const line of rl) { const trimmed = line.trim(); if (!trimmed) { @@ -483,6 +497,79 @@ export async function analyzeSessionFileMetadata( } } } + + // Context consumption: track main-thread assistant input tokens + if (parsed.type === 'assistant' && !parsed.isSidechain && parsed.model !== '') { + const inputTokens = parsed.usage?.input_tokens ?? 0; + if (inputTokens > 0) { + if (awaitingPostCompaction && compactionPhases.length > 0) { + compactionPhases[compactionPhases.length - 1].post = inputTokens; + awaitingPostCompaction = false; + } + lastMainAssistantInputTokens = inputTokens; + } + } + + // Context consumption: detect compaction events + if (parsed.isCompactSummary) { + compactionPhases.push({ pre: lastMainAssistantInputTokens, post: 0 }); + awaitingPostCompaction = true; + } + } + + // Compute context consumption from tracked phases + let contextConsumption: number | undefined; + let phaseBreakdown: PhaseTokenBreakdown[] | undefined; + + if (lastMainAssistantInputTokens > 0) { + if (compactionPhases.length === 0) { + // No compaction: just the final input tokens + contextConsumption = lastMainAssistantInputTokens; + phaseBreakdown = [ + { + phaseNumber: 1, + contribution: lastMainAssistantInputTokens, + peakTokens: lastMainAssistantInputTokens, + }, + ]; + } else { + phaseBreakdown = []; + let total = 0; + + // Phase 1: tokens up to first compaction + const phase1Contribution = compactionPhases[0].pre; + total += phase1Contribution; + phaseBreakdown.push({ + phaseNumber: 1, + contribution: phase1Contribution, + peakTokens: compactionPhases[0].pre, + postCompaction: compactionPhases[0].post, + }); + + // Middle phases: contribution = pre[i] - post[i-1] + for (let i = 1; i < compactionPhases.length; i++) { + const contribution = compactionPhases[i].pre - compactionPhases[i - 1].post; + total += contribution; + phaseBreakdown.push({ + phaseNumber: i + 1, + contribution, + peakTokens: compactionPhases[i].pre, + postCompaction: compactionPhases[i].post, + }); + } + + // Last phase: final tokens - last post-compaction + const lastPhase = compactionPhases[compactionPhases.length - 1]; + const lastContribution = lastMainAssistantInputTokens - lastPhase.post; + total += lastContribution; + phaseBreakdown.push({ + phaseNumber: compactionPhases.length + 1, + contribution: lastContribution, + peakTokens: lastMainAssistantInputTokens, + }); + + contextConsumption = total; + } } return { @@ -490,5 +577,8 @@ export async function analyzeSessionFileMetadata( messageCount, isOngoing: lastEndingIndex === -1 ? hasAnyOngoingActivity : hasActivityAfterLastEnding, gitBranch, + contextConsumption, + compactionCount: compactionPhases.length > 0 ? compactionPhases.length : undefined, + phaseBreakdown, }; } diff --git a/src/renderer/components/chat/SessionContextPanel/components/RankedInjectionList.tsx b/src/renderer/components/chat/SessionContextPanel/components/RankedInjectionList.tsx new file mode 100644 index 00000000..14bd3d77 --- /dev/null +++ b/src/renderer/components/chat/SessionContextPanel/components/RankedInjectionList.tsx @@ -0,0 +1,138 @@ +/** + * RankedInjectionList - Flat list of all context injections sorted by token size descending. + * Provides a unified view across all categories, ranked by largest token consumers. + */ + +import React, { useMemo } from 'react'; + +import { COLOR_TEXT_MUTED, COLOR_TEXT_SECONDARY } from '@renderer/constants/cssVariables'; + +import { formatTokens } from '../utils/formatting'; +import { parseTurnIndex } from '../utils/pathParsing'; + +import type { ContextInjection } from '@renderer/types/contextInjection'; + +// ============================================================================= +// Constants +// ============================================================================= + +const CATEGORY_COLORS: Record = { + 'claude-md': { bg: 'rgba(99, 102, 241, 0.15)', text: '#818cf8', label: 'CLAUDE.md' }, + 'mentioned-file': { bg: 'rgba(52, 211, 153, 0.15)', text: '#34d399', label: 'File' }, + 'tool-output': { bg: 'rgba(251, 191, 36, 0.15)', text: '#fbbf24', label: 'Tool' }, + 'thinking-text': { bg: 'rgba(167, 139, 250, 0.15)', text: '#a78bfa', label: 'Thinking' }, + 'task-coordination': { bg: 'rgba(251, 146, 60, 0.15)', text: '#fb923c', label: 'Team' }, + 'user-message': { bg: 'rgba(96, 165, 250, 0.15)', text: '#60a5fa', label: 'User' }, +}; + +// ============================================================================= +// Props +// ============================================================================= + +interface RankedInjectionListProps { + injections: ContextInjection[]; + onNavigateToTurn?: (turnIndex: number) => void; +} + +// ============================================================================= +// Helpers +// ============================================================================= + +function getInjectionDescription(injection: ContextInjection): string { + switch (injection.category) { + case 'claude-md': + return injection.displayName || injection.path; + case 'mentioned-file': + return injection.displayName; + case 'tool-output': + return `${injection.toolCount} tool${injection.toolCount !== 1 ? 's' : ''} in Turn ${injection.turnIndex + 1}`; + case 'thinking-text': + return `Turn ${injection.turnIndex + 1} thinking/text`; + case 'task-coordination': + return `Turn ${injection.turnIndex + 1} coordination`; + case 'user-message': + return injection.textPreview; + } +} + +function getInjectionTurnIndex(injection: ContextInjection): number { + switch (injection.category) { + case 'claude-md': + return parseTurnIndex(injection.firstSeenInGroup); + case 'mentioned-file': + return injection.firstSeenTurnIndex; + case 'tool-output': + case 'thinking-text': + case 'task-coordination': + case 'user-message': + return injection.turnIndex; + } +} + +// ============================================================================= +// Component +// ============================================================================= + +export const RankedInjectionList = ({ + injections, + onNavigateToTurn, +}: Readonly): React.ReactElement => { + const sortedInjections = useMemo( + () => [...injections].sort((a, b) => b.estimatedTokens - a.estimatedTokens), + [injections] + ); + + const handleNavigate = (injection: ContextInjection): void => { + if (!onNavigateToTurn) return; + const turnIndex = getInjectionTurnIndex(injection); + if (turnIndex >= 0) { + onNavigateToTurn(turnIndex); + } + }; + + return ( +
+ {sortedInjections.map((inj) => { + const categoryInfo = CATEGORY_COLORS[inj.category] ?? { + bg: 'rgba(161, 161, 170, 0.15)', + text: '#a1a1aa', + label: inj.category, + }; + const description = getInjectionDescription(inj); + + return ( + + ); + })} +
+ ); +}; diff --git a/src/renderer/components/chat/SessionContextPanel/components/SessionContextHeader.tsx b/src/renderer/components/chat/SessionContextPanel/components/SessionContextHeader.tsx index 042e12cf..ae9da2e1 100644 --- a/src/renderer/components/chat/SessionContextPanel/components/SessionContextHeader.tsx +++ b/src/renderer/components/chat/SessionContextPanel/components/SessionContextHeader.tsx @@ -12,12 +12,13 @@ import { COLOR_TEXT_MUTED, COLOR_TEXT_SECONDARY, } from '@renderer/constants/cssVariables'; -import { FileText, X } from 'lucide-react'; +import { ArrowDownWideNarrow, FileText, LayoutList, X } from 'lucide-react'; import { formatTokens } from '../utils/formatting'; import { SessionContextHelpTooltip } from './SessionContextHelpTooltip'; +import type { ContextViewMode } from '../types'; import type { ContextPhaseInfo } from '@renderer/types/contextInjection'; interface SessionContextHeaderProps { @@ -28,6 +29,8 @@ interface SessionContextHeaderProps { phaseInfo?: ContextPhaseInfo; selectedPhase: number | null; onPhaseChange: (phase: number | null) => void; + viewMode: ContextViewMode; + onViewModeChange: (mode: ContextViewMode) => void; } export const SessionContextHeader = ({ @@ -38,6 +41,8 @@ export const SessionContextHeader = ({ phaseInfo, selectedPhase, onPhaseChange, + viewMode, + onViewModeChange, }: Readonly): React.ReactElement => { return (
@@ -150,6 +155,40 @@ export const SessionContextHeader = ({
)} + + {/* View mode toggle */} +
+ + View: + + + +
); }; diff --git a/src/renderer/components/chat/SessionContextPanel/index.tsx b/src/renderer/components/chat/SessionContextPanel/index.tsx index c72387bf..b03f5ad5 100644 --- a/src/renderer/components/chat/SessionContextPanel/index.tsx +++ b/src/renderer/components/chat/SessionContextPanel/index.tsx @@ -9,6 +9,7 @@ import { COLOR_BORDER, COLOR_SURFACE, COLOR_TEXT_MUTED } from '@renderer/constan import { ClaudeMdFilesSection } from './components/ClaudeMdFilesSection'; import { MentionedFilesSection } from './components/MentionedFilesSection'; +import { RankedInjectionList } from './components/RankedInjectionList'; import { SessionContextHeader } from './components/SessionContextHeader'; import { TaskCoordinationSection } from './components/TaskCoordinationSection'; import { ThinkingTextSection } from './components/ThinkingTextSection'; @@ -23,7 +24,7 @@ import { SECTION_USER_MESSAGES, } from './types'; -import type { SectionType, SessionContextPanelProps } from './types'; +import type { ContextViewMode, SectionType, SessionContextPanelProps } from './types'; import type { ClaudeMdContextInjection, MentionedFileInjection, @@ -43,6 +44,9 @@ export const SessionContextPanel = ({ selectedPhase, onPhaseChange, }: Readonly): React.ReactElement => { + // View mode: category sections or flat ranked list + const [viewMode, setViewMode] = useState('category'); + // Track which main sections are expanded const [expandedSections, setExpandedSections] = useState>( new Set([ @@ -180,6 +184,8 @@ export const SessionContextPanel = ({ phaseInfo={phaseInfo} selectedPhase={selectedPhase} onPhaseChange={onPhaseChange} + viewMode={viewMode} + onViewModeChange={setViewMode} /> {/* Content */} @@ -191,7 +197,7 @@ export const SessionContextPanel = ({ > No context injections detected in this session - ) : ( + ) : viewMode === 'category' ? ( <> + ) : ( + )} diff --git a/src/renderer/components/chat/SessionContextPanel/types.ts b/src/renderer/components/chat/SessionContextPanel/types.ts index ef1aeb4e..0c2162f2 100644 --- a/src/renderer/components/chat/SessionContextPanel/types.ts +++ b/src/renderer/components/chat/SessionContextPanel/types.ts @@ -49,6 +49,9 @@ export type SectionType = | typeof SECTION_TASK_COORDINATION | typeof SECTION_USER_MESSAGES; +/** View mode for the context panel */ +export type ContextViewMode = 'category' | 'ranked'; + // ============================================================================= // CLAUDE.md Group Types // ============================================================================= diff --git a/src/renderer/components/sidebar/DateGroupedSessions.tsx b/src/renderer/components/sidebar/DateGroupedSessions.tsx index 48b6aa47..f3412135 100644 --- a/src/renderer/components/sidebar/DateGroupedSessions.tsx +++ b/src/renderer/components/sidebar/DateGroupedSessions.tsx @@ -12,7 +12,7 @@ import { separatePinnedSessions, } from '@renderer/utils/dateGrouping'; import { useVirtualizer } from '@tanstack/react-virtual'; -import { Calendar, Loader2, MessageSquareOff, Pin } from 'lucide-react'; +import { ArrowDownWideNarrow, Calendar, Loader2, MessageSquareOff, Pin } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; import { SessionItem } from './SessionItem'; @@ -50,6 +50,8 @@ export const DateGroupedSessions = (): React.JSX.Element => { sessionsTotalCount, fetchSessionsMore, pinnedSessionIds, + sessionSortMode, + setSessionSortMode, } = useStore( useShallow((s) => ({ sessions: s.sessions, @@ -62,6 +64,8 @@ export const DateGroupedSessions = (): React.JSX.Element => { sessionsTotalCount: s.sessionsTotalCount, fetchSessionsMore: s.fetchSessionsMore, pinnedSessionIds: s.pinnedSessionIds, + sessionSortMode: s.sessionSortMode, + setSessionSortMode: s.setSessionSortMode, })) ); @@ -82,43 +86,59 @@ export const DateGroupedSessions = (): React.JSX.Element => { [groupedSessions] ); + // Sessions sorted by context consumption (for most-context sort mode) + const contextSortedSessions = useMemo(() => { + if (sessionSortMode !== 'most-context') return []; + return [...sessions].sort((a, b) => (b.contextConsumption ?? 0) - (a.contextConsumption ?? 0)); + }, [sessions, sessionSortMode]); + // Flatten sessions with date headers into virtual list items const virtualItems = useMemo((): VirtualItem[] => { const items: VirtualItem[] = []; - // Add pinned section first - if (pinnedSessions.length > 0) { - items.push({ - type: 'pinned-header', - id: 'header-pinned', - }); - - for (const session of pinnedSessions) { + if (sessionSortMode === 'most-context') { + // Flat list sorted by consumption - no date headers, no pinned section + for (const session of contextSortedSessions) { items.push({ type: 'session', session, - isPinned: true, + isPinned: pinnedSessionIds.includes(session.id), id: `session-${session.id}`, }); } - } - - for (const category of nonEmptyCategories) { - // Add header item - items.push({ - type: 'header', - category, - id: `header-${category}`, - }); - - // Add session items - for (const session of groupedSessions[category]) { + } else { + // Default: date-grouped view with pinned section + if (pinnedSessions.length > 0) { items.push({ - type: 'session', - session, - isPinned: false, - id: `session-${session.id}`, + type: 'pinned-header', + id: 'header-pinned', }); + + for (const session of pinnedSessions) { + items.push({ + type: 'session', + session, + isPinned: true, + id: `session-${session.id}`, + }); + } + } + + for (const category of nonEmptyCategories) { + items.push({ + type: 'header', + category, + id: `header-${category}`, + }); + + for (const session of groupedSessions[category]) { + items.push({ + type: 'session', + session, + isPinned: false, + id: `session-${session.id}`, + }); + } } } @@ -131,7 +151,15 @@ export const DateGroupedSessions = (): React.JSX.Element => { } return items; - }, [pinnedSessions, nonEmptyCategories, groupedSessions, sessionsHasMore]); + }, [ + sessionSortMode, + contextSortedSessions, + pinnedSessionIds, + pinnedSessions, + nonEmptyCategories, + groupedSessions, + sessionsHasMore, + ]); // Estimate item size based on type const estimateSize = useCallback( @@ -273,12 +301,24 @@ export const DateGroupedSessions = (): React.JSX.Element => { className="text-xs uppercase tracking-wider" style={{ color: 'var(--color-text-muted)' }} > - Sessions + {sessionSortMode === 'most-context' ? 'By Context' : 'Sessions'} ({sessions.length} {sessionsTotalCount > sessions.length ? ` of ${sessionsTotalCount}` : ''}) +
diff --git a/src/renderer/components/sidebar/SessionItem.tsx b/src/renderer/components/sidebar/SessionItem.tsx index 84e34590..d519d8e9 100644 --- a/src/renderer/components/sidebar/SessionItem.tsx +++ b/src/renderer/components/sidebar/SessionItem.tsx @@ -4,10 +4,11 @@ * Supports right-click context menu for pane management. */ -import { useCallback, useState } from 'react'; +import { useCallback, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { useStore } from '@renderer/store'; +import { formatTokensCompact } from '@shared/utils/tokenFormatting'; import { formatDistanceToNowStrict } from 'date-fns'; import { MessageSquare, Pin } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; @@ -16,7 +17,7 @@ import { OngoingIndicator } from '../common/OngoingIndicator'; import { SessionContextMenu } from './SessionContextMenu'; -import type { Session } from '@renderer/types/data'; +import type { PhaseTokenBreakdown, Session } from '@renderer/types/data'; interface SessionItemProps { session: Session; @@ -46,6 +47,63 @@ function formatShortTime(date: Date): string { .replace(' year', 'y'); } +/** + * Consumption badge with hover popover showing phase breakdown. + */ +const ConsumptionBadge = ({ + contextConsumption, + phaseBreakdown, +}: Readonly<{ + contextConsumption: number; + phaseBreakdown?: PhaseTokenBreakdown[]; +}>): React.JSX.Element => { + const [showPopover, setShowPopover] = useState(false); + const badgeRef = useRef(null); + const isHigh = contextConsumption > 150_000; + + return ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions -- tooltip trigger via hover, not interactive + setShowPopover(true)} + onMouseLeave={() => setShowPopover(false)} + > + {formatTokensCompact(contextConsumption)} + {showPopover && phaseBreakdown && phaseBreakdown.length > 0 && ( +
+
+ Total Context: {formatTokensCompact(contextConsumption)} tokens +
+ {phaseBreakdown.length === 1 ? ( +
Context: {formatTokensCompact(phaseBreakdown[0].peakTokens)}
+ ) : ( + phaseBreakdown.map((phase) => ( +
+ Phase {phase.phaseNumber}: + {formatTokensCompact(phase.contribution)} + {phase.postCompaction != null && ( + + (compacted → {formatTokensCompact(phase.postCompaction)}) + + )} +
+ )) + )} +
+ )} +
+ ); +}; + export const SessionItem = ({ session, isActive, @@ -162,7 +220,7 @@ export const SessionItem = ({
- {/* Second line: message count + time */} + {/* Second line: message count + time + context consumption */}
· {formatShortTime(new Date(session.createdAt))} + {session.contextConsumption != null && session.contextConsumption > 0 && ( + <> + · + + + )}
diff --git a/src/renderer/store/slices/sessionSlice.ts b/src/renderer/store/slices/sessionSlice.ts index 489e8452..a7632c47 100644 --- a/src/renderer/store/slices/sessionSlice.ts +++ b/src/renderer/store/slices/sessionSlice.ts @@ -6,7 +6,7 @@ import { api } from '@renderer/api'; import { createLogger } from '@shared/utils/logger'; import type { AppState } from '../types'; -import type { Session } from '@renderer/types/data'; +import type { Session, SessionSortMode } from '@renderer/types/data'; import type { StateCreator } from 'zustand'; const logger = createLogger('Store:session'); @@ -34,6 +34,8 @@ export interface SessionSlice { sessionsLoadingMore: boolean; // Pinned sessions pinnedSessionIds: string[]; + // Sort mode + sessionSortMode: SessionSortMode; // Actions fetchSessions: (projectId: string) => Promise; @@ -48,6 +50,8 @@ export interface SessionSlice { togglePinSession: (sessionId: string) => Promise; /** Load pinned sessions from config for current project */ loadPinnedSessions: () => Promise; + /** Set session sort mode */ + setSessionSortMode: (mode: SessionSortMode) => void; } // ============================================================================= @@ -67,6 +71,8 @@ export const createSessionSlice: StateCreator = sessionsLoadingMore: false, // Pinned sessions pinnedSessionIds: [], + // Sort mode + sessionSortMode: 'recent' as SessionSortMode, // Fetch sessions for a specific project (legacy - not paginated) fetchSessions: async (projectId: string) => { @@ -317,4 +323,9 @@ export const createSessionSlice: StateCreator = set({ pinnedSessionIds: [] }); } }, + + // Set session sort mode + setSessionSortMode: (mode: SessionSortMode) => { + set({ sessionSortMode: mode }); + }, }); diff --git a/src/renderer/types/data.ts b/src/renderer/types/data.ts index 79f5268a..8bc87278 100644 --- a/src/renderer/types/data.ts +++ b/src/renderer/types/data.ts @@ -16,6 +16,7 @@ // Domain types export type { + PhaseTokenBreakdown, Project, RepositoryGroup, SearchResult, @@ -68,6 +69,13 @@ export type { TriggerToolName, } from './notifications'; +// ============================================================================= +// Session Sort Mode +// ============================================================================= + +/** Sort mode for session list in sidebar */ +export type SessionSortMode = 'recent' | 'most-context'; + // ============================================================================= // Renderer-Specific Type Guards // ============================================================================= From 9915cf5a03ca894a6757a07e8d0f60af549f7dd4 Mon Sep 17 00:00:00 2001 From: matt Date: Mon, 16 Feb 2026 20:06:09 +0900 Subject: [PATCH 3/4] feat(settings): enhance Claude root management and UI updates - Implemented functionality to select and manage the local Claude root folder, allowing users to specify a custom path. - Added UI components for displaying and interacting with Claude root settings, including error handling for missing directories. - Enhanced the settings view to support dynamic updates based on user selections and improved state management for pending settings. - Refactored related components to integrate the new Claude root features seamlessly, including updates to the general settings section and connection handling. --- src/main/utils/jsonl.ts | 5 +- .../components/RankedInjectionList.tsx | 154 +++++++- .../components/dashboard/DashboardView.tsx | 23 +- src/renderer/components/layout/TabBar.tsx | 2 +- .../components/settings/SettingsView.tsx | 12 +- .../settings/sections/ConnectionSection.tsx | 334 +--------------- .../settings/sections/GeneralSection.tsx | 356 +++++++++++++++++- .../components/sidebar/SessionItem.tsx | 77 ++-- src/renderer/store/slices/configSlice.ts | 15 +- 9 files changed, 585 insertions(+), 393 deletions(-) diff --git a/src/main/utils/jsonl.ts b/src/main/utils/jsonl.ts index 9e5f7ef8..1d70be2c 100644 --- a/src/main/utils/jsonl.ts +++ b/src/main/utils/jsonl.ts @@ -500,7 +500,10 @@ export async function analyzeSessionFileMetadata( // Context consumption: track main-thread assistant input tokens if (parsed.type === 'assistant' && !parsed.isSidechain && parsed.model !== '') { - const inputTokens = parsed.usage?.input_tokens ?? 0; + const inputTokens = + (parsed.usage?.input_tokens ?? 0) + + (parsed.usage?.cache_read_input_tokens ?? 0) + + (parsed.usage?.cache_creation_input_tokens ?? 0); if (inputTokens > 0) { if (awaitingPostCompaction && compactionPhases.length > 0) { compactionPhases[compactionPhases.length - 1].post = inputTokens; diff --git a/src/renderer/components/chat/SessionContextPanel/components/RankedInjectionList.tsx b/src/renderer/components/chat/SessionContextPanel/components/RankedInjectionList.tsx index 14bd3d77..e7101937 100644 --- a/src/renderer/components/chat/SessionContextPanel/components/RankedInjectionList.tsx +++ b/src/renderer/components/chat/SessionContextPanel/components/RankedInjectionList.tsx @@ -1,16 +1,18 @@ /** - * RankedInjectionList - Flat list of all context injections sorted by token size descending. - * Provides a unified view across all categories, ranked by largest token consumers. + * 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. */ -import React, { useMemo } from 'react'; +import React, { useMemo, useState } from 'react'; import { COLOR_TEXT_MUTED, COLOR_TEXT_SECONDARY } from '@renderer/constants/cssVariables'; +import { ChevronRight } from 'lucide-react'; import { formatTokens } from '../utils/formatting'; import { parseTurnIndex } from '../utils/pathParsing'; -import type { ContextInjection } from '@renderer/types/contextInjection'; +import type { ContextInjection, ToolOutputInjection } from '@renderer/types/contextInjection'; // ============================================================================= // Constants @@ -69,6 +71,113 @@ function getInjectionTurnIndex(injection: ContextInjection): number { } } +// ============================================================================= +// Sub-components +// ============================================================================= + +/** Expandable tool-output row with breakdown sorted by token count desc. */ +const ToolOutputRankedItem = ({ + injection, + onNavigateToTurn, +}: Readonly<{ + injection: ToolOutputInjection; + onNavigateToTurn?: (turnIndex: number) => void; +}>): React.ReactElement => { + const [expanded, setExpanded] = useState(false); + const hasBreakdown = injection.toolBreakdown.length > 0; + const categoryInfo = CATEGORY_COLORS['tool-output']; + + const sortedBreakdown = useMemo( + () => [...injection.toolBreakdown].sort((a, b) => b.tokenCount - a.tokenCount), + [injection.toolBreakdown] + ); + + return ( +
+ + + {/* Expanded tool breakdown */} + {expanded && hasBreakdown && ( +
+ {sortedBreakdown.map((tool, idx) => ( + + ))} +
+ )} +
+ ); +}; + // ============================================================================= // Component // ============================================================================= @@ -82,37 +191,42 @@ export const RankedInjectionList = ({ [injections] ); - const handleNavigate = (injection: ContextInjection): void => { - if (!onNavigateToTurn) return; - const turnIndex = getInjectionTurnIndex(injection); - if (turnIndex >= 0) { - onNavigateToTurn(turnIndex); - } - }; - return ( -
+
{sortedInjections.map((inj) => { + // Tool-output: expandable row + if (inj.category === 'tool-output') { + return ( + + ); + } + + // All other categories: simple row const categoryInfo = CATEGORY_COLORS[inj.category] ?? { bg: 'rgba(161, 161, 170, 0.15)', text: '#a1a1aa', label: inj.category, }; - const description = getInjectionDescription(inj); return ( + )} - )} +
{/* Projects Grid */} diff --git a/src/renderer/components/layout/TabBar.tsx b/src/renderer/components/layout/TabBar.tsx index 9cf37c90..26663710 100644 --- a/src/renderer/components/layout/TabBar.tsx +++ b/src/renderer/components/layout/TabBar.tsx @@ -387,7 +387,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { {/* Settings gear icon (Electron only - browser can't access native settings) */} {isElectronMode() && ( - - - - {isWindowsStyleDefaultPath && ( - - )} - - - {claudeRootError && ( -
-

{claudeRootError}

-
- )} - - {showWslModal && ( -
- -
- ))} - - -
- - -
- - - )} -

Connect to a remote machine to view Claude Code sessions running there diff --git a/src/renderer/components/settings/sections/GeneralSection.tsx b/src/renderer/components/settings/sections/GeneralSection.tsx index fdd1a38d..492ef7d3 100644 --- a/src/renderer/components/settings/sections/GeneralSection.tsx +++ b/src/renderer/components/settings/sections/GeneralSection.tsx @@ -1,15 +1,19 @@ /** - * GeneralSection - General settings including startup, appearance, and browser access. + * GeneralSection - General settings including startup, appearance, browser access, and local Claude root. */ import { useCallback, useEffect, useState } from 'react'; import { api } from '@renderer/api'; -import { Check, Copy, Loader2 } from 'lucide-react'; +import { confirm } from '@renderer/components/common/ConfirmDialog'; +import { useStore } from '@renderer/store'; +import { getFullResetState } from '@renderer/store/utils/stateResetHelpers'; +import { Check, Copy, FolderOpen, Laptop, Loader2, RotateCcw } from 'lucide-react'; import { SettingRow, SettingsSectionHeader, SettingsSelect, SettingsToggle } from '../components'; import type { SafeConfig } from '../hooks/useSettingsConfig'; +import type { ClaudeRootInfo, WslClaudeRootCandidate } from '@shared/types'; import type { HttpServerStatus } from '@shared/types/api'; // Theme options @@ -39,11 +43,38 @@ export const GeneralSection = ({ const [serverLoading, setServerLoading] = useState(false); const [copied, setCopied] = useState(false); - // Fetch server status on mount + // Claude Root state + const connectionMode = useStore((s) => s.connectionMode); + const fetchProjects = useStore((s) => s.fetchProjects); + const fetchRepositoryGroups = useStore((s) => s.fetchRepositoryGroups); + + const [claudeRootInfo, setClaudeRootInfo] = useState(null); + const [updatingClaudeRoot, setUpdatingClaudeRoot] = useState(false); + const [claudeRootError, setClaudeRootError] = useState(null); + const [findingWslRoots, setFindingWslRoots] = useState(false); + const [wslCandidates, setWslCandidates] = useState([]); + const [showWslModal, setShowWslModal] = useState(false); + + // Fetch server status and Claude root info on mount useEffect(() => { void api.httpServer.getStatus().then(setServerStatus); }, []); + const loadClaudeRootInfo = useCallback(async () => { + try { + const info = await api.config.getClaudeRootInfo(); + setClaudeRootInfo(info); + } catch (error) { + setClaudeRootError( + error instanceof Error ? error.message : 'Failed to load local Claude root settings' + ); + } + }, []); + + useEffect(() => { + void loadClaudeRootInfo(); + }, [loadClaudeRootInfo]); + const handleServerToggle = useCallback(async (enabled: boolean) => { setServerLoading(true); try { @@ -64,6 +95,156 @@ export const GeneralSection = ({ setTimeout(() => setCopied(false), 2000); }, [serverUrl]); + // Claude Root handlers + const resetWorkspaceForRootChange = useCallback((): void => { + useStore.setState({ + projects: [], + repositoryGroups: [], + openTabs: [], + activeTabId: null, + selectedTabIds: [], + paneLayout: { + panes: [ + { + id: 'pane-default', + tabs: [], + activeTabId: null, + selectedTabIds: [], + widthFraction: 1, + }, + ], + focusedPaneId: 'pane-default', + }, + ...getFullResetState(), + }); + }, []); + + const applyClaudeRootPath = useCallback( + async (claudeRootPath: string | null): Promise => { + try { + setUpdatingClaudeRoot(true); + setClaudeRootError(null); + + await api.config.update('general', { claudeRootPath }); + await loadClaudeRootInfo(); + + if (connectionMode === 'local') { + resetWorkspaceForRootChange(); + await Promise.all([fetchProjects(), fetchRepositoryGroups()]); + } + } catch (error) { + setClaudeRootError(error instanceof Error ? error.message : 'Failed to update Claude root'); + } finally { + setUpdatingClaudeRoot(false); + } + }, + [ + connectionMode, + fetchProjects, + fetchRepositoryGroups, + loadClaudeRootInfo, + resetWorkspaceForRootChange, + ] + ); + + const handleSelectClaudeRootFolder = useCallback(async (): Promise => { + setClaudeRootError(null); + + const selection = await api.config.selectClaudeRootFolder(); + if (!selection) { + return; + } + + if (!selection.isClaudeDirName) { + const proceed = await confirm({ + title: 'Selected folder is not .claude', + message: `This folder is named "${selection.path.split(/[\\/]/).pop() ?? selection.path}", not ".claude". Continue anyway?`, + confirmLabel: 'Use Folder', + }); + if (!proceed) { + return; + } + } + + if (!selection.hasProjectsDir) { + const proceed = await confirm({ + title: 'No projects directory found', + message: 'This folder does not contain a "projects" directory. Continue anyway?', + confirmLabel: 'Use Folder', + }); + if (!proceed) { + return; + } + } + + await applyClaudeRootPath(selection.path); + }, [applyClaudeRootPath]); + + const handleResetClaudeRoot = useCallback(async (): Promise => { + await applyClaudeRootPath(null); + }, [applyClaudeRootPath]); + + const applyWslCandidate = useCallback( + async (candidate: WslClaudeRootCandidate): Promise => { + if (!candidate.hasProjectsDir) { + const proceed = await confirm({ + title: 'WSL path missing projects directory', + message: `"${candidate.path}" does not contain a "projects" directory. Continue anyway?`, + confirmLabel: 'Use Path', + }); + if (!proceed) { + return; + } + } + + await applyClaudeRootPath(candidate.path); + setShowWslModal(false); + }, + [applyClaudeRootPath] + ); + + const handleUseWslForClaude = useCallback(async (): Promise => { + try { + setFindingWslRoots(true); + setClaudeRootError(null); + const candidates = await api.config.findWslClaudeRoots(); + setWslCandidates(candidates); + + if (candidates.length === 0) { + const pickManually = await confirm({ + title: 'No WSL Claude paths found', + message: + 'Could not find WSL distros with Claude data automatically. Select folder manually?', + confirmLabel: 'Select Folder', + }); + if (pickManually) { + await handleSelectClaudeRootFolder(); + } + return; + } + + const candidatesWithProjects = candidates.filter((candidate) => candidate.hasProjectsDir); + if (candidatesWithProjects.length === 1) { + await applyWslCandidate(candidatesWithProjects[0]); + return; + } + + setShowWslModal(true); + } catch (error) { + setClaudeRootError( + error instanceof Error ? error.message : 'Failed to detect WSL Claude root paths' + ); + } finally { + setFindingWslRoots(false); + } + }, [applyWslCandidate, handleSelectClaudeRootFolder]); + + const isCustomClaudeRoot = Boolean(claudeRootInfo?.customPath); + const resolvedClaudeRootPath = claudeRootInfo?.resolvedPath ?? '~/.claude'; + const defaultClaudeRootPath = claudeRootInfo?.defaultPath ?? '~/.claude'; + const isWindowsStyleDefaultPath = + /^[a-zA-Z]:\\/.test(defaultClaudeRootPath) || defaultClaudeRootPath.startsWith('\\\\'); + return (

@@ -94,6 +275,175 @@ export const GeneralSection = ({ /> + +

+ Choose which local folder is treated as your Claude data root +

+ + +
+
+ {resolvedClaudeRootPath} +
+
+ Auto-detected: {defaultClaudeRootPath} +
+
+
+ +
+ + + + + {isWindowsStyleDefaultPath && ( + + )} +
+ + {claudeRootError && ( +
+

{claudeRootError}

+
+ )} + + {showWslModal && ( +
+ +
+ ))} +
+ +
+ + +
+ + + )} + (null); const isHigh = contextConsumption > 150_000; + // Calculate popover position relative to viewport for portal rendering + const popoverPosition = + showPopover && badgeRef.current + ? (() => { + const rect = badgeRef.current.getBoundingClientRect(); + return { + top: rect.top - 6, + left: rect.left + rect.width / 2, + }; + })() + : null; + return ( // eslint-disable-next-line jsx-a11y/no-static-element-interactions -- tooltip trigger via hover, not interactive setShowPopover(true)} onMouseLeave={() => setShowPopover(false)} > {formatTokensCompact(contextConsumption)} - {showPopover && phaseBreakdown && phaseBreakdown.length > 0 && ( -
-
- Total Context: {formatTokensCompact(contextConsumption)} tokens -
- {phaseBreakdown.length === 1 ? ( -
Context: {formatTokensCompact(phaseBreakdown[0].peakTokens)}
- ) : ( - phaseBreakdown.map((phase) => ( -
- Phase {phase.phaseNumber}: - {formatTokensCompact(phase.contribution)} - {phase.postCompaction != null && ( + {showPopover && + popoverPosition && + phaseBreakdown && + phaseBreakdown.length > 0 && + createPortal( +
+
+ Total Context: {formatTokensCompact(contextConsumption)} tokens +
+ {phaseBreakdown.length === 1 ? ( +
Context: {formatTokensCompact(phaseBreakdown[0].peakTokens)}
+ ) : ( + phaseBreakdown.map((phase) => ( +
- (compacted → {formatTokensCompact(phase.postCompaction)}) + Phase {phase.phaseNumber}: - )} -
- )) - )} -
- )} + {formatTokensCompact(phase.contribution)} + {phase.postCompaction != null && ( + + (compacted → {formatTokensCompact(phase.postCompaction)}) + + )} +
+ )) + )} +
, + document.body + )}
); }; diff --git a/src/renderer/store/slices/configSlice.ts b/src/renderer/store/slices/configSlice.ts index da32ba77..0cdc5320 100644 --- a/src/renderer/store/slices/configSlice.ts +++ b/src/renderer/store/slices/configSlice.ts @@ -20,11 +20,13 @@ export interface ConfigSlice { appConfig: AppConfig | null; configLoading: boolean; configError: string | null; + pendingSettingsSection: string | null; // Actions fetchConfig: () => Promise; updateConfig: (section: string, data: Record) => Promise; - openSettingsTab: () => void; + openSettingsTab: (section?: string) => void; + clearPendingSettingsSection: () => void; } // ============================================================================= @@ -36,6 +38,7 @@ export const createConfigSlice: StateCreator = (s appConfig: null, configLoading: false, configError: null, + pendingSettingsSection: null, // Fetch app configuration from main process fetchConfig: async () => { @@ -70,9 +73,13 @@ export const createConfigSlice: StateCreator = (s }, // Open or focus the settings tab (per-pane singleton) - openSettingsTab: () => { + openSettingsTab: (section?: string) => { const state = get(); + if (section) { + set({ pendingSettingsSection: section }); + } + // Check if settings tab exists in focused pane const focusedPane = state.paneLayout.panes.find((p) => p.id === state.paneLayout.focusedPaneId); const settingsTab = focusedPane?.tabs.find((t) => t.type === 'settings'); @@ -87,4 +94,8 @@ export const createConfigSlice: StateCreator = (s label: 'Settings', }); }, + + clearPendingSettingsSection: () => { + set({ pendingSettingsSection: null }); + }, }); From fb2d56e23f10d01e1efa4f4a35057f2457cc5c53 Mon Sep 17 00:00:00 2001 From: matt Date: Mon, 16 Feb 2026 20:36:18 +0900 Subject: [PATCH 4/4] 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 + )}