fix: show expandable project and conversation usage totals
Group usage into project or conversation totals with collapsed Runtime/model children. Keep model grouping flat and preserve expanded rows when usage refreshes. Include aggregation and interaction tests plus matching documentation. Validation: 25 focused tests, typecheck, and lint passed. Full suite timed out after 200 seconds with four failures reported in ipc.test.ts and ApplicationMenu.test.tsx. Release note: Project and conversation usage now show totals first, with model details available by expanding each row.
This commit is contained in:
@@ -777,7 +777,7 @@ describe('ActivityPanel', () => {
|
||||
expect(within(stats).getByText('32%')).toBeInTheDocument()
|
||||
|
||||
const projectRow = screen.getByRole('row', {
|
||||
name: '项目甲直连模型 · gpt-5 100 20 10 40 40% 120'
|
||||
name: '项目甲 100 20 10 40 40% 120'
|
||||
})
|
||||
expect(projectRow).toBeInTheDocument()
|
||||
expect(
|
||||
@@ -810,6 +810,59 @@ describe('ActivityPanel', () => {
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('expands model children under project and conversation totals and keeps them current', () => {
|
||||
const usage = makeTokenUsage()
|
||||
usage.records.push({
|
||||
...usage.records[0]!,
|
||||
requestId: 'request-other-model',
|
||||
model: 'gpt-other'
|
||||
})
|
||||
const props = {
|
||||
onClear: vi.fn(),
|
||||
onOpenConversation: vi.fn(),
|
||||
records: [],
|
||||
tokenUsage: usage
|
||||
}
|
||||
const { rerender } = render(<ActivityPanel {...props} />)
|
||||
fireEvent.click(screen.getByRole('tab', { name: '用量统计' }))
|
||||
const project = screen.getByRole('button', { name: '项目甲' })
|
||||
expect(project).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(screen.queryByText('直连模型 · gpt-5')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('row', {
|
||||
name: '项目甲 200 40 20 80 40% 240'
|
||||
})).toBeInTheDocument()
|
||||
fireEvent.click(project)
|
||||
expect(project).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(screen.getByText('直连模型 · gpt-5')).toBeInTheDocument()
|
||||
expect(screen.getByText('直连模型 · gpt-other')).toBeInTheDocument()
|
||||
const updated = {
|
||||
...usage,
|
||||
records: [...usage.records, { ...usage.records[0]!, requestId: 'new' }]
|
||||
}
|
||||
rerender(<ActivityPanel {...props} tokenUsage={updated} />)
|
||||
expect(screen.getByRole('row', {
|
||||
name: '项目甲 300 60 30 120 40% 360'
|
||||
})).toBeInTheDocument()
|
||||
expect(screen.getByRole('row', {
|
||||
name: '直连模型 · gpt-5 200 40 20 80 40% 240'
|
||||
})).toBeInTheDocument()
|
||||
fireEvent.click(project)
|
||||
expect(screen.queryByText('直连模型 · gpt-5')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '按会话' }))
|
||||
const conversation = screen.getByRole('button', { name: '会话甲' })
|
||||
expect(conversation).toHaveAttribute('aria-expanded', 'false')
|
||||
fireEvent.click(conversation)
|
||||
expect(screen.getByText('直连模型 · gpt-other')).toBeInTheDocument()
|
||||
fireEvent.click(conversation)
|
||||
expect(screen.queryByText('直连模型 · gpt-other')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '按模型' }))
|
||||
const table = screen.getByRole('table')
|
||||
expect(within(table).queryByRole('button')).not.toBeInTheDocument()
|
||||
expect(within(table).getByText('直连模型 · gpt-5')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows Runtime names instead of internal provider identifiers', () => {
|
||||
const usage = makeTokenUsage()
|
||||
usage.records.push({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Activity, Trash2 } from 'lucide-react'
|
||||
import { Activity, ChevronRight, Trash2 } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { InlineHelp } from './InlineHelp'
|
||||
@@ -253,6 +253,9 @@ export function ActivityPanel({
|
||||
const [filter, setFilter] = useState<ActivityFilter>('all')
|
||||
const [tokenGroup, setTokenGroup] =
|
||||
useState<TokenUsageGroup>('project')
|
||||
const [expandedTokenRows, setExpandedTokenRows] = useState<Set<string>>(
|
||||
() => new Set()
|
||||
)
|
||||
const [confirmingClear, setConfirmingClear] = useState(false)
|
||||
const [selectedTimelineRecordId, setSelectedTimelineRecordId] =
|
||||
useState<string>()
|
||||
@@ -529,7 +532,7 @@ export function ActivityPanel({
|
||||
]
|
||||
const localizeTokenRow = (
|
||||
row: (typeof tokenRows)[number]
|
||||
): { label: string; detail?: string } => {
|
||||
): { label: string } => {
|
||||
const modelLabel =
|
||||
row.model || t('tokenUsage.fallbacks.unknownModel')
|
||||
const runtimeLabel =
|
||||
@@ -544,19 +547,15 @@ export function ActivityPanel({
|
||||
: row.runtime || t('tokenUsage.fallbacks.unknownRuntime')
|
||||
const label =
|
||||
tokenGroup === 'project' &&
|
||||
row.key.startsWith('project:unassigned:')
|
||||
row.key === 'project:unassigned'
|
||||
? t('tokenUsage.fallbacks.unassignedProject')
|
||||
: tokenGroup === 'conversation' &&
|
||||
row.key.startsWith('conversation:deleted:')
|
||||
row.key === 'conversation:deleted'
|
||||
? t('tokenUsage.fallbacks.deletedConversation')
|
||||
: tokenGroup === 'model'
|
||||
: !row.children
|
||||
? `${runtimeLabel} · ${modelLabel}`
|
||||
: row.label
|
||||
const detail =
|
||||
tokenGroup === 'model'
|
||||
? undefined
|
||||
: `${runtimeLabel} · ${modelLabel}`
|
||||
return { label, detail }
|
||||
return { label }
|
||||
}
|
||||
const renderRecordCard = (
|
||||
record: ActivityRecord,
|
||||
@@ -1086,14 +1085,44 @@ export function ActivityPanel({
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
tokenRows.map((row) => {
|
||||
tokenRows.flatMap((parent) => [
|
||||
{ row: parent, isChild: false },
|
||||
...(expandedTokenRows.has(parent.key)
|
||||
? (parent.children ?? []).map((row) => ({
|
||||
row: { ...row, key: `${parent.key}:${row.key}` },
|
||||
isChild: true
|
||||
}))
|
||||
: [])
|
||||
]).map(({ row, isChild }) => {
|
||||
const localizedRow = localizeTokenRow(row)
|
||||
return (
|
||||
<tr key={row.key}>
|
||||
<tr
|
||||
key={row.key}
|
||||
className={isChild ? 'token-usage__model-row' : undefined}
|
||||
>
|
||||
<th scope="row">
|
||||
<span>{localizedRow.label}</span>
|
||||
{localizedRow.detail && (
|
||||
<small>{localizedRow.detail}</small>
|
||||
{row.children ? (
|
||||
<button
|
||||
type="button"
|
||||
className="token-usage__expand"
|
||||
aria-expanded={expandedTokenRows.has(row.key)}
|
||||
onClick={() =>
|
||||
setExpandedTokenRows((current) => {
|
||||
const next = new Set(current)
|
||||
if (next.has(row.key)) {
|
||||
next.delete(row.key)
|
||||
} else {
|
||||
next.add(row.key)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
>
|
||||
<ChevronRight aria-hidden="true" size={14} />
|
||||
<span>{localizedRow.label}</span>
|
||||
</button>
|
||||
) : (
|
||||
<span>{localizedRow.label}</span>
|
||||
)}
|
||||
</th>
|
||||
<td>
|
||||
|
||||
@@ -14786,6 +14786,43 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.token-usage__expand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
padding: var(--space-1) 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.token-usage__expand:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.token-usage__expand:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.token-usage__expand svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.token-usage__expand[aria-expanded='true'] svg {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.token-usage .token-usage__model-row th:first-child {
|
||||
padding-left: var(--space-6);
|
||||
color: var(--text-secondary);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.token-usage tbody th span,
|
||||
.token-usage tbody th small {
|
||||
display: block;
|
||||
|
||||
@@ -67,10 +67,11 @@ describe('token usage aggregation', () => {
|
||||
})
|
||||
expect(groupTokenUsage(usage, 'project')).toEqual([
|
||||
{
|
||||
key: 'project:project-1:runtime:model:model:gpt-5',
|
||||
key: 'project:project-1',
|
||||
label: '项目一',
|
||||
model: 'gpt-5',
|
||||
runtime: 'model',
|
||||
model: '',
|
||||
runtime: '',
|
||||
children: groupTokenUsage(usage, 'model'),
|
||||
inputTokens: 112,
|
||||
outputTokens: 23,
|
||||
cacheReadTokens: 40,
|
||||
@@ -143,7 +144,7 @@ describe('token usage aggregation', () => {
|
||||
expect(groupTokenUsage(usage, 'model')[0]?.label).toBe('')
|
||||
})
|
||||
|
||||
it('keeps project and conversation totals separated by model', () => {
|
||||
it('aggregates project and conversation totals with model children', () => {
|
||||
const usage = makeTokenUsage()
|
||||
usage.records.push({
|
||||
...usage.records[0]!,
|
||||
@@ -154,8 +155,22 @@ describe('token usage aggregation', () => {
|
||||
output: 2
|
||||
})
|
||||
|
||||
expect(groupTokenUsage(usage, 'project')).toHaveLength(2)
|
||||
expect(groupTokenUsage(usage, 'conversation')).toHaveLength(3)
|
||||
const projects = groupTokenUsage(usage, 'project')
|
||||
expect(projects).toHaveLength(1)
|
||||
expect(projects[0]).toMatchObject({
|
||||
inputTokens: 119,
|
||||
outputTokens: 25,
|
||||
cacheInputTokens: 169,
|
||||
cacheHitRate: 80 / 169,
|
||||
totalTokens: 144
|
||||
})
|
||||
expect(projects[0]?.children).toEqual(groupTokenUsage(usage, 'model'))
|
||||
const conversations = groupTokenUsage(usage, 'conversation')
|
||||
expect(conversations).toHaveLength(2)
|
||||
expect(conversations[0]?.children).toHaveLength(2)
|
||||
expect(conversations[0]?.totalTokens).toBe(129)
|
||||
expect(conversations[1]?.children).toHaveLength(1)
|
||||
expect(conversations[1]?.totalTokens).toBe(15)
|
||||
})
|
||||
|
||||
it('groups identical model names by Runtime instead of provider', () => {
|
||||
|
||||
@@ -17,6 +17,7 @@ export type TokenUsageGroupRow = TokenUsageTotals & {
|
||||
label: string
|
||||
model: string
|
||||
runtime: string
|
||||
children?: TokenUsageGroupRow[]
|
||||
}
|
||||
|
||||
type TokenUsageRecord = TokenUsageSummary['records'][number]
|
||||
@@ -80,10 +81,10 @@ function groupIdentity(
|
||||
? `project:${record.projectId}`
|
||||
: 'project:unassigned'
|
||||
return {
|
||||
key: `${projectKey}:${runtimeModelKey}`,
|
||||
key: projectKey,
|
||||
label: record.projectName?.trim() || '',
|
||||
model,
|
||||
runtime
|
||||
model: '',
|
||||
runtime: ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,10 +93,10 @@ function groupIdentity(
|
||||
? `conversation:${record.conversationId}`
|
||||
: 'conversation:deleted'
|
||||
return {
|
||||
key: `${conversationKey}:${runtimeModelKey}`,
|
||||
key: conversationKey,
|
||||
label: record.conversationTitle?.trim() || '',
|
||||
model,
|
||||
runtime
|
||||
model: '',
|
||||
runtime: ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,9 +119,15 @@ export function groupTokenUsage(
|
||||
group: TokenUsageGroup
|
||||
): TokenUsageGroupRow[] {
|
||||
const rows = new Map<string, TokenUsageGroupRow>()
|
||||
const groupedRecords = new Map<string, TokenUsageRecord[]>()
|
||||
|
||||
for (const record of tokenUsage.records) {
|
||||
const identity = groupIdentity(record, group)
|
||||
if (group !== 'model') {
|
||||
const records = groupedRecords.get(identity.key) ?? []
|
||||
records.push(record)
|
||||
groupedRecords.set(identity.key, records)
|
||||
}
|
||||
const usage = usageNumbers(record, record.provider)
|
||||
const existing = rows.get(identity.key)
|
||||
|
||||
@@ -151,5 +158,15 @@ export function groupTokenUsage(
|
||||
})
|
||||
}
|
||||
|
||||
return [...rows.values()]
|
||||
return [...rows.values()].map((row) =>
|
||||
group === 'model'
|
||||
? row
|
||||
: {
|
||||
...row,
|
||||
children: groupTokenUsage(
|
||||
{ ...tokenUsage, records: groupedRecords.get(row.key)! },
|
||||
'model'
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user