From c0ff3d1afdca2fbe74b6881a69b369591d795a9c Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Sun, 27 Sep 2026 17:46:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(tasks):=20=E7=A9=BA=E9=97=B2=E6=88=90?= =?UTF-8?q?=E5=91=98=20=E2=89=A53=20=E6=97=B6=E8=87=AA=E5=8A=A8=E6=94=B6?= =?UTF-8?q?=E8=BF=9B=E8=81=9A=E5=90=88=EF=BC=8C=E8=81=9A=E5=90=88=E5=B9=B6?= =?UTF-8?q?=E5=A6=82=E5=AE=9E=E6=8A=A5=E5=87=BA=E7=8A=B6=E6=80=81=E9=85=8D?= =?UTF-8?q?=E6=AF=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户要求优化显示:「比如空闲时也折叠」。 在此之前,一个跑完一轮的 teammate(活体通道不报 running)映射成 idle, 而 idle 不在「已完成/出错」的可折叠集合里 —— 于是 8 人团队永远摆 8 张卡, 而同样「不在执行」的普通子代理却是 done、会折叠。同一事实两种显示,才是 这个痛点的根。 - 可折叠集合放宽到「不在执行」(done | idle | error);运行中/部署中永不折叠。 - 空闲**不无条件收**:按用户确认的阈值,一个父节点下空闲成员 ≥3 才随页面级 折叠规则一起收(FOLD_IDLE_MIN)。1–2 个待命成员就是这个团队的工作集, 而且它们的卡上会挂任务行,留着看;3 个以上才是「在扫名册」。 - 聚合行如实报账:聚合节点新增 doneCount/idleCount/errorCount/hasIdle, 小条写 `✓ N 已完成 · N 待命 · 出错 N`(为零的项省略,全已完成时与原先一样短), 徽标在**没有已完成项**时改说 `N 待命` —— 否则「✓ N 已完成」会把待命和出错 一起算成已完成,是撒谎。图卡片与树行共用 foldTally/foldBadgeCount。 - 手动折叠(卡片 chevron)不设阈值:那是读者点名要收的节点。 - i18n:新增 tasksFoldIdle ×20 词典。 测试:新增 6 例(阈值命中收 3 个空闲成员、2 个保持可见、运行中成员即使达标也不收、 混合聚合按状态计数、页面聚合行报「3 待命」且徽标不说已完成);pnpm test 1284 passed / 9 skipped,typecheck / lint 通过。 (cherry picked from commit 39d66ffb2e93eeb4095ec1cb2ac15933cce62742) --- src/client/TasksGraph.tsx | 12 +++-- src/client/TasksTree.tsx | 8 +-- src/client/locales-ar.ts | 1 + src/client/locales-de.ts | 1 + src/client/locales-fr.ts | 1 + src/client/locales-hi.ts | 1 + src/client/locales-id.ts | 1 + src/client/locales-it.ts | 1 + src/client/locales-ja.ts | 1 + src/client/locales-ko.ts | 1 + src/client/locales-nl.ts | 1 + src/client/locales-pl.ts | 1 + src/client/locales-pt.ts | 1 + src/client/locales-ru.ts | 1 + src/client/locales-sv.ts | 1 + src/client/locales-th.ts | 1 + src/client/locales-tr.ts | 1 + src/client/locales-vi.ts | 1 + src/client/locales-zh-HK.ts | 1 + src/client/locales-zh-MO.ts | 1 + src/client/locales-zh-TW.ts | 1 + src/client/locales.ts | 2 + src/client/tasks-card.tsx | 18 +++++-- src/client/tasks-model.ts | 98 +++++++++++++++++++++++++++++-------- src/client/tasks-shared.tsx | 27 +++++++++- tests/tasks-model.spec.ts | 72 +++++++++++++++++++++++++++ tests/tasks-page.spec.tsx | 20 ++++++++ 27 files changed, 242 insertions(+), 34 deletions(-) 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(