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.
217 lines
8.2 KiB
TypeScript
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>
|
|
)
|
|
}
|