Files
dsh-context/tests/client/components/fleetView.spec.ts
T
bowenliang123 48c423a24d fix(client): reclaim the Fleet tab's top on entry (#164)
The harness keeps one resident `[data-conversation-scroll]` scrollport across
tab switches and the chat bottom-anchors it, so a long session's scrollTop
rode into the Fleet tab and clamped to the page's own scroll floor — the tab
opened at its bottom. The page now re-claims the top per session in a layout
effect, on both the loading frame and the full page, so the inherited
position never paints. No per-session ledger here: the page's height settles
in late passes (forest arms, then details), so a restored position clamps
against the slim first frame and scroll anchoring re-pins the bottom — the
Context tab's ledger stays as is, its content lands at full height in one
commit.

Fixes #164
2026-10-18 21:25:37 +08:00

170 lines
7.6 KiB
TypeScript

// FleetView — the Fleet tab root: the agent network card on a page of its own, split out of the Context tab.
// It reads the same `contextTimeline` head and the same detail source the Context tab does, so every figure it
// paints must match the card's sidebar mount.
import { createElement as h } from 'react'
import assert from 'node:assert/strict'
import { afterEach, describe, test, vi } from 'vitest'
import { makeFleetView } from '../../../src/client/components/fleetView'
import { makeAgentHeads } from '../../../src/client/agentHeads'
import { DICT_EN } from '../../../src/client/i18n'
import { resetTimelineDetailStores } from '../../../src/client/timelineSource'
import { TestClientCtx, asClientCtx } from '../helpers/harness'
import { click, makeKit, mount, query, queryAll, text, until } from '../helpers/kit'
import { makeScroller, mountInScroller, projectionsFor, richTimeline, timeline } from './contextViewHarness'
const kit = makeKit()
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
resetTimelineDetailStores()
})
/** The outward sessions face: one row, the current session, no subagent. The snapshot object stays identical
* across reads — the card subscribes through useSyncExternalStore, which loops on a fresh snapshot per read. */
function selfOnly(id: string) {
const snapshot = { byId: { [id]: { displayTitle: 'Main Agent', running: false, blank: false, updatedAt: 1 } } }
return { list: { getSnapshot: (): unknown => snapshot, subscribe: (): (() => void) => () => {} } }
}
function makeView(ctx: TestClientCtx) {
return makeFleetView(asClientCtx(ctx), kit, makeAgentHeads())
}
const TIMING = { wallMs: 387_000, ttftMs: 0, genMs: 0, calls: 0, toolsMs: 0, toolCalls: 0, tools: {} }
describe('FleetView — the agent family on its own page', () => {
test("the card renders off the tab's own projections, footer note included", async () => {
const ctx = new TestClientCtx({ services: { sessions: selfOnly('sv-fleet') } })
const View = makeView(ctx)
const m = await mount(h(View, { sessionId: 'sv-fleet', useProjection: projectionsFor(richTimeline({ timing: TIMING })) }))
assert.ok(text(query(m.container, '.lc-agents')).includes('Agent Network'))
const self = query(m.container, '.lc-agent-card.lc-agent-self')
assert.ok(text(self).includes('Main Agent'))
// richTimeline carries three steps; the duration is the whole-session timing total.
assert.equal(query(self, '.lc-agent-meta-text').textContent, '6m27s · 3 steps')
assert.ok(text(query(m.container, '.lc-foot')).startsWith(DICT_EN['footer'].slice(0, 24)))
await m.unmount()
ctx.dispose()
})
test('the slim head composes the card before the detail collections land', async () => {
// The read stays in flight for the whole test: the head's own counters must carry the card.
vi.stubGlobal('fetch', () => new Promise(() => {}))
const ctx = new TestClientCtx({ services: { sessions: selfOnly('sv-slim') } })
const View = makeView(ctx)
const m = await mount(h(View, {
sessionId: 'sv-slim',
useProjection: projectionsFor(timeline({
contextWindow: 128000,
counts: { turns: 1, steps: 3, injects: 0, compactions: 0, prunes: 0 },
last: { seq: 6, total: 420, prompt: 410 },
detailRev: 6,
})),
}))
const self = query(m.container, '.lc-agent-card.lc-agent-self')
assert.equal(query(self, '.lc-agent-meta-text').textContent, '3 steps', 'the head counts the steps itself')
assert.ok(!text(m.container).includes(DICT_EN['detail.loading']), 'no waiting state once the head is served')
await m.unmount()
ctx.dispose()
})
test('a cold session names its retryable read instead of spinning', async () => {
let reads = 0
vi.stubGlobal('fetch', () => {
reads += 1
return Promise.resolve({ ok: false, status: 503 } as Response)
})
const ctx = new TestClientCtx({ services: { sessions: selfOnly('sv-cold') } })
const View = makeView(ctx)
const m = await mount(h(View, { sessionId: 'sv-cold', useProjection: () => undefined }))
assert.equal(query(m.container, '.lc-empty').textContent, DICT_EN['loading'], 'first paint names the read')
await until(() => text(m.container).includes(DICT_EN['detail.loadFailed']), 'the failure never surfaced')
// The note's retry refires the read; it keeps failing, so the note stands rather than the page going blank.
const before = reads
await click(query(m.container, '.lc-br-retry'))
await until(() => reads > before, 'the retry never refired the read')
assert.ok(text(m.container).includes(DICT_EN['detail.loadFailed']))
await m.unmount()
ctx.dispose()
})
test('the baseline gate rides the Fleet tab too', async () => {
const ctx = new TestClientCtx({ services: { sessions: selfOnly('sv-gate') } })
const View = makeView(ctx)
const m = await mount(h(View, {
sessionId: 'sv-gate',
useProjection: projectionsFor(timeline({ unsupported: { current: '0.1.0', minimum: '0.2.0' } })),
}))
assert.ok(query(m.container, '.lc-gate-card') !== null, 'the gate names the unsupported harness here as well')
await m.unmount()
ctx.dispose()
})
test('without a session id the page still paints and the card stays hidden', async () => {
const ctx = new TestClientCtx({ services: { sessions: selfOnly('sv-none') } })
const View = makeView(ctx)
const m = await mount(h(View, { useProjection: projectionsFor(timeline()) }))
assert.equal(queryAll(m.container, '.lc-agents').length, 0)
assert.ok(text(query(m.container, '.lc-foot')).startsWith(DICT_EN['footer'].slice(0, 24)))
await m.unmount()
ctx.dispose()
})
})
describe('FleetView — the shared scrollport claim', () => {
test("mount lands at the top: the chat's bottom-anchored scrollTop does not survive the tab switch", async () => {
const scroller = makeScroller()
scroller.scrollTop = 3491
const ctx = new TestClientCtx({ services: { sessions: selfOnly('sv-fleet') } })
const View = makeView(ctx)
const m = await mountInScroller(
h(View, { sessionId: 'sv-fleet', useProjection: projectionsFor(richTimeline({ timing: TIMING })) }),
scroller,
)
assert.equal(scroller.scrollTop, 0, 'the inherited position reset pre-paint')
await m.unmount()
ctx.dispose()
})
test('a session switch inside the tab re-claims the top', async () => {
const scroller = makeScroller()
const ctx = new TestClientCtx({ services: { sessions: selfOnly('sv-fleet') } })
const View = makeView(ctx)
const m = await mountInScroller(
h(View, { sessionId: 'sv-fleet', useProjection: projectionsFor(richTimeline({ timing: TIMING })) }),
scroller,
)
scroller.scrollTop = 300
await m.update(h(View, { sessionId: 'sv-other', useProjection: projectionsFor(richTimeline({ timing: TIMING })) }))
assert.equal(scroller.scrollTop, 0, 'the next session lands at the top again')
await m.unmount()
ctx.dispose()
})
test('a scrolled-away position does not come back: every entry re-claims the top', async () => {
const props = { sessionId: 'sv-roundtrip', useProjection: projectionsFor(richTimeline({ timing: TIMING })) }
const ctx = new TestClientCtx({ services: { sessions: selfOnly('sv-roundtrip') } })
const View = makeView(ctx)
const scroller1 = makeScroller()
const m1 = await mountInScroller(h(View, props), scroller1)
scroller1.scrollTop = 120
await m1.unmount()
const scroller2 = makeScroller()
scroller2.scrollTop = 55
const m2 = await mountInScroller(h(View, props), scroller2)
assert.equal(scroller2.scrollTop, 0, 'the page is a dashboard: it opens at its top again')
await m2.unmount()
ctx.dispose()
})
})