diff --git a/components/AppShell.tsx b/components/AppShell.tsx index e02bdd8240..4a65a5978b 100644 --- a/components/AppShell.tsx +++ b/components/AppShell.tsx @@ -5,6 +5,7 @@ import { useRouter, useSearchParams } from "next/navigation"; import { useGlobalKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts"; import { SessionSidebar, type SelectSessionOptions, type SessionSidebarControl } from "./SessionSidebar"; import { ChatWindow } from "./ChatWindow"; +import { UsageIcon } from "./UsageIcon"; import { NewSessionContextBar, type NewSessionContextControl } from "./NewSessionContextBar"; import type { ChatScrollPosition } from "@/lib/chat-scroll-position"; import { FileViewer } from "./FileViewer"; @@ -1951,17 +1952,13 @@ export function AppShell() { <> {tokens && tokens.input > 0 && ( - + {formatCompact(tokens.input)} )} {tokens && tokens.output > 0 && ( - + {formatCompact(tokens.output)} )} @@ -1985,25 +1982,19 @@ export function AppShell() { <> {tokens && tokens.input > 0 && ( - + {formatCompact(tokens.input)} )} {tokens && tokens.output > 0 && ( - + {formatCompact(tokens.output)} )} {tokens && tokens.cacheRead > 0 && ( - + {formatCompact(tokens.cacheRead)} )} diff --git a/components/ChatWindow.tsx b/components/ChatWindow.tsx index 788e10f4b3..49dfb43c8b 100644 --- a/components/ChatWindow.tsx +++ b/components/ChatWindow.tsx @@ -12,6 +12,7 @@ import { collapsesProcessDetails, countToolCallBlocks, getDisplayableAssistantBl import { extractTurnWrittenFiles, type WrittenFile } from "@/lib/turn-written-files"; import { getFinalAnswerViews, keepWrittenFiles, type FinalAnswerViews } from "@/lib/turn-views"; import { formatTurnDuration, getTurnDurationMs } from "@/lib/turn-duration"; +import { summarizeTurnUsage, type MessageUsageSummary } from "@/lib/message-usage"; import { buildQuotedSelection } from "@/lib/quoted-selection"; import { dropMentionText, splitDroppedItems, uploadFiles, type DroppedItem } from "@/lib/file-upload-client"; import { MessageView } from "./MessageView"; @@ -306,6 +307,7 @@ export function ChatWindow({ session, searchTarget, onSearchTargetHandled, initi onOpenSettings, deferInitialScroll: Boolean(pendingScrollRestore), }); + const showUsageDetails = useCallback((message: string) => addNotice({ message }), [addNotice]); // Only whether a reply is streaming and has content yet: the chunks themselves // re-render StreamingReply alone. const isStreaming = useStreamingSelector(streamingStore, selectIsStreaming); @@ -1120,7 +1122,7 @@ export function ChatWindow({ session, searchTarget, onSearchTargetHandled, initi if (idx === lastUserIdx) { (lastUserMsgRef as { current: HTMLDivElement | null }).current = el; } }; - const renderMessage = (idx: number, options: { attachRef?: boolean; keyPrefix?: string; messageOverride?: AgentMessage; showTimestamp?: boolean; writtenFiles?: WrittenFile[]; recoverTruncation?: boolean; turnDurationMs?: number; withNotices?: boolean } = {}): ReactNode => { + const renderMessage = (idx: number, options: { attachRef?: boolean; keyPrefix?: string; messageOverride?: AgentMessage; showTimestamp?: boolean; writtenFiles?: WrittenFile[]; recoverTruncation?: boolean; turnDurationMs?: number; usageSummary?: MessageUsageSummary; withNotices?: boolean } = {}): ReactNode => { const msg = options.messageOverride ?? messages[idx]; if (isHiddenCustomMessage(msg)) return null; // pi's showCacheMissNotices (#1265): read from the stored message, not a turn view's copy. @@ -1166,6 +1168,8 @@ export function ChatWindow({ session, searchTarget, onSearchTargetHandled, initi sessionId={session?.id ?? sessionIdRef.current ?? undefined} writtenFiles={options.writtenFiles} turnDurationMs={options.turnDurationMs} + usageSummary={options.usageSummary} + onShowUsageDetails={showUsageDetails} onCompact={options.recoverTruncation ? handleCompact : undefined} isCompacting={options.recoverTruncation ? isCompacting : undefined} compactError={options.recoverTruncation ? compactError : undefined} @@ -1284,7 +1288,7 @@ export function ChatWindow({ session, searchTarget, onSearchTargetHandled, initi would otherwise stay open once its answer shows up, e.g. when switching between an answered and an unanswered leaf of the same turn. Manual toggles survive every other re-render. */} - + {processViews} , @@ -1307,8 +1311,8 @@ export function ChatWindow({ session, searchTarget, onSearchTargetHandled, initi rendered.push(renderMessage(finalAssistantIdx, { messageOverride: finalAnswerMessage, writtenFiles, - // Without process details the turn's time goes under the answer. - turnDurationMs: processViews.length === 0 ? turnDurationMs : undefined, + turnDurationMs, + usageSummary: hasAnchor ? summarizeTurnUsage(messages, groupStartIdx, endIdx, turnDurationMs) : undefined, recoverTruncation: endIdx === messages.length && !isStreaming && !hasAssistantAnswer(finalAnswerMessage), })); } diff --git a/components/MessageUsage.test.mjs b/components/MessageUsage.test.mjs new file mode 100644 index 0000000000..437887eedf --- /dev/null +++ b/components/MessageUsage.test.mjs @@ -0,0 +1,197 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import { createJiti } from "jiti"; + +const jiti = createJiti(import.meta.url, { jsx: { runtime: "automatic" }, tsconfigPaths: true }); +const React = await jiti.import("react"); +const { renderToStaticMarkup } = await jiti.import("react-dom/server"); +const { MessageUsage, usageDetailsText } = await jiti.import("./MessageUsage.tsx"); +const { UsageIcon } = await jiti.import("./UsageIcon.tsx"); +const { MessageView } = await jiti.import("./MessageView.tsx"); +const { I18nProvider } = await jiti.import("@/hooks/useI18n"); +const { getLocalePlugin } = await jiti.import("@/lib/i18n/registry"); +const summary = { + usage: { + input: 314_138, output: 7_241, cacheRead: 1_600_640, cacheWrite: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0.8608 }, + }, + scope: "turn", complete: true, durationMs: 10_000, +}; +const translate = (locale) => (key, params) => { + const text = getLocalePlugin(locale).messages[key]; + assert.ok(text, `missing ${locale} ${key}`); + return text.replace(/\{(\w+)\}/g, (_, name) => String(params[name])); +}; +const render = (component, props) => renderToStaticMarkup(React.createElement(I18nProvider, null, React.createElement(component, props))); + +test("compact summary uses shared thin SVGs, decimal K/M and actual average output rate", () => { + const html = render(MessageUsage, { summary, onShowDetails() {} }); + assert.match(html, /314\.1K/); + assert.match(html, /7\.2K/); + assert.match(html, /1\.6M/); + assert.match(html, /\$0\.8608/); + assert.match(html, /724\.1 t\/s/); + assert.match(html, /took 10s/); + assert.match(html, / { + const withWrite = { ...summary, usage: { ...summary.usage, cacheWrite: 12_000 } }; + const html = render(MessageUsage, { summary: withWrite }); + const icons = [...html.matchAll(/]*>/g)].map(([tag]) => tag); + assert.equal(icons.length, 4); + for (const icon of icons) { + assert.match(icon, /stroke="currentColor"/); + assert.doesNotMatch(icon, /style="[^"]*color:/); + } + const withoutIcons = html.replace(//g, ""); + assert.doesNotMatch(withoutIcons, /color:var\(--accent\)|color:var\(--text-muted\)/); + assert.match(withoutIcons, /color:var\(--text-dim\)/); + for (const kind of ["input", "output", "cacheRead", "cacheWrite"]) { + const neutral = render(UsageIcon, { kind }); + assert.doesNotMatch(neutral, /style="[^"]*color:/, `${kind} is not colored by default`); + assert.match(neutral, /stroke="currentColor"/); + } +}); + +test("details contain exact counts, total, cost, timing and scope in all three locales", () => { + for (const locale of ["en", "zh-CN", "zh-TW"]) { + const text = usageDetailsText(summary, translate(locale), locale); + for (const count of ["314,138", "7,241", "1,600,640", "1,922,019", "$0.8608", "724.1 t/s"]) assert.ok(text.includes(count), `${locale}: ${count}`); + assert.ok(text.startsWith(translate(locale)("chat.usageTurn"))); + assert.ok(!text.includes(`\n${translate(locale)("session.cacheWrite")}:`), "zero cache write tokens are omitted"); + } +}); + +test("summary and details truncate displayed fees to four decimals without mutating recorded values", () => { + for (const [cost, expected] of [[0.023180000000000003, "0.0231"], [0.00001234, "0"], [0.123456789, "0.1234"], [0, "0"]]) { + const precise = { ...summary, usage: { ...summary.usage, cost: { ...summary.usage.cost, total: cost } } }; + const original = structuredClone(precise); + assert.ok(usageDetailsText(precise, translate("en"), "en").includes(`Cost: $${expected}\n`)); + if (cost) assert.ok(render(MessageUsage, { summary: precise }).includes(`$${expected}`)); + assert.deepEqual(precise, original); + } +}); + +test("read-only viewers have no dead button, click label or keyboard focus target", () => { + const html = render(MessageUsage, { summary }); + assert.match(html, / { + const withWrite = { ...summary, usage: { ...summary.usage, cacheWrite: 12_000 } }; + const html = render(MessageUsage, { summary: withWrite }); + assert.match(html, /12K/); + assert.match(html, /M1\.5 5a3\.5 3\.5/); + assert.match(usageDetailsText(withWrite, translate("en"), "en"), /Cache Write: 12,000/); +}); + +test("old or invalid timing and partial totals hide the rate instead of guessing", () => { + for (const durationMs of [undefined, 0, -1, NaN, Infinity]) { + const html = render(MessageUsage, { summary: { ...summary, durationMs } }); + assert.doesNotMatch(html, /[\d.] t\/s|NaN|Infinity/); + } + const partial = { ...summary, complete: false }; + assert.doesNotMatch(render(MessageUsage, { summary: partial }), /[\d.] t\/s/); + assert.match(usageDetailsText(partial, translate("en"), "en"), /Partial usage/); +}); + +test("finished message uses supplied turn totals, not the final response's own usage", () => { + const message = { + role: "assistant", provider: "p", model: "m", timestamp: 1_000, durationMs: 500, + content: [{ type: "text", text: "Done" }], + usage: { ...summary.usage, input: 12, output: 7 }, + }; + const html = render(MessageView, { message, usageSummary: summary, onShowUsageDetails() {} }); + assert.match(html, /314\.1K/); + assert.match(html, /724\.1 t\/s/); + assert.doesNotMatch(html, /14\.0 t\/s/); + const responseHtml = render(MessageView, { message }); + assert.match(responseHtml, /14\.0 t\/s/); + assert.match(responseHtml, /Response usage/); + assert.match(responseHtml, /Model: m/); + assert.match(responseHtml, /Provider: p/); + assert.match(responseHtml, /Request started:/); + assert.match(responseHtml, /Response completed:/); + assert.doesNotMatch(html, /Model:|Provider:|Request started:|Response completed:/); + const liveHtml = render(MessageView, { message, isStreaming: true }); + assert.doesNotMatch(liveHtml, /class="message-usage"/); +}); + +test("details use only supplied response metadata, with localized times and a separate turn scope", () => { + const response = { model: "model-id", provider: "provider-id", timestamp: 1_000, durationMs: 500, stopReason: "toolUse", errorMessage: "Recorded error" }; + for (const locale of ["en", "zh-CN", "zh-TW"]) { + const t = translate(locale); + const text = usageDetailsText({ ...summary, scope: "response", durationMs: 500 }, t, locale, response); + assert.ok(text.includes(`${t("chat.usageModel")}: model-id`)); + const providerLabel = { en: "Provider", "zh-CN": "模型提供商", "zh-TW": "模型供應商" }[locale]; + assert.ok(text.includes(`${providerLabel}: provider-id`)); + assert.ok(text.includes(`${t("chat.usageStopReason")}: toolUse`)); + assert.ok(text.includes(`${t("chat.usageError")}: Recorded error`)); + assert.ok(text.includes(`${t("chat.usageDuration")}: 0.5s`)); + assert.equal(text.split(`${t("chat.usageDuration")}:`).length - 1, 1); + const time = (ms) => new Date(ms).toLocaleString(locale, { + year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", hourCycle: "h23", timeZoneName: "short", + }); + assert.ok(text.includes(`${t("chat.usageRequestStarted")}: ${time(1_000)}`)); + assert.ok(text.includes(`${t("chat.usageResponseCompleted")}: ${time(1_500)}`)); + const turn = usageDetailsText(summary, t, locale, response); + assert.ok(turn.includes(`${t("chat.usageDuration")}: 10s`)); + assert.ok(!turn.includes(`${t("chat.usageModel")}:`)); + assert.ok(!turn.includes(`${t("chat.usageRequestStarted")}:`)); + assert.doesNotMatch(text, /thinkingLevel|Thinking level|思考深度/); + } +}); + +test("invalid or absent response dates never invent a response completion time", () => { + const t = translate("en"); + const single = { ...summary, scope: "response" }; + for (const timestamp of [undefined, -1, NaN, Infinity, 9e15]) { + const text = usageDetailsText(single, t, "en", { timestamp, durationMs: 500 }); + assert.doesNotMatch(text, /Request started:|Response completed:|Invalid Date|NaN|Infinity/); + } + for (const durationMs of [undefined, -1, NaN, Infinity, 9e15]) { + const text = usageDetailsText(single, t, "en", { timestamp: 1_000, durationMs }); + assert.match(text, /Request started:/); + assert.doesNotMatch(text, /Response completed:|Invalid Date|NaN|Infinity/); + } + const zero = usageDetailsText(single, t, "en", { timestamp: 0, durationMs: 0 }); + assert.match(zero, /Request started:/); + assert.match(zero, /Response completed:/); + const noMetadata = usageDetailsText(single, t, "en", {}); + assert.doesNotMatch(noMetadata, /Last response|Model:|Provider:|Stop reason:|Error:/); +}); + +test("fee breakdown truncates supplied values and does not invent missing fee categories", () => { + const cost = { input: 0.000012345, output: 0, cacheRead: 0.0002, cacheWrite: 0.0003, total: 0.000512345 }; + const text = usageDetailsText({ ...summary, usage: { ...summary.usage, cost } }, translate("en"), "en"); + assert.match(text, /Input cost: \$0\n/); + assert.match(text, /Output cost: \$0\n/); + assert.match(text, /Cache Read cost: \$0\.0002/); + assert.match(text, /Cache Write cost: \$0\.0003/); + const missing = usageDetailsText({ ...summary, usage: { ...summary.usage, cost: { total: 1 } } }, translate("en"), "en"); + assert.match(missing, /Cost: \$1/); + assert.doesNotMatch(missing, /Input cost:|Output cost:|Cache Read cost:|Cache Write cost:/); +}); + +test("derived usage changes and click callbacks participate in MessageView memoization", () => { + const props = { message: { role: "assistant", content: [] }, usageSummary: summary }; + assert.equal(MessageView.compare(props, { ...props, usageSummary: structuredClone(summary) }), true); + assert.equal(MessageView.compare(props, { ...props, usageSummary: { ...summary, durationMs: 20_000 } }), false); + assert.equal(MessageView.compare(props, { ...props, onShowUsageDetails() {} }), false); +}); + +test("chat uses the existing toast path and original turn range for summary and duration", async () => { + const source = await readFile(new URL("./ChatWindow.tsx", import.meta.url), "utf8"); + assert.match(source, /addNotice\(\{ message \}\)/); + assert.match(source, /onShowUsageDetails=\{showUsageDetails\}/); + assert.match(source, /usageSummary: hasAnchor \? summarizeTurnUsage\(messages, groupStartIdx, endIdx, turnDurationMs\) : undefined/); + assert.match(source, /durationMs=\{finalAnswerMessage \? undefined : turnDurationMs\}/); +}); diff --git a/components/MessageUsage.tsx b/components/MessageUsage.tsx new file mode 100644 index 0000000000..6a64c51aa0 --- /dev/null +++ b/components/MessageUsage.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { Fragment } from "react"; +import { useI18n } from "@/hooks/useI18n"; +import { averageOutputRate, formatUsageCost, formatUsageTokens, type MessageUsageSummary } from "@/lib/message-usage"; +import { formatTurnDuration } from "@/lib/turn-duration"; +import { UsageIcon, type UsageIconKind } from "./UsageIcon"; +import type { AssistantMessage } from "@/lib/types"; + +type Translate = (key: string, params?: Record) => string; +type ResponseDetails = Pick; + +function recordedTime(value: number | undefined, locale: string): string | undefined { + if (value === undefined || !Number.isFinite(value) || value < 0) return undefined; + const date = new Date(value); + if (!Number.isFinite(date.getTime())) return undefined; + return date.toLocaleString(locale, { + year: "numeric", month: "2-digit", day: "2-digit", + hour: "2-digit", minute: "2-digit", second: "2-digit", hourCycle: "h23", timeZoneName: "short", + }); +} + +/** Use only the response already loaded by MessageView, never current session settings. */ +function responseDetailsText(response: ResponseDetails, t: Translate, locale: string): string[] { + const lines: string[] = []; + if (response.model) lines.push(`${t("chat.usageModel")}: ${response.model}`); + if (response.provider) lines.push(`${t("chat.usageProvider")}: ${response.provider}`); + const started = recordedTime(response.timestamp, locale); + if (started) lines.push(`${t("chat.usageRequestStarted")}: ${started}`); + const ms = response.durationMs; + if (ms !== undefined && Number.isFinite(ms) && ms >= 0) { + const ended = started ? recordedTime(response.timestamp! + ms, locale) : undefined; + if (ended) lines.push(`${t("chat.usageResponseCompleted")}: ${ended}`); + } + if (response.stopReason) lines.push(`${t("chat.usageStopReason")}: ${response.stopReason}`); + if (response.errorMessage) lines.push(`${t("chat.usageError")}: ${response.errorMessage}`); + return lines; +} + +/** Unabbreviated counts in the same scope as the clickable summary. */ +export function usageDetailsText(summary: MessageUsageSummary, t: Translate, locale: string, response?: ResponseDetails): string { + const { usage, scope, durationMs } = summary; + const count = (value: number) => value.toLocaleString(locale); + const lines = [ + t(scope === "turn" ? "chat.usageTurn" : "chat.usageResponse"), + ...(["input", "output", "cacheRead", "cacheWrite"] as const) + .filter((key) => key === "input" || key === "output" || usage[key] > 0) + .map((key) => `${t(`session.${key}`)}: ${count(usage[key])}`), + `${t("session.total")}: ${count(usage.input + usage.output + usage.cacheRead + usage.cacheWrite)}`, + `${t("session.cost")}: $${formatUsageCost(usage.cost?.total ?? 0)}`, + ]; + for (const key of ["input", "output", "cacheRead", "cacheWrite"] as const) { + const cost = usage.cost?.[key]; + if (typeof cost === "number" && Number.isFinite(cost)) { + lines.push(`${t("chat.usageCostDetail", { kind: t(`session.${key}`) })}: $${formatUsageCost(cost)}`); + } + } + if (durationMs !== undefined && Number.isFinite(durationMs) && durationMs >= 0) { + lines.push(`${t("chat.usageDuration")}: ${(durationMs / 1_000).toLocaleString(locale, { maximumFractionDigits: 3 })}s`); + } + const rate = averageOutputRate(summary); + if (rate !== undefined) lines.push(`${t("chat.usageAverage")}: ${rate.toFixed(1)} t/s`); + if (!summary.complete) lines.push(t("chat.usageIncomplete")); + if (response && scope === "response") lines.push(...responseDetailsText(response, t, locale)); + return lines.join("\n"); +} + +export function MessageUsage({ summary, response, onShowDetails }: { + summary: MessageUsageSummary; + response?: ResponseDetails; + onShowDetails?: (message: string) => void; +}) { + const { t, locale } = useI18n(); + const { usage, durationMs } = summary; + const parts = (["input", "output", "cacheRead", "cacheWrite"] as UsageIconKind[]) + .filter((kind) => usage[kind] > 0) + .map((kind) => ( + + {formatUsageTokens(usage[kind])} + + )); + if (usage.cost?.total) parts.push(${formatUsageCost(usage.cost.total)}); + if (durationMs !== undefined && Number.isFinite(durationMs) && durationMs >= 0) { + parts.push({t("chat.turnDuration", { time: formatTurnDuration(durationMs) })}); + } + const rate = averageOutputRate(summary); + if (rate !== undefined) { + parts.push({rate.toFixed(1)} t/s); + } + if (parts.length === 0) return null; + const details = usageDetailsText(summary, t, locale, response); + const Tag = onShowDetails ? "button" : "span"; + return ( + onShowDetails(details) : undefined} + style={{ + display: "inline-flex", alignItems: "center", flexWrap: "wrap", gap: "2px 5px", + minWidth: 0, maxWidth: "100%", padding: "2px 0", border: "none", background: "none", + font: "inherit", fontSize: 11, color: "var(--text-dim)", textAlign: "left", + cursor: onShowDetails ? "pointer" : "default", fontVariantNumeric: "tabular-nums", + }} + > + {parts.map((part, index) => {index > 0 && }{part})} + + ); +} diff --git a/components/MessageView.tsx b/components/MessageView.tsx index 78ff10777b..3169297174 100644 --- a/components/MessageView.tsx +++ b/components/MessageView.tsx @@ -11,6 +11,9 @@ import { useIsMobile } from "@/hooks/useIsMobile"; import { parseCompactionSummary } from "@/lib/compaction-summary"; import { getAssistantErrorMessage, getThinkingPreview, hasAssistantAnswer, isAssistantTruncated, isEmptyThinkingBlock, splitThinkingPieces } from "@/lib/message-display"; import { formatTurnDuration } from "@/lib/turn-duration"; +import { sameUsageSummary, type MessageUsageSummary } from "@/lib/message-usage"; +import { MessageUsage } from "./MessageUsage"; +import { UsageIcon } from "./UsageIcon"; import { diffGapLabel, parseUnifiedPatch, unifiedDiffLines, type SplitDiffCell, type SplitDiffFile, type SplitDiffGap, type UnifiedDiffLine } from "@/lib/patch"; import { applyPatchPreviewToFiles, applyPatchResultHasFailures, extractApplyPatchPaths, getApplyPatchInputText, parseApplyPatchInput } from "@/lib/apply-patch"; import { isApplyPatchToolName, isEditToolName, isWriteToolName } from "@/lib/tool-names"; @@ -217,6 +220,8 @@ interface Props { writtenFiles?: WrittenFile[]; /** The whole turn's time, for a final answer with no process details to show it. */ turnDurationMs?: number; + usageSummary?: MessageUsageSummary; + onShowUsageDetails?: (message: string) => void; onCompact?: () => void; isCompacting?: boolean; compactError?: string | null; @@ -287,12 +292,12 @@ function haveSameRelevantToolResults( return true; } -export const MessageView = memo(function MessageView({ message, isStreaming, toolResults, modelNames, cwd, onOpenFile, onOpenSubagent, entryId, searchBlock, onFork, forking, onEditContent, onCancelEdit, isEditing, showTimestamp, prevTimestamp, sessionId, writtenFiles, turnDurationMs, onCompact, isCompacting, compactError }: Props) { +export const MessageView = memo(function MessageView({ message, isStreaming, toolResults, modelNames, cwd, onOpenFile, onOpenSubagent, entryId, searchBlock, onFork, forking, onEditContent, onCancelEdit, isEditing, showTimestamp, prevTimestamp, sessionId, writtenFiles, turnDurationMs, usageSummary, onShowUsageDetails, onCompact, isCompacting, compactError }: Props) { if (message.role === "user") { return ; } if (message.role === "assistant") { - return ; + return ; } if (message.role === "toolResult") { // Rendered inline under its toolCall — skip standalone rendering if paired @@ -327,6 +332,8 @@ export const MessageView = memo(function MessageView({ message, isStreaming, too && prev.prevTimestamp === next.prevTimestamp && prev.writtenFiles === next.writtenFiles && prev.turnDurationMs === next.turnDurationMs + && sameUsageSummary(prev.usageSummary, next.usageSummary) + && prev.onShowUsageDetails === next.onShowUsageDetails && prev.sessionId === next.sessionId && prev.onCompact === next.onCompact && prev.isCompacting === next.isCompacting @@ -658,6 +665,8 @@ function AssistantMessageView({ searchBlock, writtenFiles, turnDurationMs, + usageSummary, + onShowUsageDetails, onCompact, isCompacting, compactError, @@ -676,6 +685,8 @@ function AssistantMessageView({ searchBlock?: AssistantContentBlock; writtenFiles?: WrittenFile[]; turnDurationMs?: number; + usageSummary?: MessageUsageSummary; + onShowUsageDetails?: (message: string) => void; onCompact?: () => void; isCompacting?: boolean; compactError?: string | null; @@ -843,9 +854,7 @@ function AssistantMessageView({ {est > 0 && ( - - - + {est} {tps !== null && (() => { @@ -943,14 +952,11 @@ function AssistantMessageView({
- {(message.usage || turnDurationMs !== undefined) && !isStreaming && ( -
- {[ - message.usage ? formatUsage(message.usage) : null, - turnDurationMs !== undefined ? t("chat.turnDuration", { time: formatTurnDuration(turnDurationMs) }) : null, - ].filter(Boolean).join(" \u00b7 ")} -
- )} + {!isStreaming && (usageSummary || message.usage) ? ( + + ) : !isStreaming && turnDurationMs !== undefined ? ( + {t("chat.turnDuration", { time: formatTurnDuration(turnDurationMs) })} + ) : null} {textContent && !isStreaming && (