mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-31 05:32:58 +00:00
* fix(usage): fetch only when shown and add a host-wide off switch * fix(usage): restore policy defaults and isolate usage test state * refactor(usage): give the sidebar footer sole ownership of its summary
246 lines
8.9 KiB
TypeScript
246 lines
8.9 KiB
TypeScript
import type { TFunction } from "i18next";
|
|
import { i18n } from "@/i18n/i18next";
|
|
import { usageCopy } from "./copy";
|
|
import type { UsageDisplayAs } from "./preferences";
|
|
import type { UsageReport, UsageReportEntry, UsageView, UsageWindow } from "./types";
|
|
|
|
export function usedPercent(window: UsageWindow): number | null {
|
|
if (window.usedPct != null) return window.usedPct;
|
|
if (window.remainingPct != null) return 100 - window.remainingPct;
|
|
return null;
|
|
}
|
|
|
|
/** The percent a window shows under the user's used/remaining preference. */
|
|
export function displayPercent(window: UsageWindow, displayAs: UsageDisplayAs): number | null {
|
|
if (displayAs === "used") return usedPercent(window);
|
|
if (window.remainingPct != null) return window.remainingPct;
|
|
const used = usedPercent(window);
|
|
return used == null ? null : 100 - used;
|
|
}
|
|
|
|
/**
|
|
* A window row's accessible label: what pinning it pins, then what the row shows. The row is a
|
|
* checkbox, so its checked state says whether it is pinned: "Pin Claude Session, 31% · resets in
|
|
* 2h", checked.
|
|
*/
|
|
export function usageWindowRowLabel(input: {
|
|
pinLabel: string;
|
|
value: string;
|
|
trailing: string | null | undefined;
|
|
}): string {
|
|
const summary = input.trailing ? `${input.value} · ${input.trailing}` : input.value;
|
|
return `${input.pinLabel}, ${summary}`;
|
|
}
|
|
|
|
/** A user-requested refresh of one report. The previous report stays on screen throughout. */
|
|
export type UsageRefresh = "idle" | "pending" | "failed";
|
|
|
|
export function resolveUsageRefresh(mutation: {
|
|
isPending: boolean;
|
|
error: unknown;
|
|
}): UsageRefresh {
|
|
if (mutation.isPending) return "pending";
|
|
if (mutation.error) return "failed";
|
|
return "idle";
|
|
}
|
|
|
|
/**
|
|
* A host's report list with one report swapped for its refreshed copy, in place.
|
|
* `null` means the daemon no longer knows the ID, so the report leaves the list.
|
|
*/
|
|
export function replaceReport(
|
|
reports: readonly UsageReportEntry[],
|
|
reportId: string,
|
|
refreshed: UsageReportEntry | null,
|
|
): UsageReportEntry[] {
|
|
if (!refreshed) return reports.filter((report) => report.id !== reportId);
|
|
return reports.map((report) => (report.id === reportId ? refreshed : report));
|
|
}
|
|
|
|
/**
|
|
* The later-fetched of two copies of one report. A list request's copies were fetched when it
|
|
* started, so a Refresh that lands while it streams is newer than what the request still delivers.
|
|
*/
|
|
function laterFetched(shown: UsageReportEntry, arriving: UsageReportEntry): UsageReportEntry {
|
|
return Date.parse(shown.fetchedAt) > Date.parse(arriving.fetchedAt) ? shown : arriving;
|
|
}
|
|
|
|
/**
|
|
* A report list with one streamed report in place of its previous copy, or appended if new. A
|
|
* copy fetched after the streamed one stays.
|
|
*/
|
|
export function upsertReport(
|
|
reports: readonly UsageReportEntry[] | undefined,
|
|
report: UsageReportEntry,
|
|
): UsageReportEntry[] {
|
|
if (!reports?.some((entry) => entry.id === report.id)) return [...(reports ?? []), report];
|
|
return reports.map((entry) => (entry.id === report.id ? laterFetched(entry, report) : entry));
|
|
}
|
|
|
|
/**
|
|
* Keep displayed reports in place: the finished list is in fetch-completion order, which can
|
|
* change on every refresh. Drop missing reports, append new ones, and keep newer fetched copies.
|
|
*/
|
|
export function settleReports(
|
|
shown: readonly UsageReportEntry[] | undefined,
|
|
finished: readonly UsageReportEntry[],
|
|
): UsageReportEntry[] {
|
|
const remaining = new Map(finished.map((report) => [report.id, report]));
|
|
const settled: UsageReportEntry[] = [];
|
|
for (const report of shown ?? []) {
|
|
const arriving = remaining.get(report.id);
|
|
if (!arriving) {
|
|
continue;
|
|
}
|
|
settled.push(laterFetched(report, arriving));
|
|
remaining.delete(report.id);
|
|
}
|
|
return [...settled, ...remaining.values()];
|
|
}
|
|
|
|
export interface UsageQueryState {
|
|
data: UsageReportEntry[] | undefined;
|
|
error: unknown;
|
|
isFetching: boolean;
|
|
}
|
|
|
|
export function resolveUsageView(
|
|
input: {
|
|
enabled?: boolean;
|
|
hostLabel: string;
|
|
isConnected: boolean;
|
|
supportsUsage: boolean;
|
|
query: UsageQueryState | undefined;
|
|
},
|
|
t: TFunction = i18n.t,
|
|
): UsageView {
|
|
const { hostLabel, isConnected, supportsUsage, query } = input;
|
|
if (!isConnected)
|
|
return {
|
|
kind: "unavailable",
|
|
message: t("providerUsage.hostUnavailable", { host: hostLabel }),
|
|
};
|
|
if (!supportsUsage) {
|
|
return {
|
|
kind: "unavailable",
|
|
message: t("providerUsage.hostUpgradeRequired", { host: hostLabel }),
|
|
};
|
|
}
|
|
if (input.enabled === false)
|
|
return { kind: "off", message: t("providerUsage.off", { host: hostLabel }) };
|
|
if (query?.data) {
|
|
return { kind: "ready", reports: query.data, isRefreshing: query.isFetching };
|
|
}
|
|
if (query?.error) {
|
|
return {
|
|
kind: "error",
|
|
message: query.error instanceof Error ? query.error.message : String(query.error),
|
|
};
|
|
}
|
|
return { kind: "loading" };
|
|
}
|
|
|
|
/** What a meter popover shows of its agent's usage: nothing while the host cannot say. */
|
|
export type AgentUsageView =
|
|
| { kind: "none" }
|
|
| { kind: "loading" }
|
|
| { kind: "error"; message: string }
|
|
| { kind: "ready"; reports: UsageReportEntry[] };
|
|
|
|
export function resolveAgentUsageView(
|
|
input: {
|
|
canReport: boolean;
|
|
query: UsageQueryState;
|
|
},
|
|
t: TFunction = i18n.t,
|
|
): AgentUsageView {
|
|
const { canReport, query } = input;
|
|
if (!canReport) return { kind: "none" };
|
|
// A failed request keeps the reports from before it, or those that streamed in before it failed;
|
|
// shown alone they would pass for the agent's complete, current usage.
|
|
if (query.error) {
|
|
const reason = query.error instanceof Error ? query.error.message : String(query.error);
|
|
return { kind: "error", message: t("providerUsage.agentError", { reason }) };
|
|
}
|
|
if (query.data) {
|
|
return query.data.length === 0 ? { kind: "none" } : { kind: "ready", reports: query.data };
|
|
}
|
|
return { kind: "loading" };
|
|
}
|
|
|
|
export interface UsageHost {
|
|
serverId: string;
|
|
label: string;
|
|
isConnected: boolean;
|
|
supportsUsage: boolean;
|
|
}
|
|
|
|
/** Where usage looks for its host: the user's saved pick, then the workspace they are in. */
|
|
export interface UsageHostChoice {
|
|
pickedServerId: string | null;
|
|
activeServerId: string | null;
|
|
hosts: readonly UsageHost[];
|
|
}
|
|
|
|
/**
|
|
* The host the sidebar Usage row reads: the picked host, else the active workspace's host, else the
|
|
* first host. Each only while it is connected and reports usage.
|
|
*/
|
|
export function resolveUsageHostId(choice: UsageHostChoice): string | null {
|
|
const reporting = choice.hosts.filter((host) => host.isConnected && host.supportsUsage);
|
|
const find = (serverId: string | null) => reporting.find((host) => host.serverId === serverId);
|
|
return (
|
|
(find(choice.pickedServerId) ?? find(choice.activeServerId) ?? reporting[0])?.serverId ?? null
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The host the Usage modal shows: the picked host while it is connected, even one that cannot
|
|
* report usage so the modal says to update it; else the sidebar row's host; else the first
|
|
* connected host.
|
|
*/
|
|
export function resolveUsageModalHostId(choice: UsageHostChoice): string | null {
|
|
const connected = choice.hosts.filter((host) => host.isConnected);
|
|
const picked = connected.find((host) => host.serverId === choice.pickedServerId);
|
|
return picked?.serverId ?? resolveUsageHostId(choice) ?? connected[0]?.serverId ?? null;
|
|
}
|
|
|
|
/** Current outcome owns messages; only a successful poll owns values and freshness. */
|
|
export function resolveUsageReport(entry: UsageReportEntry, t: TFunction) {
|
|
const available = entry.report.status === "available";
|
|
// COMPAT(usageLastSuccessful): added in v0.12.0, remove after 2027-04-17 once daemon floor >= v0.12.0.
|
|
// Older daemons cannot provide history; a failed attempt is never a successful update.
|
|
const snapshot = available ? null : (entry.lastSuccessful ?? null);
|
|
const content = entry.report.status === "available" ? entry.report : (snapshot?.report ?? null);
|
|
const messages: string[] = [];
|
|
if (!available) {
|
|
if (entry.loginErrors)
|
|
messages.push(
|
|
...entry.loginErrors.map(
|
|
(login) => `${login.harness}: ${usageProblemText(login.report, t)}`,
|
|
),
|
|
);
|
|
// COMPAT(usageLoginErrors): added in v0.11.0, remove after 2027-04-05 once daemon floor >= v0.11.0.
|
|
else messages.push(usageProblemText(entry.report, t));
|
|
}
|
|
return {
|
|
windows: content?.windows ?? [],
|
|
balances: content?.balances ?? [],
|
|
details: content?.details ?? [],
|
|
planLabel: content?.planLabel ?? null,
|
|
// COMPAT(usageSuccessfulAt): added in v0.12.0, remove after 2027-04-17 once daemon floor >= v0.12.0.
|
|
// Old daemons expose only attempt time for available reports, never for failures.
|
|
successfulAt: available
|
|
? (entry.successfulAt ?? entry.fetchedAt)
|
|
: (snapshot?.fetchedAt ?? null),
|
|
retained: snapshot !== null,
|
|
messages,
|
|
};
|
|
}
|
|
|
|
function usageProblemText(report: UsageReport, t: TFunction): string {
|
|
if (report.status === "error") return report.error;
|
|
if (report.status === "unavailable") return usageCopy.problem(report.problem, new Date(), t);
|
|
return "";
|
|
}
|