Move plan/quota UI to Account; Gemini-style usage card (#73) (#74)
Deploy Beta / unit-tests (push) Successful in 10s
Unit Tests / test (push) Successful in 10s
Deploy Beta / deploy-beta (push) Successful in 1m55s

## Summary
- Closes [#73](#73)
- Remove per-message `Tokens in … · out …` under assistant bubbles
- Remove dashboard `UsageSummaryBar` (plan / prompts left / period tokens)
- Add Account **Usage limit** card (Gemini-style): plan name, prompt progress meter with rolling-window reset hint, monthly tokens meter when capped (else period in/out), upgrade hint pointing at Billing

## Test plan
- [ ] Chat dashboard: no usage bar above messages
- [ ] Assistant messages: no token footer
- [ ] Account: Usage limit card shows plan + prompt bar (+ token bar when monthly quota set)
- [ ] `npm test -- --watchAll=false --testPathPattern=ConversationDetailCard` passes

Related follow-up: [#75](#75) upgrade/change/cancel subscription.Reviewed-on: #74
This commit was merged in pull request #74.
This commit is contained in:
2026-07-31 07:08:06 -07:00
parent c6bacbb23c
commit 5a847b64cc
6 changed files with 245 additions and 121 deletions
@@ -17,8 +17,6 @@ const theme = {
const renderCard = (props: {
message: string;
user_created: boolean;
tokens_in?: number | null;
tokens_out?: number | null;
}) =>
render(
<ThemeProvider theme={theme as never}>
@@ -26,38 +24,23 @@ const renderCard = (props: {
</ThemeProvider>
);
describe('ConversationDetailCard token usage', () => {
it('shows em dash when assistant tokens are missing', () => {
describe('ConversationDetailCard', () => {
it('renders assistant message without token meta', () => {
renderCard({
message: 'Hello from the model',
user_created: false,
tokens_in: null,
tokens_out: null,
});
expect(screen.getByTestId('message-token-usage')).toHaveTextContent(
'Tokens in — · out —'
);
expect(screen.getByText('Hello from the model')).toBeInTheDocument();
expect(screen.queryByTestId('message-token-usage')).not.toBeInTheDocument();
expect(screen.queryByText(/Tokens in/i)).not.toBeInTheDocument();
});
it('shows reported token counts for assistant messages', () => {
renderCard({
message: 'Hello from the model',
user_created: false,
tokens_in: 12,
tokens_out: 34,
});
expect(screen.getByTestId('message-token-usage')).toHaveTextContent(
'Tokens in 12 · out 34'
);
});
it('hides token meta on user messages', () => {
it('renders user message without token meta', () => {
renderCard({
message: 'Hi',
user_created: true,
tokens_in: 1,
tokens_out: 2,
});
expect(screen.getByText('Hi')).toBeInTheDocument();
expect(screen.queryByTestId('message-token-usage')).not.toBeInTheDocument();
});
});
@@ -1,7 +1,6 @@
import React from "react";
import Markdown from "markdown-to-jsx";
import styled, { keyframes } from "styled-components";
import { formatTokenCount } from "../../utils/finance";
const fadeIn = keyframes`
from { opacity: 0; transform: translateY(10px); }
@@ -68,15 +67,6 @@ const Bubble = styled.div<{ $isUser: boolean }>`
}
`;
const TokenMeta = styled.div<{ $isUser: boolean }>`
margin-top: 0.35rem;
max-width: 80%;
font-size: 0.75rem;
opacity: 0.65;
color: ${({ theme }) => theme.colors.text};
text-align: ${(props) => (props.$isUser ? "right" : "left")};
`;
const LoadingDot = styled.div`
width: 8px;
height: 8px;
@@ -104,8 +94,6 @@ const LoadingContainer = styled.div`
type ConversationDetailCardProps = {
message: string;
user_created: boolean;
tokens_in?: number | null;
tokens_out?: number | null;
};
const MyPlot = ({ format, image }: { format: string; image: string }) => {
@@ -130,8 +118,6 @@ const MyError = ({ content }: { content: string }) => {
const ConversationDetailCard = ({
message,
user_created,
tokens_in = null,
tokens_out = null,
}: ConversationDetailCardProps): JSX.Element => {
if (message.length === 0) {
return (
@@ -171,8 +157,6 @@ const ConversationDetailCard = ({
}
} catch { }
const showTokens = !user_created;
return (
<MessageContainer $isUser={user_created}>
<Bubble $isUser={user_created}>
@@ -193,11 +177,6 @@ const ConversationDetailCard = ({
{contentToAdd}
</Markdown>
</Bubble>
{showTokens ? (
<TokenMeta $isUser={user_created} data-testid="message-token-usage">
Tokens in {formatTokenCount(tokens_in)} · out {formatTokenCount(tokens_out)}
</TokenMeta>
) : null}
</MessageContainer>
);
};
@@ -1,70 +0,0 @@
import React, { useCallback, useEffect, useState } from 'react';
import styled from 'styled-components';
import { axiosInstance } from '../../../axiosApi';
import {
formatTokenCount,
SubscriptionMe,
} from '../../utils/finance';
const Bar = styled.div`
display: flex;
flex-wrap: wrap;
gap: 0.75rem 1.25rem;
align-items: center;
padding: 0.65rem 1rem;
margin-bottom: 0.75rem;
border-radius: 0.75rem;
background: ${({ theme }) => theme.colors.cardBackground};
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
color: ${({ theme }) => theme.colors.text};
font-size: 0.85rem;
opacity: 0.95;
`;
const Chip = styled.span`
white-space: nowrap;
`;
const UsageSummaryBar = (): JSX.Element | null => {
const [subscription, setSubscription] = useState<SubscriptionMe | null>(null);
const load = useCallback(async () => {
try {
const response = await axiosInstance.get<SubscriptionMe>('/finance/subscription/');
setSubscription(response.data || null);
} catch {
setSubscription(null);
}
}, []);
useEffect(() => {
load();
}, [load]);
if (!subscription?.plan) {
return null;
}
const usage = subscription.usage;
const promptsLabel =
usage.prompts_remaining === null || usage.prompt_quota === null
? '—'
: `${usage.prompts_remaining}/${usage.prompt_quota}`;
return (
<Bar data-testid="usage-summary-bar">
<Chip>
<strong>{subscription.plan.name}</strong>
</Chip>
<Chip>
Prompts left ({usage.window_hours}h): {promptsLabel}
</Chip>
<Chip>
Period tokens in {formatTokenCount(usage.tokens_in_period)} · out{' '}
{formatTokenCount(usage.tokens_out_period)}
</Chip>
</Bar>
);
};
export default UsageSummaryBar;
@@ -0,0 +1,236 @@
import React, { useCallback, useEffect, useState } from 'react';
import styled from 'styled-components';
import { axiosInstance } from '../../../axiosApi';
import {
formatBillingDate,
formatTokenCount,
SubscriptionMe,
} from '../../utils/finance';
const GlassCard = styled.div`
background: ${({ theme }) => theme.colors.cardBackground};
backdrop-filter: blur(10px);
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
border-radius: 1rem;
padding: 2rem;
width: 100%;
max-width: 1000px;
margin-bottom: 2rem;
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
`;
const CardTitle = styled.h2`
font-size: 1.8rem;
margin: 0 0 0.35rem 0;
color: ${({ theme }) => theme.colors.text};
`;
const PlanName = styled.p`
margin: 0 0 1.75rem 0;
font-size: 1rem;
font-weight: 600;
color: ${({ theme }) => theme.colors.text};
opacity: 0.85;
`;
const MeterBlock = styled.div`
margin-bottom: 1.5rem;
&:last-of-type {
margin-bottom: 0;
}
`;
const MeterHeader = styled.div`
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 1rem;
margin-bottom: 0.55rem;
`;
const MeterLabel = styled.span`
font-size: 1rem;
font-weight: 600;
color: ${({ theme }) => theme.colors.text};
`;
const MeterMeta = styled.span`
font-size: 0.85rem;
color: ${({ theme }) => theme.colors.text};
opacity: 0.65;
white-space: nowrap;
`;
const Track = styled.div`
width: 100%;
height: 0.55rem;
border-radius: 999px;
background: ${({ theme }) =>
theme.darkMode ? 'rgba(255, 255, 255, 0.12)' : 'rgba(0, 0, 0, 0.1)'};
overflow: hidden;
`;
const Fill = styled.div<{ $pct: number; $warn: boolean }>`
height: 100%;
width: ${({ $pct }) => `${Math.min(100, Math.max(0, $pct))}%`};
border-radius: 999px;
background: ${({ theme, $warn }) => ($warn ? '#e67e22' : theme.main)};
transition: width 0.35s ease;
`;
const MeterFooter = styled.div`
display: flex;
justify-content: space-between;
gap: 1rem;
margin-top: 0.4rem;
font-size: 0.8rem;
color: ${({ theme }) => theme.colors.text};
opacity: 0.65;
`;
const BodyText = styled.p`
margin: 0;
color: ${({ theme }) => theme.colors.text};
opacity: 0.75;
line-height: 1.5;
`;
const UpgradeHint = styled.p`
margin: 1.5rem 0 0 0;
padding-top: 1.25rem;
border-top: 1px solid ${({ theme }) => theme.colors.cardBorder};
font-size: 0.9rem;
color: ${({ theme }) => theme.colors.text};
opacity: 0.7;
line-height: 1.45;
`;
function clampPct(used: number, quota: number): number {
if (quota <= 0) return 0;
return Math.min(100, Math.round((used / quota) * 1000) / 10);
}
type MeterProps = {
label: string;
meta: string;
usedLabel: string;
pct: number | null;
};
const UsageMeter = ({ label, meta, usedLabel, pct }: MeterProps): JSX.Element => {
const showBar = pct !== null;
const warn = showBar && pct >= 90;
return (
<MeterBlock>
<MeterHeader>
<MeterLabel>{label}</MeterLabel>
<MeterMeta>{meta}</MeterMeta>
</MeterHeader>
<Track aria-hidden={!showBar}>
{showBar ? <Fill $pct={pct} $warn={warn} /> : null}
</Track>
<MeterFooter>
<span>{usedLabel}</span>
<span>{showBar ? `${pct}%` : '—'}</span>
</MeterFooter>
</MeterBlock>
);
};
const UsageSummaryCard = (): JSX.Element => {
const [subscription, setSubscription] = useState<SubscriptionMe | null>(null);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
setLoading(true);
try {
const response = await axiosInstance.get<SubscriptionMe>('/finance/subscription/');
setSubscription(response.data || null);
} catch {
setSubscription(null);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load();
}, [load]);
const usage = subscription?.usage;
const promptQuota = usage?.prompt_quota ?? null;
const promptsUsed = usage?.prompts_in_window ?? 0;
const promptPct =
promptQuota !== null ? clampPct(promptsUsed, promptQuota) : null;
const tokenQuota = usage?.monthly_token_quota ?? null;
const tokensUsed = usage?.tokens_total_period ?? null;
const tokenPct =
tokenQuota !== null && tokensUsed !== null
? clampPct(tokensUsed, tokenQuota)
: tokenQuota !== null
? 0
: null;
return (
<GlassCard data-testid="usage-summary-card">
<CardTitle>Usage limit</CardTitle>
{loading ? (
<BodyText>Loading usage</BodyText>
) : !subscription?.plan ? (
<BodyText>No active plan yet.</BodyText>
) : (
<>
<PlanName>{subscription.plan.name}</PlanName>
<UsageMeter
label="Prompts"
meta={`Resets every ${usage?.window_hours ?? '—'}h`}
usedLabel={
promptQuota === null
? `${promptsUsed.toLocaleString()} used`
: `${promptsUsed.toLocaleString()} / ${promptQuota.toLocaleString()}`
}
pct={promptPct}
/>
{tokenQuota !== null ? (
<UsageMeter
label="Tokens this month"
meta={
usage?.period_end
? `Resets ${formatBillingDate(usage.period_end)}`
: 'Calendar month'
}
usedLabel={
tokensUsed === null
? `— / ${tokenQuota.toLocaleString()}`
: `${formatTokenCount(tokensUsed)} / ${tokenQuota.toLocaleString()}`
}
pct={tokenPct}
/>
) : (
<UsageMeter
label="Tokens this period"
meta={
usage?.period_end
? `Period ends ${formatBillingDate(usage.period_end)}`
: 'This month'
}
usedLabel={`in ${formatTokenCount(usage?.tokens_in_period ?? null)} · out ${formatTokenCount(usage?.tokens_out_period ?? null)}`}
pct={null}
/>
)}
<UpgradeHint>
Need more capacity? Manage or upgrade your plan in Billing below.
</UpgradeHint>
</>
)}
</GlassCard>
);
};
export default UsageSummaryCard;
@@ -9,6 +9,7 @@ import Header2 from "../../components/Header2/Header2";
import ParticleBackground from "../../components/ParticleBackground/ParticleBackground";
import styled from "styled-components";
import ThemeSettingsCard from "../../components/ThemeSettingsCard/ThemeSettingsCard";
import UsageSummaryCard from "../../components/UsageSummaryCard/UsageSummaryCard";
import BillingSection from "../../components/BillingSection/BillingSection";
// Styled Components
@@ -408,6 +409,7 @@ const AccountPage = (): JSX.Element => {
return (
<>
<ThemeSettingsCard />
<UsageSummaryCard />
<BillingSection />
{account?.is_company_manager ? (
<CompanyManagerCard />
@@ -16,7 +16,6 @@ import { MessageContext } from "../../contexts/MessageContext";
import ParticleBackground from "../../components/ParticleBackground/ParticleBackground";
import Header2 from "../../components/Header2/Header2";
import UsageSummaryBar from "../../components/UsageSummaryBar/UsageSummaryBar";
import { AnalyticsEvents, trackEvent } from "../../utils/analytics";
// Styled Components
@@ -517,7 +516,6 @@ const AsyncDashboardInner = (): JSX.Element => {
</Sidebar>
<MainContent>
<UsageSummaryBar />
<ChatArea>
{conversationDetails.length > 0 ? (
conversationDetails.map((convo_detail, index) =>
@@ -525,16 +523,12 @@ const AsyncDashboardInner = (): JSX.Element => {
<ConversationDetailCard
message={convo_detail.message}
user_created={convo_detail.user_created}
tokens_in={convo_detail.tokens_in}
tokens_out={convo_detail.tokens_out}
key={convo_detail.id || index}
/>
) : (
<ConversationDetailCard
message={stateMessage}
user_created={convo_detail.user_created}
tokens_in={convo_detail.tokens_in}
tokens_out={convo_detail.tokens_out}
key={convo_detail.id || index}
/>
)