mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 00:14:54 +00:00
feat(usage): refresh a report from its card header
Refresh is an icon button at the right edge of every usage card header, in the pill popover and on the Usage screen. It force-refreshes only that report, writes the result to the report's cache and its host list, shows a spinner and disables itself while pending, and prints "Unable to refresh usage" in the card on failure. Its tooltip carries the freshness; without hover the freshness stays in the card footer. The status indicator sits just left of the button, and the popover's separate refresh row is gone.
This commit is contained in:
@@ -63,7 +63,7 @@ test.describe("usage composer pill", () => {
|
||||
await expect(popover.getByText("Test plan")).toBeVisible();
|
||||
await expect(popover.getByText("42%")).toBeVisible();
|
||||
|
||||
await popover.getByTestId("usage-card-header").hover();
|
||||
await popover.getByTestId("usage-refresh").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);
|
||||
@@ -94,9 +94,11 @@ test.describe("usage composer pill", () => {
|
||||
]);
|
||||
await expect(popover.getByText("64%")).toBeVisible();
|
||||
await expect(pill).toContainText("64%");
|
||||
await expect(popover.getByTestId("usage-refresh")).toHaveText("Refresh");
|
||||
await expect(popover.getByTestId("usage-refresh")).toBeEnabled();
|
||||
|
||||
await popover.getByTestId("usage-card-header").hover();
|
||||
// Pressing closes the tooltip; it reopens on the next hover.
|
||||
await popover.getByText("Fixture plan", { exact: true }).hover();
|
||||
await popover.getByTestId("usage-refresh").hover();
|
||||
await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated just now");
|
||||
expect(usage.listRequests()).toHaveLength(2);
|
||||
} finally {
|
||||
@@ -133,7 +135,7 @@ test.describe("usage composer pill", () => {
|
||||
await refresh.click();
|
||||
await usage.waitForListRequests(3);
|
||||
await expect(popover.getByText("64%")).toBeVisible();
|
||||
await expect(popover.getByTestId("usage-refresh-error")).toHaveText("");
|
||||
await expect(popover.getByTestId("usage-refresh-error")).toHaveCount(0);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
@@ -49,11 +50,67 @@ test.describe("usage screen", () => {
|
||||
await expect(group.getByText("Beta plan", { exact: true })).toBeVisible();
|
||||
await expect(group.getByText("Unavailable", { exact: true })).toBeVisible();
|
||||
|
||||
await group.getByTestId("usage-card-header").first().hover();
|
||||
await group.getByTestId("usage-refresh").first().hover();
|
||||
await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated 2h ago");
|
||||
await expect(group.getByTestId("usage-freshness")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("refreshes one report from its card", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
const beta: UsageReportEntry = {
|
||||
id: "beta:b",
|
||||
account: {},
|
||||
fetchedAt: twoHoursAgo(),
|
||||
sourceId: "beta",
|
||||
sourceLabel: "Beta plan",
|
||||
report: {
|
||||
status: "available",
|
||||
windows: [{ id: "weekly", label: "Weekly", usedPct: 12, headline: true }],
|
||||
},
|
||||
};
|
||||
const alpha = (usedPct: number, fetchedAt: string): UsageReportEntry => ({
|
||||
id: "alpha:a",
|
||||
account: {},
|
||||
fetchedAt,
|
||||
sourceId: "alpha",
|
||||
sourceLabel: "Alpha plan",
|
||||
report: {
|
||||
status: "available",
|
||||
windows: [{ id: "weekly", label: "Weekly", usedPct, headline: true }],
|
||||
},
|
||||
});
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
lists: [
|
||||
[alpha(31, twoHoursAgo()), beta],
|
||||
() => [
|
||||
alpha(58, new Date().toISOString()),
|
||||
{ ...beta, fetchedAt: new Date().toISOString() },
|
||||
],
|
||||
],
|
||||
});
|
||||
|
||||
await gotoAppShell(page);
|
||||
await page.locator('[data-testid="sidebar-usage"]:visible').first().click();
|
||||
const group = page.getByTestId(`usage-host-${serverId}`);
|
||||
await expect(group.getByText("31%")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const alphaRefresh = group.getByTestId("usage-refresh").first();
|
||||
await alphaRefresh.click();
|
||||
await usage.waitForListRequests(2);
|
||||
expect(usage.listRequests()).toEqual([
|
||||
{ forceRefresh: false, reportIds: undefined },
|
||||
{ forceRefresh: true, reportIds: ["alpha:a"] },
|
||||
]);
|
||||
await expect(group.getByText("58%")).toBeVisible();
|
||||
await expect(group.getByText("12%")).toBeVisible();
|
||||
|
||||
await group.getByTestId("usage-refresh").nth(1).hover();
|
||||
await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated 2h ago");
|
||||
await alphaRefresh.hover();
|
||||
await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated just now");
|
||||
});
|
||||
|
||||
test("shows the host once it connects after a cold load on a phone", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
|
||||
+119
-28
@@ -1,13 +1,22 @@
|
||||
import { RefreshCw } from "lucide-react-native";
|
||||
import { useMemo } from "react";
|
||||
import { Text, View, type StyleProp, type TextStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
iconButtonChromeGlyphSize,
|
||||
mutedIconColorMapping,
|
||||
smallIconButtonChromeFrameSize,
|
||||
} from "@/components/ui/icon-button-chrome";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { ToolbarButton } from "@/components/ui/pane-content-toolbar";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { useCompactTimeAgo } from "@/hooks/use-compact-time-ago";
|
||||
import { UsageBalanceBar } from "./balance-bar";
|
||||
import { formatUsageFreshness } from "./model";
|
||||
import { usageCopy } from "./copy";
|
||||
import { formatUsageFreshness, type UsageRefresh } from "./model";
|
||||
import { useReportRefresh } from "./queries";
|
||||
import { UsageSourceIcon } from "./source-icon";
|
||||
import type { UsageReport, UsageReportEntry } from "./types";
|
||||
import { UsageWindowBar } from "./window-bar";
|
||||
@@ -17,15 +26,21 @@ function statusText(report: UsageReport): string | null {
|
||||
return report.status === "error" ? "Error" : "Unavailable";
|
||||
}
|
||||
|
||||
const ThemedRefreshIcon = withUnistyles(RefreshCw);
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
|
||||
export function UsageCard({
|
||||
serverId,
|
||||
entry,
|
||||
compact = false,
|
||||
}: {
|
||||
serverId: string;
|
||||
entry: UsageReportEntry;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
// Where there is no hover the freshness is printed on the card; elsewhere the header's tooltip.
|
||||
const { refresh, refreshState } = useReportRefresh(serverId, entry.id);
|
||||
// Where there is no hover the freshness is printed on the card; elsewhere the Refresh tooltip.
|
||||
const showsFreshnessInline = isNative || isCompact;
|
||||
const usage = entry.report;
|
||||
const status = statusText(usage);
|
||||
@@ -48,25 +63,27 @@ export function UsageCard({
|
||||
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<Tooltip delayDuration={300} enabledOnDesktop={!isNative}>
|
||||
<TooltipTrigger style={styles.header} accessible={false} testID="usage-card-header">
|
||||
<UsageSourceIcon svg={entry.icon ?? null} size={14} />
|
||||
<Text style={styles.name} numberOfLines={1}>
|
||||
{entry.sourceLabel}
|
||||
</Text>
|
||||
{usage.planLabel ? <StatusBadge label={usage.planLabel} variant="muted" /> : null}
|
||||
<View style={styles.headerSpacer} />
|
||||
{status ? (
|
||||
<View style={styles.statusRow}>
|
||||
<View style={dotStyle} />
|
||||
<Text style={styles.statusLabel}>{status}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="start" testID="usage-freshness-tooltip">
|
||||
<UsageFreshness fetchedAt={entry.fetchedAt} style={styles.tooltipText} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<View style={styles.header}>
|
||||
<UsageSourceIcon svg={entry.icon ?? null} size={14} />
|
||||
<Text style={styles.name} numberOfLines={1}>
|
||||
{entry.sourceLabel}
|
||||
</Text>
|
||||
{usage.planLabel ? <StatusBadge label={usage.planLabel} variant="muted" /> : null}
|
||||
<View style={styles.headerSpacer} />
|
||||
{status ? (
|
||||
<View style={styles.statusRow}>
|
||||
<View style={dotStyle} />
|
||||
<Text style={styles.statusLabel}>{status}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
<UsageRefreshButton
|
||||
sourceLabel={entry.sourceLabel}
|
||||
fetchedAt={entry.fetchedAt}
|
||||
refreshState={refreshState}
|
||||
onRefresh={refresh}
|
||||
compact={isCompact}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{usage.error ? (
|
||||
<Text style={styles.error} numberOfLines={3}>
|
||||
@@ -106,24 +123,96 @@ export function UsageCard({
|
||||
{footer}
|
||||
</Text>
|
||||
{showsFreshnessInline ? (
|
||||
<UsageFreshness fetchedAt={entry.fetchedAt} style={styles.freshness} />
|
||||
<UsageFreshness
|
||||
fetchedAt={entry.fetchedAt}
|
||||
style={styles.freshness}
|
||||
testID="usage-freshness"
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{refreshState === "failed" ? (
|
||||
<Text style={styles.error} testID="usage-refresh-error">
|
||||
{usageCopy.refreshFailed}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/** Refreshes this one report. Its tooltip says when the report on screen was fetched. */
|
||||
function UsageRefreshButton({
|
||||
sourceLabel,
|
||||
fetchedAt,
|
||||
refreshState,
|
||||
onRefresh,
|
||||
compact,
|
||||
}: {
|
||||
sourceLabel: string;
|
||||
fetchedAt: string;
|
||||
refreshState: UsageRefresh;
|
||||
onRefresh: () => void;
|
||||
compact: boolean;
|
||||
}) {
|
||||
const isPending = refreshState === "pending";
|
||||
const iconSize = iconButtonChromeGlyphSize("small", compact);
|
||||
const freshness = useMemo(
|
||||
() => (
|
||||
<UsageFreshness
|
||||
fetchedAt={fetchedAt}
|
||||
style={styles.tooltipText}
|
||||
testID="usage-freshness-tooltip"
|
||||
/>
|
||||
),
|
||||
[fetchedAt],
|
||||
);
|
||||
return (
|
||||
<ToolbarButton
|
||||
label={`${usageCopy.refresh} ${sourceLabel}`}
|
||||
tooltip={freshness}
|
||||
tooltipSide="top"
|
||||
compact={compact}
|
||||
disabled={isPending}
|
||||
onPress={onRefresh}
|
||||
style={compact ? styles.refreshButtonCompact : styles.refreshButton}
|
||||
testID="usage-refresh"
|
||||
>
|
||||
{isPending ? (
|
||||
<ThemedLoadingSpinner size={iconSize} uniProps={mutedIconColorMapping} />
|
||||
) : (
|
||||
<ThemedRefreshIcon size={iconSize} uniProps={mutedIconColorMapping} />
|
||||
)}
|
||||
</ToolbarButton>
|
||||
);
|
||||
}
|
||||
|
||||
/** Its own component so the relative-time clock re-renders one `<Text>`, not the card. */
|
||||
function UsageFreshness({ fetchedAt, style }: { fetchedAt: string; style: StyleProp<TextStyle> }) {
|
||||
function UsageFreshness({
|
||||
fetchedAt,
|
||||
style,
|
||||
testID,
|
||||
}: {
|
||||
fetchedAt: string;
|
||||
style: StyleProp<TextStyle>;
|
||||
testID: string;
|
||||
}) {
|
||||
const elapsed = useCompactTimeAgo(new Date(fetchedAt));
|
||||
return (
|
||||
<Text style={style} numberOfLines={1} testID="usage-freshness">
|
||||
<Text style={style} numberOfLines={1} testID={testID}>
|
||||
{formatUsageFreshness(elapsed)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
// The Refresh glyph lands on the card's right rail and the header keeps its text height;
|
||||
// the button's larger hitbox overhangs both instead of pushing them.
|
||||
function iconHitboxOverhang(compact: boolean) {
|
||||
const overhang =
|
||||
(smallIconButtonChromeFrameSize(compact) - iconButtonChromeGlyphSize("small", compact)) / 2;
|
||||
return { marginRight: -overhang, marginVertical: -overhang };
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
gap: theme.spacing[3],
|
||||
@@ -211,7 +300,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
tooltipText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
color: theme.colors.popoverForeground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
refreshButton: iconHitboxOverhang(false),
|
||||
refreshButtonCompact: iconHitboxOverhang(true),
|
||||
}));
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
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,
|
||||
@@ -15,7 +13,7 @@ import {
|
||||
} from "@/components/ui/toolbar-label-trigger";
|
||||
import { UsageCard } from "./card";
|
||||
import { usageCopy } from "./copy";
|
||||
import type { UsagePill, UsageRefresh } from "./model";
|
||||
import type { UsagePill } from "./model";
|
||||
import { useAgentUsage } from "./queries";
|
||||
import { UsageSourceIcon } from "./source-icon";
|
||||
|
||||
@@ -25,10 +23,10 @@ function pillTriggerStyle(state: MenuTriggerState) {
|
||||
|
||||
/**
|
||||
* The agent's plan usage beside the composer controls. Opens the cached usage card;
|
||||
* only Refresh asks the source for a new report.
|
||||
* only the card's Refresh asks the source for a new report.
|
||||
*/
|
||||
export function UsageComposerPill({ serverId, agentId }: { serverId: string; agentId: string }) {
|
||||
const { pill, entry, refresh, refreshState } = useAgentUsage(serverId, agentId);
|
||||
const { pill, entry } = useAgentUsage(serverId, agentId);
|
||||
|
||||
if (!pill || !entry) return null;
|
||||
|
||||
@@ -53,43 +51,13 @@ export function UsageComposerPill({ serverId, agentId }: { serverId: string; age
|
||||
sheetTitle={usageCopy.planUsage}
|
||||
>
|
||||
<View style={styles.popover}>
|
||||
<UsageCard entry={entry} compact />
|
||||
<UsageRefreshRow refreshState={refreshState} onRefresh={refresh} />
|
||||
<UsageCard serverId={serverId} entry={entry} compact />
|
||||
</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 (
|
||||
<>
|
||||
@@ -110,20 +78,5 @@ 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,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -6,6 +6,12 @@ import { UsageSection } from "./usage-section";
|
||||
export function HostUsageSection({ serverId }: { serverId: string }) {
|
||||
const { view, refresh } = useHostUsage(serverId);
|
||||
return (
|
||||
<UsageSection title={usageCopy.planUsage} view={view} onRefresh={refresh} testID="usage-card" />
|
||||
<UsageSection
|
||||
serverId={serverId}
|
||||
title={usageCopy.planUsage}
|
||||
view={view}
|
||||
onRefresh={refresh}
|
||||
testID="usage-card"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,13 +5,19 @@ import { settingsStyles } from "@/styles/settings";
|
||||
import { UsageCard } from "./card";
|
||||
import type { UsageReportEntry } from "./types";
|
||||
|
||||
export function UsageList({ reports }: { reports: UsageReportEntry[] }) {
|
||||
export function UsageList({
|
||||
serverId,
|
||||
reports,
|
||||
}: {
|
||||
serverId: string;
|
||||
reports: UsageReportEntry[];
|
||||
}) {
|
||||
return (
|
||||
<View style={settingsStyles.card}>
|
||||
{reports.map((entry, index) => (
|
||||
<Fragment key={entry.id}>
|
||||
{index > 0 ? <View style={styles.divider} /> : null}
|
||||
<UsageCard entry={entry} />
|
||||
<UsageCard serverId={serverId} entry={entry} />
|
||||
</Fragment>
|
||||
))}
|
||||
</View>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatUsageFreshness,
|
||||
groupUsageByHost,
|
||||
replaceReport,
|
||||
headlineWindow,
|
||||
resolveUsagePill,
|
||||
resolveUsageRefresh,
|
||||
@@ -194,3 +195,17 @@ describe("resolveUsageRefresh", () => {
|
||||
expect(resolveUsageRefresh({ isPending: false, error: null })).toBe("idle");
|
||||
});
|
||||
});
|
||||
|
||||
describe("replaceReport", () => {
|
||||
const alpha = entry({ sourceId: "alpha", planLabel: "Old" });
|
||||
const beta = entry({ sourceId: "beta" });
|
||||
|
||||
it("swaps only the refreshed report, in place", () => {
|
||||
const refreshed = entry({ sourceId: "alpha", planLabel: "New" });
|
||||
expect(replaceReport([alpha, beta], alpha.id, refreshed)).toEqual([refreshed, beta]);
|
||||
});
|
||||
|
||||
it("drops a report the daemon no longer knows", () => {
|
||||
expect(replaceReport([alpha, beta], alpha.id, null)).toEqual([beta]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,6 +55,19 @@ export function resolveUsageRefresh(mutation: {
|
||||
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));
|
||||
}
|
||||
|
||||
export interface UsageQueryState {
|
||||
data: UsageReportEntry[] | undefined;
|
||||
error: unknown;
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useSessionStore, type SessionState } from "@/stores/session-store";
|
||||
import { usageCopy } from "./copy";
|
||||
import {
|
||||
groupUsageByHost,
|
||||
replaceReport,
|
||||
resolveUsagePill,
|
||||
resolveUsageRefresh,
|
||||
resolveUsageView,
|
||||
@@ -161,18 +162,11 @@ export function useUsageByHost(): {
|
||||
* 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;
|
||||
refreshState: UsageRefresh;
|
||||
} {
|
||||
const queryClient = useQueryClient();
|
||||
): { pill: UsagePill | null; entry: UsageReportEntry | null } {
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const isSupported = useSessionStore((state) => supportsUsage(state.sessions[serverId]));
|
||||
const { model, isRunning } = useSessionStore(
|
||||
@@ -197,19 +191,30 @@ 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) mutate(reportId);
|
||||
}, [mutate, reportId]);
|
||||
const entry = isSupported ? (query.data ?? null) : null;
|
||||
return {
|
||||
pill: resolveUsagePill({ supportsUsage: isSupported, entry }),
|
||||
entry,
|
||||
refresh,
|
||||
refreshState: resolveUsageRefresh(refreshMutation),
|
||||
};
|
||||
return { pill: resolveUsagePill({ supportsUsage: isSupported, entry }), entry };
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces the source to fetch one report, and only that report. The result replaces
|
||||
* the report wherever it is cached — its own entry and its host's list — so every
|
||||
* surface showing it moves together; until then the previous report stays on screen.
|
||||
*/
|
||||
export function useReportRefresh(
|
||||
serverId: string,
|
||||
reportId: string,
|
||||
): { refresh: () => void; refreshState: UsageRefresh } {
|
||||
const queryClient = useQueryClient();
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => getReport(serverId, reportId, true),
|
||||
onSuccess: (report) => {
|
||||
queryClient.setQueryData(reportQueryKey(serverId, reportId), report);
|
||||
queryClient.setQueryData<UsageReportEntry[]>(usageReportsQueryKey(serverId), (reports) =>
|
||||
reports ? replaceReport(reports, reportId, report) : reports,
|
||||
);
|
||||
},
|
||||
});
|
||||
const { mutate } = mutation;
|
||||
const refresh = useCallback(() => mutate(), [mutate]);
|
||||
return { refresh, refreshState: resolveUsageRefresh(mutation) };
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ function HostUsageGroup({
|
||||
const handleRefresh = useCallback(() => onRefresh(group.serverId), [group.serverId, onRefresh]);
|
||||
return (
|
||||
<UsageSection
|
||||
serverId={group.serverId}
|
||||
title={group.label}
|
||||
view={group.view}
|
||||
onRefresh={handleRefresh}
|
||||
|
||||
@@ -11,11 +11,13 @@ import { UsageList } from "./list";
|
||||
import type { UsageView } from "./types";
|
||||
|
||||
export function UsageSection({
|
||||
serverId,
|
||||
title,
|
||||
view,
|
||||
onRefresh,
|
||||
testID,
|
||||
}: {
|
||||
serverId: string;
|
||||
title: string;
|
||||
view: UsageView;
|
||||
onRefresh: () => void;
|
||||
@@ -42,12 +44,20 @@ export function UsageSection({
|
||||
|
||||
return (
|
||||
<SettingsSection title={title} testID={testID} trailing={refreshButton}>
|
||||
<UsageBody view={view} onRefresh={onRefresh} />
|
||||
<UsageBody serverId={serverId} view={view} onRefresh={onRefresh} />
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function UsageBody({ view, onRefresh }: { view: UsageView; onRefresh: () => void }) {
|
||||
function UsageBody({
|
||||
serverId,
|
||||
view,
|
||||
onRefresh,
|
||||
}: {
|
||||
serverId: string;
|
||||
view: UsageView;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
if (view.kind === "unavailable") {
|
||||
return <UsageMessage text={view.message} />;
|
||||
}
|
||||
@@ -70,7 +80,7 @@ function UsageBody({ view, onRefresh }: { view: UsageView; onRefresh: () => void
|
||||
return <UsageMessage text={usageCopy.empty} />;
|
||||
}
|
||||
|
||||
return <UsageList reports={view.reports} />;
|
||||
return <UsageList serverId={serverId} reports={view.reports} />;
|
||||
}
|
||||
|
||||
export function UsageMessage({ text }: { text: string }) {
|
||||
|
||||
Reference in New Issue
Block a user