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.
This commit is contained in:
parent
44a499e62c
commit
8b2dbf3bcb
11 changed files with 462 additions and 35 deletions
|
|
@ -758,6 +758,9 @@ export class ProjectScanner {
|
||||||
isOngoing: metadata.isOngoing,
|
isOngoing: metadata.isOngoing,
|
||||||
gitBranch: metadata.gitBranch ?? undefined,
|
gitBranch: metadata.gitBranch ?? undefined,
|
||||||
metadataLevel,
|
metadataLevel,
|
||||||
|
contextConsumption: metadata.contextConsumption,
|
||||||
|
compactionCount: metadata.compactionCount,
|
||||||
|
phaseBreakdown: metadata.phaseBreakdown,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,20 @@ export interface Project {
|
||||||
*/
|
*/
|
||||||
export type SessionMetadataLevel = 'light' | 'deep';
|
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 {
|
export interface Session {
|
||||||
/** Session UUID (JSONL filename without extension) */
|
/** Session UUID (JSONL filename without extension) */
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -89,6 +103,12 @@ export interface Session {
|
||||||
gitBranch?: string;
|
gitBranch?: string;
|
||||||
/** Metadata completeness level */
|
/** Metadata completeness level */
|
||||||
metadataLevel?: SessionMetadataLevel;
|
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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import {
|
||||||
import { extractToolCalls, extractToolResults } from './toolExtraction';
|
import { extractToolCalls, extractToolResults } from './toolExtraction';
|
||||||
|
|
||||||
import type { FileSystemProvider } from '../services/infrastructure/FileSystemProvider';
|
import type { FileSystemProvider } from '../services/infrastructure/FileSystemProvider';
|
||||||
|
import type { PhaseTokenBreakdown } from '../types/domain';
|
||||||
|
|
||||||
const logger = createLogger('Util:jsonl');
|
const logger = createLogger('Util:jsonl');
|
||||||
|
|
||||||
|
|
@ -300,6 +301,12 @@ export interface SessionFileMetadata {
|
||||||
messageCount: number;
|
messageCount: number;
|
||||||
isOngoing: boolean;
|
isOngoing: boolean;
|
||||||
gitBranch: string | null;
|
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
|
// Track tool_use IDs that are shutdown responses so their tool_results are also ending events
|
||||||
const shutdownToolIds = new Set<string>();
|
const shutdownToolIds = new Set<string>();
|
||||||
|
|
||||||
|
// Context consumption tracking
|
||||||
|
|
||||||
|
let lastMainAssistantInputTokens = 0;
|
||||||
|
const compactionPhases: { pre: number; post: number }[] = [];
|
||||||
|
|
||||||
|
let awaitingPostCompaction = false;
|
||||||
|
|
||||||
for await (const line of rl) {
|
for await (const line of rl) {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
if (!trimmed) {
|
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 !== '<synthetic>') {
|
||||||
|
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 {
|
return {
|
||||||
|
|
@ -490,5 +577,8 @@ export async function analyzeSessionFileMetadata(
|
||||||
messageCount,
|
messageCount,
|
||||||
isOngoing: lastEndingIndex === -1 ? hasAnyOngoingActivity : hasActivityAfterLastEnding,
|
isOngoing: lastEndingIndex === -1 ? hasAnyOngoingActivity : hasActivityAfterLastEnding,
|
||||||
gitBranch,
|
gitBranch,
|
||||||
|
contextConsumption,
|
||||||
|
compactionCount: compactionPhases.length > 0 ? compactionPhases.length : undefined,
|
||||||
|
phaseBreakdown,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<string, { bg: string; text: string; label: string }> = {
|
||||||
|
'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<RankedInjectionListProps>): 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 (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{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 (
|
||||||
|
<button
|
||||||
|
key={inj.id}
|
||||||
|
onClick={() => handleNavigate(inj)}
|
||||||
|
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left transition-colors hover:bg-white/5"
|
||||||
|
>
|
||||||
|
{/* Category pill */}
|
||||||
|
<span
|
||||||
|
className="shrink-0 rounded px-1.5 py-0.5 text-[9px] font-medium"
|
||||||
|
style={{
|
||||||
|
backgroundColor: categoryInfo.bg,
|
||||||
|
color: categoryInfo.text,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{categoryInfo.label}
|
||||||
|
</span>
|
||||||
|
{/* Description */}
|
||||||
|
<span
|
||||||
|
className="min-w-0 flex-1 truncate text-xs"
|
||||||
|
style={{ color: COLOR_TEXT_SECONDARY }}
|
||||||
|
>
|
||||||
|
{description}
|
||||||
|
</span>
|
||||||
|
{/* Token count */}
|
||||||
|
<span
|
||||||
|
className="shrink-0 text-xs font-medium tabular-nums"
|
||||||
|
style={{ color: COLOR_TEXT_MUTED }}
|
||||||
|
>
|
||||||
|
{formatTokens(inj.estimatedTokens)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -12,12 +12,13 @@ import {
|
||||||
COLOR_TEXT_MUTED,
|
COLOR_TEXT_MUTED,
|
||||||
COLOR_TEXT_SECONDARY,
|
COLOR_TEXT_SECONDARY,
|
||||||
} from '@renderer/constants/cssVariables';
|
} from '@renderer/constants/cssVariables';
|
||||||
import { FileText, X } from 'lucide-react';
|
import { ArrowDownWideNarrow, FileText, LayoutList, X } from 'lucide-react';
|
||||||
|
|
||||||
import { formatTokens } from '../utils/formatting';
|
import { formatTokens } from '../utils/formatting';
|
||||||
|
|
||||||
import { SessionContextHelpTooltip } from './SessionContextHelpTooltip';
|
import { SessionContextHelpTooltip } from './SessionContextHelpTooltip';
|
||||||
|
|
||||||
|
import type { ContextViewMode } from '../types';
|
||||||
import type { ContextPhaseInfo } from '@renderer/types/contextInjection';
|
import type { ContextPhaseInfo } from '@renderer/types/contextInjection';
|
||||||
|
|
||||||
interface SessionContextHeaderProps {
|
interface SessionContextHeaderProps {
|
||||||
|
|
@ -28,6 +29,8 @@ interface SessionContextHeaderProps {
|
||||||
phaseInfo?: ContextPhaseInfo;
|
phaseInfo?: ContextPhaseInfo;
|
||||||
selectedPhase: number | null;
|
selectedPhase: number | null;
|
||||||
onPhaseChange: (phase: number | null) => void;
|
onPhaseChange: (phase: number | null) => void;
|
||||||
|
viewMode: ContextViewMode;
|
||||||
|
onViewModeChange: (mode: ContextViewMode) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SessionContextHeader = ({
|
export const SessionContextHeader = ({
|
||||||
|
|
@ -38,6 +41,8 @@ export const SessionContextHeader = ({
|
||||||
phaseInfo,
|
phaseInfo,
|
||||||
selectedPhase,
|
selectedPhase,
|
||||||
onPhaseChange,
|
onPhaseChange,
|
||||||
|
viewMode,
|
||||||
|
onViewModeChange,
|
||||||
}: Readonly<SessionContextHeaderProps>): React.ReactElement => {
|
}: Readonly<SessionContextHeaderProps>): React.ReactElement => {
|
||||||
return (
|
return (
|
||||||
<div className="shrink-0 px-4 py-3" style={{ borderBottom: `1px solid ${COLOR_BORDER}` }}>
|
<div className="shrink-0 px-4 py-3" style={{ borderBottom: `1px solid ${COLOR_BORDER}` }}>
|
||||||
|
|
@ -150,6 +155,40 @@ export const SessionContextHeader = ({
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* View mode toggle */}
|
||||||
|
<div
|
||||||
|
className="mt-2 flex items-center gap-1 pt-2"
|
||||||
|
style={{ borderTop: `1px solid ${COLOR_BORDER_SUBTLE}` }}
|
||||||
|
>
|
||||||
|
<span className="mr-1 text-[10px]" style={{ color: COLOR_TEXT_MUTED }}>
|
||||||
|
View:
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => onViewModeChange('category')}
|
||||||
|
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] transition-colors"
|
||||||
|
style={{
|
||||||
|
backgroundColor:
|
||||||
|
viewMode === 'category' ? 'rgba(99, 102, 241, 0.2)' : COLOR_SURFACE_OVERLAY,
|
||||||
|
color: viewMode === 'category' ? '#818cf8' : COLOR_TEXT_MUTED,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LayoutList size={10} />
|
||||||
|
Category
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onViewModeChange('ranked')}
|
||||||
|
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] transition-colors"
|
||||||
|
style={{
|
||||||
|
backgroundColor:
|
||||||
|
viewMode === 'ranked' ? 'rgba(99, 102, 241, 0.2)' : COLOR_SURFACE_OVERLAY,
|
||||||
|
color: viewMode === 'ranked' ? '#818cf8' : COLOR_TEXT_MUTED,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ArrowDownWideNarrow size={10} />
|
||||||
|
By Size
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { COLOR_BORDER, COLOR_SURFACE, COLOR_TEXT_MUTED } from '@renderer/constan
|
||||||
|
|
||||||
import { ClaudeMdFilesSection } from './components/ClaudeMdFilesSection';
|
import { ClaudeMdFilesSection } from './components/ClaudeMdFilesSection';
|
||||||
import { MentionedFilesSection } from './components/MentionedFilesSection';
|
import { MentionedFilesSection } from './components/MentionedFilesSection';
|
||||||
|
import { RankedInjectionList } from './components/RankedInjectionList';
|
||||||
import { SessionContextHeader } from './components/SessionContextHeader';
|
import { SessionContextHeader } from './components/SessionContextHeader';
|
||||||
import { TaskCoordinationSection } from './components/TaskCoordinationSection';
|
import { TaskCoordinationSection } from './components/TaskCoordinationSection';
|
||||||
import { ThinkingTextSection } from './components/ThinkingTextSection';
|
import { ThinkingTextSection } from './components/ThinkingTextSection';
|
||||||
|
|
@ -23,7 +24,7 @@ import {
|
||||||
SECTION_USER_MESSAGES,
|
SECTION_USER_MESSAGES,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
import type { SectionType, SessionContextPanelProps } from './types';
|
import type { ContextViewMode, SectionType, SessionContextPanelProps } from './types';
|
||||||
import type {
|
import type {
|
||||||
ClaudeMdContextInjection,
|
ClaudeMdContextInjection,
|
||||||
MentionedFileInjection,
|
MentionedFileInjection,
|
||||||
|
|
@ -43,6 +44,9 @@ export const SessionContextPanel = ({
|
||||||
selectedPhase,
|
selectedPhase,
|
||||||
onPhaseChange,
|
onPhaseChange,
|
||||||
}: Readonly<SessionContextPanelProps>): React.ReactElement => {
|
}: Readonly<SessionContextPanelProps>): React.ReactElement => {
|
||||||
|
// View mode: category sections or flat ranked list
|
||||||
|
const [viewMode, setViewMode] = useState<ContextViewMode>('category');
|
||||||
|
|
||||||
// Track which main sections are expanded
|
// Track which main sections are expanded
|
||||||
const [expandedSections, setExpandedSections] = useState<Set<SectionType>>(
|
const [expandedSections, setExpandedSections] = useState<Set<SectionType>>(
|
||||||
new Set([
|
new Set([
|
||||||
|
|
@ -180,6 +184,8 @@ export const SessionContextPanel = ({
|
||||||
phaseInfo={phaseInfo}
|
phaseInfo={phaseInfo}
|
||||||
selectedPhase={selectedPhase}
|
selectedPhase={selectedPhase}
|
||||||
onPhaseChange={onPhaseChange}
|
onPhaseChange={onPhaseChange}
|
||||||
|
viewMode={viewMode}
|
||||||
|
onViewModeChange={setViewMode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
|
|
@ -191,7 +197,7 @@ export const SessionContextPanel = ({
|
||||||
>
|
>
|
||||||
No context injections detected in this session
|
No context injections detected in this session
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : viewMode === 'category' ? (
|
||||||
<>
|
<>
|
||||||
<UserMessagesSection
|
<UserMessagesSection
|
||||||
injections={userMessageInjections}
|
injections={userMessageInjections}
|
||||||
|
|
@ -243,6 +249,8 @@ export const SessionContextPanel = ({
|
||||||
onNavigateToTurn={onNavigateToTurn}
|
onNavigateToTurn={onNavigateToTurn}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
) : (
|
||||||
|
<RankedInjectionList injections={injections} onNavigateToTurn={onNavigateToTurn} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,9 @@ export type SectionType =
|
||||||
| typeof SECTION_TASK_COORDINATION
|
| typeof SECTION_TASK_COORDINATION
|
||||||
| typeof SECTION_USER_MESSAGES;
|
| typeof SECTION_USER_MESSAGES;
|
||||||
|
|
||||||
|
/** View mode for the context panel */
|
||||||
|
export type ContextViewMode = 'category' | 'ranked';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// CLAUDE.md Group Types
|
// CLAUDE.md Group Types
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import {
|
||||||
separatePinnedSessions,
|
separatePinnedSessions,
|
||||||
} from '@renderer/utils/dateGrouping';
|
} from '@renderer/utils/dateGrouping';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
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 { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { SessionItem } from './SessionItem';
|
import { SessionItem } from './SessionItem';
|
||||||
|
|
@ -50,6 +50,8 @@ export const DateGroupedSessions = (): React.JSX.Element => {
|
||||||
sessionsTotalCount,
|
sessionsTotalCount,
|
||||||
fetchSessionsMore,
|
fetchSessionsMore,
|
||||||
pinnedSessionIds,
|
pinnedSessionIds,
|
||||||
|
sessionSortMode,
|
||||||
|
setSessionSortMode,
|
||||||
} = useStore(
|
} = useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
sessions: s.sessions,
|
sessions: s.sessions,
|
||||||
|
|
@ -62,6 +64,8 @@ export const DateGroupedSessions = (): React.JSX.Element => {
|
||||||
sessionsTotalCount: s.sessionsTotalCount,
|
sessionsTotalCount: s.sessionsTotalCount,
|
||||||
fetchSessionsMore: s.fetchSessionsMore,
|
fetchSessionsMore: s.fetchSessionsMore,
|
||||||
pinnedSessionIds: s.pinnedSessionIds,
|
pinnedSessionIds: s.pinnedSessionIds,
|
||||||
|
sessionSortMode: s.sessionSortMode,
|
||||||
|
setSessionSortMode: s.setSessionSortMode,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -82,43 +86,59 @@ export const DateGroupedSessions = (): React.JSX.Element => {
|
||||||
[groupedSessions]
|
[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
|
// Flatten sessions with date headers into virtual list items
|
||||||
const virtualItems = useMemo((): VirtualItem[] => {
|
const virtualItems = useMemo((): VirtualItem[] => {
|
||||||
const items: VirtualItem[] = [];
|
const items: VirtualItem[] = [];
|
||||||
|
|
||||||
// Add pinned section first
|
if (sessionSortMode === 'most-context') {
|
||||||
if (pinnedSessions.length > 0) {
|
// Flat list sorted by consumption - no date headers, no pinned section
|
||||||
items.push({
|
for (const session of contextSortedSessions) {
|
||||||
type: 'pinned-header',
|
|
||||||
id: 'header-pinned',
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const session of pinnedSessions) {
|
|
||||||
items.push({
|
items.push({
|
||||||
type: 'session',
|
type: 'session',
|
||||||
session,
|
session,
|
||||||
isPinned: true,
|
isPinned: pinnedSessionIds.includes(session.id),
|
||||||
id: `session-${session.id}`,
|
id: `session-${session.id}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
|
// Default: date-grouped view with pinned section
|
||||||
for (const category of nonEmptyCategories) {
|
if (pinnedSessions.length > 0) {
|
||||||
// Add header item
|
|
||||||
items.push({
|
|
||||||
type: 'header',
|
|
||||||
category,
|
|
||||||
id: `header-${category}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add session items
|
|
||||||
for (const session of groupedSessions[category]) {
|
|
||||||
items.push({
|
items.push({
|
||||||
type: 'session',
|
type: 'pinned-header',
|
||||||
session,
|
id: 'header-pinned',
|
||||||
isPinned: false,
|
|
||||||
id: `session-${session.id}`,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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;
|
return items;
|
||||||
}, [pinnedSessions, nonEmptyCategories, groupedSessions, sessionsHasMore]);
|
}, [
|
||||||
|
sessionSortMode,
|
||||||
|
contextSortedSessions,
|
||||||
|
pinnedSessionIds,
|
||||||
|
pinnedSessions,
|
||||||
|
nonEmptyCategories,
|
||||||
|
groupedSessions,
|
||||||
|
sessionsHasMore,
|
||||||
|
]);
|
||||||
|
|
||||||
// Estimate item size based on type
|
// Estimate item size based on type
|
||||||
const estimateSize = useCallback(
|
const estimateSize = useCallback(
|
||||||
|
|
@ -273,12 +301,24 @@ export const DateGroupedSessions = (): React.JSX.Element => {
|
||||||
className="text-xs uppercase tracking-wider"
|
className="text-xs uppercase tracking-wider"
|
||||||
style={{ color: 'var(--color-text-muted)' }}
|
style={{ color: 'var(--color-text-muted)' }}
|
||||||
>
|
>
|
||||||
Sessions
|
{sessionSortMode === 'most-context' ? 'By Context' : 'Sessions'}
|
||||||
</h2>
|
</h2>
|
||||||
<span className="text-xs" style={{ color: 'var(--color-text-muted)', opacity: 0.6 }}>
|
<span className="text-xs" style={{ color: 'var(--color-text-muted)', opacity: 0.6 }}>
|
||||||
({sessions.length}
|
({sessions.length}
|
||||||
{sessionsTotalCount > sessions.length ? ` of ${sessionsTotalCount}` : ''})
|
{sessionsTotalCount > sessions.length ? ` of ${sessionsTotalCount}` : ''})
|
||||||
</span>
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setSessionSortMode(sessionSortMode === 'recent' ? 'most-context' : 'recent')
|
||||||
|
}
|
||||||
|
className="ml-auto rounded p-1 transition-colors hover:bg-white/5"
|
||||||
|
title={sessionSortMode === 'recent' ? 'Sort by context consumption' : 'Sort by recent'}
|
||||||
|
style={{
|
||||||
|
color: sessionSortMode === 'most-context' ? '#818cf8' : 'var(--color-text-muted)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ArrowDownWideNarrow className="size-3.5" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref={parentRef} className="flex-1 overflow-y-auto">
|
<div ref={parentRef} className="flex-1 overflow-y-auto">
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,11 @@
|
||||||
* Supports right-click context menu for pane management.
|
* 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 { createPortal } from 'react-dom';
|
||||||
|
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { formatTokensCompact } from '@shared/utils/tokenFormatting';
|
||||||
import { formatDistanceToNowStrict } from 'date-fns';
|
import { formatDistanceToNowStrict } from 'date-fns';
|
||||||
import { MessageSquare, Pin } from 'lucide-react';
|
import { MessageSquare, Pin } from 'lucide-react';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
@ -16,7 +17,7 @@ import { OngoingIndicator } from '../common/OngoingIndicator';
|
||||||
|
|
||||||
import { SessionContextMenu } from './SessionContextMenu';
|
import { SessionContextMenu } from './SessionContextMenu';
|
||||||
|
|
||||||
import type { Session } from '@renderer/types/data';
|
import type { PhaseTokenBreakdown, Session } from '@renderer/types/data';
|
||||||
|
|
||||||
interface SessionItemProps {
|
interface SessionItemProps {
|
||||||
session: Session;
|
session: Session;
|
||||||
|
|
@ -46,6 +47,63 @@ function formatShortTime(date: Date): string {
|
||||||
.replace(' year', 'y');
|
.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<HTMLSpanElement>(null);
|
||||||
|
const isHigh = contextConsumption > 150_000;
|
||||||
|
|
||||||
|
return (
|
||||||
|
// eslint-disable-next-line jsx-a11y/no-static-element-interactions -- tooltip trigger via hover, not interactive
|
||||||
|
<span
|
||||||
|
ref={badgeRef}
|
||||||
|
className="relative tabular-nums"
|
||||||
|
style={{ color: isHigh ? 'rgb(251, 191, 36)' : undefined }}
|
||||||
|
onMouseEnter={() => setShowPopover(true)}
|
||||||
|
onMouseLeave={() => setShowPopover(false)}
|
||||||
|
>
|
||||||
|
{formatTokensCompact(contextConsumption)}
|
||||||
|
{showPopover && phaseBreakdown && phaseBreakdown.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="absolute bottom-full left-1/2 z-50 mb-1.5 -translate-x-1/2 whitespace-nowrap rounded-lg px-3 py-2 text-[10px] shadow-xl"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--color-surface-overlay)',
|
||||||
|
border: '1px solid var(--color-border-emphasis)',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="mb-1 font-medium" style={{ color: 'var(--color-text)' }}>
|
||||||
|
Total Context: {formatTokensCompact(contextConsumption)} tokens
|
||||||
|
</div>
|
||||||
|
{phaseBreakdown.length === 1 ? (
|
||||||
|
<div>Context: {formatTokensCompact(phaseBreakdown[0].peakTokens)}</div>
|
||||||
|
) : (
|
||||||
|
phaseBreakdown.map((phase) => (
|
||||||
|
<div key={phase.phaseNumber} className="flex items-center gap-1">
|
||||||
|
<span style={{ color: 'var(--color-text-muted)' }}>Phase {phase.phaseNumber}:</span>
|
||||||
|
<span className="tabular-nums">{formatTokensCompact(phase.contribution)}</span>
|
||||||
|
{phase.postCompaction != null && (
|
||||||
|
<span style={{ color: 'var(--color-text-muted)' }}>
|
||||||
|
(compacted → {formatTokensCompact(phase.postCompaction)})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const SessionItem = ({
|
export const SessionItem = ({
|
||||||
session,
|
session,
|
||||||
isActive,
|
isActive,
|
||||||
|
|
@ -162,7 +220,7 @@ export const SessionItem = ({
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Second line: message count + time */}
|
{/* Second line: message count + time + context consumption */}
|
||||||
<div
|
<div
|
||||||
className="mt-0.5 flex items-center gap-2 text-[10px] leading-tight"
|
className="mt-0.5 flex items-center gap-2 text-[10px] leading-tight"
|
||||||
style={{ color: 'var(--color-text-muted)' }}
|
style={{ color: 'var(--color-text-muted)' }}
|
||||||
|
|
@ -173,6 +231,15 @@ export const SessionItem = ({
|
||||||
</span>
|
</span>
|
||||||
<span style={{ opacity: 0.5 }}>·</span>
|
<span style={{ opacity: 0.5 }}>·</span>
|
||||||
<span className="tabular-nums">{formatShortTime(new Date(session.createdAt))}</span>
|
<span className="tabular-nums">{formatShortTime(new Date(session.createdAt))}</span>
|
||||||
|
{session.contextConsumption != null && session.contextConsumption > 0 && (
|
||||||
|
<>
|
||||||
|
<span style={{ opacity: 0.5 }}>·</span>
|
||||||
|
<ConsumptionBadge
|
||||||
|
contextConsumption={session.contextConsumption}
|
||||||
|
phaseBreakdown={session.phaseBreakdown}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { api } from '@renderer/api';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
||||||
import type { AppState } from '../types';
|
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';
|
import type { StateCreator } from 'zustand';
|
||||||
|
|
||||||
const logger = createLogger('Store:session');
|
const logger = createLogger('Store:session');
|
||||||
|
|
@ -34,6 +34,8 @@ export interface SessionSlice {
|
||||||
sessionsLoadingMore: boolean;
|
sessionsLoadingMore: boolean;
|
||||||
// Pinned sessions
|
// Pinned sessions
|
||||||
pinnedSessionIds: string[];
|
pinnedSessionIds: string[];
|
||||||
|
// Sort mode
|
||||||
|
sessionSortMode: SessionSortMode;
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
fetchSessions: (projectId: string) => Promise<void>;
|
fetchSessions: (projectId: string) => Promise<void>;
|
||||||
|
|
@ -48,6 +50,8 @@ export interface SessionSlice {
|
||||||
togglePinSession: (sessionId: string) => Promise<void>;
|
togglePinSession: (sessionId: string) => Promise<void>;
|
||||||
/** Load pinned sessions from config for current project */
|
/** Load pinned sessions from config for current project */
|
||||||
loadPinnedSessions: () => Promise<void>;
|
loadPinnedSessions: () => Promise<void>;
|
||||||
|
/** Set session sort mode */
|
||||||
|
setSessionSortMode: (mode: SessionSortMode) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -67,6 +71,8 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
|
||||||
sessionsLoadingMore: false,
|
sessionsLoadingMore: false,
|
||||||
// Pinned sessions
|
// Pinned sessions
|
||||||
pinnedSessionIds: [],
|
pinnedSessionIds: [],
|
||||||
|
// Sort mode
|
||||||
|
sessionSortMode: 'recent' as SessionSortMode,
|
||||||
|
|
||||||
// Fetch sessions for a specific project (legacy - not paginated)
|
// Fetch sessions for a specific project (legacy - not paginated)
|
||||||
fetchSessions: async (projectId: string) => {
|
fetchSessions: async (projectId: string) => {
|
||||||
|
|
@ -317,4 +323,9 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
|
||||||
set({ pinnedSessionIds: [] });
|
set({ pinnedSessionIds: [] });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Set session sort mode
|
||||||
|
setSessionSortMode: (mode: SessionSortMode) => {
|
||||||
|
set({ sessionSortMode: mode });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
|
|
||||||
// Domain types
|
// Domain types
|
||||||
export type {
|
export type {
|
||||||
|
PhaseTokenBreakdown,
|
||||||
Project,
|
Project,
|
||||||
RepositoryGroup,
|
RepositoryGroup,
|
||||||
SearchResult,
|
SearchResult,
|
||||||
|
|
@ -68,6 +69,13 @@ export type {
|
||||||
TriggerToolName,
|
TriggerToolName,
|
||||||
} from './notifications';
|
} from './notifications';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Session Sort Mode
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/** Sort mode for session list in sidebar */
|
||||||
|
export type SessionSortMode = 'recent' | 'most-context';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Renderer-Specific Type Guards
|
// Renderer-Specific Type Guards
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue