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
154 changes: 101 additions & 53 deletions apps/mobile/src/features/usage/UsageRouteScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { ChatGptUsageSummary } from "./ChatGptUsageSummary";
import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts";
import { EnvironmentId, USAGE_CONTRACT_VERSION, type UsageProviderKind } from "@t3tools/contracts";
import { type RouteProp, useIsFocused, useNavigation, useRoute } from "@react-navigation/native";
import { cursorKeychainAccessEnvironments } from "@t3tools/client-runtime/state/usage";
import {
updatingProvidersLabel,
usageEnvironmentProgress,
usageProgress,
} from "@t3tools/client-runtime/state/usage-progress";
import {
isCompatibleUsageContractVersion,
isModelCostUnknown,
Expand All @@ -21,9 +26,16 @@ import {
formatUsd,
makeWindow,
} from "@t3tools/shared/usageFormat";
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
import { Platform, Pressable, RefreshControl, View } from "react-native";
import Animated, { FadeIn, ReduceMotion } from "react-native-reanimated";
import { useCallback, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { ActivityIndicator, Platform, Pressable, RefreshControl, View } from "react-native";
import Animated, {
FadeIn,
ReduceMotion,
useAnimatedStyle,
useSharedValue,
withDelay,
withTiming,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";

import { SegmentedControl } from "../../components/SegmentedControl";
Expand Down Expand Up @@ -65,6 +77,7 @@ const METRIC_OPTIONS = [
] as const satisfies readonly { value: UsageChartMetric; label: string }[];

const CHART_HEIGHT = 180;
const providerLabel = (provider: UsageProviderKind) => PROVIDER_LABEL[provider];
const CURSOR_KEYCHAIN_COPY = "Requires access to your Cursor login in macOS Keychain.";

/**
Expand Down Expand Up @@ -156,6 +169,10 @@ export function UsageRouteScreen() {
const [refreshingUsage, setRefreshingUsage] = useState(false);
const refreshingRef = useRef(false);
const showingLimits = tab === "limits";
const progress = usageProgress(selectedEnvironments, {
refreshing: refreshingUsage,
providerLabel,
});
const selectWindow = (days: number) => {
setWindowSelection({
days,
Expand All @@ -182,10 +199,6 @@ export function UsageRouteScreen() {
};

const showEnvironmentFilter = environments.length > 0 || selectedEnvironmentIds !== null;
const hasLoadingEnvironments = selectedEnvironments.some(isUsageLoading);
const filterAccessibilityLabel = hasLoadingEnvironments
? "Filter usage environments, some environments are loading"
: "Filter usage environments";
const filterIcon =
selectedEnvironmentIds === null
? "line.3.horizontal.decrease"
Expand All @@ -201,14 +214,14 @@ export function UsageRouteScreen() {
...environments.map((environment) => ({
id: environment.environmentId,
title: environment.label,
subtitle: usageEnvironmentStatus(environment),
subtitle: usageEnvironmentStatus(environment, refreshingUsage),
state:
selectedEnvironmentIds === null || selectedEnvironmentIds.has(environment.environmentId)
? ("on" as const)
: ("off" as const),
})),
],
[environments, selectedEnvironmentIds],
[environments, refreshingUsage, selectedEnvironmentIds],
);
const selectEnvironment = useCallback(
(value: string) => {
Expand All @@ -227,37 +240,24 @@ export function UsageRouteScreen() {
<ControlPillMenu
accessible
accessibilityRole="button"
accessibilityLabel={filterAccessibilityLabel}
accessibilityLabel="Filter usage environments"
title="Environments"
actions={environmentActions}
onPressAction={({ nativeEvent }) => selectEnvironment(nativeEvent.event)}
>
<Pressable
accessibilityRole="button"
accessibilityLabel={filterAccessibilityLabel}
accessibilityLabel="Filter usage environments"
className={cn(
"items-center justify-center rounded-full",
Platform.OS === "ios" ? "size-[28px]" : "size-[44px]",
)}
>
<SymbolView name={filterIcon} size={22} tintColorClassName="accent-icon" />
{hasLoadingEnvironments ? (
<View
pointerEvents="none"
className="absolute -right-[2px] -top-[2px] size-[9px] rounded-full bg-amber-500"
/>
) : null}
</Pressable>
</ControlPillMenu>
) : null,
[
showEnvironmentFilter,
environmentActions,
selectEnvironment,
filterAccessibilityLabel,
filterIcon,
hasLoadingEnvironments,
],
[showEnvironmentFilter, environmentActions, selectEnvironment, filterIcon],
);

useLayoutEffect(() => {
Expand Down Expand Up @@ -361,26 +361,28 @@ export function UsageRouteScreen() {
{message}
</Text>
))}
<ChartCard
merged={merged}
days={chartDays}
daily={chartTotals}
metric={metric}
sinceDay={window.sinceDay}
untilDay={window.untilDay}
isPast24Hours={isPast24Hours}
timeZone={window.timeZone}
/>
<ProviderSection
merged={merged}
metric={metric}
cursorAccessEnvironments={cursorAccessEnvironments}
showCursorEnvironment={selectedEnvironments.length > 1}
onCursorEnabled={refreshAfterCursorEnable}
/>
<TotalsSection merged={merged} isPast24Hours={isPast24Hours} />
<CostSection merged={merged} />
<ModelsSection merged={merged} metric={metric} />
<UsageUpdating dimmed={progress.dimmed} label={progress.label}>
<ChartCard
merged={merged}
days={chartDays}
daily={chartTotals}
metric={metric}
sinceDay={window.sinceDay}
untilDay={window.untilDay}
isPast24Hours={isPast24Hours}
timeZone={window.timeZone}
/>
<ProviderSection
merged={merged}
metric={metric}
cursorAccessEnvironments={cursorAccessEnvironments}
showCursorEnvironment={selectedEnvironments.length > 1}
onCursorEnabled={refreshAfterCursorEnable}
/>
<TotalsSection merged={merged} isPast24Hours={isPast24Hours} />
<CostSection merged={merged} />
<ModelsSection merged={merged} metric={metric} />
</UsageUpdating>
</>
)}
</>
Expand All @@ -391,6 +393,53 @@ export function UsageRouteScreen() {
);
}

/**
* Dims totals that are about to change and says what is still updating. The
* status overlays the first child's top-right corner, the chart card's label
* row, so appearing never moves anything.
*/
function UsageUpdating({
dimmed,
label,
children,
}: {
readonly dimmed: boolean;
readonly label: string | null;
readonly children: ReactNode;
}) {
const opacity = useSharedValue(1);
useLayoutEffect(() => {
// The delay keeps a quick cached answer from flashing the dim.
opacity.set(
dimmed
? withDelay(150, withTiming(0.5, { duration: 150, reduceMotion: ReduceMotion.System }))
: withTiming(1, { duration: 150, reduceMotion: ReduceMotion.System }),
);
}, [opacity, dimmed]);
const dimStyle = useAnimatedStyle(() => ({ opacity: opacity.get() }));

return (
<View>
<Animated.View style={dimStyle} className="gap-6" accessibilityState={{ busy: dimmed }}>
{children}
</Animated.View>
{label !== null ? (
<Animated.View
entering={FadeIn.delay(150).duration(150).reduceMotion(ReduceMotion.System)}
accessibilityLiveRegion="polite"
pointerEvents="none"
className="absolute right-4 top-4 h-5 max-w-[55%] flex-row items-center gap-1.5"
>
<ActivityIndicator size="small" colorClassName="accent-adaptive-sky-600-400" />
<Text className="shrink text-sm text-adaptive-sky-600-400" numberOfLines={1}>
{label}
</Text>
</Animated.View>
) : null}
</View>
);
}

function CursorEnableAction({
environmentId,
label,
Expand Down Expand Up @@ -860,11 +909,7 @@ function ModelsSection(props: { readonly merged: MergedUsage; readonly metric: U
* one that failed, or one whose transcripts another environment already
* reported.
*/
function isUsageLoading(environment: EnvironmentUsageStatus) {
return environment.isPending || (environment.summary === null && environment.error === null);
}

function usageEnvironmentStatus(environment: EnvironmentUsageStatus): string {
function usageEnvironmentStatus(environment: EnvironmentUsageStatus, refreshing: boolean): string {
if (
environment.summary &&
!isCompatibleUsageContractVersion(environment.summary.contractVersion, USAGE_CONTRACT_VERSION)
Expand All @@ -881,7 +926,10 @@ function usageEnvironmentStatus(environment: EnvironmentUsageStatus): string {
return environment.summary ? `${environment.error} Showing saved totals.` : environment.error;
if (!environment.isConnected)
return environment.summary ? "Disconnected · showing saved usage" : "Waiting for connection…";
if (isUsageLoading(environment))
return environment.summary ? "Updating usage…" : "Loading usage…";
const progress = usageEnvironmentProgress(environment, refreshing);
if (progress.phase === "loading") return "Loading usage…";
if (progress.phase === "stale") return "Updating usage…";
if (progress.phase === "partway")
return updatingProvidersLabel(progress.providers, providerLabel);
return "Usage up to date";
}
6 changes: 5 additions & 1 deletion apps/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ import * as NativeTelemetryClient from "./resourceTelemetry/NativeTelemetryClien
import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts";
import * as ResourceMonitorBinary from "./resourceTelemetry/ResourceMonitorBinary.ts";
import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts";
import * as CursorUsageReader from "./usage/cursorUsageReader.ts";
import * as UsageService from "./usage/UsageService.ts";
import * as RuntimeLayer from "./orchestration-v2/runtimeLayer.ts";
import * as ProjectStore from "./orchestration-v2/ProjectStore.ts";
Expand Down Expand Up @@ -225,7 +226,10 @@ const layerBackground = BackgroundPolicy.layer.pipe(
Layer.provideMerge(layerServerSettings),
);

const layerUsage = UsageService.layer.pipe(Layer.provide(layerServerSettings));
const layerUsage = UsageService.layer.pipe(
Layer.provide(layerServerSettings),
Layer.provide(CursorUsageReader.layer),
);

const layerResourceDiagnostics = Layer.mergeAll(
HostResources.layer,
Expand Down
Loading
Loading