feat: refactor context token calculations and enhance UI components

- Introduced utility functions for summing context injection tokens and formatting percentages of total tokens.
- Updated SessionContextPanel and SessionContextHeader to utilize new utility functions for improved readability and maintainability.
- Refactored TeamDetailView to streamline context token calculations and enhance the display of context percentages.
- Improved error handling in ProjectPathSelector for better user experience during folder selection.
- Made minor adjustments to ActivityTimeline and ProvisioningProgressBlock for code clarity and consistency.
This commit is contained in:
iliya 2026-03-05 17:00:25 +02:00
parent 8ada8dfcf5
commit 98593b495d
8 changed files with 105 additions and 27 deletions

View file

@ -16,6 +16,7 @@ import { formatCostUsd } from '@shared/utils/costFormatting';
import { ArrowDownWideNarrow, FileText, LayoutList, X } from 'lucide-react'; import { ArrowDownWideNarrow, FileText, LayoutList, X } from 'lucide-react';
import { formatTokens } from '../utils/formatting'; import { formatTokens } from '../utils/formatting';
import { formatPercentOfTotal } from '@renderer/utils/contextMath';
import { SessionContextHelpTooltip } from './SessionContextHelpTooltip'; import { SessionContextHelpTooltip } from './SessionContextHelpTooltip';
@ -110,7 +111,7 @@ export const SessionContextHeader = ({
)} )}
</div> </div>
{/* Percentage of total */} {/* Percentage of total */}
{totalSessionTokens !== undefined && totalSessionTokens > 0 && ( {formatPercentOfTotal(totalTokens, totalSessionTokens) && (
<span <span
className="rounded px-1.5 py-0.5 tabular-nums" className="rounded px-1.5 py-0.5 tabular-nums"
style={{ style={{
@ -118,7 +119,7 @@ export const SessionContextHeader = ({
color: COLOR_TEXT_MUTED, color: COLOR_TEXT_MUTED,
}} }}
> >
{Math.min((totalTokens / totalSessionTokens) * 100, 100).toFixed(1)}% of total {formatPercentOfTotal(totalTokens, totalSessionTokens)}
</span> </span>
)} )}
</div> </div>

View file

@ -29,6 +29,7 @@ import {
SECTION_TOOL_OUTPUTS, SECTION_TOOL_OUTPUTS,
SECTION_USER_MESSAGES, SECTION_USER_MESSAGES,
} from './types'; } from './types';
import { sumContextInjectionTokens } from '@renderer/utils/contextMath';
import type { ContextViewMode, SectionType, SessionContextPanelProps } from './types'; import type { ContextViewMode, SectionType, SessionContextPanelProps } from './types';
import type { import type {
@ -133,7 +134,7 @@ export const SessionContextPanel = ({
// Calculate total tokens // Calculate total tokens
const totalTokens = useMemo( const totalTokens = useMemo(
() => injections.reduce((sum, inj) => sum + inj.estimatedTokens, 0), () => sumContextInjectionTokens(injections),
[injections] [injections]
); );

View file

@ -195,10 +195,8 @@ export const ProvisioningProgressBlock = ({
{STEP_ORDER.filter((s): s is ProvisioningStep => s !== 'ready').map((step, index) => { {STEP_ORDER.filter((s): s is ProvisioningStep => s !== 'ready').map((step, index) => {
const isDone = currentStepIndex >= 0 && index < currentStepIndex; const isDone = currentStepIndex >= 0 && index < currentStepIndex;
const isCurrent = currentStepIndex >= 0 && index === currentStepIndex; const isCurrent = currentStepIndex >= 0 && index === currentStepIndex;
return ( return (
<div key={step} className="flex items-center gap-1"> <div key={step} className="flex items-center gap-1">
{/* eslint-disable tailwindcss/no-custom-classname -- theme CSS vars */}
<Badge <Badge
variant="secondary" variant="secondary"
className={cn( className={cn(
@ -213,7 +211,6 @@ export const ProvisioningProgressBlock = ({
</span> </span>
{STEP_LABELS[step]} {STEP_LABELS[step]}
</Badge> </Badge>
{/* eslint-enable tailwindcss/no-custom-classname -- end theme CSS vars block */}
{index < STEP_ORDER.filter((s) => s !== 'ready').length - 1 ? ( {index < STEP_ORDER.filter((s) => s !== 'ready').length - 1 ? (
<span className="text-[var(--color-text-muted)]">&rarr;</span> <span className="text-[var(--color-text-muted)]">&rarr;</span>
) : null} ) : null}

View file

@ -20,6 +20,7 @@ import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { useTabUI } from '@renderer/hooks/useTabUI'; import { useTabUI } from '@renderer/hooks/useTabUI';
import { createChipFromSelection } from '@renderer/utils/chipUtils'; import { createChipFromSelection } from '@renderer/utils/chipUtils';
import { formatPercentOfTotal, sumContextInjectionTokens } from '@renderer/utils/contextMath';
import { formatProjectPath } from '@renderer/utils/pathDisplay'; import { formatProjectPath } from '@renderer/utils/pathDisplay';
import { buildTaskCountsByOwner, normalizePath } from '@renderer/utils/pathNormalize'; import { buildTaskCountsByOwner, normalizePath } from '@renderer/utils/pathNormalize';
import { nameColorSet } from '@renderer/utils/projectColor'; import { nameColorSet } from '@renderer/utils/projectColor';
@ -434,14 +435,14 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
return { allContextInjections: injections, lastAiGroupTotalTokens: totalTokens }; return { allContextInjections: injections, lastAiGroupTotalTokens: totalTokens };
}, [leadSessionLoaded, leadSessionContextStats, leadConversation, selectedContextPhase, leadSessionPhaseInfo]); }, [leadSessionLoaded, leadSessionContextStats, leadConversation, selectedContextPhase, leadSessionPhaseInfo]);
const visibleContextTokens = useMemo(() => { const visibleContextTokens = useMemo(
return allContextInjections.reduce((sum, inj) => sum + (inj.estimatedTokens ?? 0), 0); () => sumContextInjectionTokens(allContextInjections),
}, [allContextInjections]); [allContextInjections]
);
const visibleContextPercentOfTotal = useMemo(() => { const visibleContextPercentLabel = useMemo(
if (lastAiGroupTotalTokens === undefined || lastAiGroupTotalTokens <= 0) return null; () => formatPercentOfTotal(visibleContextTokens, lastAiGroupTotalTokens),
return Math.min((visibleContextTokens / lastAiGroupTotalTokens) * 100, 100); [visibleContextTokens, lastAiGroupTotalTokens]
}, [visibleContextTokens, lastAiGroupTotalTokens]); );
useEffect(() => { useEffect(() => {
if (!projectId) return; if (!projectId) return;
@ -863,9 +864,12 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
className="relative size-full flex-1 overflow-auto p-4" className="relative size-full flex-1 overflow-auto p-4"
data-team-name={teamName} data-team-name={teamName}
> >
{/* Sticky context button in top-right while scrolling */} {/* Context button pinned to bottom-right of viewport */}
{leadSessionId && ( {leadSessionId && (
<div className="pointer-events-none sticky top-0 z-20 ml-auto w-fit pb-2"> <div
className="pointer-events-none fixed bottom-4 z-20"
style={{ right: isContextPanelVisible ? 'calc(20rem + 1rem)' : '1rem' }}
>
<button <button
onClick={() => { onClick={() => {
const next = !isContextPanelVisible; const next = !isContextPanelVisible;
@ -876,7 +880,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
}} }}
onMouseEnter={() => setIsContextButtonHovered(true)} onMouseEnter={() => setIsContextButtonHovered(true)}
onMouseLeave={() => setIsContextButtonHovered(false)} onMouseLeave={() => setIsContextButtonHovered(false)}
className="pointer-events-auto flex items-center gap-1 rounded-md px-2.5 py-1.5 text-xs shadow-lg backdrop-blur-md transition-colors" className="pointer-events-auto flex w-fit items-center gap-1 rounded-md px-2.5 py-1.5 text-xs shadow-lg backdrop-blur-md transition-colors"
style={{ style={{
backgroundColor: isContextPanelVisible backgroundColor: isContextPanelVisible
? 'var(--context-btn-active-bg)' ? 'var(--context-btn-active-bg)'
@ -895,8 +899,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
: leadSessionId : leadSessionId
} }
> >
{visibleContextPercentOfTotal !== null {visibleContextPercentLabel
? `${visibleContextPercentOfTotal.toFixed(1)}% of total` ? visibleContextPercentLabel
: typeof leadContextPercent === 'number' : typeof leadContextPercent === 'number'
? `${Math.round(leadContextPercent)}%` ? `${Math.round(leadContextPercent)}%`
: 'Context'} : 'Context'}
@ -1320,7 +1324,13 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
})(); })();
}} }}
onColumnOrderChange={(columnId, orderedTaskIds) => { onColumnOrderChange={(columnId, orderedTaskIds) => {
void updateKanbanColumnOrder(teamName, columnId, orderedTaskIds); void (async () => {
try {
await updateKanbanColumnOrder(teamName, columnId, orderedTaskIds);
} catch {
// error via store
}
})();
}} }}
onScrollToTask={(taskId) => { onScrollToTask={(taskId) => {
const el = document.querySelector(`[data-task-id="${taskId}"]`); const el = document.querySelector(`[data-task-id="${taskId}"]`);
@ -1744,7 +1754,13 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
} }
}} }}
onOwnerChange={(taskId, owner) => { onOwnerChange={(taskId, owner) => {
void updateTaskOwner(teamName, taskId, owner); void (async () => {
try {
await updateTaskOwner(teamName, taskId, owner);
} catch {
// error via store
}
})();
}} }}
onViewChanges={handleViewChangesForFile} onViewChanges={handleViewChangesForFile}
onOpenInEditor={(filePath) => { onOpenInEditor={(filePath) => {
@ -1759,7 +1775,13 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
tasks={deletedTasks} tasks={deletedTasks}
onClose={() => setTrashOpen(false)} onClose={() => setTrashOpen(false)}
onRestore={(taskId) => { onRestore={(taskId) => {
void restoreTask(teamName, taskId); void (async () => {
try {
await restoreTask(teamName, taskId);
} catch {
// error via store
}
})();
}} }}
/> />

View file

@ -158,7 +158,7 @@ export const ActivityTimeline = ({
if (leadMember) { if (leadMember) {
const leadInfo = memberInfo.get(leadMember.name); const leadInfo = memberInfo.get(leadMember.name);
if (leadInfo) { if (leadInfo) {
memberInfo.set('user', { role: leadInfo.role, color: colorMap.get('user') }); memberInfo.set('user', { role: undefined, color: colorMap.get('user') });
} }
} }
} }

View file

@ -155,9 +155,13 @@ export const ProjectPathSelector = ({
size="sm" size="sm"
onClick={() => { onClick={() => {
void (async () => { void (async () => {
const paths = await api.config.selectFolders(); try {
if (paths.length > 0) { const paths = await api.config.selectFolders();
onCustomCwdChange(paths[0]); if (paths.length > 0) {
onCustomCwdChange(paths[0]);
}
} catch {
// IPC error — dialog may have been cancelled or failed
} }
})(); })();
}} }}

View file

@ -0,0 +1,28 @@
import type { ContextInjection } from '@renderer/types/contextInjection';
export function sumContextInjectionTokens(injections: readonly ContextInjection[]): number {
let sum = 0;
for (const inj of injections) {
sum += inj.estimatedTokens ?? 0;
}
return sum;
}
export function computePercentOfTotal(
visibleTokens: number,
totalSessionTokens: number | undefined
): number | null {
if (!Number.isFinite(visibleTokens) || visibleTokens <= 0) return 0;
if (totalSessionTokens === undefined || totalSessionTokens <= 0) return null;
return Math.min((visibleTokens / totalSessionTokens) * 100, 100);
}
export function formatPercentOfTotal(
visibleTokens: number,
totalSessionTokens: number | undefined
): string | null {
const pct = computePercentOfTotal(visibleTokens, totalSessionTokens);
if (pct === null) return null;
return `${pct.toFixed(1)}% of total`;
}

View file

@ -142,6 +142,24 @@ function buildGroupSummary(items: AIGroupDisplayItem[]): string {
return parts.join(', ') || 'empty'; return parts.join(', ') || 'empty';
} }
function extractAssistantMessageId(parsed: unknown): string | null {
if (!parsed || typeof parsed !== 'object') return null;
const obj = parsed as Record<string, unknown>;
if (obj.type !== 'assistant') return null;
// Direct format can include id at top-level
if (typeof obj.id === 'string' && obj.id.trim()) return obj.id.trim();
// Wrapped format: { type: "assistant", message: { id, ... } }
const msg = obj.message;
if (msg && typeof msg === 'object') {
const inner = msg as Record<string, unknown>;
if (typeof inner.id === 'string' && inner.id.trim()) return inner.id.trim();
}
return null;
}
/** /**
* Parses stream-json CLI output lines into structured groups for rich rendering. * Parses stream-json CLI output lines into structured groups for rich rendering.
* *
@ -155,20 +173,23 @@ export function parseStreamJsonToGroups(cliLogsTail: string): StreamJsonGroup[]
const groups: StreamJsonGroup[] = []; const groups: StreamJsonGroup[] = [];
let currentItems: AIGroupDisplayItem[] = []; let currentItems: AIGroupDisplayItem[] = [];
let currentTimestamp: Date | null = null; let currentTimestamp: Date | null = null;
let currentGroupId: string | null = null;
let groupCounter = 0; let groupCounter = 0;
// Stable timestamp for the entire parse (deterministic across re-renders) // Stable timestamp for the entire parse (deterministic across re-renders)
const parseTimestamp = new Date(); const parseTimestamp = new Date();
const flushGroup = (): void => { const flushGroup = (): void => {
if (currentItems.length > 0 && currentTimestamp) { if (currentItems.length > 0 && currentTimestamp) {
const id = currentGroupId ?? `stream-group-${groupCounter++}`;
groups.push({ groups.push({
id: `stream-group-${groupCounter++}`, id,
items: currentItems, items: currentItems,
summary: buildGroupSummary(currentItems), summary: buildGroupSummary(currentItems),
timestamp: currentTimestamp, timestamp: currentTimestamp,
}); });
currentItems = []; currentItems = [];
currentTimestamp = null; currentTimestamp = null;
currentGroupId = null;
} }
}; };
@ -198,6 +219,10 @@ export function parseStreamJsonToGroups(cliLogsTail: string): StreamJsonGroup[]
} }
if (!currentTimestamp) currentTimestamp = parseTimestamp; if (!currentTimestamp) currentTimestamp = parseTimestamp;
if (!currentGroupId) {
const msgId = extractAssistantMessageId(parsed);
currentGroupId = msgId ? `stream-group-${msgId}` : `stream-group-L${lineIndex}`;
}
const items = contentBlocksToDisplayItems(blocks, parseTimestamp, lineIndex); const items = contentBlocksToDisplayItems(blocks, parseTimestamp, lineIndex);
currentItems.push(...items); currentItems.push(...items);