From 5a847b64ccc9986e8682b08f030b457747d11f11 Mon Sep 17 00:00:00 2001 From: Ryan Westfall Date: Fri, 31 Jul 2026 07:08:06 -0700 Subject: [PATCH] Move plan/quota UI to Account; Gemini-style usage card (#73) (#74) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Closes [#73](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/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](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/75) upgrade/change/cancel subscription.Reviewed-on: https://git.aimloperations.com/ai_ml_operations/chat_web_app/pulls/74 --- .../ConversationDetailCard.test.tsx | 31 +-- .../ConversationDetailCard.tsx | 21 -- .../UsageSummaryBar/UsageSummaryBar.tsx | 70 ------ .../UsageSummaryCard/UsageSummaryCard.tsx | 236 ++++++++++++++++++ llm-fe/src/llm-fe/pages/Account2/Account2.tsx | 2 + .../pages/AsyncDashboard2/AsyncDashboard2.tsx | 6 - 6 files changed, 245 insertions(+), 121 deletions(-) delete mode 100644 llm-fe/src/llm-fe/components/UsageSummaryBar/UsageSummaryBar.tsx create mode 100644 llm-fe/src/llm-fe/components/UsageSummaryCard/UsageSummaryCard.tsx diff --git a/llm-fe/src/llm-fe/components/ConversationDetailCard/ConversationDetailCard.test.tsx b/llm-fe/src/llm-fe/components/ConversationDetailCard/ConversationDetailCard.test.tsx index 822fa65..7c76895 100644 --- a/llm-fe/src/llm-fe/components/ConversationDetailCard/ConversationDetailCard.test.tsx +++ b/llm-fe/src/llm-fe/components/ConversationDetailCard/ConversationDetailCard.test.tsx @@ -17,8 +17,6 @@ const theme = { const renderCard = (props: { message: string; user_created: boolean; - tokens_in?: number | null; - tokens_out?: number | null; }) => render( @@ -26,38 +24,23 @@ const renderCard = (props: { ); -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(); }); }); diff --git a/llm-fe/src/llm-fe/components/ConversationDetailCard/ConversationDetailCard.tsx b/llm-fe/src/llm-fe/components/ConversationDetailCard/ConversationDetailCard.tsx index 7129ab9..fabe98c 100644 --- a/llm-fe/src/llm-fe/components/ConversationDetailCard/ConversationDetailCard.tsx +++ b/llm-fe/src/llm-fe/components/ConversationDetailCard/ConversationDetailCard.tsx @@ -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 ( @@ -193,11 +177,6 @@ const ConversationDetailCard = ({ {contentToAdd} - {showTokens ? ( - - Tokens in {formatTokenCount(tokens_in)} · out {formatTokenCount(tokens_out)} - - ) : null} ); }; diff --git a/llm-fe/src/llm-fe/components/UsageSummaryBar/UsageSummaryBar.tsx b/llm-fe/src/llm-fe/components/UsageSummaryBar/UsageSummaryBar.tsx deleted file mode 100644 index a97d45c..0000000 --- a/llm-fe/src/llm-fe/components/UsageSummaryBar/UsageSummaryBar.tsx +++ /dev/null @@ -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(null); - - const load = useCallback(async () => { - try { - const response = await axiosInstance.get('/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 ( - - - {subscription.plan.name} - - - Prompts left ({usage.window_hours}h): {promptsLabel} - - - Period tokens in {formatTokenCount(usage.tokens_in_period)} · out{' '} - {formatTokenCount(usage.tokens_out_period)} - - - ); -}; - -export default UsageSummaryBar; diff --git a/llm-fe/src/llm-fe/components/UsageSummaryCard/UsageSummaryCard.tsx b/llm-fe/src/llm-fe/components/UsageSummaryCard/UsageSummaryCard.tsx new file mode 100644 index 0000000..703838c --- /dev/null +++ b/llm-fe/src/llm-fe/components/UsageSummaryCard/UsageSummaryCard.tsx @@ -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 ( + + + {label} + {meta} + + + {showBar ? : null} + + + {usedLabel} + {showBar ? `${pct}%` : '—'} + + + ); +}; + +const UsageSummaryCard = (): JSX.Element => { + const [subscription, setSubscription] = useState(null); + const [loading, setLoading] = useState(true); + + const load = useCallback(async () => { + setLoading(true); + try { + const response = await axiosInstance.get('/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 ( + + Usage limit + {loading ? ( + Loading usage… + ) : !subscription?.plan ? ( + No active plan yet. + ) : ( + <> + {subscription.plan.name} + + + + {tokenQuota !== null ? ( + + ) : ( + + )} + + + Need more capacity? Manage or upgrade your plan in Billing below. + + + )} + + ); +}; + +export default UsageSummaryCard; diff --git a/llm-fe/src/llm-fe/pages/Account2/Account2.tsx b/llm-fe/src/llm-fe/pages/Account2/Account2.tsx index 585d82f..790b418 100644 --- a/llm-fe/src/llm-fe/pages/Account2/Account2.tsx +++ b/llm-fe/src/llm-fe/pages/Account2/Account2.tsx @@ -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 ( <> + {account?.is_company_manager ? ( diff --git a/llm-fe/src/llm-fe/pages/AsyncDashboard2/AsyncDashboard2.tsx b/llm-fe/src/llm-fe/pages/AsyncDashboard2/AsyncDashboard2.tsx index 09116fb..1375940 100644 --- a/llm-fe/src/llm-fe/pages/AsyncDashboard2/AsyncDashboard2.tsx +++ b/llm-fe/src/llm-fe/pages/AsyncDashboard2/AsyncDashboard2.tsx @@ -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 => { - {conversationDetails.length > 0 ? ( conversationDetails.map((convo_detail, index) => @@ -525,16 +523,12 @@ const AsyncDashboardInner = (): JSX.Element => { ) : ( )