diff --git a/docs/features/task-and-job/task-center-prd.md b/docs/features/task-and-job/task-center-prd.md index 1f78675a..d4912f4f 100644 --- a/docs/features/task-and-job/task-center-prd.md +++ b/docs/features/task-and-job/task-center-prd.md @@ -69,6 +69,7 @@ FR-TC2:顶部紧凑展示当前会话消息总数与 Agent 累计回复时间 缺失历史或时间证据不完整时提示“部分历史用时不可用”,已知用时不能表示为完整总量。 远程执行缺少可靠时间证据时显示不可用或不完整,不用事件接收时间虚构远程用时。 没有当前会话时提示选择会话,项目任务列表仍保留。 +项目名称放在当前会话统计卡片内部的顶部。 FR-TC3:任务累计执行时间按其历次执行请求汇总,周期触发仍显示在同一任务内。 未执行与缺失执行证据必须区分;具体关联和统计边界见[技术设计](./technical-design.md)。 @@ -77,6 +78,8 @@ FR-TC3:任务累计执行时间按其历次执行请求汇总,周期触发 - 点击条目打开关联 Conversation,并定位到该 Task。 - FR-TC4:项目任务标题右侧依次放置“+”新建按钮和筛选图标。状态筛选默认收起、默认全部, + 项目任务列表默认收起,标题与数量、新建及筛选入口保持可见;点击标题切换展开状态,点击筛选同时展开列表。 + 标题旁同时显示当前项目顶层任务中处于 `running` 状态的数量(如“2 运行中”),不受筛选或收起影响;为零时省略。 展开后提供全部、进行中、暂停、已结束;收起不清除已选条件,非全部时图标高亮并显示筛选条件。 - 进行中包含 `idle`、`queued`、`running`、`waiting_approval`、`failed` 和 `interrupted`; 暂停对应 `paused`,已结束包含 `completed` 和 `cancelled`。分类只影响列表筛选, diff --git a/src/renderer/src/RightAssistantSidebar.resize.test.tsx b/src/renderer/src/RightAssistantSidebar.resize.test.tsx index 292638cc..aa4e74c8 100644 --- a/src/renderer/src/RightAssistantSidebar.resize.test.tsx +++ b/src/renderer/src/RightAssistantSidebar.resize.test.tsx @@ -247,6 +247,8 @@ function sidebarElement({ function renderSidebar(options: Parameters[0] = {}): HTMLElement { render(sidebarElement(options)) + const taskListToggle = screen.queryByRole('button', { name: /^项目任务 /u }) + if (taskListToggle) fireEvent.click(taskListToggle) return screen.getByRole('complementary', { name: '助手工作栏' }) @@ -360,6 +362,7 @@ describe('RightAssistantSidebar resizing', () => { taskDurations: new Map([['a', { durationMs: 125_000, incomplete: true }]]) } const view = render(sidebarElement(props)) + fireEvent.click(screen.getByRole('button', { name: '项目任务 2' })) const stats = within(screen.getByRole('region', { name: '当前会话统计' })) expect(stats.getByText('01:01:01')).toBeVisible() expect(stats.getByText('24')).toBeVisible() @@ -644,6 +647,43 @@ describe('RightAssistantSidebar resizing', () => { expect(sidebar).toHaveAttribute('role', 'complementary') }) + it('collapses project tasks by default and toggles the list from its heading', () => { + render(sidebarElement({ tab: 'tasks' })) + const toggle = screen.getByRole('button', { name: '项目任务 0' }) + const list = document.getElementById(toggle.getAttribute('aria-controls')!) + expect(toggle).toHaveAttribute('aria-expanded', 'false') + expect(list).not.toBeVisible() + expect(screen.getByRole('button', { name: '新建任务' })).toBeVisible() + fireEvent.click(toggle) + expect(list).toBeVisible() + fireEvent.click(toggle) + expect(list).not.toBeVisible() + fireEvent.click(screen.getByRole('button', { name: '筛选任务' })) + expect(toggle).toHaveAttribute('aria-expanded', 'true') + expect(list).toBeVisible() + }) + + it('keeps the project inside statistics and shows running counts while collapsed or filtered', () => { + const tasks: AssistantTask[] = ['running', 'running', 'queued', 'completed'].map((status, index) => ({ + id: `summary-${index}`, title: `Summary task ${index}`, instructions: '', + projectId: currentProject.id, origin: 'schedule', status: status as AssistantTask['status'], + createdAt: '2026-09-18T00:00:00Z' + })) + tasks.push({ ...tasks[0]!, id: 'other-project', projectId: 'other-project' }) + tasks.push({ ...tasks[0]!, id: 'child-task', parentTaskId: tasks[0]!.id }) + const view = render(sidebarElement({ tab: 'tasks', tasks })) + const stats = screen.getByRole('region', { name: '当前会话统计' }) + expect(within(stats).getByText(`项目:${currentProject.name}`)).toBeVisible() + expect(screen.getByRole('button', { name: '项目任务 4' })).toHaveAttribute('aria-expanded', 'false') + expect(screen.getByText('2 运行中')).toBeVisible() + fireEvent.click(screen.getByRole('button', { name: '筛选任务' })) + fireEvent.click(within(screen.getByRole('group', { name: '筛选任务' })).getByRole('button', { name: '已结束' })) + expect(screen.getByText('2 运行中')).toBeVisible() + view.rerender(sidebarElement({ tab: 'tasks', tasks: [] })) + expect(screen.queryByText('2 运行中')).not.toBeInTheDocument() + expect(screen.queryByText('0 运行中')).not.toBeInTheDocument() + }) + it('keeps the product Task index in the task center', () => { const onCreateCustomTask = vi.fn() renderSidebar({ tab: 'tasks', onCreateCustomTask }) diff --git a/src/renderer/src/RightAssistantSidebar.tsx b/src/renderer/src/RightAssistantSidebar.tsx index 1968bb17..6c5d53ee 100644 --- a/src/renderer/src/RightAssistantSidebar.tsx +++ b/src/renderer/src/RightAssistantSidebar.tsx @@ -8,7 +8,6 @@ import { ExternalLink, FileText, Filter, - ClockFading, Maximize2, Minimize2, Plus, @@ -886,6 +885,7 @@ export function RightAssistantSidebar({ 'all' | 'active' | 'paused' | 'finished' >('all') const [taskFiltersExpanded, setTaskFiltersExpanded] = useState(false) + const [taskListExpanded, setTaskListExpanded] = useState(false) const [actionErrorState, setActionErrorState] = useState({ scope: { instanceId: activeWorkbarInstanceId, open }, message: '' @@ -987,6 +987,7 @@ export function RightAssistantSidebar({ ), [taskMatchesScope, tasks] ) + const runningTaskCount = topLevelTasks.filter((task) => task.status === 'running').length const { approvalsByTask, unassociatedApprovals } = useMemo(() => { const tasksById = new Map(topLevelTasks.map((task) => [task.id, task])) const approvalsByTask = new Map() @@ -1933,10 +1934,10 @@ export function RightAssistantSidebar({ ) : null} {instance.appId === 'tasks' && (
+

{currentProject ? t('sidebar.tasks.projectScope', { project: currentProject.name }) : t('sidebar.tasks.scope.currentProject')}

-
{t('sidebar.tasks.stats.currentConversation')}

{activeConversationId ? currentStats?.title ?? conversationTitles.get(activeConversationId) ?? t('sidebar.tasks.stats.currentConversation') @@ -1956,9 +1957,20 @@ export function RightAssistantSidebar({ <>

- + + {runningTaskCount > 0 && + {t('sidebar.tasks.runningCount', { count: runningTaskCount })} + }

- - {taskFilter !== 'all' &&
+ {taskListExpanded && taskFilter !== 'all' &&
{t('sidebar.tasks.filters.summary', { filter: t(`sidebar.tasks.filters.${taskFilter}`), count: filteredTasks.length })}
} @@ -2013,6 +2028,7 @@ export function RightAssistantSidebar({ ))} } + )}

diff --git a/src/renderer/src/i18n/locales/en-US/workspace.ts b/src/renderer/src/i18n/locales/en-US/workspace.ts index b6d2caf5..4e09c0ee 100644 --- a/src/renderer/src/i18n/locales/en-US/workspace.ts +++ b/src/renderer/src/i18n/locales/en-US/workspace.ts @@ -323,6 +323,7 @@ export const workspace = { deny: 'Deny', allowOnce: 'Allow once', taskIndexTitle: 'Project tasks', + runningCount: '{{count}} running', stats: { label: 'Current conversation statistics', currentConversation: 'Current conversation', diff --git a/src/renderer/src/i18n/locales/zh-CN/workspace.ts b/src/renderer/src/i18n/locales/zh-CN/workspace.ts index e19178a4..95171f8c 100644 --- a/src/renderer/src/i18n/locales/zh-CN/workspace.ts +++ b/src/renderer/src/i18n/locales/zh-CN/workspace.ts @@ -315,6 +315,7 @@ export const workspace = { deny: '拒绝', allowOnce: '仅此次允许', taskIndexTitle: '项目任务', + runningCount: '{{count}} 运行中', stats: { label: '当前会话统计', currentConversation: '当前会话', diff --git a/src/renderer/src/task-center.css b/src/renderer/src/task-center.css index fed3522f..19e56bcf 100644 --- a/src/renderer/src/task-center.css +++ b/src/renderer/src/task-center.css @@ -1,5 +1,5 @@ .task-center .task-center__scope { - margin: 0; + margin: 0 0 var(--space-2); color: var(--text-secondary); font-size: var(--font-caption); overflow-wrap: anywhere; @@ -59,6 +59,28 @@ .task-center__count { color: var(--text-muted); } +.task-center__running-count { + color: var(--accent); + font-size: var(--font-caption); + white-space: nowrap; +} + +.task-center__list-toggle { + display: inline-flex; + align-items: center; + gap: var(--space-2); + padding: 0; + border: 0; + background: transparent; + color: inherit; + font: inherit; + cursor: pointer; +} + +.task-center__list-toggle[aria-expanded='true'] > svg { transform: rotate(90deg); } +.task-center__list { display: flex; flex-direction: column; gap: var(--space-2); } +.task-center__list[hidden] { display: none; } + .task-center .task-center__create, .task-center .task-center__filter-toggle { display: inline-flex;