fix(app): show loaded usage in the context popover's first frame

The agent's usage query used gcTime 0, so every close dropped its reports
and every open started from the loading state. The drop existed because an
agent resumed under another login keeps its ID. The daemon now projects an
opaque usageScopeKey that follows the harness session and model, the query
key includes it, and reports are retained for as long as the scope holds.

Hover cards placed above their trigger now hold their bottom edge. Anchored
by top, a card that grew painted past the trigger until onLayout corrected
it on a later frame.
This commit is contained in:
Mohamed Boudra
2026-11-09 16:31:18 +01:00
parent 0fa7f840b4
commit 453df213f2
10 changed files with 253 additions and 22 deletions
@@ -21,6 +21,7 @@ import {
openAgent,
pressContextWindowMeter,
qaScreenshot,
recordContextWindowFrames,
refreshUsageCard,
reloadAgent,
scriptAgentUsage,
@@ -299,9 +300,10 @@ for (const theme of ["light", "dark"] as const) {
const slowSource = gate();
usage.answerNext({ stream: [slowSource.promise, onWorkLogin(claude!)] });
const closedEarly = await hoverContextWindowMeter(page);
await expect(closedEarly.getByText("Loading usage...", { exact: true })).toBeVisible();
await expect(usageCard(closedEarly, "claude:work").getByText(LOGIN_EXPIRED)).toBeVisible();
await leaveContextWindowMeter(page);
await usage.resumeAgent(agent);
const resumed = gate();
const finished = gate();
usage.answerNext({
@@ -385,6 +387,58 @@ for (const theme of ["light", "dark"] as const) {
});
}
test("an open with loaded usage paints it in its first frame and never moves", async ({
page,
agent,
}) => {
const [claude] = claudeAndCodexReports();
const usage = await scriptAgentUsage(page);
await page.setViewportSize(DESKTOP);
await openAgent(page, agent);
usage.answerNext([onWorkLogin(claude!)]);
const loaded = await hoverContextWindowMeter(page);
await expect(usageCard(loaded, "claude:work")).toBeVisible();
await leaveContextWindowMeter(page);
// The reopen's own request stays unanswered until the card has been on screen a while.
const refetch = gate();
usage.answerNext({ stream: [refetch.promise, onWorkLogin(claude!)] });
const recording = await recordContextWindowFrames(page);
const reopened = await hoverContextWindowMeter(page);
await page.waitForTimeout(300);
refetch.open();
await page.waitForTimeout(300);
await expect(usageCard(reopened, "claude:work")).toBeVisible();
const frames = (await recording.stop()).filter((frame) => frame.mounted);
await leaveContextWindowMeter(page);
expect(frames.filter((frame) => frame.loading)).toEqual([]);
expect(frames.filter((frame) => frame.usageCards !== 1)).toEqual([]);
expect(new Set(frames.map((frame) => frame.bottom)).size).toBe(1);
});
test("usage arriving after the card opens never moves its bottom edge", async ({ page, agent }) => {
const [claude] = claudeAndCodexReports();
const usage = await scriptAgentUsage(page);
await page.setViewportSize(DESKTOP);
await openAgent(page, agent);
const arrival = gate();
usage.answerNext({ stream: [arrival.promise, onWorkLogin(claude!)] });
const recording = await recordContextWindowFrames(page);
const card = await hoverContextWindowMeter(page);
await expect(card.getByText("Loading usage...", { exact: true })).toBeVisible();
arrival.open();
await expect(usageCard(card, "claude:work")).toBeVisible();
await page.waitForTimeout(300);
const frames = (await recording.stop()).filter((frame) => frame.mounted);
await leaveContextWindowMeter(page);
expect(frames.some((frame) => frame.loading)).toBe(true);
expect(frames.at(-1)?.usageCards).toBe(1);
expect(new Set(frames.map((frame) => frame.bottom)).size).toBe(1);
});
test("context details support keyboard entry and dismiss when the viewport moves", async ({
page,
agent,
@@ -49,6 +49,83 @@ export async function moveOntoContextWindowDetails(page: Page, card: Locator): P
await page.mouse.move(meter.x + meter.width / 2, box.y + box.height / 2, { steps: 20 });
}
/** The hover card as one painted frame showed it, or unmounted. */
export interface ContextWindowFrame {
mounted: boolean;
bottom: number | null;
loading: boolean;
usageCards: number;
}
/**
* Reads the hover card as it was painted, every frame, until stopped, so a state that lasted one
* frame is seen. Stop it only after everything has settled.
*/
export async function recordContextWindowFrames(
page: Page,
): Promise<{ stop: () => Promise<ContextWindowFrame[]> }> {
await page.evaluate(() => {
const recorder = window as unknown as {
__contextWindowFrames: ContextWindowFrame[];
__contextWindowRecording: boolean;
};
recorder.__contextWindowFrames = [];
recorder.__contextWindowRecording = true;
const findCard = () =>
document.querySelector<HTMLElement>('[data-testid="context-window-details"]');
const read = (card: HTMLElement | null): ContextWindowFrame => ({
mounted: card !== null,
bottom: card?.getBoundingClientRect().bottom ?? null,
loading: card?.innerText.includes("Loading usage...") ?? false,
usageCards: card?.querySelectorAll('[data-testid^="usage-report-"]').length ?? 0,
});
// A card that just resized has not been painted yet: its frame is read from an observer,
// which runs before that paint.
let watched: HTMLElement | null = null;
let paintedHeight: number | null = null;
let observer: ResizeObserver | null = null;
const watch = (card: HTMLElement) => {
observer = new ResizeObserver(() => {
paintedHeight = card.offsetHeight;
recorder.__contextWindowFrames.push(read(card));
});
observer.observe(card);
};
const channel = new MessageChannel();
// A message posted from an animation frame runs after that frame's paint.
channel.port1.addEventListener("message", () => {
const card = findCard();
if (card !== watched) {
observer?.disconnect();
watched = card;
paintedHeight = null;
if (card) watch(card);
}
if (!card || card.offsetHeight === paintedHeight) {
recorder.__contextWindowFrames.push(read(card));
}
});
channel.port1.start();
const sample = () => {
if (!recorder.__contextWindowRecording) return;
channel.port2.postMessage(null);
requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
});
return {
stop: () =>
page.evaluate(() => {
const recorder = window as unknown as {
__contextWindowFrames: ContextWindowFrame[];
__contextWindowRecording: boolean;
};
recorder.__contextWindowRecording = false;
return recorder.__contextWindowFrames;
}),
};
}
export async function leaveContextWindowMeter(page: Page): Promise<void> {
await page.mouse.move(0, 0);
await expect(contextWindowDetails(page)).toHaveCount(0);
@@ -139,6 +216,8 @@ export interface AgentUsageScript {
agentRequests(): string[];
/** Each scoped agent Refresh, by agent ID. */
refreshedAgents(): string[];
/** Reloads the agent's session, which starts it under a new usage scope, as a resume does. */
resumeAgent(agent: MockAgentSession): Promise<void>;
}
/**
@@ -169,6 +248,8 @@ export async function scriptAgentUsage(page: Page): Promise<AgentUsageScript> {
usage
.listRequests()
.flatMap((request) => (request.forceRefresh && request.agentId ? [request.agentId] : [])),
resumeAgent: (agent) =>
usage.untilUsageScopeChanges(agent.agentId, () => agent.client.refreshAgent(agent.agentId)),
};
}
@@ -11,6 +11,8 @@ export interface UsageListRequest {
export interface UsageReportsFixture {
listRequests(): UsageListRequest[];
waitForListRequests(count: number): Promise<void>;
/** Runs `action`, then waits until the app has received the agent's next usage scope. */
untilUsageScopeChanges(agentId: string, action: () => Promise<unknown>): Promise<void>;
}
interface UsageReportsFixtureOptions {
@@ -97,6 +99,26 @@ function withUsageSupportFeature(
});
}
function agentUsageScope(message: WebSocketMessage): { agentId: string; key: string } | null {
const envelope = parseJson(message) as {
type?: unknown;
message?: {
type?: unknown;
payload?: { kind?: unknown; agent?: { id?: unknown; usageScopeKey?: unknown } };
};
} | null;
const agent = envelope?.message?.payload?.agent;
if (
envelope?.message?.type !== "agent_update" ||
envelope.message.payload?.kind !== "upsert" ||
typeof agent?.id !== "string" ||
typeof agent.usageScopeKey !== "string"
) {
return null;
}
return { agentId: agent.id, key: agent.usageScopeKey };
}
function pick<T>(values: T[], index: number): T {
const value = values[Math.min(index, values.length - 1)];
if (value === undefined) throw new Error("Usage fixture requires at least one response.");
@@ -127,6 +149,16 @@ export async function installUsageReportsFixture(
): Promise<UsageReportsFixture> {
const listRequests: UsageListRequest[] = [];
const listCounter = createCounter();
const usageScopes = new Map<string, string>();
const usageScopeChanges = new Map<string, ReturnType<typeof createCounter>>();
const usageScopeCounts = new Map<string, number>();
const usageScopeCounter = (agentId: string) => {
const existing = usageScopeChanges.get(agentId);
if (existing) return existing;
const created = createCounter();
usageScopeChanges.set(agentId, created);
return created;
};
const usageSupported = options.usageSupported ?? true;
const isUsageSupported = () =>
typeof usageSupported === "function" ? usageSupported() : usageSupported;
@@ -239,6 +271,15 @@ export async function installUsageReportsFixture(
options.usageSettingSupported ?? true,
)
: null;
const scope = agentUsageScope(message);
if (scope && usageScopes.get(scope.agentId) !== scope.key) {
usageScopes.set(scope.agentId, scope.key);
usageScopeCounts.set(scope.agentId, (usageScopeCounts.get(scope.agentId) ?? 0) + 1);
// The app must hold the new scope before a test acts on it.
ws.send(serverInfo ?? message);
usageScopeCounter(scope.agentId).increment();
return;
}
ws.send(serverInfo ?? message);
});
});
@@ -246,5 +287,10 @@ export async function installUsageReportsFixture(
return {
listRequests: () => [...listRequests],
waitForListRequests: (count) => listCounter.waitFor(count),
untilUsageScopeChanges: async (agentId, action) => {
const next = (usageScopeCounts.get(agentId) ?? 0) + 1;
await action();
await usageScopeCounter(agentId).waitFor(next);
},
};
}
+21 -10
View File
@@ -322,6 +322,17 @@ export function HoverCardContent(props: HoverCardContentProps): ReactElement | n
);
}
// A card above its trigger holds its bottom edge, so content that loads while it is open grows it
// upward without a correction. Anchored by its top, it would paint over the trigger until placed.
type CardPosition = { left: number; top: number } | { left: number; bottom: number };
function isSamePosition(current: CardPosition | null, next: CardPosition): boolean {
if (!current || current.left !== next.left) return false;
return "top" in current
? "top" in next && current.top === next.top
: "bottom" in next && current.bottom === next.bottom;
}
function readDomRect(view: View | null): Rect | null {
const element = view as unknown as HTMLElement | null;
if (!element) return null;
@@ -349,7 +360,7 @@ function HoverCardSurface({
contentRef: RefObject<View | null>;
}): ReactElement {
const overlayRef = useRef<View>(null);
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
const [position, setPosition] = useState<CardPosition | null>(null);
const place = useCallback(() => {
const triggerRect = readDomRect(triggerRef.current);
@@ -357,7 +368,7 @@ function HoverCardSurface({
const content = contentRef.current as unknown as HTMLElement | null;
if (!triggerRect || !overlayRect || !content) return;
const { width, height } = Dimensions.get("window");
const { x, y } = computePosition({
const { x, y, actualPlacement } = computePosition({
triggerRect,
contentSize: { width: content.offsetWidth, height: content.offsetHeight },
displayArea: { x: 0, y: 0, width, height },
@@ -366,19 +377,19 @@ function HoverCardSurface({
alignment,
offset,
});
const next = { x: x - overlayRect.x, y: y - overlayRect.y };
setPosition((current) => (current?.x === next.x && current.y === next.y ? current : next));
const left = x - overlayRect.x;
const next: CardPosition =
actualPlacement === "top"
? { left, bottom: overlayRect.height - (y + content.offsetHeight - overlayRect.y) }
: { left, top: y - overlayRect.y };
setPosition((current) => (isSamePosition(current, next) ? current : next));
}, [alignment, contentRef, offset, placement, triggerRef]);
useLayoutEffect(place, [place]);
const frameStyle = useMemo(
() => ({
position: "absolute" as const,
top: position?.y ?? -9999,
left: position?.x ?? -9999,
}),
[position?.x, position?.y],
() => ({ position: "absolute" as const, ...(position ?? { top: -9999, left: -9999 }) }),
[position],
);
const surfaceStyle = useMemo(() => [styles.surface, style], [style]);
+1
View File
@@ -90,6 +90,7 @@ export interface Agent {
pendingPermissions: AgentPermissionRequest[];
persistence: AgentPersistenceHandle | null;
runtimeInfo?: AgentRuntimeInfo;
usageScopeKey?: string;
lastUsage?: AgentUsage;
lastError?: string | null;
title: string | null;
+21 -11
View File
@@ -47,8 +47,16 @@ function usageReportsQueryKey(serverId: string) {
return [...hostUsageQueryKey(serverId), "reports"] as const;
}
function agentUsageQueryKey(serverId: string, agentId: string) {
return [...hostUsageQueryKey(serverId), "agent", agentId] as const;
function agentUsageQueryKey(serverId: string, agentId: string, usageScopeKey: string | undefined) {
return [...hostUsageQueryKey(serverId), "agent", agentId, usageScopeKey ?? null] as const;
}
function useAgentUsageScopeKey(serverId: string, agentId: string | undefined) {
return useSessionStore((state) =>
agentId === undefined
? undefined
: state.sessions[serverId]?.agents.get(agentId)?.usageScopeKey,
);
}
function requireClient(serverId: string) {
@@ -190,24 +198,25 @@ export function useUsageHostReports({
/**
* The reports of the account an agent runs under, as its meter's tooltip or sheet shows them.
* Fetched while either is mounted, so only while it is open.
* Fetched while either is mounted, so only while it is open. Kept in the cache between opens, so
* the first frame of a reopen shows them while its own request refreshes them.
*/
export function useAgentUsage(serverId: string, agentId: string): AgentUsageView {
const queryClient = useQueryClient();
const canReport = useHostReportsUsage(serverId);
const queryKey = agentUsageQueryKey(serverId, agentId);
const usageScopeKey = useAgentUsageScopeKey(serverId, agentId);
const queryKey = agentUsageQueryKey(serverId, agentId, usageScopeKey);
const query = useFetchQuery({
queryKey,
// Consuming the signal cancels the request when the details close, so a reopen sends a new
// request instead of joining one scoped to the login the agent ran under before.
// Consuming the signal cancels the request when the details close, so a report still on its
// way cannot land under a scope the agent has since left.
queryFn: ({ signal }) => streamReports({ queryClient, queryKey, serverId, agentId, signal }),
enabled: canReport,
// Another agent's reports never stand in while this one's load.
dataShape: "value",
// Dropped as soon as the details close: an agent resumed under another login keeps its ID, so
// reports kept from an earlier open could show the old login. The daemon's cache keeps each
// open's request cheap.
gcTime: 0,
// COMPAT(agentUsageScopeKey): added in v0.15.4, remove after 2027-05-09. Without the key,
// an agent resumed under another login keeps its query, so its reports die with the details.
gcTime: usageScopeKey === undefined ? 0 : Infinity,
// The daemon's errors (an unknown agent) do not heal on retry, and reopening the details
// fetches again; retrying would hold the loading sentence for seconds instead.
retry: false,
@@ -255,13 +264,14 @@ export function useReportRefresh(
agentId?: string,
): { refresh: () => void; refreshState: UsageRefresh } {
const queryClient = useQueryClient();
const usageScopeKey = useAgentUsageScopeKey(serverId, agentId);
const mutation = useMutation({
mutationFn: () => getReport(serverId, reportId, true, agentId),
onSuccess: (report) => {
queryClient.setQueryData<UsageReportEntry[]>(
agentId === undefined
? usageReportsQueryKey(serverId)
: agentUsageQueryKey(serverId, agentId),
: agentUsageQueryKey(serverId, agentId, usageScopeKey),
(reports) => (reports ? replaceReport(reports, reportId, report) : reports),
);
},
@@ -79,6 +79,7 @@ export function projectAgentSnapshot(agent: Agent): AgentSnapshotPayload {
pendingPermissions: agent.pendingPermissions,
persistence: agent.persistence,
...(agent.runtimeInfo ? { runtimeInfo: agent.runtimeInfo } : {}),
...(agent.usageScopeKey ? { usageScopeKey: agent.usageScopeKey } : {}),
...(agent.lastUsage ? { lastUsage: agent.lastUsage } : {}),
...(agent.lastError ? { lastError: agent.lastError } : {}),
title: agent.title,
@@ -126,6 +127,7 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId:
pendingPermissions: snapshot.pendingPermissions ?? [],
persistence: snapshot.persistence ?? null,
runtimeInfo: snapshot.runtimeInfo,
usageScopeKey: snapshot.usageScopeKey,
lastUsage: snapshot.lastUsage,
lastError: snapshot.lastError ?? null,
title: snapshot.title ?? null,
+2
View File
@@ -923,6 +923,8 @@ export const AgentSnapshotPayloadSchema = z.object({
pendingPermissions: z.array(AgentPermissionRequestPayloadSchema),
persistence: AgentPersistenceHandleSchema.nullable(),
runtimeInfo: AgentRuntimeInfoSchema.optional(),
/** Opaque. Changes whenever the accounts this agent reports usage for can change. */
usageScopeKey: z.string().optional(),
lastUsage: AgentUsageSchema.optional(),
lastError: z.string().optional(),
title: z.string().nullable(),
@@ -271,6 +271,19 @@ describe("toAgentPayload", () => {
expect(permissionA.title).toBe("Run command");
});
it("changes the usage scope key when the harness session or its model changes", () => {
function usageScopeKey(usageSession: ReturnType<NonNullable<AgentSession["usageSession"]>>) {
const session = { usageSession: () => usageSession } as unknown as AgentSession;
return toAgentPayload(createManagedAgent({ session })).usageScopeKey;
}
const scope = { provider: "claude", model: "opus", env: {}, sessionKey: "session-a" };
expect(usageScopeKey(scope)).toBe(usageScopeKey({ ...scope }));
expect(usageScopeKey({ ...scope, sessionKey: "session-b" })).not.toBe(usageScopeKey(scope));
expect(usageScopeKey({ ...scope, model: "sonnet" })).not.toBe(usageScopeKey(scope));
expect(usageScopeKey(null)).toBeUndefined();
});
it("omits usage when any numeric usage field is NaN", () => {
const fields = [
"inputTokens",
@@ -114,6 +114,12 @@ export function toStoredAgentRecord(
} satisfies StoredAgentRecord;
}
/** Account discovery depends on the harness session and its model, so the key follows both. */
function projectUsageScopeKey(agent: ManagedAgent): string | undefined {
const usageSession = agent.session?.usageSession?.();
return usageSession ? `${usageSession.sessionKey}:${usageSession.model ?? ""}` : undefined;
}
export function toAgentPayload(
agent: ManagedAgent,
options?: ProjectionOptions,
@@ -162,6 +168,11 @@ export function toAgentPayload(
...visibilityFlags(agent),
};
const usageScopeKey = projectUsageScopeKey(agent);
if (usageScopeKey !== undefined) {
payload.usageScopeKey = usageScopeKey;
}
const usage = sanitizeUsage(agent.lastUsage);
if (usage !== undefined) {
payload.lastUsage = usage;