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 without a persistent legacy-cache truncation warning; previously lost
history is not restored. Token usage is grouped by Runtime and history is not restored. Token usage is grouped by Runtime and
model and normalizes the different OpenAI-compatible and Anthropic Messages 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 by conversation and collapsed by default so long histories do not fill the
page. page.
- [x] **Compact conversation tool records**: Expand individual tool records - [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] **紧凑会话工具记录**:逐项展开工具记录即可查看和复制结果、错误及输入参数;会话与子任务进度使用简洁行,减少重复 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] **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] **记忆与智能心跳**:当前提供周期回顾、建议记忆、洞察、后续任务和可审计运行轨迹。 - [x] **记忆与智能心跳**:当前提供周期回顾、建议记忆、洞察、后续任务和可审计运行轨迹。
+1 -1
View File
@@ -738,7 +738,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
- 默认视图按“项目 → 任务或会话 → 活动详情”组织,项目范围持续可见,任务或会话详情可以折叠。 - 默认视图按“项目 → 任务或会话 → 活动详情”组织,项目范围持续可见,任务或会话详情可以折叠。
- 任务或会话的综合状态以最近一次顶层请求对应的最终 Agent 结果为准;最终结果尚未产生时使用该请求的当前状态。中间工具或子专家的失败、取消和中断保留在活动详情中,但不得覆盖最终成功状态。 - 任务或会话的综合状态以最近一次顶层请求对应的最终 Agent 结果为准;最终结果尚未产生时使用该请求的当前状态。中间工具或子专家的失败、取消和中断保留在活动详情中,但不得覆盖最终成功状态。
- 活动时间线按项目分组、按任务或会话建立横向轨道,所有轨道共享同一执行顺序并按事件时间排列,以带身份名称的节点表达用户、主 Agent、子专家、工具、审批、状态和并行关系。节点内使用 `U / G / S / T / A` 拉丁字母简称,节点下显示完整身份或名称;选择节点后显示所属会话、不可变范围快照和完整详情。 - 活动时间线按项目分组、按任务或会话建立横向轨道,所有轨道共享同一执行顺序并按事件时间排列,以带身份名称的节点表达用户、主 Agent、子专家、工具、审批、状态和并行关系。节点内使用 `U / G / S / T / A` 拉丁字母简称,节点下显示完整身份或名称;选择节点后显示所属会话、不可变范围快照和完整详情。
- 用量统计与活动记录分离,支持按项目、会话和模型切换统计维度,宽表格在独立容器内横向滚动。 - 用量统计与活动记录分离,支持按项目、会话和模型切换统计维度;按项目或会话分组时默认收起并显示汇总,展开后显示 Runtime / 模型子行,按模型分组时保持平铺。宽表格在独立容器内横向滚动。
- 活动状态筛选使用 `SegmentedControl`,不与页面页签混合。清空历史遵循破坏性操作政策。 - 活动状态筛选使用 `SegmentedControl`,不与页面页签混合。清空历史遵循破坏性操作政策。
### 13.7 魔法笔记 ### 13.7 魔法笔记
+54 -1
View File
@@ -777,7 +777,7 @@ describe('ActivityPanel', () => {
expect(within(stats).getByText('32%')).toBeInTheDocument() expect(within(stats).getByText('32%')).toBeInTheDocument()
const projectRow = screen.getByRole('row', { 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(projectRow).toBeInTheDocument()
expect( expect(
@@ -810,6 +810,59 @@ describe('ActivityPanel', () => {
).toBeInTheDocument() ).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', () => { it('shows Runtime names instead of internal provider identifiers', () => {
const usage = makeTokenUsage() const usage = makeTokenUsage()
usage.records.push({ 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 { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { InlineHelp } from './InlineHelp' import { InlineHelp } from './InlineHelp'
@@ -253,6 +253,9 @@ export function ActivityPanel({
const [filter, setFilter] = useState<ActivityFilter>('all') const [filter, setFilter] = useState<ActivityFilter>('all')
const [tokenGroup, setTokenGroup] = const [tokenGroup, setTokenGroup] =
useState<TokenUsageGroup>('project') useState<TokenUsageGroup>('project')
const [expandedTokenRows, setExpandedTokenRows] = useState<Set<string>>(
() => new Set()
)
const [confirmingClear, setConfirmingClear] = useState(false) const [confirmingClear, setConfirmingClear] = useState(false)
const [selectedTimelineRecordId, setSelectedTimelineRecordId] = const [selectedTimelineRecordId, setSelectedTimelineRecordId] =
useState<string>() useState<string>()
@@ -529,7 +532,7 @@ export function ActivityPanel({
] ]
const localizeTokenRow = ( const localizeTokenRow = (
row: (typeof tokenRows)[number] row: (typeof tokenRows)[number]
): { label: string; detail?: string } => { ): { label: string } => {
const modelLabel = const modelLabel =
row.model || t('tokenUsage.fallbacks.unknownModel') row.model || t('tokenUsage.fallbacks.unknownModel')
const runtimeLabel = const runtimeLabel =
@@ -544,19 +547,15 @@ export function ActivityPanel({
: row.runtime || t('tokenUsage.fallbacks.unknownRuntime') : row.runtime || t('tokenUsage.fallbacks.unknownRuntime')
const label = const label =
tokenGroup === 'project' && tokenGroup === 'project' &&
row.key.startsWith('project:unassigned:') row.key === 'project:unassigned'
? t('tokenUsage.fallbacks.unassignedProject') ? t('tokenUsage.fallbacks.unassignedProject')
: tokenGroup === 'conversation' && : tokenGroup === 'conversation' &&
row.key.startsWith('conversation:deleted:') row.key === 'conversation:deleted'
? t('tokenUsage.fallbacks.deletedConversation') ? t('tokenUsage.fallbacks.deletedConversation')
: tokenGroup === 'model' : !row.children
? `${runtimeLabel} · ${modelLabel}` ? `${runtimeLabel} · ${modelLabel}`
: row.label : row.label
const detail = return { label }
tokenGroup === 'model'
? undefined
: `${runtimeLabel} · ${modelLabel}`
return { label, detail }
} }
const renderRecordCard = ( const renderRecordCard = (
record: ActivityRecord, record: ActivityRecord,
@@ -1086,14 +1085,44 @@ export function ActivityPanel({
</td> </td>
</tr> </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) const localizedRow = localizeTokenRow(row)
return ( return (
<tr key={row.key}> <tr
key={row.key}
className={isChild ? 'token-usage__model-row' : undefined}
>
<th scope="row"> <th scope="row">
<span>{localizedRow.label}</span> {row.children ? (
{localizedRow.detail && ( <button
<small>{localizedRow.detail}</small> 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> </th>
<td> <td>
+37
View File
@@ -14786,6 +14786,43 @@ details.settings-section > :not(summary) + :not(summary) {
text-align: left; 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 span,
.token-usage tbody th small { .token-usage tbody th small {
display: block; display: block;
+21 -6
View File
@@ -67,10 +67,11 @@ describe('token usage aggregation', () => {
}) })
expect(groupTokenUsage(usage, 'project')).toEqual([ expect(groupTokenUsage(usage, 'project')).toEqual([
{ {
key: 'project:project-1:runtime:model:model:gpt-5', key: 'project:project-1',
label: '项目一', label: '项目一',
model: 'gpt-5', model: '',
runtime: 'model', runtime: '',
children: groupTokenUsage(usage, 'model'),
inputTokens: 112, inputTokens: 112,
outputTokens: 23, outputTokens: 23,
cacheReadTokens: 40, cacheReadTokens: 40,
@@ -143,7 +144,7 @@ describe('token usage aggregation', () => {
expect(groupTokenUsage(usage, 'model')[0]?.label).toBe('') 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() const usage = makeTokenUsage()
usage.records.push({ usage.records.push({
...usage.records[0]!, ...usage.records[0]!,
@@ -154,8 +155,22 @@ describe('token usage aggregation', () => {
output: 2 output: 2
}) })
expect(groupTokenUsage(usage, 'project')).toHaveLength(2) const projects = groupTokenUsage(usage, 'project')
expect(groupTokenUsage(usage, 'conversation')).toHaveLength(3) 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', () => { it('groups identical model names by Runtime instead of provider', () => {
+24 -7
View File
@@ -17,6 +17,7 @@ export type TokenUsageGroupRow = TokenUsageTotals & {
label: string label: string
model: string model: string
runtime: string runtime: string
children?: TokenUsageGroupRow[]
} }
type TokenUsageRecord = TokenUsageSummary['records'][number] type TokenUsageRecord = TokenUsageSummary['records'][number]
@@ -80,10 +81,10 @@ function groupIdentity(
? `project:${record.projectId}` ? `project:${record.projectId}`
: 'project:unassigned' : 'project:unassigned'
return { return {
key: `${projectKey}:${runtimeModelKey}`, key: projectKey,
label: record.projectName?.trim() || '', label: record.projectName?.trim() || '',
model, model: '',
runtime runtime: ''
} }
} }
@@ -92,10 +93,10 @@ function groupIdentity(
? `conversation:${record.conversationId}` ? `conversation:${record.conversationId}`
: 'conversation:deleted' : 'conversation:deleted'
return { return {
key: `${conversationKey}:${runtimeModelKey}`, key: conversationKey,
label: record.conversationTitle?.trim() || '', label: record.conversationTitle?.trim() || '',
model, model: '',
runtime runtime: ''
} }
} }
@@ -118,9 +119,15 @@ export function groupTokenUsage(
group: TokenUsageGroup group: TokenUsageGroup
): TokenUsageGroupRow[] { ): TokenUsageGroupRow[] {
const rows = new Map<string, TokenUsageGroupRow>() const rows = new Map<string, TokenUsageGroupRow>()
const groupedRecords = new Map<string, TokenUsageRecord[]>()
for (const record of tokenUsage.records) { for (const record of tokenUsage.records) {
const identity = groupIdentity(record, group) 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 usage = usageNumbers(record, record.provider)
const existing = rows.get(identity.key) 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'
)
}
)
} }