From be8cd7b6ebfb64d08c0b1c2f6449f19e1703de73 Mon Sep 17 00:00:00 2001 From: Jwalin Shah Date: Tue, 21 Apr 2026 14:40:08 -0700 Subject: [PATCH] feat(chat): show active token consumption and plan status in UI (#703) (#738) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a small pill in the Conversations chat header with: - Session token counter — cumulative input + output tokens across all chat turns, accumulated from chat:done events via new recordChatTurnUsage slice action. - Plan usage badge — % of 5-hour or weekly budget consumed, driven by existing useUsageState. Color-coded sage/amber/coral by severity; click navigates to /settings/billing. No new backend endpoints — session counter is client-local, plan state reuses the existing creditsApi/billingApi fetches cached by useUsageState. Closes #703. Co-authored-by: Jwalin Shah --- app/src/components/chat/TokenUsagePill.tsx | 95 ++++++++++++++++++++++ app/src/pages/Conversations.tsx | 2 + app/src/providers/ChatRuntimeProvider.tsx | 9 ++ app/src/store/chatRuntimeSlice.ts | 24 ++++++ 4 files changed, 130 insertions(+) create mode 100644 app/src/components/chat/TokenUsagePill.tsx diff --git a/app/src/components/chat/TokenUsagePill.tsx b/app/src/components/chat/TokenUsagePill.tsx new file mode 100644 index 000000000..50badfbeb --- /dev/null +++ b/app/src/components/chat/TokenUsagePill.tsx @@ -0,0 +1,95 @@ +import { useNavigate } from 'react-router-dom'; + +import { useUsageState } from '../../hooks/useUsageState'; +import { useAppSelector } from '../../store/hooks'; + +function formatTokens(n: number): string { + if (n < 1000) return String(n); + if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}K`; + return `${(n / 1_000_000).toFixed(1)}M`; +} + +interface PillSeverity { + bg: string; + text: string; + ring: string; + label: string; +} + +function severityFromPct(pct: number): PillSeverity { + if (pct >= 0.9) { + return { + bg: 'bg-coral-50', + text: 'text-coral-700', + ring: 'ring-coral-200', + label: `${Math.round(pct * 100)}%`, + }; + } + if (pct >= 0.7) { + return { + bg: 'bg-amber-50', + text: 'text-amber-700', + ring: 'ring-amber-200', + label: `${Math.round(pct * 100)}%`, + }; + } + return { + bg: 'bg-sage-50', + text: 'text-sage-700', + ring: 'ring-sage-200', + label: `${Math.round(pct * 100)}%`, + }; +} + +const TokenUsagePill = () => { + const navigate = useNavigate(); + const sessionTokens = useAppSelector(state => state.chatRuntime.sessionTokenUsage); + const { usagePct10h, usagePct7d, isAtLimit, isNearLimit, currentTier, teamUsage } = + useUsageState(); + + const totalTokens = sessionTokens.inputTokens + sessionTokens.outputTokens; + const showSessionCounter = totalTokens > 0; + + const planPct = Math.max(usagePct10h, usagePct7d); + const planSeverity = severityFromPct(planPct); + const showPlanPill = teamUsage !== null; + + const planTitle = (() => { + if (isAtLimit) return 'Usage limit reached — click to top up'; + if (isNearLimit) return 'Approaching usage limit'; + return `${currentTier.toLowerCase()} plan — click for details`; + })(); + + if (!showSessionCounter && !showPlanPill) return null; + + return ( +
+ {showSessionCounter ? ( + + + + + {formatTokens(totalTokens)} + + ) : null} + {showPlanPill ? ( + + ) : null} +
+ ); +}; + +export default TokenUsagePill; diff --git a/app/src/pages/Conversations.tsx b/app/src/pages/Conversations.tsx index 850c922d4..ef625f896 100644 --- a/app/src/pages/Conversations.tsx +++ b/app/src/pages/Conversations.tsx @@ -4,6 +4,7 @@ import Markdown from 'react-markdown'; import { useNavigate } from 'react-router-dom'; import { type ChatSendError, chatSendError } from '../chat/chatSendError'; +import TokenUsagePill from '../components/chat/TokenUsagePill'; import UpsellBanner from '../components/upsell/UpsellBanner'; import { dismissBanner, shouldShowBanner } from '../components/upsell/upsellDismissState'; import UsageLimitModal from '../components/upsell/UsageLimitModal'; @@ -1114,6 +1115,7 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => {

{threads.find(t => t.id === selectedThreadId)?.title ?? 'Select a thread'}

+