Files
dsh-context/tests/client/components/contextView.agentNetwork.spec.ts
bowenliang123 9b0618a7a6 feat(client): gate the Fleet tab behind a preference
The preferences card gains a "Fleet tab" row (Show/Hide, default Show): the tab is
registered or unwound live, and the placement preference still has the final say —
'sidebar' serves no conversation tabs at all. The ninth preference field rides the
same Config schema and settings transport as the rest, including the fail-open read
(a value the plugin cannot read degrades to Show) and the rollback when a write is
refused.

The three on/off rows (entry visibility, the Fleet tab, the duration curve) now
share one row shape and one set of Show/Hide labels instead of a copy each, and the
mount/unmount/subscribe watcher both gates ride is one helper (toggleGate.ts) —
watchInsightPage and watchFleetTab were the same skeleton twice, down to the
registration-handle guard.

One preference now costs several coordinated edits in the settings store, so the
snapshot comparison is a shallow compare over the state's own keys and the refused-
write rollback is one table lookup, not a chain that has to grow with every field.

The stats board's family cell hands its jump to the caller: statsContext no longer
reaches into the tab bar for the Agent Network view. contextView owns the targets —
the Fleet tab when the preferences serve one, else the right Sidebar panel that
keeps the card inline — and drops the cell's button role when it serves neither, so
a hidden tab can never leave a dead button behind.
2026-10-10 20:21:57 +08:00

115 lines
6.1 KiB
TypeScript

// The Agent network card's two Context-view hosts: the right Sidebar keeps it inline (its column has no room
// for a tab of its own), while the center Context tab hands it to the Fleet tab. The card's OWN session stats
// come from the view's projections — a list row carries no subagent-timing projection (the harness writes that
// one on subagent sessions alone), so its duration and step count have to arrive from here.
import { createElement as h } from 'react'
import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import { createContextSettings } from '../../../src/client/settings'
import { TestClientCtx } from '../helpers/harness'
import { click, mount, query, queryAll, text } from '../helpers/kit'
import { makeView, projectionsFor, richTimeline } from './contextViewHarness'
/** The outward sessions face the card reads: one row, the current session, no subagent.
* getSnapshot hands back the SAME object every call — the card reads it through
* useSyncExternalStore, which loops on a fresh snapshot per read. */
function selfOnly() {
const snapshot = { byId: { 'sv-self': { displayTitle: 'Main Agent', running: false, blank: false, updatedAt: 1 } } }
return { list: { getSnapshot: (): unknown => snapshot, subscribe: (): (() => void) => () => {} } }
}
const TIMING = { wallMs: 387_000, ttftMs: 0, genMs: 0, calls: 0, toolsMs: 0, toolCalls: 0, tools: {} }
describe('ContextView — the agent network card\'s own session', () => {
test("the panel's active time and step count fill the current card's footer", async () => {
const View = makeView(new TestClientCtx({ services: { sessions: selfOnly() } }))
const m = await mount(h(View, { sessionId: 'sv-self', host: 'sidebar', useProjection: projectionsFor(richTimeline({ timing: TIMING })) }))
const self = query(m.container, '.lc-agent-card.lc-agent-self')
assert.equal(text(self).includes('Main Agent'), true)
// richTimeline carries three steps; the duration is the timing card's active time.
assert.equal(query(self, '.lc-agent-meta-text').textContent, '6m27s · 3 steps')
await m.unmount()
})
test('without timing totals the card drops the duration instead of claiming 0s', async () => {
const View = makeView(new TestClientCtx({ services: { sessions: selfOnly() } }))
const m = await mount(h(View, { sessionId: 'sv-self', host: 'sidebar', useProjection: projectionsFor(richTimeline()) }))
const self = query(m.container, '.lc-agent-card.lc-agent-self')
assert.equal(query(self, '.lc-agent-meta-text').textContent, '3 steps')
await m.unmount()
})
test('the center Context tab hands the card to the Fleet tab; only the sidebar host keeps it inline', async () => {
const View = makeView(new TestClientCtx({ services: { sessions: selfOnly() } }))
const m = await mount(h(View, { sessionId: 'sv-self', useProjection: projectionsFor(richTimeline({ timing: TIMING })) }))
assert.equal(queryAll(m.container, '.lc-agents').length, 0, 'no agent network on the Context tab')
assert.equal(queryAll(m.container, '.lc-agent-card').length, 0)
// The rest of the tab still paints — the split moved the card, not the page.
assert.ok(queryAll(m.container, '.lc-card').length > 0)
await m.unmount()
})
test("the stats board's family cell opens the Fleet tab, else the panel that keeps the card", async () => {
// The tab bar the harness renders: the Fleet tab activates on click, like the real one.
const bar = document.createElement('div')
const tab = (label: string): HTMLButtonElement => {
const b = document.createElement('button')
b.setAttribute('role', 'tab')
b.setAttribute('aria-selected', 'false')
b.textContent = label
bar.appendChild(b)
return b
}
const fleet = tab('Fleet')
let fleetClicks = 0
fleet.addEventListener('click', () => { fleetClicks++ })
document.body.appendChild(bar)
try {
const View = makeView(new TestClientCtx({ services: { sessions: selfOnly() } }))
const m = await mount(h(View, { sessionId: 'sv-self', useProjection: projectionsFor(richTimeline()) }))
await click(query(m.container, '.lc-flow-team'))
assert.equal(fleetClicks, 1, 'the served Fleet tab is activated')
await m.unmount()
fleet.remove()
} finally {
bar.remove()
}
})
test('a hidden Fleet tab falls back to the right Sidebar panel, and to nothing when that is gone too', async () => {
const opened: string[] = []
const sidebar = { openTab: (kind: string): void => { opened.push(kind) } }
const View = makeView(new TestClientCtx({ services: { sessions: selfOnly(), sidebarRight: sidebar } }))
const m = await mount(h(View, { sessionId: 'sv-self', useProjection: projectionsFor(richTimeline()) }))
// No Fleet tab in the tab bar (the fleetTab preference is off): the panel that keeps the card inline opens.
await click(query(m.container, '.lc-flow-team'))
assert.deepEqual(opened, ['dsh-context'], 'the sidebar panel whose Context tab holds the card')
await m.unmount()
// A deployment serving neither: the click stays a quiet no-op.
const bare = makeView(new TestClientCtx({ services: { sessions: selfOnly() } }))
const m2 = await mount(h(bare, { sessionId: 'sv-self', useProjection: projectionsFor(richTimeline()) }))
await click(query(m2.container, '.lc-flow-team'))
await m2.unmount()
})
test('with neither view served the family cell is a plain figure, never a dead button', async () => {
// The tab off and a placement that serves no Sidebar: nothing can open, so the cell drops its button role.
const settings = createContextSettings()
settings.set('defaultPlacement', 'tab')
settings.set('fleetTab', 'hide')
const View = makeView(new TestClientCtx({ services: { sessions: selfOnly() } }), settings)
const m = await mount(h(View, { sessionId: 'sv-self', useProjection: projectionsFor(richTimeline()) }))
const cell = query(m.container, '.lc-flow-team')
assert.equal(cell.getAttribute('role'), null)
assert.equal(cell.getAttribute('tabindex'), null)
await click(cell)
await m.unmount()
})
})