feat(client): workflow-graph Tasks page with teams board and jobs drawer

The Subagent page becomes the Tasks page per the approved Variant-D design:

- tasks-model.ts feeds BOTH modes from one pre-order node list: graph
  canvas (TasksGraph: layered nodes over bezier edges, drag-pan,
  wheel-zoom-to-cursor, phase frames, bottom-right control cluster) and
  the classic tree (TasksTree: keyboard navigable) — the view toggle and
  fold toggle stay reachable in both modes.
- Workflow runs (workflows.list) hang under their origin agent; member
  agents re-parent under the run node, missing ones synthesize from run
  data; completed leaf agents fold into per-parent aggregate nodes
  (click or cluster toggle to expand/re-collapse).
- Agent Teams (experimental): roster enriches nodes; a header chip opens
  the shared task board popover with basic CAS operations (complete /
  reopen / delete / reassign / create / edit); no experimental layer →
  the whole block hides (structural degradation).
- Background jobs move into a bottom drawer that auto-collapses at 8+
  agents (manual toggle wins); job output opens as an anchored popover
  (event replay, never the model's cursor); two-click kill preserved.
- Live activity lines render as glyph + tool + args (tool-icons map onto
  host primitive icons).
- Node click jumps to the transcript; ⓘ opens the detail popover;
  popovers follow the selection-popup dismissal contract (Escape /
  outside click / anchor off-screen via IntersectionObserver).
- tasksViewMode pref (graph default) with a select row in settings;
  58 new copy keys across zh/en/ja + 18 third-language dictionaries;
  dead CSS from the old tree/dock pruned.
- Tests: 6 new page-interaction specs (auto-collapse, both-mode toggle,
  jump, fold, popover); jobs-view suite re-pointed at the popover;
  prefs/builtins/smoke lock tests updated for the new pref.
This commit is contained in:
Menghuan1918
2026-09-27 22:18:14 +08:00
parent fdc4520080
commit 6448d8bad9
38 changed files with 4190 additions and 1387 deletions
+5 -1
View File
@@ -133,7 +133,11 @@ describe('built-in tab registrations', () => {
it('the subagent tab declares its auto-open related settings', () => {
const { service } = setup()
const toggles = service.getTab('subagent')?.settings?.toggles ?? []
expect(toggles.map(t => t.key)).toEqual(['autoOpenSubagent', 'autoOpenJobs'])
expect(toggles.map(t => t.key)).toEqual(['autoOpenSubagent', 'autoOpenJobs', 'tasksViewMode'])
// The default-view row is a graph/tree select (values are strings).
const viewMode = toggles[2]
expect(viewMode?.type).toBe('select')
expect((viewMode?.options ?? []).map(o => o.value)).toEqual(['graph', 'tree'])
})
it('the editor tab declares its merged-mode (embedded file tree) setting', () => {
+3
View File
@@ -34,6 +34,7 @@ describe('side card preferences', () => {
.toEqual({
autoOpenSubagent: false,
autoOpenJobs: true,
tasksViewMode: 'graph',
agentOpenTools: true,
editorExplorer: false,
workspaceFence: true,
@@ -55,6 +56,7 @@ describe('side card preferences', () => {
.toEqual({
autoOpenSubagent: true,
autoOpenJobs: true,
tasksViewMode: 'graph',
agentOpenTools: false,
editorExplorer: false,
workspaceFence: true,
@@ -76,6 +78,7 @@ describe('side card preferences', () => {
.toEqual({
autoOpenSubagent: true,
autoOpenJobs: true,
tasksViewMode: 'graph',
agentOpenTools: false,
editorExplorer: false,
workspaceFence: true,
+123 -297
View File
@@ -1,26 +1,17 @@
/**
* Subagent page tests: the projection-backed topology (branch expansion, the
* `mode: 'unknown'` row, navigation and catalog retry) and the background-job
* section (rows read through `jobs.list` per tree session, the output dock
* with the OWNER session scope, the two-click kill confirm, and no polling
* while the page is hidden).
* Tasks page tests for the background-jobs DRAWER: rows render from the
* `jobsBySession` mirror, clicking a row peeks its output in an anchored
* popover (portaled to document.body) through `jobs.output` with the OWNER
* session scope, the kill button needs a two-click confirm, settled rows
* offer no kill, and the output popover never polls while the page is hidden.
*/
// @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, setupReactAct } from './test-utils.ts'
setupReactAct()
import { renderRoot } from './test-utils.ts'
import { SubagentView } from '../src/client/SubagentView.tsx'
import type {
Context,
SidebarJobView,
SidebarSessionList,
SidebarSubagentCatalogEntry,
SidebarSubagentAddress,
} from '../src/context-types.ts'
import type { Context, SidebarSessionList } from '../src/context-types.ts'
/** A subscribable sessions-list snapshot (mirror of the runtime list feed). */
function makeStore(initial: SidebarSessionList) {
@@ -41,58 +32,48 @@ function makeStore(initial: SidebarSessionList) {
type Store = ReturnType<typeof makeStore>
/** The navigation spy the page must reach (DSH 0.1.6+ `ctx.uiWorkspace`). */
interface NavigationSpy {
opened: Array<SidebarSubagentAddress | string>
}
/** The client context face SubagentView touches (everything else inert). */
function makeCtx(store: Store, navigation?: NavigationSpy): Context {
/** The client context face SubagentView touches (history stub; everything else inert). */
function makeCtx(store: Store): Context {
return {
sessions: {
list: store,
open: (id: string) => { navigation?.opened.push(id) },
setSubagentCatalogOpen: () => {},
openSubagent: () => {},
open: () => {},
refreshSubagents: async () => {},
},
connection: {
api: {
subagents: {
history: async () => ({ result: { ok: true, value: { events: [], hasMore: false } } }),
},
},
},
get: (name: string) => (name === 'uiWorkspace' && navigation !== undefined
? { openSession: (target: SidebarSubagentAddress | string) => { navigation.opened.push(target) } }
: undefined),
} as unknown as Context
}
const outputCalls: Array<{ sessionId: string; id: string }> = []
const killCalls: Array<{ sessionId: string; id: string }> = []
/** Job lists the stubbed `jobs.list` route answers with, keyed by OWNER session. */
let jobsByOwner: Record<string, SidebarJobView[]> = {}
/** The owner sessions the page actually read (the fence's unit of access). */
let jobListCalls: string[] = []
function jsonResponse(value: unknown): Response {
return { ok: true, status: 200, json: async () => value } as unknown as Response
}
/** One `subagentCatalog` projection row (DSH 0.1.7 shape). */
function entry(
id: string,
mode: SidebarSubagentCatalogEntry['mode'],
label?: string,
): SidebarSubagentCatalogEntry {
return { id, createdAt: 1_000, mode, ...(label === undefined ? {} : { label }) }
}
/** One session's loaded projection snapshot. */
function ready(entries: SidebarSubagentCatalogEntry[]) {
return { values: { subagentCatalog: entries }, state: 'ready' as const, error: null }
}
function baseSnapshot(): SidebarSessionList {
return {
current: 'root',
byId: {
root: { id: 'root', displayTitle: '主会话', running: true },
child: { id: 'child', displayTitle: '子代理', origin: 'subagent', parentId: 'root', running: true },
},
projectionsBySession: {
root: ready([entry('child', 'continuable')]),
child: ready([]),
subagentsByParent: {},
jobsBySession: {
root: [
{ id: 'bash-1', kind: 'bash', label: 'sleep 300', status: 'running', startedAt: 1_000 },
],
child: [
{ id: 'bash-2', kind: 'bash', label: 'echo hi', status: 'completed', startedAt: 2_000, finishedAt: 3_000 },
],
},
}
}
@@ -100,22 +81,12 @@ function baseSnapshot(): SidebarSessionList {
beforeEach(() => {
outputCalls.length = 0
killCalls.length = 0
jobListCalls = []
jobsByOwner = {
root: [{ id: 'bash-1', kind: 'bash', label: 'sleep 300', status: 'running', startedAt: 1_000 }],
child: [{ id: 'bash-2', kind: 'bash', label: 'echo hi', status: 'completed', startedAt: 2_000, finishedAt: 3_000 }],
}
vi.stubGlobal('fetch', async (url: string | URL | Request, init?: RequestInit) => {
const method = String(url).split('/').pop()
const body = JSON.parse(String(init?.body)) as { sessionId?: string; id?: string; rootSessionId?: string }
if (method === 'subagents.live') {
return jsonResponse({ ok: true, value: { live: {} } })
}
if (method === 'jobs.list') {
const owner = body.sessionId ?? ''
jobListCalls.push(owner)
return jsonResponse({ ok: true, value: { jobs: jobsByOwner[owner] ?? [] } })
}
if (method === 'jobs.output') {
outputCalls.push({ sessionId: body.sessionId ?? '', id: body.id ?? '' })
// bash-9 stands for a job the model never read (read:false).
@@ -132,6 +103,12 @@ beforeEach(() => {
killCalls.push({ sessionId: body.sessionId ?? '', id: body.id ?? '' })
return jsonResponse({ ok: true, value: { ok: true, outcome: 'requested' } })
}
if (method === 'workflows.list') {
return jsonResponse({ ok: true, value: { runs: [] } })
}
if (method === 'teams.view') {
return jsonResponse({ ok: true, value: { available: false } })
}
throw new Error(`unexpected fetch ${String(url)}`)
})
Object.defineProperty(globalThis.navigator, 'language', { value: 'zh-CN', configurable: true })
@@ -139,139 +116,15 @@ beforeEach(() => {
afterEach(() => {
vi.unstubAllGlobals()
vi.useRealTimers()
for (const el of document.querySelectorAll('body > div')) el.remove()
})
/**
* Flush the mount-time job reads: the poll fires immediately, and the request
* → envelope → json → Promise.all chain settles over several microtask ticks.
*/
async function flushJobs(): Promise<void> {
for (let tick = 0; tick < 4; tick++) {
await act(async () => { await Promise.resolve() })
}
}
/** Render the page and wait for its first job read to land. */
async function renderPage(store: Store, active = true, navigation?: NavigationSpy) {
const rendered = renderRoot(
createElement(SubagentView, { sessionId: 'root', active, ctx: makeCtx(store, navigation) }),
)
await flushJobs()
return rendered
}
/** One topology row by its aria-label prefix. */
function row(container: HTMLElement, label: string): HTMLElement {
const found = container.querySelector<HTMLElement>(`[role="treeitem"][aria-label^="${label}"]`)
if (found === null) throw new Error(`no topology row labelled ${label}`)
return found
}
describe('SubagentView topology (projectionsBySession)', () => {
it('renders the tree and expands a branch inferred from its own catalog', async () => {
const store = makeStore({
byId: {
root: { id: 'root', displayTitle: '主会话' },
child: { id: 'child', displayTitle: 'Worker', origin: 'subagent', parentId: 'root' },
grand: { id: 'grand', displayTitle: 'Grandchild', origin: 'subagent', parentId: 'child' },
},
projectionsBySession: {
root: ready([entry('child', 'continuable', 'Worker')]),
// The child carries a child of its own: `hasChildren` is not in the
// 0.1.7 row, it is the child's own catalog that says so.
child: ready([entry('grand', 'one-shot', 'Grandchild')]),
grand: ready([]),
},
})
const { container, unmount } = await renderPage(store)
const branch = row(container, 'Worker')
expect(branch.getAttribute('aria-level')).toBe('1')
expect(branch.getAttribute('aria-expanded')).toBe('true')
// The grandchild is rendered one level down and is a KNOWN LEAF (its own
// catalog loaded empty), so it carries no disclosure.
const leaf = row(container, 'Grandchild')
expect(leaf.getAttribute('aria-level')).toBe('2')
expect(leaf.hasAttribute('aria-expanded')).toBe(false)
// Mode + activity come from the row and the live channel respectively.
expect(branch.getAttribute('aria-label')).toContain('可续接')
expect(leaf.getAttribute('aria-label')).toContain('一次性')
expect(branch.getAttribute('aria-label')).toContain('空闲')
unmount()
})
it('keeps a mode-less (unknown) row unlabelled instead of claiming a mode', async () => {
const store = makeStore({
byId: { root: { id: 'root', displayTitle: '主会话' } },
projectionsBySession: {
// 0.1.7's third mode: a child the host kept without a readable
// descriptor. It claims neither mode, so the card states neither.
root: ready([entry('opaque', 'unknown', 'Opaque child')]),
opaque: ready([]),
},
})
const { container, unmount } = await renderPage(store)
const unknown = row(container, 'Opaque child')
const label = unknown.getAttribute('aria-label') ?? ''
expect(label).toContain('Opaque child')
expect(label).toContain('空闲')
expect(label).not.toContain('一次性')
expect(label).not.toContain('可续接')
unmount()
})
it('opens a child through the workspace face (the sessions face is gone since 0.1.6)', async () => {
const navigation: NavigationSpy = { opened: [] }
const store = makeStore(baseSnapshot())
const { container, unmount } = await renderPage(store, true, navigation)
await act(async () => { row(container, '子代理').click() })
// The runtime removed ISessions.openSubagent after 0.1.5-rc.2: the
// workspace face owns main-view selection and is what must be called.
expect(navigation.opened).toEqual([
{ parentSessionId: 'root', childSessionId: 'child', mode: 'continuable' },
])
unmount()
})
it('retries a failed catalog through the 0.1.7 refresh name', async () => {
const refreshed: string[] = []
const store = makeStore({
byId: { root: { id: 'root', displayTitle: '主会话' } },
projectionsBySession: {
root: {
values: {},
state: 'error',
error: { code: 'boom', message: '目录读取失败' },
},
},
})
const rendered = renderRoot(createElement(SubagentView, {
sessionId: 'root',
active: true,
ctx: {
sessions: {
list: store,
refreshProjections: async (sessionId: string) => { refreshed.push(sessionId) },
},
get: () => undefined,
} as unknown as Context,
}))
await flushJobs()
expect(rendered.container.textContent).toContain('目录读取失败')
const retry = [...rendered.container.querySelectorAll('button')]
.find(button => button.textContent?.includes('重试'))
expect(retry).toBeDefined()
await act(async () => { retry?.click() })
expect(refreshed).toEqual(['root'])
rendered.unmount()
})
})
describe('SubagentView background jobs', () => {
it('renders the tree jobs with status, durations, and owner labels', async () => {
it('renders the tree jobs with status, durations, and owner labels', () => {
const store = makeStore(baseSnapshot())
const { container, unmount } = await renderPage(store)
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
const text = container.textContent ?? ''
expect(text).toContain('后台任务')
expect(text).toContain('2 个后台任务 · 1 运行中')
@@ -280,99 +133,97 @@ describe('SubagentView background jobs', () => {
expect(text).toContain('echo hi')
expect(text).toContain('主会话')
expect(text).toContain('子代理')
// The fence admits a job to its owner only, so the tree costs one read per
// tree session (and never a read of a session outside the tree).
expect([...jobListCalls].sort()).toEqual(['child', 'root'])
// Only the running row offers a kill button.
expect(container.querySelectorAll('button[aria-label="终止"]')).toHaveLength(1)
unmount()
})
it('renders nothing job-related when the tree has no jobs', async () => {
jobsByOwner = {}
const store = makeStore({ byId: { root: { id: 'root', displayTitle: '主会话' } } })
const { container, unmount } = await renderPage(store)
it('renders nothing job-related when the mirror is empty', () => {
const store = makeStore({ current: 'root', byId: { root: { id: 'root', displayTitle: '主会话' } }, subagentsByParent: {}, jobsBySession: {} })
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
expect(container.textContent).not.toContain('后台任务')
unmount()
})
it('survives the job list emptying while mounted (hook-order regression #300)', async () => {
vi.useFakeTimers()
try {
const store = makeStore(baseSnapshot())
const { container, unmount } = await renderPage(store)
expect(container.textContent).toContain('sleep 300')
// Every job settles and drops: the section must vanish WITHOUT reordering
// hooks — a hook below the empty-state return would crash React with
// "Rendered fewer hooks than expected" (the minified #300 the sidebar
// boundary surfaces with a retry button).
jobsByOwner = {}
await act(async () => { await vi.advanceTimersByTimeAsync(3_000) })
expect(container.textContent).not.toContain('后台任务')
// And returning jobs must work too, with the same hook order.
jobsByOwner = {
root: [{ id: 'bash-1', kind: 'bash', label: 'sleep 300', status: 'running', startedAt: 1_000 }],
}
await act(async () => { await vi.advanceTimersByTimeAsync(3_000) })
expect(container.textContent).toContain('sleep 300')
unmount()
} finally {
vi.useRealTimers()
}
})
it('shows the selected job output in the bottom dock, closeable', async () => {
it('survives the mirror emptying while mounted (hook-order regression #300)', async () => {
const store = makeStore(baseSnapshot())
const { container, unmount } = await renderPage(store)
const first = container.querySelector('button[aria-label*="sleep 300"]') as HTMLButtonElement
await act(async () => { first.click() })
// The peek request carries the OWNER session (the fence compares it).
expect(outputCalls).toEqual([{ sessionId: 'root', id: 'bash-1' }])
expect(container.textContent).toContain('output-of-bash-1')
// Exactly one dock region exists (never one per row).
expect(container.querySelectorAll('[role="region"]')).toHaveLength(1)
// The selected row is marked, and the close button dismisses the dock.
expect(first.getAttribute('aria-pressed')).toBe('true')
const close = container.querySelector('button[aria-label="关闭"]') as HTMLButtonElement
await act(async () => { close.click() })
expect(container.textContent).not.toContain('output-of-bash-1')
expect(container.querySelectorAll('[role="region"]')).toHaveLength(0)
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
expect(container.textContent).toContain('sleep 300')
// The mirror empties (all jobs settled and dropped): the section must
// vanish WITHOUT reordering hooks — a hook below the empty-state return
// would crash React with "Rendered fewer hooks than expected" (the
// minified #300 the sidebar boundary surfaces with a retry button).
await act(async () => { store.set({ ...baseSnapshot(), jobsBySession: {} }) })
expect(container.textContent).not.toContain('后台任务')
// And returning jobs must work too, with the same hook order.
await act(async () => { store.set(baseSnapshot()) })
expect(container.textContent).toContain('sleep 300')
unmount()
})
it('switches the single dock between selected rows', async () => {
it('shows the selected job output in an anchored popover, dismissable', async () => {
const store = makeStore(baseSnapshot())
const { container, unmount } = await renderPage(store)
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
const row = container.querySelector('button[aria-label*="sleep 300"]') as HTMLButtonElement
await act(async () => { row.click() })
// The peek request carries the OWNER session (the fence compares it).
expect(outputCalls).toEqual([{ sessionId: 'root', id: 'bash-1' }])
// The popover is portaled to document.body (outside the tab container).
expect(document.body.textContent).toContain('output-of-bash-1')
expect(document.querySelectorAll('[role="dialog"]')).toHaveLength(1)
// Escape dismisses the popover.
await act(async () => {
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
})
expect(document.body.textContent).not.toContain('output-of-bash-1')
expect(document.querySelectorAll('[role="dialog"]')).toHaveLength(0)
unmount()
})
it('switches the single output popover between rows', async () => {
const store = makeStore(baseSnapshot())
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
const first = container.querySelector('button[aria-label*="sleep 300"]') as HTMLButtonElement
await act(async () => { first.click() })
expect(container.textContent).toContain('output-of-bash-1')
expect(document.body.textContent).toContain('output-of-bash-1')
const second = container.querySelector('button[aria-label*="echo hi"]') as HTMLButtonElement
await act(async () => { second.click() })
// One dock, now fed by the second job (its owner session scopes the replay).
// One popover, now fed by the second job (its owner session scopes the replay).
expect(outputCalls).toEqual([
{ sessionId: 'root', id: 'bash-1' },
{ sessionId: 'child', id: 'bash-2' },
])
expect(container.querySelectorAll('[role="region"]')).toHaveLength(1)
expect(container.textContent).not.toContain('output-of-bash-1')
expect(container.textContent).toContain('output-of-bash-2')
expect(document.querySelectorAll('[role="dialog"]')).toHaveLength(1)
expect(document.body.textContent).not.toContain('output-of-bash-1')
expect(document.body.textContent).toContain('output-of-bash-2')
unmount()
})
it('explains when the model has not read the job yet', async () => {
jobsByOwner = {
const snapshot = baseSnapshot()
snapshot.jobsBySession = {
root: [
...jobsByOwner.root ?? [],
...(snapshot.jobsBySession?.root ?? []),
{ id: 'bash-9', kind: 'bash', label: 'unread cmd', status: 'running', startedAt: 9_000 },
],
}
const store = makeStore(baseSnapshot())
const { container, unmount } = await renderPage(store)
const unread = container.querySelector('button[aria-label*="unread cmd"]') as HTMLButtonElement
await act(async () => { unread.click() })
// read:false → the pane explains the output awaits the model's job_output
const store = makeStore(snapshot)
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
const row = container.querySelector('button[aria-label*="unread cmd"]') as HTMLButtonElement
await act(async () => { row.click() })
// read:false → the popover explains the output awaits the model's job_output
// (never the model's cursor, so there is nothing to steal yet).
expect(container.textContent).toContain('等待模型读取该任务的输出')
expect(document.body.textContent).toContain('等待模型读取该任务的输出')
unmount()
})
@@ -385,22 +236,30 @@ describe('SubagentView background jobs', () => {
startedAt: 1_000 + index,
...(index % 2 === 0 ? {} : { finishedAt: 2_000 + index, detail: 'exit code: 1' }),
}))
jobsByOwner = { root: many }
const store = makeStore({ byId: { root: { id: 'root', displayTitle: '主会话' } } })
const { container, unmount } = await renderPage(store)
const store = makeStore({
current: 'root',
byId: { root: { id: 'root', displayTitle: '主会话' } },
subagentsByParent: {},
jobsBySession: { root: many },
})
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
expect(container.textContent).toContain('60 个后台任务 · 30 运行中')
expect(container.querySelectorAll('button[aria-label*="bulk cmd"]')).toHaveLength(60)
// Clicking a row anywhere in the long list still feeds the single dock.
const bulk = container.querySelector('button[aria-label*="bulk cmd 59"]') as HTMLButtonElement
await act(async () => { bulk.click() })
// Clicking a row anywhere in the long list still feeds the single popover.
const row = container.querySelector('button[aria-label*="bulk cmd 59"]') as HTMLButtonElement
await act(async () => { row.click() })
expect(outputCalls).toEqual([{ sessionId: 'root', id: 'bash-69' }])
expect(container.textContent).toContain('output-of-bash-69')
expect(document.body.textContent).toContain('output-of-bash-69')
unmount()
})
it('kills a live job only after the two-click confirm', async () => {
const store = makeStore(baseSnapshot())
const { container, unmount } = await renderPage(store)
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
const kill = container.querySelector('button[aria-label="终止"]') as HTMLButtonElement
await act(async () => { kill.click() })
// First click only arms the confirm — no request leaves the page.
@@ -412,54 +271,21 @@ describe('SubagentView background jobs', () => {
unmount()
})
it('does not read jobs or poll output while the page is hidden', async () => {
it('does not poll the output while the page is hidden', async () => {
vi.useFakeTimers()
try {
const store = makeStore(baseSnapshot())
const { container, unmount } = await renderPage(store, false)
// A hidden page issues no job read at all (it also has no rows to show).
expect(jobListCalls).toEqual([])
expect(container.textContent).not.toContain('后台任务')
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: false, ctx: makeCtx(store) }),
)
const row = container.querySelector('button[aria-label*="sleep 300"]') as HTMLButtonElement
await act(async () => { row.click() })
await act(async () => { await vi.advanceTimersByTimeAsync(10_000) })
expect(jobListCalls).toEqual([])
expect(outputCalls).toEqual([])
// One peek for the initial load; the 2s poll never ran while inactive.
expect(outputCalls).toHaveLength(1)
unmount()
} finally {
vi.useRealTimers()
}
})
it('stops polling the output and the job lists while the page is hidden', async () => {
vi.useFakeTimers()
try {
const store = makeStore(baseSnapshot())
// Render visible to load the rows (a hidden page has none), then hide the
// page and hold the counters still.
const rendered = renderRoot(createElement(SubagentView, {
sessionId: 'root',
active: true,
ctx: makeCtx(store),
}))
await flushJobs()
const first = rendered.container.querySelector('button[aria-label*="sleep 300"]') as HTMLButtonElement
await act(async () => { first.click() })
expect(outputCalls).toHaveLength(1)
rendered.rerender(createElement(SubagentView, {
sessionId: 'root',
active: false,
ctx: makeCtx(store),
}))
// The visibility flip itself re-pulls the dock once (the pane's own
// effect deps include `active`); what must stop is everything after it.
await flushJobs()
const peeks = outputCalls.length
const reads = jobListCalls.length
await act(async () => { await vi.advanceTimersByTimeAsync(10_000) })
expect(outputCalls).toHaveLength(peeks)
expect(jobListCalls).toHaveLength(reads)
rendered.unmount()
} finally {
vi.useRealTimers()
}
})
})
+185
View File
@@ -0,0 +1,185 @@
/**
* 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 type {
Context,
SidebarSessionList,
SidebarSessionSummary,
SidebarSubagentCatalog,
} 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 client context face SubagentView touches. */
function makeCtx(store: Store, spies: { openSubagent?: (address: unknown) => void } = {}): Context {
return {
sessions: {
list: store,
setSubagentCatalogOpen: () => {},
openSubagent: spies.openSubagent ?? (() => {}),
open: () => {},
refreshSubagents: async () => {},
},
} as unknown as Context
}
function jsonResponse(value: unknown): Response {
return { ok: true, status: 200, json: async () => value } as unknown as Response
}
beforeEach(() => {
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 === '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). */
function snapshotWithChildren(count: number): SidebarSessionList {
const byId: Record<string, SidebarSessionSummary> = {
root: { id: 'root', displayTitle: '主会话', running: true },
}
const entries: SidebarSubagentCatalog['entries'] = []
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({ kind: 'child', id, activity: 'inactive', hasChildren: false, mode: 'one-shot', label: `子代理 ${index}` })
}
return {
current: 'root',
byId,
subagentsByParent: {
root: { entries, parentAvailable: true, state: 'ready', error: null },
},
jobsBySession: {
root: [{ id: 'bash-1', kind: 'bash', label: 'sleep 300', status: 'running', startedAt: 1_000 }],
},
}
}
describe('Tasks page interactions', () => {
it('keeps the jobs drawer open below the agent threshold', () => {
const store = makeStore(snapshotWithChildren(2))
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
// 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) }),
)
// 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('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('jumps to the transcript on node click and opens the ⓘ popover', 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 }),
)
// 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('[aria-label*="子代理 0"]') as HTMLElement
await act(async () => { node.click() })
expect(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('opens the node detail popover from the ⓘ button', async () => {
const store = makeStore(snapshotWithChildren(1))
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
// The root node's ⓘ opens the detail card (portaled to body).
const info = container.querySelector('button[aria-label="状态"]') as HTMLButtonElement
await act(async () => { info.click() })
expect(document.querySelectorAll('[role="dialog"]')).toHaveLength(1)
expect(document.body.textContent).toContain('查看转录')
unmount()
})
})