/** * 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. */ 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') /** 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). */ requireModule: (spec: string) => unknown } /** * Build the renderApp factory handed to AppRoot. * @param deps - assembly inputs. * @returns factory producing the real UI tree (called once per AppRoot render after settled). */ 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 => ( <> {slots.renderSlot('conversation', { sessionId: id })} {slots.renderSlot('details', { sessionId: id })} ) // 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 => ( <> {slots.renderSlot('conversation.empty', {}, { fallback: null })} ) // 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 () => ( ) }