Files
apes-sidebar/src/client/tasks-card.tsx
T
Menghuan1918 c0ff3d1afd feat(tasks): 空闲成员 ≥3 时自动收进聚合,聚合并如实报出状态配比
用户要求优化显示:「比如空闲时也折叠」。

在此之前,一个跑完一轮的 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)
2026-09-27 22:19:05 +08:00

177 lines
7.2 KiB
TypeScript

/**
* The two-segment node card of the Tasks graph: a TOP segment that says WHAT
* the node is (kind badge, name, mono meta, optional task line) and a BOTTOM
* bar that says WHAT IT IS DOING (state dot + state word + the merged process
* activity, plus the fold chevron once the node has settled).
*
* Both modes share the model but NOT the card: the TREE keeps its compact
* indent row (its own disclosure, its own keyboard recipe), so this module is
* graph-only. Its two pieces are separate components because every node kind
* (agent / workflow run / fold aggregate) composes them differently while the
* geometry stays identical — the layout reserves the height of exactly these
* rows (`tasks-graph-layout.ts`).
*
* Colour: the group colour of a node is its tree DEPTH, from the plugin's own
* token-only ramp (`.depth0…3` in tasks-graph.module.css). The host ships no
* chart palette and the skin contract forbids literal colours, so the ramp is
* mixed from `--dsw-alias-state-business-primary`; a workflow member's PHASE
* badge uses the ink family instead, so the two groupings never share a hue.
*/
import type { ReactNode } from 'react'
import clsx from 'clsx'
import { IconChevronUpOutlineRegular, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import type { TasksNodeState } from './tasks-model.ts'
import { nodeDotState, stateLabel } from './tasks-shared.tsx'
import { t } from './locales.ts'
import css from './tasks-graph.module.css'
/** The five kinds of card the graph draws. */
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, 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 foldBadgeIdle
? t('tasksFoldIdle', { count })
: t('tasksFoldCompleted', { count })
}
}
/**
* The depth class of a node's group colour (levels past 3 share the last one).
* Typed `string | undefined` because the CSS-module map is an open record: a
* typo'd class name would otherwise pass `tsc` as a `string`.
*/
export function depthClass(depth: number): string | undefined {
if (depth <= 0) return css.depth0
if (depth === 1) return css.depth1
if (depth === 2) return css.depth2
return css.depth3
}
/** The phase class of a workflow member's badge (phases past 3 share the last one). */
export function phaseClass(index: number): string | undefined {
if (index <= 0) return css.phase0
if (index === 1) return css.phase1
if (index === 2) return css.phase2
return css.phase3
}
/** The top segment: the node's identity. */
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). */
phase?: { index: number; title?: string }
/** The node's name (already resolved by the model). */
name: string
/** The mono meta line under the name. */
meta?: string
/** Extra class for the name (the aggregate recedes one rung). */
extraClass?: string
/** Extra rows the caller appends (the team task line). */
children?: ReactNode
}): ReactNode {
const { kind, count, badgeIdle, 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)}
</span>
{phase !== undefined && (
<span
className={clsx(css.phaseBadge, phaseClass(phase.index))}
title={phase.title ?? t('workflowPhaseUnnamed')}
>
{phase.title ?? t('workflowPhaseUnnamed')}
</span>
)}
</span>
<span className={clsx(css.cardName, props.extraClass)} title={props.name}>{props.name}</span>
{props.meta !== undefined && props.meta !== '' && (
<span className={css.cardMeta} title={props.meta}>{props.meta}</span>
)}
{props.children}
</span>
)
}
/** The bottom bar: the node's state, its merged activity, and its fold chevron. */
export function CardBar(props: {
state: TasksNodeState
/** The merged process activity line (running nodes and settled summaries). */
activity?: string
/** Whether the node is executing (drives the sweep). */
running?: boolean
/** Fold the node into its parent's completed aggregate (settled nodes only). */
onFold?: (event: { stopPropagation(): void }) => void
/** The chevron's accessible name (defaults to the per-node fold word). */
foldLabel?: string
/** Render the state WORD next to the dot (off for the aggregate's action bar). */
stateWord?: boolean
/** Replace the whole bar's content (the aggregate's action row). */
children?: ReactNode
}): ReactNode {
const { state, activity, running } = props
return (
<span
className={css.cardBar}
data-card-bar={state}
data-running={running === true ? 'true' : undefined}
>
{props.children ?? (
<>
<StateDot state={nodeDotState(state)} size={6} className={css.barDot} />
{props.stateWord !== false && <span className={css.barState}>{stateLabel(state)}</span>}
{activity !== undefined && activity !== '' && (
<span className={css.barActivity} title={activity}>{activity}</span>
)}
{props.onFold !== undefined && (
<button
type="button"
className={css.barFold}
aria-label={props.foldLabel ?? t('tasksFoldOne')}
title={props.foldLabel ?? t('tasksFoldOne')}
onClick={(event) => {
// The card's own click opens the detail popover: folding must
// never do both.
event.stopPropagation()
props.onFold?.(event)
}}
>
{/*
The COLLAPSE direction (chevron up = gather into the group): the
aggregate card at the other end of the same motion offers the
EXPAND one (chevron down). Two opposite actions must not share
a glyph — the first cut drew both as a down chevron.
*/}
<IconChevronUpOutlineRegular size={12} />
</button>
)}
</>
)}
</span>
)
}