Files
apes-sidebar/src/client/TasksGraph.tsx
T
Menghuan1918 d1a9d520ae fix(tasks-ui): restore the scoped preflight subset and return the page to stock shadcn
Real-device report: the Tasks page "looks like it has no CSS at all, with
blurry borders". Two hypotheses were falsified before the real cause surfaced:
the sheet IS delivered (the chunk carries both the CSS and its injector, and
the bundle route serves it) and the cascade is NOT the problem (with the real
host stylesheet plus the real compiled sheet, a controlled sample keeps our
padding, radius, border, background and font; host CSS-module selectors only
match host nodes, and its global element rules are element-level specificity).

The cause is what we deliberately excluded: PREFLIGHT is also what normalizes
form controls. Measured on a plugin icon button that sets only an icon size,
the UA chrome survived as padding 1px 6px, border 2px outset, background
ButtonFace (#efefef) and a 13.33px system font - gray beveled buttons with a
soft double border, i.e. "no CSS".

- ui/theme.css: a .dsw-tasks-scoped preflight subset (zero margins/padding,
  border width+style, background, appearance none, inherit color/spacing,
  cursor rules) inside @layer base, written with :where() so it stays at
  (0,1,0) and utilities in the later layer still win; plus an explicit
  "@layer theme, base, components, utilities;" statement - layer order follows
  first appearance, and without it the utilities import registered utilities
  BEFORE base, so the reset beat rounded-md (measured: square buttons). The
  built chunk's order was re-checked: properties, theme, base, components,
  utilities.
- Stock shadcn restyle: static cards carry stock shadow-xs/shadow-sm again
  (floating layers keep shadow-lg), body/meta text is text-sm/text-xs instead
  of 11-13px, status uses stock Badge variants, controls use stock Button
  variants and sizes, row actions appear on hover/focus while staying keyboard
  reachable, node cards are 176x68 with shadow-xs.
- bandColsFor budgets columns at the readability floor (width / 0.78) instead
  of 1:1, so a 360px panel keeps two columns; FIT_MIN_SCALE moved into the
  layout module as GRAPH_FIT_MIN_SCALE so the fit and the column budget share
  one constant.
- Guards: tests/ui-foundation.spec.ts pins the scoped reset declarations and
  the layer-order statement; tests/ui-shadows.spec.ts now allows shadow-xs and
  shadow-sm on static panels and bans md and above; the theme guard's inert
  paint rule forced the reset to spell border-width/border-style instead of the
  "border: 0 solid" shorthand.

Verified: typecheck and lint clean; 136 files / 1505 tests; build with the
core/chunk bundle guards; mount e2e 7/7 (mount 444ms, longtasks 2 of 8); the
visual mirror page (real host stylesheet + real components) re-shot at 360 and
720 for graph, tree and the 77-node stress fixture.
2026-09-27 22:18:19 +08:00

604 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The workflow-graph mode of the Tasks page (the approved Variant-D canvas):
* layered agent/workflow nodes over bezier edges on a dotted grid, drag-pan
* from the BACKGROUND only (never from a node — the mockup's rule; capturing
* pointers on the container is what silently swallowed node clicks), wheel
* zoom to the cursor, dashed phase frames, fold aggregate nodes, and the
* bottom-right horizontal control cluster (view toggle / zoom out / level /
* zoom in / fit) that stays reachable in BOTH modes.
*
* Fitting: the canvas auto-fits while the reader has not touched the view,
* re-running on container resize and layout growth (the sidebar mounts
* hidden at zero size, so a one-shot fit on mount is not enough), centering
* on both axes and scaling UP to {@link FIT_MAX_SCALE} so a small tree fills
* the narrow panel instead of hugging the top-left corner.
*
* Visual language: every node is a shadcn `Card` — the compact stock recipe
* (`rounded-lg border bg-card p-2.5 shadow-xs`) with `hover:bg-muted` as the
* only hover change and no card tint (the old double/dashed borders and the
* filled workflow cards are gone; "current" is a 2px accent
* bar, running is the host `StateDot` plus the live line). Statuses ride the
* mono meta line, team membership is a `Badge`, a truncated line carries its
* full text in a shadcn `Tooltip`, and the control cluster is the vendored
* `Button` recipe (`outline` / `sm`) with a tooltip per control. The phase
* frames are dashed `border-border` boxes on `bg-muted/40` and the relations
* are hairline strokes
* in the border ink (see tasks-canvas.module.css for the canvas-only pieces:
* the dotted grid, the transformed layer and the edge strokes).
*
* Behaviour is unchanged: the same pointer-down-on-background pan, the same
* wheel zoom to cursor, the same click tolerance, the same auto-fit, and the
* same stable hooks (`data-graph-node`, `data-graph-controls`, `role="group"`,
* the aria-label copy).
*/
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
type ReactNode,
} from 'react'
import {
IconChecklistOutline14, IconFullscreenOutline16, IconTreeCorner8x10, StateDot,
} 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,
} from './tasks-graph-layout.ts'
import {
AgentGlyph, agentMeta, FoldGlyph, foldPreviews, LiveLine, nodeDotState, TaskLine,
WorkflowGlyph, workflowMeta,
} from './tasks-shared.tsx'
import { t } from './locales.ts'
import { Badge } from './ui/badge.tsx'
import { Button } from './ui/button.tsx'
import { Card } from './ui/card.tsx'
import { Spinner } from './ui/spinner.tsx'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip.tsx'
import { cn } from './ui/utils.ts'
import css from './tasks-canvas.module.css'
/** The zoom bounds of the canvas. */
const ZOOM_MIN = 0.3
const ZOOM_MAX = 1.5
/** Upper bound of the auto-fit scale (never balloon a two-node tree). */
const FIT_MAX_SCALE = 1.15
/** Lower bound of the auto-fit scale: below this the cards stop being
* readable, so the canvas overflows and pans instead of shrinking further. */
/** Drag-vs-click separation: pointer travel below this stays a click. */
const CLICK_TOLERANCE_PX = 4
/**
* The shared shell of every node card: absolute (the layout owns left/top),
* the compact stock node recipe — `rounded-lg border bg-card p-2.5 shadow-xs`
* — with `hover:bg-muted` as the only hover change. No tint.
*/
const NODE_CARD = 'absolute cursor-pointer gap-0 overflow-hidden rounded-lg border border-border bg-card p-2.5 shadow-xs transition-colors hover:bg-muted focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-1 focus-visible:outline-ring'
/** The on-screen session: a 2px accent bar, never a tinted card. */
const NODE_ACCENT = 'border-l-2 border-l-primary'
/** Settled nodes recede by INK, not by opacity: the whole card drops to the
* secondary ink (the title inherits it) and the meta line goes one level
* further down (`text-foreground-3`, appended over NODE_META). */
const NODE_SETTLED = 'text-muted-foreground'
/** Card line 1: `text-sm` medium, clamped to TWO lines — a single ellipsized
* line left too few CJK characters, which is what made a dense graph
* unreadable. The tooltip still carries the full name. */
const NODE_TITLE = 'min-w-0 flex-1 line-clamp-2 text-sm leading-snug font-medium [overflow-wrap:anywhere]'
/** The mono meta line: `text-xs`, tabular figures, secondary ink. */
const NODE_META = 'mt-0.5 truncate font-mono text-xs leading-[1.3] tabular-nums text-muted-foreground'
/** One phase frame of a run node (the dashed box behind its members). */
interface PhaseFrame {
key: string
title: string | undefined
x: number
y: number
w: number
h: number
}
export interface TasksGraphProps {
nodes: readonly TasksNode[]
folded: boolean
/** Re-fit trigger: the topology root id (a reroot re-centers). */
rootId: string | undefined
onNodeInfo(node: TasksAgentNode, anchor: HTMLElement): void
onWorkflowInfo(node: TasksWorkflowNode, anchor: HTMLElement): void
/** Open the shared task window for one task id. */
onOpenTask(taskId: string, anchor: HTMLElement): void
onToggleFold(): void
mode: 'graph' | 'tree'
onModeChange(mode: 'graph' | 'tree'): void
/** Fallback loading overlay while the root catalog hydrates (the same
* signal TasksTree renders its loading row for). */
loading?: boolean
}
/**
* One button of the control cluster: the vendored `Button`'s stock
* `outline` / `sm` recipe, with a shadcn tooltip carrying the same copy as
* the aria-label (`title` used to carry it; the bubble replaces it). The
* pressed state rides the outline variant's own accent surface.
*/
function ControlButton(props: {
label: string
pressed?: boolean
onClick(): void
children: ReactNode
}): ReactNode {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className={cn('flex-none', props.pressed === true && 'bg-accent text-accent-foreground')}
aria-label={props.label}
aria-pressed={props.pressed}
onClick={props.onClick}
>
{props.children}
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">{props.label}</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
/**
* One ellipsized card line plus the tooltip that carries its full text — the
* shadcn replacement for the native `title` these lines used to wear. The
* anchor is the line itself, so hovering the dot/glyph gutter stays quiet.
*/
function CardLine(props: { className: string; label: string; children?: ReactNode }): ReactNode {
return (
<Tooltip>
<TooltipTrigger asChild>
<span className={props.className}>{props.children ?? props.label}</span>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[260px] text-xs">{props.label}</TooltipContent>
</Tooltip>
)
}
/** The shared view-mode toggle (glyph + label, per the mockup). */
export function ViewModeToggle(props: {
mode: 'graph' | 'tree'
onModeChange(mode: 'graph' | 'tree'): void
}): ReactNode {
const { mode, onModeChange } = props
const toTree = mode === 'graph'
const label = toTree ? t('tasksViewTree') : t('tasksViewGraph')
return (
<ControlButton
label={t(toTree ? 'tasksViewSwitchToTree' : 'tasksViewSwitchToGraph')}
onClick={() => { onModeChange(toTree ? 'tree' : 'graph') }}
>
{toTree ? <IconTreeCorner8x10 /> : <WorkflowGlyph />}
{label}
</ControlButton>
)
}
/** The fold toggle (expand / re-collapse the settled aggregates). */
export function FoldToggleButton(props: { folded: boolean; onToggleFold(): void }): ReactNode {
const { folded, onToggleFold } = props
return (
<ControlButton
label={t(folded ? 'tasksFoldExpand' : 'tasksFoldCollapse')}
pressed={folded}
onClick={onToggleFold}
>
<IconChecklistOutline14 />
</ControlButton>
)
}
export function TasksGraph(props: TasksGraphProps): ReactNode {
const { nodes, folded, rootId, onNodeInfo, onWorkflowInfo, onOpenTask, onToggleFold, mode, onModeChange, loading } = props
const containerRef = useRef<HTMLDivElement>(null)
const [tf, setTf] = useState({ x: 0, y: 0, k: 1 })
const [dragging, setDragging] = useState(false)
/** Pointer travel of the in-flight gesture (click suppression). */
const travelRef = useRef(0)
/** The reader moved/zoomed: stop auto-fitting over their view. */
const touchedRef = useRef(false)
const tfRef = useRef(tf)
tfRef.current = tf
/** Container width drives the sibling-band wrap (see bandColsFor). */
const [width, setWidth] = useState(0)
const layout = useMemo(
() => layoutTasksGraphForWidth(nodes, width, GRAPH_FIT_MIN_SCALE),
[nodes, width],
)
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. */
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 k = Math.max(
GRAPH_FIT_MIN_SCALE,
Math.min((cw - 24) / layout.width, (ch - 24) / layout.height, FIT_MAX_SCALE),
)
setTf({
x: (cw - layout.width * k) / 2,
y: Math.max(8, (ch - layout.height * k) / 2),
k,
})
}, [layout.width, layout.height])
/** The ⌂ button: refit AND re-enable auto-fit for later resizes. */
const refit = useCallback((): void => {
touchedRef.current = false
fit()
}, [fit])
// Auto-fit on mount, on layout growth and on container resize — but never
// over a view the reader has panned/zoomed themselves.
useEffect(() => {
if (!touchedRef.current) fit()
}, [fit, rootId])
useEffect(() => {
const container = containerRef.current
if (container === null || typeof ResizeObserver === 'undefined') return
const observer = new ResizeObserver((entries) => {
const rect = entries[0]?.contentRect
if (rect !== undefined) setWidth(current => (Math.abs(current - rect.width) < 2 ? current : rect.width))
if (!touchedRef.current) fit()
})
observer.observe(container)
return () => { observer.disconnect() }
}, [fit])
// Wheel-zoom to cursor (non-passive: the page must not scroll).
useEffect(() => {
const container = containerRef.current
if (container === null) return
const onWheel = (event: WheelEvent): void => {
event.preventDefault()
const rect = container.getBoundingClientRect()
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)))
if (next === k) return
touchedRef.current = true
setTf({
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) }
}, [])
const zoomBy = useCallback((factor: number): void => {
const container = containerRef.current
const { x, y, k } = tfRef.current
const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, k * factor))
if (next === k || container === null) return
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 })
}, [])
/**
* Drag-pan. The gesture only ever starts on the BACKGROUND: a pointerdown
* on a node (or the control cluster) is left alone, so the node's own click
* fires. Listeners live on `window` — never `setPointerCapture` on the
* container, which retargets the derived click event and silently kills
* every node interaction.
*/
const onPointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>): void => {
if (event.button !== 0) return
const target = event.target as HTMLElement
if (target.closest('[data-graph-node]') !== null) return
if (target.closest('[data-graph-controls]') !== null) return
travelRef.current = 0
const startX = event.clientX
const startY = event.clientY
const { x, y } = tfRef.current
const onMove = (move: globalThis.PointerEvent): void => {
const dx = move.clientX - startX
const dy = move.clientY - startY
travelRef.current = Math.max(travelRef.current, Math.abs(dx) + Math.abs(dy))
if (travelRef.current <= CLICK_TOLERANCE_PX) return
touchedRef.current = true
setDragging(true)
setTf(current => ({ ...current, x: x + dx, y: y + dy }))
}
const onUp = (): void => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
window.removeEventListener('pointercancel', onUp)
setDragging(false)
}
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp)
window.addEventListener('pointercancel', onUp)
}, [])
/** Suppress node clicks that ended a drag gesture. */
const clickAllowed = useCallback((): boolean => travelRef.current <= CLICK_TOLERANCE_PX, [])
// Phase frames: group each run's members by phase and box them.
const phaseFrames = useMemo((): PhaseFrame[] => {
const frames: PhaseFrame[] = []
for (const node of nodes) {
if (node.kind !== 'workflow') continue
const runNode = node
runNode.run.phases.forEach((phase, phaseIndex) => {
const boxes: GraphBox[] = []
for (const member of phase.members) {
const id = member.childId !== '' ? member.childId : `wfmember:${runNode.run.runId}:${member.seq}`
const box = layout.boxes.get(id)
if (box !== undefined) boxes.push(box)
}
if (boxes.length === 0) return
const x1 = Math.min(...boxes.map(box => box.x)) - 12
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 })
})
}
return frames
}, [nodes, layout])
/** The bezier edge path between two boxes (top-down). */
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}`
}
return (
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
<div
ref={containerRef}
className={cn(css.canvas, dragging && css.canvasDragging)}
role="group"
aria-label={t('tasksViewGraph')}
onPointerDown={onPointerDown}
>
<TooltipProvider delayDuration={400}>
{/* The graph twin of TasksTree's loading row (same Spinner + the
same `loading` copy key). `pointer-events-none` keeps the
canvas's pan/zoom/node clicks fully live underneath. */}
{loading === true && nodes.length === 0 && (
<div className="pointer-events-none absolute inset-0 z-[6] flex items-center justify-center">
<span className="flex items-center gap-1.5 px-6 py-3 text-xs text-muted-foreground">
{/* The visible line carries the copy; the glyph is decoration. */}
<span aria-hidden="true" className="flex flex-none items-center">
<Spinner size={12} />
</span>
{t('loading')}
</span>
</div>
)}
<div
className={css.canvasInner}
style={{
width: layout.width,
height: layout.height,
transform: `translate(${tf.x}px, ${tf.y}px) scale(${tf.k})`,
}}
>
<svg className={css.edges} width={layout.width} height={layout.height} aria-hidden="true">
{edges.map((edge) => {
const from = layout.boxes.get(edge.from)
const to = layout.boxes.get(edge.to)
if (from === undefined || to === undefined) return null
const target = nodeById.get(edge.to)
return (
<path
key={`${edge.from}->${edge.to}`}
className={cn(
css.edge,
edge.kind === 'team' && css.edgeTeam,
edge.kind === 'workflow' && css.edgeWorkflow,
target?.kind === 'fold' && css.edgeFold,
)}
d={edgePath(from, to)}
/>
)
})}
</svg>
{phaseFrames.map(frame => (
<div
key={frame.key}
className="pointer-events-none absolute rounded-md bg-muted/40 border border-dashed border-border"
style={{ left: frame.x, top: frame.y, width: frame.w, height: frame.h }}
>
<span className="absolute -top-[7px] left-2 bg-background px-1 font-mono text-xs leading-[1.3] tracking-wide text-foreground-3 uppercase">
{frame.title ?? t('workflowPhaseUnnamed')}
</span>
</div>
))}
{/* KEYBOARD ACCESS (deliberate, F4): graph nodes render with
tabIndex={-1} on purpose — a graph with dozens of nodes would
add dozens of Tab stops and drown the page's real controls,
and the canvas itself supports pointer pan/zoom only. The
keyboard-equivalent path is the bottom-right control cluster
(`data-graph-controls`): its "切换为树状图" view toggle is a
NATIVE <button> (Tab-reachable, Enter/Space-activatable), and
tree mode provides the full ArrowUp/ArrowDown/Home/End/Enter/
Space navigation over the same nodes (see TasksTree). Do NOT
"fix" the nodes' tabIndex without fixing the tab-stop flood. */}
{nodes.map((node) => {
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 === 'workflow') {
return renderWorkflowNode(node, style, onWorkflowInfo, clickAllowed)
}
return renderAgentNode(node, style, onNodeInfo, onOpenTask, clickAllowed)
})}
</div>
</TooltipProvider>
<div className="absolute right-2.5 bottom-2.5 z-[6] flex flex-row items-center gap-0.5" data-graph-controls>
<ViewModeToggle mode={mode} onModeChange={onModeChange} />
<FoldToggleButton folded={folded} onToggleFold={onToggleFold} />
<ControlButton label={t('tasksZoomOut')} onClick={() => { zoomBy(1 / 1.2) }}>−</ControlButton>
<span
className="flex-none px-1.5 font-mono text-xs tabular-nums text-muted-foreground"
aria-hidden="true"
>
{Math.round(tf.k * 100)}%
</span>
<ControlButton label={t('tasksZoomIn')} onClick={() => { zoomBy(1.2) }}>+</ControlButton>
<ControlButton label={t('tasksZoomFit')} onClick={refit}><IconFullscreenOutline16 /></ControlButton>
</div>
</div>
</div>
)
}
/** One agent node card: dot + title, one mono meta line, live line when running. */
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,
): ReactNode {
const meta = agentMeta(node)
const settled = (node.state === 'done' || node.state === 'error') && !node.current
return (
<Card
key={node.id}
data-graph-node={node.id}
role="button"
tabIndex={-1}
aria-label={`${node.label} ${meta}`}
aria-current={node.current ? 'true' : undefined}
className={cn(
NODE_CARD,
// The lead keeps the stronger hairline rung; the meta line names it.
node.parentId === undefined && 'border-border-strong',
node.current && NODE_ACCENT,
settled && NODE_SETTLED,
)}
style={style}
onClick={(event) => {
if (!clickAllowed()) return
onNodeInfo(node, event.currentTarget)
}}
>
<span className="flex min-w-0 items-center gap-1">
<StateDot state={nodeDotState(node.state)} size={6} className="flex-none" />
<span className="flex flex-none text-foreground-3" aria-hidden="true"><AgentGlyph node={node} /></span>
<CardLine className={NODE_TITLE} label={node.label} />
{node.team?.role === 'teammate' && node.team.name !== '' && (
<Badge
variant="outline"
className="h-5 max-w-[56px] flex-none truncate font-normal text-muted-foreground"
>
{node.team.name}
</Badge>
)}
</span>
{/* Error is data, not chrome: the card keeps the shared hairline (F6)
and the state rides the StateDot plus the destructive meta ink —
the same family success/warning already use on their meta words. */}
<CardLine
className={cn(
NODE_META,
node.state === 'error' ? 'text-destructive' : settled && 'text-foreground-3',
)}
label={meta}
/>
{node.state === 'running' && <LiveLine live={node.live} />}
<TaskLine tasks={node.tasks} onOpenTask={onOpenTask} />
</Card>
)
}
/** One workflow run node card. */
function renderWorkflowNode(
node: TasksWorkflowNode,
style: { left: number; top: number; width: number; minHeight: number },
onWorkflowInfo: (node: TasksWorkflowNode, anchor: HTMLElement) => void,
clickAllowed: () => boolean,
): ReactNode {
const meta = workflowMeta(node)
const settled = node.run.status !== 'running'
return (
<Card
key={node.id}
data-graph-node={node.id}
role="button"
tabIndex={-1}
aria-label={`${node.run.name} ${meta}`}
className={cn(NODE_CARD, settled && NODE_SETTLED)}
style={style}
onClick={(event) => {
if (!clickAllowed()) return
onWorkflowInfo(node, event.currentTarget)
}}
>
<span className="flex min-w-0 items-center gap-1">
<StateDot state={node.run.status === 'running' ? 'ongoing' : 'done'} size={6} className="flex-none" />
<span className="flex flex-none text-foreground-3" aria-hidden="true"><WorkflowGlyph /></span>
<CardLine className={NODE_TITLE} label={node.run.name} />
</span>
<CardLine className={cn(NODE_META, settled && 'text-foreground-3')} label={meta} />
</Card>
)
}
/** One fold aggregate node: the settled leaves as a dashed card. */
function renderFoldNode(
node: TasksFoldNode,
style: { left: number; top: number; width: number; minHeight: number },
onToggleFold: () => void,
clickAllowed: () => boolean,
): ReactNode {
const previews = foldPreviews(node.previews)
return (
<Card
key={node.id}
data-graph-node={node.id}
role="button"
tabIndex={-1}
aria-label={`${t('tasksFoldCompleted', { count: node.count })} · ${t('tasksFoldExpand')}`}
className={cn(NODE_CARD, 'border-dashed text-muted-foreground')}
style={style}
onClick={() => {
if (!clickAllowed()) return
onToggleFold()
}}
>
<span className="flex min-w-0 items-center gap-1">
<span className="flex flex-none" aria-hidden="true"><FoldGlyph /></span>
<span className={NODE_TITLE}>{t('tasksFoldCompleted', { count: node.count })}</span>
</span>
<CardLine className={NODE_META} label={previews}>
{`${t('tasksFoldExpand')} · ${previews}`}
</CardLine>
</Card>
)
}