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:
@@ -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 })
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user