diff --git a/src/client/TasksGraph.tsx b/src/client/TasksGraph.tsx
index 3f070b7..964d823 100644
--- a/src/client/TasksGraph.tsx
+++ b/src/client/TasksGraph.tsx
@@ -41,7 +41,7 @@ import {
type GraphBox,
} from './tasks-graph-layout.ts'
import {
- agentIdentity, agentMeta, foldBadgeCount, foldPreviews, foldTally, TaskLine, WorkflowGlyph, workflowMeta,
+ agentIdentity, agentMeta, foldPreviews, foldTally, TaskLine, WorkflowGlyph, workflowMeta,
} from './tasks-shared.tsx'
import { CardBar, CardTop, phaseClass, type CardKind } from './tasks-card.tsx'
import { doneActivityTitle, liveActivityLabel } from './process-labels.ts'
@@ -558,6 +558,14 @@ function renderAgentNode(
onFold?: (node: TasksAgentNode) => void,
): ReactNode {
const settled = node.state === 'done' || node.state === 'error'
+ /**
+ * Whether this card offers its own fold chevron. A WAITING member counts:
+ * it is not executing either, and the chevron is the reader's own trigger
+ * (independent of the page-level rule and of the idle head count). The
+ * `settled` flag stays a separate idea — that one controls how the card
+ * RECEDES, and an idle teammate is not finished work.
+ */
+ const foldable = settled || node.state === 'idle'
return (
{ onFold(node) } }
: {})}
/>
@@ -667,16 +675,17 @@ function renderFoldNode(
>
-
- {/* The tally SAYS what was folded (✓N 已完成 · N 待命 · 出错 N): the
- badge alone would read as "all done" once idle members ride along,
- and the bar is too narrow for a tally AND an action word. */}
+
+ {/* The bar SAYS what the row holds — "✓ N 已完成 · 出错 N" for finished
+ work (a stray failure would otherwise hide inside the ✓) and
+ "N 待命" for the waiting members. No action word: the tally needs
+ the room and the chevron already points at 展开. */}
{foldTally(node)}
diff --git a/src/client/locales-ar.ts b/src/client/locales-ar.ts
index e92629a..50b4913 100644
--- a/src/client/locales-ar.ts
+++ b/src/client/locales-ar.ts
@@ -371,6 +371,7 @@ export const ar: Record = {
settingsViewModeGraphDesc: 'مخطط عُقد متدرج مع تكبير وتحريك',
settingsViewModeTree: 'شجرة',
settingsViewModeTreeDesc: 'شجرة كلاسيكية بمسافات بادئة، قابلة للتنقل بلوحة المفاتيح',
+ tasksFoldIdleGroup: 'N waiting',
tasksFoldIdle: 'Idle {count}',
tasksFoldCompleted: '✓ {count} مكتمل',
tasksFoldOne: 'الطي مع المكتملة',
diff --git a/src/client/locales-de.ts b/src/client/locales-de.ts
index c0d9e11..b60b997 100644
--- a/src/client/locales-de.ts
+++ b/src/client/locales-de.ts
@@ -356,6 +356,7 @@ export const de: Record = {
settingsViewModeGraphDesc: 'Geschichtetes Knotendiagramm mit Zoom und Schwenken',
settingsViewModeTree: 'Baum',
settingsViewModeTreeDesc: 'Klassischer Einrückungsbaum, per Tastatur navigierbar',
+ tasksFoldIdleGroup: 'N waiting',
tasksFoldIdle: 'Idle {count}',
tasksFoldCompleted: '✓ {count} abgeschlossen',
tasksFoldOne: 'Zu Erledigten falten',
diff --git a/src/client/locales-fr.ts b/src/client/locales-fr.ts
index d4dff73..203f19e 100644
--- a/src/client/locales-fr.ts
+++ b/src/client/locales-fr.ts
@@ -363,6 +363,7 @@ export const fr: Record = {
settingsViewModeGraphDesc: 'Graphe de nœuds en couches, avec zoom et panoramique',
settingsViewModeTree: 'Arbre',
settingsViewModeTreeDesc: 'Arbre à indentation classique, navigable au clavier',
+ tasksFoldIdleGroup: 'N waiting',
tasksFoldIdle: 'Idle {count}',
tasksFoldCompleted: '✓ {count} terminés',
tasksFoldOne: 'Replier avec les terminés',
diff --git a/src/client/locales-hi.ts b/src/client/locales-hi.ts
index 54f4a2a..7f1a13c 100644
--- a/src/client/locales-hi.ts
+++ b/src/client/locales-hi.ts
@@ -370,6 +370,7 @@ export const hi: Record = {
settingsViewModeGraphDesc: 'स्तरित नोड ग्राफ़, ज़ूम और पैन के साथ',
settingsViewModeTree: 'ट्री',
settingsViewModeTreeDesc: 'क्लासिक इंडेंटेड ट्री, कीबोर्ड से नेविगेट करने योग्य',
+ tasksFoldIdleGroup: 'N waiting',
tasksFoldIdle: 'Idle {count}',
tasksFoldCompleted: '✓ {count} पूर्ण',
tasksFoldOne: 'पूर्ण में समेटें',
diff --git a/src/client/locales-id.ts b/src/client/locales-id.ts
index 2fca80e..934e287 100644
--- a/src/client/locales-id.ts
+++ b/src/client/locales-id.ts
@@ -368,6 +368,7 @@ export const id: Record = {
settingsViewModeGraphDesc: 'Graf node berlapis dengan zoom dan pan',
settingsViewModeTree: 'Pohon',
settingsViewModeTreeDesc: 'Pohon indentasi klasik, dapat dinavigasi dengan keyboard',
+ tasksFoldIdleGroup: 'N waiting',
tasksFoldIdle: 'Idle {count}',
tasksFoldCompleted: '✓ {count} selesai',
tasksFoldOne: 'Lipat ke selesai',
diff --git a/src/client/locales-it.ts b/src/client/locales-it.ts
index 8e969cf..ce4c5bd 100644
--- a/src/client/locales-it.ts
+++ b/src/client/locales-it.ts
@@ -361,6 +361,7 @@ export const it: Record = {
settingsViewModeGraphDesc: 'Grafo di nodi a livelli, con zoom e pan',
settingsViewModeTree: 'Albero',
settingsViewModeTreeDesc: 'Albero a rientri classico, navigabile da tastiera',
+ tasksFoldIdleGroup: 'N waiting',
tasksFoldIdle: 'Idle {count}',
tasksFoldCompleted: '✓ {count} completati',
tasksFoldOne: 'Raggruppa nei completati',
diff --git a/src/client/locales-ja.ts b/src/client/locales-ja.ts
index d5fec78..cf0b5a0 100644
--- a/src/client/locales-ja.ts
+++ b/src/client/locales-ja.ts
@@ -370,6 +370,7 @@ export const ja: Record = {
settingsViewModeGraphDesc: '階層ノードグラフ(ズーム・パン対応)',
settingsViewModeTree: 'ツリー',
settingsViewModeTreeDesc: '従来のインデントツリー(キーボード操作可)',
+ tasksFoldIdleGroup: 'N 件待機',
tasksFoldIdle: '待機 {count}',
tasksFoldCompleted: '✓ {count} 件完了',
tasksFoldOne: '完了済みにまとめる',
diff --git a/src/client/locales-ko.ts b/src/client/locales-ko.ts
index 2d7e17e..68bf606 100644
--- a/src/client/locales-ko.ts
+++ b/src/client/locales-ko.ts
@@ -362,6 +362,7 @@ export const ko: Record = {
settingsViewModeGraphDesc: '계층형 노드 그래프, 확대/이동 가능',
settingsViewModeTree: '트리',
settingsViewModeTreeDesc: '클래식 들여쓰기 트리, 키보드 탐색 지원',
+ tasksFoldIdleGroup: 'N waiting',
tasksFoldIdle: 'Idle {count}',
tasksFoldCompleted: '✓ {count} 완료됨',
tasksFoldOne: '완료 항목으로 접기',
diff --git a/src/client/locales-nl.ts b/src/client/locales-nl.ts
index 0860a7c..eea28ff 100644
--- a/src/client/locales-nl.ts
+++ b/src/client/locales-nl.ts
@@ -368,6 +368,7 @@ export const nl: Record = {
settingsViewModeGraphDesc: 'Gelaagde knoppengrafiek met in- en uitzoomen en pannen',
settingsViewModeTree: 'Boom',
settingsViewModeTreeDesc: 'Klassieke inspringboom, navigeerbaar met het toetsenbord',
+ tasksFoldIdleGroup: 'N waiting',
tasksFoldIdle: 'Idle {count}',
tasksFoldCompleted: '✓ {count} voltooid',
tasksFoldOne: 'Vouwen bij afgerond',
diff --git a/src/client/locales-pl.ts b/src/client/locales-pl.ts
index 16e0e8a..2ca16e5 100644
--- a/src/client/locales-pl.ts
+++ b/src/client/locales-pl.ts
@@ -372,6 +372,7 @@ export const pl: Record = {
settingsViewModeGraphDesc: 'Warstwowy graf węzłów ze zoomowaniem i przesuwaniem',
settingsViewModeTree: 'Drzewo',
settingsViewModeTreeDesc: 'Klasyczne drzewo wcięć, nawigowalne z klawiatury',
+ tasksFoldIdleGroup: 'N waiting',
tasksFoldIdle: 'Idle {count}',
tasksFoldCompleted: '✓ {count} ukończonych',
tasksFoldOne: 'Zwiń do ukończonych',
diff --git a/src/client/locales-pt.ts b/src/client/locales-pt.ts
index 92be29a..f4c021b 100644
--- a/src/client/locales-pt.ts
+++ b/src/client/locales-pt.ts
@@ -353,6 +353,7 @@ export const pt: Record = {
settingsViewModeGraphDesc: 'Grafo de nós em camadas, com zoom e deslocamento',
settingsViewModeTree: 'Árvore',
settingsViewModeTreeDesc: 'Árvore com indentação clássica, navegável pelo teclado',
+ tasksFoldIdleGroup: 'N waiting',
tasksFoldIdle: 'Idle {count}',
tasksFoldCompleted: '✓ {count} concluídos',
tasksFoldOne: 'Dobrar nos concluídos',
diff --git a/src/client/locales-ru.ts b/src/client/locales-ru.ts
index 5b1e96e..c52e8bd 100644
--- a/src/client/locales-ru.ts
+++ b/src/client/locales-ru.ts
@@ -368,6 +368,7 @@ export const ru: Record = {
settingsViewModeGraphDesc: 'Многоуровневый граф узлов с масштабированием и панорамированием',
settingsViewModeTree: 'Дерево',
settingsViewModeTreeDesc: 'Классическое дерево с отступами, навигация с клавиатуры',
+ tasksFoldIdleGroup: 'N waiting',
tasksFoldIdle: 'Idle {count}',
tasksFoldCompleted: '✓ {count} завершено',
tasksFoldOne: 'Свернуть в завершённые',
diff --git a/src/client/locales-sv.ts b/src/client/locales-sv.ts
index 628c872..d0ff066 100644
--- a/src/client/locales-sv.ts
+++ b/src/client/locales-sv.ts
@@ -353,6 +353,7 @@ export const sv: Record = {
settingsViewModeGraphDesc: 'Lagerindelad nodgraf med zoom och panorering',
settingsViewModeTree: 'Träd',
settingsViewModeTreeDesc: 'Klassiskt indragsträd, navigerbart med tangentbord',
+ tasksFoldIdleGroup: 'N waiting',
tasksFoldIdle: 'Idle {count}',
tasksFoldCompleted: '✓ {count} klara',
tasksFoldOne: 'Vik ihop med klara',
diff --git a/src/client/locales-th.ts b/src/client/locales-th.ts
index 3cb3c9e..1fed493 100644
--- a/src/client/locales-th.ts
+++ b/src/client/locales-th.ts
@@ -370,6 +370,7 @@ export const th: Record = {
settingsViewModeGraphDesc: 'กราฟโหนดแบบชั้น ซูมและเลื่อนได้',
settingsViewModeTree: 'มุมมองต้นไม้',
settingsViewModeTreeDesc: 'ต้นไม้แบบเยื้องคลาสสิก นำทางด้วยคีย์บอร์ดได้',
+ tasksFoldIdleGroup: 'N waiting',
tasksFoldIdle: 'Idle {count}',
tasksFoldCompleted: '✓ เสร็จแล้ว {count}',
tasksFoldOne: 'พับรวมกับที่เสร็จแล้ว',
diff --git a/src/client/locales-tr.ts b/src/client/locales-tr.ts
index ebc7dec..31c3dd6 100644
--- a/src/client/locales-tr.ts
+++ b/src/client/locales-tr.ts
@@ -370,6 +370,7 @@ export const tr: Record = {
settingsViewModeGraphDesc: 'Yakınlaştırma ve kaydırma ile katmanlı düğüm grafiği',
settingsViewModeTree: 'Ağaç',
settingsViewModeTreeDesc: 'Klasik girintili ağaç, klavye ile gezilebilir',
+ tasksFoldIdleGroup: 'N waiting',
tasksFoldIdle: 'Idle {count}',
tasksFoldCompleted: '✓ {count} tamamlandı',
tasksFoldOne: 'Tamamlananlara katla',
diff --git a/src/client/locales-vi.ts b/src/client/locales-vi.ts
index 3bf45ae..e8a6fb9 100644
--- a/src/client/locales-vi.ts
+++ b/src/client/locales-vi.ts
@@ -370,6 +370,7 @@ export const vi: Record = {
settingsViewModeGraphDesc: 'Đồ thị nút phân tầng với thu phóng và di chuyển',
settingsViewModeTree: 'Cây',
settingsViewModeTreeDesc: 'Cây thụt lề cổ điển, điều hướng được bằng bàn phím',
+ tasksFoldIdleGroup: 'N waiting',
tasksFoldIdle: 'Idle {count}',
tasksFoldCompleted: '✓ {count} hoàn thành',
tasksFoldOne: 'Gộp vào đã xong',
diff --git a/src/client/locales-zh-HK.ts b/src/client/locales-zh-HK.ts
index b0d6b28..8b189bd 100644
--- a/src/client/locales-zh-HK.ts
+++ b/src/client/locales-zh-HK.ts
@@ -385,6 +385,7 @@ export const zhHK: Record = {
settingsViewModeGraphDesc: '分層節點圖,可縮放平移',
settingsViewModeTree: '樹狀圖',
settingsViewModeTreeDesc: '經典縮排樹,可用鍵盤導航',
+ tasksFoldIdleGroup: 'N 個待命',
tasksFoldIdle: '{count} 待命',
tasksFoldCompleted: '✓ {count} 已完成',
tasksFoldOne: '收進已完成聚合',
diff --git a/src/client/locales-zh-MO.ts b/src/client/locales-zh-MO.ts
index bf144fa..7229f76 100644
--- a/src/client/locales-zh-MO.ts
+++ b/src/client/locales-zh-MO.ts
@@ -385,6 +385,7 @@ export const zhMO: Record = {
settingsViewModeGraphDesc: '分層節點圖,可縮放平移',
settingsViewModeTree: '樹狀圖',
settingsViewModeTreeDesc: '經典縮排樹,可用鍵盤導航',
+ tasksFoldIdleGroup: 'N 個待命',
tasksFoldIdle: '{count} 待命',
tasksFoldCompleted: '✓ {count} 已完成',
tasksFoldOne: '收進已完成聚合',
diff --git a/src/client/locales-zh-TW.ts b/src/client/locales-zh-TW.ts
index 9cdce96..a28c831 100644
--- a/src/client/locales-zh-TW.ts
+++ b/src/client/locales-zh-TW.ts
@@ -385,6 +385,7 @@ export const zhTW: Record = {
settingsViewModeGraphDesc: '分層節點圖,可縮放平移',
settingsViewModeTree: '樹狀圖',
settingsViewModeTreeDesc: '經典縮排樹,可用鍵盤導航',
+ tasksFoldIdleGroup: 'N 個待命',
tasksFoldIdle: '{count} 待命',
tasksFoldCompleted: '✓ {count} 已完成',
tasksFoldOne: '收進已完成聚合',
diff --git a/src/client/locales.ts b/src/client/locales.ts
index cead04b..51ea459 100644
--- a/src/client/locales.ts
+++ b/src/client/locales.ts
@@ -382,6 +382,7 @@ export const zh = {
settingsViewModeGraphDesc: '分层节点图,可缩放平移',
settingsViewModeTree: '树状图',
settingsViewModeTreeDesc: '经典缩进树,键盘可导航',
+ tasksFoldIdleGroup: 'N 个待命',
tasksFoldIdle: '{count} 待命',
tasksFoldCompleted: '✓ {count} 已完成',
tasksFoldOne: '收进已完成聚合',
@@ -866,6 +867,7 @@ export const en: Record = {
settingsViewModeGraphDesc: 'Layered node graph with zoom and pan',
settingsViewModeTree: 'Tree',
settingsViewModeTreeDesc: 'Classic indentation tree, keyboard navigable',
+ tasksFoldIdleGroup: 'N waiting',
tasksFoldIdle: 'Idle {count}',
tasksFoldCompleted: '✓ {count} completed',
tasksFoldOne: 'Fold into completed',
diff --git a/src/client/tasks-card.tsx b/src/client/tasks-card.tsx
index 5d10864..6563e5d 100644
--- a/src/client/tasks-card.tsx
+++ b/src/client/tasks-card.tsx
@@ -27,27 +27,29 @@ import css from './tasks-graph.module.css'
/** The five kinds of card the graph draws. */
export type CardKind = 'main' | 'subagent' | 'teammate' | 'workflow' | 'fold'
+/** Which aggregate a fold card is (`done` = finished/failed, `idle` = waiting). */
+export type CardFoldKind = 'done' | 'idle'
/**
* The kind badge's word: the SAME vocabulary the rest of the page uses (the
* header already calls the root "主代理"), so no sixth word is invented for it.
*
- * The aggregate's badge says 已完成 only when something WAS completed: an
- * aggregate that sweeps idle teammates (or only failed rows) and still claimed
- * "✓ N 已完成" would be lying about what is inside it, so `foldBadgeIdle`
- * switches it to the idle word for exactly that case.
+ * The two aggregates say different things, because they hold different work:
+ * `done` keeps "✓ N 已完成" (finished and failed rows alike) while `idle` says
+ * "N 个待命". One mixed row could only have hedged ("✓ 2 已完成 · 3 待命"),
+ * which is exactly what splitting them removed.
* @param kind - card kind.
* @param count - folded member count (the fold aggregate's badge only).
- * @param foldBadgeIdle - the aggregate holds no completed row (see above).
+ * @param foldKind - which aggregate (`done` unless stated).
*/
-export function cardKindLabel(kind: CardKind, count = 0, foldBadgeIdle = false): string {
+export function cardKindLabel(kind: CardKind, count = 0, foldKind: CardFoldKind = 'done'): string {
switch (kind) {
case 'main': return t('subagentMainAgent')
case 'subagent': return t('tasksKindSubagent')
case 'teammate': return t('tasksKindTeammate')
case 'workflow': return t('tasksKindWorkflow')
- case 'fold': return foldBadgeIdle
- ? t('tasksFoldIdle', { count })
+ case 'fold': return foldKind === 'idle'
+ ? t('tasksFoldIdleGroup')
: t('tasksFoldCompleted', { count })
}
}
@@ -77,8 +79,8 @@ export function CardTop(props: {
kind: CardKind
/** Folded member count, for the aggregate's badge. */
count?: number
- /** Whether the aggregate holds no completed row (its badge switches word). */
- badgeIdle?: boolean
+ /** Which aggregate this card is, when `kind` is `fold`. */
+ foldKind?: CardFoldKind
/** Tree depth → the group colour of the badge and the card's left edge. */
depth: number
/** Workflow phase (a member's badge). */
@@ -92,12 +94,12 @@ export function CardTop(props: {
/** Extra rows the caller appends (the team task line). */
children?: ReactNode
}): ReactNode {
- const { kind, count, badgeIdle, depth, phase } = props
+ const { kind, count, foldKind, depth, phase } = props
return (
- {cardKindLabel(kind, count ?? 0, badgeIdle === true)}
+ {cardKindLabel(kind, count ?? 0, foldKind ?? 'done')}
{phase !== undefined && (
member.id),
+ previews: members.slice(0, 2).map(member => member.label),
+ }
+}
+
/**
* 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
@@ -398,78 +443,59 @@ export function buildTasksModel(input: TasksModelInput): TasksNode[] {
}
const visibleAgentChildren = agentChildren.filter(child => child.parentId === parentId)
- // Fold: this parent's non-executing children collapse into one aggregate.
- // Two triggers, two guard sets: the page-level `folded` rule also spares
- // branching nodes and (below the idle head count) teammates; a manual
- // `foldedIds` fold is the reader asking for THAT node, so it only spares
- // running/current work.
+ // Fold: this parent's non-executing children collapse into one aggregate
+ // PER GROUP — finished/failed work in one row, waiting members in another,
+ // so the badge can say what it holds and a reader can sweep one without
+ // the other. Two triggers, two guard sets: the page-level `folded` rule
+ // also spares branching nodes and (below the idle head count) teammates; a
+ // manual `foldedIds` fold is the reader asking for THAT node, so it only
+ // spares running/current work.
const idleMembers = visibleAgentChildren
.filter(child => child.state === 'idle' && child.team !== undefined).length
const foldIdleMembers = idleMembers >= FOLD_IDLE_MIN
const kept: TasksAgentNode[] = []
- const foldCandidates: TasksAgentNode[] = []
+ const doneFold: TasksAgentNode[] = []
+ const idleFold: TasksAgentNode[] = []
for (const child of visibleAgentChildren) {
const foldable = nodeFoldable(child, currentSessionId)
const auto = folded && foldable && isAutoFoldable(child, runsByOrigin, foldIdleMembers)
const manual = foldedIds?.has(child.id) === true && foldable
- if (auto || manual) foldCandidates.push(child)
- else kept.push(child)
+ if (!auto && !manual) {
+ kept.push(child)
+ } else if (child.state === 'idle') {
+ idleFold.push(child)
+ } else {
+ doneFold.push(child)
+ }
}
for (const child of kept) {
out.push(child)
appendChildren(child.id, child.depth)
}
- if (foldCandidates.length > 0) {
- const counts = tally(foldCandidates)
- out.push({
- kind: 'fold',
- id: `fold:${parentId}`,
- parentId,
- depth: depth + 1,
- count: foldCandidates.length,
- doneCount: counts.done,
- idleCount: counts.idle,
- errorCount: counts.error,
- hasIdle: counts.idle > 0,
- memberIds: foldCandidates.map(child => child.id),
- previews: foldCandidates.slice(0, 2).map(child => child.label),
- })
- }
+ if (doneFold.length > 0) out.push(foldNode('done', parentId, depth + 1, doneFold, ''))
+ if (idleFold.length > 0) out.push(foldNode('idle', parentId, depth + 1, idleFold, ':idle'))
for (let index = 0; index < runNodes.length; index += 1) {
const runNode = runNodes[index]
if (runNode === undefined) continue
out.push(runNode)
const members = memberNodes[index] ?? []
- // 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).
+ // The run's own members fold under the same two triggers and the same
+ // two groups; they need no leaf test (they are leaves by construction
+ // here) and no team guard (a run member is not a roster row).
const keptMembers: TasksAgentNode[] = []
- const foldedMembers: TasksAgentNode[] = []
+ const runDone: TasksAgentNode[] = []
+ const runIdle: TasksAgentNode[] = []
for (const member of members) {
- if ((folded || foldedIds?.has(member.id) === true) && nodeFoldable(member, currentSessionId)) {
- foldedMembers.push(member)
- } else {
- keptMembers.push(member)
- }
+ const hidden = (folded || foldedIds?.has(member.id) === true)
+ && nodeFoldable(member, currentSessionId)
+ if (!hidden) keptMembers.push(member)
+ else if (member.state === 'idle') runIdle.push(member)
+ else runDone.push(member)
}
out.push(...keptMembers)
- if (foldedMembers.length > 0) {
- const counts = tally(foldedMembers)
- out.push({
- kind: 'fold',
- id: `fold:${runNode.id}`,
- parentId: runNode.id,
- depth: runNode.depth + 1,
- count: foldedMembers.length,
- doneCount: counts.done,
- idleCount: counts.idle,
- errorCount: counts.error,
- hasIdle: counts.idle > 0,
- memberIds: foldedMembers.map(member => member.id),
- previews: foldedMembers.slice(0, 2).map(member => member.label),
- })
- }
+ if (runDone.length > 0) out.push(foldNode('done', runNode.id, runNode.depth + 1, runDone, ''))
+ if (runIdle.length > 0) out.push(foldNode('idle', runNode.id, runNode.depth + 1, runIdle, ':idle'))
}
}
diff --git a/src/client/tasks-shared.tsx b/src/client/tasks-shared.tsx
index d39dd7d..078a567 100644
--- a/src/client/tasks-shared.tsx
+++ b/src/client/tasks-shared.tsx
@@ -288,22 +288,18 @@ export function foldPreviews(previews: readonly string[]): string {
}
/**
- * The fold aggregate's state tally — `✓ 2 已完成 · 3 待命 · 1 出错`.
+ * The fold aggregate's bar text.
*
- * The aggregate has to SAY what it swallowed: the badge's "✓ N 已完成" is
- * true only for the settled part, and an aggregate that also sweeps idle
- * members (or errors) would otherwise read as a lie. Parts that are zero are
- * omitted, so an all-done aggregate stays exactly as short as it was.
+ * The two groups read differently on purpose. The `done` row counts its
+ * finished members and calls out failures beside them (`✓ 4 已完成 · 出错 1`
+ * — a row of nothing but failures is `出错 N`), because "✓ N 已完成" alone
+ * would quietly include them. The `idle` row says what the members are doing
+ * now: nothing (`N 待命`), which is why they were swept away.
*/
export function foldTally(node: TasksFoldNode): string {
+ if (node.foldKind === 'idle') return t('tasksFoldIdle', { count: node.count })
const parts: string[] = []
if (node.doneCount > 0) parts.push(t('tasksFoldCompleted', { count: node.doneCount }))
- if (node.idleCount > 0) parts.push(t('tasksFoldIdle', { count: node.idleCount }))
if (node.errorCount > 0) parts.push(`${t('tasksStateError')} ${node.errorCount}`)
return parts.join(' · ')
}
-
-/** The aggregate's cross-state badge word: 已完成 unless nothing is done. */
-export function foldBadgeCount(node: TasksFoldNode): number {
- return node.doneCount > 0 ? node.doneCount : node.count
-}
diff --git a/tests/tasks-model.spec.ts b/tests/tasks-model.spec.ts
index 74cbcd8..9b0befc 100644
--- a/tests/tasks-model.spec.ts
+++ b/tests/tasks-model.spec.ts
@@ -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,
})
})
diff --git a/tests/tasks-page.spec.tsx b/tests/tasks-page.spec.tsx
index bcbfd75..10c581d 100644
--- a/tests/tasks-page.spec.tsx
+++ b/tests/tasks-page.spec.tsx
@@ -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()
})