revert(tasks-ui): drop the shadcn/Tailwind layer and return to the plugin's own UI system

shadcn's defaults are built for a standalone app: 14px body, 36px controls,
solid primary fills and elevation read as a foreign surface next to this
plugin's own tabs (files, changes, side chat, settings), which are 12/11/10px
type, 28px controls, hairline borders and flat panels. Retuning the layer did
not converge, so the layer goes.

- Removed: src/client/ui/** (21 vendored components), components.json,
  scripts/ui-css.mjs, the Tailwind/PostCSS compile step in tsdown, and the deps
  tailwindcss / @tailwindcss/postcss / postcss / radix-ui /
  class-variance-authority / tailwind-merge. (diff and simple-icons stay: they
  patch undeclared bare imports of the host primitives, unrelated to shadcn.)
- The seven page surfaces are back on the plugin's own base: *.module.css with
  --dsw-* tokens plus the host primitives (Button/Menu/Modal/Pill/Tag/Switch/
  Input/Tooltip/StateDot/MarkdownText). Behaviour, data-* hooks, aria labels,
  i18n keys and the AnchoredPopover geometry/drag/dismiss contract are
  unchanged; the non-styling fixes landed during the shadcn era (model id
  dedupe, task-window CAS, drawer arming, popover dragging, the chunk packaging
  glob) were re-applied and are covered by the existing specs.
- The tasks page returns to the core bundle: `tasks` left CHUNK_NAMES /
  CHUNKS / ChunkName, src/client/chunks/tasks.tsx is gone and tabs.tsx imports
  SubagentView directly.
- The reverted stylesheet was retuned to the plugin's measured vocabulary
  (12/11/10px, 28px controls, border-l2 with border-l1 hairlines, hover fills,
  no static shadows, --dsw-shadow-lv2/3 on floating layers only) and verified
  against a 70-assertion real-browser contract; the harness's own two
  measurement defects were fixed with a falsification test proving the
  assertions still catch injected regressions (56/70, 14 FAIL).
- Tests: ui-foundation / ui-bundle / ui-shadows (the shadcn guards) deleted,
  theme.spec scoped back to the plugin's own stylesheets, bundle-route and
  manifest-consistency chunk lists back to four. Docs: AGENTS.md and the plugin
  guide drop the Tailwind/shadcn guidance; the design doc marks those sections
  superseded and records why the layer was removed.

Verified: typecheck/lint clean; 133 files / 1410 tests; core bundle 987.3 KiB
with no radix-ui/--tw-/tailwind strings and no client-tasks.js; mount e2e 7/7;
20 real-browser screenshots at 360/720 for graph, tree, board, task window,
drawer (including armed), both popovers and the 77-node fixture.
This commit is contained in:
Menghuan1918
2026-09-27 22:18:19 +08:00
parent e500a3d2b9
commit a71d158f9a
47 changed files with 2373 additions and 4107 deletions
+173 -256
View File
@@ -13,23 +13,10 @@
* 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).
* Cards: the page sheet's node recipe with a TWO-line clamped title (the 68px
* card reserve in tasks-graph-layout.ts is sized for that second line) 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,
@@ -40,64 +27,45 @@ import {
type PointerEvent as ReactPointerEvent,
type ReactNode,
} from 'react'
import clsx from 'clsx'
import {
IconChecklistOutline14, IconFullscreenOutline16, IconTreeCorner8x10, StateDot,
Button, IconChecklistOutline14, IconFullscreenOutline16, IconLoadingOutline16, 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,
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'
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
/** 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. */
/* 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. */
/** 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.
*
* Every class here is stock: `Card` already carries `border` (hairline in
* `--border`) and `text-card-foreground`, so this only tightens the padding,
* drops the stock `gap-6` (the graph's three lines have their own `mt-0.5`)
* and sets the canvas elevation. The border ink is never re-declared — the
* lead node's stronger rung is the one exception, below.
* Card line 1 of an agent / run node: the page sheet's 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 `GRAPH_NODE_H` in
* tasks-graph-layout.ts reserves the card height for that second line. The
* untruncated name stays in the `title` attribute.
*/
const NODE_CARD = 'absolute cursor-pointer gap-0 overflow-hidden rounded-lg 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'
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)
/** One phase frame of a run node (the dashed box behind its members). */
interface PhaseFrame {
@@ -121,61 +89,12 @@ export interface TasksGraphProps {
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). */
/** 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
}
/**
* 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'
@@ -185,13 +104,16 @@ export function ViewModeToggle(props: {
const toTree = mode === 'graph'
const label = toTree ? t('tasksViewTree') : t('tasksViewGraph')
return (
<ControlButton
label={t(toTree ? 'tasksViewSwitchToTree' : 'tasksViewSwitchToGraph')}
<Button
variant="ghost"
size="sm"
icon={toTree ? <IconTreeCorner8x10 /> : <WorkflowGlyph size={13} />}
aria-label={t(toTree ? 'tasksViewSwitchToTree' : 'tasksViewSwitchToGraph')}
title={t(toTree ? 'tasksViewSwitchToTree' : 'tasksViewSwitchToGraph')}
onClick={() => { onModeChange(toTree ? 'tree' : 'graph') }}
>
{toTree ? <IconTreeCorner8x10 /> : <WorkflowGlyph />}
{label}
</ControlButton>
</Button>
)
}
@@ -199,13 +121,16 @@ export function ViewModeToggle(props: {
export function FoldToggleButton(props: { folded: boolean; onToggleFold(): void }): ReactNode {
const { folded, onToggleFold } = props
return (
<ControlButton
label={t(folded ? 'tasksFoldExpand' : 'tasksFoldCollapse')}
pressed={folded}
<Button
variant="ghost"
size="sm"
className={clsx(css.controlBtn, folded && css.controlBtnActive)}
icon={<IconChecklistOutline14 size={13} />}
aria-pressed={folded}
aria-label={t(folded ? 'tasksFoldExpand' : 'tasksFoldCollapse')}
title={t(folded ? 'tasksFoldExpand' : 'tasksFoldCollapse')}
onClick={onToggleFold}
>
<IconChecklistOutline14 />
</ControlButton>
/>
)
}
@@ -380,102 +305,114 @@ export function TasksGraph(props: TasksGraphProps): ReactNode {
}
return (
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
<div className={css.graphView}>
<div
ref={containerRef}
className={cn(css.canvas, dragging && css.canvasDragging)}
className={clsx(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-10 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 className="size-3" />
</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)
})}
{/* 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
as a bare grid). `pointer-events: none` keeps the canvas's pan /
zoom / node clicks fully live underneath. */}
{loading === true && nodes.length === 0 && (
<div className={canvasCss.loading}>
<span className={canvasCss.loadingGlyph} aria-hidden="true"><IconLoadingOutline16 size={12} /></span>
{t('loading')}
</div>
</TooltipProvider>
<div className="absolute right-2.5 bottom-2.5 z-10 flex flex-row items-center gap-0.5" data-graph-controls>
)}
<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={clsx(
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={css.phaseFrame}
style={{ left: frame.x, top: frame.y, width: frame.w, height: frame.h }}
>
<span className={css.phaseLabel}>{frame.title ?? t('workflowPhaseUnnamed')}</span>
</div>
))}
{/* KEYBOARD ACCESS (deliberate): 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 "switch to
tree" toggle is a real <button> (Tab-reachable, Enter/Space-
activatable), and tree mode provides the full Arrow/Home/End/Enter
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>
<div className={css.controls} 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"
<Button
variant="ghost"
size="sm"
className={css.controlBtn}
aria-label={t('tasksZoomOut')}
title={t('tasksZoomOut')}
onClick={() => { zoomBy(1 / 1.2) }}
>
{Math.round(tf.k * 100)}%
</span>
<ControlButton label={t('tasksZoomIn')} onClick={() => { zoomBy(1.2) }}>+</ControlButton>
<ControlButton label={t('tasksZoomFit')} onClick={refit}><IconFullscreenOutline16 /></ControlButton>
−
</Button>
<span className={css.controlZoomLevel} aria-hidden="true">{Math.round(tf.k * 100)}%</span>
<Button
variant="ghost"
size="sm"
className={css.controlBtn}
aria-label={t('tasksZoomIn')}
title={t('tasksZoomIn')}
onClick={() => { zoomBy(1.2) }}
>
+
</Button>
<Button
variant="ghost"
size="sm"
className={css.controlBtn}
icon={<IconFullscreenOutline16 size={13} />}
aria-label={t('tasksZoomFit')}
title={t('tasksZoomFit')}
onClick={refit}
/>
</div>
</div>
</div>
@@ -490,22 +427,20 @@ function renderAgentNode(
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
<div
key={node.id}
data-graph-node={node.id}
role="button"
tabIndex={-1}
aria-label={`${node.label} ${meta}`}
aria-label={`${node.label} ${agentMeta(node)}`}
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,
className={clsx(
css.node,
node.team?.role === 'teammate' && css.nodeTeam,
node.current && css.nodeCurrent,
(node.state === 'done' || node.state === 'error') && !node.current && css.nodeSettled,
node.state === 'error' && css.nodeError,
)}
style={style}
onClick={(event) => {
@@ -513,32 +448,15 @@ function renderAgentNode(
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 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>
{/* 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}
/>
<span className={css.nodeMeta} title={agentMeta(node)}>{agentMeta(node)}</span>
{node.state === 'running' && <LiveLine live={node.live} />}
<TaskLine tasks={node.tasks} onOpenTask={onOpenTask} />
</Card>
</div>
)
}
@@ -549,61 +467,60 @@ function renderWorkflowNode(
onWorkflowInfo: (node: TasksWorkflowNode, anchor: HTMLElement) => void,
clickAllowed: () => boolean,
): ReactNode {
const meta = workflowMeta(node)
const settled = node.run.status !== 'running'
return (
<Card
<div
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)}
aria-label={`${node.run.name} ${workflowMeta(node)}`}
className={clsx(css.node, css.nodeWorkflow, node.run.status !== 'running' && css.nodeSettled)}
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 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>
<CardLine className={cn(NODE_META, settled && 'text-foreground-3')} label={meta} />
</Card>
<span className={css.nodeMeta}>{workflowMeta(node)}</span>
</div>
)
}
/** One fold aggregate node: the settled leaves as a dashed card. */
/** One fold aggregate node. */
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
<div
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')}
className={clsx(css.node, css.nodeFold)}
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 className={css.nodeHeader}>
<span className={css.nodeGlyph} aria-hidden="true"><FoldGlyph /></span>
<span className={NODE_TITLE_PLAIN}>
{t('tasksFoldCompleted', { count: node.count })}
</span>
</span>
<CardLine className={NODE_META} label={previews}>
{`${t('tasksFoldExpand')} · ${previews}`}
</CardLine>
</Card>
<span className={css.nodeMeta} title={foldPreviews(node.previews)}>
{`${t('tasksFoldExpand')} · ${foldPreviews(node.previews)}`}
</span>
</div>
)
}