chore: adapt the tasks/workflow/teams work to DSH 0.1.7-rc.1 (rebase onto v0.21.1)

Rebases the whole branch onto main's new 0.1.7-rc.1 baseline and lands the
adaptation the new host contract requires: the ui-primitives icon family rename
(Icon<Name><14|16> -> Icon<Name>Regular/Medium, with the 14px defaults pinned
back explicitly) and the StateDot redraw, the rewritten dsh-settings surface
(entry-id namespace, DSH schemastery with collected volatile returns,
configure auto:false), session format v4 (producer-owned source kind plus the
rewritten tool-result shape, parsers accepting both), the client snapshot move
to projectionsBySession.subagentCatalog with the jobs.list route behind it, the
^0.1.7-rc.1 peer floor with vendor and bare-import devDeps, the reduced chunk
set mirrored in all eight places, and the capability hand-backs (read-only
previews, terminal, browser).

Verified: typecheck and lint clean; 119 files / 1200 tests; build with no old
contract residue in the bundles; mount e2e 7/7; the tasks page still passes its
70-assertion real-browser contract.
This commit is contained in:
Menghuan1918
2026-09-27 22:18:19 +08:00
parent b554665bde
commit 2a39a0c8b2
41 changed files with 577 additions and 435 deletions
+8 -1
View File
@@ -14,10 +14,17 @@ import { describe, expect, it } from 'vitest'
// (CodeMirror's UA probe).
import './browser-globals.ts'
import { CHUNK_EXTERNALS } from '../src/client/chunk-loader.ts'
import { CHUNK_NAMES } from '../src/bundle-route.ts'
const g = globalThis as Record<string, unknown>
const CHUNKS = ['editor', 'mermaid']
/**
* The chunk set, DERIVED from the host route's allowlist — the same registry
* `/sidebar/bundle` serves from (src/bundle-route.ts). Never hand-mirrored:
* the hand-written list that used to live here drifted (`locale` was
* missing), so the locale artifact went unguarded.
*/
const CHUNKS = CHUNK_NAMES
/** All chunk artifacts present (tsdown emits the whole lib/ in one run). */
const chunksBuilt = CHUNKS.every(name => existsSync(`lib/client-${name}.js`))
+15
View File
@@ -146,6 +146,21 @@ describe.skipIf(!libBuilt)('registry manifest consistency (dsh.plugin.json)', ()
}
})
it('every chunk is staged by scripts/package-registry.mjs (the registry channel ships its own file list)', () => {
// The community-registry channel installs the staged `registry/` tree
// wholesale, and /sidebar/bundle reads the chunk from THAT copy — a chunk
// missing from the staging list 404s there exactly like one missing from
// package.json#files does on npm. The list is read as source text because
// the script performs its copies at module scope (it cannot be imported).
const script = readFileSync(resolve(ROOT, 'scripts/package-registry.mjs'), 'utf8')
const start = script.indexOf('const files = [')
expect(start, 'scripts/package-registry.mjs declares no `const files = [` list').toBeGreaterThan(-1)
const staged = script.slice(start, script.indexOf(']', start))
for (const file of CHUNK_FILES) {
expect(staged.includes(`'${file}'`), `${file} is not staged by scripts/package-registry.mjs`).toBe(true)
}
})
it('client bundles require only frozen module-table entries', () => {
for (const file of ['lib/client.js', manifest.client!.main!, ...CHUNK_FILES]) {
const source = readFileSync(resolve(ROOT, file), 'utf8')
+226 -90
View File
@@ -1,9 +1,13 @@
/**
* 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.
* Tasks page tests: the projection-backed topology (branch expansion inferred
* from the child's OWN catalog, the `mode: 'unknown'` row, navigation through
* the workspace face, catalog retry through the 0.1.7 refresh name) and the
* background-jobs DRAWER — rows read through the plugin's `jobs.list` route
* one session at a time (the registry's access fence admits a job to its OWNER
* only), clicking a row peeks its output in an anchored popover (portaled to
* document.body) through `jobs.output` with the owner scope, the kill button
* needs a two-click confirm, settled rows offer no kill, and nothing polls
* while the page is hidden.
*/
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -11,7 +15,13 @@ 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 } from '../src/context-types.ts'
import type {
Context,
SidebarJobView,
SidebarSessionList,
SidebarSubagentAddress,
SidebarSubagentCatalogEntry,
} from '../src/context-types.ts'
/** A subscribable sessions-list snapshot (mirror of the runtime list feed). */
function makeStore(initial: SidebarSessionList) {
@@ -32,48 +42,59 @@ function makeStore(initial: SidebarSessionList) {
type Store = ReturnType<typeof makeStore>
/** The client context face SubagentView touches (history stub; everything else inert). */
function makeCtx(store: Store): Context {
/** The navigation spy the page must reach (DSH 0.1.7 `ctx.uiWorkspace`). */
interface NavigationSpy {
opened: Array<SidebarSubagentAddress | string>
}
/**
* The client context face SubagentView touches. Navigation rides the
* workspace face: 0.1.6 moved main-view selection off `ISessions`, so a spy
* left on `sessions.openSubagent` would never fire.
*/
function makeCtx(store: Store, navigation?: NavigationSpy): Context {
return {
sessions: {
list: store,
setSubagentCatalogOpen: () => {},
openSubagent: () => {},
open: () => {},
refreshSubagents: async () => {},
},
connection: {
api: {
subagents: {
history: async () => ({ result: { ok: true, value: { events: [], hasMore: false } } }),
},
},
},
sessions: { list: store },
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 },
},
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 },
],
projectionsBySession: {
root: ready([entry('child', 'continuable')]),
child: ready([]),
},
}
}
@@ -81,12 +102,22 @@ 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).
@@ -116,15 +147,127 @@ beforeEach(() => {
afterEach(() => {
vi.unstubAllGlobals()
vi.useRealTimers()
for (const el of document.querySelectorAll('body > div')) el.remove()
})
describe('SubagentView background jobs', () => {
it('renders the tree jobs with status, durations, and owner labels', () => {
/**
* 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
}
describe('SubagentView topology (projectionsBySession)', () => {
it("expands a branch inferred from the child's 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' },
great: { id: 'great', displayTitle: 'Great', origin: 'subagent', parentId: 'grand' },
},
projectionsBySession: {
root: ready([entry('child', 'continuable', 'Worker')]),
// The child carries a child of its own: 0.1.7 rows have no
// `hasChildren`, it is the child's own catalog that says so.
child: ready([entry('grand', 'one-shot', 'Grandchild')]),
grand: ready([entry('great', 'one-shot', 'Great')]),
great: ready([]),
},
})
const { container, unmount } = await renderPage(store)
expect(container.textContent).toContain('Worker')
expect(container.textContent).toContain('Grandchild')
expect(container.textContent).toContain('可续接')
expect(container.textContent).toContain('一次性')
unmount()
})
it('never claims a mode for an unknown row', 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)
expect(container.textContent).toContain('Opaque child')
expect(container.textContent).not.toContain('一次性')
expect(container.textContent).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 } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
const { container, unmount } = await renderPage(store, true, navigation)
// Unfold so the settled child is reachable, open its detail window and
// use the jump button (the card itself is the detail affordance).
const foldToggle = container.querySelector('button[aria-label="展开已完成的节点"]') as HTMLButtonElement
await act(async () => { foldToggle.click() })
const node = container.querySelector('[data-graph-node="child"]') as HTMLElement
await act(async () => { node.click() })
const jump = [...document.querySelectorAll('[role="dialog"] button')]
.find(button => button.textContent?.includes('查看转录')) as HTMLButtonElement | undefined
await act(async () => { jump?.click() })
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()
// The banner names the failed branch count; the per-row message lives in
// the catalog view the page folds (see `subagent-catalog.ts`).
expect(rendered.container.textContent).toContain('1 个分支加载失败')
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 () => {
const store = makeStore(baseSnapshot())
const { container, unmount } = await renderPage(store)
const text = container.textContent ?? ''
expect(text).toContain('后台任务')
expect(text).toContain('2 个后台任务 · 1 运行中')
@@ -133,43 +276,50 @@ 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 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) }),
)
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)
expect(container.textContent).not.toContain('后台任务')
unmount()
})
it('survives the mirror emptying while mounted (hook-order regression #300)', async () => {
const store = makeStore(baseSnapshot())
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('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(5_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(5_000) })
expect(container.textContent).toContain('sleep 300')
unmount()
} finally {
vi.useRealTimers()
}
})
it('shows the selected job output in an anchored popover, dismissable', async () => {
const store = makeStore(baseSnapshot())
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
const { container, unmount } = await renderPage(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).
@@ -188,9 +338,7 @@ describe('SubagentView background jobs', () => {
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 { container, unmount } = await renderPage(store)
const first = container.querySelector('button[aria-label*="sleep 300"]') as HTMLButtonElement
await act(async () => { first.click() })
expect(document.body.textContent).toContain('output-of-bash-1')
@@ -208,17 +356,14 @@ describe('SubagentView background jobs', () => {
})
it('explains when the model has not read the job yet', async () => {
const snapshot = baseSnapshot()
snapshot.jobsBySession = {
jobsByOwner = {
root: [
...(snapshot.jobsBySession?.root ?? []),
...jobsByOwner.root ?? [],
{ id: 'bash-9', kind: 'bash', label: 'unread cmd', status: 'running', startedAt: 9_000 },
],
}
const store = makeStore(snapshot)
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
const store = makeStore(baseSnapshot())
const { container, unmount } = await renderPage(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
@@ -236,15 +381,9 @@ describe('SubagentView background jobs', () => {
startedAt: 1_000 + index,
...(index % 2 === 0 ? {} : { finishedAt: 2_000 + index, detail: 'exit code: 1' }),
}))
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) }),
)
jobsByOwner = { root: many }
const store = makeStore({ byId: { root: { id: 'root', displayTitle: '主会话' } } })
const { container, unmount } = await renderPage(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 popover.
@@ -257,9 +396,7 @@ describe('SubagentView background jobs', () => {
it('kills a live job only after the two-click confirm', async () => {
const store = makeStore(baseSnapshot())
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
const { container, unmount } = await renderPage(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.
@@ -271,18 +408,17 @@ describe('SubagentView background jobs', () => {
unmount()
})
it('does not poll the output while the page is hidden', async () => {
it('does not read jobs or poll output while the page is hidden', async () => {
vi.useFakeTimers()
try {
const store = makeStore(baseSnapshot())
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() })
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('后台任务')
await act(async () => { await vi.advanceTimersByTimeAsync(10_000) })
// One peek for the initial load; the 2s poll never ran while inactive.
expect(outputCalls).toHaveLength(1)
expect(jobListCalls).toEqual([])
expect(outputCalls).toEqual([])
unmount()
} finally {
vi.useRealTimers()
+39 -17
View File
@@ -13,10 +13,11 @@ import {
} from '../src/client/tasks-model.ts'
import type {
SidebarSessionSummary,
SidebarSubagentCatalog,
SidebarSubagentChildEntry,
SidebarSubagentCatalogEntry,
SidebarTeamMemberView,
} from '../src/context-types.ts'
import type { SubagentCatalogView } from '../src/client/subagent-catalog.ts'
import type { LastActivity } from '../src/subagent-activity.ts'
import type { WorkflowRunView } from '../src/workflow-runs.ts'
/** A summary row. */
@@ -24,14 +25,19 @@ function summary(id: string, over: Partial<SidebarSessionSummary> = {}): Sidebar
return { id, displayTitle: id, ...over }
}
/** A catalog child row. */
function child(id: string, over: Partial<SidebarSubagentChildEntry> = {}): SidebarSubagentChildEntry {
return { kind: 'child', id, activity: 'running', hasChildren: false, mode: 'one-shot', ...over }
/** One `subagentCatalog` projection row (DSH 0.1.7 shape). */
function child(id: string, over: Partial<SidebarSubagentCatalogEntry> = {}): SidebarSubagentCatalogEntry {
return { id, createdAt: 0, mode: 'one-shot', ...over }
}
/** A ready catalog. */
function catalog(entries: SidebarSubagentCatalog['entries']): SidebarSubagentCatalog {
return { entries, parentAvailable: true, state: 'ready', error: null }
/** A loaded catalog view (what the projection folds into). */
function catalog(entries: SidebarSubagentCatalogEntry[]): SubagentCatalogView {
return { entries, state: 'ready', error: null }
}
/** The live channel's fold: only the RUNNING children appear (DSH 0.1.7). */
function running(...ids: string[]): Record<string, LastActivity> {
return Object.fromEntries(ids.map(id => [id, { text: `${id} is working` }]))
}
/** A workflow run view. */
@@ -71,7 +77,7 @@ describe('buildTasksModel', () => {
it('walks the catalogs in pre-order and marks the current session', () => {
const model = buildTasksModel(input({
catalogs: {
root: catalog([child('a'), child('b', { activity: 'inactive' })]),
root: catalog([child('a'), child('b')]),
a: catalog([child('a1')]),
},
byId: {
@@ -79,6 +85,7 @@ describe('buildTasksModel', () => {
a: summary('a'), b: summary('b'), a1: summary('a1'),
},
currentSessionId: 'a1',
live: running('a', 'a1'),
folded: false,
}))
expect(model.map(node => node.id)).toEqual(['root', 'a', 'a1', 'b'])
@@ -86,6 +93,9 @@ describe('buildTasksModel', () => {
expect(agents(model).find(node => node.id === 'a1')?.childAddress).toEqual({
parentSessionId: 'a', childSessionId: 'a1', mode: 'one-shot',
})
// Activity comes from the live channel, and `b` (absent there) is settled.
expect(agents(model).find(node => node.id === 'a')?.state).toBe('running')
expect(agents(model).find(node => node.id === 'b')?.state).toBe('done')
})
it('excludes Side Chat threads from the model', () => {
@@ -108,7 +118,7 @@ describe('buildTasksModel', () => {
}],
})
const model = buildTasksModel(input({
catalogs: { root: catalog([child('m1', { activity: 'inactive' })]) },
catalogs: { root: catalog([child('m1')]) },
byId: { root: summary('root'), m1: summary('m1', { displayTitle: 'M1' }) },
runs: [audit],
folded: false,
@@ -138,12 +148,15 @@ describe('buildTasksModel', () => {
const model = buildTasksModel(input({
catalogs: {
root: catalog([
child('done-1', { activity: 'inactive' }),
child('done-2', { activity: 'inactive' }),
child('live', { activity: 'running' }),
child('mate', { activity: 'inactive' }),
child('parent', { activity: 'inactive', hasChildren: true }),
child('done-1'), child('done-2'), child('live'), child('mate'), child('parent'),
]),
// A KNOWN LEAF is a child whose OWN catalog loaded empty (0.1.7 rows
// carry no `hasChildren`): only those two are fold candidates.
'done-1': catalog([]),
'done-2': catalog([]),
'live': catalog([]),
'mate': catalog([]),
// `parent` has a child of its own, so it keeps its whole branch.
parent: catalog([child('grandchild')]),
},
byId: {
@@ -152,6 +165,7 @@ describe('buildTasksModel', () => {
live: summary('live'), mate: summary('mate'),
parent: summary('parent'), grandchild: summary('grandchild'),
},
live: running('live'),
teamMembers: [member({ id: 'mate', status: 'idle' })],
}))
expect(model.map(node => node.id)).toEqual([
@@ -161,9 +175,17 @@ describe('buildTasksModel', () => {
expect(fold).toMatchObject({ parentId: 'root', count: 2, memberIds: ['done-1', 'done-2'] })
})
it('keeps a child whose own catalog is unknown (never folds on missing data)', () => {
const model = buildTasksModel(input({
catalogs: { root: catalog([child('opaque')]) },
byId: { root: summary('root'), opaque: summary('opaque') },
}))
expect(model.map(node => node.id)).toEqual(['root', 'opaque'])
})
it('unfolds everything when folded is false', () => {
const base = input({
catalogs: { root: catalog([child('done-1', { activity: 'inactive' })]) },
catalogs: { root: catalog([child('done-1')]) },
byId: { root: summary('root'), 'done-1': summary('done-1') },
folded: false,
})
@@ -204,7 +226,7 @@ describe('buildTasksModel: duplicate workflow members', () => {
const model = buildTasksModel(input({
catalogs: {
root: catalog([child('a')]),
a: catalog([child('m1', { activity: 'inactive' })]),
a: catalog([child('m1')]),
},
byId: { root: summary('root'), a: summary('a'), m1: summary('m1') },
runs: [audit],
+53 -26
View File
@@ -12,9 +12,12 @@ import { renderRoot } from './test-utils.ts'
import { SubagentView } from '../src/client/SubagentView.tsx'
import type {
Context,
SidebarJobView,
SidebarProjectionSnapshot,
SidebarSessionList,
SidebarSessionSummary,
SidebarSubagentCatalog,
SidebarSubagentAddress,
SidebarSubagentCatalogEntry,
} from '../src/context-types.ts'
/** A subscribable sessions-list snapshot (mirror of the runtime list feed). */
@@ -36,16 +39,21 @@ function makeStore(initial: SidebarSessionList) {
type Store = ReturnType<typeof makeStore>
/** The navigation spy the page must reach (DSH 0.1.7 `ctx.uiWorkspace`). */
interface NavigationSpy {
opened: Array<SidebarSubagentAddress | string>
}
/** The client context face SubagentView touches. */
function makeCtx(store: Store, spies: { openSubagent?: (address: unknown) => void } = {}): Context {
function makeCtx(store: Store, navigation?: NavigationSpy): Context {
return {
sessions: {
list: store,
setSubagentCatalogOpen: () => {},
openSubagent: spies.openSubagent ?? (() => {}),
open: () => {},
refreshSubagents: async () => {},
refreshProjections: async () => {},
},
get: (name: string) => (name === 'uiWorkspace' && navigation !== undefined
? { openSession: (target: SidebarSubagentAddress | string) => { navigation.opened.push(target) } }
: undefined),
} as unknown as Context
}
@@ -56,11 +64,16 @@ function jsonResponse(value: unknown): Response {
let teamPayload: unknown = { available: false }
const teamMutations: Array<{ method: string; body: Record<string, unknown> }> = []
const fetchedMethods: string[] = []
/** Job lists the stubbed `jobs.list` route answers with, keyed by OWNER session. */
let jobsByOwner: Record<string, SidebarJobView[]> = {}
beforeEach(() => {
teamPayload = { available: false }
teamMutations.length = 0
fetchedMethods.length = 0
jobsByOwner = {
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 ?? '')
@@ -68,6 +81,9 @@ beforeEach(() => {
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: teamPayload })
if (method === 'jobs.list') {
return jsonResponse({ ok: true, value: { jobs: jobsByOwner[String(body.sessionId ?? '')] ?? [] } })
}
if (method === 'teams.taskCreate' || method === 'teams.taskUpdate') {
teamMutations.push({ method: method ?? '', body })
return jsonResponse({
@@ -92,35 +108,42 @@ afterEach(() => {
for (const el of document.querySelectorAll('body > div')) el.remove()
})
/** A snapshot with N direct children of root (all settled one-shots). */
/**
* 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 entries: SidebarSubagentCatalog['entries'] = []
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({ kind: 'child', id, activity: 'inactive', hasChildren: false, mode: 'one-shot', label: `子代理 ${index}` })
entries.push({ id, createdAt: 1_000 + index, mode: 'one-shot', label: `子代理 ${index}` })
projectionsBySession[id] = { values: { subagentCatalog: [] }, state: 'ready', error: null }
}
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 }],
},
projectionsBySession.root = { values: { subagentCatalog: entries }, state: 'ready', error: null }
return { byId, projectionsBySession }
}
/** Flush the mount-time job reads (request → envelope → json → Promise.all). */
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', () => {
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('已自动折叠')
@@ -132,6 +155,7 @@ describe('Tasks page interactions', () => {
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('已自动折叠')
@@ -162,9 +186,9 @@ describe('Tasks page interactions', () => {
})
it('opens the node detail window on card click; the jump button goes to the transcript', async () => {
const opened: unknown[] = []
const navigation: NavigationSpy = { opened: [] }
const store = makeStore(snapshotWithChildren(1))
const ctx = makeCtx(store, { openSubagent: (address) => { opened.push(address) } })
const ctx = makeCtx(store, navigation)
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx }),
)
@@ -174,14 +198,16 @@ describe('Tasks page interactions', () => {
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(opened).toEqual([])
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() })
expect(opened).toEqual([{ parentSessionId: 'root', childSessionId: 'child-0', mode: 'one-shot' }])
// 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()
})
@@ -218,9 +244,9 @@ describe('Tasks page interactions', () => {
describe('Tasks page graph interactions and team board', () => {
it('activates a graph node after a background pointerdown (no click theft)', async () => {
const opened: unknown[] = []
const navigation: NavigationSpy = { opened: [] }
const store = makeStore(snapshotWithChildren(1))
const ctx = makeCtx(store, { openSubagent: (address) => { opened.push(address) } })
const ctx = makeCtx(store, navigation)
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx }),
)
@@ -238,7 +264,7 @@ describe('Tasks page graph interactions and team board', () => {
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(opened).toEqual([])
expect(navigation.opened).toEqual([])
unmount()
})
@@ -391,6 +417,7 @@ describe('Tasks page: owned tasks, host-primitive controls, draggable output', (
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() })
const card = document.querySelector('[role="dialog"]') as HTMLElement
-101
View File
@@ -197,104 +197,3 @@ describe('skin contract: the plugin owns no color of its own', () => {
}
})
})
/**
* Task-page migration guard (the shadcn/ui rework).
*
* The vendored components under `src/client/ui/**` and every migrated
* task-page module must take their colors from a DSH token — either directly
* (`var(--dsw-…)`) or through one of the shadcn aliases bridged in
* `src/client/ui/theme.css` (`--background`, `--border`, …). Three ways that
* can rot silently:
*
* 1. A color literal (`#hex`, `rgb()`, `oklch()`, …) — the host skin can no
* longer repaint it, and the value stops flipping with dark mode.
* 2. A Tailwind default-palette class (`bg-blue-500`, `text-white`) — its
* value is defined by Tailwind's own theme, not by DSH.
* 3. A `var()` in a migrated stylesheet pointing at neither a `--dsw-*` token
* nor a bridged alias — a typo falls back to the initial color.
*
* Comments are stripped first: prose legitimately quotes the old values (the
* Badge docstring records that upstream's `text-white` became the
* `--destructive-foreground` token), and upstream issue numbers look like
* three-digit hex colors.
*/
const VENDORED_UI_DIR = 'src/client/ui'
const MIGRATED_TASK_FILES = [
'src/client/SubagentView.tsx',
'src/client/SubagentView.module.css',
'src/client/TasksGraph.tsx',
'src/client/TasksTree.tsx',
'src/client/TaskWindow.tsx',
'src/client/TeamBoard.tsx',
'src/client/JobsDrawer.tsx',
'src/client/TasksPopovers.tsx',
'src/client/AnchoredPopover.tsx',
'src/client/tasks-shared.tsx',
'src/client/tasks-canvas.module.css',
]
/** Drop block comments and whole-line `//` comments (URLs/strings stay intact). */
function stripComments(source: string): string {
return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^[ \t]*\/\/.*$/gm, '')
}
/** Any spelled-out color value (the skin contract's forbidden set). */
const COLOR_LITERAL = /#[0-9a-fA-F]{3,8}\b|\b(?:rgba?|hsla?|hwb|oklch|oklab|lab|lch|color-mix|light-dark)\(/
/** Tailwind's default palette families, which are not DSH tokens. */
const PALETTE_CLASS
= /\b(?:bg|text|border|ring|fill|stroke|from|via|to|outline|divide|shadow|accent|caret|decoration)-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose|white|black)(?:-\d{2,3})?\b/
/** Paint declarations whose value must resolve to a token. */
const PAINT_DECLARATION
= /(?:^|;)\s*(background(?:-color|-image)?|color|border(?:-(?:top|right|bottom|left))?-color|border|outline(?:-color)?|fill|stroke|box-shadow|text-decoration-color|caret-color|accent-color|scrollbar-color)\s*:\s*([^;}]+)/g
/** A paint declaration naming no token at all may only be inert. */
const INERT_PAINT = /^(?:none|transparent|currentcolor|inherit|initial|unset|revert)$/i
/** Every variable the theme bridge defines (or forwards) — the alias allowlist. */
const THEME_ALIASES = new Set(
[...readFileSync(resolve(ROOT, `${VENDORED_UI_DIR}/theme.css`), 'utf8').matchAll(/--([a-z0-9-]+)\s*:/g)]
.map(match => match[1]),
)
/** `--dsw-*` is the contract; the bridged aliases and runtime vars are the only indirection. */
function tokenAllows(name: string): boolean {
return name.startsWith('dsw-') || name.startsWith('tw-') || name.startsWith('radix-') || THEME_ALIASES.has(name)
}
describe('skin contract: the migrated task page and the vendored ui/ own no color', () => {
const uiFiles = readdirSync(resolve(ROOT, VENDORED_UI_DIR))
.filter(name => /\.(?:tsx|ts|css)$/.test(name))
.map(name => `${VENDORED_UI_DIR}/${name}`)
const files = [...uiFiles, ...MIGRATED_TASK_FILES]
it('covers the vendored components and the migrated task-page modules', () => {
expect(uiFiles.length).toBeGreaterThan(10)
for (const file of files) expect(readFileSync(resolve(ROOT, file), 'utf8').length, file).toBeGreaterThan(0)
})
it.each(files)('%s carries no color literal', (file) => {
expect(stripComments(readFileSync(resolve(ROOT, file), 'utf8')), file).not.toMatch(COLOR_LITERAL)
})
it.each(files)('%s uses no Tailwind default-palette class', (file) => {
expect(stripComments(readFileSync(resolve(ROOT, file), 'utf8')), file).not.toMatch(PALETTE_CLASS)
})
it('binds every paint declaration in the migrated stylesheets to a token', () => {
for (const file of files.filter(name => name.endsWith('.css'))) {
const styles = stripComments(readFileSync(resolve(ROOT, file), 'utf8'))
for (const declaration of styles.matchAll(PAINT_DECLARATION)) {
const [, property, value] = declaration
const vars = [...(value ?? '').matchAll(/var\(--([a-z0-9-]+)/gi)].map(match => match[1] ?? '')
if (vars.length === 0) {
expect(INERT_PAINT.test((value ?? '').trim()), `${file}: ${property}: ${value}`).toBe(true)
continue
}
for (const name of vars) expect(tokenAllows(name), `${file}: ${property}: var(--${name})`).toBe(true)
}
}
})
})