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;