Files
deepseek-harness/packages/client/ui-conversation/src/client/input/hub.ts
imccyu 0efc7f045e refactor(client): carve outward interfaces for the session, workspace, layout, slash, and conversation services
Feature packages now reach these domains through interface types only:
ISession/SessionFace (identity + prompt/cancel/loadOlder + the useSession
snapshot source), ISessions, IWorkspaces, ILayout, IConversation, and the
existing SlashServiceContract now actually mounted on Context.slash. The
concrete services implement their face; wire-pump and assembly entry
points stay on the classes. The provide-channel materialization and
current-projection logic moves into SessionProvideChannel so the
production service and the client test runtime share one implementation.
The workspaces service consumes sessions through the narrow SessionsPort.
2026-07-28 23:43:32 +08:00

146 lines
5.9 KiB
TypeScript

/**
* InputHub: the InputService implementation (`ctx.conversation.input`) — one
* SessionInputShell per session, created inside the sessions provide
* materialization (decision 19: the 'input' standard-kit entry IS the
* creation trigger) and torn down by the scope disposer (instance-and-scope
* share one lifecycle). The hub registers the three scoped input-mutation
* listeners on each session's actx (the sole consumer side of the ui-slash
* bail events) and owns the default-sink choreography: every session is a
* real host entity, so the sink is one unconditional prompt path.
*/
import type { ClientContext, ISessions, SessionBinding, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { SlashController } from '@deepseek-ai/dsh-client-ui-slash/client'
import { queueReadFaceOf } from '../queue/store.ts'
import type { ComposerKeyboard, InputService, SessionInput } from './contract.ts'
import type { PopupDismissFace } from './facade.ts'
import { SessionInputShell } from './facade.ts'
/** Structural command face for per-session popup resolution. */
interface CommandFace {
popupFor(actx: ClientContext): PopupDismissFace
}
/** Session-addressed input facade registry (InputService face + composer-layer extras). */
export class InputHub implements InputService {
private readonly shells = new Map<SessionId, SessionInputShell>()
/** @param ctx - client root context (services resolved lazily per call — boot order stays free). */
constructor(private readonly rootCtx: ClientContext) {}
/**
* Resolve the facade for one session-scope ctx (InputService face).
* @param actx - session-scope context.
* @returns the resident per-session facade.
*/
for(actx: ClientContext): SessionInput {
const sessions = this.sessions()
const id = sessions.scopeOf(actx)
if (id === undefined) throw new Error('conversation.input.for requires a session scope')
return this.shell(id)
}
/**
* Resident shell for one session binding — the provide-channel entry
* (called during scope materialization, BEFORE the scope record is
* queryable, hence binding-fed and hence the thunked slash/popup deps).
* Wires the scoped event listeners + teardown into the session scope.
* @param binding - session assembly handle.
* @returns the shell.
*/
shellFor(binding: SessionBinding): SessionInputShell {
const existing = this.shells.get(binding.sessionId)
if (existing !== undefined) return existing
const { sessionId: id, session, ctx: actx } = binding
const shell = new SessionInputShell({
actx,
slash: () => this.controller(actx),
popup: () => this.popup(actx),
queue: queueReadFaceOf(session),
defaultSink: (text, mode) => { this.sink(session, text, mode) },
})
this.shells.set(id, shell)
// The one teardown axis: listeners, shell, and map entries all ride the
// scope fiber (decision 12 — nothing here outlives the scope).
actx.effect(() => {
const offs = [
actx.on('slash/input-begin-command', req =>
shell.beginCommand(req.claim, req.span) ? true : undefined),
actx.on('slash/input-insert-reference', req =>
shell.insertReference(req.reference, req.span) ? true : undefined),
actx.on('slash/input-consume-token', req =>
shell.consumeToken(req.guard) ? true : undefined),
actx.on('slash/input-insert-text', req =>
shell.insertText(req.text, req.span) ? true : undefined),
]
return () => {
for (const off of offs) off()
shell.dispose()
this.shells.delete(id)
}
}, 'conversation.input: session shell')
return shell
}
/**
* Resident shell by session id (service-face path; the provide channel has
* normally created it already — this covers direct id-addressed access).
* @param id - session id.
* @returns the shell.
*/
shell(id: SessionId): SessionInputShell {
const existing = this.shells.get(id)
if (existing !== undefined) return existing
const binding = this.sessions().binding(id)
if (binding === undefined) throw new Error(`conversation.input: session "${id}" resolved no binding`)
return this.shellFor(binding)
}
/**
* The InputBar-exclusive keyboard command face (decision 20): the shell
* satisfies it structurally; package-internal — handed through the
* composer-bar entry's inject, never across a plugin boundary.
* @param id - session id.
* @returns the shell as the keyboard face.
*/
keyboard(id: SessionId): ComposerKeyboard {
return this.shell(id)
}
/**
* Default sink: optimistic clear + prompt. The session is always a real
* host entity (materialized when its workspace was picked), so there is
* exactly one path; a failed first prompt is an ordinary prompt failure
* (error strip via promptError, draft restored only while untouched).
*/
private sink(session: SessionFace, text: string, mode: 'queue' | 'steer'): void {
if (text === '') return
const shell = this.shells.get(session.sessionId)
// Commit, not an editable clear: undo must not resurrect sent content.
shell?.commitSend()
void session.prompt([{ type: 'text', text }], mode).then(
(result) => {
if (!result.ok && shell?.snapshot.draft === '') shell.setDraft(text)
},
() => {
if (shell?.snapshot.draft === '') shell.setDraft(text)
},
)
}
private controller(actx: ClientContext): SlashController | undefined {
const slash = this.rootCtx.get('slash')
return slash?.sessionOf(actx)
}
private popup(actx: ClientContext): PopupDismissFace | undefined {
const command = this.rootCtx.get('command') as CommandFace | undefined
return command?.popupFor(actx)
}
private sessions(): ISessions {
const sessions = this.rootCtx.get('sessions')
if (sessions === undefined) throw new Error('conversation.input: sessions service unavailable')
return sessions
}
}