fix(tasks): 逐节点折叠按钮在聚合规则下失效(手动折叠不再共用自动折叠的守卫)

用户反馈「怎么折叠和怎么自动合并功能没了?我点击没反应」。

根因是折叠入口把两个触发条件塞进了同一组守卫:

    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)
This commit is contained in:
Menghuan1918
2026-09-27 22:19:05 +08:00
parent d8bc6566df
commit a7338bee1a
2 changed files with 87 additions and 22 deletions
+47 -22
View File
@@ -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<string>
}
@@ -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<string, WorkflowRunView[]>): 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)