diff --git a/apps/desktop/src/app/chat/session-usage-footer.test.tsx b/apps/desktop/src/app/chat/session-usage-footer.test.tsx new file mode 100644 index 0000000000..70f3ef0592 --- /dev/null +++ b/apps/desktop/src/app/chat/session-usage-footer.test.tsx @@ -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() + + 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() + + 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() + + 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() + + 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() + + 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') + }) +}) diff --git a/apps/desktop/src/app/chat/session-usage-footer.tsx b/apps/desktop/src/app/chat/session-usage-footer.tsx new file mode 100644 index 0000000000..1543eb49bd --- /dev/null +++ b/apps/desktop/src/app/chat/session-usage-footer.tsx @@ -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 ( +
+

+ {copy.title} {compactNumber(totalTokens)} tok{showCost ? ` · $${cost.toFixed(2)}` : ''} +

+

+ {copy.input} {compactNumber(session.input_tokens)} · {copy.output} {compactNumber(session.output_tokens)} ·{' '} + {session.message_count} {copy.messages} +

+
+ ) +} diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index 70c7faff66..f2416c67b1 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -2806,6 +2806,12 @@ export const ar = defineLocale({ thread: { loadingSession: 'جار تحميل الجلسة...', showEarlier: 'عرض الرسائل الأقدم', + usageFooter: { + title: 'هذه الجلسة', + input: 'إدخال', + output: 'إخراج', + messages: 'رسالة' + }, loadingResponse: 'جار تحميل الرد...', resumeWhenBackgroundDone: count => count === 1 ? 'سيُستأنف عند انتهاء المهمة الخلفية' : `سيُستأنف عند انتهاء ${count} مهام خلفية`, diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 4a726f666f..a092fe0ffd 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -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', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index d84fc8365f..4ac1ae515d 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -3265,6 +3265,12 @@ export const ja = defineLocale({ thread: { loadingSession: 'セッションを読み込み中', showEarlier: '以前のメッセージを表示', + usageFooter: { + title: 'このセッション', + input: '入力', + output: '出力', + messages: '件のメッセージ' + }, loadingResponse: 'Hermes が応答を読み込み中', resumeWhenBackgroundDone: count => count === 1 diff --git a/apps/desktop/src/i18n/ru.ts b/apps/desktop/src/i18n/ru.ts index ac062e043b..1264d71afa 100644 --- a/apps/desktop/src/i18n/ru.ts +++ b/apps/desktop/src/i18n/ru.ts @@ -3569,6 +3569,12 @@ export const ru = defineLocale({ thread: { loadingSession: 'Загрузка сеанса', showEarlier: 'Показать ранние сообщения', + usageFooter: { + title: 'Этот сеанс', + input: 'вход', + output: 'выход', + messages: 'сообщений' + }, loadingResponse: 'Hermes загружает ответ', resumeWhenBackgroundDone: count => count === 1 diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index bcd99f7323..edd1f22f5b 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -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 diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index c397b29ca8..3d6854fac6 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -3221,6 +3221,12 @@ export const zhHant = defineLocale({ thread: { loadingSession: '正在載入工作階段', showEarlier: '顯示較早的訊息', + usageFooter: { + title: '本工作階段', + input: '輸入', + output: '輸出', + messages: '則訊息' + }, loadingResponse: 'Hermes 正在載入回覆', resumeWhenBackgroundDone: count => count === 1 ? '背景工作完成後將自動繼續' : `${count} 個背景工作完成後將自動繼續`, diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index c6d714ad9b..e0a6d3469c 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -4008,6 +4008,12 @@ export const zh = defineLocale({ thread: { loadingSession: '正在加载会话', showEarlier: '显示更早的消息', + usageFooter: { + title: '本会话', + input: '输入', + output: '输出', + messages: '条消息' + }, loadingResponse: 'Hermes 正在加载回复', loadingLocalModel: model => `正在将 ${model} 载入内存`, processingPrompt: '正在处理提示词',