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:
1 parent
b687a4fc6f
commit
a4add07664
14 files changed
+423
-62
No files matched your search
@@ -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}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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?.() }
|
||||
}
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
@@ -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() })
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in new issue
Block a user