Files
apes-sidebar/src/client/tasks-graph-layout.ts
T
Menghuan1918 f584b67813 feat(tasks): 图节点改为双段式卡片 + 折叠聚合 + 窄栏可读性
图模式(主显示模式)换成新的卡片语言,树模式保持不变:

- 卡片 = 上段(类型徽章 + 相位徽标 + 名称两行 + mono 元信息 + 团队任务行)
  + 下段小条(状态点 + 状态词 + 主 Agent 同款合并活动 + 完成后的折叠 chevron)。
  类型徽章用页面自己的词汇(主代理 / 子代理 / 成员 / 工作流 / ✓N 已完成)。
- 分组色(层级):按树深度取 4 档令牌色阶(color-mix 混 --dsw-alias-state-business-primary),
  作用于左描边与上段极淡底色;相位徽标走 ink 色族,两套分组不撞色。
  相位虚线框保留但改用同色极淡描边,卡片徽标承担「这是哪个阶段」。
- 执行态:小条 `data-running` + 从左到右扫光(1.5s,末 1/3 停顿,
  prefers-reduced-motion 关闭);合并活动行来自宿主 chat 词条,缺失时回退插件文案。
- 折叠:已完成节点的 chevron 把该节点「收进已完成聚合」(模型新增 foldedIds,
  与全局 folded 走同一组守卫:只在已结算叶子、非当前会话、非团队成员时生效);
  聚合节点点击 = 全部展开(清空手动集 + 关闭全局折叠);控制条仍只管全局规则。
- 布局与窄栏(用户反馈 a–f):卡片高度预算按新行重算(徽章 + 两行标题 + 元信息 +
  小条 = 103px,任务行 +18),宽度 176 → 190;fit 按内容包围盒双轴居中、
  FIT_MAX_SCALE 1.15 → 1.30;缩放范围 0.4–2.0 并归一化 wheel deltaMode;
  平移加边界夹取(至少 72px 内容留在视口内);空画布双击重新适配并恢复自动 fit;
  连线改「垂直出线 stub + 贝塞尔」并加粗到 1.5px。

验证:typecheck / lint 通过;pnpm test 1253 passed / 9 skipped(含新增双段结构、
data-running、chevron → 聚合、聚合 → 全部展开、工作流相位徽标 5 例)。

(cherry picked from commit aee571e50beb5dc979995f0841cffd0a575f2173)
2026-09-27 22:18:35 +08:00

262 lines
11 KiB
TypeScript

/**
* 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). The design target is the
* NARROW native right sidebar (~360px): a 190px card fits two per row plus
* the gutter, and the card's own rows stay readable at the fit's floor. */
export const GRAPH_NODE_W = 190
/**
* The card's heights are the SUM OF THE ROWS THE CARD RENDERS, in the real
* metrics of the page's type scale (the same numbers the rules in
* tasks-graph.module.css are written with). They are derived rather than
* hand-tuned because the card is a fixed-geometry box: its height is `auto`
* with `min-height: box.h`, so an under-reserved card does not stay inside the
* box the layout handed it — it RENDERS TALLER, and everything the layout
* derives from `box.h` (the row pitch, the phase frames, the canvas extent)
* then disagrees with the pixels on screen.
*
* The card is TWO SEGMENTS (tasks-card.tsx): a top segment carrying the kind
* badge, the clamped name, the mono meta (and, for a team member, the shared
* task line) and a bottom state bar carrying the state dot, the state word and
* the merged process activity. Every node kind reserves the SAME base height —
* a settled node shows its activity summary in that bar just like a running
* one, so the two can never disagree with the layout.
*
* Measured on the rendered page before this structure existed: a running
* teammate card carrying a two-line title, its meta, a live row and a task line
* rendered 100px while the base reserve handed it 68 — `min-height` cannot hold
* it down, so the card ate 32px of the 53px gutter to the next row and poked
* out of its phase frame.
*/
const NODE_EDGE_H = 1
/** The card's own border only: the two segments carry their own padding. */
const NODE_CHROME_H = 2 * NODE_EDGE_H
/** `.cardTop`'s vertical padding (6px top / 5px bottom). */
const NODE_PAD_TOP = 6
const NODE_PAD_BOTTOM = 5
/** One badge row — `--dsw-font-xxxs-11` (11px/14px) plus its 2px lead. */
const NODE_BADGE_H = 14 + 2
/** One title line — `--dsw-font-xxs-strong-12` (12px/18px). */
const NODE_TITLE_LINE_H = 18
/** Title lines the card reserves: the clamp in tasks-graph.module.css. */
const NODE_TITLE_LINES = 2
/** The mono meta line — `--dsw-font-xxxs-11` (11px/14px) plus its 2px lead. */
const NODE_META_H = 14 + 2
/** The state bar — `.cardBar`'s fixed height (its top border sits inside it). */
const NODE_BAR_H = 22
/** The shared-task row: its 10px/1.3 line plus the 3px lead, sized for the
* `+N` chip's own 1px outline (the tallest shape the row can take). */
const NODE_TASK_H = 15 + 3
/** Badges + TWO title lines + the mono meta line + the state bar: long agent
* names are clamped to two lines instead of ellipsizing to a couple of
* characters (see `.cardName` in tasks-graph.module.css), so the reserve
* covers that second line. */
export const GRAPH_NODE_H = NODE_CHROME_H + NODE_PAD_TOP + NODE_BADGE_H
+ NODE_TITLE_LINES * NODE_TITLE_LINE_H + NODE_META_H + NODE_PAD_BOTTOM + NODE_BAR_H
/** Extra height of an agent node carrying a shared-task line (team members). */
export const GRAPH_TASK_H = NODE_TASK_H
/**
* The readability floor the fit honours: the canvas may shrink to this scale
* before the layout starts wrapping (TasksGraph's fit and {@link bandColsFor}'s
* column budget must agree, so the constant lives here, next to the geometry).
*/
export const GRAPH_FIT_MIN_SCALE = 0.78
export const GRAPH_GAP_X = 28
export const GRAPH_GAP_Y = 53
export const GRAPH_PAD = 16
/** The row stride (one row of cards plus the vertical gutter). Every card
* shape must fit inside it — see {@link nodeHeight} for the tallest one. */
export const GRAPH_ROW_STRIDE = GRAPH_NODE_H + GRAPH_GAP_Y
/** One laid-out node. */
export interface GraphBox {
id: string
x: number
y: number
w: number
h: number
}
/** The layout result: one box per node plus the canvas extent. */
export interface GraphLayout {
boxes: ReadonlyMap<string, GraphBox>
width: number
height: number
}
/** 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: the base card plus the shared-task row a
* team member renders. Nothing else varies — the merged activity lives in the
* state bar that every card renders, whatever its state.
*/
function nodeHeight(node: TasksNode): number {
if (node.kind !== 'agent') return GRAPH_NODE_H
return (node.tasks?.length ?? 0) > 0 ? GRAPH_NODE_H + GRAPH_TASK_H : GRAPH_NODE_H
}
/**
* 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.
*/
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)
if (child === undefined) continue
const list = childrenOf.get(edge.from)
if (list === undefined) childrenOf.set(edge.from, [child])
else list.push(child)
}
/** Post-order: the width of the subtree rooted at `node`. */
const subtreeWidth = (node: TasksNode): number => {
const children = childrenOf.get(node.id) ?? []
if (children.length === 0) return GRAPH_NODE_W
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>()
/**
* 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 + row * GRAPH_ROW_STRIDE,
w: GRAPH_NODE_W,
h: nodeHeight(node),
})
const children = childrenOf.get(node.id) ?? []
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
// defensive orphan still lands inside the canvas extent.
let offset = GRAPH_PAD
for (const root of roots) {
const width = subtreeWidth(root)
place(root, offset + width / 2, 0)
offset += width + GRAPH_GAP_X
}
const allBoxes = [...boxes.values()]
const width = Math.max(GRAPH_PAD * 2 + GRAPH_NODE_W, ...allBoxes.map(box => box.x + box.w + GRAPH_PAD))
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, minScale = GRAPH_FIT_MIN_SCALE): number {
// Columns are budgeted at the readability floor, not at 1:1: the fit may
// scale the canvas DOWN to `minScale` and still be readable, so a narrow
// panel keeps two columns (scaled ~0.86 at 360px) instead of degenerating
// into a one-card-per-row ladder. `layoutTasksGraphForWidth` re-checks the
// real width against the same budget, so a too-wide pick is still narrowed.
const usable = (containerWidth <= 0 ? 360 : containerWidth) / minScale - 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 })
}