fix(tasks-ui): postmodern chrome, working clicks, adaptive layout, lean cards
Review feedback from the live 3384 install, fixed at the root: - COLORS: the new stylesheet used var(--dsw-alias-accent) in 17 places — a token the DSH theme does NOT define, so every declaration was dropped (older files only survived because they carry fallbacks); container edges used border-l1 (4% black, invisible). Accent now rides --dsw-alias-state-business-primary, container edges --dsw-alias-border-l4. - CLICKS: the canvas called setPointerCapture on pointerdown, which retargets the derived click to the container and killed every node (and ⓘ) interaction; jsdom cannot see this, a real browser can. Capture is gone and panning only starts from the background. - LAYOUT: fit gave up silently at zero container size and never retried, centered only horizontally, and capped at 100% while a five-child level scaled to 35%. Now: ResizeObserver + first non-zero fit, both axes centered, a 0.78 readability floor, and layoutTasksGraphForWidth picks the flattest sibling arrangement that still meets that floor. - CARDS: title (one line) + mono meta + live line only; display title, team role, full model id and latest text moved into the ⓘ popover. - PANEL: metrics follow the approved 360x660 mockup (132x46 cards, 112 row stride), explicit line-heights, user-select:none, horizontal control cluster with a labelled mode button, always-visible team board strip. - Tokens for two new popover headings across all 20 dictionaries. Tests: 3 new page-interaction specs (click after background pointerdown, node gestures never pan, team board visible without a click) and a rewritten 9-case layout suite (band wrap, row reservation, width solver, reserved live row). Full suite 1400 passed / 133 files.
This commit is contained in:
+163
-127
@@ -1,18 +1,21 @@
|
||||
/**
|
||||
* The workflow-graph mode of the Tasks page: the approved Variant D canvas —
|
||||
* layered agent/workflow nodes over bezier edges, pointer-drag pan,
|
||||
* wheel-zoom-to-cursor, per-phase dashed frames, fold aggregate nodes, and a
|
||||
* bottom-right control cluster (view toggle / zoom out / percentage / zoom
|
||||
* in / fit / fold toggle). The cluster lives OUTSIDE the scroll surface so
|
||||
* the view toggle stays reachable in both modes (a lesson of the mockup).
|
||||
* 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.
|
||||
*
|
||||
* The layout comes from the pure `layoutTasksGraph`; this component owns
|
||||
* only the transform state and DOM wiring.
|
||||
* 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,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
@@ -20,43 +23,25 @@ import {
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconChevronUpOutline14, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
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_H, GRAPH_NODE_W, layoutTasksGraph, type GraphBox } from './tasks-graph-layout.ts'
|
||||
import { agentSecondary, foldPreviews, LiveLine, nodeDotState } from './tasks-shared.tsx'
|
||||
import { t, type CopyKey } from './locales.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.15
|
||||
const ZOOM_MAX = 2.5
|
||||
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
|
||||
|
||||
/** The workflow run status label key. */
|
||||
function workflowStatusKey(status: TasksWorkflowNode['run']['status']): CopyKey {
|
||||
switch (status) {
|
||||
case 'running': return 'workflowRunning'
|
||||
case 'completed': return 'workflowCompleted'
|
||||
case 'cancelled': return 'workflowCancelled'
|
||||
case 'failed': return 'workflowFailed'
|
||||
}
|
||||
}
|
||||
|
||||
/** The member tally of a run (`done` = every member with an outcome). */
|
||||
function memberTally(run: TasksWorkflowNode['run']): { done: number; total: number } {
|
||||
let done = 0
|
||||
let total = 0
|
||||
for (const phase of run.phases) {
|
||||
for (const member of phase.members) {
|
||||
total += 1
|
||||
if (member.outcome !== undefined) done += 1
|
||||
}
|
||||
}
|
||||
return { done, total }
|
||||
}
|
||||
|
||||
/** One phase frame of a run node (the dashed box behind its members). */
|
||||
interface PhaseFrame {
|
||||
key: string
|
||||
@@ -70,7 +55,7 @@ interface PhaseFrame {
|
||||
export interface TasksGraphProps {
|
||||
nodes: readonly TasksNode[]
|
||||
folded: boolean
|
||||
/** Re-fit trigger: the topology root id (a tree switch re-centers). */
|
||||
/** Re-fit trigger: the topology root id (a reroot re-centers). */
|
||||
rootId: string | undefined
|
||||
onActivate(node: TasksAgentNode): void
|
||||
onNodeInfo(node: TasksAgentNode, anchor: HTMLElement): void
|
||||
@@ -80,44 +65,40 @@ export interface TasksGraphProps {
|
||||
onModeChange(mode: 'graph' | 'tree'): void
|
||||
}
|
||||
|
||||
/** The shared view-mode toggle button (also used by the tree mode). */
|
||||
/** 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 (
|
||||
<button
|
||||
type="button"
|
||||
className={css.controlBtn}
|
||||
className={clsx(css.controlBtn, css.controlMode)}
|
||||
aria-label={t(toTree ? 'tasksViewSwitchToTree' : 'tasksViewSwitchToGraph')}
|
||||
title={t(toTree ? 'tasksViewSwitchToTree' : 'tasksViewSwitchToGraph')}
|
||||
onClick={() => { onModeChange(toTree ? 'tree' : 'graph') }}
|
||||
>
|
||||
{toTree ? '☰' : '⌗'}
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/** The fold toggle button (shared by both modes). */
|
||||
/** The fold toggle (expand / re-collapse the settled aggregates). */
|
||||
export function FoldToggleButton(props: { folded: boolean; onToggleFold(): void }): ReactNode {
|
||||
const { folded, onToggleFold } = props
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.controlBtn, folded && css.controlBtnActive)}
|
||||
className={css.controlBtn}
|
||||
aria-pressed={folded}
|
||||
aria-label={t(folded ? 'tasksFoldExpand' : 'tasksFoldCollapse')}
|
||||
title={t(folded ? 'tasksFoldExpand' : 'tasksFoldCollapse')}
|
||||
onClick={onToggleFold}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{ display: 'inline-flex', transform: folded ? 'rotate(90deg)' : 'rotate(180deg)' }}
|
||||
>
|
||||
<IconChevronUpOutline14 size={12} />
|
||||
</span>
|
||||
{folded ? '⇪' : '⇩'}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -129,25 +110,61 @@ export function TasksGraph(props: TasksGraphProps): ReactNode {
|
||||
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
|
||||
|
||||
const layout = useMemo(() => layoutTasksGraph(nodes), [nodes])
|
||||
/** 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])
|
||||
|
||||
/** Fit the whole canvas into the container (initial mount + ⌂ + reroot). */
|
||||
/** 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.min((cw - 40) / layout.width, (ch - 40) / layout.height, 1)
|
||||
setTf({ x: (cw - layout.width * k) / 2, y: 12, k })
|
||||
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])
|
||||
|
||||
useLayoutEffect(() => { fit() }, [fit, rootId])
|
||||
/** 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(() => {
|
||||
@@ -161,6 +178,7 @@ export function TasksGraph(props: TasksGraphProps): ReactNode {
|
||||
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),
|
||||
@@ -176,36 +194,46 @@ export function TasksGraph(props: TasksGraphProps): ReactNode {
|
||||
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: pointer capture on the container, any start point pans.
|
||||
/**
|
||||
* 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 target = event.currentTarget
|
||||
target.setPointerCapture(event.pointerId)
|
||||
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 }))
|
||||
if (travelRef.current > CLICK_TOLERANCE_PX) setDragging(true)
|
||||
}
|
||||
const onUp = (): void => {
|
||||
target.removeEventListener('pointermove', onMove)
|
||||
target.removeEventListener('pointerup', onUp)
|
||||
target.removeEventListener('pointercancel', onUp)
|
||||
window.removeEventListener('pointermove', onMove)
|
||||
window.removeEventListener('pointerup', onUp)
|
||||
window.removeEventListener('pointercancel', onUp)
|
||||
setDragging(false)
|
||||
}
|
||||
target.addEventListener('pointermove', onMove)
|
||||
target.addEventListener('pointerup', onUp)
|
||||
target.addEventListener('pointercancel', onUp)
|
||||
window.addEventListener('pointermove', onMove)
|
||||
window.addEventListener('pointerup', onUp)
|
||||
window.addEventListener('pointercancel', onUp)
|
||||
}, [])
|
||||
|
||||
/** Suppress node clicks that ended a drag gesture. */
|
||||
@@ -226,14 +254,10 @@ export function TasksGraph(props: TasksGraphProps): ReactNode {
|
||||
}
|
||||
if (boxes.length === 0) return
|
||||
const x1 = Math.min(...boxes.map(box => box.x)) - 12
|
||||
const y1 = Math.min(...boxes.map(box => box.y)) - 22
|
||||
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,
|
||||
})
|
||||
frames.push({ key: `${runNode.id}:${phaseIndex}`, title: phase.title, x: x1, y: y1, w: x2 - x1, h: y2 - y1 })
|
||||
})
|
||||
}
|
||||
return frames
|
||||
@@ -245,8 +269,8 @@ export function TasksGraph(props: TasksGraphProps): ReactNode {
|
||||
const y1 = from.y + from.h
|
||||
const x2 = to.x + to.w / 2
|
||||
const y2 = to.y
|
||||
const midY = (y1 + y2) / 2
|
||||
return `M ${x1} ${y1} C ${x1} ${midY} ${x2} ${midY} ${x2} ${y2}`
|
||||
const dy = Math.max(24, (y2 - y1) / 2)
|
||||
return `M ${x1} ${y1} C ${x1} ${y1 + dy} ${x2} ${y2 - dy} ${x2} ${y2}`
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -277,7 +301,8 @@ export function TasksGraph(props: TasksGraphProps): ReactNode {
|
||||
key={`${edge.from}->${edge.to}`}
|
||||
className={clsx(
|
||||
css.edge,
|
||||
(edge.kind === 'team' || edge.kind === 'workflow') && css.edgeTeam,
|
||||
edge.kind === 'team' && css.edgeTeam,
|
||||
edge.kind === 'workflow' && css.edgeWorkflow,
|
||||
target?.kind === 'fold' && css.edgeFold,
|
||||
)}
|
||||
d={edgePath(from, to)}
|
||||
@@ -305,43 +330,44 @@ export function TasksGraph(props: TasksGraphProps): ReactNode {
|
||||
return renderAgentNode(node, style, onActivate, onNodeInfo, clickAllowed)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className={css.controls}>
|
||||
<ViewModeToggle mode={mode} onModeChange={onModeChange} />
|
||||
<FoldToggleButton folded={folded} onToggleFold={onToggleFold} />
|
||||
<button
|
||||
type="button"
|
||||
className={css.controlBtn}
|
||||
aria-label={t('tasksZoomOut')}
|
||||
title={t('tasksZoomOut')}
|
||||
onClick={() => { zoomBy(1 / 1.25) }}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.controlBtn, css.controlPct)}
|
||||
aria-label={t('tasksZoomFit')}
|
||||
title={t('tasksZoomFit')}
|
||||
onClick={fit}
|
||||
>
|
||||
{Math.round(tf.k * 100)}%
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.controlBtn}
|
||||
aria-label={t('tasksZoomIn')}
|
||||
title={t('tasksZoomIn')}
|
||||
onClick={() => { zoomBy(1.25) }}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<div className={css.controls} data-graph-controls>
|
||||
<ViewModeToggle mode={mode} onModeChange={onModeChange} />
|
||||
<FoldToggleButton folded={folded} onToggleFold={onToggleFold} />
|
||||
<button
|
||||
type="button"
|
||||
className={css.controlBtn}
|
||||
aria-label={t('tasksZoomOut')}
|
||||
title={t('tasksZoomOut')}
|
||||
onClick={() => { zoomBy(1 / 1.2) }}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className={css.controlZoomLevel} aria-hidden="true">{Math.round(tf.k * 100)}%</span>
|
||||
<button
|
||||
type="button"
|
||||
className={css.controlBtn}
|
||||
aria-label={t('tasksZoomIn')}
|
||||
title={t('tasksZoomIn')}
|
||||
onClick={() => { zoomBy(1.2) }}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.controlBtn}
|
||||
aria-label={t('tasksZoomFit')}
|
||||
title={t('tasksZoomFit')}
|
||||
onClick={refit}
|
||||
>
|
||||
⌂
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** One agent node card. */
|
||||
/** 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 },
|
||||
@@ -352,33 +378,37 @@ function renderAgentNode(
|
||||
return (
|
||||
<div
|
||||
key={node.id}
|
||||
data-graph-node={node.id}
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
aria-label={`${node.label} ${agentSecondary(node)}`}
|
||||
aria-label={`${node.label} ${agentMeta(node)}`}
|
||||
aria-current={node.current ? 'true' : undefined}
|
||||
className={clsx(
|
||||
css.node,
|
||||
node.state === 'running' && css.nodeRunning,
|
||||
node.state === 'done' && css.nodeDone,
|
||||
node.state === 'error' && css.nodeError,
|
||||
node.parentId === undefined && css.nodeLead,
|
||||
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) => {
|
||||
onClick={() => {
|
||||
if (!clickAllowed()) return
|
||||
onActivate(node)
|
||||
void event
|
||||
}}
|
||||
>
|
||||
<span className={css.nodeHeader}>
|
||||
<StateDot state={nodeDotState(node.state)} size={8} className={css.nodeDot} />
|
||||
<span className={css.nodeTitle} title={node.label}>{node.label}</span>
|
||||
<StateDot state={nodeDotState(node.state)} size={6} className={css.nodeDot} />
|
||||
<span className={css.nodeTitle} title={node.label}>
|
||||
{node.team?.role === 'teammate' ? `◇ ${node.label}` : node.label}
|
||||
</span>
|
||||
</span>
|
||||
<span className={css.nodeSecondary} title={agentSecondary(node)}>{agentSecondary(node)}</span>
|
||||
<span className={css.nodeMeta} title={agentMeta(node)}>{agentMeta(node)}</span>
|
||||
{node.state === 'running' && <LiveLine live={node.live} />}
|
||||
<button
|
||||
type="button"
|
||||
className={css.nodeInfo}
|
||||
className={css.treeInfo}
|
||||
style={{ position: 'absolute', top: 3, right: 3 }}
|
||||
aria-label={t('tasksNodeState')}
|
||||
title={t('tasksNodeState')}
|
||||
onClick={(event) => {
|
||||
@@ -386,7 +416,7 @@ function renderAgentNode(
|
||||
onNodeInfo(node, event.currentTarget)
|
||||
}}
|
||||
>
|
||||
ⓘ
|
||||
i
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
@@ -399,14 +429,14 @@ function renderWorkflowNode(
|
||||
onWorkflowInfo: (node: TasksWorkflowNode, anchor: HTMLElement) => void,
|
||||
clickAllowed: () => boolean,
|
||||
): ReactNode {
|
||||
const tally = memberTally(node.run)
|
||||
return (
|
||||
<div
|
||||
key={node.id}
|
||||
data-graph-node={node.id}
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
aria-label={node.run.name}
|
||||
className={clsx(css.node, css.nodeWorkflow, node.run.status === 'running' && css.nodeRunning)}
|
||||
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
|
||||
@@ -414,12 +444,10 @@ function renderWorkflowNode(
|
||||
}}
|
||||
>
|
||||
<span className={css.nodeHeader}>
|
||||
<span aria-hidden="true">▶</span>
|
||||
<span className={css.nodeTitle} title={node.run.name}>{node.run.name}</span>
|
||||
</span>
|
||||
<span className={css.nodeSecondary}>
|
||||
{`${t(workflowStatusKey(node.run.status))} · ${t('workflowMembers', { done: tally.done, total: tally.total })}`}
|
||||
<StateDot state={node.run.status === 'running' ? 'ongoing' : 'done'} size={6} className={css.nodeDot} />
|
||||
<span className={css.nodeTitle} title={node.run.name}>{`▶ ${node.run.name}`}</span>
|
||||
</span>
|
||||
<span className={css.nodeMeta}>{workflowMeta(node)}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -434,6 +462,7 @@ function renderFoldNode(
|
||||
return (
|
||||
<div
|
||||
key={node.id}
|
||||
data-graph-node={node.id}
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
aria-label={`${t('tasksFoldCompleted', { count: node.count })} · ${t('tasksFoldExpand')}`}
|
||||
@@ -444,8 +473,15 @@ function renderFoldNode(
|
||||
onToggleFold()
|
||||
}}
|
||||
>
|
||||
<span className={css.nodeTitle}>{t('tasksFoldCompleted', { count: node.count })}</span>
|
||||
<span className={css.nodeSecondary}>{foldPreviews(node.previews)}</span>
|
||||
<span className={css.nodeHeader}>
|
||||
<StateDot state="done" size={6} className={css.nodeDot} />
|
||||
<span className={clsx(css.nodeTitle, css.nodeTitlePlain)}>
|
||||
{t('tasksFoldCompleted', { count: node.count })}
|
||||
</span>
|
||||
</span>
|
||||
<span className={css.nodeMeta} title={foldPreviews(node.previews)}>
|
||||
{`⇪ ${t('tasksFoldExpand')} · ${foldPreviews(node.previews)}`}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user