Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
64 changes: 38 additions & 26 deletions apps/mobile/src/components/agents/context-usage-display.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,24 +46,38 @@ function pill(args: {
const trackOnly: HeaderPillContent = {
primary: null,
secondary: null,
hasCost: false,
costSlot: 'none',
tone: 'neutral',
arcFraction: 0,
interactive: false,
usageUnavailable: false,
usageUnavailable: true,
};

/** Settled without any usage: the same label-free placeholder, named in a11y. */
const settledWithoutUsage: HeaderPillContent = {
primary: null,
secondary: null,
hasCost: false,
/** A transcript without usage: the same ring, no dash slot. */
const trackOnlyWhileLoading: HeaderPillContent = { ...trackOnly, usageUnavailable: false };

/** No usage reported yet: both value slots stay open, the cost one with a dash. */
const unreported: HeaderPillContent = {
primary: '—',
secondary: '—',
costSlot: 'unreported',
tone: 'neutral',
arcFraction: 0,
interactive: false,
usageUnavailable: true,
};

/**
* The same pill while the session still resolves: the two dashes keep the row's
* layout and paint invisible, with one bar across it, so the pill is already as
* wide as its settled form.
*/
const unreportedWhileLoading: HeaderPillContent = {
...unreported,
costSlot: 'loading',
usageUnavailable: false,
};

describe('formatExactTokens', () => {
it('uses grouped digit formatting', () => {
expect(formatExactTokens(0)).toBe('0');
Expand Down Expand Up @@ -142,20 +156,18 @@ describe('formatRemainingTokens', () => {
});

describe('getHeaderPillContent', () => {
it('keeps the label-free placeholder, non-interactive, with no info and no transcript', () => {
expect(pill({ totalCostMicrodollars: 80_000, hasMessages: false })).toEqual(
settledWithoutUsage
);
expect(pill({ totalCostMicrodollars: 0, hasMessages: false })).toEqual(settledWithoutUsage);
expect(pill({ totalCostMicrodollars: null, hasMessages: false })).toEqual(settledWithoutUsage);
it('keeps both value slots open, non-interactive, with no info and no transcript', () => {
expect(pill({ totalCostMicrodollars: 80_000, hasMessages: false })).toEqual(unreported);
expect(pill({ totalCostMicrodollars: 0, hasMessages: false })).toEqual(unreported);
expect(pill({ totalCostMicrodollars: null, hasMessages: false })).toEqual(unreported);
});

it('keeps the loading placeholder label-free before the session resolves', () => {
it('holds a loading bar in the cost slot while the session resolves', () => {
expect(pill({ totalCostMicrodollars: 80_000, hasMessages: false, loading: true })).toEqual(
trackOnly
unreportedWhileLoading
);
expect(pill({ totalCostMicrodollars: null, hasMessages: false, loading: true })).toEqual(
trackOnly
unreportedWhileLoading
);
});

Expand All @@ -167,7 +179,7 @@ describe('getHeaderPillContent', () => {
});
expect(result).toMatchObject({
secondary: '$0.08',
hasCost: true,
costSlot: 'label',
tone: 'primary',
arcFraction: 0.42,
interactive: true,
Expand All @@ -178,7 +190,7 @@ describe('getHeaderPillContent', () => {
it('omits secondary cost when cost is zero or null', () => {
const base = {
secondary: null,
hasCost: false,
costSlot: 'none',
tone: 'primary' as const,
arcFraction: 0.1,
interactive: true,
Expand Down Expand Up @@ -223,7 +235,7 @@ describe('getHeaderPillContent', () => {
});
expect(result).toMatchObject({
secondary: '$0.12',
hasCost: true,
costSlot: 'label',
tone: 'neutral',
arcFraction: undefined,
interactive: true,
Expand All @@ -240,7 +252,7 @@ describe('getHeaderPillContent', () => {
})
).toMatchObject({
secondary: null,
hasCost: false,
costSlot: 'none',
tone: 'neutral',
arcFraction: undefined,
interactive: true,
Expand All @@ -252,24 +264,24 @@ describe('getHeaderPillContent', () => {
expect(pill({ totalCostMicrodollars: 80_000, hasMessages: true })).toEqual({
primary: '$0.08',
secondary: null,
hasCost: true,
costSlot: 'none',
tone: 'neutral',
arcFraction: 0,
interactive: false,
usageUnavailable: true,
});
});

it('keeps the label-free placeholder when a transcript exists without cost', () => {
expect(pill({ totalCostMicrodollars: null, hasMessages: true })).toEqual(settledWithoutUsage);
expect(pill({ totalCostMicrodollars: 0, hasMessages: true })).toEqual(settledWithoutUsage);
it('keeps the plain ring when a transcript exists without cost', () => {
expect(pill({ totalCostMicrodollars: null, hasMessages: true })).toEqual(trackOnly);
expect(pill({ totalCostMicrodollars: 0, hasMessages: true })).toEqual(trackOnly);
expect(pill({ totalCostMicrodollars: null, hasMessages: true, loading: true })).toEqual(
trackOnly
trackOnlyWhileLoading
);
});

it('never surfaces a bare cost before a transcript exists', () => {
expect(pill({ totalCostMicrodollars: 700, hasMessages: false })).toEqual(settledWithoutUsage);
expect(pill({ totalCostMicrodollars: 700, hasMessages: false })).toEqual(unreported);
});
});

Expand Down
61 changes: 44 additions & 17 deletions apps/mobile/src/components/agents/context-usage-display.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,26 +74,38 @@ export function formatRemainingTokens(remaining: number): string {
export type HeaderPillContent = {
primary: string | null;
secondary: string | null;
hasCost: boolean;
/**
* What the cost slot paints:
*
* - `label`: the session's cost, as text.
* - `loading`: the same dashes, laid out but painted invisible, with one bar
* across the row they take. A bar of fixed width matched the settled row
* only at the default font scale, and a dash beside a bar read as
* half-painted content.
* - `unreported`: the dash, in the label's width. The session settled without
* a cost, so nothing will fill the slot; it keeps the width so the pill does
* not shrink when it settles.
* - `none`: no cost slot at all.
*/
costSlot: PillCostSlot;
tone: ContextTone;
/** `undefined` = indeterminate arc; `0` = track only (no usage asserted). */
arcFraction: number | undefined;
interactive: boolean;
/**
* Settled without usage: the accessibility label names usage as unavailable.
* Stays false while the session is still loading, when the pill is a bare
* placeholder. Never changes the visible pill: a session that settles without
* usage keeps the placeholder it showed while loading, so the header pill is
* the same in both states.
* Stays false while the session is still loading.
*/
usageUnavailable: boolean;
};

type PillCostSlot = 'label' | 'loading' | 'unreported' | 'none';

/**
* Mark the pill shows while the model's context window is unresolved: the slot
* keeps a visible value instead of a ring that reads as zero percent. It never
* stands in for usage that has not arrived — a session without usage keeps the
* same label-free placeholder whether it is still resolving or has settled.
* Value-slot placeholder of a session that has not reported usage yet. It marks
* the slot as unfilled instead of standing in for a number: the pill keeps its
* two slots while the session resolves, so a settled pill fills the slots it
* already showed rather than growing a value out of a bare ring.
*/
const UNKNOWN_WINDOW_LABEL = '—';

Expand Down Expand Up @@ -125,7 +137,7 @@ export function getHeaderPillContent({
return {
primary,
secondary,
hasCost: secondary !== null,
costSlot: secondary !== null ? 'label' : 'none',
tone,
arcFraction: getArcFraction(info.percentage),
interactive: true,
Expand All @@ -134,16 +146,31 @@ export function getHeaderPillContent({
}

const costText = hasMessages ? formatSessionCostBadge(totalCostMicrodollars) : null;
// Usage unknown. The pill keeps the placeholder it shows while the session
// resolves — the track-only ring and, when this screen already knows it, the
// session's cost — and never a dash standing in for a value that has not
// arrived; only the dash above, for usage without a context window, is a real
// value. A settled session therefore looks the same as a loading one instead
// of changing shape late.
// Nothing has reported usage yet. A settled pill keeps both value slots, each
// marked with the dash, instead of collapsing to a bare ring: the header then
// reads as the same pill before and after the transcript resolves, and the
// slots it showed fill in place. Neither dash stands in for a value that has
// arrived; while the open resolves they lay out the row the values will take
// and the pill paints one bar across it.
if (!hasMessages) {
return {
primary: UNKNOWN_WINDOW_LABEL,
secondary: UNKNOWN_WINDOW_LABEL,
costSlot: loading ? 'loading' : 'unreported',
tone: 'neutral',
arcFraction: 0,
interactive: false,
usageUnavailable: !loading,
};
}
// A transcript without usage. The pill keeps the track-only ring and, when
// this screen already knows it, the session's cost, and never a dash standing
// in for a value that has not arrived; only the dash above, for usage without
// a context window, is a real value.
return {
primary: costText,
secondary: null,
hasCost: costText !== null,
costSlot: 'none',
tone: 'neutral',
arcFraction: 0,
interactive: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { describe, expect, it, vi } from 'vitest';
import { findAll, info, render } from './session-context-metrics-test-helpers';

vi.mock('react-native', () => ({ Pressable: 'Pressable', View: 'View' }));
vi.mock('@/components/ui/skeleton', () => ({ Skeleton: 'Skeleton' }));
vi.mock('@/components/ui/text', () => ({ Text: 'Text' }));
vi.mock('./context-usage-ring', () => ({ ContextUsageRing: 'ContextUsageRing' }));

Expand Down
81 changes: 44 additions & 37 deletions apps/mobile/src/components/agents/session-context-metrics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from './session-context-metrics-test-helpers';

vi.mock('react-native', () => ({ Pressable: 'Pressable', View: 'View' }));
vi.mock('@/components/ui/skeleton', () => ({ Skeleton: 'Skeleton' }));
vi.mock('@/components/ui/text', () => ({ Text: 'Text' }));
vi.mock('./context-usage-ring', () => ({ ContextUsageRing: 'ContextUsageRing' }));

Expand Down Expand Up @@ -279,8 +280,8 @@ describe('SessionContextMetrics', () => {
expect(
(root.props as { accessibilityElementsHidden?: boolean }).accessibilityElementsHidden
).toBeUndefined();
// The settled pill is the same shape the loading one shows: no label, never
// a dash standing in for a value that has not arrived.
// A transcript that reports neither usage nor cost keeps the ring alone:
// there is no unresolved value to hold a slot for.
expect(findAll(root, el => el.type === 'Text')).toHaveLength(0);
const ring = findAll(root, el => el.type === 'ContextUsageRing')[0];
expect(ring).toBeDefined();
Expand All @@ -290,43 +291,49 @@ describe('SessionContextMetrics', () => {
expect((ring.props as { arcFraction?: number }).arcFraction).toBe(0);
});

// A session whose transcript failed to load (no usage, no messages, loading
// finished) keeps the loading placeholder rather than changing shape late;
// the accessibility label is what names usage as unavailable.
it('load-error session keeps the placeholder ring and names usage unavailable', () => {
// No usage yet: both value slots stay, dashes while settled, one bar while loading.
it('holds the value slots over the bare ring, loading them as one bar', () => {
const classOf = (el: React.ReactElement) =>
(el.props as { className?: string }).className ?? '';
const dashes = (root: React.ReactElement) =>
findAll(root, el => (el.props as { children?: unknown }).children === '—');
const bars = (root: React.ReactElement) => findAll(root, el => el.type === 'Skeleton');
const onPress = vi.fn<() => void>();
const root = render({
info: undefined,
totalCostMicrodollars: null,
hasMessages: false,
onPress,
});
expect(root.type).toBe('Pressable');
const props = root.props as {
accessibilityLabel?: string;
className?: string;
};

const bare = (loading = false) =>
render({
info: undefined,
totalCostMicrodollars: null,
hasMessages: false,
loading,
onPress,
});
const settled = bare();
const props = settled.props as { accessibilityLabel?: string };
expect(props.accessibilityLabel).toContain('usage unavailable');
expect(props.accessibilityLabel).toContain('Tap to view context details.');
expect(props.className ?? '').not.toContain('opacity-0');
for (const token of PILL_LAYOUT_TOKENS) {
expect(props.className ?? '').toContain(token);
}
expect(findAll(root, el => el.type === 'Text')).toHaveLength(0);
});
expect(dashes(settled)).toHaveLength(2);
// The settled cost dash holds the label's width.
expect(classOf(dashes(settled)[1] ?? settled)).toContain('w-8');
expect(bars(settled)).toHaveLength(0);

it('loading pill keeps the placeholder body without a label', () => {
const onPress = vi.fn<() => void>();
const root = render({
info: undefined,
totalCostMicrodollars: null,
hasMessages: false,
loading: true,
onPress,
});
expect(root.type).toBe('Pressable');
const props = root.props as { accessibilityLabel?: string };
expect(props.accessibilityLabel).not.toContain('usage unavailable');
expect(findAll(root, el => el.type === 'Text')).toHaveLength(0);
const loading = bare(true);
expect((loading.props as { accessibilityLabel?: string }).accessibilityLabel).not.toContain(
'usage unavailable'
);
const hiddenRows = findAll(loading, el => classOf(el).includes('opacity-0'));
expect(hiddenRows).toHaveLength(1);
expect(findAll(hiddenRows[0] ?? loading, el => el.type === 'Text')).toHaveLength(2);
const [loadingBar] = bars(loading);
if (!loadingBar) {
throw new Error('expected the value-row bar');
}
// A box over the row, not a fixed width that fits only the default font scale.
expect(classOf(loadingBar)).toContain('w-full');
expect(classOf(loadingBar)).toContain('bg-muted-soft');
const overlays = findAll(
loading,
el => (el.props as { style?: { position?: string } }).style?.position === 'absolute'
);
expect(overlays).toHaveLength(1);
});
});
Loading
Loading