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
@@ -84,6 +84,24 @@ client 半(src/client/)
6. **测试断言从 `container.textContent` 迁到 `document.body`**:输出浮窗 portal 到 body(#425 契约),jobs-view 套件相应更新。
7. **fold 聚合节点的 aria-label 带计数文本**:与控制条的 fold 切换按钮消歧(两者同文案会导致 a11y 选择器歧义)。
## 复审返工(2026-09-14 晚,真机截图反馈)
用户在 3384(`DSH_HOME=~/.dsh`,profile web)看到首版实现后给出四条反馈,逐条定位并修复:
| 反馈 | 根因 | 修法 |
|---|---|---|
| 「按钮和文本的颜色不对,不够后现代」 | 新 CSS **17 处裸用 `var(--dsw-alias-accent)`——该令牌在 DSH 主题里不存在**(旧文件的用法都带 fallback 才没暴露),声明被浏览器整条丢弃;容器边框用的 `border-l1` 只有 4% 黑,几乎不可见 | accent → `--dsw-alias-state-business-primary`;容器边 → `--dsw-alias-border-l4`(16%);整体改回 mockup 语言:ink 边框、3px 硬投影、mono 微型字 + 字距大写标签、点阵画布、横向缩放条 |
| 「点 ⓘ 没反应 / 图里点不动(树能点)」 | 画布 `pointerdown` 里对容器 `setPointerCapture`,捕获把派生 click 重定向到容器,节点永远收不到点击(jsdom 不模拟捕获语义,所以单测全绿——**只有真实浏览器能暴露**) | 去掉 capture,pan 仅从背景起手(`closest('[data-graph-node]'/'[data-graph-controls]')` 直接返回),监听挂 window |
| 「图很小 / 不在中间 / 任务板没显示」 | fit 在容器为 0 尺寸时静默放弃且不再重试;只居中横轴、上限 1.0;窄面板里 5 个兄弟节点挤成 770px 宽 → 缩到 35% | ResizeObserver + 首次非零尺寸补 fit、双轴居中、`FIT_MIN_SCALE=0.78` 可读性下限;**按容器宽度求解排布**(`layoutTasksGraphForWidth`:先取仍满足可读性预算的最宽排布,只有窄到 ≤1 列才允许 20% 横向溢出);任务板改为**常驻可见条**(不再藏在 chip 后) |
| 「卡片信息过多过杂,都被省略看不见」 | 卡片同时塞 displayTitle + 模式 + 状态 + 模型 + live 文本,132px 宽(窄面板的目标宽度)下全部省略号 | 卡片只留三层:标题(1 行)/ mono 元信息(模式或模型 · 状态)/ live 行(仅运行中);其余(会话标题、team 角色、模型全名、最新文本、跳转)进 ⓘ 浮窗 |
| 「非常窄,非常挤」 | 设计按宽画布做,未以原生右侧栏窄宽为目标 | 度量全部改按 mockup 的 360×660 基准(卡片 132×46、行距 112);行高/间距/字号显式声明(宿主 body 行高曾把行撑高);`user-select: none` 防拖拽选中文本 |
回归面:`tests/tasks-page.spec.tsx` 增 3 例(背景 pointerdown 后节点仍可激活、节点上的手势绝不启动 pan、团队任务板无需点击即常驻可见);`tests/tasks-graph-layout.spec.ts` 重写 9 例(band 换行、换行 band 不得压到兄弟子树行、宽度求解两段式、运行时预留 live 行)。
### 本地可视化自检 harness(未入库,`tmp-visual/`,git-excluded)
真实浏览器里的组件级回归无法靠 jsdom 覆盖(点击捕获、fit/居中、主题令牌解析都是浏览器行为)。harness 用 esbuild 把**真实组件** + mockup 形状的 fixture(走 `buildTasksModel`,因此折叠/重挂/富化都真实生效)打包进一个页面,注入从 `dsh-client-ui-theme` 抽出的令牌 CSS,`@deepseek-ai/dsh-client-ui-primitives` 别名到轻量桩(避免把 katex/shiki 资源拖进截图包),再由 Playwright 在 360px / 720px 两档宽度截图并断言:节点点击回调、ⓘ 回调、背景拖拽平移量。本次返工的四条反馈里有三条正是它先复现、修完再确认的。
## 验证
- `pnpm typecheck` / `pnpm vitest run`(1401:新增 fold/路由/模型/布局/页面交互 58 个用例)/ `pnpm build` 全绿。
+12 -11
View File
@@ -103,12 +103,17 @@ export function JobsDrawer(props: JobsDrawerProps): ReactNode {
onClick={() => { setManualOpen(!open) }}
>
<span>{t('jobs')}</span>
<span className={css.jobsBigNum}>{liveCount > 0 ? liveCount : rows.length}</span>
<span className={css.jobsDrawerCount}>{countLabel}</span>
{!open && !autoOpen && <span className={css.jobsDrawerAuto}>{t('jobsAutoCollapsed')}</span>}
<span className={clsx(css.jobsDrawerChevron, open && css.jobsDrawerChevronOpen)} aria-hidden="true">
<span className={clsx(css.jobsDrawerChev, open && css.jobsDrawerChevOpen)} aria-hidden="true">
<IconChevronUpOutline14 size={12} />
</span>
</button>
{!autoOpen && (
<div className={css.jobsAutoNote}>
{t('jobsAutoCollapsed')}
</div>
)}
{open && (
<div className={css.jobsDrawerBody}>
{rows.map((row) => {
@@ -135,14 +140,10 @@ export function JobsDrawer(props: JobsDrawerProps): ReactNode {
title={t('jobViewOutput')}
onClick={(event) => { onOpenOutput(row, event.currentTarget) }}
>
<StateDot state={jobDotState(job.status)} size={8} />
<span className={css.jobsContent}>
<span className={css.jobsLabelLine}>
<span className={css.jobsKind}>{job.kind}</span>
<span className={css.jobsLabel} title={job.label}>{job.label}</span>
</span>
<span className={css.jobsSecondary}>{secondary}</span>
</span>
<StateDot state={jobDotState(job.status)} size={6} />
<span className={css.jobsKind}>{job.kind}</span>
<span className={css.jobsLabel} title={job.label}>{job.label}</span>
<span className={css.jobsMeta}>{secondary}</span>
</button>
{job.status === 'running' && (
<button
@@ -157,7 +158,7 @@ export function JobsDrawer(props: JobsDrawerProps): ReactNode {
else setArmedId(job.id)
}}
>
{armed ? t('jobKillConfirm') : <IconStopOutline16 size={12} />}
{armed ? '!' : <IconStopOutline16 size={12} />}
</button>
)}
{killFailed && <span className={css.jobsKillError}>{t('jobKillError')}</span>}
+18 -11
View File
@@ -9,20 +9,24 @@
flex-direction: column;
}
/* The page header keeps the mockup's rhythm: a 13px semibold title, a mono
count, and a hairline underline that separates chrome from content. */
.subagentHeader {
flex: none;
display: flex;
align-items: center;
align-items: baseline;
gap: 8px;
height: 36px;
padding: 0 8px 0 12px;
padding: 10px 10px 9px 12px;
border-bottom: 1px solid var(--dsw-alias-border-l4);
}
.subagentTitle {
flex: 1;
min-width: 0;
font: var(--dsw-font-s-14);
color: var(--dsw-alias-label-secondary);
font-size: 13px;
line-height: 1.35;
font-weight: 600;
color: var(--dsw-alias-label-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -31,8 +35,9 @@
/* The descendant count badge ("3 · 1 运行中") beside the title. */
.subagentCount {
flex: none;
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-label-tertiary);
font-family: var(--ds-font-family-code);
font-size: 10px;
color: var(--dsw-alias-label-secondary);
}
.subagentRefresh {
@@ -40,16 +45,18 @@
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
width: 20px;
height: 20px;
padding: 0;
border: none;
border-radius: 6px;
border-radius: 3px;
background: transparent;
color: var(--dsw-alias-label-secondary);
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
.subagentRefresh:hover {
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-interactive-bg-hover);
}
+13 -24
View File
@@ -47,7 +47,8 @@ import { TasksGraph } from './TasksGraph.tsx'
import { TasksTree } from './TasksTree.tsx'
import { JobsDrawer, JobOutputPopoverContent } from './JobsDrawer.tsx'
import { AnchoredPopover } from './AnchoredPopover.tsx'
import { AgentNodePopover, TeamBoardPopover, WorkflowNodePopover } from './TasksPopovers.tsx'
import { AgentNodePopover, WorkflowNodePopover } from './TasksPopovers.tsx'
import { TeamBoard } from './TeamBoard.tsx'
import type { SidebarStore } from './state.ts'
import type { WorkflowRunView } from '../workflow-runs.ts'
import legacy from './SubagentView.module.css'
@@ -130,7 +131,6 @@ type PagePopover =
| { kind: 'node'; nodeId: string; anchor: HTMLElement }
| { kind: 'workflow'; nodeId: string; anchor: HTMLElement }
| { kind: 'job'; jobId: string; anchor: HTMLElement }
| { kind: 'team'; anchor: HTMLElement }
/**
* The sidebar's Tasks page.
@@ -182,6 +182,7 @@ export function SubagentView(props: {
const mode = modeOverride ?? prefsMode
const [folded, setFolded] = useState(true)
const [teamBoardCollapsed, setTeamBoardCollapsed] = useState(false)
const [popover, setPopover] = useState<PagePopover | null>(null)
const model = useMemo(
@@ -347,22 +348,11 @@ export function SubagentView(props: {
/** The current popover's resolved content (subjects re-resolve live). */
const popoverContent = ((): ReactNode => {
if (popover === null) return null
if (popover.kind === 'team' && teamView?.available === true && teamView.team !== null && rootId !== undefined) {
return (
<TeamBoardPopover
rootId={rootId}
members={teamView.team.members}
tasks={teamView.team.tasks}
onChanged={team.refresh}
/>
)
}
if (popover.kind === 'job') {
const row = jobRows.find(candidate => candidate.job.id === popover.jobId)
if (row === undefined) return null
return <JobOutputPopoverContent ownerSessionId={row.ownerSessionId} job={row.job} active={active} />
}
if (popover.kind === 'team') return null
const node = model.find(candidate => candidate.id === popover.nodeId)
if (node === undefined) return null
if (popover.kind === 'workflow' && node.kind === 'workflow') {
@@ -398,17 +388,6 @@ export function SubagentView(props: {
: ''}
</span>
{countLabel !== undefined && <span className={legacy.subagentCount}>{countLabel}</span>}
{teamView?.available === true && teamView.team !== null && (
<button
type="button"
className={css.teamChip}
onClick={(event) => {
setPopover(popover?.kind === 'team' ? null : { kind: 'team', anchor: event.currentTarget })
}}
>
{t('teamChip', { members: teamView.team.members.length, tasks: teamView.team.tasks.length })}
</button>
)}
<button
type="button"
className={legacy.subagentRefresh}
@@ -423,6 +402,16 @@ export function SubagentView(props: {
<IconRefreshOutline14 />
</button>
</div>
{rootId !== undefined && teamView?.available === true && teamView.team !== null && (
<TeamBoard
rootId={rootId}
members={teamView.team.members}
tasks={teamView.team.tasks}
onChanged={team.refresh}
collapsed={teamBoardCollapsed}
onToggleCollapsed={() => { setTeamBoardCollapsed(current => !current) }}
/>
)}
{failedParents.length > 0 && (
<div className={legacy.subagentError}>
<span>{t('catalogLoadFailed', { count: failedParents.length })}</span>
+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>
)
}
+54 -327
View File
@@ -1,22 +1,17 @@
/**
* The anchored-popover CONTENTS of the Tasks page (geometry and dismissal
* live in AnchoredPopover): agent node details with a transcript jump, the
* workflow run detail with clickable member rows, and the Agent Teams task
* board (roster + basic CAS operations: complete / reopen / delete /
* reassign / create / edit). Every mutation passes the task's CURRENT
* revision; a conflict shows the note and re-syncs via the parent poller.
* live in AnchoredPopover): the agent node detail with a transcript jump and
* the workflow run detail with clickable member rows. Both follow the mockup
* grammar — a letter-spaced uppercase head, a dt/dd key/value grid, and one
* full-width jump action. The team board is NOT here: it is always visible as
* a strip above the canvas (TeamBoard.tsx).
*/
import { useState, type ReactNode } from 'react'
import type { ReactNode } from 'react'
import clsx from 'clsx'
import { StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import type {
SidebarSubagentAddress,
SidebarTeamMemberView,
SidebarTeamTaskView,
} from '../context-types.ts'
import type { SidebarSubagentAddress } from '../context-types.ts'
import type { TasksAgentNode, TasksWorkflowNode } from './tasks-model.ts'
import { agentSecondary, flatten, nodeDotState } from './tasks-shared.tsx'
import { api } from './api.ts'
import { flatten, nodeDotState, workflowStatusKey } from './tasks-shared.tsx'
import { t, type CopyKey } from './locales.ts'
import css from './tasks-graph.module.css'
@@ -30,13 +25,18 @@ function stateKey(state: TasksAgentNode['state']): CopyKey {
}
}
/** The popover's key/value grid. */
function PopRows(props: { children: ReactNode }): ReactNode {
return <dl className={css.popRows}>{props.children}</dl>
}
/** One key/value row of a popover. */
function PopRow(props: { label: string; children: ReactNode }): ReactNode {
function PopRow(props: { label: string; mono?: boolean; children: ReactNode }): ReactNode {
return (
<div className={css.popRow}>
<span className={css.popKey}>{props.label}</span>
<span className={css.popValue}>{props.children}</span>
</div>
<>
<dt className={css.popKey}>{props.label}</dt>
<dd className={clsx(css.popValue, props.mono === true && css.popValueMono)}>{props.children}</dd>
</>
)
}
@@ -52,37 +52,39 @@ export function AgentNodePopover(props: {
: undefined
return (
<div className={css.popCard}>
<div className={css.popHead}>
<span>{t('tasksNodeDetail')}</span>
</div>
<span className={css.popTitle} title={node.label}>{node.label}</span>
<PopRow label={t('tasksNodeState')}>
<StateDot state={nodeDotState(node.state)} size={8} /> {t(stateKey(node.state))}
</PopRow>
{node.mode !== undefined && (
<PopRow label={t('tasksNodeMode')}>
{node.mode === 'one-shot' ? t('subagentModeOneShot') : t('subagentModeContinuable')}
<PopRows>
<PopRow label={t('tasksNodeState')}>
<StateDot state={nodeDotState(node.state)} size={6} /> {t(stateKey(node.state))}
</PopRow>
)}
{node.team !== undefined && (
<>
{node.mode !== undefined && (
<PopRow label={t('tasksNodeMode')}>
{node.mode === 'one-shot' ? t('subagentModeOneShot') : t('subagentModeContinuable')}
</PopRow>
)}
{node.team !== undefined && (
<PopRow label={t('tasksNodeTeamRole')}>
{node.team.role === 'lead' ? 'lead' : node.team.name}
</PopRow>
{node.team.model !== undefined && (
<PopRow label={t('tasksNodeModel')}>{node.team.model}</PopRow>
)}
</>
)}
{node.team?.model !== undefined && (
<PopRow label={t('tasksNodeModel')} mono>{node.team.model}</PopRow>
)}
{(liveTool !== undefined || liveText !== undefined) && (
<PopRow label={t('tasksNodeActivity')} mono>{liveTool ?? liveText}</PopRow>
)}
</PopRows>
{liveText !== undefined && liveTool !== undefined && (
<div className={css.popHint}>{liveText}</div>
)}
{(liveTool !== undefined || liveText !== undefined) && (
<PopRow label={t('tasksNodeActivity')}>{liveTool ?? liveText}</PopRow>
)}
{node.childAddress !== undefined || node.parentId === undefined ? (
<button
type="button"
className={css.popPrimary}
onClick={() => { onJump(node) }}
>
{(node.childAddress !== undefined || node.parentId === undefined) && (
<button type="button" className={css.popPrimary} onClick={() => { onJump(node) }}>
{t('tasksNodeJump')}
</button>
) : null}
)}
</div>
)
}
@@ -96,26 +98,24 @@ export function WorkflowNodePopover(props: {
const { run } = node
return (
<div className={css.popCard}>
<div className={css.popHead}>
<span>{t('workflowRun')}</span>
</div>
<span className={css.popTitle} title={run.name}>▶ {run.name}</span>
<PopRow label={t('tasksNodeState')}>
{t(run.status === 'running'
? 'workflowRunning'
: run.status === 'completed'
? 'workflowCompleted'
: run.status === 'cancelled' ? 'workflowCancelled' : 'workflowFailed')}
</PopRow>
<PopRows>
<PopRow label={t('tasksNodeState')}>{t(workflowStatusKey(run.status))}</PopRow>
</PopRows>
<div className={css.popSection}>
<div className={css.popList}>
{run.phases.map((phase, phaseIndex) => (
<div key={`${phase.title ?? 'phase'}-${phaseIndex}`}>
<div className={clsx(css.popKey, css.popRow)}>
{phase.title ?? t('workflowPhaseUnnamed')}
</div>
<div className={css.popGroup}>{phase.title ?? t('workflowPhaseUnnamed')}</div>
{phase.members.map(member => (
<div
key={member.seq}
role={member.childId === '' ? undefined : 'button'}
className={css.popListRow}
title={member.label}
onClick={() => {
if (member.childId === '') return
onJumpMember({
@@ -126,13 +126,13 @@ export function WorkflowNodePopover(props: {
}}
>
<StateDot
size={8}
size={6}
state={member.outcome === undefined
? 'ongoing'
: member.outcome === 'completed' ? 'done'
: member.outcome === 'failed' ? 'error' : 'idle'}
: member.outcome === 'failed' ? 'error' : 'idle'}
/>
<span className={css.popValue} title={member.label}>{member.label}</span>
<span className={css.popListLabel}>{member.label}</span>
</div>
))}
</div>
@@ -142,276 +142,3 @@ export function WorkflowNodePopover(props: {
</div>
)
}
/** The task status label key. */
function taskStatusKey(status: SidebarTeamTaskView['status']): CopyKey {
switch (status) {
case 'pending': return 'teamTaskPending'
case 'in_progress': return 'teamTaskInProgress'
case 'completed': return 'teamTaskCompleted'
case 'deleted': return 'teamTaskDeleted'
}
}
/**
* The Agent Teams board popover: roster + shared task list with the basic
* operations. Poll-driven refresh: a successful mutation (or a conflict)
* asks the parent to re-pull `teams.view` immediately.
*/
export function TeamBoardPopover(props: {
rootId: string
members: readonly SidebarTeamMemberView[]
tasks: readonly SidebarTeamTaskView[]
onChanged(): void
}): ReactNode {
const { rootId, members, tasks, onChanged } = props
const [busy, setBusy] = useState(false)
const [note, setNote] = useState<string | undefined>(undefined)
const [editingId, setEditingId] = useState<string | undefined>(undefined)
const [creating, setCreating] = useState(false)
const [armedDeleteId, setArmedDeleteId] = useState<string | undefined>(undefined)
/** Run one mutation with the shared busy/conflict handling. */
const mutate = async (
action: () => Promise<{ ok: true } | { ok: false; error: { code: string; message: string } }>,
): Promise<void> => {
if (busy) return
setBusy(true)
setNote(undefined)
try {
const result = await action()
if (!result.ok) {
setNote(result.error.code === 'team-task-conflict' ? t('teamTaskConflict') : t('teamTaskError', { message: result.error.message }))
}
onChanged()
} catch (error) {
setNote(t('teamTaskError', { message: error instanceof Error ? error.message : String(error) }))
} finally {
setBusy(false)
}
}
return (
<div className={css.popCard} style={{ width: 300 }}>
<span className={css.popTitle}>{t('teamBoard')}</span>
<PopRow label={t('teamMembers')}>
{members.map(member => member.name).join(' · ')}
</PopRow>
<div className={css.popSection}>
<div className={css.popList}>
{tasks.length === 0 && <div className={css.popHint}>{t('teamTasksEmpty')}</div>}
{tasks.filter(task => task.status !== 'deleted').map(task => (
<TeamTaskRow
key={task.id}
rootId={rootId}
task={task}
members={members}
busy={busy}
editing={editingId === task.id}
armedDelete={armedDeleteId === task.id}
onEdit={(open) => { setEditingId(open ? task.id : undefined) }}
onArmDelete={(armed) => { setArmedDeleteId(armed ? task.id : undefined) }}
mutate={mutate}
/>
))}
</div>
{note !== undefined && <div className={css.popNote}>{note}</div>}
{creating
? (
<TeamTaskForm
busy={busy}
submitLabel={t('teamTaskCreate')}
onCancel={() => { setCreating(false) }}
onSubmit={(subject, description) => void mutate(async () => {
const result = await api.teamsTaskCreate(rootId, { subject, description })
if (result.ok) setCreating(false)
return result
})}
/>
)
: (
<button
type="button"
className={css.popGhost}
disabled={busy}
onClick={() => { setCreating(true) }}
>
+ {t('teamTaskCreate')}
</button>
)}
</div>
</div>
)
}
/** One task row of the board (status action + owner select + edit/delete). */
function TeamTaskRow(props: {
rootId: string
task: SidebarTeamTaskView
members: readonly SidebarTeamMemberView[]
busy: boolean
editing: boolean
armedDelete: boolean
onEdit(open: boolean): void
onArmDelete(armed: boolean): void
mutate(action: () => Promise<{ ok: true } | { ok: false; error: { code: string; message: string } }>): Promise<void>
}): ReactNode {
const { rootId, task, members, busy, editing, armedDelete, onEdit, onArmDelete, mutate } = props
if (editing) {
return (
<TeamTaskForm
busy={busy}
initialSubject={task.subject}
initialDescription={task.description}
submitLabel={t('teamTaskSave')}
onCancel={() => { onEdit(false) }}
onSubmit={(subject, description) => void mutate(async () => {
const result = await api.teamsTaskUpdate(rootId, {
taskId: task.id,
expectedRevision: task.revision,
action: 'edit',
subject,
description,
})
if (result.ok) onEdit(false)
return result
})}
/>
)
}
return (
<div className={css.popListRow} style={{ cursor: 'default' }}>
<StateDot
size={8}
state={task.status === 'completed' ? 'done' : task.ready ? 'ongoing' : 'warning'}
/>
<span className={css.popValue} title={task.subject}>
{task.subject}
{!task.ready && ` · ${t('teamTaskBlocked')}`}
</span>
<select
className={css.popSelect}
style={{ width: 'auto', flexShrink: 0 }}
aria-label={t('teamTaskOwner')}
title={t('teamTaskOwner')}
disabled={busy || task.status === 'completed'}
value={task.ownerName ?? ''}
onChange={(event) => {
const owner = event.target.value
void mutate(() => api.teamsTaskUpdate(rootId, {
taskId: task.id,
expectedRevision: task.revision,
action: 'reassign',
...(owner === '' ? {} : { owner }),
}))
}}
>
<option value="">{t('teamTaskUnowned')}</option>
{members.filter(member => member.role === 'teammate').map(member => (
<option key={member.id} value={member.name}>{member.name}</option>
))}
</select>
<span className={css.popActions}>
{task.status !== 'completed' && (
<button
type="button"
className={css.popGhost}
disabled={busy}
title={t('teamTaskComplete')}
aria-label={`${t('teamTaskComplete')} ${task.subject}`}
onClick={() => void mutate(() => api.teamsTaskUpdate(rootId, {
taskId: task.id, expectedRevision: task.revision, action: 'complete',
}))}
>
✓
</button>
)}
{task.status === 'completed' && (
<button
type="button"
className={css.popGhost}
disabled={busy}
title={t('teamTaskReopen')}
aria-label={`${t('teamTaskReopen')} ${task.subject}`}
onClick={() => void mutate(() => api.teamsTaskUpdate(rootId, {
taskId: task.id, expectedRevision: task.revision, action: 'reopen',
}))}
>
↺
</button>
)}
<button
type="button"
className={css.popGhost}
disabled={busy}
title={t('teamTaskEdit')}
aria-label={`${t('teamTaskEdit')} ${task.subject}`}
onClick={() => { onEdit(true) }}
>
✎
</button>
<button
type="button"
className={clsx(css.popGhost, armedDelete && css.popGhostDanger)}
disabled={busy}
title={armedDelete ? t('teamTaskDeleteConfirm') : t('teamTaskDelete')}
aria-label={`${armedDelete ? t('teamTaskDeleteConfirm') : t('teamTaskDelete')} ${task.subject}`}
onClick={() => {
if (!armedDelete) { onArmDelete(true); return }
onArmDelete(false)
void mutate(() => api.teamsTaskUpdate(rootId, {
taskId: task.id, expectedRevision: task.revision, action: 'delete',
}))
}}
>
{armedDelete ? t('teamTaskDeleteConfirm') : '🗑'}
</button>
</span>
<span className={css.popHint}>{t(taskStatusKey(task.status))}</span>
</div>
)
}
/** The create/edit task form (subject + description). */
function TeamTaskForm(props: {
busy: boolean
initialSubject?: string
initialDescription?: string
submitLabel: string
onCancel(): void
onSubmit(subject: string, description: string): void
}): ReactNode {
const [subject, setSubject] = useState(props.initialSubject ?? '')
const [description, setDescription] = useState(props.initialDescription ?? '')
return (
<div className={css.popSection}>
<input
className={css.popInput}
placeholder={t('teamTaskSubject')}
aria-label={t('teamTaskSubject')}
value={subject}
onChange={(event) => { setSubject(event.target.value) }}
/>
<input
className={css.popInput}
placeholder={t('teamTaskDescription')}
aria-label={t('teamTaskDescription')}
value={description}
onChange={(event) => { setDescription(event.target.value) }}
/>
<div className={css.popActions}>
<button
type="button"
className={css.popPrimary}
disabled={props.busy || subject.trim() === ''}
onClick={() => { props.onSubmit(subject.trim(), description.trim()) }}
>
{props.submitLabel}
</button>
<button type="button" className={css.popGhost} disabled={props.busy} onClick={props.onCancel}>
{t('teamTaskCancel')}
</button>
</div>
</div>
)
}
+125 -137
View File
@@ -1,32 +1,18 @@
/**
* 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.
* The tree mode of the Tasks page: the SAME unified model rendered as the
* classic indentation tree — nested children with a hairline connector, the
* mockup's row metrics (12px title / 9.5px mono meta), left accent bar on the
* current session, dashed fold rows, and the shared bottom-right control
* cluster (view toggle + fold toggle) so switching back is always possible.
*/
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 { agentMeta, foldPreviews, LiveLine, nodeDotState, workflowMeta } from './tasks-shared.tsx'
import { FoldToggleButton, ViewModeToggle } from './TasksGraph.tsx'
import { t, type CopyKey } from './locales.ts'
import { t } 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[]
@@ -37,7 +23,7 @@ export interface TasksTreeProps {
onToggleFold(): void
mode: 'graph' | 'tree'
onModeChange(mode: 'graph' | 'tree'): void
/** Fallback loading rows while the root catalog hydrates. */
/** Fallback loading row while the root catalog hydrates. */
loading?: boolean
}
@@ -47,24 +33,17 @@ export function TasksTree(props: TasksTreeProps): ReactNode {
} = 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
/** children by parent id (model order preserved). */
const childrenOf = useMemo(() => {
const map = new Map<string, TasksNode[]>()
const ids = new Set(nodes.map(node => node.id))
for (const node of nodes) {
const parent = node.parentId !== undefined && ids.has(node.parentId) ? node.parentId : ''
const list = map.get(parent)
if (list === undefined) map.set(parent, [node])
else list.push(node)
}
for (const node of nodes) depthOf(node, 0)
return depths
return map
}, [nodes])
/** Arrow-key navigation over the visible rows (official catalog recipe). */
@@ -99,6 +78,109 @@ export function TasksTree(props: TasksTreeProps): ReactNode {
}
}
/** One row plus its nested children (connector line per the mockup). */
const renderNode = (node: TasksNode, depth: number): ReactNode => {
const children = childrenOf.get(node.id) ?? []
const row = node.kind === 'fold'
? (
<div
key={node.id}
data-tasks-row
role="treeitem"
tabIndex={0}
aria-level={depth + 1}
aria-label={`${t('tasksFoldCompleted', { count: node.count })} · ${t('tasksFoldExpand')}`}
className={clsx(css.treeRow, css.treeFoldRow)}
onClick={onToggleFold}
onKeyDown={(event) => { activateOnKey(event, onToggleFold) }}
>
<StateDot state="done" size={6} />
<span className={css.treeContent}>
<span className={css.treeTitle}>
{t('tasksFoldCompleted', { count: node.count })}
<span className={css.treeMeta}>
{`${folded ? '⇪' : '⇩'} ${t(folded ? 'tasksFoldExpand' : 'tasksFoldCollapse')}`}
</span>
</span>
<span className={css.treeMeta}>{foldPreviews(node.previews)}</span>
</span>
</div>
)
: node.kind === 'workflow'
? (
<div
key={node.id}
data-tasks-row
role="treeitem"
tabIndex={0}
aria-level={depth + 1}
aria-expanded="true"
aria-label={`${node.run.name} ${workflowMeta(node)}`}
className={clsx(css.treeRow, node.run.status !== 'running' && css.treeRowSettled)}
onClick={(event) => { onWorkflowInfo(node, event.currentTarget) }}
onKeyDown={(event) => {
activateOnKey(event, () => { onWorkflowInfo(node, event.currentTarget as HTMLElement) })
}}
>
<StateDot state={node.run.status === 'running' ? 'ongoing' : 'done'} size={6} />
<span className={css.treeContent}>
<span className={css.treeTitle}>{`▶ ${node.run.name}`}</span>
<span className={css.treeMeta}>{workflowMeta(node)}</span>
</span>
</div>
)
: (
<div
key={node.id}
data-tasks-row
role="treeitem"
tabIndex={0}
aria-level={depth + 1}
aria-label={`${node.label} ${agentMeta(node)}`}
aria-current={node.current ? 'true' : undefined}
className={clsx(
css.treeRow,
node.current && css.treeRowActive,
(node.state === 'done' || node.state === 'error') && !node.current && css.treeRowSettled,
)}
onClick={() => { onActivate(node) }}
onKeyDown={(event) => { activateOnKey(event, () => { onActivate(node) }) }}
>
<StateDot state={nodeDotState(node.state)} size={6} />
<span className={css.treeContent}>
<span className={css.treeTitle}>
{node.team?.role === 'teammate' ? `◇ ${node.label}` : node.label}
</span>
<span className={css.treeMeta}>{agentMeta(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)
}}
>
i
</button>
</div>
)
if (children.length === 0) return <div key={node.id}>{row}</div>
return (
<div key={node.id}>
{row}
<div className={css.treeKids}>
{children.map(child => renderNode(child, depth + 1))}
</div>
</div>
)
}
const roots = childrenOf.get('') ?? []
return (
<div className={css.graphView}>
<div
@@ -108,106 +190,12 @@ export function TasksTree(props: TasksTreeProps): ReactNode {
aria-label={t('subagent')}
onKeyDown={onTreeKeyDown}
>
{loading === true && (
<div className={legacy.subagentEmpty}>{t('loading')}</div>
{loading === true && roots.length === 0 && (
<div className={css.viewEmptyHint}>{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>
)
})}
{roots.map(root => renderNode(root, 0))}
</div>
<div className={css.controls}>
<div className={css.controls} data-graph-controls>
<ViewModeToggle mode={mode} onModeChange={onModeChange} />
<FoldToggleButton folded={folded} onToggleFold={onToggleFold} />
</div>
+333
View File
@@ -0,0 +1,333 @@
/**
* The Agent Teams board — ALWAYS VISIBLE under the page header whenever the
* tree's root leads a team (the explicit review ask: the board must not hide
* behind a chip). It is a full-width strip, not a side rail, because the
* native sidebar is narrow (~360px): members wrap as chips and the task list
* scrolls inside a bounded height, so the canvas keeps its room.
*
* Mutations are CAS: every action sends the task's CURRENT revision; a
* conflict surfaces as a note and re-syncs through the parent's poller.
*/
import { useState, type ReactNode } from 'react'
import clsx from 'clsx'
import { IconChevronUpOutline14, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SidebarTeamMemberView, SidebarTeamTaskView } from '../context-types.ts'
import { api } from './api.ts'
import { t, type CopyKey } from './locales.ts'
import css from './tasks-graph.module.css'
/** The task status label key. */
function taskStatusKey(status: SidebarTeamTaskView['status']): CopyKey {
switch (status) {
case 'pending': return 'teamTaskPending'
case 'in_progress': return 'teamTaskInProgress'
case 'completed': return 'teamTaskCompleted'
case 'deleted': return 'teamTaskDeleted'
}
}
/** One mutation outcome (the host route's own union). */
type MutationResult = { ok: true } | { ok: false; error: { code: string; message: string } }
export interface TeamBoardProps {
rootId: string
members: readonly SidebarTeamMemberView[]
tasks: readonly SidebarTeamTaskView[]
/** Re-pull `teams.view` now (after every mutation / conflict). */
onChanged(): void
/** The strip's own collapse state (the reader's choice, remembered while mounted). */
collapsed: boolean
onToggleCollapsed(): void
}
export function TeamBoard(props: TeamBoardProps): ReactNode {
const { rootId, members, tasks, onChanged, collapsed, onToggleCollapsed } = props
const [busy, setBusy] = useState(false)
const [note, setNote] = useState<string | undefined>(undefined)
const [editingId, setEditingId] = useState<string | undefined>(undefined)
const [creating, setCreating] = useState(false)
const [armedDeleteId, setArmedDeleteId] = useState<string | undefined>(undefined)
const open = tasks.filter(task => task.status !== 'deleted')
/** Run one mutation with the shared busy/conflict handling. */
const mutate = async (action: () => Promise<MutationResult>): Promise<void> => {
if (busy) return
setBusy(true)
setNote(undefined)
try {
const result = await action()
if (!result.ok) {
setNote(result.error.code === 'team-task-conflict'
? t('teamTaskConflict')
: t('teamTaskError', { message: result.error.message }))
}
onChanged()
} catch (error) {
setNote(t('teamTaskError', { message: error instanceof Error ? error.message : String(error) }))
} finally {
setBusy(false)
}
}
const teammates = members.filter(member => member.role === 'teammate')
return (
<section className={css.teamBoard} aria-label={t('teamBoard')}>
<button
type="button"
className={css.teamBoardBar}
aria-expanded={!collapsed}
onClick={onToggleCollapsed}
>
<span>{t('teamBoard')}</span>
<span className={css.teamBoardCount}>
{t('teamChip', { members: members.length, tasks: open.length })}
</span>
<span
className={css.teamBoardChev}
style={{ display: 'inline-flex', transform: collapsed ? undefined : 'rotate(180deg)' }}
aria-hidden="true"
>
<IconChevronUpOutline14 size={12} />
</span>
</button>
{!collapsed && (
<>
<div className={css.teamMembers}>
{members.map(member => (
<span
key={member.id}
className={clsx(css.teamMember, member.role === 'lead' && css.teamMemberLead)}
title={`${member.name} · ${member.role}`}
>
<StateDot
size={6}
state={member.status === 'running' || member.status === 'provisioning'
? 'ongoing'
: member.status === 'failed' ? 'error' : 'idle'}
/>
<span className={css.teamMemberName}>{member.name}</span>
</span>
))}
</div>
{open.length === 0 && !creating && <div className={css.teamEmpty}>{t('teamTasksEmpty')}</div>}
{open.length > 0 && (
<div className={css.teamTasks}>
{open.map(task => (
<TeamTaskRow
key={task.id}
rootId={rootId}
task={task}
teammates={teammates}
busy={busy}
editing={editingId === task.id}
armedDelete={armedDeleteId === task.id}
onEdit={(edit) => { setEditingId(edit ? task.id : undefined) }}
onArmDelete={(armed) => { setArmedDeleteId(armed ? task.id : undefined) }}
mutate={mutate}
/>
))}
</div>
)}
{note !== undefined && <div className={css.teamEmpty}>{note}</div>}
{creating
? (
<TeamTaskForm
busy={busy}
submitLabel={t('teamTaskCreate')}
onCancel={() => { setCreating(false) }}
onSubmit={(subject, description) => void mutate(async () => {
const result = await api.teamsTaskCreate(rootId, { subject, description })
if (result.ok) setCreating(false)
return result
})}
/>
)
: (
<div className={css.teamFormActions} style={{ padding: '0 12px 8px' }}>
<button
type="button"
className={css.teamBtn}
disabled={busy}
onClick={() => { setCreating(true) }}
>
{`+ ${t('teamTaskCreate')}`}
</button>
</div>
)}
</>
)}
</section>
)
}
/** One task row: status dot, subject, owner select, status chip, actions. */
function TeamTaskRow(props: {
rootId: string
task: SidebarTeamTaskView
teammates: readonly SidebarTeamMemberView[]
busy: boolean
editing: boolean
armedDelete: boolean
onEdit(edit: boolean): void
onArmDelete(armed: boolean): void
mutate(action: () => Promise<MutationResult>): Promise<void>
}): ReactNode {
const { rootId, task, teammates, busy, editing, armedDelete, onEdit, onArmDelete, mutate } = props
if (editing) {
return (
<TeamTaskForm
busy={busy}
initialSubject={task.subject}
initialDescription={task.description}
submitLabel={t('teamTaskSave')}
onCancel={() => { onEdit(false) }}
onSubmit={(subject, description) => void mutate(async () => {
const result = await api.teamsTaskUpdate(rootId, {
taskId: task.id,
expectedRevision: task.revision,
action: 'edit',
subject,
description,
})
if (result.ok) onEdit(false)
return result
})}
/>
)
}
return (
<div className={css.teamTask}>
<StateDot size={6} state={task.status === 'completed' ? 'done' : task.ready ? 'ongoing' : 'warning'} />
<span className={css.teamTaskSubject} title={`${task.subject}${task.description === '' ? '' : `\n${task.description}`}`}>
{task.subject}
</span>
<select
className={css.teamOwnerSelect}
aria-label={t('teamTaskOwner')}
title={t('teamTaskOwner')}
disabled={busy || task.status === 'completed'}
value={task.ownerName ?? ''}
onChange={(event) => {
const owner = event.target.value
void mutate(() => api.teamsTaskUpdate(rootId, {
taskId: task.id,
expectedRevision: task.revision,
action: 'reassign',
...(owner === '' ? {} : { owner }),
}))
}}
>
<option value="">{t('teamTaskUnowned')}</option>
{teammates.map(member => (
<option key={member.id} value={member.name}>{member.name}</option>
))}
</select>
<span className={clsx(css.teamTaskStatus, !task.ready && css.teamTaskBlocked)}>
{t(task.ready ? taskStatusKey(task.status) : 'teamTaskBlocked')}
</span>
<span className={css.teamActions}>
{task.status !== 'completed'
? (
<button
type="button"
className={css.teamBtn}
disabled={busy}
title={t('teamTaskComplete')}
aria-label={`${t('teamTaskComplete')} ${task.subject}`}
onClick={() => void mutate(() => api.teamsTaskUpdate(rootId, {
taskId: task.id, expectedRevision: task.revision, action: 'complete',
}))}
>
✓
</button>
)
: (
<button
type="button"
className={css.teamBtn}
disabled={busy}
title={t('teamTaskReopen')}
aria-label={`${t('teamTaskReopen')} ${task.subject}`}
onClick={() => void mutate(() => api.teamsTaskUpdate(rootId, {
taskId: task.id, expectedRevision: task.revision, action: 'reopen',
}))}
>
↺
</button>
)}
<button
type="button"
className={css.teamBtn}
disabled={busy}
title={t('teamTaskEdit')}
aria-label={`${t('teamTaskEdit')} ${task.subject}`}
onClick={() => { onEdit(true) }}
>
✎
</button>
<button
type="button"
className={clsx(css.teamBtn, armedDelete && css.teamBtnDanger)}
disabled={busy}
title={armedDelete ? t('teamTaskDeleteConfirm') : t('teamTaskDelete')}
aria-label={`${armedDelete ? t('teamTaskDeleteConfirm') : t('teamTaskDelete')} ${task.subject}`}
onClick={() => {
if (!armedDelete) { onArmDelete(true); return }
onArmDelete(false)
void mutate(() => api.teamsTaskUpdate(rootId, {
taskId: task.id, expectedRevision: task.revision, action: 'delete',
}))
}}
>
{armedDelete ? '!' : '✕'}
</button>
</span>
</div>
)
}
/** The create/edit task form (subject + description). */
function TeamTaskForm(props: {
busy: boolean
initialSubject?: string
initialDescription?: string
submitLabel: string
onCancel(): void
onSubmit(subject: string, description: string): void
}): ReactNode {
const [subject, setSubject] = useState(props.initialSubject ?? '')
const [description, setDescription] = useState(props.initialDescription ?? '')
return (
<div className={css.teamForm}>
<input
className={css.teamInput}
placeholder={t('teamTaskSubject')}
aria-label={t('teamTaskSubject')}
value={subject}
onChange={(event) => { setSubject(event.target.value) }}
/>
<input
className={css.teamInput}
placeholder={t('teamTaskDescription')}
aria-label={t('teamTaskDescription')}
value={description}
onChange={(event) => { setDescription(event.target.value) }}
/>
<div className={css.teamFormActions}>
<button
type="button"
className={css.teamBtn}
disabled={props.busy || subject.trim() === ''}
onClick={() => { props.onSubmit(subject.trim(), description.trim()) }}
>
{props.submitLabel}
</button>
<button type="button" className={css.teamBtn} disabled={props.busy} onClick={props.onCancel}>
{t('teamTaskCancel')}
</button>
</div>
</div>
)
}
+2
View File
@@ -409,6 +409,8 @@ export const ar: Record<string, string> = {
tasksZoomOut: 'تصغير',
tasksZoomFit: 'ملاءمة اللوحة',
tasksNodeJump: 'فتح النسخة',
tasksNodeDetail: 'تفاصيل العقدة',
workflowRun: 'سير العمل',
tasksNodeState: 'الحالة',
tasksNodeMode: 'الوضع',
tasksNodeModel: 'الطراز',
+2
View File
@@ -394,6 +394,8 @@ export const de: Record<string, string> = {
tasksZoomOut: 'Verkleinern',
tasksZoomFit: 'An die Canvas anpassen',
tasksNodeJump: 'Transkript öffnen',
tasksNodeDetail: 'Knotendetails',
workflowRun: 'Workflow',
tasksNodeState: 'Status',
tasksNodeMode: 'Modus',
tasksNodeModel: 'Modell',
+2
View File
@@ -401,6 +401,8 @@ export const fr: Record<string, string> = {
tasksZoomOut: 'Zoom arrière',
tasksZoomFit: 'Ajuster au canevas',
tasksNodeJump: 'Ouvrir la transcription',
tasksNodeDetail: 'Détails du nœud',
workflowRun: 'Workflow',
tasksNodeState: 'État',
tasksNodeMode: 'Mode',
tasksNodeModel: 'Modèle',
+2
View File
@@ -408,6 +408,8 @@ export const hi: Record<string, string> = {
tasksZoomOut: 'ज़ूम आउट',
tasksZoomFit: 'कैनवास में फ़िट करें',
tasksNodeJump: 'ट्रांसक्रिप्ट खोलें',
tasksNodeDetail: 'नोड विवरण',
workflowRun: 'वर्कफ़्लो',
tasksNodeState: 'स्थिति',
tasksNodeMode: 'मोड',
tasksNodeModel: 'मॉडल',
+2
View File
@@ -406,6 +406,8 @@ export const id: Record<string, string> = {
tasksZoomOut: 'Perkecil',
tasksZoomFit: 'Sesuaikan ke kanvas',
tasksNodeJump: 'Buka transkrip',
tasksNodeDetail: 'Detail node',
workflowRun: 'Alur kerja',
tasksNodeState: 'Status',
tasksNodeMode: 'Mode',
tasksNodeModel: 'Model',
+2
View File
@@ -399,6 +399,8 @@ export const it: Record<string, string> = {
tasksZoomOut: 'Riduci',
tasksZoomFit: 'Adatta all’area di disegno',
tasksNodeJump: 'Apri trascrizione',
tasksNodeDetail: 'Dettagli del nodo',
workflowRun: 'Workflow',
tasksNodeState: 'Stato',
tasksNodeMode: 'Modalità',
tasksNodeModel: 'Modello',
+2
View File
@@ -408,6 +408,8 @@ export const ja: Record<string, string> = {
tasksZoomOut: '縮小',
tasksZoomFit: 'キャンバスに合わせる',
tasksNodeJump: 'トランスクリプトを開く',
tasksNodeDetail: 'ノード詳細',
workflowRun: 'ワークフロー',
tasksNodeState: '状態',
tasksNodeMode: 'モード',
tasksNodeModel: 'モデル',
+2
View File
@@ -400,6 +400,8 @@ export const ko: Record<string, string> = {
tasksZoomOut: '축소',
tasksZoomFit: '캔버스에 맞추기',
tasksNodeJump: '대화 기록 보기',
tasksNodeDetail: '노드 상세',
workflowRun: '워크플로',
tasksNodeState: '상태',
tasksNodeMode: '모드',
tasksNodeModel: '모델',
+2
View File
@@ -406,6 +406,8 @@ export const nl: Record<string, string> = {
tasksZoomOut: 'Uitzoomen',
tasksZoomFit: 'Passend maken',
tasksNodeJump: 'Transcript openen',
tasksNodeDetail: 'Knooppuntdetails',
workflowRun: 'Workflow',
tasksNodeState: 'Status',
tasksNodeMode: 'Modus',
tasksNodeModel: 'Model',
+2
View File
@@ -410,6 +410,8 @@ export const pl: Record<string, string> = {
tasksZoomOut: 'Oddal',
tasksZoomFit: 'Dopasuj do kanwy',
tasksNodeJump: 'Otwórz transkrypcję',
tasksNodeDetail: 'Szczegóły węzła',
workflowRun: 'Przepływ pracy',
tasksNodeState: 'Stan',
tasksNodeMode: 'Tryb',
tasksNodeModel: 'Model',
+2
View File
@@ -391,6 +391,8 @@ export const pt: Record<string, string> = {
tasksZoomOut: 'Afastar',
tasksZoomFit: 'Ajustar à tela',
tasksNodeJump: 'Abrir transcrição',
tasksNodeDetail: 'Detalhes do nó',
workflowRun: 'Workflow',
tasksNodeState: 'Estado',
tasksNodeMode: 'Modo',
tasksNodeModel: 'Modelo',
+2
View File
@@ -406,6 +406,8 @@ export const ru: Record<string, string> = {
tasksZoomOut: 'Уменьшить',
tasksZoomFit: 'По размеру холста',
tasksNodeJump: 'Открыть транскрипт',
tasksNodeDetail: 'Сведения об узле',
workflowRun: 'Рабочий процесс',
tasksNodeState: 'Состояние',
tasksNodeMode: 'Режим',
tasksNodeModel: 'Модель',
+2
View File
@@ -391,6 +391,8 @@ export const sv: Record<string, string> = {
tasksZoomOut: 'Zooma ut',
tasksZoomFit: 'Anpassa till ytan',
tasksNodeJump: 'Öppna transkription',
tasksNodeDetail: 'Noddetaljer',
workflowRun: 'Arbetsflöde',
tasksNodeState: 'Status',
tasksNodeMode: 'Läge',
tasksNodeModel: 'Modell',
+2
View File
@@ -408,6 +408,8 @@ export const th: Record<string, string> = {
tasksZoomOut: 'ซูมออก',
tasksZoomFit: 'พอดีผืนผ้าใบ',
tasksNodeJump: 'เปิดบันทึกถอดความ',
tasksNodeDetail: 'รายละเอียดโหนด',
workflowRun: 'เวิร์กโฟลว์',
tasksNodeState: 'สถานะ',
tasksNodeMode: 'โหมด',
tasksNodeModel: 'โมเดล',
+2
View File
@@ -408,6 +408,8 @@ export const tr: Record<string, string> = {
tasksZoomOut: 'Uzaklaştır',
tasksZoomFit: 'Tuvale sığdır',
tasksNodeJump: 'Transkripti aç',
tasksNodeDetail: 'Düğüm ayrıntıları',
workflowRun: 'İş akışı',
tasksNodeState: 'Durum',
tasksNodeMode: 'Mod',
tasksNodeModel: 'Model',
+2
View File
@@ -408,6 +408,8 @@ export const vi: Record<string, string> = {
tasksZoomOut: 'Thu nhỏ',
tasksZoomFit: 'Vừa khung vẽ',
tasksNodeJump: 'Mở bản ghi hội thoại',
tasksNodeDetail: 'Chi tiết nút',
workflowRun: 'Quy trình',
tasksNodeState: 'Trạng thái',
tasksNodeMode: 'Chế độ',
tasksNodeModel: 'Mô hình',
+2
View File
@@ -423,6 +423,8 @@ export const zhHK: Record<string, string> = {
tasksZoomOut: '縮小',
tasksZoomFit: '適應畫布',
tasksNodeJump: '檢視逐字稿',
tasksNodeDetail: '節點詳情',
workflowRun: '工作流',
tasksNodeState: '狀態',
tasksNodeMode: '模式',
tasksNodeModel: '模型',
+2
View File
@@ -423,6 +423,8 @@ export const zhMO: Record<string, string> = {
tasksZoomOut: '縮小',
tasksZoomFit: '適應畫布',
tasksNodeJump: '檢視逐字稿',
tasksNodeDetail: '節點詳情',
workflowRun: '工作流',
tasksNodeState: '狀態',
tasksNodeMode: '模式',
tasksNodeModel: '模型',
+2
View File
@@ -423,6 +423,8 @@ export const zhTW: Record<string, string> = {
tasksZoomOut: '縮小',
tasksZoomFit: '適應畫布',
tasksNodeJump: '檢視逐字稿',
tasksNodeDetail: '節點詳情',
workflowRun: '工作流',
tasksNodeState: '狀態',
tasksNodeMode: '模式',
tasksNodeModel: '模型',
+4
View File
@@ -420,6 +420,8 @@ export const zh = {
tasksZoomOut: '缩小',
tasksZoomFit: '适应画布',
tasksNodeJump: '查看转录',
tasksNodeDetail: '节点详情',
workflowRun: '工作流',
tasksNodeState: '状态',
tasksNodeMode: '模式',
tasksNodeModel: '模型',
@@ -913,6 +915,8 @@ export const en: Record<keyof typeof zh, string> = {
tasksZoomOut: 'Zoom out',
tasksZoomFit: 'Fit to canvas',
tasksNodeJump: 'Open transcript',
tasksNodeDetail: 'Node detail',
workflowRun: 'Workflow',
tasksNodeState: 'State',
tasksNodeMode: 'Mode',
tasksNodeModel: 'Model',
+129 -38
View File
@@ -1,22 +1,29 @@
/**
* The layered top-down layout of the Tasks graph canvas: a tidy-tree
* simplification — every node sits one row below its parent, siblings share
* a row in model order, and each parent is horizontally centered over its
* children's band. Pure and dependency-free (the bundle-purity rule forbids
* pulling a graph library into the core bundle; <100-node trees need no
* virtualisation), unit-testable in the node environment.
* The layered top-down layout of the Tasks graph canvas: a tidy-tree pass
* with a NARROW-PANEL rule — a parent's children wrap into bands of at most
* {@link LayoutOptions.maxBandCols} columns, stacking extra rows below it.
* Without wrapping a five-child level is ~770px wide and the whole graph
* shrinks to ~40% inside the 360px native sidebar, which is unreadable; with
* wrapping the canvas keeps a ~1:1 scale and the reader pans far less.
*
* Pure and dependency-free (the bundle-purity rule forbids a graph library in
* the core bundle; <100-node trees need no virtualisation).
*/
import type { TasksNode } from './tasks-model.ts'
import { tasksEdges } from './tasks-model.ts'
/** Geometry constants of the canvas (px, pre-scale). */
export const GRAPH_NODE_W = 140
export const GRAPH_NODE_H = 48
/** Geometry constants of the canvas (px, pre-scale). Metrics follow the
* approved Variant-D mockup, whose design target is the NARROW native right
* sidebar (~360px): a 132px card fits two per row plus the gutter. */
export const GRAPH_NODE_W = 132
export const GRAPH_NODE_H = 46
/** Extra height of an agent node carrying a live line. */
export const GRAPH_LIVE_H = 16
export const GRAPH_LIVE_H = 13
export const GRAPH_GAP_X = 28
export const GRAPH_GAP_Y = 72
export const GRAPH_GAP_Y = 53
export const GRAPH_PAD = 16
/** The row stride (one row of cards plus the vertical gutter). */
export const GRAPH_ROW_STRIDE = GRAPH_NODE_H + GRAPH_LIVE_H + GRAPH_GAP_Y
/** One laid-out node. */
export interface GraphBox {
@@ -34,22 +41,46 @@ export interface GraphLayout {
height: number
}
/** The display height of one node (live lines grow agent rows). */
/** Layout knobs. */
export interface LayoutOptions {
/** Columns per sibling band before it wraps to the next row (default 4). */
maxBandCols?: number
}
/**
* The display height of one node. Every RUNNING agent reserves the live-line
* row: a running node without activity yet still renders the "thinking" line
* (LiveLine's fallback), so reserving only when data arrived would clip it.
*/
function nodeHeight(node: TasksNode): number {
if (node.kind === 'agent' && node.live !== undefined) return GRAPH_NODE_H + GRAPH_LIVE_H
if (node.kind === 'agent' && node.state === 'running') return GRAPH_NODE_H + GRAPH_LIVE_H
return GRAPH_NODE_H
}
/**
* Lay out the model top-down. Nodes whose parent is missing (a fold node
* whose parent scrolled out of the model — defensive) attach to the first
* root. The algorithm is the classic two-walk tidy pass: a post-order walk
* accumulates each subtree's width, a pre-order walk assigns x so every
* parent centers over its children.
* @param nodes - the model's pre-order node list (roots = parentless rows).
* Chunk one sibling band into rows of at most `cols` children. Counting (not
* width-packing) keeps the graph SHAPE: a level stays a row of siblings, and
* a nested subtree never forces its aunts into a ladder of one-child rows.
* Wide subtrees are handled by the fit scale instead.
*/
export function layoutTasksGraph(nodes: readonly TasksNode[]): GraphLayout {
const roots = nodes.filter(node => node.parentId === undefined || !nodes.some(candidate => candidate.id === node.parentId))
function bands(children: readonly TasksNode[], cols: number): TasksNode[][] {
if (children.length <= cols) return [children.slice()]
const out: TasksNode[][] = []
for (let index = 0; index < children.length; index += cols) {
out.push(children.slice(index, index + cols))
}
return out
}
/**
* Lay out the model top-down, wrapping wide sibling bands.
* @param nodes - the model's pre-order node list (roots = parentless rows).
* @param options - see {@link LayoutOptions}.
*/
export function layoutTasksGraph(nodes: readonly TasksNode[], options: LayoutOptions = {}): GraphLayout {
const cols = Math.max(1, options.maxBandCols ?? 4)
const ids = new Set(nodes.map(node => node.id))
const roots = nodes.filter(node => node.parentId === undefined || !ids.has(node.parentId))
const childrenOf = new Map<string, TasksNode[]>()
for (const edge of tasksEdges(nodes)) {
const child = nodes.find(node => node.id === edge.to)
@@ -63,33 +94,48 @@ export function layoutTasksGraph(nodes: readonly TasksNode[]): GraphLayout {
const subtreeWidth = (node: TasksNode): number => {
const children = childrenOf.get(node.id) ?? []
if (children.length === 0) return GRAPH_NODE_W
const band = children.reduce((sum, child) => sum + subtreeWidth(child), 0)
+ GRAPH_GAP_X * (children.length - 1)
return Math.max(GRAPH_NODE_W, band)
let widest = GRAPH_NODE_W
for (const band of bands(children, cols)) {
const width = band.reduce((sum, child) => sum + subtreeWidth(child), 0)
+ GRAPH_GAP_X * (band.length - 1)
widest = Math.max(widest, width)
}
return widest
}
const boxes = new Map<string, GraphBox>()
/** Pre-order: place `node` so its band centers on `centerX`; `depth` is
* the row (the tallest ancestor row so far offsets uniformly). */
const place = (node: TasksNode, centerX: number, depth: number): void => {
const h = nodeHeight(node)
/**
* Place `node` centered on `centerX` at row `row`; returns the LAST row its
* subtree occupies. Wrapped bands advance to the row after the deepest row
* of the previous band, so a sibling's children can never land on top of
* the next band (the overlap a naive +1 counter produced).
*/
const place = (node: TasksNode, centerX: number, row: number): number => {
boxes.set(node.id, {
id: node.id,
x: Math.round(centerX - GRAPH_NODE_W / 2),
y: GRAPH_PAD + depth * (GRAPH_NODE_H + GRAPH_LIVE_H + GRAPH_GAP_Y),
y: GRAPH_PAD + row * GRAPH_ROW_STRIDE,
w: GRAPH_NODE_W,
h,
h: nodeHeight(node),
})
const children = childrenOf.get(node.id) ?? []
if (children.length === 0) return
const total = children.reduce((sum, child) => sum + subtreeWidth(child), 0)
+ GRAPH_GAP_X * (children.length - 1)
let cursor = centerX - total / 2
for (const child of children) {
const width = subtreeWidth(child)
place(child, cursor + width / 2, depth + 1)
cursor += width + GRAPH_GAP_X
if (children.length === 0) return row
let deepest = row
let bandRow = row + 1
for (const band of bands(children, cols)) {
const bandWidth = band.reduce((sum, child) => sum + subtreeWidth(child), 0)
+ GRAPH_GAP_X * (band.length - 1)
let cursor = centerX - bandWidth / 2
let bandDeepest = bandRow
for (const child of band) {
const width = subtreeWidth(child)
bandDeepest = Math.max(bandDeepest, place(child, cursor + width / 2, bandRow))
cursor += width + GRAPH_GAP_X
}
deepest = Math.max(deepest, bandDeepest)
bandRow = bandDeepest + 1
}
return deepest
}
// Single-root forest in practice (the topology root); the loop stays so a
@@ -106,3 +152,48 @@ export function layoutTasksGraph(nodes: readonly TasksNode[]): GraphLayout {
const height = Math.max(GRAPH_PAD * 2 + GRAPH_NODE_H, ...allBoxes.map(box => box.y + box.h + GRAPH_PAD))
return { boxes, width, height }
}
/**
* How many sibling columns fit the given container width at ~1:1 scale. The
* canvas keeps its readability floor instead of shrinking: at 360px two cards
* per row, at 720px four.
*/
export function bandColsFor(containerWidth: number): number {
const usable = containerWidth - GRAPH_PAD * 2
const cols = Math.floor((usable + GRAPH_GAP_X) / (GRAPH_NODE_W + GRAPH_GAP_X))
return Math.min(4, Math.max(1, cols))
}
/**
* The layout for one container width: the WIDEST sibling arrangement whose
* canvas still fits `containerWidth / minScale` — i.e. the least-tall layout
* the reader can see without zooming below the readability floor. Wrapping
* more than needed turns the graph into a ladder; wrapping too little forces
* an unreadable scale, so the loop tries the widest first and narrows.
*
* @param nodes - the model's node list.
* @param containerWidth - the canvas viewport width in px.
* @param minScale - the readability floor the fit honours (default 0.78).
*/
export function layoutTasksGraphForWidth(
nodes: readonly TasksNode[],
containerWidth: number,
minScale = 0.78,
): GraphLayout {
const budget = containerWidth <= 0 ? Number.POSITIVE_INFINITY : (containerWidth - 24) / minScale
const widest = bandColsFor(containerWidth <= 0 ? 360 : containerWidth)
// Pass 1: the least-tall arrangement that FITS the readability budget at
// two or more columns (below two the graph degenerates into a ladder).
for (let cols = widest; cols >= 2; cols -= 1) {
const layout = layoutTasksGraph(nodes, { maxBandCols: cols })
if (layout.width <= budget) return layout
}
// Pass 2: nothing flat fits — accept a slight horizontal overflow (the
// reader pans a little) rather than stacking every child vertically.
const tolerant = budget * 1.2
for (let cols = widest; cols >= 2; cols -= 1) {
const layout = layoutTasksGraph(nodes, { maxBandCols: cols })
if (layout.width <= tolerant) return layout
}
return layoutTasksGraph(nodes, { maxBandCols: 1 })
}
File diff suppressed because it is too large Load Diff
+72 -31
View File
@@ -1,20 +1,29 @@
/**
* Shared presentation bits of the Tasks page's two modes (graph + tree):
* the state-dot mapping, the secondary card line, the iconified live
* activity row ("glyph + tool + args" — the user-approved format replacing
* the old `>read[file]` text), and the fold aggregate caption. Kept in one
* module so graph and tree can never drift apart visually.
* Shared presentation bits of the Tasks page's two modes (graph + tree): the
* state-dot mapping, the SHORT mono meta line (one or two tokens — the card
* is 132px wide in the narrow native sidebar, so anything longer moves into
* the ⓘ popover), the iconified live activity row ("glyph + tool + args",
* the approved format), and the fold aggregate caption.
*
* Card content contract (the page's answer to "everything is ellipsized"):
* line 1 dot + name (11px, semibold, one line)
* line 2 mode/model · activity (9px mono, one line)
* line 3 live line (running nodes only)
* Everything else — display title, full model id, team role, phase names,
* latest output text, jump action — lives in the anchored popover.
*/
import type { ReactNode } from 'react'
import { StateDot, type StateDotState } from '@deepseek-ai/dsh-client-ui-primitives'
import type { LastActivity } from '../subagent-activity.ts'
import type { TasksAgentNode, TasksNodeState } from './tasks-model.ts'
import type { TasksAgentNode, TasksNodeState, TasksWorkflowNode } from './tasks-model.ts'
import { toolGlyph } from './tool-icons.tsx'
import { t } from './locales.ts'
import { t, type CopyKey } from './locales.ts'
import css from './tasks-graph.module.css'
/** Preview cap of one tool-call argument line. */
const ARGS_PREVIEW = 60
const ARGS_PREVIEW = 42
/** Preview cap of one text line. */
const TEXT_PREVIEW = 72
/** The host StateDot semantic of a node display state. */
export function nodeDotState(state: TasksNodeState): StateDotState {
@@ -26,6 +35,16 @@ export function nodeDotState(state: TasksNodeState): StateDotState {
}
}
/** The short state word of a node (the meta line's second token). */
export function stateLabel(state: TasksNodeState): string {
const key: CopyKey = state === 'running'
? 'tasksStateRunning'
: state === 'idle'
? 'subagentInactive'
: state === 'error' ? 'tasksStateError' : 'tasksStateDone'
return t(key)
}
/** First `limit` characters with an ellipsis when truncated. */
export function preview(text: string, limit: number = ARGS_PREVIEW): string {
return text.length > limit ? `${text.slice(0, limit)}…` : text
@@ -37,46 +56,68 @@ export function flatten(text: string): string {
}
/**
* The secondary line of an agent node: display title (when it differs from
* the durable label) · mode · activity · teammate model. Empty parts drop
* out. Synthesized workflow members show their origin instead of a mode.
* The mono meta line of an agent card — at most two short tokens:
* - the topology root: 主代理 · 状态
* - a teammate with a known model: 模型 · 状态
* - any other agent: 模式 · 状态
*/
export function agentSecondary(node: TasksAgentNode): string {
const parts: string[] = []
if (node.title !== undefined && node.title !== node.label) parts.push(node.title)
export function agentMeta(node: TasksAgentNode): string {
const state = stateLabel(node.state)
if (node.parentId === undefined) return `${t('subagentMainAgent')} · ${state}`
if (node.team?.role === 'teammate' && node.team.model !== undefined && node.team.model !== '') {
return `${node.team.model} · ${state}`
}
if (node.mode !== undefined) {
parts.push(node.mode === 'one-shot' ? t('subagentModeOneShot') : t('subagentModeContinuable'))
} else if (node.synthesized === true) {
parts.push(t('tasksWorkflowMember'))
return `${node.mode === 'one-shot' ? t('subagentModeOneShot') : t('subagentModeContinuable')} · ${state}`
}
return state
}
/** The mono meta line of a workflow run card: status · member tally. */
export function workflowMeta(node: TasksWorkflowNode): string {
const { run } = node
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 `${t(workflowStatusKey(run.status))} · ${t('workflowMembers', { done, total })}`
}
/** The workflow run status label key. */
export 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'
}
parts.push(node.activity === 'running' ? t('subagentRunning') : t('subagentInactive'))
if (node.team?.model !== undefined) parts.push(node.team.model)
return parts.join(' · ')
}
/**
* The live activity row of a RUNNING agent node: the tool line renders as
* "glyph + tool + args" (the approved live-line format), plus the flattened
* last text line underneath. A running node with neither reads "thinking…".
* The live activity row of a RUNNING agent: "glyph + tool + args" (the
* approved format), plus the flattened last text line underneath. A running
* node with neither reads as thinking.
*/
export function LiveLine(props: { live: LastActivity | undefined }): ReactNode {
const { live } = props
if (live?.text === undefined && live?.tool === undefined) {
return <span className={css.liveLine}>{t('subagentThinking')}</span>
return <span className={css.nodeMeta}>{t('subagentThinking')}</span>
}
return (
<>
{live.tool !== undefined && (
<span className={css.liveLine}>
<span className={css.liveGlyph} aria-hidden="true">{toolGlyph(live.tool.name)(10)}</span>
<span className={css.live}>
<span className={css.liveGlyph} aria-hidden="true">{toolGlyph(live.tool.name)(8)}</span>
<span className={css.liveTool}>{live.tool.name}</span>
{live.tool.args !== '' && (
<span className={css.liveArgs}>{preview(live.tool.args)}</span>
)}
{live.tool.args !== '' && <span className={css.liveArgs}>{preview(live.tool.args)}</span>}
</span>
)}
{live.text !== undefined && (
<span className={css.liveText}>{flatten(live.text)}</span>
<span className={css.liveText}>{preview(flatten(live.text), TEXT_PREVIEW)}</span>
)}
</>
)
@@ -84,5 +125,5 @@ export function LiveLine(props: { live: LastActivity | undefined }): ReactNode {
/** The fold aggregate's subtitle: up to two label previews joined by ·. */
export function foldPreviews(previews: readonly string[]): string {
return previews.join(' · ')
return previews.join(' / ')
}
+69 -8
View File
@@ -1,6 +1,8 @@
/**
* Unit tests for the layered graph layout (layoutTasksGraph): parent
* centering over children bands, depth rows, and live-line heights.
* Unit tests for the layered graph layout: parent centering, depth rows, the
* sibling-band WRAP that keeps a narrow sidebar readable (and the row
* reservation that stops a wrapped band from landing on a sibling's
* children), the live-line height, and the width-aware solver.
*/
import { describe, expect, it } from 'vitest'
import {
@@ -9,7 +11,10 @@ import {
GRAPH_NODE_H,
GRAPH_NODE_W,
GRAPH_PAD,
GRAPH_ROW_STRIDE,
bandColsFor,
layoutTasksGraph,
layoutTasksGraphForWidth,
} from '../src/client/tasks-graph-layout.ts'
import type { TasksAgentNode, TasksNode } from '../src/client/tasks-model.ts'
@@ -22,14 +27,21 @@ function agent(id: string, parentId?: string, live = false): TasksAgentNode {
}
}
/** The row index of one laid-out node. */
function layoutRow(layout: ReturnType<typeof layoutTasksGraph>, id: string): number {
const box = layout.boxes.get(id)
if (box === undefined) throw new Error(`no box for ${id}`)
return (box.y - GRAPH_PAD) / GRAPH_ROW_STRIDE
}
describe('layoutTasksGraph', () => {
it('places the root on row 0 and children one row below', () => {
const nodes: TasksNode[] = [agent('root'), agent('a', 'root'), agent('b', 'root')]
const layout = layoutTasksGraph(nodes)
expect(layout.boxes.get('root')).toMatchObject({ y: GRAPH_PAD, h: GRAPH_NODE_H })
const rowY = GRAPH_PAD + GRAPH_NODE_H + GRAPH_LIVE_H + GRAPH_GAP_Y
expect(layout.boxes.get('a')?.y).toBe(rowY)
expect(layout.boxes.get('b')?.y).toBe(rowY)
// A running agent reserves the live-line row (its "thinking" fallback).
expect(layout.boxes.get('root')).toMatchObject({ y: GRAPH_PAD, h: GRAPH_NODE_H + GRAPH_LIVE_H })
expect(layout.boxes.get('a')?.y).toBe(GRAPH_PAD + GRAPH_ROW_STRIDE)
expect(layout.boxes.get('b')?.y).toBe(GRAPH_PAD + GRAPH_ROW_STRIDE)
})
it('centers a parent over its children band', () => {
@@ -39,11 +51,11 @@ describe('layoutTasksGraph', () => {
const b = layout.boxes.get('b')
const root = layout.boxes.get('root')
expect(a).toBeDefined(); expect(b).toBeDefined(); expect(root).toBeDefined()
const bandCenter = ((a!.x + b!.x + GRAPH_NODE_W) / 2)
const bandCenter = (a!.x + b!.x + GRAPH_NODE_W) / 2
expect(root!.x + GRAPH_NODE_W / 2).toBeCloseTo(bandCenter, 5)
})
it('widens the canvas for deep/wide forests and grows live rows', () => {
it('grows live rows and widens the canvas for deep/wide forests', () => {
const nodes: TasksNode[] = [
agent('root'), agent('a', 'root', true),
agent('a1', 'a'), agent('a2', 'a'), agent('a3', 'a'),
@@ -60,4 +72,53 @@ describe('layoutTasksGraph', () => {
expect(layout.boxes.get('root')).toBeDefined()
expect(layout.boxes.get('ghost')).toBeDefined()
})
it('wraps a wide sibling band into rows of maxBandCols', () => {
const nodes: TasksNode[] = [agent('root')]
for (let index = 0; index < 5; index += 1) nodes.push(agent(`c${index}`, 'root'))
const layout = layoutTasksGraph(nodes, { maxBandCols: 2 })
// Three wrapped rows of 2/2/1, never five on one line.
expect([layoutRow(layout, 'c0'), layoutRow(layout, 'c1')]).toEqual([1, 1])
expect([layoutRow(layout, 'c2'), layoutRow(layout, 'c3')]).toEqual([2, 2])
expect(layoutRow(layout, 'c4')).toBe(3)
// The canvas stays as narrow as one wrapped band.
expect(layout.width).toBeLessThanOrEqual(2 * GRAPH_NODE_W + 28 + 2 * GRAPH_PAD)
})
it('never lets a wrapped band land on a sibling subtree row', () => {
// root → [a (with a child), b, c, d] wrapped at 2 columns: the second
// band must start BELOW a's child, not on the same row as it.
const nodes: TasksNode[] = [
agent('root'), agent('a', 'root'), agent('b', 'root'),
agent('c', 'root'), agent('d', 'root'), agent('a1', 'a'),
]
const layout = layoutTasksGraph(nodes, { maxBandCols: 2 })
expect(layoutRow(layout, 'c')).toBeGreaterThan(layoutRow(layout, 'a1'))
expect(layoutRow(layout, 'd')).toBe(layoutRow(layout, 'c'))
})
})
describe('bandColsFor / layoutTasksGraphForWidth', () => {
it('derives the column count from the container width', () => {
expect(bandColsFor(360)).toBe(2)
expect(bandColsFor(720)).toBe(4)
expect(bandColsFor(120)).toBe(1)
})
it('prefers a flat band when the container is wide', () => {
const nodes: TasksNode[] = [agent('root')]
for (let index = 0; index < 4; index += 1) nodes.push(agent(`c${index}`, 'root'))
const wide = layoutTasksGraphForWidth(nodes, 720)
expect(['c0', 'c1', 'c2', 'c3'].map(id => layoutRow(wide, id))).toEqual([1, 1, 1, 1])
})
it('wraps instead of overflowing wildly when the container is narrow', () => {
const nodes: TasksNode[] = [agent('root')]
for (let index = 0; index < 6; index += 1) nodes.push(agent(`c${index}`, 'root'))
const narrow = layoutTasksGraphForWidth(nodes, 360)
// Never wider than the tolerant budget (a small pan is allowed, a 3x
// overflow is not), and never a one-child-per-row ladder.
expect(narrow.width).toBeLessThanOrEqual(((360 - 24) / 0.78) * 1.2)
expect(layoutRow(narrow, 'c1')).toBe(layoutRow(narrow, 'c0'))
})
})
+75 -1
View File
@@ -53,12 +53,15 @@ function jsonResponse(value: unknown): Response {
return { ok: true, status: 200, json: async () => value } as unknown as Response
}
let teamPayload: unknown = { available: false }
beforeEach(() => {
teamPayload = { available: false }
vi.stubGlobal('fetch', async (url: string | URL | Request) => {
const method = String(url).split('/').pop()
if (method === 'subagents.live') return jsonResponse({ ok: true, value: { live: {} } })
if (method === 'workflows.list') return jsonResponse({ ok: true, value: { runs: [] } })
if (method === 'teams.view') return jsonResponse({ ok: true, value: { available: false } })
if (method === 'teams.view') return jsonResponse({ ok: true, value: teamPayload })
if (method === 'jobs.output') return jsonResponse({ ok: true, value: { text: 'out', truncated: false, read: true } })
throw new Error(`unexpected fetch ${String(url)}`)
})
@@ -183,3 +186,74 @@ describe('Tasks page interactions', () => {
unmount()
})
})
describe('Tasks page graph interactions and team board', () => {
it('activates a graph node after a background pointerdown (no click theft)', async () => {
const opened: unknown[] = []
const store = makeStore(snapshotWithChildren(1))
const ctx = makeCtx(store, { openSubagent: (address) => { opened.push(address) } })
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx }),
)
// Unfold so the settled child is visible, then simulate the real gesture
// order on the canvas background before clicking the node.
const foldToggle = container.querySelector('[data-graph-controls] button:nth-child(2)') as HTMLButtonElement
await act(async () => { foldToggle.click() })
const canvas = container.querySelector('[role="group"]') as HTMLElement
await act(async () => {
canvas.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 5, clientY: 5 }))
window.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
})
const node = container.querySelector('[data-graph-node="child-0"]') as HTMLElement
expect(node).not.toBeNull()
await act(async () => { node.click() })
expect(opened).toEqual([{ parentSessionId: 'root', childSessionId: 'child-0', mode: 'one-shot' }])
unmount()
})
it('never starts a pan from a node (the gesture belongs to the node)', async () => {
const store = makeStore(snapshotWithChildren(1))
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
const foldToggle = container.querySelector('[data-graph-controls] button:nth-child(2)') as HTMLButtonElement
await act(async () => { foldToggle.click() })
const node = container.querySelector('[data-graph-node="child-0"]') as HTMLElement
const inner = node.parentElement as HTMLElement
const before = inner.style.transform
await act(async () => {
node.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 10, clientY: 10 }))
window.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 80, clientY: 90 }))
window.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
})
expect(inner.style.transform).toBe(before)
unmount()
})
it('shows the team board without any click when the root leads a team', async () => {
teamPayload = {
available: true,
team: {
members: [
{ id: 'root', name: 'lead', role: 'lead', status: 'running', diagnostics: [] },
{ id: 'child-0', name: 'writer', role: 'teammate', status: 'idle', model: 'glm-5.3', diagnostics: [] },
],
tasks: [{
id: 't1', revision: 1, subject: '收窄卡片', description: '', status: 'in_progress',
blockedBy: [], writeScopes: [], ready: true, writeScopeWarnings: [],
}],
},
}
const store = makeStore(snapshotWithChildren(1))
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
await act(async () => { await Promise.resolve() })
// Visible by default: header, members and the task row (no chip to click).
expect(container.textContent).toContain('团队任务板')
expect(container.textContent).toContain('lead')
expect(container.textContent).toContain('writer')
expect(container.textContent).toContain('收窄卡片')
unmount()
})
})