diff --git a/FEATURES.md b/FEATURES.md index e7cd4b20..6386b005 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -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 diff --git a/FEATURES.zh-CN.md b/FEATURES.zh-CN.md index 031a047b..a932eb71 100644 --- a/FEATURES.zh-CN.md +++ b/FEATURES.zh-CN.md @@ -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] **记忆与智能心跳**:当前提供周期回顾、建议记忆、洞察、后续任务和可审计运行轨迹。 diff --git a/UI-DESIGN.md b/UI-DESIGN.md index c4f9520c..d6684a74 100644 --- a/UI-DESIGN.md +++ b/UI-DESIGN.md @@ -738,7 +738,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证 - 默认视图按“项目 → 任务或会话 → 活动详情”组织,项目范围持续可见,任务或会话详情可以折叠。 - 任务或会话的综合状态以最近一次顶层请求对应的最终 Agent 结果为准;最终结果尚未产生时使用该请求的当前状态。中间工具或子专家的失败、取消和中断保留在活动详情中,但不得覆盖最终成功状态。 - 活动时间线按项目分组、按任务或会话建立横向轨道,所有轨道共享同一执行顺序并按事件时间排列,以带身份名称的节点表达用户、主 Agent、子专家、工具、审批、状态和并行关系。节点内使用 `U / G / S / T / A` 拉丁字母简称,节点下显示完整身份或名称;选择节点后显示所属会话、不可变范围快照和完整详情。 -- 用量统计与活动记录分离,支持按项目、会话和模型切换统计维度,宽表格在独立容器内横向滚动。 +- 用量统计与活动记录分离,支持按项目、会话和模型切换统计维度;按项目或会话分组时默认收起并显示汇总,展开后显示 Runtime / 模型子行,按模型分组时保持平铺。宽表格在独立容器内横向滚动。 - 活动状态筛选使用 `SegmentedControl`,不与页面页签混合。清空历史遵循破坏性操作政策。 ### 13.7 魔法笔记 diff --git a/src/renderer/src/ActivityPanel.test.tsx b/src/renderer/src/ActivityPanel.test.tsx index e54f33fa..dcb916b3 100644 --- a/src/renderer/src/ActivityPanel.test.tsx +++ b/src/renderer/src/ActivityPanel.test.tsx @@ -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() + 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() + 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({ diff --git a/src/renderer/src/ActivityPanel.tsx b/src/renderer/src/ActivityPanel.tsx index 4a3dc1e2..4f0e9f0b 100644 --- a/src/renderer/src/ActivityPanel.tsx +++ b/src/renderer/src/ActivityPanel.tsx @@ -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('all') const [tokenGroup, setTokenGroup] = useState('project') + const [expandedTokenRows, setExpandedTokenRows] = useState>( + () => new Set() + ) const [confirmingClear, setConfirmingClear] = useState(false) const [selectedTimelineRecordId, setSelectedTimelineRecordId] = useState() @@ -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({ ) : ( - 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 ( - + - {localizedRow.label} - {localizedRow.detail && ( - {localizedRow.detail} + {row.children ? ( + + ) : ( + {localizedRow.label} )} diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index af0c423f..90388d14 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -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; diff --git a/src/renderer/src/token-usage.test.ts b/src/renderer/src/token-usage.test.ts index a92af1bb..470a45ed 100644 --- a/src/renderer/src/token-usage.test.ts +++ b/src/renderer/src/token-usage.test.ts @@ -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', () => { diff --git a/src/renderer/src/token-usage.ts b/src/renderer/src/token-usage.ts index 706ea774..bfa3629d 100644 --- a/src/renderer/src/token-usage.ts +++ b/src/renderer/src/token-usage.ts @@ -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() + const groupedRecords = new Map() 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' + ) + } + ) }