fix(desktop): resolve session usage footer by stored id with lineage match
CI / Check no case-colliding filenames (push) Has been cancelled
CI / package-lock.json diff (push) Has been cancelled
CI / Lint Docker scripts (push) Has been cancelled
CI / Supply-chain scan (push) Has been cancelled
CI / Review label gate (push) Has been cancelled
CI / All required checks pass (push) Has been cancelled
CI / Check contributors (push) Has been cancelled
CI / Check uv.lock (push) Has been cancelled
CI / Check no committed infographics (push) Has been cancelled
CI / Profile artifact check (push) Has been cancelled
CI / Detect affected areas (push) Has been cancelled
CI / Python tests (push) Has been cancelled
CI / OS-specific tests (push) Has been cancelled
CI / Python lints (push) Has been cancelled
CI / JS & TS checks (push) Has been cancelled
CI / Installer tests (push) Has been cancelled
CI / Rust tests (push) Has been cancelled
CI / Desktop E2E (push) Has been cancelled
CI / Docs Site (push) Has been cancelled
CI / Deny unrelated histories (push) Has been cancelled
CI / CI timing report (push) Has been cancelled
Docker Build, Test, and Publish / Detect affected areas (push) Has been cancelled
Docker Build, Test, and Publish / build (amd64, type=gha,scope=docker-amd64, type=gha,mode=max,scope=docker-amd64, linux/amd64, ubuntu-latest-32-core) (push) Has been cancelled
Docker Build, Test, and Publish / build (arm64, type=gha,scope=docker-arm64, type=gha,mode=max,scope=docker-arm64, linux/arm64, ubuntu-latest-32-arm-core) (push) Has been cancelled
Docker Build, Test, and Publish / publish (amd64, type=gha,scope=docker-amd64, type=gha,mode=max,scope=docker-amd64, linux/amd64, ubuntu-latest-32-core) (push) Has been cancelled
Docker Build, Test, and Publish / publish (arm64, type=gha,scope=docker-arm64, type=gha,mode=max,scope=docker-arm64, linux/arm64, ubuntu-latest-32-arm-core) (push) Has been cancelled
Docker Build, Test, and Publish / merge (push) Has been cancelled
auto-fix lint issues & formatting / Generate eslint --fix patch (push) Has been cancelled
auto-fix lint issues & formatting / Apply patch (push) Has been cancelled
Nix flake check / Detect affected areas (push) Has been cancelled
Nix flake check / nix flake check (push) Has been cancelled
Deploy Site / deploy-vercel (push) Has been cancelled
Deploy Site / deploy-docs (push) Has been cancelled
Build Skills Index / build-index (push) Has been cancelled
Build Skills Index / trigger-deploy (push) Has been cancelled

CDP verification on the live app showed the footer never rendered: ChatView
passed the runtime id (view.$runtimeId) while $sessions rows key off stored
ids, and compression chains hand the view a lineage id rather than the row's
own id. Look up with selectedSessionId || routedSessionId and
sessionMatchesStoredId, same as ChatHeader.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
m4
2026-09-18 17:34:54 +08:00
parent 71b37dd42d
commit 9845e0b262
3 changed files with 19 additions and 4 deletions
+5 -2
View File
@@ -697,9 +697,12 @@ const ChatViewContent = memo(function ChatViewContent({
// memo'd Thread/ThreadMessageList, and a fresh element per render would
// defeat their bail-out. Stats changes re-render inside SessionUsageFooter
// via its own useStore($sessions) subscription, not through these props.
// The footer needs a STORED id ($sessions rows key off it): activeSessionId
// is the runtime id, so fall back through the routed id for fresh resumes.
const footerSessionId = selectedSessionId || routedSessionId
const threadFooter = useMemo(
() => (activeSessionId ? <SessionUsageFooter sessionId={activeSessionId} /> : undefined),
[activeSessionId]
() => (footerSessionId ? <SessionUsageFooter sessionId={footerSessionId} /> : undefined),
[footerSessionId]
)
return (
@@ -58,6 +58,16 @@ describe('SessionUsageFooter', () => {
expect(container.querySelector('[data-slot="session-usage-footer"]')).toBeNull()
})
it('matches a live lineage id to the stored row, like the header does', () => {
// Auto-compression rotates the session id: the view holds a chain tip while
// the list row still carries its own id plus the lineage ids.
setSessions([makeSessionInfo({ _lineage_ids: ['root', 'tip'], id: 'root', input_tokens: 2_000, output_tokens: 400 })])
const { container } = render(<SessionUsageFooter sessionId="tip" />)
expect(container.querySelector('[data-slot="session-usage-footer"]')?.textContent).toContain('2.4k tok')
})
it('re-renders when the sessions atom refreshes after a turn', () => {
setSessions([makeSessionInfo({ id: 's1', input_tokens: 1_000, output_tokens: 200 })])
@@ -2,7 +2,7 @@ import { useStore } from '@nanostores/react'
import { useI18n } from '@/i18n'
import { compactNumber } from '@/lib/format'
import { $sessions } from '@/store/session'
import { $sessions, sessionMatchesStoredId } from '@/store/session'
import { sessionCostUsd } from '@/store/sidebar-archive'
/** Cumulative token/cost totals for one session, rendered at the end of the
@@ -13,7 +13,9 @@ import { sessionCostUsd } from '@/store/sidebar-archive'
export function SessionUsageFooter({ sessionId }: { sessionId: string }) {
const { t } = useI18n()
const sessions = useStore($sessions)
const session = sessions.find(candidate => candidate.id === sessionId)
// Match like ChatHeader does: a compression chain hands the live view a
// lineage id, not necessarily the row's own id.
const session = sessions.find(candidate => sessionMatchesStoredId(candidate, sessionId))
if (!session) {
return null