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)
This commit is contained in:
@@ -125,7 +125,15 @@ describe('SubagentView live polling', () => {
|
||||
if (method === 'subagents.live') {
|
||||
const body = JSON.parse(String(init?.body)) as { rootSessionId?: string }
|
||||
liveCalls.push(body.rootSessionId ?? '')
|
||||
return jsonResponse({ ok: true, value: { live: { a: { running: true }, b: { running: true } } } })
|
||||
return jsonResponse({
|
||||
ok: true,
|
||||
value: {
|
||||
live: {
|
||||
a: { running: true, summary: { counts: [{ kind: 'read', count: 1 }], runningDetail: '' } },
|
||||
b: { running: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
if (method === 'jobs.list') return jsonResponse({ ok: true, value: { jobs: [] } })
|
||||
throw new Error(`unexpected fetch ${String(url)}`)
|
||||
@@ -139,7 +147,11 @@ describe('SubagentView live polling', () => {
|
||||
await act(async () => { await Promise.resolve() })
|
||||
expect(liveCalls).toEqual(['root'])
|
||||
expect(historySpy).not.toHaveBeenCalled()
|
||||
expect(container.textContent).toContain('思考中…')
|
||||
// The live map reached the cards: a running node's bar carries the sweep
|
||||
// hook, and its merged activity line renders on the bar.
|
||||
const bar = container.querySelector('[data-graph-node="a"] [data-running="true"]')
|
||||
expect(bar).not.toBeNull()
|
||||
expect(bar?.textContent).toBeTruthy()
|
||||
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(3_000) })
|
||||
expect(liveCalls).toEqual(['root', 'root'])
|
||||
@@ -235,7 +247,11 @@ describe('SubagentView live polling', () => {
|
||||
if (method === 'subagents.live') {
|
||||
const body = JSON.parse(String(init?.body)) as { rootSessionId?: string }
|
||||
liveCalls.push(body.rootSessionId ?? '')
|
||||
const live = body.rootSessionId === 'root' ? { a: { running: true, text: 'hello' } } : {}
|
||||
// `text` is the child's newest assistant line, which the card shows in
|
||||
// its detail POPOVER; the bar shows the merged activity summary.
|
||||
const live = body.rootSessionId === 'root'
|
||||
? { a: { running: true, summary: { counts: [{ kind: 'read', count: 1 }], runningDetail: 'hello' } } }
|
||||
: {}
|
||||
return jsonResponse({ ok: true, value: { live } })
|
||||
}
|
||||
if (method === 'jobs.list') return jsonResponse({ ok: true, value: { jobs: [] } })
|
||||
@@ -248,7 +264,8 @@ describe('SubagentView live polling', () => {
|
||||
)
|
||||
await act(async () => { await Promise.resolve() })
|
||||
expect(liveCalls).toEqual(['root'])
|
||||
expect(container.textContent).toContain('hello')
|
||||
const bar = container.querySelector('[data-graph-node="a"] [data-running="true"]')
|
||||
expect(bar?.textContent).toContain('hello')
|
||||
|
||||
// The tree is re-rooted under a new ancestor: old rows must not leak.
|
||||
store.set(reRootedSnapshot())
|
||||
|
||||
@@ -7,11 +7,7 @@
|
||||
* the width-aware solver.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SidebarChildLiveView } from '../src/context-types.ts'
|
||||
import {
|
||||
GRAPH_GAP_Y,
|
||||
GRAPH_LIVE_H,
|
||||
GRAPH_LIVE_TAIL_H,
|
||||
GRAPH_NODE_H,
|
||||
GRAPH_NODE_W,
|
||||
GRAPH_PAD,
|
||||
@@ -38,12 +34,19 @@ function agent(
|
||||
): TasksAgentNode {
|
||||
return {
|
||||
kind: 'agent', id, ...(parentId === undefined ? {} : { parentId }),
|
||||
depth: parentId === undefined ? 0 : 1,
|
||||
label: id, state: 'running', activity: 'running', current: false,
|
||||
...(live ? { live: { text: 'x' } } : {}),
|
||||
...(live ? { live: { running: true, text: 'x' } } : {}),
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
/** One running node's live view: a merged activity row (the bar's text). */
|
||||
const ACTIVITY = {
|
||||
running: true,
|
||||
summary: { counts: [{ kind: 'read' as const, count: 1 }], running: 'read' as const, runningDetail: 'x' },
|
||||
}
|
||||
|
||||
/** The row index of one laid-out node. */
|
||||
function layoutRow(layout: ReturnType<typeof layoutTasksGraph>, id: string): number {
|
||||
const box = layout.boxes.get(id)
|
||||
@@ -51,77 +54,25 @@ function layoutRow(layout: ReturnType<typeof layoutTasksGraph>, id: string): num
|
||||
return (box.y - GRAPH_PAD) / GRAPH_ROW_STRIDE
|
||||
}
|
||||
|
||||
/** One running node's live view: a merged activity row, no text tail. */
|
||||
const ACTIVITY: SidebarChildLiveView = {
|
||||
running: true,
|
||||
summary: { counts: [{ kind: 'read', count: 1 }], running: 'read', runningDetail: 'x' },
|
||||
}
|
||||
|
||||
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.
|
||||
describe('layout: card heights', () => {
|
||||
it('reserves the state bar for every node kind, and the task row only for team members', () => {
|
||||
const nodes: TasksNode[] = [
|
||||
agent('root'),
|
||||
agent('mate', 'root', false, { state: 'idle', tasks: [task('t1')] }),
|
||||
agent('busy', 'root', false, { live: ACTIVITY }),
|
||||
agent('owner', 'root', false, { 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 activity AND text', () => {
|
||||
const nodes: TasksNode[] = [
|
||||
agent('root'),
|
||||
agent('tool-only', 'root', false, { live: ACTIVITY }),
|
||||
agent('both', 'root', false, { live: { ...ACTIVITY, 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)
|
||||
// The merged activity lives in the bar, which every card renders: a
|
||||
// running node and a settled one reserve exactly the same base height.
|
||||
expect(layout.boxes.get('root')?.h).toBe(GRAPH_NODE_H)
|
||||
expect(layout.boxes.get('busy')?.h).toBe(GRAPH_NODE_H)
|
||||
expect(layout.boxes.get('owner')?.h).toBe(GRAPH_NODE_H + GRAPH_TASK_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
|
||||
const tallest = GRAPH_NODE_H + GRAPH_TASK_H
|
||||
expect(GRAPH_ROW_STRIDE).toBeGreaterThanOrEqual(tallest)
|
||||
const nodes: TasksNode[] = [
|
||||
agent('root'),
|
||||
|
||||
+164
-3
@@ -66,9 +66,15 @@ const teamMutations: Array<{ method: string; body: Record<string, unknown> }> =
|
||||
const fetchedMethods: string[] = []
|
||||
/** Job lists the stubbed `jobs.list` route answers with, keyed by OWNER session. */
|
||||
let jobsByOwner: Record<string, SidebarJobView[]> = {}
|
||||
/** The `subagents.live` payload the stub answers with (session id → live view). */
|
||||
let livePayload: Record<string, unknown> = {}
|
||||
/** The folded workflow runs the stub answers `workflows.list` with. */
|
||||
let runsPayload: unknown[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
teamPayload = { available: false }
|
||||
livePayload = {}
|
||||
runsPayload = []
|
||||
teamMutations.length = 0
|
||||
fetchedMethods.length = 0
|
||||
jobsByOwner = {
|
||||
@@ -78,8 +84,8 @@ beforeEach(() => {
|
||||
const method = String(url).split('/').pop()
|
||||
fetchedMethods.push(method ?? '')
|
||||
const body = JSON.parse(String(init?.body ?? '{}')) as Record<string, unknown>
|
||||
if (method === 'subagents.live') return jsonResponse({ ok: true, value: { live: {} } })
|
||||
if (method === 'workflows.list') return jsonResponse({ ok: true, value: { runs: [] } })
|
||||
if (method === 'subagents.live') return jsonResponse({ ok: true, value: { live: livePayload } })
|
||||
if (method === 'workflows.list') return jsonResponse({ ok: true, value: { runs: runsPayload } })
|
||||
if (method === 'teams.view') return jsonResponse({ ok: true, value: teamPayload })
|
||||
if (method === 'jobs.list') {
|
||||
return jsonResponse({ ok: true, value: { jobs: jobsByOwner[String(body.sessionId ?? '')] ?? [] } })
|
||||
@@ -130,7 +136,29 @@ function snapshotWithChildren(count: number): SidebarSessionList {
|
||||
return { byId, projectionsBySession }
|
||||
}
|
||||
|
||||
/** Flush the mount-time job reads (request → envelope → json → Promise.all). */
|
||||
/**
|
||||
* A snapshot whose root ran one workflow with TWO phases (the experiments the
|
||||
* status badge and the phase badge read). The members are ordinary catalog
|
||||
* children, which the model re-parents under the run node.
|
||||
*/
|
||||
function snapshotWithRun(): SidebarSessionList {
|
||||
const byId: Record<string, SidebarSessionSummary> = {
|
||||
root: { id: 'root', displayTitle: '主会话', running: true },
|
||||
}
|
||||
const projectionsBySession: Record<string, SidebarProjectionSnapshot> = {}
|
||||
const entries: SidebarSubagentCatalogEntry[] = [
|
||||
{ id: 'm1', createdAt: 1, mode: 'one-shot', label: '审计 A' },
|
||||
{ id: 'm2', createdAt: 2, mode: 'one-shot', label: '审计 B' },
|
||||
]
|
||||
for (const entry of entries) {
|
||||
byId[entry.id] = { id: entry.id, displayTitle: entry.id, origin: 'subagent', parentId: 'root', running: false }
|
||||
projectionsBySession[entry.id] = { values: { subagentCatalog: [] }, state: 'ready', error: null }
|
||||
}
|
||||
projectionsBySession.root = { values: { subagentCatalog: entries }, state: 'ready', error: null }
|
||||
return { byId, projectionsBySession }
|
||||
}
|
||||
|
||||
|
||||
async function flushJobs(): Promise<void> {
|
||||
for (let tick = 0; tick < 4; tick++) {
|
||||
await act(async () => { await Promise.resolve() })
|
||||
@@ -512,4 +540,137 @@ describe('Tasks page: the shared task window', () => {
|
||||
expect(update?.body).toMatchObject({ action: 'reassign', owner: 'reviewer', expectedRevision: 7 })
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('draws the two-segment card: kind badge + name on top, state bar below', async () => {
|
||||
const store = makeStore(snapshotWithChildren(2))
|
||||
const { container, unmount } = renderRoot(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
|
||||
)
|
||||
await flushJobs()
|
||||
// The tree is folded by default: unfold so the settled children are cards.
|
||||
const foldToggle = container.querySelector('button[aria-label="展开已完成的节点"]') as HTMLButtonElement
|
||||
await act(async () => { foldToggle.click() })
|
||||
|
||||
const root = container.querySelector('[data-graph-node="root"]') as HTMLElement
|
||||
expect(root.getAttribute('data-depth')).toBe('0')
|
||||
// The root is the main agent, a child is a subagent — the page's own words.
|
||||
expect(root.textContent).toContain('主代理')
|
||||
const child = container.querySelector('[data-graph-node="child-0"]') as HTMLElement
|
||||
expect(child.getAttribute('data-depth')).toBe('1')
|
||||
expect(child.textContent).toContain('子代理')
|
||||
// Both segments are present: the identity block and the state bar.
|
||||
const bar = child.querySelector('[data-state]') as HTMLElement
|
||||
expect(bar).not.toBeNull()
|
||||
expect(bar.textContent).toContain('已完成')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('marks a running card with data-running (the sweep hook) and never a settled one', async () => {
|
||||
livePayload = {
|
||||
'child-0': {
|
||||
running: true,
|
||||
summary: { counts: [{ kind: 'read', count: 2 }], runningDetail: '' },
|
||||
},
|
||||
}
|
||||
const store = makeStore(snapshotWithChildren(2))
|
||||
const { container, unmount } = renderRoot(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
|
||||
)
|
||||
await flushJobs()
|
||||
// The global fold is ON by default: unfold so the settled sibling is a card
|
||||
// too (the running one is never a fold candidate).
|
||||
const foldToggle = container.querySelector('button[aria-label="展开已完成的节点"]') as HTMLButtonElement
|
||||
await act(async () => { foldToggle.click() })
|
||||
const child = container.querySelector('[data-graph-node="child-0"]') as HTMLElement
|
||||
const bar = child.querySelector('[data-state]') as HTMLElement
|
||||
expect(bar.getAttribute('data-running')).toBe('true')
|
||||
// The merged activity wording comes from the plugin's own copy when the
|
||||
// host's chat namespace is absent (this harness attaches no locale).
|
||||
expect(bar.textContent).toBeTruthy()
|
||||
const settled = container.querySelector('[data-graph-node="child-1"]') as HTMLElement
|
||||
expect(settled.querySelector('[data-state]')?.getAttribute('data-running')).toBeNull()
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('folds one settled card into the aggregate from its bar, and expands it back', async () => {
|
||||
const store = makeStore(snapshotWithChildren(3))
|
||||
const { container, unmount } = renderRoot(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
|
||||
)
|
||||
await flushJobs()
|
||||
// The global fold is ON by default, so the settled children are already
|
||||
// aggregated; unfolding reveals them as individual cards again.
|
||||
const foldToggle = container.querySelector('button[aria-label="展开已完成的节点"]') as HTMLButtonElement
|
||||
await act(async () => { foldToggle.click() })
|
||||
expect(container.querySelector('[data-graph-node="child-0"]')).not.toBeNull()
|
||||
|
||||
const chevron = container.querySelector(
|
||||
'[data-graph-node="child-0"] button[aria-label="收进已完成聚合"]',
|
||||
) as HTMLButtonElement
|
||||
expect(chevron).not.toBeNull()
|
||||
await act(async () => { chevron.click() })
|
||||
// The node left the canvas…
|
||||
expect(container.querySelector('[data-graph-node="child-0"]')).toBeNull()
|
||||
// …and the aggregate says how many it holds.
|
||||
const aggregate = container.querySelector('[data-graph-node="fold:root"]') as HTMLElement
|
||||
expect(aggregate).not.toBeNull()
|
||||
expect(aggregate.textContent).toContain('1')
|
||||
|
||||
// Clicking the aggregate expands EVERYTHING (manual + global folds).
|
||||
await act(async () => { aggregate.click() })
|
||||
expect(container.querySelector('[data-graph-node="child-0"]')).not.toBeNull()
|
||||
expect(container.querySelector('[data-graph-node="fold:root"]')).toBeNull()
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('offers no fold chevron on a running card or on the current session', async () => {
|
||||
livePayload = { 'child-0': { running: true, summary: { counts: [], runningDetail: '' } } }
|
||||
const store = makeStore(snapshotWithChildren(2))
|
||||
const { container, unmount } = renderRoot(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
|
||||
)
|
||||
await flushJobs()
|
||||
const running = container.querySelector('[data-graph-node="child-0"]') as HTMLElement
|
||||
expect(running.querySelector('button[aria-label="收进已完成聚合"]')).toBeNull()
|
||||
// The root IS the current session: never foldable.
|
||||
const root = container.querySelector('[data-graph-node="root"]') as HTMLElement
|
||||
expect(root.querySelector('button[aria-label="收进已完成聚合"]')).toBeNull()
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('badges a workflow run and its members with the phase they belong to', async () => {
|
||||
runsPayload = [{
|
||||
runId: 'run-1',
|
||||
name: 'audit',
|
||||
originSessionId: 'root',
|
||||
status: 'running',
|
||||
startedSeq: 1,
|
||||
startedAt: 0,
|
||||
phases: [
|
||||
{ title: '扫描', members: [{ childId: 'm1', label: '审计 A', seq: 2, phase: '扫描' }] },
|
||||
{ title: '复核', members: [{ childId: 'm2', label: '审计 B', seq: 3, phase: '复核' }] },
|
||||
],
|
||||
}]
|
||||
const store = makeStore(snapshotWithRun())
|
||||
const { container, unmount } = renderRoot(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
|
||||
)
|
||||
await flushJobs()
|
||||
// The run's members are settled, so the global fold has already gathered
|
||||
// them: unfold to see the member cards themselves.
|
||||
const foldToggle = container.querySelector('button[aria-label="展开已完成的节点"]') as HTMLButtonElement
|
||||
await act(async () => { foldToggle.click() })
|
||||
// The run node is a card of its own: kind badge 工作流 + its status bar.
|
||||
const runNode = container.querySelector('[data-graph-node="run:run-1"]') as HTMLElement
|
||||
expect(runNode).not.toBeNull()
|
||||
expect(runNode.textContent).toContain('工作流')
|
||||
expect(runNode.querySelector('[data-state]')).not.toBeNull()
|
||||
// Each member card carries ITS phase badge (the second grouping axis).
|
||||
const first = container.querySelector('[data-graph-node="m1"]') as HTMLElement
|
||||
const second = container.querySelector('[data-graph-node="m2"]') as HTMLElement
|
||||
expect(first.textContent).toContain('扫描')
|
||||
expect(second.textContent).toContain('复核')
|
||||
expect(first.getAttribute('data-depth')).toBe('2')
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user