From 453df213f21204eba4d60129bc4221c5bed29637 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 9 Nov 2026 16:31:18 +0100 Subject: [PATCH] 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. --- .../e2e/browser/context-window-meter.spec.ts | 56 ++++++++++++- .../app/e2e/support/helpers/context-window.ts | 81 +++++++++++++++++++ .../app/e2e/support/helpers/usage-reports.ts | 46 +++++++++++ packages/app/src/components/ui/hover-card.tsx | 31 ++++--- packages/app/src/stores/session-store.ts | 1 + packages/app/src/usage/queries.ts | 32 +++++--- packages/app/src/utils/agent-snapshots.ts | 2 + packages/protocol/src/messages.ts | 2 + .../server/agent/agent-projections.test.ts | 13 +++ .../src/server/agent/agent-projections.ts | 11 +++ 10 files changed, 253 insertions(+), 22 deletions(-) diff --git a/packages/app/e2e/browser/context-window-meter.spec.ts b/packages/app/e2e/browser/context-window-meter.spec.ts index f79b7fd760..82e75b151a 100644 --- a/packages/app/e2e/browser/context-window-meter.spec.ts +++ b/packages/app/e2e/browser/context-window-meter.spec.ts @@ -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, diff --git a/packages/app/e2e/support/helpers/context-window.ts b/packages/app/e2e/support/helpers/context-window.ts index 5320b73b32..67e3ddb029 100644 --- a/packages/app/e2e/support/helpers/context-window.ts +++ b/packages/app/e2e/support/helpers/context-window.ts @@ -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 }> { + await page.evaluate(() => { + const recorder = window as unknown as { + __contextWindowFrames: ContextWindowFrame[]; + __contextWindowRecording: boolean; + }; + recorder.__contextWindowFrames = []; + recorder.__contextWindowRecording = true; + const findCard = () => + document.querySelector('[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 { 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; } /** @@ -169,6 +248,8 @@ export async function scriptAgentUsage(page: Page): Promise { usage .listRequests() .flatMap((request) => (request.forceRefresh && request.agentId ? [request.agentId] : [])), + resumeAgent: (agent) => + usage.untilUsageScopeChanges(agent.agentId, () => agent.client.refreshAgent(agent.agentId)), }; } diff --git a/packages/app/e2e/support/helpers/usage-reports.ts b/packages/app/e2e/support/helpers/usage-reports.ts index bb46e52f95..6f617f70b2 100644 --- a/packages/app/e2e/support/helpers/usage-reports.ts +++ b/packages/app/e2e/support/helpers/usage-reports.ts @@ -11,6 +11,8 @@ export interface UsageListRequest { export interface UsageReportsFixture { listRequests(): UsageListRequest[]; waitForListRequests(count: number): Promise; + /** Runs `action`, then waits until the app has received the agent's next usage scope. */ + untilUsageScopeChanges(agentId: string, action: () => Promise): Promise; } 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(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 { const listRequests: UsageListRequest[] = []; const listCounter = createCounter(); + const usageScopes = new Map(); + const usageScopeChanges = new Map>(); + const usageScopeCounts = new Map(); + 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); + }, }; } diff --git a/packages/app/src/components/ui/hover-card.tsx b/packages/app/src/components/ui/hover-card.tsx index 1ca418a541..ecf4c59414 100644 --- a/packages/app/src/components/ui/hover-card.tsx +++ b/packages/app/src/components/ui/hover-card.tsx @@ -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; }): ReactElement { const overlayRef = useRef(null); - const [position, setPosition] = useState<{ x: number; y: number } | null>(null); + const [position, setPosition] = useState(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]); diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 9cb48d8096..625c3d8c2f 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -90,6 +90,7 @@ export interface Agent { pendingPermissions: AgentPermissionRequest[]; persistence: AgentPersistenceHandle | null; runtimeInfo?: AgentRuntimeInfo; + usageScopeKey?: string; lastUsage?: AgentUsage; lastError?: string | null; title: string | null; diff --git a/packages/app/src/usage/queries.ts b/packages/app/src/usage/queries.ts index bac8bd7ffa..26a7fa62ea 100644 --- a/packages/app/src/usage/queries.ts +++ b/packages/app/src/usage/queries.ts @@ -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( agentId === undefined ? usageReportsQueryKey(serverId) - : agentUsageQueryKey(serverId, agentId), + : agentUsageQueryKey(serverId, agentId, usageScopeKey), (reports) => (reports ? replaceReport(reports, reportId, report) : reports), ); }, diff --git a/packages/app/src/utils/agent-snapshots.ts b/packages/app/src/utils/agent-snapshots.ts index b5f2355d0a..86da628bdf 100644 --- a/packages/app/src/utils/agent-snapshots.ts +++ b/packages/app/src/utils/agent-snapshots.ts @@ -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, diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 1aa01e811e..271d3bab9e 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -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(), diff --git a/packages/server/src/server/agent/agent-projections.test.ts b/packages/server/src/server/agent/agent-projections.test.ts index 366926e4c7..5e4fa77eab 100644 --- a/packages/server/src/server/agent/agent-projections.test.ts +++ b/packages/server/src/server/agent/agent-projections.test.ts @@ -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>) { + 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", diff --git a/packages/server/src/server/agent/agent-projections.ts b/packages/server/src/server/agent/agent-projections.ts index d0258ffd70..09d9461858 100644 --- a/packages/server/src/server/agent/agent-projections.ts +++ b/packages/server/src/server/agent/agent-projections.ts @@ -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;