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
170 lines
7.6 KiB
TypeScript
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()
|
|
})
|
|
})
|