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