From 76ef3d31cbd357f8e7099628970c9de156dad687 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 14 Jul 2026 18:47:54 +0200 Subject: [PATCH 1/2] feat(mobile): show agent context usage --- .../agents/context-usage-display.test.ts | 334 ++++++++++++++++++ .../agents/context-usage-display.ts | 188 ++++++++++ .../components/agents/context-usage-ring.tsx | 79 +++++ .../agents/session-context-metrics.tsx | 99 ++++++ .../agents/session-context-sheet.tsx | 152 ++++++++ .../agents/session-detail-content.tsx | 62 +++- .../src/lib/hooks/use-available-models.ts | 4 + .../lib/hooks/use-session-model-options.ts | 10 +- .../src/lib/session-context-info.test.ts | 220 ++++++++++++ apps/mobile/src/lib/session-context-info.ts | 127 +++++++ .../src/lib/use-session-model-options.test.ts | 175 +++++++++ apps/mobile/vitest.config.ts | 3 + apps/web/src/lib/cloud-agent-sdk/index.ts | 3 + 13 files changed, 1447 insertions(+), 9 deletions(-) create mode 100644 apps/mobile/src/components/agents/context-usage-display.test.ts create mode 100644 apps/mobile/src/components/agents/context-usage-display.ts create mode 100644 apps/mobile/src/components/agents/context-usage-ring.tsx create mode 100644 apps/mobile/src/components/agents/session-context-metrics.tsx create mode 100644 apps/mobile/src/components/agents/session-context-sheet.tsx create mode 100644 apps/mobile/src/lib/session-context-info.test.ts create mode 100644 apps/mobile/src/lib/session-context-info.ts diff --git a/apps/mobile/src/components/agents/context-usage-display.test.ts b/apps/mobile/src/components/agents/context-usage-display.test.ts new file mode 100644 index 0000000000..9b6081b5c4 --- /dev/null +++ b/apps/mobile/src/components/agents/context-usage-display.test.ts @@ -0,0 +1,334 @@ +import { describe, expect, it } from 'vitest'; + +import { type SessionContextInfo } from '@/lib/session-context-info'; + +import { + type ContextTone, + formatCompactTokens, + formatCost, + formatExactTokens, + formatRemainingTokens, + getArcFraction, + getContextSheetContent, + getContextSheetMountState, + getContextTone, + getHeaderSummary, + getIndeterminateArcFraction, + getMetricsAccessibilityLabel, + getRemainingTokens, +} from './context-usage-display'; + +function info(partial: Partial): SessionContextInfo { + return { + contextTokens: 32_418, + providerID: 'kilo', + modelID: 'anthropic/claude-sonnet-4', + contextWindow: 200_000, + percentage: 16, + ...partial, + }; +} + +describe('formatCompactTokens', () => { + it('returns the raw number below one thousand', () => { + expect(formatCompactTokens(0)).toBe('0'); + expect(formatCompactTokens(999)).toBe('999'); + }); + + it('switches to one-decimal thousands at the 1000 boundary', () => { + expect(formatCompactTokens(1000)).toBe('1.0K'); + expect(formatCompactTokens(32_418)).toBe('32.4K'); + }); + + it('switches to millions at one million', () => { + expect(formatCompactTokens(1_200_000)).toBe('1.2M'); + }); + + it('handles sub-thousand precision for fractional thousands', () => { + expect(formatCompactTokens(1500)).toBe('1.5K'); + expect(formatCompactTokens(995_000)).toBe('995.0K'); + }); +}); + +describe('formatExactTokens', () => { + it('uses grouped digit formatting', () => { + expect(formatExactTokens(0)).toBe('0'); + expect(formatExactTokens(999)).toBe('999'); + expect(formatExactTokens(32_418)).toBe('32,418'); + expect(formatExactTokens(1_234_567)).toBe('1,234,567'); + }); +}); + +describe('formatCost', () => { + it('preserves the existing four-decimal dollar format', () => { + expect(formatCost(0)).toBe('$0.0000'); + expect(formatCost(0.08)).toBe('$0.0800'); + expect(formatCost(0.123_456)).toBe('$0.1235'); + expect(formatCost(1.2)).toBe('$1.2000'); + }); +}); + +describe('getContextTone', () => { + const cases: readonly { percentage: number | undefined; expected: ContextTone }[] = [ + { percentage: undefined, expected: 'neutral' }, + { percentage: 0, expected: 'primary' }, + { percentage: 42, expected: 'primary' }, + { percentage: 74, expected: 'primary' }, + { percentage: 75, expected: 'warning' }, + { percentage: 89, expected: 'warning' }, + { percentage: 90, expected: 'destructive' }, + { percentage: 100, expected: 'destructive' }, + { percentage: 125, expected: 'destructive' }, + ]; + + for (const { percentage, expected } of cases) { + it(`classifies ${String(percentage)}% as ${expected}`, () => { + expect(getContextTone(percentage)).toBe(expected); + }); + } +}); + +describe('getArcFraction', () => { + it('maps zero to an empty arc', () => { + expect(getArcFraction(0)).toBe(0); + }); + + it('maps fifty percent to half', () => { + expect(getArcFraction(50)).toBe(0.5); + }); + + it('clamps to one at exactly one hundred percent', () => { + expect(getArcFraction(100)).toBe(1); + }); + + it('clamps to one even when the real percentage overflows', () => { + expect(getArcFraction(125)).toBe(1); + }); + + it('returns undefined for unknown capacity so callers render indeterminate', () => { + expect(getArcFraction(undefined)).toBeUndefined(); + }); +}); + +describe('getIndeterminateArcFraction', () => { + it('returns a stable non-empty neutral arc fraction', () => { + const fraction = getIndeterminateArcFraction(); + expect(fraction).toBeGreaterThan(0); + expect(fraction).toBeLessThan(1); + }); + + it('is a pure value (same on repeated calls) so render output is stable', () => { + expect(getIndeterminateArcFraction()).toBe(getIndeterminateArcFraction()); + }); +}); + +describe('getRemainingTokens', () => { + it('reports the remaining window when capacity is known', () => { + expect(getRemainingTokens(info({ contextTokens: 32_418, contextWindow: 200_000 }))).toBe( + 167_582 + ); + }); + + it('reports zero remaining when usage meets or exceeds the window', () => { + expect(getRemainingTokens(info({ contextTokens: 200_000, contextWindow: 200_000 }))).toBe(0); + expect(getRemainingTokens(info({ contextTokens: 250_000, contextWindow: 200_000 }))).toBe(0); + }); + + it('returns undefined when capacity is unknown', () => { + expect( + getRemainingTokens(info({ contextWindow: undefined, percentage: undefined })) + ).toBeUndefined(); + }); +}); + +describe('formatRemainingTokens', () => { + it('uses exact grouped formatting', () => { + expect(formatRemainingTokens(0)).toBe('0'); + expect(formatRemainingTokens(167_582)).toBe('167,582'); + }); +}); + +describe('getHeaderSummary', () => { + it('returns null when there is no completed assistant context usage', () => { + expect(getHeaderSummary(undefined, 0.08)).toBeNull(); + expect(getHeaderSummary(undefined, 0)).toBeNull(); + }); + + it('shows percentage as primary and cost as secondary when capacity is known', () => { + const summary = getHeaderSummary(info({ percentage: 42 }), 0.08); + expect(summary).toEqual({ + primary: '42%', + secondary: '$0.0800', + 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('uses percentage as primary and a warning tone at 75-89%', () => { + const summary = getHeaderSummary(info({ percentage: 80 }), 0.5); + expect(summary?.primary).toBe('80%'); + expect(summary?.tone).toBe('warning'); + expect(summary?.secondary).toBe('$0.5000'); + }); + + 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 + ); + expect(summary?.primary).toBe('125%'); + expect(summary?.tone).toBe('destructive'); + }); + + 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 + ); + expect(summary).toEqual({ + primary: '32.4K', + secondary: '$0.1200', + hasCost: true, + tone: 'neutral', + }); + }); + + it('omits the secondary cost when capacity is unknown and cost is zero', () => { + const summary = getHeaderSummary( + info({ contextWindow: undefined, percentage: undefined, contextTokens: 32_418 }), + 0 + ); + expect(summary).toEqual({ primary: '32.4K', hasCost: false, tone: 'neutral' }); + }); +}); + +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 + ); + 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.tone).toBe('primary'); + }); + + it('preserves the real overflow percentage and reports zero remaining tokens and 0% remaining', () => { + const content = getContextSheetContent( + info({ contextTokens: 250_000, contextWindow: 200_000, percentage: 125 }), + 0 + ); + expect(content.percentage).toBe('125%'); + expect(content.remainingTokens).toBe('0'); + expect(content.remainingPercentage).toBe('0%'); + expect(content.cost).toBeNull(); + expect(content.tone).toBe('destructive'); + }); + + it('reports used tokens, an unavailable window, and the unavailable copy when capacity is unknown', () => { + const content = getContextSheetContent( + info({ contextWindow: undefined, percentage: undefined, contextTokens: 32_418 }), + 0 + ); + expect(content.usedTokens).toBe('32,418'); + expect(content.windowTokens).toBeNull(); + expect(content.windowUnavailable).toBe(true); + expect(content.percentage).toBeNull(); + expect(content.remainingTokens).toBeNull(); + expect(content.cost).toBeNull(); + expect(content.windowUnavailableLabel).toBe('Context-window size unavailable'); + expect(content.tone).toBe('neutral'); + }); + + it('omits the cost line when total cost is zero', () => { + const content = getContextSheetContent(info({ percentage: 20 }), 0); + expect(content.cost).toBeNull(); + }); +}); + +describe('getMetricsAccessibilityLabel', () => { + it('includes exact usage, real percentage, and tap intent when capacity is known with cost', () => { + const label = getMetricsAccessibilityLabel( + info({ contextTokens: 84_000, contextWindow: 200_000, percentage: 42 }), + 0.08 + ); + expect(label).toContain('84,000'); + expect(label).toContain('200,000'); + expect(label).toContain('42%'); + expect(label).toContain('$0.0800'); + expect(label.toLowerCase()).toContain('context details'); + }); + + it('omits the cost clause when no positive cost is available', () => { + const label = getMetricsAccessibilityLabel( + info({ contextTokens: 84_000, contextWindow: 200_000, percentage: 42 }), + 0 + ); + expect(label).not.toContain('$'); + }); + + it('switches to the unavailable-capacity copy and omits percentage/cost when not available', () => { + const label = getMetricsAccessibilityLabel( + info({ contextWindow: undefined, percentage: undefined, contextTokens: 32_418 }), + 0 + ); + expect(label).toContain('32,418'); + expect(label.toLowerCase()).toContain('unavailable'); + expect(label).not.toContain('%'); + expect(label).not.toContain('$'); + }); + + it('includes the positive cost in the unknown-capacity case', () => { + const label = getMetricsAccessibilityLabel( + info({ contextWindow: undefined, percentage: undefined, contextTokens: 32_418 }), + 0.12 + ); + expect(label).toContain('$0.1200'); + }); + + it('preserves the real overflow percentage in the known-capacity case (125%)', () => { + const label = getMetricsAccessibilityLabel( + info({ contextTokens: 250_000, contextWindow: 200_000, percentage: 125 }), + 0 + ); + expect(label).toContain('125%'); + expect(label).not.toContain('100%'); + }); +}); + +describe('pure integration fallback', () => { + it('shows the cost-only header 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); + expect(summary).toBeNull(); + }); +}); + +describe('getContextSheetMountState', () => { + it('unmounts when there is no context info regardless of open state', () => { + expect(getContextSheetMountState(undefined, false)).toEqual({ mounted: false }); + expect(getContextSheetMountState(undefined, true)).toEqual({ mounted: false }); + }); + + it('mounts visible when context info exists and the sheet is open', () => { + const result = getContextSheetMountState(info({}), true); + expect(result).toEqual({ mounted: true, visible: true, info: info({}) }); + }); + + it('mounts hidden when context info exists but the sheet is closed', () => { + const result = getContextSheetMountState(info({}), false); + expect(result).toEqual({ mounted: true, visible: false, info: info({}) }); + }); +}); diff --git a/apps/mobile/src/components/agents/context-usage-display.ts b/apps/mobile/src/components/agents/context-usage-display.ts new file mode 100644 index 0000000000..5815138d59 --- /dev/null +++ b/apps/mobile/src/components/agents/context-usage-display.ts @@ -0,0 +1,188 @@ +import { type SessionContextInfo } from '@/lib/session-context-info'; + +export type ContextTone = 'primary' | 'warning' | 'destructive' | 'neutral'; + +const NUMBER_FORMAT = new Intl.NumberFormat('en-US'); + +const WARNING_TONE_THRESHOLD = 75; +const DESTRUCTIVE_TONE_THRESHOLD = 90; + +const INDETERMINATE_ARC_FRACTION = 0.25; +const WINDOW_UNAVAILABLE_LABEL = 'Context-window size unavailable'; + +export function formatCompactTokens(tokens: number): string { + if (!Number.isFinite(tokens) || tokens < 0) { + return '0'; + } + if (tokens < 1000) { + return String(Math.trunc(tokens)); + } + if (tokens < 1_000_000) { + return `${(tokens / 1000).toFixed(1)}K`; + } + return `${(tokens / 1_000_000).toFixed(1)}M`; +} + +export function formatExactTokens(tokens: number): string { + if (!Number.isFinite(tokens) || tokens < 0) { + return '0'; + } + return NUMBER_FORMAT.format(Math.trunc(tokens)); +} + +export function formatCost(cost: number): string { + if (!Number.isFinite(cost) || cost <= 0) { + return '$0.0000'; + } + return `$${cost.toFixed(4)}`; +} + +export function getContextTone(percentage: number | undefined): ContextTone { + if (percentage === undefined || !Number.isFinite(percentage)) { + return 'neutral'; + } + if (percentage >= DESTRUCTIVE_TONE_THRESHOLD) { + return 'destructive'; + } + if (percentage >= WARNING_TONE_THRESHOLD) { + return 'warning'; + } + return 'primary'; +} + +export function getArcFraction(percentage: number | undefined): number | undefined { + if (percentage === undefined || !Number.isFinite(percentage)) { + return undefined; + } + if (percentage <= 0) { + return 0; + } + if (percentage >= 100) { + return 1; + } + return percentage / 100; +} + +/** + * Stable partial neutral arc used by the ring when capacity is unknown. Kept + * pure and exported so the visual is testable without an RN render harness. + */ +export function getIndeterminateArcFraction(): number { + return INDETERMINATE_ARC_FRACTION; +} + +export function getRemainingTokens(info: SessionContextInfo): number | undefined { + if (info.contextWindow === undefined) { + return undefined; + } + return Math.max(0, info.contextWindow - info.contextTokens); +} + +export function formatRemainingTokens(remaining: number): string { + return formatExactTokens(remaining); +} + +type HeaderSummary = { + primary: string; + secondary?: string; + hasCost: boolean; + tone: ContextTone; +}; + +export function getHeaderSummary( + info: SessionContextInfo | undefined, + totalCost: number +): HeaderSummary | null { + if (!info) { + return null; + } + const tone = getContextTone(info.percentage); + const primary = + info.percentage !== undefined ? `${info.percentage}%` : formatCompactTokens(info.contextTokens); + if (totalCost <= 0) { + return { primary, hasCost: false, tone }; + } + return { primary, secondary: formatCost(totalCost), hasCost: true, tone }; +} + +type ContextSheetContent = { + usedTokens: string; + windowTokens: string | null; + windowUnavailable: boolean; + windowUnavailableLabel: string; + capacityKnown: boolean; + percentage: string | null; + remainingTokens: string | null; + remainingPercentage: string | null; + cost: string | null; + tone: ContextTone; +}; + +export function getContextSheetContent( + info: SessionContextInfo, + totalCost: number +): ContextSheetContent { + const tone = getContextTone(info.percentage); + const usedTokens = formatExactTokens(info.contextTokens); + const cost = totalCost > 0 ? formatCost(totalCost) : null; + if (info.contextWindow === undefined) { + return { + usedTokens, + windowTokens: null, + windowUnavailable: true, + windowUnavailableLabel: WINDOW_UNAVAILABLE_LABEL, + capacityKnown: false, + percentage: null, + remainingTokens: null, + remainingPercentage: null, + cost, + tone, + }; + } + const realPercentage = info.percentage ?? 0; + const remaining = getRemainingTokens(info) ?? 0; + // Remaining share is only meaningful when usage is below the window. At or + // above 100% the remaining tokens and remaining percentage both clamp to 0; + // the visible used percentage above stays the real value. + const remainingPercentage = realPercentage >= 100 ? 0 : Math.max(0, 100 - realPercentage); + return { + usedTokens, + windowTokens: formatExactTokens(info.contextWindow), + windowUnavailable: false, + windowUnavailableLabel: WINDOW_UNAVAILABLE_LABEL, + capacityKnown: true, + percentage: `${realPercentage}%`, + remainingTokens: formatExactTokens(remaining), + remainingPercentage: `${remainingPercentage}%`, + cost, + tone, + }; +} + +export function getMetricsAccessibilityLabel(info: SessionContextInfo, totalCost: number): string { + const costPart = totalCost > 0 ? `, cost ${formatCost(totalCost)}` : ''; + if (info.contextWindow === undefined) { + return `Context ${formatExactTokens(info.contextTokens)} tokens, window unavailable${costPart}. Tap to view context details.`; + } + const realPercentage = info.percentage ?? 0; + return `Context ${formatExactTokens(info.contextTokens)} of ${formatExactTokens(info.contextWindow)} tokens, ${realPercentage}% used${costPart}. Tap to view context details.`; +} + +type SheetMountState = + | { mounted: false } + | { mounted: true; visible: boolean; info: SessionContextInfo }; + +/** + * Controls when the native Modal is mounted and when it is visible. Keeping + * the sheet mounted while contextInfo exists lets `visible` transition from + * true → false so the native pageSheet dismissal animation runs. + */ +export function getContextSheetMountState( + info: SessionContextInfo | undefined, + isOpen: boolean +): SheetMountState { + if (!info) { + return { mounted: false }; + } + return { mounted: true, visible: isOpen, info }; +} diff --git a/apps/mobile/src/components/agents/context-usage-ring.tsx b/apps/mobile/src/components/agents/context-usage-ring.tsx new file mode 100644 index 0000000000..8b98aa6c0d --- /dev/null +++ b/apps/mobile/src/components/agents/context-usage-ring.tsx @@ -0,0 +1,79 @@ +import Svg, { Circle } from 'react-native-svg'; + +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; + +import { type ContextTone, getIndeterminateArcFraction } from './context-usage-display'; + +type ContextUsageRingProps = { + size?: number; + strokeWidth?: number; + /** Clamped [0,1] arc length. Undefined renders a stable partial neutral arc. */ + arcFraction: number | undefined; + tone: ContextTone; + testID?: string; +}; + +const DEFAULT_SIZE = 28; +const DEFAULT_STROKE = 3; + +const TONE_COLORS: Record> = { + destructive: 'destructive', + warning: 'warn', + primary: 'primary', + neutral: 'mutedForeground', +}; + +function toneColor(tone: ContextTone, colors: ReturnType): string { + return colors[TONE_COLORS[tone]]; +} + +export function ContextUsageRing({ + size = DEFAULT_SIZE, + strokeWidth = DEFAULT_STROKE, + arcFraction, + tone, + testID, +}: Readonly) { + const colors = useThemeColors(); + const radius = (size - strokeWidth) / 2; + const circumference = 2 * Math.PI * radius; + const trackColor = colors.hairSoft; + const arcColor = toneColor(tone, colors); + // Undefined percentage → indeterminate: a stable partial neutral arc so the + // visual is clearly "we don't know", not a broken empty ring. + const effectiveFraction = arcFraction ?? getIndeterminateArcFraction(); + const dashLength = Math.max(0, Math.min(1, effectiveFraction)) * circumference; + const dashGap = circumference - dashLength; + const rotation = -90; + + return ( + + + + + ); +} diff --git a/apps/mobile/src/components/agents/session-context-metrics.tsx b/apps/mobile/src/components/agents/session-context-metrics.tsx new file mode 100644 index 0000000000..4387c2b289 --- /dev/null +++ b/apps/mobile/src/components/agents/session-context-metrics.tsx @@ -0,0 +1,99 @@ +import { Pressable, View } from 'react-native'; + +import { Text } from '@/components/ui/text'; +import { cn } from '@/lib/utils'; +import { type SessionContextInfo } from '@/lib/session-context-info'; + +import { ContextUsageRing } from './context-usage-ring'; +import { + type ContextTone, + getArcFraction, + getContextTone, + getHeaderSummary, + getMetricsAccessibilityLabel, +} from './context-usage-display'; + +type SessionContextMetricsProps = { + info: SessionContextInfo; + totalCost: number; + onPress: () => void; +}; + +const RING_SIZE = 28; +const RING_STROKE = 3; + +const TONE_TEXT_CLASS: Record = { + destructive: 'text-destructive', + warning: 'text-warn', + primary: 'text-foreground', + neutral: 'text-foreground', +}; + +function toneTextClass(tone: ContextTone): string { + return TONE_TEXT_CLASS[tone]; +} + +export function SessionContextMetrics({ + info, + totalCost, + onPress, +}: Readonly) { + const summary = getHeaderSummary(info, totalCost); + if (!summary) { + return null; + } + const tone = getContextTone(info.percentage); + const arcFraction = getArcFraction(info.percentage); + const accessibilityLabel = getMetricsAccessibilityLabel(info, totalCost); + + return ( + + + + + {summary.primary} + + {summary.hasCost && summary.secondary ? ( + + {summary.secondary} + + ) : null} + + + ); +} + +// 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) { + return null; + } + return ( + + ${totalCost.toFixed(4)} + + ); +} diff --git a/apps/mobile/src/components/agents/session-context-sheet.tsx b/apps/mobile/src/components/agents/session-context-sheet.tsx new file mode 100644 index 0000000000..81131be603 --- /dev/null +++ b/apps/mobile/src/components/agents/session-context-sheet.tsx @@ -0,0 +1,152 @@ +import { Modal, ScrollView, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { SheetHeader } from '@/components/sheet-header'; +import { Text } from '@/components/ui/text'; +import { cn } from '@/lib/utils'; +import { type SessionContextInfo } from '@/lib/session-context-info'; + +import { ContextUsageRing } from './context-usage-ring'; +import { + type ContextTone, + getArcFraction, + getContextSheetContent, + getContextTone, +} from './context-usage-display'; + +type SessionContextSheetProps = { + visible: boolean; + info: SessionContextInfo; + modelDisplay: string; + providerDisplay: string; + totalCost: number; + onClose: () => void; +}; + +const SHEET_RING_SIZE = 96; +const SHEET_RING_STROKE = 8; + +const TONE_TEXT_CLASS: Record = { + destructive: 'text-destructive', + warning: 'text-warn', + primary: 'text-foreground', + neutral: 'text-foreground', +}; + +function toneTextClass(tone: ContextTone): string { + return TONE_TEXT_CLASS[tone]; +} + +export function SessionContextSheet({ + visible, + info, + modelDisplay, + providerDisplay, + totalCost, + onClose, +}: Readonly) { + const insets = useSafeAreaInsets(); + const content = getContextSheetContent(info, totalCost); + const tone = getContextTone(info.percentage); + const arcFraction = getArcFraction(info.percentage); + + return ( + + + + + {/* Rows below are exposed individually to screen readers; collapsing + them behind a single ScrollView accessibilityLabel would shadow the + natural read order. */} + + + + {content.percentage ? ( + + {content.percentage} + + ) : ( + + {content.windowUnavailableLabel} + + )} + + + + + + {content.usedTokens} + {content.capacityKnown && content.windowTokens ? ( + + {' '} + of {content.windowTokens} tokens + + ) : ( + tokens + )} + + + + {content.capacityKnown ? ( + + + {content.remainingTokens} + + {' '} + tokens ({content.remainingPercentage}) + + + + ) : null} + + + {modelDisplay} + + + + {providerDisplay} + + + {content.cost ? ( + + + {content.cost} + + + ) : null} + + + Usage reflects the latest completed assistant response. + + + + + + + + ); +} + +function Row({ label, children }: Readonly<{ label: string; children: React.ReactNode }>) { + return ( + + {label} + {children} + + ); +} diff --git a/apps/mobile/src/components/agents/session-detail-content.tsx b/apps/mobile/src/components/agents/session-detail-content.tsx index 34df5f1330..76412bb4cd 100644 --- a/apps/mobile/src/components/agents/session-detail-content.tsx +++ b/apps/mobile/src/components/agents/session-detail-content.tsx @@ -15,6 +15,12 @@ import { ModelPickerSelectionScopeProvider } from '@/components/agents/model-sel import { PermissionCard } from '@/components/agents/permission-card'; import { QuestionCard } from '@/components/agents/question-card'; import { getSessionKeyboardContainerKind } from '@/components/agents/session-keyboard-container-state'; +import { getContextSheetMountState } from '@/components/agents/context-usage-display'; +import { + SessionContextCostFallback, + SessionContextMetrics, +} from '@/components/agents/session-context-metrics'; +import { SessionContextSheet } from '@/components/agents/session-context-sheet'; import { useSessionManager } from '@/components/agents/session-provider'; import { SessionStatusIndicator } from '@/components/agents/session-status-indicator'; import { @@ -51,6 +57,7 @@ import { revalidateLegacyGatewayOverride, useSessionModelOptions, } from '@/lib/hooks/use-session-model-options'; +import { resolveSessionContextInfo } from '@/lib/session-context-info'; import { areModelPickerSelectionScopesEqual, type ModelPickerSelection, @@ -99,6 +106,8 @@ export function SessionDetailContent({ const remoteModelState = useAtomValue(manager.atoms.remoteModelState); const observedModel = useAtomValue(manager.atoms.observedModel); const remoteModelOverride = useAtomValue(manager.atoms.remoteModelOverride); + const contextUsage = useAtomValue(manager.atoms.contextUsage); + const [isContextSheetOpen, setContextSheetOpen] = useState(false); const { isConnected } = useAppLifecycle(); const { bottom } = useSafeAreaInsets(); @@ -137,6 +146,37 @@ export function SessionDetailContent({ organizationId, }); const modelOptions = sessionModels.options; + const contextInfo = useMemo( + () => resolveSessionContextInfo(contextUsage, sessionModels.options), + [contextUsage, sessionModels.options] + ); + const contextModelAndProvider = useMemo(() => { + if (!contextInfo) { + return { model: '', provider: '' }; + } + const match = sessionModels.options.find( + option => + option.modelRef !== undefined && + option.modelRef.providerID === contextInfo.providerID && + option.modelRef.modelID === contextInfo.modelID + ); + return { + model: match?.name ?? match?.displayId ?? contextInfo.modelID, + provider: match?.provider?.name ?? contextInfo.providerID, + }; + }, [contextInfo, sessionModels.options]); + const headerRight = contextInfo ? ( + { + setContextSheetOpen(true); + }} + /> + ) : ( + + ); + const sheetMountState = getContextSheetMountState(contextInfo, isContextSheetOpen); const catalogGenerationIdentity = remoteModelState.protocol === 'v1' ? (remoteModelState.catalog ?? null) : gatewayModels; const modelPickerSelectionScope = useMemo( @@ -382,14 +422,7 @@ export function SessionDetailContent({ return ( - 0 ? ( - ${totalCost.toFixed(4)} - ) : undefined - } - /> + {!isConnected && } @@ -405,6 +438,19 @@ export function SessionDetailContent({ + {sheetMountState.mounted ? ( + { + setContextSheetOpen(false); + }} + /> + ) : null} + {childSession ? ( ; @@ -139,6 +141,7 @@ export function useAvailableModels(organizationId: string | undefined) { hasUserByokAvailable: model.hasUserByokAvailable, variants: Object.keys(model.opencode?.variants ?? {}), preferredIndex: model.preferredIndex, + context_length: model.context_length ?? null, })); items.sort((a, b) => { @@ -165,6 +168,7 @@ export function useAvailableModels(organizationId: string | undefined) { isFree: item.isFree, mayTrainOnYourPrompts: item.mayTrainOnYourPrompts, hasUserByokAvailable: item.hasUserByokAvailable, + context_length: item.context_length, })); }, [data]); diff --git a/apps/mobile/src/lib/hooks/use-session-model-options.ts b/apps/mobile/src/lib/hooks/use-session-model-options.ts index 70c3812920..35eb5f869e 100644 --- a/apps/mobile/src/lib/hooks/use-session-model-options.ts +++ b/apps/mobile/src/lib/hooks/use-session-model-options.ts @@ -26,6 +26,7 @@ export type SessionModelOption = { isFree?: boolean; mayTrainOnYourPrompts?: boolean; hasUserByokAvailable?: boolean; + contextWindow?: number; provider?: { id: string; name: string }; modelRef?: ModelRef; overrideSource?: RemoteModelOverride['source']; @@ -209,6 +210,7 @@ function buildCliCatalogOptions(input: BuildSessionModelOptionsInput): SessionMo isFree: model.isFree, mayTrainOnYourPrompts: model.mayTrainOnYourPrompts, hasUserByokAvailable: model.hasUserByokAvailable, + contextWindow: model.limits.context, provider: { id: provider.id, name: provider.name ?? provider.id }, modelRef: { providerID: provider.id, modelID: model.id }, overrideSource: 'cli-catalog', @@ -260,9 +262,15 @@ function createUnavailableOption(modelRef: ModelRef): SessionModelOption { } function createGatewayOption(model: ModelOption): SessionModelOption { + // Strip the raw `context_length` so it doesn't leak onto SessionModelOption; + // the projection below owns the camelCase `contextWindow` field. + const { context_length: _contextLength, ...rest } = model; return { - ...model, + ...rest, displayId: model.id, + contextWindow: model.context_length ?? undefined, + provider: { id: 'kilo', name: 'Kilo' }, + modelRef: { providerID: 'kilo', modelID: model.id }, showGatewayMetadata: true, }; } diff --git a/apps/mobile/src/lib/session-context-info.test.ts b/apps/mobile/src/lib/session-context-info.test.ts new file mode 100644 index 0000000000..414e4ab261 --- /dev/null +++ b/apps/mobile/src/lib/session-context-info.test.ts @@ -0,0 +1,220 @@ +import { describe, expect, it } from 'vitest'; +import { type ContextUsage } from 'cloud-agent-sdk/context-usage'; + +import { type SessionModelOption } from './hooks/use-session-model-options'; +import { resolveSessionContextInfo } from './session-context-info'; + +function gatewayOption(partial: { + id: string; + contextWindow?: number; + unavailable?: boolean; +}): SessionModelOption { + return { + id: partial.id, + name: partial.id, + displayId: partial.id, + variants: [], + isPreferred: false, + showGatewayMetadata: !partial.unavailable, + modelRef: { providerID: 'kilo', modelID: partial.id }, + ...(partial.unavailable ? { unavailable: true } : {}), + ...(partial.contextWindow !== undefined ? { contextWindow: partial.contextWindow } : {}), + }; +} + +function cliOption(partial: { + providerID: string; + modelID: string; + contextWindow?: number; + unavailable?: boolean; +}): SessionModelOption { + return { + id: `remote-model-${partial.providerID}-${partial.modelID}`, + name: partial.modelID, + displayId: partial.modelID, + variants: [], + isPreferred: false, + showGatewayMetadata: false, + provider: { id: partial.providerID, name: partial.providerID }, + modelRef: { providerID: partial.providerID, modelID: partial.modelID }, + overrideSource: 'cli-catalog', + ...(partial.unavailable ? { unavailable: true } : {}), + ...(partial.contextWindow !== undefined ? { contextWindow: partial.contextWindow } : {}), + }; +} + +const kiloUsage: ContextUsage = { + contextTokens: 32_418, + providerID: 'kilo', + modelID: 'anthropic/claude-sonnet-4', +}; + +const remoteUsage: ContextUsage = { + contextTokens: 1024, + providerID: 'anthropic-local', + modelID: 'shared/model', +}; + +describe('resolveSessionContextInfo', () => { + it('returns undefined when there is no context usage', () => { + expect(resolveSessionContextInfo(undefined, [])).toBeUndefined(); + }); + + it('resolves a kilo Gateway response by exact modelID', () => { + const result = resolveSessionContextInfo(kiloUsage, [ + gatewayOption({ id: 'anthropic/claude-sonnet-4', contextWindow: 200_000 }), + gatewayOption({ id: 'other/model', contextWindow: 8000 }), + ]); + + expect(result).toEqual({ + contextTokens: 32_418, + providerID: 'kilo', + modelID: 'anthropic/claude-sonnet-4', + contextWindow: 200_000, + percentage: 16, + }); + }); + + it('resolves a remote CLI response by exact providerID + modelID', () => { + const result = resolveSessionContextInfo(remoteUsage, [ + cliOption({ providerID: 'anthropic-local', modelID: 'shared/model', contextWindow: 200_000 }), + cliOption({ providerID: 'custom-openai', modelID: 'shared/model', contextWindow: 32_000 }), + ]); + + expect(result).toEqual({ + contextTokens: 1024, + providerID: 'anthropic-local', + modelID: 'shared/model', + contextWindow: 200_000, + percentage: 1, + }); + }); + + it('resolves a remote CLI response with a kilo-named CLI provider', () => { + const result = resolveSessionContextInfo( + { ...kiloUsage, providerID: 'kilo', modelID: 'cli-kilo-model' }, + [ + cliOption({ providerID: 'kilo', modelID: 'cli-kilo-model', contextWindow: 500_000 }), + gatewayOption({ id: 'gateway/model', contextWindow: 100_000 }), + ] + ); + + expect(result?.contextWindow).toBe(500_000); + }); + + it('returns undefined when a kilo usage is missing from Gateway options', () => { + const result = resolveSessionContextInfo(kiloUsage, [ + gatewayOption({ id: 'other/model', contextWindow: 8000 }), + ]); + + expect(result).toEqual({ + contextTokens: 32_418, + providerID: 'kilo', + modelID: 'anthropic/claude-sonnet-4', + contextWindow: undefined, + percentage: undefined, + }); + }); + + it('returns undefined when a non-kilo usage has no matching CLI catalog', () => { + const result = resolveSessionContextInfo(remoteUsage, [ + cliOption({ providerID: 'custom-openai', modelID: 'shared/model', contextWindow: 32_000 }), + ]); + + expect(result?.contextWindow).toBeUndefined(); + expect(result?.percentage).toBeUndefined(); + }); + + it('ignores options without a resolved contextWindow', () => { + const result = resolveSessionContextInfo(kiloUsage, [ + gatewayOption({ id: 'anthropic/claude-sonnet-4' }), + ]); + + expect(result?.contextWindow).toBeUndefined(); + }); + + it('ignores unavailable options even when their modelRef matches', () => { + const result = resolveSessionContextInfo({ ...kiloUsage, modelID: 'gone/model' }, [ + gatewayOption({ id: 'gone/model', contextWindow: 100_000, unavailable: true }), + ]); + + expect(result?.contextWindow).toBeUndefined(); + }); + + it('returns undefined for invalid (zero/negative/NaN) contextWindow values', () => { + const zero = resolveSessionContextInfo(kiloUsage, [ + gatewayOption({ id: 'anthropic/claude-sonnet-4', contextWindow: 0 }), + ]); + const negative = resolveSessionContextInfo(kiloUsage, [ + gatewayOption({ id: 'anthropic/claude-sonnet-4', contextWindow: -1 }), + ]); + const nan = resolveSessionContextInfo(kiloUsage, [ + gatewayOption({ id: 'anthropic/claude-sonnet-4', contextWindow: Number.NaN }), + ]); + const infinite = resolveSessionContextInfo(kiloUsage, [ + gatewayOption({ id: 'anthropic/claude-sonnet-4', contextWindow: Number.POSITIVE_INFINITY }), + ]); + + expect(zero?.contextWindow).toBeUndefined(); + expect(negative?.contextWindow).toBeUndefined(); + expect(nan?.contextWindow).toBeUndefined(); + expect(infinite?.contextWindow).toBeUndefined(); + }); + + it('blacklists conflicting duplicate kilo modelIDs rather than picking arbitrarily', () => { + const result = resolveSessionContextInfo(kiloUsage, [ + gatewayOption({ id: 'anthropic/claude-sonnet-4', contextWindow: 200_000 }), + gatewayOption({ id: 'anthropic/claude-sonnet-4', contextWindow: 80_000 }), + ]); + + expect(result?.contextWindow).toBeUndefined(); + }); + + it('keeps agreeing duplicate kilo modelIDs', () => { + const result = resolveSessionContextInfo(kiloUsage, [ + gatewayOption({ id: 'anthropic/claude-sonnet-4', contextWindow: 200_000 }), + gatewayOption({ id: 'anthropic/claude-sonnet-4', contextWindow: 200_000 }), + ]); + + expect(result?.contextWindow).toBe(200_000); + }); + + it('blacklists conflicting duplicate provider+model identities across CLI rows', () => { + const result = resolveSessionContextInfo(remoteUsage, [ + cliOption({ providerID: 'anthropic-local', modelID: 'shared/model', contextWindow: 200_000 }), + cliOption({ providerID: 'anthropic-local', modelID: 'shared/model', contextWindow: 32_000 }), + cliOption({ providerID: 'custom-openai', modelID: 'shared/model', contextWindow: 32_000 }), + ]); + + expect(result?.contextWindow).toBeUndefined(); + }); + + it('keeps conflicting CLI identities distinct across providers', () => { + const result = resolveSessionContextInfo(remoteUsage, [ + cliOption({ providerID: 'anthropic-local', modelID: 'shared/model', contextWindow: 200_000 }), + cliOption({ providerID: 'custom-openai', modelID: 'shared/model', contextWindow: 32_000 }), + ]); + + expect(result?.contextWindow).toBe(200_000); + }); + + it('does not infer a non-kilo response from a Gateway catalog', () => { + const result = resolveSessionContextInfo(remoteUsage, [ + gatewayOption({ id: 'shared/model', contextWindow: 200_000 }), + ]); + + expect(result?.contextWindow).toBeUndefined(); + }); + + it('preserves the runtime contextTokens, providerID, and modelID when unresolved', () => { + const result = resolveSessionContextInfo(kiloUsage, []); + + expect(result).toEqual({ + contextTokens: 32_418, + providerID: 'kilo', + modelID: 'anthropic/claude-sonnet-4', + contextWindow: undefined, + percentage: undefined, + }); + }); +}); diff --git a/apps/mobile/src/lib/session-context-info.ts b/apps/mobile/src/lib/session-context-info.ts new file mode 100644 index 0000000000..488b8d4a04 --- /dev/null +++ b/apps/mobile/src/lib/session-context-info.ts @@ -0,0 +1,127 @@ +import { calculateContextUsagePercentage, type ContextUsage } from 'cloud-agent-sdk/context-usage'; + +import { type SessionModelOption } from './hooks/use-session-model-options'; + +export type SessionContextInfo = { + contextTokens: number; + providerID: string; + modelID: string; + contextWindow: number | undefined; + percentage: number | undefined; +}; + +// Pure resolver for the runtime context window. Matches the Cloud Agent +// Gateway catalog only when the response reports `providerID === 'kilo'` and +// resolves a remote CLI response by exact provider+model identity. Conflicting +// duplicate exact identities are blacklisted so callers never see an arbitrary +// guess. +export function resolveSessionContextInfo( + contextUsage: ContextUsage | undefined, + options: readonly SessionModelOption[] +): SessionContextInfo | undefined { + if (!contextUsage) { + return undefined; + } + + const contextWindow = resolveContextWindow(contextUsage, options); + const percentage = calculateContextUsagePercentage(contextUsage.contextTokens, contextWindow); + + return { + contextTokens: contextUsage.contextTokens, + providerID: contextUsage.providerID, + modelID: contextUsage.modelID, + contextWindow, + percentage, + }; +} + +type ContextLengthIndex = { + lengths: Map; + conflicts: Set; +}; + +function createContextLengthIndex(): ContextLengthIndex { + return { lengths: new Map(), conflicts: new Set() }; +} + +function isRecordable(option: SessionModelOption): option is SessionModelOption & { + modelRef: NonNullable; + contextWindow: number; +} { + if (option.unavailable) { + return false; + } + if (option.contextWindow === undefined) { + return false; + } + if (!isFinitePositive(option.contextWindow)) { + return false; + } + if (!option.modelRef) { + return false; + } + return true; +} + +function resolveContextWindow( + contextUsage: ContextUsage, + options: readonly SessionModelOption[] +): number | undefined { + const kiloIndex = createContextLengthIndex(); + const remoteIndices = new Map(); + + for (const option of options.filter(candidate => isRecordable(candidate))) { + const providerID = option.modelRef.providerID; + if (providerID === 'kilo') { + recordContextLength(kiloIndex, option.modelRef.modelID, option.contextWindow); + } else { + let index = remoteIndices.get(providerID); + if (!index) { + index = createContextLengthIndex(); + remoteIndices.set(providerID, index); + } + recordContextLength(index, option.modelRef.modelID, option.contextWindow); + } + } + + if (contextUsage.providerID === 'kilo') { + return takeLength(kiloIndex, contextUsage.modelID); + } + const remoteIndex = remoteIndices.get(contextUsage.providerID); + if (!remoteIndex) { + return undefined; + } + return takeLength(remoteIndex, contextUsage.modelID); +} + +function isFinitePositive(value: number): boolean { + return Number.isFinite(value) && value > 0; +} + +// First positive value wins. A later conflicting value permanently removes +// the identity so we never return an arbitrary guess. +function recordContextLength(index: ContextLengthIndex, id: string, value: number): void { + if (index.conflicts.has(id)) { + return; + } + const existing = index.lengths.get(id); + if (existing === undefined) { + index.lengths.set(id, value); + return; + } + if (existing !== value) { + index.lengths.delete(id); + index.conflicts.add(id); + } +} + +function takeLength(index: ContextLengthIndex, id: string): number | undefined { + if (index.conflicts.has(id)) { + return undefined; + } + const value = index.lengths.get(id); + if (value === undefined) { + return undefined; + } + return isFinitePositive(value) ? value : undefined; +} diff --git a/apps/mobile/src/lib/use-session-model-options.test.ts b/apps/mobile/src/lib/use-session-model-options.test.ts index 04c7512e7b..bd2895fb9b 100644 --- a/apps/mobile/src/lib/use-session-model-options.test.ts +++ b/apps/mobile/src/lib/use-session-model-options.test.ts @@ -1,11 +1,13 @@ /* eslint-disable max-lines -- Model option tests mirror the SDK/web suite. */ import { describe, expect, it } from 'vitest'; +import { type ContextUsage } from 'cloud-agent-sdk/context-usage'; import { buildSessionModelOptions, createRemoteModelOverride, revalidateLegacyGatewayOverride, } from './hooks/use-session-model-options'; +import { resolveSessionContextInfo } from './session-context-info'; const gatewayModels = [ { @@ -14,6 +16,7 @@ const gatewayModels = [ variants: ['high'], isPreferred: true, isFree: true, + context_length: 200_000, }, ]; @@ -444,3 +447,175 @@ describe('buildSessionModelOptions', () => { expect(result.selectedVariant).toBe('high'); }); }); + +describe('buildSessionModelOptions capacity projection', () => { + it('projects Cloud Agent Gateway context_length onto every option', () => { + const result = buildSessionModelOptions({ + activeSessionType: 'cloud-agent', + remoteModelState: { + ownerConnectionId: null, + protocol: 'unknown', + refresh: 'idle', + }, + observedModel: null, + remoteModelOverride: null, + gatewayModels: [ + { id: 'gateway/a', name: 'A', variants: [], isPreferred: true, context_length: 200_000 }, + { id: 'gateway/b', name: 'B', variants: [], isPreferred: false, context_length: 32_000 }, + { + id: 'gateway/missing', + name: 'M', + variants: [], + isPreferred: false, + context_length: null, + }, + { id: 'gateway/undefined', name: 'U', variants: [], isPreferred: false }, + ], + gatewayModelsLoading: false, + organizationId: 'org-1', + }); + + expect(result.source).toBe('cloud-agent-gateway'); + expect(result.options.find(option => option.id === 'gateway/a')?.contextWindow).toBe(200_000); + expect(result.options.find(option => option.id === 'gateway/b')?.contextWindow).toBe(32_000); + expect( + result.options.find(option => option.id === 'gateway/missing')?.contextWindow + ).toBeUndefined(); + expect( + result.options.find(option => option.id === 'gateway/undefined')?.contextWindow + ).toBeUndefined(); + }); + + it('projects legacy Gateway context_length and omits contextWindow on the unavailable option', () => { + const result = buildSessionModelOptions({ + activeSessionType: 'remote', + remoteModelState: { + ownerConnectionId: 'legacy-owner', + protocol: 'legacy', + refresh: 'idle', + }, + observedModel: { + model: { providerID: 'local-provider', modelID: 'private-model' }, + variant: 'old-variant', + }, + remoteModelOverride: null, + gatewayModels: [ + { + id: 'gateway/model', + name: 'Gateway Model', + variants: ['high'], + isPreferred: true, + context_length: 200_000, + }, + ], + gatewayModelsLoading: false, + organizationId: 'org-legacy', + }); + + const legacyOption = result.options.find(option => option.overrideSource === 'legacy-gateway'); + const unavailableOption = result.options.find(option => option.unavailable); + + expect(result.source).toBe('remote-legacy-gateway'); + expect(legacyOption?.contextWindow).toBe(200_000); + expect(legacyOption?.modelRef).toEqual({ providerID: 'kilo', modelID: 'gateway/model' }); + expect(unavailableOption?.contextWindow).toBeUndefined(); + }); + + it('projects v1 CLI limits.context onto each provider-aware row, distinct per provider', () => { + const result = buildSessionModelOptions({ + activeSessionType: 'remote', + remoteModelState: { + ownerConnectionId: 'cli-owner', + protocol: 'v1', + refresh: 'idle', + catalog: { + protocolVersion: 1, + truncated: false, + providers: [ + { + id: 'anthropic-local', + name: 'Anthropic Local', + models: [ + { + id: 'shared/model.id', + name: 'Claude Workspace', + variants: ['low', 'high'], + capabilities: { attachment: true, reasoning: true }, + limits: { context: 200_000, output: 8192 }, + }, + ], + }, + { + id: 'custom-openai', + name: 'Custom OpenAI', + models: [ + { + id: 'shared/model.id', + name: 'Internal Deployment', + variants: [], + capabilities: { attachment: false, reasoning: false }, + limits: { context: 32_000, output: 4096 }, + }, + ], + }, + ], + }, + }, + observedModel: null, + remoteModelOverride: null, + gatewayModels: [], + gatewayModelsLoading: false, + organizationId: 'org-cli', + }); + + const anthropicRow = result.options.find( + option => option.modelRef?.providerID === 'anthropic-local' + ); + const customRow = result.options.find( + option => option.modelRef?.providerID === 'custom-openai' + ); + + expect(anthropicRow?.contextWindow).toBe(200_000); + expect(customRow?.contextWindow).toBe(32_000); + }); + + it('resolves Cloud Agent Gateway context capacity through buildSessionModelOptions', () => { + const result = buildSessionModelOptions({ + activeSessionType: 'cloud-agent', + remoteModelState: { + ownerConnectionId: null, + protocol: 'unknown', + refresh: 'idle', + }, + observedModel: null, + remoteModelOverride: null, + gatewayModels: [ + { + id: 'gateway/model', + name: 'Gateway Model', + variants: [], + isPreferred: true, + context_length: 200_000, + }, + ], + gatewayModelsLoading: false, + organizationId: 'org-1', + }); + + const contextUsage: ContextUsage = { + contextTokens: 84_000, + providerID: 'kilo', + modelID: 'gateway/model', + }; + + const resolved = resolveSessionContextInfo(contextUsage, result.options); + + expect(resolved).toEqual({ + contextTokens: 84_000, + providerID: 'kilo', + modelID: 'gateway/model', + contextWindow: 200_000, + percentage: 42, + }); + }); +}); diff --git a/apps/mobile/vitest.config.ts b/apps/mobile/vitest.config.ts index c48690fa15..5b31b92a3f 100644 --- a/apps/mobile/vitest.config.ts +++ b/apps/mobile/vitest.config.ts @@ -9,6 +9,9 @@ export default defineConfig({ 'cloud-agent-sdk/message-id': fileURLToPath( new URL('../../apps/web/src/lib/cloud-agent-sdk/message-id.ts', import.meta.url) ), + 'cloud-agent-sdk/context-usage': fileURLToPath( + new URL('../../apps/web/src/lib/cloud-agent-sdk/context-usage.ts', import.meta.url) + ), 'cloud-agent-sdk/cli-model': fileURLToPath( new URL('../../apps/web/src/lib/cloud-agent-sdk/cli-model.ts', import.meta.url) ), diff --git a/apps/web/src/lib/cloud-agent-sdk/index.ts b/apps/web/src/lib/cloud-agent-sdk/index.ts index 1b27e5d164..0e083919b4 100644 --- a/apps/web/src/lib/cloud-agent-sdk/index.ts +++ b/apps/web/src/lib/cloud-agent-sdk/index.ts @@ -123,6 +123,9 @@ export type { SessionStorage, StorageMutation } from './storage/types'; export { stripPartContentIfFile } from './part-utils'; export { splitByContiguousPrefix } from './array-utils'; +export { calculateContextUsagePercentage } from './context-usage'; +export type { ContextUsage } from './context-usage'; + export type { MessageInfo, ProcessedMessage, From 4752c1142184346642630ec36b6933260864819c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 14 Jul 2026 21:09:16 +0200 Subject: [PATCH 2/2] fix(mobile): address context usage review --- .../agents/context-sheet-mount-state.test.ts | 82 +++++++++++++++++++ .../agents/context-usage-display.test.ts | 18 ---- .../agents/context-usage-display.ts | 15 +++- .../agents/session-context-sheet.tsx | 6 +- .../agents/session-detail-content.tsx | 49 +++++++++-- .../lib/hooks/use-session-model-options.ts | 2 - .../src/lib/session-context-info.test.ts | 1 - apps/mobile/src/lib/session-context-info.ts | 15 ++-- .../src/lib/use-session-model-options.test.ts | 45 ++++++++++ 9 files changed, 187 insertions(+), 46 deletions(-) create mode 100644 apps/mobile/src/components/agents/context-sheet-mount-state.test.ts diff --git a/apps/mobile/src/components/agents/context-sheet-mount-state.test.ts b/apps/mobile/src/components/agents/context-sheet-mount-state.test.ts new file mode 100644 index 0000000000..5c897b9739 --- /dev/null +++ b/apps/mobile/src/components/agents/context-sheet-mount-state.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; + +import { type SessionContextInfo } from '@/lib/session-context-info'; + +import { getContextSheetMountState } from './context-usage-display'; + +const currentInfo: SessionContextInfo = { + contextTokens: 32_418, + providerID: 'kilo', + modelID: 'anthropic/claude-sonnet-4', + contextWindow: 200_000, + percentage: 16, +}; + +describe('getContextSheetMountState', () => { + it('unmounts when there is no context info regardless of open state', () => { + expect(getContextSheetMountState(undefined, null, 'current-session')).toEqual({ + mounted: false, + }); + expect( + getContextSheetMountState( + undefined, + { + sessionId: 'current-session', + providerID: currentInfo.providerID, + modelID: currentInfo.modelID, + }, + 'current-session' + ) + ).toEqual({ mounted: false }); + }); + + it('mounts visible when context info exists and its identity is open', () => { + const result = getContextSheetMountState( + currentInfo, + { + sessionId: 'current-session', + providerID: currentInfo.providerID, + modelID: currentInfo.modelID, + }, + 'current-session' + ); + + expect(result).toEqual({ mounted: true, visible: true, info: currentInfo }); + }); + + it('mounts hidden when context info exists but the sheet is closed', () => { + const result = getContextSheetMountState(currentInfo, null, 'current-session'); + + expect(result).toEqual({ mounted: true, visible: false, info: currentInfo }); + }); + + it('does not reopen after the session changes', () => { + expect( + getContextSheetMountState( + currentInfo, + { + sessionId: 'previous-session', + providerID: currentInfo.providerID, + modelID: currentInfo.modelID, + }, + 'current-session' + ) + ).toEqual({ mounted: true, visible: false, info: currentInfo }); + }); + + it('does not reopen when the runtime model identity changes', () => { + const nextInfo = { ...currentInfo, modelID: 'next-model' }; + + expect( + getContextSheetMountState( + nextInfo, + { + sessionId: 'current-session', + providerID: 'kilo', + modelID: 'previous-model', + }, + 'current-session' + ) + ).toEqual({ mounted: true, visible: false, info: nextInfo }); + }); +}); diff --git a/apps/mobile/src/components/agents/context-usage-display.test.ts b/apps/mobile/src/components/agents/context-usage-display.test.ts index 9b6081b5c4..1cffecbedd 100644 --- a/apps/mobile/src/components/agents/context-usage-display.test.ts +++ b/apps/mobile/src/components/agents/context-usage-display.test.ts @@ -10,7 +10,6 @@ import { formatRemainingTokens, getArcFraction, getContextSheetContent, - getContextSheetMountState, getContextTone, getHeaderSummary, getIndeterminateArcFraction, @@ -315,20 +314,3 @@ describe('pure integration fallback', () => { expect(summary).toBeNull(); }); }); - -describe('getContextSheetMountState', () => { - it('unmounts when there is no context info regardless of open state', () => { - expect(getContextSheetMountState(undefined, false)).toEqual({ mounted: false }); - expect(getContextSheetMountState(undefined, true)).toEqual({ mounted: false }); - }); - - it('mounts visible when context info exists and the sheet is open', () => { - const result = getContextSheetMountState(info({}), true); - expect(result).toEqual({ mounted: true, visible: true, info: info({}) }); - }); - - it('mounts hidden when context info exists but the sheet is closed', () => { - const result = getContextSheetMountState(info({}), false); - expect(result).toEqual({ mounted: true, visible: false, info: info({}) }); - }); -}); diff --git a/apps/mobile/src/components/agents/context-usage-display.ts b/apps/mobile/src/components/agents/context-usage-display.ts index 5815138d59..8d42445208 100644 --- a/apps/mobile/src/components/agents/context-usage-display.ts +++ b/apps/mobile/src/components/agents/context-usage-display.ts @@ -172,6 +172,12 @@ type SheetMountState = | { mounted: false } | { mounted: true; visible: boolean; info: SessionContextInfo }; +export type ContextSheetIdentity = { + sessionId: string; + providerID: string; + modelID: string; +}; + /** * Controls when the native Modal is mounted and when it is visible. Keeping * the sheet mounted while contextInfo exists lets `visible` transition from @@ -179,10 +185,15 @@ type SheetMountState = */ export function getContextSheetMountState( info: SessionContextInfo | undefined, - isOpen: boolean + openIdentity: ContextSheetIdentity | null, + sessionId: string ): SheetMountState { if (!info) { return { mounted: false }; } - return { mounted: true, visible: isOpen, info }; + const visible = + openIdentity?.sessionId === sessionId && + openIdentity.providerID === info.providerID && + openIdentity.modelID === info.modelID; + return { mounted: true, visible, info }; } diff --git a/apps/mobile/src/components/agents/session-context-sheet.tsx b/apps/mobile/src/components/agents/session-context-sheet.tsx index 81131be603..0da5fdbd24 100644 --- a/apps/mobile/src/components/agents/session-context-sheet.tsx +++ b/apps/mobile/src/components/agents/session-context-sheet.tsx @@ -64,11 +64,7 @@ export function SessionContextSheet({ them behind a single ScrollView accessibilityLabel would shadow the natural read order. */} - + (null); const { isConnected } = useAppLifecycle(); const { bottom } = useSafeAreaInsets(); @@ -156,13 +160,17 @@ export function SessionDetailContent({ } const match = sessionModels.options.find( option => - option.modelRef !== undefined && - option.modelRef.providerID === contextInfo.providerID && - option.modelRef.modelID === contextInfo.modelID + (option.modelRef?.providerID === contextInfo.providerID && + option.modelRef.modelID === contextInfo.modelID) || + (contextInfo.providerID === 'kilo' && + option.showGatewayMetadata && + option.id === contextInfo.modelID) ); return { model: match?.name ?? match?.displayId ?? contextInfo.modelID, - provider: match?.provider?.name ?? contextInfo.providerID, + provider: + match?.provider?.name ?? + (contextInfo.providerID === 'kilo' ? 'Kilo' : contextInfo.providerID), }; }, [contextInfo, sessionModels.options]); const headerRight = contextInfo ? ( @@ -170,13 +178,21 @@ export function SessionDetailContent({ info={contextInfo} totalCost={totalCost} onPress={() => { - setContextSheetOpen(true); + setOpenContextSheetIdentity({ + sessionId, + providerID: contextInfo.providerID, + modelID: contextInfo.modelID, + }); }} /> ) : ( ); - const sheetMountState = getContextSheetMountState(contextInfo, isContextSheetOpen); + const sheetMountState = getContextSheetMountState( + contextInfo, + openContextSheetIdentity, + sessionId + ); const catalogGenerationIdentity = remoteModelState.protocol === 'v1' ? (remoteModelState.catalog ?? null) : gatewayModels; const modelPickerSelectionScope = useMemo( @@ -241,6 +257,21 @@ export function SessionDetailContent({ void manager.switchSession(sessionId); }, [sessionId, manager]); + useEffect(() => { + setOpenContextSheetIdentity(openIdentity => { + if ( + !openIdentity || + (contextInfo && + openIdentity.sessionId === sessionId && + openIdentity.providerID === contextInfo.providerID && + openIdentity.modelID === contextInfo.modelID) + ) { + return openIdentity; + } + return null; + }); + }, [contextInfo, sessionId]); + useEffect(() => { if ( activeSessionType !== 'remote' || @@ -446,7 +477,7 @@ export function SessionDetailContent({ providerDisplay={contextModelAndProvider.provider} totalCost={totalCost} onClose={() => { - setContextSheetOpen(false); + setOpenContextSheetIdentity(null); }} /> ) : null} diff --git a/apps/mobile/src/lib/hooks/use-session-model-options.ts b/apps/mobile/src/lib/hooks/use-session-model-options.ts index 35eb5f869e..2a9284d596 100644 --- a/apps/mobile/src/lib/hooks/use-session-model-options.ts +++ b/apps/mobile/src/lib/hooks/use-session-model-options.ts @@ -269,8 +269,6 @@ function createGatewayOption(model: ModelOption): SessionModelOption { ...rest, displayId: model.id, contextWindow: model.context_length ?? undefined, - provider: { id: 'kilo', name: 'Kilo' }, - modelRef: { providerID: 'kilo', modelID: model.id }, showGatewayMetadata: true, }; } diff --git a/apps/mobile/src/lib/session-context-info.test.ts b/apps/mobile/src/lib/session-context-info.test.ts index 414e4ab261..5c35ea3f2a 100644 --- a/apps/mobile/src/lib/session-context-info.test.ts +++ b/apps/mobile/src/lib/session-context-info.test.ts @@ -16,7 +16,6 @@ function gatewayOption(partial: { variants: [], isPreferred: false, showGatewayMetadata: !partial.unavailable, - modelRef: { providerID: 'kilo', modelID: partial.id }, ...(partial.unavailable ? { unavailable: true } : {}), ...(partial.contextWindow !== undefined ? { contextWindow: partial.contextWindow } : {}), }; diff --git a/apps/mobile/src/lib/session-context-info.ts b/apps/mobile/src/lib/session-context-info.ts index 488b8d4a04..4433f0e94f 100644 --- a/apps/mobile/src/lib/session-context-info.ts +++ b/apps/mobile/src/lib/session-context-info.ts @@ -45,7 +45,6 @@ function createContextLengthIndex(): ContextLengthIndex { } function isRecordable(option: SessionModelOption): option is SessionModelOption & { - modelRef: NonNullable; contextWindow: number; } { if (option.unavailable) { @@ -57,9 +56,6 @@ function isRecordable(option: SessionModelOption): option is SessionModelOption if (!isFinitePositive(option.contextWindow)) { return false; } - if (!option.modelRef) { - return false; - } return true; } @@ -71,16 +67,17 @@ function resolveContextWindow( const remoteIndices = new Map(); for (const option of options.filter(candidate => isRecordable(candidate))) { - const providerID = option.modelRef.providerID; - if (providerID === 'kilo') { - recordContextLength(kiloIndex, option.modelRef.modelID, option.contextWindow); - } else { + const providerID = option.modelRef?.providerID ?? (option.showGatewayMetadata ? 'kilo' : null); + const modelID = option.modelRef?.modelID ?? (option.showGatewayMetadata ? option.id : null); + if (providerID && modelID && providerID === 'kilo') { + recordContextLength(kiloIndex, modelID, option.contextWindow); + } else if (providerID && modelID) { let index = remoteIndices.get(providerID); if (!index) { index = createContextLengthIndex(); remoteIndices.set(providerID, index); } - recordContextLength(index, option.modelRef.modelID, option.contextWindow); + recordContextLength(index, modelID, option.contextWindow); } } diff --git a/apps/mobile/src/lib/use-session-model-options.test.ts b/apps/mobile/src/lib/use-session-model-options.test.ts index bd2895fb9b..8a6ba17c04 100644 --- a/apps/mobile/src/lib/use-session-model-options.test.ts +++ b/apps/mobile/src/lib/use-session-model-options.test.ts @@ -7,6 +7,7 @@ import { createRemoteModelOverride, revalidateLegacyGatewayOverride, } from './hooks/use-session-model-options'; +import { buildModelPickerRows, modelPickerFavoriteId } from './model-picker-rows'; import { resolveSessionContextInfo } from './session-context-info'; const gatewayModels = [ @@ -449,6 +450,50 @@ describe('buildSessionModelOptions', () => { }); describe('buildSessionModelOptions capacity projection', () => { + it('preserves Gateway picker grouping and favorite identity while projecting context capacity', () => { + const result = buildSessionModelOptions({ + activeSessionType: 'cloud-agent', + remoteModelState: { + ownerConnectionId: null, + protocol: 'unknown', + refresh: 'idle', + }, + observedModel: null, + remoteModelOverride: null, + gatewayModels: [ + { + id: 'gateway/recommended', + name: 'Recommended', + variants: [], + isPreferred: true, + context_length: 200_000, + }, + { + id: 'gateway/other', + name: 'Other', + variants: [], + isPreferred: false, + context_length: 32_000, + }, + ], + gatewayModelsLoading: false, + organizationId: 'org-1', + }); + + const [gatewayOption] = result.options; + if (!gatewayOption) { + throw new Error('Expected a Gateway option'); + } + expect(gatewayOption.provider).toBeUndefined(); + expect(gatewayOption.modelRef).toBeUndefined(); + expect(modelPickerFavoriteId(gatewayOption)).toBe('gateway/recommended'); + expect( + buildModelPickerRows({ models: result.options, search: '', favoriteIds: new Set() }) + .filter(row => row.type === 'header') + .map(row => row.title) + ).toEqual(['RECOMMENDED', 'ALL MODELS']); + }); + it('projects Cloud Agent Gateway context_length onto every option', () => { const result = buildSessionModelOptions({ activeSessionType: 'cloud-agent',