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:
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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: 'لا مهام مشتركة بعد',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'अभी कोई साझा कार्य नहीं',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '共有タスクはまだありません',
|
||||
|
||||
@@ -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: '공유 작업이 없습니다',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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ń',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'Общих задач пока нет',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'ยังไม่มีงานที่แชร์',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '暫無共用任務',
|
||||
|
||||
@@ -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: '暫無共用任務',
|
||||
|
||||
@@ -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: '暫無共用任務',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user