From 4fd21097e16d18c3635f3734a0af8095c276a640 Mon Sep 17 00:00:00 2001
From: chengyongru <2755839590@qq.com>
Date: Sun, 23 Aug 2026 22:22:49 +0800
Subject: [PATCH] fix(webui): surface current context in turn usage
---
webui/src/components/MessageBubble.tsx | 68 ++++++++++++++-------
webui/src/i18n/locales/en/common.json | 8 ++-
webui/src/i18n/locales/es/common.json | 8 ++-
webui/src/i18n/locales/fr/common.json | 8 ++-
webui/src/i18n/locales/id/common.json | 8 ++-
webui/src/i18n/locales/ja/common.json | 8 ++-
webui/src/i18n/locales/ko/common.json | 8 ++-
webui/src/i18n/locales/pt-BR/common.json | 8 ++-
webui/src/i18n/locales/vi/common.json | 8 ++-
webui/src/i18n/locales/zh-CN/common.json | 8 ++-
webui/src/i18n/locales/zh-TW/common.json | 6 +-
webui/src/tests/i18n.test.tsx | 7 +++
webui/src/tests/message-bubble.test.tsx | 78 ++++++++++++++++--------
13 files changed, 164 insertions(+), 67 deletions(-)
diff --git a/webui/src/components/MessageBubble.tsx b/webui/src/components/MessageBubble.tsx
index d527749e9..2eb4f930b 100644
--- a/webui/src/components/MessageBubble.tsx
+++ b/webui/src/components/MessageBubble.tsx
@@ -192,46 +192,69 @@ function compactDuration(milliseconds: number): string {
function TurnUsageMeta({
usage,
latencyMs,
- contextWindowTokens,
}: {
usage: TurnUsage;
latencyMs?: number;
- contextWindowTokens?: number;
}) {
const { t } = useTranslation();
+ const context = usage.context_tokens;
const prompt = usage.prompt_tokens;
const completion = usage.completion_tokens;
const approximate = (usage.estimated_tokens ?? 0) > 0 ? "~" : "";
+ const formatTokens = (tokens: number) => `${approximate}${formatCompactTokenCount(tokens)}`;
const parts: string[] = [];
- if (typeof prompt === "number") parts.push(`${approximate}${formatCompactTokenCount(prompt)} in`);
- if (typeof completion === "number") parts.push(`${approximate}${formatCompactTokenCount(completion)} out`);
+ const details: string[] = [];
+
+ if (typeof context === "number" && context >= 0) {
+ parts.push(t("message.usage.context", {
+ tokens: formatTokens(context),
+ defaultValue: "{{tokens}} context",
+ }));
+ if (typeof prompt === "number") {
+ details.push(t("message.usage.turnInput", {
+ tokens: formatTokens(prompt),
+ defaultValue: "{{tokens}} turn input",
+ }));
+ }
+ } else if (typeof prompt === "number") {
+ parts.push(t("message.usage.turnInput", {
+ tokens: formatTokens(prompt),
+ defaultValue: "{{tokens}} turn input",
+ }));
+ }
+
+ if (typeof usage.request_count === "number" && usage.request_count > 0) {
+ const requestKey = usage.request_count === 1
+ ? "message.usage.request"
+ : "message.usage.requests";
+ details.push(t(requestKey, {
+ count: usage.request_count,
+ defaultValue: usage.request_count === 1 ? "{{count}} request" : "{{count}} requests",
+ }));
+ }
+
+ if (typeof completion === "number") {
+ const output = t("message.usage.output", {
+ tokens: formatTokens(completion),
+ defaultValue: "{{tokens}} out",
+ });
+ if (parts.length > 0) details.push(output);
+ else parts.push(output);
+ }
+
if (
typeof usage.cached_tokens === "number"
&& typeof prompt === "number"
&& prompt > 0
) {
- parts.push(`${Math.round(Math.min(1, usage.cached_tokens / prompt) * 100)}% cached`);
- }
- if (typeof usage.request_count === "number" && usage.request_count > 1) {
- parts.push(t("message.usage.requests", {
- count: usage.request_count,
- defaultValue: "{{count}} calls this turn",
+ details.push(t("message.usage.cached", {
+ percent: Math.round(Math.min(1, usage.cached_tokens / prompt) * 100),
+ defaultValue: "{{percent}}% cached",
}));
}
+
if (typeof latencyMs === "number" && latencyMs >= 0) parts.push(compactDuration(latencyMs));
if (parts.length === 0) return null;
-
- const details: string[] = [];
- if (typeof usage.context_tokens === "number" && usage.context_tokens >= 0) {
- const capacity = typeof contextWindowTokens === "number" && contextWindowTokens > 0
- ? ` / ${formatCompactTokenCount(contextWindowTokens)}`
- : "";
- details.push(t("message.usage.context", {
- tokens: `${approximate}${formatCompactTokenCount(usage.context_tokens)}`,
- capacity,
- defaultValue: "Context now: {{tokens}}{{capacity}}",
- }));
- }
if (approximate) {
details.push(t("message.usage.estimated", { defaultValue: "Includes estimated usage" }));
}
@@ -639,7 +662,6 @@ export function MessageBubble({
) : null}
{showAssistantTimestamp ? (
diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json
index a31c8866c..81abcf15d 100644
--- a/webui/src/i18n/locales/en/common.json
+++ b/webui/src/i18n/locales/en/common.json
@@ -1431,8 +1431,12 @@
"fileEditOpenFile": "Open file",
"fileEditDiffTruncated": "Diff truncated. Open the file for the full change.",
"usage": {
- "context": "Context now: {{tokens}}{{capacity}}",
- "requests": "{{count}} calls this turn",
+ "context": "{{tokens}} context",
+ "turnInput": "{{tokens}} turn input",
+ "request": "{{count}} request",
+ "requests": "{{count}} requests",
+ "output": "{{tokens}} out",
+ "cached": "{{percent}}% cached",
"estimated": "Includes estimated usage"
}
},
diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json
index 5dc215773..b960fb670 100644
--- a/webui/src/i18n/locales/es/common.json
+++ b/webui/src/i18n/locales/es/common.json
@@ -1401,8 +1401,12 @@
"fileEditOpenFile": "Abrir archivo",
"fileEditDiffTruncated": "Diferencias truncadas. Abre el archivo para ver el cambio completo.",
"usage": {
- "context": "Contexto más reciente: {{tokens}}{{capacity}} tokens",
- "requests": "{{count}} solicitudes al modelo",
+ "context": "{{tokens}} de contexto",
+ "turnInput": "{{tokens}} de entrada en el turno",
+ "request": "{{count}} solicitud",
+ "requests": "{{count}} solicitudes",
+ "output": "{{tokens}} de salida",
+ "cached": "{{percent}}% en caché",
"estimated": "Incluye uso estimado"
},
"activityThinkingFor": "Pensando durante {{duration}}",
diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json
index 360ebed75..c7686a501 100644
--- a/webui/src/i18n/locales/fr/common.json
+++ b/webui/src/i18n/locales/fr/common.json
@@ -1400,8 +1400,12 @@
"fileEditOpenFile": "Ouvrir le fichier",
"fileEditDiffTruncated": "Différences tronquées. Ouvrez le fichier pour voir la modification complète.",
"usage": {
- "context": "Dernier contexte : {{tokens}}{{capacity}} tokens",
- "requests": "{{count}} requêtes au modèle",
+ "context": "{{tokens}} de contexte",
+ "turnInput": "{{tokens}} en entrée sur ce tour",
+ "request": "{{count}} requête",
+ "requests": "{{count}} requêtes",
+ "output": "{{tokens}} en sortie",
+ "cached": "{{percent}} % en cache",
"estimated": "Inclut une estimation de l’utilisation"
},
"activityThinkingFor": "Réflexion pendant {{duration}}",
diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json
index fa4708f95..c50da5313 100644
--- a/webui/src/i18n/locales/id/common.json
+++ b/webui/src/i18n/locales/id/common.json
@@ -1400,8 +1400,12 @@
"fileEditOpenFile": "Buka file",
"fileEditDiffTruncated": "Perbedaan dipotong. Buka file untuk melihat perubahan lengkap.",
"usage": {
- "context": "Konteks terbaru: {{tokens}}{{capacity}} token",
- "requests": "{{count}} permintaan model",
+ "context": "Konteks {{tokens}}",
+ "turnInput": "Input giliran {{tokens}}",
+ "request": "{{count}} permintaan",
+ "requests": "{{count}} permintaan",
+ "output": "Output {{tokens}}",
+ "cached": "{{percent}}% di-cache",
"estimated": "Termasuk penggunaan perkiraan"
},
"activityThinkingFor": "Berpikir selama {{duration}}",
diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json
index 4622ca056..dc0fc843e 100644
--- a/webui/src/i18n/locales/ja/common.json
+++ b/webui/src/i18n/locales/ja/common.json
@@ -1400,8 +1400,12 @@
"fileEditOpenFile": "ファイルを開く",
"fileEditDiffTruncated": "差分は切り詰められました。完全な変更はファイルを開いて確認してください。",
"usage": {
- "context": "最新のコンテキスト: {{tokens}}{{capacity}} トークン",
- "requests": "モデルリクエスト {{count}} 回",
+ "context": "コンテキスト {{tokens}}",
+ "turnInput": "ターン入力 {{tokens}}",
+ "request": "リクエスト {{count}} 回",
+ "requests": "リクエスト {{count}} 回",
+ "output": "出力 {{tokens}}",
+ "cached": "キャッシュ {{percent}}%",
"estimated": "推定使用量を含みます"
},
"activityThinkingFor": "{{duration}}考えています",
diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json
index 7cff407ec..2b8206a08 100644
--- a/webui/src/i18n/locales/ko/common.json
+++ b/webui/src/i18n/locales/ko/common.json
@@ -1400,8 +1400,12 @@
"fileEditOpenFile": "파일 열기",
"fileEditDiffTruncated": "변경 사항이 잘렸습니다. 전체 변경은 파일을 열어 확인하세요.",
"usage": {
- "context": "최근 컨텍스트: {{tokens}}{{capacity}} 토큰",
- "requests": "모델 요청 {{count}}회",
+ "context": "컨텍스트 {{tokens}}",
+ "turnInput": "턴 입력 {{tokens}}",
+ "request": "요청 {{count}}회",
+ "requests": "요청 {{count}}회",
+ "output": "출력 {{tokens}}",
+ "cached": "캐시됨 {{percent}}%",
"estimated": "예상 사용량 포함"
},
"activityThinkingFor": "{{duration}} 동안 생각 중",
diff --git a/webui/src/i18n/locales/pt-BR/common.json b/webui/src/i18n/locales/pt-BR/common.json
index f6e0b0329..e17d43ecd 100644
--- a/webui/src/i18n/locales/pt-BR/common.json
+++ b/webui/src/i18n/locales/pt-BR/common.json
@@ -1431,8 +1431,12 @@
"fileEditOpenFile": "Abrir arquivo",
"fileEditDiffTruncated": "Diferenças truncadas. Abra o arquivo para ver a alteração completa.",
"usage": {
- "context": "Contexto atual: {{tokens}}{{capacity}}",
- "requests": "{{count}} chamadas nesta rodada",
+ "context": "{{tokens}} de contexto",
+ "turnInput": "{{tokens}} de entrada na rodada",
+ "request": "{{count}} chamada",
+ "requests": "{{count}} chamadas",
+ "output": "{{tokens}} de saída",
+ "cached": "{{percent}}% em cache",
"estimated": "Inclui uso estimado"
}
},
diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json
index b17017947..03869794a 100644
--- a/webui/src/i18n/locales/vi/common.json
+++ b/webui/src/i18n/locales/vi/common.json
@@ -1400,8 +1400,12 @@
"fileEditOpenFile": "Mở tệp",
"fileEditDiffTruncated": "Khác biệt đã bị cắt bớt. Mở tệp để xem toàn bộ thay đổi.",
"usage": {
- "context": "Ngữ cảnh gần nhất: {{tokens}}{{capacity}} token",
- "requests": "{{count}} yêu cầu mô hình",
+ "context": "Ngữ cảnh {{tokens}}",
+ "turnInput": "Đầu vào lượt {{tokens}}",
+ "request": "{{count}} yêu cầu",
+ "requests": "{{count}} yêu cầu",
+ "output": "Đầu ra {{tokens}}",
+ "cached": "{{percent}}% đã lưu đệm",
"estimated": "Bao gồm mức sử dụng ước tính"
},
"activityThinkingFor": "Đang suy nghĩ trong {{duration}}",
diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json
index 93cc48ec6..361b9fd29 100644
--- a/webui/src/i18n/locales/zh-CN/common.json
+++ b/webui/src/i18n/locales/zh-CN/common.json
@@ -1431,8 +1431,12 @@
"fileEditOpenFile": "打开文件",
"fileEditDiffTruncated": "差异内容已截断。打开文件可查看完整更改。",
"usage": {
- "context": "当前上下文:{{tokens}}{{capacity}}",
- "requests": "本轮 {{count}} 次模型调用",
+ "context": "上下文 {{tokens}}",
+ "turnInput": "本轮输入 {{tokens}}",
+ "request": "{{count}} 次模型调用",
+ "requests": "{{count}} 次模型调用",
+ "output": "输出 {{tokens}}",
+ "cached": "缓存 {{percent}}%",
"estimated": "包含估算用量"
}
},
diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json
index 99e05d33d..127058c71 100644
--- a/webui/src/i18n/locales/zh-TW/common.json
+++ b/webui/src/i18n/locales/zh-TW/common.json
@@ -1399,8 +1399,12 @@
"fileEditOpenFile": "開啟檔案",
"fileEditDiffTruncated": "差異內容已截斷。請開啟檔案以檢視完整變更。",
"usage": {
- "context": "最近一次上下文:{{tokens}}{{capacity}} tokens",
+ "context": "上下文 {{tokens}}",
+ "turnInput": "本輪輸入 {{tokens}}",
+ "request": "{{count}} 次模型請求",
"requests": "{{count}} 次模型請求",
+ "output": "輸出 {{tokens}}",
+ "cached": "快取 {{percent}}%",
"estimated": "包含估算用量"
},
"activityThinkingFor": "思考中,已 {{duration}}",
diff --git a/webui/src/tests/i18n.test.tsx b/webui/src/tests/i18n.test.tsx
index 2891cbd97..29644f81f 100644
--- a/webui/src/tests/i18n.test.tsx
+++ b/webui/src/tests/i18n.test.tsx
@@ -291,6 +291,13 @@ const LOCALIZED_NEW_SURFACE_KEYS = [
"message.openLink",
"message.openAttachment",
"message.skill",
+ "message.usage.context",
+ "message.usage.turnInput",
+ "message.usage.request",
+ "message.usage.requests",
+ "message.usage.output",
+ "message.usage.cached",
+ "message.usage.estimated",
"settings.channels.connectionChecks",
"settings.channels.open",
];
diff --git a/webui/src/tests/message-bubble.test.tsx b/webui/src/tests/message-bubble.test.tsx
index 601624176..cba5040da 100644
--- a/webui/src/tests/message-bubble.test.tsx
+++ b/webui/src/tests/message-bubble.test.tsx
@@ -1013,35 +1013,59 @@ describe("MessageBubble", () => {
expect(screen.queryByLabelText("File attachment")).not.toBeInTheDocument();
});
- it("distinguishes aggregate turn usage from the latest context", () => {
- const message: UIMessage = {
- id: "a-usage",
- role: "assistant",
- content: "done",
- createdAt: Date.now(),
- latencyMs: 18_200,
- contextWindowTokens: 128_000,
- usage: {
- prompt_tokens: 12_400,
- completion_tokens: 823,
- cached_tokens: 9_672,
- context_tokens: 8_100,
- request_count: 3,
+ it("shows consecutive contexts while keeping each turn's aggregate cost in details", async () => {
+ const turns: UIMessage[] = [
+ {
+ id: "turn-a",
+ role: "assistant",
+ content: "first reply",
+ createdAt: Date.now(),
+ usage: {
+ prompt_tokens: 111_000,
+ completion_tokens: 333,
+ cached_tokens: 55_500,
+ context_tokens: 56_000,
+ request_count: 2,
+ },
},
- };
+ {
+ id: "turn-b",
+ role: "assistant",
+ content: "second reply",
+ createdAt: Date.now(),
+ usage: {
+ prompt_tokens: 57_000,
+ completion_tokens: 192,
+ cached_tokens: 55_290,
+ context_tokens: 57_000,
+ request_count: 1,
+ },
+ },
+ ];
- render();
+ render(<>{turns.map((message) => )}>);
- const usage = screen.getByText("12.4K in · 823 out · 78% cached · 3 calls this turn · 18s");
- expect(usage).toHaveAttribute("data-turn-usage");
- expect(usage).toHaveAttribute("tabindex", "0");
+ const turnAUsage = screen.getByText("56K context");
+ const turnBUsage = screen.getByText("57K context");
+ expect(turnAUsage).toHaveAttribute("data-turn-usage");
+ expect(turnBUsage).toHaveAttribute("data-turn-usage");
+ expect(screen.queryByText(/111K turn input/)).not.toBeInTheDocument();
- fireEvent.focus(usage);
+ fireEvent.focus(turnAUsage);
+ expect(screen.getByRole("tooltip")).toHaveTextContent(
+ "111K turn input · 2 requests · 333 out · 50% cached",
+ );
- expect(screen.getByRole("tooltip")).toHaveTextContent("Context now: 8.1K / 128K");
+ fireEvent.blur(turnAUsage);
+ await waitFor(() => expect(screen.queryByRole("tooltip")).not.toBeInTheDocument());
+
+ fireEvent.focus(turnBUsage);
+ expect(screen.getByRole("tooltip")).toHaveTextContent(
+ "57K turn input · 1 request · 192 out · 97% cached",
+ );
});
- it("marks estimated usage and omits cache when the provider did not report it", () => {
+ it("falls back to clearly labeled estimated turn input when context is unavailable", () => {
const message: UIMessage = {
id: "a-estimated-usage",
role: "assistant",
@@ -1050,15 +1074,19 @@ describe("MessageBubble", () => {
usage: {
prompt_tokens: 1_250,
completion_tokens: 90,
+ request_count: 1,
estimated_tokens: 1_340,
},
};
render();
- const usage = screen.getByText("~1.3K in · ~90 out");
- expect(usage).not.toHaveTextContent("cached");
+ const usage = screen.getByText("~1.3K turn input");
+ expect(usage).not.toHaveTextContent("context");
fireEvent.focus(usage);
- expect(screen.getByRole("tooltip")).toHaveTextContent("Includes estimated usage");
+ expect(screen.getByRole("tooltip")).toHaveTextContent(
+ "1 request · ~90 out · Includes estimated usage",
+ );
+ expect(screen.getByRole("tooltip")).not.toHaveTextContent("cached");
});
});