Files
dsh-context/tests/client/fleetTab.spec.ts
T
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

98 lines
4.1 KiB
TypeScript

// watchFleetTab (src/client/fleetTab.ts): the Fleet tab's own show/hide preference, layered on the placement
// gate — 'hide' unwinds the registration live, 'show' brings it back, and a sidebar-only placement keeps it off.
import { createElement as h, type ReactElement } from 'react'
import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import { watchFleetTab } from '../../src/client/fleetTab'
import { createContextSettings } from '../../src/client/settings'
import { TestClientCtx, asClientCtx } from './helpers/harness'
import { mount, query, text } from './helpers/kit'
const NS = 'dsh-context'
function view(): ReactElement {
return h('div', { className: 'fleet-view' }, 'fleet')
}
function wire(ctx: TestClientCtx, settings = createContextSettings()): { dispose: () => void; settings: ReturnType<typeof createContextSettings> } {
const t = ctx.locale.bind(NS)
const dispose = watchFleetTab(asClientCtx(ctx), settings, view, t, NS)
return { dispose, settings }
}
describe('watchFleetTab — the Fleet tab\'s own gate', () => {
test('show (the default) registers the tab right of the Context tab', async () => {
const ctx = new TestClientCtx()
const { dispose } = wire(ctx)
const entries = ctx.slots.of('conversation.view')
assert.equal(entries.length, 1)
const { registration, component } = entries[0]
assert.equal(registration.name, 'conversation.view')
assert.equal(registration.id, 'fleet')
assert.equal(registration.order, 30, 'right of the Context tab (20)')
assert.equal(registration.locale, NS)
assert.equal(registration.label?.(), 'Fleet')
const m = await mount(component({ sessionId: 's-1' }) as ReactElement)
assert.equal(text(query(m.container, '.fleet-view')), 'fleet')
await m.unmount()
dispose()
assert.deepEqual(ctx.slots.of('conversation.view'), [], 'the watcher dispose unwinds the tab')
ctx.dispose()
})
test('hide unwinds it live and show brings it back, with no churn in between', () => {
const ctx = new TestClientCtx()
const settings = createContextSettings()
const scope = {
getSnapshot: () => ({ status: 'ready', value: { fleetTab: 'hide' }, writable: true }),
subscribe: () => () => {},
set: () => Promise.resolve(),
}
settings.attach(scope)
assert.equal(settings.fleetTab(), 'hide')
const { dispose } = wire(ctx, settings)
assert.deepEqual(ctx.slots.of('conversation.view'), [], 'a hidden tab never registers')
// An unrelated preference never remounts the tab.
settings.set('defaultGranularity', 'turn')
assert.deepEqual(ctx.slots.of('conversation.view'), [])
settings.set('fleetTab', 'show')
assert.equal(ctx.slots.of('conversation.view').length, 1)
assert.equal(ctx.slots.of('conversation.view')[0].registration.id, 'fleet')
settings.set('fleetTab', 'hide')
assert.deepEqual(ctx.slots.of('conversation.view'), [])
// The subscription stops with the watcher: a later flip registers nothing.
dispose()
settings.set('fleetTab', 'show')
assert.deepEqual(ctx.slots.of('conversation.view'), [])
ctx.dispose()
})
test('a sidebar-only placement keeps the tab off whatever the preference says', () => {
const ctx = new TestClientCtx()
const settings = createContextSettings()
settings.set('defaultPlacement', 'sidebar')
const { dispose } = wire(ctx, settings)
assert.deepEqual(ctx.slots.of('conversation.view'), [], 'placement-sidebar serves no conversation tabs')
settings.set('defaultPlacement', 'all')
assert.equal(ctx.slots.of('conversation.view').length, 1)
settings.set('defaultPlacement', 'sidebar')
assert.deepEqual(ctx.slots.of('conversation.view'), [])
dispose()
assert.deepEqual(ctx.slots.of('conversation.view'), [])
ctx.dispose()
})
test('a registry that hands back no disposer never throws on show, hide or disposal', () => {
const ctx = new TestClientCtx()
;(ctx.slots as unknown as { register: () => unknown }).register = () => undefined
const { dispose, settings } = wire(ctx)
settings.set('fleetTab', 'hide')
dispose()
assert.deepEqual(ctx.slots.of('conversation.view'), [])
ctx.dispose()
})
})