Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input
# Conflicts: # apps/cli/README.md # apps/cli/src/web.ts # apps/web/tests/smoke-fixture.e2e.ts # docs/architecture.i18n.yaml # docs/module-graph.md # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl # packages/client/connection/src/client/api.ts # packages/client/connection/src/client/fixture.ts # packages/client/runtime/README.md # packages/client/runtime/src/client/index.ts # packages/client/runtime/src/client/sessions/conversation.ts # packages/client/runtime/src/client/sessions/service.ts # packages/client/runtime/src/client/sessions/session.ts # packages/client/ui-conversation/src/client/apply.ts # packages/client/ui-conversation/src/client/contract/slots.ts # packages/client/ui-conversation/src/client/index.ts # packages/client/ui-conversation/src/client/service.ts # packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx # packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx # packages/client/ui-conversation/src/client/skeleton/InputBar.tsx # packages/client/ui-conversation/src/client/stores.ts # packages/client/ui-conversation/tests/apply-inject.spec.tsx # packages/client/ui-conversation/tests/service-orchestration.spec.ts # packages/client/ui-conversation/tests/skeleton-branches.spec.tsx # packages/client/ui-conversation/tests/skeleton.spec.tsx # packages/cordis/tool-cordis/src/api-catalog.ts # packages/host/apiproxy/package.json # packages/host/apiproxy/src/api-proxy.ts # packages/host/apiproxy/src/api/sessions.schema.ts # packages/host/runtime/package.json # packages/host/runtime/src/boot.ts # packages/host/runtime/tests/host-runtime.spec.ts # packages/host/runtime/tsconfig.json # packages/host/webserver/README.md # packages/host/webserver/src/index.ts # packages/host/webserver/tests/webserver.spec.ts # packages/llm/llm-pi-ai/tests/convert.spec.ts # packages/ui/acp/src/codec.ts # packages/ui/acp/tests/codec.spec.ts # pnpm-lock.yaml
This commit is contained in:
1264 files changed
+37694
-29428
No files matched your search
@@ -161,11 +161,7 @@ function deepFreeze(value: unknown): void {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 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.
|
||||
// ui-slots owns the contract; this module supplies the engine implementation.
|
||||
|
||||
/** A live engine instance: the contract instance plus the raw engine store. */
|
||||
export interface EngineStoreInstance<T, A extends ActionsDecl<T>> extends StoreInstance<T, A> {
|
||||
|
||||
@@ -1,55 +1,38 @@
|
||||
/**
|
||||
* Browser half: the whole runtime contract surface (api-contracts v3 §4) —
|
||||
* SlotsService (declaration ledger + renderer seam + store axis, built-in
|
||||
* 'root'), SessionsService (list store + current selection + scope tree +
|
||||
* object layer), and the cordis Context/Events merges. apply mounts
|
||||
* ctx.slots + ctx.sessions and wires the connection stream loop into the
|
||||
* object layer. A static-arrival entry: the web shell bundles this module
|
||||
* and mounts it through the host graph (module loading lives in
|
||||
* @deepseek-ai/dsh-client-modules, entry governance in the vendored Loader).
|
||||
*/
|
||||
/** Browser runtime services for slots, sessions, workspaces, and connection-stream delivery. */
|
||||
import type { Context } from 'cordis'
|
||||
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotsService } from './slots.ts'
|
||||
import { SessionsService } from './sessions/service.ts'
|
||||
import type { SessionListState } from './sessions/service.ts'
|
||||
import { WorkspacesService } from './workspaces/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 { SessionCreateError, SessionsService, scopeOf, workspaceTitleOf } from './sessions/service.ts'
|
||||
export { WorkspacesService } from './workspaces/service.ts'
|
||||
export type { Session } from './sessions/session.ts'
|
||||
export type { SessionBinding, SessionListState, SessionSummary } from './sessions/service.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 type { SessionIntentListSnapshot, SessionListPhase } from './sessions/manager.ts'
|
||||
export type { WorkspaceListPhase } from './workspaces/manager.ts'
|
||||
export type { WorkspaceListState } from './workspaces/service.ts'
|
||||
export type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
// Runtime owns the snapshot store; web-react only binds it to React.
|
||||
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,
|
||||
RunningToolCall, SteeringMessageNode,
|
||||
ToolResultNode, UnknownSurfaceNode, UserMessageNode,
|
||||
AssistantBlock, AssistantMessageNode, ComposerPhase, ContextMessageNode, ConversationNode,
|
||||
ConversationSnapshot, PendingPrompt, RunningToolCall, SessionIntentSnapshot, SessionIntentTarget,
|
||||
SteeringMessageNode, ToolResultNode, UnknownSurfaceNode, UserMessageNode,
|
||||
} from './sessions/conversation.ts'
|
||||
// PendingWait is a value export: tests construct fixture waits directly.
|
||||
export { PendingWait } from './sessions/pending.ts'
|
||||
export type { PendingInteraction, PendingKind, PendingPayloads } from './sessions/pending.ts'
|
||||
export type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
|
||||
// ---- Narrowed aliases (the single narrowing point of the slot type chain:
|
||||
// ui-slots/web-react stay generic and dependency-inverted; the client-tree
|
||||
// concrete types live here, where their subjects live) ----
|
||||
|
||||
/**
|
||||
* The client cordis context face: the base Context plus the service keys
|
||||
* this package's declaration merge contributes (slots/sessions/loader) and
|
||||
* every later plugin's merge. A plain alias — the merges land on Context
|
||||
* itself inside the client program; the name marks intent at consumer seams.
|
||||
*/
|
||||
/** Client-side Cordis context after declaration merging. */
|
||||
export type ClientContext = Context
|
||||
|
||||
/** The conversation-snapshot selector hook (ConvViewProps/ToolRowProps take this). */
|
||||
@@ -69,15 +52,15 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
* 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. */
|
||||
/** Props injected into every global slot component. */
|
||||
interface GlobalStandardProps {
|
||||
/** Selector hook over the session list snapshot (`current` included — the arbitrated selection seat). */
|
||||
useSessions: SnapshotSelectorHook<SessionListState>
|
||||
/** Selector hook over real Workspaces and their independent baseline lifecycle. */
|
||||
useWorkspaces: SnapshotSelectorHook<import('./workspaces/service.ts').WorkspaceListState>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,25 +76,32 @@ declare module 'cordis' {
|
||||
interface Context {
|
||||
slots: import('./slots.ts').SlotsService
|
||||
sessions: import('./sessions/service.ts').SessionsService
|
||||
workspaces: import('./workspaces/service.ts').WorkspacesService
|
||||
}
|
||||
}
|
||||
|
||||
/** Required services: the wire handle mounted by the connection plugin. */
|
||||
export const inject = ['connection']
|
||||
|
||||
/**
|
||||
* Client plugin body: mount slots + sessions, start the stream loop.
|
||||
* @param ctx - client cordis context.
|
||||
/** Mounts the browser runtime services and connection stream.
|
||||
* @param ctx - Client Cordis context.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.plugin(SlotsService)
|
||||
const connection = ctx.get('connection') as ConnectionHandle
|
||||
const sessions = new SessionsService(ctx, connection.api)
|
||||
const workspaces = new WorkspacesService(ctx, connection.api, sessions)
|
||||
const loop = connection.start({
|
||||
onMuxEnvelope: (envelope) => { sessions.manager.handleMuxEnvelope(envelope) },
|
||||
onHostEnvelope: (envelope) => { sessions.manager.handleHostEnvelope(envelope) },
|
||||
onMuxEnvelope: (envelope) => { sessions.handleMuxEnvelope(envelope) },
|
||||
onHostEnvelope: (envelope) => {
|
||||
sessions.handleHostEnvelope(envelope)
|
||||
workspaces.handleHostEnvelope(envelope)
|
||||
},
|
||||
onDescription: (description) => { sessions.handleDescription(description) },
|
||||
onConnected: () => { sessions.manager.handleConnected() },
|
||||
onConnected: () => {
|
||||
sessions.handleConnected()
|
||||
workspaces.handleConnected()
|
||||
},
|
||||
})
|
||||
ctx.effect(() => () => { loop.stop() }, 'runtime: connection stream loop')
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Merge an authoritative baseline without moving identities already visible to
|
||||
* the client. Baseline-only identities are inserted relative to the nearest
|
||||
* following known identity; identities absent from the baseline are removed.
|
||||
*
|
||||
* @param current - the established client order.
|
||||
* @param baseline - the latest authoritative rows.
|
||||
* @param keyOf - stable identity selector.
|
||||
* @returns baseline-valued rows with the established relative order retained.
|
||||
*/
|
||||
export function mergeOrderedBaseline<T>(
|
||||
current: readonly T[],
|
||||
baseline: readonly T[],
|
||||
keyOf: (value: T) => unknown,
|
||||
): T[] {
|
||||
const baselineByKey = new Map<unknown, T>()
|
||||
for (const value of baseline) baselineByKey.set(keyOf(value), value)
|
||||
|
||||
const merged = current
|
||||
.map(value => baselineByKey.get(keyOf(value)))
|
||||
.filter((value): value is T => value !== undefined)
|
||||
const mergedKeys = new Set(merged.map(keyOf))
|
||||
|
||||
for (let index = 0; index < baseline.length; index++) {
|
||||
const value = baseline[index]
|
||||
/* v8 ignore next -- dense-array guard: index is bounded by baseline.length. */
|
||||
if (value === undefined || mergedKeys.has(keyOf(value))) continue
|
||||
let insertion = merged.length
|
||||
for (let following = index + 1; following < baseline.length; following++) {
|
||||
const candidate = baseline[following]
|
||||
/* v8 ignore next -- dense-array guard: following is bounded by baseline.length. */
|
||||
if (candidate === undefined) continue
|
||||
const known = merged.findIndex(item => keyOf(item) === keyOf(candidate))
|
||||
if (known !== -1) {
|
||||
insertion = known
|
||||
break
|
||||
}
|
||||
}
|
||||
merged.splice(insertion, 0, value)
|
||||
mergedKeys.add(keyOf(value))
|
||||
}
|
||||
return merged
|
||||
}
|
||||
@@ -5,7 +5,9 @@
|
||||
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import type { RpcError, SessionId, ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
RpcError, SessionId, ToolCallView, ToolResultView, WorkspaceId,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { PendingInteraction } from './pending.ts'
|
||||
|
||||
/** Assistant content blocks sorted by what the UI cares about
|
||||
@@ -156,12 +158,58 @@ export interface PartialAssistant {
|
||||
/** History-open lifecycle of a Session window. */
|
||||
export type OpenState = 'cold' | 'loading' | 'open' | 'error'
|
||||
|
||||
/**
|
||||
* Input-area shape of an OPEN session, derived at snapshot assembly (the one
|
||||
* place that knows the predicate — consumers switch, never re-derive):
|
||||
*
|
||||
* - `blank`: no activity ever (no nodes, no partial, not running, no pending
|
||||
* waits, no prompt attempt) — the UI renders the blank-session guidance
|
||||
* hero.
|
||||
* - `engaging`: the first prompt was initiated but no content landed yet —
|
||||
* the UI holds the composer through the accept → running → first-event
|
||||
* frames. Entered synchronously before prompt()'s first await.
|
||||
* - `active`: content exists (nodes, partial, running turn, or pending
|
||||
* waits) — the ordinary conversation view.
|
||||
*
|
||||
* Monotone within a session object: blank → engaging → active, no returns.
|
||||
* A failed first prompt stays `engaging` (composer + error strip — retry
|
||||
* semantics; bouncing back to the hero would discard the error context).
|
||||
* Sessions whose window is not open (`loading`/`error`) are outside phase
|
||||
* jurisdiction: consumers branch on {@link ConversationSnapshot.openState}
|
||||
* first (phase still reports `active`-ish facts but must not be rendered).
|
||||
*/
|
||||
export type ComposerPhase = 'blank' | 'engaging' | 'active'
|
||||
|
||||
/** Send/stop failure surfaced in the input error strip; op picks the user-facing copy (发送失败 vs 停止失败). */
|
||||
export interface PromptError {
|
||||
op: 'send' | 'stop'
|
||||
error: RpcError
|
||||
}
|
||||
|
||||
/** Workspace target of a frontend-only Session. */
|
||||
export type SessionIntentTarget =
|
||||
| { kind: 'workspace'; workspaceId: WorkspaceId }
|
||||
| { kind: 'workspace-intent' }
|
||||
|
||||
/** Publication state owned by a frontend Session before it joins the Host. */
|
||||
export interface SessionIntentSnapshot {
|
||||
target: SessionIntentTarget
|
||||
phase: 'ready' | 'connecting'
|
||||
error?: { step: 'session'; message: string }
|
||||
}
|
||||
|
||||
/** One editable prompt retained by its Session until the Host accepts it. */
|
||||
export interface PendingPrompt {
|
||||
text: string
|
||||
phase: 'editing' | 'sending' | 'failed'
|
||||
/** Failed prerequisite retried before sending, or the send itself. */
|
||||
retry: 'connect' | 'send'
|
||||
/** Workspace needed when retrying Session attachment. */
|
||||
workspaceId?: WorkspaceId
|
||||
/** Last failure diagnostic, absent while editing or sending. */
|
||||
error?: string
|
||||
}
|
||||
|
||||
/** The immutable snapshot contract Session hands to uSES (see the web client architecture RFC). */
|
||||
export interface ConversationSnapshot {
|
||||
sessionId: SessionId
|
||||
@@ -173,6 +221,8 @@ export interface ConversationSnapshot {
|
||||
runningCalls: readonly RunningToolCall[]
|
||||
pending: readonly PendingInteraction[]
|
||||
running: boolean
|
||||
/** Input-area shape (see {@link ComposerPhase}); derived here, switched on by consumers. */
|
||||
composerPhase: ComposerPhase
|
||||
/** Set after host/session-removed; the UI grays out and disables input. */
|
||||
removed: boolean
|
||||
openState: OpenState
|
||||
@@ -180,5 +230,9 @@ export interface ConversationSnapshot {
|
||||
hasMore: boolean
|
||||
loadingOlder: boolean
|
||||
promptError: PromptError | null
|
||||
/** Frontend-only publication state; null for a Host-connected Session. */
|
||||
intent: SessionIntentSnapshot | null
|
||||
/** Session-owned editable prompt waiting for connection, attachment, or send. */
|
||||
pendingPrompt: PendingPrompt | null
|
||||
lastAgentError: string | null
|
||||
}
|
||||
@@ -24,10 +24,10 @@ export interface CallIndexEntry {
|
||||
callView: ToolCallView | null
|
||||
}
|
||||
|
||||
/** Non-surface-eligible sentinel event (safely skipped by surfaceOpOf's undefined branch).
|
||||
* 'noop/padding' is not a real event type on purpose: a genuine type with fake data would
|
||||
* surface as garbage the day anyone adds handling for it (design §D.1; the cast is the one
|
||||
* place a synthetic event enters the window). */
|
||||
/** Non-surface sentinel used to preserve paged-window sequence offsets.
|
||||
* `noop/padding` is deliberately not a real event type, so it cannot acquire
|
||||
* surface behavior; this cast is the only synthetic event entry point.
|
||||
*/
|
||||
function paddingEvent(seq: number): SessionEvent {
|
||||
return { type: 'noop/padding', seq, time: 0, data: {} } as unknown as SessionEvent
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// flattenLineage: summaries -> flat list with lineage indentation (pure function).
|
||||
// Roots sort by updatedAt desc, DFS expansion with children in the same order; orphaned lineage
|
||||
// degrades to root level; cycles fail soft and emit as roots.
|
||||
// The input order is authoritative; lineage only makes each child adjacent to its parent.
|
||||
// Orphaned lineage degrades to root level; cycles fail soft and emit as roots.
|
||||
|
||||
import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client'
|
||||
|
||||
@@ -22,8 +22,9 @@ export interface SessionListEntry {
|
||||
}
|
||||
|
||||
/**
|
||||
* summaries -> flat list with lineage indentation (pure; roots by updatedAt
|
||||
* desc, DFS children in the same order, orphans degrade to roots).
|
||||
* Summaries -> flat list with lineage indentation. Root and sibling order
|
||||
* follows the established input order; this projection never re-sorts a
|
||||
* hydrated list from mutable timestamps.
|
||||
* @param summaries - the host's session.list items.
|
||||
* @returns display rows in render order.
|
||||
*/
|
||||
@@ -43,9 +44,6 @@ export function flattenLineage(summaries: readonly TitledSessionSummary[]): Sess
|
||||
}
|
||||
}
|
||||
|
||||
const byUpdatedDesc = (a: TitledSessionSummary, b: TitledSessionSummary): number => b.updatedAt - a.updatedAt
|
||||
roots.sort(byUpdatedDesc)
|
||||
|
||||
const out: SessionListEntry[] = []
|
||||
const visited = new Set<SessionId>()
|
||||
const walk = (s: TitledSessionSummary, depth: number): void => {
|
||||
@@ -57,7 +55,6 @@ export function flattenLineage(summaries: readonly TitledSessionSummary[]): Sess
|
||||
out.push({ ...s, depth })
|
||||
const kids = children.get(s.sessionId)
|
||||
if (kids === undefined) return
|
||||
kids.sort(byUpdatedDesc)
|
||||
for (const kid of kids) walk(kid, depth + 1)
|
||||
}
|
||||
for (const root of roots) walk(root, 0)
|
||||
|
||||
@@ -2,22 +2,51 @@
|
||||
// dispatch entry + list state, constructed and held by SessionsService (one per client runtime).
|
||||
// List data never enters zustand; React connects via subscribe/getListSnapshot.
|
||||
|
||||
import type { IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId, SessionSummary, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
// Value import from the inline-safe wire layer (not the connection plugin):
|
||||
// plugin-to-plugin value imports are a bundle purity error.
|
||||
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { mergeOrderedBaseline } from '../ordered-baseline.ts'
|
||||
import type { SessionListEntry, TitledSessionSummary } from './lineage.ts'
|
||||
import { flattenLineage } from './lineage.ts'
|
||||
import { Notifier } from './notifier.ts'
|
||||
import { Session } from './session.ts'
|
||||
import type { SessionIntentSnapshot, SessionIntentTarget } from './conversation.ts'
|
||||
|
||||
/**
|
||||
* List arrival lifecycle, orthogonal to the pull-activity `state` axis:
|
||||
* `pending` (no successful pull yet — an empty items array means "nothing
|
||||
* arrived", not "nothing exists") → `ready` (at least one pull landed).
|
||||
* Monotone: `ready` never steps back — later pull failures and reconnect
|
||||
* re-pulls ride the `state`/`error` axis, which is where failure is modeled
|
||||
* (no `error` phase here; that would duplicate `state`).
|
||||
*/
|
||||
export type SessionListPhase = 'pending' | 'ready'
|
||||
|
||||
/** Session-owned frontend Intent projected into the global list snapshot. */
|
||||
export interface SessionIntentListSnapshot extends SessionIntentSnapshot {
|
||||
sessionId: SessionId
|
||||
prompt: string
|
||||
}
|
||||
|
||||
/** Immutable session-list snapshot for useSessionList. */
|
||||
export interface SessionListSnapshot {
|
||||
items: readonly SessionListEntry[]
|
||||
/** Selected real or frontend-only Session id. */
|
||||
current: SessionId | undefined
|
||||
/** Sole page-local frontend Session projection; its state remains owned by Session. */
|
||||
intent: SessionIntentListSnapshot | undefined
|
||||
state: 'idle' | 'loading' | 'error'
|
||||
/** Arrival lifecycle (see {@link SessionListPhase}); `state` stays the pull-activity axis. */
|
||||
phase: SessionListPhase
|
||||
error: RpcError | null
|
||||
}
|
||||
|
||||
type SessionListMutation =
|
||||
| { kind: 'upsert'; summary: SessionSummary }
|
||||
| { kind: 'remove'; sessionId: SessionId }
|
||||
| { kind: 'status'; sessionId: SessionId; running: boolean }
|
||||
|
||||
/** Per-session cap for pre-instantiation approval/question buffering (low-frequency frames; a few dozen covers any real backlog). */
|
||||
const PENDING_BUFFER_CAP = 32
|
||||
|
||||
@@ -39,8 +68,16 @@ export class SessionManager {
|
||||
private readonly titleSnapshots = new Map<SessionId, SessionTitleSnapshot>()
|
||||
private summaries: SessionSummary[] = []
|
||||
private listState: 'idle' | 'loading' | 'error' = 'idle'
|
||||
/** Arrival phase; the pending → ready edge fires on the first successful pull (see SessionListPhase). */
|
||||
private listPhase: SessionListPhase = 'pending'
|
||||
private listError: RpcError | null = null
|
||||
private listInflight: Promise<void> | null = null
|
||||
/** Mutations arriving after a list request starts are replayed over its response. */
|
||||
private listMutations: SessionListMutation[] | null = null
|
||||
|
||||
private selected: SessionId | undefined
|
||||
private intentSessionId: SessionId | undefined
|
||||
private stopIntentWatch: (() => void) | undefined
|
||||
|
||||
private listSnapshotCache: SessionListSnapshot
|
||||
/** Entry-identity cache (§C.2 reference stability): list rebuilds reuse the previous entry
|
||||
@@ -52,10 +89,90 @@ export class SessionManager {
|
||||
this.listSnapshotCache = this.buildListSnapshot()
|
||||
})
|
||||
|
||||
constructor(private readonly api: IApiClient) {
|
||||
/**
|
||||
* @param api - shared wire client.
|
||||
* @param restoredSelection - persisted real-Session selection candidate.
|
||||
*/
|
||||
constructor(
|
||||
private readonly api: IApiClient,
|
||||
restoredSelection?: SessionId,
|
||||
) {
|
||||
this.selected = restoredSelection
|
||||
this.listSnapshotCache = this.buildListSnapshot()
|
||||
}
|
||||
|
||||
// ---- Selection and client-local intents ----
|
||||
|
||||
/**
|
||||
* Select a real Session and discard the unmaterialized intent.
|
||||
* @param sessionId - listed real Session id.
|
||||
*/
|
||||
select(sessionId: SessionId): void {
|
||||
if (!this.summaries.some(summary => summary.sessionId === sessionId)) {
|
||||
throw new Error(`sessions.select: unknown session ${sessionId}`)
|
||||
}
|
||||
this.discardIntent()
|
||||
this.selected = sessionId
|
||||
this.notifier.notifyNow()
|
||||
}
|
||||
|
||||
/** Clear selection and abandon any frontend-only Session. */
|
||||
clearSelection(): void {
|
||||
this.discardIntent()
|
||||
this.selected = undefined
|
||||
this.notifier.notifyNow()
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a frontend Session against a real or still-local Workspace target.
|
||||
* @param target - real Workspace or the WorkspacesService-owned local target.
|
||||
* @param prompt - optional prompt retained when retargeting from a picker.
|
||||
* @returns the frontend Session object that owns the Intent.
|
||||
*/
|
||||
startIntent(target: SessionIntentTarget, prompt = ''): Session {
|
||||
this.discardIntent()
|
||||
const sessionId = `client-session-${crypto.randomUUID()}` as SessionId
|
||||
const session = this.createSession(sessionId, { target, prompt })
|
||||
this.sessions.set(sessionId, session)
|
||||
this.intentSessionId = sessionId
|
||||
this.selected = sessionId
|
||||
this.stopIntentWatch = session.subscribe(() => {
|
||||
if (this.intentSessionId !== sessionId) return
|
||||
if (session.getSnapshot().intent === null) {
|
||||
this.intentSessionId = undefined
|
||||
this.stopIntentWatch?.()
|
||||
this.stopIntentWatch = undefined
|
||||
}
|
||||
this.notifier.markDirty()
|
||||
})
|
||||
this.notifier.notifyNow()
|
||||
return session
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the active frontend Session Intent.
|
||||
* @returns the active frontend Session, if one remains selected.
|
||||
*/
|
||||
getIntent(): Session | undefined {
|
||||
return this.intentSessionId === undefined ? undefined : this.sessions.get(this.intentSessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the retained prompt of the active frontend Session.
|
||||
* @param text - exact controlled-input value for the active frontend Session.
|
||||
*/
|
||||
updateIntent(text: string): void {
|
||||
this.getIntent()?.updatePendingPrompt(text)
|
||||
}
|
||||
|
||||
private discardIntent(): void {
|
||||
const session = this.getIntent()
|
||||
this.intentSessionId = undefined
|
||||
this.stopIntentWatch?.()
|
||||
this.stopIntentWatch = undefined
|
||||
session?.abandonIntent()
|
||||
}
|
||||
|
||||
// ---- Instance management ----
|
||||
|
||||
/**
|
||||
@@ -67,7 +184,7 @@ export class SessionManager {
|
||||
get(sessionId: SessionId): Session {
|
||||
let session = this.sessions.get(sessionId)
|
||||
if (session === undefined) {
|
||||
session = new Session(sessionId, this.api)
|
||||
session = this.createSession(sessionId)
|
||||
this.sessions.set(sessionId, session)
|
||||
// Sync the running bit from the list snapshot into the new instance (consistency when the list precedes open).
|
||||
const summary = this.summaries.find(s => s.sessionId === sessionId)
|
||||
@@ -82,6 +199,22 @@ export class SessionManager {
|
||||
return session
|
||||
}
|
||||
|
||||
private createSession(
|
||||
sessionId: SessionId,
|
||||
intent?: { target: SessionIntentTarget; prompt: string },
|
||||
): Session {
|
||||
return new Session(sessionId, this.api, {
|
||||
...(intent === undefined ? {} : { intent }),
|
||||
onPublished: (published) => {
|
||||
this.sessions.set(published.sessionId, published)
|
||||
this.recordMutation({
|
||||
kind: 'upsert',
|
||||
summary: { sessionId: published.sessionId, updatedAt: Date.now(), running: false },
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ---- List surface ----
|
||||
|
||||
/** Full refresh via session.list (single-flight: an in-flight call is reused). */
|
||||
@@ -89,13 +222,21 @@ export class SessionManager {
|
||||
if (this.listInflight !== null) return this.listInflight
|
||||
this.listState = 'loading'
|
||||
this.listError = null
|
||||
const established = this.summaries
|
||||
const mutations: SessionListMutation[] = []
|
||||
this.listMutations = mutations
|
||||
this.notifier.markDirty()
|
||||
this.listInflight = (async () => {
|
||||
try {
|
||||
const { result } = await this.api.sessions.list({})
|
||||
if (result.ok) {
|
||||
this.summaries = result.value.items
|
||||
let summaries = this.listPhase === 'pending'
|
||||
? result.value.items
|
||||
: mergeOrderedBaseline(established, result.value.items, summary => summary.sessionId)
|
||||
for (const mutation of mutations) summaries = applyMutation(summaries, mutation)
|
||||
this.summaries = summaries
|
||||
this.listState = 'idle'
|
||||
this.listPhase = 'ready'
|
||||
// Push running bits down to instantiated Sessions (the list is the authoritative summary source).
|
||||
for (const s of this.summaries) this.sessions.get(s.sessionId)?.handleRunning(s.running)
|
||||
} else {
|
||||
@@ -108,6 +249,7 @@ export class SessionManager {
|
||||
/* v8 ignore next -- the `? null` arm is unreachable: transportError always returns ok:false. */
|
||||
this.listError = folded.ok ? null : folded.error
|
||||
} finally {
|
||||
this.listMutations = null
|
||||
this.listInflight = null
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
@@ -118,18 +260,37 @@ export class SessionManager {
|
||||
/**
|
||||
* Contract session.create; on success merge into summaries immediately (no
|
||||
* wait for the next refresh).
|
||||
* @param cwd - optional working directory for the new session.
|
||||
* @param opts - target workspace or working directory, plus an optional caller-owned id.
|
||||
* @returns the create result.
|
||||
*/
|
||||
async create(cwd?: string): Promise<RpcResult<{ sessionId: SessionId }>> {
|
||||
async create(
|
||||
opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId } = {},
|
||||
): Promise<RpcResult<{ sessionId: SessionId }>> {
|
||||
try {
|
||||
const { result } = await this.api.sessions.create(cwd === undefined ? {} : { cwd })
|
||||
if (result.ok && !this.summaries.some(s => s.sessionId === result.value.sessionId)) {
|
||||
this.summaries = [
|
||||
{ sessionId: result.value.sessionId, updatedAt: Date.now(), running: false, ...(cwd !== undefined ? { cwd } : {}) },
|
||||
...this.summaries,
|
||||
]
|
||||
this.notifier.markDirty()
|
||||
const payload = opts.workspaceId !== undefined
|
||||
? { workspaceId: opts.workspaceId, ...(opts.sessionId === undefined ? {} : { sessionId: opts.sessionId }) }
|
||||
: {
|
||||
...(opts.cwd === undefined ? {} : { cwd: opts.cwd }),
|
||||
...(opts.sessionId === undefined ? {} : { sessionId: opts.sessionId }),
|
||||
}
|
||||
const { result } = await this.api.sessions.create(payload)
|
||||
if (result.ok) {
|
||||
this.recordMutation({ kind: 'upsert', summary: {
|
||||
sessionId: result.value.sessionId, updatedAt: Date.now(), running: false,
|
||||
...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
|
||||
} })
|
||||
} else {
|
||||
const publishedSessionId = workspaceAttachSessionId(result.error)
|
||||
// Publication precedes attachment. The error's id is a real Session,
|
||||
// so expose it immediately as Ungrouped while the caller keeps the
|
||||
// prompt buffer and decides whether to retry attachment.
|
||||
if (publishedSessionId !== undefined) {
|
||||
this.recordMutation({ kind: 'upsert', summary: {
|
||||
sessionId: publishedSessionId,
|
||||
updatedAt: Date.now(),
|
||||
running: false,
|
||||
} })
|
||||
}
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
@@ -137,6 +298,23 @@ export class SessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert-or-enrich a locally synthesized summary: a new id prepends; an
|
||||
* existing entry only gains fields it lacks (the session-added frame and the
|
||||
* create() echo race — whichever lands second must fill the placeholder's
|
||||
* missing cwd/parentSessionId, never overwrite list-refresh data).
|
||||
*/
|
||||
private mergeSummary(summary: SessionSummary): void {
|
||||
this.recordMutation({ kind: 'upsert', summary })
|
||||
}
|
||||
|
||||
/** Apply immediately and retain for replay when a list response is in flight. */
|
||||
private recordMutation(mutation: SessionListMutation): void {
|
||||
this.listMutations?.push(mutation)
|
||||
this.summaries = applyMutation(this.summaries, mutation)
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
// ---- Subscription surface (for useSessionList) ----
|
||||
|
||||
/**
|
||||
@@ -216,31 +394,24 @@ export class SessionManager {
|
||||
const frame = envelope.payload
|
||||
switch (frame.type) {
|
||||
case 'host/session-added': {
|
||||
if (!this.summaries.some(s => s.sessionId === frame.sessionId)) {
|
||||
this.summaries = [
|
||||
{
|
||||
sessionId: frame.sessionId, updatedAt: Date.now(), running: false,
|
||||
...(frame.parentSessionId !== undefined ? { parentSessionId: frame.parentSessionId } : {}),
|
||||
},
|
||||
...this.summaries,
|
||||
]
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
this.mergeSummary({
|
||||
sessionId: frame.sessionId, updatedAt: Date.now(), running: false,
|
||||
...(frame.parentSessionId !== undefined ? { parentSessionId: frame.parentSessionId } : {}),
|
||||
...(frame.cwd !== undefined ? { cwd: frame.cwd } : {}),
|
||||
})
|
||||
this.sessions.get(frame.sessionId)?.handlePublished()
|
||||
return
|
||||
}
|
||||
case 'host/session-removed': {
|
||||
this.summaries = this.summaries.filter(s => s.sessionId !== frame.sessionId)
|
||||
this.recordMutation({ kind: 'remove', sessionId: frame.sessionId })
|
||||
this.sessions.get(frame.sessionId)?.handleRemoved() // instance survives (resident-instance rule), only flagged in the snapshot
|
||||
this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation
|
||||
this.titleSnapshots.delete(frame.sessionId)
|
||||
this.notifier.markDirty()
|
||||
return
|
||||
}
|
||||
case 'host/session-status': {
|
||||
this.summaries = this.summaries.map(s =>
|
||||
s.sessionId === frame.sessionId && s.running !== frame.running ? { ...s, running: frame.running } : s)
|
||||
this.recordMutation({ kind: 'status', sessionId: frame.sessionId, running: frame.running })
|
||||
this.sessions.get(frame.sessionId)?.handleRunning(frame.running)
|
||||
this.notifier.markDirty()
|
||||
return
|
||||
}
|
||||
case 'host/agent-error': {
|
||||
@@ -252,7 +423,7 @@ export class SessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** After each connection generation (first connect included): refresh the list + resync opened instances (reconnect = rebuild). */
|
||||
/** After each connection generation: refresh the session baseline and rebuild opened windows. */
|
||||
handleConnected(): void {
|
||||
void this.refreshList()
|
||||
for (const session of this.sessions.values()) void session.resync()
|
||||
@@ -281,6 +452,57 @@ export class SessionManager {
|
||||
}
|
||||
const sameOrder = items.length === this.itemsCache.length && items.every((e, i) => e === this.itemsCache[i])
|
||||
if (!sameOrder) this.itemsCache = items
|
||||
return { items: this.itemsCache, state: this.listState, error: this.listError }
|
||||
const intentSession = this.getIntent()
|
||||
const intentState = intentSession?.getSnapshot()
|
||||
const intent = intentSession !== undefined
|
||||
&& intentState !== undefined && intentState.intent !== null && intentState.pendingPrompt !== null
|
||||
? {
|
||||
sessionId: intentSession.sessionId,
|
||||
...intentState.intent,
|
||||
prompt: intentState.pendingPrompt.text,
|
||||
}
|
||||
: undefined
|
||||
const selected = this.selected
|
||||
const current = selected !== undefined && (
|
||||
intent?.sessionId === selected || items.some(item => item.sessionId === selected)
|
||||
) ? selected : undefined
|
||||
return {
|
||||
items: this.itemsCache,
|
||||
current,
|
||||
intent,
|
||||
state: this.listState,
|
||||
phase: this.listPhase,
|
||||
error: this.listError,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply one list mutation without deriving display order. */
|
||||
function applyMutation(summaries: readonly SessionSummary[], mutation: SessionListMutation): SessionSummary[] {
|
||||
switch (mutation.kind) {
|
||||
case 'upsert': {
|
||||
const existing = summaries.find(summary => summary.sessionId === mutation.summary.sessionId)
|
||||
if (existing === undefined) return [mutation.summary, ...summaries]
|
||||
const filled: SessionSummary = {
|
||||
...existing,
|
||||
...(existing.cwd === undefined && mutation.summary.cwd !== undefined ? { cwd: mutation.summary.cwd } : {}),
|
||||
...(existing.parentSessionId === undefined && mutation.summary.parentSessionId !== undefined
|
||||
? { parentSessionId: mutation.summary.parentSessionId } : {}),
|
||||
}
|
||||
if (filled.cwd === existing.cwd && filled.parentSessionId === existing.parentSessionId) return [...summaries]
|
||||
return summaries.map(summary => summary.sessionId === mutation.summary.sessionId ? filled : summary)
|
||||
}
|
||||
case 'remove':
|
||||
return summaries.filter(summary => summary.sessionId !== mutation.sessionId)
|
||||
case 'status':
|
||||
return summaries.map(summary => summary.sessionId === mutation.sessionId && summary.running !== mutation.running
|
||||
? { ...summary, running: mutation.running }
|
||||
: summary)
|
||||
}
|
||||
}
|
||||
|
||||
/** Temporary source-plane bridge while the Host contract and client project build independently. */
|
||||
function workspaceAttachSessionId(error: RpcError): SessionId | undefined {
|
||||
const candidate = error as unknown as { code: string; details: { sessionId?: SessionId } }
|
||||
return candidate.code === 'workspace-attach-failed' ? candidate.details.sessionId : undefined
|
||||
}
|
||||
@@ -15,12 +15,18 @@
|
||||
* survives frozen (read-only view) until the stage moves on.
|
||||
*/
|
||||
import type { Context, Fiber } from 'cordis'
|
||||
import type { HostDescription, IApiClient, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
HostDescription, IApiClient, RpcError, SessionId, WorkspaceId,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
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 {
|
||||
SessionIntentListSnapshot, SessionListPhase,
|
||||
} from './manager.ts'
|
||||
import type { Session } from './session.ts'
|
||||
import type { SessionIntentTarget } from './conversation.ts'
|
||||
|
||||
/** Session list row projected from the host list RPC plus live stream increments. */
|
||||
export interface SessionSummary {
|
||||
@@ -40,7 +46,36 @@ export interface SessionSummary {
|
||||
* 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 }
|
||||
export interface SessionListState {
|
||||
ids: SessionId[]
|
||||
byId: Record<SessionId, SessionSummary>
|
||||
current: SessionId | undefined
|
||||
/** Frontend Session Intent projected from its owning Session object. */
|
||||
intent: SessionIntentListSnapshot | undefined
|
||||
/** Arrival lifecycle projected 1:1 from the manager snapshot (see SessionListPhase): empty-with-ready means "truly no sessions". */
|
||||
phase: SessionListPhase
|
||||
}
|
||||
|
||||
/** Structured session-create failure preserving partial publication identity. */
|
||||
export class SessionCreateError extends Error {
|
||||
override readonly name = 'SessionCreateError'
|
||||
/** Definitely published by Host before Workspace attachment failed. */
|
||||
readonly publishedSessionId: SessionId | undefined
|
||||
|
||||
/**
|
||||
* @param rpcError - Host business or folded transport error.
|
||||
* @param requestedSessionId - caller-preallocated id used for later stream/list reconciliation.
|
||||
*/
|
||||
constructor(
|
||||
readonly rpcError: RpcError,
|
||||
readonly requestedSessionId: SessionId | undefined,
|
||||
) {
|
||||
super(`session create failed: ${rpcError.code}: ${rpcError.message}`)
|
||||
this.publishedSessionId = rpcError.code === 'workspace-attach-failed'
|
||||
? rpcError.details.sessionId
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Session assembly handle for SessionProvider/inject factories (identity-stable per session). */
|
||||
export interface SessionBinding {
|
||||
@@ -64,6 +99,20 @@ export function scopeOf(ctx: Context): SessionId | undefined {
|
||||
/** Shared no-op plugin backing each session scope fiber. */
|
||||
function sessionScope(): void {}
|
||||
|
||||
/**
|
||||
* Workspace display title of a session cwd: the path's last non-empty
|
||||
* segment (both separators accepted; trailing separators ignored), or ''
|
||||
* for separator-only paths — callers own their fallback (session id, raw
|
||||
* cwd, default-directory copy). The repo-wide single basename derivation —
|
||||
* every surface naming a workspace (picker rows, toggle labels, list titles)
|
||||
* calls this instead of re-splitting paths.
|
||||
* @param cwd - workspace directory path.
|
||||
* @returns basename title, or '' when no non-empty segment exists.
|
||||
*/
|
||||
export function workspaceTitleOf(cwd: string): string {
|
||||
return cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop() ?? ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Display title projection: durable title, project directory basename, then
|
||||
* the raw id.
|
||||
@@ -71,8 +120,8 @@ function sessionScope(): void {}
|
||||
function displayTitleOf(title: string | undefined, cwd: string | undefined, id: SessionId): string {
|
||||
if (title !== undefined) return title
|
||||
if (cwd !== undefined && cwd !== '') {
|
||||
const base = cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop()
|
||||
if (base !== undefined && base !== '') return base
|
||||
const base = workspaceTitleOf(cwd)
|
||||
if (base !== '') return base
|
||||
}
|
||||
return id
|
||||
}
|
||||
@@ -89,8 +138,8 @@ interface ScopeRecord {
|
||||
export class SessionsService {
|
||||
/** 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
|
||||
/** The object-layer instance cluster and frame dispatch entry. */
|
||||
private readonly manager: SessionManager
|
||||
|
||||
/**
|
||||
* Persisted selection cell (the durable half of `list.current`). Private on
|
||||
@@ -118,12 +167,14 @@ export class SessionsService {
|
||||
* @param ctx - client root context (scope fibers mount under it).
|
||||
* @param api - wire client shared with every Session.
|
||||
*/
|
||||
constructor(private readonly rootCtx: Context, private readonly api: IApiClient) {
|
||||
this.manager = new SessionManager(api)
|
||||
constructor(private readonly rootCtx: Context, api: IApiClient) {
|
||||
this.selection = createSnapshotStore<{ sessionId?: SessionId }>(
|
||||
{},
|
||||
{ persist: { name: 'dsh.sessions.current' } })
|
||||
this.list = createSnapshotStore<SessionListState>({ ids: [], byId: {}, current: undefined })
|
||||
this.manager = new SessionManager(api, this.selection.getSnapshot().sessionId)
|
||||
this.list = createSnapshotStore<SessionListState>({
|
||||
ids: [], byId: {}, current: undefined, intent: undefined, phase: 'pending',
|
||||
})
|
||||
// The manager owns wire truth; the store is its projection. Manager
|
||||
// notifications are already microtask-batched.
|
||||
this.manager.subscribe(() => { this.projectList() })
|
||||
@@ -159,56 +210,88 @@ export class SessionsService {
|
||||
* @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 })
|
||||
this.manager.select(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the current selection so the layout shows the no-session empty
|
||||
* state. Wipes the persisted selection too — a reload stays on empty until
|
||||
* the user opens or starts a session. Staging holds the previous occupant
|
||||
* across the blank (same masked-gap rule as a transient list miss).
|
||||
* state (new-session affordance and the workspace preselection flow).
|
||||
* Wipes the persisted selection too — a reload stays on empty until the
|
||||
* user opens or starts a session. The staged scope keeps its frozen view
|
||||
* per the masked-gap contract until the next open() moves the stage.
|
||||
*/
|
||||
clear(): void {
|
||||
this.selection.set({})
|
||||
this.list.update((draft) => { draft.current = undefined })
|
||||
this.manager.clearSelection()
|
||||
}
|
||||
|
||||
/**
|
||||
* Start or retarget the sole client-local Session intent.
|
||||
* @param target - resolved real or frontend-only Workspace target.
|
||||
* @param prompt - optional prompt retained across retargeting.
|
||||
* @returns the frontend Session object that owns the Intent.
|
||||
*/
|
||||
startIntent(target: SessionIntentTarget, prompt = ''): Session {
|
||||
return this.manager.startIntent(target, prompt)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the active frontend Session Intent.
|
||||
* @returns the active frontend Session object, if one exists.
|
||||
*/
|
||||
intent(): Session | undefined {
|
||||
return this.manager.getIntent()
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the retained prompt of the active frontend Session.
|
||||
* @param text - exact controlled-input value for the current Session Intent.
|
||||
*/
|
||||
updateIntent(text: string): void {
|
||||
this.manager.updateIntent(text)
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the real Session baseline, reusing an in-flight pull.
|
||||
* @returns completion of the current or newly started baseline pull.
|
||||
*/
|
||||
refresh(): Promise<void> {
|
||||
return this.manager.refreshList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a mux stream envelope into the Session object layer.
|
||||
* @param envelope - validated mux stream envelope.
|
||||
*/
|
||||
handleMuxEnvelope(envelope: Parameters<SessionManager['handleMuxEnvelope']>[0]): void {
|
||||
this.manager.handleMuxEnvelope(envelope)
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a Host stream envelope into the Session object layer.
|
||||
* @param envelope - validated Host stream envelope.
|
||||
*/
|
||||
handleHostEnvelope(envelope: Parameters<SessionManager['handleHostEnvelope']>[0]): void {
|
||||
this.manager.handleHostEnvelope(envelope)
|
||||
}
|
||||
|
||||
/** Rebuild the Session baseline and every opened window after connection. */
|
||||
handleConnected(): void {
|
||||
this.manager.handleConnected()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a session on the host.
|
||||
* @param opts - creation options (project directory).
|
||||
* @param opts - target workspace or directory and an optional preallocated id.
|
||||
* @returns the new session id.
|
||||
* @throws {SessionCreateError} with the requested id and, after an attach
|
||||
* failure, the definitely published id.
|
||||
*/
|
||||
async create(opts: { cwd?: string } = {}): Promise<SessionId> {
|
||||
const result = await this.manager.create(opts.cwd)
|
||||
if (!result.ok) throw new Error(`session create failed: ${result.error.code}: ${result.error.message}`)
|
||||
async create(opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId } = {}): Promise<SessionId> {
|
||||
const result = await this.manager.create(opts)
|
||||
if (!result.ok) throw new SessionCreateError(result.error, opts.sessionId)
|
||||
return result.value.sessionId
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a workspace folder under the host process cwd and a session in it.
|
||||
* Name is a single path segment (no separators); the host mkdir runs inside
|
||||
* session.create. Caller opens the returned id when it wants the session staged.
|
||||
* @param name - workspace folder basename.
|
||||
* @returns the new session id.
|
||||
*/
|
||||
async createWorkspace(name: string): Promise<SessionId> {
|
||||
const trimmed = name.trim()
|
||||
if (trimmed === '') throw new Error('sessions.createWorkspace: name is required')
|
||||
if (/[/\\]/.test(trimmed)) {
|
||||
throw new Error('sessions.createWorkspace: name must not contain path separators')
|
||||
}
|
||||
const { result } = await this.api.host.describe({})
|
||||
if (!result.ok) {
|
||||
throw new Error(`host.describe failed: ${result.error.code}: ${result.error.message}`)
|
||||
}
|
||||
const hostCwd = result.value.cwd.replace(/[/\\]+$/, '')
|
||||
return this.create({ cwd: `${hostCwd}/${trimmed}` })
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a session-scoped context view (use-and-discard).
|
||||
* @param id - session id.
|
||||
@@ -261,11 +344,12 @@ export class SessionsService {
|
||||
* failed one retries the next time current is touched).
|
||||
*/
|
||||
private followCurrent(): void {
|
||||
const current = this.list.getSnapshot().current
|
||||
const snapshot = this.list.getSnapshot()
|
||||
const current = snapshot.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
|
||||
if (current === undefined || snapshot.byId[current] === undefined || current === this.watched) return
|
||||
this.watched = current
|
||||
this.sweepDeferred()
|
||||
const record = this.resolve(current)
|
||||
@@ -308,8 +392,7 @@ export class SessionsService {
|
||||
fiber,
|
||||
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.
|
||||
// Session is the observable; React binds a selector hook at its own seam.
|
||||
cell: { sessionId: id, session },
|
||||
}
|
||||
this.scopes.set(id, record)
|
||||
@@ -318,7 +401,7 @@ export class SessionsService {
|
||||
|
||||
/** Project the manager's list snapshot into the store (title derivation is display-only). */
|
||||
private projectList(): void {
|
||||
const items = this.manager.getListSnapshot().items
|
||||
const { items, current, intent, phase } = this.manager.getListSnapshot()
|
||||
const ids: SessionId[] = []
|
||||
const byId: Record<SessionId, SessionSummary> = {}
|
||||
for (const entry of items) {
|
||||
@@ -333,11 +416,13 @@ export class SessionsService {
|
||||
...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}),
|
||||
}
|
||||
}
|
||||
// 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 })
|
||||
const persisted = this.selection.getSnapshot().sessionId
|
||||
if (intent?.sessionId === current) {
|
||||
if (persisted !== undefined) this.selection.set({})
|
||||
} else if (current !== undefined && byId[current] !== undefined && persisted !== current) {
|
||||
this.selection.set({ sessionId: current })
|
||||
}
|
||||
this.list.set({ ids, byId, current, intent, phase })
|
||||
this.pruneScopes(byId)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
// Session: wraps every contract call that needs a sessionId + all conversation state for this
|
||||
// session (design §A.2/§A.9/§D.2/§D.3). Instances are resident (ruling 2): never destroyed once
|
||||
// created, they keep consuming mux frames in the background; React connects directly via
|
||||
// subscribe/getSnapshot.
|
||||
// Sessions remain resident after creation so they continue consuming mux frames off-screen.
|
||||
|
||||
import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
HistoryEntry, IApiClient, MuxFrame, PromptContentPart, RpcError, RpcId,
|
||||
RpcResult, SessionId, ToolEventView,
|
||||
RpcResult, SessionId, ToolEventView, WorkspaceId,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
// Value import from the inline-safe wire layer (not the connection plugin):
|
||||
// plugin-to-plugin value imports are a bundle purity error.
|
||||
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { ObservableSnapshot } from '../contract/store.ts'
|
||||
import type {
|
||||
ConversationNode, ConversationSnapshot, OpenState, PromptError, RunningToolCall,
|
||||
ComposerPhase, ConversationNode, ConversationSnapshot, OpenState, PendingPrompt,
|
||||
PromptError, RunningToolCall, SessionIntentSnapshot, SessionIntentTarget,
|
||||
} from './conversation.ts'
|
||||
import type { PendingInteraction } from './pending.ts'
|
||||
import { PendingWait } from './pending.ts'
|
||||
@@ -22,14 +20,18 @@ import { FoldAdapter } from './fold-adapter.ts'
|
||||
import { Notifier } from './notifier.ts'
|
||||
import { PartialAccumulator } from './partial.ts'
|
||||
|
||||
/** Messages per page (F.4 ledger: promote to Config at graduation; every call site references this constant). */
|
||||
/** Messages requested per history page. */
|
||||
export const PAGE_MESSAGES = 50
|
||||
|
||||
/** Optional frontend Intent and publication observer for a Session object. */
|
||||
export interface SessionOptions {
|
||||
intent?: { target: SessionIntentTarget; prompt: string }
|
||||
onPublished?(session: Session): void
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Owns a session's event window, derived conversation state, and observable
|
||||
* snapshot. React bindings remain outside this data layer.
|
||||
*/
|
||||
export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
// ---- Window and derived state (all private; the snapshot is the only read surface) ----
|
||||
@@ -54,8 +56,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
* Derived from window events (turn/end sweep) — rebuilt by rebuildDerivedFromWindow like partial/openCalls. */
|
||||
private frozenNodes: ConversationNode[] = []
|
||||
private pending = new Map<string, PendingInteraction>()
|
||||
// Revision counters + caches backing the snapshot's reference-stability contract (§A.9.4/§C.2,
|
||||
// audit S5): buildSnapshot reuses the previous array when the revision is unchanged, so
|
||||
// Revision counters preserve array identity when derived content is unchanged, so
|
||||
// React.memo children survive unrelated snapshot swaps (chunk storms must not re-render every
|
||||
// tool card and pending card). Mutation sites bump the matching revision. partial needs no
|
||||
// counter — PartialAccumulator.toPartial already returns a cached reference when unchanged.
|
||||
@@ -66,12 +67,24 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
private frozenRev = 0
|
||||
private nodesCache: { folded: readonly ConversationNode[]; frozenRev: number; value: readonly ConversationNode[] } | null = null
|
||||
private running = false
|
||||
/**
|
||||
* Sticky send marker, private input of the composerPhase derivation: set
|
||||
* synchronously before prompt()'s first await, never reset — the blank →
|
||||
* engaging edge of the phase machine (see ComposerPhase).
|
||||
*/
|
||||
private promptAttempted = false
|
||||
private removed = false
|
||||
private promptError: PromptError | null = null
|
||||
private intent: SessionIntentSnapshot | null
|
||||
private pendingPrompt: PendingPrompt | null
|
||||
/** Browser-prepared image parts retained with a Session Intent across attach/send retries. */
|
||||
private pendingImages: PromptContentPart[] = []
|
||||
private intentGeneration = 0
|
||||
private published: boolean
|
||||
private lastAgentError: string | null = null
|
||||
/** Buffer for live events arriving while open/resync is in flight (stitched by seq once history lands, §D.3). */
|
||||
/** Live events buffered during open/resync and stitched by sequence once history lands. */
|
||||
private liveBuffer: { event: SessionEvent; view: ToolEventView | undefined }[] = []
|
||||
/** Gap-repair (resync-lite) in flight: acceptLiveEvent detours to liveBuffer until the tail page lands (audit S3). */
|
||||
/** Gap repair in flight; live events detour to the buffer until the tail page lands. */
|
||||
private stitching = false
|
||||
/** subscribed.lastSeq baseline (gap detection; null when no subscribed frame arrived — degrade to the liveBuffer dedup path). */
|
||||
private subscribedLastSeq: number | null = null
|
||||
@@ -81,7 +94,23 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
this.snapshotCache = this.buildSnapshot()
|
||||
})
|
||||
|
||||
constructor(readonly sessionId: SessionId, private readonly api: IApiClient) {
|
||||
/**
|
||||
* @param sessionId - stable identity shared by the frontend Intent and Host entity.
|
||||
* @param api - shared wire client.
|
||||
* @param options - optional frontend-only initial state and publication observer.
|
||||
*/
|
||||
constructor(
|
||||
readonly sessionId: SessionId,
|
||||
private readonly api: IApiClient,
|
||||
private readonly options: SessionOptions = {},
|
||||
) {
|
||||
this.intent = options.intent === undefined
|
||||
? null
|
||||
: { target: options.intent.target, phase: 'ready' }
|
||||
this.pendingPrompt = options.intent === undefined
|
||||
? null
|
||||
: { text: options.intent.prompt, phase: 'editing', retry: 'send' }
|
||||
this.published = options.intent === undefined
|
||||
this.snapshotCache = this.buildSnapshot()
|
||||
}
|
||||
|
||||
@@ -96,6 +125,10 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
async prompt(content: PromptContentPart[], mode: 'queue' | 'steer'): Promise<RpcResult<{ accepted: true }>> {
|
||||
this.promptError = null
|
||||
this.lastAgentError = null
|
||||
// Synchronous, before the first await: the blank → engaging edge must be
|
||||
// visible on the session area's very first frame when a caller sends
|
||||
// ahead of navigation (first-send flow).
|
||||
this.promptAttempted = true
|
||||
this.notifier.markDirty()
|
||||
let result: RpcResult<{ accepted: true }>
|
||||
try {
|
||||
@@ -132,6 +165,79 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update this Session's retained prompt while it remains editable.
|
||||
* @param text - exact controlled value of this Session's retained prompt.
|
||||
*/
|
||||
updatePendingPrompt(text: string): void {
|
||||
const pending = this.pendingPrompt
|
||||
if (pending === null || pending.phase === 'sending') return
|
||||
this.pendingPrompt = { ...pending, text }
|
||||
this.notifier.notifyNow()
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace images retained by this editable Session Intent.
|
||||
* @param images - canonical prompt image parts prepared by the browser UI.
|
||||
*/
|
||||
updatePendingImages(images: readonly PromptContentPart[]): void {
|
||||
const pending = this.pendingPrompt
|
||||
if (pending === null || pending.phase === 'sending') return
|
||||
this.pendingImages = images.map(image => ({ ...image }))
|
||||
this.notifier.notifyNow()
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the retained Session Intent has text or images to submit.
|
||||
* @returns true when connect/send may proceed.
|
||||
*/
|
||||
hasPendingContent(): boolean {
|
||||
return (this.pendingPrompt?.text.trim() ?? '') !== '' || this.pendingImages.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect this frontend Session to a real Workspace and flush its retained prompt.
|
||||
* @param workspaceId - real Workspace target.
|
||||
*/
|
||||
connect(workspaceId: WorkspaceId): void {
|
||||
const intent = this.intent
|
||||
const pending = this.pendingPrompt
|
||||
if (intent === null || intent.phase === 'connecting' || pending === null || !this.hasPendingContent()) return
|
||||
const connecting: SessionIntentSnapshot = {
|
||||
target: { kind: 'workspace', workspaceId },
|
||||
phase: 'connecting',
|
||||
}
|
||||
const queued: PendingPrompt = {
|
||||
...pending,
|
||||
phase: 'sending',
|
||||
retry: 'connect',
|
||||
workspaceId,
|
||||
}
|
||||
delete queued.error
|
||||
this.intent = connecting
|
||||
this.pendingPrompt = queued
|
||||
this.notifier.notifyNow()
|
||||
void this.flushPendingPrompt()
|
||||
}
|
||||
|
||||
/** Stop a superseded frontend Intent from automatically sending after publication. */
|
||||
abandonIntent(): void {
|
||||
if (this.intent === null) return
|
||||
this.intentGeneration += 1
|
||||
}
|
||||
|
||||
/** Retry this Session's retained prompt from its failed prerequisite. */
|
||||
retryPendingPrompt(): void {
|
||||
const pending = this.pendingPrompt
|
||||
if (pending === null || pending.phase === 'sending' || !this.hasPendingContent()) return
|
||||
const sending: PendingPrompt = { ...pending, phase: 'sending' }
|
||||
delete sending.error
|
||||
this.pendingPrompt = sending
|
||||
this.promptError = null
|
||||
this.notifier.markDirty()
|
||||
void this.flushPendingPrompt()
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop: contract session.cancel 1:1; failures land in promptError (same error-strip display slot).
|
||||
* @returns the cancel result.
|
||||
@@ -299,6 +405,11 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
/** Mark that Host publication is known without resolving an uncertain local create response. */
|
||||
handlePublished(): void {
|
||||
this.markPublished()
|
||||
}
|
||||
|
||||
/** host/session-removed relay: flag the snapshot (instance survives — resident-instance rule). */
|
||||
handleRemoved(): void {
|
||||
this.removed = true
|
||||
@@ -314,8 +425,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
/** Instance-eviction hook, reserved no-op (design §F.6): resident instances are never destroyed
|
||||
* in v1; an eviction policy lands here (unsubscribe, drop buffers) without touching call sites. */
|
||||
/** No-op because session instances remain resident. */
|
||||
dispose(): void {}
|
||||
|
||||
// ---- 私有 ----
|
||||
@@ -333,6 +443,119 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
this.pendingRev++
|
||||
}
|
||||
|
||||
/** Advance the retained prompt through Session attachment and submission. */
|
||||
private async flushPendingPrompt(): Promise<void> {
|
||||
const pending = this.pendingPrompt
|
||||
if (pending?.phase === 'sending') {
|
||||
const ready = pending.retry === 'connect'
|
||||
? await this.attachPendingPrompt(pending)
|
||||
: pending
|
||||
if (ready !== null) await this.sendPendingPrompt(ready)
|
||||
}
|
||||
}
|
||||
|
||||
/** Complete the Host Session prerequisite and return the prompt's send step. */
|
||||
private async attachPendingPrompt(pending: PendingPrompt): Promise<PendingPrompt | null> {
|
||||
const workspaceId = pending.workspaceId
|
||||
if (workspaceId === undefined) throw new Error('a Session attachment requires a Workspace id')
|
||||
const originIntent = this.intent
|
||||
const originGeneration = this.intentGeneration
|
||||
let result: RpcResult<{ sessionId: SessionId }>
|
||||
try {
|
||||
result = (await this.api.sessions.create({ sessionId: this.sessionId, workspaceId })).result
|
||||
} catch (error) {
|
||||
result = transportError(error)
|
||||
}
|
||||
let ready: PendingPrompt | null = null
|
||||
if (result.ok) {
|
||||
ready = this.completePendingAttachment(pending, originIntent, originGeneration)
|
||||
} else {
|
||||
this.failPendingAttachment(pending, originIntent, originGeneration, result.error)
|
||||
}
|
||||
this.notifier.markDirty()
|
||||
return ready
|
||||
}
|
||||
|
||||
/** Move a published Session to the send step unless its page intent was superseded. */
|
||||
private completePendingAttachment(
|
||||
pending: PendingPrompt,
|
||||
originIntent: SessionIntentSnapshot | null,
|
||||
originGeneration: number,
|
||||
): PendingPrompt | null {
|
||||
this.markPublished()
|
||||
this.intent = null
|
||||
this.promptAttempted = true
|
||||
const superseded = originIntent !== null && originGeneration !== this.intentGeneration
|
||||
const next: PendingPrompt = {
|
||||
...pending,
|
||||
phase: superseded ? 'failed' : 'sending',
|
||||
retry: 'send',
|
||||
...(superseded ? { error: 'Message was not sent because you navigated away.' } : {}),
|
||||
}
|
||||
if (!superseded) delete next.error
|
||||
this.pendingPrompt = next
|
||||
return superseded ? null : next
|
||||
}
|
||||
|
||||
/** Retain the prompt at the failed attachment step that owns the retry. */
|
||||
private failPendingAttachment(
|
||||
pending: PendingPrompt,
|
||||
originIntent: SessionIntentSnapshot | null,
|
||||
originGeneration: number,
|
||||
error: RpcError,
|
||||
): void {
|
||||
const partiallyPublished = error.code === 'workspace-attach-failed'
|
||||
if (partiallyPublished) {
|
||||
this.markPublished()
|
||||
this.intent = null
|
||||
this.promptAttempted = true
|
||||
}
|
||||
const activeIntent = !partiallyPublished
|
||||
&& originIntent !== null
|
||||
&& originGeneration === this.intentGeneration
|
||||
&& this.intent === originIntent
|
||||
if (activeIntent) {
|
||||
this.intent = {
|
||||
target: originIntent.target,
|
||||
phase: 'ready',
|
||||
error: { step: 'session', message: rpcErrorMessage(error) },
|
||||
}
|
||||
this.pendingPrompt = { ...pending, phase: 'editing' }
|
||||
}
|
||||
if (!activeIntent && (partiallyPublished || originIntent === null) && this.pendingPrompt === pending) {
|
||||
this.pendingPrompt = { ...pending, phase: 'failed', error: rpcErrorMessage(error) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Submit the retained prompt and keep it only when Host rejects the send. */
|
||||
private async sendPendingPrompt(pending: PendingPrompt): Promise<void> {
|
||||
const text = pending.text.trim()
|
||||
const result = await this.prompt([
|
||||
...this.pendingImages,
|
||||
...(text === '' ? [] : [{ type: 'text' as const, text }]),
|
||||
], 'queue')
|
||||
if (this.pendingPrompt === pending) {
|
||||
if (result.ok) {
|
||||
this.pendingImages = []
|
||||
this.pendingPrompt = null
|
||||
} else {
|
||||
this.pendingPrompt = {
|
||||
...pending,
|
||||
retry: 'send',
|
||||
phase: 'failed',
|
||||
error: rpcErrorMessage(result.error),
|
||||
}
|
||||
}
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
}
|
||||
|
||||
private markPublished(): void {
|
||||
if (this.published) return
|
||||
this.published = true
|
||||
this.options.onPublished?.(this)
|
||||
}
|
||||
|
||||
/** @param generation - openGeneration at launch; every await re-checks it and a stale pass
|
||||
* drops all writes (resync superseded this open — its outcome belongs to a dead connection). */
|
||||
private async doOpen(generation: number): Promise<void> {
|
||||
@@ -549,21 +772,47 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
if (this.pendingCache === null || this.pendingCache.rev !== this.pendingRev) {
|
||||
this.pendingCache = { rev: this.pendingRev, value: [...this.pending.values()] }
|
||||
}
|
||||
const partial = this.partial?.toPartial() ?? null
|
||||
return {
|
||||
sessionId: this.sessionId,
|
||||
nodes,
|
||||
foldDegraded: degraded,
|
||||
partial: this.partial?.toPartial() ?? null,
|
||||
partial,
|
||||
runningCalls: this.callsCache.value,
|
||||
pending: this.pendingCache.value,
|
||||
running: this.running,
|
||||
composerPhase: derivePhase(
|
||||
nodes.length > 0 || partial !== null || this.running || this.pendingCache.value.length > 0,
|
||||
this.promptAttempted,
|
||||
),
|
||||
removed: this.removed,
|
||||
openState: this.openState,
|
||||
openError: this.openError,
|
||||
hasMore: this.hasMore,
|
||||
loadingOlder: this.loadingOlder,
|
||||
promptError: this.promptError,
|
||||
intent: this.intent,
|
||||
pendingPrompt: this.pendingPrompt,
|
||||
lastAgentError: this.lastAgentError,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function rpcErrorMessage(error: RpcError): string {
|
||||
return `${error.code}: ${error.message}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The composerPhase judgment — the single site that knows the predicate
|
||||
* (consumers switch on the result, never re-derive). Monotone per session
|
||||
* object: `hasContent` only grows within a window and `promptAttempted` is
|
||||
* sticky, so blank → engaging → active never steps back; a failed first
|
||||
* prompt stays engaging (retry semantics — see ComposerPhase).
|
||||
* @param hasContent - any conversation material exists (nodes, partial, running turn, pending waits).
|
||||
* @param promptAttempted - a prompt was initiated on this session object.
|
||||
* @returns the derived phase.
|
||||
*/
|
||||
function derivePhase(hasContent: boolean, promptAttempted: boolean): ComposerPhase {
|
||||
if (hasContent) return 'active'
|
||||
return promptAttempted ? 'engaging' : 'blank'
|
||||
}
|
||||
@@ -235,13 +235,17 @@ export class SlotsService extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
/** Build (once) the host face the installed renderer reads; sessions resolve lazily at first render. */
|
||||
/** Build once after both object-layer services mount; session cells still resolve lazily. */
|
||||
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")
|
||||
}
|
||||
const workspaces = this.ctx.get('workspaces')
|
||||
if (workspaces === undefined) {
|
||||
throw new Error("renderSlot('root') before the workspaces 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.
|
||||
@@ -262,6 +266,7 @@ export class SlotsService extends Service {
|
||||
current,
|
||||
cell: id => sessions.cell(id),
|
||||
},
|
||||
workspaces: { list: workspaces.list },
|
||||
}
|
||||
return this._host
|
||||
}
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
/** Workspace baseline, incremental-frame, and unary-action owner. */
|
||||
|
||||
import type {
|
||||
HostFrame, IApiClient, RpcError, RpcRequest, RpcResult, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { mergeOrderedBaseline } from '../ordered-baseline.ts'
|
||||
import { Notifier } from '../sessions/notifier.ts'
|
||||
import {
|
||||
Workspace, type WorkspaceCreateInput, type WorkspaceIntentSnapshot,
|
||||
} from './workspace.ts'
|
||||
|
||||
export type { WorkspaceIntentSnapshot } from './workspace.ts'
|
||||
|
||||
/** Monotone workspace-list arrival lifecycle. */
|
||||
export type WorkspaceListPhase = 'pending' | 'ready'
|
||||
|
||||
/** Immutable workspace-list snapshot. */
|
||||
export interface WorkspaceListSnapshot {
|
||||
items: readonly WorkspaceView[]
|
||||
/** The sole page-local Workspace intent; never persisted or sent over the Host stream. */
|
||||
intent: WorkspaceIntentSnapshot | undefined
|
||||
state: 'idle' | 'loading' | 'error'
|
||||
phase: WorkspaceListPhase
|
||||
error: RpcError | null
|
||||
}
|
||||
|
||||
/** Workspace object cluster driven by one list baseline and changed-frame upserts. */
|
||||
export class WorkspaceManager {
|
||||
private items: Workspace[] = []
|
||||
private intent: Workspace | undefined
|
||||
private itemViewsSource: readonly Workspace[] | null = null
|
||||
private itemViewsCache: readonly WorkspaceView[] = []
|
||||
private state: WorkspaceListSnapshot['state'] = 'idle'
|
||||
private phase: WorkspaceListPhase = 'pending'
|
||||
private error: RpcError | null = null
|
||||
private inflight: Promise<void> | null = null
|
||||
private refreshFrames: WorkspaceView[] | null = null
|
||||
private snapshotCache: WorkspaceListSnapshot
|
||||
private readonly notifier = new Notifier(() => {
|
||||
this.snapshotCache = this.buildSnapshot()
|
||||
})
|
||||
|
||||
/** @param api - shared wire client. */
|
||||
constructor(private readonly api: IApiClient) {
|
||||
this.snapshotCache = this.buildSnapshot()
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the current client-local Workspace intent object.
|
||||
* @param name - directory/display name used if the intent is materialized.
|
||||
* @returns the new intent snapshot.
|
||||
*/
|
||||
startIntent(name = 'workspace'): WorkspaceIntentSnapshot {
|
||||
this.intent = new Workspace(this.api, { name })
|
||||
this.notifier.notifyNow()
|
||||
return this.intent.getSnapshot().intent as WorkspaceIntentSnapshot
|
||||
}
|
||||
|
||||
/** Discard the current client-local Workspace intent. */
|
||||
discardIntent(): void {
|
||||
if (this.intent === undefined) return
|
||||
this.intent = undefined
|
||||
this.notifier.notifyNow()
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialize the current Workspace intent through the ordinary Host create seam.
|
||||
* A superseded intent is never cleared by an older completion.
|
||||
* @returns the Host create result, or undefined when no intent exists.
|
||||
*/
|
||||
async materializeIntent(): Promise<RpcResult<{ workspace: WorkspaceView; created: boolean }> | undefined> {
|
||||
const intent = this.intent
|
||||
if (intent?.getSnapshot().intent?.phase !== 'ready') return undefined
|
||||
const completion = intent.materialize()
|
||||
if (completion === undefined) return undefined
|
||||
this.notifier.notifyNow()
|
||||
const result = await completion
|
||||
if (result.ok) {
|
||||
this.upsert(result.value.workspace, intent)
|
||||
if (this.intent === intent) this.intent = undefined
|
||||
}
|
||||
this.notifier.markDirty()
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh from workspace.list. The first successful response establishes
|
||||
* Host order; later responses update membership and values without moving
|
||||
* identities already visible to the client. Frames arriving during the RPC
|
||||
* are replayed over its response.
|
||||
* @returns the shared in-flight refresh.
|
||||
*/
|
||||
refresh(): Promise<void> {
|
||||
if (this.inflight !== null) return this.inflight
|
||||
this.state = 'loading'
|
||||
this.error = null
|
||||
const established = this.itemViews()
|
||||
const frames: WorkspaceView[] = []
|
||||
this.refreshFrames = frames
|
||||
this.notifier.markDirty()
|
||||
this.inflight = (async () => {
|
||||
try {
|
||||
const { result } = await this.api.workspace.list({})
|
||||
if (result.ok) {
|
||||
let items = this.phase === 'pending'
|
||||
? result.value.items
|
||||
: mergeOrderedBaseline(established, result.value.items, workspace => workspace.workspaceId)
|
||||
for (const workspace of frames) items = upsertWorkspace(items, workspace)
|
||||
this.installViews(items)
|
||||
this.state = 'idle'
|
||||
this.phase = 'ready'
|
||||
} else {
|
||||
this.state = 'error'
|
||||
this.error = result.error
|
||||
}
|
||||
} catch (error) {
|
||||
this.state = 'error'
|
||||
const folded = transportError<never>(error)
|
||||
/* v8 ignore next -- transportError always returns the failure branch. */
|
||||
this.error = folded.ok ? null : folded.error
|
||||
} finally {
|
||||
this.refreshFrames = null
|
||||
this.inflight = null
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
})()
|
||||
return this.inflight
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or resolve a real Workspace, then publish its returned snapshot
|
||||
* without waiting for the changed frame.
|
||||
* @param input - name under workspaceRoot or an existing absolute path.
|
||||
* @returns the wire result.
|
||||
*/
|
||||
async create(input: WorkspaceCreateInput): Promise<RpcResult<{ workspace: WorkspaceView; created: boolean }>> {
|
||||
const workspace = new Workspace(this.api, input)
|
||||
const completion = workspace.materialize()
|
||||
if (completion === undefined) throw new Error('a local Workspace must be materializable')
|
||||
const result = await completion
|
||||
if (result.ok) this.upsert(result.value.workspace, workspace)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Host-frame entry. Non-workspace frames are ignored so the runtime can
|
||||
* fan one host stream out to both object managers.
|
||||
* @param envelope - host stream envelope.
|
||||
*/
|
||||
handleHostEnvelope(envelope: RpcRequest<HostFrame>): void {
|
||||
if (envelope.payload.type === 'host/workspace-changed') this.upsert(envelope.payload.workspace)
|
||||
}
|
||||
|
||||
/** Re-pull the baseline after each connection generation. */
|
||||
handleConnected(): void {
|
||||
void this.refresh()
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to workspace snapshot invalidation.
|
||||
* @param listener - snapshot invalidation callback.
|
||||
* @returns unsubscribe function.
|
||||
*/
|
||||
subscribe(listener: () => void): () => void {
|
||||
return this.notifier.subscribe(listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the cached workspace snapshot after flushing pending notifications.
|
||||
* @returns the cached workspace snapshot.
|
||||
*/
|
||||
getSnapshot(): WorkspaceListSnapshot {
|
||||
this.notifier.ensureFresh()
|
||||
return this.snapshotCache
|
||||
}
|
||||
|
||||
private buildSnapshot(): WorkspaceListSnapshot {
|
||||
return {
|
||||
items: this.itemViews(),
|
||||
intent: this.intent?.getSnapshot().intent,
|
||||
state: this.state,
|
||||
phase: this.phase,
|
||||
error: this.error,
|
||||
}
|
||||
}
|
||||
|
||||
/** Upsert one Host view, optionally retaining the local object that materialized it. */
|
||||
private upsert(view: WorkspaceView, identity?: Workspace): void {
|
||||
this.refreshFrames?.push(view)
|
||||
const index = this.items.findIndex(item => item.getSnapshot().view?.workspaceId === view.workspaceId)
|
||||
if (identity !== undefined) {
|
||||
this.items = index === -1
|
||||
? [identity, ...this.items]
|
||||
: this.items.map((item, position) => position === index ? identity : item)
|
||||
} else if (index === -1) {
|
||||
this.items = [new Workspace(this.api, view), ...this.items]
|
||||
} else {
|
||||
this.items[index]?.adopt(view)
|
||||
this.items = [...this.items]
|
||||
}
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
private installViews(views: readonly WorkspaceView[]): void {
|
||||
const existing = new Map(
|
||||
this.items.flatMap((workspace) => {
|
||||
const view = workspace.getSnapshot().view
|
||||
return view === undefined ? [] : [[view.workspaceId, workspace] as const]
|
||||
}),
|
||||
)
|
||||
const installed = new Map<WorkspaceView['workspaceId'], Workspace>()
|
||||
for (const view of views) {
|
||||
const duplicate = installed.get(view.workspaceId)
|
||||
if (duplicate !== undefined) {
|
||||
duplicate.adopt(view)
|
||||
continue
|
||||
}
|
||||
const workspace = existing.get(view.workspaceId) ?? new Workspace(this.api, view)
|
||||
workspace.adopt(view)
|
||||
installed.set(view.workspaceId, workspace)
|
||||
}
|
||||
this.items = [...installed.values()]
|
||||
}
|
||||
|
||||
private itemViews(): readonly WorkspaceView[] {
|
||||
if (this.itemViewsSource === this.items) return this.itemViewsCache
|
||||
this.itemViewsSource = this.items
|
||||
this.itemViewsCache = this.items.flatMap((workspace) => {
|
||||
const view = workspace.getSnapshot().view
|
||||
return view === undefined ? [] : [view]
|
||||
})
|
||||
return this.itemViewsCache
|
||||
}
|
||||
}
|
||||
|
||||
/** Known ids retain their position; a newly created Workspace enters first. */
|
||||
function upsertWorkspace(items: readonly WorkspaceView[], workspace: WorkspaceView): WorkspaceView[] {
|
||||
const index = items.findIndex(item => item.workspaceId === workspace.workspaceId)
|
||||
return index === -1
|
||||
? [workspace, ...items]
|
||||
: items.map((item, position) => position === index ? workspace : item)
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/** WorkspacesService projects the Workspace object manager for UI consumers. */
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type {
|
||||
IApiClient, RpcError, WorkspaceId, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SnapshotStore } from '../contract/store.ts'
|
||||
import { createSnapshotStore } from '../contract/store.ts'
|
||||
import type { SessionsService } from '../sessions/service.ts'
|
||||
import { WorkspaceManager, type WorkspaceIntentSnapshot, type WorkspaceListPhase } from './manager.ts'
|
||||
|
||||
/** Workspace list plus the two-baseline readiness and default-target projection. */
|
||||
export interface WorkspaceListState {
|
||||
items: readonly WorkspaceView[]
|
||||
/** Sole client-local Workspace projection; its state remains owned by Workspace. */
|
||||
intent: WorkspaceIntentSnapshot | undefined
|
||||
state: 'idle' | 'loading' | 'error'
|
||||
phase: WorkspaceListPhase
|
||||
error: RpcError | null
|
||||
/** True only after both workspace.list and session.list have succeeded. */
|
||||
baselinesReady: boolean
|
||||
/** Most recently active Workspace, derived without changing `items` order. */
|
||||
recentWorkspaceId: WorkspaceId | undefined
|
||||
}
|
||||
|
||||
/** Real Workspace object layer and Host actions. */
|
||||
export class WorkspacesService {
|
||||
/** UI-facing immutable projection; the manager remains wire truth. */
|
||||
readonly list: SnapshotStore<WorkspaceListState>
|
||||
/** Workspace baseline and frame owner. */
|
||||
private readonly manager: WorkspaceManager
|
||||
private initialSessionResolved = false
|
||||
private composingIntent = false
|
||||
|
||||
/**
|
||||
* @param ctx - client root context.
|
||||
* @param api - shared wire client.
|
||||
* @param sessions - lower-level Session service used for recency and cross-domain intent orchestration.
|
||||
*/
|
||||
constructor(ctx: Context, api: IApiClient, private readonly sessions: SessionsService) {
|
||||
this.manager = new WorkspaceManager(api)
|
||||
this.list = createSnapshotStore<WorkspaceListState>({
|
||||
items: [], intent: undefined, state: 'idle', phase: 'pending', error: null,
|
||||
baselinesReady: false, recentWorkspaceId: undefined,
|
||||
})
|
||||
this.manager.subscribe(() => { if (!this.composingIntent) this.project() })
|
||||
this.sessions.list.subscribe(() => { if (!this.composingIntent) this.project() })
|
||||
ctx.reflect.provide('workspaces', this, undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the sole Session intent, resolving the default Workspace here.
|
||||
* @param workspaceId - optional explicit real Workspace target.
|
||||
* @param prompt - optional prompt retained while retargeting.
|
||||
*/
|
||||
startSession(workspaceId?: WorkspaceId, prompt = ''): void {
|
||||
const snapshot = this.list.getSnapshot()
|
||||
const resolved = workspaceId ?? snapshot.recentWorkspaceId ?? snapshot.items[0]?.workspaceId
|
||||
this.composingIntent = true
|
||||
try {
|
||||
if (resolved === undefined) {
|
||||
this.manager.startIntent()
|
||||
this.sessions.startIntent({ kind: 'workspace-intent' }, prompt)
|
||||
} else {
|
||||
this.manager.discardIntent()
|
||||
this.sessions.startIntent({ kind: 'workspace', workspaceId: resolved }, prompt)
|
||||
}
|
||||
} finally {
|
||||
this.composingIntent = false
|
||||
this.project()
|
||||
}
|
||||
}
|
||||
|
||||
/** Connect the current frontend Workspace and Session, then flush the Session-owned prompt. */
|
||||
sendSession(): void {
|
||||
const session = this.sessions.intent()
|
||||
const target = session?.getSnapshot().intent?.target
|
||||
if (session === undefined || target === undefined) return
|
||||
if (target.kind === 'workspace') {
|
||||
session.connect(target.workspaceId)
|
||||
return
|
||||
}
|
||||
if (!session.hasPendingContent()) return
|
||||
void this.manager.materializeIntent().then((result) => {
|
||||
if (this.sessions.intent() !== session) return
|
||||
if (result?.ok) {
|
||||
session.connect(result.value.workspace.workspaceId)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Workspace by name or register an existing path.
|
||||
* @param input - exactly one Host create spelling.
|
||||
* @returns the created or idempotently resolved Workspace.
|
||||
*/
|
||||
async create(input: { name: string } | { path: string }): Promise<WorkspaceView> {
|
||||
const result = await this.manager.create(input)
|
||||
if (!result.ok) throw new Error(`workspace create failed: ${result.error.code}: ${result.error.message}`)
|
||||
return result.value.workspace
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the workspace baseline, reusing an in-flight pull.
|
||||
* @returns completion of the current or newly started workspace baseline pull.
|
||||
*/
|
||||
refresh(): Promise<void> {
|
||||
return this.manager.refresh()
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a Host stream envelope into the Workspace object layer.
|
||||
* @param envelope - validated Host stream envelope.
|
||||
*/
|
||||
handleHostEnvelope(envelope: Parameters<WorkspaceManager['handleHostEnvelope']>[0]): void {
|
||||
this.manager.handleHostEnvelope(envelope)
|
||||
}
|
||||
|
||||
/** Rebuild the Workspace baseline after connection. */
|
||||
handleConnected(): void {
|
||||
this.manager.handleConnected()
|
||||
}
|
||||
|
||||
private project(): void {
|
||||
const workspace = this.manager.getSnapshot()
|
||||
const sessions = this.sessions.list.getSnapshot()
|
||||
if (workspace.intent !== undefined && sessions.intent?.target.kind !== 'workspace-intent') {
|
||||
this.manager.discardIntent()
|
||||
return
|
||||
}
|
||||
const baselinesReady = workspace.phase === 'ready' && sessions.phase === 'ready'
|
||||
this.list.set({
|
||||
...workspace,
|
||||
baselinesReady,
|
||||
recentWorkspaceId: baselinesReady ? recentWorkspace(workspace.items, sessions.byId) : undefined,
|
||||
})
|
||||
if (!this.initialSessionResolved && baselinesReady) {
|
||||
this.initialSessionResolved = true
|
||||
if (sessions.current === undefined && sessions.intent === undefined) this.startSession()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Stable tie-breaking follows Host Workspace order. */
|
||||
function recentWorkspace(
|
||||
workspaces: readonly WorkspaceView[],
|
||||
sessions: ReturnType<SessionsService['list']['getSnapshot']>['byId'],
|
||||
): WorkspaceId | undefined {
|
||||
let selected: WorkspaceId | undefined
|
||||
let selectedTime = Number.NEGATIVE_INFINITY
|
||||
for (const workspace of workspaces) {
|
||||
let latest = Number.NEGATIVE_INFINITY
|
||||
for (const sessionId of workspace.sessionIds) {
|
||||
const session = sessions[sessionId]
|
||||
if (session !== undefined) latest = Math.max(latest, session.updatedAt)
|
||||
}
|
||||
if (latest === Number.NEGATIVE_INFINITY) latest = Date.parse(workspace.createdAt)
|
||||
if (selected === undefined || latest > selectedTime) {
|
||||
selected = workspace.workspaceId
|
||||
selectedTime = latest
|
||||
}
|
||||
}
|
||||
return selected
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/** React-free Workspace entity with a client-local materialization lifecycle. */
|
||||
|
||||
import type {
|
||||
IApiClient, RpcResult, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { ObservableSnapshot } from '../contract/store.ts'
|
||||
import { Notifier } from '../sessions/notifier.ts'
|
||||
|
||||
/** Host input retained by a local Workspace until materialization succeeds. */
|
||||
export type WorkspaceCreateInput = { name: string } | { path: string }
|
||||
|
||||
/** Observable state of a client-local Workspace intent. */
|
||||
export interface WorkspaceIntentSnapshot {
|
||||
name: string
|
||||
phase: 'ready' | 'creating'
|
||||
error?: string
|
||||
}
|
||||
|
||||
/** A Workspace is either a local intent or a materialized Host view. */
|
||||
export interface WorkspaceSnapshot {
|
||||
view: WorkspaceView | undefined
|
||||
intent: WorkspaceIntentSnapshot | undefined
|
||||
}
|
||||
|
||||
interface WorkspaceIntent {
|
||||
input: WorkspaceCreateInput
|
||||
snapshot: WorkspaceIntentSnapshot
|
||||
}
|
||||
|
||||
/**
|
||||
* Observable Workspace object whose identity survives Host materialization.
|
||||
* Local instances retain their create input and failure state; materialized
|
||||
* instances expose the latest Host view.
|
||||
*/
|
||||
export class Workspace implements ObservableSnapshot<WorkspaceSnapshot> {
|
||||
private view: WorkspaceView | undefined
|
||||
private intent: WorkspaceIntent | undefined
|
||||
private materialization: Promise<RpcResult<{ workspace: WorkspaceView; created: boolean }>> | null = null
|
||||
private snapshotCache: WorkspaceSnapshot
|
||||
private readonly notifier = new Notifier(() => {
|
||||
this.snapshotCache = this.buildSnapshot()
|
||||
})
|
||||
|
||||
/**
|
||||
* @param api - shared wire client.
|
||||
* @param source - local create input or an existing Host Workspace view.
|
||||
*/
|
||||
constructor(private readonly api: IApiClient, source: WorkspaceCreateInput | WorkspaceView) {
|
||||
if ('workspaceId' in source) {
|
||||
this.view = source
|
||||
} else {
|
||||
this.intent = {
|
||||
input: source,
|
||||
snapshot: { name: intentName(source), phase: 'ready' },
|
||||
}
|
||||
}
|
||||
this.snapshotCache = this.buildSnapshot()
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialize this local Workspace through the Host create seam.
|
||||
* Re-entry shares the in-flight completion; a materialized instance returns undefined.
|
||||
* @returns the Host result, or undefined when this Workspace is already materialized.
|
||||
*/
|
||||
materialize(): Promise<RpcResult<{ workspace: WorkspaceView; created: boolean }>> | undefined {
|
||||
if (this.materialization !== null) return this.materialization
|
||||
const intent = this.intent
|
||||
if (intent === undefined) return undefined
|
||||
intent.snapshot = { name: intent.snapshot.name, phase: 'creating' }
|
||||
this.notifier.notifyNow()
|
||||
const completion = this.completeMaterialization(intent).finally(() => {
|
||||
if (this.materialization === completion) this.materialization = null
|
||||
})
|
||||
this.materialization = completion
|
||||
return completion
|
||||
}
|
||||
|
||||
/**
|
||||
* Adopt a Host view without replacing this Workspace object.
|
||||
* An existing materialized identity accepts updates only for the same Workspace id.
|
||||
* @param view - latest Host projection.
|
||||
*/
|
||||
adopt(view: WorkspaceView): void {
|
||||
if (this.view !== undefined && this.view.workspaceId !== view.workspaceId) {
|
||||
throw new Error('cannot adopt a different Workspace id')
|
||||
}
|
||||
this.view = view
|
||||
this.intent = undefined
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to Workspace snapshot invalidation.
|
||||
* @param listener - snapshot invalidation callback.
|
||||
* @returns unsubscribe function.
|
||||
*/
|
||||
subscribe(listener: () => void): () => void {
|
||||
return this.notifier.subscribe(listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the cached Workspace snapshot after flushing pending notifications.
|
||||
* @returns the cached Workspace snapshot.
|
||||
*/
|
||||
getSnapshot(): WorkspaceSnapshot {
|
||||
this.notifier.ensureFresh()
|
||||
return this.snapshotCache
|
||||
}
|
||||
|
||||
private async completeMaterialization(
|
||||
intent: WorkspaceIntent,
|
||||
): Promise<RpcResult<{ workspace: WorkspaceView; created: boolean }>> {
|
||||
let result: RpcResult<{ workspace: WorkspaceView; created: boolean }>
|
||||
try {
|
||||
result = (await this.api.workspace.create(intent.input)).result
|
||||
} catch (error) {
|
||||
result = transportError(error)
|
||||
}
|
||||
if (this.intent !== intent) return result
|
||||
if (result.ok) {
|
||||
this.adopt(result.value.workspace)
|
||||
} else {
|
||||
intent.snapshot = {
|
||||
name: intent.snapshot.name,
|
||||
phase: 'ready',
|
||||
error: `${result.error.code}: ${result.error.message}`,
|
||||
}
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private buildSnapshot(): WorkspaceSnapshot {
|
||||
return { view: this.view, intent: this.intent?.snapshot }
|
||||
}
|
||||
}
|
||||
|
||||
function intentName(input: WorkspaceCreateInput): string {
|
||||
if ('name' in input) return input.name
|
||||
const trimmed = input.path.replace(/[\\/]+$/, '')
|
||||
return trimmed.split(/[\\/]/).pop() ?? input.path
|
||||
}
|
||||
@@ -1,11 +1,4 @@
|
||||
/**
|
||||
* Runtime plugin, node half. The implementation lives entirely in the client
|
||||
* half (src/client/ — SlotsService, SessionsService + object layer, and the
|
||||
* shell-held ClientLoader under ./loader); consumers import the /client or
|
||||
* /loader subpaths. The empty apply exists so the plugin appears in the host
|
||||
* Loader (lifecycle governance + dshClient discovery). Contract:
|
||||
* api-contracts v3 section 4.
|
||||
*/
|
||||
/** Host loader entry for the browser runtime exported from `./client` and `./loader`. */
|
||||
|
||||
/** Host plugin body — no host-side behavior for the runtime plugin. */
|
||||
export function apply(_ctx: unknown): void {}
|
||||
Reference in New Issue
Block a user