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:
+3
-1
@@ -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
@@ -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
@@ -738,7 +738,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
||||
- 默认视图按“项目 → 任务或会话 → 活动详情”组织,项目范围持续可见,任务或会话详情可以折叠。
|
||||
- 任务或会话的综合状态以最近一次顶层请求对应的最终 Agent 结果为准;最终结果尚未产生时使用该请求的当前状态。中间工具或子专家的失败、取消和中断保留在活动详情中,但不得覆盖最终成功状态。
|
||||
- 活动时间线按项目分组、按任务或会话建立横向轨道,所有轨道共享同一执行顺序并按事件时间排列,以带身份名称的节点表达用户、主 Agent、子专家、工具、审批、状态和并行关系。节点内使用 `U / G / S / T / A` 拉丁字母简称,节点下显示完整身份或名称;选择节点后显示所属会话、不可变范围快照和完整详情。
|
||||
- 用量统计与活动记录分离,支持按项目、会话和模型切换统计维度,宽表格在独立容器内横向滚动。
|
||||
- 用量统计与活动记录分离,支持按项目、会话和模型切换统计维度;按项目或会话分组时默认收起并显示汇总,展开后显示 Runtime / 模型子行,按模型分组时保持平铺。宽表格在独立容器内横向滚动。
|
||||
- 活动状态筛选使用 `SegmentedControl`,不与页面页签混合。清空历史遵循破坏性操作政策。
|
||||
|
||||
### 13.7 魔法笔记
|
||||
|
||||
@@ -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