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:
mesalogo
2026-09-23 16:33:13 +08:00
parent 0265cfb762
commit cdd573778c
8 changed files with 185 additions and 32 deletions
+3 -1
View File
@@ -696,7 +696,9 @@ records are listed separately and do not introduce another feature status.
without a persistent legacy-cache truncation warning; previously lost
history is not restored. Token usage is grouped by Runtime and
model and normalizes the different OpenAI-compatible and Anthropic Messages
cache-reporting semantics when showing cache hit rate. Activity is grouped
cache-reporting semantics when showing cache hit rate. Project and conversation
usage groups show collapsed totals by default and expand to Runtime/model child
rows; model grouping remains flat. Activity is grouped
by conversation and collapsed by default so long histories do not fill the
page.
- [x] **Compact conversation tool records**: Expand individual tool records
+1 -1
View File
@@ -107,7 +107,7 @@
### 工作管理、长期协作与工作流
- [x] **任务、活动与成果**:集中管理任务状态、审计活动和独立成果文件;普通聊天回复只保留在会话中,不再自动复制到成果栏,已有重复聊天 Markdown 从成果列表隐藏但不物理删除。运行记录改由 Main SQLite 保存,不再受旧 Renderer 的 500 条、单详情 4,000 字符或约 2 MB 上限影响,页面按批继续显示;不再常驻旧缓存截断警告,但不会恢复此前已丢失的历史。Token 用量按 Runtime 与模型归类,并针对 OpenAI 兼容与 Anthropic Messages 的不同上报口径归一化展示缓存命中率;活动按会话分组并默认收起,避免长历史占满页面。
- [x] **任务、活动与成果**:集中管理任务状态、审计活动和独立成果文件;普通聊天回复只保留在会话中,不再自动复制到成果栏,已有重复聊天 Markdown 从成果列表隐藏但不物理删除。运行记录改由 Main SQLite 保存,不再受旧 Renderer 的 500 条、单详情 4,000 字符或约 2 MB 上限影响,页面按批继续显示;不再常驻旧缓存截断警告,但不会恢复此前已丢失的历史。Token 用量按 Runtime 与模型归类,并针对 OpenAI 兼容与 Anthropic Messages 的不同上报口径归一化展示缓存命中率;用量按项目或会话分组时默认收起并显示汇总,展开后显示 Runtime / 模型子行,按模型分组时保持平铺;活动按会话分组并默认收起,避免长历史占满页面。
- [x] **紧凑会话工具记录**:逐项展开工具记录即可查看和复制结果、错误及输入参数;会话与子任务进度使用简洁行,减少重复 Runtime 摘要。OpenCode、Continue、DeepSeek Harness 和直连模型可显示已有文件路径、命令或搜索摘要,并在工具完成和会话重载后保留。
- [x] **Task 与定制任务体验**:每个产品级 Task 只关联一条 Conversation,一条 Conversation 可承载多个 Task;左侧会话列表通过行首展开按钮显示带共享状态点的 Task 子项,父会话行不重复任务标签,UI 只展示到 Task,不暴露 Job/Run 层级。新建定制任务可关联当前或新 Conversation,默认 Execute 并沿用 Runtime、工具和审批边界;重复触发复用同一 Task,文本结果回写 Conversation,独立文件和图片保留为成果。普通消息与到期 Scheduled Task 共用 Conversation 级持久发送队列,同一会话一次只执行一项;当前回复期间仍可继续发送,队列按顺序续跑,并允许删除或“立即中断并插入”。Task Center 继续作为完整索引,不建设独立 Automation Center。当前计划触发支持单次、每日和每周;高级时区、Cron、事件触发与重试治理仍按 PRD 逐步实现。详见 [Task Center PRD](./docs/features/task-and-job/task-center-prd.md) 和 [Scheduled Task PRD](./docs/features/task-and-job/scheduled-task-prd.md)。
- [x] **记忆与智能心跳**:当前提供周期回顾、建议记忆、洞察、后续任务和可审计运行轨迹。
+1 -1
View File
@@ -738,7 +738,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
- 默认视图按“项目 → 任务或会话 → 活动详情”组织,项目范围持续可见,任务或会话详情可以折叠。
- 任务或会话的综合状态以最近一次顶层请求对应的最终 Agent 结果为准;最终结果尚未产生时使用该请求的当前状态。中间工具或子专家的失败、取消和中断保留在活动详情中,但不得覆盖最终成功状态。
- 活动时间线按项目分组、按任务或会话建立横向轨道,所有轨道共享同一执行顺序并按事件时间排列,以带身份名称的节点表达用户、主 Agent、子专家、工具、审批、状态和并行关系。节点内使用 `U / G / S / T / A` 拉丁字母简称,节点下显示完整身份或名称;选择节点后显示所属会话、不可变范围快照和完整详情。
- 用量统计与活动记录分离,支持按项目、会话和模型切换统计维度,宽表格在独立容器内横向滚动。
- 用量统计与活动记录分离,支持按项目、会话和模型切换统计维度;按项目或会话分组时默认收起并显示汇总,展开后显示 Runtime / 模型子行,按模型分组时保持平铺。宽表格在独立容器内横向滚动。
- 活动状态筛选使用 `SegmentedControl`,不与页面页签混合。清空历史遵循破坏性操作政策。
### 13.7 魔法笔记
+54 -1
View File
@@ -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({
+44 -15
View File
@@ -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>
+37
View File
@@ -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;
+21 -6
View File
@@ -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', () => {
+24 -7
View File
@@ -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'
)
}
)
}