diff --git a/entrypoints/claude-ai.content.ts b/entrypoints/claude-ai.content.ts index dc9d3f3..49754fa 100644 --- a/entrypoints/claude-ai.content.ts +++ b/entrypoints/claude-ai.content.ts @@ -2,10 +2,11 @@ // Thin orchestrator: validates bridge messages, drives state transitions, renders overlay. // All logic lives in imported modules; this file only wires them together. -import type { LcoBridgeMessage, StoreTokenBatchMessage, StoreMessageLimitMessage, StoreTokenBatchResponse, RecordTurnMessage, FinalizeConversationMessage, SetActiveConvMessage, StoreUsageLimitsMessage } from '../lib/message-types'; +import type { LcoBridgeMessage, StoreTokenBatchMessage, StoreMessageLimitMessage, StoreTokenBatchResponse, RecordTurnMessage, FinalizeConversationMessage, SetActiveConvMessage, StoreUsageLimitsMessage, UsageLimitsData, UsageBudgetResult } from '../lib/message-types'; import { LCO_NAMESPACE } from '../lib/message-types'; import { isValidBridgeSchema } from '../lib/bridge-validation'; -import { INITIAL_STATE, applyTokenBatch, applyStreamComplete, applyStorageResponse, applyHealthBroken, applyHealthRecovered, applyMessageLimit, applyRestoredConversation, applyDraftEstimate, clearDraftEstimate } from '../lib/overlay-state'; +import { INITIAL_STATE, applyTokenBatch, applyStreamComplete, applyStorageResponse, applyHealthBroken, applyHealthRecovered, applyMessageLimit, applyRestoredConversation, applyDraftEstimate, clearDraftEstimate, applyUsageBudget } from '../lib/overlay-state'; +import { computeUsageBudget } from '../lib/usage-budget'; import { computePreSubmitEstimate } from '../lib/pre-submit'; import { createOverlay } from '../ui/overlay'; import { showEnableBanner } from '../ui/enable-banner'; @@ -59,20 +60,22 @@ async function fetchStoredRecord(orgId: string | null, conversationId: string): /** * Fetch the Anthropic usage limits for this account, forward to background for - * storage, and return the 5-hour session utilization percentage. + * storage, and return a UsageBudgetResult (session + weekly utilization with + * reset metadata), or null on any failure. * * The usage endpoint returns exact session and weekly utilization with reset * timestamps — the same data shown on claude.ai/settings/limits. * * Called on ORGANIZATION_DETECTED (page load) and after each STREAM_COMPLETE. - * The returned utilization value is used for delta tracking: the caller snapshots - * the before-value, calls this, and subtracts to get the exact session cost of - * the last message. + * Callers use result.sessionPct for delta tracking: snapshot the before-value, + * call this, and subtract to get the exact session cost of the last message. + * result.weeklyPct drives the overlay weekly-cap bar and the side panel + * Usage Budget card. * * Returns null on any failure (network error, malformed response). The caller * treats null as "delta uncomputable" and records the turn without a delta. */ -async function fetchAndStoreUsageLimits(orgId: string): Promise { +async function fetchAndStoreUsageLimits(orgId: string): Promise { try { const response = await fetch(`/api/organizations/${orgId}/usage`, { credentials: 'same-origin' }); if (!response.ok) return null; @@ -94,7 +97,13 @@ async function fetchAndStoreUsageLimits(orgId: string): Promise { sevenDayUtilization: sevenDay.utilization, sevenDayResetsAt: sevenDay.resets_at, } satisfies StoreUsageLimitsMessage).catch(() => { /* non-critical */ }); - return fiveHour.utilization; + const capturedAt = Date.now(); + const limits: UsageLimitsData = { + fiveHour: { utilization: fiveHour.utilization, resetsAt: fiveHour.resets_at }, + sevenDay: { utilization: sevenDay.utilization, resetsAt: sevenDay.resets_at }, + capturedAt, + }; + return computeUsageBudget(limits, capturedAt); } catch { // Network errors are silently ignored; the dashboard shows stale data. return null; @@ -281,11 +290,15 @@ async function initializeMonitoring(): Promise { } // Fetch usage limits now that we have the org ID. Populates the - // Usage Budget card in the side panel immediately on load. - // Capture the returned utilization as the initial before-snapshot - // so the first STREAM_COMPLETE can compute a delta. - fetchAndStoreUsageLimits(currentOrgId).then(u => { - if (u !== null) lastKnownUtilization = u; + // Usage Budget card in the side panel and weekly bar on the overlay. + // Capture sessionPct as the initial before-snapshot so the first + // STREAM_COMPLETE can compute a delta. + fetchAndStoreUsageLimits(currentOrgId).then(budget => { + if (budget !== null) { + lastKnownUtilization = budget.sessionPct; + state = applyUsageBudget(state, budget); + overlay.render(state); + } }); // Pre-fetch token economics for the Delta Coach and Prompt Agent. @@ -453,7 +466,8 @@ async function initializeMonitoring(): Promise { // fetchAndStoreUsageLimits catches all errors internally; it never // throws. The .then() always runs. - fetchAndStoreUsageLimits(orgId).then(utilizationAfter => { + fetchAndStoreUsageLimits(orgId).then(budgetAfter => { + const utilizationAfter = budgetAfter?.sessionPct ?? null; // Update the cached value so the next STREAM_COMPLETE has a // fresh before-snapshot. Only update on valid (non-null) reads. if (utilizationAfter !== null) { @@ -472,11 +486,19 @@ async function initializeMonitoring(): Promise { deltaUtilization = utilizationAfter - utilizationBefore; } + // Apply fresh budget to overlay state. Combine with delta update in + // one state object so the render below covers both changes. + if (budgetAfter !== null) { + state = applyUsageBudget(state, budgetAfter); + } + // Update overlay immediately with the exact session cost for // this reply. Re-render so the user sees "X.X% of session" the // moment the usage endpoint responds (typically < 200ms post-stream). if (deltaUtilization !== null) { state = { ...state, lastDeltaUtilization: deltaUtilization }; + } + if (budgetAfter !== null || deltaUtilization !== null) { overlay.render(state); } diff --git a/lib/overlay-state.ts b/lib/overlay-state.ts index e24f777..7989981 100644 --- a/lib/overlay-state.ts +++ b/lib/overlay-state.ts @@ -4,7 +4,7 @@ // The content script calls these and passes the result to overlay.render(). import { calculateCost, getContextWindowSize } from './pricing'; -import type { TabState } from './message-types'; +import type { TabState, UsageBudgetResult } from './message-types'; import type { HealthScore } from './health-score'; import type { ConversationRecord } from './conversation-store'; import type { PreSubmitEstimate } from './pre-submit'; @@ -41,6 +41,12 @@ export interface OverlayState { * Set by the compose box observer or the pre-send fallback in inject.ts. */ draftEstimate: PreSubmitEstimate | null; + /** + * Weekly cap utilization derived from /api/organizations/{orgId}/usage. + * Null until the first successful fetchAndStoreUsageLimits call, or when + * the extension is running outside of claude.ai (no usage endpoint available). + */ + usageBudget: UsageBudgetResult | null; } export const INITIAL_STATE: Readonly = { @@ -53,6 +59,7 @@ export const INITIAL_STATE: Readonly = { health: null, lastDeltaUtilization: null, draftEstimate: null, + usageBudget: null, }; @@ -168,3 +175,8 @@ export function applyDraftEstimate(state: OverlayState, estimate: PreSubmitEstim export function clearDraftEstimate(state: OverlayState): OverlayState { return { ...state, draftEstimate: null }; } + +/** Apply a fresh UsageBudgetResult. Called after every fetchAndStoreUsageLimits call. */ +export function applyUsageBudget(state: OverlayState, budget: UsageBudgetResult): OverlayState { + return { ...state, usageBudget: budget }; +} diff --git a/tests/unit/overlay-state.test.ts b/tests/unit/overlay-state.test.ts index 7094aee..0db0512 100644 --- a/tests/unit/overlay-state.test.ts +++ b/tests/unit/overlay-state.test.ts @@ -10,9 +10,10 @@ import { applyHealthBroken, applyHealthRecovered, applyMessageLimit, + applyUsageBudget, } from '../../lib/overlay-state'; import type { OverlayState } from '../../lib/overlay-state'; -import type { TabState } from '../../lib/message-types'; +import type { TabState, UsageBudgetResult } from '../../lib/message-types'; const MODEL = 'claude-haiku-4-5'; const TOKEN_PAYLOAD = { inputTokens: 1000, outputTokens: 200, model: MODEL }; @@ -265,6 +266,61 @@ describe('lastDeltaUtilization spread semantics', () => { }); }); +// ── applyUsageBudget ────────────────────────────────────────────────────────── + +function makeBudget(weeklyPct: number, zone: UsageBudgetResult['zone'] = 'comfortable'): UsageBudgetResult { + return { + sessionPct: 10, + weeklyPct, + sessionMinutesUntilReset: 120, + weeklyResetLabel: 'Wed 9:00 AM', + zone, + statusLabel: `10% used; resets in 2h`, + }; +} + +describe('applyUsageBudget', () => { + it('sets usageBudget on state', () => { + const budget = makeBudget(71, 'moderate'); + const next = applyUsageBudget(INITIAL_STATE, budget); + expect(next.usageBudget).toBe(budget); + }); + + it('overwrites a previous usageBudget value', () => { + const first = makeBudget(30, 'comfortable'); + const second = makeBudget(85, 'tight'); + const state = applyUsageBudget(INITIAL_STATE, first); + const next = applyUsageBudget(state, second); + expect(next.usageBudget?.weeklyPct).toBe(85); + }); + + it('does not mutate other fields', () => { + const budget = makeBudget(50); + const next = applyUsageBudget(INITIAL_STATE, budget); + expect(next.streaming).toBe(INITIAL_STATE.streaming); + expect(next.lastRequest).toBe(INITIAL_STATE.lastRequest); + expect(next.messageLimitUtilization).toBe(INITIAL_STATE.messageLimitUtilization); + }); + + it('INITIAL_STATE has null usageBudget', () => { + expect(INITIAL_STATE.usageBudget).toBeNull(); + }); + + it('is preserved through applyTokenBatch', () => { + const budget = makeBudget(71, 'moderate'); + const state: OverlayState = { ...INITIAL_STATE, usageBudget: budget }; + const next = applyTokenBatch(state, TOKEN_PAYLOAD); + expect(next.usageBudget).toBe(budget); + }); + + it('is preserved through applyStreamComplete', () => { + const budget = makeBudget(91, 'critical'); + const state: OverlayState = { ...INITIAL_STATE, usageBudget: budget }; + const next = applyStreamComplete(state, TOKEN_PAYLOAD); + expect(next.usageBudget).toBe(budget); + }); +}); + // ── state immutability (cross-function) ─────────────────────────────────────── describe('immutability', () => { diff --git a/tests/unit/overlay-weekly-cap.test.ts b/tests/unit/overlay-weekly-cap.test.ts new file mode 100644 index 0000000..5130bcd --- /dev/null +++ b/tests/unit/overlay-weekly-cap.test.ts @@ -0,0 +1,204 @@ +// @vitest-environment happy-dom +// Tests for the weekly cap bar in ui/overlay.ts. +// +// Validates: render visibility, fill transform, zone class, label text, +// and the invariant that the overlay and side panel derive the same weeklyPct +// from the same UsageLimitsData snapshot. + +import { describe, it, expect, beforeEach } from 'vitest'; +import { createOverlay } from '../../ui/overlay'; +import { INITIAL_STATE, applyUsageBudget } from '../../lib/overlay-state'; +import { computeUsageBudget, classifyZone } from '../../lib/usage-budget'; +import type { UsageLimitsData, UsageBudgetResult } from '../../lib/message-types'; + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const NOW = new Date('2026-04-07T00:00:00.000Z').getTime(); + +function makeLimits(sessionPct: number, weeklyPct: number): UsageLimitsData { + return { + fiveHour: { + utilization: sessionPct, + resetsAt: new Date(NOW + 60 * 60000).toISOString(), + }, + sevenDay: { + utilization: weeklyPct, + resetsAt: new Date('2026-04-08T09:00:00.000Z').toISOString(), + }, + capturedAt: NOW, + }; +} + +function makeBudget(weeklyPct: number, zone: UsageBudgetResult['zone'] = 'comfortable'): UsageBudgetResult { + return { + sessionPct: 10, + weeklyPct, + sessionMinutesUntilReset: 60, + weeklyResetLabel: 'Wed 9:00 AM', + zone, + statusLabel: `10% used; resets in 1h`, + }; +} + +function mountOverlay() { + const overlay = createOverlay(); + const host = document.createElement('div'); + document.body.appendChild(host); + const shadow = host.attachShadow({ mode: 'open' }); + overlay.mount(shadow); + overlay.render(INITIAL_STATE); + return { overlay, shadow }; +} + +function getWeeklyRow(shadow: ShadowRoot): HTMLElement | null { + return shadow.querySelector('.lco-weekly-row'); +} + +function getWeeklyFill(shadow: ShadowRoot): HTMLElement | null { + return shadow.querySelector('.lco-weekly-row .lco-bar-fill'); +} + +function getWeeklyLabel(shadow: ShadowRoot): HTMLElement | null { + return shadow.querySelector('.lco-weekly-row .lco-bar-label'); +} + +beforeEach(() => { + document.body.innerHTML = ''; +}); + +// ── Visibility ──────────────────────────────────────────────────────────────── + +describe('weekly bar visibility', () => { + it('is hidden when usageBudget is null', () => { + const { shadow } = mountOverlay(); + const row = getWeeklyRow(shadow); + expect(row).not.toBeNull(); + expect(row!.style.display).toBe('none'); + }); + + it('is visible when usageBudget is set', () => { + const { overlay, shadow } = mountOverlay(); + const budget = makeBudget(71, 'moderate'); + const state = applyUsageBudget(INITIAL_STATE, budget); + overlay.render(state); + expect(getWeeklyRow(shadow)!.style.display).not.toBe('none'); + }); + + it('hides again when usageBudget returns to null via re-render', () => { + const { overlay, shadow } = mountOverlay(); + const budget = makeBudget(71, 'moderate'); + overlay.render(applyUsageBudget(INITIAL_STATE, budget)); + overlay.render(INITIAL_STATE); + expect(getWeeklyRow(shadow)!.style.display).toBe('none'); + }); +}); + +// ── Fill transform ──────────────────────────────────────────────────────────── + +describe('weekly bar fill transform', () => { + it('sets scaleX to weeklyPct / 100', () => { + const { overlay, shadow } = mountOverlay(); + overlay.render(applyUsageBudget(INITIAL_STATE, makeBudget(71))); + expect(getWeeklyFill(shadow)!.style.transform).toBe('scaleX(0.71)'); + }); + + it('clamps fill to 1.0 at 100%', () => { + const { overlay, shadow } = mountOverlay(); + overlay.render(applyUsageBudget(INITIAL_STATE, makeBudget(100, 'critical'))); + expect(getWeeklyFill(shadow)!.style.transform).toBe('scaleX(1)'); + }); + + it('clamps fill to 0 at 0%', () => { + const { overlay, shadow } = mountOverlay(); + overlay.render(applyUsageBudget(INITIAL_STATE, makeBudget(0, 'comfortable'))); + expect(getWeeklyFill(shadow)!.style.transform).toBe('scaleX(0)'); + }); +}); + +// ── Zone class ──────────────────────────────────────────────────────────────── + +describe('weekly bar zone class', () => { + it('applies --comfortable class at 30%', () => { + const { overlay, shadow } = mountOverlay(); + overlay.render(applyUsageBudget(INITIAL_STATE, makeBudget(30, 'comfortable'))); + expect(getWeeklyFill(shadow)!.classList.contains('lco-bar-fill--comfortable')).toBe(true); + }); + + it('applies --moderate class at 60%', () => { + const { overlay, shadow } = mountOverlay(); + overlay.render(applyUsageBudget(INITIAL_STATE, makeBudget(60, 'moderate'))); + expect(getWeeklyFill(shadow)!.classList.contains('lco-bar-fill--moderate')).toBe(true); + }); + + it('applies --tight class at 80%', () => { + const { overlay, shadow } = mountOverlay(); + overlay.render(applyUsageBudget(INITIAL_STATE, makeBudget(80, 'tight'))); + expect(getWeeklyFill(shadow)!.classList.contains('lco-bar-fill--tight')).toBe(true); + }); + + it('applies --critical class at 92%', () => { + const { overlay, shadow } = mountOverlay(); + overlay.render(applyUsageBudget(INITIAL_STATE, makeBudget(92, 'critical'))); + expect(getWeeklyFill(shadow)!.classList.contains('lco-bar-fill--critical')).toBe(true); + }); + + it('zone class uses classifyZone(weeklyPct), not the overall budget zone', () => { + // Budget zone is driven by max(session, weekly). Weekly bar colors itself + // on weekly alone, matching the UsageBudgetCard.tsx convention. + const { overlay, shadow } = mountOverlay(); + // sessionPct=95 (critical overall) but weeklyPct=30 (comfortable). + const budget: UsageBudgetResult = { + sessionPct: 95, + weeklyPct: 30, + sessionMinutesUntilReset: 5, + weeklyResetLabel: 'Wed 9:00 AM', + zone: 'critical', + statusLabel: '95% used; session nearly exhausted', + }; + overlay.render(applyUsageBudget(INITIAL_STATE, budget)); + const fill = getWeeklyFill(shadow)!; + expect(fill.classList.contains('lco-bar-fill--comfortable')).toBe(true); + expect(fill.classList.contains('lco-bar-fill--critical')).toBe(false); + }); +}); + +// ── Label text ──────────────────────────────────────────────────────────────── + +describe('weekly bar label', () => { + it('shows rounded percentage with "weekly" suffix', () => { + const { overlay, shadow } = mountOverlay(); + overlay.render(applyUsageBudget(INITIAL_STATE, makeBudget(71))); + expect(getWeeklyLabel(shadow)!.textContent).toBe('71% weekly'); + }); + + it('rounds fractional percentages', () => { + const { overlay, shadow } = mountOverlay(); + overlay.render(applyUsageBudget(INITIAL_STATE, makeBudget(71.6))); + expect(getWeeklyLabel(shadow)!.textContent).toBe('72% weekly'); + }); +}); + +// ── Invariant: overlay weeklyPct equals side-panel weeklyPct ────────────────── + +describe('overlay/side-panel weeklyPct invariant', () => { + it('both surfaces read weeklyPct from the same computeUsageBudget call path', () => { + const limits = makeLimits(12, 71); + const budget = computeUsageBudget(limits, NOW); + // weeklyPct is a direct pass-through of sevenDay.utilization — no transformation. + expect(budget.weeklyPct).toBe(71); + // The overlay will call applyUsageBudget(state, budget), which stores this value. + // The side panel calls computeUsageBudget(limits, Date.now()) in useDashboardData.ts. + // Both derive from the same source data; the weeklyPct is always equal. + const state = applyUsageBudget(INITIAL_STATE, budget); + expect(state.usageBudget?.weeklyPct).toBe(budget.weeklyPct); + }); + + it('zone classification agrees for the same weeklyPct', () => { + const limits = makeLimits(10, 71); + const budget = computeUsageBudget(limits, NOW); + // The overlay bar uses classifyZone(weeklyPct) directly. + // The side panel UsageBudgetCard uses classifyZone(weeklyPct) for the weekly fill. + // Both reference the same exported function with the same input. + expect(classifyZone(budget.weeklyPct)).toBe('moderate'); + }); +}); diff --git a/ui/overlay-styles.ts b/ui/overlay-styles.ts index 9839255..e9b8bbf 100644 --- a/ui/overlay-styles.ts +++ b/ui/overlay-styles.ts @@ -355,9 +355,12 @@ export const OVERLAY_CSS = ` box-shadow: 0 0 6px var(--lco-warn-glow); } -.lco-bar-fill--healthy { background: #86efac; box-shadow: 0 0 6px rgba(134, 239, 172, 0.3); } -.lco-bar-fill--degrading { background: #f59e0b; box-shadow: 0 0 6px rgba(245, 158, 11, 0.3); } -.lco-bar-fill--critical { background: #ef4444; box-shadow: 0 0 6px rgba(239, 68, 68, 0.3); } +.lco-bar-fill--healthy, +.lco-bar-fill--comfortable { background: #86efac; box-shadow: 0 0 6px rgba(134, 239, 172, 0.3); } +.lco-bar-fill--degrading, +.lco-bar-fill--moderate { background: #f59e0b; box-shadow: 0 0 6px rgba(245, 158, 11, 0.3); } +.lco-bar-fill--critical { background: #ef4444; box-shadow: 0 0 6px rgba(239, 68, 68, 0.3); } +.lco-bar-fill--tight { background: #fb923c; box-shadow: 0 0 6px rgba(251, 146, 60, 0.3); } .lco-bar-fill.lco-streaming { animation: lco-bar-pulse 1.2s ease-in-out infinite; diff --git a/ui/overlay.ts b/ui/overlay.ts index f1eba3c..5d8cfc0 100644 --- a/ui/overlay.ts +++ b/ui/overlay.ts @@ -7,6 +7,7 @@ import { OVERLAY_CSS } from './overlay-styles'; import type { OverlayState } from '../lib/overlay-state'; import type { ContextSignal } from '../lib/context-intelligence'; +import { classifyZone } from '../lib/usage-budget'; export interface OverlayHandle { mount(shadow: ShadowRoot): void; @@ -57,6 +58,9 @@ export function createOverlay(): OverlayHandle { let elDraftValue: HTMLElement | null = null; let elDraftCompare: HTMLElement | null = null; let elDraftWarning: HTMLElement | null = null; + let elWeeklyRow: HTMLElement | null = null; + let elWeeklyFill: HTMLElement | null = null; + let elWeeklyLabel: HTMLElement | null = null; function mount(shadow: ShadowRoot): void { const style = document.createElement('style'); @@ -212,6 +216,26 @@ export function createOverlay(): OverlayHandle { limitRow.appendChild(limitLabel); body.appendChild(limitRow); + // Weekly cap bar — hidden until usageBudget is available + const weeklyRow = document.createElement('div'); + weeklyRow.className = 'lco-bar-row lco-weekly-row'; + weeklyRow.style.display = 'none'; + elWeeklyRow = weeklyRow; + const weeklyTrack = document.createElement('div'); + weeklyTrack.className = 'lco-bar-track'; + const weeklyFill = document.createElement('div'); + weeklyFill.className = 'lco-bar-fill'; + weeklyFill.style.transform = 'scaleX(0)'; + elWeeklyFill = weeklyFill; + weeklyTrack.appendChild(weeklyFill); + const weeklyLabel = document.createElement('span'); + weeklyLabel.className = 'lco-bar-label'; + weeklyLabel.textContent = '—% weekly'; + elWeeklyLabel = weeklyLabel; + weeklyRow.appendChild(weeklyTrack); + weeklyRow.appendChild(weeklyLabel); + body.appendChild(weeklyRow); + // Divider — hidden until first request completes const divider = document.createElement('div'); divider.className = 'lco-divider'; @@ -385,6 +409,18 @@ export function createOverlay(): OverlayHandle { } } + if (elWeeklyRow && elWeeklyFill && elWeeklyLabel) { + const budget = state.usageBudget; + const visible = budget !== null; + elWeeklyRow.style.display = visible ? '' : 'none'; + if (visible) { + const pct = Math.min(Math.max(budget!.weeklyPct, 0), 100); + elWeeklyFill.style.transform = `scaleX(${pct / 100})`; + elWeeklyFill.className = `lco-bar-fill lco-bar-fill--${classifyZone(pct)}`; + elWeeklyLabel.textContent = `${Math.round(pct)}% weekly`; + } + } + const sessionVisible = state.session.requestCount > 0; if (elDivider) elDivider.style.display = sessionVisible ? '' : 'none'; if (elSessionRow) elSessionRow.style.display = sessionVisible ? '' : 'none';