mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
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,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', {})
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
|
||||
Reference in New Issue
Block a user