Files
apes-sidebar/tests/tasks-graph-layout.spec.ts
T
Menghuan1918 b554665bde feat(tasks-ui): readability pass, height-budget fix and leftover sweep
The native port is finished off with the readability work the earlier rounds
left open, plus the cleanup of everything the shadcn era or the older canvas
left behind.

- Geometry: node height is now composed from named parts (chrome, two title
  lines, meta row) and per-node extras, so a teammate card that carries a task
  line no longer gets clipped by the old fixed 68px budget; the layout spec
  follows the new model and GRAPH_ROW_STRIDE stays exported for it.
- Readability: row rhythm (28-32px), three ink levels for title/meta/owner,
  truncation with title hints, current-row and hover expressed as an accent bar
  plus a hover fill (no shadows), phase frames and edges back on the l1/l2
  hairlines, control cluster uniformly 28px, drawer durations on tabular
  figures, kill action revealed on hover/focus without shifting the row, no
  horizontal overflow at 360px.
- Task window: the markdown body is pulled onto the page scale (12px body,
  12/13px headings, tightened list indent and paragraph spacing) while the
  shared MarkdownText/markdownTextProps contract is untouched.
- Cleanup: dead stylesheet rules, unused TSX exports and helpers, stale
  comments that still described the retired Variant-D canvas, zero-reference
  i18n keys across the dictionaries, and the Tailwind/shadcn/Variant-D
  narrative in the design doc, AGENTS.md and the plugin guide (kept as one
  short "why it was rolled back" section).

Verified here: typecheck and lint clean; 133 files / 1413 tests; build with no
radix-ui or --tw- strings in lib/client.js (988.9 KiB); the 70-assertion
real-browser contract at 70/70 with the 20 reference screenshots regenerated;
mount e2e 7/7.
2026-09-27 22:18:19 +08:00

195 lines
8.6 KiB
TypeScript

/**
* 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 PER-SHAPE card height (the live rows and the shared-task row
* a card renders are reserved, so a card never renders outside its box), and
* the width-aware solver.
*/
import { describe, expect, it } from 'vitest'
import {
GRAPH_GAP_Y,
GRAPH_LIVE_H,
GRAPH_LIVE_TAIL_H,
GRAPH_NODE_H,
GRAPH_NODE_W,
GRAPH_PAD,
GRAPH_ROW_STRIDE,
GRAPH_TASK_H,
bandColsFor,
layoutTasksGraph,
layoutTasksGraphForWidth,
} from '../src/client/tasks-graph-layout.ts'
import type { TasksAgentNode, TasksNode, TasksNodeTask } from '../src/client/tasks-model.ts'
/** One shared task as the card's task line shows it. */
function task(id: string): TasksNodeTask {
return { id, subject: id, status: 'in_progress', ready: true }
}
/** An agent node. `live` gives it a text-only tail; `extra` shapes the card
* further (a settled state, a tool call, owned shared tasks). */
function agent(
id: string,
parentId?: string,
live = false,
extra: Partial<TasksAgentNode> = {},
): TasksAgentNode {
return {
kind: 'agent', id, ...(parentId === undefined ? {} : { parentId }),
label: id, state: 'running', activity: 'running', current: false,
...(live ? { live: { text: 'x' } } : {}),
...extra,
}
}
/** 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)
// 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', () => {
const nodes: TasksNode[] = [agent('root'), agent('a', 'root'), agent('b', 'root')]
const layout = layoutTasksGraph(nodes)
const a = layout.boxes.get('a')
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
expect(root!.x + GRAPH_NODE_W / 2).toBeCloseTo(bandCenter, 5)
})
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'),
]
const layout = layoutTasksGraph(nodes)
expect(layout.boxes.get('a')?.h).toBe(GRAPH_NODE_H + GRAPH_LIVE_H)
expect(layout.width).toBeGreaterThan(3 * GRAPH_NODE_W)
expect(layout.height).toBeGreaterThan(2 * (GRAPH_NODE_H + GRAPH_GAP_Y))
})
it('reserves the shared-task row a teammate card renders', () => {
// A non-running teammate with a task line renders two title lines + the
// meta line + the task row; the base card alone reserves 16px less, and
// the card (height auto) renders that much outside its own box — which is
// what the edges and the phase frames are placed from.
const nodes: TasksNode[] = [
agent('root'),
agent('mate', 'root', false, { state: 'idle', tasks: [task('t1')] }),
]
const layout = layoutTasksGraph(nodes)
expect(layout.boxes.get('mate')?.h).toBe(GRAPH_NODE_H + GRAPH_TASK_H)
// Without tasks the card keeps the base shape.
const plain = layoutTasksGraph([agent('root'), agent('mate', 'root', false, { state: 'idle' })])
expect(plain.boxes.get('mate')?.h).toBe(GRAPH_NODE_H)
})
it('reserves the live tail row only when the tail carries a tool AND text', () => {
const nodes: TasksNode[] = [
agent('root'),
agent('tool-only', 'root', false, { live: { tool: { name: 'read', args: 'x' } } }),
agent('both', 'root', false, { live: { tool: { name: 'read', args: 'x' }, text: 'y' } }),
]
const layout = layoutTasksGraph(nodes)
// One live row either way (the tool row, or the meta-styled thinking
// fallback); the flattened text row is the second one.
expect(layout.boxes.get('tool-only')?.h).toBe(GRAPH_NODE_H + GRAPH_LIVE_H)
expect(layout.boxes.get('both')?.h).toBe(GRAPH_NODE_H + GRAPH_LIVE_H + GRAPH_LIVE_TAIL_H)
})
it('keeps every card shape inside one row stride', () => {
// The stride is the row pitch: a card taller than it would overlap the
// next row (and its phase frame).
const tallest = GRAPH_NODE_H + GRAPH_LIVE_H + GRAPH_LIVE_TAIL_H + GRAPH_TASK_H
expect(GRAPH_ROW_STRIDE).toBeGreaterThanOrEqual(tallest)
const nodes: TasksNode[] = [
agent('root'),
agent('busiest', 'root', false, {
live: { tool: { name: 'read', args: 'x' }, text: 'y' }, tasks: [task('t1')],
}),
]
const layout = layoutTasksGraph(nodes)
expect(layout.boxes.get('busiest')?.h).toBe(tallest)
})
it('gives every node a box even with a defensive orphan', () => {
const nodes: TasksNode[] = [agent('root'), agent('ghost', 'missing')]
const layout = layoutTasksGraph(nodes)
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', () => {
// The budget is measured at the READABILITY FLOOR, not at 1:1: the fit may
// scale the canvas down to 0.78 and stay readable, so a 360px panel keeps
// two 176px cards per row (scaled ~0.86) instead of collapsing into a
// one-card-per-row ladder. 720px fits four at the floor (the cap), and a
// panel too narrow for even two columns falls back to one.
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)
// Four 176px cards fit one band inside the floor budget at 720px, so the
// whole sibling set stays on one row.
expect(['c0', 'c1', 'c2', 'c3'].map(id => layoutRow(wide, id))).toEqual([1, 1, 1, 1])
expect(wide.width).toBeLessThanOrEqual(720 / 0.78)
})
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'))
// 360px (the narrowest native-sidebar panel) is where the anti-ladder
// rule matters most: two cards per band, scaled to fit.
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'))
})
})