From b40b05f62347818aac71b064573e360ef644188d Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Mon, 23 Mar 2026 17:59:35 -0700 Subject: [PATCH] feat(frontend): display token usage per conversation turn (#1229) Surface the usage_metadata that PR #1218 added to the streaming API. A compact indicator in the chat header shows cumulative tokens consumed per thread, with a tooltip breakdown of input/output/total counts. Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 Co-authored-by: Willem Jiang --- .../[agent_name]/chats/[thread_id]/page.tsx | 2 + .../app/workspace/chats/[thread_id]/page.tsx | 4 +- .../workspace/token-usage-indicator.tsx | 74 +++++++++++++++++++ frontend/src/core/i18n/locales/en-US.ts | 6 ++ frontend/src/core/i18n/locales/types.ts | 6 ++ frontend/src/core/i18n/locales/zh-CN.ts | 6 ++ frontend/src/core/messages/usage.ts | 62 ++++++++++++++++ 7 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/workspace/token-usage-indicator.tsx create mode 100644 frontend/src/core/messages/usage.ts diff --git a/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx index 9219c26..12ba855 100644 --- a/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx +++ b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx @@ -15,6 +15,7 @@ import { MessageList } from "@/components/workspace/messages"; import { ThreadContext } from "@/components/workspace/messages/context"; import { ThreadTitle } from "@/components/workspace/thread-title"; import { TodoList } from "@/components/workspace/todo-list"; +import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator"; import { Tooltip } from "@/components/workspace/tooltip"; import { useAgent } from "@/core/agents"; import { useI18n } from "@/core/i18n/hooks"; @@ -115,6 +116,7 @@ export default function AgentChatPage() { {t.agents.newChat} + diff --git a/frontend/src/app/workspace/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/chats/[thread_id]/page.tsx index a447e6f..6241100 100644 --- a/frontend/src/app/workspace/chats/[thread_id]/page.tsx +++ b/frontend/src/app/workspace/chats/[thread_id]/page.tsx @@ -15,6 +15,7 @@ import { MessageList } from "@/components/workspace/messages"; import { ThreadContext } from "@/components/workspace/messages/context"; import { ThreadTitle } from "@/components/workspace/thread-title"; import { TodoList } from "@/components/workspace/todo-list"; +import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator"; import { Welcome } from "@/components/workspace/welcome"; import { useI18n } from "@/core/i18n/hooks"; import { useNotification } from "@/core/notification/hooks"; @@ -85,7 +86,8 @@ export default function ChatPage() {
-
+
+
diff --git a/frontend/src/components/workspace/token-usage-indicator.tsx b/frontend/src/components/workspace/token-usage-indicator.tsx new file mode 100644 index 0000000..9f0b02f --- /dev/null +++ b/frontend/src/components/workspace/token-usage-indicator.tsx @@ -0,0 +1,74 @@ +"use client"; + +import type { Message } from "@langchain/langgraph-sdk"; +import { CoinsIcon } from "lucide-react"; +import { useMemo } from "react"; + +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { useI18n } from "@/core/i18n/hooks"; +import { accumulateUsage, formatTokenCount } from "@/core/messages/usage"; +import { cn } from "@/lib/utils"; + +interface TokenUsageIndicatorProps { + messages: Message[]; + className?: string; +} + +export function TokenUsageIndicator({ + messages, + className, +}: TokenUsageIndicatorProps) { + const { t } = useI18n(); + + const usage = useMemo(() => accumulateUsage(messages), [messages]); + + if (!usage) { + return null; + } + + return ( + + + + + +
+
{t.tokenUsage.title}
+
+ {t.tokenUsage.input} + + {formatTokenCount(usage.inputTokens)} + +
+
+ {t.tokenUsage.output} + + {formatTokenCount(usage.outputTokens)} + +
+
+
+ {t.tokenUsage.total} + + {formatTokenCount(usage.totalTokens)} + +
+
+
+
+
+ ); +} diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index 4a99dfd..3617c98 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -274,6 +274,12 @@ export const enUS: Translations = { failed: "Subtask failed", }, + // Token Usage + tokenUsage: { + title: "Token Usage", + input: "Input", + output: "Output", + total: "Total", // Shortcuts shortcuts: { searchActions: "Search actions...", diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index b9525e4..cfdb21a 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -211,6 +211,12 @@ export interface Translations { failed: string; }; + // Token Usage + tokenUsage: { + title: string; + input: string; + output: string; + total: string; // Shortcuts shortcuts: { searchActions: string; diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index 80ab890..8d2e88e 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -261,6 +261,12 @@ export const zhCN: Translations = { failed: "子任务失败", }, + // Token Usage + tokenUsage: { + title: "Token 用量", + input: "输入", + output: "输出", + total: "总计", // Shortcuts shortcuts: { searchActions: "搜索操作...", diff --git a/frontend/src/core/messages/usage.ts b/frontend/src/core/messages/usage.ts new file mode 100644 index 0000000..44cee07 --- /dev/null +++ b/frontend/src/core/messages/usage.ts @@ -0,0 +1,62 @@ +import type { Message } from "@langchain/langgraph-sdk"; + +export interface TokenUsage { + inputTokens: number; + outputTokens: number; + totalTokens: number; +} + +/** + * Extract usage_metadata from an AI message if present. + * The field is added by the backend (PR #1218) but not typed in the SDK. + */ +function getUsageMetadata( + message: Message, +): TokenUsage | null { + if (message.type !== "ai") { + return null; + } + const usage = (message as Record).usage_metadata as + | { input_tokens?: number; output_tokens?: number; total_tokens?: number } + | undefined; + if (!usage) { + return null; + } + return { + inputTokens: usage.input_tokens ?? 0, + outputTokens: usage.output_tokens ?? 0, + totalTokens: usage.total_tokens ?? 0, + }; +} + +/** + * Accumulate token usage across all AI messages in a thread. + */ +export function accumulateUsage(messages: Message[]): TokenUsage | null { + const cumulative: TokenUsage = { + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + }; + let hasUsage = false; + for (const message of messages) { + const usage = getUsageMetadata(message); + if (usage) { + hasUsage = true; + cumulative.inputTokens += usage.inputTokens; + cumulative.outputTokens += usage.outputTokens; + cumulative.totalTokens += usage.totalTokens; + } + } + return hasUsage ? cumulative : null; +} + +/** + * Format a token count for display: 1234 -> "1,234", 12345 -> "12.3K" + */ +export function formatTokenCount(count: number): string { + if (count < 10_000) { + return count.toLocaleString(); + } + return `${(count / 1000).toFixed(1)}K`; +}