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:
@@ -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', () => {
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user