From a4add07664b4eed60f91ac2eaebc90073cdcc778 Mon Sep 17 00:00:00 2001 From: bowenliang123 Date: Fri, 11 Sep 2026 13:44:21 +0800 Subject: [PATCH] feat: add a placement preference (tab / sidebar / both) for the Context view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/client/components/settingsCard.tsx | 11 ++ src/client/i18n.ts | 4 + src/client/index.ts | 30 ++--- src/client/placement.ts | 51 +++++++++ src/client/settings.ts | 22 +++- src/client/sidebar.ts | 10 +- src/host/settings.ts | 5 +- src/shared/types.ts | 4 + tests/client/components/settingsCard.spec.ts | 56 ++++++--- tests/client/helpers/harness.ts | 55 +++++++-- tests/client/index.spec.ts | 75 +++++++++++- tests/client/placement.spec.ts | 114 +++++++++++++++++++ tests/client/settings.spec.ts | 26 ++++- tests/host/settings.spec.ts | 22 +++- 14 files changed, 423 insertions(+), 62 deletions(-) create mode 100644 src/client/placement.ts create mode 100644 tests/client/placement.spec.ts diff --git a/src/client/components/settingsCard.tsx b/src/client/components/settingsCard.tsx index 4800455..e1915b6 100644 --- a/src/client/components/settingsCard.tsx +++ b/src/client/components/settingsCard.tsx @@ -97,6 +97,17 @@ export function makeSettingsCard(kit: ViewKit): (props: SettingsCardProps) => Re {!state.writable && state.status === 'ready' ?

{t('settings.readOnly')}

: null} + { props.set?.('defaultPlacement', id) }} + /> = { '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 = { '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', diff --git a/src/client/index.ts b/src/client/index.ts index 15ef8f5..03dc61a 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -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 diff --git a/src/client/placement.ts b/src/client/placement.ts new file mode 100644 index 0000000..caeb53c --- /dev/null +++ b/src/client/placement.ts @@ -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 + } +} diff --git a/src/client/settings.ts b/src/client/settings.ts index 1b90aa3..077be5f 100644 --- a/src/client/settings.ts +++ b/src/client/settings.ts @@ -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 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, diff --git a/src/client/sidebar.ts b/src/client/sidebar.ts index 72c0f29..2003d0b 100644 --- a/src/client/sidebar.ts +++ b/src/client/sidebar.ts @@ -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?.() } } diff --git a/src/host/settings.ts b/src/host/settings.ts index 8847351..60ca2a7 100644 --- a/src/host/settings.ts +++ b/src/host/settings.ts @@ -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 = 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(), }) diff --git a/src/shared/types.ts b/src/shared/types.ts index 7f28e85..9f8f13e 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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 diff --git a/tests/client/components/settingsCard.spec.ts b/tests/client/components/settingsCard.spec.ts index f86e674..561131b 100644 --- a/tests/client/components/settingsCard.spec.ts +++ b/tests/client/components/settingsCard.spec.ts @@ -21,7 +21,7 @@ function hookFor(state: SettingsState) { } function stateOf(partial: Partial = {}): 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(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) diff --git a/tests/client/helpers/harness.ts b/tests/client/helpers/harness.ts index 3696b74..f2a4a1f 100644 --- a/tests/client/helpers/harness.ts +++ b/tests/client/helpers/harness.ts @@ -73,7 +73,7 @@ export class TestClientCtx { readonly slots = new TestSlots() readonly locale: TestLocale private readonly services = new Map() - 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, 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, cb: (ctx: TestClientCtx) => (() => void) | void): { dispose: () => Promise } { 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 { diff --git a/tests/client/index.spec.ts b/tests/client/index.spec.ts index d26521a..5f908aa 100644 --- a/tests/client/index.spec.ts +++ b/tests/client/index.spec.ts @@ -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[] = [] diff --git a/tests/client/placement.spec.ts b/tests/client/placement.spec.ts new file mode 100644 index 0000000..780d6a5 --- /dev/null +++ b/tests/client/placement.spec.ts @@ -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() }) + }) +}) diff --git a/tests/client/settings.spec.ts b/tests/client/settings.spec.ts index 415c86c..be5d686 100644 --- a/tests/client/settings.spec.ts +++ b/tests/client/settings.spec.ts @@ -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) }) diff --git a/tests/host/settings.spec.ts b/tests/host/settings.spec.ts index 6f1b442..1c4f120 100644 --- a/tests/host/settings.spec.ts +++ b/tests/host/settings.spec.ts @@ -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')