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:
parent
8ada8dfcf5
commit
98593b495d
8 changed files with 105 additions and 27 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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)]">→</span>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
})();
|
||||
}}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -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') });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
})();
|
||||
}}
|
||||
|
|
|
|||
28
src/renderer/utils/contextMath.ts
Normal file
28
src/renderer/utils/contextMath.ts
Normal 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`;
|
||||
}
|
||||
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue