Files
apes-sidebar/src/client/TasksTree.tsx
T
Menghuan1918 6448d8bad9 feat(client): workflow-graph Tasks page with teams board and jobs drawer
The Subagent page becomes the Tasks page per the approved Variant-D design:

- tasks-model.ts feeds BOTH modes from one pre-order node list: graph
  canvas (TasksGraph: layered nodes over bezier edges, drag-pan,
  wheel-zoom-to-cursor, phase frames, bottom-right control cluster) and
  the classic tree (TasksTree: keyboard navigable) — the view toggle and
  fold toggle stay reachable in both modes.
- Workflow runs (workflows.list) hang under their origin agent; member
  agents re-parent under the run node, missing ones synthesize from run
  data; completed leaf agents fold into per-parent aggregate nodes
  (click or cluster toggle to expand/re-collapse).
- Agent Teams (experimental): roster enriches nodes; a header chip opens
  the shared task board popover with basic CAS operations (complete /
  reopen / delete / reassign / create / edit); no experimental layer →
  the whole block hides (structural degradation).
- Background jobs move into a bottom drawer that auto-collapses at 8+
  agents (manual toggle wins); job output opens as an anchored popover
  (event replay, never the model's cursor); two-click kill preserved.
- Live activity lines render as glyph + tool + args (tool-icons map onto
  host primitive icons).
- Node click jumps to the transcript; ⓘ opens the detail popover;
  popovers follow the selection-popup dismissal contract (Escape /
  outside click / anchor off-screen via IntersectionObserver).
- tasksViewMode pref (graph default) with a select row in settings;
  58 new copy keys across zh/en/ja + 18 third-language dictionaries;
  dead CSS from the old tree/dock pruned.
- Tests: 6 new page-interaction specs (auto-collapse, both-mode toggle,
  jump, fold, popover); jobs-view suite re-pointed at the popover;
  prefs/builtins/smoke lock tests updated for the new pref.
2026-09-27 22:18:14 +08:00

217 lines
8.2 KiB
TypeScript

/**
* The tree mode of the Tasks page: the SAME unified model rendered as a
* classic indentation tree (keyboard navigable, the official catalog's
* arrow-key recipe). Fold aggregates are rows; workflow runs are group rows
* whose members indent one level deeper. The control cluster (view toggle +
* fold toggle) stays visible bottom-right — the mockup's lesson.
*/
import { useCallback, useMemo, useRef, type KeyboardEvent, type ReactNode } from 'react'
import clsx from 'clsx'
import { StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import type { TasksAgentNode, TasksNode, TasksWorkflowNode } from './tasks-model.ts'
import { agentSecondary, foldPreviews, LiveLine, nodeDotState } from './tasks-shared.tsx'
import { FoldToggleButton, ViewModeToggle } from './TasksGraph.tsx'
import { t, type CopyKey } from './locales.ts'
import css from './tasks-graph.module.css'
import legacy from './SubagentView.module.css'
/** Pixels of indentation per depth level (the classic tree's rhythm). */
const INDENT_PX = 18
/** 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'
}
}
export interface TasksTreeProps {
nodes: readonly TasksNode[]
folded: boolean
onActivate(node: TasksAgentNode): void
onNodeInfo(node: TasksAgentNode, anchor: HTMLElement): void
onWorkflowInfo(node: TasksWorkflowNode, anchor: HTMLElement): void
onToggleFold(): void
mode: 'graph' | 'tree'
onModeChange(mode: 'graph' | 'tree'): void
/** Fallback loading rows while the root catalog hydrates. */
loading?: boolean
}
export function TasksTree(props: TasksTreeProps): ReactNode {
const {
nodes, folded, onActivate, onNodeInfo, onWorkflowInfo, onToggleFold, mode, onModeChange, loading,
} = props
const bodyRef = useRef<HTMLDivElement>(null)
/** Depth of every node (roots at 0), derived from the parent chain. */
const depthById = useMemo(() => {
const byId = new Map(nodes.map(node => [node.id, node]))
const depths = new Map<string, number>()
const depthOf = (node: TasksNode, guard: number): number => {
const known = depths.get(node.id)
if (known !== undefined) return known
if (node.parentId === undefined || guard > 64) {
depths.set(node.id, 0)
return 0
}
const parent = byId.get(node.parentId)
const depth = parent === undefined ? 0 : depthOf(parent, guard + 1) + 1
depths.set(node.id, depth)
return depth
}
for (const node of nodes) depthOf(node, 0)
return depths
}, [nodes])
/** Arrow-key navigation over the visible rows (official catalog recipe). */
const focusAt = useCallback((index: number): void => {
const items = bodyRef.current?.querySelectorAll<HTMLElement>('[data-tasks-row]') ?? []
if (items.length === 0) return
items[(index + items.length) % items.length]?.focus()
}, [])
const onTreeKeyDown = useCallback((event: KeyboardEvent<HTMLDivElement>): void => {
const items = bodyRef.current?.querySelectorAll<HTMLElement>('[data-tasks-row]') ?? []
const index = Array.prototype.indexOf.call(items, document.activeElement)
if (event.key === 'ArrowDown') {
event.preventDefault()
focusAt(index + 1)
} else if (event.key === 'ArrowUp') {
event.preventDefault()
focusAt(index < 0 ? items.length - 1 : index - 1)
} else if (event.key === 'Home') {
event.preventDefault()
focusAt(0)
} else if (event.key === 'End') {
event.preventDefault()
focusAt(items.length - 1)
}
}, [focusAt])
const activateOnKey = (event: KeyboardEvent, action: () => void): void => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
event.stopPropagation()
action()
}
}
return (
<div className={css.graphView}>
<div
ref={bodyRef}
className={css.treeView}
role="tree"
aria-label={t('subagent')}
onKeyDown={onTreeKeyDown}
>
{loading === true && (
<div className={legacy.subagentEmpty}>{t('loading')}</div>
)}
{nodes.map((node) => {
const depth = depthById.get(node.id) ?? 0
const indent = { marginLeft: depth * INDENT_PX }
if (node.kind === 'fold') {
return (
<div
key={node.id}
data-tasks-row
role="treeitem"
tabIndex={0}
aria-level={depth + 1}
aria-label={t('tasksFoldExpand')}
className={clsx(css.treeRow, css.treeFold)}
style={indent}
onClick={onToggleFold}
onKeyDown={(event) => { activateOnKey(event, onToggleFold) }}
>
<span className={css.treeContent}>
<span className={css.nodeTitle}>{t('tasksFoldCompleted', { count: node.count })}</span>
<span className={css.nodeSecondary}>{foldPreviews(node.previews)}</span>
</span>
</div>
)
}
if (node.kind === 'workflow') {
const done = node.run.phases.reduce(
(sum, phase) => sum + phase.members.filter(member => member.outcome !== undefined).length,
0,
)
const total = node.run.phases.reduce((sum, phase) => sum + phase.members.length, 0)
return (
<div
key={node.id}
data-tasks-row
role="treeitem"
tabIndex={0}
aria-level={depth + 1}
aria-expanded="true"
aria-label={node.run.name}
className={css.treeRow}
style={indent}
onClick={(event) => { onWorkflowInfo(node, event.currentTarget) }}
onKeyDown={(event) => {
activateOnKey(event, () => { onWorkflowInfo(node, event.currentTarget as HTMLElement) })
}}
>
<span aria-hidden="true">▶</span>
<span className={css.treeContent}>
<span className={css.nodeTitle}>{node.run.name}</span>
<span className={css.nodeSecondary}>
{`${t(workflowStatusKey(node.run.status))} · ${t('workflowMembers', { done, total })}`}
</span>
</span>
</div>
)
}
return (
<div
key={node.id}
data-tasks-row
role="treeitem"
tabIndex={0}
aria-level={depth + 1}
aria-label={`${node.label} ${agentSecondary(node)}`}
aria-current={node.current ? 'true' : undefined}
className={clsx(
css.treeRow,
node.current && css.treeRowActive,
node.state === 'done' && css.treeRowDone,
)}
style={indent}
onClick={() => { onActivate(node) }}
onKeyDown={(event) => { activateOnKey(event, () => { onActivate(node) }) }}
>
<StateDot state={nodeDotState(node.state)} className={legacy.subagentDot} />
<span className={css.treeContent}>
<span className={css.nodeTitle}>{node.label}</span>
<span className={css.nodeSecondary}>{agentSecondary(node)}</span>
{node.state === 'running' && <LiveLine live={node.live} />}
</span>
<button
type="button"
className={css.treeInfo}
aria-label={t('tasksNodeState')}
title={t('tasksNodeState')}
onClick={(event) => {
event.stopPropagation()
onNodeInfo(node, event.currentTarget)
}}
>
ⓘ
</button>
</div>
)
})}
</div>
<div className={css.controls}>
<ViewModeToggle mode={mode} onModeChange={onModeChange} />
<FoldToggleButton folded={folded} onToggleFold={onToggleFold} />
</div>
</div>
)
}