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: '正在处理提示词',