Files
apes-sidebar/tests/tasks-page.spec.tsx
T
Menghuan1918 d38fefe55d feat(tasks): 任务输出常驻浮动窗 + 节点卡去竖线 + 手机设置
三件事一起落地(用户 brief),各自独立可验证:

1) 后台任务输出改为常驻浮动窗
   - 新增 src/client/FloatingWindow.tsx + .module.css:可复用组件,标题栏(拖拽手柄 +
     复制/关闭)、内容区(自滚动,长输出不再跑出视口)、可选底部行、三个拉伸柄
     (右/下/右下)。生命周期与 AnchoredPopover 相反:外部点击、失焦、锚点离屏都不关,
     只有关闭按钮与 Escape 结束;尺寸只在挂载时量一次(流式输出帧不会挪动读者摆好的窗);
     拖拽保持整窗在视口内(放不下才退化到保留 48px 可达);视口变化时夹回。
   - 手势复用既有范式:移动走 window 级 pointermove(同 AnchoredPopover),拉伸走
     setPointerCapture + createFrameBatcher(同 EditorHost 的面板拉伸)。
   - JobOutputPopoverContent → JobOutputWindow;顺带修掉「跟随最新」从不滚动的 bug
     (原先滚的是 TerminalBlock 外面那个永不滚动的 div),并清掉随旧弹窗死掉的
     .popPre / .jobPopPre 规则。节点/工作流详情与任务窗保持原样。

2) 节点卡统一
   - 删除两条左侧竖条(层级条 .cardTop::before、当前会话条 .nodeCurrent::before);
     层级改由上段极淡底色承担(7/5/3.5/2.5% → 9/7/5/3.5% 补偿对比度);
     当前会话改为 2px accent 描边(「边框加粗高亮」)。
   - 圆角 4px → 8px(与浮动窗同一家族);成员/工作流/聚合按用户选择保留类型区分。
   - 2px 而非 1.5px:真机 recon 发现 Chromium 在 DPR 1 下把亚像素边框向下取整,
     1.5px 的「加粗」完全看不出(实测 borderTopWidth: "1px")。几何不受影响
     (border-box + min-height,卡片外框 247×134 与预算逐像素一致)。

3) 手机设置
   - 两个新偏好 mobileNoAutoOpen / mobileDefaultTree(默认都开),落到 prefs-shared、
     PrefsSchema(volatile 自动)、parsePrefs 校验与设置页新增的「手机」分组。
   - 窄屏(沿用 768px 断点)时:新子代理与新后台任务都不再自动激活任务页;
     任务页默认树状图(页内切换仍是临时覆盖)。宽屏或开关关掉后行为完全不变。
   - 5 条词条 ×20 份词典;外部插件指南的 PrefsSchema 键清单同步。

验证:typecheck / lint ✅;pnpm test 1254 passed / 9 skipped(新增 14 例);
挂载 lane 7/7(真实 DSH 0.1.7-rc.2);3080 真机自测通过(长输出窗口内部滚动
5459px/449px、拖拽 424→304、拉伸 1008×540→918×480、外部点击不关、Escape 关、
卡片 8px/2px/无竖条、窄屏不自动弹出且默认树状图)。

(cherry picked from commit 7d9b428c6c34d28ad2d60c2086496eb533902273)
2026-09-27 22:18:57 +08:00

772 lines
36 KiB
TypeScript

/**
* Tasks page interaction tests (jsdom): the jobs drawer's auto-collapse at
* the agent-count threshold, the graph/tree view toggle visible in BOTH
* modes, node click → transcript jump, ⓘ → anchored popover, and the
* settled-leaf fold aggregate expanding on click.
*/
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createElement } from 'react'
import { act } from 'react-dom/test-utils'
import { renderRoot } from './test-utils.ts'
import { SubagentView } from '../src/client/SubagentView.tsx'
import { createSidebarStore } from '../src/client/state.ts'
import type {
Context,
SidebarClientJobsService,
SidebarJobsSnapshot,
SidebarProjectionSnapshot,
SidebarSessionList,
SidebarSessionSummary,
SidebarSubagentAddress,
SidebarSubagentCatalogEntry,
} from '../src/context-types.ts'
/** A subscribable sessions-list snapshot (mirror of the runtime list feed). */
function makeStore(initial: SidebarSessionList) {
let snapshot = initial
const listeners = new Set<() => void>()
return {
getSnapshot: () => snapshot,
subscribe: (fn: () => void) => {
listeners.add(fn)
return () => { listeners.delete(fn) }
},
set(next: SidebarSessionList) {
snapshot = next
for (const fn of [...listeners]) fn()
},
}
}
type Store = ReturnType<typeof makeStore>
/** The navigation spy the page must reach (DSH 0.1.7 `ctx.uiWorkspace`). */
interface NavigationSpy {
opened: Array<SidebarSubagentAddress | string>
}
/**
* A structurally faithful double of the host's client jobs service: a
* subscribable snapshot, a reference-counted `watchRows`, an `observe` that
* records its calls, and a `kill` that records its arguments.
*/
function makeJobsService() {
let snapshot: SidebarJobsSnapshot = { rows: {}, observed: {} }
const listeners = new Set<() => void>()
const observations: Array<{ sessionId: string | undefined; jobId: string }> = []
const kills: Array<{ sessionId: string; jobId: string }> = []
const service: SidebarClientJobsService = {
state: {
getSnapshot: () => snapshot,
subscribe: (listener: () => void) => {
listeners.add(listener)
return () => { listeners.delete(listener) }
},
},
watchRows: () => () => {},
observe: (sessionId: string | undefined, jobId: string) => {
observations.push({ sessionId, jobId })
return () => {}
},
kill: async (sessionId: string, jobId: string) => { kills.push({ sessionId, jobId }) },
}
const emit = (next: Partial<SidebarJobsSnapshot>): void => {
snapshot = { ...snapshot, ...next }
for (const listener of [...listeners]) listener()
}
return { service, emit, observations, kills }
}
/** The jobs service the page reads (rebuilt per test in beforeEach). */
let jobsFake: ReturnType<typeof makeJobsService>
/** The client context face SubagentView touches. */
function makeCtx(store: Store, navigation?: NavigationSpy): Context {
return {
sessions: {
list: store,
refreshProjections: async () => {},
},
get: (name: string) => {
if (name === 'jobs') return jobsFake.service
if (name === 'uiWorkspace' && navigation !== undefined) {
return { openSession: (target: SidebarSubagentAddress | string) => { navigation.opened.push(target) } }
}
return undefined
},
} as unknown as Context
}
function jsonResponse(value: unknown): Response {
return { ok: true, status: 200, json: async () => value } as unknown as Response
}
let teamPayload: unknown = { available: false }
const teamMutations: Array<{ method: string; body: Record<string, unknown> }> = []
const fetchedMethods: string[] = []
/** 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
jobsFake = makeJobsService()
jobsFake.emit({
rows: {
root: [{ id: 'bash-1', kind: 'bash', label: 'sleep 300', status: 'running', startedAt: 1_000 }],
},
})
vi.stubGlobal('fetch', async (url: string | URL | Request, init?: RequestInit) => {
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: livePayload } })
if (method === 'workflows.list') return jsonResponse({ ok: true, value: { runs: runsPayload } })
if (method === 'teams.view') return jsonResponse({ ok: true, value: teamPayload })
if (method === 'teams.taskCreate' || method === 'teams.taskUpdate') {
teamMutations.push({ method: method ?? '', body })
return jsonResponse({
ok: true,
value: {
ok: true,
value: {
id: 't9', revision: 9, subject: body.subject ?? 'x', description: body.description ?? '',
status: 'pending', blockedBy: [], writeScopes: [], ready: true, writeScopeWarnings: [],
},
},
})
}
if (method === 'jobs.output') return jsonResponse({ ok: true, value: { text: 'out', truncated: false, read: true } })
throw new Error(`unexpected fetch ${String(url)}`)
})
Object.defineProperty(globalThis.navigator, 'language', { value: 'zh-CN', configurable: true })
})
afterEach(() => {
vi.unstubAllGlobals()
for (const el of document.querySelectorAll('body > div')) el.remove()
})
/**
* A snapshot with N direct children of root (all settled one-shots). The rows
* come from the host's `subagentCatalog` projection and each child carries a
* loaded EMPTY catalog of its own — DSH 0.1.7 rows have no `hasChildren`, so
* that is what makes them known leaves (and therefore fold candidates).
*/
function snapshotWithChildren(count: number): SidebarSessionList {
const byId: Record<string, SidebarSessionSummary> = {
root: { id: 'root', displayTitle: '主会话', running: true },
}
const projectionsBySession: Record<string, SidebarProjectionSnapshot> = {}
const entries: SidebarSubagentCatalogEntry[] = []
for (let index = 0; index < count; index += 1) {
const id = `child-${index}`
byId[id] = { id, displayTitle: id, origin: 'subagent', parentId: 'root', running: false }
entries.push({ id, createdAt: 1_000 + index, mode: 'one-shot', label: `子代理 ${index}` })
projectionsBySession[id] = { values: { subagentCatalog: [] }, state: 'ready', error: null }
}
projectionsBySession.root = { values: { subagentCatalog: entries }, state: 'ready', error: null }
return { byId, projectionsBySession }
}
/**
* 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() })
}
}
describe('Tasks page interactions', () => {
it('keeps the jobs drawer open below the agent threshold', async () => {
const store = makeStore(snapshotWithChildren(2))
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
await flushJobs()
// 3 agents (root + 2 children) < 8: the drawer renders its rows open.
expect(container.textContent).toContain('sleep 300')
expect(container.textContent).not.toContain('已自动折叠')
unmount()
})
it('auto-collapses the jobs drawer at 8+ agents, expandable by the bar', async () => {
const store = makeStore(snapshotWithChildren(8))
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
await flushJobs()
// 9 agents ≥ 8: collapsed, the auto note explains, no rows rendered.
expect(container.textContent).not.toContain('sleep 300')
expect(container.textContent).toContain('已自动折叠')
const bar = container.querySelector('button[aria-expanded]') as HTMLButtonElement
expect(bar.getAttribute('aria-expanded')).toBe('false')
await act(async () => { bar.click() })
expect(container.textContent).toContain('sleep 300')
unmount()
})
it('opens in the TREE by default on a narrow viewport when the mobile switch is on', async () => {
// jsdom's window is the plugin's mobile bracket here (767 < 768).
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 420 })
try {
const store = makeStore(snapshotWithChildren(2))
const sidebar = createSidebarStore()
sidebar.setPrefs({ ...sidebar.getPrefs(), tasksViewMode: 'graph' })
const first = renderRoot(
createElement(SubagentView, {
sessionId: 'root', active: true, ctx: makeCtx(store), store: sidebar,
}),
)
await flushJobs()
// The mobile adaptation wins over the wide-viewport default…
expect(first.container.querySelector('[role="tree"]')).not.toBeNull()
expect(first.container.querySelector('[role="group"]')).toBeNull()
// …but the in-page toggle still switches this session's page ad hoc.
const toGraph = first.container.querySelector('button[aria-label="切换为工作流图"]') as HTMLButtonElement
await act(async () => { toGraph.click() })
expect(first.container.querySelector('[role="group"]')).not.toBeNull()
first.unmount()
// Disarming the switch restores the settings default (graph) on the same
// narrow viewport.
const off = makeStore(snapshotWithChildren(2))
const offSidebar = createSidebarStore()
offSidebar.setPrefs({ ...offSidebar.getPrefs(), tasksViewMode: 'graph', mobileDefaultTree: false })
const second = renderRoot(
createElement(SubagentView, {
sessionId: 'root', active: true, ctx: makeCtx(off), store: offSidebar,
}),
)
await flushJobs()
expect(second.container.querySelector('[role="group"]')).not.toBeNull()
second.unmount()
} finally {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1024 })
}
})
it('toggles between graph and tree with the cluster visible in BOTH modes', async () => {
const store = makeStore(snapshotWithChildren(2))
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
// Default graph: the toggle offers the tree switch.
const toTree = container.querySelector('button[aria-label="切换为树状图"]') as HTMLButtonElement
expect(toTree).not.toBeNull()
expect(container.querySelector('[role="group"]')).not.toBeNull()
await act(async () => { toTree.click() })
// Tree mode: the toggle stays (the mockup's hidden-toggle bug is guarded).
const toGraph = container.querySelector('button[aria-label="切换为工作流图"]') as HTMLButtonElement
expect(toGraph).not.toBeNull()
expect(container.querySelector('[role="tree"]')).not.toBeNull()
await act(async () => { toGraph.click() })
expect(container.querySelector('[role="group"]')).not.toBeNull()
unmount()
})
it('opens the node detail window on card click; the jump button goes to the transcript', async () => {
const navigation: NavigationSpy = { opened: [] }
const store = makeStore(snapshotWithChildren(1))
const ctx = makeCtx(store, navigation)
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx }),
)
// Folded by default: unfold so the settled child is visible.
const foldToggle = container.querySelector('button[aria-label="展开已完成的节点"]') as HTMLButtonElement
await act(async () => { foldToggle.click() })
const node = container.querySelector('[data-graph-node="child-0"]') as HTMLElement
await act(async () => { node.click() })
// The card no longer jumps directly: it IS the detail affordance.
expect(navigation.opened).toEqual([])
const dialog = document.querySelector('[role="dialog"]') as HTMLElement
expect(dialog).not.toBeNull()
expect(dialog.textContent).toContain('节点详情')
const jump = [...dialog.querySelectorAll('button')].find(button => button.textContent?.includes('查看转录'))
expect(jump).toBeDefined()
await act(async () => { jump?.click() })
// The workspace face owns main-view selection since 0.1.6; ISessions.openSubagent
// is gone from the runtime, so that is the call the page must make.
expect(navigation.opened).toEqual([{ parentSessionId: 'root', childSessionId: 'child-0', mode: 'one-shot' }])
unmount()
})
it('folds settled leaves into an aggregate that expands on click', async () => {
const store = makeStore(snapshotWithChildren(3))
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
// Folded: one aggregate node, no individual children.
expect(container.textContent).toContain('✓ 3 已完成')
expect(container.querySelector('[aria-label*="子代理 0"]')).toBeNull()
const fold = container.querySelector('[aria-label*="✓ 3 已完成"]') as HTMLElement
await act(async () => { fold.click() })
expect(container.textContent).toContain('子代理 0')
expect(container.textContent).toContain('子代理 2')
unmount()
})
it('ships no per-card detail button any more (the card itself is the affordance)', async () => {
const store = makeStore(snapshotWithChildren(1))
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
const root = container.querySelector('[data-graph-node="root"]') as HTMLElement
expect(root).not.toBeNull()
// A card is a single click target: no nested control inside it.
expect(root.querySelectorAll('button')).toHaveLength(0)
await act(async () => { root.click() })
expect(document.querySelectorAll('[role="dialog"]')).toHaveLength(1)
expect(document.body.textContent).toContain('查看转录')
unmount()
})
})
describe('Tasks page graph interactions and team board', () => {
it('activates a graph node after a background pointerdown (no click theft)', async () => {
const navigation: NavigationSpy = { opened: [] }
const store = makeStore(snapshotWithChildren(1))
const ctx = makeCtx(store, navigation)
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() })
// The click is not stolen: the card's own action (its detail window) ran.
expect(document.querySelector('[role="dialog"]')).not.toBeNull()
expect(navigation.opened).toEqual([])
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()
})
})
describe('Tasks page: owned tasks, host-primitive controls, draggable output', () => {
/** A team payload whose only task is owned by the child agent. */
function teamWithOwnedTask(): unknown {
return {
available: true,
team: {
members: [
{ id: 'root', name: 'lead', role: 'lead', status: 'running', diagnostics: [] },
{ id: 'child-0', name: 'writer', role: 'teammate', status: 'running', model: 'glm-5.3', diagnostics: [] },
],
tasks: [
{
id: 't1', revision: 1, subject: '收窄卡片与图标化', description: '细节', status: 'in_progress',
ownerName: 'writer', blockedBy: [], writeScopes: [], ready: true, writeScopeWarnings: [],
},
{
id: 't2', revision: 2, subject: '补点击回归', description: '', status: 'pending',
ownerName: 'writer', blockedBy: ['t1'], writeScopes: [], ready: false, writeScopeWarnings: [],
},
],
},
}
}
it('renders the owned task on its agent node', async () => {
teamPayload = teamWithOwnedTask()
const store = makeStore(snapshotWithChildren(1))
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
await act(async () => { await Promise.resolve() })
const node = container.querySelector('[data-graph-node="child-0"]') as HTMLElement
expect(node).not.toBeNull()
// The node carries the task subject + its status word + the +N tail.
expect(node.textContent).toContain('收窄卡片与图标化')
expect(node.textContent).toContain('进行中')
expect(node.textContent).toContain('+1')
unmount()
})
it('opens the shared task window from a board row (markdown first, no native select)', async () => {
teamPayload = teamWithOwnedTask()
const store = makeStore(snapshotWithChildren(1))
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
await act(async () => { await Promise.resolve() })
expect(container.querySelectorAll('select')).toHaveLength(0)
const row = container.querySelector('button[aria-label^="任务详情"]') as HTMLButtonElement
expect(row).not.toBeNull()
await act(async () => { row.click() })
const dialog = document.querySelector('[role="dialog"]') as HTMLElement
expect(dialog).not.toBeNull()
// View mode first: the description renders as markdown, edited only on demand.
expect(dialog.textContent).toContain('细节')
expect(dialog.querySelector('textarea')).toBeNull()
const edit = [...dialog.querySelectorAll('button')].find(button => button.textContent?.includes('编辑'))
expect(edit).toBeDefined()
await act(async () => { edit?.click() })
// Editing turns the SAME window into the multi-line editor.
const textarea = dialog.querySelector('textarea')
expect(textarea).not.toBeNull()
expect(textarea?.getAttribute('aria-label')).toBe('描述')
unmount()
})
it('creates a task through the dialog (host Input + modal) and posts the CAS-free create', async () => {
teamPayload = teamWithOwnedTask()
const store = makeStore(snapshotWithChildren(1))
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
await act(async () => { await Promise.resolve() })
const create = [...container.querySelectorAll('button')].find(button => button.textContent?.includes('新建任务'))
expect(create).toBeDefined()
await act(async () => { create?.click() })
// The dialog's field is a host Input (rendered as a real <input> inside the modal).
const field = document.querySelector('input[aria-label="标题"]') as HTMLInputElement
expect(field).not.toBeNull()
await act(async () => {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set
setter?.call(field, '新任务标题')
field.dispatchEvent(new Event('input', { bubbles: true }))
})
const dialog = document.querySelector('[role="dialog"]') as HTMLElement
expect(dialog).not.toBeNull()
const save = [...dialog.querySelectorAll('button')].find(button => button.textContent?.includes('新建任务'))
expect(save).toBeDefined()
expect(field.value).toBe('新任务标题')
expect((save as HTMLButtonElement).disabled).toBe(false)
await act(async () => { save?.click() })
await act(async () => { await Promise.resolve() })
expect(fetchedMethods).toContain('teams.taskCreate')
expect(teamMutations[0]?.body.subject).toBe('新任务标题')
unmount()
})
it('opens the job output in the persistent floating window and drags it by its title bar', async () => {
const store = makeStore(snapshotWithChildren(1))
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
await flushJobs()
const row = container.querySelector('button[aria-label*="sleep 300"]') as HTMLButtonElement
await act(async () => { row.click() })
// The job output is a WINDOW, not the 280px anchored popover: it carries
// the plugin's frame (a title bar to drag, a scrolling body).
const frame = document.querySelector('[data-floating-window]') as HTMLElement
expect(frame).not.toBeNull()
expect(frame.getAttribute('aria-label')).toBe('sleep 300')
expect(frame.querySelector('[data-window-body]')).not.toBeNull()
// An outside click must NOT dismiss it (the window is persistent); Escape does.
await act(async () => {
document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
})
expect(document.querySelector('[data-floating-window]')).not.toBeNull()
const before = { left: frame.style.left, top: frame.style.top }
const handle = frame.querySelector('[data-window-handle]') as HTMLElement
await act(async () => {
handle.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 40, clientY: 40 }))
window.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 140, clientY: 120 }))
window.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
})
expect({ left: frame.style.left, top: frame.style.top }).not.toEqual(before)
await act(async () => {
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
})
expect(document.querySelector('[data-floating-window]')).toBeNull()
unmount()
})
})
describe('Tasks page: the shared task window', () => {
it('opens from an agent node task line and saves a multi-line edit', async () => {
teamPayload = {
available: true,
team: {
members: [
{ id: 'root', name: 'lead', role: 'lead', status: 'running', diagnostics: [] },
{ id: 'child-0', name: 'writer', role: 'teammate', status: 'running', model: 'glm-5.3', diagnostics: [] },
],
tasks: [{
id: 't1', revision: 4, subject: '收窄卡片', description: '第一行\n\n- 第二行', status: 'in_progress',
ownerName: 'writer', 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() })
const node = container.querySelector('[data-graph-node="child-0"]') as HTMLElement
const taskLine = node.querySelector('[role="button"]') as HTMLElement
expect(taskLine).not.toBeNull()
await act(async () => { taskLine.click() })
const dialog = document.querySelector('[role="dialog"]') as HTMLElement
expect(dialog).not.toBeNull()
expect(dialog.textContent).toContain('收窄卡片')
// Markdown view renders the multi-line body before any editing.
expect(dialog.querySelector('textarea')).toBeNull()
const edit = [...dialog.querySelectorAll('button')].find(button => button.textContent?.includes('编辑'))
await act(async () => { edit?.click() })
const area = dialog.querySelector('textarea') as HTMLTextAreaElement
expect(area.value).toBe('第一行\n\n- 第二行')
await act(async () => {
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set
setter?.call(area, '改过的描述')
area.dispatchEvent(new Event('input', { bubbles: true }))
})
const save = [...dialog.querySelectorAll('button')].find(button => button.textContent?.includes('保存'))
await act(async () => { save?.click() })
await act(async () => { await Promise.resolve() })
const update = teamMutations.find(entry => entry.method === 'teams.taskUpdate')
expect(update?.body.action).toBe('edit')
expect(update?.body.expectedRevision).toBe(4)
expect(update?.body.description).toBe('改过的描述')
unmount()
})
it('reassigns the owner straight from the window (CAS on the current revision)', async () => {
teamPayload = {
available: true,
team: {
members: [
{ id: 'root', name: 'lead', role: 'lead', status: 'running', diagnostics: [] },
{ id: 'child-0', name: 'writer', role: 'teammate', status: 'running', diagnostics: [] },
{ id: 'child-1', name: 'reviewer', role: 'teammate', status: 'idle', diagnostics: [] },
],
tasks: [{
id: 't1', revision: 7, subject: '收窄卡片', description: '', status: 'pending',
ownerName: 'writer', blockedBy: [], writeScopes: [], ready: true, writeScopeWarnings: [],
}],
},
}
const store = makeStore(snapshotWithChildren(2))
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
await act(async () => { await Promise.resolve() })
const row = container.querySelector('button[aria-label^="任务详情"]') as HTMLButtonElement
await act(async () => { row.click() })
const dialog = document.querySelector('[role="dialog"]') as HTMLElement
const reviewer = [...dialog.querySelectorAll('button')].find(button => button.textContent === 'reviewer')
expect(reviewer).toBeDefined()
await act(async () => { reviewer?.click() })
await act(async () => { await Promise.resolve() })
const update = teamMutations.find(entry => entry.method === 'teams.taskUpdate')
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-card-bar]') 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-card-bar]') 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-card-bar]')?.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-card-bar]')).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()
})
})