feat(desktop): add SessionUsageFooter component and i18n keys

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
m4
2026-09-18 16:50:10 +08:00
parent 74fa6881ea
commit 917290bec2
9 changed files with 163 additions and 0 deletions
@@ -0,0 +1,74 @@
import { act, cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { $sessions, setSessions } from '@/store/session'
import { makeSessionInfo } from '@/test/session-info'
import { SessionUsageFooter } from './session-usage-footer'
afterEach(() => {
cleanup()
$sessions.set([])
})
describe('SessionUsageFooter', () => {
it('renders cumulative totals, cost and counts for the session', () => {
setSessions([
makeSessionInfo({
actual_cost_usd: 2.31,
id: 's1',
input_tokens: 120_140,
message_count: 42,
output_tokens: 8_320
})
])
const { container } = render(<SessionUsageFooter sessionId="s1" />)
const footer = container.querySelector('[data-slot="session-usage-footer"]')
expect(footer?.textContent).toContain('128.5k tok')
expect(footer?.textContent).toContain('$2.31')
expect(footer?.textContent).toContain('120.1k')
expect(footer?.textContent).toContain('8.3k')
expect(footer?.textContent).toContain('42')
expect(footer?.getAttribute('title')).toContain('120,140')
})
it('omits the cost segment when spend is under a cent (subscription auth)', () => {
setSessions([makeSessionInfo({ estimated_cost_usd: 0.004, id: 's1', input_tokens: 500, output_tokens: 100 })])
const { container } = render(<SessionUsageFooter sessionId="s1" />)
expect(container.querySelector('[data-slot="session-usage-footer"]')?.textContent).not.toContain('$')
})
it('renders nothing for a session with no tokens yet', () => {
setSessions([makeSessionInfo({ id: 's1', input_tokens: 0, message_count: 3, output_tokens: 0 })])
const { container } = render(<SessionUsageFooter sessionId="s1" />)
expect(container.querySelector('[data-slot="session-usage-footer"]')).toBeNull()
})
it('renders nothing when the session is not in the list', () => {
setSessions([makeSessionInfo({ id: 'other' })])
const { container } = render(<SessionUsageFooter sessionId="s1" />)
expect(container.querySelector('[data-slot="session-usage-footer"]')).toBeNull()
})
it('re-renders when the sessions atom refreshes after a turn', () => {
setSessions([makeSessionInfo({ id: 's1', input_tokens: 1_000, output_tokens: 200 })])
const { container } = render(<SessionUsageFooter sessionId="s1" />)
const footer = () => container.querySelector('[data-slot="session-usage-footer"]')
expect(footer()?.textContent).toContain('1.2k tok')
act(() => {
setSessions([makeSessionInfo({ id: 's1', input_tokens: 2_000, output_tokens: 400 })])
})
expect(footer()?.textContent).toContain('2.4k tok')
})
})
@@ -0,0 +1,47 @@
import { useStore } from '@nanostores/react'
import { useI18n } from '@/i18n'
import { compactNumber } from '@/lib/format'
import { $sessions } from '@/store/session'
import { sessionCostUsd } from '@/store/sidebar-archive'
/** Cumulative token/cost totals for one session, rendered at the end of the
* transcript. Reads the sidebar's own source of truth (`$sessions`) so the
* numbers can never disagree with the row, and re-renders itself off the
* nanostores subscription — ChatView hands Thread a footer element keyed
* only by sessionId, keeping Thread's memo bail-out intact. */
export function SessionUsageFooter({ sessionId }: { sessionId: string }) {
const { t } = useI18n()
const sessions = useStore($sessions)
const session = sessions.find(candidate => candidate.id === sessionId)
if (!session) {
return null
}
const totalTokens = session.input_tokens + session.output_tokens
if (totalTokens <= 0) {
return null
}
const cost = sessionCostUsd(session)
const showCost = cost >= 0.01
const copy = t.assistant.thread.usageFooter
return (
<div
className="mt-(--conversation-turn-gap) border-t border-border/65 pb-1 pt-2 text-xs text-muted-foreground"
data-slot="session-usage-footer"
title={`${session.input_tokens.toLocaleString('en-US')} + ${session.output_tokens.toLocaleString('en-US')}${showCost ? ` · $${cost.toFixed(2)}` : ''}`}
>
<p>
{copy.title} {compactNumber(totalTokens)} tok{showCost ? ` · $${cost.toFixed(2)}` : ''}
</p>
<p className="mt-0.5">
{copy.input} {compactNumber(session.input_tokens)} · {copy.output} {compactNumber(session.output_tokens)} ·{' '}
{session.message_count} {copy.messages}
</p>
</div>
)
}
+6
View File
@@ -2806,6 +2806,12 @@ export const ar = defineLocale({
thread: {
loadingSession: 'جار تحميل الجلسة...',
showEarlier: 'عرض الرسائل الأقدم',
usageFooter: {
title: 'هذه الجلسة',
input: 'إدخال',
output: 'إخراج',
messages: 'رسالة'
},
loadingResponse: 'جار تحميل الرد...',
resumeWhenBackgroundDone: count =>
count === 1 ? 'سيُستأنف عند انتهاء المهمة الخلفية' : `سيُستأنف عند انتهاء ${count} مهام خلفية`,
+6
View File
@@ -3928,6 +3928,12 @@ export const en: Translations = {
thread: {
loadingSession: 'Loading session',
showEarlier: 'Show earlier messages',
usageFooter: {
title: 'This session',
input: 'in',
output: 'out',
messages: 'messages'
},
loadingResponse: 'Hermes is loading a response',
loadingLocalModel: model => `Loading ${model} into memory`,
processingPrompt: 'Processing prompt',
+6
View File
@@ -3265,6 +3265,12 @@ export const ja = defineLocale({
thread: {
loadingSession: 'セッションを読み込み中',
showEarlier: '以前のメッセージを表示',
usageFooter: {
title: 'このセッション',
input: '入力',
output: '出力',
messages: '件のメッセージ'
},
loadingResponse: 'Hermes が応答を読み込み中',
resumeWhenBackgroundDone: count =>
count === 1
+6
View File
@@ -3569,6 +3569,12 @@ export const ru = defineLocale({
thread: {
loadingSession: 'Загрузка сеанса',
showEarlier: 'Показать ранние сообщения',
usageFooter: {
title: 'Этот сеанс',
input: 'вход',
output: 'выход',
messages: 'сообщений'
},
loadingResponse: 'Hermes загружает ответ',
resumeWhenBackgroundDone: count =>
count === 1
+6
View File
@@ -3451,6 +3451,12 @@ export interface Translations {
thread: {
loadingSession: string
showEarlier: string
usageFooter: {
title: string
input: string
output: string
messages: string
}
loadingResponse: string
loadingLocalModel: (model: string) => string
processingPrompt: string
+6
View File
@@ -3221,6 +3221,12 @@ export const zhHant = defineLocale({
thread: {
loadingSession: '正在載入工作階段',
showEarlier: '顯示較早的訊息',
usageFooter: {
title: '本工作階段',
input: '輸入',
output: '輸出',
messages: '則訊息'
},
loadingResponse: 'Hermes 正在載入回覆',
resumeWhenBackgroundDone: count =>
count === 1 ? '背景工作完成後將自動繼續' : `${count} 個背景工作完成後將自動繼續`,
+6
View File
@@ -4008,6 +4008,12 @@ export const zh = defineLocale({
thread: {
loadingSession: '正在加载会话',
showEarlier: '显示更早的消息',
usageFooter: {
title: '本会话',
input: '输入',
output: '输出',
messages: '条消息'
},
loadingResponse: 'Hermes 正在加载回复',
loadingLocalModel: model => `正在将 ${model} 载入内存`,
processingPrompt: '正在处理提示词',