feat(tasks): 空闲成员 ≥3 时自动收进聚合,聚合并如实报出状态配比
用户要求优化显示:「比如空闲时也折叠」。 在此之前,一个跑完一轮的 teammate(活体通道不报 running)映射成 idle, 而 idle 不在「已完成/出错」的可折叠集合里 —— 于是 8 人团队永远摆 8 张卡, 而同样「不在执行」的普通子代理却是 done、会折叠。同一事实两种显示,才是 这个痛点的根。 - 可折叠集合放宽到「不在执行」(done | idle | error);运行中/部署中永不折叠。 - 空闲**不无条件收**:按用户确认的阈值,一个父节点下空闲成员 ≥3 才随页面级 折叠规则一起收(FOLD_IDLE_MIN)。1–2 个待命成员就是这个团队的工作集, 而且它们的卡上会挂任务行,留着看;3 个以上才是「在扫名册」。 - 聚合行如实报账:聚合节点新增 doneCount/idleCount/errorCount/hasIdle, 小条写 `✓ N 已完成 · N 待命 · 出错 N`(为零的项省略,全已完成时与原先一样短), 徽标在**没有已完成项**时改说 `N 待命` —— 否则「✓ N 已完成」会把待命和出错 一起算成已完成,是撒谎。图卡片与树行共用 foldTally/foldBadgeCount。 - 手动折叠(卡片 chevron)不设阈值:那是读者点名要收的节点。 - i18n:新增 tasksFoldIdle ×20 词典。 测试:新增 6 例(阈值命中收 3 个空闲成员、2 个保持可见、运行中成员即使达标也不收、 混合聚合按状态计数、页面聚合行报「3 待命」且徽标不说已完成);pnpm test 1284 passed / 9 skipped,typecheck / lint 通过。 (cherry picked from commit 39d66ffb2e93eeb4095ec1cb2ac15933cce62742)
This commit is contained in:
@@ -226,6 +226,78 @@ describe('buildTasksModel', () => {
|
||||
expect(fold).toMatchObject({ 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.
|
||||
const mates = ['m1', 'm2', 'm3']
|
||||
const model = buildTasksModel(input({
|
||||
catalogs: {
|
||||
root: catalog(mates.map(id => child(id))),
|
||||
...Object.fromEntries(mates.map(id => [id, catalog([])])),
|
||||
},
|
||||
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.find(node => node.kind === 'fold')).toMatchObject({
|
||||
count: 3, doneCount: 0, idleCount: 3, errorCount: 0, hasIdle: true,
|
||||
memberIds: ['m1', 'm2', 'm3'],
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps two idle team members visible (below the threshold)', () => {
|
||||
const mates = ['m1', 'm2']
|
||||
const model = buildTasksModel(input({
|
||||
catalogs: {
|
||||
root: catalog(mates.map(id => child(id))),
|
||||
...Object.fromEntries(mates.map(id => [id, catalog([])])),
|
||||
},
|
||||
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', 'm1', 'm2'])
|
||||
})
|
||||
|
||||
it('never sweeps a RUNNING member even when the idle head count is met', () => {
|
||||
const model = buildTasksModel(input({
|
||||
catalogs: {
|
||||
root: catalog(['m1', 'm2', 'm3', 'busy'].map(id => child(id))),
|
||||
...Object.fromEntries(['m1', 'm2', 'm3', 'busy'].map(id => [id, catalog([])])),
|
||||
},
|
||||
byId: Object.fromEntries(
|
||||
[['root', summary('root')], ...['m1', 'm2', 'm3', 'busy'].map(id => [id, summary(id)])],
|
||||
),
|
||||
live: running('busy'),
|
||||
teamMembers: [
|
||||
...['m1', 'm2', 'm3'].map(id => member({ id, name: id, status: 'idle' })),
|
||||
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 })
|
||||
})
|
||||
|
||||
it('counts a mixed aggregate per state so the tally cannot over-claim', () => {
|
||||
const model = buildTasksModel(input({
|
||||
catalogs: {
|
||||
root: catalog([child('m1'), child('m2'), child('m3'), child('done-1'), child('failed-1')]),
|
||||
...Object.fromEntries(
|
||||
['m1', 'm2', 'm3', 'done-1', 'failed-1'].map(id => [id, catalog([])]),
|
||||
),
|
||||
},
|
||||
byId: Object.fromEntries(
|
||||
[['root', summary('root')], ...['m1', 'm2', 'm3', 'done-1', 'failed-1'].map(id => [id, summary(id)])],
|
||||
),
|
||||
teamMembers: [
|
||||
...['m1', 'm2', 'm3'].map(id => member({ id, name: id, status: 'idle' })),
|
||||
member({ id: 'failed-1', name: 'failed-1', phase: 'failed', status: 'failed' }),
|
||||
],
|
||||
}))
|
||||
expect(model.find(node => node.kind === 'fold')).toMatchObject({
|
||||
count: 5, doneCount: 1, idleCount: 3, errorCount: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a child whose own catalog is unknown (never folds on missing data)', () => {
|
||||
const model = buildTasksModel(input({
|
||||
catalogs: { root: catalog([child('opaque')]) },
|
||||
|
||||
@@ -827,6 +827,26 @@ 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.
|
||||
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, 2].map(mate)] })
|
||||
const store = makeStore(snapshotWithChildren(3))
|
||||
const { container, unmount } = renderRoot(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
|
||||
)
|
||||
await flushJobs()
|
||||
const aggregate = container.querySelector('[data-graph-node="fold:root"]') 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('已完成')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('draws COLLAPSE and EXPAND with different glyphs (cluster, card, aggregate)', async () => {
|
||||
const store = makeStore(snapshotWithChildren(2))
|
||||
const { container, unmount } = renderRoot(
|
||||
|
||||
Reference in New Issue
Block a user