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 15b2cc1f..1d70be2c 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[]; } /** @@ -328,6 +335,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; @@ -337,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) { @@ -357,6 +373,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) { @@ -472,6 +497,82 @@ 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) + + (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; + 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 { @@ -479,5 +580,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/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 new file mode 100644 index 00000000..ebbe6d5f --- /dev/null +++ b/src/renderer/components/chat/SessionContextPanel/components/RankedInjectionList.tsx @@ -0,0 +1,284 @@ +/** + * 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'; + +import { formatTokens } from '../utils/formatting'; +import { parseTurnIndex } from '../utils/pathParsing'; + +import type { ContextInjection, ToolOutputInjection } 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; + onNavigateToTool?: (turnIndex: number, toolUseId: string) => void; + onNavigateToUserGroup?: (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; + } +} + +/** 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 +// ============================================================================= + +/** Expandable tool-output row with breakdown sorted by token count desc. */ +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; + 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 +// ============================================================================= + +export const RankedInjectionList = ({ + injections, + onNavigateToTurn, + onNavigateToTool, + onNavigateToUserGroup, +}: Readonly): React.ReactElement => { + const sortedInjections = useMemo( + () => [...injections].sort((a, b) => b.estimatedTokens - a.estimatedTokens), + [injections] + ); + + return ( +
+ {sortedInjections.map((inj) => { + // Tool-output: expandable row + if (inj.category === 'tool-output') { + return ( + + ); + } + + 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 ( +
+ + {/* Copy path button for CLAUDE.md and File items */} + {copyPath && ( + e.stopPropagation()}> + + + )} +
+ ); + })} +
+ ); +}; 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..28c540e2 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, @@ -38,11 +39,16 @@ export const SessionContextPanel = ({ onClose, projectRoot, onNavigateToTurn, + onNavigateToTool, + onNavigateToUserGroup, totalSessionTokens, phaseInfo, 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 +186,8 @@ export const SessionContextPanel = ({ phaseInfo={phaseInfo} selectedPhase={selectedPhase} onPhaseChange={onPhaseChange} + viewMode={viewMode} + onViewModeChange={setViewMode} /> {/* Content */} @@ -191,7 +199,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..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 */ @@ -49,6 +53,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/dashboard/DashboardView.tsx b/src/renderer/components/dashboard/DashboardView.tsx index 593be370..a85afb05 100644 --- a/src/renderer/components/dashboard/DashboardView.tsx +++ b/src/renderer/components/dashboard/DashboardView.tsx @@ -16,7 +16,7 @@ import { useShallow } from 'zustand/react/shallow'; const logger = createLogger('Component:DashboardView'); import { formatDistanceToNow } from 'date-fns'; -import { Command, FolderGit2, FolderOpen, GitBranch, Search } from 'lucide-react'; +import { Command, FolderGit2, FolderOpen, GitBranch, Search, Settings } from 'lucide-react'; import type { RepositoryGroup } from '@renderer/types/data'; @@ -394,6 +394,7 @@ const ProjectsGrid = ({ export const DashboardView = (): React.JSX.Element => { const [searchQuery, setSearchQuery] = useState(''); + const openSettingsTab = useStore((s) => s.openSettingsTab); return (
@@ -415,14 +416,24 @@ export const DashboardView = (): React.JSX.Element => {

{searchQuery.trim() ? 'Search Results' : 'Recent Projects'}

- {searchQuery.trim() && ( +
+ {searchQuery.trim() && ( + + )} - )} +
{/* 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 && ( +
+ +
+ ))} +
+ +
+ + +
+ + + )} + { sessionsError, sessionsHasMore, sessionsLoadingMore, - sessionsTotalCount, fetchSessionsMore, pinnedSessionIds, + sessionSortMode, + setSessionSortMode, } = useStore( useShallow((s) => ({ sessions: s.sessions, @@ -59,13 +61,16 @@ 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, + setSessionSortMode: s.setSessionSortMode, })) ); const parentRef = useRef(null); + const countRef = useRef(null); + const [showCountTooltip, setShowCountTooltip] = useState(false); // Separate pinned sessions from unpinned const { pinned: pinnedSessions, unpinned: unpinnedSessions } = useMemo( @@ -82,43 +87,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 +152,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 +302,53 @@ 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'} - + {/* 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 + )} +
diff --git a/src/renderer/components/sidebar/SessionItem.tsx b/src/renderer/components/sidebar/SessionItem.tsx index 84e34590..6345765d 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,84 @@ 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; + + // 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 && + popoverPosition && + phaseBreakdown && + phaseBreakdown.length > 0 && + createPortal( +
+
+ 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)}) + + )} +
+ )) + )} +
, + document.body + )} +
+ ); +}; + export const SessionItem = ({ session, isActive, @@ -162,7 +241,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/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 }); + }, }); 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/contextInjection.ts b/src/renderer/types/contextInjection.ts index 3ad3be56..86406839 100644 --- a/src/renderer/types/contextInjection.ts +++ b/src/renderer/types/contextInjection.ts @@ -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; } /** 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 // ============================================================================= diff --git a/src/renderer/utils/contextTracker.ts b/src/renderer/utils/contextTracker.ts index d1b54aef..021afb5a 100644 --- a/src/renderer/utils/contextTracker.ts +++ b/src/renderer/utils/contextTracker.ts @@ -214,6 +214,7 @@ function aggregateToolOutputs( toolName: displayName, tokenCount: toolTokenCount, isError: linkedTool.result?.isError ?? false, + toolUseId: linkedTool.id, }); totalTokens += toolTokenCount; } 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 {