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 { 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>
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)]">→</span>
|
<span className="text-[var(--color-text-muted)]">→</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
})();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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') });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
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';
|
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);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue