Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 39 additions & 27 deletions apps/mobile/src/components/agents/context-usage-display.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,36 +149,45 @@ describe('formatRemainingTokens', () => {

describe('getHeaderSummary', () => {
it('returns null when there is no completed assistant context usage', () => {
expect(getHeaderSummary(undefined, 0.08)).toBeNull();
expect(getHeaderSummary(undefined, 80_000)).toBeNull();
expect(getHeaderSummary(undefined, 0)).toBeNull();
expect(getHeaderSummary(undefined, null)).toBeNull();
});

it('shows percentage as primary and cost as secondary when capacity is known', () => {
const summary = getHeaderSummary(info({ percentage: 42 }), 0.08);
const summary = getHeaderSummary(info({ percentage: 42 }), 80_000);
expect(summary).toEqual({
primary: '42%',
secondary: '$0.0800',
secondary: '$0.08',
hasCost: true,
tone: 'primary',
});
});

it('omits the secondary cost when cost is zero', () => {
const summary = getHeaderSummary(info({ percentage: 10 }), 0);
expect(summary).toEqual({ primary: '10%', hasCost: false, tone: 'primary' });
it('omits the secondary cost when cost is zero or null', () => {
expect(getHeaderSummary(info({ percentage: 10 }), 0)).toEqual({
primary: '10%',
hasCost: false,
tone: 'primary',
});
expect(getHeaderSummary(info({ percentage: 10 }), null)).toEqual({
primary: '10%',
hasCost: false,
tone: 'primary',
});
});

it('uses percentage as primary and a warning tone at 75-89%', () => {
const summary = getHeaderSummary(info({ percentage: 80 }), 0.5);
const summary = getHeaderSummary(info({ percentage: 80 }), 500_000);
expect(summary?.primary).toBe('80%');
expect(summary?.tone).toBe('warning');
expect(summary?.secondary).toBe('$0.5000');
expect(summary?.secondary).toBe('$0.50');
});

it('keeps the real overflow percentage visible (does not clamp above 100) and uses a destructive tone', () => {
const summary = getHeaderSummary(
info({ contextTokens: 250_000, contextWindow: 200_000, percentage: 125 }),
1
1_000_000
);
expect(summary?.primary).toBe('125%');
expect(summary?.tone).toBe('destructive');
Expand All @@ -187,11 +196,11 @@ describe('getHeaderSummary', () => {
it('falls back to compact tokens and a neutral tone when capacity is unknown', () => {
const summary = getHeaderSummary(
info({ contextWindow: undefined, percentage: undefined, contextTokens: 32_418 }),
0.12
120_000
);
expect(summary).toEqual({
primary: '32.4K',
secondary: '$0.1200',
secondary: '$0.12',
hasCost: true,
tone: 'neutral',
});
Expand All @@ -210,15 +219,15 @@ describe('getContextSheetContent', () => {
it('describes exact usage and remaining tokens when capacity is known', () => {
const content = getContextSheetContent(
info({ contextTokens: 84_000, contextWindow: 200_000, percentage: 42 }),
0.08
80_000
);
expect(content.usedTokens).toBe('84,000');
expect(content.windowTokens).toBe('200,000');
expect(content.capacityKnown).toBe(true);
expect(content.percentage).toBe('42%');
expect(content.remainingTokens).toBe('116,000');
expect(content.remainingPercentage).toBe('58%');
expect(content.cost).toBe('$0.0800');
expect(content.cost).toBe('$0.08');
expect(content.tone).toBe('primary');
});

Expand All @@ -230,7 +239,7 @@ describe('getContextSheetContent', () => {
expect(content.percentage).toBe('125%');
expect(content.remainingTokens).toBe('0');
expect(content.remainingPercentage).toBe('0%');
expect(content.cost).toBe('$0.0000');
expect(content.cost).toBeNull();
expect(content.tone).toBe('destructive');
});

Expand All @@ -244,27 +253,28 @@ describe('getContextSheetContent', () => {
expect(content.windowUnavailable).toBe(true);
expect(content.percentage).toBeNull();
expect(content.remainingTokens).toBeNull();
expect(content.cost).toBe('$0.0000');
expect(content.cost).toBeNull();
expect(content.windowUnavailableLabel).toBe('Context-window size unavailable');
expect(content.tone).toBe('neutral');
});

it('shows the cost line as $0.0000 when total cost is zero', () => {
it('returns null cost when total cost is zero (sheet omits the Total cost row)', () => {
const content = getContextSheetContent(info({ percentage: 20 }), 0);
expect(content.cost).toBe('$0.0000');
expect(content.cost).toBeNull();
});
});

describe('getMetricsAccessibilityLabel', () => {
it('includes exact usage, real percentage, and tap intent when capacity is known with cost', () => {
it('includes exact usage, real percentage, humanized cost, and tap intent when capacity is known with cost', () => {
const label = getMetricsAccessibilityLabel(
info({ contextTokens: 84_000, contextWindow: 200_000, percentage: 42 }),
0.08
80_000
);
expect(label).toContain('84,000');
expect(label).toContain('200,000');
expect(label).toContain('42%');
expect(label).toContain('$0.0800');
expect(label).toContain('8 cents');
expect(label).not.toContain('$');
expect(label.toLowerCase()).toContain('context details');
});

Expand All @@ -274,6 +284,7 @@ describe('getMetricsAccessibilityLabel', () => {
0
);
expect(label).not.toContain('$');
expect(label).not.toContain('cost');
});

it('switches to the unavailable-capacity copy and omits percentage/cost when not available', () => {
Expand All @@ -287,12 +298,13 @@ describe('getMetricsAccessibilityLabel', () => {
expect(label).not.toContain('$');
});

it('includes the positive cost in the unknown-capacity case', () => {
it('includes the humanized positive cost in the unknown-capacity case', () => {
const label = getMetricsAccessibilityLabel(
info({ contextWindow: undefined, percentage: undefined, contextTokens: 32_418 }),
0.12
120_000
);
expect(label).toContain('$0.1200');
expect(label).toContain('12 cents');
expect(label).not.toContain('$');
});

it('preserves the real overflow percentage in the known-capacity case (125%)', () => {
Expand All @@ -306,11 +318,11 @@ describe('getMetricsAccessibilityLabel', () => {
});

describe('pure integration fallback', () => {
it('shows the cost-only header when there is no completed assistant context usage', () => {
it('returns null summary when there is no completed assistant context usage', () => {
// Mirrors the SessionDetailContent integration: when resolveSessionContextInfo
// returns undefined the header should keep the legacy positive cost text
// (no context control, no sheet) rather than an empty header.
const summary = getHeaderSummary(undefined, 0.08);
// returns undefined the header falls through to SessionContextCostFallback
// rather than a context control.
const summary = getHeaderSummary(undefined, 80_000);
expect(summary).toBeNull();
});
});
24 changes: 16 additions & 8 deletions apps/mobile/src/components/agents/context-usage-display.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { type SessionContextInfo } from '@/lib/session-context-info';

import { formatSessionTotalCost } from './session-list-helpers';
import { formatSpokenCost } from './session-row-accessibility-label';

export type ContextTone = 'primary' | 'warning' | 'destructive' | 'neutral';

const NUMBER_FORMAT = new Intl.NumberFormat('en-US');
Expand Down Expand Up @@ -91,18 +94,19 @@ type HeaderSummary = {

export function getHeaderSummary(
info: SessionContextInfo | undefined,
totalCost: number
totalCostMicrodollars: number | null
): HeaderSummary | null {
if (!info) {
return null;
}
const tone = getContextTone(info.percentage);
const primary =
info.percentage !== undefined ? `${info.percentage}%` : formatCompactTokens(info.contextTokens);
if (totalCost <= 0) {
const secondary = formatSessionTotalCost(totalCostMicrodollars);
if (secondary === null) {
return { primary, hasCost: false, tone };
}
return { primary, secondary: formatCost(totalCost), hasCost: true, tone };
return { primary, secondary, hasCost: true, tone };
}

type ContextSheetContent = {
Expand All @@ -114,17 +118,17 @@ type ContextSheetContent = {
percentage: string | null;
remainingTokens: string | null;
remainingPercentage: string | null;
cost: string;
cost: string | null;
tone: ContextTone;
};

export function getContextSheetContent(
info: SessionContextInfo,
totalCost: number
totalCostMicrodollars: number | null
): ContextSheetContent {
const tone = getContextTone(info.percentage);
const usedTokens = formatExactTokens(info.contextTokens);
const cost = formatCost(totalCost);
const cost = formatSessionTotalCost(totalCostMicrodollars);
if (info.contextWindow === undefined) {
return {
usedTokens,
Expand Down Expand Up @@ -159,8 +163,12 @@ export function getContextSheetContent(
};
}

export function getMetricsAccessibilityLabel(info: SessionContextInfo, totalCost: number): string {
const costPart = totalCost > 0 ? `, cost ${formatCost(totalCost)}` : '';
export function getMetricsAccessibilityLabel(
info: SessionContextInfo,
totalCostMicrodollars: number | null
): string {
const spoken = formatSpokenCost(totalCostMicrodollars);
const costPart = spoken ? `, cost ${spoken}` : '';
if (info.contextWindow === undefined) {
return `Context ${formatExactTokens(info.contextTokens)} tokens, window unavailable${costPart}. Tap to view context details.`;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,7 @@ export function createMobileAgentSessionManager({
initialMessageId: rs?.initialMessageId ?? null,
associatedPr: sessionResult.associatedPr,
runtimeAgents: rs?.runtimeAgents,
totalCostMicrodollars: sessionResult.total_cost_microdollars,
createdOnPlatform: sessionResult.created_on_platform,
};
},
Expand Down
26 changes: 16 additions & 10 deletions apps/mobile/src/components/agents/session-context-metrics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ import {
getHeaderSummary,
getMetricsAccessibilityLabel,
} from './context-usage-display';
import { formatSessionTotalCost } from './session-list-helpers';
import { formatSpokenCost } from './session-row-accessibility-label';

type SessionContextMetricsProps = {
info: SessionContextInfo;
totalCost: number;
totalCostMicrodollars: number | null;
onPress: () => void;
};

Expand All @@ -35,16 +37,16 @@ function toneTextClass(tone: ContextTone): string {

export function SessionContextMetrics({
info,
totalCost,
totalCostMicrodollars,
onPress,
}: Readonly<SessionContextMetricsProps>) {
const summary = getHeaderSummary(info, totalCost);
const summary = getHeaderSummary(info, totalCostMicrodollars);
if (!summary) {
return null;
}
const tone = getContextTone(info.percentage);
const arcFraction = getArcFraction(info.percentage);
const accessibilityLabel = getMetricsAccessibilityLabel(info, totalCost);
const accessibilityLabel = getMetricsAccessibilityLabel(info, totalCostMicrodollars);

return (
<Pressable
Expand Down Expand Up @@ -82,18 +84,22 @@ export function SessionContextMetrics({
);
}

// Preserves the legacy positive-cost header text when no completed context
// usage exists. Marked noninteractive; VoiceOver reads the cost directly.
export function SessionContextCostFallback({ totalCost }: Readonly<{ totalCost: number }>) {
if (totalCost <= 0) {
// Preserves the positive-cost header text when no completed context usage
// exists. Marked noninteractive; VoiceOver reads the humanized cost.
export function SessionContextCostFallback({
totalCostMicrodollars,
}: Readonly<{ totalCostMicrodollars: number | null }>) {
const visible = formatSessionTotalCost(totalCostMicrodollars);
if (visible === null) {
return null;
}
const spoken = formatSpokenCost(totalCostMicrodollars);
return (
<Text
className="text-sm text-muted-foreground"
accessibilityLabel={`Session cost $${totalCost.toFixed(4)}`}
accessibilityLabel={spoken ? `Session cost ${spoken}` : undefined}
>
${totalCost.toFixed(4)}
{visible}
</Text>
);
}
24 changes: 14 additions & 10 deletions apps/mobile/src/components/agents/session-context-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ type SessionContextSheetProps = {
info: SessionContextInfo;
modelDisplay: string;
providerDisplay: string;
totalCost: number;
totalCostMicrodollars: number | null;
breakdownCostUsd: number;
messages: StoredMessage[];
modelOptions: SessionModelOption[];
onClose: () => void;
Expand All @@ -59,18 +60,19 @@ export function SessionContextSheet({
info,
modelDisplay,
providerDisplay,
totalCost,
totalCostMicrodollars,
breakdownCostUsd,
messages,
modelOptions,
onClose,
}: Readonly<SessionContextSheetProps>) {
const insets = useSafeAreaInsets();
const content = getContextSheetContent(info, totalCost);
const content = getContextSheetContent(info, totalCostMicrodollars);
const tone = getContextTone(info.percentage);
const arcFraction = getArcFraction(info.percentage);
const breakdown = useMemo<SessionCostBreakdown>(
() => getSessionCostBreakdown(messages, totalCost),
[messages, totalCost]
() => getSessionCostBreakdown(messages, breakdownCostUsd),
[messages, breakdownCostUsd]
);
// Render-only filter: totals/subagent residual still use the full breakdown.
const visibleModels = useMemo(
Expand Down Expand Up @@ -147,11 +149,13 @@ export function SessionContextSheet({
<Text className="text-base font-medium text-foreground">{providerDisplay}</Text>
</Row>

<Row label="Total cost">
<Text className="text-base font-medium text-foreground tabular-nums">
{content.cost}
</Text>
</Row>
{content.cost !== null ? (
<Row label="Total cost">
<Text className="text-base font-medium text-foreground tabular-nums">
{content.cost}
</Text>
</Row>
) : null}

<Text className="text-xs text-muted-foreground">
Usage reflects the latest completed assistant response.
Expand Down
Loading
Loading