feat(tasks): 空闲与已完成分成两个聚合行;待命卡片补上自己的折叠按钮
用户两条反馈: 1. 空闲和已完成分开折叠合集 2. 空闲的无法折叠 第 2 条是纯疏漏:chevron 的出现条件写死在 `settled = done | error` 上, 「待命」的卡因此从来不画折叠按钮(跟折叠规则无关,规则早就允许手动收)。 现在 chevron 按「不在执行」(done | error | idle)出现,而 `settled` 仍只 表示完成/出错 —— 它管的是卡片该不该变淡,待命不是完成的工作。 第 1 条把上一轮那个「混合聚合」拆开: - 一个父节点现在最多两行 —— `fold:<parent>`(已完成/出错)与 `fold:<parent>:idle`(待命成员); - 各自的徽标说实话:完成行仍是「✓ N 已完成」,待命行是「N 个待命」; 完成行的条会额外报 `出错 N`(全是失败时只剩这一项),否则失败会藏在 ✓ 里; - 待命行的条只写 `N 待命`(同组同状态,不重复计数); - 阈值只作用于**自动**规则(≥3 待命成员才随页面级折叠一起收),手动 chevron 永远有效,两行互不牵连 —— 想扫掉待命的成员,不必连已完成的一起带走。 新增 tasksFoldIdleGroup ×20 词典;删掉上一轮的 hasIdle 与 foldBadgeCount (分组后它们没有意义)。 测试:新增/改写 7 例 —— 三个待命成员收进 idle 行、两个保持可见、运行中成员 达标也不收、混合场景两行各归各的、手动折叠单个待命成员落进 idle 行、 待命卡确实带 chevron 且点后只剩聚合、聚合徽标说「N 个待命」。 pnpm test 1286 passed / 9 skipped,typecheck / lint 通过。 (cherry picked from commit 0c9c8abca85e8aeb54bb2e9fcaaeed0486a56c28)
This commit is contained in:
@@ -223,12 +223,14 @@ describe('buildTasksModel', () => {
|
||||
'root', 'live', 'mate', 'parent', 'grandchild', 'fold:root',
|
||||
])
|
||||
const fold = model.find(node => node.kind === 'fold')
|
||||
expect(fold).toMatchObject({ parentId: 'root', count: 2, memberIds: ['done-1', 'done-2'] })
|
||||
expect(fold).toMatchObject({
|
||||
foldKind: 'done', parentId: 'root', count: 2, memberIds: ['done-1', 'done-2'],
|
||||
})
|
||||
})
|
||||
|
||||
it('sweeps idle team members once a parent has FOLD_IDLE_MIN of them', () => {
|
||||
// Three idle teammates = a roster the reader scans, not a working set:
|
||||
// they fold, and the aggregate keeps saying who is only waiting.
|
||||
// they fold into their OWN row, which says they are waiting.
|
||||
const mates = ['m1', 'm2', 'm3']
|
||||
const model = buildTasksModel(input({
|
||||
catalogs: {
|
||||
@@ -238,9 +240,9 @@ describe('buildTasksModel', () => {
|
||||
byId: Object.fromEntries([['root', summary('root')], ...mates.map(id => [id, summary(id)])]),
|
||||
teamMembers: mates.map(id => member({ id, name: id, status: 'idle' })),
|
||||
}))
|
||||
expect(model.map(node => node.id)).toEqual(['root', 'fold:root'])
|
||||
expect(model.map(node => node.id)).toEqual(['root', 'fold:root:idle'])
|
||||
expect(model.find(node => node.kind === 'fold')).toMatchObject({
|
||||
count: 3, doneCount: 0, idleCount: 3, errorCount: 0, hasIdle: true,
|
||||
foldKind: 'idle', count: 3, doneCount: 0, idleCount: 3, errorCount: 0,
|
||||
memberIds: ['m1', 'm2', 'm3'],
|
||||
})
|
||||
})
|
||||
@@ -273,11 +275,13 @@ describe('buildTasksModel', () => {
|
||||
member({ id: 'busy', name: 'busy', status: 'running' }),
|
||||
],
|
||||
}))
|
||||
expect(model.map(node => node.id)).toEqual(['root', 'busy', 'fold:root'])
|
||||
expect(model.find(node => node.kind === 'fold')).toMatchObject({ idleCount: 3, doneCount: 0 })
|
||||
expect(model.map(node => node.id)).toEqual(['root', 'busy', 'fold:root:idle'])
|
||||
expect(model.find(node => node.kind === 'fold')).toMatchObject({
|
||||
foldKind: 'idle', idleCount: 3, doneCount: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('counts a mixed aggregate per state so the tally cannot over-claim', () => {
|
||||
it('keeps the finished and the waiting members in SEPARATE rows', () => {
|
||||
const model = buildTasksModel(input({
|
||||
catalogs: {
|
||||
root: catalog([child('m1'), child('m2'), child('m3'), child('done-1'), child('failed-1')]),
|
||||
@@ -293,8 +297,31 @@ describe('buildTasksModel', () => {
|
||||
member({ id: 'failed-1', name: 'failed-1', phase: 'failed', status: 'failed' }),
|
||||
],
|
||||
}))
|
||||
const folders = model.filter(node => node.kind === 'fold')
|
||||
expect(folders.map(node => node.id)).toEqual(['fold:root', 'fold:root:idle'])
|
||||
// finished work + the failure, with the failure called out beside the ✓
|
||||
expect(folders[0]).toMatchObject({
|
||||
foldKind: 'done', count: 2, doneCount: 1, idleCount: 0, errorCount: 1,
|
||||
memberIds: ['done-1', 'failed-1'],
|
||||
})
|
||||
// …and the waiting members in their own row, which claims no completion
|
||||
expect(folders[1]).toMatchObject({
|
||||
foldKind: 'idle', count: 3, doneCount: 0, idleCount: 3, errorCount: 0,
|
||||
memberIds: ['m1', 'm2', 'm3'],
|
||||
})
|
||||
})
|
||||
|
||||
it('folds a single idle member into the IDLE row from its own chevron', () => {
|
||||
const model = buildTasksModel(input({
|
||||
catalogs: { root: catalog([child('mate')]) },
|
||||
byId: { root: summary('root'), mate: summary('mate') },
|
||||
teamMembers: [member({ id: 'mate', name: 'mate', status: 'idle' })],
|
||||
folded: false,
|
||||
foldedIds: new Set(['mate']),
|
||||
}))
|
||||
expect(model.map(node => node.id)).toEqual(['root', 'fold:root:idle'])
|
||||
expect(model.find(node => node.kind === 'fold')).toMatchObject({
|
||||
count: 5, doneCount: 1, idleCount: 3, errorCount: 1,
|
||||
foldKind: 'idle', count: 1, idleCount: 1,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -827,9 +827,10 @@ describe('Tasks page: the shared task window', () => {
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('reports the idle part of the aggregate instead of claiming everything is done', async () => {
|
||||
// Three idle teammates meet the idle head count, so the team folds — and
|
||||
// the aggregate row has to say that they are WAITING, not finished.
|
||||
it('gives the waiting members their own aggregate row, and folds one by its chevron', async () => {
|
||||
// Three idle teammates meet the idle head count, so they fold — into a row
|
||||
// of their OWN: the finished work keeps its "✓ N 已完成" badge and the
|
||||
// waiting members say "N 个待命" instead of hiding inside a ✓ count.
|
||||
const mate = (index: number): SidebarTeamMemberProjection => ({
|
||||
id: `child-${index}`, name: `mate-${index}`, role: 'teammate', phase: 'active',
|
||||
})
|
||||
@@ -839,11 +840,39 @@ describe('Tasks page: the shared task window', () => {
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
|
||||
)
|
||||
await flushJobs()
|
||||
const aggregate = container.querySelector('[data-graph-node="fold:root"]') as HTMLElement
|
||||
const aggregate = container.querySelector('[data-graph-node="fold:root:idle"]') as HTMLElement
|
||||
expect(aggregate).not.toBeNull()
|
||||
expect(aggregate.textContent).toContain('3 待命')
|
||||
// Nothing was completed, so the badge must not claim a completed count.
|
||||
expect(aggregate.textContent).not.toContain('已完成')
|
||||
// The finished group is a DIFFERENT row (empty here: nothing is done).
|
||||
expect(container.querySelector('[data-graph-node="fold:root"]')).toBeNull()
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('offers a WAITING card its own fold chevron (idle is foldable by hand)', async () => {
|
||||
// Below the idle head count nothing folds automatically; each waiting card
|
||||
// still carries the chevron, which is what "点击没反应" was about.
|
||||
const mate = (index: number): SidebarTeamMemberProjection => ({
|
||||
id: `child-${index}`, name: `mate-${index}`, role: 'teammate', phase: 'active',
|
||||
})
|
||||
withTeam({ members: [{ id: 'root', name: 'lead', role: 'lead', phase: 'active' }, ...[0, 1].map(mate)] })
|
||||
const store = makeStore(snapshotWithChildren(2))
|
||||
const { container, unmount } = renderRoot(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
|
||||
)
|
||||
await flushJobs()
|
||||
expect(container.querySelector('[data-graph-node="fold:root:idle"]')).toBeNull()
|
||||
const chevron = container.querySelector(
|
||||
'[data-graph-node="child-0"] button[aria-label="收进已完成聚合"]',
|
||||
) as HTMLButtonElement
|
||||
expect(chevron).not.toBeNull()
|
||||
await act(async () => { chevron.click() })
|
||||
const aggregate = container.querySelector('[data-graph-node="fold:root:idle"]') as HTMLElement
|
||||
expect(aggregate).not.toBeNull()
|
||||
expect(aggregate.textContent).toContain('1 待命')
|
||||
// The aggregate's badge says 待命 too, so the row never reads as finished.
|
||||
expect(aggregate.querySelector('[data-card-kind="fold"]')?.textContent).toBe('N 个待命')
|
||||
expect(container.querySelector('[data-graph-node="child-0"]')).toBeNull()
|
||||
unmount()
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user