Skip to content
Open
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
195 changes: 158 additions & 37 deletions apps/mobile/src/features/usage/UsageRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
isModelCostUnknown,
type DailyTotals,
type MergedUsage,
type ProjectTotals,
} from "@t3tools/shared/usageMerge";
import {
enumerateDays,
Expand Down Expand Up @@ -48,6 +49,12 @@ import type { UsageChartMetric } from "./usageChartData";
import { PROVIDER_LABEL, useProviderColors, useUsageMixColors } from "./usageProviders";

type UsageView = UsageChartMetric | "limits";
type UsageBreakdown = "model" | "project";
const BREAKDOWN_OPTIONS = [
{ value: "model", label: "Model" },
{ value: "project", label: "Project" },
] as const;

const VIEW_OPTIONS = [
{ value: "cost", label: "Cost" },
{ value: "tokens", label: "Tokens" },
Expand Down Expand Up @@ -94,6 +101,7 @@ function UsageScreen(props: {
// A workspace pane knows its width in the render that resizes it; measuring lags a frame.
const width = useWorkspaceSettledContentWidth() ?? measuredWidth;
const wide = width >= WIDE_WIDTH;
const [breakdown, setBreakdown] = useState<UsageBreakdown>("model");
const [windowSelection, setWindowSelection] = useState(() => ({
days: 30,
window: makeWindow(30),
Expand Down Expand Up @@ -348,7 +356,14 @@ function UsageScreen(props: {
columns={width >= 900 ? 6 : wide ? 3 : 2}
/>
<CostSection merged={merged} wide={wide} />
<ModelsSection merged={merged} metric={metric} columns={wide ? 2 : 1} />
<ModelsSection
merged={merged}
metric={metric}
columns={wide ? 2 : 1}
breakdown={breakdown}
onBreakdownChange={setBreakdown}
showEnvironment={selectedEnvironments.length > 1}
/>
</UsageUpdating>
</>
)}
Expand Down Expand Up @@ -743,10 +758,13 @@ function ModelsSection(props: {
readonly merged: MergedUsage;
readonly metric: UsageChartMetric;
readonly columns: 1 | 2;
readonly breakdown: UsageBreakdown;
readonly onBreakdownChange: (value: UsageBreakdown) => void;
readonly showEnvironment: boolean;
}) {
const { merged, metric } = props;
const colors = useProviderColors();
if (merged.models.length === 0) return null;
if (merged.models.length === 0 && merged.projects.length === 0) return null;

// Hermes lacks toSorted.
const ordered = [...merged.models].sort((a, b) =>
Expand All @@ -756,50 +774,153 @@ function ModelsSection(props: {
);

return (
<SettingsSection title="By model">
<View className="flex-row flex-wrap">
{ordered.map((model, index) => (
<View
key={`${model.provider}:${model.model}`}
className={cn(
"flex-row items-center gap-3 p-4",
index >= props.columns && "border-t border-border-subtle",
index % props.columns === 1 && "border-l border-border-subtle",
)}
style={{ width: `${100 / props.columns}%` }}
>
<SettingsSection
title="Breakdown"
trailing={
<SegmentedControl
options={BREAKDOWN_OPTIONS}
selected={props.breakdown}
onSelect={props.onBreakdownChange}
className="w-48"
/>
}
>
{props.breakdown === "project" ? (
<ProjectRows
merged={merged}
metric={metric}
columns={props.columns}
showEnvironment={props.showEnvironment}
/>
) : (
<View className="flex-row flex-wrap">
{ordered.map((model, index) => (
<View
className="size-2.5 shrink-0 rounded-full"
style={{ backgroundColor: colors[model.provider] }}
/>
<View className="min-w-0 flex-1 gap-0.5">
<Text className="text-base text-foreground" numberOfLines={1}>
{model.model}
</Text>
<Text className="text-sm text-foreground-muted">
key={`${model.provider}:${model.model}`}
className={cn(
"flex-row items-center gap-3 p-4",
index >= props.columns && "border-t border-border-subtle",
index % props.columns === 1 && "border-l border-border-subtle",
)}
style={{ width: `${100 / props.columns}%` }}
>
<View
className="size-2.5 shrink-0 rounded-full"
style={{ backgroundColor: colors[model.provider] }}
/>
<View className="min-w-0 flex-1 gap-0.5">
<Text className="text-base text-foreground" numberOfLines={1}>
{model.model}
</Text>
<Text className="text-sm text-foreground-muted">
{metric === "tokens"
? `${formatPercent(model.tokenShare)} of tokens · ${
isModelCostUnknown(model) ? "no known rates" : formatUsd(model.costUsd)
}`
: isModelCostUnknown(model)
? `no known rates · ${formatTokens(model.totalTokens)} tokens`
: `${formatPercent(model.costShare)} of cost · ${formatTokens(model.totalTokens)} tokens`}
</Text>
</View>
<Text className="text-base tabular-nums text-foreground">
{metric === "tokens"
? `${formatPercent(model.tokenShare)} of tokens · ${
isModelCostUnknown(model) ? "no known rates" : formatUsd(model.costUsd)
}`
? formatTokens(model.totalTokens)
: isModelCostUnknown(model)
? `no known rates · ${formatTokens(model.totalTokens)} tokens`
: `${formatPercent(model.costShare)} of cost · ${formatTokens(model.totalTokens)} tokens`}
? "Unpriced"
: formatUsd(model.costUsd)}
</Text>
</View>
<Text className="text-base tabular-nums text-foreground">
{metric === "tokens"
? formatTokens(model.totalTokens)
: isModelCostUnknown(model)
? "Unpriced"
: formatUsd(model.costUsd)}
</Text>
</View>
))}
</View>
))}
</View>
)}
</SettingsSection>
);
}

function ProjectRows(props: {
readonly merged: MergedUsage;
readonly metric: UsageChartMetric;
readonly columns: 1 | 2;
readonly showEnvironment: boolean;
}) {
const { merged, metric, columns } = props;
// Hermes lacks toSorted.
const ordered = [...merged.projects].sort((a, b) =>
metric === "cost"
? b.costUsd - a.costUsd || b.totalTokens - a.totalTokens
: b.totalTokens - a.totalTokens || b.costUsd - a.costUsd,
);
return (
<View>
<View className="flex-row flex-wrap">
{ordered.map((row, index) => {
const unknown = isModelCostUnknown(row);
const label = row.kind === "project" ? row.title : "No project";
const value =
metric === "tokens"
? formatTokens(row.totalTokens)
: unknown
? "Unpriced"
: formatUsd(row.costUsd);
const detail = projectDetail(row, metric, unknown, props.showEnvironment);
return (
<View
key={row.key}
accessible
accessibilityLabel={`${label}, ${value}, ${detail}`}
className={cn(
"flex-row items-center gap-3 p-4",
index >= columns && "border-t border-border-subtle",
index % columns === 1 && "border-l border-border-subtle",
)}
style={{ width: `${100 / columns}%` }}
>
<View className="min-w-0 flex-1 gap-0.5">
<Text
className={
row.kind === "project"
? "text-base text-foreground"
: "text-base text-foreground-muted"
}
numberOfLines={2}
>
{label}
</Text>
<Text className="text-sm text-foreground-muted">{detail}</Text>
</View>
<Text className="text-base tabular-nums text-foreground">{value}</Text>
</View>
);
})}
</View>
{ordered.length === 0 ? (
<Text className="p-4 text-sm text-foreground-muted">No project usage in this window.</Text>
) : null}
</View>
);
}

function projectDetail(
row: ProjectTotals,
metric: UsageChartMetric,
unknown: boolean,
showEnvironment: boolean,
) {
const qualifiers: string[] = [];
if (row.kind === "project") {
if (showEnvironment) qualifiers.push(row.environmentLabel);
if (row.duplicateTitleId !== null) qualifiers.push(`ID ${row.duplicateTitleId}`);
}
const partial = !unknown && row.unpricedRecords > 0 ? " · some unpriced" : "";
const figure =
metric === "tokens"
? `${formatPercent(row.tokenShare)} of tokens · ${unknown ? "no known rates" : formatUsd(row.costUsd)}${partial}`
: unknown
? `no known rates · ${formatTokens(row.totalTokens)} tokens`
: `${formatPercent(row.costShare)} of cost · ${formatTokens(row.totalTokens)} tokens${partial}`;
return [...qualifiers, figure].join(" · ");
}

function usageEnvironmentStatus(environment: EnvironmentUsageStatus, refreshing: boolean): string {
if (
environment.summary &&
Expand Down
58 changes: 58 additions & 0 deletions apps/server/src/orchestration-v2/ProjectStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,64 @@ it.layer(ProjectStore.layer.pipe(Layer.provideMerge(SqlitePersistence.layerMemor
}),
);

it.effect("lists each provider session with its thread's project", () =>
Effect.gen(function* () {
const projects = yield* ProjectStore.ProjectStoreV2;
const sql = yield* SqlClient.SqlClient;
const project = (id: string, title: string) =>
sql`INSERT INTO projection_projects (project_id, title, workspace_root, scripts_json, created_at, updated_at)
VALUES (${id}, ${title}, ${`/work/${id}`}, '[]', '2026-08-01', '2026-08-01')`;
const thread = (id: string, projectId: string) =>
sql`INSERT INTO orchestration_v2_projection_threads (thread_id, project_id, title, default_provider, runtime_mode, interaction_mode, created_at, updated_at, payload_json)
VALUES (${id}, ${projectId}, ${id}, 'claudeAgent', 'full-access', 'default', '2026-08-01', '2026-08-01', '{}')`;
const binding = (id: string, threadId: string, nativeId: string | null) =>
sql`INSERT INTO orchestration_v2_projection_provider_threads (provider_thread_id, thread_id, provider, driver, status, updated_at, payload_json)
VALUES (${id}, ${threadId}, 'claudeAgent', 'claudeAgent', 'idle', '2026-08-01', ${JSON.stringify(
{ nativeThreadRef: nativeId === null ? null : { driver: "claudeAgent", nativeId } },
)})`;
const attempt = (
id: string,
threadId: string,
providerThreadId: string,
nativeThreadId?: string,
) =>
sql`INSERT INTO orchestration_v2_projection_run_attempts (attempt_id, thread_id, run_id, attempt_ordinal, root_node_id, provider, provider_thread_id, status, payload_json)
VALUES (${id}, ${threadId}, ${id}, 1, ${id}, 'claudeAgent', ${providerThreadId}, 'completed', ${JSON.stringify(
{ nativeThreadId },
)})`;

yield* project("p-app", "App");
yield* project("p-other", "Other");
yield* thread("t-main", "p-app");
yield* thread("t-worktree", "p-app");
yield* thread("t-other", "p-other");
yield* binding("pt-main", "t-main", "bound");
yield* binding("pt-worktree", "t-worktree", "worktree");
yield* binding("pt-ambiguous-app", "t-main", "ambiguous");
yield* binding("pt-ambiguous-other", "t-other", "ambiguous");
yield* binding("pt-forgotten", "t-worktree", null);
yield* attempt("a-old", "t-main", "pt-main", "before-recovery");
yield* attempt("a-old-again", "t-main", "pt-main", "before-recovery");
yield* attempt("a-current", "t-main", "pt-main", "bound");
yield* attempt("a-forgotten", "t-worktree", "pt-forgotten", "forgotten");
yield* attempt("a-no-native", "t-main", "pt-main");
yield* sql`UPDATE projection_projects SET deleted_at = '2026-08-02' WHERE project_id = 'p-app'`;

const rows = yield* projects.listProviderSessionProjects();
assert.deepStrictEqual(
rows.map((row) => `${row.nativeId} ${row.projectId} ${row.title}`).sort(),
[
"ambiguous p-app App",
"ambiguous p-other Other",
"before-recovery p-app App",
"bound p-app App",
"forgotten p-app App",
"worktree p-app App",
],
);
}),
);

it.effect("marks a project's title as custom only once it is renamed", () =>
Effect.gen(function* () {
const projects = yield* ProjectStore.ProjectStoreV2;
Expand Down
Loading
Loading