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

View file

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

View file

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

View file

@ -20,6 +20,7 @@ import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store';
import { useTabUI } from '@renderer/hooks/useTabUI';
import { createChipFromSelection } from '@renderer/utils/chipUtils';
import { formatPercentOfTotal, sumContextInjectionTokens } from '@renderer/utils/contextMath';
import { formatProjectPath } from '@renderer/utils/pathDisplay';
import { buildTaskCountsByOwner, normalizePath } from '@renderer/utils/pathNormalize';
import { nameColorSet } from '@renderer/utils/projectColor';
@ -434,14 +435,14 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
return { allContextInjections: injections, lastAiGroupTotalTokens: totalTokens };
}, [leadSessionLoaded, leadSessionContextStats, leadConversation, selectedContextPhase, leadSessionPhaseInfo]);
const visibleContextTokens = useMemo(() => {
return allContextInjections.reduce((sum, inj) => sum + (inj.estimatedTokens ?? 0), 0);
}, [allContextInjections]);
const visibleContextPercentOfTotal = useMemo(() => {
if (lastAiGroupTotalTokens === undefined || lastAiGroupTotalTokens <= 0) return null;
return Math.min((visibleContextTokens / lastAiGroupTotalTokens) * 100, 100);
}, [visibleContextTokens, lastAiGroupTotalTokens]);
const visibleContextTokens = useMemo(
() => sumContextInjectionTokens(allContextInjections),
[allContextInjections]
);
const visibleContextPercentLabel = useMemo(
() => formatPercentOfTotal(visibleContextTokens, lastAiGroupTotalTokens),
[visibleContextTokens, lastAiGroupTotalTokens]
);
useEffect(() => {
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"
data-team-name={teamName}
>
{/* Sticky context button in top-right while scrolling */}
{/* Context button pinned to bottom-right of viewport */}
{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
onClick={() => {
const next = !isContextPanelVisible;
@ -876,7 +880,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
}}
onMouseEnter={() => setIsContextButtonHovered(true)}
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={{
backgroundColor: isContextPanelVisible
? 'var(--context-btn-active-bg)'
@ -895,8 +899,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
: leadSessionId
}
>
{visibleContextPercentOfTotal !== null
? `${visibleContextPercentOfTotal.toFixed(1)}% of total`
{visibleContextPercentLabel
? visibleContextPercentLabel
: typeof leadContextPercent === 'number'
? `${Math.round(leadContextPercent)}%`
: 'Context'}
@ -1320,7 +1324,13 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
})();
}}
onColumnOrderChange={(columnId, orderedTaskIds) => {
void updateKanbanColumnOrder(teamName, columnId, orderedTaskIds);
void (async () => {
try {
await updateKanbanColumnOrder(teamName, columnId, orderedTaskIds);
} catch {
// error via store
}
})();
}}
onScrollToTask={(taskId) => {
const el = document.querySelector(`[data-task-id="${taskId}"]`);
@ -1744,7 +1754,13 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
}
}}
onOwnerChange={(taskId, owner) => {
void updateTaskOwner(teamName, taskId, owner);
void (async () => {
try {
await updateTaskOwner(teamName, taskId, owner);
} catch {
// error via store
}
})();
}}
onViewChanges={handleViewChangesForFile}
onOpenInEditor={(filePath) => {
@ -1759,7 +1775,13 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
tasks={deletedTasks}
onClose={() => setTrashOpen(false)}
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) {
const leadInfo = memberInfo.get(leadMember.name);
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"
onClick={() => {
void (async () => {
const paths = await api.config.selectFolders();
if (paths.length > 0) {
onCustomCwdChange(paths[0]);
try {
const paths = await api.config.selectFolders();
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';
}
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.
*
@ -155,20 +173,23 @@ export function parseStreamJsonToGroups(cliLogsTail: string): StreamJsonGroup[]
const groups: StreamJsonGroup[] = [];
let currentItems: AIGroupDisplayItem[] = [];
let currentTimestamp: Date | null = null;
let currentGroupId: string | null = null;
let groupCounter = 0;
// Stable timestamp for the entire parse (deterministic across re-renders)
const parseTimestamp = new Date();
const flushGroup = (): void => {
if (currentItems.length > 0 && currentTimestamp) {
const id = currentGroupId ?? `stream-group-${groupCounter++}`;
groups.push({
id: `stream-group-${groupCounter++}`,
id,
items: currentItems,
summary: buildGroupSummary(currentItems),
timestamp: currentTimestamp,
});
currentItems = [];
currentTimestamp = null;
currentGroupId = null;
}
};
@ -198,6 +219,10 @@ export function parseStreamJsonToGroups(cliLogsTail: string): StreamJsonGroup[]
}
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);
currentItems.push(...items);