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.
98 lines
4.1 KiB
TypeScript
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()
|
|
})
|
|
})
|