fix: simplify task center scopes and status filters

Task Center previously split unbound tasks into a global scope and tasks needing attention into a separate filter. Keep Current project and All projects, label unbound tasks, and restore legacy global scope preferences as all-projects without changing task data.

Keep Active, Paused, and Finished filters. Active includes idle, queued, running, waiting_approval, failed, and interrupted tasks; task details and project-scoped approvals remain available.

Release note: Task Center now offers Current project or All projects and groups tasks needing attention under Active, while retaining project labels and approval actions.
This commit is contained in:
mesalogo
2026-09-16 14:05:36 +08:00
parent e84d7bd185
commit cb245a6402
12 changed files with 149 additions and 68 deletions
@@ -156,6 +156,8 @@ function sidebarElement({
onNavigateBrowser,
onReloadBrowser,
onStopLoadingBrowser,
onOpenTask = vi.fn(),
onRespondApproval = vi.fn(),
onTabChange = vi.fn()
}: {
open?: boolean
@@ -188,6 +190,8 @@ function sidebarElement({
onNavigateBrowser?: (conversationId: string, tabId: BrowserTabId, url: string) => Promise<void>
onReloadBrowser?: (conversationId: string, tabId: BrowserTabId) => Promise<void>
onStopLoadingBrowser?: (conversationId: string, tabId: BrowserTabId) => Promise<void>
onOpenTask?: (task: AssistantTask) => void
onRespondApproval?: React.ComponentProps<typeof RightAssistantSidebar>['onRespondApproval']
onTabChange?: (tab: AssistantSidebarTab) => void
} = {}): React.JSX.Element {
return (
@@ -215,11 +219,11 @@ function sidebarElement({
onRefreshChanges={vi.fn(async () => undefined)}
onReloadBrowser={onReloadBrowser}
onRemoveSchedule={vi.fn(async () => undefined)}
onRespondApproval={vi.fn()}
onRespondApproval={onRespondApproval}
onRunSchedule={vi.fn(async () => undefined)}
onSetScheduleEnabled={vi.fn(async () => undefined)}
onStopLoadingBrowser={onStopLoadingBrowser}
onOpenTask={vi.fn()}
onOpenTask={onOpenTask}
onTabChange={onTabChange}
open={open}
restoreFocusRef={restoreFocusRef}
@@ -568,6 +572,65 @@ describe('RightAssistantSidebar resizing', () => {
expect(screen.queryByText('最近任务')).not.toBeInTheDocument()
})
it('groups attention states as active while preserving task details and approvals across filters', () => {
const statuses: AssistantTask['status'][] = [
'idle', 'queued', 'running', 'waiting_approval', 'failed', 'interrupted',
'paused', 'completed', 'cancelled'
]
const tasks = statuses.map((status): AssistantTask => ({
id: status,
title: `Task ${status}`,
instructions: status,
projectId: currentProject.id,
origin: 'schedule',
status,
error: status === 'failed' ? 'Task execution failed' : undefined,
createdAt: '2026-09-09T00:00:00.000Z'
}))
const approval: PendingSidebarApproval = {
conversationId: 'conversation-1', messageId: 'message-1',
approvalId: 'approval-1', projectId: currentProject.id,
title: '写入工作区', description: '更新 release.md'
}
const onOpenTask = vi.fn()
const onRespondApproval = vi.fn()
renderSidebar({
tasks: [...tasks, { ...tasks[4]!, id: 'child', title: 'Child task', parentTaskId: 'running' }],
approvals: [approval], onOpenTask, onRespondApproval
})
const filters = within(screen.getByRole('group', { name: '筛选任务' }))
expect(filters.getAllByRole('button').map((button) => button.textContent))
.toEqual(['进行中', '暂停', '已结束'])
expect(filters.getByRole('button', { name: '进行中' })).toHaveAttribute('aria-pressed', 'true')
for (const task of tasks.slice(0, 6)) {
const row = screen.getByText(task.title).closest('article')!
expect(within(row).getByText(i18n.t(`task.status.${task.status}`, { ns: 'workspace' }))).toBeVisible()
fireEvent.click(within(row).getByRole('button'))
expect(onOpenTask).toHaveBeenLastCalledWith(task)
}
expect(screen.getByText('Task execution failed')).toBeVisible()
expect(screen.queryByText('Child task')).not.toBeInTheDocument()
for (const [filter, visibleStatuses] of [
['进行中', statuses.slice(0, 6)],
['暂停', ['paused']],
['已结束', ['completed', 'cancelled']]
] as const) {
fireEvent.click(filters.getByRole('button', { name: filter }))
for (const task of tasks) {
expect(Boolean(screen.queryByText(task.title)))
.toBe((visibleStatuses as readonly string[]).includes(task.status))
}
expect(screen.getByLabelText('等待审批: 1')).toBeVisible()
expect(screen.getByLabelText('等待审批: 写入工作区')).toBeVisible()
}
fireEvent.click(screen.getByRole('button', { name: '仅此次允许' }))
expect(onRespondApproval).toHaveBeenLastCalledWith(approval, 'once')
fireEvent.click(screen.getByRole('button', { name: '拒绝' }))
expect(onRespondApproval).toHaveBeenLastCalledWith(approval, 'deny')
})
it('labels approval counts and cards without duplicate live regions', () => {
renderSidebar({
approvals: [
@@ -722,15 +785,15 @@ describe('RightAssistantSidebar resizing', () => {
fireEvent.click(
within(screen.getByRole('group', { name: '任务范围' })).getByRole(
'button',
{ name: '全局' }
{ name: '所有项目' }
)
)
expect(screen.getByText('全局审批')).toBeInTheDocument()
expect(screen.queryByText('当前项目审批')).not.toBeInTheDocument()
expect(screen.getByLabelText('等待审批: 1')).toBeInTheDocument()
expect(screen.getByText('当前项目审批')).toBeInTheDocument()
expect(screen.getByLabelText('等待审批: 2')).toBeInTheDocument()
})
it('scopes tasks to the current project, global tasks, or all projects and persists the choice', () => {
it('scopes tasks to the current or all projects, labels unbound tasks and persists the choice', () => {
const createTask = (
id: string,
title: string,
@@ -758,15 +821,10 @@ describe('RightAssistantSidebar resizing', () => {
expect(screen.getByText('当前项目任务')).toBeInTheDocument()
expect(screen.queryByText('全局任务')).not.toBeInTheDocument()
fireEvent.click(
within(screen.getByRole('group', { name: '任务范围' })).getByRole(
'button',
{ name: '全局' }
)
)
expect(screen.getByText('全局任务')).toBeInTheDocument()
expect(screen.queryByText('当前项目任务')).not.toBeInTheDocument()
expect(screen.queryByText('其他项目任务')).not.toBeInTheDocument()
expect(within(screen.getByRole('group', { name: '任务范围' })).getAllByRole('button'))
.toHaveLength(2)
expect(screen.queryByRole('button', { name: '全局' })).not.toBeInTheDocument()
fireEvent.click(
within(screen.getByRole('group', { name: '任务范围' })).getByRole(
@@ -777,10 +835,33 @@ describe('RightAssistantSidebar resizing', () => {
expect(screen.getByText('当前项目任务')).toBeInTheDocument()
expect(screen.getByText('全局任务')).toBeInTheDocument()
expect(screen.getByText('其他项目任务')).toBeInTheDocument()
expect(screen.getAllByText('未绑定项目')).toHaveLength(1)
expect(screen.getByText('项目:00000000-0000-4000-8000-000000000302')).toBeInTheDocument()
expect(JSON.parse(localStorage.getItem('goodbuddy.workbar-layout.v1')!))
.toEqual(expect.objectContaining({ taskScope: 'all-projects' }))
})
it('restores the legacy global scope as all projects and saves the updated preference', () => {
localStorage.setItem('goodbuddy.workbar-layout.v1', JSON.stringify({
instances: DEFAULT_WORKBAR_INSTANCES,
activeInstanceId: DEFAULT_WORKBAR_INSTANCES[0]!.id,
expanded: true,
dock: 'right',
widthRatio: 0.3,
taskScope: 'global'
}))
renderSidebar({ activeProject: null, tasks: [{
id: 'legacy-unbound-task', title: '历史任务', instructions: '历史任务',
origin: 'schedule', status: 'running', createdAt: '2026-09-09T00:00:00.000Z'
}] })
expect(screen.getByRole('button', { name: '所有项目' })).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByText('历史任务')).toBeInTheDocument()
expect(screen.getByText('未绑定项目')).toBeInTheDocument()
expect(JSON.parse(localStorage.getItem('goodbuddy.workbar-layout.v1')!).taskScope)
.toBe('all-projects')
})
it('shows a clear current-project empty state when no project is active', () => {
renderSidebar({ activeProject: null })
+12 -25
View File
@@ -831,10 +831,12 @@ export function RightAssistantSidebar({
const [workspacePreviewLoadMoreError, setWorkspacePreviewLoadMoreError] =
useState('')
const [taskFilter, setTaskFilter] = useState<
'attention' | 'active' | 'paused' | 'finished'
'active' | 'paused' | 'finished'
>('active')
const [taskScope, setTaskScope] = useState<WorkbarTaskScope>(
initialLayout?.taskScope ?? 'current-project'
const [taskScope, setTaskScope] = useState<Exclude<WorkbarTaskScope, 'global'>>(
initialLayout?.taskScope === 'global'
? 'all-projects'
: initialLayout?.taskScope ?? 'current-project'
)
const [actionErrorState, setActionErrorState] = useState({
scope: { instanceId: activeWorkbarInstanceId, open },
@@ -925,9 +927,6 @@ export function RightAssistantSidebar({
if (taskScope === 'all-projects') {
return true
}
if (taskScope === 'global') {
return !projectId
}
return Boolean(currentProject && projectId === currentProject.id)
},
[currentProject, taskScope]
@@ -946,18 +945,14 @@ export function RightAssistantSidebar({
const filteredTasks = useMemo(
() =>
topLevelTasks.filter((task) => {
if (taskFilter === 'attention') {
return (
task.status === 'waiting_approval' ||
task.status === 'failed' ||
task.status === 'interrupted'
)
}
if (taskFilter === 'active') {
return (
task.status === 'idle' ||
task.status === 'queued' ||
task.status === 'running'
task.status === 'running' ||
task.status === 'waiting_approval' ||
task.status === 'failed' ||
task.status === 'interrupted'
)
}
if (taskFilter === 'paused') {
@@ -1885,10 +1880,6 @@ export function RightAssistantSidebar({
value: 'current-project',
label: t('sidebar.tasks.scope.currentProject')
},
{
value: 'global',
label: t('sidebar.tasks.scope.global')
},
{
value: 'all-projects',
label: t('sidebar.tasks.scope.allProjects')
@@ -1966,10 +1957,6 @@ export function RightAssistantSidebar({
ariaLabel={t('sidebar.tasks.filters.ariaLabel')}
onChange={setTaskFilter}
options={[
{
value: 'attention',
label: t('sidebar.tasks.filters.attention')
},
{
value: 'active',
label: t('sidebar.tasks.filters.active')
@@ -2034,11 +2021,11 @@ export function RightAssistantSidebar({
</button>
<div className="task-center__metadata">
<span>
{projectName
{task.projectId
? t('sidebar.tasks.projectScope', {
project: projectName
project: projectName ?? task.projectId
})
: t('sidebar.tasks.globalScope')}
: t('sidebar.tasks.unboundProject')}
</span>
<span>
{schedule
@@ -327,21 +327,19 @@ export const workspace = {
noFilterResults: 'No tasks match this filter.',
conversationUnavailable: 'Conversation unavailable',
projectScope: 'Project: {{project}}',
globalScope: 'Global',
unboundProject: 'Unbound project',
startedAt: 'Started {{time}}',
nextRunAt: 'Next run: {{time}}',
notStarted: 'Not run yet',
scope: {
ariaLabel: 'Task scope',
currentProject: 'Current project',
global: 'Global',
allProjects: 'All projects',
noCurrentProject:
'No project is active. Choose another task scope or open a project.'
},
filters: {
ariaLabel: 'Filter tasks',
attention: 'Attention',
active: 'Active',
paused: 'Paused',
finished: 'Finished'
@@ -319,23 +319,21 @@ export const workspace = {
noFilterResults: '当前筛选条件下没有任务。',
conversationUnavailable: '关联会话不可用',
projectScope: '项目:{{project}}',
globalScope: '全局',
unboundProject: '未绑定项目',
startedAt: '{{time}} 开始',
nextRunAt: '下次运行:{{time}}',
notStarted: '尚未运行',
scope: {
ariaLabel: '任务范围',
currentProject: '当前项目',
global: '全局',
allProjects: '所有项目',
noCurrentProject: '没有活动项目。请选择其他任务范围或先打开一个项目。'
},
filters: {
ariaLabel: '筛选任务',
attention: '待关注',
active: '进行中',
paused: '已暂停',
finished: '已完成'
paused: '暂停',
finished: '已结束'
},
schedule: {
recurrence: {