/** * The workflow-graph mode of the Tasks page: layered agent / workflow nodes * over bezier edges on a dotted grid, drag-pan from the BACKGROUND only * (never from a node — capturing pointers on the container retargets the * derived click and silently swallows node clicks), wheel zoom to the cursor, * dashed phase frames, fold aggregate nodes, and the bottom-right horizontal * control cluster (view toggle / fold / 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. * * Cards: the node card recipe of tasks-graph.module.css with a TWO-line * clamped title (the card reserve in tasks-graph-layout.ts is sized for that * second line, plus one row per live / task line the card renders) and the * full name in the `title` attribute. While the root catalog hydrates, an * empty canvas shows the same hint as the tree (`loading`). */ import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent, type ReactNode, } from 'react' import clsx from 'clsx' import { Button, IconChevronDownOutlineRegular, IconChevronUpOutlineRegular, 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_H, GRAPH_NODE_W, GRAPH_PAD, layoutTasksGraphForWidth, type GraphBox, } from './tasks-graph-layout.ts' import { agentIdentity, agentMeta, foldBadgeCount, foldPreviews, foldTally, 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. 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.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 /** * 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. */ 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 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 /** 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 * TasksTree's loading row: the same copy key, the same "nothing to show * yet" condition). */ loading?: boolean } /** The shared view-mode toggle (glyph + label) both modes render in their * bottom-right control cluster. */ 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). */ /** * The cluster's fold switch. Its glyph names the action it will take, not the * state it is in: while the settled nodes are folded the button offers to * EXPAND (chevron down), and while they are open it offers to FOLD (chevron * up, the same collapse direction the cards' own bar buttons wear). The old * single checklist glyph read identically in both states, which is what the * reader reported as "折叠按钮和展开一样". */ export function FoldToggleButton(props: { folded: boolean; onToggleFold(): void }): ReactNode { const { folded, onToggleFold } = props return (