feat(tasks-ui): tasks on their owner nodes, host-primitive controls, icon glyphs, draggable output

Round-two review feedback, all three points:

- TASKS ON NODES: the model maps each shared task (ownerName -> member name
  -> member session id) onto its owner's node; graph cards and tree rows
  render a fourth line (icon + subject + status + '+N'), and the node detail
  popover lists every owned task.
- TEAM BOARD: member Pills double as the owner filter; each task row is a
  status dot + subject + owner + status Tag + ONE overflow Menu (complete /
  reopen / edit / reassign submenu / delete with a two-step arm). Creating
  and editing moved into a real Modal with labeled Inputs and owner Pills —
  the narrow strip is no longer where you type.
- HOST PRIMITIVES ONLY: the board and the jobs drawer now use Menu / Modal /
  Input / Button / Pill / Tag / Switch / StateDot; no native select or input
  remains (guarded by a spec).
- ICONS, NOT HAND-DRAWN GLYPHS: every text glyph (circle/diamond/triangle/
  check/chevrons/i/house) is replaced by a host icon — agent preset, user
  (teammate), branch (workflow run), checklist (fold + tasks), tree corner,
  fullscreen (fit), ellipsis (node details), copy/stop (jobs). The live line
  now shows the tool's own icon + name + args without the drawn box.
- JOBS UX: the output popover is DRAGGABLE (offset clamped to the viewport,
  double click re-anchors, controls do not start a drag), defaults to 380px,
  and gains copy-output, a follow-latest switch, kind/status Tags and a drag
  hint; the drawer highlights the open row.
- 19 new copy keys across zh/en/ja and the 18 third-language dictionaries.

Tests: 4 new specs (task rendered on its node, no native select/input +
menu-driven board, create-through-dialog posts the CAS-free create, job
popover drag moves the card). Full suite 1404 passed / 133 files.
This commit is contained in:
Menghuan1918
2026-09-27 22:18:19 +08:00
parent 85a19cecc3
commit e41f10afc9
32 changed files with 1257 additions and 382 deletions
+49 -39
View File
@@ -23,11 +23,17 @@ import {
type ReactNode,
} from 'react'
import clsx from 'clsx'
import { StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import {
Button, IconChecklistOutline14, IconEllipsisOutline16, 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_NODE_W, layoutTasksGraphForWidth, type GraphBox } from './tasks-graph-layout.ts'
import { agentMeta, foldPreviews, LiveLine, nodeDotState, workflowMeta } from './tasks-shared.tsx'
import {
AgentGlyph, agentMeta, FoldGlyph, foldPreviews, LiveLine, nodeDotState, TaskLine,
WorkflowGlyph, workflowMeta,
} from './tasks-shared.tsx'
import { t } from './locales.ts'
import css from './tasks-graph.module.css'
@@ -72,17 +78,19 @@ export function ViewModeToggle(props: {
}): ReactNode {
const { mode, onModeChange } = props
const toTree = mode === 'graph'
const label = toTree ? `▤ ${t('tasksViewTree')}` : `⌗ ${t('tasksViewGraph')}`
const label = toTree ? t('tasksViewTree') : t('tasksViewGraph')
return (
<button
type="button"
className={clsx(css.controlBtn, css.controlMode)}
<Button
variant="ghost"
size="sm"
className={css.controlMode}
icon={toTree ? <IconTreeCorner8x10 /> : <WorkflowGlyph size={13} />}
aria-label={t(toTree ? 'tasksViewSwitchToTree' : 'tasksViewSwitchToGraph')}
title={t(toTree ? 'tasksViewSwitchToTree' : 'tasksViewSwitchToGraph')}
onClick={() => { onModeChange(toTree ? 'tree' : 'graph') }}
>
{label}
</button>
</Button>
)
}
@@ -90,16 +98,16 @@ export function ViewModeToggle(props: {
export function FoldToggleButton(props: { folded: boolean; onToggleFold(): void }): ReactNode {
const { folded, onToggleFold } = props
return (
<button
type="button"
className={css.controlBtn}
<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}
>
{folded ? '⇪' : '⇩'}
</button>
/>
)
}
@@ -333,34 +341,36 @@ export function TasksGraph(props: TasksGraphProps): ReactNode {
<div className={css.controls} data-graph-controls>
<ViewModeToggle mode={mode} onModeChange={onModeChange} />
<FoldToggleButton folded={folded} onToggleFold={onToggleFold} />
<button
type="button"
<Button
variant="ghost"
size="sm"
className={css.controlBtn}
aria-label={t('tasksZoomOut')}
title={t('tasksZoomOut')}
onClick={() => { zoomBy(1 / 1.2) }}
>
−
</button>
</Button>
<span className={css.controlZoomLevel} aria-hidden="true">{Math.round(tf.k * 100)}%</span>
<button
type="button"
<Button
variant="ghost"
size="sm"
className={css.controlBtn}
aria-label={t('tasksZoomIn')}
title={t('tasksZoomIn')}
onClick={() => { zoomBy(1.2) }}
>
+
</button>
<button
type="button"
</Button>
<Button
variant="ghost"
size="sm"
className={css.controlBtn}
icon={<IconFullscreenOutline16 size={13} />}
aria-label={t('tasksZoomFit')}
title={t('tasksZoomFit')}
onClick={refit}
>
⌂
</button>
/>
</div>
</div>
</div>
@@ -399,25 +409,24 @@ function renderAgentNode(
>
<span className={css.nodeHeader}>
<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 className={css.nodeGlyph} aria-hidden="true"><AgentGlyph node={node} /></span>
<span className={css.nodeTitle} title={node.label}>{node.label}</span>
</span>
<span className={css.nodeMeta} title={agentMeta(node)}>{agentMeta(node)}</span>
{node.state === 'running' && <LiveLine live={node.live} />}
<button
type="button"
className={css.treeInfo}
style={{ position: 'absolute', top: 3, right: 3 }}
aria-label={t('tasksNodeState')}
title={t('tasksNodeState')}
<TaskLine tasks={node.tasks} />
<Button
variant="ghost"
size="sm"
className={css.nodeInfo}
icon={<IconEllipsisOutline16 size={12} />}
aria-label={t('tasksNodeDetail')}
title={t('tasksNodeDetail')}
onClick={(event) => {
event.stopPropagation()
onNodeInfo(node, event.currentTarget)
}}
>
i
</button>
/>
</div>
)
}
@@ -445,7 +454,8 @@ function renderWorkflowNode(
>
<span className={css.nodeHeader}>
<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 className={css.nodeGlyph} aria-hidden="true"><WorkflowGlyph /></span>
<span className={css.nodeTitle} title={node.run.name}>{node.run.name}</span>
</span>
<span className={css.nodeMeta}>{workflowMeta(node)}</span>
</div>
@@ -474,13 +484,13 @@ function renderFoldNode(
}}
>
<span className={css.nodeHeader}>
<StateDot state="done" size={6} className={css.nodeDot} />
<span className={css.nodeGlyph} aria-hidden="true"><FoldGlyph /></span>
<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)}`}
{`${t('tasksFoldExpand')} · ${foldPreviews(node.previews)}`}
</span>
</div>
)