refactor(ui): add Session and Workspace React adapters

This commit is contained in:
imccyu
2026-08-23 16:28:16 +08:00
parent 1b535f611c
commit d231c8777a
45 changed files with 2566 additions and 1924 deletions
@@ -1,94 +0,0 @@
/**
* The outward workspaces-service face — what `ctx.workspaces` exposes to
* feature packages and the renderer host, and therefore exactly what the
* test runtime's workspaces double must implement. Wire-pump entry points
* (handleHostEnvelope/handleConnected/refresh/startInitialSelection) stay on
* the concrete class. Widening this interface is the explicit act of
* widening what features may do to the workspaces domain.
*/
import type { DirectoryListing, SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-remotes/client'
import type { WorkspaceListState } from '../workspaces/service.ts'
import type { ObservableSnapshot } from './store.ts'
/** The workspaces-service face injected as `ctx.workspaces`. */
export interface IWorkspaces {
/** The useWorkspaces standard feed (read face — writes stay inside the domain). */
readonly list: ObservableSnapshot<WorkspaceListState>
/**
* Connect a Workspace to its reusable or freshly created blank session.
* @param workspaceId - target workspace.
* @returns the connected session id.
*/
connectWorkspace(workspaceId: WorkspaceId): Promise<SessionId>
/**
* The New Session flow: connect the explicit, current-Session, or recent
* Workspace and open the resulting session; failures surface on the session
* list state.
* @param workspaceId - explicit target; omitted inherits the current
* Session's Workspace before falling back to the recency projection.
*/
startSession(workspaceId?: WorkspaceId): void
/**
* Register an existing path as a Workspace.
* @param input - the Host create payload.
* @returns the created or idempotently resolved Workspace.
*/
create(input: { path: string }): Promise<WorkspaceView>
/**
* Open the Host's native directory picker.
* @returns the selected path, or null when the user cancelled.
*/
pickDirectory(): Promise<string | null>
/**
* List one directory level through the Host's `browse` capability.
* @param path - absolute directory to list; absent lists the Host home directory.
* @param signal - aborts the wire request (and the Host's scan) when the caller supersedes it.
* @returns the level's listing with breadcrumb ancestry.
*/
listDirectory(path?: string, signal?: AbortSignal): Promise<DirectoryListing>
/**
* Create one child directory through the Host's `browse` capability.
* @param path - absolute existing parent directory.
* @param name - single non-blank path segment.
* @returns the created directory's absolute path.
*/
createDirectory(path: string, name: string): Promise<string>
/**
* Open a filesystem path with the Host operating system's default application.
* @param path - absolute or host-resolvable path.
*/
openPath(path: string): Promise<void>
/**
* Rename a Workspace.
* @param workspaceId - target workspace.
* @param title - the new display title.
* @returns the updated Workspace view.
*/
rename(workspaceId: WorkspaceId, title: string): Promise<WorkspaceView>
/**
* Delete a Workspace (its sessions fall back to the unaccounted group).
* @param workspaceId - target workspace.
*/
delete(workspaceId: WorkspaceId): Promise<void>
/**
* Move a Workspace within the registry display order.
* @param workspaceId - Workspace to move.
* @param beforeWorkspaceId - Anchor workspace; omitted appends.
*/
insertBefore(workspaceId: WorkspaceId, beforeWorkspaceId?: WorkspaceId): Promise<void>
/**
* Move an accounted session within/into a Workspace's ordered list.
* @param workspaceId - target workspace.
* @param sessionId - accounted session to move.
* @param beforeSessionId - accounted anchor to insert before; omitted appends.
* @returns the updated Workspace view.
*/
insertSessionBefore(workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId): Promise<WorkspaceView>
/**
* Archive a session into the registry-global set (hidden from grouping
* surfaces; session log and accounting slot remain). Archiving the current
* session clears the selection into the New Session view state.
* @param sessionId - session to archive.
*/
archiveSession(sessionId: SessionId): Promise<void>
}
@@ -1,190 +0,0 @@
/**
* The session standard-props provide channel: provider roster, bundle
* materialization (fail-loud on undeclared/missing/duplicate members), the
* static no-session projection, and the atomic current-session projection
* observable. One implementation — SessionRuntime drives it from wire
* truth, the test runtime's sessions double drives it from fixtures — so
* the materialization rules and the projection semantics cannot drift
* between production and the test bench.
*/
import type { HostObservable, SessionMaybeProvideInfo, SessionProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionBinding, SessionProvideDescriptor } from './service.ts'
/** The owner-side hooks: how the channel reaches the owner's live bundles and current selection. */
export interface SessionProvideChannelHost {
/**
* Re-materialize every already-materialized bundle against the new roster
* (call {@link SessionProvideChannel.materializeInfo} per live binding).
* Lazily-materialized sessions pick the new roster up on first resolve.
*/
rebuildBundles(): void
/** Resolve the current selection's bundle (the owner's maybe-provide lookup). */
resolveCurrent(): SessionMaybeProvideInfo
}
/**
* Provider roster + materialization + current projection. The channel owns
* every rule a provider contribution must satisfy; owners keep only their
* per-session bundle storage and the definition of "current".
*/
export class SessionProvideChannel {
private readonly providers: SessionProvideDescriptor[] = []
private maybeInfoCache: SessionMaybeProvideInfo
/** Latest published current bundle (identity comparison dedupes republish). */
private currentSnapshot: SessionMaybeProvideInfo
/** Projection subscribers (plain cell: bundles hold live session sources, so no store freeze may touch them). */
private readonly listeners = new Set<() => void>()
/**
* Atomic current-session provide projection: selection changes and
* provider-roster changes publish through this one source, so a roster
* change under a stable current id republishes the bundle instead of
* stranding mounted entries.
*/
readonly currentProvideInfo: HostObservable<SessionMaybeProvideInfo>
/**
* @param host - owner-side bundle storage and current-selection resolution.
*/
constructor(private readonly host: SessionProvideChannelHost) {
// The runtime's own contribution comes first: useSession rides the same
// provide channel every plugin uses (no renderer special case).
this.providers.push({
hooks: ['session'],
resolve: binding => ({ hooks: { session: binding.session } }),
})
this.maybeInfoCache = this.materializeMaybeInfo()
this.currentSnapshot = this.maybeInfoCache
this.currentProvideInfo = {
getSnapshot: () => this.currentSnapshot,
subscribe: (fn) => {
this.listeners.add(fn)
return () => { this.listeners.delete(fn) }
},
}
}
/** The static no-session projection under the current roster (declared names present, values undefined). */
get maybeInfo(): SessionMaybeProvideInfo {
return this.maybeInfoCache
}
/**
* Register a per-session standard-props provider (see
* SessionRuntime.provide for the product contract). Live bundles rebuild
* immediately; misdeclared providers fail loud here, at the registration
* edge, and the registration rolls back — the channel never stays on a
* roster it cannot materialize.
* @param descriptor - static member roster plus per-session resolver.
* @returns disposer removing the provider.
*/
provide(descriptor: SessionProvideDescriptor): () => void {
this.providers.push(descriptor)
try {
this.applyRosterChange()
} catch (error) {
this.providers.splice(this.providers.indexOf(descriptor), 1)
// Restore the previous (valid) roster's bundles; cannot rethrow — the
// pre-push roster materialized successfully before.
this.applyRosterChange()
throw error
}
return () => {
const at = this.providers.indexOf(descriptor)
if (at >= 0) this.providers.splice(at, 1)
this.applyRosterChange()
}
}
/**
* Re-derive the current selection's bundle and publish it when it changed.
* Bundles are identity-stable per (scope, roster) materialization, so an
* identity compare is exact; synchronous notify — call sites (the owner's
* list subscription, provide()) already sit behind their own batching or
* registration edges.
*/
publishCurrent(): void {
const next = this.host.resolveCurrent()
if (next === this.currentSnapshot) return
this.currentSnapshot = next
for (const fn of [...this.listeners]) {
try {
fn()
} catch (error) {
// Contain subscriber failures: this notify runs inside the list
// notification, where a throwing render-side subscriber would starve
// later listeners and abort the projection pass that scheduled it.
console.error('sessions.currentProvideInfo subscriber failed:', error)
}
}
}
/**
* Materialize the standard-props bundle for one session (fails loud on
* undeclared, missing, and duplicate member names).
* @param binding - session assembly handle fed to every resolver.
* @returns the materialized bundle (identity-stable until the next materialization).
*/
materializeInfo(binding: SessionBinding): SessionProvideInfo {
const hooks: Record<string, HostObservable<unknown>> = {}
const props: Record<string, unknown> = {}
for (const descriptor of this.providers) {
const contribution = descriptor.resolve(binding)
const contributedHooks = contribution.hooks ?? {}
const contributedProps = contribution.props ?? {}
for (const name of Object.keys(contributedHooks)) {
if (!(descriptor.hooks ?? []).includes(name)) {
throw new Error(`sessions.provide: undeclared hook "${name}"`)
}
}
for (const name of Object.keys(contributedProps)) {
if (!(descriptor.props ?? []).includes(name)) {
throw new Error(`sessions.provide: undeclared prop "${name}"`)
}
}
for (const name of descriptor.hooks ?? []) {
const source = contributedHooks[name]
if (source === undefined) throw new Error(`sessions.provide: missing hook "${name}"`)
if (Object.hasOwn(hooks, name)) throw new Error(`sessions.provide: duplicate hook "${name}"`)
hooks[name] = source
}
for (const name of descriptor.props ?? []) {
if (!Object.hasOwn(contributedProps, name)) throw new Error(`sessions.provide: missing prop "${name}"`)
if (Object.hasOwn(props, name)) throw new Error(`sessions.provide: duplicate prop "${name}"`)
props[name] = contributedProps[name]
}
}
return {
sessionId: binding.sessionId,
hooks,
props,
// The useProjection seat: key-addressed bare value faces off the
// session's projection store (open key space — never a static roster member).
projections: { faceOf: key => binding.session.projections.faceOf(key) },
}
}
/** Rebuild the static projection and the owner's live bundles, then republish the current one. */
private applyRosterChange(): void {
this.maybeInfoCache = this.materializeMaybeInfo()
this.host.rebuildBundles()
this.publishCurrent()
}
/** Build the static no-session kit and reject duplicate declared names. */
private materializeMaybeInfo(): SessionMaybeProvideInfo {
const hooks: Record<string, undefined> = {}
const props: Record<string, undefined> = {}
for (const descriptor of this.providers) {
for (const name of descriptor.hooks ?? []) {
if (Object.hasOwn(hooks, name)) throw new Error(`sessions.provide: duplicate hook "${name}"`)
hooks[name] = undefined
}
for (const name of descriptor.props ?? []) {
if (Object.hasOwn(props, name)) throw new Error(`sessions.provide: duplicate prop "${name}"`)
props[name] = undefined
}
}
return { sessionId: undefined, hooks, props } // no projections face: every key reads absent without a session
}
}
@@ -1,362 +0,0 @@
/** WorkspaceRuntime combines controller-owned Workspace state with Session/UI behavior. */
import type { Context } from '@deepseek-ai/cordis'
import type {
DirectoryListing, IApiClient, RpcError,
SessionId, WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-api-remotes/client'
import type {
ClientWorkspaceModel, WorkspaceListPhase,
} from '@deepseek-ai/dsh-api-workspace-controller/client'
import type { RemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
import type { SnapshotStore } from '../contract/store.ts'
import { createSnapshotStore } from '../contract/store.ts'
import type { SessionsPort, SessionsPortList } from '../contract/sessions-port.ts'
import type { IWorkspaces } from '../contract/workspaces.ts'
/** Workspace list plus the two-baseline readiness and default-target projection. */
export interface WorkspaceListState {
items: readonly WorkspaceView[]
/**
* Registry-global archive set in Host order: grouping surfaces hide these
* sessions everywhere (workspace groups and the ungrouped bucket) while
* their session logs and workspace accounting slots remain. A plain array
* (store-engine vocabulary; immer drafts reject Sets) — membership lookups
* build their own transient Set.
*/
archivedSessionIds: readonly SessionId[]
state: 'idle' | 'loading' | 'error'
phase: WorkspaceListPhase
error: RemoteFailure | null
/** True only after both Workspace and Session stream baselines have arrived. */
baselinesReady: boolean
/** Most recently active Workspace, derived without changing `items` order. */
recentWorkspaceId: WorkspaceId | undefined
}
/** Structured create failure for UI flows that distinguish Host business errors. */
export class WorkspaceCreateError extends Error {
constructor(readonly rpcError: RemoteFailure) {
super(`workspace create failed: ${rpcError.code}: ${rpcError.message}`)
this.name = 'WorkspaceCreateError'
}
}
/** Structured browse failure so the directory browser can branch on Host business codes. */
export class DirectoryBrowseError extends Error {
constructor(readonly rpcError: RpcError) {
super(`directory browse failed: ${rpcError.code}: ${rpcError.message}`)
this.name = 'DirectoryBrowseError'
}
}
/** Real Workspace object layer and Host actions. */
export class WorkspaceRuntime implements IWorkspaces {
/** UI-facing projection derived from the controller model and Session list. */
readonly list: SnapshotStore<WorkspaceListState>
/** In-flight blank-session creates keyed by workspace (connectWorkspace coalescing). */
private readonly connecting = new Map<WorkspaceId, Promise<SessionId>>()
/** Guards the runtime-owned one-shot initial-selection subscription. */
private initialSelectionStarted = false
/**
* @param ctx - client root context.
* @param api - shared wire client.
* @param model - Workspace Controller's Client state model.
* @param sessions - cross-domain sessions face used for recency and blank-session reuse.
*/
constructor(
ctx: Context,
private readonly api: IApiClient,
private readonly model: ClientWorkspaceModel,
private readonly sessions: SessionsPort,
) {
this.list = createSnapshotStore<WorkspaceListState>({
items: [], archivedSessionIds: [], state: 'loading', phase: 'pending', error: null,
baselinesReady: false, recentWorkspaceId: undefined,
})
this.model.subscribe(() => { this.project() })
this.sessions.list.subscribe(() => { this.project() })
ctx.reflect.provide('workspaces', this, undefined)
}
/**
* Resolve the session a New Session flow lands in once this Workspace is
* chosen: reuse the workspace's existing blank session when one is in the
* list mirror, else create a fresh one on the host (`session.create` births
* the full Session+Agent — the client holds no intermediate state). The
* caller owns navigation: take the returned id to `sessions.open`.
* Resolution guarantee (both arms): the returned id is already in the list
* store and `sessions.binding(id)` resolves synchronously — draft hand-off
* may write the new scope's machine before opening.
* @param workspaceId - chosen Workspace (must be in the workspace list).
* @returns the reused or newly created session id.
*/
async connectWorkspace(workspaceId: WorkspaceId): Promise<SessionId> {
const workspace = this.list.getSnapshot().items.find(item => item.workspaceId === workspaceId)
if (workspace === undefined) throw new Error(`workspaces.connectWorkspace: unknown workspace ${workspaceId}`)
// Coalesce concurrent connects: a create's summary lands without cwd
// until the host frame arrives, so a second call inside that window
// would miss the reuse scan and mint another hidden blank session.
const inflight = this.connecting.get(workspaceId)
if (inflight !== undefined) return inflight
// Reuse requires workspace membership (id in sessionIds AND same
// canonical cwd — the host's own membership rule), never cwd alone:
// a cwd match can belong to no account (sessions the CLI/TUI birthed at
// the host cwd, or a deleted/recreated registration) and reusing it
// would open a session no grouping surface shows under this workspace.
// An archived blank is never reused either: reuse would open a session
// no grouping surface can show, so New Session mints a fresh one instead.
const archived = this.list.getSnapshot().archivedSessionIds
const sessions = this.sessions.list.getSnapshot()
for (const id of sessions.ids) {
const summary = sessions.byId[id]
if (summary !== undefined && summary.blank && summary.cwd === workspace.path
&& workspace.sessionIds.includes(summary.id)
&& !archived.includes(summary.id)) return summary.id
}
const attempt = this.sessions.create({ workspaceId })
.finally(() => { this.connecting.delete(workspaceId) })
this.connecting.set(workspaceId, attempt)
return attempt
}
/**
* Follow the first complete Workspace/Session baseline and select a default
* session exactly once. A restored current session wins; otherwise the most
* recent Workspace is connected (reusing or creating its blank session).
* Later explicit clears stay cleared instead of retriggering this startup
* policy. A failed connect may retry on the next baseline projection.
* @returns disposer for the baseline subscription; late work cannot navigate after disposal.
*/
startInitialSelection(): () => void {
if (this.initialSelectionStarted) {
throw new Error('workspaces.startInitialSelection: already started')
}
this.initialSelectionStarted = true
let state: 'waiting' | 'connecting' | 'done' = 'waiting'
let disposed = false
const reconcile = (): void => {
if (disposed || state !== 'waiting') return
const workspace = this.list.getSnapshot()
if (!workspace.baselinesReady) return
const current = this.sessions.list.getSnapshot().current
const target = workspace.recentWorkspaceId
if (current !== undefined || target === undefined) {
state = 'done'
return
}
state = 'connecting'
void this.connectWorkspace(target).then(
(sessionId) => {
if (disposed) return
if (this.sessions.list.getSnapshot().current === undefined) {
this.sessions.open(sessionId)
}
state = 'done'
},
(reason: unknown) => {
if (disposed) return
state = 'waiting'
console.warn('initial workspace selection failed:', reason)
},
)
}
const unsubscribe = this.list.subscribe(reconcile)
reconcile()
return () => {
disposed = true
unsubscribe()
}
}
/**
* The shared New Session action behind the shell entry points (sidebar
* button, workspace browser): resolve the target Workspace — explicit wins,
* then the current Session's Workspace, then the recent-Workspace
* projection — connect its blank session and navigate there; with no
* Workspace at all, clear the selection into the New Session view state.
* Connect failures are non-fatal (console diagnostics; the current view
* stays usable).
* @param workspaceId - explicit target Workspace for scoped actions.
*/
startSession(workspaceId?: WorkspaceId): void {
const workspace = this.list.getSnapshot()
const current = this.sessions.list.getSnapshot().current
const currentWorkspaceId = current === undefined
? undefined
: workspace.items.find(item => item.sessionIds.includes(current))?.workspaceId
const target = workspaceId ?? currentWorkspaceId ?? workspace.recentWorkspaceId
if (target === undefined) {
this.sessions.clear()
return
}
void this.connectWorkspace(target).then(
(sessionId) => { this.sessions.open(sessionId) },
(reason: unknown) => { console.warn('new session failed:', reason) },
)
}
/**
* Register an existing path as a Workspace.
* @param input - the Host create payload.
* @returns the created or idempotently resolved Workspace.
*/
async create(input: { path: string }): Promise<WorkspaceView> {
const result = await this.model.create(input)
if (!result.ok) throw new WorkspaceCreateError(result.error)
return result.value.workspace
}
/**
* Open the Host's native directory picker (the `native` capability).
* @returns the selected path, or null when the user cancelled.
*/
async pickDirectory(): Promise<string | null> {
const response = await this.api.host.pickDirectory({})
if (!response.result.ok) {
throw new Error(`directory picker failed: ${response.result.error.message}`)
}
return response.result.value.path
}
/**
* List one directory level through the Host's `browse` capability.
* @param path - absolute directory to list; absent lists the Host home directory.
* @param signal - aborts the wire request (and the Host's scan) when the caller supersedes it.
* @returns the level's listing with breadcrumb ancestry.
*/
async listDirectory(path?: string, signal?: AbortSignal): Promise<DirectoryListing> {
const response = await this.api.host.listDirectory(path === undefined ? {} : { path }, signal)
if (!response.result.ok) throw new DirectoryBrowseError(response.result.error)
return response.result.value
}
/**
* Create one child directory through the Host's `browse` capability.
* @param path - absolute existing parent directory.
* @param name - single non-blank path segment.
* @returns the created directory's absolute path.
*/
async createDirectory(path: string, name: string): Promise<string> {
const response = await this.api.host.createDirectory({ path, name })
if (!response.result.ok) throw new DirectoryBrowseError(response.result.error)
return response.result.value.path
}
/**
* Open a filesystem path with the Host operating system's default application.
* @param path - absolute or host-resolvable path.
*/
async openPath(path: string): Promise<void> {
const response = await this.api.host.openPath({ path })
if (!response.result.ok) {
throw new Error(`path open failed: ${response.result.error.message}`)
}
}
/**
* Rename a Workspace.
* @param workspaceId - target workspace.
* @param title - new display title (trimmed non-empty by the Host).
* @returns the renamed Workspace view.
*/
async rename(workspaceId: WorkspaceId, title: string): Promise<WorkspaceView> {
const result = await this.model.rename(workspaceId, title)
if (!result.ok) throw new Error(`workspace rename failed: ${result.error.code}: ${result.error.message}`)
return result.value.workspace
}
/**
* Delete one Workspace registration. Sessions, session logs, and the
* directory remain Host-owned outside this operation.
* @param workspaceId - target workspace.
*/
async delete(workspaceId: WorkspaceId): Promise<void> {
const result = await this.model.delete(workspaceId)
if (!result.ok) throw new Error(`workspace delete failed: ${result.error.code}: ${result.error.message}`)
}
/**
* Move a Workspace within the durable registry display order.
* @param workspaceId - Workspace to move.
* @param beforeWorkspaceId - Anchor workspace; omitted appends.
*/
async insertBefore(workspaceId: WorkspaceId, beforeWorkspaceId?: WorkspaceId): Promise<void> {
const result = await this.model.insertBefore(workspaceId, beforeWorkspaceId)
if (!result.ok) throw new Error(`workspace reorder failed: ${result.error.code}: ${result.error.message}`)
}
/**
* Archive a session into the registry-global set. Clearing an archived
* current selection is the projection sweep's job (one rule for the local
* echo and a remote tab's frame alike).
* @param sessionId - session to archive.
*/
async archiveSession(sessionId: SessionId): Promise<void> {
const result = await this.model.archiveSession(sessionId)
if (!result.ok) throw new Error(`session archive failed: ${result.error.code}: ${result.error.message}`)
}
/**
* Move a session within its Workspace's manual order (DOM-insertBefore-like).
* @param workspaceId - owning workspace.
* @param sessionId - accounted session to move.
* @param beforeSessionId - accounted anchor to insert before; omitted appends.
* @returns the updated Workspace view.
*/
async insertSessionBefore(
workspaceId: WorkspaceId,
sessionId: SessionId,
beforeSessionId?: SessionId,
): Promise<WorkspaceView> {
const result = await this.model.insertSessionBefore(workspaceId, sessionId, beforeSessionId)
if (!result.ok) throw new Error(`workspace move failed: ${result.error.code}: ${result.error.message}`)
return result.value.workspace
}
private project(): void {
const workspace = this.model.getSnapshot()
const sessions = this.sessions.list.getSnapshot()
const baselinesReady = workspace.phase === 'ready' && sessions.phase === 'ready'
// An archived current selection clears into the New Session view state —
// a hidden row must not stay open behind the list. Sweeping here covers
// every install path with one rule: the local unary echo, another tab's
// changed frame, and a reconnect baseline restoring a persisted
// selection that was archived while this client was away.
if (sessions.current !== undefined && workspace.archivedSessionIds.includes(sessions.current)) {
this.sessions.clear()
}
this.list.set({
items: workspace.items,
archivedSessionIds: workspace.archivedSessionIds,
state: workspace.state,
phase: workspace.phase,
error: workspace.error,
baselinesReady,
recentWorkspaceId: baselinesReady ? recentWorkspace(workspace.items, sessions.byId) : undefined,
})
}
}
/** Stable tie-breaking follows Host Workspace order. */
function recentWorkspace(
workspaces: readonly WorkspaceView[],
sessions: SessionsPortList['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
}
@@ -1,429 +0,0 @@
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-remotes/client'
import { ClientWorkspaceModel } from '@deepseek-ai/dsh-api-workspace-controller/client'
import { SessionRuntime } from '../src/client/sessions/service.ts'
import { DirectoryBrowseError, WorkspaceCreateError, WorkspaceRuntime } from '../src/client/workspaces/service.ts'
import {
FakeApiClient, err, fakeRemote, ok, remoteOk, workspaceErr,
} from './fake-api.client.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,
}
}
const runtimeModels = new WeakMap<WorkspaceRuntime, ClientWorkspaceModel>()
function runtimeFor(
ctx: Context,
api: FakeApiClient,
sessions: SessionRuntime,
): WorkspaceRuntime {
const model = new ClientWorkspaceModel(fakeRemote(api).workspace)
const runtime = new WorkspaceRuntime(ctx, api, model, sessions)
runtimeModels.set(runtime, model)
return runtime
}
function baseline(
target: WorkspaceRuntime,
items: readonly WorkspaceView[] = [],
archivedSessionIds: readonly SessionId[] = [],
): void {
modelOf(target).replaceBaseline({ items, archivedSessionIds })
}
function modelOf(runtime: WorkspaceRuntime): ClientWorkspaceModel {
const model = runtimeModels.get(runtime)
if (model === undefined) throw new Error('WorkspaceRuntime test model missing')
return model
}
async function flush(): Promise<void> {
await Promise.resolve()
await Promise.resolve()
}
describe('WorkspaceRuntime', () => {
it('feeds readiness and recent-Workspace targeting without changing Host order', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionRuntime(ctx, api, fakeRemote(api))
const workspaces = runtimeFor(ctx, api, sessions)
baseline(workspaces, [
workspace('stable-first', [], '2026-01-03T00:00:00.000Z'),
workspace('active', [sid('s-active')], '2026-01-01T00:00:00.000Z'),
])
await flush()
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, blank: false }] as never[],
}))
await sessions.refresh()
await Promise.resolve()
await Promise.resolve()
expect(workspaces.list.getSnapshot()).toMatchObject({
baselinesReady: true,
recentWorkspaceId: 'active',
})
expect(workspaces.list.getSnapshot().items.map(item => item.workspaceId)).toEqual(['stable-first', 'active'])
})
it('connectWorkspace reuses the workspace-member blank session and creates otherwise', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionRuntime(ctx, api, fakeRemote(api))
const workspaces = runtimeFor(ctx, api, sessions)
baseline(workspaces, [
workspace('alpha', [sid('s-blank')]), workspace('beta'), workspace('gamma'),
])
api.onList = () => Promise.resolve(ok({
items: [
// Stray blank at alpha's path but NOT accounted under alpha (a CLI
// session birthed at the host cwd), sorted before the member blank:
// the scan must skip it and keep looking for a member hit.
{ sessionId: sid('s-stray-alpha'), updatedAt: 1, running: false, blank: true, cwd: '/w/alpha' },
// Blank session parked in alpha (cwd == workspace path canon AND
// accounted under alpha): the reuse hit.
{ sessionId: sid('s-blank'), updatedAt: 2, running: false, blank: true, cwd: '/w/alpha' },
// Non-blank sibling in beta must never be reused.
{ sessionId: sid('s-active'), updatedAt: 3, running: false, blank: false, cwd: '/w/beta' },
// Stray blank at gamma's path but NOT accounted under gamma (a CLI
// session birthed at the host cwd): cwd alone must not hijack it —
// reuse would open a session gamma cannot show, so New Session mints
// a fresh accounted one instead.
{ sessionId: sid('s-stray'), updatedAt: 4, running: false, blank: true, cwd: '/w/gamma' },
] as never[],
}))
await sessions.refresh()
await flush()
// Hit: same workspace → the parked member blank comes back (the earlier
// cwd-matching non-member stray is skipped), no create RPC.
await expect(workspaces.connectWorkspace(wid('alpha'))).resolves.toBe('s-blank')
expect(api.callsOf('session.create')).toEqual([])
// Resolution guarantee: the id is binding-resolvable synchronously.
expect(sessions.binding(sid('s-blank'))).toBeDefined()
// Miss: beta has only a non-blank session → host create with workspaceId.
api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-fresh') }))
await expect(workspaces.connectWorkspace(wid('beta'))).resolves.toBe('s-fresh')
expect(api.callsOf('session.create')).toEqual([{ workspaceId: 'beta' }])
// Same guarantee on the create arm (draft hand-off writes the machine pre-open).
expect(sessions.binding(sid('s-fresh'))).toBeDefined()
// Miss: the stray blank matches gamma's path but is not a gamma member →
// never reused, a fresh accounted session is created instead.
api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-fresh-3') }))
await expect(workspaces.connectWorkspace(wid('gamma'))).resolves.toBe('s-fresh-3')
expect(api.callsOf('session.create')).toEqual([{ workspaceId: 'beta' }, { workspaceId: 'gamma' }])
// Unknown workspace fails loud instead of silently creating in nowhere.
await expect(workspaces.connectWorkspace(wid('ghost'))).rejects.toThrow(/unknown workspace ghost/)
// An archived blank is never reused: no surface can show it, so New
// Session mints a fresh one for alpha instead.
await workspaces.archiveSession(sid('s-blank'))
api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-fresh-2') }))
await expect(workspaces.connectWorkspace(wid('alpha'))).resolves.toBe('s-fresh-2')
})
it('a rejected first prompt keeps the blank session eligible for connectWorkspace reuse', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionRuntime(ctx, api, fakeRemote(api))
const workspaces = runtimeFor(ctx, api, sessions)
baseline(workspaces, [workspace('alpha', [sid('s-blank')])])
api.onList = () => Promise.resolve(ok({
items: [{ sessionId: sid('s-blank'), updatedAt: 2, running: false, blank: true, cwd: '/w/alpha' }] as never[],
}))
await sessions.refresh()
await flush()
const session = sessions.binding(sid('s-blank'))!.session
api.onPrompt = () => Promise.resolve(err({ code: 'internal', message: 'agent busy', details: {} }) as never)
await session.prompt([{ type: 'text', text: 'hi' }], 'queue')
await Promise.resolve()
// Failure leaves blank intact, so the same session is still the reuse hit.
await expect(workspaces.connectWorkspace(wid('alpha'))).resolves.toBe('s-blank')
expect(api.callsOf('session.create')).toEqual([])
})
it('returns created Workspaces and preserves Host business errors', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionRuntime(ctx, api, fakeRemote(api))
const workspaces = runtimeFor(ctx, api, sessions)
api.onWorkspaceCreate = () => Promise.resolve(remoteOk({
workspace: { ...workspace('picked'), path: '/w/alpha', title: 'alpha' }, created: true,
}))
await expect(workspaces.create({ path: '/w/alpha' })).resolves.toMatchObject({ workspaceId: 'picked' })
expect(workspaces.list.getSnapshot().items[0]).toMatchObject({ path: '/w/alpha', title: 'alpha' })
expect(api.callsOf('workspace.create')).toEqual([{ path: '/w/alpha' }])
api.onWorkspaceCreate = () => Promise.resolve(workspaceErr({
code: 'workspace-invalid-path', message: 'missing', details: { path: '/missing' },
}))
const rejected = workspaces.create({ path: '/missing' })
await expect(rejected).rejects.toThrow(/workspace-invalid-path: missing/)
await expect(rejected).rejects.toBeInstanceOf(WorkspaceCreateError)
})
it('passes native directory selection and cancellation through without local state', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionRuntime(ctx, api, fakeRemote(api))
const workspaces = runtimeFor(ctx, api, sessions)
api.onPickDirectory = () => Promise.resolve(ok({ path: '/w/alpha' }))
await expect(workspaces.pickDirectory()).resolves.toBe('/w/alpha')
api.onPickDirectory = () => Promise.resolve(ok({ path: null }))
await expect(workspaces.pickDirectory()).resolves.toBeNull()
expect(api.callsOf('host.pickDirectory')).toEqual([{}, {}])
api.onPickDirectory = () => Promise.resolve(err({ code: 'internal', message: 'no chooser', details: {} }))
await expect(workspaces.pickDirectory()).rejects.toThrow(/no chooser/)
})
it('passes listings and creation through the browse wire, wrapping business failures', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const workspaces = runtimeFor(ctx, api, new SessionRuntime(ctx, api, fakeRemote(api)))
const listing = { path: '/home/u', home: '/home/u', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [{ name: 'p', path: '/home/u/p', hidden: false }], truncated: false }
api.onListDirectory = () => Promise.resolve(ok(listing))
await expect(workspaces.listDirectory()).resolves.toEqual(listing)
await expect(workspaces.listDirectory('/home/u')).resolves.toEqual(listing)
// The optional path is omitted from the payload, not sent as undefined.
expect(api.callsOf('host.listDirectory')).toEqual([{}, { path: '/home/u' }])
api.onListDirectory = () => Promise.resolve(err({ code: 'directory-unreadable', message: 'denied', details: { path: '/x' } }))
const listFailure = workspaces.listDirectory('/x')
await expect(listFailure).rejects.toBeInstanceOf(DirectoryBrowseError)
await expect(listFailure).rejects.toMatchObject({ rpcError: { code: 'directory-unreadable' } })
await expect(workspaces.createDirectory('/home/u', 'fresh')).resolves.toBe('/home/fake/new')
expect(api.callsOf('host.createDirectory')).toEqual([{ path: '/home/u', name: 'fresh' }])
api.onCreateDirectory = () => Promise.resolve(err({ code: 'directory-exists', message: 'taken', details: { path: '/home/u/fresh' } }))
await expect(workspaces.createDirectory('/home/u', 'fresh')).rejects.toMatchObject({ rpcError: { code: 'directory-exists' } })
})
it('opens a filesystem path through the host without local state', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionRuntime(ctx, api, fakeRemote(api))
const workspaces = runtimeFor(ctx, api, sessions)
await expect(workspaces.openPath('/w/alpha/a.ts')).resolves.toBeUndefined()
expect(api.callsOf('host.openPath')).toEqual([{ path: '/w/alpha/a.ts' }])
api.onOpenPath = () => Promise.resolve(err({ code: 'internal', message: 'boom', details: {} }))
await expect(workspaces.openPath('/missing')).rejects.toThrow(/path open failed/)
})
it('deletes a Workspace or preserves it when the Host rejects deletion', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionRuntime(ctx, api, fakeRemote(api))
const workspaces = runtimeFor(ctx, api, sessions)
baseline(workspaces, [workspace('alpha')])
await flush()
await expect(workspaces.delete(wid('alpha'))).resolves.toBeUndefined()
expect(workspaces.list.getSnapshot().items).toEqual([])
api.onWorkspaceDelete = () => Promise.resolve(workspaceErr({
code: 'workspace-not-found', message: 'gone', details: { workspaceId: wid('ghost') },
}))
await expect(workspaces.delete(wid('ghost'))).rejects.toThrow(/workspace-not-found: gone/)
})
it('moves a Workspace through the durable order RPC and surfaces Host rejection', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const workspaces = runtimeFor(ctx, api, new SessionRuntime(ctx, api, fakeRemote(api)))
baseline(workspaces, [workspace('one'), workspace('two')])
await flush()
api.onWorkspaceInsertBefore = () => Promise.resolve(remoteOk({
workspaceIds: [wid('two'), wid('one')],
}))
await expect(workspaces.insertBefore(wid('two'), wid('one'))).resolves.toBeUndefined()
expect(api.callsOf('workspace.insertBefore')).toEqual([{
workspaceId: 'two', beforeWorkspaceId: 'one',
}])
expect(workspaces.list.getSnapshot().items.map(item => item.workspaceId)).toEqual(['two', 'one'])
api.onWorkspaceInsertBefore = () => Promise.resolve(workspaceErr({
code: 'workspace-not-found', message: 'gone', details: { workspaceId: wid('ghost') },
}))
await expect(workspaces.insertBefore(wid('ghost'))).rejects.toThrow(/workspace-not-found: gone/)
})
it('targets New Session at explicit, current-session, then recent Workspaces and clears with none', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionRuntime(ctx, api, fakeRemote(api))
const workspaces = runtimeFor(ctx, api, sessions)
baseline(workspaces, [
workspace('current-home', [sid('current')]),
workspace('recent-home', [sid('recent')]),
])
api.onList = () => Promise.resolve(ok({ items: [
{ sessionId: sid('current'), updatedAt: 1, running: false, blank: false },
{ sessionId: sid('recent'), updatedAt: 2, running: false, blank: false },
] as never[] }))
await sessions.refresh()
await flush()
sessions.open(sid('current'))
const unresolved = new Promise<SessionId>(() => {})
const connect = vi.spyOn(workspaces, 'connectWorkspace').mockReturnValue(unresolved)
workspaces.startSession(wid('recent-home'))
await Promise.resolve()
expect(connect).toHaveBeenLastCalledWith(wid('recent-home'))
workspaces.startSession()
await Promise.resolve()
expect(connect).toHaveBeenLastCalledWith(wid('current-home'))
sessions.clear()
workspaces.startSession()
await Promise.resolve()
expect(connect).toHaveBeenLastCalledWith(wid('recent-home'))
const emptyCtx = new Context()
const emptyApi = new FakeApiClient()
const emptySessions = new SessionRuntime(emptyCtx, emptyApi, fakeRemote(emptyApi))
const emptyWorkspaces = runtimeFor(emptyCtx, emptyApi, emptySessions)
const clear = vi.spyOn(emptySessions, 'clear')
emptyWorkspaces.startSession()
expect(clear).toHaveBeenCalledOnce()
})
it('archives a session, projects unary and stream state, and clears only the current one', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionRuntime(ctx, api, fakeRemote(api))
const workspaces = runtimeFor(ctx, api, sessions)
api.onList = () => Promise.resolve(ok({
items: [
{ sessionId: sid('s-open'), updatedAt: 2, running: false, blank: false },
{ sessionId: sid('s-idle'), updatedAt: 1, running: false, blank: false },
],
}) as never)
await sessions.refresh()
sessions.open(sid('s-open'))
// Archiving a non-current session installs the unary echo and keeps the selection.
await expect(workspaces.archiveSession(sid('s-idle'))).resolves.toBeUndefined()
expect(api.callsOf('workspace.archiveSession')).toEqual([{ sessionId: 's-idle' }])
expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-idle'])
expect(sessions.list.getSnapshot().current).toBe('s-open')
// Archiving the current session clears it into the New Session view state.
api.onWorkspaceArchiveSession = () => Promise.resolve(remoteOk({ archivedSessionIds: [sid('s-idle'), sid('s-open')] }))
await workspaces.archiveSession(sid('s-open'))
expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-idle', 's-open'])
expect(sessions.list.getSnapshot().current).toBeUndefined()
// A Host failure leaves the set and the selection untouched.
api.onWorkspaceArchiveSession = () => Promise.resolve(workspaceErr({
code: 'session-not-found', message: 'no session ghost', details: { sessionId: sid('ghost') },
}))
await expect(workspaces.archiveSession(sid('ghost'))).rejects.toThrow(/session-not-found/)
expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-idle', 's-open'])
modelOf(workspaces).replaceArchived([sid('s-idle')])
await flush()
expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-idle'])
baseline(workspaces, [], [sid('s-open')])
await flush()
expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-open'])
})
it('clears a current archived by a stream increment and accepts the next baseline as authoritative', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionRuntime(ctx, api, fakeRemote(api))
const workspaces = runtimeFor(ctx, api, sessions)
api.onList = () => Promise.resolve(ok({
items: [{ sessionId: sid('s-open'), updatedAt: 1, running: false, blank: false }],
}) as never)
await sessions.refresh()
sessions.open(sid('s-open'))
modelOf(workspaces).replaceArchived([sid('s-open')])
await flush()
expect(sessions.list.getSnapshot().current).toBeUndefined()
expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-open'])
baseline(workspaces)
await flush()
expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual([])
})
})
describe('startInitialSelection', () => {
function bench() {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionRuntime(ctx, api, fakeRemote(api))
const workspaces = runtimeFor(ctx, api, sessions)
return { api, sessions, workspaces }
}
it('connects the recent Workspace blank session once baselines are ready and opens it', async () => {
const b = bench()
const stop = b.workspaces.startInitialSelection()
// Nothing happens before both baselines land.
expect(b.api.callsOf('session.create')).toHaveLength(0)
b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-new') }))
baseline(b.workspaces, [workspace('recent', [], '2026-01-02T00:00:00.000Z')])
await b.sessions.refresh()
// Store notifications and the connect round trip are microtask-batched.
await new Promise(resolve => setTimeout(resolve, 0))
expect(b.api.callsOf('session.create')).toEqual([{ workspaceId: 'recent' }])
expect(b.sessions.list.getSnapshot().current).toBe('s-new')
stop()
})
it('stays idle when a session is already current or no recent Workspace exists', async () => {
const withCurrent = bench()
withCurrent.api.onList = () => Promise.resolve(ok({
items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }] as never[],
}))
await withCurrent.sessions.refresh()
withCurrent.sessions.open(sid('s1'))
const stopCurrent = withCurrent.workspaces.startInitialSelection()
baseline(withCurrent.workspaces, [workspace('w1', [sid('s1')])])
await new Promise(resolve => setTimeout(resolve, 0))
expect(withCurrent.api.callsOf('session.create')).toHaveLength(0)
stopCurrent()
const noRecent = bench()
const stopEmpty = noRecent.workspaces.startInitialSelection()
baseline(noRecent.workspaces)
await noRecent.sessions.refresh()
await new Promise(resolve => setTimeout(resolve, 0))
expect(noRecent.api.callsOf('session.create')).toHaveLength(0)
expect(() => noRecent.workspaces.startInitialSelection()).toThrow(/already started/)
stopEmpty()
})
it('a failed connect returns to waiting and retries on the next list change', async () => {
const b = bench()
b.api.onCreate = () => Promise.resolve(err({ code: 'internal', message: 'attach exploded', details: {} }))
const stop = b.workspaces.startInitialSelection()
baseline(b.workspaces, [workspace('recent', [], '2026-01-02T00:00:00.000Z')])
await b.sessions.refresh()
await new Promise(resolve => setTimeout(resolve, 0))
expect(b.api.callsOf('session.create')).toHaveLength(1)
expect(b.sessions.list.getSnapshot().current).toBeUndefined()
// Recovery: the next Workspace stream change re-runs the reconcile.
b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-retry') }))
modelOf(b.workspaces).upsertView(workspace('recent', [], '2026-01-03T00:00:00.000Z'))
await new Promise(resolve => setTimeout(resolve, 0))
expect(b.api.callsOf('session.create')).toHaveLength(2)
expect(b.sessions.list.getSnapshot().current).toBe('s-retry')
stop()
})
})
+13 -5
View File
@@ -32,10 +32,11 @@
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-api-session-controller",
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-conversation",
"@deepseek-ai/dsh-client-ui-session",
"@deepseek-ai/dsh-client-ui-settings",
"@deepseek-ai/dsh-api-remotes"
],
@@ -50,27 +51,34 @@
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-api-session-controller": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^"
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
"@deepseek-ai/dsh-client-ui-session": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-api-session-controller": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-store": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@types/react": "~18.3.1",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
"react": "^18.2.0",
"@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
"@deepseek-ai/dsh-client-ui-session": "workspace:^"
},
"files": [
"lib/index.js",
@@ -12,6 +12,8 @@
*/
import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client'
// Type-only: pulls the Session Controller service merge (ctx.sessions).
import type {} from '@deepseek-ai/dsh-api-session-controller/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
// Type-only: pulls the ctx.remote merge and the forwarded-event key face
@@ -19,7 +21,10 @@ import type {} from '@deepseek-ai/dsh-client-locale/client'
import type {} from '@deepseek-ai/dsh-api-remotes/client'
// Type-only: pulls the settings shell's SlotMap merge (the 'settings.section' entry).
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
// Type-only: pulls the Session UI navigation service merge (ctx.uiSession).
import type {} from '@deepseek-ai/dsh-client-ui-session/client'
import type { Context as ClientContext } from '@deepseek-ai/cordis'
import { AgentPresetLabel } from './AgentPresetLabel.tsx'
import type { AgentPresetLabelInjected } from './AgentPresetLabel.tsx'
import { AgentPresetRow } from './AgentPresetRow.tsx'
@@ -99,7 +104,7 @@ export function apply(ctx: ClientContext): void {
// The new-session chip and the header label: one controller, because the
// staged choice belongs to the flow rather than to any one session.
ctx.inject(['slots', 'conversation', 'sessions', 'workspaces'], (scope: ClientContext) => {
ctx.inject(['slots', 'conversation', 'sessions', 'uiSession'], (scope: ClientContext) => {
const api = (scope.get('connection') as ConnectionHandle).api
const seat = new AgentPresetSeatController(api, (): SeatSessionSummary | undefined => {
const state = scope.sessions.list.getSnapshot()
@@ -160,7 +165,7 @@ export function apply(ctx: ClientContext): void {
// The introduce cue makes the chip announce the pick the user never
// made on this screen — the stage happened back in settings.
seat.stage('cordis', true)
scope.workspaces.startSession()
scope.uiSession.startSession()
}
const chip = scope.slots.register({
name: 'conversation.hero.agentPreset',
@@ -8,7 +8,7 @@
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client'
@@ -143,8 +143,8 @@ function declareConversation(slots: SlotRegistry): () => void {
} as never, () => null)
}
/** A workspaces double recording new-session starts. */
function workspacesDouble() {
/** A Session UI double recording new-session starts. */
function uiSessionDouble() {
const starts: unknown[] = []
return {
starts,
@@ -306,8 +306,8 @@ describe('ui-agent-preset apply', () => {
const conversation = declareConversation(slots)
ctx.provide('conversation', {} as never)
ctx.provide('sessions', sessionsDouble({ byId: {} }) as never)
ctx.provide('workspaces', workspacesDouble() as never)
const fiber = ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply })
ctx.provide('uiSession', uiSessionDouble() as never)
const fiber = ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiSession'], apply })
await fiber.await()
const chip = slots.entries('conversation.hero.agentPreset')[0]!
@@ -328,8 +328,8 @@ describe('ui-agent-preset apply', () => {
const conversation = declareConversation(slots)
ctx.provide('conversation', {} as never)
ctx.provide('sessions', sessionsDouble({ byId: {} }) as never)
ctx.provide('workspaces', workspacesDouble() as never)
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await()
ctx.provide('uiSession', uiSessionDouble() as never)
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiSession'], apply }).await()
const chip = slots.entries('conversation.hero.agentPreset')[0]!
const seat = (chip.inject as unknown as () => AgentPresetSeatInjected)()
@@ -364,8 +364,8 @@ describe('ui-agent-preset apply', () => {
byId: { s1: { id: 's1', blank: true, agentPreset: 'standard' } },
}
ctx.provide('sessions', sessionsDouble(state) as never)
ctx.provide('workspaces', workspacesDouble() as never)
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await()
ctx.provide('uiSession', uiSessionDouble() as never)
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiSession'], apply }).await()
remote.emit('agent-preset/selected', ['s1', 'minimal'])
@@ -378,8 +378,8 @@ describe('ui-agent-preset apply', () => {
const conversation = declareConversation(slots)
ctx.provide('conversation', {} as never)
ctx.provide('sessions', sessionsDouble({ byId: {} }) as never)
ctx.provide('workspaces', workspacesDouble() as never)
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await()
ctx.provide('uiSession', uiSessionDouble() as never)
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiSession'], apply }).await()
const chip = slots.entries('conversation.hero.agentPreset')[0]!
const seat = (chip.inject as unknown as () => AgentPresetSeatInjected)()
@@ -413,8 +413,8 @@ describe('ui-agent-preset apply', () => {
} = { byId: {} }
const sessions = sessionsDouble(state)
ctx.provide('sessions', sessions as never)
ctx.provide('workspaces', workspacesDouble() as never)
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await()
ctx.provide('uiSession', uiSessionDouble() as never)
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiSession'], apply }).await()
const chip = (slots.entries('conversation.hero.agentPreset')[0]!
.inject as unknown as () => AgentPresetSeatInjected)()
@@ -441,8 +441,8 @@ describe('ui-agent-preset apply', () => {
byId: { s1: { id: 's1', blank: true } },
})
ctx.provide('sessions', sessions as never)
ctx.provide('workspaces', workspacesDouble() as never)
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await()
ctx.provide('uiSession', uiSessionDouble() as never)
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiSession'], apply }).await()
const chip = (slots.entries('conversation.hero.agentPreset')[0]!
.inject as unknown as () => AgentPresetSeatInjected)()
@@ -465,8 +465,8 @@ describe('ui-agent-preset apply', () => {
}
const sessions = sessionsDouble(state)
ctx.provide('sessions', sessions as never)
ctx.provide('workspaces', workspacesDouble() as never)
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await()
ctx.provide('uiSession', uiSessionDouble() as never)
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiSession'], apply }).await()
const chip = (slots.entries('conversation.hero.agentPreset')[0]!
.inject as unknown as () => AgentPresetSeatInjected)()
@@ -488,8 +488,8 @@ describe('ui-agent-preset apply', () => {
declareConversation(slots)
ctx.provide('conversation', {} as never)
ctx.provide('sessions', sessionsDouble({ byId: {} }) as never)
ctx.provide('workspaces', workspacesDouble() as never)
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await()
ctx.provide('uiSession', uiSessionDouble() as never)
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiSession'], apply }).await()
const label = (slots.entries('conversation.session.header.actions')[0]!
.inject as unknown as () => AgentPresetLabelInjected)()
const row = (slots.entries('settings.general.item')[0]!
@@ -509,9 +509,9 @@ describe('ui-agent-preset apply', () => {
const conversation = declareConversation(slots)
ctx.provide('conversation', {} as never)
ctx.provide('sessions', sessionsDouble({ byId: {} }) as never)
const workspaces = workspacesDouble()
ctx.provide('workspaces', workspaces as never)
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await()
const uiSession = uiSessionDouble()
ctx.provide('uiSession', uiSession as never)
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiSession'], apply }).await()
const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)()
const seat = (slots.entries('conversation.hero.agentPreset')[0]!
.inject as unknown as () => AgentPresetSeatInjected)()
@@ -523,7 +523,7 @@ describe('ui-agent-preset apply', () => {
// new-session flow began.
expect(section.startCreatorDraft).toBeDefined()
expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('cordis')
expect(workspaces.starts).toHaveLength(1)
expect(uiSession.starts).toHaveLength(1)
// A cross-screen stage carries the introduce cue; the chip acknowledges
// it once, and a repeat acknowledgement leaves the snapshot untouched.
@@ -547,8 +547,8 @@ describe('ui-agent-preset apply', () => {
} = { byId: {} }
const sessions = sessionsDouble(state)
ctx.provide('sessions', sessions as never)
ctx.provide('workspaces', workspacesDouble() as never)
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await()
ctx.provide('uiSession', uiSessionDouble() as never)
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiSession'], apply }).await()
const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)()
const seat = (slots.entries('conversation.hero.agentPreset')[0]!
.inject as unknown as () => AgentPresetSeatInjected)()
+13 -1
View File
@@ -15,7 +15,7 @@
"path": "../../../vendor/cordis"
},
{
"path": "../runtime"
"path": "../store"
},
{
"path": "../../test-support/client-runtime"
@@ -26,6 +26,12 @@
{
"path": "../ui-primitives"
},
{
"path": "../ui-renderer"
},
{
"path": "../ui-session"
},
{
"path": "../ui-settings"
},
@@ -37,6 +43,12 @@
},
{
"path": "../../api/remotes/tsconfig.client.json"
},
{
"path": "../../api/session-controller/tsconfig.client.json"
},
{
"path": "../../core/session"
}
]
}
+27 -24
View File
@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-client-ui-conversation",
"description": "Conversation domain: skeleton, ordered chat flow, composer with the Host-backed busy-Enter preference, and details host",
"description": "Target-neutral Conversation assembly, shell, composer, queue, and view navigation",
"version": "0.1.1-rc.2",
"publishConfig": {
"access": "public"
@@ -31,13 +31,16 @@
},
"dsh": {
"client": {
"external": [
"@deepseek-ai/dsh-api-session-controller/client"
],
"inject": [
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-api-session-controller",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-settings",
"@deepseek-ai/dsh-api-remotes",
"@deepseek-ai/dsh-client-ui-layout"
"@deepseek-ai/dsh-client-ui-layout",
"@deepseek-ai/dsh-client-ui-renderer",
"@deepseek-ai/dsh-client-ui-session",
"@deepseek-ai/dsh-client-ui-settings"
],
"platform": "web"
}
@@ -48,64 +51,64 @@
},
"license": "MIT",
"dependencies": {
"@deepseek-ai/schemastery": "workspace:^",
"clsx": "^2.0.0"
"clsx": "^2.0.0",
"@deepseek-ai/schemastery": "workspace:^"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-api-session-controller": "workspace:^",
"@deepseek-ai/dsh-api-workspace-controller": "workspace:^",
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^",
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
"@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
"@deepseek-ai/dsh-client-ui-session": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-compaction": "workspace:^",
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-permission-presets": "workspace:^",
"@deepseek-ai/dsh-plan-mode": "workspace:^",
"@deepseek-ai/dsh-session-stats": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^",
"@deepseek-ai/dsh-token-meter": "workspace:^",
"@deepseek-ai/dsh-tool-todo": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-util-crypto": "workspace:^"
"@deepseek-ai/dsh-util-crypto": "workspace:^",
"@deepseek-ai/dsh-workspace": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-api-session-controller": "workspace:^",
"@deepseek-ai/dsh-api-workspace-controller": "workspace:^",
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-store": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^",
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
"@deepseek-ai/dsh-client-ui-session": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-compaction": "workspace:^",
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-permission-presets": "workspace:^",
"@deepseek-ai/dsh-plan-mode": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-session-stats": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^",
"@deepseek-ai/dsh-token-meter": "workspace:^",
"@deepseek-ai/dsh-tool-todo": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-util-crypto": "workspace:^",
"@deepseek-ai/dsh-workspace": "workspace:^",
"@types/react": "~18.3.1",
"react": "^18.2.0"
},
@@ -1,67 +1,56 @@
/** Registers the conversation components, shared store, and service callbacks. */
/** Registers the target-neutral Conversation assembly, shell, input, and docks. */
import type { Context } from '@deepseek-ai/cordis'
import { resolveSlotLabel, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
import {
PendingWait, resolveWorkspacePath, type ISessions, type SessionId,
} from '@deepseek-ai/dsh-client-runtime/client'
// Type-only: the ctx.settingsScope Context merge. Cross-plugin collaboration
// goes through the service, never a value import (client bundle purity gate).
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client'
import { createSnapshotStore, type BoundActions } from '@deepseek-ai/dsh-client-store'
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
// Type-only service and declaration merges used by this assembly.
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
import type {} from '@deepseek-ai/dsh-client-ui-session/client'
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
import { UiConversation } from './conversation/assembly.ts'
import type { ViewTab } from './contract/views.ts'
import type {
ApprovalWait, ChatNodeTurnDataInjected, ChatScrollPosition, ChatViewInjected, ComposerBarInjected,
ComposerChainProps, ConversationInjected, ConversationSessionHeaderInjected, ConversationSessionInjected,
DetailsInjected,
ComposerBarInjected, ConversationInjected, ConversationSessionHeaderInjected,
ConversationSessionInjected,
} from './contract/slots.ts'
import type { InputNotice } from './input/contract.ts'
import { createChatStore } from './stores.ts'
import type { InputNotice } from './contract/input.ts'
import { createConversationStore } from './stores.ts'
import { ConversationController, UnsupportedImageMediaTypeError } from './service.ts'
import type { IConversation } from './service.ts'
import { ComposerBlockRegistry } from './input/blocks.ts'
import type { ComposerBlock } from './input/blocks.ts'
import type { ComposerBlock } from './contract/composer-blocks.ts'
import { InputHub } from './input/hub.ts'
import { ComposerSubmissionPolicy } from './input/submission-policy.ts'
import { InputBar } from './skeleton/InputBar.tsx'
import { queueDockEntry } from './queue/QueueDock.tsx'
import { EnterBehaviorRow } from './settings/EnterBehaviorRow.tsx'
import type { EnterBehaviorRowInjected } from './settings/EnterBehaviorRow.tsx'
import { ChatView } from './chat/ChatView.tsx'
import { StatsLine } from './chat/StatsLine.tsx'
import { ApprovalPanel } from './skeleton/ApprovalPanel.tsx'
import { todoDockEntry } from './skeleton/TodoPanel.tsx'
import { queueDockEntry } from './queue/QueueDock.tsx'
import { ConversationRoot } from './skeleton/ConversationRoot.tsx'
import { ConversationSession, ConversationSessionHeader } from './skeleton/ConversationSession.tsx'
import { DetailsPanel } from './skeleton/DetailsPanel.tsx'
import { InputBar } from './skeleton/InputBar.tsx'
import { todoDockEntry } from './skeleton/TodoPanel.tsx'
import { en, NS, zh, type ConversationKey } from './locales.ts'
import { registerConversationNodes } from './conversation-nodes/register.ts'
import { registerChatNodeRenderers } from './chat/register-node-renderers.ts'
import { CONVERSATION_SETTINGS_NAMESPACE, type ConversationSettings } from '../submission-settings.ts'
import { PendingInteractionPresenter } from './pending-interactions.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** The conversation skeleton, chat flow, commands, details, and docks copy. */
/** Conversation shell, composer, queue, and dock copy. */
conversation: ConversationKey
}
}
/** Services required by the conversation plugin. */
/** Services required by the Conversation plugin. */
export const inject = [
'slots', 'layout', 'sessions', 'workspaces', 'locale', 'connection', 'remote', 'settingsScope',
'conversationEvents', 'conversationViews',
'slots', 'sessions', 'uiSession', 'locale', 'settingsScope',
]
// Static no-session sources for the composer-bar hooks compartment: module
// constants so the render side's per-source hook cache (observableHook) keeps
// one identity across every no-session render.
// Stable no-session sources keep the renderer's observable-hook cache and
// hook order unchanged across current-Session transitions.
const ABSENT_NOTICES = {
getSnapshot: (): InputNotice | null => null,
subscribe: () => () => {},
}
/** No session, therefore nothing to block; same one-identity rule as above. */
const ABSENT_BLOCK = {
getSnapshot: (): ComposerBlock | undefined => undefined,
subscribe: () => () => {},
@@ -76,61 +65,36 @@ const ABSENT_MENU_LAUNCHER = {
subscribe: () => () => {},
}
const CHAT_NODE_INJECT: ChatNodeTurnDataInjected = {
hooks: {
turnData: ({ useSession }, nodeKey) => function useTurnData(key) {
return useSession((snapshot) => {
const location = snapshot.chat.nodes.get(nodeKey)?.location
return location?.kind === 'turn' || location?.kind === 'step'
? location.turn.data.get(key)
: undefined
})
},
},
}
/** Resolve the session-scoped conversation face (scope-addressed send/cancel), failing loud. */
/** Resolve the session-scoped Conversation action face, failing loud. */
function scopedConversation(sessions: ISessions, id: SessionId): IConversation {
const scoped = sessions.scope(id)
if (scoped === undefined) throw new Error(`ui-conversation: session "${id}" resolved no scope`)
const conversation = scoped.get('conversation')
if (conversation === undefined) throw new Error('ui-conversation: conversation service unavailable through the session scope')
if (conversation === undefined) {
throw new Error('ui-conversation: conversation service unavailable through the session scope')
}
return conversation
}
/** Resolve package-internal attachment operations from the public service registration. */
/** Resolve package-internal attachment operations from the public service. */
function concreteConversation(ctx: Context): ConversationController {
const conversation = ctx.get('conversation') as ConversationController | undefined
if (conversation === undefined) throw new Error('ui-conversation: conversation service unavailable')
return conversation
}
/** Chain routing: claim the composer while an approval wait is pending (pure — owner props only). */
function selectApproval({ pendingInteraction }: ComposerChainProps): ApprovalWait | null {
return pendingInteraction?.kind === 'approval' ? pendingInteraction : null
}
/** Mounts the conversation plugin.
/**
* Mount the Conversation core and target-neutral presentation.
* @param ctx - Client root context.
*/
export function apply(ctx: Context): void {
const sessions = ctx.sessions
const workspaces = ctx.workspaces
const layout = ctx.layout
const slots = ctx.slots
registerConversationNodes(ctx)
registerChatNodeRenderers(ctx)
const uiConversation = new UiConversation(ctx, sessions)
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-conversation: dictionaries')
// Registration-time text (the view tab label) reads through the bound
// translate as a thunk, so it follows the active locale without
// re-registration; components read the standard `t` seat instead.
const t = ctx.locale.bind(NS)
// Apply-time construction keeps store identity bound to this fiber.
const chatStore = createChatStore()
const conversationStore = createConversationStore()
const submissionPolicy = new ComposerSubmissionPolicy(
ctx.settingsScope.bind<ConversationSettings>({ namespace: CONVERSATION_SETTINGS_NAMESPACE }),
)
@@ -146,55 +110,58 @@ export function apply(ctx: Context): void {
}),
}, EnterBehaviorRow))
// Chat semantic reader positions by session, surviving view switches and
// width reflow when the tab ring remounts the view. Deliberately not
// persisted: a fresh page load keeps the open-jump-to-bottom default.
const chatScrollPositions = new Map<SessionId, ChatScrollPosition>()
const viewTabs = (): ViewTab[] => {
const tabs: ViewTab[] = []
for (const entry of slots.entries('conversation.view')) {
/* v8 ignore next -- unreachable: list registration validates id at load. */
/* v8 ignore next -- list registration validates id at load. */
if (entry.options.id === undefined) continue
tabs.push({ id: entry.options.id, label: resolveSlotLabel(entry.options.label) ?? entry.options.id })
tabs.push({
id: entry.options.id,
label: resolveSlotLabel(entry.options.label) ?? entry.options.id,
})
}
return tabs
}
const views = {
list: viewTabs,
subscribe: (fn: () => void) => slots.subscribe('conversation.view', fn),
version: () => slots.getVersion('conversation.view'),
const conversationViews = createSnapshotStore<readonly ViewTab[]>(viewTabs())
const refreshViews = (): void => {
const current = conversationViews.getSnapshot()
const next = viewTabs()
if (current.length === next.length
&& current.every((tab, index) => {
const candidate = next.at(index)
return candidate !== undefined && tab.id === candidate.id && tab.label === candidate.label
})) return
conversationViews.set(next)
}
ctx.effect(() => {
const disposeViews = slots.subscribe('conversation.view', refreshViews)
const disposeLocale = ctx.locale.subscribe(refreshViews)
return () => {
disposeLocale()
disposeViews()
}
}, 'ui-conversation: View roster')
// The per-session input machine registry (SessionInputResolver face; published as
// ctx.conversation.input by the service below sharing this one instance).
const inputHub = new InputHub(ctx, t)
// The composer-block registry: a plugin that knows a session cannot send —
// ui-model-selection, when no adapter serves the session's route — raises a block
// here, and the bar reads its own session's store. It cannot flow the other
// way: this package must not import the plugins that would know.
const composerBlocks = new ComposerBlockRegistry()
const pendingInteractions = new PendingInteractionPresenter()
// The input machine feeds every session-scope slot
// component through the standard provide channel — the 'input' hook plus
// the two public actions. Materialization is the shell creation trigger
// (per-session lazy; scope disposer tears down).
ctx.effect(() => sessions.provide({
hooks: ['input'],
// Conversation assembly and input share the Session binding lifecycle. The
// source roster is installed before any consuming Slot entry.
ctx.uiSession.provide({
hooks: ['conversation', 'input'],
props: ['inputActions'],
resolve: (binding) => {
const shell = inputHub.shellFor(binding)
return {
hooks: { input: shell.state },
hooks: {
conversation: uiConversation.binding(binding).snapshot,
input: shell.state,
},
props: { inputActions: shell.actions },
}
},
}), 'ui-conversation: input standard-kit provider')
})
// Resident current-session-optional shell. It owns the stable Hero/composer
// frame while strict session slots fill only their session-bound regions.
slots.register({
name: 'conversation',
locale: NS,
@@ -215,10 +182,9 @@ export function apply(ctx: Context): void {
inject: (sessionId: SessionId | undefined): ConversationInjected => ({
hooks: {
composerBlock: sessionId === undefined ? ABSENT_BLOCK : composerBlocks.storeFor(sessionId),
sessionPendingInteraction: pendingInteractions.forSession(sessionId),
},
selectWorkspace: async (workspaceId) => {
const nextId = await workspaces.connectWorkspace(workspaceId)
const nextId = await ctx.uiSession.connectWorkspace(workspaceId)
if (sessionId !== undefined && nextId !== sessionId) {
const from = inputHub.shell(sessionId)
const draft = from.snapshot.draft
@@ -239,27 +205,18 @@ export function apply(ctx: Context): void {
}),
}, ConversationRoot)
// The strict session body fills the resident scrollport without owning it;
// the Hero/composer path therefore stays fixed while the first blank
// session appears after a Workspace pick.
slots.register({
name: 'conversation.session',
children: {
'conversation.view': { kind: 'list', scope: 'session' },
},
store: chatStore,
inject: (sessionId: SessionId, _actions: BoundActions<typeof chatStore>): ConversationSessionInjected => {
const conversation = concreteConversation(ctx)
return {
views,
releaseSessionImages: (id) => { conversation.releaseSessionImages(id) },
bindDraftMirror: write => inputHub.shell(sessionId).bindMirror(write),
}
},
store: conversationStore,
inject: (sessionId: SessionId, _actions: BoundActions<typeof conversationStore>): ConversationSessionInjected => ({
hooks: { conversationViews },
bindDraftMirror: write => inputHub.shell(sessionId).bindMirror(write),
}),
}, ConversationSession)
// Header chrome sits above the resident scrollport but shares the same
// per-session chat store (active view) as its body and view entries.
slots.register({
name: 'conversation.session.header',
locale: NS,
@@ -268,26 +225,16 @@ export function apply(ctx: Context): void {
'conversation.session.header.actions': { kind: 'list', scope: 'session' },
'conversation.session.header.utilities': { kind: 'list', scope: 'session' },
},
store: chatStore,
store: conversationStore,
inject: (): ConversationSessionHeaderInjected => ({
views,
hooks: { conversationViews },
open: (id) => { sessions.open(id) },
}),
}, ConversationSessionHeader)
// The default composer body: its own single slot inside the composer
// chain's fallback. Public machine surface arrives via the
// provide channel above; the keyboard command face and the stop/retry
// verbs ride this inject (package-internal — hub and bar are one plugin).
// Session-maybe: with no current session the machine faces are absent and
// the hooks compartment binds static empty sources (module constants, so
// observableHook caching and hook order stay stable across transitions).
slots.register({
name: 'conversation.composer.bar',
locale: NS,
// The two named control seats in the bar's tool row (plan beside the
// access control, model right); empty until their owning plugins
// register.
children: {
'conversation.input.attachments': { kind: 'single', scope: 'session-maybe' },
'conversation.input.plan': { kind: 'single', scope: 'session' },
@@ -305,7 +252,11 @@ export function apply(ctx: Context): void {
toggleCommandMenu: undefined,
stop: undefined,
command: undefined,
hooks: { notices: ABSENT_NOTICES, lexicon: ABSENT_LEXICON, menuLauncher: ABSENT_MENU_LAUNCHER },
hooks: {
notices: ABSENT_NOTICES,
lexicon: ABSENT_LEXICON,
menuLauncher: ABSENT_MENU_LAUNCHER,
},
}
}
const conversation = concreteConversation(ctx)
@@ -321,11 +272,7 @@ export function apply(ctx: Context): void {
}
return null
} catch (error: unknown) {
if (error instanceof UnsupportedImageMediaTypeError) {
// Positive copy: the supported list is fixed in imageMediaType,
// and naming it beats echoing the rejected MIME type back.
return t('image.unsupportedType')
}
if (error instanceof UnsupportedImageMediaTypeError) return t('image.unsupportedType')
return error instanceof Error ? error.message : String(error)
}
},
@@ -351,7 +298,7 @@ export function apply(ctx: Context): void {
},
stop: () => {
scopedConversation(sessions, sessionId).cancel().catch(() => {
// Stop failure surfaces via snapshot.promptError; nothing to restore.
// Stop failure is published through Session promptError.
})
},
command: async (line) => {
@@ -369,123 +316,7 @@ export function apply(ctx: Context): void {
},
}, InputBar)
// The approval takeover: a selector-routed entry of the chain this package
// just declared (the ui-user-questions registration pattern; the entry lives here
// because approval answering is core conversation UX, not an optional tool).
// Zero business face — data and verbs both ride the matched carrier.
// priority 1: question takeovers (default 0) win when both kinds are
// pending — a question is a conversation the model is waiting on, while an
// approval only blocks one tool call; answering the question first cannot
// strand the approval (it re-elects the moment the question resolves).
slots.register({ name: 'conversation.composer', select: selectApproval, priority: 1, locale: NS }, ApprovalPanel)
// The chat view: first entry of the ring this package just declared.
// ChatView owns only the stable ordered Node list. Business renderers are
// independently keyed behind its one Node seat.
slots.register({
name: 'conversation.view',
id: 'chat',
order: 0,
label: () => t('view.chat'),
locale: NS,
children: {
'conversation.chat.node': { kind: 'keyed', scope: 'session', inject: CHAT_NODE_INJECT },
'conversation.message.images': { kind: 'single', scope: 'session' },
},
store: chatStore,
inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ChatViewInjected => {
const conversation = concreteConversation(ctx)
const scoped = scopedConversation(sessions, sessionId)
return {
openDetails: (target) => {
actions.select(target)
layout.openDetails()
},
fileMentions: owner => ctx.get('chatFileMentions')?.forClosing(owner),
openFile: (path) => {
const cwd = sessions.list.getSnapshot().byId[sessionId]?.cwd
return workspaces.openPath(resolveWorkspacePath(cwd, path))
},
loadOlder: () => { void scoped.loadOlder() },
loadImage: attachment => conversation.resolveImage(sessionId, attachment),
// Unregistered 'trajectory' id is safe: the tab ring falls back to
// the first view, and the untouched inspect target stays inert.
inspectCall: (callId) => {
actions.setInspect({ callId })
actions.setView('trajectory')
},
chatScroll: {
save: (position) => {
if (position === null) chatScrollPositions.delete(sessionId)
else chatScrollPositions.set(sessionId, position)
},
read: () => chatScrollPositions.get(sessionId) ?? null,
},
forkAt: (seq) => {
sessions.fork({ sessionId, atSeq: seq, increaseTitle: true })
.then((childId) => { sessions.open(childId) })
.catch(() => {
// Fork or child-rename failure keeps the source view untouched.
})
},
}
},
}, ChatView)
// Session stats stick with the composer (composer.dock = stats-line family).
slots.register({ name: 'conversation.composer.dock', id: 'stats', order: 0, locale: NS }, StatsLine)
// Class-plugin mount (packages/AGENTS.md service form): the service
// registers itself as `conversation` and lives on its own child fiber.
// Presentation registrants depend directly on their slot declarations;
// this service remains only where conversation actions are required.
ctx.plugin(ConversationController, { input: inputHub, blocks: composerBlocks, pendingInteractions })
let nextApprovalKey = 0
ctx.remote.$on('approval/request', function (request, next) {
const sessionId = sessions.scopeOf(this)
if (sessionId === undefined) return next()
nextApprovalKey += 1
const interactionId = `remote-${String(nextApprovalKey)}`
const completion = Promise.withResolvers<Awaited<ReturnType<typeof next>>>()
const wait = new PendingWait('approval', interactionId, sessionId, {
approvalId: interactionId,
toolName: request.toolName,
...(request.callId === undefined ? {} : { callId: request.callId }),
...(request.reason === undefined ? {} : { reason: request.reason }),
}, (response) => {
if (response.result.ok) completion.resolve(response.result.value.outcome)
return Promise.resolve({ ok: true, value: { accepted: true } })
})
const remove = pendingInteractions.present(wait, 'approval', 0)
const abort = (): void => {
completion.reject(request.signal?.reason ?? new Error('approval request was aborted'))
}
request.signal?.addEventListener('abort', abort, { once: true })
if (request.signal?.aborted === true) abort()
return completion.promise.finally(() => {
request.signal?.removeEventListener('abort', abort)
remove()
})
})
// The plan strip rides the input dock above the queue rows (same posture).
ctx.plugin(ConversationController, { input: inputHub, blocks: composerBlocks })
ctx.plugin(todoDockEntry)
// The read-only queue dock entry rides the same
// registration path into the input dock declared above.
ctx.plugin(queueDockEntry)
slots.register({
name: 'details',
locale: NS,
children: {
'conversation.details.tool': { kind: 'single', scope: 'session' },
},
store: chatStore,
inject: (): DetailsInjected => ({
closeDetails: () => { layout.closeDetails() },
}),
}, DetailsPanel)
}
@@ -1,37 +1,29 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from 'vitest'
import { SlotTestRuntime, usePinnedBrowserLanguages, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
import type { SessionBehaviorOverrides } from '@deepseek-ai/dsh-client-test-runtime'
import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import type { ClientContext, ISession, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {
ChatViewInjected, ComposerBarInjected, ConversationInjected, ConversationSessionHeaderInjected,
ConversationSessionInjected, DetailsInjected,
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
import {
SlotTestRuntime, stubSettingsScope, usePinnedBrowserLanguages,
} from '@deepseek-ai/dsh-client-test-runtime'
import type { SessionBehaviorOverrides } from '@deepseek-ai/dsh-client-test-runtime'
import {
apply, inject, type ComposerBarInjected, type ConversationInjected,
type ConversationSessionInjected, type ViewTab,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import { PendingApproval } from '../src/client/contract/slots.ts'
import type { createChatStore } from '../src/client/stores.ts'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
import { createConversationStore } from '../src/client/stores.ts'
// The service reads its initial locale from the browser; these specs assert
// the shipped Chinese copy, so they state the browser they assume.
usePinnedBrowserLanguages('zh-CN')
const ROOT = 'root-1' as SessionId
type ChatInstance = ReturnType<ReturnType<typeof createChatStore>['create']>
type ChatActions = ChatInstance['actions']
type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable'
type ApprovalListener = (
this: ClientContext,
request: { toolName: string; callId?: string; reason?: string; signal?: AbortSignal },
next: () => Promise<ApprovalOutcome>,
) => Promise<ApprovalOutcome>
type ConversationInstance = ReturnType<ReturnType<typeof createConversationStore>['create']>
type ConversationActions = ConversationInstance['actions']
/** ISession verb mocks, typed against the production face (['prompt'] etc. keep vitest mock ergonomics). */
function sessionFakeFor() {
return {
open: vi.fn(() => Promise.resolve()),
loadOlder: vi.fn<ISession['loadOlder']>(() => Promise.resolve()),
prompt: vi.fn<ISession['prompt']>(() => Promise.resolve({ ok: true, value: { accepted: true } })),
cancel: vi.fn<ISession['cancel']>(() => Promise.resolve({ ok: true, value: { accepted: true } })),
@@ -40,54 +32,32 @@ function sessionFakeFor() {
async function bench() {
const runtime = await SlotTestRuntime.create()
runtime.provide('connection', { api: { settings: {} }, isLoopback: false })
let approvalListener: ApprovalListener | undefined
const remoteOn = vi.fn((event: string, listener: ApprovalListener) => {
expect(event).toBe('approval/request')
approvalListener = listener
return () => { approvalListener = undefined }
})
runtime.provide('remote', { $on: remoteOn } as never)
runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
const connectWorkspace = vi.spyOn(runtime.ctx.uiSession, 'connectWorkspace').mockResolvedValue(ROOT)
const sessionFake = sessionFakeFor()
await runtime.sessions.add({
id: ROOT,
summary: { title: 'R', displayTitle: 'R', cwd: '/proj' },
session: sessionFake,
})
const layoutFake = { openDetails: vi.fn(), closeDetails: vi.fn() }
runtime.provide('layout', layoutFake)
}, { current: false })
const locale = new LocaleRuntime(runtime.ctx)
runtime.provide('locale', locale)
runtime.ctx.provide('locale', locale)
runtime.slots.installLocale(locale)
// The AppFrame role: the conversation-package slots must be declared by a
// live entry before apply can contribute into them.
await runtime.root.declare({
'conversation': { kind: 'single', scope: 'session-maybe' },
'details': { kind: 'single', scope: 'session' },
}, (_p: { renderSlot?: unknown }) => null)
}, (_props: { renderSlot?: unknown }) => null)
const feature = await runtime.mount({ inject: [...inject], apply })
// The host face (store resolution) exists only inside the installed
// renderer, so materialize it the way the shell does.
runtime.renderRoot()
const entryOf = (key: 'conversation' | 'conversation.session' | 'conversation.session.header' | 'conversation.composer.bar' | 'conversation.view' | 'details') =>
const entryOf = (key: 'conversation' | 'conversation.session' | 'conversation.composer.bar') =>
runtime.slots.entries(key)[0]!
/** Resolve store instance + call the inject the way the outlet would. */
const conversationApi = (id: SessionId) => {
const entry = entryOf('conversation.session')
const instance = runtime.storeOf('conversation.session', id) as ChatInstance
const injected = (entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ConversationSessionInjected)(
id, instance.actions)
return { instance, injected }
}
const conversationHeaderApi = (id: SessionId) => {
const entry = entryOf('conversation.session.header')
const instance = runtime.storeOf('conversation.session.header', id) as ChatInstance
const injected = (entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ConversationSessionHeaderInjected)(
id, instance.actions)
const instance = runtime.storeOf('conversation.session', id) as ConversationInstance
const injected = (entry.inject as unknown as (
sessionId: SessionId,
actions: ConversationActions,
) => ConversationSessionInjected)(id, instance.actions)
return { instance, injected }
}
const residentApi = (id: SessionId | undefined) => {
@@ -98,330 +68,151 @@ async function bench() {
const entry = entryOf('conversation.composer.bar')
return (entry.inject as unknown as (sessionId: SessionId | undefined) => ComposerBarInjected)(id)
}
/** Same resolution for the chat entry riding the view ring. */
const chatViewApi = (id: SessionId) => {
const entry = entryOf('conversation.view')
const instance = runtime.storeOf('conversation.view', id) as ChatInstance
const injected = (entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ChatViewInjected)(
id, instance.actions)
return { instance, injected }
}
/** Materialize the input provide contribution the way the runtime does. */
const inputApi = (id: SessionId) => {
const info = runtime.sessions.provideInfo(id)!
const state = info.hooks['input'] as {
getSnapshot: () => { draft: string }
subscribe: (fn: () => void) => () => void
}
const actions = info.props['inputActions'] as {
setDraft: (text: string) => void
submit: () => void
}
return { state, actions }
const input = runtime.ctx.conversation.input.for(runtime.sessions.scope(id)!)
return { state: input.state, actions: input }
}
const viewSource = (id: SessionId): ObservableSnapshot<readonly ViewTab[]> =>
conversationApi(id).injected.hooks.conversationViews
return {
runtime, feature, slots: runtime.slots, entryOf,
conversationApi, conversationHeaderApi, residentApi, composerApi, chatViewApi, inputApi,
sessionFake, layoutFake, remoteOn,
invokeApproval(
owner: ClientContext,
request: Parameters<ApprovalListener>[0],
next: Parameters<ApprovalListener>[1],
): Promise<ApprovalOutcome> {
if (approvalListener === undefined) throw new Error('approval listener was not installed')
return approvalListener.call(owner, request, next)
},
runtime, feature, slots: runtime.slots, entryOf, conversationApi, residentApi, composerApi,
inputApi, viewSource, sessionFake, connectWorkspace,
}
}
describe('conversation slot inject API', () => {
it('presents a scoped approval until its Remote Event waterfall resolves', async () => {
const b = await bench()
const scope = b.runtime.sessions.scope(ROOT)
if (scope === undefined) throw new Error('Session scope was not created')
const next = vi.fn(() => Promise.resolve<ApprovalOutcome>('unavailable'))
const result = b.invokeApproval(scope, {
toolName: 'bash', callId: 'call-1', reason: 'needs access',
}, next)
const source = b.residentApi(ROOT).hooks.sessionPendingInteraction
const wait = source.getSnapshot()[0]
if (wait === undefined || wait.kind !== 'approval') {
throw new Error('approval wait was not presented')
}
expect(b.remoteOn).toHaveBeenCalledOnce()
expect(b.runtime.ctx.conversation.pendingInteractions.statuses.getSnapshot().get(ROOT))
.toBe('approval')
await new PendingApproval(wait).answer('allowed-once')
await expect(result).resolves.toBe('allowed-once')
expect(next).not.toHaveBeenCalled()
expect(source.getSnapshot()).toEqual([])
expect(b.runtime.ctx.conversation.pendingInteractions.statuses.getSnapshot()).toEqual(new Map())
await b.runtime.dispose()
})
it('removes a scoped approval when its Remote Event lifetime aborts', async () => {
const b = await bench()
const scope = b.runtime.sessions.scope(ROOT)
if (scope === undefined) throw new Error('Session scope was not created')
const controller = new AbortController()
const removeEventListener = vi.spyOn(controller.signal, 'removeEventListener')
const reason = new DOMException('aborted by Host', 'AbortError')
const result = b.invokeApproval(scope, {
toolName: 'bash', signal: controller.signal,
}, () => Promise.resolve('unavailable'))
const source = b.residentApi(ROOT).hooks.sessionPendingInteraction
expect(source.getSnapshot()).toHaveLength(1)
controller.abort(reason)
await expect(result).rejects.toBe(reason)
expect(removeEventListener).toHaveBeenCalledWith('abort', expect.any(Function))
expect(source.getSnapshot()).toEqual([])
await b.runtime.dispose()
})
it('delegates an approval without a Session-scoped Client Context', async () => {
const b = await bench()
const next = vi.fn(() => Promise.resolve<ApprovalOutcome>('unavailable'))
await expect(b.invokeApproval(b.runtime.ctx, { toolName: 'bash' }, next))
.resolves.toBe('unavailable')
expect(next).toHaveBeenCalledOnce()
expect(b.runtime.ctx.conversation.pendingInteractions.statuses.getSnapshot()).toEqual(new Map())
await b.runtime.dispose()
})
it('assembles the thin API side-effect-free', async () => {
describe('Conversation inject API', () => {
it('assembles the target-neutral read face without Session side effects', async () => {
const b = await bench()
const { injected } = b.conversationApi(ROOT)
// Assembly has no session side effects: opening the event window belongs
// to the runtime watch path, not the inject factory.
expect(b.sessionFake.open).not.toHaveBeenCalled()
expect(injected.views.list().map(v => v.id)).toEqual(['chat'])
const chatView = b.chatViewApi(ROOT)
chatView.injected.loadOlder()
expect(b.sessionFake.loadOlder).toHaveBeenCalledTimes(1)
chatView.injected.forkAt(17)
await vi.waitFor(() => {
expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [ROOT] })
})
expect(b.runtime.sessions.calls).toContainEqual({
method: 'fork', args: [{ sessionId: ROOT, atSeq: 17, increaseTitle: true }],
})
expect(b.sessionFake.loadOlder).not.toHaveBeenCalled()
expect(Object.keys(injected)).toEqual(['hooks', 'bindDraftMirror'])
expect(b.viewSource(ROOT).getSnapshot()).toEqual([])
await b.runtime.dispose()
})
it('the provide-channel input face submits through the machine sink: trim, transactional clear, failure retains the draft', async () => {
it('submits through the provided input machine and mirrors accepted draft edits', async () => {
const b = await bench()
const { injected } = b.conversationApi(ROOT)
const { state, actions } = b.inputApi(ROOT)
// Whitespace-only: the machine treats it as empty — no prompt, draft kept.
actions.setDraft(' ')
actions.submit()
expect(b.sessionFake.prompt).not.toHaveBeenCalled()
expect(state.getSnapshot().draft).toBe(' ')
// Success: the draft clears only after the sink settles.
actions.setDraft('hello')
actions.submit()
await vi.waitFor(() => {
expect(state.getSnapshot().draft).toBe('')
await vi.waitFor(() => { expect(state.getSnapshot().draft).toBe('') })
expect(b.sessionFake.prompt).toHaveBeenCalledWith(
[{ type: 'text', text: 'hello' }], 'queue', expect.any(AbortSignal),
)
b.sessionFake.prompt.mockResolvedValueOnce({
ok: false, error: { code: 'agent-busy', message: 'busy', details: { reason: 'busy' } },
})
expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'queue', expect.any(AbortSignal))
// Failure: the draft is retained through the round-trip.
b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b', details: { reason: 'b' } } })
actions.setDraft('retry me')
actions.submit()
await vi.waitFor(() => {
expect(b.sessionFake.prompt).toHaveBeenCalledTimes(2)
})
await new Promise(r => setTimeout(r, 0))
await vi.waitFor(() => { expect(b.sessionFake.prompt).toHaveBeenCalledTimes(2) })
await Promise.resolve()
expect(state.getSnapshot().draft).toBe('retry me')
// Failure landing after new typing: no clobber (the interleaved edit wins).
b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b', details: { reason: 'b' } } })
actions.submit()
actions.setDraft('typed during flight')
await new Promise(r => setTimeout(r, 0))
expect(state.getSnapshot().draft).toBe('typed during flight')
// The provide contribution is idempotent per session: one shell identity.
expect(b.inputApi(ROOT).state).toBe(state)
// The draft mirror rides the conversation inject face.
const mirrored: string[] = []
const unbind = injected.bindDraftMirror(text => mirrored.push(text))
actions.setDraft('mirrored text')
expect(mirrored).toEqual(['mirrored text'])
unbind()
// Stop failure is swallowed (promptError owns the display).
b.sessionFake.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'x', details: {} } })
expect(b.inputApi(ROOT).state).toBe(state)
b.sessionFake.cancel.mockResolvedValueOnce({
ok: false, error: { code: 'internal', message: 'stop failed', details: {} },
})
b.composerApi(ROOT).stop!()
await new Promise(r => setTimeout(r, 0))
expect(b.sessionFake.cancel).toHaveBeenCalledTimes(1)
await vi.waitFor(() => { expect(b.sessionFake.cancel).toHaveBeenCalledOnce() })
await b.runtime.dispose()
})
it('inject fails loud when the session resolves no binding or the scope lacks the service', async () => {
it('fails loud for an unknown binding or an unloaded scoped service', async () => {
const b = await bench()
const entry = b.entryOf('conversation.composer.bar')
const injectFn = entry.inject as unknown as (sessionId: SessionId | undefined) => ComposerBarInjected
// Unknown session: the keyboard face's binding resolution answers nothing.
expect(() => { injectFn('ghost' as SessionId).stop!() }).toThrow(/resolved no binding/)
// No session (session-maybe absent side): machine faces absent, static
// hooks compartment still present so the render side's hook order holds.
const absent = injectFn(undefined)
const injectBar = entry.inject as unknown as (
sessionId: SessionId | undefined,
) => ComposerBarInjected
expect(() => { injectBar('ghost' as SessionId).stop!() }).toThrow(/resolved no binding/)
const absent = injectBar(undefined)
expect(absent.keyboard).toBeUndefined()
expect(absent.toggleCommandMenu).toBeUndefined()
expect(absent.stop).toBeUndefined()
expect(absent.hooks.notices.getSnapshot()).toBeNull()
expect(absent.hooks.lexicon.getSnapshot().size).toBe(0)
expect(absent.hooks.menuLauncher.getSnapshot()).toBeNull()
// A scope whose service tree lost 'conversation' (the feature fiber
// unloaded while a retained inject closure re-runs): fails loud too.
const stop = injectFn(ROOT).stop!
const stop = injectBar(ROOT).stop!
await b.feature.dispose()
expect(() => { stop() }).toThrow(/unavailable through the session scope/)
await b.runtime.dispose()
})
it('openDetails (chat view face) writes the selection through the store actions and opens the panel', async () => {
const b = await bench()
const { instance, injected } = b.chatViewApi(ROOT)
injected.openDetails({ turnSeq: 2, callId: 'c1' })
expect(instance.store.getSnapshot().selection).toEqual({ turnSeq: 2, callId: 'c1' })
expect(b.layoutFake.openDetails).toHaveBeenCalledTimes(1)
// The chat view shares the conversation entry's store instance: selection
// writes land where the skeleton and details read.
const conv = b.conversationApi(ROOT)
expect(conv.instance).toBe(instance)
await b.runtime.dispose()
})
it('openFile (chat view face) resolves against session cwd and calls workspaces.openPath', async () => {
const b = await bench()
const { injected } = b.chatViewApi(ROOT)
await injected.openFile('src/a.ts')
await vi.waitFor(() => {
expect(b.runtime.workspaces.calls).toContainEqual({ method: 'openPath', args: ['/proj/src/a.ts'] })
})
await b.runtime.dispose()
})
it('openFile rejects when the Host cannot open the path', async () => {
const b = await bench()
b.runtime.workspaces.stub('openPath', () => Promise.reject(new Error('xdg-open is not available')))
const { injected } = b.chatViewApi(ROOT)
await expect(injected.openFile('src/a.ts')).rejects.toThrow('xdg-open is not available')
await b.runtime.dispose()
})
it('routes workspace switching through the runtime owner, carrying the draft', async () => {
it('moves a draft only when Workspace navigation changes Session', async () => {
const b = await bench()
const resident = b.residentApi(ROOT)
// Same-session connect (the picked workspace resolves to this session):
// no draft movement, plain re-open.
b.runtime.workspaces.stub('connectWorkspace', () => Promise.resolve(ROOT))
const { state, actions } = b.inputApi(ROOT)
actions.setDraft('carry me')
void resident.selectWorkspace('workspace-1' as never)
await vi.waitFor(() => {
expect(b.runtime.sessions.calls.filter(c => c.method === 'open')).toHaveLength(1)
})
expect(b.runtime.workspaces.calls).toContainEqual({ method: 'connectWorkspace', args: ['workspace-1'] })
b.connectWorkspace.mockResolvedValueOnce(ROOT)
await resident.selectWorkspace('workspace-1' as WorkspaceId)
expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [ROOT] })
expect(state.getSnapshot().draft).toBe('carry me')
// Cross-session connect: the draft MOVES — the old machine empties, the
// new session's machine receives the text, then navigation lands there.
const OTHER = 'other-1' as SessionId
await b.runtime.sessions.add({ id: OTHER }, { current: false })
b.runtime.workspaces.stub('connectWorkspace', () => Promise.resolve(OTHER))
void resident.selectWorkspace('workspace-2' as never)
await vi.waitFor(() => {
expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [OTHER] })
})
const other = 'other-1' as SessionId
await b.runtime.sessions.add({ id: other }, { current: false })
b.connectWorkspace.mockResolvedValueOnce(other)
await resident.selectWorkspace('workspace-2' as WorkspaceId)
expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [other] })
expect(state.getSnapshot().draft).toBe('')
expect(b.inputApi(OTHER).state.getSnapshot().draft).toBe('carry me')
expect(b.inputApi(other).state.getSnapshot().draft).toBe('carry me')
await b.runtime.dispose()
})
it('selectWorkspace edge arms: no-session resident, empty-draft move, connect failure retryable', async () => {
it('supports no-Session navigation and propagates Workspace connection failure', async () => {
const b = await bench()
// No-session resident (hero before any session): connect resolves and
// navigation proceeds without any draft choreography.
const noSession = b.residentApi(undefined)
b.runtime.workspaces.stub('connectWorkspace', () => Promise.resolve(ROOT))
void noSession.selectWorkspace('workspace-0' as never)
await vi.waitFor(() => {
expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [ROOT] })
})
b.connectWorkspace.mockResolvedValueOnce(ROOT)
await b.residentApi(undefined).selectWorkspace('workspace-0' as WorkspaceId)
expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [ROOT] })
// Cross-session connect with an EMPTY draft: no move, no clearing.
const OTHER = 'b9-other' as SessionId
await b.runtime.sessions.add({ id: OTHER }, { current: false })
const resident = b.residentApi(ROOT)
const { state } = b.inputApi(ROOT)
expect(state.getSnapshot().draft).toBe('')
b.runtime.workspaces.stub('connectWorkspace', () => Promise.resolve(OTHER))
void resident.selectWorkspace('workspace-3' as never)
await vi.waitFor(() => {
expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [OTHER] })
})
expect(b.inputApi(OTHER).state.getSnapshot().draft).toBe('')
// Connect failure: the rejection propagates to the caller (the view owns
// the rollback) and no further navigation happens.
const opens = b.runtime.sessions.calls.filter(c => c.method === 'open').length
b.runtime.workspaces.stub('connectWorkspace', () => Promise.reject(new Error('offline')))
await expect(resident.selectWorkspace('workspace-4' as never)).rejects.toThrow('offline')
expect(b.runtime.sessions.calls.filter(c => c.method === 'open')).toHaveLength(opens)
const opens = b.runtime.sessions.calls.filter(call => call.method === 'open').length
b.connectWorkspace.mockRejectedValueOnce(new Error('offline'))
await expect(b.residentApi(ROOT).selectWorkspace('workspace-4' as WorkspaceId))
.rejects.toThrow('offline')
expect(b.runtime.sessions.calls.filter(call => call.method === 'open')).toHaveLength(opens)
await b.runtime.dispose()
})
it('scopedConversation fails loud when the session resolves no scope', async () => {
it('projects the dynamic View registration ledger', async () => {
const b = await bench()
// The chat-view inject resolves the scoped conversation service at inject
// time: an unlisted session hits the scope() === undefined throw directly.
const entry = b.entryOf('conversation.view')
const injectFn = entry.inject as unknown as (sessionId: SessionId, actions: unknown) => unknown
expect(() => injectFn('never-listed' as SessionId, {})).toThrow(/resolved no scope/)
await b.runtime.dispose()
})
it('views read face projects the ring ledger (subscribe/version through ctx.slots)', async () => {
const b = await bench()
const { injected } = b.conversationApi(ROOT)
const before = injected.views.version()
const source = b.viewSource(ROOT)
const before = source.getSnapshot()
const listener = vi.fn()
const unsub = injected.views.subscribe(listener)
// A second ring rider (what ui-trajectory does in production).
const off = b.slots.register(
{ name: 'conversation.view', id: 'chat2', order: 5, label: 'X' } as never, (() => null) as never)
await Promise.resolve() // ledger notifications batch per microtask
expect(listener).toHaveBeenCalled()
expect(injected.views.version()).toBeGreaterThan(before)
expect(injected.views.list().map(v => v.id)).toEqual(['chat', 'chat2'])
// Label falls back to the id when a rider declares none.
const off2 = b.slots.register(
{ name: 'conversation.view', id: 'bare', order: 6 } as never, (() => null) as never)
expect(injected.views.list().map(v => v.label)).toEqual(['对话', 'X', 'bare'])
off()
off2()
unsub()
await b.runtime.dispose()
})
})
const unsubscribe = source.subscribe(listener)
const removeNamed = b.slots.register(
{ name: 'conversation.view', id: 'trajectory', order: 5, label: 'Trajectory' },
(() => null) as never,
)
await vi.waitFor(() => {
expect(source.getSnapshot()).toEqual([{ id: 'trajectory', label: 'Trajectory' }])
})
expect(listener).toHaveBeenCalledOnce()
expect(source.getSnapshot()).not.toBe(before)
describe('details inject API', () => {
it('details injects the one layout callback; selection rides the shared store instead', async () => {
const b = await bench()
const entry = b.entryOf('details')
const injected = (entry.inject as unknown as () => DetailsInjected)()
expect(Object.keys(injected)).toEqual(['closeDetails'])
injected.closeDetails()
expect(b.layoutFake.closeDetails).toHaveBeenCalledTimes(1)
// The shared handle: details resolves the SAME instance conversation writes.
const conv = b.runtime.storeOf('conversation.session', ROOT)
const details = b.runtime.storeOf('details', ROOT)
expect(details).toBe(conv)
const removeBare = b.slots.register(
{ name: 'conversation.view', id: 'bare', order: 6 },
(() => null) as never,
)
await vi.waitFor(() => {
expect(source.getSnapshot().map(view => view.label)).toEqual(['Trajectory', 'bare'])
})
removeNamed()
removeBare()
unsubscribe()
await b.runtime.dispose()
})
})
@@ -0,0 +1,98 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from 'vitest'
import {
SlotTestRuntime, stubSettingsScope, usePinnedBrowserLanguages,
} from '@deepseek-ai/dsh-client-test-runtime'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { apply, inject, type ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
usePinnedBrowserLanguages('zh-CN')
const SID = 'session-1' as SessionId
async function bench() {
const runtime = await SlotTestRuntime.create()
runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
const locale = new LocaleRuntime(runtime.ctx)
runtime.ctx.provide('locale', locale)
runtime.slots.installLocale(locale)
await runtime.root.declare({
'conversation': { kind: 'single', scope: 'session-maybe' },
'settings.general.item': { kind: 'list', scope: 'root' },
}, (_props: { renderSlot?: unknown }) => null)
const feature = await runtime.mount({ inject: [...inject], apply })
return { runtime, feature }
}
function entry(
runtime: SlotTestRuntime,
key: 'conversation' | 'conversation.session' | 'conversation.session.header',
) {
return runtime.slots.entries(key)[0] as { store?: unknown } | undefined
}
describe('target-neutral Conversation apply wiring', () => {
it('provides both action and assembly services without installing Chat', async () => {
const b = await bench()
expect(b.runtime.ctx.get('conversation')).toBeDefined()
expect(b.runtime.ctx.get('uiConversation')).toBeDefined()
expect(b.runtime.slots.entries('conversation.view')).toHaveLength(0)
await b.runtime.dispose()
})
it('owns shell slots and shares only the Conversation store', async () => {
const b = await bench()
const session = entry(b.runtime, 'conversation.session')
const header = entry(b.runtime, 'conversation.session.header')
expect(entry(b.runtime, 'conversation')?.store).toBeUndefined()
expect(session?.store).toBeDefined()
expect(header?.store).toBe(session?.store)
expect(b.runtime.slots.spec('conversation.composer'))
.toEqual({ kind: 'chain', scope: 'session' })
expect(b.runtime.slots.entries('settings.general.item').map(row => row.options.id))
.toEqual(['composer-enter'])
await b.runtime.dispose()
})
it('binds a cached locale-aware View roster only to its shell entries', async () => {
const b = await bench()
await b.runtime.sessions.add({ id: SID }, { current: false })
expect(b.runtime.ctx.uiSession.adapter.resolve(SID)?.hooks.conversationViews).toBeUndefined()
const header = b.runtime.slots.entries('conversation.session.header')[0]
const source = (header?.inject?.() as {
hooks: { conversationViews: ObservableSnapshot<readonly ViewTab[]> }
} | undefined)?.hooks.conversationViews
expect(source).toBeDefined()
expect(source?.getSnapshot()).toBe(source?.getSnapshot())
const disposeView = b.runtime.slots.register({
name: 'conversation.view',
id: 'probe',
label: () => b.runtime.ctx.locale.getSnapshot().active,
}, (() => null) as never)
await vi.waitFor(() => {
expect(source?.getSnapshot()).toEqual([{ id: 'probe', label: 'zh' }])
})
const chinese = source?.getSnapshot()
b.runtime.ctx.locale.setLocale('en')
expect(source?.getSnapshot()).toEqual([{ id: 'probe', label: 'en' }])
expect(source?.getSnapshot()).not.toBe(chinese)
disposeView()
await vi.waitFor(() => { expect(source?.getSnapshot()).toEqual([]) })
await b.runtime.dispose()
})
it('removes services, entries, and declarations with the plugin fiber', async () => {
const b = await bench()
await b.feature.dispose()
expect(b.runtime.ctx.get('conversation')).toBeUndefined()
expect(b.runtime.ctx.get('uiConversation')).toBeUndefined()
expect(b.runtime.slots.entries('conversation')).toHaveLength(0)
expect(b.runtime.slots.spec('conversation.view')).toBeUndefined()
await b.runtime.dispose()
})
})
@@ -4,10 +4,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, waitFor, within } from '@testing-library/react'
import { useState } from 'react'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import type { ISession, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotTestRuntime, usePinnedBrowserLanguages, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
import { apply, inject, type EmptyWorkspaceOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
usePinnedBrowserLanguages('zh-CN')
@@ -29,14 +30,13 @@ beforeEach(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
})
type AppRootProps = PropsRenderSlots<'conversation' | 'details'>
type AppRootProps = PropsRenderSlots<'conversation'>
function AppRoot({ renderSlot }: AppRootProps) {
return <>{renderSlot('conversation', {})}</>
}
const LAYOUT_CHILDREN = {
'conversation': { kind: 'single', scope: 'session-maybe' },
'details': { kind: 'single', scope: 'session' },
} as const
function WorkspaceProbe({ open }: EmptyWorkspaceOwnerProps) {
@@ -50,21 +50,14 @@ function WorkspaceProbe({ open }: EmptyWorkspaceOwnerProps) {
async function bench(opts?: { blank?: boolean }) {
const runtime = await SlotTestRuntime.create()
runtime.provide('connection', { api: { settings: {} }, isLoopback: false })
// The plugin injects both; these specs exercise no settings path.
runtime.provide('remote', { $on: () => () => {} })
runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
const locale = new LocaleRuntime(runtime.ctx)
runtime.provide('locale', locale)
runtime.ctx.provide('locale', locale)
runtime.slots.installLocale(locale)
await runtime.sessions.add({
id: SID,
summary: { title: 'S', displayTitle: 'S', cwd: '/proj' },
snapshot: {
nodes: [],
...(opts?.blank === true ? { blank: true, composerPhase: 'blank' as const } : {}),
},
...(opts?.blank === true ? { snapshot: { blank: true } } : {}),
session: {
loadOlder: vi.fn<ISession['loadOlder']>(),
prompt: vi.fn<ISession['prompt']>(async () => ({ ok: true, value: { accepted: true } })),
@@ -78,13 +71,9 @@ async function bench(opts?: { blank?: boolean }) {
describe('resident composer', () => {
it('renders the locked view state while no session exists at all', async () => {
const runtime = await SlotTestRuntime.create()
runtime.provide('connection', { api: { settings: {} }, isLoopback: false })
// The plugin injects both; these specs exercise no settings path.
runtime.provide('remote', { $on: () => () => {} })
runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
const locale = new LocaleRuntime(runtime.ctx)
runtime.provide('locale', locale)
runtime.ctx.provide('locale', locale)
runtime.slots.installLocale(locale)
await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)
await runtime.mount({ inject: [...inject], apply })
@@ -108,13 +97,9 @@ describe('resident composer', () => {
it('keeps the complete Hero tree mounted when the first Workspace session appears', async () => {
const runtime = await SlotTestRuntime.create()
runtime.provide('connection', { api: { settings: {} }, isLoopback: false })
// The plugin injects both; these specs exercise no settings path.
runtime.provide('remote', { $on: () => () => {} })
runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
const locale = new LocaleRuntime(runtime.ctx)
runtime.provide('locale', locale)
runtime.ctx.provide('locale', locale)
runtime.slots.installLocale(locale)
await runtime.workspaces.update((draft) => {
draft.items = [{ workspaceId: 'w1', title: 'Proj', path: '/proj', sessionIds: [SID] }] as never
@@ -140,7 +125,7 @@ describe('resident composer', () => {
await runtime.sessions.add({
id: SID,
summary: { title: 'S', displayTitle: 'S', cwd: '/proj', blank: true },
snapshot: { blank: true, composerPhase: 'blank' },
snapshot: { blank: true },
})
expect(view.container.querySelector('[data-phase="hero"]')).toBe(root)
@@ -165,9 +150,8 @@ describe('resident composer', () => {
expect(hero).not.toBeNull()
expect(hero!.disabled).toBe(false)
await runtime.sessions.updateSnapshot(SID, (draft) => {
await runtime.sessions.updateSessionSnapshot(SID, (draft) => {
draft.blank = false
draft.composerPhase = 'active'
})
expect(view.container.querySelector('textarea')).toBe(hero)
await runtime.dispose()
@@ -177,13 +161,9 @@ describe('resident composer', () => {
describe('prompt rejection through the assembled composer', () => {
it('renders the promptError alert strip and keeps the draft in the machine', async () => {
const runtime = await SlotTestRuntime.create()
runtime.provide('connection', { api: { settings: {} }, isLoopback: false })
// The plugin injects both; these specs exercise no settings path.
runtime.provide('remote', { $on: () => () => {} })
runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
const locale = new LocaleRuntime(runtime.ctx)
runtime.provide('locale', locale)
runtime.ctx.provide('locale', locale)
runtime.slots.installLocale(locale)
const prompt = vi.fn<ISession['prompt']>(async () => ({
ok: false, error: { code: 'agent-busy', message: 'prompt rejected before acceptance', details: { reason: 'busy' } },
@@ -202,7 +182,7 @@ describe('prompt rejection through the assembled composer', () => {
fireEvent.keyDown(composer, { key: 'Enter' })
await waitFor(() => { expect(prompt).toHaveBeenCalledOnce() })
await runtime.sessions.updateSnapshot(SID, (draft) => {
await runtime.sessions.updateSessionSnapshot(SID, (draft) => {
draft.promptError = {
op: 'send',
error: { code: 'agent-busy', message: 'prompt rejected before acceptance', details: { reason: 'busy' } },
+76
View File
@@ -0,0 +1,76 @@
{
"name": "@deepseek-ai/dsh-client-ui-session",
"description": "Session Controller adapter for React and session-scoped slots",
"version": "0.1.1-rc.2",
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/client/ui-session"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dsh": {
"client": {
"external": [
"@deepseek-ai/dsh-api-workspace-controller/client"
],
"inject": [
"@deepseek-ai/dsh-api-session-controller",
"@deepseek-ai/dsh-api-workspace-controller",
"@deepseek-ai/dsh-client-ui-renderer"
],
"platform": "web"
}
},
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
},
"license": "MIT",
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-api-session-controller": "workspace:^",
"@deepseek-ai/dsh-api-workspace-controller": "workspace:^",
"@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-api-session-controller": "workspace:^",
"@deepseek-ai/dsh-api-workspace-controller": "workspace:^",
"@deepseek-ai/dsh-client-store": "workspace:^",
"@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@types/react": "~18.3.1",
"react": "^18.2.0"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts"
]
}
@@ -0,0 +1,503 @@
/** Session Controller adapter for React selector hooks and Slot scope data. */
import { Service, type Context } from '@deepseek-ai/cordis'
import type {
ISessions,
SessionBinding,
SessionListState,
SessionSnapshot,
UseProjection,
} from '@deepseek-ai/dsh-api-session-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { notifySubscribers } from '@deepseek-ai/dsh-client-store'
import { standardHookPropName } from '@deepseek-ai/dsh-client-ui-slots'
import type {
HostObservable,
KeyedStandardSource,
MaybeSnapshotSelectorHook,
RootStandardSourceContribution,
ScopedStandardSourceBinding,
SlotScopeAdapter,
SnapshotSelectorHook,
StandardSourceBinding,
} from '@deepseek-ai/dsh-client-ui-slots'
// Type-only service merge for ctx.slots.
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
import { renderSessionArea } from './session-provider.tsx'
/** Selector hook over the Session Controller list and current selection. */
export type UseSessions = SnapshotSelectorHook<SessionListState>
/** Selector hook over one Session's lifecycle and control state. */
export type SessionSnapshotSelector = SnapshotSelectorHook<SessionSnapshot>
/** Public name for the Session lifecycle selector hook. */
export type UseSession = SessionSnapshotSelector
/** Common identity carried by every Session-scoped pending interaction. */
export interface SessionPendingInteractionBase {
/** Opaque request identity; a replacement request must use a new key. */
readonly key: string
/** Domain-owned presentation discriminator. */
readonly kind: string
/** Session whose UI can answer this interaction. */
readonly sessionId: SessionId
}
/** Declaration-merged roster of domain-owned pending interaction values. */
export interface SessionPendingInteractionMap {}
/** Every pending interaction contributed by the assembled Client. */
export type SessionPendingInteraction =
[keyof SessionPendingInteractionMap] extends [never]
? SessionPendingInteractionBase
: SessionPendingInteractionMap[keyof SessionPendingInteractionMap]
/** Current effective pending interaction by Session. */
export type SessionPendingInteractionSnapshot = ReadonlyMap<SessionId, SessionPendingInteraction>
/** Selector hook over Session-scoped pending interactions. */
export type UseSessionPendingInteraction = SnapshotSelectorHook<SessionPendingInteractionSnapshot>
class PendingInteractionDomain<T extends SessionPendingInteractionBase> {
private readonly values = new Map<string, T>()
constructor(
readonly precedence: (interaction: T) => number,
private readonly changed: () => void,
) {}
valuesSnapshot(): readonly T[] {
return [...this.values.values()]
}
publish(interaction: T): () => void {
if (this.values.has(interaction.key)) {
throw new Error(`ui-session: duplicate pending interaction key '${interaction.key}'`)
}
this.values.set(interaction.key, interaction)
this.changed()
let active = true
return () => {
if (!active) return
active = false
if (this.values.get(interaction.key) !== interaction) return
this.values.delete(interaction.key)
this.changed()
}
}
}
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface GlobalStandardProps {
/** Session list and current selection. */
useSessions: UseSessions
/** Pending user interaction presented by a Session-scoped UI consumer. */
useSessionPendingInteraction: UseSessionPendingInteraction
}
interface SessionStandardProps {
/** Current Session lifecycle and control state. */
useSession: SessionSnapshotSelector
/** Current Session identity. */
sessionId: SessionId
/** Host-computed projection values addressed by projection key. */
useProjection: UseProjection
}
interface SessionMaybeStandardProps {
/** Current Session state, absent while no Session is selected. */
useSession: MaybeSnapshotSelectorHook<SessionSnapshot>
/** Current Session identity, absent while no Session is selected. */
sessionId: SessionId | undefined
/** Host-computed projection values; every key is absent without a Session. */
useProjection: UseProjection
}
}
declare module '@deepseek-ai/cordis' {
interface Context {
/** Session Controller adapter and session-scoped source registry. */
uiSession: UiSession
}
}
type SessionSourceRoster = readonly string[] | undefined
type StandardMemberKind = 'hook' | 'keyed hook' | 'prop'
type SessionSourceRecord<Roster extends SessionSourceRoster, Value> =
Roster extends readonly string[] ? Readonly<Record<Roster[number], Value>> : never
/** Bare values produced by one Session-scoped source contribution. */
export interface SessionSourceContribution<
Hooks extends SessionSourceRoster = SessionSourceRoster,
KeyedHooks extends SessionSourceRoster = SessionSourceRoster,
Props extends SessionSourceRoster = SessionSourceRoster,
> {
readonly hooks?: SessionSourceRecord<Hooks, HostObservable<unknown>>
readonly keyedHooks?: SessionSourceRecord<KeyedHooks, KeyedStandardSource>
readonly props?: SessionSourceRecord<Props, unknown>
}
/** Static roster and per-Session resolver for one standard-props contribution. */
export interface SessionSourceDescriptor<
Hooks extends SessionSourceRoster = SessionSourceRoster,
KeyedHooks extends SessionSourceRoster = SessionSourceRoster,
Props extends SessionSourceRoster = SessionSourceRoster,
> {
readonly hooks?: Hooks
readonly keyedHooks?: KeyedHooks
readonly props?: Props
/**
* Resolve every declared member for one Session binding.
* @param binding - Controller-owned Session binding.
* @returns all declared bare sources and stable props.
*/
resolve(binding: SessionBinding): SessionSourceContribution<
NoInfer<Hooks>,
NoInfer<KeyedHooks>,
NoInfer<Props>
>
}
interface RuntimeSessionSourceContribution {
readonly hooks?: Readonly<Record<string, HostObservable<unknown>>>
readonly keyedHooks?: Readonly<Record<string, KeyedStandardSource>>
readonly props?: Readonly<Record<string, unknown>>
}
interface RuntimeSessionSourceDescriptor {
readonly hooks?: readonly string[]
readonly keyedHooks?: readonly string[]
readonly props?: readonly string[]
resolve(binding: SessionBinding): RuntimeSessionSourceContribution
}
type RuntimePendingDomain = PendingInteractionDomain<SessionPendingInteractionBase>
interface MaterializedBinding {
readonly owner: SessionBinding
readonly value: ScopedStandardSourceBinding
readonly release: () => void
}
const BUILTIN_SOURCE = {
hooks: ['session'],
keyedHooks: ['projection'],
props: ['sessionId'],
resolve: binding => ({
hooks: { session: binding.session },
keyedHooks: { projection: key => binding.session.projections.faceOf(key) },
props: { sessionId: binding.sessionId },
}),
} satisfies SessionSourceDescriptor<
readonly ['session'],
readonly ['projection'],
readonly ['sessionId']
>
/** Session-scoped source roster and renderer adapter. */
export class UiSession extends Service {
private readonly descriptors: RuntimeSessionSourceDescriptor[] = [
BUILTIN_SOURCE,
]
private bindings = new Map<SessionId, MaterializedBinding>()
private absent: StandardSourceBinding
private currentBinding: StandardSourceBinding
private readonly currentListeners = new Set<() => void>()
private readonly pendingDomains: RuntimePendingDomain[] = []
private pendingSnapshot: ReadonlyMap<SessionId, SessionPendingInteractionBase> = new Map()
private readonly pendingListeners = new Set<() => void>()
/** Root source of pending UI interactions, independent from Controller snapshots. */
readonly pendingInteractions: HostObservable<SessionPendingInteractionSnapshot> = {
getSnapshot: () => this.pendingSnapshot as SessionPendingInteractionSnapshot,
subscribe: (listener) => {
this.pendingListeners.add(listener)
return () => { this.pendingListeners.delete(listener) }
},
}
/** Renderer-facing adapter for `session` and `session-maybe` scopes. */
readonly adapter: SlotScopeAdapter
/**
* @param ctx - Client root context.
* @param sessions - Controller-owned Session object layer.
*/
constructor(
ctx: Context,
private readonly sessions: ISessions,
) {
super(ctx, 'uiSession')
this.absent = this.materializeAbsent()
this.currentBinding = this.resolveCurrent()
this.adapter = {
current: {
getSnapshot: () => this.currentBinding,
subscribe: (listener) => {
this.currentListeners.add(listener)
return () => { this.currentListeners.delete(listener) }
},
},
resolve: key => this.resolve(key as SessionId),
renderArea: renderSessionArea,
}
ctx.effect(() => {
const disposeList = sessions.list.subscribe(() => { this.publishCurrent() })
return () => {
disposeList()
const records = [...this.bindings.values()]
this.bindings.clear()
for (const record of records) record.release()
}
}, 'ui-session: Session binding projection')
}
/**
* Register one Session-scoped standard-source contribution.
* @param descriptor - static member roster and per-binding resolver.
* @returns disposer owned by the caller's Cordis fiber.
*/
provide<
const Hooks extends SessionSourceRoster = undefined,
const KeyedHooks extends SessionSourceRoster = undefined,
const Props extends SessionSourceRoster = undefined,
>(descriptor: SessionSourceDescriptor<Hooks, KeyedHooks, Props>): () => void {
const runtimeDescriptor = descriptor as unknown as RuntimeSessionSourceDescriptor
const dispose = this.ctx.effect(() => {
this.descriptors.push(runtimeDescriptor)
try {
this.rebuildBindings()
} catch (error) {
this.descriptors.pop()
throw error
}
return () => {
const index = this.descriptors.indexOf(runtimeDescriptor)
this.descriptors.splice(index, 1)
this.rebuildBindings()
}
}, 'uiSession.provide()')
return () => { void dispose() }
}
/**
* Register one pending-interaction domain and return its publication function.
* @param precedence - deterministic cross-domain precedence; larger values win.
* @returns a function that publishes one exact interaction until its disposer runs.
*/
attend<T extends SessionPendingInteractionBase>(
precedence: (interaction: T) => number,
): (interaction: T) => () => void {
const domain = new PendingInteractionDomain(precedence, () => {
this.publishPendingInteractions()
})
const runtimeDomain = domain as unknown as RuntimePendingDomain
this.ctx.effect(() => {
this.pendingDomains.push(runtimeDomain)
this.publishPendingInteractions()
return () => {
const index = this.pendingDomains.indexOf(runtimeDomain)
if (index !== -1) this.pendingDomains.splice(index, 1)
this.publishPendingInteractions()
}
}, 'uiSession.attend()')
return interaction => domain.publish(interaction)
}
private rebuildBindings(): void {
const absent = this.materializeAbsent()
const bindings = new Map<SessionId, MaterializedBinding>()
try {
for (const [sessionId, cached] of this.bindings) {
bindings.set(sessionId, this.createMaterializedBinding(cached.owner))
}
} catch (error) {
for (const record of bindings.values()) record.release()
throw error
}
const previous = this.bindings
this.absent = absent
this.bindings = bindings
for (const record of previous.values()) record.release()
this.publishCurrent()
}
private resolve(key: SessionId): ScopedStandardSourceBinding | undefined {
const owner = this.sessions.binding(key)
if (owner === undefined) return undefined
const cached = this.bindings.get(key)
if (cached?.owner === owner) return cached.value
const record = this.createMaterializedBinding(owner)
this.bindings.set(key, record)
cached?.release()
return record.value
}
private resolveCurrent(): StandardSourceBinding {
const current = this.sessions.list.getSnapshot().current
return current === undefined ? this.absent : this.resolve(current) ?? this.absent
}
private publishCurrent(): void {
const next = this.resolveCurrent()
if (next === this.currentBinding) return
this.currentBinding = next
notifySubscribers(this.currentListeners, '[ui-session] current binding')
}
private publishPendingInteractions(): void {
const next = new Map<SessionId, {
interaction: SessionPendingInteractionBase
precedence: number
}>()
for (const domain of this.pendingDomains) {
for (const interaction of domain.valuesSnapshot()) {
const precedence = domain.precedence(interaction)
const previous = next.get(interaction.sessionId)
if (previous === undefined || precedence >= previous.precedence) {
next.set(interaction.sessionId, { interaction, precedence })
}
}
}
const projected = new Map(
[...next].map(([sessionId, value]) => [sessionId, value.interaction] as const),
)
if (samePendingInteractions(this.pendingSnapshot, projected)) return
this.pendingSnapshot = projected
notifySubscribers(this.pendingListeners, '[ui-session] pending interactions')
}
private createMaterializedBinding(owner: SessionBinding): MaterializedBinding {
const value = this.materialize(owner)
let releaseEffect: () => void | Promise<void> = () => {}
const record: MaterializedBinding = {
owner,
value,
release: () => { void releaseEffect() },
}
releaseEffect = owner.ctx.effect(() => () => {
if (this.bindings.get(owner.sessionId) !== record) return
this.bindings.delete(owner.sessionId)
if (this.currentBinding !== value) return
this.currentBinding = this.absent
notifySubscribers(this.currentListeners, '[ui-session] current binding')
}, `ui-session: binding ${owner.sessionId}`)
return record
}
private materialize(binding: SessionBinding): ScopedStandardSourceBinding {
const hooks: Record<string, HostObservable<unknown>> = {}
const keyedHooks: Record<string, KeyedStandardSource> = {}
const props: Record<string, unknown> = {}
const finalProps = new Set<string>()
for (const descriptor of this.descriptors) {
const contribution = descriptor.resolve(binding)
validateContribution(descriptor, contribution)
copyDeclared('hook', hooks, descriptor.hooks, contribution.hooks, finalProps)
copyDeclared('keyed hook', keyedHooks, descriptor.keyedHooks, contribution.keyedHooks, finalProps)
copyDeclared('prop', props, descriptor.props, contribution.props, finalProps)
}
return {
key: binding.sessionId,
ctx: binding.ctx,
hooks,
keyedHooks,
props,
}
}
private materializeAbsent(): StandardSourceBinding {
const hooks: Record<string, undefined> = {}
const keyedHooks: Record<string, undefined> = {}
const props: Record<string, undefined> = {}
const finalProps = new Set<string>()
for (const descriptor of this.descriptors) {
declareAbsent('hook', hooks, descriptor.hooks, finalProps)
declareAbsent('keyed hook', keyedHooks, descriptor.keyedHooks, finalProps)
declareAbsent('prop', props, descriptor.props, finalProps)
}
return { key: undefined, hooks, keyedHooks, props }
}
}
function validateContribution(
descriptor: RuntimeSessionSourceDescriptor,
contribution: RuntimeSessionSourceContribution,
): void {
rejectUndeclared('hook', descriptor.hooks, contribution.hooks)
rejectUndeclared('keyed hook', descriptor.keyedHooks, contribution.keyedHooks)
rejectUndeclared('prop', descriptor.props, contribution.props)
}
function rejectUndeclared(
kind: string,
declared: readonly string[] | undefined,
values: Readonly<Record<string, unknown>> | undefined,
): void {
for (const name of Object.keys(values ?? {})) {
if (!(declared ?? []).includes(name)) {
throw new Error(`uiSession.provide: undeclared ${kind} '${name}'`)
}
}
}
function copyDeclared<T>(
kind: StandardMemberKind,
target: Record<string, T>,
declared: readonly string[] | undefined,
values: Readonly<Record<string, T>> | undefined,
finalProps: Set<string>,
): void {
for (const name of declared ?? []) {
claimStandardProp(kind, name, finalProps)
const value = values?.[name]
if (value === undefined) throw new Error(`uiSession.provide: missing ${kind} '${name}'`)
target[name] = value
}
}
function declareAbsent(
kind: StandardMemberKind,
target: Record<string, undefined>,
declared: readonly string[] | undefined,
finalProps: Set<string>,
): void {
for (const name of declared ?? []) {
claimStandardProp(kind, name, finalProps)
target[name] = undefined
}
}
function claimStandardProp(kind: StandardMemberKind, name: string, finalProps: Set<string>): void {
const propName = kind === 'prop' ? name : standardHookPropName(name)
if (finalProps.has(propName)) {
throw new Error(`uiSession.provide: duplicate ${kind} '${name}' at prop '${propName}'`)
}
finalProps.add(propName)
}
/** Required Controller and renderer services. */
export const inject = ['sessions', 'slots']
/**
* Install the Session root source and scoped adapter.
* @param ctx - Client Cordis context.
*/
export function apply(ctx: Context): void {
const service = new UiSession(ctx, ctx.sessions)
ctx.slots.provideRoot({
hooks: {
sessions: ctx.sessions.list,
sessionPendingInteraction: service.pendingInteractions,
},
} satisfies RootStandardSourceContribution)
ctx.slots.installScope('session', service.adapter)
}
function samePendingInteractions(
left: ReadonlyMap<SessionId, SessionPendingInteractionBase>,
right: ReadonlyMap<SessionId, SessionPendingInteractionBase>,
): boolean {
if (left.size !== right.size) return false
for (const [sessionId, interaction] of left) {
if (right.get(sessionId) !== interaction) return false
}
return true
}
@@ -0,0 +1,20 @@
/** Session-owned rendering semantics for the standard SessionProvider seat. */
import { Fragment, type ReactNode } from 'react'
import type {
SessionAreaProps, StandardSourceBinding,
} from '@deepseek-ai/dsh-client-ui-slots'
/**
* Render the selected Session body or its empty branch.
* @param binding - current Session scope binding.
* @param props - standard Session area render props.
* @returns the selected Session subtree, keyed by Session identity.
*/
export function renderSessionArea(
binding: StandardSourceBinding,
{ empty, children }: SessionAreaProps,
): ReactNode {
const sessionId = binding.key
if (sessionId === undefined) return <>{empty?.() ?? null}</>
return <Fragment key={sessionId}>{children}</Fragment>
}
+4
View File
@@ -0,0 +1,4 @@
/** Host loader entry for the browser-only Session UI adapter. */
/** Provides no Host-side behavior. */
export function apply(): void {}
@@ -0,0 +1,21 @@
/** Package-owned invariant companion for the Session UI adapter. */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-session'
/** Cordis companion plugin name. */
export const name = 'client-ui-session-invariant'
/** Service required before the companion reserves package ownership. */
export const inject = ['invariants']
/** No runtime invariant: the adapter materialization path enforces Session binding consistency. */
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
@@ -0,0 +1,469 @@
import { Context } from '@deepseek-ai/cordis'
import InvariantRegistry from '@deepseek-ai/dsh-invariants'
import type {
AgentContext,
ISessions,
SessionBinding,
SessionListState,
SessionSnapshot,
} from '@deepseek-ai/dsh-api-session-controller/client'
import { MutableSessionEventSource } from '@deepseek-ai/dsh-api-session-controller/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { Fragment } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
apply,
type SessionPendingInteractionBase,
UiSession,
} from '../src/client/index.ts'
import { apply as nodeApply } from '../src/index.ts'
import * as SessionInvariant from '../src/invariant.ts'
interface SessionsBench {
readonly sessions: ISessions
readonly list: ReturnType<typeof createSnapshotStore<SessionListState>>
readonly resolveBinding: ReturnType<typeof vi.fn<(id: SessionId) => SessionBinding | undefined>>
readonly createSession: ReturnType<typeof vi.fn<ISessions['create']>>
readonly openSession: ReturnType<typeof vi.fn<(id: SessionId) => void>>
readonly clearSession: ReturnType<typeof vi.fn<() => void>>
binding(id: SessionId): SessionBinding
select(id: SessionId | undefined): void
release(id: SessionId): Promise<void>
}
const sessionId = (value: string): SessionId => value as SessionId
function createSessionsBench(_ctx: Context): SessionsBench {
const list = createSnapshotStore<SessionListState>({
ids: [],
byId: {},
current: undefined,
phase: 'ready',
subagentsByParent: {},
jobsBySession: {},
currentAddress: undefined,
})
const bindings = new Map<SessionId, SessionBinding>()
const scopes = new Map<SessionId, Context>()
const resolveBinding = vi.fn((id: SessionId) => bindings.get(id))
const createSession = vi.fn<ISessions['create']>(async options =>
options?.sessionId ?? sessionId(`created-${String(options?.workspaceId ?? 'none')}`))
const openSession = vi.fn((id: SessionId) => {
list.update((draft) => { draft.current = id })
})
const clearSession = vi.fn(() => {
list.update((draft) => { draft.current = undefined })
})
const sessions = {
list,
create: createSession,
open: openSession,
clear: clearSession,
binding: resolveBinding,
} as unknown as ISessions
return {
sessions,
list,
resolveBinding,
createSession,
openSession,
clearSession,
binding(id) {
const scopeCtx = new Context()
const snapshot = createSnapshotStore<SessionSnapshot>({
sessionId: id,
queue: [],
running: false,
subagent: null,
removed: false,
openState: 'open',
openError: null,
hasMore: false,
loadingOlder: false,
promptError: null,
blank: false,
lastAgentError: null,
promptAttempted: false,
awaitingFirstTurn: false,
})
const projections = new Map<string, HostObservable<unknown>>()
const session = {
sessionId: id,
projections: {
faceOf(key: string) {
let source = projections.get(key)
if (source === undefined) {
source = createSnapshotStore<unknown>(undefined)
projections.set(key, source)
}
return source
},
},
getSnapshot: () => snapshot.getSnapshot(),
subscribe: (listener: () => void) => snapshot.subscribe(listener),
} as unknown as SessionBinding['session']
const binding: SessionBinding = {
sessionId: id,
session,
eventSource: new MutableSessionEventSource(),
ctx: scopeCtx as AgentContext,
}
bindings.set(id, binding)
scopes.set(id, scopeCtx)
list.update((draft) => {
if (!draft.ids.includes(id)) draft.ids.push(id)
draft.byId[id] = {
id,
displayTitle: id,
running: false,
blank: false,
updatedAt: 1,
}
})
return binding
},
select(id) {
list.update((draft) => { draft.current = id })
},
async release(id) {
bindings.delete(id)
const scopeCtx = scopes.get(id)
scopes.delete(id)
await scopeCtx?.fiber.dispose()
},
}
}
function createUiSession(ctx: Context, bench: SessionsBench): UiSession {
return new UiSession(ctx, bench.sessions)
}
afterEach(() => {
vi.restoreAllMocks()
})
describe('UiSession bindings', () => {
it('materializes built-in sources, caches a binding, and publishes selection and release', async () => {
const ctx = new Context()
const bench = createSessionsBench(ctx)
const service = createUiSession(ctx, bench)
const id = sessionId('s1')
const binding = bench.binding(id)
const current = vi.fn()
const offCurrent = service.adapter.current.subscribe(current)
expect(service.adapter.current.getSnapshot()).toEqual({
key: undefined,
hooks: { session: undefined },
keyedHooks: { projection: undefined },
props: { sessionId: undefined },
})
expect(service.adapter.resolve('missing')).toBeUndefined()
const first = service.adapter.resolve(id)!
expect(service.adapter.resolve(id)).toBe(first)
expect(first.key).toBe(id)
expect(first.hooks.session).toBe(binding.session)
expect(first.props.sessionId).toBe(id)
expect(first.keyedHooks.projection?.('status'))
.toBe(binding.session.projections.faceOf('status'))
bench.select(id)
expect(current).toHaveBeenCalledTimes(1)
expect(service.adapter.current.getSnapshot()).toBe(first)
bench.select(id)
expect(current).toHaveBeenCalledTimes(1)
bench.resolveBinding.mockClear()
await bench.release(id)
expect(bench.resolveBinding).not.toHaveBeenCalled()
expect(current).toHaveBeenCalledTimes(2)
expect(service.adapter.current.getSnapshot().key).toBeUndefined()
const other = sessionId('s2')
bench.binding(other)
service.adapter.resolve(other)
bench.resolveBinding.mockClear()
await bench.release(other)
expect(bench.resolveBinding).not.toHaveBeenCalled()
expect(current).toHaveBeenCalledTimes(2)
offCurrent()
await ctx.fiber.dispose()
})
it('renders the empty area and a Session-keyed selected area', () => {
const ctx = new Context()
const bench = createSessionsBench(ctx)
const service = createUiSession(ctx, bench)
const empty = vi.fn(() => 'empty')
const children = 'session body'
if (service.adapter.renderArea === undefined) throw new Error('Session area renderer was not installed')
const emptyArea = service.adapter.renderArea(
service.adapter.current.getSnapshot(),
{ empty, children },
)
expect(emptyArea).toMatchObject({
type: Fragment,
key: null,
props: { children: 'empty' },
})
expect(empty).toHaveBeenCalledOnce()
const defaultEmptyArea = service.adapter.renderArea(
service.adapter.current.getSnapshot(),
{ children },
)
expect(defaultEmptyArea).toMatchObject({
type: Fragment,
key: null,
props: { children: null },
})
const id = sessionId('s1')
bench.binding(id)
bench.select(id)
const selectedArea = service.adapter.renderArea(
service.adapter.current.getSnapshot(),
{ empty, children },
)
expect(selectedArea).toMatchObject({
type: Fragment,
key: id,
props: { children },
})
expect(empty).toHaveBeenCalledOnce()
})
it('contains a failing current-binding subscriber and continues dispatch', () => {
const ctx = new Context()
const bench = createSessionsBench(ctx)
const service = createUiSession(ctx, bench)
const id = sessionId('s1')
bench.binding(id)
const failure = new Error('subscriber failed')
const report = vi.spyOn(console, 'error').mockImplementation(() => {})
service.adapter.current.subscribe(() => { throw failure })
const after = vi.fn()
service.adapter.current.subscribe(after)
bench.select(id)
expect(after).toHaveBeenCalledOnce()
expect(report).toHaveBeenCalledWith(
'[ui-session] current binding subscriber failed:',
failure,
)
})
it('rebuilds live bindings and removes only the disposed source contribution', () => {
const ctx = new Context()
const bench = createSessionsBench(ctx)
const service = createUiSession(ctx, bench)
const id = sessionId('s1')
bench.binding(id)
bench.select(id)
const custom = createSnapshotStore({ value: 1 })
const keyed = (key: string): HostObservable<unknown> => createSnapshotStore(key)
const dispose = service.provide({
hooks: ['custom'],
keyedHooks: ['customKeyed'],
props: ['customProp'],
resolve: () => ({
hooks: { custom },
keyedHooks: { customKeyed: keyed },
props: { customProp: 'value' },
}),
})
const disposeNeighbor = service.provide({
props: ['neighborProp'],
resolve: () => ({ props: { neighborProp: 'neighbor' } }),
})
const contributed = service.adapter.current.getSnapshot()
expect(contributed.hooks.custom).toBe(custom)
expect(contributed.keyedHooks.customKeyed).toBe(keyed)
expect(contributed.props.customProp).toBe('value')
expect(contributed.props.neighborProp).toBe('neighbor')
dispose()
const restored = service.adapter.current.getSnapshot()
expect(restored.hooks.session).toBeDefined()
expect(typeof restored.keyedHooks.projection).toBe('function')
expect(restored.props.sessionId).toBe(id)
expect(restored.hooks).not.toHaveProperty('custom')
expect(restored.props.neighborProp).toBe('neighbor')
dispose()
expect(service.adapter.current.getSnapshot().props.neighborProp).toBe('neighbor')
disposeNeighbor()
expect(service.adapter.current.getSnapshot().props).not.toHaveProperty('neighborProp')
})
it.each([
['hook', { resolve: () => ({ hooks: { surprise: createSnapshotStore(1) } }) }],
['keyed hook', { resolve: () => ({ keyedHooks: { surprise: () => createSnapshotStore(1) } }) }],
['prop', { resolve: () => ({ props: { surprise: 1 } }) }],
] as const)('rejects an undeclared %s returned by a contribution', (kind, descriptor) => {
const ctx = new Context()
const bench = createSessionsBench(ctx)
const service = createUiSession(ctx, bench)
service.adapter.resolve(bench.binding(sessionId('s1')).sessionId)
expect(() => { service.provide(descriptor as never) })
.toThrow(`uiSession.provide: undeclared ${kind} 'surprise'`)
})
it.each([
['hook', { hooks: ['missing'], resolve: () => ({}) }],
['keyed hook', { keyedHooks: ['missing'], resolve: () => ({}) }],
['prop', { props: ['missing'], resolve: () => ({}) }],
] as const)('rejects a missing declared %s', (kind, descriptor) => {
const ctx = new Context()
const bench = createSessionsBench(ctx)
const service = createUiSession(ctx, bench)
service.adapter.resolve(bench.binding(sessionId('s1')).sessionId)
expect(() => { service.provide(descriptor) })
.toThrow(`uiSession.provide: missing ${kind} 'missing'`)
})
it.each([
['hook', { hooks: ['session'], resolve: () => ({ hooks: { session: createSnapshotStore(1) } }) }],
['keyed hook', {
keyedHooks: ['projection'],
resolve: () => ({ keyedHooks: { projection: () => createSnapshotStore(1) } }),
}],
['prop', { props: ['sessionId'], resolve: () => ({ props: { sessionId: 'other' } }) }],
] as const)('rejects a duplicate declared %s', (kind, descriptor) => {
const ctx = new Context()
const bench = createSessionsBench(ctx)
const service = createUiSession(ctx, bench)
expect(() => { service.provide(descriptor) })
.toThrow(`uiSession.provide: duplicate ${kind}`)
})
it('rejects cross-compartment collisions at the final standard prop name', () => {
const ctx = new Context()
const bench = createSessionsBench(ctx)
const service = createUiSession(ctx, bench)
const source = createSnapshotStore(1)
service.provide({
hooks: ['feature'],
resolve: () => ({ hooks: { feature: source } }),
})
const before = service.adapter.current.getSnapshot()
expect(() => service.provide({
keyedHooks: ['feature'],
resolve: () => ({ keyedHooks: { feature: () => source } }),
})).toThrow("uiSession.provide: duplicate keyed hook 'feature' at prop 'useFeature'")
expect(() => service.provide({
props: ['useFeature'],
resolve: () => ({ props: { useFeature: true } }),
})).toThrow("uiSession.provide: duplicate prop 'useFeature' at prop 'useFeature'")
expect(service.adapter.current.getSnapshot()).toBe(before)
})
})
describe('UiSession pending interactions', () => {
it('publishes the highest-precedence exact object and removes each source independently', () => {
const ctx = new Context()
const bench = createSessionsBench(ctx)
const service = createUiSession(ctx, bench)
const id = sessionId('s1')
const listener = vi.fn()
const off = service.pendingInteractions.subscribe(listener)
const attendApproval = service.attend<SessionPendingInteractionBase>(() => 0)
const attendQuestion = service.attend<SessionPendingInteractionBase>(
interaction => interaction.kind === 'plan-review' ? 2 : 1,
)
listener.mockClear()
const approval = { key: 'approval:1', kind: 'approval', sessionId: id }
const duplicate = { key: 'approval:2', kind: 'approval', sessionId: id }
const question = { key: 'question:1', kind: 'question', sessionId: id }
const plan = { key: 'question:2', kind: 'plan-review', sessionId: id }
const removeApproval = attendApproval(approval)
expect(service.pendingInteractions.getSnapshot().get(id)).toBe(approval)
const removeDuplicate = attendApproval(duplicate)
expect(service.pendingInteractions.getSnapshot().get(id)).toBe(duplicate)
const removeQuestion = attendQuestion(question)
expect(service.pendingInteractions.getSnapshot().get(id)).toBe(question)
const removePlan = attendQuestion(plan)
expect(service.pendingInteractions.getSnapshot().get(id)).toBe(plan)
removeQuestion()
expect(service.pendingInteractions.getSnapshot().get(id)).toBe(plan)
removePlan()
expect(service.pendingInteractions.getSnapshot().get(id)).toBe(duplicate)
removeDuplicate()
expect(service.pendingInteractions.getSnapshot().get(id)).toBe(approval)
removeApproval()
expect(service.pendingInteractions.getSnapshot().has(id)).toBe(false)
off()
})
it('rejects duplicate keys and contains a failing aggregate subscriber', () => {
const ctx = new Context()
const bench = createSessionsBench(ctx)
const service = createUiSession(ctx, bench)
const attend = service.attend<SessionPendingInteractionBase>(() => 1)
const interaction = { key: 'question:1', kind: 'question', sessionId: sessionId('s1') }
const remove = attend(interaction)
expect(() => { attend(interaction) })
.toThrow("ui-session: duplicate pending interaction key 'question:1'")
const failure = new Error('pending subscriber failed')
const report = vi.spyOn(console, 'error').mockImplementation(() => {})
service.pendingInteractions.subscribe(() => { throw failure })
const after = vi.fn()
service.pendingInteractions.subscribe(after)
remove()
expect(after).toHaveBeenCalledOnce()
expect(report).toHaveBeenCalledWith(
'[ui-session] pending interactions subscriber failed:',
failure,
)
})
})
describe('ui-session apply', () => {
it('provides the root sources and installs the Session scope adapter', () => {
const ctx = new Context()
const bench = createSessionsBench(ctx)
const slots = {
provideRoot: vi.fn(),
installScope: vi.fn(),
}
ctx.provide('sessions', bench.sessions)
ctx.provide('slots', slots as never)
apply(ctx)
expect(ctx.uiSession).toBeInstanceOf(UiSession)
expect(slots.provideRoot).toHaveBeenCalledWith({
hooks: {
sessions: bench.sessions.list,
sessionPendingInteraction: ctx.uiSession.pendingInteractions,
},
})
expect(slots.installScope).toHaveBeenCalledWith('session', ctx.uiSession.adapter)
})
it('keeps the Host loader half inert and registers the invariant companion', async () => {
expect(() => { nodeApply() }).not.toThrow()
const ctx = new Context()
await ctx.plugin(InvariantRegistry, { enabled: true })
await expect(ctx.plugin(SessionInvariant).await()).resolves.toBeDefined()
})
})
+36
View File
@@ -0,0 +1,36 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../../api/session-controller/tsconfig.client.json"
},
{
"path": "../../api/workspace-controller/tsconfig.client.json"
},
{
"path": "../../core/session"
},
{
"path": "../store"
},
{
"path": "../ui-renderer"
},
{
"path": "../ui-slots"
},
{
"path": "../../runtime-diagnostics/invariants"
}
]
}
@@ -0,0 +1,3 @@
import { clientBundle } from '../tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-client-ui-session', ['lib/types/index.js', 'lib/types/invariant.js'])
+9 -3
View File
@@ -32,8 +32,10 @@
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-api-workspace-controller",
"@deepseek-ai/dsh-client-ui-renderer",
"@deepseek-ai/dsh-client-ui-layout",
"@deepseek-ai/dsh-client-ui-session",
"@deepseek-ai/dsh-client-locale"
],
"platform": "web"
@@ -48,19 +50,23 @@
"clsx": "^2.0.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-api-workspace-controller": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
"@deepseek-ai/dsh-client-ui-session": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-client-ui-layout": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-api-workspace-controller": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-ui-session": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"@deepseek-ai/cordis": "workspace:^",
@@ -1,7 +1,11 @@
/** Registers the sidebar shell into the layout-owned slot. */
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { Context as ClientContext } from '@deepseek-ai/cordis'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
// Type-only: pulls the SlotRegistry service merge (ctx.slots).
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
// Type-only: pulls the Session UI navigation service merge (ctx.uiSession).
import type {} from '@deepseek-ai/dsh-client-ui-session/client'
import type { SidebarRootInjected } from './contract/slots.ts'
import { SidebarRoot } from './SidebarRoot.tsx'
import { en, zh, type SidebarKey } from './locales.ts'
@@ -23,7 +27,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
const NS = 'sidebar'
/** Services required by the sidebar plugin. */
export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'locale']
export const inject = ['slots', 'layout', 'uiSession', 'locale']
/** Registers the sidebar shell and its service callbacks.
* @param ctx - Client root context.
@@ -32,9 +36,9 @@ export function apply(ctx: ClientContext): void {
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-sidebar: dictionaries')
const injectProps = (): SidebarRootInjected => ({
// The shell's New Session button rides the runtime's shared action
// The shell's New Session button rides the Session UI's shared action
// (current Session Workspace, then recent Workspace).
startSession: (workspaceId) => { ctx.workspaces.startSession(workspaceId) },
startSession: (workspaceId) => { ctx.uiSession.startSession(workspaceId) },
toggleSidebar: () => { ctx.layout.toggleSidebar() },
})
ctx.effect(
@@ -1,7 +1,7 @@
/** Sidebar shell slot registration and its plain runtime/layout callbacks. */
/** Sidebar shell slot registration and its Session/layout callbacks. */
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-sidebar/client'
import type { SidebarRootInjected } from '@deepseek-ai/dsh-client-ui-sidebar/client'
@@ -10,11 +10,9 @@ async function bench(declare = true) {
const ctx = new Context()
await ctx.plugin(SlotRegistry).await()
const layout = { toggleSidebar: vi.fn() }
const workspaces = { startSession: vi.fn() }
const sessions = { open: vi.fn(), clear: vi.fn() }
const uiSession = { startSession: vi.fn() }
ctx.provide('layout', layout)
ctx.provide('sessions', sessions as never)
ctx.provide('workspaces', workspaces as never)
ctx.provide('uiSession', uiSession as never)
ctx.provide('locale', new LocaleRuntime(ctx))
const slots = ctx.get('slots') as SlotRegistry
if (declare) {
@@ -23,12 +21,12 @@ async function bench(declare = true) {
() => null,
)
}
return { ctx, slots, layout, workspaces, sessions }
return { ctx, slots, layout, uiSession }
}
describe('ui-sidebar apply', () => {
it('declares only the services it uses', () => {
expect(inject).toEqual(['slots', 'layout', 'sessions', 'workspaces', 'locale'])
expect(inject).toEqual(['slots', 'layout', 'uiSession', 'locale'])
})
it('registers the shell and declares its child seats', async () => {
@@ -44,11 +42,11 @@ describe('ui-sidebar apply', () => {
expect(b.slots.entries('sidebar')[0]!.locale).toBe('sidebar')
const injected = (b.slots.entries('sidebar')[0]!.inject as () => SidebarRootInjected)()
expect(Object.keys(injected)).toEqual(['startSession', 'toggleSidebar'])
// Both arms delegate to the runtime's shared New Session action.
// Both arms delegate to the Session UI's shared New Session action.
injected.startSession('workspace' as never)
expect(b.workspaces.startSession).toHaveBeenCalledWith('workspace')
expect(b.uiSession.startSession).toHaveBeenCalledWith('workspace')
injected.startSession()
expect(b.workspaces.startSession).toHaveBeenLastCalledWith(undefined)
expect(b.uiSession.startSession).toHaveBeenLastCalledWith(undefined)
injected.toggleSidebar()
expect(b.layout.toggleSidebar).toHaveBeenCalledOnce()
})
@@ -33,10 +33,11 @@ afterEach(() => {
*/
async function bench(options: { locale?: 'en' } = {}) {
const runtime = await SlotTestRuntime.create()
runtime.provide('layout', { toggleSidebar: vi.fn() })
runtime.ctx.provide('layout', { toggleSidebar: vi.fn() })
vi.spyOn(runtime.ctx.uiSession, 'startSession').mockImplementation(() => undefined)
const locale = new LocaleRuntime(runtime.ctx)
if (options.locale === 'en') locale.setLocale('en')
runtime.provide('locale', locale)
runtime.ctx.provide('locale', locale)
runtime.slots.installLocale(locale)
await runtime.declare({ 'sidebar': { kind: 'single', scope: 'root' } })
await runtime.mount({ inject: [...inject], apply })
+7 -1
View File
@@ -8,6 +8,9 @@
"src"
],
"references": [
{
"path": "../../api/workspace-controller/tsconfig.client.json"
},
{
"path": "../../../vendor/cordis"
},
@@ -18,7 +21,10 @@
"path": "../ui-primitives"
},
{
"path": "../runtime"
"path": "../ui-renderer"
},
{
"path": "../ui-session"
},
{
"path": "../ui-layout"
+21 -5
View File
@@ -31,11 +31,18 @@
},
"dsh": {
"client": {
"external": [
"@deepseek-ai/dsh-api-session-controller/client",
"@deepseek-ai/dsh-api-workspace-controller/client"
],
"inject": [
"@deepseek-ai/dsh-api-session-controller",
"@deepseek-ai/dsh-api-workspace-controller",
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-conversation",
"@deepseek-ai/dsh-client-ui-renderer",
"@deepseek-ai/dsh-client-ui-session",
"@deepseek-ai/dsh-client-ui-sidebar"
],
"platform": "web"
@@ -50,24 +57,33 @@
"clsx": "^2.0.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-api-session-controller": "workspace:^",
"@deepseek-ai/dsh-api-workspace-controller": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
"@deepseek-ai/dsh-client-ui-session": "workspace:^",
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^"
"@deepseek-ai/dsh-session": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-api-session-controller": "workspace:^",
"@deepseek-ai/dsh-api-workspace-controller": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-store": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
"@deepseek-ai/dsh-client-ui-session": "workspace:^",
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@types/react": "~18.3.1",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
@@ -14,8 +14,8 @@ import {
Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type {
WorkspaceId, WorkspaceListState, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
WorkspaceId, WorkspaceSnapshot, WorkspaceView,
} from '@deepseek-ai/dsh-api-workspace-controller/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import type { DirectoryFlowOwnerProps, WorkspacePickerProps } from './contract/slots.ts'
import css from './WorkspacePicker.module.css'
@@ -31,7 +31,7 @@ export interface WorkspacePickFlowProps {
/** The anchor button element — the popover's placement anchor. */
anchorRef?: RefObject<HTMLElement | null> | undefined
/** Selector hook over the workspace list (framework standard hook). */
useWorkspaces: <S>(selector: (state: WorkspaceListState) => S) => S
useWorkspaces: <S>(selector: (state: WorkspaceSnapshot) => S) => S
/** Adopt a picked host directory as a real Workspace. */
createWorkspace: (input: { path: string }) => Promise<WorkspaceView>
/** Bound occupancy selector hook for this surface's directory-flow hole (empty leaves the surface with no add action). */
@@ -28,9 +28,9 @@ import type { HostObservable, PropsHooks, PropsLocale, PropsRenderSlots, PropsRu
// runtime shares below.
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {
PendingInteractionStatus, SessionId, SessionSearchResultItem, WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionSearchResultItem } from '@deepseek-ai/dsh-api-session-controller/client'
import type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { createWorkspaceViewStore } from '../stores.ts'
/**
@@ -91,8 +91,6 @@ export type WorkspaceBrowserInjected = {
hooks: DirectoryPickingInjected['hooks'] & {
/** Current generation's Host description, bound by the slot renderer. */
hostDescription: HostDescriptionSource
/** Effective pending Remote Event interaction by Session. */
pendingInteractions: HostObservable<ReadonlyMap<SessionId, PendingInteractionStatus>>
}
/**
* Start a New Session in a Workspace: reuse-or-create its blank session and
@@ -8,17 +8,29 @@
* client half (see the contract module doc). Export discipline:
* packages/client/AGENTS.md.
*/
import type { Context } from '@deepseek-ai/cordis'
import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client'
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { IWorkspaces, WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client'
import type { HostObservable, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
// Type-only: pulls the Controller service merges.
import type {} from '@deepseek-ai/dsh-api-session-controller/client'
import type {} from '@deepseek-ai/dsh-api-workspace-controller/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
// Type-only: pulls the SlotRegistry service merge (ctx.slots).
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
// Type-only: pulls the Session root standard-hook merge.
import type {} from '@deepseek-ai/dsh-client-ui-session/client'
import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from './contract/slots.ts'
import { UiWorkspaceService } from './navigation.ts'
import { createWorkspaceViewStore } from './stores.ts'
import { WorkspaceBrowser } from './WorkspaceBrowser.tsx'
import { WorkspaceBrowser } from './rows/WorkspaceBrowser.tsx'
import { WorkspacePicker } from './WorkspacePicker.tsx'
import { en, zh, type WorkspaceKey } from './locales.ts'
export { DirectoryBrowseError } from './navigation.ts'
export type { UiWorkspace } from './navigation.ts'
export type {
DirectoryFlowOwnerProps, DirectoryFlowSlotName, DirectoryPickingHooks, DirectoryPickingInjected,
WorkspaceBrowserInjected, WorkspaceBrowserProps, WorkspacePickerInjected, WorkspacePickerProps,
@@ -26,6 +38,11 @@ export type {
export type { WorkspaceKey } from './locales.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface GlobalStandardProps {
/** Selector hook over the pure Workspace Controller snapshot. */
useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>
}
interface LocaleNamespaceMap {
/** The workspace browsing region and pick/create flow copy. */
workspace: WorkspaceKey
@@ -43,7 +60,7 @@ const NS = 'workspace'
* provides a waitable service. apply therefore depends on each slot
* declaration through `slots.inject()` instead of assuming order.
*/
export const inject = ['slots', 'sessions', 'workspaces', 'conversation', 'locale', 'connection']
export const inject = ['slots', 'sessions', 'workspaces', 'locale', 'connection']
/**
* Register the browser and picker once their slot declarations are on the
@@ -51,13 +68,17 @@ export const inject = ['slots', 'sessions', 'workspaces', 'conversation', 'local
* framework's global hooks.
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
export function apply(ctx: Context): void {
const connection = ctx.get('connection') as ConnectionHandle
const sessions = ctx.get('sessions') as ISessions
const workspaces = ctx.get('workspaces') as IWorkspaces
const hostDescription = connection.hostDescription
const uiWorkspace = new UiWorkspaceService(ctx, connection.api, workspaces, sessions)
ctx.slots.provideRoot({ hooks: { workspaces: workspaces.list } })
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-workspace: dictionaries')
const searchSessions: WorkspaceBrowserInjected['searchSessions'] = async (query, signal) => {
const result = await ctx.sessions.search(query, signal)
const result = await sessions.search(query, signal)
if (!result.ok) throw new Error(result.error.message)
return result.value
}
@@ -73,43 +94,39 @@ export function apply(ctx: ClientContext): void {
const browserInjected = (): WorkspaceBrowserInjected => ({
// Explicit group actions keep their target; unscoped New Session inherits
// the current Session Workspace before the recent-Workspace fallback.
startSession: (workspaceId) => { ctx.workspaces.startSession(workspaceId) },
open: (sessionId) => { ctx.sessions.open(sessionId) },
startSession: (workspaceId) => { uiWorkspace.startSession(workspaceId) },
open: (sessionId) => { sessions.open(sessionId) },
searchSessions,
searchResultLimit: ctx.sessions.searchResultLimit,
searchResultLimit: sessions.searchResultLimit,
renameSession: async (sessionId, title) => {
// Row → session-face hop: rename is a per-session verb (ISession), not
// a list-service verb; the binding resolves any listed session.
const session = ctx.sessions.binding(sessionId)?.session
const session = sessions.binding(sessionId)?.session
if (session === undefined) throw new Error(`unknown session "${sessionId}"`)
const result = await session.rename(title)
if (!result.ok) throw new Error(result.error.message)
},
forkSession: (sessionId) => {
ctx.sessions.fork({ sessionId, increaseTitle: true })
.then((childId) => { ctx.sessions.open(childId) })
sessions.fork({ sessionId, increaseTitle: true })
.then((childId) => { sessions.open(childId) })
.catch(() => {
// Fork or child-rename failure keeps the current selection.
})
},
renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) },
deleteWorkspace: async (workspaceId) => { await ctx.workspaces.delete(workspaceId) },
renameWorkspace: async (workspaceId, title) => { await workspaces.rename(workspaceId, title) },
deleteWorkspace: async (workspaceId) => { await workspaces.delete(workspaceId) },
insertWorkspaceBefore: async (workspaceId, beforeWorkspaceId) => {
await ctx.workspaces.insertBefore(workspaceId, beforeWorkspaceId)
await workspaces.insertBefore(workspaceId, beforeWorkspaceId)
},
archiveSession: async (sessionId) => { await ctx.workspaces.archiveSession(sessionId) },
archiveSession: async (sessionId) => { await uiWorkspace.archiveSession(sessionId) },
insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => {
await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId)
},
createWorkspace: input => ctx.workspaces.create(input),
hooks: {
directoryFlow: browserFlowSource,
hostDescription,
pendingInteractions: ctx.conversation.pendingInteractions.statuses,
await workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId)
},
createWorkspace: input => workspaces.create(input),
hooks: { directoryFlow: browserFlowSource, hostDescription },
})
const pickerInjected = (): WorkspacePickerInjected => ({
createWorkspace: input => ctx.workspaces.create(input),
createWorkspace: input => workspaces.create(input),
hooks: { directoryFlow: pickerFlowSource },
})
// Each registration declares its directory-flow child in the same call;
@@ -0,0 +1,248 @@
/** Workspace archive and directory UI capability. */
import { Service, type Context } from '@deepseek-ai/cordis'
import type {
DirectoryListing, IApiClient, RpcError,
} from '@deepseek-ai/dsh-client-connection/client'
import type {
ISessions,
SessionListState,
} from '@deepseek-ai/dsh-api-session-controller/client'
import type {
IWorkspaces, WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-api-workspace-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
/** Workspace archive and directory operations consumed by Client UI domains. */
export interface UiWorkspace {
/**
* Resolve the reusable or newly created blank Session for a Workspace.
* @param workspaceId - target Workspace.
* @returns a Session already addressable through the Session Controller.
*/
connectWorkspace(workspaceId: WorkspaceId): Promise<SessionId>
/**
* Start a New Session flow and navigate to its Session.
* @param workspaceId - explicit target; absent inherits the current or most recent Workspace.
*/
startSession(workspaceId?: WorkspaceId): void
/**
* Archive a Session and clear it when it is the current selection.
* @param sessionId - Session to archive.
*/
archiveSession(sessionId: SessionId): Promise<void>
/** @returns the Host-native picked directory, or null when cancelled. */
pickDirectory(): Promise<string | null>
/**
* List one Host directory level.
* @param path - directory path; absent selects the Host home.
* @param signal - cancellation for a superseded scan.
* @returns directory entries and breadcrumb ancestry.
*/
listDirectory(path?: string, signal?: AbortSignal): Promise<DirectoryListing>
/**
* Create a child directory.
* @param path - existing parent directory.
* @param name - child directory name.
* @returns created absolute path.
*/
createDirectory(path: string, name: string): Promise<string>
/**
* Open a path with the Host operating system.
* @param path - absolute or Host-resolvable path.
*/
openPath(path: string): Promise<void>
}
declare module '@deepseek-ai/cordis' {
interface Context {
/** Cross-Controller Workspace navigation and directory UI capability. */
uiWorkspace: UiWorkspace
}
}
/** Structured directory failure exposed to directory UI consumers. */
export class DirectoryBrowseError extends Error {
override readonly name = 'DirectoryBrowseError'
/** @param rpcError - Host directory business failure. */
constructor(readonly rpcError: RpcError) {
super(`directory browse failed: ${rpcError.code}: ${rpcError.message}`)
}
}
/** Implements Workspace archive and directory UI operations. */
class UiWorkspaceService extends Service implements UiWorkspace {
private readonly connecting = new Map<WorkspaceId, Promise<SessionId>>()
/**
* @param ctx - Client root Context.
* @param api - shared Host API carrier.
* @param workspaces - pure Workspace Controller.
* @param sessions - pure Session Controller.
*/
constructor(
ctx: Context,
private readonly api: IApiClient,
private readonly workspaces: IWorkspaces,
private readonly sessions: ISessions,
) {
super(ctx, 'uiWorkspace')
ctx.effect(() => this.watchNavigation(), 'ui-workspace: Workspace navigation policy')
}
async connectWorkspace(workspaceId: WorkspaceId): Promise<SessionId> {
const workspace = this.workspaces.list.getSnapshot().items
.find(item => item.workspaceId === workspaceId)
if (workspace === undefined) {
throw new Error(`uiWorkspace.connectWorkspace: unknown workspace ${workspaceId}`)
}
const inflight = this.connecting.get(workspaceId)
if (inflight !== undefined) return inflight
const archived = this.workspaces.list.getSnapshot().archivedSessionIds
const sessions = this.sessions.list.getSnapshot()
for (const id of sessions.ids) {
const summary = sessions.byId[id]
if (summary !== undefined && summary.blank && summary.cwd === workspace.path
&& workspace.sessionIds.includes(summary.id)
&& !archived.includes(summary.id)) return summary.id
}
const attempt = this.sessions.create({ workspaceId })
.finally(() => { this.connecting.delete(workspaceId) })
this.connecting.set(workspaceId, attempt)
return attempt
}
startSession(workspaceId?: WorkspaceId): void {
const workspace = this.workspaces.list.getSnapshot()
const sessions = this.sessions.list.getSnapshot()
const current = sessions.current
const currentWorkspaceId = current === undefined
? undefined
: workspace.items.find(item => item.sessionIds.includes(current))?.workspaceId
const recent = workspace.phase === 'ready' && sessions.phase === 'ready'
? recentWorkspace(workspace.items, sessions.byId)
: undefined
const target = workspaceId ?? currentWorkspaceId ?? recent
if (target === undefined) {
this.sessions.clear()
return
}
void this.connectWorkspace(target).then(
(sessionId) => { this.sessions.open(sessionId) },
(reason: unknown) => { console.warn('new session failed:', reason) },
)
}
async archiveSession(sessionId: SessionId): Promise<void> {
await this.workspaces.archiveSession(sessionId)
}
async pickDirectory(): Promise<string | null> {
const response = await this.api.host.pickDirectory({})
if (!response.result.ok) {
throw new Error(`directory picker failed: ${response.result.error.message}`)
}
return response.result.value.path
}
async listDirectory(path?: string, signal?: AbortSignal): Promise<DirectoryListing> {
const response = await this.api.host.listDirectory(path === undefined ? {} : { path }, signal)
if (!response.result.ok) throw new DirectoryBrowseError(response.result.error)
return response.result.value
}
async createDirectory(path: string, name: string): Promise<string> {
const response = await this.api.host.createDirectory({ path, name })
if (!response.result.ok) throw new DirectoryBrowseError(response.result.error)
return response.result.value.path
}
async openPath(path: string): Promise<void> {
const response = await this.api.host.openPath({ path })
if (!response.result.ok) {
throw new Error(`path open failed: ${response.result.error.message}`)
}
}
private watchNavigation(): () => void {
let initial: 'waiting' | 'connecting' | 'done' = 'waiting'
let disposed = false
const reconcile = (): void => {
if (disposed) return
if (this.clearArchivedCurrent()) return
if (initial !== 'waiting') return
const workspace = this.workspaces.list.getSnapshot()
const sessions = this.sessions.list.getSnapshot()
if (workspace.phase !== 'ready' || sessions.phase !== 'ready') return
if (sessions.current !== undefined) {
initial = 'done'
return
}
const target = recentWorkspace(workspace.items, sessions.byId)
if (target === undefined) {
initial = 'done'
return
}
initial = 'connecting'
void this.connectWorkspace(target).then(
(sessionId) => {
if (disposed) return
if (this.sessions.list.getSnapshot().current === undefined) {
this.sessions.open(sessionId)
}
initial = 'done'
},
(reason: unknown) => {
if (disposed) return
initial = 'waiting'
console.warn('initial workspace selection failed:', reason)
},
)
}
const disposeWorkspaces = this.workspaces.list.subscribe(reconcile)
const disposeSessions = this.sessions.list.subscribe(reconcile)
reconcile()
return () => {
disposed = true
disposeSessions()
disposeWorkspaces()
}
}
/** @returns true when an archived current selection was cleared. */
private clearArchivedCurrent(): boolean {
const current = this.sessions.list.getSnapshot().current
if (current === undefined
|| !this.workspaces.list.getSnapshot().archivedSessionIds.includes(current)) return false
this.sessions.clear()
return true
}
}
/** Stable tie-breaking follows Host Workspace order. */
function recentWorkspace(
workspaces: readonly WorkspaceView[],
sessions: SessionListState['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
}
export { UiWorkspaceService }
@@ -13,7 +13,7 @@ import {
IconTrashOutline16, IconTriangleRightFill14, Menu, StateDot,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { StateDotState } from '@deepseek-ai/dsh-client-ui-primitives'
import { abbreviateHomePath } from '@deepseek-ai/dsh-client-runtime/client'
import { abbreviateHomePath } from '@deepseek-ai/dsh-api-workspace-controller/client'
import type { WorkspaceBrowserProps } from '../contract/slots.ts'
import type { GroupNode, SearchResultNode, SessionNode } from '../tree.ts'
import { relativeTime } from '../tree.ts'
@@ -16,14 +16,16 @@ import {
IconProjectAddOutline16, IconSearchOutline16, Menu, Modal, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type {
SessionId, SessionListState, SessionSearchResultItem, WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { WorkspaceBrowserProps } from './contract/slots.ts'
import type { SessionNode, SessionOrderBy } from './tree.ts'
import { deriveFlat, deriveGroups, deriveSearchResults, UNGROUPED_KEY } from './tree.ts'
import { ProjectRowItem, SearchResultItem, SessionNodeItem } from './rows/Rows.tsx'
import { FLAT_SESSION_ORDER_KEY } from './stores.ts'
import { WorkspacePickFlow } from './WorkspacePicker.tsx'
SessionListState, SessionSearchResultItem,
} from '@deepseek-ai/dsh-api-session-controller/client'
import type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { WorkspaceBrowserProps } from '../contract/slots.ts'
import type { SessionNode, SessionOrderBy } from '../tree.ts'
import { deriveFlat, deriveGroups, deriveSearchResults, UNGROUPED_KEY } from '../tree.ts'
import { ProjectRowItem, SearchResultItem, SessionNodeItem } from './Rows.tsx'
import { FLAT_SESSION_ORDER_KEY } from '../stores.ts'
import { WorkspacePickFlow } from '../WorkspacePicker.tsx'
import css from './WorkspaceBrowser.module.css'
/**
@@ -215,7 +217,7 @@ function workspaceGroupHalf(e: { clientY: number; currentTarget: HTMLElement }):
type SessionTreeProps = Pick<
WorkspaceBrowserProps,
'useSessions' | 'usePendingInteractions' | 'startSession' | 'open' | 'forkSession'
'useSessions' | 'useSessionPendingInteraction' | 'startSession' | 'open' | 'forkSession'
| 'insertWorkspaceBefore' | 'insertSessionBefore' | 't'
> & {
/** Host account home for POSIX hover-path abbreviation. */
@@ -249,14 +251,14 @@ type SessionTreeProps = Pick<
/** The scrolling session tree; unmounting drops the sessions subscription and expand-all state. */
function SessionTree({
useSessions, usePendingInteractions, startSession, open, forkSession, workspaces, archivedSessionIds,
useSessions, useSessionPendingInteraction, startSession, open, forkSession, workspaces, archivedSessionIds,
onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive,
insertWorkspaceBefore, insertSessionBefore, orderBy,
groupExpansion, setGroupExpanded,
sessionOrderByAccount, sessionUpdatedAtByAccount, syncSessionOrderAccount, setSessionOrder, home, t,
}: SessionTreeProps) {
const list = useSessions(s => s)
const pendingInteractions = usePendingInteractions(s => s)
const pendingInteractions = useSessionPendingInteraction(s => s)
const current = list.current
const [expandedSessionGroups, setExpandedSessionGroups] = useState<string[]>([])
// Transient drag marker state; the selected mode owns the resulting order.
@@ -281,7 +283,7 @@ function SessionTree({
)
const ungroupedSessionIds = useMemo(() => {
const accounted = new Set(workspaces.flatMap(workspace => workspace.sessionIds))
return list.ids.filter(id => list.byId[id] !== undefined && !accounted.has(id))
return list.ids.filter((id: SessionId) => list.byId[id] !== undefined && !accounted.has(id))
}, [list, workspaces])
useEffect(() => {
if (list.phase !== 'ready') return
@@ -548,12 +550,13 @@ function SessionTree({
/** The flat "In one list" body: every session is one draggable top-level row. */
function FlatList({
useSessions, usePendingInteractions, open, forkSession, onSessionRename, onSessionArchive, archivedSessionIds,
useSessions, useSessionPendingInteraction, open, forkSession, onSessionRename, onSessionArchive,
archivedSessionIds,
orderBy, sessionOrderByAccount, sessionUpdatedAtByAccount, syncSessionOrderAccount, setSessionOrder, t,
}: Pick<
SessionTreeProps,
| 'useSessions'
| 'usePendingInteractions'
| 'useSessionPendingInteraction'
| 'open'
| 'forkSession'
| 'onSessionRename'
@@ -567,7 +570,7 @@ function FlatList({
| 't'
>) {
const list = useSessions(s => s)
const pendingInteractions = usePendingInteractions(s => s)
const pendingInteractions = useSessionPendingInteraction(s => s)
const baseRows = useMemo(
() => deriveFlat(list, archivedSessionIds, pendingInteractions),
[list, archivedSessionIds, pendingInteractions],
@@ -678,7 +681,7 @@ interface RemoteSearchState {
/** Flat search body: local metadata matches plus the current Host result page. */
function SearchResults({
useSessions,
usePendingInteractions,
useSessionPendingInteraction,
open,
workspaces,
archivedSessionIds,
@@ -686,7 +689,7 @@ function SearchResults({
remote,
resultLimit,
t,
}: Pick<SessionTreeProps, 'useSessions' | 'usePendingInteractions' | 'open' | 't'> & {
}: Pick<SessionTreeProps, 'useSessions' | 'useSessionPendingInteraction' | 'open' | 't'> & {
workspaces: readonly WorkspaceView[]
archivedSessionIds: readonly SessionNode['id'][]
query: string
@@ -694,13 +697,19 @@ function SearchResults({
resultLimit: number
}) {
const list = useSessions(s => s)
const pendingInteractions = usePendingInteractions(s => s)
const pendingInteractions = useSessionPendingInteraction(s => s)
const currentRemote = remote.query === query
? remote
: { query, status: 'loading' as const, items: [], hasMore: false }
const results = useMemo(
() => deriveSearchResults(
list, workspaces, query, archivedSessionIds, pendingInteractions, currentRemote, resultLimit,
list,
workspaces,
query,
archivedSessionIds,
pendingInteractions,
currentRemote,
resultLimit,
),
[list, workspaces, query, archivedSessionIds, pendingInteractions, currentRemote, resultLimit],
)
@@ -752,7 +761,7 @@ export function WorkspaceBrowser({
wide,
expandSidebar,
useSessions,
usePendingInteractions,
useSessionPendingInteraction,
useWorkspaces,
useStore,
actions,
@@ -799,8 +808,9 @@ export function WorkspaceBrowser({
promotedBlank.current = undefined
return
}
if (promotedBlank.current?.sessionId === currentBlankSessionId
&& promotedBlank.current.accountKey === currentBlankAccount) return
const promoted = promotedBlank.current
if (promoted !== undefined && promoted.sessionId === currentBlankSessionId
&& promoted.accountKey === currentBlankAccount) return
promotedBlank.current = { sessionId: currentBlankSessionId, accountKey: currentBlankAccount }
for (const accountKey of new Set([currentBlankAccount, FLAT_SESSION_ORDER_KEY])) {
const previous = sessionOrderByAccount[accountKey] ?? []
@@ -1153,7 +1163,7 @@ export function WorkspaceBrowser({
? (
<SearchResults
useSessions={useSessions}
usePendingInteractions={usePendingInteractions}
useSessionPendingInteraction={useSessionPendingInteraction}
open={open}
workspaces={workspaces}
archivedSessionIds={archivedSessionIds}
@@ -1166,7 +1176,7 @@ export function WorkspaceBrowser({
: groupBy === 'flat'
? (
<FlatList
useSessions={useSessions} usePendingInteractions={usePendingInteractions}
useSessions={useSessions} useSessionPendingInteraction={useSessionPendingInteraction}
open={open} forkSession={forkSession}
onSessionRename={onSessionRename} onSessionArchive={onSessionArchive}
archivedSessionIds={archivedSessionIds}
@@ -1181,7 +1191,7 @@ export function WorkspaceBrowser({
: (
<SessionTree
useSessions={useSessions}
usePendingInteractions={usePendingInteractions}
useSessionPendingInteraction={useSessionPendingInteraction}
onSessionRename={onSessionRename}
onSessionArchive={onSessionArchive}
forkSession={forkSession}
@@ -5,7 +5,7 @@
* register() receives the factory and the browser derives its PropsStore
* share from the return type.
*/
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client'
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store'
/** Browser-local order account for the hierarchy-free flat Session list. */
export const FLAT_SESSION_ORDER_KEY = '__flat_session_order__'
+36 -18
View File
@@ -4,12 +4,14 @@
* remains visible.
*/
import {
indexSubagentDescendants, type PendingInteractionStatus, type SessionId, type SessionListState,
indexSubagentDescendants, type SessionListState,
type SessionSearchResultItem, type SessionSummary, type SubagentDescendantSummary,
type WorkspaceId, type WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
type PendingInteractions = ReadonlyMap<SessionId, PendingInteractionStatus>
} from '@deepseek-ai/dsh-api-session-controller/client'
import type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-controller/client'
import type {
SessionPendingInteractionBase,
} from '@deepseek-ai/dsh-client-ui-session/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
/** Group key for Sessions outside every Workspace. */
export const UNGROUPED_KEY = ''
@@ -17,6 +19,10 @@ export const UNGROUPED_KEY = ''
/** Display label for the ungrouped bucket row. */
export const UNGROUPED_LABEL = 'Ungrouped'
/** Pending interaction kinds with dedicated Workspace-row presentation. */
export type SessionPendingInteractionStatus = 'approval' | 'plan-review' | 'question'
type SessionPendingInteractions = ReadonlyMap<SessionId, SessionPendingInteractionBase>
/** One top-level session row in a group or the flat list. */
export interface SessionNode {
id: SessionId
@@ -24,8 +30,8 @@ export interface SessionNode {
title: string
/** The provisional blank session (renderer shows the localized New Session title). */
blank: boolean
/** A Remote Event interaction awaiting this user. */
pendingInteraction?: PendingInteractionStatus
/** A Session-scoped UI consumer is awaiting this user. */
pendingInteraction?: SessionPendingInteractionStatus
running: boolean
/** Running descendants connected through uninterrupted subagent-origin lineage. */
runningSubagentCount: number
@@ -61,8 +67,8 @@ export interface SearchResultNode {
id: SessionId
title: string
workspace: string
/** A Remote Event interaction awaiting this user. */
pendingInteraction?: PendingInteractionStatus
/** A Session-scoped UI consumer is awaiting this user. */
pendingInteraction?: SessionPendingInteractionStatus
running: boolean
/** Running descendants connected through uninterrupted subagent-origin lineage. */
runningSubagentCount: number
@@ -213,12 +219,24 @@ function groupByWorkspace(
return groups
}
/** Keep navigation presentation independent from domain-owned interaction objects. */
function visiblePendingKind(kind: string | undefined): SessionPendingInteractionStatus | undefined {
switch (kind) {
case 'approval':
case 'plan-review':
case 'question':
return kind
default:
return undefined
}
}
function sessionNode(
s: SessionSummary,
descendants: ReadonlyMap<SessionId, SubagentDescendantSummary>,
pendingInteractions: PendingInteractions,
pendingInteractions: SessionPendingInteractions,
): SessionNode {
const pendingInteraction = pendingInteractions.get(s.id)
const pendingInteraction = visiblePendingKind(pendingInteractions.get(s.id)?.kind)
return {
id: s.id,
title: sessionTitle(s),
@@ -242,7 +260,7 @@ function sessionNode(
* @param list - sessions list snapshot (`current` feeds containsCurrent).
* @param workspaces - real workspaces in stable Host order.
* @param archivedSessionIds - registry-global archive set.
* @param pendingInteractions - pending Remote Event presentation by Session.
* @param pendingInteractions - pending UI interactions by Session.
* @param view - local expansion arrays.
* @returns group sections in render order.
*/
@@ -250,7 +268,7 @@ export function deriveGroups(
list: SessionListState,
workspaces: readonly WorkspaceView[],
archivedSessionIds: readonly SessionId[],
pendingInteractions: PendingInteractions,
pendingInteractions: SessionPendingInteractions,
view: TreeView,
): GroupNode[] {
const archived = new Set(archivedSessionIds)
@@ -287,13 +305,13 @@ export function deriveGroups(
* (see {@link deriveSearchResults}).
* @param list - sessions list snapshot.
* @param archivedSessionIds - registry-global archive set.
* @param pendingInteractions - pending Remote Event presentation by Session.
* @param pendingInteractions - pending UI interactions by Session.
* @returns flat rows in render order.
*/
export function deriveFlat(
list: SessionListState,
archivedSessionIds: readonly SessionId[],
pendingInteractions: PendingInteractions,
pendingInteractions: SessionPendingInteractions,
): SessionNode[] {
const archived = new Set(archivedSessionIds)
const descendants = indexSubagentDescendants(list.byId)
@@ -324,7 +342,7 @@ export interface RelativeTime {
* @param workspaces - Workspace membership and display labels.
* @param query - caller text; surrounding whitespace is ignored.
* @param archivedSessionIds - registry-global archive set (members never match).
* @param pendingInteractions - pending Remote Event presentation by Session.
* @param pendingInteractions - pending UI interactions by Session.
* @param content - ranked Host content-search page.
* @param limit - protocol-owned maximum merged row count.
* @returns bounded deduplicated flat rows and a refine-query hint bit.
@@ -334,7 +352,7 @@ export function deriveSearchResults(
workspaces: readonly WorkspaceView[],
query: string,
archivedSessionIds: readonly SessionId[],
pendingInteractions: PendingInteractions,
pendingInteractions: SessionPendingInteractions,
content: { items: readonly SessionSearchResultItem[]; hasMore: boolean },
limit: number,
): SearchResultSet {
@@ -387,7 +405,7 @@ export function deriveSearchResults(
return {
items: ordered.slice(0, limit).map((summary) => {
const match = contentBySession.get(summary.id)
const pendingInteraction = pendingInteractions.get(summary.id)
const pendingInteraction = visiblePendingKind(pendingInteractions.get(summary.id)?.kind)
return {
id: summary.id,
title: sessionTitle(summary),
@@ -1,10 +1,10 @@
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client'
import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from '@deepseek-ai/dsh-client-ui-workspace/client'
import { WorkspaceBrowser } from '../src/client/WorkspaceBrowser.tsx'
import { WorkspaceBrowser } from '../src/client/rows/WorkspaceBrowser.tsx'
import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx'
async function bench() {
@@ -15,7 +15,6 @@ async function bench() {
path: 'name' in input ? `/projects/${input.name}` : input.path,
title: 'new', sessionIds: [], createdAt: '0', updatedAt: '0',
}))
const startSession = vi.fn()
const rename = vi.fn(async () => ({}))
const insertSessionBefore = vi.fn(async () => ({}))
const open = vi.fn()
@@ -27,18 +26,40 @@ async function bench() {
const renameSession = vi.fn(async (title: string) => ({ ok: true, value: { title, seq: 1 } }))
const binding = vi.fn(() => ({ session: { rename: renameSession } }))
const fork = vi.fn(async () => 'forked' as never)
const subscribe = () => () => {}
ctx.provide('workspaces', {
create, startSession, rename, insertSessionBefore,
list: {
getSnapshot: () => ({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
}),
subscribe,
},
create,
rename,
delete: vi.fn(async () => undefined),
insertBefore: vi.fn(async () => undefined),
archiveSession: vi.fn(async () => undefined),
insertSessionBefore,
} as never)
ctx.provide('sessions', {
list: {
getSnapshot: () => ({
ids: [], byId: {}, current: undefined, phase: 'ready',
subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
}),
subscribe,
},
create: vi.fn(async () => 'created' as never),
open,
clear,
search,
searchResultLimit: 20,
binding,
fork,
} as never)
ctx.provide('sessions', { open, clear, search, searchResultLimit: 20, binding, fork } as never)
ctx.provide('connection', {
hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} },
} as never)
ctx.provide('conversation', {
pendingInteractions: {
statuses: { getSnapshot: () => new Map(), subscribe: () => () => {} },
},
} as never)
const locale = new LocaleRuntime(ctx)
// These specs assert the shipped Chinese copy. There is no jsdom `window`
// in this lane, so browser-language detection never runs and the locale
@@ -46,7 +67,7 @@ async function bench() {
locale.setLocale('zh')
ctx.provide('locale', locale)
return {
ctx, slots: ctx.get('slots') as SlotRegistry, locale, create, startSession, rename,
ctx, slots: ctx.get('slots') as SlotRegistry, locale, create, rename,
insertSessionBefore, open, clear, search, renameSession, binding, fork,
}
}
@@ -61,7 +82,9 @@ function declare(slots: SlotRegistry, ...names: HoleName[]): () => void {
describe('ui-workspace apply', () => {
it('declares the services it drives', () => {
expect(inject).toEqual(['slots', 'sessions', 'workspaces', 'conversation', 'locale', 'connection'])
expect(inject).toEqual([
'slots', 'sessions', 'workspaces', 'locale', 'connection',
])
})
it('registers browser and pickers for declarations arriving before or after apply', async () => {
@@ -86,13 +109,14 @@ describe('ui-workspace apply', () => {
const b = await bench()
declare(b.slots, 'sidebar.workspaces', 'conversation.hero.workspace')
await b.ctx.plugin({ inject: [...inject], apply }).await()
const startSession = vi.spyOn(b.ctx.uiWorkspace, 'startSession').mockImplementation(() => undefined)
const browser = (b.slots.entries('sidebar.workspaces')[0]!.inject as () => WorkspaceBrowserInjected)()
// Both arms delegate to the runtime's shared New Session action.
// Both arms delegate to the shared Session navigation action.
browser.startSession('ws' as never)
expect(b.startSession).toHaveBeenCalledWith('ws')
expect(startSession).toHaveBeenCalledWith('ws')
browser.startSession()
expect(b.startSession).toHaveBeenLastCalledWith(undefined)
expect(startSession).toHaveBeenLastCalledWith(undefined)
browser.open('session' as never)
expect(b.open).toHaveBeenCalledWith('session')
const signal = new AbortController().signal
@@ -148,7 +172,7 @@ describe('ui-workspace apply', () => {
unsubscribe()
})
it('rejects the browser search callback on a runtime business error', async () => {
it('rejects the browser search callback on a Session Controller business error', async () => {
const b = await bench()
b.search.mockImplementationOnce(async () => ({
ok: false,
@@ -7,7 +7,7 @@ import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const css = readFileSync(fileURLToPath(new URL('../src/client/WorkspaceBrowser.module.css', import.meta.url)), 'utf8')
const css = readFileSync(fileURLToPath(new URL('../src/client/rows/WorkspaceBrowser.module.css', import.meta.url)), 'utf8')
const rowsCss = readFileSync(fileURLToPath(new URL('../src/client/rows/Rows.module.css', import.meta.url)), 'utf8')
/**
@@ -7,13 +7,15 @@
* the list state no push-frame wait. Coverage split: the assembled-app
* snapshot (apps/web/tests/session-actions.snapshot.ts) pins the full-app
* transcript; the
* verb's wire behavior stays with the runtime package
* verb's wire behavior stays with the Session Controller client package
* (session.spec.ts#rename), the dialog's own arms with rows.spec /
* workspace-browser.spec.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, waitFor, within } from '@testing-library/react'
import type { ISession, SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client'
import type { WorkspaceId } from '@deepseek-ai/dsh-api-workspace-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotTestRuntime, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
@@ -31,19 +33,12 @@ beforeEach(() => { localStorage.clear() })
/** Runtime with the locale face installed (the browser entry declares `locale:` — zh default backs the t seat). */
async function createRuntime(): Promise<SlotTestRuntime> {
const runtime = await SlotTestRuntime.create()
const noPendingInteractions = new Map()
runtime.provide('connection', {
runtime.releaseWorkspaceSource()
runtime.ctx.provide('connection', {
hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} },
})
runtime.provide('conversation', {
pendingInteractions: {
statuses: { getSnapshot: () => noPendingInteractions, subscribe: () => () => {} },
forSession: () => ({ getSnapshot: () => [], subscribe: () => () => {} }),
present: () => () => {},
},
})
const locale = new LocaleRuntime(runtime.ctx)
runtime.provide('locale', locale)
runtime.ctx.provide('locale', locale)
runtime.slots.installLocale(locale)
return runtime
}
@@ -1,7 +1,8 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, createEvent, fireEvent, render, screen } from '@testing-library/react'
import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type { WorkspaceId } from '@deepseek-ai/dsh-api-workspace-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import type { RowDragProps } from '../src/client/rows/Rows.tsx'
@@ -1,7 +1,8 @@
import { describe, expect, it } from 'vitest'
import type {
PendingInteractionStatus, SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionListState, SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client'
import type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-controller/client'
import type { SessionPendingInteractionBase } from '@deepseek-ai/dsh-client-ui-session/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import {
deriveFlat, deriveGroups, deriveSearchResults, workspaceLabel, relativeTime,
UNGROUPED_KEY, UNGROUPED_LABEL,
@@ -29,14 +30,14 @@ const view = (expandedGroups: readonly string[] = [], ungroupedOrder?: readonly
...(ungroupedOrder === undefined ? {} : { ungroupedOrder }),
})
const noArchive: readonly SessionId[] = []
const noPending: ReadonlyMap<SessionId, PendingInteractionStatus> = new Map()
const noAttention: ReadonlyMap<SessionId, SessionPendingInteractionBase> = new Map()
const archived = (...ids: string[]): readonly SessionId[] => ids.map(sid)
describe('deriveGroups', () => {
it('keeps Host Workspace and sessionIds order without Client recency sorting', () => {
const sessions = list(summary('newer', 20), summary('older', 10))
const workspaces = [workspace('first', ['older', 'newer']), workspace('empty', [])]
const groups = deriveGroups(sessions, workspaces, noArchive, noPending, view(['first']))
const groups = deriveGroups(sessions, workspaces, noArchive, noAttention, view(['first']))
expect(groups.map(group => group.key)).toEqual(['first', 'empty'])
expect(groups[0]!.sessions.map(session => session.id)).toEqual([sid('older'), sid('newer')])
})
@@ -44,20 +45,22 @@ describe('deriveGroups', () => {
it('projects pending-interaction state into grouped and flat rows', () => {
const awaiting = { ...summary('awaiting', 10), running: true }
const sessions = list(awaiting)
const pending = new Map([[awaiting.id, 'plan-review' as const]])
const attention: ReadonlyMap<SessionId, SessionPendingInteractionBase> = new Map([[
awaiting.id,
{ key: 'question:1', kind: 'plan-review', sessionId: awaiting.id },
]])
const grouped = deriveGroups(
sessions, [workspace('project', ['awaiting'])], noArchive, pending, view(['project']),
sessions, [workspace('project', ['awaiting'])], noArchive, attention, view(['project']),
)
expect(grouped[0]!.sessions[0]).toMatchObject({ pendingInteraction: 'plan-review', running: true })
expect(deriveFlat(sessions, noArchive, pending)[0]).toMatchObject({
pendingInteraction: 'plan-review', running: true,
})
expect(deriveFlat(sessions, noArchive, attention)[0])
.toMatchObject({ pendingInteraction: 'plan-review', running: true })
})
it('puts only real unaccounted Sessions in the trailing Ungrouped group', () => {
const sessions = list(summary('owned', 1, '/projects/first'), summary('loose', 9, '/other'))
const groups = deriveGroups(
sessions, [workspace('first', ['owned'])], noArchive, noPending, view([UNGROUPED_KEY]),
sessions, [workspace('first', ['owned'])], noArchive, noAttention, view([UNGROUPED_KEY]),
)
expect(groups.map(group => group.key)).toEqual(['first', UNGROUPED_KEY])
expect(groups[1]!.sessions.map(session => session.id)).toEqual([sid('loose')])
@@ -69,7 +72,7 @@ describe('deriveGroups', () => {
sessions,
[],
noArchive,
noPending,
noAttention,
view([UNGROUPED_KEY], ['two', 'stale', 'two']),
)
expect(groups[0]!.sessions.map(session => session.id)).toEqual([
@@ -87,7 +90,7 @@ describe('deriveGroups', () => {
}
const groups = deriveGroups(
sessions, [workspace('first', ['shown', 'current-blank', 'stale-blank'])],
noArchive, noPending, view(['first']),
noArchive, noAttention, view(['first']),
)
expect(groups[0]!.sessions.map(session => session.id)).toEqual([real.id, currentBlank.id])
const blankNode = groups[0]!.sessions.find(session => session.id === currentBlank.id)!
@@ -99,8 +102,8 @@ describe('deriveGroups', () => {
expect(groups[0]!.sessionCount).toBe(2)
// A non-current blank stray never surfaces an Ungrouped bucket either.
const strayGroups = deriveGroups(
list({ ...summary('stray', 2), blank: true }), [workspace('first', [])],
noArchive, noPending, view(),
list({ ...summary('stray', 2), blank: true }),
[workspace('first', [])], noArchive, noAttention, view(),
)
expect(strayGroups.map(group => group.key)).toEqual(['first'])
})
@@ -110,16 +113,16 @@ describe('deriveGroups', () => {
const plain = summary('plain', 2)
const sessions = list(done, plain)
const groups = deriveGroups(
sessions, [workspace('first', ['done', 'plain'])], noArchive, noPending, view(['first']),
sessions, [workspace('first', ['done', 'plain'])], noArchive, noAttention, view(['first']),
)
const doneNode = groups[0]!.sessions.find(session => session.id === done.id)!
const plainNode = groups[0]!.sessions.find(session => session.id === plain.id)!
expect(doneNode.completed).toBe(true)
expect(plainNode.completed).toBe(false)
expect(deriveFlat(sessions, noArchive, noPending).find(node => node.id === done.id)!.completed).toBe(true)
expect(deriveFlat(sessions, noArchive, noAttention).find(node => node.id === done.id)!.completed).toBe(true)
const search = deriveSearchResults(
sessions, [workspace('first', ['done', 'plain'])], 'done', noArchive,
noPending, { items: [], hasMore: false }, 10,
noAttention, { items: [], hasMore: false }, 10,
)
expect(search.items[0]?.completed).toBe(true)
})
@@ -141,7 +144,7 @@ describe('deriveGroups', () => {
sessions,
[workspace('first', ['parent', 'fork', 'subagent', 'grandchild', 'fork-child'])],
noArchive,
noPending,
noAttention,
view(['first']),
)
@@ -149,12 +152,12 @@ describe('deriveGroups', () => {
expect(groups[0]!.sessionCount).toBe(2)
expect(groups[0]!.sessions[0]).toMatchObject({ running: false, runningSubagentCount: 2 })
expect(groups[0]!.sessions[1]).toMatchObject({ running: false, runningSubagentCount: 1 })
expect(deriveFlat(sessions, noArchive, noPending).map(node => [node.id, node.runningSubagentCount])).toEqual([
expect(deriveFlat(sessions, noArchive, noAttention).map(node => [node.id, node.runningSubagentCount])).toEqual([
[fork.id, 1], [parent.id, 2],
])
expect(deriveSearchResults(
sessions, [workspace('first', ['parent', 'fork'])], 'parent', noArchive, noPending,
{ items: [], hasMore: false }, 10,
sessions, [workspace('first', ['parent', 'fork'])], 'parent', noArchive,
noAttention, { items: [], hasMore: false }, 10,
).items[0]).toMatchObject({ id: parent.id, runningSubagentCount: 2 })
})
@@ -172,7 +175,7 @@ describe('deriveGroups', () => {
list(parent, oldChild, newChild, tieB, tieA, self, orphan, cycleA, cycleB),
[],
noArchive,
noPending,
noAttention,
{ expandedGroups: [UNGROUPED_KEY] },
)
@@ -184,7 +187,7 @@ describe('deriveGroups', () => {
// Equal timestamps use ids as a deterministic tiebreak in either input order.
expect(deriveGroups(
list(summary('tie-a', 1), summary('tie-b', 1)), [], noArchive, noPending, view([UNGROUPED_KEY]),
list(summary('tie-a', 1), summary('tie-b', 1)), [], noArchive, noAttention, view([UNGROUPED_KEY]),
)[0]!
.sessions.map(node => node.id)).toEqual([sid('tie-a'), sid('tie-b')])
})
@@ -196,7 +199,7 @@ describe('deriveGroups', () => {
byId: { [sid('present')]: summary('present', 1) },
}
const groups = deriveGroups(
partial, [workspace('project', ['missing', 'present'])], noArchive, noPending, view(['project']),
partial, [workspace('project', ['missing', 'present'])], noArchive, noAttention, view(['project']),
)
expect(groups[0]!.sessions.map(node => node.id)).toEqual([sid('present')])
})
@@ -208,7 +211,7 @@ describe('deriveGroups', () => {
const sessions = list(kept, gone, looseGone)
const groups = deriveGroups(
sessions, [workspace('first', ['kept', 'gone'])], archived('gone', 'loose-gone'),
noPending, view(['first', UNGROUPED_KEY]),
noAttention, view(['first', UNGROUPED_KEY]),
)
// The archived member drops from its group AND the archived stray never
// surfaces an Ungrouped bucket; counts follow the visible rows.
@@ -222,11 +225,11 @@ describe('deriveGroups', () => {
const loose = summary('loose', 2)
const ws = workspace('project', ['owned'])
const ownedGroups = deriveGroups(
{ ...list(owned, loose), current: owned.id }, [ws], noArchive, noPending, view(),
{ ...list(owned, loose), current: owned.id }, [ws], noArchive, noAttention, view(),
)
expect(ownedGroups.find(group => group.key === 'project')!.containsCurrent).toBe(true)
const looseGroups = deriveGroups(
{ ...list(owned, loose), current: loose.id }, [ws], noArchive, noPending, view(),
{ ...list(owned, loose), current: loose.id }, [ws], noArchive, noAttention, view(),
)
expect(looseGroups.find(group => group.key === UNGROUPED_KEY)!.containsCurrent).toBe(true)
})
@@ -238,7 +241,7 @@ describe('deriveFlat', () => {
const child = { ...summary('child', 30), parentId: parent.id }
const tieB = summary('tie-b', 20)
const tieA = summary('tie-a', 20)
const rows = deriveFlat(list(parent, child, tieB, tieA), noArchive, noPending)
const rows = deriveFlat(list(parent, child, tieB, tieA), noArchive, noAttention)
expect(rows.map(row => row.id)).toEqual([sid('child'), sid('tie-a'), sid('tie-b'), sid('parent')])
})
@@ -249,14 +252,14 @@ describe('deriveFlat', () => {
const rows = deriveFlat(
{ ...list(parent, fork, subagent), current: subagent.id },
noArchive,
noPending,
noAttention,
)
expect(rows.map(row => row.id)).toEqual([fork.id, parent.id])
})
it('tolerates ids whose summary has not landed yet', () => {
const partial: SessionListState = { ...list(summary('present', 1)), ids: [sid('ghost'), sid('present')] }
expect(deriveFlat(partial, noArchive, noPending).map(row => row.id)).toEqual([sid('present')])
expect(deriveFlat(partial, noArchive, noAttention).map(row => row.id)).toEqual([sid('present')])
})
it('shows only the current blank session and excludes blanks from search', () => {
@@ -266,7 +269,7 @@ describe('deriveFlat', () => {
...list(summary('real', 1), currentBlank, staleBlank),
current: currentBlank.id,
}
const rows = deriveFlat(sessions, noArchive, noPending)
const rows = deriveFlat(sessions, noArchive, noAttention)
expect(rows.map(row => row.id)).toEqual([currentBlank.id, sid('real')])
expect(rows.map(row => row.title)).toEqual(['New Session', 'real'])
expect(rows.map(row => row.blank)).toEqual([true, false])
@@ -275,7 +278,7 @@ describe('deriveFlat', () => {
it('hides archived sessions in flat mode', () => {
const kept = summary('kept', 1)
const gone = summary('gone', 2)
expect(deriveFlat(list(kept, gone), archived('gone'), noPending).map(row => row.id)).toEqual([kept.id])
expect(deriveFlat(list(kept, gone), archived('gone'), noAttention).map(row => row.id)).toEqual([kept.id])
})
})
@@ -290,7 +293,7 @@ describe('deriveSearchResults archive filtering', () => {
[],
'needle',
archived('gone'),
noPending,
noAttention,
{ items: [{ sessionId: gone.id, snippet: 'needle body' }], hasMore: false },
10,
)
@@ -302,7 +305,6 @@ describe('deriveSearchResults', () => {
it('merges local title/Workspace matches before ranked content hits and enriches duplicates', () => {
const titleHit = summary('title-hit', 30, '/projects/a')
titleHit.displayTitle = 'Needle title'
const pending = new Map([[titleHit.id, 'plan-review' as const]])
const workspaceHit = summary('workspace-hit', 20, '/projects/b')
workspaceHit.displayTitle = 'Ordinary title'
const contentHit = summary('content-hit', 10, '/projects/c')
@@ -316,7 +318,9 @@ describe('deriveSearchResults', () => {
],
' NEEDLE ',
noArchive,
pending,
new Map([[titleHit.id, {
key: 'question:1', kind: 'plan-review', sessionId: titleHit.id,
}]]),
{
items: [
{ sessionId: contentHit.id, snippet: 'body needle excerpt' },
@@ -377,7 +381,7 @@ describe('deriveSearchResults', () => {
[workspace('first', ['opaque-current', 'new session stale'])],
'new session',
noArchive,
noPending,
noAttention,
{
items: [
{ sessionId: staleBlank.id, snippet: 'stale body' },
@@ -401,7 +405,7 @@ describe('deriveSearchResults', () => {
[],
'needle',
noArchive,
noPending,
noAttention,
{ items: [], hasMore: false },
3,
)
@@ -413,13 +417,13 @@ describe('deriveSearchResults', () => {
[],
'needle',
noArchive,
noPending,
noAttention,
{ items: [{ sessionId: sid('body'), snippet: 'needle' }], hasMore: true },
3,
)
expect(backendMore.items).toHaveLength(1)
expect(backendMore.hasMore).toBe(true)
expect(deriveSearchResults(list(), [], ' ', noArchive, noPending, { items: [], hasMore: true }, 3))
expect(deriveSearchResults(list(), [], ' ', noArchive, noAttention, { items: [], hasMore: true }, 3))
.toEqual({ items: [], hasMore: false })
})
})
@@ -2,16 +2,18 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, createEvent, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
import type { SessionListState, SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client'
import type {
PendingInteractionStatus, SessionId, SessionListState, SessionSummary, WorkspaceId,
WorkspaceListState, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
WorkspaceId, WorkspaceSnapshot, WorkspaceView,
} from '@deepseek-ai/dsh-api-workspace-controller/client'
import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import type { WorkspaceBrowserProps } from '../src/client/contract/slots.ts'
import { createWorkspaceViewStore, FLAT_SESSION_ORDER_KEY } from '../src/client/stores.ts'
import { UNGROUPED_KEY } from '../src/client/tree.ts'
import { WorkspaceBrowser } from '../src/client/WorkspaceBrowser.tsx'
import { WorkspaceBrowser } from '../src/client/rows/WorkspaceBrowser.tsx'
import { zh } from '../src/client/locales.ts'
afterEach(cleanup)
@@ -39,10 +41,11 @@ const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView
workspaceId: wid(id), path: `/projects/${id}`, title,
sessionIds: sessionIds.map(sid), createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
})
const workspaceState = (items: readonly WorkspaceView[], archivedSessionIds: readonly SessionId[] = []): WorkspaceListState => ({
items, archivedSessionIds, state: 'idle', phase: 'ready', error: null, baselinesReady: true,
recentWorkspaceId: items[0]?.workspaceId,
})
const workspaceState = (
items: readonly WorkspaceView[],
archivedSessionIds: readonly SessionId[] = [],
): WorkspaceSnapshot => ({ items, archivedSessionIds, state: 'idle', phase: 'ready', error: null })
const noPendingInteraction: SessionPendingInteractionSnapshot = new Map()
function hook<T>(snapshot: T) {
return function select<S>(selector: (state: T) => S): S { return selector(snapshot) }
}
@@ -65,7 +68,7 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
wide: true,
expandSidebar: vi.fn(),
useSessions: hook(sessionState([])),
usePendingInteractions: hook(new Map<SessionId, PendingInteractionStatus>()),
useSessionPendingInteraction: hook(noPendingInteraction),
useWorkspaces: hook(workspaceState([])),
useStore: bindSnapshotSelector(store),
actions: store.actions,
@@ -124,7 +127,6 @@ describe('WorkspaceBrowser', () => {
...workspaceState([]),
phase: 'pending' as const,
state: 'loading' as const,
baselinesReady: false,
}
const b = mount({ useWorkspaces: hook(pending) })
act(() => {
@@ -1,11 +1,14 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
import type {
SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
WorkspaceId, WorkspaceSnapshot, WorkspaceView,
} from '@deepseek-ai/dsh-api-workspace-controller/client'
import type {} from '@deepseek-ai/dsh-client-locale/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
import type { DirectoryFlowOwnerProps, WorkspacePickerProps } from '../src/client/contract/slots.ts'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx'
@@ -30,9 +33,9 @@ function hook<T>(snapshot: T) {
const sessions: SessionListState = {
ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
}
const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => ({
items, archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true,
recentWorkspaceId: items[0]?.workspaceId,
const noPendingInteraction: SessionPendingInteractionSnapshot = new Map()
const workspaceState = (items: readonly WorkspaceView[]): WorkspaceSnapshot => ({
items, archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
})
function anchor(): { current: HTMLElement } {
const element = document.createElement('button')
@@ -91,6 +94,7 @@ function mount(
open
anchorRef={anchorRef}
useSessions={hook(sessions)}
useSessionPendingInteraction={hook(noPendingInteraction)}
useWorkspaces={hook(workspaceState(nextItems))}
onPick={onPick}
onClose={onClose}
@@ -211,6 +215,7 @@ describe('WorkspacePicker', () => {
render(
<WorkspacePicker
open useSessions={hook(sessions)} useWorkspaces={hook(workspaceState([workspace('alpha', 'Alpha')]))}
useSessionPendingInteraction={hook(noPendingInteraction)}
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()}
useDirectoryFlow={occupancySource().useDirectoryFlow} renderSlot={renderSlot} t={t}
/>,
@@ -219,13 +224,14 @@ describe('WorkspacePicker', () => {
})
it('keeps the menu up while the list baseline is still in flight', () => {
const state: WorkspaceListState = {
...workspaceState([]), phase: 'pending', state: 'loading', baselinesReady: false,
const state: WorkspaceSnapshot = {
...workspaceState([]), phase: 'pending', state: 'loading',
}
const { renderSlot } = flowProbe()
render(
<WorkspacePicker
open anchorRef={anchor()} useSessions={hook(sessions)} useWorkspaces={hook(state)}
useSessionPendingInteraction={hook(noPendingInteraction)}
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()}
useDirectoryFlow={occupancySource().useDirectoryFlow} renderSlot={renderSlot} t={t}
/>,
@@ -0,0 +1,488 @@
import { Context } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type {
ISessions, SessionListState, SessionSummary,
} from '@deepseek-ai/dsh-api-session-controller/client'
import type {
IWorkspaces, WorkspaceId, WorkspaceSnapshot, WorkspaceView,
} from '@deepseek-ai/dsh-api-workspace-controller/client'
import {
RpcId,
type DirectoryListing,
type IApiClient,
type RpcError,
type RpcResponse,
} from '@deepseek-ai/dsh-client-connection/client'
import { SessionId } from '@deepseek-ai/dsh-session/types'
import { DirectoryBrowseError } from '../src/client/index.ts'
import { UiWorkspaceService } from '../src/client/navigation.ts'
const sid = (id: string): SessionId => SessionId(id)
const wid = (id: string): WorkspaceId => id as WorkspaceId
afterEach(() => {
vi.restoreAllMocks()
})
function workspace(
id: string,
sessionIds: readonly SessionId[] = [],
createdAt = '2026-01-01T00:00:00.000Z',
): WorkspaceView {
return {
workspaceId: wid(id),
path: `/w/${id}`,
title: id,
sessionIds,
createdAt,
updatedAt: createdAt,
}
}
function summary(id: string, overrides: Partial<SessionSummary> = {}): SessionSummary {
return {
id: sid(id),
displayTitle: id,
running: false,
blank: false,
updatedAt: 0,
...overrides,
}
}
function sessionState(
summaries: readonly SessionSummary[] = [],
current?: SessionId,
phase: SessionListState['phase'] = 'ready',
): SessionListState {
return {
ids: summaries.map(item => item.id),
byId: Object.fromEntries(summaries.map(item => [item.id, item])),
current,
phase,
subagentsByParent: {},
jobsBySession: {},
currentAddress: undefined,
}
}
function workspaceState(
items: WorkspaceSnapshot['items'] = [],
archivedSessionIds: readonly SessionId[] = [],
phase: WorkspaceSnapshot['phase'] = 'ready',
): WorkspaceSnapshot {
return {
items,
archivedSessionIds,
phase,
state: phase === 'ready' ? 'idle' : 'loading',
error: null,
}
}
class MutableSource<T> {
private readonly listeners = new Set<() => void>()
constructor(private value: T) {}
getSnapshot(): T {
return this.value
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener)
return () => { this.listeners.delete(listener) }
}
set(value: T): void {
this.value = value
for (const listener of [...this.listeners]) listener()
}
update(update: (value: T) => T): void {
this.set(update(this.value))
}
listenersSnapshot(): readonly (() => void)[] {
return [...this.listeners]
}
}
class FakeSessions {
readonly list: MutableSource<SessionListState>
readonly create: ReturnType<typeof vi.fn<ISessions['create']>>
readonly open: ReturnType<typeof vi.fn<(id: SessionId) => void>>
readonly clear: ReturnType<typeof vi.fn<() => void>>
constructor(initial: SessionListState) {
this.list = new MutableSource(initial)
this.create = vi.fn<ISessions['create']>(async options =>
options?.sessionId ?? sid(`created-${String(options?.workspaceId ?? 'none')}`))
this.open = vi.fn((id: SessionId) => {
this.list.update(state => ({ ...state, current: id }))
})
this.clear = vi.fn(() => {
this.list.update(state => ({ ...state, current: undefined }))
})
}
}
class FakeWorkspaces implements IWorkspaces {
readonly list: MutableSource<WorkspaceSnapshot>
readonly archiveCalls: SessionId[] = []
onArchive: IWorkspaces['archiveSession'] = async (sessionId) => {
this.list.update(state => ({
...state,
archivedSessionIds: [...state.archivedSessionIds, sessionId],
}))
}
declare readonly create: IWorkspaces['create']
declare readonly rename: IWorkspaces['rename']
declare readonly delete: IWorkspaces['delete']
declare readonly insertBefore: IWorkspaces['insertBefore']
declare readonly insertSessionBefore: IWorkspaces['insertSessionBefore']
constructor(initial: WorkspaceSnapshot) {
this.list = new MutableSource(initial)
}
archiveSession(sessionId: SessionId): Promise<void> {
this.archiveCalls.push(sessionId)
return this.onArchive(sessionId)
}
}
let nextRpcId = 0
function ok<T>(value: T): RpcResponse<T> {
return { rpcId: RpcId(`workspace-test-${nextRpcId++}`), result: { ok: true, value } }
}
function failed<T>(error: RpcError): RpcResponse<T> {
return { rpcId: RpcId(`workspace-test-${nextRpcId++}`), result: { ok: false, error } }
}
const listing: DirectoryListing = {
path: '/home/u',
home: '/home/u',
crumbs: [{ name: '/', path: '/', hidden: false }],
entries: [{ name: 'project', path: '/home/u/project', hidden: false }],
truncated: false,
}
class FakeApiClient implements IApiClient {
readonly calls: Array<{ readonly method: string; readonly payload: unknown }> = []
onDescribe: IApiClient['host']['describe'] = () => Promise.resolve(ok({
version: 'test',
cwd: '/home/u',
attachedSessions: 0,
home: '/home/u',
canOpenPath: true,
}))
onPickDirectory: IApiClient['host']['pickDirectory'] = () => Promise.resolve(ok({ path: null }))
onListDirectory: IApiClient['host']['listDirectory'] = () => Promise.resolve(ok(listing))
onCreateDirectory: IApiClient['host']['createDirectory'] = () => Promise.resolve(ok({ path: '/home/u/new' }))
onOpenPath: IApiClient['host']['openPath'] = () => Promise.resolve(ok({ opened: true }))
declare readonly subagents: IApiClient['subagents']
declare readonly skills: IApiClient['skills']
declare readonly agentPresets: IApiClient['agentPresets']
declare readonly goals: IApiClient['goals']
declare readonly settings: IApiClient['settings']
declare readonly credentials: IApiClient['credentials']
declare readonly llm: IApiClient['llm']
readonly host: IApiClient['host'] = {
describe: (payload, signal) => this.record('host.describe', payload, this.onDescribe(payload, signal)),
pickDirectory: (payload, signal) => this.record('host.pickDirectory', payload, this.onPickDirectory(payload, signal)),
listDirectory: (payload, signal) => this.record('host.listDirectory', payload, this.onListDirectory(payload, signal)),
createDirectory: (payload, signal) => this.record('host.createDirectory', payload, this.onCreateDirectory(payload, signal)),
openPath: (payload, signal) => this.record('host.openPath', payload, this.onOpenPath(payload, signal)),
}
callsOf(method: string): unknown[] {
return this.calls.filter(call => call.method === method).map(call => call.payload)
}
private record<T>(method: string, payload: unknown, response: Promise<T>): Promise<T> {
this.calls.push({ method, payload })
return response
}
}
interface BenchOptions {
readonly workspaces?: WorkspaceSnapshot
readonly sessions?: SessionListState
}
function bench(options: BenchOptions = {}) {
const ctx = new Context()
const api = new FakeApiClient()
const workspaces = new FakeWorkspaces(options.workspaces ?? workspaceState([], [], 'pending'))
const sessions = new FakeSessions(options.sessions ?? sessionState([], undefined, 'pending'))
const uiWorkspace = new UiWorkspaceService(
ctx,
api,
workspaces,
sessions as unknown as ISessions,
)
return { api, ctx, sessions, uiWorkspace, workspaces }
}
async function flush(): Promise<void> {
await Promise.resolve()
await Promise.resolve()
}
describe('UiWorkspaceService', () => {
it('reuses only an unarchived member blank and coalesces concurrent creation', async () => {
const b = bench()
const memberBlank = sid('member-blank')
const archivedBlank = sid('archived-blank')
const summaries: readonly SessionSummary[] = [
summary('stray', { blank: true, cwd: '/w/alpha' }),
summary('member-blank', { blank: true, cwd: '/w/alpha' }),
summary('active', { cwd: '/w/beta' }),
summary('archived-blank', { blank: true, cwd: '/w/gamma' }),
]
b.workspaces.list.set(workspaceState([
workspace('alpha', [memberBlank]),
workspace('beta', [sid('active')]),
workspace('gamma', [archivedBlank]),
], [archivedBlank]))
b.sessions.list.set({
...sessionState(summaries, memberBlank),
ids: [sid('missing'), ...summaries.map(item => item.id)],
})
await expect(Promise.all([
b.uiWorkspace.connectWorkspace(wid('alpha')),
b.uiWorkspace.connectWorkspace(wid('alpha')),
])).resolves.toEqual([memberBlank, memberBlank])
expect(b.sessions.create).not.toHaveBeenCalled()
const creation = Promise.withResolvers<SessionId>()
b.sessions.create.mockImplementation(() => creation.promise)
const first = b.uiWorkspace.connectWorkspace(wid('beta'))
const second = b.uiWorkspace.connectWorkspace(wid('beta'))
expect(b.sessions.create).toHaveBeenCalledTimes(1)
creation.resolve(sid('fresh-beta'))
await expect(Promise.all([first, second])).resolves.toEqual([sid('fresh-beta'), sid('fresh-beta')])
b.sessions.create.mockImplementation(async options => sid(`fresh-${String(options?.workspaceId)}`))
await expect(b.uiWorkspace.connectWorkspace(wid('gamma'))).resolves.toBe(sid('fresh-gamma'))
expect(b.sessions.create).toHaveBeenLastCalledWith({ workspaceId: wid('gamma') })
await expect(b.uiWorkspace.connectWorkspace(wid('ghost')))
.rejects.toThrow('uiWorkspace.connectWorkspace: unknown workspace ghost')
})
it('targets an explicit, current-session, then recent Workspace and reports failed starts', async () => {
const current = summary('current', { cwd: '/w/current-home', updatedAt: 1 })
const recent = summary('recent', { cwd: '/w/recent-home', updatedAt: 2 })
const b = bench({
sessions: sessionState([current, recent], current.id),
workspaces: workspaceState([
workspace('current-home', [current.id]),
workspace('recent-home', [recent.id]),
]),
})
b.sessions.create.mockImplementation(async options => sid(`opened-${String(options?.workspaceId)}`))
b.uiWorkspace.startSession(wid('recent-home'))
await vi.waitFor(() => {
expect(b.sessions.open).toHaveBeenLastCalledWith(sid('opened-recent-home'))
})
b.sessions.open(current.id)
b.uiWorkspace.startSession()
await vi.waitFor(() => {
expect(b.sessions.open).toHaveBeenLastCalledWith(sid('opened-current-home'))
})
b.sessions.clear()
b.uiWorkspace.startSession()
await vi.waitFor(() => {
expect(b.sessions.open).toHaveBeenLastCalledWith(sid('opened-recent-home'))
})
const empty = bench()
empty.uiWorkspace.startSession()
expect(empty.sessions.clear).toHaveBeenCalledOnce()
const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
b.sessions.create.mockRejectedValueOnce(new Error('create failed'))
b.uiWorkspace.startSession(wid('recent-home'))
await vi.waitFor(() => {
expect(warning).toHaveBeenCalledWith('new session failed:', expect.any(Error))
})
})
it('opens the recent Workspace after both baselines arrive', async () => {
const b = bench()
b.sessions.create.mockResolvedValue(sid('initial'))
const stableFirst = workspace('stable-first', [], '2026-01-01T00:00:00.000Z')
const recent = workspace('recent', [], '2026-01-02T00:00:00.000Z')
b.workspaces.list.set(workspaceState([stableFirst, recent]))
expect(b.sessions.create).not.toHaveBeenCalled()
b.sessions.list.set(sessionState())
await vi.waitFor(() => {
expect(b.sessions.open).toHaveBeenCalledWith(sid('initial'))
})
expect(b.sessions.create).toHaveBeenCalledWith({ workspaceId: wid('recent') })
expect(b.workspaces.list.getSnapshot().items.map(item => item.workspaceId)).toEqual([
wid('stable-first'), wid('recent'),
])
})
it('uses Workspace creation time when members are absent and preserves Host tie order', async () => {
const b = bench()
b.sessions.create.mockResolvedValue(sid('initial'))
b.workspaces.list.set(workspaceState([
workspace('newest', [sid('missing')], '2026-03-01T00:00:00.000Z'),
workspace('same-time', [], '2026-03-01T00:00:00.000Z'),
workspace('older', [], '2026-01-01T00:00:00.000Z'),
]))
b.sessions.list.set(sessionState())
await vi.waitFor(() => {
expect(b.sessions.open).toHaveBeenCalledWith(sid('initial'))
})
expect(b.sessions.create).toHaveBeenCalledWith({ workspaceId: wid('newest') })
})
it('retries failed initial selection and never overwrites a later selection', async () => {
const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const b = bench()
let attempts = 0
b.sessions.create.mockImplementation(() => ++attempts === 1
? Promise.reject(new Error('attach exploded'))
: Promise.resolve(sid('retry')))
b.workspaces.list.set(workspaceState([workspace('recent')]))
b.sessions.list.set(sessionState())
await vi.waitFor(() => {
expect(warning).toHaveBeenCalledWith('initial workspace selection failed:', expect.any(Error))
})
b.workspaces.list.update(state => ({ ...state, items: [...state.items] }))
await vi.waitFor(() => {
expect(b.sessions.open).toHaveBeenCalledWith(sid('retry'))
})
expect(attempts).toBe(2)
const changed = bench()
const pending = Promise.withResolvers<SessionId>()
changed.sessions.create.mockImplementation(() => pending.promise)
changed.workspaces.list.set(workspaceState([workspace('recent')]))
changed.sessions.list.set(sessionState())
await vi.waitFor(() => { expect(changed.sessions.create).toHaveBeenCalledOnce() })
changed.sessions.open(sid('manual'))
pending.resolve(sid('automatic'))
await flush()
expect(changed.sessions.open).toHaveBeenCalledTimes(1)
expect(changed.sessions.open).toHaveBeenCalledWith(sid('manual'))
})
it('stops initial navigation when its Cordis lifetime is disposed', async () => {
const success = bench()
const resolved = Promise.withResolvers<SessionId>()
success.sessions.create.mockImplementation(() => resolved.promise)
success.workspaces.list.set(workspaceState([workspace('recent')]))
success.sessions.list.set(sessionState())
await vi.waitFor(() => { expect(success.sessions.create).toHaveBeenCalledOnce() })
await success.ctx.fiber.dispose()
resolved.resolve(sid('late'))
await flush()
expect(success.sessions.open).not.toHaveBeenCalled()
success.workspaces.list.set(workspaceState([workspace('ignored')]))
expect(success.sessions.create).toHaveBeenCalledOnce()
const failure = bench()
const rejected = Promise.withResolvers<SessionId>()
failure.sessions.create.mockImplementation(() => rejected.promise)
failure.workspaces.list.set(workspaceState([workspace('recent')]))
failure.sessions.list.set(sessionState())
await vi.waitFor(() => { expect(failure.sessions.create).toHaveBeenCalledOnce() })
const staleReconciles = failure.workspaces.list.listenersSnapshot()
const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
await failure.ctx.fiber.dispose()
rejected.reject(new Error('late failure'))
await flush()
for (const reconcile of staleReconciles) reconcile()
expect(warning).not.toHaveBeenCalled()
expect(failure.sessions.create).toHaveBeenCalledOnce()
})
it('clears a current Session only after it enters the archive baseline', () => {
const current = summary('current')
const idle = summary('idle')
const b = bench({
sessions: sessionState([current, idle], current.id),
workspaces: workspaceState([workspace('one', [current.id, idle.id])]),
})
b.workspaces.list.update(state => ({ ...state, archivedSessionIds: [idle.id] }))
expect(b.sessions.clear).not.toHaveBeenCalled()
b.workspaces.list.update(state => ({ ...state, archivedSessionIds: [current.id] }))
expect(b.sessions.clear).toHaveBeenCalledOnce()
b.sessions.open(idle.id)
b.workspaces.list.update(state => ({ ...state, archivedSessionIds: [idle.id] }))
expect(b.sessions.clear).toHaveBeenCalledTimes(2)
const archived = bench({
sessions: sessionState([current], current.id),
workspaces: workspaceState([workspace('one', [current.id])], [current.id]),
})
expect(archived.sessions.clear).toHaveBeenCalledOnce()
})
it('forwards archive commands and preserves failures', async () => {
const idle = sid('idle')
const b = bench()
await b.uiWorkspace.archiveSession(idle)
expect(b.workspaces.archiveCalls).toEqual([idle])
b.workspaces.onArchive = () => Promise.reject(new Error('archive rejected'))
await expect(b.uiWorkspace.archiveSession(idle)).rejects.toThrow('archive rejected')
expect(b.workspaces.archiveCalls).toEqual([idle, idle])
})
it('passes directory operations to the Host and preserves structured browse failures', async () => {
const b = bench()
b.api.onPickDirectory = () => Promise.resolve(ok({ path: '/w/alpha' }))
await expect(b.uiWorkspace.pickDirectory()).resolves.toBe('/w/alpha')
b.api.onPickDirectory = () => Promise.resolve(ok({ path: null }))
await expect(b.uiWorkspace.pickDirectory()).resolves.toBeNull()
expect(b.api.callsOf('host.pickDirectory')).toEqual([{}, {}])
await expect(b.uiWorkspace.listDirectory()).resolves.toEqual(listing)
await expect(b.uiWorkspace.listDirectory('/home/u')).resolves.toEqual(listing)
expect(b.api.callsOf('host.listDirectory')).toEqual([{}, { path: '/home/u' }])
await expect(b.uiWorkspace.createDirectory('/home/u', 'new')).resolves.toBe('/home/u/new')
expect(b.api.callsOf('host.createDirectory')).toEqual([{ path: '/home/u', name: 'new' }])
await expect(b.uiWorkspace.openPath('/w/alpha/file.ts')).resolves.toBeUndefined()
expect(b.api.callsOf('host.openPath')).toEqual([{ path: '/w/alpha/file.ts' }])
b.api.onPickDirectory = () => Promise.resolve(failed({ code: 'internal', message: 'no chooser', details: {} }))
await expect(b.uiWorkspace.pickDirectory()).rejects.toThrow('directory picker failed: no chooser')
b.api.onListDirectory = () => Promise.resolve(failed({
code: 'directory-unreadable', message: 'denied', details: { path: '/private' },
}))
const listFailure = b.uiWorkspace.listDirectory('/private')
await expect(listFailure).rejects.toBeInstanceOf(DirectoryBrowseError)
await expect(listFailure).rejects.toMatchObject({ rpcError: { code: 'directory-unreadable' } })
b.api.onCreateDirectory = () => Promise.resolve(failed({
code: 'directory-exists', message: 'taken', details: { path: '/home/u/new' },
}))
await expect(b.uiWorkspace.createDirectory('/home/u', 'new')).rejects.toMatchObject({
rpcError: { code: 'directory-exists' },
})
b.api.onOpenPath = () => Promise.resolve(failed({ code: 'internal', message: 'boom', details: {} }))
await expect(b.uiWorkspace.openPath('/missing')).rejects.toThrow('path open failed: boom')
})
})
+16 -1
View File
@@ -8,6 +8,12 @@
"src"
],
"references": [
{
"path": "../../api/session-controller/tsconfig.client.json"
},
{
"path": "../../api/workspace-controller/tsconfig.client.json"
},
{
"path": "../locale"
},
@@ -24,7 +30,10 @@
"path": "../connection/tsconfig.client.json"
},
{
"path": "../runtime"
"path": "../store"
},
{
"path": "../../core/session"
},
{
"path": "../ui-sidebar"
@@ -32,6 +41,12 @@
{
"path": "../ui-conversation"
},
{
"path": "../ui-renderer"
},
{
"path": "../ui-session"
},
{
"path": "../../runtime-diagnostics/invariants"
}