Merge remote-tracking branch 'origin/master' into goal-ui-merge-master

This commit is contained in:
imccyu
2026-07-29 00:52:03 +08:00
138 changed files with 3519 additions and 858 deletions
@@ -1,7 +1,7 @@
/** Registers the conversation components, shared store, and service callbacks. */
import type { Context } from 'cordis'
import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type { ViewTab } from './contract/views.ts'
import type {
@@ -10,6 +10,7 @@ import type {
import { resolveToolPath } from './contract/tool-call-model.ts'
import { createChatStore } from './stores.ts'
import { ConversationService } from './service.ts'
import type { IConversation } from './service.ts'
import { InputHub } from './input/hub.ts'
import { InputBar } from './skeleton/InputBar.tsx'
import { ChatView } from './chat/ChatView.tsx'
@@ -24,8 +25,8 @@ import { DetailsPanel } from './skeleton/DetailsPanel.tsx'
/** Services required by the conversation plugin. */
export const inject = ['slots', 'layout', 'sessions', 'workspaces']
/** Resolve the session-scoped conversation service (scope-addressed send/cancel), failing loud. */
function scopedConversation(sessions: SessionsService, id: SessionId): ConversationService {
/** Resolve the session-scoped conversation face (scope-addressed send/cancel), failing loud. */
function scopedConversation(sessions: ISessions, id: SessionId): IConversation {
const scoped = sessions.scope(id)
if (scoped === undefined) throw new Error(`ui-conversation: session "${id}" resolved no scope`)
const conversation = scoped.get('conversation')
@@ -3,10 +3,9 @@
* between the independently implemented skeleton and chat domains; `apply.ts`
* owns their slot assembly.
*/
import type { ConversationService } from './service.ts'
export { apply, inject } from './apply.ts'
export { ConversationService } from './service.ts'
export type { IConversation } from './service.ts'
export type {
CallId, ChatStoreState, SelectionTarget, ViewTab,
@@ -22,6 +21,7 @@ export type {
declare module 'cordis' {
interface Context {
conversation: ConversationService
/** The outward face only; the concrete service stays inside this plugin. */
conversation: import('./service.ts').IConversation
}
}
@@ -8,9 +8,8 @@
* 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, Session, SessionBinding, SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { SlashController, SlashServiceContract } from '@deepseek-ai/dsh-client-ui-slash/client'
import type {} from '@deepseek-ai/dsh-client-ui-slash/client'
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'
@@ -113,7 +112,7 @@ export class InputHub implements InputService {
* exactly one path; a failed first prompt is an ordinary prompt failure
* (error strip via promptError, draft restored only while untouched).
*/
private sink(session: Session, text: string, mode: 'queue' | 'steer'): void {
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.
@@ -129,7 +128,7 @@ export class InputHub implements InputService {
}
private controller(actx: ClientContext): SlashController | undefined {
const slash = this.rootCtx.get('slash') as SlashServiceContract | undefined
const slash = this.rootCtx.get('slash')
return slash?.sessionOf(actx)
}
@@ -138,7 +137,7 @@ export class InputHub implements InputService {
return command?.popupFor(actx)
}
private sessions(): SessionsService {
private sessions(): ISessions {
const sessions = this.rootCtx.get('sessions')
if (sessions === undefined) throw new Error('conversation.input: sessions service unavailable')
return sessions
@@ -5,7 +5,7 @@
* reference-stable across unrelated snapshot swaps, so this is a pure
* projection — no second store, no copy.
*/
import type { ObservableSnapshot, Session } from '@deepseek-ai/dsh-client-runtime/client'
import type { ObservableSnapshot, SessionFace } from '@deepseek-ai/dsh-client-runtime/client'
import type { QueuedMessage } from '../input/contract.ts'
/**
@@ -13,10 +13,10 @@ import type { QueuedMessage } from '../input/contract.ts'
* The wiring layer (T5) overlays this onto InputState.queue; the runtime
* QueuedMessage and the input-contract QueuedMessage are structurally the
* same frozen shape ({key, preview}).
* @param session - the resident session instance.
* @param session - the resident session face.
* @returns the queue read face (snapshot reference stable while the queue is unchanged).
*/
export function queueReadFaceOf(session: Session): ObservableSnapshot<readonly QueuedMessage[]> {
export function queueReadFaceOf(session: SessionFace): ObservableSnapshot<readonly QueuedMessage[]> {
return {
getSnapshot: () => session.getSnapshot().queue,
subscribe: fn => session.subscribe(fn),
@@ -12,24 +12,50 @@ import type { Context } from 'cordis'
// Type-only imports: a plugin-to-plugin value import is a bundle purity
// error, so scope resolution goes through the sessions service (scopeOf
// method) instead of the standalone helper.
import type { Session, SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import { InputHub } from './input/hub.ts'
import type { ISessions, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { InputService } from './input/contract.ts'
/**
* The outward conversation face (`ctx.conversation`): the scope-addressed
* verbs and the input registry other plugins may reach — and exactly what a
* test fake must supply.
*/
export interface IConversation {
/** The per-session input machine registry (InputService face). */
readonly input: InputService
/**
* Send a prompt into the caller scope's session.
* @param text - prompt text, sent verbatim as one text block.
* @param mode - queue after the current turn, or steer into it.
* @returns completion; business failures reject (and land in promptError).
*/
send(text: string, mode: 'queue' | 'steer'): Promise<void>
/**
* Cancel the scoped session's in-flight turn.
* @returns completion; failures reject as in send.
*/
cancel(): Promise<void>
/**
* Pull one older history page for the scoped session.
* @returns completion of the page pull.
*/
loadOlder(): Promise<void>
}
/** Scope-addressed conversation service (root singleton, provided as `conversation`). */
export class ConversationService extends Service {
export class ConversationService extends Service implements IConversation {
/** The per-session input machine registry (InputService face, design §5.2). */
readonly input: InputHub
readonly input: InputService
/**
* @param ctx - owning root context (the plugin apply context; the service
* registers itself and follows that fiber's lifetime).
* @param config - the shared InputHub constructed by the plugin apply
* (shared with the slot inject factories); absent = own instance
* (object-layer tests that never touch slots).
* @param config - carries the InputService instance constructed by the
* plugin apply (the same InputHub the slot inject factories close over).
*/
constructor(ctx: Context, config?: { input?: InputHub }) {
constructor(ctx: Context, config: { input: InputService }) {
super(ctx, 'conversation')
this.input = config?.input ?? new InputHub(ctx)
this.input = config.input
}
/**
@@ -57,8 +83,8 @@ export class ConversationService extends Service {
await this.scopedSession('loadOlder').loadOlder()
}
/** Resolve the caller scope's Session or throw on root contexts. */
private scopedSession(op: string): Session {
/** Resolve the caller scope's session face or throw on root contexts. */
private scopedSession(op: string): SessionFace {
const id = this.scopeId(op)
const binding = this.requireSessions().binding(id)
if (binding === undefined) throw new Error(`conversation.${op}: session "${id}" resolved no binding`)
@@ -74,10 +100,9 @@ export class ConversationService extends Service {
return id
}
private requireSessions(): SessionsService {
// ctx.get instead of ctx.sessions: the typed Context merge is suspended
// while the client/host `sessions` declaration collision awaits
// arbitration (see the runtime package's Context merge note).
private requireSessions(): ISessions {
// Strict ctx.get, not the injection proxy: the scope-addressed pattern
// reads the service off whatever context the tracker rebound.
const sessions = this.ctx.get('sessions')
if (sessions === undefined) throw new Error('conversation: sessions service unavailable')
return sessions