mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 00:14:54 +00:00
feat(usage): open the pill popover from cache and refresh on demand
Opening the popover no longer forces a vendor fetch; it shows the cached report. A Refresh control force-refreshes only this report (usage.list_reports with its single ID and forceRefresh), shows Refreshing... while it runs, keeps the previous report visible, and says "Unable to refresh usage" if the request fails.
This commit is contained in:
@@ -4,11 +4,18 @@ import { expectComposerVisible, submitMessage } from "../support/helpers/compose
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
|
||||
|
||||
function agentEntry(usedPct: number): UsageReportEntry {
|
||||
const REPORT_ID = "fixture:fixture-account";
|
||||
|
||||
// Two hours reads "2h ago" for an hour, so the assertion cannot race the clock.
|
||||
function twoHoursAgo(): string {
|
||||
return new Date(Date.now() - 2 * 60 * 60_000).toISOString();
|
||||
}
|
||||
|
||||
function agentEntry(usedPct: number, fetchedAt = "2026-01-01T00:00:00.000Z"): UsageReportEntry {
|
||||
return {
|
||||
id: "fixture:fixture-account",
|
||||
account: {},
|
||||
fetchedAt: "2026-01-01T00:00:00.000Z",
|
||||
id: REPORT_ID,
|
||||
account: { label: "Fixture account" },
|
||||
fetchedAt,
|
||||
sourceId: "fixture",
|
||||
sourceLabel: "Fixture plan",
|
||||
icon: '<svg viewBox="0 0 24 24"><rect width="24" height="24" fill="currentColor"/></svg>',
|
||||
@@ -35,30 +42,118 @@ async function openMockAgent(page: Page) {
|
||||
}
|
||||
|
||||
test.describe("usage composer pill", () => {
|
||||
test("shows the headline percent and opens the usage card with a forced refresh", async ({
|
||||
test("opens the cached report without a request and shows when it was fetched", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
agentReportIds: ["fixture:fixture-account"],
|
||||
lists: [[agentEntry(42)], [agentEntry(64)]],
|
||||
agentReportIds: [REPORT_ID],
|
||||
lists: [[agentEntry(42, twoHoursAgo())]],
|
||||
});
|
||||
const session = await openMockAgent(page);
|
||||
try {
|
||||
const pill = page.getByTestId("usage-composer-pill");
|
||||
await expect(pill).toContainText("42%", { timeout: 30_000 });
|
||||
expect(usage.agentRequests()[0]).toEqual({ agentId: session.agentId });
|
||||
expect(usage.listRequests()).toEqual([{ forceRefresh: false, reportIds: [REPORT_ID] }]);
|
||||
|
||||
const requestsBeforeOpen = usage.listRequests().length;
|
||||
await pill.click();
|
||||
await usage.waitForListRequests(requestsBeforeOpen + 1);
|
||||
expect(usage.listRequests().at(-1)?.forceRefresh).toBe(true);
|
||||
|
||||
const popover = page.getByTestId("usage-composer-popover");
|
||||
await expect(popover.getByText("Fixture plan", { exact: true })).toBeVisible();
|
||||
await expect(popover.getByText("Test plan")).toBeVisible();
|
||||
await expect(popover.getByText("42%")).toBeVisible();
|
||||
|
||||
await popover.getByTestId("usage-card-header").hover();
|
||||
await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated 2h ago");
|
||||
await expect(popover.getByTestId("usage-freshness")).toHaveCount(0);
|
||||
expect(usage.listRequests()).toHaveLength(1);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("refreshes only its report from the popover", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
agentReportIds: [REPORT_ID],
|
||||
lists: [[agentEntry(42, twoHoursAgo())], () => [agentEntry(64, new Date().toISOString())]],
|
||||
});
|
||||
const session = await openMockAgent(page);
|
||||
try {
|
||||
const pill = page.getByTestId("usage-composer-pill");
|
||||
await expect(pill).toContainText("42%", { timeout: 30_000 });
|
||||
await pill.click();
|
||||
const popover = page.getByTestId("usage-composer-popover");
|
||||
await expect(popover.getByText("42%")).toBeVisible();
|
||||
|
||||
await popover.getByTestId("usage-refresh").click();
|
||||
await usage.waitForListRequests(2);
|
||||
expect(usage.listRequests()).toEqual([
|
||||
{ forceRefresh: false, reportIds: [REPORT_ID] },
|
||||
{ forceRefresh: true, reportIds: [REPORT_ID] },
|
||||
]);
|
||||
await expect(popover.getByText("64%")).toBeVisible();
|
||||
await expect(pill).toContainText("64%");
|
||||
await expect(popover.getByTestId("usage-refresh")).toHaveText("Refresh");
|
||||
|
||||
await popover.getByTestId("usage-card-header").hover();
|
||||
await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated just now");
|
||||
expect(usage.listRequests()).toHaveLength(2);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the previous report and says so when a refresh fails", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
agentReportIds: [REPORT_ID],
|
||||
lists: [
|
||||
[agentEntry(42, twoHoursAgo())],
|
||||
{ error: "Usage source timed out" },
|
||||
() => [agentEntry(64, new Date().toISOString())],
|
||||
],
|
||||
});
|
||||
const session = await openMockAgent(page);
|
||||
try {
|
||||
const pill = page.getByTestId("usage-composer-pill");
|
||||
await expect(pill).toContainText("42%", { timeout: 30_000 });
|
||||
await pill.click();
|
||||
const popover = page.getByTestId("usage-composer-popover");
|
||||
const refresh = popover.getByTestId("usage-refresh");
|
||||
|
||||
await refresh.click();
|
||||
await usage.waitForListRequests(2);
|
||||
await expect(popover.getByTestId("usage-refresh-error")).toHaveText(
|
||||
"Unable to refresh usage",
|
||||
);
|
||||
await expect(popover.getByText("42%")).toBeVisible();
|
||||
await expect(pill).toContainText("42%");
|
||||
|
||||
await refresh.click();
|
||||
await usage.waitForListRequests(3);
|
||||
await expect(popover.getByText("64%")).toBeVisible();
|
||||
await expect(popover.getByTestId("usage-refresh-error")).toHaveText("");
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("prints the freshness on the card where there is no hover", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
agentReportIds: [REPORT_ID],
|
||||
lists: [[agentEntry(42, twoHoursAgo())]],
|
||||
});
|
||||
const session = await openMockAgent(page);
|
||||
try {
|
||||
const pill = page.getByTestId("usage-composer-pill");
|
||||
await expect(pill).toContainText("42%", { timeout: 30_000 });
|
||||
await pill.click();
|
||||
const popover = page.getByTestId("usage-composer-popover");
|
||||
await expect(popover.getByTestId("usage-freshness")).toHaveText("Updated 2h ago");
|
||||
expect(usage.listRequests()).toHaveLength(1);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
@@ -67,7 +162,7 @@ test.describe("usage composer pill", () => {
|
||||
test("re-resolves the report ID after a turn and reuses the cached report", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
agentReportIds: ["fixture:fixture-account"],
|
||||
agentReportIds: [REPORT_ID],
|
||||
lists: [[agentEntry(42)]],
|
||||
});
|
||||
const session = await openMockAgent(page);
|
||||
@@ -107,7 +202,7 @@ test.describe("usage composer pill", () => {
|
||||
test.setTimeout(180_000);
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
usageSources: false,
|
||||
agentReportIds: ["fixture:fixture-account"],
|
||||
agentReportIds: [REPORT_ID],
|
||||
lists: [[agentEntry(42)]],
|
||||
});
|
||||
const session = await openMockAgent(page);
|
||||
|
||||
@@ -10,14 +10,19 @@ export interface UsageReportsFixture {
|
||||
}
|
||||
|
||||
interface UsageReportsFixtureOptions {
|
||||
/** Successive `usage.list_reports` responses; the last one repeats. */
|
||||
lists?: UsageReportEntry[][];
|
||||
/**
|
||||
* Successive `usage.list_reports` responses; the last one repeats. `{ error }` fails that
|
||||
* request; a function builds the response when the request arrives (e.g. a fresh `fetchedAt`).
|
||||
*/
|
||||
lists?: Array<UsageListResponse | (() => UsageListResponse)>;
|
||||
/** Successive agent report IDs; the last one repeats. */
|
||||
agentReportIds?: Array<string | null>;
|
||||
/** Advertise `features.usageSources`. False simulates a host from before usage sources. */
|
||||
usageSources?: boolean;
|
||||
}
|
||||
|
||||
type UsageListResponse = UsageReportEntry[] | { error: string };
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
function parseJson(message: WebSocketMessage): unknown {
|
||||
@@ -106,9 +111,28 @@ export async function installUsageReportsFixture(
|
||||
forceRefresh: request.forceRefresh === true,
|
||||
reportIds: Array.isArray(request.reportIds) ? (request.reportIds as string[]) : undefined,
|
||||
});
|
||||
const available = pick(options.lists ?? [[]], listRequests.length - 1);
|
||||
const scripted = pick(options.lists ?? [[]], listRequests.length - 1);
|
||||
const response = typeof scripted === "function" ? scripted() : scripted;
|
||||
if ("error" in response) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "rpc_error",
|
||||
payload: {
|
||||
requestId,
|
||||
requestType: "usage.list_reports.request",
|
||||
error: response.error,
|
||||
code: "transport",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
listCounter.increment();
|
||||
return;
|
||||
}
|
||||
const ids = Array.isArray(request.reportIds) ? request.reportIds : null;
|
||||
const reports = ids ? available.filter((entry) => ids.includes(entry.id)) : available;
|
||||
const reports = ids ? response.filter((entry) => ids.includes(entry.id)) : response;
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback } from "react";
|
||||
import { RefreshCw } from "lucide-react-native";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -14,7 +15,7 @@ import {
|
||||
} from "@/components/ui/toolbar-label-trigger";
|
||||
import { UsageCard } from "./card";
|
||||
import { usageCopy } from "./copy";
|
||||
import type { UsagePill } from "./model";
|
||||
import type { UsagePill, UsageRefresh } from "./model";
|
||||
import { useAgentUsage } from "./queries";
|
||||
import { UsageSourceIcon } from "./source-icon";
|
||||
|
||||
@@ -22,20 +23,17 @@ function pillTriggerStyle(state: MenuTriggerState) {
|
||||
return [toolbarLabelTriggerStyle(state), styles.trigger];
|
||||
}
|
||||
|
||||
/** The agent's plan usage beside the composer controls. Opens the usage card. */
|
||||
/**
|
||||
* The agent's plan usage beside the composer controls. Opens the cached usage card;
|
||||
* only Refresh asks the source for a new report.
|
||||
*/
|
||||
export function UsageComposerPill({ serverId, agentId }: { serverId: string; agentId: string }) {
|
||||
const { pill, entry, refresh } = useAgentUsage(serverId, agentId);
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (open) refresh();
|
||||
},
|
||||
[refresh],
|
||||
);
|
||||
const { pill, entry, refresh, refreshState } = useAgentUsage(serverId, agentId);
|
||||
|
||||
if (!pill || !entry) return null;
|
||||
|
||||
return (
|
||||
<DropdownMenu onOpenChange={handleOpenChange}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
style={pillTriggerStyle}
|
||||
accessibilityRole="button"
|
||||
@@ -56,12 +54,42 @@ export function UsageComposerPill({ serverId, agentId }: { serverId: string; age
|
||||
>
|
||||
<View style={styles.popover}>
|
||||
<UsageCard entry={entry} compact />
|
||||
<UsageRefreshRow refreshState={refreshState} onRefresh={refresh} />
|
||||
</View>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function UsageRefreshRow({
|
||||
refreshState,
|
||||
onRefresh,
|
||||
}: {
|
||||
refreshState: UsageRefresh;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const isPending = refreshState === "pending";
|
||||
return (
|
||||
<View style={styles.refreshRow}>
|
||||
<Text style={styles.refreshError} numberOfLines={2} testID="usage-refresh-error">
|
||||
{refreshState === "failed" ? usageCopy.refreshFailed : null}
|
||||
</Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
leftIcon={RefreshCw}
|
||||
loading={isPending}
|
||||
onPress={onRefresh}
|
||||
style={styles.refreshButton}
|
||||
accessibilityLabel={usageCopy.refresh}
|
||||
testID="usage-refresh"
|
||||
>
|
||||
{isPending ? usageCopy.refreshing : usageCopy.refresh}
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function UsagePillContent({ pill, highlighted }: { pill: UsagePill; highlighted: boolean }) {
|
||||
return (
|
||||
<>
|
||||
@@ -82,5 +110,20 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
popover: {
|
||||
padding: theme.spacing[3],
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
refreshRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
// The ghost button's padding hangs past the card's right rail so its label lands on it.
|
||||
refreshButton: {
|
||||
marginRight: -theme.spacing[3],
|
||||
},
|
||||
refreshError: {
|
||||
flex: 1,
|
||||
color: theme.colors.palette.red[300],
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -5,6 +5,7 @@ export const usageCopy = {
|
||||
planUsage: "Plan usage",
|
||||
refresh: "Refresh",
|
||||
refreshing: "Refreshing...",
|
||||
refreshFailed: "Unable to refresh usage",
|
||||
updated: "Updated",
|
||||
loading: "Loading usage...",
|
||||
empty: "No usage data",
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
groupUsageByHost,
|
||||
headlineWindow,
|
||||
resolveUsagePill,
|
||||
resolveUsageRefresh,
|
||||
resolveUsageView,
|
||||
type UsageQueryState,
|
||||
} from "./model";
|
||||
@@ -179,3 +180,17 @@ describe("formatUsageFreshness", () => {
|
||||
expect(formatUsageFreshness("Jan 15")).toBe("Updated Jan 15");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveUsageRefresh", () => {
|
||||
it("is pending while a refresh runs, even after an earlier failure", () => {
|
||||
expect(resolveUsageRefresh({ isPending: true, error: new Error("boom") })).toBe("pending");
|
||||
});
|
||||
|
||||
it("is failed after a refresh errors", () => {
|
||||
expect(resolveUsageRefresh({ isPending: false, error: new Error("boom") })).toBe("failed");
|
||||
});
|
||||
|
||||
it("is idle before and after a successful refresh", () => {
|
||||
expect(resolveUsageRefresh({ isPending: false, error: null })).toBe("idle");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,6 +43,18 @@ export function formatUsageFreshness(compactTimeAgo: string): string {
|
||||
return `${usageCopy.updated} ${formatCompactTimeAgoAsProse(compactTimeAgo)}`;
|
||||
}
|
||||
|
||||
/** 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";
|
||||
}
|
||||
|
||||
export interface UsageQueryState {
|
||||
data: UsageReportEntry[] | undefined;
|
||||
error: unknown;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useQueryClient, type QueryClient } from "@tanstack/react-query";
|
||||
import { useMutation, useQueryClient, type QueryClient } from "@tanstack/react-query";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import { useFetchQueries, useFetchQuery } from "@/data/query";
|
||||
import {
|
||||
@@ -13,10 +13,12 @@ import { usageCopy } from "./copy";
|
||||
import {
|
||||
groupUsageByHost,
|
||||
resolveUsagePill,
|
||||
resolveUsageRefresh,
|
||||
resolveUsageView,
|
||||
type UsageHostGroup,
|
||||
type UsagePill,
|
||||
type UsageQueryState,
|
||||
type UsageRefresh,
|
||||
} from "./model";
|
||||
import type { UsageReportEntry, UsageView } from "./types";
|
||||
|
||||
@@ -156,14 +158,20 @@ export function useUsageByHost(): {
|
||||
|
||||
/**
|
||||
* The usage report for the account an agent is spending. The daemon resolves the
|
||||
* account at fetch time, so the query refetches when the model changes (a new key)
|
||||
* and when a turn completes (the query is paused while the agent runs, and its
|
||||
* data is always stale, so resuming refetches).
|
||||
* report ID, which is re-resolved when the model changes (a new key) and when a
|
||||
* turn completes (the query is paused while the agent runs, and its data is always
|
||||
* stale, so resuming refetches). The report itself is read from the shared cache.
|
||||
* `refresh` is the only path that forces the source to fetch, and only this report.
|
||||
*/
|
||||
export function useAgentUsage(
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
): { pill: UsagePill | null; entry: UsageReportEntry | null; refresh: () => void } {
|
||||
): {
|
||||
pill: UsagePill | null;
|
||||
entry: UsageReportEntry | null;
|
||||
refresh: () => void;
|
||||
refreshState: UsageRefresh;
|
||||
} {
|
||||
const queryClient = useQueryClient();
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const isSupported = useSessionStore((state) => supportsUsage(state.sessions[serverId]));
|
||||
@@ -189,16 +197,19 @@ export function useAgentUsage(
|
||||
dataShape: "value",
|
||||
staleTimeMs: REPORTS_STALE_TIME_MS,
|
||||
});
|
||||
const refreshMutation = useMutation({
|
||||
mutationFn: (id: string) => getReport(serverId, id, true),
|
||||
onSuccess: (report, id) => queryClient.setQueryData(reportQueryKey(serverId, id), report),
|
||||
});
|
||||
const { mutate } = refreshMutation;
|
||||
const refresh = useCallback(() => {
|
||||
if (!reportId) return;
|
||||
void queryClient
|
||||
.fetchQuery({
|
||||
queryKey: reportQueryKey(serverId, reportId),
|
||||
queryFn: () => getReport(serverId, reportId, true),
|
||||
staleTime: 0,
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, [queryClient, reportId, serverId]);
|
||||
if (reportId) mutate(reportId);
|
||||
}, [mutate, reportId]);
|
||||
const entry = isSupported ? (query.data ?? null) : null;
|
||||
return { pill: resolveUsagePill({ supportsUsage: isSupported, entry }), entry, refresh };
|
||||
return {
|
||||
pill: resolveUsagePill({ supportsUsage: isSupported, entry }),
|
||||
entry,
|
||||
refresh,
|
||||
refreshState: resolveUsageRefresh(refreshMutation),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user