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:
imccyu
2026-07-23 01:40:30 +08:00
parent efa4326ff4
commit 1b0ea07bce
95 changed files with 5024 additions and 3322 deletions

View File

@@ -1,24 +1,19 @@
/**
* Real-UI assembly closure. Runs only after loader.settled(): resolves the
* layout plugin's export surface from the loader module table (type-only
* import keeps the plugin out of the shell bundle), closes SessionProvider and
* scopedSlots over the shell's whitelist, and mounts RootBindingProvider so
* root-slot inject factories can reach ctx.
* Real-UI assembly closure. Runs only after loader.settled(): the whole
* layout tree hangs off the built-in 'root' slot (ui-layout registers
* AppFrame there and renders the child slots internally) — the shell's
* render is the one ctx-level renderSlot call in the program.
*/
import type { ReactNode } from 'react'
import type { Context } from 'cordis'
import {
createSessionProvider, RootBindingProvider, scopedSlots,
} from '@deepseek-ai/dsh-client-web-react'
import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
type LayoutExports = typeof import('@deepseek-ai/dsh-client-ui-layout/client')
// Type-only: pulls the runtime's SlotMap declaration merge (the 'root' key) into this program.
import type {} from '@deepseek-ai/dsh-client-runtime/client'
/** Assembly inputs: the settled root ctx plus the loader's module-table read surface. */
export interface AssemblyDeps {
/** Client root context (all plugin services provided). */
ctx: Context
/** Module-table resolver (the loader's require; missing spec = throw). */
/** Module-table resolver (the loader's require; missing spec = throw). Kept in the seam for future shell needs. */
requireModule: (spec: string) => unknown
}
@@ -29,61 +24,5 @@ export interface AssemblyDeps {
*/
export function buildRenderApp(deps: AssemblyDeps): () => ReactNode {
const { ctx } = deps
const layoutExports = deps.requireModule('@deepseek-ai/dsh-client-ui-layout/client') as LayoutExports
const { AppFrame, CenterColumn, DetailsColumn } = layoutExports
const layout = ctx.layout
// ctx.get: the typed `sessions` Context merge is suspended pending the
// client/host declaration-collision arbitration (runtime's merge note).
const sessions = ctx.get('sessions') as SessionsService | undefined
if (sessions === undefined) throw new Error('shell assembly: sessions service unavailable')
// Whitelist closure: the four layout-owned top slots, granted to the shell assembler.
const slots = scopedSlots(ctx.slots.core, 'sidebar', 'conversation', 'details', 'conversation.empty')
// Stable references — created once per assembly, never per render.
const rootBinding = { ctx }
const useCurrent = (): SessionId | undefined => layout.current.useSelector((s) => s.sessionId)
const useSidebar = layout.sidebar.useSelector
const useDetails = layout.details.useSelector
const setSidebarWidth = (px: number): void => { layout.setSidebarWidth(px) }
const setDetailsWidth = (px: number): void => { layout.setDetailsWidth(px) }
const renderBody = (id: SessionId): ReactNode => (
<>
<CenterColumn>{slots.renderSlot('conversation', { sessionId: id })}</CenterColumn>
<DetailsColumn>{slots.renderSlot('details', { sessionId: id })}</DetailsColumn>
</>
)
// No selected session: the conversation.empty root slot carries EmptyState
// (ui-conversation registers it); the fallback keeps the grid shape until
// that owner lands.
const renderEmpty = (): ReactNode => (
<>
<CenterColumn>{slots.renderSlot('conversation.empty', {}, { fallback: null })}</CenterColumn>
<DetailsColumn />
</>
)
// Provider deps speak plain string (web-react's inversion: it never imports
// runtime); the assembler re-brands at this boundary — ids entering the
// provider came from layout.current, which only holds validated SessionIds.
const SessionProvider = createSessionProvider({
useCurrent,
resolveBinding: (id) => sessions.binding(id as SessionId),
renderBody: (id) => renderBody(id as SessionId),
})
return () => (
<RootBindingProvider value={rootBinding}>
<AppFrame
useSidebar={useSidebar}
useDetails={useDetails}
setSidebarWidth={setSidebarWidth}
setDetailsWidth={setDetailsWidth}
sidebar={slots.renderSlot('sidebar', {})}
>
<SessionProvider renderEmpty={renderEmpty} />
</AppFrame>
</RootBindingProvider>
)
return () => ctx.slots.renderSlot('root', {})
}

View File

@@ -11,6 +11,7 @@ import { Context } from 'cordis'
import { createRoot } from 'react-dom/client'
import type { ReactNode } from 'react'
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-web-react'
import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react'
import { createClientLoader, type ClientLoaderOptions } from '@deepseek-ai/dsh-client-runtime/loader'
import { AppRoot } from './AppRoot.tsx'
import { buildRenderApp } from './app.tsx'
@@ -59,7 +60,13 @@ export function bootWebShell(el: HTMLElement, seams?: BootSeams): () => void {
loader.start()
loader.settled().then(
() => { settled.flip() },
() => {
// The renderer install is a shell-boot act, but ctx.slots exists only
// once the runtime plugin loaded — so it lands here, after settled and
// before the flip that lets renderApp call renderSlot('root').
ctx.slots.install(createSlotRenderer())
settled.flip()
},
() => { /* stay on the loading page; failures render from loader.status */ },
)
return () => { root.unmount() }