diff --git a/apps/code/src/main/di/container.ts b/apps/code/src/main/di/container.ts index 04c245598f..e1aeb69d11 100644 --- a/apps/code/src/main/di/container.ts +++ b/apps/code/src/main/di/container.ts @@ -111,7 +111,10 @@ import { DEV_HOST_ACTIONS_SERVICE } from "@posthog/platform/dev-host-actions"; import { DIALOG_SERVICE } from "@posthog/platform/dialog"; import { FILE_ICON_SERVICE } from "@posthog/platform/file-icon"; import { IMAGE_PROCESSOR_SERVICE } from "@posthog/platform/image-processor"; -import { MAIN_WINDOW_SERVICE } from "@posthog/platform/main-window"; +import { + type IMainWindow, + MAIN_WINDOW_SERVICE, +} from "@posthog/platform/main-window"; import { NOTIFIER_SERVICE } from "@posthog/platform/notifier"; import { POWER_MANAGER_SERVICE } from "@posthog/platform/power-manager"; import { SECURE_STORAGE_SERVICE } from "@posthog/platform/secure-storage"; @@ -680,6 +683,8 @@ container.bind(MAIN_UPDATES_SERVICE).toService(UPDATES_SERVICE); container.load(usageMonitorModule); container.bind(USAGE_HOST).toDynamicValue((ctx) => { const agent = () => ctx.get(AGENT_SERVICE); + const mainWindow = () => ctx.get(MAIN_WINDOW_SERVICE); + const windowFocusUnsubscribers = new WeakMap<() => void, () => void>(); return { fetchUsage: () => ctx.get(MAIN_LLM_GATEWAY_SERVICE).fetchUsage(), @@ -688,6 +693,10 @@ container.bind(USAGE_HOST).toDynamicValue((ctx) => { offLlmActivity: (listener: () => void) => agent().off(AgentServiceEvent.LlmActivity, listener), hasActiveSessions: () => agent().hasActiveSessions(), + onWindowFocus: (listener: () => void) => + windowFocusUnsubscribers.set(listener, mainWindow().onFocus(listener)), + offWindowFocus: (listener: () => void) => + windowFocusUnsubscribers.get(listener)?.(), getThresholdsSeen: () => electronUsageThresholdStore.getThresholdsSeen(), setThresholdsSeen: (value: Record) => electronUsageThresholdStore.setThresholdsSeen(value), diff --git a/packages/core/src/usage/identifiers.ts b/packages/core/src/usage/identifiers.ts index 1fee3e5d84..eaaf3638d7 100644 --- a/packages/core/src/usage/identifiers.ts +++ b/packages/core/src/usage/identifiers.ts @@ -12,6 +12,13 @@ export interface UsageHost { offLlmActivity(listener: () => void): void; hasActiveSessions(): boolean; + // Refreshes usage as soon as the user looks at the window again — the only + // in-app signal is LLM activity, so usage consumed elsewhere (the website, + // another device, a teammate) would otherwise sit stale for up to the + // 30-minute backstop while the window was unfocused. + onWindowFocus(listener: () => void): void; + offWindowFocus(listener: () => void): void; + getThresholdsSeen(): Record; setThresholdsSeen(value: Record): void; } diff --git a/packages/core/src/usage/usage-monitor.test.ts b/packages/core/src/usage/usage-monitor.test.ts index 3f91589e94..53f3053196 100644 --- a/packages/core/src/usage/usage-monitor.test.ts +++ b/packages/core/src/usage/usage-monitor.test.ts @@ -7,24 +7,35 @@ import { UsageMonitorService } from "./usage-monitor"; type ActivitySlice = Pick< UsageHost, - "onLlmActivity" | "offLlmActivity" | "hasActiveSessions" + | "onLlmActivity" + | "offLlmActivity" + | "hasActiveSessions" + | "onWindowFocus" + | "offWindowFocus" >; interface MockActivityMonitor extends ActivitySlice { fireLlmActivity(): void; + fireWindowFocus(): void; } function makeActivityMonitor(opts?: { hasActiveSessions?: boolean; }): MockActivityMonitor { const listeners = new Set<() => void>(); + const focusListeners = new Set<() => void>(); return { onLlmActivity: (l) => listeners.add(l), offLlmActivity: (l) => listeners.delete(l), hasActiveSessions: () => opts?.hasActiveSessions ?? false, + onWindowFocus: (l) => focusListeners.add(l), + offWindowFocus: (l) => focusListeners.delete(l), fireLlmActivity: () => { for (const l of [...listeners]) l(); }, + fireWindowFocus: () => { + for (const l of [...focusListeners]) l(); + }, }; } @@ -432,4 +443,32 @@ describe("UsageMonitorService", () => { await vi.advanceTimersByTimeAsync(10_000); expect(gateway.fetchUsage).toHaveBeenCalledTimes(baseline); }); + + it("refreshes when the window regains focus, not just on LLM activity", async () => { + const gateway = mockGateway(makeUsage({ burstPercent: 10 })); + const agent = makeActivityMonitor(); + service = makeService(gateway, agent); + service.init(); + await vi.advanceTimersByTimeAsync(0); + expect(gateway.fetchUsage).toHaveBeenCalledTimes(1); + + agent.fireWindowFocus(); + await vi.advanceTimersByTimeAsync(5_000); + expect(gateway.fetchUsage).toHaveBeenCalledTimes(2); + }); + + it("unsubscribes from window focus events on stop()", async () => { + const gateway = mockGateway(makeUsage({ burstPercent: 10 })); + const agent = makeActivityMonitor(); + service = makeService(gateway, agent); + service.init(); + await vi.advanceTimersByTimeAsync(0); + const baseline = (gateway.fetchUsage as ReturnType).mock.calls + .length; + + service.stop(); + agent.fireWindowFocus(); + await vi.advanceTimersByTimeAsync(10_000); + expect(gateway.fetchUsage).toHaveBeenCalledTimes(baseline); + }); }); diff --git a/packages/core/src/usage/usage-monitor.ts b/packages/core/src/usage/usage-monitor.ts index b394ee512b..08cef3787e 100644 --- a/packages/core/src/usage/usage-monitor.ts +++ b/packages/core/src/usage/usage-monitor.ts @@ -31,6 +31,7 @@ export class UsageMonitorService extends TypedEventEmitter { private latestUsage: UsageOutput | null = null; private readonly onLlmActivity = (): void => this.requestRefresh(); + private readonly onWindowFocus = (): void => this.requestRefresh(); constructor( @inject(USAGE_HOST) @@ -83,6 +84,7 @@ export class UsageMonitorService extends TypedEventEmitter { init(): void { this.pruneStaleEntries(); this.host.onLlmActivity(this.onLlmActivity); + this.host.onWindowFocus(this.onWindowFocus); void this.fetchOnce(); this.scheduleBackstop(); } @@ -90,6 +92,7 @@ export class UsageMonitorService extends TypedEventEmitter { @preDestroy() stop(): void { this.host.offLlmActivity(this.onLlmActivity); + this.host.offWindowFocus(this.onWindowFocus); if (this.backstopTimeoutId) { clearTimeout(this.backstopTimeoutId); this.backstopTimeoutId = null;