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
36 changes: 35 additions & 1 deletion entrypoints/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ import {
storeUsageLimits,
appendUsageDelta,
getUsageDeltas,
appendUsageBudgetSnapshot,
getUsageBudgetSnapshots,
clearUsageBudgetSnapshots,
todayDateString,
isoWeekId,
RETENTION_DAYS,
Expand Down Expand Up @@ -417,7 +420,38 @@ export default defineBackground({
limits = { kind: 'unsupported', capturedAt };
}
storeUsageLimits(message.organizationId, limits)
.then(() => sendResponse({ ok: true }))
.then(async () => {
// Append a snapshot for the weekly-cap ETA agent (session tier only).
// Skip credit and unsupported: they do not have a weekly rolling window.
if (message.kind === 'session') {
try {
const orgId = message.organizationId;
const weeklyPct = message.sevenDayUtilization;
const sessionPct = message.fiveHourUtilization;

// Reset detection: a significant drop in weeklyPct signals a
// weekly-cap reset. Stale pre-reset snapshots would skew the ETA
// toward zero; clear them so the projection rebuilds from scratch.
const existing = await getUsageBudgetSnapshots(orgId);
if (existing.length > 0) {
const lastPct = existing[existing.length - 1].weeklyPct;
if (weeklyPct < lastPct - 5) {
await clearUsageBudgetSnapshots(orgId);
}
}

await appendUsageBudgetSnapshot(orgId, {
timestamp: capturedAt,
weeklyPct,
sessionPct,
});
} catch (err) {
// Non-critical: ETA simply stays null until next successful append.
console.error('[LCO-ERROR] Failed to append usage budget snapshot:', err);
}
}
sendResponse({ ok: true });
Comment thread
DevanshuNEU marked this conversation as resolved.
})
.catch((err) => {
console.error('[LCO-ERROR] Failed to store usage limits:', err);
sendResponse({ ok: false });
Expand Down
38 changes: 34 additions & 4 deletions entrypoints/claude-ai.content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
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 { INITIAL_STATE, applyTokenBatch, applyStreamComplete, applyStorageResponse, applyHealthBroken, applyHealthRecovered, applyMessageLimit, applyRestoredConversation, applyDraftEstimate, clearDraftEstimate, applyUsageBudget, applyWeeklyEta } from '../lib/overlay-state';
import { computeUsageBudget, getTrackedUtilization } from '../lib/usage-budget';
import { parseUsageResponse } from '../lib/usage-limits-parser';
import { computePreSubmitEstimate, MIN_DRAFT_CHARS } from '../lib/pre-submit';
Expand All @@ -22,10 +22,12 @@ import type { PromptCharacteristics, DeltaPromptContext } from '../lib/prompt-an
import { analyzeDelta } from '../lib/delta-coaching';
import type { DeltaCoachInput } from '../lib/delta-coaching';
import { getContextWindowSize, calculateCost } from '../lib/pricing';
import { extractConversationId } from '../lib/conversation-store';
import { extractConversationId, usageBudgetSnapshotsKey } from '../lib/conversation-store';
import type { ConversationRecord } from '../lib/conversation-store';
import { computeHealthScore, computeGrowthRate } from '../lib/health-score';
import { buildHandoffSummary } from '../lib/handoff-summary';
import { computeWeeklyEta } from '../lib/weekly-cap-eta';
import type { UsageBudgetSnapshot } from '../lib/weekly-cap-eta';

export default defineContentScript({
matches: ['https://claude.ai/*'],
Expand Down Expand Up @@ -127,6 +129,24 @@ async function fetchAndStoreUsageLimits(orgId: string): Promise<UsageBudgetResul
}
}

/**
* Read the rolling snapshot list for an org and compute the weekly-cap ETA.
* Uses chrome.storage.local directly (no conversation-store setStorage dependency
* in the content-script context). Returns null on any read error or when the
* ETA agent suppresses the result (too few samples, flat/declining usage, etc).
*/
async function computeEtaForOrg(orgId: string): Promise<import('../lib/weekly-cap-eta').WeeklyEta | null> {
try {
const key = usageBudgetSnapshotsKey(orgId);
const data = await browser.storage.local.get(key);
const raw = data[key];
const snapshots: UsageBudgetSnapshot[] = Array.isArray(raw) ? raw as UsageBudgetSnapshot[] : [];
return computeWeeklyEta(snapshots, Date.now());
} catch {
return null;
}
}

/**
* Build local conversation state from a stored record.
* Uses per-turn contextPct values from TurnRecord when available — these are
Expand Down Expand Up @@ -343,10 +363,16 @@ async function initializeMonitoring(): Promise<void> {
// 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 => {
const orgIdForEta = currentOrgId;
fetchAndStoreUsageLimits(currentOrgId).then(async budget => {
if (budget !== null && budget.kind !== 'unsupported') {
lastKnownUtilization = getTrackedUtilization(budget);
state = applyUsageBudget(state, budget);
if (budget.kind === 'session') {
const eta = await computeEtaForOrg(orgIdForEta);
if (currentOrgId !== orgIdForEta) return;
state = applyWeeklyEta(state, eta);
}
overlay.render(state);
}
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Expand Down Expand Up @@ -529,7 +555,7 @@ async function initializeMonitoring(): Promise<void> {

// fetchAndStoreUsageLimits catches all errors internally; it never
// throws. The .then() always runs.
fetchAndStoreUsageLimits(orgId).then(budgetAfter => {
fetchAndStoreUsageLimits(orgId).then(async budgetAfter => {
// 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
Expand Down Expand Up @@ -560,6 +586,10 @@ async function initializeMonitoring(): Promise<void> {
// Unsupported variants have no UI to render, so they never enter state.
if (budgetAfter !== null && budgetAfter.kind !== 'unsupported') {
state = applyUsageBudget(state, budgetAfter);
if (budgetAfter.kind === 'session') {
const eta = await computeEtaForOrg(orgId);
state = applyWeeklyEta(state, eta);
}
}

// Update overlay immediately with the exact session cost for
Expand Down
3 changes: 2 additions & 1 deletion entrypoints/sidepanel/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export default function App() {
conversations,
budget,
isClaudeTab,
weeklyEta,
loading,
} = useDashboardData();

Expand Down Expand Up @@ -78,7 +79,7 @@ export default function App() {
tier variant (session/credit/unsupported) and chooses the
empty-state copy from `isClaudeTab`. */}
<CollapsibleSection title="Usage Budget" storageKey="budget" defaultOpen>
<UsageBudgetCard budget={budget} isClaudeTab={isClaudeTab} />
<UsageBudgetCard budget={budget} isClaudeTab={isClaudeTab} weeklyEta={weeklyEta} />
</CollapsibleSection>

<CollapsibleSection title="Active Conversation" storageKey="active" defaultOpen>
Expand Down
36 changes: 33 additions & 3 deletions entrypoints/sidepanel/components/UsageBudgetCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import React from 'react';
import type { UsageBudgetResult, UsageBudgetSession, UsageBudgetCredit, BudgetZone } from '../../../lib/message-types';
import { classifyZone } from '../../../lib/usage-budget';
import { formatEtaLabel, type WeeklyEta } from '../../../lib/weekly-cap-eta';

interface Props {
budget: UsageBudgetResult | null;
Expand All @@ -30,6 +31,12 @@ interface Props {
* data get the "account type not supported" message.
*/
isClaudeTab: boolean;
/**
* Weekly-cap ETA from the weekly-cap ETA agent. Null until enough snapshots
* have accumulated, or when usage is flat/declining, or post-reset.
* Session tier only; credit/unsupported cards never receive this.
*/
weeklyEta?: WeeklyEta | null;
}

// Zone-to-label mapping for the dot and fills. Mirrors the health dot
Expand All @@ -43,7 +50,7 @@ const ZONE_LABELS: Record<BudgetZone, string> = {
critical: 'Critical',
};

export default function UsageBudgetCard({ budget, isClaudeTab }: Props) {
export default function UsageBudgetCard({ budget, isClaudeTab, weeklyEta }: 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) {
Expand All @@ -69,13 +76,13 @@ export default function UsageBudgetCard({ budget, isClaudeTab }: Props) {
// 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} />
? <SessionBudget budget={budget} eta={weeklyEta ?? null} />
: <CreditBudget budget={budget} />;
}

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

function SessionBudget({ budget }: { budget: UsageBudgetSession }) {
function SessionBudget({ budget, eta }: { budget: UsageBudgetSession; eta: WeeklyEta | null }) {
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 @@ -124,6 +131,13 @@ function SessionBudget({ budget }: { budget: UsageBudgetSession }) {
<span className="lco-dash-budget-row-pct">{Math.round(safeWeeklyPct)}%</span>
</div>

{/* ETA projection: shown under the weekly bar when the agent has
enough history to project confidently. Hidden when null (too few
snapshots, flat/declining usage, or immediately after a reset). */}
{eta !== null && (
<p className="lco-dash-budget-eta">{formatEtaLine(eta)}</p>
)}

{/* Reset times */}
<div className="lco-dash-budget-resets">
<span>Session resets in {resetText}</span>
Expand Down Expand Up @@ -182,3 +196,19 @@ function formatSessionReset(minutes: number): string {
const m = minutes % 60;
return m === 0 ? `${h}h` : `${h}h ${m}m`;
}

/**
* Build the one-liner ETA coaching copy for the side-panel card.
* Copy varies by confidence to calibrate the user's expectation of accuracy.
*/
function formatEtaLine(eta: WeeklyEta): string {
const label = formatEtaLabel(eta.etaTimestamp);
switch (eta.confidence) {
case 'high':
return `At this pace, you'll hit your weekly cap by ${label}.`;
case 'medium':
return `Estimated cap: ${label}. Estimate firms up over the next day.`;
case 'low':
return `Estimating: cap by ${label}. Need more data for confidence.`;
}
}
Loading
Loading