feat(gui): chain slot kind with select routing and renderSlotChain

This commit is contained in:
imccyu
2026-07-23 17:19:59 +08:00
parent 0c8b3813fa
commit 3826b50b4a
8 changed files with 400 additions and 36 deletions

View File

@@ -13,6 +13,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
'test.session': { kind: 'single'; scope: 'session' }
'test.list': { kind: 'list'; scope: 'root' }
'test.keyed': { kind: 'keyed'; scope: 'session' }
'test.chain': { kind: 'chain'; scope: 'session'; owner: { tags: string[] } }
'test.grandchild': { kind: 'single'; scope: 'root' }
}
}
@@ -39,6 +40,7 @@ function mountFrame(core: SlotCore) {
'test.session': { kind: 'single', scope: 'session' },
'test.list': { kind: 'list', scope: 'root' },
'test.keyed': { kind: 'keyed', scope: 'session' },
'test.chain': { kind: 'chain', scope: 'session' },
},
// Type-level renderSlot presence is proven by the type-chain spec; erasing
// here keeps runtime fixtures terse.
@@ -148,6 +150,31 @@ describe('kind semantics', () => {
expect(core.entries('test.list').map(e => e.options.id)).toEqual(['a', 'b', 'c'])
})
it('chain: missing select throws; select and priority land on the stored entry', () => {
const core = new SlotCore()
mountFrame(core)
// Statically rejected (KindOptions); runtime guard stays for dynamic callers.
// @ts-expect-error chain registration requires options.select
expect(() => core.register({ name: 'test.chain' }, Comp)).toThrow('requires options.select')
const select = ({ tags }: { tags: string[] }) => tags[0] ?? null
core.register({ name: 'test.chain', select, priority: 5 }, Comp as never)
const entry = core.entries('test.chain')[0]!
expect(entry.select).toBe(select)
expect(entry.options.priority).toBe(5)
})
it('chain: entries sort by priority ascending, ties keep registration order', () => {
const core = new SlotCore()
mountFrame(core)
const sel = () => null
core.register({ name: 'test.chain', select: sel, priority: 10, registrant: 'late' }, Comp as never)
core.register({ name: 'test.chain', select: sel, registrant: 'default-a' }, Comp as never)
core.register({ name: 'test.chain', select: sel, registrant: 'default-b' }, Comp as never)
core.register({ name: 'test.chain', select: sel, priority: -1, registrant: 'first' }, Comp as never)
expect(core.entries('test.chain').map(e => e.registrant))
.toEqual(['first', 'default-a', 'default-b', 'late'])
})
it('single: second registration throws, disposer frees the seat', () => {
const core = new SlotCore()
mountFrame(core)
@@ -294,7 +321,7 @@ describe('subscription surface', () => {
const off = core.onMutate(key => keys.push(key))
mountFrame(core)
// Contribution first, then each declared child key.
expect(keys).toEqual(['root', 'test.single', 'test.session', 'test.list', 'test.keyed'])
expect(keys).toEqual(['root', 'test.single', 'test.session', 'test.list', 'test.keyed', 'test.chain'])
keys.length = 0
core.register({ name: 'test.list', id: 'a' }, Comp)
expect(keys).toEqual(['test.list'])

View File

@@ -20,9 +20,13 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
'chain.side': { kind: 'single'; scope: 'root'; owner: { collapsed: boolean; width: number } }
'chain.conv': { kind: 'single'; scope: 'session' }
'chain.tools': { kind: 'keyed'; scope: 'session' }
'chain.takeover': { kind: 'chain'; scope: 'session'; owner: { items: readonly Item[] } }
}
}
/** Chain-currency fixture: the owner share carries a union the selectors narrow. */
interface Item { kind: 'q' | 'a'; id: string }
declare const defineStore: DefineStore
/** Factory form (exclusive seat): module-level export, never a handle. */
@@ -68,6 +72,9 @@ declare function NoDecl(props: PropsRuntime<'chain.frame'> & PropsRenderSlots<'c
declare function Blind(props: PropsRuntime<'chain.frame'>): ReactNode
declare function WrongStore(props: PropsRuntime<'chain.conv'> & PropsStore<ReturnType<typeof createPanelStore>>): ReactNode
declare function Needs(props: PropsRuntime<'chain.conv'> & { send: (t: string) => void }): ReactNode
declare function Takeover(props: PropsRuntime<'chain.takeover'> & { matched: Item }): ReactNode
declare function WideTakeover(props: PropsRuntime<'chain.takeover'> & { matched: Item | string }): ReactNode
declare function NarrowTakeover(props: PropsRuntime<'chain.takeover'> & { matched: { kind: 'q'; id: string; extra: number } }): ReactNode
describe('terminal-design type chain', () => {
it('holds the positive chain and the compile-time negatives', () => {
@@ -115,6 +122,28 @@ describe('terminal-design type chain', () => {
// Keyed registration carries key.
core.register({ name: 'chain.tools', key: 'bash' }, Tool)
// Chain registration: select is mandatory, M infers from its return,
// matched joins the component constraint; priority is the explicit
// chain position.
core.register({
name: 'chain.takeover',
select: ({ items }) => items.find((i) => i.kind === 'q') ?? null,
priority: 1,
}, Takeover)
// A component accepting a wider matched than the selector supplies
// checks through parameter contravariance.
core.register({
name: 'chain.takeover',
select: ({ items }) => items.find((i) => i.kind === 'q') ?? null,
}, WideTakeover)
// renderSlotChain share: chain keys dispatch with the fallback bag;
// non-chain keys stay on renderSlot.
const chainSlots: PropsRenderSlots<'chain.takeover' | 'chain.conv'> = null as never
chainSlots.renderSlotChain('chain.takeover', { items: [] }, { fallback: null })
chainSlots.renderSlot('chain.conv', {})
// ── negatives ──────────────────────────────────────────────────
// children spec must match the SlotMap entry.
core.register({
@@ -156,6 +185,34 @@ describe('terminal-design type chain', () => {
// @ts-expect-error keyed registration requires options.key
core.register({ name: 'chain.tools' }, Tool)
// chain registration without select.
// @ts-expect-error chain registration requires options.select
core.register({ name: 'chain.takeover' }, Takeover)
// Drifted chain component: demands a matched shape the selector cannot
// supply (NoInfer pins M to the select return — the component position
// must not widen it).
// @ts-expect-error component matched prop drifts from the select return
core.register({
name: 'chain.takeover',
select: ({ items }: { items: readonly Item[] }) => items.find((i) => i.kind === 'q') ?? null,
}, NarrowTakeover)
// select must return M | null, not undefined (find() must be coalesced).
// @ts-expect-error select may not return undefined
core.register({
name: 'chain.takeover',
select: ({ items }: { items: readonly Item[] }) => items.find((i) => i.kind === 'q'),
}, Takeover)
// Chain keys are not renderSlot-dispatchable (and vice versa).
// @ts-expect-error chain keys dispatch through renderSlotChain only
chainSlots.renderSlot('chain.takeover', { items: [] })
// @ts-expect-error non-chain keys have no renderSlotChain dispatch
chainSlots.renderSlotChain('chain.conv', {})
// @ts-expect-error a children set without chain keys provides no renderSlotChain
fp.renderSlotChain
// renderSlot owner share typed at the call site.
// @ts-expect-error owner shape mismatch (width missing)
fp.renderSlot('chain.side', { collapsed: false })