feat(tasks): 空闲与已完成分成两个聚合行;待命卡片补上自己的折叠按钮
用户两条反馈: 1. 空闲和已完成分开折叠合集 2. 空闲的无法折叠 第 2 条是纯疏漏:chevron 的出现条件写死在 `settled = done | error` 上, 「待命」的卡因此从来不画折叠按钮(跟折叠规则无关,规则早就允许手动收)。 现在 chevron 按「不在执行」(done | error | idle)出现,而 `settled` 仍只 表示完成/出错 —— 它管的是卡片该不该变淡,待命不是完成的工作。 第 1 条把上一轮那个「混合聚合」拆开: - 一个父节点现在最多两行 —— `fold:<parent>`(已完成/出错)与 `fold:<parent>:idle`(待命成员); - 各自的徽标说实话:完成行仍是「✓ N 已完成」,待命行是「N 个待命」; 完成行的条会额外报 `出错 N`(全是失败时只剩这一项),否则失败会藏在 ✓ 里; - 待命行的条只写 `N 待命`(同组同状态,不重复计数); - 阈值只作用于**自动**规则(≥3 待命成员才随页面级折叠一起收),手动 chevron 永远有效,两行互不牵连 —— 想扫掉待命的成员,不必连已完成的一起带走。 新增 tasksFoldIdleGroup ×20 词典;删掉上一轮的 hasIdle 与 foldBadgeCount (分组后它们没有意义)。 测试:新增/改写 7 例 —— 三个待命成员收进 idle 行、两个保持可见、运行中成员 达标也不收、混合场景两行各归各的、手动折叠单个待命成员落进 idle 行、 待命卡确实带 chevron 且点后只剩聚合、聚合徽标说「N 个待命」。 pnpm test 1286 passed / 9 skipped,typecheck / lint 通过。 (cherry picked from commit 0c9c8abca85e8aeb54bb2e9fcaaeed0486a56c28)
This commit is contained in:
@@ -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 (
|
||||
<div
|
||||
key={node.id}
|
||||
@@ -593,7 +601,7 @@ function renderAgentNode(
|
||||
state={node.state}
|
||||
running={node.state === 'running'}
|
||||
{...(barActivity(node) === undefined ? {} : { activity: barActivity(node) })}
|
||||
{...(settled && !node.current && onFold !== undefined
|
||||
{...(foldable && !node.current && onFold !== undefined
|
||||
? { onFold: () => { onFold(node) } }
|
||||
: {})}
|
||||
/>
|
||||
@@ -667,16 +675,17 @@ function renderFoldNode(
|
||||
>
|
||||
<CardTop
|
||||
kind="fold"
|
||||
count={foldBadgeCount(node)}
|
||||
badgeIdle={node.doneCount === 0}
|
||||
count={node.count}
|
||||
foldKind={node.foldKind}
|
||||
depth={node.depth}
|
||||
name={foldPreviews(node.previews)}
|
||||
extraClass={css.cardNamePlain}
|
||||
/>
|
||||
<CardBar state="done" stateWord={false}>
|
||||
{/* 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. */}
|
||||
<CardBar state={node.foldKind === 'idle' ? 'idle' : 'done'} stateWord={false}>
|
||||
{/* 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 展开. */}
|
||||
<span className={css.barActivity} title={foldTally(node)}>{foldTally(node)}</span>
|
||||
<span className={css.barFold} aria-hidden="true">
|
||||
<IconChevronDownOutlineRegular size={12} />
|
||||
|
||||
@@ -371,6 +371,7 @@ export const ar: Record<string, string> = {
|
||||
settingsViewModeGraphDesc: 'مخطط عُقد متدرج مع تكبير وتحريك',
|
||||
settingsViewModeTree: 'شجرة',
|
||||
settingsViewModeTreeDesc: 'شجرة كلاسيكية بمسافات بادئة، قابلة للتنقل بلوحة المفاتيح',
|
||||
tasksFoldIdleGroup: 'N waiting',
|
||||
tasksFoldIdle: 'Idle {count}',
|
||||
tasksFoldCompleted: '✓ {count} مكتمل',
|
||||
tasksFoldOne: 'الطي مع المكتملة',
|
||||
|
||||
@@ -356,6 +356,7 @@ export const de: Record<string, string> = {
|
||||
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',
|
||||
|
||||
@@ -363,6 +363,7 @@ export const fr: Record<string, string> = {
|
||||
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',
|
||||
|
||||
@@ -370,6 +370,7 @@ export const hi: Record<string, string> = {
|
||||
settingsViewModeGraphDesc: 'स्तरित नोड ग्राफ़, ज़ूम और पैन के साथ',
|
||||
settingsViewModeTree: 'ट्री',
|
||||
settingsViewModeTreeDesc: 'क्लासिक इंडेंटेड ट्री, कीबोर्ड से नेविगेट करने योग्य',
|
||||
tasksFoldIdleGroup: 'N waiting',
|
||||
tasksFoldIdle: 'Idle {count}',
|
||||
tasksFoldCompleted: '✓ {count} पूर्ण',
|
||||
tasksFoldOne: 'पूर्ण में समेटें',
|
||||
|
||||
@@ -368,6 +368,7 @@ export const id: Record<string, string> = {
|
||||
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',
|
||||
|
||||
@@ -361,6 +361,7 @@ export const it: Record<string, string> = {
|
||||
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',
|
||||
|
||||
@@ -370,6 +370,7 @@ export const ja: Record<string, string> = {
|
||||
settingsViewModeGraphDesc: '階層ノードグラフ(ズーム・パン対応)',
|
||||
settingsViewModeTree: 'ツリー',
|
||||
settingsViewModeTreeDesc: '従来のインデントツリー(キーボード操作可)',
|
||||
tasksFoldIdleGroup: 'N 件待機',
|
||||
tasksFoldIdle: '待機 {count}',
|
||||
tasksFoldCompleted: '✓ {count} 件完了',
|
||||
tasksFoldOne: '完了済みにまとめる',
|
||||
|
||||
@@ -362,6 +362,7 @@ export const ko: Record<string, string> = {
|
||||
settingsViewModeGraphDesc: '계층형 노드 그래프, 확대/이동 가능',
|
||||
settingsViewModeTree: '트리',
|
||||
settingsViewModeTreeDesc: '클래식 들여쓰기 트리, 키보드 탐색 지원',
|
||||
tasksFoldIdleGroup: 'N waiting',
|
||||
tasksFoldIdle: 'Idle {count}',
|
||||
tasksFoldCompleted: '✓ {count} 완료됨',
|
||||
tasksFoldOne: '완료 항목으로 접기',
|
||||
|
||||
@@ -368,6 +368,7 @@ export const nl: Record<string, string> = {
|
||||
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',
|
||||
|
||||
@@ -372,6 +372,7 @@ export const pl: Record<string, string> = {
|
||||
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',
|
||||
|
||||
@@ -353,6 +353,7 @@ export const pt: Record<string, string> = {
|
||||
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',
|
||||
|
||||
@@ -368,6 +368,7 @@ export const ru: Record<string, string> = {
|
||||
settingsViewModeGraphDesc: 'Многоуровневый граф узлов с масштабированием и панорамированием',
|
||||
settingsViewModeTree: 'Дерево',
|
||||
settingsViewModeTreeDesc: 'Классическое дерево с отступами, навигация с клавиатуры',
|
||||
tasksFoldIdleGroup: 'N waiting',
|
||||
tasksFoldIdle: 'Idle {count}',
|
||||
tasksFoldCompleted: '✓ {count} завершено',
|
||||
tasksFoldOne: 'Свернуть в завершённые',
|
||||
|
||||
@@ -353,6 +353,7 @@ export const sv: Record<string, string> = {
|
||||
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',
|
||||
|
||||
@@ -370,6 +370,7 @@ export const th: Record<string, string> = {
|
||||
settingsViewModeGraphDesc: 'กราฟโหนดแบบชั้น ซูมและเลื่อนได้',
|
||||
settingsViewModeTree: 'มุมมองต้นไม้',
|
||||
settingsViewModeTreeDesc: 'ต้นไม้แบบเยื้องคลาสสิก นำทางด้วยคีย์บอร์ดได้',
|
||||
tasksFoldIdleGroup: 'N waiting',
|
||||
tasksFoldIdle: 'Idle {count}',
|
||||
tasksFoldCompleted: '✓ เสร็จแล้ว {count}',
|
||||
tasksFoldOne: 'พับรวมกับที่เสร็จแล้ว',
|
||||
|
||||
@@ -370,6 +370,7 @@ export const tr: Record<string, string> = {
|
||||
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',
|
||||
|
||||
@@ -370,6 +370,7 @@ export const vi: Record<string, string> = {
|
||||
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',
|
||||
|
||||
@@ -385,6 +385,7 @@ export const zhHK: Record<string, string> = {
|
||||
settingsViewModeGraphDesc: '分層節點圖,可縮放平移',
|
||||
settingsViewModeTree: '樹狀圖',
|
||||
settingsViewModeTreeDesc: '經典縮排樹,可用鍵盤導航',
|
||||
tasksFoldIdleGroup: 'N 個待命',
|
||||
tasksFoldIdle: '{count} 待命',
|
||||
tasksFoldCompleted: '✓ {count} 已完成',
|
||||
tasksFoldOne: '收進已完成聚合',
|
||||
|
||||
@@ -385,6 +385,7 @@ export const zhMO: Record<string, string> = {
|
||||
settingsViewModeGraphDesc: '分層節點圖,可縮放平移',
|
||||
settingsViewModeTree: '樹狀圖',
|
||||
settingsViewModeTreeDesc: '經典縮排樹,可用鍵盤導航',
|
||||
tasksFoldIdleGroup: 'N 個待命',
|
||||
tasksFoldIdle: '{count} 待命',
|
||||
tasksFoldCompleted: '✓ {count} 已完成',
|
||||
tasksFoldOne: '收進已完成聚合',
|
||||
|
||||
@@ -385,6 +385,7 @@ export const zhTW: Record<string, string> = {
|
||||
settingsViewModeGraphDesc: '分層節點圖,可縮放平移',
|
||||
settingsViewModeTree: '樹狀圖',
|
||||
settingsViewModeTreeDesc: '經典縮排樹,可用鍵盤導航',
|
||||
tasksFoldIdleGroup: 'N 個待命',
|
||||
tasksFoldIdle: '{count} 待命',
|
||||
tasksFoldCompleted: '✓ {count} 已完成',
|
||||
tasksFoldOne: '收進已完成聚合',
|
||||
|
||||
@@ -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<keyof typeof zh, string> = {
|
||||
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',
|
||||
|
||||
+14
-12
@@ -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 (
|
||||
<span className={clsx(css.cardTop, depthClass(depth))}>
|
||||
<span className={css.cardBadges}>
|
||||
<span className={css.kindBadge} data-card-kind={kind}>
|
||||
{cardKindLabel(kind, count ?? 0, badgeIdle === true)}
|
||||
{cardKindLabel(kind, count ?? 0, foldKind ?? 'done')}
|
||||
</span>
|
||||
{phase !== undefined && (
|
||||
<span
|
||||
|
||||
+82
-56
@@ -99,22 +99,30 @@ export interface TasksWorkflowNode {
|
||||
run: WorkflowRunView
|
||||
}
|
||||
|
||||
/** One fold aggregate node (settled leaves of one parent, collapsed). */
|
||||
/**
|
||||
* Which kind of state one fold aggregate holds. The two are SEPARATE rows:
|
||||
* mixing them meant the aggregate had to explain itself with a tally and its
|
||||
* badge had to hedge ("✓ N 已完成 · N 待命"), and a reader who wanted to sweep
|
||||
* the waiting teammates away had to take the finished work with them.
|
||||
*/
|
||||
export type TasksFoldKind = 'done' | 'idle'
|
||||
|
||||
/** One fold aggregate node (the settled or the waiting children of one parent). */
|
||||
export interface TasksFoldNode {
|
||||
kind: 'fold'
|
||||
/** Which group this row is: finished work, or members waiting for a turn. */
|
||||
foldKind: TasksFoldKind
|
||||
id: string
|
||||
parentId: string
|
||||
/** Tree depth (see {@link TasksAgentNode.depth}). */
|
||||
depth: number
|
||||
count: number
|
||||
/** How many of {@link count} are done (the badge keeps saying 已完成). */
|
||||
/** How many of {@link count} are done (the badge's own count). */
|
||||
doneCount: number
|
||||
/** How many are waiting for a turn (待命 idle members, never executing). */
|
||||
idleCount: number
|
||||
/** How many ended in a failure (出错). */
|
||||
/** How many ended in a failure (出错); they belong to the `done` group. */
|
||||
errorCount: number
|
||||
/** Whether this row also sweeps IDLE team members (see {@link FOLD_IDLE_MIN}). */
|
||||
hasIdle: boolean
|
||||
/** The folded session ids, in original order (expansion restores them). */
|
||||
memberIds: string[]
|
||||
/** Up to two label previews for the collapsed subtitle. */
|
||||
@@ -203,14 +211,14 @@ function isAutoFoldable(
|
||||
*/
|
||||
export const FOLD_IDLE_MIN = 3
|
||||
|
||||
/** The state tally of one parent's folded children. */
|
||||
/** The state tally of one fold group. */
|
||||
interface FoldTally {
|
||||
done: number
|
||||
idle: number
|
||||
error: number
|
||||
}
|
||||
|
||||
/** Count the folded children by display state. */
|
||||
/** Count one future fold group's members by display state. */
|
||||
function tally(folded: readonly TasksAgentNode[]): FoldTally {
|
||||
const counts: FoldTally = { done: 0, idle: 0, error: 0 }
|
||||
for (const node of folded) {
|
||||
@@ -221,6 +229,43 @@ function tally(folded: readonly TasksAgentNode[]): FoldTally {
|
||||
return counts
|
||||
}
|
||||
|
||||
/**
|
||||
* Build one aggregate row for the given group of children.
|
||||
*
|
||||
* `foldKind` decides which members the row owns: the `done` group takes the
|
||||
* finished and failed ones, the `idle` group the waiting members. The caller
|
||||
* passes only its own members, so the counts and previews come straight off
|
||||
* that list.
|
||||
*
|
||||
* @param foldKind - which group this row is.
|
||||
* @param parentId - the row's parent (its tree position).
|
||||
* @param depth - the row's tree depth.
|
||||
* @param members - the children this group swallowed, in page order.
|
||||
* @param idSuffix - disambiguates the two rows of one parent (`''` / `':idle'`).
|
||||
*/
|
||||
function foldNode(
|
||||
foldKind: TasksFoldKind,
|
||||
parentId: string,
|
||||
depth: number,
|
||||
members: readonly TasksAgentNode[],
|
||||
idSuffix: string,
|
||||
): TasksFoldNode {
|
||||
const counts = tally(members)
|
||||
return {
|
||||
kind: 'fold',
|
||||
foldKind,
|
||||
id: `fold:${parentId}${idSuffix}`,
|
||||
parentId,
|
||||
depth,
|
||||
count: members.length,
|
||||
doneCount: counts.done,
|
||||
idleCount: counts.idle,
|
||||
errorCount: counts.error,
|
||||
memberIds: members.map(member => 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'))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user