feat(web): add workspace-aware session flow

This commit is contained in:
imccyu
2026-07-25 16:04:48 +08:00
parent 755e2a8c51
commit 9eb9c70a8a
170 changed files with 7573 additions and 3006 deletions

View File

@@ -1,6 +1,16 @@
# @deepseek-ai/dsh-client-runtime
Client cordis boot + core services: SlotsService (Service wrapper over SlotCore + 'slots/changed' bridge), SessionsService (list store projection, scope tree, bindings, ancestry), the Session object layer (exported as a type; instances are owned and handed out by SessionsService — the manager/paging internals stay package-internal, tests reach them via src), ClientLoader (`./loader` subpath, statically held by the shell). Contract: api-contracts v3 §4.
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list/scope/history state, and page-local Session Intent state; WorkspacesService owns Workspace objects, list/actions, page-local Workspace Intent state, and default-target derivation. The runtime fans the shared Host stream into both managers. Contract: api-contracts v3 §4.
## Workspace and Session lists
Workspace and Session lists have independent monotone `pending``ready` baseline phases and separate refresh activity/error state. Incremental frames arriving during a list request replay over its response. The first successful baseline establishes Host order; later refreshes update rows and membership without changing the relative order of identities already shown. Workspace recency is derived only after both baselines are ready and never changes Workspace list order.
SlotsService gives the renderer separate bare observables for `useSessions` and `useWorkspaces`; web-react creates the hooks. Workspace business state does not enter `SessionListState` or an entry store.
## Session creation failures
`SessionsService.create` accepts an optional caller-preallocated SessionId. It throws `SessionCreateError` on failure: `requestedSessionId` remains available after transport uncertainty, while `publishedSessionId` is set when `workspace-attach-failed` proves the Host published a real Session before attachment failed. For the New Session flow, the frontend Session object owns its retained prompt and advances it through attachment and send; a partially published Session keeps the same object and prompt while it appears as Ungrouped.
## Session title projection

View File

@@ -1,30 +1,32 @@
/**
* Browser runtime services for slots, sessions, and connection-stream
* delivery. The web shell mounts this static client entry through the host
* plugin graph.
*/
/** 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'
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'
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'
export { PendingWait } from './sessions/pending.ts'
export type { PendingInteraction, PendingKind, PendingPayloads } from './sessions/pending.ts'
@@ -57,6 +59,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
/** Props injected into every global slot component. */
interface GlobalStandardProps {
useSessions: SnapshotSelectorHook<SessionListState>
/** Selector hook over real Workspaces and their independent baseline lifecycle. */
useWorkspaces: SnapshotSelectorHook<import('./workspaces/service.ts').WorkspaceListState>
}
}
@@ -72,6 +76,7 @@ declare module 'cordis' {
interface Context {
slots: import('./slots.ts').SlotsService
sessions: import('./sessions/service.ts').SessionsService
workspaces: import('./workspaces/service.ts').WorkspacesService
}
}
@@ -85,10 +90,17 @@ 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) },
onConnected: () => { sessions.manager.handleConnected() },
onMuxEnvelope: (envelope) => { sessions.handleMuxEnvelope(envelope) },
onHostEnvelope: (envelope) => {
sessions.handleHostEnvelope(envelope)
workspaces.handleHostEnvelope(envelope)
},
onConnected: () => {
sessions.handleConnected()
workspaces.handleConnected()
},
})
ctx.effect(() => () => { loop.stop() }, 'runtime: connection stream loop')
}

View File

@@ -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
}

View File

@@ -4,7 +4,9 @@
// string here (narrow to real brands when convenient).
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
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
@@ -149,12 +151,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
@@ -166,6 +214,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
@@ -173,5 +223,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
}

View File

@@ -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)

View File

@@ -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,84 @@ 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
}
/** @returns the active frontend Session, if one remains selected. */
getIntent(): Session | undefined {
return this.intentSessionId === undefined ? undefined : this.sessions.get(this.intentSessionId)
}
/** @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 +178,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 +193,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 +216,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 +243,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 +254,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 +292,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 +388,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 +417,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 +446,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
}

View File

@@ -15,12 +15,16 @@
* survives frozen (read-only view) until the stage moves on.
*/
import type { Context, Fiber } from 'cordis'
import type { IApiClient, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { 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 +44,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 +97,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 +118,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 +136,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
@@ -117,12 +164,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() })
@@ -142,56 +191,81 @@ 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.
*/
startIntent(target: SessionIntentTarget, prompt = ''): Session {
return this.manager.startIntent(target, prompt)
}
/** @returns the active frontend Session object, if one exists. */
intent(): Session | undefined {
return this.manager.getIntent()
}
/** @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.
@@ -244,11 +318,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)
@@ -300,7 +375,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) {
@@ -315,11 +390,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)
}

View File

@@ -4,14 +4,15 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {
HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult,
SessionId, ToolEventView,
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,6 +23,12 @@ import { PartialAccumulator } from './partial.ts'
/** 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
}
/**
* Owns a session's event window, derived conversation state, and observable
* snapshot. React bindings remain outside this data layer.
@@ -60,8 +67,18 @@ 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
private intentGeneration = 0
private published: boolean
private lastAgentError: string | null = null
/** Live events buffered during open/resync and stitched by sequence once history lands. */
private liveBuffer: { event: SessionEvent; view: ToolEventView | undefined }[] = []
@@ -75,7 +92,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()
}
@@ -90,6 +123,10 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
async prompt(content: ContentBlock[], 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 {
@@ -104,6 +141,57 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
return result
}
/** @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()
}
/**
* 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 || pending.text.trim() === '') 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' || pending.text.trim() === '') 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.
@@ -271,6 +359,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
@@ -304,6 +397,112 @@ 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 result = await this.prompt([{ type: 'text', text: pending.text.trim() }], 'queue')
if (this.pendingPrompt === pending) {
this.pendingPrompt = result.ok
? null
: {
...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> {
@@ -520,21 +719,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'
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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.getSnapshot().pendingPrompt?.text.trim() === '') 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
}

View File

@@ -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
}

View File

@@ -1,5 +1,5 @@
/**
* Runtime plugin browser-half apply: slots + sessions mounting over the
* Runtime plugin browser-half apply: slots + object services mounting over the
* connection handle, stream-loop sink wiring into the object layer, and the
* fiber-scoped loop teardown.
*/
@@ -34,14 +34,17 @@ async function mount(): Promise<Bench> {
}
describe('runtime client apply', () => {
it('mounts ctx.slots + ctx.sessions and wires the stream sinks into the manager', async () => {
it('mounts slots, Sessions, and Workspaces and fans host frames into both managers', async () => {
const bench = await mount()
expect(bench.ctx.get('slots') !== undefined).toBe(true)
// The built-in 'root' declaration ships with this package's SlotsService
// (the SlotMap 'root' merge lives here since the slot-parity rework).
expect(bench.ctx.slots.spec('root')).toEqual({ kind: 'single', scope: 'root' })
const sessions = bench.ctx.get('sessions')
const workspaces = bench.ctx.get('workspaces')
expect(sessions !== undefined).toBe(true)
expect(workspaces !== undefined).toBe(true)
if (workspaces === undefined) throw new Error('WorkspacesService missing after runtime apply')
expect(bench.sinks).toBeDefined()
// Frame sinks reach the object layer: a host session-added lands in the list store.
@@ -51,6 +54,18 @@ describe('runtime client apply', () => {
})
await Promise.resolve()
expect((sessions as { list: { getSnapshot(): { ids: string[] } } }).list.getSnapshot().ids).toContain('s-new')
bench.sinks?.onHostEnvelope?.({
rpcId: 'r-workspace' as never,
payload: {
type: 'host/workspace-changed',
workspace: {
workspaceId: 'w-new', path: '/w/new', title: 'new', sessionIds: [],
createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
},
} as never,
})
await Promise.resolve()
expect(workspaces.list.getSnapshot().items[0]?.workspaceId).toBe('w-new')
// Mux sink and onConnected route without throwing (manager semantics own the behavior).
bench.sinks?.onMuxEnvelope?.({ rpcId: 'r2' as never, payload: { type: 'stream/error', message: 'x' } as never })
bench.sinks?.onConnected?.()

View File

@@ -3,9 +3,23 @@
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
import type {
ClientResponse, HostFrame, IApiClient, MuxFrame, RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId,
WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-connection/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
/** Programmable-default workspace row (branded id, ISO-ish times). */
function fakeWorkspace(id: string, over: Partial<WorkspaceView> = {}): WorkspaceView {
return {
workspaceId: id as WorkspaceId,
path: '/f/ws',
title: 'ws',
sessionIds: [],
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
...over,
}
}
export interface Deferred<T> {
promise: Promise<T>
resolve(value: T): void
@@ -74,6 +88,15 @@ export class FakeApiClient implements IApiClient {
describe: (payload: unknown) => this.record('host.describe', payload, this.onDescribe(payload)),
}
onWorkspaceList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
onWorkspaceCreate: (payload: unknown) => Promise<RpcResponse<{ workspace: WorkspaceView; created: boolean }>> =
() => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws'), created: true }))
readonly workspace: IApiClient['workspace'] = {
list: (payload: unknown) => this.record('workspace.list', payload, this.onWorkspaceList(payload)),
create: (payload: unknown) => this.record('workspace.create', payload, this.onWorkspaceCreate(payload)),
}
/** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */
suppressStreamOpen = false

View File

@@ -13,7 +13,7 @@ const s = (id: string, updatedAt: number, parent?: string): SessionSummary => ({
})
describe('flattenLineage', () => {
it('sorts roots by updatedAt desc and expands children DFS with depth, children sorted too', () => {
it('keeps established root and sibling order while expanding children DFS with depth', () => {
const out = flattenLineage([
s('old-root', 10),
s('new-root', 30),
@@ -22,7 +22,7 @@ describe('flattenLineage', () => {
s('grandkid', 5, 'kid-new'),
])
expect(out.map(e => [e.sessionId, e.depth])).toEqual([
['new-root', 0], ['kid-new', 1], ['grandkid', 2], ['kid-old', 1], ['old-root', 0],
['old-root', 0], ['new-root', 0], ['kid-old', 1], ['kid-new', 1], ['grandkid', 2],
])
})

View File

@@ -57,7 +57,7 @@ describe('instances', () => {
})
describe('list lifecycle', () => {
it('single-flights refreshList and lands items sorted through lineage flattening', async () => {
it('single-flights refreshList and preserves the Host baseline order', async () => {
const api = new FakeApiClient()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
api.onList = () => gate.promise
@@ -65,12 +65,33 @@ describe('list lifecycle', () => {
const first = manager.refreshList()
const second = manager.refreshList()
expect(manager.getListSnapshot().state).toBe('loading')
gate.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] }))
gate.resolve(ok({ items: [summary(S2, { updatedAt: 200 }), summary(S1)] as never[] }))
await Promise.all([first, second])
expect(api.callsOf('session.list')).toHaveLength(1)
const snapshot = manager.getListSnapshot()
expect(snapshot.state).toBe('idle')
expect(snapshot.items.map(i => i.sessionId)).toEqual([S2, S1]) // updatedAt desc
expect(snapshot.items.map(i => i.sessionId)).toEqual([S2, S1])
})
it('replays incremental frames over hydration and never batch-reorders established ids', async () => {
const api = new FakeApiClient()
const first = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
api.onList = () => first.promise
const manager = new SessionManager(api)
const hydration = manager.refreshList()
manager.handleHostEnvelope({
rpcId: 'during-first' as never,
payload: { type: 'host/session-added', sessionId: S2 },
})
first.resolve(ok({ items: [summary(S1)] as never[] }))
await hydration
expect(manager.getListSnapshot().items.map(item => item.sessionId)).toEqual([S2, S1])
api.onList = () => Promise.resolve(ok({
items: [summary(S1, { updatedAt: 900 }), summary(S2, { updatedAt: 800 })] as never[],
}))
await manager.refreshList()
expect(manager.getListSnapshot().items.map(item => item.sessionId)).toEqual([S2, S1])
})
it('keeps the error in the list snapshot on failure', async () => {
@@ -79,6 +100,26 @@ describe('list lifecycle', () => {
const manager = new SessionManager(api)
await manager.refreshList()
expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'internal' } })
// A failed pull does not step the arrival phase: still pending.
expect(manager.getListSnapshot().phase).toBe('pending')
})
it('phase steps pending → ready on the first successful pull and never returns', async () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
expect(manager.getListSnapshot().phase).toBe('pending')
await manager.refreshList()
expect(manager.getListSnapshot().phase).toBe('ready')
// Sticky across later failures: the pull-activity axis reports the error,
// the arrival phase holds.
api.onList = () => Promise.resolve(err({ code: 'internal', message: 'down', details: {} }))
await manager.refreshList()
expect(manager.getListSnapshot()).toMatchObject({ state: 'error', phase: 'ready' })
// And across an empty re-pull (empty-with-ready = truly no sessions).
api.onList = () => Promise.resolve(ok({ items: [] as never[] }))
await manager.refreshList()
expect(manager.getListSnapshot()).toMatchObject({ state: 'idle', phase: 'ready' })
expect(manager.getListSnapshot().items).toEqual([])
})
it('merges create into the list immediately without waiting for a refresh', async () => {
@@ -192,14 +233,14 @@ describe('remaining branches', () => {
expect(session.getSnapshot().running).toBe(true)
})
it('create passes cwd through, folds transport throws, and skips the merge when already listed', async () => {
it('create passes cwd and a preallocated id, folds transport throws, and deduplicates the echo', async () => {
const api = new FakeApiClient()
api.onCreate = () => Promise.resolve(ok({ sessionId: S1 }))
const manager = new SessionManager(api)
await manager.create('/tmp/w')
expect(api.callsOf('session.create')).toEqual([{ cwd: '/tmp/w' }])
await manager.create({ cwd: '/tmp/w', sessionId: S1 })
expect(api.callsOf('session.create')).toEqual([{ cwd: '/tmp/w', sessionId: S1 }])
expect(manager.getListSnapshot().items[0]).toMatchObject({ sessionId: S1, cwd: '/tmp/w' })
await manager.create('/tmp/w') // same id returned: no duplicate row
await manager.create({ cwd: '/tmp/w' }) // same id returned: no duplicate row
expect(manager.getListSnapshot().items).toHaveLength(1)
api.onCreate = () => Promise.reject(new Error('create wire down'))
expect(await manager.create()).toMatchObject({ ok: false, error: { code: 'internal' } })
@@ -208,6 +249,42 @@ describe('remaining branches', () => {
expect(await manager.create()).toMatchObject({ ok: false })
})
it('publishes a real Ungrouped summary from workspace-attach-failed', async () => {
const api = new FakeApiClient()
api.onCreate = () => Promise.resolve(err({
code: 'workspace-attach-failed',
message: 'published but unattached',
details: { sessionId: S1, workspaceId: 'w1' },
} as never))
const manager = new SessionManager(api)
const result = await manager.create({ workspaceId: 'w1' as never, sessionId: S1 })
expect(result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } })
expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({ sessionId: S1 })])
expect(manager.getListSnapshot().items[0]).not.toHaveProperty('cwd')
})
it('reconciles a preallocated id after an ordinary transport failure', async () => {
const api = new FakeApiClient()
api.onCreate = () => Promise.reject(new Error('response lost'))
const manager = new SessionManager(api)
const failed = await manager.create({ workspaceId: 'w1' as never, sessionId: S1 })
expect(failed).toMatchObject({ ok: false, error: { message: 'response lost' } })
expect(manager.getListSnapshot().items).toEqual([])
manager.handleHostEnvelope({
rpcId: 'published-later' as never,
payload: { type: 'host/session-added', sessionId: S1, cwd: '/w/one' },
})
expect(manager.getListSnapshot().items).toEqual([
expect.objectContaining({ sessionId: S1, cwd: '/w/one' }),
])
manager.handleHostEnvelope({
rpcId: 'duplicate-frame' as never,
payload: { type: 'host/session-added', sessionId: S1, cwd: '/w/one' },
})
expect(manager.getListSnapshot().items).toHaveLength(1)
})
it('subscribe notifies on list changes and stops after unsubscribe', async () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)

View File

@@ -0,0 +1,191 @@
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client'
import { SessionsService } from '../src/client/sessions/service.ts'
import { WorkspacesService } from '../src/client/workspaces/service.ts'
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
const sid = (id: string): SessionId => id as SessionId
const wid = (id: string): WorkspaceId => id as WorkspaceId
function workspace(id: string, sessionIds: SessionId[] = []): WorkspaceView {
return {
workspaceId: wid(id),
path: `/w/${id}`,
title: id,
sessionIds,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
}
}
async function ready(
api: FakeApiClient,
workspaces: WorkspacesService,
sessions: SessionsService,
workspaceRows: WorkspaceView[],
sessionRows: { sessionId: SessionId; updatedAt: number; running: boolean }[] = [],
): Promise<void> {
api.onWorkspaceList = () => Promise.resolve(ok({ items: workspaceRows as never[] }))
api.onList = () => Promise.resolve(ok({ items: sessionRows as never[] }))
await Promise.all([workspaces.refresh(), sessions.refresh()])
await Promise.resolve()
}
function services(api: FakeApiClient): { sessions: SessionsService; workspaces: WorkspacesService } {
const ctx = new Context()
const sessions = new SessionsService(ctx, api)
const workspaces = new WorkspacesService(ctx, api, sessions)
return { sessions, workspaces }
}
function pendingPrompt(sessions: SessionsService, sessionId: SessionId) {
return sessions.binding(sessionId)?.session.getSnapshot().pendingPrompt
}
describe('frontend Session and Workspace intents', () => {
it('resolves the initial intent into the most recently active Workspace', async () => {
const api = new FakeApiClient()
const { sessions, workspaces } = services(api)
const old = workspace('old', [sid('s-old')])
const recent = workspace('recent', [sid('s-recent')])
await ready(api, workspaces, sessions, [old, recent], [
{ sessionId: sid('s-old'), updatedAt: 1, running: false },
{ sessionId: sid('s-recent'), updatedAt: 2, running: false },
])
expect(sessions.list.getSnapshot().intent).toMatchObject({
target: { kind: 'workspace', workspaceId: 'recent' },
phase: 'ready',
})
expect(workspaces.list.getSnapshot().intent).toBeUndefined()
})
it('materializes zero-state Workspace and Session intents and retains a rejected first prompt', async () => {
const api = new FakeApiClient()
const { sessions, workspaces } = services(api)
await ready(api, workspaces, sessions, [])
expect(workspaces.list.getSnapshot().intent).toMatchObject({ name: 'workspace', phase: 'ready' })
sessions.updateIntent('first prompt')
api.onWorkspaceCreate = () => Promise.resolve(ok({ workspace: workspace('created'), created: true }))
api.onCreate = payload => Promise.resolve(ok({
sessionId: (payload as { sessionId: SessionId }).sessionId,
}))
api.onPrompt = () => Promise.resolve(err({ code: 'internal', message: 'prompt offline', details: {} }))
workspaces.sendSession()
await vi.waitFor(() => {
const sessionId = sessions.list.getSnapshot().current as SessionId
expect(pendingPrompt(sessions, sessionId)).toMatchObject({
text: 'first prompt', phase: 'failed', retry: 'send',
})
})
expect(api.callsOf('workspace.create')).toEqual([{ name: 'workspace' }])
const create = api.callsOf('session.create')[0] as { workspaceId: WorkspaceId; sessionId: SessionId }
expect(create.workspaceId).toBe('created')
expect(api.callsOf('session.prompt')).toEqual([{
sessionId: create.sessionId,
mode: 'queue',
content: [{ type: 'text', text: 'first prompt' }],
}])
expect(workspaces.list.getSnapshot().intent).toBeUndefined()
})
it('turns Workspace attachment failure into a focused real Session and retries its prompt', async () => {
const api = new FakeApiClient()
const { sessions, workspaces } = services(api)
const target = workspace('target')
await ready(api, workspaces, sessions, [target])
sessions.updateIntent('keep this')
api.onCreate = (payload) => {
const sessionId = (payload as { sessionId: SessionId }).sessionId
return Promise.resolve(err({
code: 'workspace-attach-failed',
message: 'attach rejected',
details: { sessionId, workspaceId: target.workspaceId },
}))
}
workspaces.sendSession()
await vi.waitFor(() => {
const snapshot = sessions.list.getSnapshot()
expect(snapshot.intent).toBeUndefined()
expect(pendingPrompt(sessions, snapshot.current as SessionId)).toMatchObject({
text: 'keep this', phase: 'failed', retry: 'connect',
})
})
const published = sessions.list.getSnapshot().current as SessionId
const session = sessions.binding(published)!.session
session.updatePendingPrompt('retry this')
api.onCreate = () => Promise.resolve(ok({ sessionId: published }))
session.retryPendingPrompt()
await vi.waitFor(() => {
expect(pendingPrompt(sessions, published)).toBeNull()
})
expect(api.callsOf('session.prompt').at(-1)).toMatchObject({
sessionId: published,
content: [{ type: 'text', text: 'retry this' }],
})
})
it('does not send after navigation while Session creation is in flight', async () => {
const api = new FakeApiClient()
const { sessions, workspaces } = services(api)
const target = workspace('target')
await ready(api, workspaces, sessions, [target])
const gate = deferred<Awaited<ReturnType<FakeApiClient['onCreate']>>>()
api.onCreate = () => gate.promise
sessions.updateIntent('do not send yet')
workspaces.sendSession()
await vi.waitFor(() => { expect(api.callsOf('session.create')).toHaveLength(1) })
const requested = (api.callsOf('session.create')[0] as { sessionId: SessionId }).sessionId
workspaces.startSession(target.workspaceId)
const replacement = sessions.list.getSnapshot().intent!
gate.resolve(ok({ sessionId: requested }))
await vi.waitFor(() => {
expect(pendingPrompt(sessions, requested)).toMatchObject({
text: 'do not send yet', phase: 'failed', retry: 'send',
})
})
expect(api.callsOf('session.prompt')).toEqual([])
expect(sessions.list.getSnapshot()).toMatchObject({
current: replacement.sessionId,
intent: { sessionId: replacement.sessionId },
})
})
it('keeps a lost-response Intent and retries creation with its preallocated id', async () => {
const api = new FakeApiClient()
const { sessions, workspaces } = services(api)
const target = workspace('target')
await ready(api, workspaces, sessions, [target])
sessions.updateIntent('preserve me')
api.onCreate = () => Promise.reject(new Error('response lost'))
workspaces.sendSession()
await vi.waitFor(() => {
expect(sessions.list.getSnapshot().intent?.error).toMatchObject({ step: 'session' })
})
const requested = sessions.list.getSnapshot().intent?.sessionId as SessionId
sessions.handleHostEnvelope({
rpcId: 'published-later' as never,
payload: { type: 'host/session-added', sessionId: requested, cwd: target.path },
})
expect(sessions.list.getSnapshot()).toMatchObject({
current: requested,
intent: { sessionId: requested, error: { step: 'session' } },
})
expect(sessions.intent()?.getSnapshot().pendingPrompt).toMatchObject({
text: 'preserve me', phase: 'editing',
})
api.onCreate = payload => Promise.resolve(ok({
sessionId: (payload as { sessionId: SessionId }).sessionId,
}))
workspaces.sendSession()
await vi.waitFor(() => {
expect(api.callsOf('session.create')).toHaveLength(2)
expect(api.callsOf('session.prompt')).toHaveLength(1)
expect(sessions.list.getSnapshot()).toMatchObject({ current: requested, intent: undefined })
expect(pendingPrompt(sessions, requested)).toBeNull()
})
expect(api.callsOf('session.create').map(call => (call as { sessionId: SessionId }).sessionId))
.toEqual([requested, requested])
})
})

View File

@@ -217,19 +217,33 @@ describe('paging', () => {
})
describe('prompt and cancel errors', () => {
it('sends content through session.prompt with the mode passed through', async () => {
it('sends content through session.prompt; composerPhase steps blank → engaging synchronously at send entry', async () => {
const { api, session } = makeSession()
const result = await session.prompt([{ type: 'text', text: '要发的' }], 'queue')
// The blank → engaging edge fires before the RPC settles: the first-send
// flow reads the phase on the session area's first frame to keep the
// guidance hero from flashing back in.
expect(session.getSnapshot().composerPhase).toBe('blank')
const inFlight = session.prompt([{ type: 'text', text: '要发的' }], 'queue')
expect(session.getSnapshot().composerPhase).toBe('engaging')
const result = await inFlight
expect(result.ok).toBe(true)
// Monotone: settlement alone does not step the phase anywhere.
expect(session.getSnapshot().composerPhase).toBe('engaging')
expect(api.callsOf('session.prompt')).toMatchObject([{ sessionId: SID, mode: 'queue', content: [{ type: 'text', text: '要发的' }] }])
// First content lands (running turn): engaging → active.
session.handleRunning(true)
expect(session.getSnapshot().composerPhase).toBe('active')
})
it('business failure lands in promptError with op=send', async () => {
it('business failure lands in promptError with op=send; the phase stays engaging (retry, no hero bounce)', async () => {
const { api, session } = makeSession()
api.onPrompt = () => Promise.resolve(err({ code: 'agent-busy', message: 'busy', details: { reason: 'x' } }))
const result = await session.prompt([{ type: 'text', text: '失败的' }], 'queue')
expect(result.ok).toBe(false)
expect(session.getSnapshot().promptError).toMatchObject({ op: 'send', error: { code: 'agent-busy' } })
// Failed first prompt: composer + error strip is the retry surface —
// blank is unreachable once a send was initiated.
expect(session.getSnapshot().composerPhase).toBe('engaging')
})
it('lands cancel failures in promptError with op=stop', async () => {

View File

@@ -9,7 +9,7 @@
import { Context } from 'cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { SessionsService, scopeOf } from '../src/client/sessions/service.ts'
import { SessionCreateError, SessionsService, scopeOf } from '../src/client/sessions/service.ts'
import { FakeApiClient, ok } from './fake-api.ts'
const sid = (s: string): SessionId => s as SessionId
@@ -36,14 +36,14 @@ async function feedList(b: Bench, rows: { id: string; cwd?: string; parentId?: s
...(r.parentId !== undefined ? { parentSessionId: sid(r.parentId) } : {}),
})),
}) as never)
await b.svc.manager.refreshList()
await b.svc.refresh()
await Promise.resolve() // manager notifier flush
}
describe('list store projection', () => {
it('projects durable titles separately from cwd/id display fallbacks and parent links', async () => {
const b = bench()
b.svc.manager.handleMuxEnvelope({
b.svc.handleMuxEnvelope({
rpcId: 'title' as never,
payload: { type: 'session/title', sessionId: sid('s1'), title: 'Durable title', eventSeq: 2, updatedAt: 3 },
})
@@ -61,7 +61,7 @@ describe('list store projection', () => {
it('reflects live increments (host stream via manager) into the store', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
b.svc.manager.handleHostEnvelope({ rpcId: 'r1' as never, payload: { type: 'host/session-added', sessionId: sid('s2') } as never })
b.svc.handleHostEnvelope({ rpcId: 'r1' as never, payload: { type: 'host/session-added', sessionId: sid('s2') } as never })
await Promise.resolve()
expect(b.svc.list.getSnapshot().ids).toContain('s2')
})
@@ -77,7 +77,7 @@ describe('scope tree', () => {
expect(scopeOf(scoped as Context)).toBe('s1')
expect(scopeOf(b.ctx)).toBeUndefined()
const binding = b.svc.binding(sid('s1'))
expect(binding?.session).toBe(b.svc.manager.get(sid('s1')))
expect(binding?.session).toBe(b.svc.cell('s1')?.session)
expect(b.svc.binding(sid('s1'))).toBe(binding)
expect(binding?.ctx).toBe(scoped)
})
@@ -187,8 +187,8 @@ describe('cell (render-layer session kit)', () => {
const cell = b.svc.cell('s1')
expect(cell).toBeDefined()
expect(cell?.sessionId).toBe('s1')
// Hook binding happens in React; the cell carries the observable itself.
expect(cell?.session).toBe(b.svc.manager.get(sid('s1')))
// The cell carries the observable; hook binding happens in React.
expect(cell?.session).toBe(b.svc.binding(sid('s1'))?.session)
expect(b.svc.cell('s1')).toBe(cell)
expect(b.svc.cell('ghost')).toBeUndefined()
})
@@ -284,36 +284,45 @@ describe('ancestry', () => {
})
describe('create', () => {
it('returns the new id on ok and throws a coded error on failure', async () => {
it('passes a preallocated id and preserves it on ordinary failure', async () => {
const b = bench()
b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('fresh') }))
await expect(b.svc.create({ cwd: '/w' })).resolves.toBe('fresh')
await expect(b.svc.create({ cwd: '/w', sessionId: sid('fresh') })).resolves.toBe('fresh')
expect(b.api.callsOf('session.create')).toEqual([{ cwd: '/w', sessionId: 'fresh' }])
b.api.onCreate = () => Promise.resolve({
rpcId: 'e' as never,
result: { ok: false as const, error: { code: 'internal' as const, message: '爆了', details: {} } },
} as never)
await expect(b.svc.create()).rejects.toThrow(/internal: 爆了/)
})
})
describe('createWorkspace', () => {
it('joins host.describe cwd with the name and creates there', async () => {
const b = bench()
b.api.onDescribe = () => Promise.resolve(ok({ version: '0', cwd: '/host/root', attachedSessions: 0 }))
b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('ws') }))
await expect(b.svc.createWorkspace('My Proj')).resolves.toBe('ws')
expect(b.api.callsOf('session.create')).toEqual([{ cwd: '/host/root/My Proj' }])
const failure = await b.svc.create({ sessionId: sid('candidate') }).catch((error: unknown) => error)
expect(failure).toBeInstanceOf(SessionCreateError)
expect(failure).toMatchObject({
requestedSessionId: 'candidate', publishedSessionId: undefined,
rpcError: { code: 'internal', message: '爆了' },
})
})
it('rejects empty names and path separators; surfaces describe failures', async () => {
it('surfaces the definitely published id after Workspace attachment fails', async () => {
const b = bench()
await expect(b.svc.createWorkspace(' ')).rejects.toThrow(/name is required/)
await expect(b.svc.createWorkspace('a/b')).rejects.toThrow(/path separators/)
b.api.onDescribe = () => Promise.resolve({
rpcId: 'e' as never,
result: { ok: false as const, error: { code: 'internal' as const, message: 'down', details: {} } },
b.api.onCreate = () => Promise.resolve({
rpcId: 'attach' as never,
result: {
ok: false,
error: {
code: 'workspace-attach-failed', message: 'ledger unavailable',
details: { sessionId: sid('published'), workspaceId: 'ws' },
},
},
} as never)
await expect(b.svc.createWorkspace('ok')).rejects.toThrow(/host.describe failed/)
const failure = await b.svc.create({
workspaceId: 'ws' as never,
sessionId: sid('published'),
}).catch((error: unknown) => error)
await Promise.resolve()
expect(failure).toMatchObject({
publishedSessionId: 'published', requestedSessionId: 'published',
rpcError: { code: 'workspace-attach-failed' },
})
expect(b.svc.list.getSnapshot().byId[sid('published')]).toMatchObject({ id: 'published' })
})
})

View File

@@ -85,11 +85,18 @@ function captureHost(bench: Bench, children?: object): SlotRendererHost {
})
bench.erased.register({ name: 'root', ...(children !== undefined ? { children } : {}) }, C)
bench.ctx.reflect.provide('sessions', fakeSessions())
bench.ctx.reflect.provide('workspaces', fakeWorkspaces())
bench.erased.renderSlot('root', {})
if (host === undefined) throw new Error('renderer never received the host')
return host
}
/** Minimal independent Workspace list source for the renderer host seam. */
function fakeWorkspaces() {
const state = { items: [], phase: 'ready' as const }
return { list: { getSnapshot: () => state, subscribe: () => () => undefined } }
}
/** Minimal sessions face for the host seam (list observable + cell). */
function fakeSessions() {
const state = { ids: [], byId: {}, current: undefined as string | undefined }
@@ -190,9 +197,18 @@ describe('renderer install seam', () => {
bench.erased.install({ renderRoot })
bench.erased.register({ name: 'root' }, C)
bench.ctx.reflect.provide('sessions', fakeSessions())
bench.ctx.reflect.provide('workspaces', fakeWorkspaces())
expect(bench.erased.renderSlot('root', {})).toBe('tree')
expect(renderRoot).toHaveBeenCalledTimes(1)
})
it('fails before rendering when the Workspace object layer is absent', async () => {
const bench = await boot()
bench.erased.install({ renderRoot: () => null })
bench.erased.register({ name: 'root' }, C)
bench.ctx.reflect.provide('sessions', fakeSessions())
expect(() => bench.erased.renderSlot('root', {})).toThrow(/workspaces service mounted/)
})
})
describe('host face', () => {
@@ -220,6 +236,12 @@ describe('host face', () => {
expect(host.sessions.cell('known')).toMatchObject({ sessionId: 'known' })
expect(host.sessions.cell('ghost')).toBeUndefined()
})
it('exposes the independent Workspace list source', async () => {
const bench = await boot()
const host = captureHost(bench)
expect(host.workspaces.list.getSnapshot()).toEqual({ items: [], phase: 'ready' })
})
})
describe('store instance axis', () => {
@@ -315,6 +337,7 @@ describe('entry-unload cascade', () => {
renderRoot: (h: SlotRendererHost) => { host = h; return 'rendered' },
})
bench.ctx.reflect.provide('sessions', fakeSessions())
bench.ctx.reflect.provide('workspaces', fakeWorkspaces())
// The declarer here is NOT the root occupant: root stays occupied by a
// separate entry so disposing the declarer only kills its children.
const disposeRoot = bench.erased.register({ name: 'root' }, C)

View File

@@ -0,0 +1,157 @@
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client'
import { SessionsService } from '../src/client/sessions/service.ts'
import { WorkspaceManager } from '../src/client/workspaces/manager.ts'
import { WorkspacesService } from '../src/client/workspaces/service.ts'
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
const sid = (id: string): SessionId => id as SessionId
const wid = (id: string): WorkspaceId => id as WorkspaceId
function workspace(id: string, sessionIds: SessionId[] = [], createdAt = '2026-01-01T00:00:00.000Z'): WorkspaceView {
return {
workspaceId: wid(id), path: `/w/${id}`, title: id, sessionIds,
createdAt, updatedAt: createdAt,
}
}
describe('WorkspaceManager', () => {
it('owns, materializes, retries, supersedes, and discards Workspace objects with local intents', async () => {
const api = new FakeApiClient()
const manager = new WorkspaceManager(api)
manager.startIntent('first')
expect(manager.getSnapshot().intent).toEqual({ name: 'first', phase: 'ready' })
api.onWorkspaceCreate = () => Promise.resolve(err({
code: 'workspace-name-conflict', message: 'taken', details: { name: 'first' },
} as never))
await expect(manager.materializeIntent()).resolves.toMatchObject({ ok: false })
expect(manager.getSnapshot().intent).toMatchObject({ name: 'first', phase: 'ready' })
expect(typeof manager.getSnapshot().intent?.error).toBe('string')
const gate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceCreate']>>>()
api.onWorkspaceCreate = () => gate.promise
const stale = manager.materializeIntent()
expect(manager.getSnapshot().intent?.phase).toBe('creating')
manager.startIntent('replacement')
gate.resolve(ok({ workspace: workspace('first'), created: true }))
await stale
expect(manager.getSnapshot().intent).toEqual({ name: 'replacement', phase: 'ready' })
api.onWorkspaceCreate = () => Promise.resolve(ok({ workspace: workspace('replacement'), created: true }))
await expect(manager.materializeIntent()).resolves.toMatchObject({ ok: true })
expect(manager.getSnapshot().intent).toBeUndefined()
await expect(manager.materializeIntent()).resolves.toBeUndefined()
manager.discardIntent()
manager.startIntent('discarded')
manager.discardIntent()
expect(manager.getSnapshot().intent).toBeUndefined()
})
it('replays changed frames over hydration and keeps established order on refresh', async () => {
const api = new FakeApiClient()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceList']>>>()
api.onWorkspaceList = () => gate.promise
const manager = new WorkspaceManager(api)
const hydration = manager.refresh()
manager.handleHostEnvelope({
rpcId: 'changed' as never,
payload: { type: 'host/workspace-changed', workspace: workspace('new') },
})
gate.resolve(ok({ items: [workspace('old')] as never[] }))
await hydration
expect(manager.getSnapshot()).toMatchObject({ phase: 'ready', state: 'idle' })
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['new', 'old'])
api.onWorkspaceList = () => Promise.resolve(ok({
items: [workspace('old'), workspace('new')] as never[],
}))
await manager.refresh()
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['new', 'old'])
})
it('single-flights refreshes and exposes result and transport failures independently of readiness', async () => {
const api = new FakeApiClient()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceList']>>>()
api.onWorkspaceList = () => gate.promise
const manager = new WorkspaceManager(api)
const first = manager.refresh()
const second = manager.refresh()
expect(manager.getSnapshot().state).toBe('loading')
gate.resolve(ok({ items: [] }))
await Promise.all([first, second])
expect(api.callsOf('workspace.list')).toHaveLength(1)
api.onWorkspaceList = () => Promise.resolve(err({ code: 'internal', message: 'down', details: {} }))
await manager.refresh()
expect(manager.getSnapshot()).toMatchObject({ phase: 'ready', state: 'error', error: { message: 'down' } })
api.onWorkspaceList = () => Promise.reject(new Error('wire down'))
await manager.refresh()
expect(manager.getSnapshot()).toMatchObject({ phase: 'ready', state: 'error', error: { message: 'wire down' } })
})
it('creates by name/path, prepends a new row, and folds failures', async () => {
const api = new FakeApiClient()
const manager = new WorkspaceManager(api)
api.onWorkspaceCreate = payload => Promise.resolve(ok({
workspace: workspace('created', [], '2026-02-01T00:00:00.000Z'),
created: true,
payload,
} as never))
await expect(manager.create({ name: 'created' })).resolves.toMatchObject({ ok: true })
expect(api.callsOf('workspace.create')).toEqual([{ name: 'created' }])
expect(manager.getSnapshot().items[0]?.workspaceId).toBe('created')
api.onWorkspaceCreate = () => Promise.reject(new Error('create transport'))
await expect(manager.create({ path: '/w/existing' })).resolves.toMatchObject({
ok: false, error: { code: 'internal', message: 'create transport' },
})
})
})
describe('WorkspacesService', () => {
it('feeds SessionManager readiness and recent-Workspace targeting without changing Host order', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api)
const workspaces = new WorkspacesService(ctx, api, sessions)
api.onWorkspaceList = () => Promise.resolve(ok({
items: [
workspace('stable-first', [], '2026-01-03T00:00:00.000Z'),
workspace('active', [sid('s-active')], '2026-01-01T00:00:00.000Z'),
] as never[],
}))
await workspaces.refresh()
await Promise.resolve()
expect(workspaces.list.getSnapshot()).toMatchObject({ baselinesReady: false, recentWorkspaceId: undefined })
api.onList = () => Promise.resolve(ok({
items: [{ sessionId: sid('s-active'), updatedAt: Date.parse('2026-02-01'), running: false }] as never[],
}))
await sessions.refresh()
await Promise.resolve()
await Promise.resolve()
expect(workspaces.list.getSnapshot()).toMatchObject({
baselinesReady: true,
recentWorkspaceId: 'active',
})
expect(sessions.list.getSnapshot().intent).toMatchObject({
target: { kind: 'workspace', workspaceId: 'active' },
})
expect(workspaces.list.getSnapshot().items.map(item => item.workspaceId)).toEqual(['stable-first', 'active'])
})
it('returns created Workspaces and preserves Host business errors', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api)
const workspaces = new WorkspacesService(ctx, api, sessions)
await expect(workspaces.create({ path: '/w/existing' })).resolves.toMatchObject({ workspaceId: 'fk-ws' })
expect(api.callsOf('workspace.create')).toEqual([{ path: '/w/existing' }])
api.onWorkspaceCreate = () => Promise.resolve(err({
code: 'workspace-invalid-path', message: 'missing', details: { path: '/missing' },
}))
await expect(workspaces.create({ path: '/missing' })).rejects.toThrow(/workspace-invalid-path: missing/)
})
})