fix(tasks-ui): postmodern chrome, working clicks, adaptive layout, lean cards

Review feedback from the live 3384 install, fixed at the root:

- COLORS: the new stylesheet used var(--dsw-alias-accent) in 17 places — a
  token the DSH theme does NOT define, so every declaration was dropped
  (older files only survived because they carry fallbacks); container edges
  used border-l1 (4% black, invisible). Accent now rides
  --dsw-alias-state-business-primary, container edges --dsw-alias-border-l4.
- CLICKS: the canvas called setPointerCapture on pointerdown, which
  retargets the derived click to the container and killed every node (and
  ⓘ) interaction; jsdom cannot see this, a real browser can. Capture is
  gone and panning only starts from the background.
- LAYOUT: fit gave up silently at zero container size and never retried,
  centered only horizontally, and capped at 100% while a five-child level
  scaled to 35%. Now: ResizeObserver + first non-zero fit, both axes
  centered, a 0.78 readability floor, and layoutTasksGraphForWidth picks
  the flattest sibling arrangement that still meets that floor.
- CARDS: title (one line) + mono meta + live line only; display title,
  team role, full model id and latest text moved into the ⓘ popover.
- PANEL: metrics follow the approved 360x660 mockup (132x46 cards, 112 row
  stride), explicit line-heights, user-select:none, horizontal control
  cluster with a labelled mode button, always-visible team board strip.
- Tokens for two new popover headings across all 20 dictionaries.

Tests: 3 new page-interaction specs (click after background pointerdown,
node gestures never pan, team board visible without a click) and a
rewritten 9-case layout suite (band wrap, row reservation, width solver,
reserved live row). Full suite 1400 passed / 133 files.
This commit is contained in:
Menghuan1918
2026-09-27 22:18:19 +08:00
parent 9b6628602e
commit 490e96080a
33 changed files with 1744 additions and 1046 deletions
+69 -8
View File
@@ -1,6 +1,8 @@
/**
* Unit tests for the layered graph layout (layoutTasksGraph): parent
* centering over children bands, depth rows, and live-line heights.
* Unit tests for the layered graph layout: parent centering, depth rows, the
* sibling-band WRAP that keeps a narrow sidebar readable (and the row
* reservation that stops a wrapped band from landing on a sibling's
* children), the live-line height, and the width-aware solver.
*/
import { describe, expect, it } from 'vitest'
import {
@@ -9,7 +11,10 @@ import {
GRAPH_NODE_H,
GRAPH_NODE_W,
GRAPH_PAD,
GRAPH_ROW_STRIDE,
bandColsFor,
layoutTasksGraph,
layoutTasksGraphForWidth,
} from '../src/client/tasks-graph-layout.ts'
import type { TasksAgentNode, TasksNode } from '../src/client/tasks-model.ts'
@@ -22,14 +27,21 @@ function agent(id: string, parentId?: string, live = false): TasksAgentNode {
}
}
/** The row index of one laid-out node. */
function layoutRow(layout: ReturnType<typeof layoutTasksGraph>, id: string): number {
const box = layout.boxes.get(id)
if (box === undefined) throw new Error(`no box for ${id}`)
return (box.y - GRAPH_PAD) / GRAPH_ROW_STRIDE
}
describe('layoutTasksGraph', () => {
it('places the root on row 0 and children one row below', () => {
const nodes: TasksNode[] = [agent('root'), agent('a', 'root'), agent('b', 'root')]
const layout = layoutTasksGraph(nodes)
expect(layout.boxes.get('root')).toMatchObject({ y: GRAPH_PAD, h: GRAPH_NODE_H })
const rowY = GRAPH_PAD + GRAPH_NODE_H + GRAPH_LIVE_H + GRAPH_GAP_Y
expect(layout.boxes.get('a')?.y).toBe(rowY)
expect(layout.boxes.get('b')?.y).toBe(rowY)
// A running agent reserves the live-line row (its "thinking" fallback).
expect(layout.boxes.get('root')).toMatchObject({ y: GRAPH_PAD, h: GRAPH_NODE_H + GRAPH_LIVE_H })
expect(layout.boxes.get('a')?.y).toBe(GRAPH_PAD + GRAPH_ROW_STRIDE)
expect(layout.boxes.get('b')?.y).toBe(GRAPH_PAD + GRAPH_ROW_STRIDE)
})
it('centers a parent over its children band', () => {
@@ -39,11 +51,11 @@ describe('layoutTasksGraph', () => {
const b = layout.boxes.get('b')
const root = layout.boxes.get('root')
expect(a).toBeDefined(); expect(b).toBeDefined(); expect(root).toBeDefined()
const bandCenter = ((a!.x + b!.x + GRAPH_NODE_W) / 2)
const bandCenter = (a!.x + b!.x + GRAPH_NODE_W) / 2
expect(root!.x + GRAPH_NODE_W / 2).toBeCloseTo(bandCenter, 5)
})
it('widens the canvas for deep/wide forests and grows live rows', () => {
it('grows live rows and widens the canvas for deep/wide forests', () => {
const nodes: TasksNode[] = [
agent('root'), agent('a', 'root', true),
agent('a1', 'a'), agent('a2', 'a'), agent('a3', 'a'),
@@ -60,4 +72,53 @@ describe('layoutTasksGraph', () => {
expect(layout.boxes.get('root')).toBeDefined()
expect(layout.boxes.get('ghost')).toBeDefined()
})
it('wraps a wide sibling band into rows of maxBandCols', () => {
const nodes: TasksNode[] = [agent('root')]
for (let index = 0; index < 5; index += 1) nodes.push(agent(`c${index}`, 'root'))
const layout = layoutTasksGraph(nodes, { maxBandCols: 2 })
// Three wrapped rows of 2/2/1, never five on one line.
expect([layoutRow(layout, 'c0'), layoutRow(layout, 'c1')]).toEqual([1, 1])
expect([layoutRow(layout, 'c2'), layoutRow(layout, 'c3')]).toEqual([2, 2])
expect(layoutRow(layout, 'c4')).toBe(3)
// The canvas stays as narrow as one wrapped band.
expect(layout.width).toBeLessThanOrEqual(2 * GRAPH_NODE_W + 28 + 2 * GRAPH_PAD)
})
it('never lets a wrapped band land on a sibling subtree row', () => {
// root → [a (with a child), b, c, d] wrapped at 2 columns: the second
// band must start BELOW a's child, not on the same row as it.
const nodes: TasksNode[] = [
agent('root'), agent('a', 'root'), agent('b', 'root'),
agent('c', 'root'), agent('d', 'root'), agent('a1', 'a'),
]
const layout = layoutTasksGraph(nodes, { maxBandCols: 2 })
expect(layoutRow(layout, 'c')).toBeGreaterThan(layoutRow(layout, 'a1'))
expect(layoutRow(layout, 'd')).toBe(layoutRow(layout, 'c'))
})
})
describe('bandColsFor / layoutTasksGraphForWidth', () => {
it('derives the column count from the container width', () => {
expect(bandColsFor(360)).toBe(2)
expect(bandColsFor(720)).toBe(4)
expect(bandColsFor(120)).toBe(1)
})
it('prefers a flat band when the container is wide', () => {
const nodes: TasksNode[] = [agent('root')]
for (let index = 0; index < 4; index += 1) nodes.push(agent(`c${index}`, 'root'))
const wide = layoutTasksGraphForWidth(nodes, 720)
expect(['c0', 'c1', 'c2', 'c3'].map(id => layoutRow(wide, id))).toEqual([1, 1, 1, 1])
})
it('wraps instead of overflowing wildly when the container is narrow', () => {
const nodes: TasksNode[] = [agent('root')]
for (let index = 0; index < 6; index += 1) nodes.push(agent(`c${index}`, 'root'))
const narrow = layoutTasksGraphForWidth(nodes, 360)
// Never wider than the tolerant budget (a small pan is allowed, a 3x
// overflow is not), and never a one-child-per-row ladder.
expect(narrow.width).toBeLessThanOrEqual(((360 - 24) / 0.78) * 1.2)
expect(layoutRow(narrow, 'c1')).toBe(layoutRow(narrow, 'c0'))
})
})
+75 -1
View File
@@ -53,12 +53,15 @@ function jsonResponse(value: unknown): Response {
return { ok: true, status: 200, json: async () => value } as unknown as Response
}
let teamPayload: unknown = { available: false }
beforeEach(() => {
teamPayload = { available: false }
vi.stubGlobal('fetch', async (url: string | URL | Request) => {
const method = String(url).split('/').pop()
if (method === 'subagents.live') return jsonResponse({ ok: true, value: { live: {} } })
if (method === 'workflows.list') return jsonResponse({ ok: true, value: { runs: [] } })
if (method === 'teams.view') return jsonResponse({ ok: true, value: { available: false } })
if (method === 'teams.view') return jsonResponse({ ok: true, value: teamPayload })
if (method === 'jobs.output') return jsonResponse({ ok: true, value: { text: 'out', truncated: false, read: true } })
throw new Error(`unexpected fetch ${String(url)}`)
})
@@ -183,3 +186,74 @@ describe('Tasks page interactions', () => {
unmount()
})
})
describe('Tasks page graph interactions and team board', () => {
it('activates a graph node after a background pointerdown (no click theft)', async () => {
const opened: unknown[] = []
const store = makeStore(snapshotWithChildren(1))
const ctx = makeCtx(store, { openSubagent: (address) => { opened.push(address) } })
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx }),
)
// Unfold so the settled child is visible, then simulate the real gesture
// order on the canvas background before clicking the node.
const foldToggle = container.querySelector('[data-graph-controls] button:nth-child(2)') as HTMLButtonElement
await act(async () => { foldToggle.click() })
const canvas = container.querySelector('[role="group"]') as HTMLElement
await act(async () => {
canvas.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 5, clientY: 5 }))
window.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
})
const node = container.querySelector('[data-graph-node="child-0"]') as HTMLElement
expect(node).not.toBeNull()
await act(async () => { node.click() })
expect(opened).toEqual([{ parentSessionId: 'root', childSessionId: 'child-0', mode: 'one-shot' }])
unmount()
})
it('never starts a pan from a node (the gesture belongs to the node)', async () => {
const store = makeStore(snapshotWithChildren(1))
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
const foldToggle = container.querySelector('[data-graph-controls] button:nth-child(2)') as HTMLButtonElement
await act(async () => { foldToggle.click() })
const node = container.querySelector('[data-graph-node="child-0"]') as HTMLElement
const inner = node.parentElement as HTMLElement
const before = inner.style.transform
await act(async () => {
node.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 10, clientY: 10 }))
window.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 80, clientY: 90 }))
window.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
})
expect(inner.style.transform).toBe(before)
unmount()
})
it('shows the team board without any click when the root leads a team', async () => {
teamPayload = {
available: true,
team: {
members: [
{ id: 'root', name: 'lead', role: 'lead', status: 'running', diagnostics: [] },
{ id: 'child-0', name: 'writer', role: 'teammate', status: 'idle', model: 'glm-5.3', diagnostics: [] },
],
tasks: [{
id: 't1', revision: 1, subject: '收窄卡片', description: '', status: 'in_progress',
blockedBy: [], writeScopes: [], ready: true, writeScopeWarnings: [],
}],
},
}
const store = makeStore(snapshotWithChildren(1))
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
await act(async () => { await Promise.resolve() })
// Visible by default: header, members and the task row (no chip to click).
expect(container.textContent).toContain('团队任务板')
expect(container.textContent).toContain('lead')
expect(container.textContent).toContain('writer')
expect(container.textContent).toContain('收窄卡片')
unmount()
})
})