diff --git a/packages/client/runtime/src/client/contract/workspaces.ts b/packages/client/runtime/src/client/contract/workspaces.ts deleted file mode 100644 index 4012086c3d..0000000000 --- a/packages/client/runtime/src/client/contract/workspaces.ts +++ /dev/null @@ -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 - /** - * Connect a Workspace to its reusable or freshly created blank session. - * @param workspaceId - target workspace. - * @returns the connected session id. - */ - connectWorkspace(workspaceId: WorkspaceId): Promise - /** - * 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 - /** - * Open the Host's native directory picker. - * @returns the selected path, or null when the user cancelled. - */ - pickDirectory(): Promise - /** - * 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 - /** - * 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 - /** - * Open a filesystem path with the Host operating system's default application. - * @param path - absolute or host-resolvable path. - */ - openPath(path: string): Promise - /** - * Rename a Workspace. - * @param workspaceId - target workspace. - * @param title - the new display title. - * @returns the updated Workspace view. - */ - rename(workspaceId: WorkspaceId, title: string): Promise - /** - * Delete a Workspace (its sessions fall back to the unaccounted group). - * @param workspaceId - target workspace. - */ - delete(workspaceId: WorkspaceId): Promise - /** - * 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 - /** - * 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 - /** - * 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 -} diff --git a/packages/client/runtime/src/client/sessions/provide.ts b/packages/client/runtime/src/client/sessions/provide.ts deleted file mode 100644 index 1ff3f1eaa3..0000000000 --- a/packages/client/runtime/src/client/sessions/provide.ts +++ /dev/null @@ -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 - - /** - * @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> = {} - const props: Record = {} - 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 = {} - const props: Record = {} - 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 - } -} diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts deleted file mode 100644 index b22aa65d70..0000000000 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ /dev/null @@ -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 - /** In-flight blank-session creates keyed by workspace (connectWorkspace coalescing). */ - private readonly connecting = new Map>() - /** 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({ - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 -} diff --git a/packages/client/runtime/tests/workspaces-service.client.spec.ts b/packages/client/runtime/tests/workspaces-service.client.spec.ts deleted file mode 100644 index 4074c933b3..0000000000 --- a/packages/client/runtime/tests/workspaces-service.client.spec.ts +++ /dev/null @@ -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() - -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 { - 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(() => {}) - 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() - }) -}) diff --git a/packages/client/ui-agent-preset/package.json b/packages/client/ui-agent-preset/package.json index e7bb8e1cce..bd4928f158 100644 --- a/packages/client/ui-agent-preset/package.json +++ b/packages/client/ui-agent-preset/package.json @@ -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", diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts index e28039fe45..c21aed8bd8 100644 --- a/packages/client/ui-agent-preset/src/client/index.ts +++ b/packages/client/ui-agent-preset/src/client/index.ts @@ -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', diff --git a/packages/client/ui-agent-preset/tests/apply.client.spec.ts b/packages/client/ui-agent-preset/tests/apply.client.spec.ts index 38adff21a7..7c3e30a96b 100644 --- a/packages/client/ui-agent-preset/tests/apply.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.client.spec.ts @@ -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)() diff --git a/packages/client/ui-agent-preset/tsconfig.json b/packages/client/ui-agent-preset/tsconfig.json index 901f3f6c8a..4d2086c4af 100644 --- a/packages/client/ui-agent-preset/tsconfig.json +++ b/packages/client/ui-agent-preset/tsconfig.json @@ -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" } ] } diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index f06804790b..d3fb4c8b13 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -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" }, diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 409635e016..cfd335d59b 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -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({ 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() - 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(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): 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): 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): 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>>() - 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) - } diff --git a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx index 09ed9346a0..490f28a835 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx @@ -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['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, -) => Promise +type ConversationInstance = ReturnType['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(() => Promise.resolve()), prompt: vi.fn(() => Promise.resolve({ ok: true, value: { accepted: true } })), cancel: vi.fn(() => 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 => + 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[0], - next: Parameters[1], - ): Promise { - 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('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('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() }) }) diff --git a/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx b/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx new file mode 100644 index 0000000000..5d7da62bd6 --- /dev/null +++ b/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx @@ -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 } + } | 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() + }) +}) diff --git a/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx b/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx index 2a2b67b128..15b38417c2 100644 --- a/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx +++ b/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx @@ -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(), prompt: vi.fn(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(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' } }, diff --git a/packages/client/ui-session/package.json b/packages/client/ui-session/package.json new file mode 100644 index 0000000000..4957b9fcdc --- /dev/null +++ b/packages/client/ui-session/package.json @@ -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" + ] +} diff --git a/packages/client/ui-session/src/client/index.ts b/packages/client/ui-session/src/client/index.ts new file mode 100644 index 0000000000..e84a7132da --- /dev/null +++ b/packages/client/ui-session/src/client/index.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 +/** Selector hook over one Session's lifecycle and control state. */ +export type SessionSnapshotSelector = SnapshotSelectorHook +/** 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 +/** Selector hook over Session-scoped pending interactions. */ +export type UseSessionPendingInteraction = SnapshotSelectorHook + +class PendingInteractionDomain { + private readonly values = new Map() + + 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 + /** 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 readonly string[] ? Readonly> : 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> + readonly keyedHooks?: SessionSourceRecord + readonly props?: SessionSourceRecord +} + +/** 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, + NoInfer, + NoInfer + > +} + +interface RuntimeSessionSourceContribution { + readonly hooks?: Readonly>> + readonly keyedHooks?: Readonly> + readonly props?: Readonly> +} + +interface RuntimeSessionSourceDescriptor { + readonly hooks?: readonly string[] + readonly keyedHooks?: readonly string[] + readonly props?: readonly string[] + resolve(binding: SessionBinding): RuntimeSessionSourceContribution +} + +type RuntimePendingDomain = PendingInteractionDomain + +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() + private absent: StandardSourceBinding + private currentBinding: StandardSourceBinding + private readonly currentListeners = new Set<() => void>() + private readonly pendingDomains: RuntimePendingDomain[] = [] + private pendingSnapshot: ReadonlyMap = new Map() + private readonly pendingListeners = new Set<() => void>() + /** Root source of pending UI interactions, independent from Controller snapshots. */ + readonly pendingInteractions: HostObservable = { + 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): () => 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( + 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() + 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() + 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 = () => {} + 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> = {} + const keyedHooks: Record = {} + const props: Record = {} + const finalProps = new Set() + 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 = {} + const keyedHooks: Record = {} + const props: Record = {} + const finalProps = new Set() + 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> | undefined, +): void { + for (const name of Object.keys(values ?? {})) { + if (!(declared ?? []).includes(name)) { + throw new Error(`uiSession.provide: undeclared ${kind} '${name}'`) + } + } +} + +function copyDeclared( + kind: StandardMemberKind, + target: Record, + declared: readonly string[] | undefined, + values: Readonly> | undefined, + finalProps: Set, +): 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, + declared: readonly string[] | undefined, + finalProps: Set, +): void { + for (const name of declared ?? []) { + claimStandardProp(kind, name, finalProps) + target[name] = undefined + } +} + +function claimStandardProp(kind: StandardMemberKind, name: string, finalProps: Set): 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, + right: ReadonlyMap, +): boolean { + if (left.size !== right.size) return false + for (const [sessionId, interaction] of left) { + if (right.get(sessionId) !== interaction) return false + } + return true +} diff --git a/packages/client/ui-session/src/client/session-provider.tsx b/packages/client/ui-session/src/client/session-provider.tsx new file mode 100644 index 0000000000..6dc01aadc7 --- /dev/null +++ b/packages/client/ui-session/src/client/session-provider.tsx @@ -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 {children} +} diff --git a/packages/client/ui-session/src/index.ts b/packages/client/ui-session/src/index.ts new file mode 100644 index 0000000000..5c12a00665 --- /dev/null +++ b/packages/client/ui-session/src/index.ts @@ -0,0 +1,4 @@ +/** Host loader entry for the browser-only Session UI adapter. */ + +/** Provides no Host-side behavior. */ +export function apply(): void {} diff --git a/packages/client/ui-session/src/invariant.ts b/packages/client/ui-session/src/invariant.ts new file mode 100644 index 0000000000..21e6363265 --- /dev/null +++ b/packages/client/ui-session/src/invariant.ts @@ -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)) diff --git a/packages/client/ui-session/tests/ui-session.client.spec.ts b/packages/client/ui-session/tests/ui-session.client.spec.ts new file mode 100644 index 0000000000..044199513a --- /dev/null +++ b/packages/client/ui-session/tests/ui-session.client.spec.ts @@ -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> + readonly resolveBinding: ReturnType SessionBinding | undefined>> + readonly createSession: ReturnType> + readonly openSession: ReturnType void>> + readonly clearSession: ReturnType void>> + binding(id: SessionId): SessionBinding + select(id: SessionId | undefined): void + release(id: SessionId): Promise +} + +const sessionId = (value: string): SessionId => value as SessionId + +function createSessionsBench(_ctx: Context): SessionsBench { + const list = createSnapshotStore({ + ids: [], + byId: {}, + current: undefined, + phase: 'ready', + subagentsByParent: {}, + jobsBySession: {}, + currentAddress: undefined, + }) + const bindings = new Map() + const scopes = new Map() + const resolveBinding = vi.fn((id: SessionId) => bindings.get(id)) + const createSession = vi.fn(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({ + 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>() + const session = { + sessionId: id, + projections: { + faceOf(key: string) { + let source = projections.get(key) + if (source === undefined) { + source = createSnapshotStore(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 => 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(() => 0) + const attendQuestion = service.attend( + 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(() => 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() + }) +}) diff --git a/packages/client/ui-session/tsconfig.json b/packages/client/ui-session/tsconfig.json new file mode 100644 index 0000000000..075c9117ed --- /dev/null +++ b/packages/client/ui-session/tsconfig.json @@ -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" + } + ] +} diff --git a/packages/client/ui-session/tsdown.config.ts b/packages/client/ui-session/tsdown.config.ts new file mode 100644 index 0000000000..9e1e60c6ee --- /dev/null +++ b/packages/client/ui-session/tsdown.config.ts @@ -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']) diff --git a/packages/client/ui-sidebar/package.json b/packages/client/ui-sidebar/package.json index b008baa603..e617deaf80 100644 --- a/packages/client/ui-sidebar/package.json +++ b/packages/client/ui-sidebar/package.json @@ -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:^", diff --git a/packages/client/ui-sidebar/src/client/index.ts b/packages/client/ui-sidebar/src/client/index.ts index 5b8ac288f7..a3bc3ff156 100644 --- a/packages/client/ui-sidebar/src/client/index.ts +++ b/packages/client/ui-sidebar/src/client/index.ts @@ -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( diff --git a/packages/client/ui-sidebar/tests/apply.client.spec.tsx b/packages/client/ui-sidebar/tests/apply.client.spec.tsx index ed40b6e1b4..b1f8fa8952 100644 --- a/packages/client/ui-sidebar/tests/apply.client.spec.tsx +++ b/packages/client/ui-sidebar/tests/apply.client.spec.tsx @@ -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() }) diff --git a/packages/client/ui-sidebar/tests/sidebar-snapshot.client.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-snapshot.client.spec.tsx index dccea1bfc2..e8d4968af1 100644 --- a/packages/client/ui-sidebar/tests/sidebar-snapshot.client.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-snapshot.client.spec.tsx @@ -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 }) diff --git a/packages/client/ui-sidebar/tsconfig.json b/packages/client/ui-sidebar/tsconfig.json index 8caf638424..2bffe00661 100644 --- a/packages/client/ui-sidebar/tsconfig.json +++ b/packages/client/ui-sidebar/tsconfig.json @@ -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" diff --git a/packages/client/ui-workspace/package.json b/packages/client/ui-workspace/package.json index 8ce02c90bc..73b7c6650d 100644 --- a/packages/client/ui-workspace/package.json +++ b/packages/client/ui-workspace/package.json @@ -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" diff --git a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx index c72d8cab39..b1801cc3d9 100644 --- a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx +++ b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx @@ -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 | undefined /** Selector hook over the workspace list (framework standard hook). */ - useWorkspaces: (selector: (state: WorkspaceListState) => S) => S + useWorkspaces: (selector: (state: WorkspaceSnapshot) => S) => S /** Adopt a picked host directory as a real Workspace. */ createWorkspace: (input: { path: string }) => Promise /** Bound occupancy selector hook for this surface's directory-flow hole (empty leaves the surface with no add action). */ diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts index 79f8690976..f25270aad3 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -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> } /** * Start a New Session in a Workspace: reuse-or-create its blank session and diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index 0dbc17c975..b33adad512 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -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 + } + 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; diff --git a/packages/client/ui-workspace/src/client/navigation.ts b/packages/client/ui-workspace/src/client/navigation.ts new file mode 100644 index 0000000000..7d209baec9 --- /dev/null +++ b/packages/client/ui-workspace/src/client/navigation.ts @@ -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 + /** + * 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 + /** @returns the Host-native picked directory, or null when cancelled. */ + pickDirectory(): Promise + /** + * 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 + /** + * Create a child directory. + * @param path - existing parent directory. + * @param name - child directory name. + * @returns created absolute path. + */ + createDirectory(path: string, name: string): Promise + /** + * Open a path with the Host operating system. + * @param path - absolute or Host-resolvable path. + */ + openPath(path: string): Promise +} + +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>() + + /** + * @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 { + 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 { + await this.workspaces.archiveSession(sessionId) + } + + async pickDirectory(): Promise { + 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 { + 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 { + 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 { + 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 } diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index 8689da3cc9..3aa18981c4 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -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' diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css similarity index 100% rename from packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css rename to packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx similarity index 96% rename from packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx rename to packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx index 7d6bbe9c34..c95ad179e9 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx @@ -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([]) // 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 & { +}: Pick & { 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({ ? ( +} 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 + /** 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, - 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), diff --git a/packages/client/ui-workspace/tests/apply.client.spec.ts b/packages/client/ui-workspace/tests/apply.client.spec.ts index eb649cea96..79aef72717 100644 --- a/packages/client/ui-workspace/tests/apply.client.spec.ts +++ b/packages/client/ui-workspace/tests/apply.client.spec.ts @@ -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, diff --git a/packages/client/ui-workspace/tests/browser-styles.client.spec.ts b/packages/client/ui-workspace/tests/browser-styles.client.spec.ts index 5554c977bb..3d0f31b318 100644 --- a/packages/client/ui-workspace/tests/browser-styles.client.spec.ts +++ b/packages/client/ui-workspace/tests/browser-styles.client.spec.ts @@ -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') /** diff --git a/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx b/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx index cc2bc10f49..7db2ebd545 100644 --- a/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx +++ b/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx @@ -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 { 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 } diff --git a/packages/client/ui-workspace/tests/rows.client.spec.tsx b/packages/client/ui-workspace/tests/rows.client.spec.tsx index b7f7844721..b6b0429467 100644 --- a/packages/client/ui-workspace/tests/rows.client.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.client.spec.tsx @@ -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' diff --git a/packages/client/ui-workspace/tests/tree.client.spec.ts b/packages/client/ui-workspace/tests/tree.client.spec.ts index 7aee3671c9..c32359ce9e 100644 --- a/packages/client/ui-workspace/tests/tree.client.spec.ts +++ b/packages/client/ui-workspace/tests/tree.client.spec.ts @@ -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 = new Map() +const noAttention: ReadonlyMap = 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 = 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 }) }) }) diff --git a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx index 10a3aa8453..b15650e888 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx @@ -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(snapshot: T) { return function select(selector: (state: T) => S): S { return selector(snapshot) } } @@ -65,7 +68,7 @@ function mount(overrides: Partial = {}) { wide: true, expandSidebar: vi.fn(), useSessions: hook(sessionState([])), - usePendingInteractions: hook(new Map()), + 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(() => { diff --git a/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx index 87e3a3943b..816f78e29c 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx @@ -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(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( , @@ -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( , diff --git a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts new file mode 100644 index 0000000000..7c128e187e --- /dev/null +++ b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts @@ -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 { + 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 { + 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 + readonly create: ReturnType> + readonly open: ReturnType void>> + readonly clear: ReturnType void>> + + constructor(initial: SessionListState) { + this.list = new MutableSource(initial) + this.create = vi.fn(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 + 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 { + this.archiveCalls.push(sessionId) + return this.onArchive(sessionId) + } +} + +let nextRpcId = 0 + +function ok(value: T): RpcResponse { + return { rpcId: RpcId(`workspace-test-${nextRpcId++}`), result: { ok: true, value } } +} + +function failed(error: RpcError): RpcResponse { + 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(method: string, payload: unknown, response: Promise): Promise { + 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 { + 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() + 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() + 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() + 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() + 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') + }) +}) diff --git a/packages/client/ui-workspace/tsconfig.json b/packages/client/ui-workspace/tsconfig.json index e419270fb8..80bc10dc0d 100644 --- a/packages/client/ui-workspace/tsconfig.json +++ b/packages/client/ui-workspace/tsconfig.json @@ -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" }