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:
Menghuan1918
2026-09-27 22:18:19 +08:00
parent 9b6628602e
commit 490e96080a
33 changed files with 1744 additions and 1046 deletions
+163 -127
View File
@@ -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>
)
}