Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 27 additions & 7 deletions entrypoints/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,14 +389,34 @@ export default defineBackground({
// Usage limit data fetched from /api/organizations/{orgId}/usage by the content script.
// Stored as usageLimits:{orgId} in chrome.storage.local (single record, overwritten).
// Powers the Usage Budget card in the side panel dashboard.
//
// The content script has already classified the response into one of three
// tier variants. We rebuild the matching UsageLimitsData here rather than
// re-parsing the raw endpoint, which keeps the wire format strongly typed
// and the storage layer indifferent to which tier it is persisting.
if (message.type === 'STORE_USAGE_LIMITS') {
const { organizationId, fiveHourUtilization, fiveHourResetsAt, sevenDayUtilization, sevenDayResetsAt } = message;
const limits: UsageLimitsData = {
fiveHour: { utilization: fiveHourUtilization, resetsAt: fiveHourResetsAt },
sevenDay: { utilization: sevenDayUtilization, resetsAt: sevenDayResetsAt },
capturedAt: Date.now(),
};
storeUsageLimits(organizationId, limits)
const capturedAt = Date.now();
let limits: UsageLimitsData;
if (message.kind === 'session') {
limits = {
kind: 'session',
fiveHour: { utilization: message.fiveHourUtilization, resetsAt: message.fiveHourResetsAt },
sevenDay: { utilization: message.sevenDayUtilization, resetsAt: message.sevenDayResetsAt },
capturedAt,
};
} else if (message.kind === 'credit') {
limits = {
kind: 'credit',
monthlyLimitCents: message.monthlyLimitCents,
usedCents: message.usedCents,
utilizationPct: message.utilizationPct,
currency: message.currency,
capturedAt,
};
} else {
limits = { kind: 'unsupported', capturedAt };
}
storeUsageLimits(message.organizationId, limits)
.then(() => sendResponse({ ok: true }))
.catch((err) => {
console.error('[LCO-ERROR] Failed to store usage limits:', err);
Expand Down
89 changes: 56 additions & 33 deletions entrypoints/claude-ai.content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@
// 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, UsageLimitsData, UsageBudgetResult } from '../lib/message-types';
import type { LcoBridgeMessage, StoreTokenBatchMessage, StoreMessageLimitMessage, StoreTokenBatchResponse, RecordTurnMessage, FinalizeConversationMessage, SetActiveConvMessage, StoreUsageLimitsMessage, 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, applyUsageBudget } from '../lib/overlay-state';
import { computeUsageBudget } from '../lib/usage-budget';
import { computeUsageBudget, getTrackedUtilization } from '../lib/usage-budget';
import { parseUsageResponse } from '../lib/usage-limits-parser';
import { computePreSubmitEstimate } from '../lib/pre-submit';
import { createOverlay } from '../ui/overlay';
import { showEnableBanner } from '../ui/enable-banner';
Expand Down Expand Up @@ -79,31 +80,44 @@ async function fetchAndStoreUsageLimits(orgId: string): Promise<UsageBudgetResul
try {
const response = await fetch(`/api/organizations/${orgId}/usage`, { credentials: 'same-origin' });
if (!response.ok) return null;
const data = await response.json() as {
five_hour?: { utilization?: number; resets_at?: string };
seven_day?: { utilization?: number; resets_at?: string };
};
const fiveHour = data.five_hour;
const sevenDay = data.seven_day;
if (
!fiveHour || typeof fiveHour.utilization !== 'number' || typeof fiveHour.resets_at !== 'string' ||
!sevenDay || typeof sevenDay.utilization !== 'number' || typeof sevenDay.resets_at !== 'string'
) return null;
browser.runtime.sendMessage({
type: 'STORE_USAGE_LIMITS',
organizationId: orgId,
fiveHourUtilization: fiveHour.utilization,
fiveHourResetsAt: fiveHour.resets_at,
sevenDayUtilization: sevenDay.utilization,
sevenDayResetsAt: sevenDay.resets_at,
} satisfies StoreUsageLimitsMessage).catch(() => { /* non-critical */ });
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);
const rawJson: unknown = await response.json();

// The parser is the single source of tier dispatch. It returns null only
// when the body is not even an object we can inspect; in that case we
// pretend the request failed and leave any previous render in place.
const limits = parseUsageResponse(rawJson);
if (!limits) return null;

// Forward the typed result to the background. The kind discriminator
// tells the handler which UsageLimitsData variant to rebuild.
const storeMessage: StoreUsageLimitsMessage = limits.kind === 'session'
? {
type: 'STORE_USAGE_LIMITS',
kind: 'session',
organizationId: orgId,
fiveHourUtilization: limits.fiveHour.utilization,
fiveHourResetsAt: limits.fiveHour.resetsAt,
sevenDayUtilization: limits.sevenDay.utilization,
sevenDayResetsAt: limits.sevenDay.resetsAt,
}
: limits.kind === 'credit'
? {
type: 'STORE_USAGE_LIMITS',
kind: 'credit',
organizationId: orgId,
monthlyLimitCents: limits.monthlyLimitCents,
usedCents: limits.usedCents,
utilizationPct: limits.utilizationPct,
currency: limits.currency,
}
: {
type: 'STORE_USAGE_LIMITS',
kind: 'unsupported',
organizationId: orgId,
};
browser.runtime.sendMessage(storeMessage).catch(() => { /* non-critical */ });

return computeUsageBudget(limits, limits.kind === 'unsupported' ? Date.now() : limits.capturedAt);
} catch {
// Network errors are silently ignored; the dashboard shows stale data.
return null;
Expand Down Expand Up @@ -291,11 +305,13 @@ async function initializeMonitoring(): Promise<void> {

// Fetch usage limits now that we have the org ID. Populates the
// 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.
// Capture the tier-appropriate utilization (session% on Pro,
// monthly% on Enterprise) as the initial before-snapshot so the
// first STREAM_COMPLETE can compute a delta in matching units.
// The unsupported variant has nothing to track or display.
fetchAndStoreUsageLimits(currentOrgId).then(budget => {
if (budget !== null) {
lastKnownUtilization = budget.sessionPct;
if (budget !== null && budget.kind !== 'unsupported') {
lastKnownUtilization = getTrackedUtilization(budget);
state = applyUsageBudget(state, budget);
overlay.render(state);
}
Expand Down Expand Up @@ -467,7 +483,13 @@ async function initializeMonitoring(): Promise<void> {
// fetchAndStoreUsageLimits catches all errors internally; it never
// throws. The .then() always runs.
fetchAndStoreUsageLimits(orgId).then(budgetAfter => {
const utilizationAfter = budgetAfter?.sessionPct ?? null;
// Tracked utilization is tier-aware: 5-hour session % on Pro,
// monthly credit % on Enterprise. The unsupported variant has
// nothing to track, so we leave the snapshot null and skip
// the delta math entirely.
const utilizationAfter = (budgetAfter !== null && budgetAfter.kind !== 'unsupported')
? getTrackedUtilization(budgetAfter)
: 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) {
Expand All @@ -488,7 +510,8 @@ async function initializeMonitoring(): Promise<void> {

// Apply fresh budget to overlay state. Combine with delta update in
// one state object so the render below covers both changes.
if (budgetAfter !== null) {
// Unsupported variants have no UI to render, so they never enter state.
if (budgetAfter !== null && budgetAfter.kind !== 'unsupported') {
state = applyUsageBudget(state, budgetAfter);
}

Expand Down
7 changes: 4 additions & 3 deletions entrypoints/sidepanel/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,11 @@ export default function App() {
)}

{/* Usage Budget: live session data. budget is null when !isClaudeTab
(cleared by useDashboardData); UsageBudgetCard renders its own
empty state when budget is null. */}
(cleared by useDashboardData); UsageBudgetCard branches on the
tier variant (session/credit/unsupported) and chooses the
empty-state copy from `isClaudeTab`. */}
<CollapsibleSection title="Usage Budget" storageKey="budget" defaultOpen>
<UsageBudgetCard budget={budget} />
<UsageBudgetCard budget={budget} isClaudeTab={isClaudeTab} />
</CollapsibleSection>

<CollapsibleSection title="Active Conversation" storageKey="active" defaultOpen>
Expand Down
96 changes: 89 additions & 7 deletions entrypoints/sidepanel/components/UsageBudgetCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,35 +5,77 @@
// page load and after each response, stored as usageLimits:{accountId}).
// The numbers here match claude.ai/settings/limits exactly -- no estimation.
//
// Props: UsageBudgetResult from lib/usage-budget.ts (the Usage Budget Agent).
// When budget is null, renders a placeholder prompt to load claude.ai.
// Tier dispatch:
// - session → original Pro/Personal/Max layout: session bar + weekly bar
// - credit → Enterprise: a single monthly spend bar in dollars
// - unsupported → explicit "we can't read this account type" empty state
//
// Empty states:
// - !isClaudeTab && !budget → prompt the user to open claude.ai
// - isClaudeTab but no usable budget → tell them this account isn't supported
//
// Props: typed budget result from lib/usage-budget.ts plus the tab-awareness
// flag from useDashboardData.ts. Components further down receive pre-computed
// state and never touch chrome.* directly.

import React from 'react';
import type { UsageBudgetResult, BudgetZone } from '../../../lib/message-types';
import type { UsageBudgetResult, UsageBudgetSession, UsageBudgetCredit, BudgetZone } from '../../../lib/message-types';
import { classifyZone } from '../../../lib/usage-budget';

interface Props {
budget: UsageBudgetResult | null;
/**
* True when the active tab is on claude.ai. Drives the empty-state copy:
* off-tab users get a prompt to open claude.ai; on-tab users with no usable
* data get the "account type not supported" message.
*/
isClaudeTab: boolean;
}

// Zone-to-label mapping for the dot and fills.
// Mirrors the health dot pattern in ActiveConversation.tsx.
// Zone-to-label mapping for the dot and fills. Mirrors the health dot
// pattern in ActiveConversation.tsx. The credit variant replaces the zone
// name with a tier label ("Enterprise") because the four-zone vocabulary
// does not translate cleanly to a single monthly bar.
const ZONE_LABELS: Record<BudgetZone, string> = {
comfortable: 'Comfortable',
moderate: 'Moderate',
tight: 'Tight',
critical: 'Critical',
};

export default function UsageBudgetCard({ budget }: Props) {
if (!budget) {
export default function UsageBudgetCard({ budget, isClaudeTab }: Props) {
// No data at all + the user is on a tab where we cannot fetch any.
// Surface the obvious next action rather than a silent empty card.
if (!budget && !isClaudeTab) {
return (
<div className="lco-dash-budget lco-dash-budget--empty">
<p className="lco-dash-placeholder">Open claude.ai to load usage data</p>
</div>
);
}

// We are on claude.ai but the response did not match either tier shape we
// know about. This is the honest state for Teams accounts (and possibly
// future tiers): we fetched, parsed, and found nothing actionable. Better
// than silently rendering empty bars that look like a fresh session.
if (!budget || budget.kind === 'unsupported') {
return (
<div className="lco-dash-budget lco-dash-budget--empty">
<p className="lco-dash-placeholder">Saar can&apos;t read usage on this account type yet</p>
</div>
);
}

// Session and credit each get their own render path; the discriminator on
// `budget.kind` lets TypeScript narrow into the right field set.
return budget.kind === 'session'
? <SessionBudget budget={budget} />
: <CreditBudget budget={budget} />;
}

// ── Session variant (Pro / Personal / Max) ───────────────────────────────────

function SessionBudget({ budget }: { budget: UsageBudgetSession }) {
const { sessionPct, weeklyPct, sessionMinutesUntilReset, weeklyResetLabel, zone, statusLabel } = budget;

// Clamp to [0, 100] for the bar fill. The API returns 0-100 already, but
Expand Down Expand Up @@ -91,6 +133,46 @@ export default function UsageBudgetCard({ budget }: Props) {
);
}

// ── Credit variant (Enterprise) ──────────────────────────────────────────────

function CreditBudget({ budget }: { budget: UsageBudgetCredit }) {
const { utilizationPct, zone, statusLabel, resetLabel } = budget;
const safePct = Math.min(Math.max(utilizationPct, 0), 100);

return (
<div className="lco-dash-budget">
{/* Header: zone dot drives the bar color, but the label is the tier
name. "Comfortable / Moderate / Tight / Critical" applies to a
rolling window — for a monthly credit pool, the user just wants
to know what tier they're on. */}
<div className="lco-dash-budget-header">
<span className={`lco-dash-budget-dot lco-dash-budget-dot--${zone}`} />
<span className="lco-dash-budget-zone-label">Enterprise</span>
</div>

{/* Primary status line: "$304.91 of $500.00 spent" */}
<p className="lco-dash-budget-status">{statusLabel}</p>

{/* Single monthly spend bar */}
<div className="lco-dash-budget-row">
<span className="lco-dash-budget-row-label">Monthly</span>
<div className="lco-dash-budget-bar">
<div
className={`lco-dash-budget-fill lco-dash-budget-fill--${zone}`}
style={{ transform: `scaleX(${safePct / 100})` }}
/>
</div>
<span className="lco-dash-budget-row-pct">{Math.round(safePct)}%</span>
</div>

{/* Reset line: "Resets May 1" */}
<div className="lco-dash-budget-resets">
<span>{resetLabel}</span>
</div>
</div>
);
}

// ── Helpers ───────────────────────────────────────────────────────────────────

function formatSessionReset(minutes: number): string {
Expand Down
28 changes: 27 additions & 1 deletion lib/conversation-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -730,12 +730,38 @@ export async function storeUsageLimits(accountId: string, limits: UsageLimitsDat
* Returns null if no data has been stored yet (e.g. user has not loaded claude.ai
* with the extension active since this feature shipped).
*
* Forward-compatible read shim: records written before tier dispatch (GET-20)
* have no `kind` field. They are always the session shape (Pro/Personal was
* the only tier we wrote to storage), so we tag them in-memory as 'session'
* and return. Storage is left untouched; the next write overwrites with the
* fully-tagged shape, and the legacy record never leaks back out.
*
* @param accountId - Organization UUID
*/
export async function getUsageLimits(accountId: string): Promise<UsageLimitsData | null> {
const key = usageLimitsKey(accountId);
const data = await store().get(key);
return (data[key] as UsageLimitsData | undefined) ?? null;
const raw = data[key];
if (!raw || typeof raw !== 'object') return null;

const record = raw as Partial<UsageLimitsData> & {
fiveHour?: unknown;
sevenDay?: unknown;
};

// Already-tagged (session/credit/unsupported): return verbatim.
if (record.kind === 'session' || record.kind === 'credit' || record.kind === 'unsupported') {
return record as UsageLimitsData;
}

// Untagged legacy record: only the session shape was ever written. Detect
// it by the two windows and re-emit as a session variant. Anything else
// we cannot place gets dropped to null so downstream code does not have
// to defend against half-typed records.
if (record.fiveHour && record.sevenDay) {
return { ...(record as Omit<UsageLimitsData, 'kind'>), kind: 'session' } as UsageLimitsData;
}
return null;
}

// ── Usage delta log ───────────────────────────────────────────────────────────
Expand Down
Loading
Loading