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:
matt 2026-02-15 14:49:29 +09:00
parent 44a499e62c
commit 8b2dbf3bcb
11 changed files with 462 additions and 35 deletions

View file

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

View file

@ -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[];
} }
/** /**

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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