From 67d30d9e21f66341acf460969012eaaa54835448 Mon Sep 17 00:00:00 2001 From: mesalogo Date: Sat, 15 Aug 2026 15:04:00 +0800 Subject: [PATCH] feat: redesign run history views --- UI-DESIGN.md | 14 +- src/renderer/src/ActivityPanel.test.tsx | 212 +++- src/renderer/src/ActivityPanel.tsx | 1044 ++++++++++++----- src/renderer/src/App.test.tsx | 11 +- .../src/i18n/locales/en-US/activity.ts | 43 +- src/renderer/src/i18n/locales/en-US/app.ts | 2 +- .../src/i18n/locales/zh-CN/activity.ts | 43 +- src/renderer/src/i18n/locales/zh-CN/app.ts | 2 +- src/renderer/src/styles.css | 415 ++++++- 9 files changed, 1392 insertions(+), 394 deletions(-) diff --git a/UI-DESIGN.md b/UI-DESIGN.md index 845a05a..d59bc87 100644 --- a/UI-DESIGN.md +++ b/UI-DESIGN.md @@ -2,7 +2,7 @@ ## 1. 目的与适用范围 -本文定义 GoodBuddy 桌面端的统一界面规则,适用于聊天与最近对话、知识库、智能心跳、任务与活动记录,以及后续新增的一级页面。 +本文定义 GoodBuddy 桌面端的统一界面规则,适用于聊天与最近对话、知识库、智能心跳、运行记录,以及后续新增的一级页面。 设计系统解决两类问题: @@ -501,13 +501,13 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证 - 状态卡片使用统一状态令牌,不只依赖颜色。 - 运行历史与配置使用明确区块,不以多套相似页签混合导航、开关和筛选。 -### 13.5 任务与活动 +### 13.5 运行记录 -- 任务使用 `standard` 壳层,活动记录使用 `dashboard` 壳层。 -- “任务 / 活动”作为同级页面时使用 `PageTabs`。 -- 任务状态筛选使用 `SegmentedControl` 或筛选工具栏,不再模拟页签。 -- 活动记录保留审计字段和范围,支持独立容器横向滚动。 -- 批量停止、删除和清空历史遵循破坏性操作政策。 +- 使用 `dashboard` 壳层,并通过 `PageTabs` 提供“任务与会话 / 活动时间线 / 用量统计”三个同级视图。 +- 默认视图按“项目 → 任务或会话 → 活动详情”组织,项目范围持续可见,任务或会话详情可以折叠。 +- 活动时间线按项目分组、按任务或会话建立横向轨道,所有轨道共享同一执行顺序并按事件时间排列,以带身份名称的节点表达用户、主 Agent、子专家、工具、审批、状态和并行关系;选择节点后显示所属会话、不可变范围快照和完整详情。 +- 用量统计与活动记录分离,支持按项目、会话和模型切换统计维度,宽表格在独立容器内横向滚动。 +- 活动状态筛选使用 `SegmentedControl`,不与页面页签混合。清空历史遵循破坏性操作政策。 ### 13.6 魔法笔记 diff --git a/src/renderer/src/ActivityPanel.test.tsx b/src/renderer/src/ActivityPanel.test.tsx index f40ede7..9e66d5c 100644 --- a/src/renderer/src/ActivityPanel.test.tsx +++ b/src/renderer/src/ActivityPanel.test.tsx @@ -81,7 +81,7 @@ describe('ActivityPanel', () => { await i18n.changeLanguage('zh-CN') }) - it('renders English interface copy while preserving activity content', async () => { + it('renders localized tabs and keeps usage separate from activity', async () => { await i18n.changeLanguage('en-US') const record = makeRecord(1, 'running') render( @@ -102,19 +102,67 @@ describe('ActivityPanel', () => { }).format(new Date(record.createdAt)) expect(screen.getAllByText(englishDate).length).toBeGreaterThan(0) expect( - screen.getByRole('heading', { - level: 1, - name: 'Tasks and activity' - }) + screen.getByRole('heading', { level: 1, name: 'Run history' }) ).toBeInTheDocument() expect( - screen.getByRole('button', { name: 'In progress' }) - ).toBeInTheDocument() + screen.getByRole('tab', { name: 'Tasks and conversations' }) + ).toHaveAttribute('aria-selected', 'true') expect(screen.getByText(record.title)).toBeInTheDocument() + expect(screen.queryByText('Token usage')).not.toBeInTheDocument() + + fireEvent.click( + screen.getByRole('tab', { name: 'Usage analytics' }) + ) expect(screen.getByText('Token usage')).toBeInTheDocument() + expect(screen.queryByText(record.title)).not.toBeInTheDocument() }) - it('filters active and unsuccessful activity and opens its conversation', () => { + it('organizes records by project, conversation, and detail', () => { + const request: ActivityRecord = { + ...makeRecord(1, 'running'), + kind: 'request', + title: '分析登录问题', + scope: { + kind: 'project', + projectId: 'project-1', + projectName: '项目甲' + } + } + const tool: ActivityRecord = { + ...makeRecord(2), + conversationId: request.conversationId, + requestId: request.requestId, + title: '搜索 IPC 代码', + scope: request.scope + } + const { container } = render( + + ) + + expect(container.querySelectorAll('.activity-project')).toHaveLength(2) + expect(screen.getByText('项目:项目甲')).toBeInTheDocument() + expect( + screen.getByText('1 个任务或会话 · 2 条活动') + ).toBeInTheDocument() + + const projectConversation = screen + .getByText('对话:分析登录问题') + .closest('details') + expect(projectConversation).not.toBeNull() + expect(projectConversation).not.toHaveAttribute('open') + fireEvent.click(screen.getByText('对话:分析登录问题')) + expect(projectConversation).toHaveAttribute('open') + expect( + projectConversation?.querySelectorAll('article') + ).toHaveLength(2) + }) + + it('filters active and exceptional activity and opens its conversation', () => { const onOpenConversation = vi.fn() render( { ) fireEvent.click(screen.getByRole('button', { name: '进行中' })) - fireEvent.click(screen.getByText('对话:活动 1')) - expect(screen.getByText('活动 1')).toBeInTheDocument() + expect(screen.getByText('对话:活动 1')).toBeInTheDocument() expect(screen.queryByText('活动 2')).not.toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: '失败' })) - fireEvent.click(screen.getByText('对话:活动 2')) - expect(screen.getByText('活动 2')).toBeInTheDocument() - expect(screen.getByText('活动 3')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: '异常' })) + expect(screen.getByText('对话:活动 2')).toBeInTheDocument() + expect(screen.getByText('对话:活动 3')).toBeInTheDocument() expect(screen.queryByText('活动 1')).not.toBeInTheDocument() + fireEvent.click(screen.getByText('对话:活动 2')) fireEvent.click( screen.getAllByRole('button', { name: '打开所属对话' })[0]! ) @@ -236,35 +283,7 @@ describe('ActivityPanel', () => { expect(within(item).getByText('进行中')).toBeInTheDocument() }) - it('groups activity by conversation in collapsible sections', () => { - const first = makeRecord(1, 'running') - const second = { - ...makeRecord(2, 'failed'), - conversationId: first.conversationId - } - const { container } = render( - - ) - - const groups = - container.querySelectorAll( - 'details.activity-group' - ) - expect(groups).toHaveLength(1) - expect(groups[0]).not.toHaveAttribute('open') - expect(within(groups[0]!).getByText('2 条活动')).toBeInTheDocument() - - fireEvent.click(within(groups[0]!).getByText('对话:活动 1')) - expect(groups[0]).toHaveAttribute('open') - expect(groups[0]!.querySelectorAll('article')).toHaveLength(2) - }) - - it('shows immutable scope snapshots on groups and records', () => { + it('shows immutable project and unavailable scope snapshots', () => { const projectRecord: ActivityRecord = { ...makeRecord(1), scope: { @@ -286,11 +305,11 @@ describe('ActivityPanel', () => { /> ) - expect(screen.getAllByText('项目:项目甲')).toHaveLength(2) - expect(screen.getAllByText('范围不可用')).toHaveLength(2) + expect(screen.getByText('项目:项目甲')).toBeInTheDocument() + expect(screen.getByText('范围不可用')).toBeInTheDocument() }) - it('uses the shared page hierarchy and explicit global scope', () => { + it('uses shared page tabs and explicit global scope', () => { render( { ) expect( - screen.getByRole('heading', { level: 1, name: '任务与活动' }) + screen.getByRole('heading', { level: 1, name: '运行记录' }) ).toBeInTheDocument() expect(screen.getByText('全部项目')).toHaveClass('scope-badge') - expect(screen.getByLabelText('Token 用量分组')).toHaveClass( - 'segmented-control' + expect(screen.getByLabelText('运行记录视图')).toHaveClass( + 'page-tabs' ) + expect( + screen.getByRole('tabpanel', { name: '任务与会话' }) + ).toBeInTheDocument() expect(screen.getByLabelText('筛选活动')).toHaveClass( 'segmented-control' ) + expect( + screen.queryByLabelText('Token 用量分组') + ).not.toBeInTheDocument() + }) + + it('renders shared-time project and conversation activity tracks', () => { + const first: ActivityRecord = { + ...makeRecord(1, 'running'), + kind: 'request', + title: '分析登录问题', + scope: { + kind: 'project', + projectId: 'project-1', + projectName: '项目甲' + } + } + const second: ActivityRecord = { + ...makeRecord(2, 'failed'), + conversationId: first.conversationId, + requestId: first.requestId, + scope: first.scope + } + const third: ActivityRecord = { + ...makeRecord(3), + conversationId: 'conversation-parallel', + requestId: 'request-parallel', + kind: 'subagent', + title: '研究专家', + scope: first.scope + } + const fourth: ActivityRecord = { + ...makeRecord(4), + conversationId: first.conversationId, + requestId: first.requestId, + kind: 'result', + title: '任务执行完成', + scope: first.scope + } + render( + + ) + + fireEvent.click(screen.getByRole('tab', { name: '活动时间线' })) + const timeline = screen.getByLabelText( + '按项目和会话分组的并行活动轨道' + ) + expect(timeline).toHaveClass('activity-tracks') + expect( + timeline.querySelectorAll('.activity-track') + ).toHaveLength(2) + expect( + timeline.querySelectorAll('.activity-track__node') + ).toHaveLength(4) + expect(within(timeline).getByText('分析登录问题')).toBeInTheDocument() + expect( + within(timeline).getAllByText('研究专家').length + ).toBeGreaterThan(0) + expect(within(timeline).getAllByText('用户').length).toBeGreaterThan(0) + expect( + within(timeline).getAllByText('GoodBuddy').length + ).toBeGreaterThan(0) + expect(within(timeline).getByText('项目:项目甲')).toBeInTheDocument() + expect( + within(timeline).getByText( + '所有轨道共享同一执行顺序,节点按发生时间依次展开,点击节点查看身份和活动详情。' + ) + ).toBeInTheDocument() + + fireEvent.click( + screen.getByRole('button', { + name: /工具,失败,活动 2/u + }) + ) + const detail = screen.getByLabelText('选中的活动节点详情') + expect(within(detail).getByText('活动 2')).toBeInTheDocument() + expect( + within(detail).getByText('对话:分析登录问题') + ).toBeInTheDocument() + expect(within(detail).getByText('项目:项目甲')).toBeInTheDocument() }) it('never renders more than 500 records', () => { @@ -339,11 +445,10 @@ describe('ActivityPanel', () => { tokenUsage={makeTokenUsage()} /> ) + fireEvent.click(screen.getByRole('tab', { name: '用量统计' })) const stats = screen.getByLabelText('Token 用量统计') - expect( - within(stats).getByText('150') - ).toBeInTheDocument() + expect(within(stats).getByText('150')).toBeInTheDocument() const projectRow = screen.getByRole('row', { name: '项目甲gpt-5 · openai 100 20 10 40 120' @@ -354,7 +459,7 @@ describe('ActivityPanel', () => { ).not.toBeInTheDocument() }) - it('groups token usage and displays fallback labels', () => { + it('groups token usage by project, conversation, and model', () => { render( { tokenUsage={makeTokenUsage()} /> ) + fireEvent.click(screen.getByRole('tab', { name: '用量统计' })) expect(screen.getByText('项目甲')).toBeInTheDocument() expect(screen.getByText('未归属项目')).toBeInTheDocument() diff --git a/src/renderer/src/ActivityPanel.tsx b/src/renderer/src/ActivityPanel.tsx index 7cdef7c..62bb7c2 100644 --- a/src/renderer/src/ActivityPanel.tsx +++ b/src/renderer/src/ActivityPanel.tsx @@ -15,12 +15,20 @@ import { DestructiveConfirmActions, EmptyState, PageHeader, + PageTabs, ScopeBadge, SegmentedControl, type WorkspaceScope } from './WorkspacePrimitives' type ActivityFilter = 'all' | 'active' | 'failed' +type ActivityView = 'tasks' | 'timeline' | 'usage' +type ActivityActorKind = + | 'user' + | 'assistant' + | 'subagent' + | 'tool' + | 'approval' export type ActivityPanelProps = { records: readonly ActivityRecord[] @@ -29,6 +37,21 @@ export type ActivityPanelProps = { onOpenConversation: (conversationId: string) => void } +type ConversationActivityGroup = { + conversationId: string + title: string + records: ActivityRecord[] + latestAt: number + status: ActivityRecord['status'] +} + +type ProjectActivityGroup = { + key: string + scope: ActivityRecord['scope'] + conversations: ConversationActivityGroup[] + activityCount: number +} + function isActive(record: ActivityRecord): boolean { return record.status === 'pending' || record.status === 'running' } @@ -78,15 +101,6 @@ function formatTime( } } -type ActivityGroup = { - conversationId: string - title: string - records: ActivityRecord[] - scope: ActivityRecord['scope'] - latestAt: number - status: ActivityRecord['status'] -} - function activityWorkspaceScope( scope: ActivityRecord['scope'], unavailableExplanation: string @@ -103,12 +117,28 @@ function activityWorkspaceScope( } } -function groupActivityRecords( - records: readonly ActivityRecord[], - allRecords: readonly ActivityRecord[] -): ActivityGroup[] { +function activityScopeKey(scope: ActivityRecord['scope']): string { + if (scope.kind === 'project') { + return `project:${scope.projectId}` + } + return scope.kind +} + +function conversationStatus( + records: readonly ActivityRecord[] +): ActivityRecord['status'] { + return ( + records.find(isActive)?.status ?? + records.find(isFailed)?.status ?? + 'completed' + ) +} + +function getConversationTitles( + records: readonly ActivityRecord[] +): Map { const conversationTitles = new Map() - for (const record of allRecords) { + for (const record of records) { if ( record.kind === 'request' && !conversationTitles.has(record.conversationId) @@ -116,27 +146,57 @@ function groupActivityRecords( conversationTitles.set(record.conversationId, record.title) } } - const groups = new Map() + return conversationTitles +} + +function groupActivityRecordsByProject( + records: readonly ActivityRecord[], + allRecords: readonly ActivityRecord[] +): ProjectActivityGroup[] { + const conversationTitles = getConversationTitles(allRecords) + const projectGroups = new Map< + string, + { + scope: ActivityRecord['scope'] + conversations: Map + } + >() + for (const record of records) { - const current = groups.get(record.conversationId) ?? [] - current.push(record) - groups.set(record.conversationId, current) + const projectKey = activityScopeKey(record.scope) + const projectGroup = projectGroups.get(projectKey) ?? { + scope: record.scope, + conversations: new Map() + } + const conversationRecords = + projectGroup.conversations.get(record.conversationId) ?? [] + conversationRecords.push(record) + projectGroup.conversations.set( + record.conversationId, + conversationRecords + ) + projectGroups.set(projectKey, projectGroup) } - return [...groups.entries()].map(([conversationId, items]) => { - const request = items.find((record) => record.kind === 'request') - const activeRecord = items.find(isActive) - const failedRecord = items.find(isFailed) - const status = activeRecord?.status ?? failedRecord?.status ?? 'completed' + + return [...projectGroups.entries()].map(([key, projectGroup]) => { + const conversations = [...projectGroup.conversations.entries()].map( + ([conversationId, items]) => ({ + conversationId, + title: + conversationTitles.get(conversationId) ?? items[0]!.title, + records: items, + latestAt: Math.max(...items.map((record) => record.createdAt)), + status: conversationStatus(items) + }) + ) return { - conversationId, - title: - conversationTitles.get(conversationId) ?? - request?.title ?? - items[0]!.title, - records: items, - scope: items[0]!.scope, - latestAt: Math.max(...items.map((record) => record.createdAt)), - status + key, + scope: projectGroup.scope, + conversations, + activityCount: conversations.reduce( + (count, conversation) => count + conversation.records.length, + 0 + ) } }) } @@ -148,10 +208,14 @@ export function ActivityPanel({ onOpenConversation }: ActivityPanelProps): React.JSX.Element { const { t, i18n } = useTranslation('activity') + const [activeView, setActiveView] = + useState('tasks') const [filter, setFilter] = useState('all') const [tokenGroup, setTokenGroup] = useState('project') const [confirmingClear, setConfirmingClear] = useState(false) + const [selectedTimelineRecordId, setSelectedTimelineRecordId] = + useState() const dateTimeFormatter = useMemo( () => new Intl.DateTimeFormat(i18n.resolvedLanguage || 'zh-CN', { @@ -193,6 +257,14 @@ export function ActivityPanel({ { value: 'active', label: t('filters.active') }, { value: 'failed', label: t('filters.failed') } ] + const views: ReadonlyArray<{ + id: ActivityView + label: string + }> = [ + { id: 'tasks', label: t('tabs.tasks') }, + { id: 'timeline', label: t('tabs.timeline') }, + { id: 'usage', label: t('tabs.usage') } + ] const tokenGroups: ReadonlyArray<{ value: TokenUsageGroup label: string @@ -223,10 +295,38 @@ export function ActivityPanel({ () => visibleRecords.filter((record) => matchesFilter(record, filter)), [filter, visibleRecords] ) - const activityGroups = useMemo( - () => groupActivityRecords(filteredRecords, records), + const projectGroups = useMemo( + () => groupActivityRecordsByProject(filteredRecords, records), [filteredRecords, records] ) + const timelineBounds = useMemo(() => { + const timestamps = filteredRecords.map((record) => record.createdAt) + return { + start: Math.min(...timestamps), + end: Math.max(...timestamps) + } + }, [filteredRecords]) + const timelineNodePositions = useMemo(() => { + const chronologicalRecords = [...filteredRecords].sort( + (left, right) => + left.createdAt - right.createdAt || left.id.localeCompare(right.id) + ) + return new Map( + chronologicalRecords.map((record, index) => [ + record.id, + chronologicalRecords.length === 1 + ? 50 + : 5 + (index / (chronologicalRecords.length - 1)) * 90 + ]) + ) + }, [filteredRecords]) + const selectedTimelineRecord = filteredRecords.find( + (record) => record.id === selectedTimelineRecordId + ) + const conversationTitles = useMemo( + () => getConversationTitles(records), + [records] + ) const activeCount = visibleRecords.filter(isActive).length const failedCount = visibleRecords.filter(isFailed).length const tokenTotals = useMemo( @@ -246,6 +346,69 @@ export function ActivityPanel({ : filter === 'failed' ? t('empty.failed') : t('empty.all') + const timelineStart = formatTime( + timelineBounds.start, + dateTimeFormatter, + t('records.unknownTime') + ) + const timelineEnd = formatTime( + timelineBounds.end, + dateTimeFormatter, + t('records.unknownTime') + ) + const getActivityActor = ( + record: ActivityRecord + ): { + kind: ActivityActorKind + name: string + abbreviation: string + } => { + if (record.kind === 'request') { + return { + kind: 'user', + name: t('timeline.actors.user'), + abbreviation: t('timeline.nodes.user') + } + } + if (record.kind === 'result') { + return { + kind: 'assistant', + name: t('timeline.actors.assistant'), + abbreviation: t('timeline.nodes.assistant') + } + } + if (record.kind === 'subagent') { + return { + kind: 'subagent', + name: record.title, + abbreviation: + Array.from(record.title.trim())[0] ?? + t('timeline.nodes.subagent') + } + } + if (record.kind === 'tool') { + return { + kind: 'tool', + name: record.title, + abbreviation: t('timeline.nodes.tool') + } + } + return { + kind: 'approval', + name: t('timeline.actors.approval'), + abbreviation: t('timeline.nodes.approval') + } + } + const timelineLegend: ReadonlyArray<{ + kind: ActivityActorKind + label: string + }> = [ + { kind: 'user', label: t('timeline.actors.user') }, + { kind: 'assistant', label: t('timeline.actors.assistant') }, + { kind: 'subagent', label: t('timeline.actors.subagent') }, + { kind: 'tool', label: t('timeline.actors.tool') }, + { kind: 'approval', label: t('timeline.actors.approval') } + ] const localizeTokenRow = ( row: (typeof tokenRows)[number] ): { label: string; detail?: string } => { @@ -273,6 +436,137 @@ export function ActivityPanel({ : row.detail return { label, detail } } + const renderRecordCard = ( + record: ActivityRecord, + showContext: boolean + ): React.JSX.Element => { + const time = formatTime( + record.createdAt, + dateTimeFormatter, + t('records.unknownTime') + ) + const conversationTitle = + conversationTitles.get(record.conversationId) ?? record.title + return ( +
+
+
+ + {kindLabels[record.kind]} + + + {statusLabels[record.status]} + +
+ +
+

{record.title}

+ {record.detail.length > 0 &&

{record.detail}

} + {showContext && ( +
+ + {t('records.conversation', { + title: conversationTitle + })} + + +
+ )} + +
+ ) + } + const recordSummary = ( +
+
+
{t('stats.all')}
+
{formatCount(visibleRecords.length)}
+
+
+
{t('stats.active')}
+
{formatCount(activeCount)}
+
+
+
{t('stats.failed')}
+
{formatCount(failedCount)}
+
+
+ ) + const recordToolbar = ( +
+ +
+ ) + const emptyState = ( + setFilter('all')} + type="button" + > + {t('filters.clear')} + + ) + } + description={emptyDescription} + icon={} + level="section" + title={ + filter === 'all' + ? t('empty.noRecordsTitle') + : t('empty.noMatchesTitle') + } + /> + ) return (
+ ))} + + )} + + )} + + {activeView === 'timeline' && ( +
+ {recordSummary} + {recordToolbar} + {filteredRecords.length === 0 ? ( + emptyState + ) : ( +
+

+ {t('timeline.description')} +

+
    + {timelineLegend.map((item) => ( +
  • +
  • + ))} +
+
+
+ + {projectGroups.map((project) => ( +
+
+ + + {t('records.projectSummary', { + conversationCount: formatCount( + project.conversations.length + ), + activityCount: formatCount( + project.activityCount + ) + })} + +
+ {project.conversations.map((conversation) => ( +
+
+ + {conversation.title} + + + + {statusLabels[conversation.status]} + + {t('records.activityCount', { + count: conversation.records.length, + formattedCount: formatCount( + conversation.records.length + ) + })} + +
+
+ {conversation.records.map((record, index) => { + const nodeTime = formatTime( + record.createdAt, + dateTimeFormatter, + t('records.unknownTime') + ) + const selected = + selectedTimelineRecord?.id === record.id + const actor = getActivityActor(record) + return ( + + ) + })} +
+
+ ))} +
+ ))} +
+
+ {selectedTimelineRecord && ( + + )} +
+ )} +
+ )} + + {activeView === 'usage' && ( +
+
+
+

{t('tokenUsage.title')}

+ +
+ +
+
+
{t('tokenUsage.columns.input')}
+
+ {tokenCountFormatter.format(tokenTotals.inputTokens)} +
+
+
+
{t('tokenUsage.columns.output')}
+
+ {tokenCountFormatter.format(tokenTotals.outputTokens)} +
+
+
+
{t('tokenUsage.columns.cacheWrite')}
+
+ {tokenCountFormatter.format( + tokenTotals.cacheWriteTokens + )} +
+
+
+
{t('tokenUsage.columns.cacheRead')}
+
+ {tokenCountFormatter.format( + tokenTotals.cacheReadTokens + )} +
+
+
+
{t('tokenUsage.columns.total')}
+
+ {tokenCountFormatter.format(tokenTotals.totalTokens)} +
+
+
+ +
+ + + + + + + + + + + + + {tokenRows.length === 0 ? ( + + + + ) : ( + tokenRows.map((row) => { + const localizedRow = localizeTokenRow(row) + return ( + + + + + + + + + ) + }) + )} + +
{tokenGroupLabel}{t('tokenUsage.columns.input')}{t('tokenUsage.columns.output')} + {t('tokenUsage.columns.cacheWrite')} + + {t('tokenUsage.columns.cacheRead')} + + {t('tokenUsage.columns.total')} +
+ {t('tokenUsage.empty')} +
+ {localizedRow.label} + {localizedRow.detail && ( + {localizedRow.detail} )} - /> -

{record.title}

- {record.detail.length > 0 &&

{record.detail}

} - - - - ) - })} - - - ) - })} +
+ {tokenCountFormatter.format( + row.inputTokens + )} + + {tokenCountFormatter.format( + row.outputTokens + )} + + {tokenCountFormatter.format( + row.cacheWriteTokens + )} + + {tokenCountFormatter.format( + row.cacheReadTokens + )} + + {tokenCountFormatter.format( + row.totalTokens + )} +
+
+
)} diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index be5a982..40d1ef0 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -3067,10 +3067,13 @@ describe('App', () => { throw new Error('Missing request') } - fireEvent.click(screen.getByText('任务与活动')) + fireEvent.click(screen.getByText('运行记录')) + fireEvent.click( + await screen.findByRole('tab', { name: '用量统计' }) + ) const stats = await screen.findByLabelText('Token 用量统计') expect( - screen.getByRole('heading', { level: 1, name: '任务与活动' }) + screen.getByRole('heading', { level: 1, name: '运行记录' }) ).toBeInTheDocument() expect( screen.queryByRole('button', { name: /^专家角色:/u }) @@ -3841,7 +3844,7 @@ describe('App', () => { ) expect(cancelledDot).toHaveClass('message__status-dot') expect(cancelledDot).not.toHaveClass('message__status-dot--active') - fireEvent.click(screen.getByText('任务与活动')) + fireEvent.click(screen.getByText('运行记录')) expect((await screen.findAllByText('已取消')).length).toBeGreaterThan(0) fireEvent.click(screen.getByRole('button', { name: '进行中' })) expect( @@ -5170,7 +5173,7 @@ describe('App', () => { expect(within(statusRegion).getByText('已取消')).toBeInTheDocument() expect(within(statusRegion).getByText('父任务已停止')).toBeInTheDocument() - fireEvent.click(screen.getByText('任务与活动')) + fireEvent.click(screen.getByText('运行记录')) expect(await screen.findAllByText('子专家')).toHaveLength(4) expect(screen.getAllByText(/智能路由/u).length).toBeGreaterThan(0) expect(screen.getAllByText(/手动指定/u).length).toBeGreaterThan(0) diff --git a/src/renderer/src/i18n/locales/en-US/activity.ts b/src/renderer/src/i18n/locales/en-US/activity.ts index b4c5623..b7a03c9 100644 --- a/src/renderer/src/i18n/locales/en-US/activity.ts +++ b/src/renderer/src/i18n/locales/en-US/activity.ts @@ -3,10 +3,16 @@ import type { activity as chineseActivity } from '../zh-CN/activity' export const activity = { header: { - eyebrow: 'ACTIVITY AUDIT', - title: 'Tasks and activity', + eyebrow: 'RUN HISTORY', + title: 'Run history', description: - 'View task requests, child experts, tool calls, approval results, and token usage across all projects.' + 'Review execution details by project, task, and conversation, or browse the activity timeline and model usage.' + }, + tabs: { + ariaLabel: 'Run history views', + tasks: 'Tasks and conversations', + timeline: 'Activity timeline', + usage: 'Usage analytics' }, clear: { confirmAriaLabel: 'Confirm clearing activity history', @@ -34,7 +40,7 @@ export const activity = { filters: { all: 'All', active: 'In progress', - failed: 'Failed', + failed: 'Exceptions', ariaLabel: 'Filter activity', clear: 'Clear filter' }, @@ -69,7 +75,32 @@ export const activity = { ariaLabel: 'Activity totals', all: 'All', active: 'In progress', - failed: 'Failed' + failed: 'Exceptions' + }, + timeline: { + ariaLabel: 'Parallel activity tracks grouped by project and conversation', + description: + 'Every track shares the same execution order. Nodes are spread by event time. Select a node to inspect its identity and details.', + lanes: 'Project / conversation', + laneAriaLabel: 'Activity track for conversation {{title}}', + nodeAriaLabel: '{{actor}}, {{kind}}, {{status}}, {{title}}, {{time}}', + legendAriaLabel: 'Activity node identity legend', + detailAriaLabel: 'Selected activity node details', + closeDetail: 'Close details', + actors: { + user: 'User', + assistant: 'GoodBuddy', + subagent: 'Child expert', + tool: 'Tool', + approval: 'Approval' + }, + nodes: { + user: 'U', + assistant: 'G', + tool: 'T', + approval: 'A', + subagent: 'S' + } }, empty: { active: 'There is no pending or running activity.', @@ -87,6 +118,8 @@ export const activity = { 'The scope could not be determined when this activity was recorded.', conversation: 'Conversation: {{title}}', activityCount: 'Activity items: {{formattedCount}}', + projectSummary: + '{{conversationCount}} tasks or conversations · {{activityCount}} activity items', openConversation: 'Open conversation' } } satisfies TranslationShape diff --git a/src/renderer/src/i18n/locales/en-US/app.ts b/src/renderer/src/i18n/locales/en-US/app.ts index dc7937f..f1687b4 100644 --- a/src/renderer/src/i18n/locales/en-US/app.ts +++ b/src/renderer/src/i18n/locales/en-US/app.ts @@ -46,7 +46,7 @@ export const app = { magicNotes: 'Magic Notes', knowledge: 'Knowledge', heartbeat: 'Smart Heartbeat', - activity: 'Tasks & Activity', + activity: 'Run history', pendingSuggestions: '{{count}} pending suggestions' }, route: { diff --git a/src/renderer/src/i18n/locales/zh-CN/activity.ts b/src/renderer/src/i18n/locales/zh-CN/activity.ts index d28cd27..d26addd 100644 --- a/src/renderer/src/i18n/locales/zh-CN/activity.ts +++ b/src/renderer/src/i18n/locales/zh-CN/activity.ts @@ -1,9 +1,15 @@ export const activity = { header: { - eyebrow: 'ACTIVITY AUDIT', - title: '任务与活动', + eyebrow: 'RUN HISTORY', + title: '运行记录', description: - '查看全部项目中的任务请求、子专家、工具调用、审批结果和 Token 用量。' + '按项目、任务与会话查看执行详情,或浏览活动时间线和模型用量。' + }, + tabs: { + ariaLabel: '运行记录视图', + tasks: '任务与会话', + timeline: '活动时间线', + usage: '用量统计' }, clear: { confirmAriaLabel: '确认清空 {{formattedCount}} 条活动记录', @@ -31,7 +37,7 @@ export const activity = { filters: { all: '全部', active: '进行中', - failed: '失败', + failed: '异常', ariaLabel: '筛选活动', clear: '清除筛选' }, @@ -66,7 +72,32 @@ export const activity = { ariaLabel: '活动统计', all: '全部', active: '进行中', - failed: '失败' + failed: '异常' + }, + timeline: { + ariaLabel: '按项目和会话分组的并行活动轨道', + description: + '所有轨道共享同一执行顺序,节点按发生时间依次展开,点击节点查看身份和活动详情。', + lanes: '项目 / 会话', + laneAriaLabel: '会话 {{title}} 的活动轨道', + nodeAriaLabel: '{{actor}},{{kind}},{{status}},{{title}},{{time}}', + legendAriaLabel: '活动节点身份图例', + detailAriaLabel: '选中的活动节点详情', + closeDetail: '关闭详情', + actors: { + user: '用户', + assistant: 'GoodBuddy', + subagent: '子专家', + tool: '工具', + approval: '审批' + }, + nodes: { + user: '用', + assistant: 'G', + tool: '工', + approval: '审', + subagent: '专' + } }, empty: { active: '当前没有等待中或正在运行的活动。', @@ -83,6 +114,8 @@ export const activity = { '创建此活动记录时未能确定其归属范围。', conversation: '对话:{{title}}', activityCount: '{{formattedCount}} 条活动', + projectSummary: + '{{conversationCount}} 个任务或会话 · {{activityCount}} 条活动', openConversation: '打开所属对话' } } as const diff --git a/src/renderer/src/i18n/locales/zh-CN/app.ts b/src/renderer/src/i18n/locales/zh-CN/app.ts index 2657f52..5b72ed2 100644 --- a/src/renderer/src/i18n/locales/zh-CN/app.ts +++ b/src/renderer/src/i18n/locales/zh-CN/app.ts @@ -42,7 +42,7 @@ export const app = { magicNotes: '魔法笔记', knowledge: '知识库', heartbeat: '智能心跳', - activity: '任务与活动', + activity: '运行记录', pendingSuggestions: '{{count}} 条待处理建议' }, route: { diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index 01adac5..e94aa3a 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -8445,6 +8445,16 @@ details.settings-section > :not(summary) + :not(summary) { width: 100%; min-height: 100%; flex-direction: column; + container: activity-panel / inline-size; +} + +.activity-panel > .page-tabs { + padding: var(--space-4) 0; +} + +.activity-panel__tab-panel { + min-width: 0; + padding-bottom: var(--space-6); } .activity-list { @@ -8458,7 +8468,6 @@ details.settings-section > :not(summary) + :not(summary) { padding: 14px; border: 1px solid var(--border-default); border-radius: var(--radius-card); - margin-top: var(--space-4); background: var(--surface-raised); } @@ -8468,11 +8477,11 @@ details.settings-section > :not(summary) + :not(summary) { gap: 12px; } -.token-usage__header h3 { +.token-usage__header h2 { flex: 1; margin: 0; color: var(--text-primary); - font-size: 13px; + font-size: var(--font-section-title); } .token-usage__stats { @@ -8561,7 +8570,7 @@ details.settings-section > :not(summary) + :not(summary) { .activity-panel__stats { display: grid; - margin: 18px 0 14px; + margin: 0 0 var(--space-3); gap: 9px; grid-template-columns: repeat(3, 1fr); } @@ -8585,22 +8594,54 @@ details.settings-section > :not(summary) + :not(summary) { font-weight: 700; } -.activity-panel__filters { +.activity-panel__toolbar { display: flex; flex-wrap: wrap; - margin-bottom: 12px; - gap: 6px; + align-items: flex-start; + justify-content: space-between; + margin-bottom: var(--space-3); + gap: var(--space-3); } -.activity-groups { +.activity-projects { display: grid; + gap: var(--space-4); +} + +.activity-project { + overflow: clip; + border: 1px solid var(--border-default); + border-radius: var(--radius-card); + background: var(--surface-raised); +} + +.activity-project__header { + display: flex; + min-height: 44px; + align-items: center; + justify-content: space-between; + padding: var(--space-2) var(--space-4); + border-bottom: 1px solid var(--border-subtle); + background: var(--surface-subtle); gap: var(--space-3); } +.activity-project__header > span:last-child { + color: var(--text-muted); + font-size: var(--font-caption); +} + +.activity-conversations { + display: grid; + padding: var(--space-3); + background: var(--surface-canvas); + gap: var(--space-2); +} + .activity-group { overflow: clip; border: 1px solid var(--border-default); - border-radius: var(--radius-card); + border-radius: var(--radius-control); background: var(--surface-raised); } @@ -8648,11 +8689,15 @@ details.settings-section > :not(summary) + :not(summary) { .activity-group > .activity-list { display: grid; padding: var(--space-3); - background: var(--surface-canvas); + border-top: 1px solid var(--border-subtle); gap: var(--space-2); } -.activity-group .activity-item { +.activity-list__item { + list-style: none; +} + +.activity-list__item .activity-item { margin-bottom: 0; } @@ -8725,6 +8770,23 @@ details.settings-section > :not(summary) + :not(summary) { white-space: pre-wrap; } +.activity-item__context { + display: flex; + min-width: 0; + flex-wrap: wrap; + align-items: center; + margin-top: var(--space-2); + color: var(--text-muted); + font-size: var(--font-caption); + gap: var(--space-2); +} + +.activity-item__context > span:first-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .activity-item__conversation { padding: 5px 0; margin-top: 7px; @@ -8735,6 +8797,305 @@ details.settings-section > :not(summary) + :not(summary) { font-weight: 650; } +.activity-tracks { + display: grid; + min-width: 0; + gap: var(--space-3); +} + +.activity-tracks__description { + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-control); + margin: 0; + background: var(--surface-subtle); + color: var(--text-secondary); + font-size: var(--font-caption); + line-height: 1.5; +} + +.activity-tracks__legend { + display: flex; + padding: 0; + margin: 0; + color: var(--text-secondary); + flex-wrap: wrap; + font-size: var(--font-caption); + gap: var(--space-3); + list-style: none; +} + +.activity-tracks__legend li { + display: inline-flex; + align-items: center; + gap: var(--space-1); +} + +.activity-tracks__legend-swatch { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--accent-solid); +} + +.activity-tracks__legend-swatch--assistant { + background: var(--success); +} + +.activity-tracks__legend-swatch--subagent { + background: var(--warning); +} + +.activity-tracks__legend-swatch--tool { + background: color-mix( + in srgb, + var(--accent) 55%, + var(--success) + ); +} + +.activity-tracks__legend-swatch--approval { + border-radius: 3px; + background: color-mix( + in srgb, + var(--warning) 65%, + var(--danger) + ); +} + +.activity-tracks__scroll { + max-width: 100%; + border: 1px solid var(--border-default); + border-radius: var(--radius-card); + overflow-x: auto; + background: var(--surface-raised); +} + +.activity-tracks__canvas { + width: 100%; + min-width: 760px; +} + +.activity-tracks__axis { + display: grid; + min-height: 38px; + align-items: center; + padding: 0 var(--space-3); + border-bottom: 1px solid var(--border-subtle); + color: var(--text-muted); + font-size: var(--font-caption); + grid-template-columns: 210px minmax(0, 1fr) auto; +} + +.activity-tracks__axis strong { + position: sticky; + z-index: 3; + left: var(--space-3); + padding-right: var(--space-3); + background: var(--surface-raised); + color: var(--text-secondary); +} + +.activity-tracks__axis span:nth-child(2) { + padding-left: var(--space-3); +} + +.activity-tracks__project > header { + display: flex; + min-height: 38px; + align-items: center; + justify-content: space-between; + padding: var(--space-2) var(--space-3); + border-bottom: 1px solid var(--border-subtle); + background: var(--surface-subtle); + gap: var(--space-3); +} + +.activity-tracks__project > header > span:last-child { + color: var(--text-muted); + font-size: var(--font-caption); +} + +.activity-track { + display: grid; + min-height: 84px; + border-bottom: 1px solid var(--border-subtle); + grid-template-columns: 210px minmax(0, 1fr); +} + +.activity-tracks__project:last-child .activity-track:last-child { + border-bottom: 0; +} + +.activity-track__label { + position: sticky; + z-index: 2; + left: 0; + display: grid; + min-width: 0; + align-content: center; + padding: var(--space-2) var(--space-3); + border-right: 1px solid var(--border-subtle); + background: var(--surface-raised); + gap: var(--space-1); +} + +.activity-track__label > strong { + overflow: hidden; + color: var(--text-primary); + font-size: var(--font-body); + text-overflow: ellipsis; + white-space: nowrap; +} + +.activity-track__label > span { + display: flex; + align-items: center; + color: var(--text-muted); + font-size: var(--font-caption); + gap: var(--space-2); +} + +.activity-track__rail { + position: relative; + min-width: 0; + background-image: linear-gradient( + to right, + var(--border-subtle) 1px, + transparent 1px + ); + background-size: 20% 100%; +} + +.activity-track__rail::before { + position: absolute; + top: 50%; + right: var(--space-3); + left: var(--space-3); + height: 2px; + background: var(--border-default); + content: ''; + transform: translateY(-50%); +} + +.activity-track__node { + position: absolute; + z-index: 1; + top: 50%; + display: flex; + width: 68px; + height: 58px; + padding: 0; + border: 0; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + align-items: center; + flex-direction: column; + font-size: 10px; + gap: 2px; + transform: translate(-50%, -50%); +} + +.activity-track__node:hover { + z-index: 3; + color: var(--text-primary); + transform: translate(-50%, -50%) scale(1.05); +} + +.activity-track__node:focus-visible { + z-index: 4; + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.activity-track__node > span { + display: grid; + width: 32px; + height: 32px; + border: 2px solid var(--surface-raised); + border-radius: 50%; + background: var(--accent-solid); + color: var(--text-on-accent); + font-weight: 750; + place-items: center; +} + +.activity-track__node > small { + display: block; + width: 100%; + overflow: hidden; + font-size: 9px; + line-height: 1.2; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; +} + +.activity-track__node[aria-pressed='true'] > span { + box-shadow: 0 0 0 3px var(--accent-selected); +} + +.activity-track__node--actor-assistant > span { + background: var(--success); +} + +.activity-track__node--actor-subagent > span { + background: var(--warning); + color: var(--text-primary); +} + +.activity-track__node--actor-tool > span { + background: color-mix( + in srgb, + var(--accent) 55%, + var(--success) + ); +} + +.activity-track__node--actor-approval > span { + border-radius: 6px; + background: color-mix( + in srgb, + var(--warning) 65%, + var(--danger) + ); +} + +.activity-track__node--running > span { + box-shadow: 0 0 0 3px var(--accent-selected); +} + +.activity-track__node--failed > span, +.activity-track__node--denied > span, +.activity-track__node--cancelled > span, +.activity-track__node--interrupted > span { + border-color: var(--danger); + box-shadow: 0 0 0 2px var(--danger-subtle); +} + +.activity-track__node--pending > span { + border-color: var(--accent); + background: var(--surface-raised); + color: var(--accent); +} + +.activity-tracks__detail { + display: grid; + padding: var(--space-3); + border: 1px solid var(--border-default); + border-radius: var(--radius-card); + background: var(--surface-subtle); + gap: var(--space-2); +} + +.activity-tracks__detail > .secondary-button { + justify-self: end; +} + +.activity-tracks__detail .activity-item { + margin: 0; +} + .heartbeat-center { display: flex; width: 100%; @@ -9820,6 +10181,9 @@ details.settings-section > :not(summary) + :not(summary) { grid-template-columns: 1fr; } +} + +@container activity-panel (max-width: 720px) { .activity-group > summary { grid-template-columns: minmax(0, 1fr) auto; } @@ -9827,6 +10191,35 @@ details.settings-section > :not(summary) + :not(summary) { .activity-group > summary time { grid-column: 1 / -1; } + + .activity-panel__toolbar { + align-items: stretch; + flex-direction: column; + } + + .activity-panel__toolbar > .segmented-control { + width: fit-content; + } +} + +@container activity-panel (max-width: 480px) { + .activity-panel__stats { + grid-template-columns: 1fr; + } + + .activity-project__header { + align-items: flex-start; + flex-direction: column; + } + + .activity-conversations { + padding: var(--space-2); + } + + .activity-item__context > span:first-child { + width: 100%; + white-space: normal; + } } @container heartbeat-center (max-width: 620px) {