mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 08:13:11 +03:00
fix(webui): gate turn usage behind local preference
This commit is contained in:
@@ -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 (
|
||||
<span
|
||||
data-turn-latency
|
||||
className="text-[11px] leading-none text-muted-foreground/70 tabular-nums"
|
||||
>
|
||||
{compactDuration(latencyMs)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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({
|
||||
<TooltipContent side="top" align="center">{forkLabel}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{showUsage ? (
|
||||
<TurnUsageMeta
|
||||
usage={message.usage!}
|
||||
latencyMs={message.latencyMs}
|
||||
/>
|
||||
) : null}
|
||||
{showUsage ? <TurnUsageMeta usage={message.usage!} /> : null}
|
||||
{showLatency ? <TurnLatencyMeta latencyMs={message.latencyMs!} /> : null}
|
||||
{showAssistantTimestamp ? (
|
||||
<MessageTimestamp
|
||||
{...(showCompletedAt ? { "data-assistant-completed-at": true } : {})}
|
||||
|
||||
@@ -407,6 +407,22 @@ export function AppearanceSettings({
|
||||
label={localPrefs.codeWrap ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
title={tx("settings.rows.showTurnUsage", "Show per-turn token usage")}
|
||||
description={tx(
|
||||
"settings.help.showTurnUsage",
|
||||
"Show context, aggregate input, output, cache rate, and model calls under assistant messages. Stored only in this browser.",
|
||||
)}
|
||||
>
|
||||
<ToggleButton
|
||||
checked={localPrefs.showTurnUsage}
|
||||
onChange={(showTurnUsage) => (
|
||||
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")}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
title={tx("settings.rows.brandLogos", "Brand logos")}
|
||||
description={tx(
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { MessageBubble } from "@/components/MessageBubble";
|
||||
import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster";
|
||||
import { AssistantSelectionAction } from "@/components/thread/AssistantSelectionAction";
|
||||
import { useShowTurnUsage } from "@/hooks/useShowTurnUsage";
|
||||
import { projectActivityTimeline, type TurnUnit } from "@/lib/activity-timeline";
|
||||
import type { CliAppInfo, McpPresetInfo, SlashCommand, UIMessage } from "@/lib/types";
|
||||
|
||||
@@ -67,6 +68,7 @@ export function ThreadMessages({
|
||||
onQuoteSelection,
|
||||
}: ThreadMessagesProps) {
|
||||
const { t } = useTranslation();
|
||||
const showTurnUsage = useShowTurnUsage();
|
||||
const messageListRef = useRef<HTMLDivElement>(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
|
||||
|
||||
@@ -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<Partial<LocalPreferences> | 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;
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -142,6 +142,7 @@
|
||||
"density": "表示密度",
|
||||
"activityMode": "アクティビティ詳細",
|
||||
"fileEditDisplay": "ファイル編集表示",
|
||||
"showTurnUsage": "ターンごとのトークン使用量を表示",
|
||||
"codeWrap": "コードの折り返し",
|
||||
"maxResults": "最大結果数",
|
||||
"timeout": "タイムアウト",
|
||||
@@ -185,6 +186,7 @@
|
||||
"density": "このブラウザーにのみ保存されます。",
|
||||
"activityMode": "既定で表示するエージェントアクティビティの詳細量を選択します。",
|
||||
"fileEditDisplay": "ファイル編集アクティビティを行数または差分で表示します。",
|
||||
"showTurnUsage": "アシスタントのメッセージの下に、コンテキスト、累積入力、出力、キャッシュ率、モデル呼び出し回数を表示します。このブラウザーにのみ保存されます。",
|
||||
"codeWrap": "小さな画面でも長いコード行を読みやすくします。",
|
||||
"maxResults": "各 web_search 呼び出しで返す結果数です。",
|
||||
"timeout": "検索プロバイダーのリクエストがタイムアウトするまでの秒数です。",
|
||||
|
||||
@@ -142,6 +142,7 @@
|
||||
"density": "밀도",
|
||||
"activityMode": "활동 상세",
|
||||
"fileEditDisplay": "파일 편집 표시",
|
||||
"showTurnUsage": "턴별 토큰 사용량 표시",
|
||||
"codeWrap": "코드 줄바꿈",
|
||||
"maxResults": "최대 결과 수",
|
||||
"timeout": "타임아웃",
|
||||
@@ -185,6 +186,7 @@
|
||||
"density": "이 브라우저에만 저장됩니다.",
|
||||
"activityMode": "기본으로 표시할 에이전트 활동 세부 수준을 선택합니다.",
|
||||
"fileEditDisplay": "파일 편집 활동을 줄 수 또는 변경 사항으로 표시할지 선택합니다.",
|
||||
"showTurnUsage": "어시스턴트 메시지 아래에 컨텍스트, 누적 입력, 출력, 캐시 비율, 모델 호출 횟수를 표시합니다. 이 브라우저에만 저장됩니다.",
|
||||
"codeWrap": "작은 화면에서도 긴 코드 줄을 읽기 쉽게 유지합니다.",
|
||||
"maxResults": "각 web_search 호출에서 반환되는 결과 수입니다.",
|
||||
"timeout": "검색 제공자 요청이 타임아웃되기 전의 초입니다.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 调用返回的结果数。",
|
||||
|
||||
@@ -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": "搜尋供應商請求逾時前的秒數。",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 <MessageBubble message={message} showTurnUsage={showTurnUsage} />;
|
||||
}
|
||||
|
||||
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(<PreferenceAwareMessageBubble message={message} />);
|
||||
|
||||
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) => <MessageBubble key={message.id} message={message} />)}</>);
|
||||
render(<>{turns.map((message) => (
|
||||
<MessageBubble key={message.id} message={message} showTurnUsage />
|
||||
))}</>);
|
||||
|
||||
const turnAUsage = screen.getByText("56K context");
|
||||
const turnBUsage = screen.getByText("57K context");
|
||||
@@ -1079,7 +1130,7 @@ describe("MessageBubble", () => {
|
||||
},
|
||||
};
|
||||
|
||||
render(<MessageBubble message={message} />);
|
||||
render(<MessageBubble message={message} showTurnUsage />);
|
||||
|
||||
const usage = screen.getByText("~1.3K turn input");
|
||||
expect(usage).not.toHaveTextContent("context");
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user