用户要求优化显示:「比如空闲时也折叠」。 在此之前,一个跑完一轮的 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)
177 lines
7.2 KiB
TypeScript
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>
|
|
)
|
|
}
|