/** * 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. */ import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent, type ReactNode, } from 'react' import clsx from 'clsx' import { 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_NODE_W, layoutTasksGraphForWidth, type GraphBox } from './tasks-graph-layout.ts' import { agentMeta, foldPreviews, LiveLine, nodeDotState, workflowMeta } from './tasks-shared.tsx' import { t } from './locales.ts' import css from './tasks-graph.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. */ const FIT_MIN_SCALE = 0.78 /** Drag-vs-click separation: pointer travel below this stays a click. */ const CLICK_TOLERANCE_PX = 4 /** 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 onActivate(node: TasksAgentNode): void onNodeInfo(node: TasksAgentNode, anchor: HTMLElement): void onWorkflowInfo(node: TasksWorkflowNode, anchor: HTMLElement): void onToggleFold(): void mode: 'graph' | 'tree' onModeChange(mode: 'graph' | 'tree'): void } /** 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 ( ) } /** The fold toggle (expand / re-collapse the settled aggregates). */ export function FoldToggleButton(props: { folded: boolean; onToggleFold(): void }): ReactNode { const { folded, onToggleFold } = props return ( ) } export function TasksGraph(props: TasksGraphProps): ReactNode { const { nodes, folded, rootId, onActivate, onNodeInfo, onWorkflowInfo, onToggleFold, mode, onModeChange } = props const containerRef = useRef(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, 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( 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): 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 (
{phaseFrames.map(frame => (
{frame.title ?? t('workflowPhaseUnnamed')}
))} {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, onActivate, onNodeInfo, clickAllowed) })}
) } /** 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 }, onActivate: (node: TasksAgentNode) => void, onNodeInfo: (node: TasksAgentNode, anchor: HTMLElement) => void, clickAllowed: () => boolean, ): ReactNode { return (
{ if (!clickAllowed()) return onActivate(node) }} > {node.team?.role === 'teammate' ? `◇ ${node.label}` : node.label} {agentMeta(node)} {node.state === 'running' && }
) } /** 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 { return (
{ if (!clickAllowed()) return onWorkflowInfo(node, event.currentTarget) }} > {`▶ ${node.run.name}`} {workflowMeta(node)}
) } /** One fold aggregate node. */ function renderFoldNode( node: TasksFoldNode, style: { left: number; top: number; width: number; minHeight: number }, onToggleFold: () => void, clickAllowed: () => boolean, ): ReactNode { return (
{ if (!clickAllowed()) return onToggleFold() }} > {t('tasksFoldCompleted', { count: node.count })} {`⇪ ${t('tasksFoldExpand')} · ${foldPreviews(node.previews)}`}
) }