feat: add a placement preference (tab / sidebar / both) for the Context view

Closes #58: the Context panel hung off two registrations at once (the
conversation tab and the right-Sidebar guide entry), with no way to keep
just one. A new per-user `defaultPlacement` preference — the first row of
the Plugin configuration card — picks who carries the view: the
conversation tab, the right Sidebar, or both (the default, so existing
deployments see no change).

- shared: `DefaultPlacement` vocabulary (`all` | `tab` | `sidebar`) on the
  settings namespace; the Host schema gains a loose `defaultPlacement`
  field so a stale persisted value degrades to `all`
- client: `placement.ts` mounts each registration through its own factory
  and, on a preference flip, disposes exactly what the new value drops
  (the harness's own dynamic-mount idiom); `watchSidebarContextTab`
  returns its deferred-inject disposer so the sidebar mount can be taken
  down and put back; the settings card leads with the placement row
This commit is contained in:
bowenliang123 committed 2026-09-11 13:44:21 +08:00
1 parent b687a4fc6f
commit a4add07664
14 files changed
+423 -62

No files matched your search

+11
View File
@@ -97,6 +97,17 @@ export function makeSettingsCard(kit: ViewKit): (props: SettingsCardProps) => Re
{!state.writable && state.status === 'ready'
? <p className="lc-settings-note" role="status">{t('settings.readOnly')}</p>
: null}
<PrefRow
label={t('settings.placement')}
value={state.placement}
disabled={disabled}
options={[
{ id: 'tab', label: t('placement.tab') },
{ id: 'sidebar', label: t('placement.sidebar') },
{ id: 'all', label: t('placement.all') },
]}
onPick={(id) => { props.set?.('defaultPlacement', id) }}
/>
<PrefRow
label={t('settings.gran')}
value={state.granularity}
+4
View File
@@ -54,6 +54,8 @@ export const DICT_ZH: Record<string, string> = {
'trend.title': '上下文趋势',
'gran.step': '步骤', 'gran.turn': '轮次',
'settings.title': '上下文', 'settings.desc': 'dsh-context 插件中上下文面板的偏好设置',
'settings.placement': '展示位置',
'placement.tab': '会话标签页', 'placement.sidebar': '侧边栏', 'placement.all': '标签页和侧边栏',
'settings.gran': '趋势图 · 默认粒度',
'settings.mode': '趋势图 · 默认展示方式',
'settings.fileSort': '文件活动 · 默认排序',
@@ -283,6 +285,8 @@ export const DICT_EN: Record<string, string> = {
'trend.title': 'Context Trend',
'gran.step': 'Step', 'gran.turn': 'Turn',
'settings.title': 'Context', 'settings.desc': 'Preferences for Context panel in dsh-context plugin',
'settings.placement': 'Show in',
'placement.tab': 'Conversation tab', 'placement.sidebar': 'Sidebar', 'placement.all': 'Both',
'settings.gran': 'Trend Chart · Default granularity',
'settings.mode': 'Trend Chart · Default display',
'settings.fileSort': 'File Activity · Default sort',
+17 -13
View File
@@ -33,6 +33,7 @@ import { createContextSettings, type SettingsField, type SettingsScopeBinderFace
import { makeContextView } from './components/contextView'
import { makeContextJumpButton } from './components/contextJump'
import { watchHistoryFaces } from './historyPage'
import { watchPlacement } from './placement'
import { watchSidebarContextTab } from './sidebar'
import { makeViewKit } from './viewkit'
@@ -79,19 +80,22 @@ function apply(ctx: ClientCtx): void {
const settings = createContextSettings()
const ContextView = makeContextView(ctx, kit, settings)
ctx.slots.inject('conversation.view', () => {
return ctx.slots.register(
// order 20 renders right of Chat (0) and Trajectory (10); the locale
// namespace put the framework `t` seat on the component's props too.
{ name: 'conversation.view', id: 'context', order: 20, locale: NS, label: () => t('tab') },
props => h(ContextView, props),
)
})
// Right Sidebar (dsh 0.1.5-rc.1+): the same view as a panel tab, offered
// from the sidebar's guide page. Optional by contract — a harness without
// the sidebar services simply never gets the tab (see sidebar.ts).
watchSidebarContextTab(ctx, ContextView, t, NS)
// Placement: the per-user `defaultPlacement` preference picks which
// registration carries the view — the conversation tab, the right Sidebar
// (dsh 0.1.5-rc.1+, optional by contract), or both (the default). Each
// mount owns its disposer so the watcher takes down exactly what a
// preference flip drops (see placement.ts).
ctx.effect(() => watchPlacement(settings, {
tab: () => ctx.slots.inject('conversation.view', () => {
return ctx.slots.register(
// order 20 renders right of Chat (0) and Trajectory (10); the locale
// namespace put the framework `t` seat on the component's props too.
{ name: 'conversation.view', id: 'context', order: 20, locale: NS, label: () => t('tab') },
props => h(ContextView, props),
)
}),
sidebar: () => watchSidebarContextTab(ctx, ContextView, t, NS),
}), 'dsh-context: placement')
// Chat → Context jump: an icon in each finalized reply's action row that
// opens this tab pinned to that reply's turn (see contextJump.tsx; the
+51
View File
@@ -0,0 +1,51 @@
/**
* Placement gating for the Context view: the per-user `defaultPlacement`
* preference (the settings namespace's `defaultPlacement` field) picks which
* registrations carry it — the conversation tab, the right Sidebar panel, or
* both.
*
* Each mount is a factory returning its own disposer, so a preference flip
* takes down only what the new value drops and mounts only what it adds; the
* underlying `slots.inject` / deferred-inject disposers are the harness's own
* dynamic-mount idiom. A mount landing no disposer (a harness face that
* returns nothing) simply has nothing to unwind.
*
* @module dsh-context/client/placement
*/
import type { ContextSettings } from './settings'
import type { DefaultPlacement } from '../shared/types'
/** One registration factory per placement; each returns its mount's disposer. */
export interface PlacementMounts {
tab(): unknown
sidebar(): unknown
}
/**
* Mount per the current preference and keep the registrations glued to it
* until the returned disposer runs.
*/
export function watchPlacement(settings: ContextSettings, mounts: PlacementMounts): () => void {
let tab: (() => void) | undefined
let sidebar: (() => void) | undefined
const own = (result: unknown): (() => void) | undefined =>
typeof result === 'function' ? result as () => void : undefined
const mount = (placement: DefaultPlacement): void => {
const wantTab = placement !== 'sidebar'
const wantSidebar = placement !== 'tab'
if (wantTab && tab === undefined) tab = own(mounts.tab())
if (!wantTab && tab !== undefined) { tab(); tab = undefined }
if (wantSidebar && sidebar === undefined) sidebar = own(mounts.sidebar())
if (!wantSidebar && sidebar !== undefined) { sidebar(); sidebar = undefined }
}
mount(settings.defaultPlacement())
const unsubscribe = settings.store.subscribe(() => { mount(settings.defaultPlacement()) })
return () => {
unsubscribe()
tab?.()
sidebar?.()
tab = undefined
sidebar = undefined
}
}
+17 -5
View File
@@ -12,11 +12,11 @@
* members keeps the dependency graph honest.
*/
import type { DefaultFileSort, DefaultGranularity, DefaultTrendMode, SettingsField } from '../shared/types'
import type { DefaultFileSort, DefaultGranularity, DefaultPlacement, DefaultTrendMode, SettingsField } from '../shared/types'
// The preference vocabulary is declared once in shared/types.ts; re-exported
// here so client-side consumers keep their canonical import path.
export type { DefaultFileSort, DefaultGranularity, DefaultTrendMode, SettingsField } from '../shared/types'
export type { DefaultFileSort, DefaultGranularity, DefaultPlacement, DefaultTrendMode, SettingsField } from '../shared/types'
/** The bound settings scope (ctx.settingsScope.bind result), as consumed. */
export interface SettingsScopeLike {
@@ -34,6 +34,7 @@ export interface SettingsScopeBinderFace {
export interface SettingsState {
/** Scope sync: loading until the first Host section, unavailable when unserved. */
status: 'loading' | 'ready' | 'unavailable'
placement: DefaultPlacement
granularity: DefaultGranularity
mode: DefaultTrendMode
fileSort: DefaultFileSort
@@ -43,6 +44,7 @@ export interface SettingsState {
export interface ContextSettings {
/** Observable snapshot store, bound onto card props as `useContextSettings`. */
store: { subscribe(listener: () => void): () => void; getSnapshot(): SettingsState }
defaultPlacement(): DefaultPlacement
defaultGranularity(): DefaultGranularity
defaultTrendMode(): DefaultTrendMode
defaultFileSort(): DefaultFileSort
@@ -51,10 +53,18 @@ export interface ContextSettings {
set(field: SettingsField, value: string): void
}
function prefsOf(value: unknown): { granularity?: DefaultGranularity; mode?: DefaultTrendMode; fileSort?: DefaultFileSort } {
type Prefs = {
placement?: DefaultPlacement
granularity?: DefaultGranularity
mode?: DefaultTrendMode
fileSort?: DefaultFileSort
}
function prefsOf(value: unknown): Prefs {
if (value === null || typeof value !== 'object') return {}
const v = value as Record<string, unknown>
return {
...(v.defaultPlacement === 'all' || v.defaultPlacement === 'tab' || v.defaultPlacement === 'sidebar' ? { placement: v.defaultPlacement } : {}),
...(v.defaultGranularity === 'step' || v.defaultGranularity === 'turn' ? { granularity: v.defaultGranularity } : {}),
...(v.defaultTrendMode === 'total' || v.defaultTrendMode === 'delta' ? { mode: v.defaultTrendMode } : {}),
...(v.defaultFileSort === 'count' || v.defaultFileSort === 'latest' || v.defaultFileSort === 'path' ? { fileSort: v.defaultFileSort } : {}),
@@ -62,11 +72,11 @@ function prefsOf(value: unknown): { granularity?: DefaultGranularity; mode?: Def
}
export function createContextSettings(): ContextSettings {
let state: SettingsState = { status: 'loading', granularity: 'step', mode: 'total', fileSort: 'count', writable: false }
let state: SettingsState = { status: 'loading', placement: 'all', granularity: 'step', mode: 'total', fileSort: 'count', writable: false }
let scope: SettingsScopeLike | undefined
const listeners = new Set<() => void>()
const publish = (next: SettingsState): void => {
if (next.status === state.status && next.granularity === state.granularity
if (next.status === state.status && next.placement === state.placement && next.granularity === state.granularity
&& next.mode === state.mode && next.fileSort === state.fileSort && next.writable === state.writable) return
state = next
for (const listener of listeners) listener()
@@ -79,6 +89,7 @@ export function createContextSettings(): ContextSettings {
publish({
status: snap.status === 'ready' || snap.status === 'unavailable' ? snap.status : 'loading',
// A section without the field (older Host half) keeps the default.
placement: prefs.placement ?? state.placement,
granularity: prefs.granularity ?? state.granularity,
mode: prefs.mode ?? state.mode,
fileSort: prefs.fileSort ?? state.fileSort,
@@ -93,6 +104,7 @@ export function createContextSettings(): ContextSettings {
},
getSnapshot: () => state,
},
defaultPlacement: () => state.placement,
defaultGranularity: () => state.granularity,
defaultTrendMode: () => state.mode,
defaultFileSort: () => state.fileSort,
+8 -2
View File
@@ -50,16 +50,18 @@ const GUIDE_ORDER = 20
* locale at call time, so a language switch relabels the guide entry.
* @param ns - the plugin's locale namespace, put on the body registration so
* the framework synthesizes the `t` seat for the panel too.
* @returns the deferred inject's disposer (placement toggling calls it to
* take the mount down; a no-op on faces that return no handle).
*/
export function watchSidebarContextTab(
ctx: ClientCtx,
view: (props: ContextViewProps) => unknown,
t: Translate,
ns: string,
): void {
): () => void {
// Deferred: a harness without the right Sidebar never fires this, and the
// plugin is simply a conversation tab there — no pending fiber, no error.
ctx.inject(['sidebarRightTabs'], (raw) => {
const fiber = ctx.inject(['sidebarRightTabs'], (raw) => {
const injected = raw as unknown as ClientCtx & { sidebarRightTabs?: SidebarTabsFace }
// Every registration that already landed, so a partial failure below
// unwinds exactly what this callback owns.
@@ -104,4 +106,8 @@ export function watchSidebarContextTab(
for (const dispose of disposers) dispose()
}
})
// The cordis inject handle disposes the callback's fiber; re-proved at
// runtime because a harness face may return no handle at all.
const handle = fiber as { dispose?: () => unknown } | undefined
return () => { void handle?.dispose?.() }
}
+3 -2
View File
@@ -23,12 +23,13 @@ export const SETTINGS_NAMESPACE = 'dsh-context'
// The preference vocabulary is declared once in shared/types.ts; re-exported
// here so host-side consumers keep their canonical import path.
export type { DefaultFileSort, DefaultGranularity, DefaultTrendMode, PluginSettings } from '../shared/types'
export type { DefaultFileSort, DefaultGranularity, DefaultPlacement, DefaultTrendMode, PluginSettings } from '../shared/types'
/** Section schema: also the wire envelope the browser scope validates against. */
export const SettingsSchema: z<PluginSettings> = z.object({
defaultGranularity: z.union(['step', 'turn']).default('step'),
// Loose: a stale persisted value degrades to the default instead of breaking the section.
defaultPlacement: z.union(['all', 'tab', 'sidebar']).default('all').loose(),
defaultGranularity: z.union(['step', 'turn']).default('step'),
defaultTrendMode: z.union(['total', 'delta']).default('total').loose(),
defaultFileSort: z.union(['count', 'latest', 'path']).default('count').loose(),
})
+4
View File
@@ -103,7 +103,11 @@ export type DefaultTrendMode = 'total' | 'delta'
/** File Activity row order: most operations first, most-recently-touched first, or path ascending. */
export type DefaultFileSort = 'count' | 'latest' | 'path'
/** Where the Context view is offered: the conversation tab, the right Sidebar, or both. */
export type DefaultPlacement = 'all' | 'tab' | 'sidebar'
export interface PluginSettings {
defaultPlacement: DefaultPlacement
defaultGranularity: DefaultGranularity
defaultTrendMode: DefaultTrendMode
defaultFileSort: DefaultFileSort
+39 -17
View File
@@ -21,7 +21,7 @@ function hookFor(state: SettingsState) {
}
function stateOf(partial: Partial<SettingsState> = {}): SettingsState {
return { status: 'ready', granularity: 'step', mode: 'total', fileSort: 'count', writable: true, ...partial }
return { status: 'ready', placement: 'all', granularity: 'step', mode: 'total', fileSort: 'count', writable: true, ...partial }
}
/** Menu items portaled into document.body while a select is open. */
@@ -65,7 +65,7 @@ describe('SettingsCard', () => {
assert.equal(head.getAttribute('aria-label'), `${DICT_EN['settings.collapse']}: ${DICT_EN['settings.title']}`)
assert.ok(card.className.includes('lc-settings-open'))
const selects = queryAll(m.container, '.lc-settings-select')
assert.equal(selects.length, 3)
assert.equal(selects.length, 4)
assert.ok(selects.every(s => (s as HTMLButtonElement).disabled))
// Loading is not ready: no read-only note.
assert.equal(m.container.querySelector('.lc-settings-note'), null)
@@ -87,33 +87,54 @@ describe('SettingsCard', () => {
const selects = queryAll<HTMLButtonElement>(m.container, '.lc-settings-select')
assert.ok(selects.every(s => !s.disabled))
assert.equal(m.container.querySelector('.lc-settings-note'), null)
// Active option labels resolve through the options list.
assert.ok(text(selects[0]).includes(DICT_EN['gran.step']))
assert.ok(text(selects[1]).includes(DICT_EN['gran.total']))
assert.ok(text(m.container).includes(DICT_EN['settings.fileSort']))
assert.ok(text(selects[2]).includes(DICT_EN['files.sort.count']))
// The placement row leads; its label resolves through the options list.
assert.ok(text(m.container).includes(DICT_EN['settings.placement']))
assert.ok(text(selects[0]).includes(DICT_EN['placement.all']))
await click(selects[0])
assert.equal(selects[0].getAttribute('aria-expanded'), 'true')
const placementItems = menuItems()
assert.equal(placementItems.length, 3)
assert.deepEqual(placementItems.map(i => text(i)), [
DICT_EN['placement.tab'],
DICT_EN['placement.sidebar'],
DICT_EN['placement.all'],
])
await click(placementItems[1]) // 'Sidebar'
assert.deepEqual(calls, [['defaultPlacement', 'sidebar']])
assert.equal(selects[0].getAttribute('aria-expanded'), 'false')
assert.equal(document.body.querySelector('[role="menu"]'), null)
// The granularity row follows.
assert.ok(text(selects[1]).includes(DICT_EN['gran.step']))
assert.ok(text(selects[2]).includes(DICT_EN['gran.total']))
assert.ok(text(m.container).includes(DICT_EN['settings.fileSort']))
assert.ok(text(selects[3]).includes(DICT_EN['files.sort.count']))
await click(selects[1])
assert.equal(selects[1].getAttribute('aria-expanded'), 'true')
const items = menuItems()
assert.equal(items.length, 2)
assert.deepEqual(items.map(i => text(i)), [DICT_EN['gran.step'], DICT_EN['gran.turn']])
await click(items[1]) // 'Turn'
assert.deepEqual(calls, [['defaultGranularity', 'turn']])
assert.equal(selects[0].getAttribute('aria-expanded'), 'false')
assert.deepEqual(calls, [['defaultPlacement', 'sidebar'], ['defaultGranularity', 'turn']])
assert.equal(selects[1].getAttribute('aria-expanded'), 'false')
assert.equal(document.body.querySelector('[role="menu"]'), null)
// The trend-mode row writes the other field.
await click(selects[1])
await click(selects[2])
const modeItems = menuItems()
assert.deepEqual(modeItems.map(i => text(i)), [DICT_EN['gran.total'], DICT_EN['gran.delta']])
await click(modeItems[1]) // 'Delta'
assert.deepEqual(calls, [['defaultGranularity', 'turn'], ['defaultTrendMode', 'delta']])
assert.deepEqual(calls, [
['defaultPlacement', 'sidebar'],
['defaultGranularity', 'turn'],
['defaultTrendMode', 'delta'],
])
assert.equal(document.body.querySelector('[role="menu"]'), null)
// The file-sort row writes the third field.
await click(selects[2])
// The file-sort row writes the last field.
await click(selects[3])
const sortItems = menuItems()
assert.deepEqual(sortItems.map(i => text(i)), [
DICT_EN['files.sort.count'],
@@ -122,6 +143,7 @@ describe('SettingsCard', () => {
])
await click(sortItems[2]) // 'By path'
assert.deepEqual(calls, [
['defaultPlacement', 'sidebar'],
['defaultGranularity', 'turn'],
['defaultTrendMode', 'delta'],
['defaultFileSort', 'path'],
@@ -147,7 +169,7 @@ describe('SettingsCard', () => {
const select = query(m.container, '.lc-settings-select')
await click(select)
assert.equal(select.getAttribute('aria-expanded'), 'true')
assert.equal(menuItems().length, 2)
assert.equal(menuItems().length, 3, 'the placement row leads with three options')
await keydown('Escape', document.body)
assert.equal(select.getAttribute('aria-expanded'), 'false')
assert.equal(document.body.querySelector('[role="menu"]'), null)
@@ -167,7 +189,7 @@ describe('SettingsCard', () => {
})
test('a value matching no option falls back to the raw id; a missing set never throws', async () => {
const m = await mount(h(SettingsCard, { useContextSettings: hookFor(stateOf({ granularity: 'weird' as never })) }))
const m = await mount(h(SettingsCard, { useContextSettings: hookFor(stateOf({ placement: 'weird' as never })) }))
await click(query(m.container, '.lc-settings-head'))
const selects = queryAll(m.container, '.lc-settings-select')
assert.ok(text(selects[0]).includes('weird'))
@@ -189,7 +211,7 @@ describe('SettingsCard', () => {
const card = query(m.container, '.lc-settings-card')
assert.ok(card.className.includes('lc-settings-open'))
assert.equal(query(m.container, '.lc-settings-head').getAttribute('aria-expanded'), 'true')
assert.equal(queryAll(m.container, '.lc-settings-select').length, 3)
assert.equal(queryAll(m.container, '.lc-settings-select').length, 4)
assert.equal(scrolled.length, 1, 'the card scrolls itself into view')
assert.deepEqual(scrolled[0].arg, { block: 'nearest' })
assert.equal(scrolled[0].el, card)
+43 -12
View File
@@ -73,7 +73,7 @@ export class TestClientCtx {
readonly slots = new TestSlots()
readonly locale: TestLocale
private readonly services = new Map<string, unknown>()
private readonly pending: { deps: string[]; cb: (ctx: TestClientCtx) => void }[] = []
private readonly pending: { deps: string[]; cb: (ctx: TestClientCtx) => (() => void) | void }[] = []
private readonly disposers: (() => void)[] = []
constructor(options: TestClientCtxOptions = {}) {
@@ -96,22 +96,53 @@ export class TestClientCtx {
const p = this.pending[i]
if (p.deps.every(d => this.services.has(d))) {
this.pending.splice(i, 1)
this.runInjected(p.cb)
p.cb(this)
}
}
}
private runInjected(cb: (ctx: TestClientCtx) => void): void {
// Cordis runs the inject callback as a disposable plugin: a returned
// disposer belongs to the fiber and runs on unload.
const dispose = cb(this)
if (typeof dispose === 'function') this.disposers.push(dispose)
}
inject(deps: string[] | Record<string, unknown>, cb: (ctx: TestClientCtx) => void): void {
/**
* Cordis semantics: an inject gets its fiber handle as soon as it is
* declared (PENDING while a service is still missing) — disposing it either
* cancels the pending wait or unwinds the loaded callback, and the loaded
* disposer also belongs to the context's own dispose (LIFO). Either path
* retires it, so the two lifetimes never run it twice.
*/
inject(deps: string[] | Record<string, unknown>, cb: (ctx: TestClientCtx) => (() => void) | void): { dispose: () => Promise<void> } {
const list = Array.isArray(deps) ? deps : Object.keys(deps)
if (list.every(d => this.services.has(d))) this.runInjected(cb)
else this.pending.push({ deps: list, cb })
let ran = false
let disposed = false
let retired = false
let inner: (() => void) | undefined
const fiber = {
dispose: async () => {
if (disposed) return
disposed = true
const i = this.pending.findIndex(p => p.cb === wrapped)
if (i >= 0) {
this.pending.splice(i, 1)
return
}
if (ran && inner !== undefined && !retired) {
inner()
}
},
}
const wrapped = (ctx: TestClientCtx): void => {
ran = true
const dispose = cb(ctx)
if (typeof dispose === 'function') {
inner = () => {
if (retired) return
retired = true
dispose()
}
this.disposers.push(inner)
}
}
if (list.every(d => this.services.has(d))) wrapped(this)
else this.pending.push({ deps: list, cb: wrapped })
return fiber
}
effect(fn: () => (() => void) | void, _label?: string): () => void {
+74 -1
View File
@@ -313,13 +313,86 @@ describe('client entry: settings card slot', () => {
assert.ok(query(m.container, '.lc-settings-card'))
await click(query(m.container, '.lc-settings-head'))
const selects = queryAll(m.container, '.lc-settings-select')
assert.equal(selects.length, 3)
assert.equal(selects.length, 4)
for (const s of selects) assert.ok((s as HTMLButtonElement).disabled)
await m.unmount()
ctx.dispose()
})
})
describe('client entry: placement gating', () => {
/** A settings scope whose snapshot the test drives (the emit path). */
function scopeWith(snapshot: { status: string; value: unknown; writable: boolean }): SettingsScopeLike & {
emit(next: { status: string; value: unknown; writable: boolean }): void
} {
let current = snapshot
const listeners = new Set<() => void>()
return {
getSnapshot: () => current,
subscribe(listener) {
listeners.add(listener)
return () => { listeners.delete(listener) }
},
set: () => Promise.resolve(),
emit(next) {
current = next
for (const listener of listeners) listener()
},
}
}
/** The sidebar registry stand-in counting registrations and disposer calls. */
function registry(): { definitions: unknown[]; disposed: number; register(d: unknown): () => void } {
const rec = {
definitions: [] as unknown[],
disposed: 0,
register(d: unknown): () => void {
rec.definitions.push(d)
return () => { rec.disposed += 1 }
},
}
return rec
}
test("the persisted 'sidebar' placement skips the conversation tab and keeps the sidebar", () => {
const scope = scopeWith({ status: 'ready', value: { defaultPlacement: 'sidebar' }, writable: true })
const ctx = new TestClientCtx({ services: { settingsScope: { bind: () => scope } } })
applyTo(ctx)
assert.deepEqual(ctx.slots.of('conversation.view'), [], 'the dropped tab never registered')
const tabs = registry()
ctx.setService('sidebarRightTabs', tabs)
assert.equal(tabs.definitions.length, 1, 'the kept sidebar still registers')
assert.equal(ctx.slots.of('sidebar.right.pane.tab').length, 1)
ctx.dispose()
assert.equal(tabs.disposed, 1)
assert.deepEqual(ctx.slots.of('sidebar.right.pane.tab'), [])
})
test('a preference flip moves the registrations live; disposal unwinds everything', () => {
const scope = scopeWith({ status: 'ready', value: {}, writable: true })
const ctx = new TestClientCtx({ services: { settingsScope: { bind: () => scope } } })
applyTo(ctx)
const tabs = registry()
ctx.setService('sidebarRightTabs', tabs)
assert.equal(ctx.slots.of('conversation.view').length, 1, "the default 'all' carries both")
scope.emit({ status: 'ready', value: { defaultPlacement: 'tab' }, writable: true })
assert.equal(tabs.disposed, 1, 'the sidebar registration unwound')
assert.deepEqual(ctx.slots.of('sidebar.right.pane.tab'), [])
assert.deepEqual(ctx.slots.of('sidebar.right.pane.tab.title'), [])
assert.equal(ctx.slots.of('conversation.view').length, 1)
scope.emit({ status: 'ready', value: { defaultPlacement: 'all' }, writable: true })
assert.equal(tabs.definitions.length, 2, 'the sidebar re-registered')
assert.equal(ctx.slots.of('sidebar.right.pane.tab').length, 1)
assert.equal(ctx.slots.of('conversation.view').length, 1, 'the kept tab is never churned')
ctx.dispose()
assert.equal(tabs.disposed, 2)
assert.deepEqual(ctx.slots.of('conversation.view'), [])
assert.deepEqual(ctx.slots.of('sidebar.right.pane.tab'), [])
})
})
describe('client entry: dispose', () => {
test('a full apply unwinds dictionaries and the command source', () => {
const sources: unknown[] = []
+114
View File
@@ -0,0 +1,114 @@
// Placement gating (src/client/placement.ts): the mounts follow the
// `defaultPlacement` preference — both, the tab only, or the sidebar only —
// a flip disposes exactly what the new value drops, and the watcher's own
// disposer unwinds everything and detaches the store.
import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import { createContextSettings } from '../../src/client/settings'
import { watchPlacement } from '../../src/client/placement'
/** A mount factory counting its mounts and the disposer calls it received. */
function mountSpy(): { factory: () => () => void; mounted: () => number; disposed: () => number } {
let mountedCount = 0
let disposedCount = 0
return {
factory: () => {
mountedCount += 1
return () => { disposedCount += 1 }
},
mounted: () => mountedCount,
disposed: () => disposedCount,
}
}
describe('watchPlacement', () => {
test("the default 'all' mounts both; a flip to one side disposes only the other", () => {
const settings = createContextSettings()
const tab = mountSpy()
const sidebar = mountSpy()
const dispose = watchPlacement(settings, { tab: tab.factory, sidebar: sidebar.factory })
assert.equal(tab.mounted(), 1)
assert.equal(sidebar.mounted(), 1)
settings.set('defaultPlacement', 'sidebar')
assert.equal(tab.disposed(), 1, 'the dropped tab registration was disposed')
assert.equal(tab.mounted(), 1, 'no remount churn')
assert.equal(sidebar.disposed(), 0, 'the sidebar stays mounted')
assert.equal(sidebar.mounted(), 1)
settings.set('defaultPlacement', 'tab')
assert.equal(sidebar.disposed(), 1)
assert.equal(sidebar.mounted(), 1)
assert.equal(tab.mounted(), 2, 'the tab comes back')
assert.equal(tab.disposed(), 1)
dispose()
})
test("'all' restores both; unrelated preference changes never remount", () => {
const settings = createContextSettings()
const tab = mountSpy()
const sidebar = mountSpy()
const dispose = watchPlacement(settings, { tab: tab.factory, sidebar: sidebar.factory })
settings.set('defaultGranularity', 'turn')
assert.equal(tab.mounted(), 1)
assert.equal(sidebar.mounted(), 1)
settings.set('defaultPlacement', 'tab')
settings.set('defaultPlacement', 'all')
assert.equal(tab.mounted(), 1, 'the kept side is never churned')
assert.equal(sidebar.mounted(), 2, 'the dropped side remounts once')
assert.equal(sidebar.disposed(), 1)
dispose()
})
test("a 'tab'-only or 'sidebar'-only start mounts just that side", () => {
const settings = createContextSettings()
settings.set('defaultPlacement', 'tab')
const tab = mountSpy()
const sidebar = mountSpy()
const dispose = watchPlacement(settings, { tab: tab.factory, sidebar: sidebar.factory })
assert.equal(tab.mounted(), 1)
assert.equal(sidebar.mounted(), 0)
dispose()
assert.equal(tab.disposed(), 1)
assert.equal(sidebar.disposed(), 0)
const again = createContextSettings()
again.set('defaultPlacement', 'sidebar')
const tab2 = mountSpy()
const sidebar2 = mountSpy()
const dispose2 = watchPlacement(again, { tab: tab2.factory, sidebar: sidebar2.factory })
assert.equal(tab2.mounted(), 0)
assert.equal(sidebar2.mounted(), 1)
dispose2()
})
test('the watcher disposer unwinds every mount and detaches the store', () => {
const settings = createContextSettings()
const tab = mountSpy()
const sidebar = mountSpy()
const dispose = watchPlacement(settings, { tab: tab.factory, sidebar: sidebar.factory })
dispose()
assert.equal(tab.disposed(), 1)
assert.equal(sidebar.disposed(), 1)
// The store subscription is gone: a later flip reaches no mount.
settings.set('defaultPlacement', 'sidebar')
assert.equal(tab.mounted(), 1)
assert.equal(sidebar.mounted(), 1)
// The disposer is safe to repeat.
assert.doesNotThrow(dispose)
assert.equal(tab.disposed(), 1, 'no double unwind')
})
test('a mount landing no disposer (a face returning nothing) is tolerated', () => {
const settings = createContextSettings()
const dispose = watchPlacement(settings, {
tab: () => undefined,
sidebar: () => undefined,
})
assert.doesNotThrow(() => { settings.set('defaultPlacement', 'tab') })
assert.doesNotThrow(() => { dispose() })
})
})
+22 -4
View File
@@ -47,11 +47,13 @@ describe('createContextSettings defaults', () => {
const s = createContextSettings()
assert.deepEqual(s.store.getSnapshot(), {
status: 'loading',
placement: 'all',
granularity: 'step',
mode: 'total',
fileSort: 'count',
writable: false,
})
assert.equal(s.defaultPlacement(), 'all')
assert.equal(s.defaultGranularity(), 'step')
assert.equal(s.defaultTrendMode(), 'total')
assert.equal(s.defaultFileSort(), 'count')
@@ -77,6 +79,8 @@ describe('set', () => {
const s = createContextSettings()
s.set('defaultGranularity', 'turn')
assert.equal(s.defaultGranularity(), 'turn')
s.set('defaultPlacement', 'sidebar')
assert.equal(s.defaultPlacement(), 'sidebar')
})
test('an unchanged value does not notify listeners', () => {
@@ -135,12 +139,18 @@ describe('attach', () => {
const s = createContextSettings()
const scope = new TestSettingsScope({
status: 'ready',
value: { defaultGranularity: 'turn', defaultTrendMode: 'delta', defaultFileSort: 'path' },
value: {
defaultPlacement: 'sidebar',
defaultGranularity: 'turn',
defaultTrendMode: 'delta',
defaultFileSort: 'path',
},
writable: true,
})
s.attach(scope)
assert.deepEqual(s.store.getSnapshot(), {
status: 'ready',
placement: 'sidebar',
granularity: 'turn',
mode: 'delta',
fileSort: 'path',
@@ -168,6 +178,7 @@ describe('attach', () => {
s.attach(new TestSettingsScope({ status: 'ready', value, writable: false }))
assert.deepEqual(s.store.getSnapshot(), {
status: 'ready',
placement: 'all',
granularity: 'step',
mode: 'total',
fileSort: 'count',
@@ -180,9 +191,10 @@ describe('attach', () => {
const s = createContextSettings()
s.attach(new TestSettingsScope({
status: 'ready',
value: { defaultGranularity: 'bogus', defaultTrendMode: 7, defaultFileSort: 'alpha' },
value: { defaultPlacement: 'window', defaultGranularity: 'bogus', defaultTrendMode: 7, defaultFileSort: 'alpha' },
writable: false,
}))
assert.equal(s.defaultPlacement(), 'all')
assert.equal(s.defaultGranularity(), 'step')
assert.equal(s.defaultTrendMode(), 'total')
assert.equal(s.defaultFileSort(), 'count')
@@ -192,9 +204,10 @@ describe('attach', () => {
const s = createContextSettings()
s.attach(new TestSettingsScope({
status: 'ready',
value: { defaultGranularity: 'step', defaultTrendMode: 'total', defaultFileSort: 'count' },
value: { defaultPlacement: 'all', defaultGranularity: 'step', defaultTrendMode: 'total', defaultFileSort: 'count' },
writable: false,
}))
assert.equal(s.defaultPlacement(), 'all')
assert.equal(s.defaultGranularity(), 'step')
assert.equal(s.defaultTrendMode(), 'total')
assert.equal(s.defaultFileSort(), 'count')
@@ -202,7 +215,9 @@ describe('attach', () => {
test('missing fields keep the current state', () => {
const s = createContextSettings()
s.set('defaultPlacement', 'tab')
s.attach(new TestSettingsScope({ status: 'ready', value: { defaultFileSort: 'latest' }, writable: false }))
assert.equal(s.defaultPlacement(), 'tab', 'the in-session choice survives a section without the field')
assert.equal(s.defaultGranularity(), 'step')
assert.equal(s.defaultTrendMode(), 'total')
assert.equal(s.defaultFileSort(), 'latest')
@@ -223,8 +238,11 @@ describe('attach', () => {
scope.emit({ status: 'ready', value: { defaultFileSort: 'path' }, writable: true })
assert.equal(calls, 3)
assert.equal(s.defaultFileSort(), 'path')
scope.emit({ status: 'ready', value: { defaultFileSort: 'path' }, writable: false })
scope.emit({ status: 'ready', value: { defaultPlacement: 'sidebar' }, writable: true })
assert.equal(calls, 4)
assert.equal(s.defaultPlacement(), 'sidebar')
scope.emit({ status: 'ready', value: { defaultFileSort: 'path' }, writable: false })
assert.equal(calls, 5)
assert.equal(s.store.getSnapshot().writable, false)
})
+16 -6
View File
@@ -66,6 +66,7 @@ describe('installSettings', () => {
const descriptors = ctx.settings.describe()
assert.ok(descriptors.some(d => String(d.ns) === 'dsh-context'), 'the namespace is registered')
assert.deepEqual(ctx.settings.get(ns), {
defaultPlacement: 'all',
defaultGranularity: 'step',
defaultTrendMode: 'total',
defaultFileSort: 'count',
@@ -76,14 +77,16 @@ describe('installSettings', () => {
const { ctx, provider } = await boot()
await ctx.settings.update(ns, { defaultGranularity: 'turn' })
assert.deepEqual(ctx.settings.get(ns), {
defaultPlacement: 'all',
defaultGranularity: 'turn',
defaultTrendMode: 'total',
defaultFileSort: 'count',
}, 'the update resolves over the schema defaults')
assert.deepEqual(provider.doc['dsh-context'], { defaultGranularity: 'turn' }, 'the provider persisted the section')
await ctx.settings.update(ns, { defaultTrendMode: 'delta', defaultFileSort: 'path' })
await ctx.settings.update(ns, { defaultPlacement: 'sidebar', defaultTrendMode: 'delta', defaultFileSort: 'path' })
assert.deepEqual(ctx.settings.get(ns), {
defaultPlacement: 'sidebar',
defaultGranularity: 'turn',
defaultTrendMode: 'delta',
defaultFileSort: 'path',
@@ -93,19 +96,26 @@ describe('installSettings', () => {
ctx.settings.update(ns, { defaultGranularity: 'week' }),
'an unknown granularity fails validation before anything persists',
)
// The loose fields degrade instead of rejecting: a stale file sort resolves to the default.
await ctx.settings.update(ns, { defaultFileSort: 'net' })
// The loose fields degrade instead of rejecting: a stale file sort or
// placement resolves to the default.
await ctx.settings.update(ns, { defaultFileSort: 'net', defaultPlacement: 'window' })
assert.deepEqual(ctx.settings.get(ns), {
defaultPlacement: 'all',
defaultGranularity: 'turn',
defaultTrendMode: 'delta',
defaultFileSort: 'count',
}, 'a stale file sort degrades to the schema default')
assert.deepEqual(provider.doc['dsh-context'], { defaultGranularity: 'turn', defaultTrendMode: 'delta', defaultFileSort: 'net' }, 'the stale value stays raw in storage and degrades at read')
}, 'a stale value degrades to the schema default')
assert.deepEqual(
provider.doc['dsh-context'],
{ defaultPlacement: 'window', defaultGranularity: 'turn', defaultTrendMode: 'delta', defaultFileSort: 'net' },
'the stale value stays raw in storage and degrades at read',
)
})
test('a stale persisted preference degrades to the default (loose)', async () => {
const { ctx } = await boot({ 'dsh-context': { defaultTrendMode: 'net', defaultFileSort: 'alpha' } })
const { ctx } = await boot({ 'dsh-context': { defaultPlacement: 'window', defaultTrendMode: 'net', defaultFileSort: 'alpha' } })
const value = ctx.settings.get(ns) as PluginSettings
assert.equal(value.defaultPlacement, 'all', 'the stale placement falls back instead of breaking the section')
assert.equal(value.defaultTrendMode, 'total', 'the stale value falls back instead of breaking the section')
assert.equal(value.defaultFileSort, 'count', 'the stale file sort falls back instead of breaking the section')
assert.equal(value.defaultGranularity, 'step')