From a7338bee1a62a4506d0ea0ca2af2d9832879ed98 Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Sun, 27 Sep 2026 17:20:18 +0800 Subject: [PATCH] =?UTF-8?q?fix(tasks):=20=E9=80=90=E8=8A=82=E7=82=B9?= =?UTF-8?q?=E6=8A=98=E5=8F=A0=E6=8C=89=E9=92=AE=E5=9C=A8=E8=81=9A=E5=90=88?= =?UTF-8?q?=E8=A7=84=E5=88=99=E4=B8=8B=E5=A4=B1=E6=95=88=EF=BC=88=E6=89=8B?= =?UTF-8?q?=E5=8A=A8=E6=8A=98=E5=8F=A0=E4=B8=8D=E5=86=8D=E5=85=B1=E7=94=A8?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E6=8A=98=E5=8F=A0=E7=9A=84=E5=AE=88=E5=8D=AB?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户反馈「怎么折叠和怎么自动合并功能没了?我点击没反应」。 根因是折叠入口把两个触发条件塞进了同一组守卫: if ((folded || foldedIds?.has(child.id)) && isLeaf && !child.current && child.team === undefined && (done || error)) `child.team === undefined` 是给**页面级自动聚合**准备的(teammate 是真实可续接 的工作者,不该被自动扫走),但它同样卡住了 `foldedIds` 这条**手动**路径。而卡片小条 的折叠 chevron 是按「已结算」画出来的——于是出现「按钮画出来了,点了没反应」这种 最差的失效形态:既没有折叠,也没有任何反馈。 改为按触发源分别守卫(`isAutoFoldable` = leaf + 非成员;`nodeFoldable` = 已结算 + 非当前会话),手动折叠只需后者;workflow run 成员两个触发都走 `nodeFoldable`。 测试:新增 4 例——手动折叠把「出错」的成员收进聚合、自动规则不动它、 运行中的成员即使用手动折叠也不被收起;pnpm test 1279 passed / 9 skipped, typecheck / lint 通过。 (cherry picked from commit 3a47ddafe3c2d57f8997dc97f0118eb0530b114a) --- src/client/tasks-model.ts | 69 ++++++++++++++++++++++++++------------- tests/tasks-model.spec.ts | 40 +++++++++++++++++++++++ 2 files changed, 87 insertions(+), 22 deletions(-) diff --git a/src/client/tasks-model.ts b/src/client/tasks-model.ts index 9bd03ea..a25d693 100644 --- a/src/client/tasks-model.ts +++ b/src/client/tasks-model.ts @@ -130,9 +130,12 @@ export interface TasksModelInput { /** Whether settled leaves collapse into fold nodes. */ folded: boolean /** - * Nodes the READER folded by hand (a completed card's bar chevron). They fold - * under the same guards as the global rule, so a manual fold can never hide a - * running or branching node — it only adds a per-node trigger. + * Nodes the READER folded by hand (a settled card's bar chevron). This is a + * PER-NODE trigger, not a second copy of the global rule, so it only has to + * satisfy the guards that protect the reader from hiding live work: never a + * running node, never the current session, never a branching one. It is + * deliberately NOT subject to {@link isAutoFoldable} — the chevron is drawn + * on every settled card, and a trigger that cannot fire is a dead control. */ foldedIds?: ReadonlySet } @@ -145,6 +148,35 @@ function childLabel( return entry.label ?? summary?.displayTitle ?? entry.id } +/** + * Whether the page-level fold rule may sweep this node into its parent's + * aggregate. Everything the AUTO rule excludes, it excludes for a reason: + * + * - a branching node or one with a run of its own — folding it hides a whole + * branch, not a finished leaf; + * - the session on screen — the "you are here" marker must stay reachable; + * - a TEAMMATE — a roster row is a real, resumable worker the reader is + * watching, not a settled subagent to sweep away; + * - anything not settled — 运行中 must never be hidden. + * + * The manual trigger ({@link TasksModelInput.foldedIds}) shares only the last + * two guards; see `nodeBlocksFold`. + */ +function isAutoFoldable(node: TasksAgentNode, runsByOrigin: Map): boolean { + const isLeaf = node.hasChildren !== true && !runsByOrigin.has(node.id) + return isLeaf && node.team === undefined +} + +/** + * The guards BOTH fold triggers answer to: only a SETTLED node (done/error) + * that is not the session on screen may be hidden — 运行中 and 待命 nodes stay + * visible under either trigger. + */ +function nodeFoldable(node: TasksAgentNode, currentSessionId: string): boolean { + const settled = node.state === 'done' || node.state === 'error' + return settled && !node.current && node.id !== currentSessionId +} + /** * Map a team member's derived status onto the node display state. The status * already folds in the durable phase (./team-projection.ts), so this is a @@ -323,23 +355,17 @@ export function buildTasksModel(input: TasksModelInput): TasksNode[] { const visibleAgentChildren = agentChildren.filter(child => child.parentId === parentId) // Fold: settled agent leaves of THIS parent collapse into one aggregate. - // `folded` is the page-level rule; `foldedIds` are the nodes the reader - // folded by hand from their own card bar. Both run through the SAME guards. + // Two triggers, two guard sets: the page-level `folded` rule also spares + // team members and branching nodes; a manual `foldedIds` fold is the + // reader asking for THAT node, so it only spares live/current work. const kept: TasksAgentNode[] = [] const foldCandidates: TasksAgentNode[] = [] for (const child of visibleAgentChildren) { - // A leaf: no durable children (catalog signal) and no workflow run of - // its own — folding a parent would hide its live branch. - const isLeaf = child.hasChildren !== true && !runsByOrigin.has(child.id) - if ( - (folded || (foldedIds?.has(child.id) ?? false)) && isLeaf && !child.current - && child.team === undefined - && (child.state === 'done' || child.state === 'error') - ) { - foldCandidates.push(child) - } else { - kept.push(child) - } + const foldable = nodeFoldable(child, currentSessionId) + const auto = folded && foldable && isAutoFoldable(child, runsByOrigin) + const manual = foldedIds?.has(child.id) === true && foldable + if (auto || manual) foldCandidates.push(child) + else kept.push(child) } for (const child of kept) { @@ -362,14 +388,13 @@ export function buildTasksModel(input: TasksModelInput): TasksNode[] { if (runNode === undefined) continue out.push(runNode) const members = memberNodes[index] ?? [] - // Fold settled members of the run too (same leaf rule, run as parent). + // The run's own members fold under the same two triggers; they need no + // leaf test (they are leaves by construction here) and no team guard + // (a run member is not a roster row the reader is watching). const keptMembers: TasksAgentNode[] = [] const foldedMembers: TasksAgentNode[] = [] for (const member of members) { - if ( - (folded || (foldedIds?.has(member.id) ?? false)) && !member.current - && (member.state === 'done' || member.state === 'error') - ) { + if ((folded || foldedIds?.has(member.id) === true) && nodeFoldable(member, currentSessionId)) { foldedMembers.push(member) } else { keptMembers.push(member) diff --git a/tests/tasks-model.spec.ts b/tests/tasks-model.spec.ts index f470d0c..307f94d 100644 --- a/tests/tasks-model.spec.ts +++ b/tests/tasks-model.spec.ts @@ -155,6 +155,46 @@ describe('buildTasksModel', () => { expect(agents(model).find(node => node.id === 'w')?.state).toBe('running') }) + it('folds a settled team member when the READER folds it by hand', () => { + // The card's chevron is drawn whenever a node is settled, and a teammate + // can settle as 出错 (a failed roster row). Auto-fold keeps team members + // out of the aggregate — a roster row is a real, resumable worker, not a + // settled subagent to sweep away — but a manual fold is the reader's + // explicit request for THAT node and must not be silently dropped. + const model = buildTasksModel(input({ + catalogs: { root: catalog([child('mate'), child('sub')]), sub: catalog([]) }, + byId: { root: summary('root'), mate: summary('mate'), sub: summary('sub') }, + teamMembers: [member({ id: 'mate', phase: 'failed', status: 'failed' })], + folded: false, + foldedIds: new Set(['mate', 'sub']), + })) + expect(model.map(node => node.id)).toEqual(['root', 'fold:root']) + expect(model.find(node => node.kind === 'fold')).toMatchObject({ + count: 2, memberIds: ['mate', 'sub'], + }) + }) + + it('leaves a failed teammate alone under the AUTO rule (only a manual fold takes it)', () => { + const model = buildTasksModel(input({ + catalogs: { root: catalog([child('mate'), child('sub')]), sub: catalog([]) }, + byId: { root: summary('root'), mate: summary('mate'), sub: summary('sub') }, + teamMembers: [member({ id: 'mate', phase: 'failed', status: 'failed' })], + folded: true, + })) + expect(model.map(node => node.id)).toEqual(['root', 'mate', 'fold:root']) + }) + + it('never folds a RUNNING team member, even when the reader clicks its chevron', () => { + const model = buildTasksModel(input({ + catalogs: { root: catalog([child('mate')]) }, + byId: { root: summary('root'), mate: summary('mate') }, + teamMembers: [member({ id: 'mate', phase: 'active', status: 'running' })], + folded: false, + foldedIds: new Set(['mate']), + })) + expect(model.map(node => node.id)).toEqual(['root', 'mate']) + }) + it('folds settled leaves per parent and keeps running/teammate/current rows', () => { const model = buildTasksModel(input({ catalogs: {