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:
@@ -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