feat(ui-slots): bind inject hooks compartments into use<Name> selector hooks

Registrant-private reactive facts previously reached components as raw
observables that each component subscribed by hand (InputBar notices/
lexicon via uSES, SettingsRoot via a version/subscribe/getter triple).
The inject face now carries a reserved hooks compartment of bare
sources; the renderer binds each into a use<Name> selector hook through
the same machinery as the provide channel, so components consume
useNotices/useLexicon/useSections and never see a subscription
primitive. InputBar and SettingsRoot are the first two consumers.
This commit is contained in:
imccyu
2026-07-28 12:08:24 +08:00
parent b7f3cd3d78
commit b5168bbf86
20 changed files with 185 additions and 89 deletions
@@ -135,13 +135,15 @@ export function apply(ctx: Context): void {
'conversation.input.model': { kind: 'single', scope: 'session' },
},
inject: (sessionId: SessionId): ComposerBarInjected => {
const shell = inputHub.shell(sessionId)
return {
keyboard: inputHub.keyboard(sessionId),
keyboard: shell,
stop: () => {
scopedConversation(sessions, sessionId).cancel().catch(() => {
// Stop failure surfaces via snapshot.promptError; nothing to restore.
})
},
hooks: { notices: shell.notices, lexicon: shell.lexicon },
}
},
}, InputBar)
@@ -1,11 +1,11 @@
/** Conversation slot declarations and their composed component props. */
import type { ReactNode, RefObject } from 'react'
import type {
MaybeSnapshotSelectorHook, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook,
InjectFace, MaybeSnapshotSelectorHook, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook,
} from '@deepseek-ai/dsh-client-ui-slots'
import type { ConversationSnapshot, PendingInteraction, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSnapshot, ObservableSnapshot, PendingInteraction, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type { ComposerKeyboard, InputActions, InputState } from '../input/contract.ts'
import type { ComposerKeyboard, InputActions, InputNotice, InputState } from '../input/contract.ts'
import type { createChatStore } from '../stores.ts'
import type { CallId, SelectionTarget, ViewTab } from './views.ts'
@@ -220,6 +220,13 @@ export interface ComposerBarInjected {
keyboard: ComposerKeyboard
/** Cancel the in-flight turn. */
stop: () => void
/** Registrant hooks compartment: the renderer binds these to useNotices/useLexicon. */
hooks: {
/** Latest surfaced notice (null after none; seq keys re-render of repeats). */
notices: ObservableSnapshot<InputNotice | null>
/** Hot plain-text reference lexicon for the decoration scan (decision 21). */
lexicon: ObservableSnapshot<ReadonlyMap<'/' | '@', readonly string[]>>
}
}
/**
@@ -231,11 +238,11 @@ export interface InputControlOwnerProps {
locked: boolean
}
/** Full composer-bar component props: standard kit & owner share & control-seat render share & injected share. */
/** Full composer-bar component props: standard kit & owner share & control-seat render share & injected share (hooks compartment bound). */
export type ComposerBarProps =
PropsRuntime<'conversation.composer.bar'>
& PropsRenderSlots<'conversation.input.plan' | 'conversation.input.model'>
& ComposerBarInjected
& InjectFace<ComposerBarInjected>
/**
* Composer chain currency: what ConversationRoot dispatches at its
@@ -5,7 +5,7 @@
* conversation wiring layer alone sees the full SessionInput. InputMachine
* (machine.ts) is package-private and never exported.
*/
import type { ClientContext, ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { ClientContext, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type {
ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, PickOutcome,
ReferenceInsert, SubmitOutcome, TokenSpan,
@@ -77,8 +77,6 @@ export interface InputNotice {
* satisfies it structurally.
*/
export interface ComposerKeyboard {
/** Latest surfaced notice store (null after none). */
readonly notices: SnapshotStore<InputNotice | null>
/** Live machine state for event-handler reads (render reads go through useInput). */
readonly snapshot: InputState
/** Draft write with the DOM-observed edit shape (narrows occurrence math). */
@@ -99,8 +97,6 @@ export interface ComposerKeyboard {
space(): boolean
/** Dismiss the popupSelect shell (any interaction outside the box). */
dismissPopup(): void
/** Hot plain-text reference lexicon source for the decoration scan (decision 21; empty Map without a pipeline). */
readonly lexicon: ObservableSnapshot<ReadonlyMap<'/' | '@', readonly string[]>>
}
/** One queued-message row projected from the session/queued frames (T9 supplies the store). */
@@ -1,11 +1,12 @@
/** The default composer body: the 'conversation.composer.bar' slot entry
* (decision 20). Machine state arrives through the standard provide channel
* (useInput + inputActions); the keyboard/DOM command face and stop arrive
* through this entry's own inject; layout-phase inputs (variant, placeholder,
* through this entry's own inject, whose hooks compartment binds
* useNotices/useLexicon; layout-phase inputs (variant, placeholder,
* region-slot content) ride the owner props. Session facts
* (running/removed/promptError) are self-selected via useSession. */
import { useEffect, useRef, useState, useSyncExternalStore } from 'react'
import { useEffect, useRef, useState } from 'react'
import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
import clsx from 'clsx'
import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
@@ -27,20 +28,12 @@ const READONLY_OPTIONS: readonly { id: string; label: string }[] = [
]
export function InputBar({
useSession, useInput, inputActions, keyboard, stop, renderSlot,
useSession, useInput, inputActions, keyboard, stop, renderSlot, useNotices, useLexicon,
variant, placeholder, accessory, overlay, leftItems, rightItems, onAdd, addLabel = 'Add attachment',
}: InputBarProps) {
const input = useInput(s => s)
const noticeStore = keyboard.notices
const notice = useSyncExternalStore(
(fn: () => void) => noticeStore.subscribe(fn),
() => noticeStore.getSnapshot(),
)
const lexiconStore = keyboard.lexicon
const lexicon = useSyncExternalStore(
(fn: () => void) => lexiconStore.subscribe(fn),
() => lexiconStore.getSnapshot(),
)
const notice = useNotices(s => s)
const lexicon = useLexicon(s => s)
const promptError = useSession(s => s.promptError)
const running = useSession(s => s.running)
const disabled = useSession(s => s.removed)