feat(desktop): add SessionUsageFooter component and i18n keys
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -2806,6 +2806,12 @@ export const ar = defineLocale({
|
||||
thread: {
|
||||
loadingSession: 'جار تحميل الجلسة...',
|
||||
showEarlier: 'عرض الرسائل الأقدم',
|
||||
usageFooter: {
|
||||
title: 'هذه الجلسة',
|
||||
input: 'إدخال',
|
||||
output: 'إخراج',
|
||||
messages: 'رسالة'
|
||||
},
|
||||
loadingResponse: 'جار تحميل الرد...',
|
||||
resumeWhenBackgroundDone: count =>
|
||||
count === 1 ? 'سيُستأنف عند انتهاء المهمة الخلفية' : `سيُستأنف عند انتهاء ${count} مهام خلفية`,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -3265,6 +3265,12 @@ export const ja = defineLocale({
|
||||
thread: {
|
||||
loadingSession: 'セッションを読み込み中',
|
||||
showEarlier: '以前のメッセージを表示',
|
||||
usageFooter: {
|
||||
title: 'このセッション',
|
||||
input: '入力',
|
||||
output: '出力',
|
||||
messages: '件のメッセージ'
|
||||
},
|
||||
loadingResponse: 'Hermes が応答を読み込み中',
|
||||
resumeWhenBackgroundDone: count =>
|
||||
count === 1
|
||||
|
||||
@@ -3569,6 +3569,12 @@ export const ru = defineLocale({
|
||||
thread: {
|
||||
loadingSession: 'Загрузка сеанса',
|
||||
showEarlier: 'Показать ранние сообщения',
|
||||
usageFooter: {
|
||||
title: 'Этот сеанс',
|
||||
input: 'вход',
|
||||
output: 'выход',
|
||||
messages: 'сообщений'
|
||||
},
|
||||
loadingResponse: 'Hermes загружает ответ',
|
||||
resumeWhenBackgroundDone: count =>
|
||||
count === 1
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3221,6 +3221,12 @@ export const zhHant = defineLocale({
|
||||
thread: {
|
||||
loadingSession: '正在載入工作階段',
|
||||
showEarlier: '顯示較早的訊息',
|
||||
usageFooter: {
|
||||
title: '本工作階段',
|
||||
input: '輸入',
|
||||
output: '輸出',
|
||||
messages: '則訊息'
|
||||
},
|
||||
loadingResponse: 'Hermes 正在載入回覆',
|
||||
resumeWhenBackgroundDone: count =>
|
||||
count === 1 ? '背景工作完成後將自動繼續' : `${count} 個背景工作完成後將自動繼續`,
|
||||
|
||||
@@ -4008,6 +4008,12 @@ export const zh = defineLocale({
|
||||
thread: {
|
||||
loadingSession: '正在加载会话',
|
||||
showEarlier: '显示更早的消息',
|
||||
usageFooter: {
|
||||
title: '本会话',
|
||||
input: '输入',
|
||||
output: '输出',
|
||||
messages: '条消息'
|
||||
},
|
||||
loadingResponse: 'Hermes 正在加载回复',
|
||||
loadingLocalModel: model => `正在将 ${model} 载入内存`,
|
||||
processingPrompt: '正在处理提示词',
|
||||
|
||||
Reference in New Issue
Block a user