/**
* 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 (
{props.label}
)
}
/**
* 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 (
{props.children ?? props.label}{props.label}
)
}
/** 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 (
{ onModeChange(toTree ? 'tree' : 'graph') }}
>
{toTree ? : }
{label}
)
}
/** 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, onNodeInfo, onWorkflowInfo, onOpenTask, onToggleFold, mode, onModeChange, loading } = 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, 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): 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 (
{/* 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 && (
{/* The visible line carries the copy; the glyph is decoration. */}
{t('loading')}
)}
{phaseFrames.map(frame => (
{frame.title ?? t('workflowPhaseUnnamed')}
))}
{/* 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