From 0228b126aef5e57779ecea41567f0e29df483ee6 Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Sun, 23 Aug 2026 22:55:36 +0800 Subject: [PATCH] fix(webui): gate turn usage behind local preference --- webui/src/components/MessageBubble.tsx | 47 +++++++++------ .../settings/overview/OverviewSettings.tsx | 16 ++++++ .../src/components/thread/ThreadMessages.tsx | 7 +++ webui/src/hooks/useShowTurnUsage.ts | 33 +++++++++++ webui/src/i18n/locales/en/common.json | 2 + webui/src/i18n/locales/es/common.json | 2 + webui/src/i18n/locales/fr/common.json | 2 + webui/src/i18n/locales/id/common.json | 2 + webui/src/i18n/locales/ja/common.json | 2 + webui/src/i18n/locales/ko/common.json | 2 + webui/src/i18n/locales/pt-BR/common.json | 2 + webui/src/i18n/locales/vi/common.json | 2 + webui/src/i18n/locales/zh-CN/common.json | 2 + webui/src/i18n/locales/zh-TW/common.json | 2 + webui/src/lib/local-preferences.ts | 3 + webui/src/tests/i18n.test.tsx | 2 + webui/src/tests/message-bubble.test.tsx | 57 ++++++++++++++++++- webui/src/tests/settings-overview.test.tsx | 41 ++++++++++++- 18 files changed, 206 insertions(+), 20 deletions(-) create mode 100644 webui/src/hooks/useShowTurnUsage.ts diff --git a/webui/src/components/MessageBubble.tsx b/webui/src/components/MessageBubble.tsx index 2eb4f930b..7a825cd46 100644 --- a/webui/src/components/MessageBubble.tsx +++ b/webui/src/components/MessageBubble.tsx @@ -65,6 +65,8 @@ interface MessageBubbleProps { temporary?: boolean; /** When false, hide this message's copy button. Default true. */ showCopyAction?: boolean; + /** Show opt-in per-turn token usage for completed assistant messages. */ + showTurnUsage?: boolean; cliApps?: CliAppInfo[]; mcpPresets?: McpPresetInfo[]; slashCommands?: SlashCommand[]; @@ -189,13 +191,7 @@ function compactDuration(milliseconds: number): string { return `${minutes}m ${Math.round(seconds % 60)}s`; } -function TurnUsageMeta({ - usage, - latencyMs, -}: { - usage: TurnUsage; - latencyMs?: number; -}) { +function TurnUsageMeta({ usage }: { usage: TurnUsage }) { const { t } = useTranslation(); const context = usage.context_tokens; const prompt = usage.prompt_tokens; @@ -253,7 +249,6 @@ function TurnUsageMeta({ })); } - if (typeof latencyMs === "number" && latencyMs >= 0) parts.push(compactDuration(latencyMs)); if (parts.length === 0) return null; if (approximate) { details.push(t("message.usage.estimated", { defaultValue: "Includes estimated usage" })); @@ -287,6 +282,17 @@ function TurnUsageMeta({ ); } +function TurnLatencyMeta({ latencyMs }: { latencyMs: number }) { + return ( + + {compactDuration(latencyMs)} + + ); +} + function deliveryErrorCopy( kind: MessageDeliveryErrorKind | undefined, t: (key: string) => string, @@ -435,6 +441,7 @@ export function MessageBubble({ isTurnStreaming = false, temporary = false, showCopyAction = true, + showTurnUsage = false, cliApps = [], mcpPresets = [], slashCommands = [], @@ -590,9 +597,21 @@ export function MessageBubble({ && (!empty || hasReasoning || media.length > 0); const assistantTimestampTitle = showAssistantTimestamp ? fmtDateTime(assistantTimestamp) : ""; const showAutomationTrigger = showAssistantTimestamp && automationSourceLabel.length > 0; - const showUsage = message.role === "assistant" && !!message.usage && !message.isStreaming; + const showUsage = ( + showTurnUsage + && message.role === "assistant" + && !!message.usage + && !message.isStreaming + ); + const showLatency = ( + message.role === "assistant" + && !!message.usage + && !message.isStreaming + && typeof message.latencyMs === "number" + && message.latencyMs >= 0 + ); const showAssistantFooterRow = - showCopyButton || showForkButton || showAssistantTimestamp || showUsage; + showCopyButton || showForkButton || showAssistantTimestamp || showUsage || showLatency; const showAssistantFooterSlot = message.role === "assistant" && (!empty || hasReasoning || media.length > 0); @@ -658,12 +677,8 @@ export function MessageBubble({ {forkLabel} ) : null} - {showUsage ? ( - - ) : null} + {showUsage ? : null} + {showLatency ? : null} {showAssistantTimestamp ? ( + + ( + onChangeLocalPrefs((prev) => ({ ...prev, showTurnUsage })) + )} + ariaLabel={tx("settings.rows.showTurnUsage", "Show per-turn token usage")} + label={localPrefs.showTurnUsage ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")} + /> + (null); const units = useMemo( () => buildDisplayUnits(messages, isStreaming, activeTurnId), @@ -158,6 +160,7 @@ export function ThreadMessages({ showForkBoundary={index === forkBoundaryAfterUnitIndex} forkBoundaryLabel={t("thread.forkedFromHistory")} temporary={temporary} + showTurnUsage={showTurnUsage} cliApps={cliApps} mcpPresets={mcpPresets} slashCommands={slashCommands} @@ -239,6 +242,7 @@ interface ThreadDisplayUnitProps { showForkBoundary: boolean; forkBoundaryLabel: string; temporary: boolean; + showTurnUsage: boolean; cliApps: CliAppInfo[]; mcpPresets: McpPresetInfo[]; slashCommands: SlashCommand[]; @@ -257,6 +261,7 @@ const ThreadDisplayUnit = memo(function ThreadDisplayUnit({ showForkBoundary, forkBoundaryLabel, temporary, + showTurnUsage, cliApps, mcpPresets, slashCommands, @@ -295,6 +300,7 @@ const ThreadDisplayUnit = memo(function ThreadDisplayUnit({ message={unit.message} isTurnStreaming={isTurnStreaming} temporary={temporary} + showTurnUsage={showTurnUsage} cliApps={cliApps} mcpPresets={mcpPresets} slashCommands={slashCommands} @@ -323,6 +329,7 @@ function threadDisplayUnitPropsEqual( && previous.showForkBoundary === next.showForkBoundary && previous.forkBoundaryLabel === next.forkBoundaryLabel && previous.temporary === next.temporary + && previous.showTurnUsage === next.showTurnUsage && previous.cliApps === next.cliApps && previous.mcpPresets === next.mcpPresets && previous.slashCommands === next.slashCommands diff --git a/webui/src/hooks/useShowTurnUsage.ts b/webui/src/hooks/useShowTurnUsage.ts new file mode 100644 index 000000000..3e2cf056c --- /dev/null +++ b/webui/src/hooks/useShowTurnUsage.ts @@ -0,0 +1,33 @@ +import { useEffect, useState } from "react"; + +import { + LOCAL_PREFS_CHANGED_EVENT, + readLocalPreferences, + type LocalPreferences, +} from "@/lib/local-preferences"; + +export function useShowTurnUsage(): boolean { + const [showTurnUsage, setShowTurnUsage] = useState( + () => readLocalPreferences().showTurnUsage, + ); + + useEffect(() => { + const refresh = () => setShowTurnUsage(readLocalPreferences().showTurnUsage); + const refreshFromLocalPreferenceEvent = (event: Event) => { + const detail = (event as CustomEvent | undefined>).detail; + setShowTurnUsage( + detail ? detail.showTurnUsage === true : readLocalPreferences().showTurnUsage, + ); + }; + window.addEventListener("storage", refresh); + window.addEventListener("focus", refresh); + window.addEventListener(LOCAL_PREFS_CHANGED_EVENT, refreshFromLocalPreferenceEvent); + return () => { + window.removeEventListener("storage", refresh); + window.removeEventListener("focus", refresh); + window.removeEventListener(LOCAL_PREFS_CHANGED_EVENT, refreshFromLocalPreferenceEvent); + }; + }, []); + + return showTurnUsage; +} diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json index 81abcf15d..14e73d0f2 100644 --- a/webui/src/i18n/locales/en/common.json +++ b/webui/src/i18n/locales/en/common.json @@ -196,6 +196,7 @@ "density": "Density", "activityMode": "Activity detail", "fileEditDisplay": "File edit display", + "showTurnUsage": "Show per-turn token usage", "codeWrap": "Code wrapping", "brandLogos": "Brand logos", "maxResults": "Max results", @@ -241,6 +242,7 @@ "density": "Stored only in this browser.", "activityMode": "Choose how much agent activity chrome to show by default.", "fileEditDisplay": "Choose whether file edit activity opens as line counts or a diff.", + "showTurnUsage": "Show context, aggregate input, output, cache rate, and model calls under assistant messages. Stored only in this browser.", "codeWrap": "Keep long code lines readable on smaller screens.", "brandLogos": "Show third-party provider and CLI logos in Settings.", "maxResults": "Results returned by each web_search call.", diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json index b960fb670..b709c61f8 100644 --- a/webui/src/i18n/locales/es/common.json +++ b/webui/src/i18n/locales/es/common.json @@ -142,6 +142,7 @@ "density": "Densidad", "activityMode": "Detalle de actividad", "fileEditDisplay": "Vista de edición de archivos", + "showTurnUsage": "Mostrar uso de tokens por turno", "codeWrap": "Ajuste de código", "maxResults": "Resultados máximos", "timeout": "Tiempo de espera", @@ -185,6 +186,7 @@ "density": "Solo se guarda en este navegador.", "activityMode": "Elige cuánto detalle de actividad del agente se muestra por defecto.", "fileEditDisplay": "Elige si la actividad de edición muestra recuentos de líneas o diferencias.", + "showTurnUsage": "Muestra el contexto, la entrada acumulada, la salida, la tasa de caché y las llamadas al modelo debajo de los mensajes del asistente. Solo se guarda en este navegador.", "codeWrap": "Mantiene legibles las líneas largas de código en pantallas pequeñas.", "maxResults": "Resultados devueltos por cada llamada web_search.", "timeout": "Segundos antes de que una solicitud de búsqueda expire.", diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json index c7686a501..2f1ff43e2 100644 --- a/webui/src/i18n/locales/fr/common.json +++ b/webui/src/i18n/locales/fr/common.json @@ -142,6 +142,7 @@ "density": "Densité", "activityMode": "Détail d’activité", "fileEditDisplay": "Affichage des modifications de fichiers", + "showTurnUsage": "Afficher l’utilisation des tokens par tour", "codeWrap": "Retour à la ligne du code", "maxResults": "Résultats max.", "timeout": "Délai d’attente", @@ -185,6 +186,7 @@ "density": "Enregistré seulement dans ce navigateur.", "activityMode": "Choisissez le niveau de détail de l’activité de l’agent affiché par défaut.", "fileEditDisplay": "Choisissez si l’activité de modification affiche le nombre de lignes ou les différences.", + "showTurnUsage": "Affiche le contexte, l’entrée cumulée, la sortie, le taux de cache et le nombre d’appels au modèle sous les messages de l’assistant. Enregistré uniquement dans ce navigateur.", "codeWrap": "Garde les longues lignes de code lisibles sur les petits écrans.", "maxResults": "Résultats renvoyés par chaque appel web_search.", "timeout": "Nombre de secondes avant l’expiration d’une requête de recherche.", diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json index c50da5313..b66cee339 100644 --- a/webui/src/i18n/locales/id/common.json +++ b/webui/src/i18n/locales/id/common.json @@ -142,6 +142,7 @@ "density": "Kerapatan", "activityMode": "Detail aktivitas", "fileEditDisplay": "Tampilan perubahan file", + "showTurnUsage": "Tampilkan penggunaan token per giliran", "codeWrap": "Bungkus kode", "maxResults": "Hasil maksimum", "timeout": "Batas waktu", @@ -185,6 +186,7 @@ "density": "Hanya disimpan di browser ini.", "activityMode": "Pilih seberapa banyak detail aktivitas agen yang ditampilkan secara default.", "fileEditDisplay": "Pilih apakah aktivitas perubahan file ditampilkan sebagai jumlah baris atau perbedaan.", + "showTurnUsage": "Tampilkan konteks, input kumulatif, output, rasio cache, dan jumlah panggilan model di bawah pesan asisten. Hanya disimpan di browser ini.", "codeWrap": "Menjaga baris kode panjang tetap terbaca di layar kecil.", "maxResults": "Hasil yang dikembalikan oleh setiap panggilan web_search.", "timeout": "Detik sebelum permintaan penyedia pencarian mencapai batas waktu.", diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json index dc0fc843e..b19ad96b9 100644 --- a/webui/src/i18n/locales/ja/common.json +++ b/webui/src/i18n/locales/ja/common.json @@ -142,6 +142,7 @@ "density": "表示密度", "activityMode": "アクティビティ詳細", "fileEditDisplay": "ファイル編集表示", + "showTurnUsage": "ターンごとのトークン使用量を表示", "codeWrap": "コードの折り返し", "maxResults": "最大結果数", "timeout": "タイムアウト", @@ -185,6 +186,7 @@ "density": "このブラウザーにのみ保存されます。", "activityMode": "既定で表示するエージェントアクティビティの詳細量を選択します。", "fileEditDisplay": "ファイル編集アクティビティを行数または差分で表示します。", + "showTurnUsage": "アシスタントのメッセージの下に、コンテキスト、累積入力、出力、キャッシュ率、モデル呼び出し回数を表示します。このブラウザーにのみ保存されます。", "codeWrap": "小さな画面でも長いコード行を読みやすくします。", "maxResults": "各 web_search 呼び出しで返す結果数です。", "timeout": "検索プロバイダーのリクエストがタイムアウトするまでの秒数です。", diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json index 2b8206a08..6fe0b17af 100644 --- a/webui/src/i18n/locales/ko/common.json +++ b/webui/src/i18n/locales/ko/common.json @@ -142,6 +142,7 @@ "density": "밀도", "activityMode": "활동 상세", "fileEditDisplay": "파일 편집 표시", + "showTurnUsage": "턴별 토큰 사용량 표시", "codeWrap": "코드 줄바꿈", "maxResults": "최대 결과 수", "timeout": "타임아웃", @@ -185,6 +186,7 @@ "density": "이 브라우저에만 저장됩니다.", "activityMode": "기본으로 표시할 에이전트 활동 세부 수준을 선택합니다.", "fileEditDisplay": "파일 편집 활동을 줄 수 또는 변경 사항으로 표시할지 선택합니다.", + "showTurnUsage": "어시스턴트 메시지 아래에 컨텍스트, 누적 입력, 출력, 캐시 비율, 모델 호출 횟수를 표시합니다. 이 브라우저에만 저장됩니다.", "codeWrap": "작은 화면에서도 긴 코드 줄을 읽기 쉽게 유지합니다.", "maxResults": "각 web_search 호출에서 반환되는 결과 수입니다.", "timeout": "검색 제공자 요청이 타임아웃되기 전의 초입니다.", diff --git a/webui/src/i18n/locales/pt-BR/common.json b/webui/src/i18n/locales/pt-BR/common.json index e17d43ecd..1444323b1 100644 --- a/webui/src/i18n/locales/pt-BR/common.json +++ b/webui/src/i18n/locales/pt-BR/common.json @@ -196,6 +196,7 @@ "density": "Densidade", "activityMode": "Detalhe de atividade", "fileEditDisplay": "Exibição de edição de arquivo", + "showTurnUsage": "Mostrar uso de tokens por turno", "codeWrap": "Quebra de linha no código", "brandLogos": "Logos de marca", "maxResults": "Máx. de resultados", @@ -241,6 +242,7 @@ "density": "Armazenado apenas neste navegador.", "activityMode": "Escolha quanto detalhe de atividade do agente é exibido por padrão.", "fileEditDisplay": "Escolha se a atividade de edição de arquivo é exibida como contagem de linhas ou como diferenças.", + "showTurnUsage": "Mostra contexto, entrada acumulada, saída, taxa de cache e chamadas do modelo abaixo das mensagens do assistente. Salvo somente neste navegador.", "codeWrap": "Mantém linhas longas de código legíveis em telas menores.", "brandLogos": "Mostra logotipos de provedores terceiros e de CLIs em Configurações.", "maxResults": "Resultados retornados por cada chamada de web_search.", diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json index 03869794a..704bcc583 100644 --- a/webui/src/i18n/locales/vi/common.json +++ b/webui/src/i18n/locales/vi/common.json @@ -142,6 +142,7 @@ "density": "Mật độ", "activityMode": "Chi tiết hoạt động", "fileEditDisplay": "Hiển thị sửa tệp", + "showTurnUsage": "Hiển thị mức dùng token theo lượt", "codeWrap": "Xuống dòng mã", "maxResults": "Kết quả tối đa", "timeout": "Thời gian chờ", @@ -185,6 +186,7 @@ "density": "Chỉ lưu trong trình duyệt này.", "activityMode": "Chọn mức chi tiết hoạt động của tác nhân hiển thị mặc định.", "fileEditDisplay": "Chọn hoạt động sửa tệp hiển thị số dòng hay khác biệt.", + "showTurnUsage": "Hiển thị ngữ cảnh, đầu vào tích lũy, đầu ra, tỷ lệ bộ nhớ đệm và số lần gọi mô hình bên dưới tin nhắn của trợ lý. Chỉ được lưu trong trình duyệt này.", "codeWrap": "Giữ các dòng mã dài dễ đọc trên màn hình nhỏ.", "maxResults": "Số kết quả được trả về sau mỗi lần gọi web_search.", "timeout": "Số giây trước khi yêu cầu của nhà cung cấp tìm kiếm hết thời gian.", diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json index 361b9fd29..0dfb92d75 100644 --- a/webui/src/i18n/locales/zh-CN/common.json +++ b/webui/src/i18n/locales/zh-CN/common.json @@ -196,6 +196,7 @@ "density": "密度", "activityMode": "活动详情", "fileEditDisplay": "文件编辑显示", + "showTurnUsage": "显示每轮 Token 用量", "codeWrap": "代码换行", "brandLogos": "品牌 Logo", "maxResults": "最大结果数", @@ -241,6 +242,7 @@ "density": "只保存在此浏览器中。", "activityMode": "选择默认显示多少智能体活动详情。", "fileEditDisplay": "选择文件编辑活动默认显示行数还是差异。", + "showTurnUsage": "在助手消息下显示上下文、累计输入、输出、缓存率和模型调用次数;仅保存在此浏览器。", "codeWrap": "让长代码行在小屏幕上也易读。", "brandLogos": "在设置中显示第三方提供商和 CLI 图标。", "maxResults": "每次 web_search 调用返回的结果数。", diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json index 127058c71..b71526fab 100644 --- a/webui/src/i18n/locales/zh-TW/common.json +++ b/webui/src/i18n/locales/zh-TW/common.json @@ -142,6 +142,7 @@ "density": "密度", "activityMode": "活動細節", "fileEditDisplay": "檔案編輯顯示", + "showTurnUsage": "顯示每輪 Token 用量", "codeWrap": "程式碼換行", "maxResults": "最大結果數", "timeout": "逾時", @@ -185,6 +186,7 @@ "density": "只儲存在此瀏覽器中。", "activityMode": "選擇預設顯示多少智能體活動細節。", "fileEditDisplay": "選擇檔案編輯活動預設顯示行數或差異。", + "showTurnUsage": "在助理訊息下顯示上下文、累計輸入、輸出、快取率和模型呼叫次數;僅儲存在此瀏覽器。", "codeWrap": "讓長程式碼行在小螢幕上也易讀。", "maxResults": "每次呼叫 web_search 所回傳的結果數。", "timeout": "搜尋供應商請求逾時前的秒數。", diff --git a/webui/src/lib/local-preferences.ts b/webui/src/lib/local-preferences.ts index 53e3c4836..96c79ac92 100644 --- a/webui/src/lib/local-preferences.ts +++ b/webui/src/lib/local-preferences.ts @@ -8,6 +8,7 @@ export interface LocalPreferences { codeWrap: boolean; brandLogos: boolean; fileEditDisplayMode: FileEditDisplayMode; + showTurnUsage: boolean; } export const LOCAL_PREFS_STORAGE_KEY = "nanobot-webui.settings-preferences"; @@ -19,6 +20,7 @@ export const DEFAULT_LOCAL_PREFS: LocalPreferences = { codeWrap: true, brandLogos: false, fileEditDisplayMode: "summary", + showTurnUsage: false, }; export function normalizeFileEditDisplayMode(value: unknown): FileEditDisplayMode { @@ -36,6 +38,7 @@ export function readLocalPreferences(): LocalPreferences { codeWrap: parsed.codeWrap !== false, brandLogos: parsed.brandLogos === true, fileEditDisplayMode: normalizeFileEditDisplayMode(parsed.fileEditDisplayMode), + showTurnUsage: parsed.showTurnUsage === true, }; } catch { return DEFAULT_LOCAL_PREFS; diff --git a/webui/src/tests/i18n.test.tsx b/webui/src/tests/i18n.test.tsx index 29644f81f..6539ffeb3 100644 --- a/webui/src/tests/i18n.test.tsx +++ b/webui/src/tests/i18n.test.tsx @@ -160,6 +160,7 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [ "settings.rows.activityMode", "settings.rows.fileEditDisplay", "settings.rows.codeWrap", + "settings.rows.showTurnUsage", "settings.rows.brandLogos", "settings.rows.currentModel", "settings.rows.localServiceAccess", @@ -171,6 +172,7 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [ "settings.help.activityMode", "settings.help.fileEditDisplay", "settings.help.codeWrap", + "settings.help.showTurnUsage", "settings.help.brandLogos", "settings.help.currentModel", "settings.help.localServiceAccess", diff --git a/webui/src/tests/message-bubble.test.tsx b/webui/src/tests/message-bubble.test.tsx index cba5040da..3e62eec7d 100644 --- a/webui/src/tests/message-bubble.test.tsx +++ b/webui/src/tests/message-bubble.test.tsx @@ -1,8 +1,14 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { MessageBubble } from "@/components/MessageBubble"; +import { useShowTurnUsage } from "@/hooks/useShowTurnUsage"; import { fmtDateTime, formatMessageEndTime } from "@/lib/format"; +import { + DEFAULT_LOCAL_PREFS, + LOCAL_PREFS_STORAGE_KEY, + writeLocalPreferences, +} from "@/lib/local-preferences"; import type { CliAppInfo, McpPresetInfo, @@ -67,6 +73,11 @@ const MCP_PRESETS: McpPresetInfo[] = [ }, ]; +function PreferenceAwareMessageBubble({ message }: { message: UIMessage }) { + const showTurnUsage = useShowTurnUsage(); + return ; +} + const SLASH_COMMANDS: SlashCommand[] = [ { command: "/model", @@ -95,6 +106,10 @@ const SLASH_COMMANDS: SlashCommand[] = [ ]; describe("MessageBubble", () => { + afterEach(() => { + localStorage.removeItem(LOCAL_PREFS_STORAGE_KEY); + }); + it("renders user messages as right-aligned pills", () => { const message: UIMessage = { id: "u1", @@ -1013,6 +1028,40 @@ describe("MessageBubble", () => { expect(screen.queryByLabelText("File attachment")).not.toBeInTheDocument(); }); + it("hides turn usage by default and leaves latency visible when the preference changes", async () => { + const message: UIMessage = { + id: "turn-usage-preference", + role: "assistant", + content: "done", + createdAt: Date.now(), + latencyMs: 18_200, + usage: { + prompt_tokens: 12_400, + completion_tokens: 823, + context_tokens: 8_100, + request_count: 1, + }, + }; + + const { container } = render(); + + expect(container.querySelector("[data-turn-usage]")).not.toBeInTheDocument(); + expect(container.querySelector("[data-turn-latency]")).toHaveTextContent("18s"); + + act(() => { + writeLocalPreferences({ ...DEFAULT_LOCAL_PREFS, showTurnUsage: true }); + }); + expect(await screen.findByText("8.1K context")).toHaveAttribute("data-turn-usage"); + + act(() => { + writeLocalPreferences({ ...DEFAULT_LOCAL_PREFS, showTurnUsage: false }); + }); + await waitFor(() => { + expect(container.querySelector("[data-turn-usage]")).not.toBeInTheDocument(); + }); + expect(container.querySelector("[data-turn-latency]")).toHaveTextContent("18s"); + }); + it("shows consecutive contexts while keeping each turn's aggregate cost in details", async () => { const turns: UIMessage[] = [ { @@ -1043,7 +1092,9 @@ describe("MessageBubble", () => { }, ]; - render(<>{turns.map((message) => )}); + render(<>{turns.map((message) => ( + + ))}); const turnAUsage = screen.getByText("56K context"); const turnBUsage = screen.getByText("57K context"); @@ -1079,7 +1130,7 @@ describe("MessageBubble", () => { }, }; - render(); + render(); const usage = screen.getByText("~1.3K turn input"); expect(usage).not.toHaveTextContent("context"); diff --git a/webui/src/tests/settings-overview.test.tsx b/webui/src/tests/settings-overview.test.tsx index a9dfdc970..4e2684650 100644 --- a/webui/src/tests/settings-overview.test.tsx +++ b/webui/src/tests/settings-overview.test.tsx @@ -1,5 +1,6 @@ -import { act, fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { act, cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react"; import { expect, it, vi } from "vitest"; +import { LOCAL_PREFS_STORAGE_KEY } from "@/lib/local-preferences"; import type { SettingsPayload } from "@/lib/types"; import { jsonResponse, settingsPayload, renderSettingsView, installSettingsViewTestHooks } from "@/tests/settings-test-utils"; @@ -10,6 +11,44 @@ describe("Settings overview and appearance", () => { installSettingsViewTestHooks(); + it("defaults per-turn token usage off and persists changes across remounts", async () => { + const renderAppearance = () => renderSettingsView({ + initialSection: "appearance", + initialSettings: settingsPayload(), + showSidebar: true, + }); + + renderAppearance(); + + const title = "Show per-turn token usage"; + const description = ( + "Show context, aggregate input, output, cache rate, and model calls under assistant messages. " + + "Stored only in this browser." + ); + expect(screen.getByText(description)).toBeInTheDocument(); + expect(screen.getByRole("switch", { name: title })).toHaveAttribute("aria-checked", "false"); + + fireEvent.click(screen.getByRole("switch", { name: title })); + await waitFor(() => { + const saved = JSON.parse(localStorage.getItem(LOCAL_PREFS_STORAGE_KEY) || "{}"); + expect(saved.showTurnUsage).toBe(true); + }); + + cleanup(); + renderAppearance(); + expect(screen.getByRole("switch", { name: title })).toHaveAttribute("aria-checked", "true"); + + fireEvent.click(screen.getByRole("switch", { name: title })); + await waitFor(() => { + const saved = JSON.parse(localStorage.getItem(LOCAL_PREFS_STORAGE_KEY) || "{}"); + expect(saved.showTurnUsage).toBe(false); + }); + + cleanup(); + renderAppearance(); + expect(screen.getByRole("switch", { name: title })).toHaveAttribute("aria-checked", "false"); + }); + it("persists the file edit display local preference", async () => { renderSettingsView({ initialSection: "appearance",