Files
bowenliang123 ace15cac11 feat(client): move the agent network onto its own Fleet tab
The Agent network card leaves the Context tab's foot for a page of its own —
the Fleet / 编队 tab, right of Context (order 30). The card is unchanged,
but both mounts now read one shared `useContextSession` data path and fold
the current agent's figures with one `agentSelfOf`, so the panel and the tab
cannot disagree; the Fleet page keeps the same cold-start retry note and the
same baseline gate. The tab label key splits into `tab.context` / `tab.fleet`.

The right Sidebar's Context panel keeps the card inline — its column has no
room for a tab of its own — so the center Context tab renders it only for the
sidebar host, and nothing else leaves that tab.

The stats board's Agent Network cell used to scroll to the card further down
the same page; it now activates the Fleet tab (activateContextTab is
generalized to activateViewTab). Its miss arm is unreachable for our own
tabs: the board and the Fleet tab ride the same placement gate.

The bundle smoke's fake registry now records `register()` calls instead of an
inject callback's return value, matching the real registry — one inject may
declare several registrations, which the second tab exposed.
2026-10-10 20:10:55 +08:00

126 lines
5.3 KiB
TypeScript

// viewFocus (src/client/viewFocus.ts) — the chat→Context jump relay, the sidebar-tab opener, and the
// conversation-tab activation against a real jsdom tab bar.
import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import { activateViewTab, openContextSidebar, requestContextFocus, subscribeContextFocus, takeContextFocus } from '../../src/client/viewFocus'
import { asClientCtx, TestClientCtx } from './helpers/harness'
describe('context focus relay', () => {
test('one request survives until taken, then the map entry is consumed', () => {
requestContextFocus('sv-relay', 42)
assert.equal(takeContextFocus('sv-relay'), 42)
assert.equal(takeContextFocus('sv-relay'), null, 'one-shot: a second take finds nothing')
})
test('a fresh request replaces an unconsumed one; sessions are isolated', () => {
requestContextFocus('sv-race', 7)
requestContextFocus('sv-race', 9)
assert.equal(takeContextFocus('sv-race'), 9, 'the latest click wins')
requestContextFocus('sv-other', 1)
assert.equal(takeContextFocus('sv-other'), 1)
assert.equal(takeContextFocus('sv-unknown'), null)
})
})
describe('context focus subscription', () => {
test('every record wakes the subscriber; the unsubscribe stops delivery', () => {
let hits = 0
const off = subscribeContextFocus(() => { hits++ })
requestContextFocus('sv-a', 1)
assert.equal(hits, 1)
requestContextFocus('sv-b', 2)
assert.equal(hits, 2, 'other-session records wake the view too — it takes only its own entry')
off()
requestContextFocus('sv-a', 3)
assert.equal(hits, 2, 'the detached listener hears nothing')
takeContextFocus('sv-a')
takeContextFocus('sv-b')
})
test('a listener unsubscribing another mid-notify skips it (later records do not re-run it)', () => {
let late = 0
let offLate: () => void = () => {}
// Insertion order matters: the remover runs first and detaches the victim before Set iteration reaches it.
const offRemover = subscribeContextFocus(() => { offLate() })
offLate = subscribeContextFocus(() => { late++ })
requestContextFocus('sv-mid', 1)
assert.equal(late, 0, 'an element removed before iteration reaches it never runs')
requestContextFocus('sv-mid', 2)
assert.equal(late, 0, 'and it stays detached')
offRemover()
takeContextFocus('sv-mid')
})
})
describe('openContextSidebar', () => {
test('opens the registered kind through the sidebarRight face', () => {
const opened: string[] = []
const ctx = new TestClientCtx({ services: { sidebarRight: { openTab: (kind: string) => { opened.push(kind) } } } })
assert.equal(openContextSidebar(asClientCtx(ctx)), true)
assert.deepEqual(opened, ['dsh-context'])
})
test('no service, or a face without the verb, reports false', () => {
assert.equal(openContextSidebar(asClientCtx(new TestClientCtx())), false)
assert.equal(openContextSidebar(asClientCtx(new TestClientCtx({ services: { sidebarRight: {} } }))), false)
assert.equal(openContextSidebar(asClientCtx(new TestClientCtx({ services: { sidebarRight: null } }))), false)
assert.equal(openContextSidebar(asClientCtx(new TestClientCtx({ services: { sidebarRight: 'x' } }))), false)
})
test('a hostile face — throwing read or openTab — reports false, never throws', () => {
const throwingRead = {
get(_name: string): unknown { throw new Error('boom') },
} as unknown as TestClientCtx
assert.equal(openContextSidebar(asClientCtx(throwingRead)), false)
const ctx = new TestClientCtx({ services: {
sidebarRight: { openTab: () => { throw new Error('no session surface mounted') } },
} })
assert.equal(openContextSidebar(asClientCtx(ctx)), false)
})
})
describe('activateViewTab', () => {
test('clicks the inactive tab matching the label, skips the active one, and reports misses', () => {
type Counted = HTMLElement & { __clicks: () => number }
const bar = document.createElement('div')
const mk = (label: string, selected: boolean): Counted => {
const b = document.createElement('button')
b.setAttribute('role', 'tab')
if (selected) b.setAttribute('aria-selected', 'true')
b.textContent = label
let clicks = 0
b.addEventListener('click', () => { clicks++ })
const counted = Object.assign(b, { __clicks: () => clicks })
bar.appendChild(counted)
return counted
}
const chat = mk('Chat', true)
const context = mk('Context', false)
const trajectory = mk('Trajectory', false)
document.body.appendChild(bar)
try {
assert.equal(activateViewTab('Context'), true)
assert.equal(context.__clicks(), 1)
assert.equal(chat.__clicks(), 0)
// Already-active: reported success without a redundant click.
context.setAttribute('aria-selected', 'true')
assert.equal(activateViewTab('Context'), true)
assert.equal(context.__clicks(), 1)
// Whitespace-padded label text still matches the trimmed comparison.
context.setAttribute('aria-selected', 'false')
context.textContent = ' Context '
assert.equal(activateViewTab('Context'), true)
assert.equal(context.__clicks(), 2)
// No tab carries the label → nothing clicked, false.
assert.equal(activateViewTab('Missing'), false)
assert.equal(trajectory.__clicks(), 0)
} finally {
bar.remove()
}
})
})