Merge remote-tracking branch 'origin/master' into worktree/web-session-titles

# Conflicts:
#	.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml
#	packages/client/ui-conversation/tests/apply-inject.spec.tsx
#	packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx
#	packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx
#	packages/client/ui-conversation/tests/selection-survival.spec.ts
#	packages/client/ui-conversation/tests/skeleton-branches.spec.tsx
#	packages/client/ui-conversation/tests/skeleton.spec.tsx
#	packages/client/ui-layout/tests/service.spec.ts
#	packages/client/ui-sidebar/tests/apply.spec.tsx
#	packages/client/ui-sidebar/tests/store.spec.ts
#	packages/client/ui-trajectory/tests/views.spec.tsx
#	packages/client/web/src/app.tsx
#	packages/client/web/tests/boot.spec.tsx
#	packages/host/runtime/README.md
#	packages/host/runtime/tests/host-runtime.spec.ts
This commit is contained in:
Tianyi Cui
2026-07-23 18:39:48 +08:00
285 changed files with 11631 additions and 6072 deletions

View File

@@ -0,0 +1,244 @@
/**
* Snapshot store engine (zustand vanilla + immer + subscribeWithSelector +
* rafFlush middleware + opt-in persist + dev freeze) plus the declarative
* shell over it: {@link defineStore} bakes an init/persist/actions literal
* into a {@link StoreHandle}, the registration-side store seat of the slot
* terminal design (§4). Lives in the React-free runtime (store-migration
* ruling: the data layer owns its engine; web-react is shell-only React
* glue): engine products are bare observables — subscribe/getSnapshot/
* update/set, NO selector hook. Hook synthesis is web-react's (the one
* uSES bridge, cached per source at the binding site).
*/
import { createStore, type StoreApi } from 'zustand/vanilla'
import { subscribeWithSelector } from 'zustand/middleware'
import { shallow } from 'zustand/shallow'
import { produce } from 'immer'
import type {
ActionsDecl, BakedActions, StoreHandle, StoreInstance, StoreSpec,
} from '@deepseek-ai/dsh-client-ui-slots'
// Store contract types are ui-slots authority; re-exported beside the engine
// so store consumers get one import surface.
export type {
ActionsDecl, BakedActions, BoundActions, StoreFactory, StoreHandle, StoreInstance, StoreSpec,
} from '@deepseek-ai/dsh-client-ui-slots'
/** Minimal observable snapshot source: Session objects and snapshot stores both satisfy it. */
export interface ObservableSnapshot<T> { getSnapshot(): T; subscribe(fn: () => void): () => void }
/** Writable snapshot store (bare data face; React selector hooks are synthesized in web-react). */
export interface SnapshotStore<T> extends ObservableSnapshot<T> {
/**
* Mutate the state through an immer draft.
* @param mutator - draft mutator.
*/
update(mutator: (draft: T) => void): void
/**
* Replace the state wholesale.
* @param next - next state.
*/
set(next: T): void
}
/**
* Shallow equality for selector slices (zustand/shallow semantics; travels
* with the engine so hook consumers need no zustand dependency).
* @param a - left value.
* @param b - right value.
* @returns whether the values are shallowly equal.
*/
export function shallowEqual(a: unknown, b: unknown): boolean {
return shallow(a, b)
}
/** Batches subscriber notification into one flush per animation frame. */
function rafBatch(notify: () => void): () => void {
// Fall back to microtask batching where rAF is absent (node unit tests);
// both preserve the N-changes=1-notification contract within a tick.
const schedule: (fn: () => void) => void =
typeof requestAnimationFrame === 'function'
? (fn) => { requestAnimationFrame(() => { fn() }) }
: (fn) => { queueMicrotask(fn) }
let scheduled = false
return () => {
if (scheduled) return
scheduled = true
schedule(() => {
scheduled = false
notify()
})
}
}
/**
* Create a snapshot store.
*
* Flush default is 'sync' (controlled inputs need same-tick echo); frame-driven
* stores opt into 'raf', where a frame's worth of updates coalesces into one
* notification. Known raf-mode tradeoff: a component mounting mid-frame reads
* fresh state while existing subscribers hear it next flush — transient
* frame-level skew, same nature as the object layer's microtask batching.
*
* @param init - initial state.
* @param opts - flush mode and opt-in persistence (localStorage, keyed by name).
* @returns the store.
*/
export function createSnapshotStore<T>(
init: T, opts?: { flush?: 'raf' | 'sync'; persist?: { name: string } }): SnapshotStore<T> {
// Immer enters through produce() in update() below (identical semantics to
// the immer middleware without its setState-signature mutator generics).
const withSelector = subscribeWithSelector(() => init)
const api: StoreApi<T> = createStore<T>()(withSelector)
if (opts?.persist) attachPersistence(api, opts.persist.name)
let subscribe = (fn: () => void) => api.subscribe(fn)
if (opts?.flush === 'raf') {
const listeners = new Set<() => void>()
const flush = rafBatch(() => { for (const fn of [...listeners]) fn() })
api.subscribe(flush)
subscribe = (fn: () => void) => {
listeners.add(fn)
return () => { listeners.delete(fn) }
}
}
return {
getSnapshot: () => api.getState(),
subscribe: fn => subscribe(fn),
update: (mutator) => {
// Immer's produce (not setState's partial-merge path) so scalar and
// array roots replace correctly; produce also freezes in dev.
api.setState(produce(api.getState(), (draft) => { mutator(draft as T) }), true)
},
set: (next) => {
api.setState(devFreeze(next), true)
},
}
}
/**
* Whole-value JSON persistence to localStorage. Hand-rolled instead of the
* zustand persist middleware: its write path spreads state into an object
* (`partialize({ ...get() })`), exploding primitive state (a persisted string
* draft becomes {0:'h',1:'e',...}) — not fixable via merge/deserialize options
* because the corruption happens before serialization. Storage failures
* (quota, private mode) only disable persistence, never break the store.
*/
function attachPersistence<T>(api: StoreApi<T>, name: string): void {
// Non-browser runs (node e2e booting the client tree) have no localStorage:
// persistence silently disables — same contract as a storage failure, minus
// the per-store console noise a ReferenceError would produce.
if (typeof localStorage === 'undefined') return
try {
const raw = localStorage.getItem(name)
if (raw !== null) {
api.setState(devFreeze(JSON.parse(raw) as T), true)
}
} catch (error) {
console.error(`snapshot store '${name}' rehydration failed:`, error)
}
api.subscribe((state) => {
try {
localStorage.setItem(name, JSON.stringify(state))
} catch (error) {
console.error(`snapshot store '${name}' persistence failed:`, error)
}
})
}
/** Deep-freeze wholesale-set state outside production: set() bypasses immer's freeze. */
function devFreeze<T>(value: T): T {
if (process.env.NODE_ENV === 'production') return value
deepFreeze(value)
return value
}
function deepFreeze(value: unknown): void {
if (typeof value !== 'object' || value === null || Object.isFrozen(value)) return
Object.freeze(value)
for (const key of Reflect.ownKeys(value)) {
deepFreeze((value as Record<PropertyKey, unknown>)[key])
}
}
// ---- defineStore shell (slot terminal design §4) ----
// The type authority is ui-slots' store family (create(scopeKey?) and
// clearPersisted() included); this module houses only the engine-backed
// implementation. The one engine-side widening left: instances expose the
// raw engine store for framework/test surfaces.
/** A live engine instance: the contract instance plus the raw engine store. */
export interface EngineStoreInstance<T, A extends ActionsDecl<T>> extends StoreInstance<T, A> {
/** The underlying engine store (framework/test surface; components never see it). */
readonly store: SnapshotStore<T>
}
/** The engine-backed handle: create() narrowed to the engine instance. */
export interface EngineStoreHandle<T, A extends ActionsDecl<T>> extends StoreHandle<T, A> {
/**
* Construct a live engine instance (see the contract JSDoc on
* {@link StoreHandle.create} for scopeKey/persist semantics).
*
* Known boundary: the persist key is the storage identity, so multiple live
* instances created under the same resolved key share (and cross-pollute)
* one localStorage entry. Instance uniqueness per key is the caller's
* responsibility — production is safe because the framework caches one
* instance per handle x scope key; tests wanting isolation use distinct
* scope keys or persist-free declarations (multi-create freedom is a
* feature there, so create() deliberately does not dedupe or throw).
* @param scopeKey - session id for session-scope instances; omitted for root scope.
* @returns the engine instance.
*/
create(scopeKey?: string): EngineStoreInstance<T, A>
}
/**
* Declare a store: initial state, optional persistence, and the full write
* set as pure draft mutators. The returned handle is the registration
* currency of the store seat — its identity keys instance sharing. Satisfies
* ui-slots' DefineStore contract (the handle/instance are the engine-extended
* subtypes).
*
* The `A & ActionsDecl<T>` actions position is load-bearing: T resolves from
* `init` in the first inference round, and the intersection then contextually
* types each mutator's draft parameter (context-sensitive functions defer),
* so call sites write `(d, x: X) => { ... }` with no draft annotation. If a
* future TS version breaks this single-literal inference, the design's
* documented fallback is currying (`defineStore(init).actions({...})`).
* @param decl - init lambda (fresh state per instance), optional persist key, actions table.
* @returns the store handle.
*/
export function defineStore<T, A extends ActionsDecl<T>>(
decl: StoreSpec<T, A> & { actions: A & ActionsDecl<T> }): EngineStoreHandle<T, A> {
return {
spec: decl,
create(scopeKey?: string): EngineStoreInstance<T, A> {
const persistKey = decl.persist === undefined
? undefined
: scopeKey === undefined ? decl.persist : `${decl.persist}.${scopeKey}`
const store = createSnapshotStore<T>(
decl.init(),
persistKey !== undefined ? { persist: { name: persistKey } } : undefined)
const actions = {} as Record<string, (...params: unknown[]) => void>
for (const key of Object.keys(decl.actions)) {
const mutate = decl.actions[key] as (draft: T, ...params: unknown[]) => void
actions[key] = (...params: unknown[]) => { store.update((draft) => { mutate(draft, ...params) }) }
}
return {
actions: actions as BakedActions<T, A>,
getSnapshot: () => store.getSnapshot(),
subscribe: fn => store.subscribe(fn),
store,
clearPersisted: () => {
if (persistKey === undefined || typeof localStorage === 'undefined') return
try {
localStorage.removeItem(persistKey)
} catch {
// Storage failures (private mode, quota teardown races) only skip
// cleanup — the same non-fatal contract as attachPersistence.
}
},
}
},
}
}

View File

@@ -1,30 +1,40 @@
/**
* Browser half: the whole runtime contract surface (api-contracts v3 §4) —
* SlotsService, SessionsService (list store + scope tree + object layer),
* the ClientLoader interface, and the cordis Context/Events merges. apply
* SlotsService (declaration ledger + renderer seam + store axis, built-in
* 'root'), SessionsService (list store + current selection + scope tree +
* object layer), the ClientLoader interface, and the cordis Context/Events
* merges. apply
* mounts ctx.slots + ctx.sessions and wires the connection stream loop into
* the object layer. The loader machinery implementation is NOT in the plugin
* bundle — it ships via the package's `./loader` subpath, statically held by
* the web shell (a loader cannot load itself).
*/
import type { Context } from 'cordis'
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionBinding as GenericSessionBinding } from '@deepseek-ai/dsh-client-ui-slots'
import type { SnapshotStore, UseSession } from '@deepseek-ai/dsh-client-web-react'
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import type { SnapshotStore } from './contract/store.ts'
import { SlotsService } from './slots.ts'
import { SessionsService } from './sessions/service.ts'
import type { SessionListState } from './sessions/service.ts'
import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from './sessions/conversation.ts'
export { SlotsService } from './slots.ts'
// RootOwnerProps rides the 'root' SlotMap row (both migrated here from
// ui-layout: the framework slot is declared by the framework package).
export type { RootOwnerProps } from './slots.ts'
export { SessionsService, scopeOf } from './sessions/service.ts'
export type { Session } from './sessions/session.ts'
export type { SessionBinding, SessionListState, SessionSummary } from './sessions/service.ts'
export { SessionManager } from './sessions/manager.ts'
export type { SessionListSnapshot } from './sessions/manager.ts'
export { Session, PAGE_MESSAGES } from './sessions/session.ts'
export type { SessionListEntry } from './sessions/lineage.ts'
// The snapshot-store engine lives here since the store migration (the data
// layer owns its substrate; web-react is React glue only). The './client'
// main export is the single serving door — no store subpath.
export { createSnapshotStore, defineStore, shallowEqual } from './contract/store.ts'
export type {
EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore,
} from './contract/store.ts'
export type {
AssistantBlock, AssistantMessageNode, ContextMessageNode, ConversationNode, ConversationSnapshot,
OpenState, PartialAssistant, PendingInteraction, PromptError, RunningToolCall, SteeringMessageNode,
PendingInteraction, RunningToolCall, SteeringMessageNode,
ToolResultNode, UnknownSurfaceNode, UserMessageNode,
} from './sessions/conversation.ts'
export type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
@@ -41,11 +51,8 @@ export type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
*/
export type ClientContext = Context
/** SessionBinding narrowed to the client context (inject factories dot services directly). */
export type ClientSessionBinding = GenericSessionBinding<ClientContext>
/** The conversation-snapshot selector hook (ConvViewProps/ToolViewProps take this). */
export type UseConversationSession = UseSession<ConversationSnapshot>
/** The conversation-snapshot selector hook (ConvViewProps/ToolRowProps take this). */
export type UseConversationSession = SnapshotSelectorHook<ConversationSnapshot>
/**
* One tool call as the chat flow renders it: still-running (spinner card) or
@@ -54,6 +61,25 @@ export type UseConversationSession = UseSession<ConversationSnapshot>
*/
export type ToolCallBlock = RunningToolCall | ToolResultNode
declare module '@deepseek-ai/dsh-client-ui-slots' {
/**
* Session standard kit, real members (ui-slots declares the empty seat;
* the runtime — where the subjects live — merges the concrete types):
* every session-scope slot component receives these from the framework.
*/
interface SessionStandardProps {
/** Selector hook over this session's conversation snapshot. */
useSession: SnapshotSelectorHook<ConversationSnapshot>
/** The framework-resolved session id (owners never pass it). */
sessionId: SessionId
}
/** Global standard kit, real members: the session-list hook every slot component receives. */
interface GlobalStandardProps {
/** Selector hook over the session list snapshot (`current` included — the arbitrated selection seat). */
useSessions: SnapshotSelectorHook<SessionListState>
}
}
declare module 'cordis' {
interface Events {
/**

View File

@@ -17,7 +17,7 @@
* load one by one in inject topology.
*/
import type { Context } from 'cordis'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '../contract/store.ts'
import type { BootPluginEntry, ClientLoader, LoaderStatus } from '../index.ts'
export type { BootPluginEntry, ClientLoader, LoaderStatus } from '../index.ts'

View File

@@ -1,20 +1,24 @@
/**
* SessionsService: root sessions service — list snapshot store (manager
* projection), session scope tree (mintScope pattern: no-op plugin Fiber +
* ctx.extend scope tag), stable SessionBinding cache, ancestry walk.
* projection; carries `current`, the persisted selection every
* session-scoped surface keys off — migrated here from ui-layout per the
* slot-parity design), session scope tree (mintScope pattern: no-op plugin
* Fiber + ctx.extend scope tag), stable SessionBinding cache, ancestry walk.
*
* Scope lifecycle is watch-driven: a scope is minted lazily on first
* resolution; a session leaving the list tears its scope down only when
* nobody is watching it. "Watched" is approximated as the most recently
* resolved binding id — SessionProvider re-resolves on every selection
* change (keyed remount), so a switch away always re-evaluates the deferred
* teardown; a host-side death without list removal keeps the scope (frozen
* read-only view).
* Scope lifecycle is stage-driven: a scope is minted lazily on first
* resolution (pure — resolution has no side effects and is render-safe);
* the event window and deferred teardown key off the STAGED session, which
* follows `list.current` exactly. Staging is the open signal: the window
* opens ⟺ the session is on stage (today the stage is `current`; the staged
* state can widen to a multi-pane list later). A session leaving the list
* tears its scope down immediately unless it is the staged one, whose scope
* survives frozen (read-only view) until the stage moves on.
*/
import type { Context, Fiber } from 'cordis'
import type { IApiClient, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import type { SessionCell } from '@deepseek-ai/dsh-client-ui-slots'
import type { SnapshotStore } from '../contract/store.ts'
import { createSnapshotStore } from '../contract/store.ts'
import { SessionManager } from './manager.ts'
import type { Session } from './session.ts'
@@ -31,8 +35,12 @@ export interface SessionSummary {
updatedAt: number
}
/** Session list store shape. */
export interface SessionListState { ids: SessionId[]; byId: Record<SessionId, SessionSummary> }
/**
* Session list store shape. `current` rides the same snapshot (arbitrated:
* the single useSessions standard hook reads list and selection together —
* sidebar highlighting and SessionProvider share one fact source).
*/
export interface SessionListState { ids: SessionId[]; byId: Record<SessionId, SessionSummary>; current: SessionId | undefined }
/** Session assembly handle for SessionProvider/inject factories (identity-stable per session). */
export interface SessionBinding {
@@ -73,19 +81,35 @@ interface ScopeRecord {
fiber: Fiber
ctx: Context
binding: SessionBinding
/** Render-layer standard kit (identity-stable per scope; the renderer's per-cell caches key off it). */
cell: SessionCell
}
/** Root sessions service: list store, object-layer manager, scope tree, bindings, ancestry. */
/** Root sessions service: list store, current selection, object-layer manager, scope tree, bindings, ancestry. */
export class SessionsService {
/** List snapshot store (list RPC + host stream increments; re-pulled on reconnect). */
/** List snapshot store (list RPC + host stream increments; re-pulled on reconnect) — the useSessions standard feed, current included. */
readonly list: SnapshotStore<SessionListState>
/** The object-layer instance cluster and frame dispatch entry (wired to the connection by the runtime apply). */
readonly manager: SessionManager
/**
* Persisted selection cell (the durable half of `list.current`). Private on
* purpose: reads go through the list snapshot; writes through {@link
* SessionsService.open}. Projection validates it against the live list
* instead of destructively pruning, so a selection survives transient list
* states (reconnect re-pull) and resurfaces when its session returns.
*/
private readonly selection: SnapshotStore<{ sessionId?: SessionId }>
private readonly scopes = new Map<SessionId, ScopeRecord>()
/** Most recently resolved binding id — the watch approximation for deferred teardown. */
/**
* The staged session id — follows `list.current` exactly, holding its last
* defined value across masked gaps (a transiently absent selection blanks
* `current` without moving the stage, so reconnect re-pulls and removals
* keep the staged scope's frozen view alive until the stage moves on).
*/
private watched: SessionId | undefined
/** Removed-while-watched sessions whose teardown waits for the watch to move away. */
/** Removed-while-staged sessions whose teardown waits for the stage to move away. */
private readonly deferredRemovals = new Set<SessionId>()
/**
@@ -94,13 +118,36 @@ export class SessionsService {
*/
constructor(private readonly rootCtx: Context, api: IApiClient) {
this.manager = new SessionManager(api)
this.list = createSnapshotStore<SessionListState>({ ids: [], byId: {} })
this.selection = createSnapshotStore<{ sessionId?: SessionId }>(
{},
{ persist: { name: 'dsh.sessions.current' } })
this.list = createSnapshotStore<SessionListState>({ ids: [], byId: {}, current: undefined })
// The manager owns wire truth; the store is its projection. Manager
// notifications are already microtask-batched.
this.manager.subscribe(() => { this.projectList() })
// Stage follower: every current write (open() and projection alike)
// re-evaluates staging, so startup restore (persisted selection validated
// by the projection) and reconnect resurfacing open their window with no
// dedicated code path. Safe to run synchronously inside the store notify:
// the follower writes no list state — session.open()'s synchronous prefix
// touches only session-side state and its own microtask-batched notifier.
this.list.subscribe(() => { this.followCurrent() })
rootCtx.reflect.provide('sessions', this, undefined)
}
/**
* Select a session as current. Unknown ids fail loud instead of navigating
* nowhere (the sole selection write path).
* @param id - session id (must exist in the list store).
*/
open(id: SessionId): void {
if (this.list.getSnapshot().byId[id] === undefined) {
throw new Error(`sessions.open: unknown session ${id}`)
}
this.selection.update((draft) => { draft.sessionId = id })
this.list.update((draft) => { draft.current = id })
}
/**
* Create a session on the host.
* @param opts - creation options (project directory).
@@ -122,18 +169,50 @@ export class SessionsService {
}
/**
* Resolve the stable session binding (SessionProvider's resolveBinding feed).
* Resolve the stable session binding (scope-addressed assembly feed). Pure
* resolution — no staging, no window side effects.
* @param id - session id.
* @returns binding, or undefined for a session neither listed nor already scoped.
*/
binding(id: SessionId): SessionBinding | undefined {
const record = this.resolve(id)
if (record === undefined) return undefined
if (this.watched !== id) {
this.watched = id
this.sweepDeferred()
return this.resolve(id)?.binding
}
/**
* Resolve the render-layer session cell (SessionProvider's feed through
* the renderer host; ctx never enters the render layer). Pure resolution —
* render-safe: SessionProvider calls this during render, so no staging, no
* window side effects (StrictMode double-invokes and concurrent discarded
* passes must stay free).
* @param id - session id.
* @returns cell, or undefined for a session neither listed nor already scoped.
*/
cell(id: string): SessionCell | undefined {
return this.resolve(id as SessionId)?.cell
}
/**
* Move the stage to the list's current session: sweep teardowns deferred
* behind the previous occupant and pull the new occupant's history window.
* Staging IS the open signal — the window opens ⟺ the session is on stage
* — and open() is idempotent (an in-flight or completed open no-ops; a
* failed one retries the next time current is touched).
*/
private followCurrent(): void {
const current = this.list.getSnapshot().current
// A masked gap (current blanked while the selection's session is
// transiently absent) holds the stage: tearing down on the gap would
// destroy exactly the frozen scope the mask exists to preserve.
if (current === undefined || current === this.watched) return
this.watched = current
this.sweepDeferred()
const record = this.resolve(current)
/* v8 ignore next 3 -- defensive: current is always a listed id (open()
* validates and the projection masks absent selections), so resolve
* cannot miss; kept so a future current writer cannot crash the notify. */
if (record !== undefined) {
void record.binding.session.open()
}
return record.binding
}
/**
@@ -162,10 +241,14 @@ export class SessionsService {
if (this.list.getSnapshot().byId[id] === undefined) return undefined
const fiber = this.rootCtx.plugin(sessionScope)
const ctx = fiber.ctx.extend({ [kScope]: id })
const session = this.manager.get(id)
const record: ScopeRecord = {
fiber,
ctx,
binding: { sessionId: id, session: this.manager.get(id), ctx },
binding: { sessionId: id, session, ctx },
// Bare source form (store migration): the Session object IS the
// observable; the React side binds the useSession hook per cell.
cell: { sessionId: id, session },
}
this.scopes.set(id, record)
return record
@@ -188,11 +271,15 @@ export class SessionsService {
...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}),
}
}
this.list.set({ ids, byId })
// current = the persisted selection, masked while its session is absent
// (falls to the empty state; resurfaces if the session returns).
const selected = this.selection.getSnapshot().sessionId
const current = selected !== undefined && byId[selected] !== undefined ? selected : undefined
this.list.set({ ids, byId, current })
this.pruneScopes(byId)
}
/** Tear down scopes for removed sessions nobody watches; the watched one defers until the watch moves. */
/** Tear down scopes for removed sessions off stage; the staged one defers until the stage moves. */
private pruneScopes(byId: Record<SessionId, SessionSummary>): void {
for (const [id, record] of this.scopes) {
if (byId[id] !== undefined) continue
@@ -202,15 +289,23 @@ export class SessionsService {
}
this.scopes.delete(id)
this.deferredRemovals.delete(id)
void record.fiber.dispose()
this.dropScope(id, record)
}
}
/** Run deferred teardowns whose session is no longer watched (called when the watch moves). */
/** Dispose a scope fiber and its session-keyed slot-store instances together (single lifecycle axis). */
private dropScope(id: SessionId, record: ScopeRecord): void {
void record.fiber.dispose()
// Optional lookup: slots and sessions are sibling services with no
// declared dependency; a slots-less boot (object-layer tests) skips.
this.rootCtx.get('slots')?.pruneStoreScope(id)
}
/** Run deferred teardowns whose session is no longer staged (called when the stage moves). */
private sweepDeferred(): void {
for (const id of [...this.deferredRemovals]) {
/* v8 ignore next -- defensive: only the watched id ever defers, and every
* watch move sweeps first, so the set cannot contain the id the watch just
/* v8 ignore next -- defensive: only the staged id ever defers, and every
* stage move sweeps first, so the set cannot contain the id the stage just
* moved to; kept as a guard against future extra sweep call sites. */
if (id === this.watched) continue
// Still absent from the list? (A re-added id cancels the deferred teardown.)
@@ -225,7 +320,7 @@ export class SessionsService {
* future teardown path cannot double-dispose. */
if (record !== undefined) {
this.scopes.delete(id)
void record.fiber.dispose()
this.dropScope(id, record)
}
}
}

View File

@@ -7,8 +7,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type { HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult, SessionId, ToolEventView } from '@deepseek-ai/dsh-client-connection/client'
import { transportError } from '@deepseek-ai/dsh-client-connection/client'
import type { ObservableSnapshot, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { ObservableSnapshot } from '../contract/store.ts'
import type {
ConversationNode, ConversationSnapshot, OpenState, PendingInteraction, PromptError, RunningToolCall,
} from './conversation.ts'
@@ -19,11 +18,13 @@ import { PartialAccumulator } from './partial.ts'
/** Messages per page (F.4 ledger: promote to Config at graduation; every call site references this constant). */
export const PAGE_MESSAGES = 50
/** Per-session state owner: event window + fold + partial, snapshot out via uSES (see the web client architecture RFC). */
/**
* Per-session state owner: event window + fold + partial, snapshot out via
* subscribe/getSnapshot (see the web client architecture RFC). Bare source
* only (store migration): the React machinery binds the per-cell useSession
* hook at its own seam — no selector hook member lives on the data layer.
*/
export class Session implements ObservableSnapshot<ConversationSnapshot> {
/** Typed selector hook bound to this instance (the SessionBinding `useSession` source). */
readonly useSelector: SnapshotSelectorHook<ConversationSnapshot> = bindSnapshotSelector(this)
// ---- Window and derived state (all private; the snapshot is the only read surface) ----
private events: SessionEvent[] = []
/** Wire views aligned with `events` by index (envelope-level annotations; undefined = no view).

View File

@@ -1,22 +1,84 @@
/**
* SlotsService: cordis Service wrapper over the pure SlotCore (ui-slots).
* Every mutation re-emits as the 'slots/changed' cordis event; define/register
* run through the caller's ctx.effect so a plugin's registrations are
* collected when its fiber unloads (cordis-native cascade).
* SlotsService: the cordis Service layer of the slot system over the pure
* SlotCore (ui-slots owns registration semantics, the declaration ledger,
* the load-time validations, and the unload cascade). This layer owns what
* needs the runtime: the 'slots/changed' event bridge, register through the
* caller's ctx.effect (fiber unload collects registrations), the renderer
* install seam (install()/renderSlot('root') + the SlotRendererHost face),
* and the store INSTANCE axis — handle x scope key -> create/cache, dropped
* with the last holding entry, session instances cleared (with persisted
* state) on scope death.
*/
/* eslint-disable @typescript-eslint/no-redundant-type-constituents --
* `keyof SlotMap & string` is the declare-merge key pattern: SlotMap is empty
* in this compilation unit (intersection reads `never`) but consumers merge
* keys in; the rule fires on the empty-map view, not on real redundancy. */
* `keyof SlotMap & string` is the declare-merge key pattern: SlotMap only
* holds this package's 'root' row in this compilation unit, but consumers
* merge keys in; the rule fires on the narrow-map view, not on real
* redundancy. */
import { Service } from 'cordis'
import type { Context } from 'cordis'
import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
import type { ComposedProps, RegisterArgs, SlotComponent, SlotEntry, SlotEntryDef, SlotMap, SlotSpec } from '@deepseek-ai/dsh-client-ui-slots'
import type { ClientContext } from './index.ts'
import type {
OwnerOf, SlotEntryDef, SlotMap, SlotRenderer, SlotRendererHost,
SlotScope, SlotSpec, StoreDecl, StoredEntry, StoreInstanceLike,
} from '@deepseek-ai/dsh-client-ui-slots'
/** cordis Service wrapper over the pure SlotCore; mutations re-emit as 'slots/changed'. */
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
/** The built-in render-tree root hole (seeded by SlotCore): rendered only by the shell, occupied by a layout entry. */
'root': { kind: 'single'; scope: 'root'; owner: RootOwnerProps }
}
}
/** Root owner share: the shell supplies nothing — the frame is inject-assembled. */
export interface RootOwnerProps { children?: never }
/** Instance key for root-scoped store records (session records key by session id, so the literal cannot collide). */
const ROOT_INSTANCE_KEY = 'root'
// FIXME(slot-parity): the engine's arbitrated persist extensions — create()
// takes the scope key (per-session localStorage suffix) and instances expose
// clearPersisted() — are not yet on ui-slots' StoreHandle/StoreInstanceLike;
// these local structural faces bridge until fw-slots lifts them.
/** Store handle face as the engine actually ships it (scope-key-aware create). */
interface EngineStoreHandle { create(scopeKey?: string): EngineStoreInstance }
/** Engine instance face: the host-contract shape plus persisted-state cleanup. */
interface EngineStoreInstance extends StoreInstanceLike { clearPersisted(): void }
/** Store axis record: one per live handle, dropped when the last holding entry unloads. */
interface StoreAxisRecord {
/** Scope of the slot the handle mounted under (the core validated cross-scope conflicts). */
scope: SlotScope
/** Live registrations holding the handle. */
refs: number
/** Root scope: the single instance under {@link ROOT_INSTANCE_KEY}; session scope: one per session id. */
instances: Map<string, EngineStoreInstance>
}
/** Type-erased options view the implementation works with (the typed overloads proved the shares). */
interface ErasedRegisterOptions {
name: string
children?: Record<string, SlotSpec<SlotEntryDef>>
store?: StoreDecl
inject?: (...args: never[]) => Record<string, unknown>
key?: string
id?: string
order?: number
label?: string
registrant?: string
}
/** Erased core call face (the service re-erases at its own boundary; the core's typed face targets end callers). */
interface ErasedCore { register(options: object, component: unknown): () => void }
/** cordis Service layer of the slot system; see the module doc for the split with SlotCore. */
export class SlotsService extends Service {
private readonly _core = new SlotCore()
/** Store-instance axis: handle -> mounted scope, refcount, resolved instances. */
private readonly _stores = new Map<EngineStoreHandle, StoreAxisRecord>()
private _renderer: SlotRenderer | undefined
private _host: SlotRendererHost | undefined
/**
* @param ctx - owning root context.
@@ -27,44 +89,92 @@ export class SlotsService extends Service {
}
/**
* Record a slot spec (delegates to SlotCore.define; disposal follows the caller's fiber).
* @param key - SlotMap key.
* @param spec - kind/scope spec.
* @returns disposer.
* The single registration API. The typed face IS the core's register
* (both overloads reused verbatim — one authority, no structural copy;
* see SlotCore.register for children declaration, store seat, inject
* face, load-time validation, and the unload cascade). This layer adds:
* disposal through the caller's ctx.effect (fiber unload = cascade),
* exclusive-factory minting (`store: createXxxStore` becomes a per-entry
* handle), the registrant diagnostics stamp, and store-instance lifecycle
* on the entry axis.
*
* Declared here, implemented by prototype assignment below the class: it
* MUST stay a prototype method (never an instance arrow) — the cordis
* service proxy binds `this.ctx` to the CALLER's context at call time,
* which is what routes the effect (and the unload cascade) into the
* caller's fiber. An arrow property would freeze `this` to the service's
* own root ctx and silently break per-plugin disposal.
*/
define<K extends keyof SlotMap & string>(key: K, spec: SlotSpec<SlotMap[K]>): () => void {
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return this.ctx.effect(() => this._core.define(key, spec), 'slots.define()')
declare readonly register: SlotCore['register']
/**
* Install the shell's renderer (web-react's createSlotRenderer product).
* Boot-once: a second install throws. Runs through the caller's ctx.effect,
* so shell fiber unload uninstalls the renderer.
* @param renderer - the outlet machinery implementing SlotRenderer.
*/
install(renderer: SlotRenderer): void {
if (this._renderer !== undefined) throw new Error('slot renderer already installed (install() is boot-once)')
this.ctx.effect(() => {
this._renderer = renderer
return () => {
if (this._renderer === renderer) this._renderer = undefined
}
}, 'slots.install()')
}
/**
* Contribute a component (delegates to SlotCore.register; disposal follows the caller's fiber).
* @param key - SlotMap key.
* @param component - contributed component.
* @param args - kind-shaped options (mandatory for keyed/list kinds); the
* inject factory's binding is pinned to ClientContext.
* @returns disposer.
* The single ctx-level render entry: the shell renders 'root'; every other
* key renders inside components through the props renderSlot face. All
* three guards are fail-loud boot-order checks, no fallback.
* @param key - must be 'root' (runtime-enforced for dynamically composed callers).
* @param owner - owner share for the root entry (the shell supplies {}).
* @returns the rendered root tree.
*/
register<K extends keyof SlotMap & string, I extends object = Record<string, unknown>>(
// Client-context registrations have exactly one ctx shape: pin Ctx to
// ClientContext so inject factories dot services without a cast.
key: K, component: SlotComponent<ComposedProps<K, NoInfer<I>>>,
...args: RegisterArgs<SlotMap[K], I, ClientContext>): () => void {
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return this.ctx.effect(() => this._core.register<K, I, ClientContext>(key, component, ...args), 'slots.register()')
renderSlot<K extends keyof SlotMap & string>(key: K, owner: OwnerOf<K>): ReturnType<SlotRenderer['renderRoot']> {
// Widened: in this package's own program SlotMap holds only 'root', which
// would fold the guard to constant-false; the check exists for plain-JS
// and cross-program callers where K is wider.
if ((key as string) !== 'root') {
throw new Error(`ctx-level renderSlot only renders 'root' (got "${key}"); child slots render through the component props face`)
}
if (this._renderer === undefined) {
throw new Error("slot renderer not installed — boot must call ctx.slots.install(createSlotRenderer()) before rendering 'root'")
}
if (this._core.entries('root').length === 0) {
throw new Error("'root' has no registration — a layout entry must register into 'root' before the shell renders it")
}
return this._renderer.renderRoot(this.hostFace(), owner)
}
/**
* Snapshot entries for a key.
* @param key - SlotMap key.
* @returns registered entries (stable reference between mutations).
* Drop the per-session store instances of a dead session (the sessions
* service calls this on scope teardown; root-scoped records are untouched).
* Persisted state goes with the session — a never-rendered dead session can
* still own keys from an earlier page load, so the instance is materialized
* transiently just to clear storage (no-op for unpersisted stores).
* @param sessionId - the torn-down session.
*/
entries<K extends keyof SlotMap & string>(key: K): readonly SlotEntry<SlotMap[K]>[] {
pruneStoreScope(sessionId: string): void {
for (const [handle, record] of this._stores) {
if (record.scope !== 'session') continue
const instance = record.instances.get(sessionId) ?? handle.create(sessionId)
instance.clearPersisted()
record.instances.delete(sessionId)
}
}
/**
* Snapshot entries for a key (render-erased view; stable reference between mutations).
* @param key - SlotMap key.
* @returns registered entries.
*/
entries(key: keyof SlotMap & string): readonly StoredEntry[] {
return this._core.entries(key)
}
/**
* Look up a defined spec.
* Look up a declared spec (register-declared or the built-in 'root').
* @param key - SlotMap key.
* @returns spec or undefined.
*/
@@ -72,15 +182,6 @@ export class SlotsService extends Service {
return this._core.spec(key)
}
/**
* Dynamic-key escape hatch for spec lookup (renderer-side string keys).
* @param key - candidate slot key.
* @returns wide-typed spec or undefined.
*/
specDynamic(key: string): SlotSpec<SlotEntryDef> | undefined {
return this._core.specDynamic(key)
}
/**
* Subscribe to a key's registration changes (microtask-batched).
* @param key - SlotMap key.
@@ -100,8 +201,114 @@ export class SlotsService extends Service {
return this._core.getVersion(key)
}
/** The wrapped pure core (web-react's scopedSlots outlet reads through this). */
get core(): SlotCore {
return this._core
/** Delegating registration path: factory minting + registrant stamp + core write + instance-axis bookkeeping. */
private _register(options: ErasedRegisterOptions, component: unknown): () => void {
// Exclusive stores pass the factory itself: minted here into a per-entry
// handle so the stored entry always carries a resolvable handle (the
// core's shared-handle scope pinning applies to it harmlessly).
const store = typeof options.store === 'function' ? options.store() : options.store
const registrant = options.registrant ?? (this.ctx.fiber as { name?: string } | undefined)?.name
const erased: ErasedRegisterOptions = {
...options,
...(store !== undefined ? { store } : {}),
...(registrant !== undefined ? { registrant } : {}),
}
// Core write first: all load-time validation (undeclared target,
// duplicate declaration, kind conflicts, cross-scope handle) throws
// there before this layer commits anything.
const dispose = (this._core as unknown as ErasedCore).register(erased, component)
if (store !== undefined) {
// Register succeeded, so the target's spec is on the ledger.
const scope = (this._core.specDynamic(options.name) as SlotSpec<never>).scope
this._acquire(store, scope)
}
let disposed = false
return () => {
if (disposed) return
disposed = true
dispose()
if (store !== undefined) this._release(store)
}
}
/** Build (once) the host face the installed renderer reads; sessions resolve lazily at first render. */
private hostFace(): SlotRendererHost {
if (this._host !== undefined) return this._host
const sessions = this.ctx.get('sessions')
if (sessions === undefined) {
throw new Error("renderSlot('root') before the sessions service mounted — boot order puts runtime apply first")
}
// Identity-stable view: current rides the list snapshot (arbitrated), but
// the provider consumes it as its own observable; one cached object keeps
// the renderer's per-source hook cache stable.
const current = {
getSnapshot: () => sessions.list.getSnapshot().current as string | undefined,
subscribe: (fn: () => void) => sessions.list.subscribe(fn),
}
this._host = {
subscribe: (key, fn) => this._core.subscribe(key, fn),
getVersion: key => this._core.getVersion(key),
entriesOf: key => this._core.entries(key),
specOf: key => this._core.specDynamic(key),
isLive: entry => this._core.isLive(entry),
storeOf: (entry, scopeKey) =>
entry.store === undefined ? undefined : this.resolveStore(entry.store as unknown as EngineStoreHandle, scopeKey),
sessions: {
list: sessions.list,
current,
cell: id => sessions.cell(id),
},
}
return this._host
}
/** Resolve (create or reuse) the store instance for a registered handle under a scope key. */
private resolveStore(handle: EngineStoreHandle, sessionId: string | undefined): StoreInstanceLike {
const record = this._stores.get(handle)
if (record === undefined) throw new Error('store handle is not registered (entry unloaded, or the handle never went through register)')
const key = record.scope === 'session' ? sessionId : ROOT_INSTANCE_KEY
if (key === undefined) throw new Error('session-scoped store resolution requires a session id')
let instance = record.instances.get(key)
if (instance === undefined) {
// Session instances get the scope key (the engine suffixes the persist
// key per session); root instances stay keyless.
instance = record.scope === 'session' ? handle.create(key) : handle.create()
record.instances.set(key, instance)
}
return instance
}
/** Bind (or re-reference) a handle on the axis; cross-scope conflicts already threw in the core. */
private _acquire(handle: EngineStoreHandle, scope: SlotScope): void {
const record = this._stores.get(handle)
if (record === undefined) {
this._stores.set(handle, { scope, refs: 1, instances: new Map() })
return
}
record.refs += 1
}
/** Drop one reference; the last holder's unload drops the record (instances go with it — engine stores need no explicit dispose). */
private _release(handle: EngineStoreHandle): void {
const record = this._stores.get(handle)
/* v8 ignore next -- defensive: release only runs from a disposer whose
* register acquired the same handle, so the record must exist; kept so a
* future call site cannot underflow the axis. */
if (record === undefined) return
record.refs -= 1
if (record.refs === 0) this._stores.delete(handle)
}
}
// register's implementation (prototype assignment pairs with the `declare`
// inside the class — see its JSDoc for why it must live on the prototype).
// Element access reaches the private _register legally and keeps it a
// TS-visible read.
;(SlotsService.prototype as { register: (options: object, component: unknown) => () => void }).register
= function register(this: SlotsService, rawOptions: object, component: unknown): () => void {
// The core's overloads proved the shares; the implementation works on
// the erased view (same pattern as the core's own implementation arm).
const options = rawOptions as ErasedRegisterOptions
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return this.ctx.effect(() => this['_register'](options, component), 'slots.register()')
}

5
packages/client/runtime/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/**
* Bundler-replaced NODE_ENV: vite/tsdown substitute the literal, so browsers
* never evaluate a bare `process`. tsconfig carries no node types on purpose.
*/
declare const process: { env: { NODE_ENV?: string } }