refactor(gui): slot system standard — single register, four props shares, framework store seat
The definitive slot model for the web client, replacing the first-generation
define/register two-step, ScopedSlots whitelist faces, and binding handles:
- 'root' is the only a-priori slot (SlotsService built-in); the shell renders
exactly ctx.slots.renderSlot('root', {}).
- register is the single API: children = slot declaration + render
authorization + runtime spec in one options object; misconfiguration fails
loud at load (duplicate declaration, undeclared contribution, one store
handle under two scopes).
- Component props arrive in four auto-derived shares: PropsRuntime<K>
(owner params + session/global standard kits via declare-merge),
PropsRenderSlots<S>, PropsStore<H>, and the inject business face.
sessionId is framework-supplied; hooks are framework-made only.
- Framework store seat: defineStore factories declare schema/actions/persist;
read = useStore, write = baked actions only; store scope derives from the
mounting entry; per-session persist keys and clearPersisted lifecycle.
- inject factories read the apply closure's own ctx (binding handles retired;
root-ctx back door closed); SessionProvider is self-wired render-prop.
- Rendering sits behind the SlotRenderer install seam; runtime stays
React-free; ownership ledger keyed to the single entry axis closes the
stale-authority window (StaleAuthorizationError probes).
Docs: the slot type-chain note is refreshed in place as the slot system
standard RFC (bilingual pair re-recorded); the web client architecture RFC
defers its slot sections there; packages/client/AGENTS.md gains the slot and
props discipline; gui-testing/web-styling notes drop missions/ references.
Tests: suites rewritten to the standard (props fed directly, real store
engines via createXXXStore().create(), no render machinery); load-time
negative samples for declaration/authorization/store conflicts; verified by
real-host playwright run (three columns, empty state, collapse, keyed session
remount, cross-slot selection sharing).
docs(ui-sidebar): point contract reference at the committed slot standard RFC
missions/ is workspace-local and never committed; the README must not cite it.
This commit is contained in:
@@ -1,16 +1,19 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* M1a regression pin: the per-scope selection must survive list refreshes.
|
||||
* Drives the REAL SessionsService + ConversationService chain over the
|
||||
* programmable wire fake — a late list refresh that upgrades the display
|
||||
* title (bare id → cwd basename) and a reconnect-driven refreshList+resync
|
||||
* must neither recreate the session scope nor clear the selection account.
|
||||
* Selection survival across the store seat (terminal design §4): the chat
|
||||
* store now carries what the per-scope selection account used to — this pins
|
||||
* the same behavior contract in the new mechanism. Drives the REAL
|
||||
* SlotsService store axis with the shared createChatStore handle (the exact
|
||||
* apply.ts shape: one handle, two session-slot registrations): same session's
|
||||
* two slots resolve one instance (conversation writes, details reads);
|
||||
* sessions are isolated; a session's death buries its instance AND its
|
||||
* persisted draft; a list refresh does not touch instance identity.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { SessionsService, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createChatStore } from '../src/client/stores.ts'
|
||||
|
||||
// The runtime package's programmable fake lives in its tests; import through
|
||||
// the src path (same pattern the runtime specs use — test-support material).
|
||||
@@ -22,15 +25,31 @@ interface Bench {
|
||||
ctx: Context
|
||||
api: FakeApiClient
|
||||
sessions: SessionsService
|
||||
conversation: ConversationService
|
||||
slots: SlotsService
|
||||
chat: ReturnType<typeof createChatStore>
|
||||
}
|
||||
|
||||
function bench(): Bench {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
const conversation = new ConversationService(ctx)
|
||||
return { ctx, api, sessions, conversation }
|
||||
// Service self-registers as ctx 'slots' (cordis Service constructor).
|
||||
const slots = new SlotsService(ctx)
|
||||
const chat = createChatStore()
|
||||
// The apply.ts shape: one shared handle across both session-slot
|
||||
// registrations. 'conversation'/'details' must first exist in the ledger —
|
||||
// register a root occupant declaring them (the AppFrame role; the stand-in
|
||||
// consumes renderSlot to satisfy the declare-means-render check).
|
||||
slots.register({
|
||||
name: 'root',
|
||||
children: {
|
||||
'conversation': { kind: 'single', scope: 'session' },
|
||||
'details': { kind: 'single', scope: 'session' },
|
||||
},
|
||||
}, (_p: { renderSlot?: unknown }) => null)
|
||||
slots.register({ name: 'conversation', store: chat }, () => null)
|
||||
slots.register({ name: 'details', store: chat }, () => null)
|
||||
return { ctx, api, sessions, slots, chat }
|
||||
}
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
@@ -48,8 +67,63 @@ function feed(b: Bench, rows: { id: string; cwd?: string; running?: boolean }[])
|
||||
}) as never)
|
||||
}
|
||||
|
||||
describe('selection survives list refreshes (M1a)', () => {
|
||||
it('create → select → title-upgrading refresh keeps scope, binding, store and value', async () => {
|
||||
/** Resolve the store instance the renderer would hand a slot's component for a session. */
|
||||
function storeFor(b: Bench, slot: 'conversation' | 'details', sessionId: SessionId) {
|
||||
const host = renderHost(b)
|
||||
const entry = host.entriesOf(slot)[0]!
|
||||
return host.storeOf(entry, sessionId)! as ReturnType<ReturnType<typeof createChatStore>['create']>
|
||||
}
|
||||
|
||||
/** The host face is only built at renderSlot time; install a stub renderer once to reach it. */
|
||||
function renderHost(b: Bench): import('@deepseek-ai/dsh-client-web-react').SlotRendererHost {
|
||||
const captured = (b as unknown as { _host?: import('@deepseek-ai/dsh-client-web-react').SlotRendererHost })
|
||||
if (captured._host === undefined) {
|
||||
b.slots.install({
|
||||
renderRoot: (host) => {
|
||||
captured._host = host
|
||||
return null
|
||||
},
|
||||
})
|
||||
b.slots.renderSlot('root', {})
|
||||
}
|
||||
return captured._host!
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
describe('selection survives on the store seat', () => {
|
||||
it('one session, two slots: conversation writes, details reads the SAME instance', async () => {
|
||||
const b = bench()
|
||||
feed(b, [{ id: 's1' }])
|
||||
await b.sessions.manager.refreshList()
|
||||
await flush()
|
||||
|
||||
const conv = storeFor(b, 'conversation', sid('s1'))
|
||||
const details = storeFor(b, 'details', sid('s1'))
|
||||
conv.actions.select({ turnSeq: 3, callId: 'c1' })
|
||||
expect(details.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1' })
|
||||
// Identity, not just value: the shared handle resolves one instance per scope key.
|
||||
expect(details).toBe(conv)
|
||||
})
|
||||
|
||||
it('sessions are isolated: s2 selection never bleeds into s1', async () => {
|
||||
const b = bench()
|
||||
feed(b, [{ id: 's1' }, { id: 's2' }])
|
||||
await b.sessions.manager.refreshList()
|
||||
await flush()
|
||||
|
||||
const one = storeFor(b, 'conversation', sid('s1'))
|
||||
const two = storeFor(b, 'conversation', sid('s2'))
|
||||
expect(two).not.toBe(one)
|
||||
one.actions.select({ turnSeq: 1, callId: 'a' })
|
||||
two.actions.select({ turnSeq: 9, callId: 'z' })
|
||||
expect(one.store.getSnapshot().selection).toEqual({ turnSeq: 1, callId: 'a' })
|
||||
expect(two.store.getSnapshot().selection).toEqual({ turnSeq: 9, callId: 'z' })
|
||||
})
|
||||
|
||||
it('a title-upgrading list refresh keeps instance identity and the selection value', async () => {
|
||||
const b = bench()
|
||||
// First-send shape: client-side create inserts the row without cwd (title = bare id).
|
||||
b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s1') }))
|
||||
@@ -57,11 +131,9 @@ describe('selection survives list refreshes (M1a)', () => {
|
||||
await flush()
|
||||
expect(b.sessions.list.getSnapshot().byId[id]?.title).toBe('s1')
|
||||
|
||||
const binding = b.sessions.binding(id)
|
||||
expect(binding).toBeDefined()
|
||||
const scoped = b.sessions.scope(id)!
|
||||
const store = (scoped.get('conversation') as ConversationService).selection
|
||||
store.set({ turnSeq: 3, callId: 'c1' })
|
||||
const store = storeFor(b, 'conversation', id)
|
||||
store.actions.select({ turnSeq: 3, callId: 'c1' })
|
||||
store.actions.setDraft('half-typed')
|
||||
|
||||
// The late list refresh lands (host knows the cwd → formal title).
|
||||
feed(b, [{ id: 's1', cwd: '/w/proj-a' }])
|
||||
@@ -69,53 +141,40 @@ describe('selection survives list refreshes (M1a)', () => {
|
||||
await flush()
|
||||
expect(b.sessions.list.getSnapshot().byId[id]?.title).toBe('proj-a')
|
||||
|
||||
// Scope, binding and the selection account must all be identity-stable.
|
||||
expect(b.sessions.scope(id)).toBe(scoped)
|
||||
expect(b.sessions.binding(id)).toBe(binding)
|
||||
const after = (b.sessions.scope(id)!.get('conversation') as ConversationService).selection
|
||||
const after = storeFor(b, 'conversation', id)
|
||||
expect(after).toBe(store)
|
||||
expect(after.getSnapshot()).toEqual({ turnSeq: 3, callId: 'c1' })
|
||||
expect(after.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1' })
|
||||
expect(after.store.getSnapshot().draft).toBe('half-typed')
|
||||
})
|
||||
|
||||
it('reconnect (handleConnected: refreshList + resync) keeps the selection account', async () => {
|
||||
it('session death buries the instance and its persisted draft', async () => {
|
||||
const b = bench()
|
||||
feed(b, [{ id: 's1' }])
|
||||
feed(b, [{ id: 's1' }, { id: 's2' }])
|
||||
await b.sessions.manager.refreshList()
|
||||
await flush()
|
||||
|
||||
const scoped = b.sessions.scope(sid('s1'))!
|
||||
const store = (scoped.get('conversation') as ConversationService).selection
|
||||
store.set({ turnSeq: 1, callId: 'c9' })
|
||||
// Mint the scope (store prune rides the scope-teardown axis: no scope,
|
||||
// no teardown — the real page always resolves the binding to render).
|
||||
b.sessions.binding(sid('s1'))
|
||||
const doomed = storeFor(b, 'conversation', sid('s1'))
|
||||
doomed.actions.setDraft('to be buried')
|
||||
doomed.actions.select({ turnSeq: 1 })
|
||||
expect(localStorage.getItem('dsh.conversation.chat.s1')).not.toBeNull()
|
||||
|
||||
// Reconnect generation: title upgrade arrives with the re-pull.
|
||||
feed(b, [{ id: 's1', cwd: '/w/proj-a', running: true }])
|
||||
b.sessions.manager.handleConnected()
|
||||
await flush()
|
||||
await flush()
|
||||
|
||||
expect(b.sessions.scope(sid('s1'))).toBe(scoped)
|
||||
const after = (b.sessions.scope(sid('s1'))!.get('conversation') as ConversationService).selection
|
||||
expect(after).toBe(store)
|
||||
expect(after.getSnapshot()).toEqual({ turnSeq: 1, callId: 'c9' })
|
||||
})
|
||||
|
||||
it('a transiently failing list refresh does not prune live scopes', async () => {
|
||||
const b = bench()
|
||||
feed(b, [{ id: 's1' }])
|
||||
// Watch elsewhere so s1's scope teardown is not deferred, then remove it.
|
||||
b.sessions.binding(sid('s2'))
|
||||
feed(b, [{ id: 's2' }])
|
||||
await b.sessions.manager.refreshList()
|
||||
await flush()
|
||||
const scoped = b.sessions.scope(sid('s1'))!
|
||||
const store = (scoped.get('conversation') as ConversationService).selection
|
||||
store.set({ turnSeq: 2, callId: 'c2' })
|
||||
|
||||
// Wire hiccup: the reconnect-time list RPC throws (transport error).
|
||||
b.api.onList = () => Promise.reject(new Error('boom'))
|
||||
b.sessions.manager.handleConnected()
|
||||
// Persisted residue is gone with the session...
|
||||
expect(localStorage.getItem('dsh.conversation.chat.s1')).toBeNull()
|
||||
// ...and a re-created same-id session starts from a FRESH instance.
|
||||
feed(b, [{ id: 's1' }, { id: 's2' }])
|
||||
await b.sessions.manager.refreshList()
|
||||
await flush()
|
||||
await flush()
|
||||
|
||||
expect(b.sessions.scope(sid('s1'))).toBe(scoped)
|
||||
expect((b.sessions.scope(sid('s1'))!.get('conversation') as ConversationService).selection.getSnapshot())
|
||||
.toEqual({ turnSeq: 2, callId: 'c2' })
|
||||
const reborn = storeFor(b, 'conversation', sid('s1'))
|
||||
expect(reborn).not.toBe(doomed)
|
||||
expect(reborn.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null })
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user