diff --git a/src/client/TasksGraph.tsx b/src/client/TasksGraph.tsx
index 43a9357..3f070b7 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, foldPreviews, TaskLine, WorkflowGlyph, workflowMeta,
+ agentIdentity, agentMeta, foldBadgeCount, 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'
@@ -657,7 +657,7 @@ function renderFoldNode(
data-depth={node.depth}
role="button"
tabIndex={-1}
- aria-label={`${t('tasksFoldCompleted', { count: node.count })} · ${t('tasksFoldExpand')}`}
+ aria-label={`${foldTally(node)} · ${t('tasksFoldExpand')}`}
className={clsx(css.node, css.nodeFold)}
style={style}
onClick={() => {
@@ -667,13 +667,17 @@ function renderFoldNode(
>
- {t('tasksFoldExpand')}
+ {/* 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. */}
+ {foldTally(node)}
diff --git a/src/client/TasksTree.tsx b/src/client/TasksTree.tsx
index 089e582..b074b62 100644
--- a/src/client/TasksTree.tsx
+++ b/src/client/TasksTree.tsx
@@ -20,8 +20,8 @@ import clsx from 'clsx'
import { IconLoadingOutlineRegular, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import type { TasksAgentNode, TasksNode, TasksWorkflowNode } from './tasks-model.ts'
import {
- AgentGlyph, agentMeta, FoldGlyph, foldPreviews, LiveLine, nodeDotState, TaskLine,
- WorkflowGlyph, workflowMeta,
+ AgentGlyph, agentMeta, FoldGlyph, foldPreviews, foldTally, LiveLine, nodeDotState,
+ TaskLine, WorkflowGlyph, workflowMeta,
} from './tasks-shared.tsx'
import { FoldToggleButton, ViewModeToggle } from './TasksGraph.tsx'
import { t } from './locales.ts'
@@ -104,7 +104,7 @@ export function TasksTree(props: TasksTreeProps): ReactNode {
role="treeitem"
tabIndex={0}
aria-level={depth + 1}
- aria-label={`${t('tasksFoldCompleted', { count: node.count })} · ${t('tasksFoldExpand')}`}
+ aria-label={`${foldTally(node)} · ${t('tasksFoldExpand')}`}
className={clsx(css.treeRow, css.treeFoldRow)}
onClick={onToggleFold}
onKeyDown={(event) => { activateOnKey(event, onToggleFold) }}
@@ -112,7 +112,7 @@ export function TasksTree(props: TasksTreeProps): ReactNode {
- {t('tasksFoldCompleted', { count: node.count })}
+ {foldTally(node)}
{t(folded ? 'tasksFoldExpand' : 'tasksFoldCollapse')}
diff --git a/src/client/locales-ar.ts b/src/client/locales-ar.ts
index 3c24139..e92629a 100644
--- a/src/client/locales-ar.ts
+++ b/src/client/locales-ar.ts
@@ -371,6 +371,7 @@ export const ar: Record = {
settingsViewModeGraphDesc: 'مخطط عُقد متدرج مع تكبير وتحريك',
settingsViewModeTree: 'شجرة',
settingsViewModeTreeDesc: 'شجرة كلاسيكية بمسافات بادئة، قابلة للتنقل بلوحة المفاتيح',
+ tasksFoldIdle: 'Idle {count}',
tasksFoldCompleted: '✓ {count} مكتمل',
tasksFoldOne: 'الطي مع المكتملة',
tasksFoldExpand: 'توسيع العُقد المكتملة',
diff --git a/src/client/locales-de.ts b/src/client/locales-de.ts
index 0e53637..c0d9e11 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',
+ tasksFoldIdle: 'Idle {count}',
tasksFoldCompleted: '✓ {count} abgeschlossen',
tasksFoldOne: 'Zu Erledigten falten',
tasksFoldExpand: 'Abgeschlossene Knoten ausklappen',
diff --git a/src/client/locales-fr.ts b/src/client/locales-fr.ts
index 1a52578..d4dff73 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',
+ tasksFoldIdle: 'Idle {count}',
tasksFoldCompleted: '✓ {count} terminés',
tasksFoldOne: 'Replier avec les terminés',
tasksFoldExpand: 'Déplier les nœuds terminés',
diff --git a/src/client/locales-hi.ts b/src/client/locales-hi.ts
index ab6c1be..54f4a2a 100644
--- a/src/client/locales-hi.ts
+++ b/src/client/locales-hi.ts
@@ -370,6 +370,7 @@ export const hi: Record = {
settingsViewModeGraphDesc: 'स्तरित नोड ग्राफ़, ज़ूम और पैन के साथ',
settingsViewModeTree: 'ट्री',
settingsViewModeTreeDesc: 'क्लासिक इंडेंटेड ट्री, कीबोर्ड से नेविगेट करने योग्य',
+ tasksFoldIdle: 'Idle {count}',
tasksFoldCompleted: '✓ {count} पूर्ण',
tasksFoldOne: 'पूर्ण में समेटें',
tasksFoldExpand: 'पूर्ण नोड्स विस्तारित करें',
diff --git a/src/client/locales-id.ts b/src/client/locales-id.ts
index 1283395..2fca80e 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',
+ tasksFoldIdle: 'Idle {count}',
tasksFoldCompleted: '✓ {count} selesai',
tasksFoldOne: 'Lipat ke selesai',
tasksFoldExpand: 'Perluas node yang selesai',
diff --git a/src/client/locales-it.ts b/src/client/locales-it.ts
index 1882118..8e969cf 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',
+ tasksFoldIdle: 'Idle {count}',
tasksFoldCompleted: '✓ {count} completati',
tasksFoldOne: 'Raggruppa nei completati',
tasksFoldExpand: 'Espandi i nodi completati',
diff --git a/src/client/locales-ja.ts b/src/client/locales-ja.ts
index dd4a0bf..d5fec78 100644
--- a/src/client/locales-ja.ts
+++ b/src/client/locales-ja.ts
@@ -370,6 +370,7 @@ export const ja: Record = {
settingsViewModeGraphDesc: '階層ノードグラフ(ズーム・パン対応)',
settingsViewModeTree: 'ツリー',
settingsViewModeTreeDesc: '従来のインデントツリー(キーボード操作可)',
+ tasksFoldIdle: '待機 {count}',
tasksFoldCompleted: '✓ {count} 件完了',
tasksFoldOne: '完了済みにまとめる',
tasksFoldExpand: '完了ノードを展開',
diff --git a/src/client/locales-ko.ts b/src/client/locales-ko.ts
index 795113e..2d7e17e 100644
--- a/src/client/locales-ko.ts
+++ b/src/client/locales-ko.ts
@@ -362,6 +362,7 @@ export const ko: Record = {
settingsViewModeGraphDesc: '계층형 노드 그래프, 확대/이동 가능',
settingsViewModeTree: '트리',
settingsViewModeTreeDesc: '클래식 들여쓰기 트리, 키보드 탐색 지원',
+ tasksFoldIdle: 'Idle {count}',
tasksFoldCompleted: '✓ {count} 완료됨',
tasksFoldOne: '완료 항목으로 접기',
tasksFoldExpand: '완료된 노드 펼치기',
diff --git a/src/client/locales-nl.ts b/src/client/locales-nl.ts
index ef222cc..0860a7c 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',
+ tasksFoldIdle: 'Idle {count}',
tasksFoldCompleted: '✓ {count} voltooid',
tasksFoldOne: 'Vouwen bij afgerond',
tasksFoldExpand: 'Voltooide knopen uitklappen',
diff --git a/src/client/locales-pl.ts b/src/client/locales-pl.ts
index 6fa3c25..16e0e8a 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',
+ tasksFoldIdle: 'Idle {count}',
tasksFoldCompleted: '✓ {count} ukończonych',
tasksFoldOne: 'Zwiń do ukończonych',
tasksFoldExpand: 'Rozwiń ukończone węzły',
diff --git a/src/client/locales-pt.ts b/src/client/locales-pt.ts
index 69414e9..92be29a 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',
+ tasksFoldIdle: 'Idle {count}',
tasksFoldCompleted: '✓ {count} concluídos',
tasksFoldOne: 'Dobrar nos concluídos',
tasksFoldExpand: 'Expandir os nós concluídos',
diff --git a/src/client/locales-ru.ts b/src/client/locales-ru.ts
index e2ec197..5b1e96e 100644
--- a/src/client/locales-ru.ts
+++ b/src/client/locales-ru.ts
@@ -368,6 +368,7 @@ export const ru: Record = {
settingsViewModeGraphDesc: 'Многоуровневый граф узлов с масштабированием и панорамированием',
settingsViewModeTree: 'Дерево',
settingsViewModeTreeDesc: 'Классическое дерево с отступами, навигация с клавиатуры',
+ tasksFoldIdle: 'Idle {count}',
tasksFoldCompleted: '✓ {count} завершено',
tasksFoldOne: 'Свернуть в завершённые',
tasksFoldExpand: 'Развернуть завершённые узлы',
diff --git a/src/client/locales-sv.ts b/src/client/locales-sv.ts
index f1d013c..628c872 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',
+ tasksFoldIdle: 'Idle {count}',
tasksFoldCompleted: '✓ {count} klara',
tasksFoldOne: 'Vik ihop med klara',
tasksFoldExpand: 'Visa färdiga noder',
diff --git a/src/client/locales-th.ts b/src/client/locales-th.ts
index 614771c..3cb3c9e 100644
--- a/src/client/locales-th.ts
+++ b/src/client/locales-th.ts
@@ -370,6 +370,7 @@ export const th: Record = {
settingsViewModeGraphDesc: 'กราฟโหนดแบบชั้น ซูมและเลื่อนได้',
settingsViewModeTree: 'มุมมองต้นไม้',
settingsViewModeTreeDesc: 'ต้นไม้แบบเยื้องคลาสสิก นำทางด้วยคีย์บอร์ดได้',
+ tasksFoldIdle: 'Idle {count}',
tasksFoldCompleted: '✓ เสร็จแล้ว {count}',
tasksFoldOne: 'พับรวมกับที่เสร็จแล้ว',
tasksFoldExpand: 'ขยายโหนดที่เสร็จแล้ว',
diff --git a/src/client/locales-tr.ts b/src/client/locales-tr.ts
index 9a30f5b..ebc7dec 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',
+ tasksFoldIdle: 'Idle {count}',
tasksFoldCompleted: '✓ {count} tamamlandı',
tasksFoldOne: 'Tamamlananlara katla',
tasksFoldExpand: 'Tamamlanan düğümleri genişlet',
diff --git a/src/client/locales-vi.ts b/src/client/locales-vi.ts
index e1dea51..3bf45ae 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',
+ tasksFoldIdle: 'Idle {count}',
tasksFoldCompleted: '✓ {count} hoàn thành',
tasksFoldOne: 'Gộp vào đã xong',
tasksFoldExpand: 'Mở rộng các nút đã hoàn thành',
diff --git a/src/client/locales-zh-HK.ts b/src/client/locales-zh-HK.ts
index 9eab98a..b0d6b28 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: '經典縮排樹,可用鍵盤導航',
+ tasksFoldIdle: '{count} 待命',
tasksFoldCompleted: '✓ {count} 已完成',
tasksFoldOne: '收進已完成聚合',
tasksFoldExpand: '展開已完成的節點',
diff --git a/src/client/locales-zh-MO.ts b/src/client/locales-zh-MO.ts
index 5aea694..bf144fa 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: '經典縮排樹,可用鍵盤導航',
+ tasksFoldIdle: '{count} 待命',
tasksFoldCompleted: '✓ {count} 已完成',
tasksFoldOne: '收進已完成聚合',
tasksFoldExpand: '展開已完成的節點',
diff --git a/src/client/locales-zh-TW.ts b/src/client/locales-zh-TW.ts
index 80348c3..9cdce96 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: '經典縮排樹,可用鍵盤導航',
+ tasksFoldIdle: '{count} 待命',
tasksFoldCompleted: '✓ {count} 已完成',
tasksFoldOne: '收進已完成聚合',
tasksFoldExpand: '展開已完成的節點',
diff --git a/src/client/locales.ts b/src/client/locales.ts
index f9425ae..cead04b 100644
--- a/src/client/locales.ts
+++ b/src/client/locales.ts
@@ -382,6 +382,7 @@ export const zh = {
settingsViewModeGraphDesc: '分层节点图,可缩放平移',
settingsViewModeTree: '树状图',
settingsViewModeTreeDesc: '经典缩进树,键盘可导航',
+ tasksFoldIdle: '{count} 待命',
tasksFoldCompleted: '✓ {count} 已完成',
tasksFoldOne: '收进已完成聚合',
tasksFoldExpand: '展开已完成的节点',
@@ -865,6 +866,7 @@ export const en: Record = {
settingsViewModeGraphDesc: 'Layered node graph with zoom and pan',
settingsViewModeTree: 'Tree',
settingsViewModeTreeDesc: 'Classic indentation tree, keyboard navigable',
+ tasksFoldIdle: 'Idle {count}',
tasksFoldCompleted: '✓ {count} completed',
tasksFoldOne: 'Fold into completed',
tasksFoldExpand: 'Expand the completed nodes',
diff --git a/src/client/tasks-card.tsx b/src/client/tasks-card.tsx
index 6ced5ca..5d10864 100644
--- a/src/client/tasks-card.tsx
+++ b/src/client/tasks-card.tsx
@@ -31,16 +31,24 @@ export type CardKind = 'main' | 'subagent' | 'teammate' | 'workflow' | 'fold'
/**
* 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.
* @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).
*/
-export function cardKindLabel(kind: CardKind, count = 0): string {
+export function cardKindLabel(kind: CardKind, count = 0, foldBadgeIdle = false): 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 t('tasksFoldCompleted', { count })
+ case 'fold': return foldBadgeIdle
+ ? t('tasksFoldIdle', { count })
+ : t('tasksFoldCompleted', { count })
}
}
@@ -69,6 +77,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
/** Tree depth → the group colour of the badge and the card's left edge. */
depth: number
/** Workflow phase (a member's badge). */
@@ -82,12 +92,12 @@ export function CardTop(props: {
/** Extra rows the caller appends (the team task line). */
children?: ReactNode
}): ReactNode {
- const { kind, count, depth, phase } = props
+ const { kind, count, badgeIdle, depth, phase } = props
return (
- {cardKindLabel(kind, count ?? 0)}
+ {cardKindLabel(kind, count ?? 0, badgeIdle === true)}
{phase !== undefined && (
): boolean {
- const isLeaf = node.hasChildren !== true && !runsByOrigin.has(node.id)
- return isLeaf && node.team === undefined
+function nodeFoldable(node: TasksAgentNode, currentSessionId: string): boolean {
+ return !node.current && node.id !== currentSessionId
+ && node.state !== 'running'
+}
+
+/** The extra guard the PAGE-LEVEL rule adds to {@link nodeFoldable}. */
+function isAutoFoldable(
+ node: TasksAgentNode,
+ runsByOrigin: Map,
+ foldIdleMembers: boolean,
+): boolean {
+ if (!foldIdleMembers && node.team !== undefined) return false
+ return node.hasChildren !== true && !runsByOrigin.has(node.id)
}
/**
- * 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.
+ * How many IDLE team members one parent needs before the page-level rule
+ * sweeps them into the aggregate too.
+ *
+ * A teammate that has finished its turn is `idle`: it is not executing, has
+ * nothing in flight, and can be resumed later — the same fact a plain
+ * subagent reports as `done`. It used to stay out of the aggregate entirely,
+ * so a wide team kept a card per member forever. The count is kept at three
+ * on purpose: one or two idle members are the team's working set (and their
+ * cards are where a task line shows up), while three or more is a roster the
+ * reader is scanning rather than watching. Nothing is lost either way — the
+ * aggregate row names the counts and expands on one click.
*/
-function nodeFoldable(node: TasksAgentNode, currentSessionId: string): boolean {
- const settled = node.state === 'done' || node.state === 'error'
- return settled && !node.current && node.id !== currentSessionId
+export const FOLD_IDLE_MIN = 3
+
+/** The state tally of one parent's folded children. */
+interface FoldTally {
+ done: number
+ idle: number
+ error: number
+}
+
+/** Count the folded children by display state. */
+function tally(folded: readonly TasksAgentNode[]): FoldTally {
+ const counts: FoldTally = { done: 0, idle: 0, error: 0 }
+ for (const node of folded) {
+ if (node.state === 'done') counts.done += 1
+ else if (node.state === 'idle') counts.idle += 1
+ else counts.error += 1
+ }
+ return counts
}
/**
@@ -354,15 +398,19 @@ 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.
+ // Fold: this parent's non-executing children collapse into one aggregate.
// 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.
+ // 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[] = []
for (const child of visibleAgentChildren) {
const foldable = nodeFoldable(child, currentSessionId)
- const auto = folded && foldable && isAutoFoldable(child, runsByOrigin)
+ 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)
@@ -373,12 +421,17 @@ export function buildTasksModel(input: TasksModelInput): TasksNode[] {
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),
})
@@ -402,12 +455,17 @@ export function buildTasksModel(input: TasksModelInput): TasksNode[] {
}
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),
})
diff --git a/src/client/tasks-shared.tsx b/src/client/tasks-shared.tsx
index 7c59702..d39dd7d 100644
--- a/src/client/tasks-shared.tsx
+++ b/src/client/tasks-shared.tsx
@@ -18,7 +18,9 @@ import {
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { SidebarChildLiveView } from '../context-types.ts'
import { liveActivityLabel } from './process-labels.ts'
-import type { TasksAgentNode, TasksNodeState, TasksNodeTask, TasksWorkflowNode } from './tasks-model.ts'
+import type {
+ TasksAgentNode, TasksFoldNode, TasksNodeState, TasksNodeTask, TasksWorkflowNode,
+} from './tasks-model.ts'
import { toolGlyph } from './tool-icons.tsx'
import { t, type CopyKey } from './locales.ts'
import css from './tasks-graph.module.css'
@@ -280,7 +282,28 @@ export function LiveLine(props: { live: SidebarChildLiveView | undefined }): Rea
)
}
-/** The fold aggregate's subtitle: up to two label previews joined by ·. */
+/** The fold aggregate's subtitle: up to two label previews joined by `/`. */
export function foldPreviews(previews: readonly string[]): string {
return previews.join(' / ')
}
+
+/**
+ * The fold aggregate's state tally — `✓ 2 已完成 · 3 待命 · 1 出错`.
+ *
+ * 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.
+ */
+export function foldTally(node: TasksFoldNode): string {
+ 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 307f94d..74cbcd8 100644
--- a/tests/tasks-model.spec.ts
+++ b/tests/tasks-model.spec.ts
@@ -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')]) },
diff --git a/tests/tasks-page.spec.tsx b/tests/tasks-page.spec.tsx
index 54d188a..bcbfd75 100644
--- a/tests/tasks-page.spec.tsx
+++ b/tests/tasks-page.spec.tsx
@@ -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(