fix: simplify task center status selection and rendering

This commit is contained in:
mesalogo
2026-09-20 22:03:27 +08:00
parent fe093bc15f
commit 6739e8e902
5 changed files with 36 additions and 79 deletions
+1 -1
View File
@@ -17,7 +17,7 @@
[当前会话统计 Demo](./hierarchy-demo.html) 用于评审任务中心中的紧凑统计区:上方显示当前会话的消息数与 Agent 累计回复时长,下方管理当前项目全部会话的任务及用时。切换会话只更新会话统计,任务范围固定为当前项目,没有全局或所有项目入口。支持状态筛选、定位所属会话,以及暂停、继续和取消任务的模拟操作。新建任务支持填写内容、关联本项目已有或新建会话、立即或定时执行;仅在页面内模拟,刷新后恢复样例。 [当前会话统计 Demo](./hierarchy-demo.html) 用于评审任务中心中的紧凑统计区:上方显示当前会话的消息数与 Agent 累计回复时长,下方管理当前项目全部会话的任务及用时。切换会话只更新会话统计,任务范围固定为当前项目,没有全局或所有项目入口。支持状态筛选、定位所属会话,以及暂停、继续和取消任务的模拟操作。新建任务支持填写内容、关联本项目已有或新建会话、立即或定时执行;仅在页面内模拟,刷新后恢复样例。
配套 `hierarchy-demo.css` 与 `hierarchy-demo.js` 仅服务于该静态原型;使用演示数据,未接入产品。 配套 `hierarchy-demo.css` 与 `hierarchy-demo.js` 仅服务于该静态原型;使用演示数据,未接入产品。
项目任务标题右侧依次为 `+` 新建按钮与筛选图标,状态筛选默认收起;应用筛选后图标高亮并显示当前筛选条件。 应用中的项目任务标题右侧仅保留 `+` 新建按钮;展开列表后直接显示状态选项,默认选中“进行中”,收起后保留所选状态。静态 Demo 的筛选入口仍为旧交互。
时间为固定样例,Demo 从每次回复的起止秒数计算。正式功能按请求有效区间累计,包含轮内工具和审批等待、不计轮间空闲;任务用时按 `schedule_runs` 关联请求汇总,不按父任务生命周期计算。缺失历史有提示,远程缺少可靠证据时不推算用时,数据来源与边界以技术设计为准。 时间为固定样例,Demo 从每次回复的起止秒数计算。正式功能按请求有效区间累计,包含轮内工具和审批等待、不计轮间空闲;任务用时按 `schedule_runs` 关联请求汇总,不按父任务生命周期计算。缺失历史有提示,远程缺少可靠证据时不推算用时,数据来源与边界以技术设计为准。
先阅读统一领域模型。其他功能文档不得重新定义 Task、Conversation、Job、Run 或 Subagent。 先阅读统一领域模型。其他功能文档不得重新定义 Task、Conversation、Job、Run 或 Subagent。
@@ -77,10 +77,10 @@ FR-TC3:任务累计执行时间按其历次执行请求汇总,周期触发
## 5. 交互 ## 5. 交互
- 点击条目打开关联 Conversation,并定位到该 Task。 - 点击条目打开关联 Conversation,并定位到该 Task。
- FR-TC4:项目任务标题右侧依次放置“+”新建按钮和筛选图标。状态筛选默认收起、默认全部, - FR-TC4:项目任务标题右侧放置“+”新建按钮,不再提供独立筛选图标。
项目任务列表默认收起,标题与数量、新建及筛选入口保持可见;点击标题切换展开状态,点击筛选同时展开列表。 项目任务列表默认收起,标题与数量、新建入口保持可见;点击标题切换展开状态。
标题旁同时显示当前项目顶层任务中处于 `running` 状态的数量(如“2 运行中”),不受筛选或收起影响;为零时省略。 标题旁同时显示当前项目顶层任务中处于 `running` 状态的数量(如“2 运行中”),不受筛选或收起影响;为零时省略。
展开后提供全部、进行中、暂停、已结束;收起不清除已选条件,非全部时图标高亮并显示筛选条件。 展开后直接显示全部、进行中、暂停、已结束状态选项,初始选中进行中;收起不清除已选条件。
- 进行中包含 `idle`、`queued`、`running`、`waiting_approval`、`failed` 和 `interrupted`; - 进行中包含 `idle`、`queued`、`running`、`waiting_approval`、`failed` 和 `interrupted`;
暂停对应 `paused`,已结束包含 `completed` 和 `cancelled`。分类只影响列表筛选, 暂停对应 `paused`,已结束包含 `completed` 和 `cancelled`。分类只影响列表筛选,
不改变任务状态语义;任务行保留具体状态、错误信息和原有操作。 不改变任务状态语义;任务行保留具体状态、错误信息和原有操作。
@@ -158,7 +158,7 @@ FR-TC3:任务累计执行时间按其历次执行请求汇总,周期触发
- [ ] FR-TC1:仅显示当前项目任务,切换会话不缩小列表;无项目时显示选择项目提示。 - [ ] FR-TC1:仅显示当前项目任务,切换会话不缩小列表;无项目时显示选择项目提示。
- [ ] FR-TC2:消息总数与累计回复时间随当前会话更新;轮内工具和审批计时,轮间空闲不计时,历史缺失及远程证据不足有提示。 - [ ] FR-TC2:消息总数与累计回复时间随当前会话更新;轮内工具和审批计时,轮间空闲不计时,历史缺失及远程证据不足有提示。
- [ ] FR-TC3:多次执行汇总到同一任务,不按父 Task 生命周期计算用时。 - [ ] FR-TC3:多次执行汇总到同一任务,不按父 Task 生命周期计算用时。
- [ ] FR-TC4:标题右侧先新建、后筛选;筛选默认收起且为全部,收起后保留所选条件。 - [x] FR-TC4:标题右侧仅保留新建;展开列表直接显示状态选项,默认进行中,收起后保留所选条件。
- [ ] FR-TC5:创建默认立即执行和当前会话,可选择本项目其他会话、新会话及定时周期,空名称从内容生成。 - [ ] FR-TC5:创建默认立即执行和当前会话,可选择本项目其他会话、新会话及定时周期,空名称从内容生成。
- [x] 任务中心保持单实例、默认存在且不可关闭。 - [x] 任务中心保持单实例、默认存在且不可关闭。
@@ -363,6 +363,7 @@ describe('RightAssistantSidebar resizing', () => {
} }
const view = render(sidebarElement(props)) const view = render(sidebarElement(props))
fireEvent.click(screen.getByRole('button', { name: '项目任务 2' })) fireEvent.click(screen.getByRole('button', { name: '项目任务 2' }))
fireEvent.click(screen.getByRole('button', { name: '全部' }))
const stats = within(screen.getByRole('region', { name: '当前会话统计' })) const stats = within(screen.getByRole('region', { name: '当前会话统计' }))
expect(stats.getByText('01:01:01')).toBeVisible() expect(stats.getByText('01:01:01')).toBeVisible()
expect(stats.getByText('24')).toBeVisible() expect(stats.getByText('24')).toBeVisible()
@@ -385,29 +386,22 @@ describe('RightAssistantSidebar resizing', () => {
for (const task of tasks) expect(screen.queryByText(task.title)).not.toBeInTheDocument() for (const task of tasks) expect(screen.queryByText(task.title)).not.toBeInTheDocument()
}) })
it('keeps an applied filter visible when collapsed and clears it without opening the filter panel', () => { it('shows the task status tabs when expanded and defaults to active tasks', () => {
renderSidebar({ tasks: [{ renderSidebar({ tasks: [{
id: 'completed', projectId: currentProject.id, title: 'Completed task', instructions: '', id: 'completed', projectId: currentProject.id, title: 'Completed task', instructions: '',
origin: 'schedule', status: 'completed', createdAt: '2026-09-17T00:00:00Z' origin: 'schedule', status: 'completed', createdAt: '2026-09-17T00:00:00Z'
}] }) }] })
const toggle = screen.getByRole('button', { name: '筛选任务' }) expect(screen.queryByText('Completed task')).not.toBeInTheDocument()
expect(screen.getByText('Completed task')).toBeVisible() expect(screen.queryByRole('button', { name: '筛选任务' })).not.toBeInTheDocument()
fireEvent.click(toggle) const filters = within(screen.getByRole('group', { name: '筛选任务' }))
fireEvent.click(screen.getByRole('button', { name: '进行中' })) expect(filters.getByRole('button', { name: '进行中' })).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(toggle) expect(screen.queryByText('Completed task')).not.toBeInTheDocument()
expect(toggle).toHaveAttribute('aria-expanded', 'false') fireEvent.click(filters.getByRole('button', { name: '已结束' }))
expect(toggle).toHaveAttribute('data-active', 'true')
expect(screen.queryByRole('group', { name: '筛选任务' })).not.toBeInTheDocument()
expect(screen.getByText('筛选:进行中 · 0 项')).toBeVisible()
expect(screen.getByText('当前筛选条件下没有任务。')).toBeVisible()
fireEvent.click(screen.getByRole('button', { name: '清除筛选' }))
expect(toggle).toHaveAttribute('data-active', 'false')
expect(screen.getByText('Completed task')).toBeVisible() expect(screen.getByText('Completed task')).toBeVisible()
}) })
it('scrolls the focused task filter into view when keyboard navigation reaches either end', () => { it('scrolls the focused task filter into view when keyboard navigation reaches either end', () => {
renderSidebar() renderSidebar()
fireEvent.click(screen.getByRole('button', { name: '筛选任务' }))
const filters = within(screen.getByRole('group', { name: '筛选任务' })) const filters = within(screen.getByRole('group', { name: '筛选任务' }))
const first = filters.getByRole('button', { name: '全部' }) const first = filters.getByRole('button', { name: '全部' })
const last = filters.getByRole('button', { name: '已结束' }) const last = filters.getByRole('button', { name: '已结束' })
@@ -658,7 +652,7 @@ describe('RightAssistantSidebar resizing', () => {
expect(list).toBeVisible() expect(list).toBeVisible()
fireEvent.click(toggle) fireEvent.click(toggle)
expect(list).not.toBeVisible() expect(list).not.toBeVisible()
fireEvent.click(screen.getByRole('button', { name: '筛选任务' })) fireEvent.click(toggle)
expect(toggle).toHaveAttribute('aria-expanded', 'true') expect(toggle).toHaveAttribute('aria-expanded', 'true')
expect(list).toBeVisible() expect(list).toBeVisible()
}) })
@@ -676,7 +670,7 @@ describe('RightAssistantSidebar resizing', () => {
expect(within(stats).getByText(`项目:${currentProject.name}`)).toBeVisible() expect(within(stats).getByText(`项目:${currentProject.name}`)).toBeVisible()
expect(screen.getByRole('button', { name: '项目任务 4' })).toHaveAttribute('aria-expanded', 'false') expect(screen.getByRole('button', { name: '项目任务 4' })).toHaveAttribute('aria-expanded', 'false')
expect(screen.getByText('2 运行中')).toBeVisible() expect(screen.getByText('2 运行中')).toBeVisible()
fireEvent.click(screen.getByRole('button', { name: '筛选任务' })) fireEvent.click(screen.getByRole('button', { name: '项目任务 4' }))
fireEvent.click(within(screen.getByRole('group', { name: '筛选任务' })).getByRole('button', { name: '已结束' })) fireEvent.click(within(screen.getByRole('group', { name: '筛选任务' })).getByRole('button', { name: '已结束' }))
expect(screen.getByText('2 运行中')).toBeVisible() expect(screen.getByText('2 运行中')).toBeVisible()
view.rerender(sidebarElement({ tab: 'tasks', tasks: [] })) view.rerender(sidebarElement({ tab: 'tasks', tasks: [] }))
@@ -701,10 +695,7 @@ describe('RightAssistantSidebar resizing', () => {
) )
fireEvent.click(newTaskButton) fireEvent.click(newTaskButton)
expect(onCreateCustomTask).toHaveBeenCalledOnce() expect(onCreateCustomTask).toHaveBeenCalledOnce()
expect( expect(screen.getByRole('group', { name: '筛选任务' })).toBeVisible()
screen.queryByRole('group', { name: '筛选任务' })
).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: '筛选任务' })).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByLabelText('定时任务标题')).not.toBeInTheDocument() expect(screen.queryByLabelText('定时任务标题')).not.toBeInTheDocument()
expect(screen.queryByText('最近任务')).not.toBeInTheDocument() expect(screen.queryByText('最近任务')).not.toBeInTheDocument()
}) })
@@ -736,7 +727,6 @@ describe('RightAssistantSidebar resizing', () => {
approvals: [approval], onOpenTask, onRespondApproval approvals: [approval], onOpenTask, onRespondApproval
}) })
fireEvent.click(screen.getByRole('button', { name: '筛选任务' }))
const filters = within(screen.getByRole('group', { name: '筛选任务' })) const filters = within(screen.getByRole('group', { name: '筛选任务' }))
expect(filters.getAllByRole('button').map((button) => button.textContent)) expect(filters.getAllByRole('button').map((button) => button.textContent))
.toEqual(['全部', '进行中', '暂停', '已结束']) .toEqual(['全部', '进行中', '暂停', '已结束'])
@@ -782,7 +772,6 @@ describe('RightAssistantSidebar resizing', () => {
} }
const onRespondApproval = vi.fn() const onRespondApproval = vi.fn()
renderSidebar({ tasks: [task], approvals: [approval], onRespondApproval }) renderSidebar({ tasks: [task], approvals: [approval], onRespondApproval })
fireEvent.click(screen.getByRole('button', { name: '筛选任务' }))
const row = screen.getByText(task.title).closest('article')! const row = screen.getByText(task.title).closest('article')!
expect(within(row).getByLabelText('等待审批: Confirm write')).toBeVisible() expect(within(row).getByLabelText('等待审批: Confirm write')).toBeVisible()
expect(screen.queryByRole('heading', { name: '等待审批' })).not.toBeInTheDocument() expect(screen.queryByRole('heading', { name: '等待审批' })).not.toBeInTheDocument()
+13 -23
View File
@@ -7,7 +7,6 @@ import {
Copy, Copy,
ExternalLink, ExternalLink,
FileText, FileText,
Filter,
Maximize2, Maximize2,
Minimize2, Minimize2,
Plus, Plus,
@@ -48,7 +47,6 @@ import {
isBrowserViewportOccluded isBrowserViewportOccluded
} from './browser-viewport-occlusion' } from './browser-viewport-occlusion'
import { import {
findTaskSchedule,
TaskScheduleActions TaskScheduleActions
} from './TaskScheduleActions' } from './TaskScheduleActions'
import type { import type {
@@ -883,8 +881,7 @@ export function RightAssistantSidebar({
useState('') useState('')
const [taskFilter, setTaskFilter] = useState< const [taskFilter, setTaskFilter] = useState<
'all' | 'active' | 'paused' | 'finished' 'all' | 'active' | 'paused' | 'finished'
>('all') >('active')
const [taskFiltersExpanded, setTaskFiltersExpanded] = useState(false)
const [taskListExpanded, setTaskListExpanded] = useState(false) const [taskListExpanded, setTaskListExpanded] = useState(false)
const [actionErrorState, setActionErrorState] = useState({ const [actionErrorState, setActionErrorState] = useState({
scope: { instanceId: activeWorkbarInstanceId, open }, scope: { instanceId: activeWorkbarInstanceId, open },
@@ -1027,6 +1024,14 @@ export function RightAssistantSidebar({
}), }),
[approvalsByTask, taskFilter, topLevelTasks] [approvalsByTask, taskFilter, topLevelTasks]
) )
const schedulesByTaskKey = useMemo(() => {
const byKey = new Map<string, AssistantSchedule>()
for (const schedule of schedules) {
byKey.set(schedule.id, schedule)
byKey.set(schedule.taskId, schedule)
}
return byKey
}, [schedules])
useEffect(() => { useEffect(() => {
// Context switches preserve the selected instance and its pinned binding. // Context switches preserve the selected instance and its pinned binding.
@@ -1981,21 +1986,8 @@ export function RightAssistantSidebar({
> >
<Plus aria-hidden="true" size={13} /> <Plus aria-hidden="true" size={13} />
</button> </button>
<button
className="secondary-button task-center__filter-toggle"
aria-label={t('sidebar.tasks.filters.ariaLabel')}
title={t('sidebar.tasks.filters.ariaLabel')}
aria-expanded={taskListExpanded && taskFiltersExpanded}
aria-controls={`task-filters-${instance.id}`}
data-active={taskFilter !== 'all'}
onClick={() => {
setTaskListExpanded(true)
setTaskFiltersExpanded((expanded) => !expanded)
}}
type="button"
><Filter aria-hidden="true" size={14} /></button>
</div> </div>
<div className="task-center__filters" id={`task-filters-${instance.id}`} hidden={!taskListExpanded || !taskFiltersExpanded} <div className="task-center__filters" id={`task-filters-${instance.id}`} hidden={!taskListExpanded}
onFocusCapture={(event) => event.target.scrollIntoView?.({ block: 'nearest', inline: 'nearest' })}> onFocusCapture={(event) => event.target.scrollIntoView?.({ block: 'nearest', inline: 'nearest' })}>
<SegmentedControl <SegmentedControl
ariaLabel={t('sidebar.tasks.filters.ariaLabel')} ariaLabel={t('sidebar.tasks.filters.ariaLabel')}
@@ -2018,10 +2010,6 @@ export function RightAssistantSidebar({
value={taskFilter} value={taskFilter}
/> />
</div> </div>
{taskListExpanded && taskFilter !== 'all' && <div className="task-center__filter-summary">
<span>{t('sidebar.tasks.filters.summary', { filter: t(`sidebar.tasks.filters.${taskFilter}`), count: filteredTasks.length })}</span>
<button type="button" onClick={() => setTaskFilter('all')}>{t('sidebar.tasks.filters.clear')}</button>
</div>}
{unassociatedApprovals.length > 0 && <> {unassociatedApprovals.length > 0 && <>
<h3><ShieldAlert size={15} />{t('sidebar.tasks.approvalsTitle')}</h3> <h3><ShieldAlert size={15} />{t('sidebar.tasks.approvalsTitle')}</h3>
{unassociatedApprovals.map((approval) => ( {unassociatedApprovals.map((approval) => (
@@ -2037,7 +2025,9 @@ export function RightAssistantSidebar({
</p> </p>
) : ( ) : (
filteredTasks.map((task) => { filteredTasks.map((task) => {
const schedule = findTaskSchedule(task, schedules) const schedule =
schedulesByTaskKey.get(task.scheduleId ?? '') ??
schedulesByTaskKey.get(task.id)
const conversationTitle = task.conversationId const conversationTitle = task.conversationId
? conversationTitles.get(task.conversationId) ? conversationTitles.get(task.conversationId)
: undefined : undefined
+7 -29
View File
@@ -81,8 +81,13 @@
.task-center__list { display: flex; flex-direction: column; gap: var(--space-2); } .task-center__list { display: flex; flex-direction: column; gap: var(--space-2); }
.task-center__list[hidden] { display: none; } .task-center__list[hidden] { display: none; }
.task-center .task-center__create, /* Keep expanding a long history responsive by skipping off-screen cards. */
.task-center .task-center__filter-toggle { .task-center__list > .task-center__item {
content-visibility: auto;
contain-intrinsic-size: 0 132px;
}
.task-center .task-center__create {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -92,13 +97,6 @@
padding: 0; padding: 0;
} }
.task-center .task-center__filter-toggle[data-active='true'],
.task-center .task-center__filter-toggle[aria-expanded='true'] {
border-color: var(--accent-selected);
background: var(--accent-subtle);
color: var(--accent);
}
.task-center .task-center__filters { flex: 0 0 auto; } .task-center .task-center__filters { flex: 0 0 auto; }
.task-center .task-center__filters .segmented-control { overflow-x: auto; } .task-center .task-center__filters .segmented-control { overflow-x: auto; }
.task-center .task-center__filters .segmented-control__option { .task-center .task-center__filters .segmented-control__option {
@@ -107,26 +105,6 @@
} }
.task-center .task-center__filters[hidden] { display: none; } .task-center .task-center__filters[hidden] { display: none; }
.task-center__filter-summary {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
color: var(--text-secondary);
font-size: var(--font-caption);
}
.task-center__filter-summary button {
border: 0;
background: transparent;
color: var(--accent);
padding: var(--space-2);
cursor: pointer;
font: inherit;
}
.task-center__filter-summary button:hover { background: var(--accent-subtle); }
.task-center button:focus-visible, .task-center button:focus-visible,
.task-center summary:focus-visible { .task-center summary:focus-visible {
outline: 2px solid var(--accent); outline: 2px solid var(--accent);