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:
@@ -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
@@ -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>}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -409,6 +409,8 @@ export const ar: Record<string, string> = {
|
||||
tasksZoomOut: 'تصغير',
|
||||
tasksZoomFit: 'ملاءمة اللوحة',
|
||||
tasksNodeJump: 'فتح النسخة',
|
||||
tasksNodeDetail: 'تفاصيل العقدة',
|
||||
workflowRun: 'سير العمل',
|
||||
tasksNodeState: 'الحالة',
|
||||
tasksNodeMode: 'الوضع',
|
||||
tasksNodeModel: 'الطراز',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -408,6 +408,8 @@ export const hi: Record<string, string> = {
|
||||
tasksZoomOut: 'ज़ूम आउट',
|
||||
tasksZoomFit: 'कैनवास में फ़िट करें',
|
||||
tasksNodeJump: 'ट्रांसक्रिप्ट खोलें',
|
||||
tasksNodeDetail: 'नोड विवरण',
|
||||
workflowRun: 'वर्कफ़्लो',
|
||||
tasksNodeState: 'स्थिति',
|
||||
tasksNodeMode: 'मोड',
|
||||
tasksNodeModel: 'मॉडल',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -408,6 +408,8 @@ export const ja: Record<string, string> = {
|
||||
tasksZoomOut: '縮小',
|
||||
tasksZoomFit: 'キャンバスに合わせる',
|
||||
tasksNodeJump: 'トランスクリプトを開く',
|
||||
tasksNodeDetail: 'ノード詳細',
|
||||
workflowRun: 'ワークフロー',
|
||||
tasksNodeState: '状態',
|
||||
tasksNodeMode: 'モード',
|
||||
tasksNodeModel: 'モデル',
|
||||
|
||||
@@ -400,6 +400,8 @@ export const ko: Record<string, string> = {
|
||||
tasksZoomOut: '축소',
|
||||
tasksZoomFit: '캔버스에 맞추기',
|
||||
tasksNodeJump: '대화 기록 보기',
|
||||
tasksNodeDetail: '노드 상세',
|
||||
workflowRun: '워크플로',
|
||||
tasksNodeState: '상태',
|
||||
tasksNodeMode: '모드',
|
||||
tasksNodeModel: '모델',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -406,6 +406,8 @@ export const ru: Record<string, string> = {
|
||||
tasksZoomOut: 'Уменьшить',
|
||||
tasksZoomFit: 'По размеру холста',
|
||||
tasksNodeJump: 'Открыть транскрипт',
|
||||
tasksNodeDetail: 'Сведения об узле',
|
||||
workflowRun: 'Рабочий процесс',
|
||||
tasksNodeState: 'Состояние',
|
||||
tasksNodeMode: 'Режим',
|
||||
tasksNodeModel: 'Модель',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -408,6 +408,8 @@ export const th: Record<string, string> = {
|
||||
tasksZoomOut: 'ซูมออก',
|
||||
tasksZoomFit: 'พอดีผืนผ้าใบ',
|
||||
tasksNodeJump: 'เปิดบันทึกถอดความ',
|
||||
tasksNodeDetail: 'รายละเอียดโหนด',
|
||||
workflowRun: 'เวิร์กโฟลว์',
|
||||
tasksNodeState: 'สถานะ',
|
||||
tasksNodeMode: 'โหมด',
|
||||
tasksNodeModel: 'โมเดล',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -423,6 +423,8 @@ export const zhHK: Record<string, string> = {
|
||||
tasksZoomOut: '縮小',
|
||||
tasksZoomFit: '適應畫布',
|
||||
tasksNodeJump: '檢視逐字稿',
|
||||
tasksNodeDetail: '節點詳情',
|
||||
workflowRun: '工作流',
|
||||
tasksNodeState: '狀態',
|
||||
tasksNodeMode: '模式',
|
||||
tasksNodeModel: '模型',
|
||||
|
||||
@@ -423,6 +423,8 @@ export const zhMO: Record<string, string> = {
|
||||
tasksZoomOut: '縮小',
|
||||
tasksZoomFit: '適應畫布',
|
||||
tasksNodeJump: '檢視逐字稿',
|
||||
tasksNodeDetail: '節點詳情',
|
||||
workflowRun: '工作流',
|
||||
tasksNodeState: '狀態',
|
||||
tasksNodeMode: '模式',
|
||||
tasksNodeModel: '模型',
|
||||
|
||||
@@ -423,6 +423,8 @@ export const zhTW: Record<string, string> = {
|
||||
tasksZoomOut: '縮小',
|
||||
tasksZoomFit: '適應畫布',
|
||||
tasksNodeJump: '檢視逐字稿',
|
||||
tasksNodeDetail: '節點詳情',
|
||||
workflowRun: '工作流',
|
||||
tasksNodeState: '狀態',
|
||||
tasksNodeMode: '模式',
|
||||
tasksNodeModel: '模型',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
+621
-331
File diff suppressed because it is too large
Load Diff
+72
-31
@@ -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(' / ')
|
||||
}
|
||||
|
||||
@@ -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'))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user