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