feat(client): inject slot declaration lifetimes

This commit is contained in:
imccyu
2026-08-05 23:38:29 +08:00
parent bb53e25ed0
commit 86965b053c
100 changed files with 1065 additions and 837 deletions

View File

@@ -55,14 +55,10 @@ export const inject = ['slash', 'sessions', 'connection', 'locale']
export function apply(ctx: ClientContext): void {
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-command: dictionaries')
ctx.plugin(CommandService)
// Conditional mount, same seam as ui-slash's MenuView registration:
// 'conversation.input.overlay' is declared by the conversation composer
// entry, and the conversation service's presence is the registration-safe
// signal that the declaration is on the ledger.
ctx.inject(['slots', 'conversation', 'command', 'sessions'], (scope: ClientContext) => {
ctx.inject(['slots', 'command', 'sessions'], (scope: ClientContext) => {
const command = scope.command
const sessions = scope.sessions
scope.effect(() => scope.slots.register({
scope.slots.inject('conversation.input.overlay', () => scope.slots.register({
name: 'conversation.input.overlay',
id: 'command-popup',
order: 1,
@@ -72,6 +68,6 @@ export function apply(ctx: ClientContext): void {
if (actx === undefined) throw new Error(`ui-command: session "${String(sessionId)}" resolved no scope`)
return { popup: command.popupFor(actx) }
},
}, PopupSelectView), 'ui-command: popupSelect overlay registration')
}, PopupSelectView))
})
}

View File

@@ -2,13 +2,13 @@
* ui-command browser half on a real cordis Context with fake slash/slots
* faces and real session scopes: the plugin body mounts CommandService as
* `command`, the popupSelect shell registers into conversation.input.overlay
* once the conversation seam is up with a per-session inject (sessionId →
* through slot declaration injection with a per-session inject (sessionId →
* scope → popupFor; unknown id fails loud), both fold up on fiber disposal
* (HMR safety), and the service satisfies the frozen CommandServiceContract.
*/
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import { createScope, scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
import { createScope, scopeOf, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
import type { CommandServiceContract } from '../src/client/contract.ts'
@@ -21,7 +21,6 @@ const sid = (k: string): SessionId => k as SessionId
async function bench() {
const ctx = new Context()
const sources = new Map<string, SlashSource>()
const overlays = new Map<string, { inject: unknown }>()
ctx.provide('slash', {
registerSource(src: SlashSource) {
sources.set(`${src.trigger} ${src.name}`, src)
@@ -34,14 +33,10 @@ async function bench() {
scopeOf: (c: Context) => scopeOf(c),
})
ctx.provide('connection', { api: { commands: { list: () => Promise.resolve({ result: { ok: true, value: { commands: [] } } }) } } })
ctx.provide('slots', {
register(options: { name: string; id?: string; inject?: unknown }) {
const key = `${options.name}#${options.id ?? ''}`
overlays.set(key, { inject: options.inject })
return () => { overlays.delete(key) }
},
})
ctx.provide('conversation', {})
await ctx.plugin(SlotsService).await()
ctx.slots.register({
name: 'root', children: { 'conversation.input.overlay': { kind: 'list', scope: 'session' } },
} as never, (() => null) as never)
ctx.provide('locale', new LocaleService(ctx))
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
@@ -50,7 +45,7 @@ async function bench() {
scopes.set(sid(key), handle.ctx)
return handle
}
return { ctx, fiber, sources, overlays, mint }
return { ctx, fiber, sources, slots: ctx.slots, mint }
}
describe('apply', () => {
@@ -59,7 +54,7 @@ describe('apply', () => {
})
it('mounts ctx.command, registers the source and the overlay entry, and folds up on disposal', async () => {
const { ctx, fiber, sources, overlays } = await bench()
const { ctx, fiber, sources, slots } = await bench()
const command = ctx.get('command')
expect(command).toBeInstanceOf(CommandService)
// Frozen-contract conformance (compile-time check rides the assignment).
@@ -67,18 +62,18 @@ describe('apply', () => {
expect(typeof contract.register).toBe('function')
expect(typeof contract.popupFor).toBe('function')
expect([...sources.keys()]).toEqual(['/ command'])
expect([...overlays.keys()]).toEqual(['conversation.input.overlay#command-popup'])
expect(slots.entries('conversation.input.overlay').map(entry => entry.options.id)).toEqual(['command-popup'])
await fiber.dispose()
expect(sources.size).toBe(0)
expect(overlays.size).toBe(0)
expect(slots.entries('conversation.input.overlay')).toHaveLength(0)
})
it('the overlay inject resolves the per-session popup controller by sessionId and fails loud on an unknown id', async () => {
const { ctx, overlays, mint } = await bench()
const { ctx, slots, mint } = await bench()
const command = ctx.get('command') as CommandService
const scope = mint('s1')
const entry = overlays.get('conversation.input.overlay#command-popup')!
const injectEntry = entry.inject as (sessionId: SessionId) => PopupSelectInjected
const entry = slots.entries('conversation.input.overlay')[0]!
const injectEntry = entry.inject as unknown as (sessionId: SessionId) => PopupSelectInjected
expect(injectEntry(sid('s1')).popup).toBe(command.popupFor(scope.ctx))
expect(() => injectEntry(sid('ghost'))).toThrow(/resolved no scope/)
})