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
10 changes: 9 additions & 1 deletion entrypoints/sidepanel/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ export default function App() {
budget,
isClaudeTab,
weeklyEta,
spendTrajectory,
topSpendConversations,
loading,
} = useDashboardData();

Expand Down Expand Up @@ -79,7 +81,13 @@ 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} weeklyEta={weeklyEta} />
<UsageBudgetCard
budget={budget}
isClaudeTab={isClaudeTab}
weeklyEta={weeklyEta}
spendTrajectory={spendTrajectory}
topSpendConversations={topSpendConversations}
/>
</CollapsibleSection>

<CollapsibleSection title="Active Conversation" storageKey="active" defaultOpen>
Expand Down
122 changes: 118 additions & 4 deletions entrypoints/sidepanel/components/UsageBudgetCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ 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';
import { formatCurrencyCents } from '../../../lib/format';
import type { SpendTrajectory, TopSpender } from '../../../lib/spend-trajectory';

interface Props {
budget: UsageBudgetResult | null;
Expand All @@ -37,6 +39,18 @@ interface Props {
* Session tier only; credit/unsupported cards never receive this.
*/
weeklyEta?: WeeklyEta | null;
/**
* Month-end spend projection. Credit-tier only; null on session/unsupported
* tiers and until at least 7 distinct cost-bearing days have accumulated
* in the current month.
*/
spendTrajectory?: SpendTrajectory | null;
/**
* Top conversations of the current month, ranked descending by total cost,
* with subjects already resolved by the dashboard hook.
* Credit-tier only; empty array everywhere else.
*/
topSpendConversations?: TopSpender[];
}

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

export default function UsageBudgetCard({ budget, isClaudeTab, weeklyEta }: Props) {
export default function UsageBudgetCard({
budget,
isClaudeTab,
weeklyEta,
spendTrajectory,
topSpendConversations,
}: 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 @@ -77,7 +97,11 @@ export default function UsageBudgetCard({ budget, isClaudeTab, weeklyEta }: Prop
// `budget.kind` lets TypeScript narrow into the right field set.
return budget.kind === 'session'
? <SessionBudget budget={budget} eta={weeklyEta ?? null} />
: <CreditBudget budget={budget} />;
: <CreditBudget
budget={budget}
trajectory={spendTrajectory ?? null}
topSpenders={topSpendConversations ?? []}
/>;
}

// ── Session variant (Pro / Personal / Max) ───────────────────────────────────
Expand Down Expand Up @@ -149,8 +173,16 @@ function SessionBudget({ budget, eta }: { budget: UsageBudgetSession; eta: Weekl

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

function CreditBudget({ budget }: { budget: UsageBudgetCredit }) {
const { utilizationPct, zone, statusLabel, resetLabel } = budget;
function CreditBudget({
budget,
trajectory,
topSpenders,
}: {
budget: UsageBudgetCredit;
trajectory: SpendTrajectory | null;
topSpenders: TopSpender[];
}) {
const { utilizationPct, zone, statusLabel, resetLabel, currency, monthlyLimitCents } = budget;
const safePct = Math.min(Math.max(utilizationPct, 0), 100);

return (
Expand All @@ -167,6 +199,13 @@ function CreditBudget({ budget }: { budget: UsageBudgetCredit }) {
{/* Primary status line: "$304.91 of $500.00 spent" */}
<p className="lco-dash-budget-status">{statusLabel}</p>

{/* Trajectory line: confidence-tiered projection, or a "need more data"
placeholder when fewer than 7 cost-bearing days have accumulated this
month. Hidden in the empty-state when there is nothing to say. */}
<p className="lco-dash-budget-trajectory">
{formatTrajectoryLine(trajectory, currency, monthlyLimitCents)}
</p>

{/* Single monthly spend bar */}
<div className="lco-dash-budget-row">
<span className="lco-dash-budget-row-label">Monthly</span>
Expand All @@ -183,6 +222,45 @@ function CreditBudget({ budget }: { budget: UsageBudgetCredit }) {
<div className="lco-dash-budget-resets">
<span>{resetLabel}</span>
</div>

{/* Top spenders: native <details> for native expand/collapse without
extra state. Hidden when there is nothing to rank yet. */}
{topSpenders.length > 0 && (
<details className="lco-dash-budget-spenders">
<summary className="lco-dash-budget-spenders-summary">
Top conversations this month
</summary>
<div className="lco-dash-budget-spenders-list">
{topSpenders.map((spender) => (
<SpenderRow
key={spender.conversationId}
spender={spender}
currency={currency}
/>
))}
</div>
</details>
)}
</div>
);
}

function SpenderRow({
spender,
currency,
}: {
spender: TopSpender;
currency: string;
}) {
return (
<div className="lco-dash-budget-spender">
<span className="lco-dash-budget-spender-subject">{spender.subject}</span>
<span className="lco-dash-budget-spender-cost">
{formatCurrencyCents(spender.totalCostCents, currency)}
</span>
<span className="lco-dash-budget-spender-turns">
{spender.turnCount} turn{spender.turnCount === 1 ? '' : 's'}
</span>
</div>
);
}
Expand Down Expand Up @@ -212,3 +290,39 @@ function formatEtaLine(eta: WeeklyEta): string {
return `Estimating: cap by ${label}. Need more data for confidence.`;
}
}

/**
* Build the credit-tier trajectory line. Copy varies by confidence to set the
* user's expectation; a null trajectory becomes the "need more data" placeholder
* so the layout never collapses while the agent waits for enough samples.
*/
function formatTrajectoryLine(
trajectory: SpendTrajectory | null,
currency: string,
monthlyLimitCents: number,
): string {
if (!trajectory) {
return 'Need 7+ days of usage before we can project month-end.';
}
const projected = formatCurrencyCents(trajectory.projectedSpentCents, currency);
const limit = formatCurrencyCents(monthlyLimitCents, currency);
const monthEnd = formatMonthEndLabel(trajectory.daysRemaining);
switch (trajectory.confidence) {
case 'high':
return `On track for ${projected} of ${limit} by ${monthEnd}.`;
case 'medium':
return `Estimated month-end: ${projected} of ${limit}. Estimate firms up over the next week.`;
case 'low':
return `Estimating: ${projected} of ${limit} by ${monthEnd}. Need more data for confidence.`;
}
}

/** "Apr 30" / "May 1": the user-facing label for the projection target. */
function formatMonthEndLabel(daysRemaining: number): string {
const target = new Date();
target.setDate(target.getDate() + daysRemaining);
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
}).format(target);
}
102 changes: 102 additions & 0 deletions entrypoints/sidepanel/dashboard.css
Original file line number Diff line number Diff line change
Expand Up @@ -733,6 +733,98 @@ body {
color: var(--lco-text-muted);
}

/* Trajectory line on the credit card: "On track for $312 of $500 by Apr 30."
Sits directly under .lco-dash-budget-status, smaller and muted to keep
the primary status line as the hero. The "need more data" placeholder uses
the same class — italics give it a softer, less load-bearing voice. */
.lco-dash-budget-trajectory {
margin: -8px 0 12px;
font-size: 12px;
color: var(--lco-text-muted);
line-height: 1.4;
font-variant-numeric: tabular-nums;
}

/* Top-spenders expandable list. Mirrors the History row pattern visually
without inheriting its hover/animation behavior — these rows are summary
data, not navigable items. */
.lco-dash-budget-spenders {
margin-top: 10px;
border-top: 1px solid var(--lco-border);
padding-top: 8px;
}

.lco-dash-budget-spenders-summary {
cursor: pointer;
font-size: 11px;
color: var(--lco-text-muted);
list-style: none;
user-select: none;
padding: 2px 0;
}

.lco-dash-budget-spenders-summary::-webkit-details-marker {
display: none;
}

/* Visible keyboard focus state. The native disclosure marker is hidden above,
so without this rule keyboard users have no indicator that the summary has
focus. Border-radius matches the row pattern. */
.lco-dash-budget-spenders-summary:focus-visible {
outline: 2px solid var(--lco-focus-ring, var(--lco-text));
outline-offset: 2px;
border-radius: 3px;
}

.lco-dash-budget-spenders-summary::before {
content: '▸';
display: inline-block;
margin-right: 6px;
transition: transform 0.15s ease;
}

.lco-dash-budget-spenders[open] .lco-dash-budget-spenders-summary::before {
transform: rotate(90deg);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

.lco-dash-budget-spenders-list {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 6px;
}

.lco-dash-budget-spender {
display: grid;
grid-template-columns: 1fr auto auto;
align-items: baseline;
gap: 8px;
padding: 6px 8px;
border-radius: var(--lco-radius);
background: var(--lco-bg-card);
border: 1px solid var(--lco-border);
font-size: 12px;
}

.lco-dash-budget-spender-subject {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--lco-text);
}

.lco-dash-budget-spender-cost {
color: var(--lco-text);
font-family: var(--lco-font-mono);
font-variant-numeric: tabular-nums;
font-size: 11px;
}

.lco-dash-budget-spender-turns {
color: var(--lco-text-muted);
font-size: 11px;
}

/* ── Conversation list ──────────────────────────────────────────────────────── */

.lco-dash-convlist {
Expand Down Expand Up @@ -1212,3 +1304,13 @@ body {
:root[data-density='compact'] .lco-dash-budget-resets {
margin-top: 4px;
}
:root[data-density='compact'] .lco-dash-budget-trajectory {
margin: -6px 0 8px;
}
:root[data-density='compact'] .lco-dash-budget-spenders {
margin-top: 6px;
padding-top: 6px;
}
:root[data-density='compact'] .lco-dash-budget-spender {
padding: 4px 6px;
}
Loading
Loading