feat(tasks): 图节点改为双段式卡片 + 折叠聚合 + 窄栏可读性

图模式(主显示模式)换成新的卡片语言,树模式保持不变:

- 卡片 = 上段(类型徽章 + 相位徽标 + 名称两行 + mono 元信息 + 团队任务行)
  + 下段小条(状态点 + 状态词 + 主 Agent 同款合并活动 + 完成后的折叠 chevron)。
  类型徽章用页面自己的词汇(主代理 / 子代理 / 成员 / 工作流 / ✓N 已完成)。
- 分组色(层级):按树深度取 4 档令牌色阶(color-mix 混 --dsw-alias-state-business-primary),
  作用于左描边与上段极淡底色;相位徽标走 ink 色族,两套分组不撞色。
  相位虚线框保留但改用同色极淡描边,卡片徽标承担「这是哪个阶段」。
- 执行态:小条 `data-running` + 从左到右扫光(1.5s,末 1/3 停顿,
  prefers-reduced-motion 关闭);合并活动行来自宿主 chat 词条,缺失时回退插件文案。
- 折叠:已完成节点的 chevron 把该节点「收进已完成聚合」(模型新增 foldedIds,
  与全局 folded 走同一组守卫:只在已结算叶子、非当前会话、非团队成员时生效);
  聚合节点点击 = 全部展开(清空手动集 + 关闭全局折叠);控制条仍只管全局规则。
- 布局与窄栏(用户反馈 a–f):卡片高度预算按新行重算(徽章 + 两行标题 + 元信息 +
  小条 = 103px,任务行 +18),宽度 176 → 190;fit 按内容包围盒双轴居中、
  FIT_MAX_SCALE 1.15 → 1.30;缩放范围 0.4–2.0 并归一化 wheel deltaMode;
  平移加边界夹取(至少 72px 内容留在视口内);空画布双击重新适配并恢复自动 fit;
  连线改「垂直出线 stub + 贝塞尔」并加粗到 1.5px。

验证:typecheck / lint 通过;pnpm test 1253 passed / 9 skipped(含新增双段结构、
data-running、chevron → 聚合、聚合 → 全部展开、工作流相位徽标 5 例)。

(cherry picked from commit aee571e50beb5dc979995f0841cffd0a575f2173)
This commit is contained in:
Menghuan1918
2026-09-27 22:18:35 +08:00
parent a454cfc9fe
commit f584b67813
29 changed files with 999 additions and 212 deletions
+25 -1
View File
@@ -256,6 +256,12 @@ export function SubagentView(props: {
const mode = modeOverride ?? prefsMode
const [folded, setFolded] = useState(true)
/**
* Nodes the reader folded by hand from their own card bar. Kept apart from
* `folded` (the global rule) so the control cluster and a single card never
* fight: the aggregate's click clears BOTH, which is what "expand" means.
*/
const [foldedIds, setFoldedIds] = useState<ReadonlySet<string>>(() => new Set())
const [teamBoardCollapsed, setTeamBoardCollapsed] = useState(false)
const [popover, setPopover] = useState<PagePopover | null>(null)
@@ -272,8 +278,9 @@ export function SubagentView(props: {
teamMembers,
teamTasks: teamView?.available === true ? teamView.team?.tasks ?? [] : [],
folded,
foldedIds,
})),
[byId, catalogs, rootId, sessionId, live, runs, teamMembers, teamView, folded],
[byId, catalogs, rootId, sessionId, live, runs, teamMembers, teamView, folded, foldedIds],
)
/**
@@ -337,6 +344,21 @@ export function SubagentView(props: {
}
}, [openChild, openMain])
/** Fold one settled node into its parent's aggregate (its card's chevron). */
const foldNode = useCallback((node: TasksAgentNode): void => {
setFoldedIds(current => {
const next = new Set(current)
next.add(node.id)
return next
})
}, [])
/** Expand every fold: the global rule off AND the manual folds cleared. */
const expandFold = useCallback((): void => {
setFolded(false)
setFoldedIds(current => (current.size === 0 ? current : new Set()))
}, [])
/** Open the shared task window from a board row (undefined = create). */
const openTaskFromBoard = useCallback((
task: { id: string } | undefined,
@@ -542,6 +564,8 @@ export function SubagentView(props: {
onWorkflowInfo={(node, anchor) => { setPopover({ kind: 'workflow', nodeId: node.id, anchor }) }}
onOpenTask={openTaskById}
onToggleFold={() => { setFolded(current => !current) }}
onExpandFold={expandFold}
onFoldNode={foldNode}
mode={mode}
onModeChange={setModeOverride}
/>
+208 -65
View File
@@ -30,49 +30,87 @@ import {
} from 'react'
import clsx from 'clsx'
import {
Button, IconChecklistOutlineRegular, IconFullscreenOutlineRegular, IconLoadingOutlineRegular, IconTreeCornerRegular,
StateDot,
Button, IconChecklistOutlineRegular, IconChevronDownOutlineRegular, IconFullscreenOutlineRegular,
IconLoadingOutlineRegular, IconTreeCornerRegular,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { TasksAgentNode, TasksFoldNode, TasksNode, TasksWorkflowNode } from './tasks-model.ts'
import { tasksEdges } from './tasks-model.ts'
import {
GRAPH_FIT_MIN_SCALE, GRAPH_NODE_W, layoutTasksGraphForWidth, type GraphBox,
GRAPH_FIT_MIN_SCALE, GRAPH_NODE_H, GRAPH_NODE_W, GRAPH_PAD, layoutTasksGraphForWidth,
type GraphBox,
} from './tasks-graph-layout.ts'
import {
AgentGlyph, agentMeta, FoldGlyph, foldPreviews, LiveLine, nodeDotState, TaskLine,
WorkflowGlyph, workflowMeta,
agentMeta, foldPreviews, TaskLine, WorkflowGlyph, workflowMeta,
} from './tasks-shared.tsx'
import { CardBar, CardTop, phaseClass, type CardKind } from './tasks-card.tsx'
import { doneActivityTitle, liveActivityLabel } from './process-labels.ts'
import { t } from './locales.ts'
import css from './tasks-graph.module.css'
import canvasCss from './tasks-canvas.module.css'
/** The zoom bounds of the canvas. */
const ZOOM_MIN = 0.3
const ZOOM_MAX = 1.5
/** The zoom bounds of the canvas. The upper bound is deliberately above 1: on
* a narrow panel the reader zooms IN to read a card, and the fit's own cap
* (FIT_MAX_SCALE) is what keeps a two-node tree from ballooning. */
const ZOOM_MIN = 0.4
const ZOOM_MAX = 2
/** Upper bound of the auto-fit scale (never balloon a two-node tree). */
const FIT_MAX_SCALE = 1.15
const FIT_MAX_SCALE = 1.3
/** Breathing room the fit leaves around the CONTENT box, per axis. */
const FIT_MARGIN = 24
/** How much of the canvas must stay on screen while panning (px). */
const PAN_KEEP = 72
/* The readability floor the fit honours is `GRAPH_FIT_MIN_SCALE` (imported):
* the layout budgets its columns from the same number, so scaling and wrapping
* can never disagree. */
/** The perpendicular stub each connector leaves a card with (px, pre-scale). */
const EDGE_STUB = 8
/** Drag-vs-click separation: pointer travel below this stays a click. */
const CLICK_TOLERANCE_PX = 4
/**
* Card line 1 of an agent / run node: the canvas node title with the
* truncation swapped for a TWO-line clamp — a single ellipsized line left too
* few CJK characters to tell two siblings apart, and `nodeHeight` in
* tasks-graph-layout.ts reserves the card height for that second line (plus
* one row per live / task line below it). The untruncated name stays in the
* `title` attribute.
* The card kind of one agent node: the node's own role decides the badge, so a
* teammate never reads as a plain subagent and the root never reads as a child.
*/
const NODE_TITLE = canvasCss.nodeTitle
/** The fold aggregate's title: the same clamp on the receding plain ink. */
const NODE_TITLE_PLAIN = clsx(NODE_TITLE, canvasCss.nodeTitlePlain)
function agentCardKind(node: TasksAgentNode): CardKind {
if (node.team?.role === 'teammate') return 'teammate'
return node.parentId === undefined ? 'main' : 'subagent'
}
/**
* The bar's activity line. A running node shows the LIVE merged activity; a
* settled one shows the same merged wording in its completed form (the host's
* own `done.*` phrasing) — a settled card whose bar read only "已完成" would
* throw away the one thing the fold is for.
*/
function barActivity(node: TasksAgentNode): string | undefined {
const summary = node.live?.summary
if (node.state === 'running') return liveActivityLabel(summary)
return summary === undefined ? undefined : doneActivityTitle(summary)
}
/** Members of one run that already reported an outcome. */
function doneMembers(node: TasksWorkflowNode): number {
let done = 0
for (const phase of node.run.phases) {
for (const member of phase.members) if (member.outcome !== undefined) done += 1
}
return done
}
/** Members of one run across every phase. */
function totalMembers(node: TasksWorkflowNode): number {
let total = 0
for (const phase of node.run.phases) total += phase.members.length
return total
}
/** One phase frame of a run node (the dashed box behind its members). */
interface PhaseFrame {
key: string
title: string | undefined
/** The phase's index within its run — drives the frame's colour, which the
* members' own phase badges repeat (badge + colour, no lines to decode). */
index: number
x: number
y: number
w: number
@@ -88,7 +126,12 @@ export interface TasksGraphProps {
onWorkflowInfo(node: TasksWorkflowNode, anchor: HTMLElement): void
/** Open the shared task window for one task id. */
onOpenTask(taskId: string, anchor: HTMLElement): void
/** The control cluster's global fold switch (settled leaves). */
onToggleFold(): void
/** The fold AGGREGATE's click: expand everything (global + manual folds). */
onExpandFold(): void
/** Fold ONE settled node into its parent's aggregate (the bar's chevron). */
onFoldNode(node: TasksAgentNode): void
mode: 'graph' | 'tree'
onModeChange(mode: 'graph' | 'tree'): void
/** Fallback hint while the root catalog hydrates (the graph twin of
@@ -138,7 +181,10 @@ export function FoldToggleButton(props: { folded: boolean; onToggleFold(): void
}
export function TasksGraph(props: TasksGraphProps): ReactNode {
const { nodes, folded, rootId, onNodeInfo, onWorkflowInfo, onOpenTask, onToggleFold, mode, onModeChange, loading } = props
const {
nodes, folded, rootId, onNodeInfo, onWorkflowInfo, onOpenTask, onToggleFold, onExpandFold,
onFoldNode, mode, onModeChange, loading,
} = props
const containerRef = useRef<HTMLDivElement>(null)
const [tf, setTf] = useState({ x: 0, y: 0, k: 1 })
const [dragging, setDragging] = useState(false)
@@ -158,24 +204,58 @@ export function TasksGraph(props: TasksGraphProps): ReactNode {
const edges = useMemo(() => tasksEdges(nodes), [nodes])
const nodeById = useMemo(() => new Map(nodes.map(node => [node.id, node])), [nodes])
/** Center the canvas in the container (both axes) and scale to fill. */
/**
* Center the canvas and scale it to fill. The fit measures the CONTENT box
* (the layout's padding is breathing room, not content): centering the padded
* box leaves the graph visibly off-center in a narrow panel, which is exactly
* how "the graph looks small and sits in a corner" happens.
*/
const fit = useCallback((): void => {
const container = containerRef.current
if (container === null) return
const cw = container.clientWidth
const ch = container.clientHeight
if (cw === 0 || ch === 0) return
const contentW = Math.max(GRAPH_NODE_W, layout.width - GRAPH_PAD * 2)
const contentH = Math.max(GRAPH_NODE_H, layout.height - GRAPH_PAD * 2)
const k = Math.max(
GRAPH_FIT_MIN_SCALE,
Math.min((cw - 24) / layout.width, (ch - 24) / layout.height, FIT_MAX_SCALE),
Math.min(
(cw - FIT_MARGIN * 2) / contentW,
(ch - FIT_MARGIN * 2) / contentH,
FIT_MAX_SCALE,
),
)
setTf({
x: (cw - layout.width * k) / 2,
y: Math.max(8, (ch - layout.height * k) / 2),
x: (cw - contentW * k) / 2 - GRAPH_PAD * k,
y: Math.max(FIT_MARGIN / 2, (ch - contentH * k) / 2) - GRAPH_PAD * k,
k,
})
}, [layout.width, layout.height])
/**
* Keep the canvas reachable: a pan or a zoom-out may never push the whole
* graph off screen (the classic "I dragged it away and cannot find it").
* `PAN_KEEP` px of content stay inside the container on every axis.
*/
const clampTf = useCallback((next: { x: number; y: number; k: number }) => {
const container = containerRef.current
if (container === null) return next
const cw = container.clientWidth
const ch = container.clientHeight
const w = layout.width * next.k
const h = layout.height * next.k
const minX = Math.min(PAN_KEEP, cw - PAN_KEEP) - w
const maxX = Math.max(PAN_KEEP, cw - PAN_KEEP)
const minY = Math.min(PAN_KEEP, ch - PAN_KEEP) - h
const maxY = Math.max(PAN_KEEP, ch - PAN_KEEP)
return {
x: Math.min(maxX, Math.max(minX, next.x)),
y: Math.min(maxY, Math.max(minY, next.y)),
k: next.k,
}
}, [layout.width, layout.height])
/** The ⌂ button: refit AND re-enable auto-fit for later resizes. */
const refit = useCallback((): void => {
touchedRef.current = false
@@ -210,18 +290,22 @@ export function TasksGraph(props: TasksGraphProps): ReactNode {
const mx = event.clientX - rect.left
const my = event.clientY - rect.top
const { x, y, k } = tfRef.current
const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, k * Math.exp(-event.deltaY * 0.0014)))
// Wheel deltas arrive in pixels, lines or pages depending on the device;
// normalise first, or a line-scrolling mouse zooms 30× slower than a
// trackpad (and a trackpad pinch, which reports ctrlKey, feels stuck).
const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 100 : 1
const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, k * Math.exp(-event.deltaY * unit * 0.0014)))
if (next === k) return
touchedRef.current = true
setTf({
setTf(clampTf({
x: mx - (mx - x) * (next / k),
y: my - (my - y) * (next / k),
k: next,
})
}))
}
container.addEventListener('wheel', onWheel, { passive: false })
return () => { container.removeEventListener('wheel', onWheel) }
}, [])
}, [clampTf])
const zoomBy = useCallback((factor: number): void => {
const container = containerRef.current
@@ -231,8 +315,8 @@ export function TasksGraph(props: TasksGraphProps): ReactNode {
touchedRef.current = true
const mx = container.clientWidth / 2
const my = container.clientHeight / 2
setTf({ x: mx - (mx - x) * (next / k), y: my - (my - y) * (next / k), k: next })
}, [])
setTf(clampTf({ x: mx - (mx - x) * (next / k), y: my - (my - y) * (next / k), k: next }))
}, [clampTf])
/**
* Drag-pan. The gesture only ever starts on the BACKGROUND: a pointerdown
@@ -241,6 +325,18 @@ export function TasksGraph(props: TasksGraphProps): ReactNode {
* container, which retargets the derived click event and silently kills
* every node interaction.
*/
/**
* Double-clicking the BACKGROUND re-fits and hands the view back to auto-fit
* (the gesture a pan/zoom away from the graph needs); a double-click on a
* card stays the card's own.
*/
const onDoubleClick = useCallback((event: React.MouseEvent<HTMLDivElement>): void => {
const target = event.target as HTMLElement
if (target.closest('[data-graph-node]') !== null) return
if (target.closest('[data-graph-controls]') !== null) return
refit()
}, [refit])
const onPointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>): void => {
if (event.button !== 0) return
const target = event.target as HTMLElement
@@ -257,7 +353,7 @@ export function TasksGraph(props: TasksGraphProps): ReactNode {
if (travelRef.current <= CLICK_TOLERANCE_PX) return
touchedRef.current = true
setDragging(true)
setTf(current => ({ ...current, x: x + dx, y: y + dy }))
setTf(clampTf({ x: x + dx, y: y + dy, k: tfRef.current.k }))
}
const onUp = (): void => {
window.removeEventListener('pointermove', onMove)
@@ -268,7 +364,7 @@ export function TasksGraph(props: TasksGraphProps): ReactNode {
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp)
window.addEventListener('pointercancel', onUp)
}, [])
}, [clampTf])
/** Suppress node clicks that ended a drag gesture. */
const clickAllowed = useCallback((): boolean => travelRef.current <= CLICK_TOLERANCE_PX, [])
@@ -291,20 +387,38 @@ export function TasksGraph(props: TasksGraphProps): ReactNode {
const y1 = Math.min(...boxes.map(box => box.y)) - 20
const x2 = Math.max(...boxes.map(box => box.x + box.w)) + 12
const y2 = Math.max(...boxes.map(box => box.y + box.h)) + 12
frames.push({ key: `${runNode.id}:${phaseIndex}`, title: phase.title, x: x1, y: y1, w: x2 - x1, h: y2 - y1 })
frames.push({
key: `${runNode.id}:${phaseIndex}`,
title: phase.title,
index: phaseIndex,
x: x1,
y: y1,
w: x2 - x1,
h: y2 - y1,
})
})
}
return frames
}, [nodes, layout])
/** The bezier edge path between two boxes (top-down). */
/**
* The connector between two boxes: a straight PERPENDICULAR stub out of the
* parent's bottom and into the child's top, joined by one bezier. The stubs
* are what make the hierarchy legible where several lines leave one card —
* a pure bottom-center-to-top-center bezier leaves at an angle, so a ranked
* tree reads as a fan of diagonals over the cards between the rows.
*/
const edgePath = (from: GraphBox, to: GraphBox): string => {
const x1 = from.x + from.w / 2
const y1 = from.y + from.h
const x2 = to.x + to.w / 2
const y2 = to.y
const dy = Math.max(24, (y2 - y1) / 2)
return `M ${x1} ${y1} C ${x1} ${y1 + dy} ${x2} ${y2 - dy} ${x2} ${y2}`
const span = Math.max(0, y2 - y1)
const stub = Math.min(EDGE_STUB, span / 3)
const dy = Math.max(16, (y2 - y1 - stub * 2) / 2)
return `M ${x1} ${y1} L ${x1} ${y1 + stub} `
+ `C ${x1} ${y1 + stub + dy} ${x2} ${y2 - stub - dy} ${x2} ${y2 - stub} `
+ `L ${x2} ${y2}`
}
return (
@@ -315,6 +429,7 @@ export function TasksGraph(props: TasksGraphProps): ReactNode {
role="group"
aria-label={t('tasksViewGraph')}
onPointerDown={onPointerDown}
onDoubleClick={onDoubleClick}
>
{/* The graph twin of TasksTree's loading row: the same copy key and the
same "nothing to show yet" condition (an empty canvas would render
@@ -357,7 +472,7 @@ export function TasksGraph(props: TasksGraphProps): ReactNode {
{phaseFrames.map(frame => (
<div
key={frame.key}
className={css.phaseFrame}
className={clsx(css.phaseFrame, phaseClass(frame.index))}
style={{ left: frame.x, top: frame.y, width: frame.w, height: frame.h }}
>
<span className={css.phaseLabel}>{frame.title ?? t('workflowPhaseUnnamed')}</span>
@@ -376,11 +491,11 @@ export function TasksGraph(props: TasksGraphProps): ReactNode {
const box = layout.boxes.get(node.id)
if (box === undefined) return null
const style = { left: box.x, top: box.y, width: GRAPH_NODE_W, minHeight: box.h }
if (node.kind === 'fold') return renderFoldNode(node, style, onToggleFold, clickAllowed)
if (node.kind === 'fold') return renderFoldNode(node, style, onExpandFold, clickAllowed)
if (node.kind === 'workflow') {
return renderWorkflowNode(node, style, onWorkflowInfo, clickAllowed)
}
return renderAgentNode(node, style, onNodeInfo, onOpenTask, clickAllowed)
return renderAgentNode(node, style, onNodeInfo, onOpenTask, clickAllowed, onFoldNode)
})}
</div>
<div className={css.controls} data-graph-controls>
@@ -422,18 +537,21 @@ export function TasksGraph(props: TasksGraphProps): ReactNode {
)
}
/** One agent node card: dot + title, one mono meta line, live line when running. */
/** One agent node card: identity on top, the state bar at the bottom. */
function renderAgentNode(
node: TasksAgentNode,
style: { left: number; top: number; width: number; minHeight: number },
onNodeInfo: (node: TasksAgentNode, anchor: HTMLElement) => void,
onOpenTask: (taskId: string, anchor: HTMLElement) => void,
clickAllowed: () => boolean,
onFold?: (node: TasksAgentNode) => void,
): ReactNode {
const settled = node.state === 'done' || node.state === 'error'
return (
<div
key={node.id}
data-graph-node={node.id}
data-depth={node.depth}
role="button"
tabIndex={-1}
aria-label={`${node.label} ${agentMeta(node)}`}
@@ -442,7 +560,7 @@ function renderAgentNode(
css.node,
node.team?.role === 'teammate' && css.nodeTeam,
node.current && css.nodeCurrent,
(node.state === 'done' || node.state === 'error') && !node.current && css.nodeSettled,
settled && !node.current && css.nodeSettled,
node.state === 'error' && css.nodeError,
)}
style={style}
@@ -451,50 +569,70 @@ function renderAgentNode(
onNodeInfo(node, event.currentTarget)
}}
>
<span className={css.nodeHeader}>
<StateDot state={nodeDotState(node.state)} size={6} className={css.nodeDot} />
<span className={css.nodeGlyph} aria-hidden="true"><AgentGlyph node={node} /></span>
<span className={NODE_TITLE} title={node.label}>{node.label}</span>
</span>
<span className={css.nodeMeta} title={agentMeta(node)}>{agentMeta(node)}</span>
{node.state === 'running' && <LiveLine live={node.live} />}
<TaskLine tasks={node.tasks} onOpenTask={onOpenTask} />
<CardTop
kind={agentCardKind(node)}
depth={node.depth}
{...(node.phase === undefined ? {} : { phase: node.phase })}
name={node.label}
meta={agentMeta(node)}
>
<TaskLine tasks={node.tasks} onOpenTask={onOpenTask} />
</CardTop>
<CardBar
state={node.state}
running={node.state === 'running'}
{...(barActivity(node) === undefined ? {} : { activity: barActivity(node) })}
{...(settled && !node.current && onFold !== undefined
? { onFold: () => { onFold(node) } }
: {})}
/>
</div>
)
}
/** One workflow run node card. */
/** One workflow run node card: same frame, its own badges and state. */
function renderWorkflowNode(
node: TasksWorkflowNode,
style: { left: number; top: number; width: number; minHeight: number },
onWorkflowInfo: (node: TasksWorkflowNode, anchor: HTMLElement) => void,
clickAllowed: () => boolean,
): ReactNode {
const running = node.run.status === 'running'
return (
<div
key={node.id}
data-graph-node={node.id}
data-depth={node.depth}
role="button"
tabIndex={-1}
aria-label={`${node.run.name} ${workflowMeta(node)}`}
className={clsx(css.node, css.nodeWorkflow, node.run.status !== 'running' && css.nodeSettled)}
className={clsx(css.node, css.nodeWorkflow, !running && css.nodeSettled)}
style={style}
onClick={(event) => {
if (!clickAllowed()) return
onWorkflowInfo(node, event.currentTarget)
}}
>
<span className={css.nodeHeader}>
<StateDot state={node.run.status === 'running' ? 'ongoing' : 'done'} size={6} className={css.nodeDot} />
<span className={css.nodeGlyph} aria-hidden="true"><WorkflowGlyph /></span>
<span className={NODE_TITLE} title={node.run.name}>{node.run.name}</span>
</span>
<span className={css.nodeMeta}>{workflowMeta(node)}</span>
<CardTop
kind="workflow"
depth={node.depth}
name={node.run.name}
meta={workflowMeta(node)}
/>
<CardBar
state={running ? 'running' : 'done'}
running={running}
stateWord={false}
activity={t('workflowMembers', { done: doneMembers(node), total: totalMembers(node) })}
/>
</div>
)
}
/** One fold aggregate node. */
/**
* One fold aggregate node: the completed leaves of one parent, collapsed. Its
* bar is the action row (expand), so the whole card stays one click target.
*/
function renderFoldNode(
node: TasksFoldNode,
style: { left: number; top: number; width: number; minHeight: number },
@@ -505,6 +643,7 @@ function renderFoldNode(
<div
key={node.id}
data-graph-node={node.id}
data-depth={node.depth}
role="button"
tabIndex={-1}
aria-label={`${t('tasksFoldCompleted', { count: node.count })} · ${t('tasksFoldExpand')}`}
@@ -515,15 +654,19 @@ function renderFoldNode(
onToggleFold()
}}
>
<span className={css.nodeHeader}>
<span className={css.nodeGlyph} aria-hidden="true"><FoldGlyph /></span>
<span className={NODE_TITLE_PLAIN}>
{t('tasksFoldCompleted', { count: node.count })}
<CardTop
kind="fold"
count={node.count}
depth={node.depth}
name={foldPreviews(node.previews)}
extraClass={css.cardNamePlain}
/>
<CardBar state="done" stateWord={false}>
<span className={css.barActivity}>{t('tasksFoldExpand')}</span>
<span className={css.barFold} aria-hidden="true">
<IconChevronDownOutlineRegular size={12} />
</span>
</span>
<span className={css.nodeMeta} title={foldPreviews(node.previews)}>
{`${t('tasksFoldExpand')} · ${foldPreviews(node.previews)}`}
</span>
</CardBar>
</div>
)
}
+4
View File
@@ -367,6 +367,7 @@ export const ar: Record<string, string> = {
settingsViewModeTree: 'شجرة',
settingsViewModeTreeDesc: 'شجرة كلاسيكية بمسافات بادئة، قابلة للتنقل بلوحة المفاتيح',
tasksFoldCompleted: '✓ {count} مكتمل',
tasksFoldOne: 'الطي مع المكتملة',
tasksFoldExpand: 'توسيع العُقد المكتملة',
tasksFoldCollapse: 'طي العُقد المكتملة',
tasksZoomIn: 'تكبير',
@@ -384,6 +385,9 @@ export const ar: Record<string, string> = {
tasksStateIdle: 'خامل',
tasksStateDone: 'مكتمل',
tasksStateError: 'خطأ',
tasksKindSubagent: 'وكيل فرعي',
tasksKindTeammate: 'عضو فريق',
tasksKindWorkflow: 'سير عمل',
teamBoard: 'لوحة مهام الفريق',
teamChip: 'فريق {members} أعضاء · {tasks} مهام',
teamTasksEmpty: 'لا مهام مشتركة بعد',
+4
View File
@@ -352,6 +352,7 @@ export const de: Record<string, string> = {
settingsViewModeTree: 'Baum',
settingsViewModeTreeDesc: 'Klassischer Einrückungsbaum, per Tastatur navigierbar',
tasksFoldCompleted: '✓ {count} abgeschlossen',
tasksFoldOne: 'Zu Erledigten falten',
tasksFoldExpand: 'Abgeschlossene Knoten ausklappen',
tasksFoldCollapse: 'Abgeschlossene Knoten einklappen',
tasksZoomIn: 'Vergrößern',
@@ -369,6 +370,9 @@ export const de: Record<string, string> = {
tasksStateIdle: 'Bereit',
tasksStateDone: 'Abgeschlossen',
tasksStateError: 'Fehler',
tasksKindSubagent: 'Subagent',
tasksKindTeammate: 'Teammitglied',
tasksKindWorkflow: 'Workflow',
teamBoard: 'Team-Aufgabenboard',
teamChip: 'Team {members} Mitglieder · {tasks} Aufgaben',
teamTasksEmpty: 'Noch keine geteilten Aufgaben',
+4
View File
@@ -359,6 +359,7 @@ export const fr: Record<string, string> = {
settingsViewModeTree: 'Arbre',
settingsViewModeTreeDesc: 'Arbre à indentation classique, navigable au clavier',
tasksFoldCompleted: '✓ {count} terminés',
tasksFoldOne: 'Replier avec les terminés',
tasksFoldExpand: 'Déplier les nœuds terminés',
tasksFoldCollapse: 'Replier les nœuds terminés',
tasksZoomIn: 'Zoom avant',
@@ -376,6 +377,9 @@ export const fr: Record<string, string> = {
tasksStateIdle: 'Inactif',
tasksStateDone: 'Terminé',
tasksStateError: 'Erreur',
tasksKindSubagent: 'Sous-agent',
tasksKindTeammate: 'Membre',
tasksKindWorkflow: 'Workflow',
teamBoard: 'Tableau des tâches d’équipe',
teamChip: 'Équipe {members} membres · {tasks} tâches',
teamTasksEmpty: 'Aucune tâche partagée pour l’instant',
+4
View File
@@ -366,6 +366,7 @@ export const hi: Record<string, string> = {
settingsViewModeTree: 'ट्री',
settingsViewModeTreeDesc: 'क्लासिक इंडेंटेड ट्री, कीबोर्ड से नेविगेट करने योग्य',
tasksFoldCompleted: '✓ {count} पूर्ण',
tasksFoldOne: 'पूर्ण में समेटें',
tasksFoldExpand: 'पूर्ण नोड्स विस्तारित करें',
tasksFoldCollapse: 'पूर्ण नोड्स संकुचित करें',
tasksZoomIn: 'ज़ूम इन',
@@ -383,6 +384,9 @@ export const hi: Record<string, string> = {
tasksStateIdle: 'निष्क्रिय',
tasksStateDone: 'पूर्ण',
tasksStateError: 'त्रुटि',
tasksKindSubagent: 'उप-एजेंट',
tasksKindTeammate: 'टीम सदस्य',
tasksKindWorkflow: 'वर्कफ़्लो',
teamBoard: 'टीम कार्य बोर्ड',
teamChip: 'टीम {members} सदस्य · {tasks} कार्य',
teamTasksEmpty: 'अभी कोई साझा कार्य नहीं',
+4
View File
@@ -364,6 +364,7 @@ export const id: Record<string, string> = {
settingsViewModeTree: 'Pohon',
settingsViewModeTreeDesc: 'Pohon indentasi klasik, dapat dinavigasi dengan keyboard',
tasksFoldCompleted: '✓ {count} selesai',
tasksFoldOne: 'Lipat ke selesai',
tasksFoldExpand: 'Perluas node yang selesai',
tasksFoldCollapse: 'Lipat node yang selesai',
tasksZoomIn: 'Perbesar',
@@ -381,6 +382,9 @@ export const id: Record<string, string> = {
tasksStateIdle: 'Siaga',
tasksStateDone: 'Selesai',
tasksStateError: 'Error',
tasksKindSubagent: 'Subagen',
tasksKindTeammate: 'Anggota tim',
tasksKindWorkflow: 'Alur kerja',
teamBoard: 'Papan tugas tim',
teamChip: 'Tim {members} anggota · {tasks} tugas',
teamTasksEmpty: 'Belum ada tugas bersama',
+4
View File
@@ -357,6 +357,7 @@ export const it: Record<string, string> = {
settingsViewModeTree: 'Albero',
settingsViewModeTreeDesc: 'Albero a rientri classico, navigabile da tastiera',
tasksFoldCompleted: '✓ {count} completati',
tasksFoldOne: 'Raggruppa nei completati',
tasksFoldExpand: 'Espandi i nodi completati',
tasksFoldCollapse: 'Comprimi i nodi completati',
tasksZoomIn: 'Ingrandisci',
@@ -374,6 +375,9 @@ export const it: Record<string, string> = {
tasksStateIdle: 'In attesa',
tasksStateDone: 'Completato',
tasksStateError: 'Errore',
tasksKindSubagent: 'Subagent',
tasksKindTeammate: 'Membro del team',
tasksKindWorkflow: 'Workflow',
teamBoard: 'Bacheca delle attività del team',
teamChip: 'Team {members} membri · {tasks} attività',
teamTasksEmpty: 'Ancora nessuna attività condivisa',
+4
View File
@@ -366,6 +366,7 @@ export const ja: Record<string, string> = {
settingsViewModeTree: 'ツリー',
settingsViewModeTreeDesc: '従来のインデントツリー(キーボード操作可)',
tasksFoldCompleted: '✓ {count} 件完了',
tasksFoldOne: '完了済みにまとめる',
tasksFoldExpand: '完了ノードを展開',
tasksFoldCollapse: '完了ノードを折りたたむ',
tasksZoomIn: '拡大',
@@ -383,6 +384,9 @@ export const ja: Record<string, string> = {
tasksStateIdle: '待機',
tasksStateDone: '完了',
tasksStateError: 'エラー',
tasksKindSubagent: 'サブエージェント',
tasksKindTeammate: 'チームメンバー',
tasksKindWorkflow: 'ワークフロー',
teamBoard: 'チームタスクボード',
teamChip: 'チーム {members} 名 · {tasks} タスク',
teamTasksEmpty: '共有タスクはまだありません',
+4
View File
@@ -358,6 +358,7 @@ export const ko: Record<string, string> = {
settingsViewModeTree: '트리',
settingsViewModeTreeDesc: '클래식 들여쓰기 트리, 키보드 탐색 지원',
tasksFoldCompleted: '✓ {count} 완료됨',
tasksFoldOne: '완료 항목으로 접기',
tasksFoldExpand: '완료된 노드 펼치기',
tasksFoldCollapse: '완료된 노드 접기',
tasksZoomIn: '확대',
@@ -375,6 +376,9 @@ export const ko: Record<string, string> = {
tasksStateIdle: '대기 중',
tasksStateDone: '완료됨',
tasksStateError: '오류',
tasksKindSubagent: '하위 에이전트',
tasksKindTeammate: '팀 멤버',
tasksKindWorkflow: '워크플로',
teamBoard: '팀 작업 보드',
teamChip: '팀 멤버 {members}명 · 작업 {tasks}개',
teamTasksEmpty: '공유 작업이 없습니다',
+4
View File
@@ -364,6 +364,7 @@ export const nl: Record<string, string> = {
settingsViewModeTree: 'Boom',
settingsViewModeTreeDesc: 'Klassieke inspringboom, navigeerbaar met het toetsenbord',
tasksFoldCompleted: '✓ {count} voltooid',
tasksFoldOne: 'Vouwen bij afgerond',
tasksFoldExpand: 'Voltooide knopen uitklappen',
tasksFoldCollapse: 'Voltooide knopen inklappen',
tasksZoomIn: 'Inzoomen',
@@ -381,6 +382,9 @@ export const nl: Record<string, string> = {
tasksStateIdle: 'Inactief',
tasksStateDone: 'Voltooid',
tasksStateError: 'Fout',
tasksKindSubagent: 'Subagent',
tasksKindTeammate: 'Teamlid',
tasksKindWorkflow: 'Workflow',
teamBoard: 'Teamtakenbord',
teamChip: 'Team {members} leden · {tasks} taken',
teamTasksEmpty: 'Nog geen gedeelde taken',
+4
View File
@@ -368,6 +368,7 @@ export const pl: Record<string, string> = {
settingsViewModeTree: 'Drzewo',
settingsViewModeTreeDesc: 'Klasyczne drzewo wcięć, nawigowalne z klawiatury',
tasksFoldCompleted: '✓ {count} ukończonych',
tasksFoldOne: 'Zwiń do ukończonych',
tasksFoldExpand: 'Rozwiń ukończone węzły',
tasksFoldCollapse: 'Zwiń ukończone węzły',
tasksZoomIn: 'Przybliż',
@@ -385,6 +386,9 @@ export const pl: Record<string, string> = {
tasksStateIdle: 'Gotowe',
tasksStateDone: 'Ukończone',
tasksStateError: 'Błąd',
tasksKindSubagent: 'Podagent',
tasksKindTeammate: 'Członek zespołu',
tasksKindWorkflow: 'Przepływ pracy',
teamBoard: 'Tablica zadań zespołu',
teamChip: 'Zespół {members} członków · {tasks} zadań',
teamTasksEmpty: 'Brak wspólnych zadań',
+4
View File
@@ -349,6 +349,7 @@ export const pt: Record<string, string> = {
settingsViewModeTree: 'Árvore',
settingsViewModeTreeDesc: 'Árvore com indentação clássica, navegável pelo teclado',
tasksFoldCompleted: '✓ {count} concluídos',
tasksFoldOne: 'Dobrar nos concluídos',
tasksFoldExpand: 'Expandir os nós concluídos',
tasksFoldCollapse: 'Recolher os nós concluídos',
tasksZoomIn: 'Aproximar',
@@ -366,6 +367,9 @@ export const pt: Record<string, string> = {
tasksStateIdle: 'Em espera',
tasksStateDone: 'Concluído',
tasksStateError: 'Erro',
tasksKindSubagent: 'Subagente',
tasksKindTeammate: 'Membro da equipe',
tasksKindWorkflow: 'Fluxo de trabalho',
teamBoard: 'Quadro de tarefas da equipe',
teamChip: 'Equipe {members} membros · {tasks} tarefas',
teamTasksEmpty: 'Ainda não há tarefas compartilhadas',
+4
View File
@@ -364,6 +364,7 @@ export const ru: Record<string, string> = {
settingsViewModeTree: 'Дерево',
settingsViewModeTreeDesc: 'Классическое дерево с отступами, навигация с клавиатуры',
tasksFoldCompleted: '✓ {count} завершено',
tasksFoldOne: 'Свернуть в завершённые',
tasksFoldExpand: 'Развернуть завершённые узлы',
tasksFoldCollapse: 'Свернуть завершённые узлы',
tasksZoomIn: 'Увеличить',
@@ -381,6 +382,9 @@ export const ru: Record<string, string> = {
tasksStateIdle: 'Ожидает',
tasksStateDone: 'Завершено',
tasksStateError: 'Ошибка',
tasksKindSubagent: 'Субагент',
tasksKindTeammate: 'Участник команды',
tasksKindWorkflow: 'Рабочий процесс',
teamBoard: 'Доска задач команды',
teamChip: 'Команда: {members} участников · {tasks} задач',
teamTasksEmpty: 'Общих задач пока нет',
+4
View File
@@ -349,6 +349,7 @@ export const sv: Record<string, string> = {
settingsViewModeTree: 'Träd',
settingsViewModeTreeDesc: 'Klassiskt indragsträd, navigerbart med tangentbord',
tasksFoldCompleted: '✓ {count} klara',
tasksFoldOne: 'Vik ihop med klara',
tasksFoldExpand: 'Visa färdiga noder',
tasksFoldCollapse: 'Dölj färdiga noder',
tasksZoomIn: 'Zooma in',
@@ -366,6 +367,9 @@ export const sv: Record<string, string> = {
tasksStateIdle: 'Vilar',
tasksStateDone: 'Klar',
tasksStateError: 'Fel',
tasksKindSubagent: 'Subagent',
tasksKindTeammate: 'Teammedlem',
tasksKindWorkflow: 'Arbetsflöde',
teamBoard: 'Teamuppgiftstavla',
teamChip: 'Team {members} medlemmar · {tasks} uppgifter',
teamTasksEmpty: 'Inga delade uppgifter än',
+4
View File
@@ -366,6 +366,7 @@ export const th: Record<string, string> = {
settingsViewModeTree: 'มุมมองต้นไม้',
settingsViewModeTreeDesc: 'ต้นไม้แบบเยื้องคลาสสิก นำทางด้วยคีย์บอร์ดได้',
tasksFoldCompleted: '✓ เสร็จแล้ว {count}',
tasksFoldOne: 'พับรวมกับที่เสร็จแล้ว',
tasksFoldExpand: 'ขยายโหนดที่เสร็จแล้ว',
tasksFoldCollapse: 'ย่อโหนดที่เสร็จแล้ว',
tasksZoomIn: 'ซูมเข้า',
@@ -383,6 +384,9 @@ export const th: Record<string, string> = {
tasksStateIdle: 'พร้อม',
tasksStateDone: 'เสร็จแล้ว',
tasksStateError: 'ข้อผิดพลาด',
tasksKindSubagent: 'เอเจนต์ย่อย',
tasksKindTeammate: 'สมาชิกทีม',
tasksKindWorkflow: 'เวิร์กโฟลว์',
teamBoard: 'บอร์ดงานของทีม',
teamChip: 'ทีม {members} สมาชิก · {tasks} งาน',
teamTasksEmpty: 'ยังไม่มีงานที่แชร์',
+4
View File
@@ -366,6 +366,7 @@ export const tr: Record<string, string> = {
settingsViewModeTree: 'Ağaç',
settingsViewModeTreeDesc: 'Klasik girintili ağaç, klavye ile gezilebilir',
tasksFoldCompleted: '✓ {count} tamamlandı',
tasksFoldOne: 'Tamamlananlara katla',
tasksFoldExpand: 'Tamamlanan düğümleri genişlet',
tasksFoldCollapse: 'Tamamlanan düğümleri daralt',
tasksZoomIn: 'Yakınlaştır',
@@ -383,6 +384,9 @@ export const tr: Record<string, string> = {
tasksStateIdle: 'Beklemede',
tasksStateDone: 'Tamamlandı',
tasksStateError: 'Hata',
tasksKindSubagent: 'Alt ajan',
tasksKindTeammate: 'Ekip üyesi',
tasksKindWorkflow: 'İş akışı',
teamBoard: 'Takım görev panosu',
teamChip: 'Takım {members} üye · {tasks} görev',
teamTasksEmpty: 'Henüz paylaşılan görev yok',
+4
View File
@@ -366,6 +366,7 @@ export const vi: Record<string, string> = {
settingsViewModeTree: 'Cây',
settingsViewModeTreeDesc: 'Cây thụt lề cổ điển, điều hướng được bằng bàn phím',
tasksFoldCompleted: '✓ {count} hoàn thành',
tasksFoldOne: 'Gộp vào đã xong',
tasksFoldExpand: 'Mở rộng các nút đã hoàn thành',
tasksFoldCollapse: 'Thu gọn các nút đã hoàn thành',
tasksZoomIn: 'Phóng to',
@@ -383,6 +384,9 @@ export const vi: Record<string, string> = {
tasksStateIdle: 'Nhàn rỗi',
tasksStateDone: 'Hoàn thành',
tasksStateError: 'Lỗi',
tasksKindSubagent: 'Tác nhân con',
tasksKindTeammate: 'Thành viên nhóm',
tasksKindWorkflow: 'Quy trình',
teamBoard: 'Bảng tác vụ nhóm',
teamChip: 'Nhóm {members} thành viên · {tasks} tác vụ',
teamTasksEmpty: 'Chưa có tác vụ dùng chung',
+4
View File
@@ -381,6 +381,7 @@ export const zhHK: Record<string, string> = {
settingsViewModeTree: '樹狀圖',
settingsViewModeTreeDesc: '經典縮排樹,可用鍵盤導航',
tasksFoldCompleted: '✓ {count} 已完成',
tasksFoldOne: '收進已完成聚合',
tasksFoldExpand: '展開已完成的節點',
tasksFoldCollapse: '收起已完成的節點',
tasksZoomIn: '放大',
@@ -398,6 +399,9 @@ export const zhHK: Record<string, string> = {
tasksStateIdle: '閒置',
tasksStateDone: '已完成',
tasksStateError: '出錯',
tasksKindSubagent: '子代理',
tasksKindTeammate: '成員',
tasksKindWorkflow: '工作流',
teamBoard: '團隊任務板',
teamChip: '團隊 {members} 成員 · {tasks} 任務',
teamTasksEmpty: '暫無共用任務',
+4
View File
@@ -381,6 +381,7 @@ export const zhMO: Record<string, string> = {
settingsViewModeTree: '樹狀圖',
settingsViewModeTreeDesc: '經典縮排樹,可用鍵盤導航',
tasksFoldCompleted: '✓ {count} 已完成',
tasksFoldOne: '收進已完成聚合',
tasksFoldExpand: '展開已完成的節點',
tasksFoldCollapse: '收起已完成的節點',
tasksZoomIn: '放大',
@@ -398,6 +399,9 @@ export const zhMO: Record<string, string> = {
tasksStateIdle: '閒置',
tasksStateDone: '已完成',
tasksStateError: '出錯',
tasksKindSubagent: '子代理',
tasksKindTeammate: '成員',
tasksKindWorkflow: '工作流',
teamBoard: '團隊任務板',
teamChip: '團隊 {members} 成員 · {tasks} 任務',
teamTasksEmpty: '暫無共用任務',
+4
View File
@@ -381,6 +381,7 @@ export const zhTW: Record<string, string> = {
settingsViewModeTree: '樹狀圖',
settingsViewModeTreeDesc: '經典縮排樹,可用鍵盤導航',
tasksFoldCompleted: '✓ {count} 已完成',
tasksFoldOne: '收進已完成聚合',
tasksFoldExpand: '展開已完成的節點',
tasksFoldCollapse: '收起已完成的節點',
tasksZoomIn: '放大',
@@ -398,6 +399,9 @@ export const zhTW: Record<string, string> = {
tasksStateIdle: '閒置',
tasksStateDone: '已完成',
tasksStateError: '出錯',
tasksKindSubagent: '子代理',
tasksKindTeammate: '成員',
tasksKindWorkflow: '工作流',
teamBoard: '團隊任務板',
teamChip: '團隊 {members} 成員 · {tasks} 任務',
teamTasksEmpty: '暫無共用任務',
+8
View File
@@ -378,6 +378,7 @@ export const zh = {
settingsViewModeTree: '树状图',
settingsViewModeTreeDesc: '经典缩进树,键盘可导航',
tasksFoldCompleted: '✓ {count} 已完成',
tasksFoldOne: '收进已完成聚合',
tasksFoldExpand: '展开已完成的节点',
tasksFoldCollapse: '折叠已完成的节点',
tasksZoomIn: '放大',
@@ -395,6 +396,9 @@ export const zh = {
tasksStateIdle: '待命',
tasksStateDone: '已完成',
tasksStateError: '出错',
tasksKindSubagent: '子代理',
tasksKindTeammate: '成员',
tasksKindWorkflow: '工作流',
teamBoard: '团队任务板',
teamChip: '团队 {members} 成员 · {tasks} 任务',
teamTasksEmpty: '暂无共享任务',
@@ -848,6 +852,7 @@ export const en: Record<keyof typeof zh, string> = {
settingsViewModeTree: 'Tree',
settingsViewModeTreeDesc: 'Classic indentation tree, keyboard navigable',
tasksFoldCompleted: '✓ {count} completed',
tasksFoldOne: 'Fold into completed',
tasksFoldExpand: 'Expand the completed nodes',
tasksFoldCollapse: 'Collapse the completed nodes',
tasksZoomIn: 'Zoom in',
@@ -865,6 +870,9 @@ export const en: Record<keyof typeof zh, string> = {
tasksStateIdle: 'Idle',
tasksStateDone: 'Done',
tasksStateError: 'Error',
tasksKindSubagent: 'Subagent',
tasksKindTeammate: 'Teammate',
tasksKindWorkflow: 'Workflow',
teamBoard: 'Team task board',
teamChip: 'Team {members} members · {tasks} tasks',
teamTasksEmpty: 'No shared tasks yet',
+158
View File
@@ -0,0 +1,158 @@
/**
* 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 { IconChevronDownOutlineRegular, 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.
* @param kind - card kind.
* @param count - folded member count (the fold aggregate's badge only).
*/
export function cardKindLabel(kind: CardKind, count = 0): 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 })
}
}
/**
* 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
/** 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, 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)}
</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 && <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-running={running === true ? 'true' : undefined}
data-state={state}
>
{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)
}}
>
<IconChevronDownOutlineRegular size={12} />
</button>
)}
</>
)}
</span>
)
}
+42 -61
View File
@@ -10,59 +10,62 @@
* the core bundle; <100-node trees need no virtualisation).
*/
import type { TasksNode } from './tasks-model.ts'
import type { SidebarChildLiveView } from '../context-types.ts'
import { tasksEdges } from './tasks-model.ts'
/** Geometry constants of the canvas (px, pre-scale). The design target is the
* NARROW native right sidebar (~360px): a 176px card fits two per row plus
* the gutter. */
export const GRAPH_NODE_W = 176
* NARROW native right sidebar (~360px): a 190px card fits two per row plus
* the gutter, and the card's own rows stay readable at the fit's floor. */
export const GRAPH_NODE_W = 190
/**
* The card's heights are the SUM OF THE ROWS THE CARD RENDERS, in the real
* metrics of the page's type scale (the same numbers the rules in
* tasks-graph.module.css / tasks-canvas.module.css are written with). They are
* derived rather than hand-tuned because the card is a fixed-geometry box: its
* height is `auto` with `min-height: box.h`, so an under-reserved card does not
* stay inside the box the layout handed it — it RENDERS TALLER, and everything
* the layout derives from `box.h` (the row pitch, the phase frames, the canvas
* extent) then disagrees with the pixels on screen.
* tasks-graph.module.css are written with). They are derived rather than
* hand-tuned because the card is a fixed-geometry box: its height is `auto`
* with `min-height: box.h`, so an under-reserved card does not stay inside the
* box the layout handed it — it RENDERS TALLER, and everything the layout
* derives from `box.h` (the row pitch, the phase frames, the canvas extent)
* then disagrees with the pixels on screen.
*
* Measured on the rendered page: a running teammate card carrying a two-line
* title, its meta, a live row and a task line renders 100px while the base
* reserve handed it 68 — `min-height` cannot hold it down, so the card ate
* 32px of the 53px gutter to the next row and poked out of its phase frame.
* The card is TWO SEGMENTS (tasks-card.tsx): a top segment carrying the kind
* badge, the clamped name, the mono meta (and, for a team member, the shared
* task line) and a bottom state bar carrying the state dot, the state word and
* the merged process activity. Every node kind reserves the SAME base height —
* a settled node shows its activity summary in that bar just like a running
* one, so the two can never disagree with the layout.
*
* Measured on the rendered page before this structure existed: a running
* teammate card carrying a two-line title, its meta, a live row and a task line
* rendered 100px while the base reserve handed it 68 — `min-height` cannot hold
* it down, so the card ate 32px of the 53px gutter to the next row and poked
* out of its phase frame.
*/
const NODE_EDGE_H = 1
const NODE_PAD_Y = 6
/** Both edges' 1px outline plus the vertical padding (`.node` box-sizing). */
const NODE_CHROME_H = 2 * (NODE_PAD_Y + NODE_EDGE_H)
/** The card's own border only: the two segments carry their own padding. */
const NODE_CHROME_H = 2 * NODE_EDGE_H
/** `.cardTop`'s vertical padding (6px top / 5px bottom). */
const NODE_PAD_TOP = 6
const NODE_PAD_BOTTOM = 5
/** One badge row — `--dsw-font-xxxs-11` (11px/14px) plus its 2px lead. */
const NODE_BADGE_H = 14 + 2
/** One title line — `--dsw-font-xxs-strong-12` (12px/18px). */
const NODE_TITLE_LINE_H = 18
/** Title lines the card reserves: the clamp in tasks-canvas.module.css. */
/** Title lines the card reserves: the clamp in tasks-graph.module.css. */
const NODE_TITLE_LINES = 2
/** The mono meta line — `--dsw-font-xxxs-11` (11px/14px) plus its 2px lead. */
const NODE_META_H = 14 + 2
/** One live row: LiveLine's 10px/1.3 tool row (or its meta-styled fallback)
* plus the 3px lead. */
const NODE_LIVE_H = 13 + 3
/** LiveLine's flattened text row — a 10px line whose box measures 14px, plus
* its 2px lead — which a tail carrying both a tool call and text renders
* UNDER the tool row. */
const NODE_LIVE_TAIL_H = 14 + 2
/** The state bar — `.cardBar`'s fixed height (its top border sits inside it). */
const NODE_BAR_H = 22
/** The shared-task row: its 10px/1.3 line plus the 3px lead, sized for the
* `+N` chip's own 1px outline (the tallest shape the row can take). */
const NODE_TASK_H = 15 + 3
/** TWO title lines plus the mono meta line: long agent names are clamped to
* two lines instead of ellipsizing to a couple of characters (see NODE_TITLE
* in TasksGraph.tsx), so the reserve covers that second line. */
export const GRAPH_NODE_H = NODE_CHROME_H + NODE_TITLE_LINES * NODE_TITLE_LINE_H + NODE_META_H
/** Extra height of a RUNNING agent node: the live row it always shows (a
* running node without activity yet renders LiveLine's "thinking" fallback). */
export const GRAPH_LIVE_H = NODE_LIVE_H
/** Extra height of a running node whose tail carries text as well as a tool. */
export const GRAPH_LIVE_TAIL_H = NODE_LIVE_TAIL_H
/** Badges + TWO title lines + the mono meta line + the state bar: long agent
* names are clamped to two lines instead of ellipsizing to a couple of
* characters (see `.cardName` in tasks-graph.module.css), so the reserve
* covers that second line. */
export const GRAPH_NODE_H = NODE_CHROME_H + NODE_PAD_TOP + NODE_BADGE_H
+ NODE_TITLE_LINES * NODE_TITLE_LINE_H + NODE_META_H + NODE_PAD_BOTTOM + NODE_BAR_H
/** Extra height of an agent node carrying a shared-task line (team members). */
export const GRAPH_TASK_H = NODE_TASK_H
@@ -77,7 +80,7 @@ export const GRAPH_GAP_Y = 53
export const GRAPH_PAD = 16
/** The row stride (one row of cards plus the vertical gutter). Every card
* shape must fit inside it — see {@link nodeHeight} for the tallest one. */
export const GRAPH_ROW_STRIDE = GRAPH_NODE_H + GRAPH_LIVE_H + GRAPH_GAP_Y
export const GRAPH_ROW_STRIDE = GRAPH_NODE_H + GRAPH_GAP_Y
/** One laid-out node. */
export interface GraphBox {
@@ -102,35 +105,13 @@ export interface LayoutOptions {
}
/**
* The display height of one node: the base card plus one reserved row per
* line the card will actually render, so the pixels and the box agree (see
* the constants above for what a mismatch costs).
*
* - a RUNNING agent always reserves the live row: without activity yet it
* still renders the "thinking" line (LiveLine's fallback), and a tail that
* arrived with a tool call reserves its text row too;
* - an agent owning shared tasks (a team member) reserves the task row —
* without it a two-line title, the meta and the task row render 16px below
* the reserved box.
*
* The tallest shape therefore is base + live + live-tail + task, which stays
* inside {@link GRAPH_ROW_STRIDE} (rows can never overlap).
* The display height of one node: the base card plus the shared-task row a
* team member renders. Nothing else varies — the merged activity lives in the
* state bar that every card renders, whatever its state.
*/
/** Whether a node's live view carries a merged-activity row to render. */
function hasMergedActivity(live: SidebarChildLiveView | undefined): boolean {
if (live === undefined) return false
return (live.summary?.counts.length ?? 0) > 0 || live.summary?.running !== undefined
}
function nodeHeight(node: TasksNode): number {
if (node.kind !== 'agent') return GRAPH_NODE_H
let height = GRAPH_NODE_H
if (node.state === 'running') {
height += GRAPH_LIVE_H
if (hasMergedActivity(node.live) && node.live?.text !== undefined) height += GRAPH_LIVE_TAIL_H
}
if ((node.tasks?.length ?? 0) > 0) height += GRAPH_TASK_H
return height
return (node.tasks?.length ?? 0) > 0 ? GRAPH_NODE_H + GRAPH_TASK_H : GRAPH_NODE_H
}
/**
+231 -3
View File
@@ -66,7 +66,11 @@
.edge {
fill: none;
stroke: var(--dsw-alias-label-tertiary);
stroke-width: 1;
/* 1.5px, not 1: at the narrow panel's fit scale (0.78–0.86) a 1px stroke
lands under a device pixel and the whole hierarchy fades out — measured on
the rendered page by swapping the token and the width. */
stroke-width: 1.5;
stroke-linecap: round;
}
.edgeTeam {
@@ -90,6 +94,10 @@
groups its members without competing with the solid cards and the ink
connectors drawn over it. Its label is the part a reader actually reads, so
the label — and only the label — climbs onto a text ink. */
/* The phase frame is the SPATIAL grouping of one workflow phase; the phase
badges on its member cards carry the same colour, so the reader can match
frame ↔ member without reading the frame's own label. It stays a hairline:
the frame must not compete with the cards it encloses. */
.phaseFrame {
position: absolute;
border: 1px dashed var(--dsw-alias-border-l2);
@@ -97,6 +105,11 @@
pointer-events: none;
}
.phaseFrame.phase0 { border-color: color-mix(in srgb, var(--dsw-alias-label-primary) 42%, transparent); }
.phaseFrame.phase1 { border-color: color-mix(in srgb, var(--dsw-alias-label-primary) 34%, transparent); }
.phaseFrame.phase2 { border-color: color-mix(in srgb, var(--dsw-alias-label-primary) 27%, transparent); }
.phaseFrame.phase3 { border-color: color-mix(in srgb, var(--dsw-alias-label-primary) 21%, transparent); }
.phaseLabel {
position: absolute;
top: -7px;
@@ -122,13 +135,17 @@
centering the column turns that leftover into even padding around the block
instead of a hole under it (the box size itself is fixed — the edges and the
phase frames are placed from it, so a card can never be content-sized). */
/* The two-segment node card. The segments own their padding because each one
paints its own surface: the TOP segment carries the identity (kind badge,
name, meta, task line) over the level's faint tint, the BOTTOM bar carries
the state and the merged activity. The layout in tasks-graph-layout.ts
reserves both rows, so the box handed to a card is exactly its height. */
.node {
position: absolute;
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: center;
padding: 6px 8px;
padding: 0;
background: var(--dsw-alias-bg-layer-1);
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 4px;
@@ -172,6 +189,217 @@
border-color: var(--dsw-alias-state-error-primary);
}
/* ── The two-segment card: identity on top, state bar at the bottom ─────── */
/* The group colour of a node is its DEPTH. The host ships no chart palette and
the skin contract forbids literal colours, so the ramp mixes the one accent
token: level 0 is the accent itself, deeper levels recede. The level also
tints the top segment faintly, so a wide graph still reads as bands. */
.depth0 { --card-edge: 100%; --card-tint: 7%; }
.depth1 { --card-edge: 72%; --card-tint: 5%; }
.depth2 { --card-edge: 48%; --card-tint: 3.5%; }
.depth3 { --card-edge: 28%; --card-tint: 2.5%; }
.cardTop {
position: relative;
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
min-width: 0;
padding: 6px 8px 5px 10px;
background: color-mix(
in srgb, var(--dsw-alias-state-business-primary) var(--card-tint, 6%), var(--dsw-alias-bg-layer-1)
);
}
/* The level edge: 2px, full height of the segment, never a second border. */
.cardTop::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 2px;
background: color-mix(
in srgb, var(--dsw-alias-state-business-primary) var(--card-edge, 100%), transparent
);
}
.cardBadges {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
}
/* The kind badge names WHAT the node is (main agent / subagent / teammate /
workflow / completed aggregate) — the vocabulary the page header already
uses, never a glyph the reader has to decode. */
.kindBadge {
flex: none;
padding: 0 4px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 2px;
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-label-secondary);
}
/* The workflow phase badge: the SECOND grouping axis. It takes the ink family
(the level edge owns the accent family) so the two never read as one. */
.phaseBadge {
flex: none;
max-width: 62%;
padding: 0 4px;
border: 1px solid;
border-radius: 2px;
font: var(--dsw-font-xxxs-11);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.phase0 {
color: var(--dsw-alias-label-primary);
border-color: color-mix(in srgb, var(--dsw-alias-label-primary) 45%, transparent);
}
.phase1 {
color: color-mix(in srgb, var(--dsw-alias-label-primary) 78%, transparent);
border-color: color-mix(in srgb, var(--dsw-alias-label-primary) 38%, transparent);
}
.phase2 {
color: color-mix(in srgb, var(--dsw-alias-label-primary) 58%, transparent);
border-color: color-mix(in srgb, var(--dsw-alias-label-primary) 30%, transparent);
}
.phase3 {
color: color-mix(in srgb, var(--dsw-alias-label-primary) 42%, transparent);
border-color: color-mix(in srgb, var(--dsw-alias-label-primary) 24%, transparent);
}
.cardName {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
overflow-wrap: anywhere;
font: var(--dsw-font-xxs-strong-12);
color: var(--dsw-alias-label-primary);
}
/* The aggregate carries a count instead of a name: one rung down in weight and
ink, so a settled summary never competes with a live node. */
.cardNamePlain {
-webkit-line-clamp: 1;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-secondary);
}
.cardMeta {
font: var(--dsw-font-xxxs-11);
font-family: var(--ds-font-family-code, monospace);
color: var(--dsw-alias-label-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── The state bar ──────────────────────────────────────────────────────── */
.cardBar {
position: relative;
display: flex;
align-items: center;
gap: 5px;
flex: none;
height: 22px;
padding: 0 6px 0 8px;
border-top: 1px solid var(--dsw-alias-border-l2);
background: var(--dsw-alias-bg-base);
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-label-secondary);
overflow: hidden;
}
/* Executing: the bar's background SWEEPS left → right (the same motion the
main conversation's shimmer uses, applied to a strip instead of text). The
band is 45% of the bar and travels the full width; the last third of the
cycle rests, so a fast glance still catches it at rest. */
.cardBar[data-running='true']::after {
content: '';
position: absolute;
inset: 0;
background-image: linear-gradient(
90deg,
transparent 0%,
color-mix(in srgb, var(--dsw-alias-state-business-primary) 14%, transparent) 50%,
transparent 100%
);
background-size: 45% 100%;
background-repeat: no-repeat;
background-position: 0% center;
animation: dsh-tasks-bar-sweep 1.5s cubic-bezier(0.33, 0, 0.67, 1) infinite;
pointer-events: none;
}
@keyframes dsh-tasks-bar-sweep {
0% { background-position: 0% center; }
66.6667%, 100% { background-position: 100% center; }
}
/* Motion is decoration here: the state word and the activity text carry the
same information without it. */
@media (prefers-reduced-motion: reduce) {
.cardBar[data-running='true']::after {
animation: none;
opacity: 0;
}
}
.barDot {
flex: none;
}
.barState {
flex: none;
color: var(--dsw-alias-label-secondary);
}
/* The merged activity: the main agent's own wording for the node's newest
range, in the mono voice the page uses for machine text. */
.barActivity {
flex: 1;
min-width: 0;
font-family: var(--ds-font-family-code, monospace);
color: var(--dsw-alias-state-business-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.barFold {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
margin-left: auto;
padding: 0;
border: none;
border-radius: 2px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
.barFold:hover {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
/* The fold aggregate mirrors the tree's dashed fold row (`treeFoldRow`). */
.nodeFold {
border: 1px dashed var(--dsw-alias-border-l2);
+48 -8
View File
@@ -69,6 +69,13 @@ export interface TasksAgentNode {
status: SidebarTeamMemberView['status']
diagnostics: string[]
}
/**
* Tree depth (the root is 0). Drives the card's GROUP COLOUR: every node of
* one level wears the same left edge, so a wide graph still reads as bands.
*/
depth: number
/** Workflow phase this node is a member of (workflow runs only). */
phase?: { key: string; index: number; title?: string }
/** The catalog's durable children signal (fold candidacy's leaf test). */
hasChildren?: boolean
/** Shared tasks owned by this agent (team boards only; empty otherwise). */
@@ -84,6 +91,8 @@ export interface TasksWorkflowNode {
kind: 'workflow'
id: string
parentId: string
/** Tree depth (see {@link TasksAgentNode.depth}). */
depth: number
run: WorkflowRunView
}
@@ -92,6 +101,8 @@ export interface TasksFoldNode {
kind: 'fold'
id: string
parentId: string
/** Tree depth (see {@link TasksAgentNode.depth}). */
depth: number
count: number
/** The folded session ids, in original order (expansion restores them). */
memberIds: string[]
@@ -115,6 +126,12 @@ export interface TasksModelInput {
teamTasks?: readonly SidebarTeamTaskView[]
/** Whether settled leaves collapse into fold nodes. */
folded: boolean
/**
* Nodes the READER folded by hand (a completed card's bar chevron). They fold
* under the same guards as the global rule, so a manual fold can never hide a
* running or branching node — it only adds a per-node trigger.
*/
foldedIds?: ReadonlySet<string>
}
/** Human label of one catalog child (the classic rule). */
@@ -153,7 +170,9 @@ function memberOutcomeState(outcome: 'completed' | 'failed' | 'cancelled' | unde
* trails its parent's remaining children.
*/
export function buildTasksModel(input: TasksModelInput): TasksNode[] {
const { byId, catalogs, rootId, currentSessionId, live, runs, teamMembers, folded } = input
const {
byId, catalogs, rootId, currentSessionId, live, runs, teamMembers, folded, foldedIds,
} = input
const teamTasks = input.teamTasks ?? []
const teamById = new Map(teamMembers.map(member => [member.id, member]))
/** Owner display name → node id (the board assigns by member name). */
@@ -205,7 +224,7 @@ export function buildTasksModel(input: TasksModelInput): TasksNode[] {
/** The agent children of one parent, in catalog order (side threads
* excluded), with workflow runs appended in start order. */
const appendChildren = (parentId: string): void => {
const appendChildren = (parentId: string, depth: number): void => {
const catalog = catalogs[parentId]
const entries = catalog?.state === 'ready' ? catalog.entries : []
const agentChildren: TasksAgentNode[] = []
@@ -226,6 +245,7 @@ export function buildTasksModel(input: TasksModelInput): TasksNode[] {
kind: 'agent',
id: entry.id,
parentId,
depth: depth + 1,
label: childLabel(entry, summary),
...(summary?.displayTitle !== undefined && summary.displayTitle !== entry.label
? { title: summary.displayTitle } : {}),
@@ -246,16 +266,25 @@ export function buildTasksModel(input: TasksModelInput): TasksNode[] {
const runNodes: TasksWorkflowNode[] = []
const memberNodes: TasksAgentNode[][] = []
for (const run of runsByOrigin.get(parentId) ?? []) {
const runNode: TasksWorkflowNode = { kind: 'workflow', id: `run:${run.runId}`, parentId, run }
const runNode: TasksWorkflowNode = {
kind: 'workflow', id: `run:${run.runId}`, parentId, depth: depth + 1, run,
}
runNodes.push(runNode)
const members: TasksAgentNode[] = []
for (const phase of run.phases) {
for (const [phaseIndex, phase] of run.phases.entries()) {
for (const member of phase.members) {
const existing = member.childId !== ''
? agentChildren.find(candidate => candidate.id === member.childId)
: undefined
const phaseRef = {
key: `${run.runId}:${phaseIndex}`,
index: phaseIndex,
...(phase.title === undefined ? {} : { title: phase.title }),
}
if (existing !== undefined) {
existing.parentId = runNode.id
existing.depth = runNode.depth + 1
existing.phase = phaseRef
if (tasksByNode.get(existing.id) !== undefined) existing.tasks = tasksByNode.get(existing.id)
members.push(existing)
} else if (member.childId !== '' && knownAgentIds.has(member.childId)) {
@@ -267,6 +296,8 @@ export function buildTasksModel(input: TasksModelInput): TasksNode[] {
kind: 'agent',
id: childKnown ? member.childId : `wfmember:${run.runId}:${member.seq}`,
parentId: runNode.id,
depth: runNode.depth + 1,
phase: phaseRef,
label: member.label,
state: memberOutcomeState(member.outcome),
activity: member.outcome === undefined ? 'running' : 'inactive',
@@ -286,6 +317,8 @@ 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.
// `folded` is the page-level rule; `foldedIds` are the nodes the reader
// folded by hand from their own card bar. Both run through the SAME guards.
const kept: TasksAgentNode[] = []
const foldCandidates: TasksAgentNode[] = []
for (const child of visibleAgentChildren) {
@@ -293,7 +326,8 @@ export function buildTasksModel(input: TasksModelInput): TasksNode[] {
// its own — folding a parent would hide its live branch.
const isLeaf = child.hasChildren !== true && !runsByOrigin.has(child.id)
if (
folded && isLeaf && !child.current && child.team === undefined
(folded || (foldedIds?.has(child.id) ?? false)) && isLeaf && !child.current
&& child.team === undefined
&& (child.state === 'done' || child.state === 'error')
) {
foldCandidates.push(child)
@@ -304,13 +338,14 @@ export function buildTasksModel(input: TasksModelInput): TasksNode[] {
for (const child of kept) {
out.push(child)
appendChildren(child.id)
appendChildren(child.id, child.depth)
}
if (foldCandidates.length > 0) {
out.push({
kind: 'fold',
id: `fold:${parentId}`,
parentId,
depth: depth + 1,
count: foldCandidates.length,
memberIds: foldCandidates.map(child => child.id),
previews: foldCandidates.slice(0, 2).map(child => child.label),
@@ -325,7 +360,10 @@ export function buildTasksModel(input: TasksModelInput): TasksNode[] {
const keptMembers: TasksAgentNode[] = []
const foldedMembers: TasksAgentNode[] = []
for (const member of members) {
if (folded && !member.current && (member.state === 'done' || member.state === 'error')) {
if (
(folded || (foldedIds?.has(member.id) ?? false)) && !member.current
&& (member.state === 'done' || member.state === 'error')
) {
foldedMembers.push(member)
} else {
keptMembers.push(member)
@@ -337,6 +375,7 @@ export function buildTasksModel(input: TasksModelInput): TasksNode[] {
kind: 'fold',
id: `fold:${runNode.id}`,
parentId: runNode.id,
depth: runNode.depth + 1,
count: foldedMembers.length,
memberIds: foldedMembers.map(member => member.id),
previews: foldedMembers.slice(0, 2).map(member => member.label),
@@ -350,6 +389,7 @@ export function buildTasksModel(input: TasksModelInput): TasksNode[] {
out.push({
kind: 'agent',
id: rootId,
depth: 0,
label: rootSummary?.displayTitle !== undefined && rootSummary.displayTitle !== ''
? rootSummary.displayTitle
: rootId,
@@ -359,7 +399,7 @@ export function buildTasksModel(input: TasksModelInput): TasksNode[] {
...(rootTeam !== undefined ? { team: rootTeam } : {}),
...(tasksByNode.get(rootId) !== undefined ? { tasks: tasksByNode.get(rootId) } : {}),
})
appendChildren(rootId)
appendChildren(rootId, 0)
// Last-resort guard: one node per session id, first occurrence wins. Real
// catalogs list a child under exactly one parent, so this only fires on