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