feat(web): add workspace-aware session flow

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

View File

@@ -10,8 +10,8 @@ The [slot system standard](../../.agents/notes/implemented/architecture/2026-07-
1. **One API**: a plugin composes UI only through `ctx.slots.register({ name, children?, store?, inject? }, Component)`. There is no separate slot-definition call, no whitelist face object, no face-minting helper. The shell alone renders `'root'`.
2. **children = declaration + authorization**: the slots your component renders are exactly the keys of your register call's `children` object (spec values: `kind`/`scope`). Rendering a slot you didn't declare, or declaring one someone else declared, fails at load — do not work around it; the conflict is the design speaking. Slot names mirror the composition path: `<domain>.<entry>.<hole>` (e.g. `'conversation.chat.toolview'`).
3. **Component props are the four shares, all derived**: `PropsRuntime<K>` (SlotMap: owner params + `useSession`/`sessionId` on session scope + `useSessions`) & `PropsRenderSlots<S>` (children keys) & `PropsStore<H>` (store factory) & the inject face. Never hand-write a member a share already derives; never re-type a share locally.
4. **Hooks are framework-made only**: `useSession`, `useSessions`, `useStore`, `renderSlot` are the four seats. Business code never creates a hook or selector as a prop value — pass plain data and callbacks. (Component-internal behavioral hooks that subscribe to nothing external are fine.)
3. **Component props are the four shares, all derived**: `PropsRuntime<K>` (SlotMap: owner params + `useSession`/`sessionId` on session scope + global `useSessions`/`useWorkspaces`) & `PropsRenderSlots<S>` (children keys) & `PropsStore<H>` (store factory) & the inject face. Never hand-write a member a share already derives; never re-type a share locally.
4. **Hooks are framework-made only**: `useSession`, `useSessions`, `useWorkspaces`, `useStore`, `renderSlot` are the five seats. Business code never creates a hook or selector as a prop value — pass plain data and callbacks. (Component-internal behavioral hooks that subscribe to nothing external are fine.)
5. **Live data has exactly three channels**: parent knows it → owner props at the renderSlot site; only the component knows it → local state; shared across entries or survives remounts → a store declared at register. Derived data is a pure function over framework-hook data (`useMemo`), never its own subscription.
6. **Stores: read `props.useStore`, write `props.actions.*`** — the declared actions are the complete mutation surface. Write the store as an exported `createXXXStore()` factory (module-level handles are forbidden — de-facto singletons); share by passing one handle to several registers inside `apply`. Production code never calls the factory or `.create()` outside `apply`; tests do (that is the sanctioned zero-machinery path).
7. **inject returns plain data and callbacks** from the apply closure's own ctx — no hooks, no ReactNode producers, no whole-service objects. Its capability boundary is the plugin's declared `inject` topology; there is no wider ctx to reach for.
@@ -70,6 +70,16 @@ Run the narrowest rung that covers what you touched; escalate only when the chan
If `test:gui` is red on code you did not touch, neither silently fix nor ignore it: note it in your handoff so it lands in the next PR window's sweep.
## New plugin package checklist
Bringing up a new `packages/client/<name>` plugin package (ui-workspace is the latest walked example; ui-sidebar/ui-question are good skeletons to copy):
1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-<name>`, exports `.`/`./invariant`/`./client`/`./src/*`/`./package.json`, `dshClient` manifest, `files` list), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency plus `support/invariants`), `tsdown.config.ts` (`clientBundle(id, ['lib/types/index.js', 'lib/types/invariant.js'])`), `src/index.ts` (empty node-half apply), `src/invariant.ts` (companion with a real reason), `src/css-modules.d.ts` when using CSS Modules, `README.md` with the Model Experience section.
2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; the `CLIENT_PACKAGES` roster in `apps/cli/src/web.ts`; an `apps/cli/package.json` dependency (`mountWebPlugins` resolves roster packages against the composing app's URL — a roster row that is not a dependency of `apps/cli` fails to mount). `pnpm-workspace.yaml` already globs `packages/*/*`.
3. **dshClient manifest semantics**: `platform: 'web'` always; `immediately: true` only for stage-one-prefetch infrastructure rows. `inject` lists package-name dependency edges — they are **informational only** (preflight display, HMR diffing); they do not sequence entry activation or apply order. Activation order is cordis fiber inject waiting on *services*, nothing else.
4. **Registering into another package's slot**: if the declaring host provides no waitable service, your apply's order relative to the host's is unconstrained — a bare `slots.register` into its slot races boot (intermittent `slot "..." is not declared` page failures). Register with declaration-aware deferral: check `ctx.slots.spec(name)`, otherwise `ctx.slots.subscribe(name)` and register on the declaration event (SlotCore supports subscribing ahead of declaration); make the registration idempotent, and unsubscribe + dispose in the effect disposer. Only take a service edge in `inject` when the host actually provides one (ui-question → `'conversation'` is that case).
5. Rebuild the bundle (`pnpm --filter <pkg> bundle`) before probing a live `dsh web` server — the registry serves `lib/client.js`, not sources.
## New component checklist
1. Compose through register: merge the slot contract into `SlotMap`, declare the slot in its parent entry's `children`, register your component — see the [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). No other composition route exists.

View File

@@ -2,6 +2,10 @@
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3.
## Keyless fixture
Any `fixture` query parameter selects the in-memory carrier. `fixture=empty` starts with no Workspace or Session; `fixturePrompt=reject` rejects prompts before acceptance; `fixtureAttach=fail` publishes a Session but rejects its Workspace attachment; `fixtureSessionCreate=drop-response` publishes and frames a Session before dropping the create response; and `fixtureFrames=workspace-first` reverses the default session-first create-frame order. Workspace creation by name/path and caller-preallocated SessionIds remain deterministic enough for assembled Web tests to reconcile list and frame arrival.
## Model Experience
None, as the wire consumer layer moves already-composed messages between browser and host; nothing here reaches a model request.

View File

@@ -8,6 +8,7 @@
export type {
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
WorkspaceApi, WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-host-apiproxy/api'
export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
export type {

View File

@@ -10,7 +10,7 @@ import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
import type {
ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt,
RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary,
ToolCallView, ToolEventView, ToolResultView,
ToolCallView, ToolEventView, ToolResultView, WorkspaceId, WorkspaceView,
} from './api.ts'
import type { RequestPayload, ResponseValue, RpcMethodMap } from '@deepseek-ai/dsh-host-apiproxy/api'
import { AbstractApiClient, RpcId } from './api.ts'
@@ -242,6 +242,20 @@ interface StreamConn<F> {
push(envelope: RpcRequest<F>): void
}
/** Deterministic fixture branches used by keyless Web assembly tests. */
export interface FixtureOptions {
/** Start with no real Workspace or Session. */
empty?: boolean
/** Reject every prompt before appending its user event. */
rejectPrompt?: boolean
/** Publish the Session but fail its Workspace account write. */
failWorkspaceAttach?: boolean
/** Publish and frame the Session, then throw instead of returning create. */
dropSessionCreateResponse?: boolean
/** Order of the two successful create frames. */
createFrameOrder?: 'session-first' | 'workspace-first'
}
/** Inbox pump shared by both stream generators (FrameQueue pattern: ONE abort listener hung
* outside the loop — a per-iteration {once:true} listener never fires for non-final rounds and
* piles up for the stream's lifetime, audit C5). breakNow force-ends the stream without the
@@ -286,10 +300,11 @@ class FxInbox<F> implements StreamConn<F> {
/**
* In-memory fake host: fx-alpha carries history and replay scripts; fx-beta is fx-alpha's child session (lineage indent material).
* @param options - fixture branches for empty state and failure timing.
* @returns an ApiProxy backed entirely by in-memory state — no host process, no network.
*/
export function createFixtureApi(): ApiProxy {
const sessions: SessionSummary[] = [
export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
const sessions: SessionSummary[] = options.empty ? [] : [
{ sessionId: sid('fx-alpha'), updatedAt: Date.now(), running: true, cwd: '/tmp/fixture' },
{ sessionId: sid('fx-beta'), updatedAt: Date.now() - 60_000, running: false, parentSessionId: sid('fx-alpha'), cwd: '/tmp/fixture' },
{ sessionId: sid('fx-gamma'), updatedAt: Date.now() - 120_000, running: false, cwd: '/tmp/fixture' },
@@ -298,6 +313,20 @@ export function createFixtureApi(): ApiProxy {
const nextTurn = new Map<SessionId, number>([[sid('fx-alpha'), 60]])
let nextSession = 1
let nextRpc = 1
let attachedSessions = options.empty ? 0 : 1
// Workspace entities mirroring the host registry: the fixture sessions all
// live under one workspace, whose account carries them in attach order.
const wid = (raw: string): WorkspaceId => raw as WorkspaceId
const fixtureEpoch = new Date(Date.now() - 300_000).toISOString()
const workspaces: WorkspaceView[] = options.empty ? [] : [{
workspaceId: wid('fx-ws-fixture'),
path: '/tmp/fixture',
title: 'fixture',
sessionIds: [sid('fx-alpha'), sid('fx-beta'), sid('fx-gamma')],
createdAt: fixtureEpoch,
updatedAt: fixtureEpoch,
}]
let nextWorkspace = 1
const mint = (): ReturnType<typeof RpcId> => RpcId(`fx-rpc-${nextRpc++}`)
/** Resident pending approval (stable rpcId: every mux open replays the same id, matching host replay semantics). */
const pendingApprovalRpcId = mint()
@@ -464,12 +493,71 @@ export function createFixtureApi(): ApiProxy {
return {
sessions: {
list: request => ok(request, { items: [...sessions].sort((a, b) => b.updatedAt - a.updatedAt) }),
create: (request) => {
create: async (request) => {
const workspace = request.payload.workspaceId === undefined
? undefined
: workspaces.find(w => w.workspaceId === request.payload.workspaceId)
if (request.payload.workspaceId !== undefined && workspace === undefined) {
return err(request, {
code: 'workspace-not-found',
message: `no workspace ${request.payload.workspaceId}`,
details: { workspaceId: request.payload.workspaceId },
})
}
const cwd = workspace?.path ?? request.payload.cwd ?? '/tmp/fixture'
const requestedId = request.payload.sessionId
const attachWorkspace = (sessionId: SessionId): void => {
/* v8 ignore next -- callers enter only when a target Workspace exists. */
if (workspace === undefined || workspace.sessionIds.includes(sessionId)) return
workspace.sessionIds = [sessionId, ...workspace.sessionIds]
workspace.updatedAt = new Date().toISOString()
emitHost({ type: 'host/workspace-changed', workspace: { ...workspace } })
}
const attachFailure = (
sessionId: SessionId,
workspaceId: WorkspaceId,
): Promise<RpcResponse<{ sessionId: SessionId }>> => err(request, {
code: 'workspace-attach-failed' as const,
message: `fixture rejected Workspace attachment for ${sessionId}`,
details: { sessionId, workspaceId },
})
if (requestedId !== undefined) {
const existing = summaryOf(requestedId)
if (existing !== undefined) {
if (existing.cwd !== cwd) {
return err(request, {
code: 'session-conflict',
message: `session ${requestedId} already uses ${existing.cwd ?? 'no cwd'}`,
details: { sessionId: requestedId, requestedCwd: cwd, ...existing.cwd === undefined ? {} : { existingCwd: existing.cwd } },
})
}
if (workspace !== undefined && !workspace.sessionIds.includes(requestedId)) {
if (options.failWorkspaceAttach) return attachFailure(requestedId, workspace.workspaceId)
attachWorkspace(requestedId)
}
return ok(request, { sessionId: requestedId })
}
}
const created: SessionSummary = {
sessionId: sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, cwd: '/tmp/fixture',
sessionId: requestedId ?? sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, cwd,
}
sessions.push(created)
emitHost({ type: 'host/session-added', sessionId: created.sessionId })
attachedSessions += 1
const emitSession = (): void => {
emitHost({ type: 'host/session-added', sessionId: created.sessionId, cwd })
}
if (workspace !== undefined && options.failWorkspaceAttach) {
emitSession()
return attachFailure(created.sessionId, workspace.workspaceId)
}
if (workspace !== undefined && options.createFrameOrder === 'workspace-first') {
attachWorkspace(created.sessionId)
emitSession()
} else {
emitSession()
if (workspace !== undefined) attachWorkspace(created.sessionId)
}
if (options.dropSessionCreateResponse) throw new Error('fixture: dropped session.create response after publication')
return ok(request, { sessionId: created.sessionId })
},
history: async (request) => {
@@ -489,6 +577,13 @@ export function createFixtureApi(): ApiProxy {
if (summary === undefined) {
return err(request, { code: 'session-not-found', message: `no session ${id}`, details: { sessionId: id } })
}
if (options.rejectPrompt) {
return err(request, {
code: 'agent-busy',
message: 'fixture: prompt rejected before acceptance',
details: { reason: 'fixture-prompt-rejection' },
})
}
summary.updatedAt = Date.now()
const userText = content.map(b => (b.type === 'text' ? b.text : '')).join('')
if (mode === 'steer' && replays.has(id)) {
@@ -524,7 +619,28 @@ export function createFixtureApi(): ApiProxy {
},
},
host: {
describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions: 1 }),
describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions }),
},
workspace: {
list: request => ok(request, { items: workspaces.map(w => ({ ...w })) }),
create: (request) => {
const { path, name } = request.payload
const target = path ?? `/tmp/fixture-workspaces/${name ?? ''}`
const existing = workspaces.find(w => w.path === target)
if (existing !== undefined) return ok(request, { workspace: { ...existing }, created: false })
const now = new Date().toISOString()
const created: WorkspaceView = {
workspaceId: wid(`fx-ws-${nextWorkspace++}`),
path: target,
title: name ?? target.split('/').filter(Boolean).at(-1) ?? target,
sessionIds: [],
createdAt: now,
updatedAt: now,
}
workspaces.unshift(created)
emitHost({ type: 'host/workspace-changed', workspace: { ...created } })
return ok(request, { workspace: { ...created }, created: true })
},
},
events: {
async *mux(_request, signal) {
@@ -606,7 +722,12 @@ export function createFixtureApi(): ApiProxy {
* to the isomorphic pipeline (InProcessApiClient over toFetchHandler(fixtureImpl)).
*/
export class FixtureApiClient extends AbstractApiClient {
private readonly api = createFixtureApi()
private readonly api: ApiProxy
constructor() {
super()
this.api = createFixtureApi(fixtureOptionsFromLocation())
}
protected doFetch(): Promise<Response> {
throw new Error('FixtureApiClient overrides all protocol paths; doFetch must be unreachable')
@@ -634,6 +755,8 @@ export class FixtureApiClient extends AbstractApiClient {
case 'session.prompt': return this.api.sessions.prompt(request)
case 'session.cancel': return this.api.sessions.cancel(request)
case 'host.describe': return this.api.host.describe(request)
case 'workspace.list': return this.api.workspace.list(request)
case 'workspace.create': return this.api.workspace.create(request)
}
}
@@ -678,3 +801,16 @@ export class FixtureApiClient extends AbstractApiClient {
return this.api.respond(message)
}
}
/** Browser query mapping; direct unit callers pass FixtureOptions explicitly. */
function fixtureOptionsFromLocation(): FixtureOptions {
if (typeof location === 'undefined') return {}
const query = new URLSearchParams(location.search)
return {
empty: query.get('fixture') === 'empty',
rejectPrompt: query.get('fixturePrompt') === 'reject',
failWorkspaceAttach: query.get('fixtureAttach') === 'fail',
dropSessionCreateResponse: query.get('fixtureSessionCreate') === 'drop-response',
createFrameOrder: query.get('fixtureFrames') === 'workspace-first' ? 'workspace-first' : 'session-first',
}
}

View File

@@ -13,7 +13,7 @@ import { WebApiClient } from './web-api-client.ts'
export type {
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
ToolCallView, ToolResultView,
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,

View File

@@ -71,6 +71,14 @@ export class FakeApiClient implements IApiClient {
describe: payload => this.record('host.describe', payload, this.onDescribe(payload)),
}
readonly workspace: IApiClient['workspace'] = {
list: (payload: unknown) => this.record('workspace.list', payload, Promise.resolve(ok({ items: [] }))),
create: (payload: unknown) => this.record('workspace.create', payload, Promise.resolve(ok({
workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' },
created: true,
}))),
}
/** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */
suppressStreamOpen = false

View File

@@ -5,7 +5,7 @@
* the hand-written fixture/host parallel implementations.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SessionId } from '../src/client/api.ts'
import type { SessionId, WorkspaceId } from '../src/client/api.ts'
import { RpcId } from '../src/client/api.ts'
import type { HostFrame, MuxFrame, RpcMessage, RpcRequest } from '../src/client/api.ts'
import { FixtureApiClient, createFixtureApi } from '../src/client/fixture.ts'
@@ -87,7 +87,7 @@ describe('createFixtureApi', () => {
await consuming
if (!created.result.ok) throw new Error('create failed')
const createdId = created.result.value.sessionId
expect(seen).toEqual([{ type: 'host/session-added', sessionId: createdId }])
expect(seen).toEqual([{ type: 'host/session-added', sessionId: createdId, cwd: '/tmp/fixture' }])
const list = await api.sessions.list(req({}))
if (!list.result.ok) throw new Error('list failed')
expect(list.result.value.items.some(s => s.sessionId === createdId)).toBe(true)
@@ -259,6 +259,177 @@ describe('createFixtureApi', () => {
const api = createFixtureApi()
const response = await api.host.describe(req({}))
expect(response.result).toMatchObject({ ok: true, value: { version: '0.0.0-fixture', attachedSessions: 1 } })
const empty = await createFixtureApi({ empty: true }).host.describe(req({}))
expect(empty.result).toMatchObject({ ok: true, value: { attachedSessions: 0 } })
})
it('workspace.list serves the resident account and create reuses on path collision', async () => {
const api = createFixtureApi()
const listed = await api.workspace.list(req({}))
if (!listed.result.ok) throw new Error('list failed')
expect(listed.result.value.items).toEqual([expect.objectContaining({
workspaceId: 'fx-ws-fixture', path: '/tmp/fixture', title: 'fixture',
sessionIds: ['fx-alpha', 'fx-beta', 'fx-gamma'],
})])
// path collision → the existing entity comes back, created:false, no frame.
const reused = await api.workspace.create(req({ path: '/tmp/fixture' }))
if (!reused.result.ok) throw new Error('reuse failed')
expect(reused.result.value).toMatchObject({ created: false, workspace: { workspaceId: 'fx-ws-fixture' } })
})
it('workspace.create by name mints a new entity and pushes host/workspace-changed', async () => {
const api = createFixtureApi()
const abort = new AbortController()
const seen: HostFrame[] = []
const consuming = (async () => {
for await (const envelope of api.events.host(req({}), abort.signal)) {
seen.push(envelope.payload)
abort.abort()
}
})()
await new Promise(resolve => setTimeout(resolve, 10))
const created = await api.workspace.create(req({ name: 'nova' }))
if (!created.result.ok) throw new Error('create failed')
expect(created.result.value.created).toBe(true)
expect(created.result.value.workspace).toMatchObject({
path: '/tmp/fixture-workspaces/nova', title: 'nova', sessionIds: [],
})
await consuming
expect(seen).toEqual([{ type: 'host/workspace-changed', workspace: created.result.value.workspace }])
// path spelling falls back to the basename when no title/name rides along.
const pathOnly = await api.workspace.create(req({ path: '/tmp/fixture-elsewhere/base' }))
if (!pathOnly.result.ok) throw new Error('pathOnly failed')
expect(pathOnly.result.value.workspace.title).toBe('base')
// Degenerate spellings reach the impl unfiltered (the fixture carrier has
// no schema gate): both-absent falls back to the bucket dir, and a
// basename-less path serves as its own title.
const bare = await api.workspace.create(req({}))
if (!bare.result.ok) throw new Error('bare failed')
expect(bare.result.value.workspace).toMatchObject({ path: '/tmp/fixture-workspaces/', title: 'fixture-workspaces' })
const rootPath = await api.workspace.create(req({ path: '/' }))
if (!rootPath.result.ok) throw new Error('rootPath failed')
expect(rootPath.result.value.workspace.title).toBe('/')
})
it('session.create({workspaceId}) lands on the account and unknown ids error', async () => {
const api = createFixtureApi()
const abort = new AbortController()
const seen: HostFrame[] = []
const consuming = (async () => {
for await (const envelope of api.events.host(req({}), abort.signal)) {
seen.push(envelope.payload)
if (seen.length >= 2) abort.abort()
}
})()
await new Promise(resolve => setTimeout(resolve, 10))
const missing = await api.sessions.create(req({ workspaceId: 'fx-ws-void' as WorkspaceId }))
expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found', details: { workspaceId: 'fx-ws-void' } } })
const created = await api.sessions.create(req({ workspaceId: 'fx-ws-fixture' as WorkspaceId }))
if (!created.result.ok) throw new Error('create failed')
const id = created.result.value.sessionId
await consuming
// The session lands with the workspace's path as cwd, and the account
// write pushes the fresh workspace snapshot after session-added.
expect(seen[0]).toEqual({ type: 'host/session-added', sessionId: id, cwd: '/tmp/fixture' })
expect(seen[1]).toMatchObject({
type: 'host/workspace-changed',
workspace: { workspaceId: 'fx-ws-fixture', sessionIds: [id, 'fx-alpha', 'fx-beta', 'fx-gamma'] },
})
})
it('supports an empty baseline, preallocated ids, workspace-first frames, and idempotent retry', async () => {
const api = createFixtureApi({ empty: true, createFrameOrder: 'workspace-first' })
const initialSessions = await api.sessions.list(req({}))
const initialWorkspaces = await api.workspace.list(req({}))
expect(initialSessions.result).toMatchObject({ ok: true, value: { items: [] } })
expect(initialWorkspaces.result).toMatchObject({ ok: true, value: { items: [] } })
const made = await api.workspace.create(req({ name: 'nova' }))
if (!made.result.ok) throw new Error('workspace create failed')
const abort = new AbortController()
const framesPromise = collect(api.events.host(req({}), abort.signal), abort, frames => frames.length === 2)
await new Promise(resolve => setTimeout(resolve, 10))
const preallocated = sid('fx-preallocated')
const created = await api.sessions.create(req({
workspaceId: made.result.value.workspace.workspaceId,
sessionId: preallocated,
}))
expect(created.result).toEqual({ ok: true, value: { sessionId: preallocated } })
const frames = await framesPromise
expect(frames[0]).toMatchObject({
type: 'host/workspace-changed', workspace: { sessionIds: [preallocated] },
})
expect(frames[1]).toEqual({ type: 'host/session-added', sessionId: preallocated, cwd: made.result.value.workspace.path })
const retried = await api.sessions.create(req({
workspaceId: made.result.value.workspace.workspaceId,
sessionId: preallocated,
}))
expect(retried.result).toEqual({ ok: true, value: { sessionId: preallocated } })
const listed = await api.sessions.list(req({}))
if (!listed.result.ok) throw new Error('session list failed')
expect(listed.result.value.items.filter(item => item.sessionId === preallocated)).toHaveLength(1)
const conflict = await api.sessions.create(req({ sessionId: preallocated, cwd: '/elsewhere' }))
expect(conflict.result).toMatchObject({
ok: false,
error: { code: 'session-conflict', details: { sessionId: preallocated, requestedCwd: '/elsewhere' } },
})
})
it('publishes an ungrouped Session when Workspace attachment fails', async () => {
const api = createFixtureApi({ failWorkspaceAttach: true })
const sessionId = sid('fx-partial')
const created = await api.sessions.create(req({
workspaceId: 'fx-ws-fixture' as WorkspaceId,
sessionId,
}))
expect(created.result).toMatchObject({
ok: false,
error: { code: 'workspace-attach-failed', details: { sessionId, workspaceId: 'fx-ws-fixture' } },
})
const listed = await api.sessions.list(req({}))
const workspaces = await api.workspace.list(req({}))
if (!listed.result.ok || !workspaces.result.ok) throw new Error('list failed')
expect(listed.result.value.items.filter(item => item.sessionId === sessionId)).toHaveLength(1)
expect(workspaces.result.value.items[0]?.sessionIds).not.toContain(sessionId)
const retried = await api.sessions.create(req({
workspaceId: 'fx-ws-fixture' as WorkspaceId,
sessionId,
}))
expect(retried.result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } })
const afterRetry = await api.sessions.list(req({}))
if (!afterRetry.result.ok) throw new Error('list failed')
expect(afterRetry.result.value.items.filter(item => item.sessionId === sessionId)).toHaveLength(1)
})
it('reconciles a dropped create response and can reject a prompt before acceptance', async () => {
const sessionId = sid('fx-lost-response')
const dropped = createFixtureApi({ dropSessionCreateResponse: true })
await expect(Promise.resolve().then(() => dropped.sessions.create(req({
workspaceId: 'fx-ws-fixture' as WorkspaceId,
sessionId,
})))).rejects.toThrow(/dropped session\.create response/)
const listed = await dropped.sessions.list(req({}))
const workspaces = await dropped.workspace.list(req({}))
if (!listed.result.ok || !workspaces.result.ok) throw new Error('list failed')
expect(listed.result.value.items.some(item => item.sessionId === sessionId)).toBe(true)
expect(workspaces.result.value.items[0]?.sessionIds).toContain(sessionId)
await expect(dropped.sessions.create(req({
workspaceId: 'fx-ws-fixture' as WorkspaceId,
sessionId,
}))).resolves.toMatchObject({ result: { ok: true, value: { sessionId } } })
const rejecting = createFixtureApi({ empty: true, rejectPrompt: true })
const real = await rejecting.sessions.create(req({ sessionId: sid('fx-rejected') }))
if (!real.result.ok) throw new Error('session create failed')
const prompt = await rejecting.sessions.prompt(req({
sessionId: real.result.value.sessionId,
mode: 'queue' as const,
content: [{ type: 'text' as const, text: 'keep me' }],
}))
expect(prompt.result).toMatchObject({ ok: false, error: { code: 'agent-busy' } })
})
it('timing hooks: history delay + one-shot failure, silent append, and breakStreams end open generators', async () => {
@@ -311,6 +482,7 @@ describe('createFixtureApi', () => {
describe('FixtureApiClient (protocol-level fake carrier)', () => {
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
it('doFetch is an unreachable tripwire (all protocol paths overridden)', () => {
@@ -346,6 +518,57 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => {
expect((await client.sessions.prompt({ sessionId: id, mode: 'queue', content: [{ type: 'text', text: '嗨' }] })).result.ok).toBe(true)
expect((await client.sessions.cancel({ sessionId: id })).result.ok).toBe(true)
expect((await client.host.describe({})).result.ok).toBe(true)
expect((await client.workspace.list({})).result.ok).toBe(true)
const workspace = await client.workspace.create({ name: 'via-client' })
if (!workspace.result.ok) throw new Error('workspace create failed')
expect(workspace.result.value.workspace.title).toBe('via-client')
})
it('maps empty, prompt-reject, and workspace-first query scenarios', async () => {
vi.stubGlobal('location', {
search: '?fixture=empty&fixturePrompt=reject&fixtureFrames=workspace-first',
})
const client = new FixtureApiClient()
await expect(client.sessions.list({})).resolves.toMatchObject({ result: { ok: true, value: { items: [] } } })
const made = await client.workspace.create({ name: 'query-workspace' })
if (!made.result.ok) throw new Error('workspace create failed')
const abort = new AbortController()
const framesPromise = collect(client.events.host({}, abort.signal), abort, frames => frames.length === 2)
await new Promise(resolve => setTimeout(resolve, 10))
const sessionId = sid('fx-query-session')
const created = await client.sessions.create({
workspaceId: made.result.value.workspace.workspaceId,
sessionId,
})
expect(created.result).toMatchObject({ ok: true, value: { sessionId } })
const frames = await framesPromise
expect(frames.map(frame => frame.type)).toEqual(['host/workspace-changed', 'host/session-added'])
const rejected = await client.sessions.prompt({
sessionId,
mode: 'queue',
content: [{ type: 'text', text: 'retain' }],
})
expect(rejected.result).toMatchObject({ ok: false, error: { code: 'agent-busy' } })
})
it('maps attach-failure and dropped-response query scenarios', async () => {
vi.stubGlobal('location', { search: '?fixture&fixtureAttach=fail' })
const partial = new FixtureApiClient()
const partialResult = await partial.sessions.create({
workspaceId: 'fx-ws-fixture' as WorkspaceId,
sessionId: sid('fx-query-partial'),
})
expect(partialResult.result).toMatchObject({
ok: false,
error: { code: 'workspace-attach-failed', details: { sessionId: 'fx-query-partial' } },
})
vi.stubGlobal('location', { search: '?fixture&fixtureSessionCreate=drop-response' })
const dropped = new FixtureApiClient()
await expect(dropped.sessions.create({
workspaceId: 'fx-ws-fixture' as WorkspaceId,
sessionId: sid('fx-query-dropped'),
})).rejects.toThrow(/dropped session\.create response/)
})
it('fires onOpen at stream-iteration start and taps server-request full forms', async () => {

View File

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

View File

@@ -1,30 +1,32 @@
/**
* Browser runtime services for slots, sessions, and connection-stream
* delivery. The web shell mounts this static client entry through the host
* plugin graph.
*/
/** Browser runtime services for slots, sessions, workspaces, and connection-stream delivery. */
import type { Context } from 'cordis'
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotsService } from './slots.ts'
import { SessionsService } from './sessions/service.ts'
import type { SessionListState } from './sessions/service.ts'
import { WorkspacesService } from './workspaces/service.ts'
import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from './sessions/conversation.ts'
export { SlotsService } from './slots.ts'
export type { RootOwnerProps } from './slots.ts'
export { SessionsService, scopeOf } from './sessions/service.ts'
export { SessionCreateError, SessionsService, scopeOf, workspaceTitleOf } from './sessions/service.ts'
export { WorkspacesService } from './workspaces/service.ts'
export type { Session } from './sessions/session.ts'
export type { SessionBinding, SessionListState, SessionSummary } from './sessions/service.ts'
export type { SessionIntentListSnapshot, SessionListPhase } from './sessions/manager.ts'
export type { WorkspaceListPhase } from './workspaces/manager.ts'
export type { WorkspaceListState } from './workspaces/service.ts'
export type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client'
// Runtime owns the snapshot store; web-react only binds it to React.
export { createSnapshotStore, defineStore, shallowEqual } from './contract/store.ts'
export type {
EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore,
} from './contract/store.ts'
export type {
AssistantBlock, AssistantMessageNode, ContextMessageNode, ConversationNode, ConversationSnapshot,
RunningToolCall, SteeringMessageNode,
ToolResultNode, UnknownSurfaceNode, UserMessageNode,
AssistantBlock, AssistantMessageNode, ComposerPhase, ContextMessageNode, ConversationNode,
ConversationSnapshot, PendingPrompt, RunningToolCall, SessionIntentSnapshot, SessionIntentTarget,
SteeringMessageNode, ToolResultNode, UnknownSurfaceNode, UserMessageNode,
} from './sessions/conversation.ts'
export { PendingWait } from './sessions/pending.ts'
export type { PendingInteraction, PendingKind, PendingPayloads } from './sessions/pending.ts'
@@ -57,6 +59,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
/** Props injected into every global slot component. */
interface GlobalStandardProps {
useSessions: SnapshotSelectorHook<SessionListState>
/** Selector hook over real Workspaces and their independent baseline lifecycle. */
useWorkspaces: SnapshotSelectorHook<import('./workspaces/service.ts').WorkspaceListState>
}
}
@@ -72,6 +76,7 @@ declare module 'cordis' {
interface Context {
slots: import('./slots.ts').SlotsService
sessions: import('./sessions/service.ts').SessionsService
workspaces: import('./workspaces/service.ts').WorkspacesService
}
}
@@ -85,10 +90,17 @@ export function apply(ctx: Context): void {
ctx.plugin(SlotsService)
const connection = ctx.get('connection') as ConnectionHandle
const sessions = new SessionsService(ctx, connection.api)
const workspaces = new WorkspacesService(ctx, connection.api, sessions)
const loop = connection.start({
onMuxEnvelope: (envelope) => { sessions.manager.handleMuxEnvelope(envelope) },
onHostEnvelope: (envelope) => { sessions.manager.handleHostEnvelope(envelope) },
onConnected: () => { sessions.manager.handleConnected() },
onMuxEnvelope: (envelope) => { sessions.handleMuxEnvelope(envelope) },
onHostEnvelope: (envelope) => {
sessions.handleHostEnvelope(envelope)
workspaces.handleHostEnvelope(envelope)
},
onConnected: () => {
sessions.handleConnected()
workspaces.handleConnected()
},
})
ctx.effect(() => () => { loop.stop() }, 'runtime: connection stream loop')
}

View File

@@ -0,0 +1,43 @@
/**
* Merge an authoritative baseline without moving identities already visible to
* the client. Baseline-only identities are inserted relative to the nearest
* following known identity; identities absent from the baseline are removed.
*
* @param current - the established client order.
* @param baseline - the latest authoritative rows.
* @param keyOf - stable identity selector.
* @returns baseline-valued rows with the established relative order retained.
*/
export function mergeOrderedBaseline<T>(
current: readonly T[],
baseline: readonly T[],
keyOf: (value: T) => unknown,
): T[] {
const baselineByKey = new Map<unknown, T>()
for (const value of baseline) baselineByKey.set(keyOf(value), value)
const merged = current
.map(value => baselineByKey.get(keyOf(value)))
.filter((value): value is T => value !== undefined)
const mergedKeys = new Set(merged.map(keyOf))
for (let index = 0; index < baseline.length; index++) {
const value = baseline[index]
/* v8 ignore next -- dense-array guard: index is bounded by baseline.length. */
if (value === undefined || mergedKeys.has(keyOf(value))) continue
let insertion = merged.length
for (let following = index + 1; following < baseline.length; following++) {
const candidate = baseline[following]
/* v8 ignore next -- dense-array guard: following is bounded by baseline.length. */
if (candidate === undefined) continue
const known = merged.findIndex(item => keyOf(item) === keyOf(candidate))
if (known !== -1) {
insertion = known
break
}
}
merged.splice(insertion, 0, value)
mergedKeys.add(keyOf(value))
}
return merged
}

View File

@@ -4,7 +4,9 @@
// string here (narrow to real brands when convenient).
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { RpcError, SessionId, ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
import type {
RpcError, SessionId, ToolCallView, ToolResultView, WorkspaceId,
} from '@deepseek-ai/dsh-client-connection/client'
import type { PendingInteraction } from './pending.ts'
/** Assistant content blocks sorted by what the UI cares about
@@ -149,12 +151,58 @@ export interface PartialAssistant {
/** History-open lifecycle of a Session window. */
export type OpenState = 'cold' | 'loading' | 'open' | 'error'
/**
* Input-area shape of an OPEN session, derived at snapshot assembly (the one
* place that knows the predicate — consumers switch, never re-derive):
*
* - `blank`: no activity ever (no nodes, no partial, not running, no pending
* waits, no prompt attempt) — the UI renders the blank-session guidance
* hero.
* - `engaging`: the first prompt was initiated but no content landed yet —
* the UI holds the composer through the accept → running → first-event
* frames. Entered synchronously before prompt()'s first await.
* - `active`: content exists (nodes, partial, running turn, or pending
* waits) — the ordinary conversation view.
*
* Monotone within a session object: blank → engaging → active, no returns.
* A failed first prompt stays `engaging` (composer + error strip — retry
* semantics; bouncing back to the hero would discard the error context).
* Sessions whose window is not open (`loading`/`error`) are outside phase
* jurisdiction: consumers branch on {@link ConversationSnapshot.openState}
* first (phase still reports `active`-ish facts but must not be rendered).
*/
export type ComposerPhase = 'blank' | 'engaging' | 'active'
/** Send/stop failure surfaced in the input error strip; op picks the user-facing copy (发送失败 vs 停止失败). */
export interface PromptError {
op: 'send' | 'stop'
error: RpcError
}
/** Workspace target of a frontend-only Session. */
export type SessionIntentTarget =
| { kind: 'workspace'; workspaceId: WorkspaceId }
| { kind: 'workspace-intent' }
/** Publication state owned by a frontend Session before it joins the Host. */
export interface SessionIntentSnapshot {
target: SessionIntentTarget
phase: 'ready' | 'connecting'
error?: { step: 'session'; message: string }
}
/** One editable prompt retained by its Session until the Host accepts it. */
export interface PendingPrompt {
text: string
phase: 'editing' | 'sending' | 'failed'
/** Failed prerequisite retried before sending, or the send itself. */
retry: 'connect' | 'send'
/** Workspace needed when retrying Session attachment. */
workspaceId?: WorkspaceId
/** Last failure diagnostic, absent while editing or sending. */
error?: string
}
/** The immutable snapshot contract Session hands to uSES (see the web client architecture RFC). */
export interface ConversationSnapshot {
sessionId: SessionId
@@ -166,6 +214,8 @@ export interface ConversationSnapshot {
runningCalls: readonly RunningToolCall[]
pending: readonly PendingInteraction[]
running: boolean
/** Input-area shape (see {@link ComposerPhase}); derived here, switched on by consumers. */
composerPhase: ComposerPhase
/** Set after host/session-removed; the UI grays out and disables input. */
removed: boolean
openState: OpenState
@@ -173,5 +223,9 @@ export interface ConversationSnapshot {
hasMore: boolean
loadingOlder: boolean
promptError: PromptError | null
/** Frontend-only publication state; null for a Host-connected Session. */
intent: SessionIntentSnapshot | null
/** Session-owned editable prompt waiting for connection, attachment, or send. */
pendingPrompt: PendingPrompt | null
lastAgentError: string | null
}

View File

@@ -1,6 +1,6 @@
// flattenLineage: summaries -> flat list with lineage indentation (pure function).
// Roots sort by updatedAt desc, DFS expansion with children in the same order; orphaned lineage
// degrades to root level; cycles fail soft and emit as roots.
// The input order is authoritative; lineage only makes each child adjacent to its parent.
// Orphaned lineage degrades to root level; cycles fail soft and emit as roots.
import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client'
@@ -22,8 +22,9 @@ export interface SessionListEntry {
}
/**
* summaries -> flat list with lineage indentation (pure; roots by updatedAt
* desc, DFS children in the same order, orphans degrade to roots).
* Summaries -> flat list with lineage indentation. Root and sibling order
* follows the established input order; this projection never re-sorts a
* hydrated list from mutable timestamps.
* @param summaries - the host's session.list items.
* @returns display rows in render order.
*/
@@ -43,9 +44,6 @@ export function flattenLineage(summaries: readonly TitledSessionSummary[]): Sess
}
}
const byUpdatedDesc = (a: TitledSessionSummary, b: TitledSessionSummary): number => b.updatedAt - a.updatedAt
roots.sort(byUpdatedDesc)
const out: SessionListEntry[] = []
const visited = new Set<SessionId>()
const walk = (s: TitledSessionSummary, depth: number): void => {
@@ -57,7 +55,6 @@ export function flattenLineage(summaries: readonly TitledSessionSummary[]): Sess
out.push({ ...s, depth })
const kids = children.get(s.sessionId)
if (kids === undefined) return
kids.sort(byUpdatedDesc)
for (const kid of kids) walk(kid, depth + 1)
}
for (const root of roots) walk(root, 0)

View File

@@ -2,22 +2,51 @@
// dispatch entry + list state, constructed and held by SessionsService (one per client runtime).
// List data never enters zustand; React connects via subscribe/getListSnapshot.
import type { IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client'
import type { IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId, SessionSummary, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client'
// Value import from the inline-safe wire layer (not the connection plugin):
// plugin-to-plugin value imports are a bundle purity error.
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
import { mergeOrderedBaseline } from '../ordered-baseline.ts'
import type { SessionListEntry, TitledSessionSummary } from './lineage.ts'
import { flattenLineage } from './lineage.ts'
import { Notifier } from './notifier.ts'
import { Session } from './session.ts'
import type { SessionIntentSnapshot, SessionIntentTarget } from './conversation.ts'
/**
* List arrival lifecycle, orthogonal to the pull-activity `state` axis:
* `pending` (no successful pull yet — an empty items array means "nothing
* arrived", not "nothing exists") → `ready` (at least one pull landed).
* Monotone: `ready` never steps back — later pull failures and reconnect
* re-pulls ride the `state`/`error` axis, which is where failure is modeled
* (no `error` phase here; that would duplicate `state`).
*/
export type SessionListPhase = 'pending' | 'ready'
/** Session-owned frontend Intent projected into the global list snapshot. */
export interface SessionIntentListSnapshot extends SessionIntentSnapshot {
sessionId: SessionId
prompt: string
}
/** Immutable session-list snapshot for useSessionList. */
export interface SessionListSnapshot {
items: readonly SessionListEntry[]
/** Selected real or frontend-only Session id. */
current: SessionId | undefined
/** Sole page-local frontend Session projection; its state remains owned by Session. */
intent: SessionIntentListSnapshot | undefined
state: 'idle' | 'loading' | 'error'
/** Arrival lifecycle (see {@link SessionListPhase}); `state` stays the pull-activity axis. */
phase: SessionListPhase
error: RpcError | null
}
type SessionListMutation =
| { kind: 'upsert'; summary: SessionSummary }
| { kind: 'remove'; sessionId: SessionId }
| { kind: 'status'; sessionId: SessionId; running: boolean }
/** Per-session cap for pre-instantiation approval/question buffering (low-frequency frames; a few dozen covers any real backlog). */
const PENDING_BUFFER_CAP = 32
@@ -39,8 +68,16 @@ export class SessionManager {
private readonly titleSnapshots = new Map<SessionId, SessionTitleSnapshot>()
private summaries: SessionSummary[] = []
private listState: 'idle' | 'loading' | 'error' = 'idle'
/** Arrival phase; the pending → ready edge fires on the first successful pull (see SessionListPhase). */
private listPhase: SessionListPhase = 'pending'
private listError: RpcError | null = null
private listInflight: Promise<void> | null = null
/** Mutations arriving after a list request starts are replayed over its response. */
private listMutations: SessionListMutation[] | null = null
private selected: SessionId | undefined
private intentSessionId: SessionId | undefined
private stopIntentWatch: (() => void) | undefined
private listSnapshotCache: SessionListSnapshot
/** Entry-identity cache (§C.2 reference stability): list rebuilds reuse the previous entry
@@ -52,10 +89,84 @@ export class SessionManager {
this.listSnapshotCache = this.buildListSnapshot()
})
constructor(private readonly api: IApiClient) {
/**
* @param api - shared wire client.
* @param restoredSelection - persisted real-Session selection candidate.
*/
constructor(
private readonly api: IApiClient,
restoredSelection?: SessionId,
) {
this.selected = restoredSelection
this.listSnapshotCache = this.buildListSnapshot()
}
// ---- Selection and client-local intents ----
/**
* Select a real Session and discard the unmaterialized intent.
* @param sessionId - listed real Session id.
*/
select(sessionId: SessionId): void {
if (!this.summaries.some(summary => summary.sessionId === sessionId)) {
throw new Error(`sessions.select: unknown session ${sessionId}`)
}
this.discardIntent()
this.selected = sessionId
this.notifier.notifyNow()
}
/** Clear selection and abandon any frontend-only Session. */
clearSelection(): void {
this.discardIntent()
this.selected = undefined
this.notifier.notifyNow()
}
/**
* Start a frontend Session against a real or still-local Workspace target.
* @param target - real Workspace or the WorkspacesService-owned local target.
* @param prompt - optional prompt retained when retargeting from a picker.
* @returns the frontend Session object that owns the Intent.
*/
startIntent(target: SessionIntentTarget, prompt = ''): Session {
this.discardIntent()
const sessionId = `client-session-${crypto.randomUUID()}` as SessionId
const session = this.createSession(sessionId, { target, prompt })
this.sessions.set(sessionId, session)
this.intentSessionId = sessionId
this.selected = sessionId
this.stopIntentWatch = session.subscribe(() => {
if (this.intentSessionId !== sessionId) return
if (session.getSnapshot().intent === null) {
this.intentSessionId = undefined
this.stopIntentWatch?.()
this.stopIntentWatch = undefined
}
this.notifier.markDirty()
})
this.notifier.notifyNow()
return session
}
/** @returns the active frontend Session, if one remains selected. */
getIntent(): Session | undefined {
return this.intentSessionId === undefined ? undefined : this.sessions.get(this.intentSessionId)
}
/** @param text - exact controlled-input value for the active frontend Session. */
updateIntent(text: string): void {
this.getIntent()?.updatePendingPrompt(text)
}
private discardIntent(): void {
const session = this.getIntent()
this.intentSessionId = undefined
this.stopIntentWatch?.()
this.stopIntentWatch = undefined
session?.abandonIntent()
}
// ---- Instance management ----
/**
@@ -67,7 +178,7 @@ export class SessionManager {
get(sessionId: SessionId): Session {
let session = this.sessions.get(sessionId)
if (session === undefined) {
session = new Session(sessionId, this.api)
session = this.createSession(sessionId)
this.sessions.set(sessionId, session)
// Sync the running bit from the list snapshot into the new instance (consistency when the list precedes open).
const summary = this.summaries.find(s => s.sessionId === sessionId)
@@ -82,6 +193,22 @@ export class SessionManager {
return session
}
private createSession(
sessionId: SessionId,
intent?: { target: SessionIntentTarget; prompt: string },
): Session {
return new Session(sessionId, this.api, {
...(intent === undefined ? {} : { intent }),
onPublished: (published) => {
this.sessions.set(published.sessionId, published)
this.recordMutation({
kind: 'upsert',
summary: { sessionId: published.sessionId, updatedAt: Date.now(), running: false },
})
},
})
}
// ---- List surface ----
/** Full refresh via session.list (single-flight: an in-flight call is reused). */
@@ -89,13 +216,21 @@ export class SessionManager {
if (this.listInflight !== null) return this.listInflight
this.listState = 'loading'
this.listError = null
const established = this.summaries
const mutations: SessionListMutation[] = []
this.listMutations = mutations
this.notifier.markDirty()
this.listInflight = (async () => {
try {
const { result } = await this.api.sessions.list({})
if (result.ok) {
this.summaries = result.value.items
let summaries = this.listPhase === 'pending'
? result.value.items
: mergeOrderedBaseline(established, result.value.items, summary => summary.sessionId)
for (const mutation of mutations) summaries = applyMutation(summaries, mutation)
this.summaries = summaries
this.listState = 'idle'
this.listPhase = 'ready'
// Push running bits down to instantiated Sessions (the list is the authoritative summary source).
for (const s of this.summaries) this.sessions.get(s.sessionId)?.handleRunning(s.running)
} else {
@@ -108,6 +243,7 @@ export class SessionManager {
/* v8 ignore next -- the `? null` arm is unreachable: transportError always returns ok:false. */
this.listError = folded.ok ? null : folded.error
} finally {
this.listMutations = null
this.listInflight = null
this.notifier.markDirty()
}
@@ -118,18 +254,37 @@ export class SessionManager {
/**
* Contract session.create; on success merge into summaries immediately (no
* wait for the next refresh).
* @param cwd - optional working directory for the new session.
* @param opts - target workspace or working directory, plus an optional caller-owned id.
* @returns the create result.
*/
async create(cwd?: string): Promise<RpcResult<{ sessionId: SessionId }>> {
async create(
opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId } = {},
): Promise<RpcResult<{ sessionId: SessionId }>> {
try {
const { result } = await this.api.sessions.create(cwd === undefined ? {} : { cwd })
if (result.ok && !this.summaries.some(s => s.sessionId === result.value.sessionId)) {
this.summaries = [
{ sessionId: result.value.sessionId, updatedAt: Date.now(), running: false, ...(cwd !== undefined ? { cwd } : {}) },
...this.summaries,
]
this.notifier.markDirty()
const payload = opts.workspaceId !== undefined
? { workspaceId: opts.workspaceId, ...(opts.sessionId === undefined ? {} : { sessionId: opts.sessionId }) }
: {
...(opts.cwd === undefined ? {} : { cwd: opts.cwd }),
...(opts.sessionId === undefined ? {} : { sessionId: opts.sessionId }),
}
const { result } = await this.api.sessions.create(payload)
if (result.ok) {
this.recordMutation({ kind: 'upsert', summary: {
sessionId: result.value.sessionId, updatedAt: Date.now(), running: false,
...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
} })
} else {
const publishedSessionId = workspaceAttachSessionId(result.error)
// Publication precedes attachment. The error's id is a real Session,
// so expose it immediately as Ungrouped while the caller keeps the
// prompt buffer and decides whether to retry attachment.
if (publishedSessionId !== undefined) {
this.recordMutation({ kind: 'upsert', summary: {
sessionId: publishedSessionId,
updatedAt: Date.now(),
running: false,
} })
}
}
return result
} catch (error) {
@@ -137,6 +292,23 @@ export class SessionManager {
}
}
/**
* Insert-or-enrich a locally synthesized summary: a new id prepends; an
* existing entry only gains fields it lacks (the session-added frame and the
* create() echo race — whichever lands second must fill the placeholder's
* missing cwd/parentSessionId, never overwrite list-refresh data).
*/
private mergeSummary(summary: SessionSummary): void {
this.recordMutation({ kind: 'upsert', summary })
}
/** Apply immediately and retain for replay when a list response is in flight. */
private recordMutation(mutation: SessionListMutation): void {
this.listMutations?.push(mutation)
this.summaries = applyMutation(this.summaries, mutation)
this.notifier.markDirty()
}
// ---- Subscription surface (for useSessionList) ----
/**
@@ -216,31 +388,24 @@ export class SessionManager {
const frame = envelope.payload
switch (frame.type) {
case 'host/session-added': {
if (!this.summaries.some(s => s.sessionId === frame.sessionId)) {
this.summaries = [
{
sessionId: frame.sessionId, updatedAt: Date.now(), running: false,
...(frame.parentSessionId !== undefined ? { parentSessionId: frame.parentSessionId } : {}),
},
...this.summaries,
]
this.notifier.markDirty()
}
this.mergeSummary({
sessionId: frame.sessionId, updatedAt: Date.now(), running: false,
...(frame.parentSessionId !== undefined ? { parentSessionId: frame.parentSessionId } : {}),
...(frame.cwd !== undefined ? { cwd: frame.cwd } : {}),
})
this.sessions.get(frame.sessionId)?.handlePublished()
return
}
case 'host/session-removed': {
this.summaries = this.summaries.filter(s => s.sessionId !== frame.sessionId)
this.recordMutation({ kind: 'remove', sessionId: frame.sessionId })
this.sessions.get(frame.sessionId)?.handleRemoved() // instance survives (resident-instance rule), only flagged in the snapshot
this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation
this.titleSnapshots.delete(frame.sessionId)
this.notifier.markDirty()
return
}
case 'host/session-status': {
this.summaries = this.summaries.map(s =>
s.sessionId === frame.sessionId && s.running !== frame.running ? { ...s, running: frame.running } : s)
this.recordMutation({ kind: 'status', sessionId: frame.sessionId, running: frame.running })
this.sessions.get(frame.sessionId)?.handleRunning(frame.running)
this.notifier.markDirty()
return
}
case 'host/agent-error': {
@@ -252,7 +417,7 @@ export class SessionManager {
}
}
/** After each connection generation (first connect included): refresh the list + resync opened instances (reconnect = rebuild). */
/** After each connection generation: refresh the session baseline and rebuild opened windows. */
handleConnected(): void {
void this.refreshList()
for (const session of this.sessions.values()) void session.resync()
@@ -281,6 +446,57 @@ export class SessionManager {
}
const sameOrder = items.length === this.itemsCache.length && items.every((e, i) => e === this.itemsCache[i])
if (!sameOrder) this.itemsCache = items
return { items: this.itemsCache, state: this.listState, error: this.listError }
const intentSession = this.getIntent()
const intentState = intentSession?.getSnapshot()
const intent = intentSession !== undefined
&& intentState !== undefined && intentState.intent !== null && intentState.pendingPrompt !== null
? {
sessionId: intentSession.sessionId,
...intentState.intent,
prompt: intentState.pendingPrompt.text,
}
: undefined
const selected = this.selected
const current = selected !== undefined && (
intent?.sessionId === selected || items.some(item => item.sessionId === selected)
) ? selected : undefined
return {
items: this.itemsCache,
current,
intent,
state: this.listState,
phase: this.listPhase,
error: this.listError,
}
}
}
/** Apply one list mutation without deriving display order. */
function applyMutation(summaries: readonly SessionSummary[], mutation: SessionListMutation): SessionSummary[] {
switch (mutation.kind) {
case 'upsert': {
const existing = summaries.find(summary => summary.sessionId === mutation.summary.sessionId)
if (existing === undefined) return [mutation.summary, ...summaries]
const filled: SessionSummary = {
...existing,
...(existing.cwd === undefined && mutation.summary.cwd !== undefined ? { cwd: mutation.summary.cwd } : {}),
...(existing.parentSessionId === undefined && mutation.summary.parentSessionId !== undefined
? { parentSessionId: mutation.summary.parentSessionId } : {}),
}
if (filled.cwd === existing.cwd && filled.parentSessionId === existing.parentSessionId) return [...summaries]
return summaries.map(summary => summary.sessionId === mutation.summary.sessionId ? filled : summary)
}
case 'remove':
return summaries.filter(summary => summary.sessionId !== mutation.sessionId)
case 'status':
return summaries.map(summary => summary.sessionId === mutation.sessionId && summary.running !== mutation.running
? { ...summary, running: mutation.running }
: summary)
}
}
/** Temporary source-plane bridge while the Host contract and client project build independently. */
function workspaceAttachSessionId(error: RpcError): SessionId | undefined {
const candidate = error as unknown as { code: string; details: { sessionId?: SessionId } }
return candidate.code === 'workspace-attach-failed' ? candidate.details.sessionId : undefined
}

View File

@@ -15,12 +15,16 @@
* survives frozen (read-only view) until the stage moves on.
*/
import type { Context, Fiber } from 'cordis'
import type { IApiClient, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { IApiClient, RpcError, SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionCell } from '@deepseek-ai/dsh-client-ui-slots'
import type { SnapshotStore } from '../contract/store.ts'
import { createSnapshotStore } from '../contract/store.ts'
import { SessionManager } from './manager.ts'
import type {
SessionIntentListSnapshot, SessionListPhase,
} from './manager.ts'
import type { Session } from './session.ts'
import type { SessionIntentTarget } from './conversation.ts'
/** Session list row projected from the host list RPC plus live stream increments. */
export interface SessionSummary {
@@ -40,7 +44,36 @@ export interface SessionSummary {
* the single useSessions standard hook reads list and selection together —
* sidebar highlighting and SessionProvider share one fact source).
*/
export interface SessionListState { ids: SessionId[]; byId: Record<SessionId, SessionSummary>; current: SessionId | undefined }
export interface SessionListState {
ids: SessionId[]
byId: Record<SessionId, SessionSummary>
current: SessionId | undefined
/** Frontend Session Intent projected from its owning Session object. */
intent: SessionIntentListSnapshot | undefined
/** Arrival lifecycle projected 1:1 from the manager snapshot (see SessionListPhase): empty-with-ready means "truly no sessions". */
phase: SessionListPhase
}
/** Structured session-create failure preserving partial publication identity. */
export class SessionCreateError extends Error {
override readonly name = 'SessionCreateError'
/** Definitely published by Host before Workspace attachment failed. */
readonly publishedSessionId: SessionId | undefined
/**
* @param rpcError - Host business or folded transport error.
* @param requestedSessionId - caller-preallocated id used for later stream/list reconciliation.
*/
constructor(
readonly rpcError: RpcError,
readonly requestedSessionId: SessionId | undefined,
) {
super(`session create failed: ${rpcError.code}: ${rpcError.message}`)
this.publishedSessionId = rpcError.code === 'workspace-attach-failed'
? rpcError.details.sessionId
: undefined
}
}
/** Session assembly handle for SessionProvider/inject factories (identity-stable per session). */
export interface SessionBinding {
@@ -64,6 +97,20 @@ export function scopeOf(ctx: Context): SessionId | undefined {
/** Shared no-op plugin backing each session scope fiber. */
function sessionScope(): void {}
/**
* Workspace display title of a session cwd: the path's last non-empty
* segment (both separators accepted; trailing separators ignored), or ''
* for separator-only paths — callers own their fallback (session id, raw
* cwd, default-directory copy). The repo-wide single basename derivation —
* every surface naming a workspace (picker rows, toggle labels, list titles)
* calls this instead of re-splitting paths.
* @param cwd - workspace directory path.
* @returns basename title, or '' when no non-empty segment exists.
*/
export function workspaceTitleOf(cwd: string): string {
return cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop() ?? ''
}
/**
* Display title projection: durable title, project directory basename, then
* the raw id.
@@ -71,8 +118,8 @@ function sessionScope(): void {}
function displayTitleOf(title: string | undefined, cwd: string | undefined, id: SessionId): string {
if (title !== undefined) return title
if (cwd !== undefined && cwd !== '') {
const base = cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop()
if (base !== undefined && base !== '') return base
const base = workspaceTitleOf(cwd)
if (base !== '') return base
}
return id
}
@@ -89,8 +136,8 @@ interface ScopeRecord {
export class SessionsService {
/** List snapshot store (list RPC + host stream increments; re-pulled on reconnect) — the useSessions standard feed, current included. */
readonly list: SnapshotStore<SessionListState>
/** The object-layer instance cluster and frame dispatch entry (wired to the connection by the runtime apply). */
readonly manager: SessionManager
/** The object-layer instance cluster and frame dispatch entry. */
private readonly manager: SessionManager
/**
* Persisted selection cell (the durable half of `list.current`). Private on
@@ -117,12 +164,14 @@ export class SessionsService {
* @param ctx - client root context (scope fibers mount under it).
* @param api - wire client shared with every Session.
*/
constructor(private readonly rootCtx: Context, private readonly api: IApiClient) {
this.manager = new SessionManager(api)
constructor(private readonly rootCtx: Context, api: IApiClient) {
this.selection = createSnapshotStore<{ sessionId?: SessionId }>(
{},
{ persist: { name: 'dsh.sessions.current' } })
this.list = createSnapshotStore<SessionListState>({ ids: [], byId: {}, current: undefined })
this.manager = new SessionManager(api, this.selection.getSnapshot().sessionId)
this.list = createSnapshotStore<SessionListState>({
ids: [], byId: {}, current: undefined, intent: undefined, phase: 'pending',
})
// The manager owns wire truth; the store is its projection. Manager
// notifications are already microtask-batched.
this.manager.subscribe(() => { this.projectList() })
@@ -142,56 +191,81 @@ export class SessionsService {
* @param id - session id (must exist in the list store).
*/
open(id: SessionId): void {
if (this.list.getSnapshot().byId[id] === undefined) {
throw new Error(`sessions.open: unknown session ${id}`)
}
this.selection.update((draft) => { draft.sessionId = id })
this.list.update((draft) => { draft.current = id })
this.manager.select(id)
}
/**
* Clear the current selection so the layout shows the no-session empty
* state. Wipes the persisted selection too — a reload stays on empty until
* the user opens or starts a session. Staging holds the previous occupant
* across the blank (same masked-gap rule as a transient list miss).
* state (new-session affordance and the workspace preselection flow).
* Wipes the persisted selection too — a reload stays on empty until the
* user opens or starts a session. The staged scope keeps its frozen view
* per the masked-gap contract until the next open() moves the stage.
*/
clear(): void {
this.selection.set({})
this.list.update((draft) => { draft.current = undefined })
this.manager.clearSelection()
}
/**
* Start or retarget the sole client-local Session intent.
* @param target - resolved real or frontend-only Workspace target.
* @param prompt - optional prompt retained across retargeting.
*/
startIntent(target: SessionIntentTarget, prompt = ''): Session {
return this.manager.startIntent(target, prompt)
}
/** @returns the active frontend Session object, if one exists. */
intent(): Session | undefined {
return this.manager.getIntent()
}
/** @param text - exact controlled-input value for the current Session Intent. */
updateIntent(text: string): void {
this.manager.updateIntent(text)
}
/**
* Refresh the real Session baseline, reusing an in-flight pull.
* @returns completion of the current or newly started baseline pull.
*/
refresh(): Promise<void> {
return this.manager.refreshList()
}
/**
* Route a mux stream envelope into the Session object layer.
* @param envelope - validated mux stream envelope.
*/
handleMuxEnvelope(envelope: Parameters<SessionManager['handleMuxEnvelope']>[0]): void {
this.manager.handleMuxEnvelope(envelope)
}
/**
* Route a Host stream envelope into the Session object layer.
* @param envelope - validated Host stream envelope.
*/
handleHostEnvelope(envelope: Parameters<SessionManager['handleHostEnvelope']>[0]): void {
this.manager.handleHostEnvelope(envelope)
}
/** Rebuild the Session baseline and every opened window after connection. */
handleConnected(): void {
this.manager.handleConnected()
}
/**
* Create a session on the host.
* @param opts - creation options (project directory).
* @param opts - target workspace or directory and an optional preallocated id.
* @returns the new session id.
* @throws {SessionCreateError} with the requested id and, after an attach
* failure, the definitely published id.
*/
async create(opts: { cwd?: string } = {}): Promise<SessionId> {
const result = await this.manager.create(opts.cwd)
if (!result.ok) throw new Error(`session create failed: ${result.error.code}: ${result.error.message}`)
async create(opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId } = {}): Promise<SessionId> {
const result = await this.manager.create(opts)
if (!result.ok) throw new SessionCreateError(result.error, opts.sessionId)
return result.value.sessionId
}
/**
* Create a workspace folder under the host process cwd and a session in it.
* Name is a single path segment (no separators); the host mkdir runs inside
* session.create. Caller opens the returned id when it wants the session staged.
* @param name - workspace folder basename.
* @returns the new session id.
*/
async createWorkspace(name: string): Promise<SessionId> {
const trimmed = name.trim()
if (trimmed === '') throw new Error('sessions.createWorkspace: name is required')
if (/[/\\]/.test(trimmed)) {
throw new Error('sessions.createWorkspace: name must not contain path separators')
}
const { result } = await this.api.host.describe({})
if (!result.ok) {
throw new Error(`host.describe failed: ${result.error.code}: ${result.error.message}`)
}
const hostCwd = result.value.cwd.replace(/[/\\]+$/, '')
return this.create({ cwd: `${hostCwd}/${trimmed}` })
}
/**
* Resolve a session-scoped context view (use-and-discard).
* @param id - session id.
@@ -244,11 +318,12 @@ export class SessionsService {
* failed one retries the next time current is touched).
*/
private followCurrent(): void {
const current = this.list.getSnapshot().current
const snapshot = this.list.getSnapshot()
const current = snapshot.current
// A masked gap (current blanked while the selection's session is
// transiently absent) holds the stage: tearing down on the gap would
// destroy exactly the frozen scope the mask exists to preserve.
if (current === undefined || current === this.watched) return
if (current === undefined || snapshot.byId[current] === undefined || current === this.watched) return
this.watched = current
this.sweepDeferred()
const record = this.resolve(current)
@@ -300,7 +375,7 @@ export class SessionsService {
/** Project the manager's list snapshot into the store (title derivation is display-only). */
private projectList(): void {
const items = this.manager.getListSnapshot().items
const { items, current, intent, phase } = this.manager.getListSnapshot()
const ids: SessionId[] = []
const byId: Record<SessionId, SessionSummary> = {}
for (const entry of items) {
@@ -315,11 +390,13 @@ export class SessionsService {
...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}),
}
}
// current = the persisted selection, masked while its session is absent
// (falls to the empty state; resurfaces if the session returns).
const selected = this.selection.getSnapshot().sessionId
const current = selected !== undefined && byId[selected] !== undefined ? selected : undefined
this.list.set({ ids, byId, current })
const persisted = this.selection.getSnapshot().sessionId
if (intent?.sessionId === current) {
if (persisted !== undefined) this.selection.set({})
} else if (current !== undefined && byId[current] !== undefined && persisted !== current) {
this.selection.set({ sessionId: current })
}
this.list.set({ ids, byId, current, intent, phase })
this.pruneScopes(byId)
}

View File

@@ -4,14 +4,15 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {
HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult,
SessionId, ToolEventView,
SessionId, ToolEventView, WorkspaceId,
} from '@deepseek-ai/dsh-client-connection/client'
// Value import from the inline-safe wire layer (not the connection plugin):
// plugin-to-plugin value imports are a bundle purity error.
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { ObservableSnapshot } from '../contract/store.ts'
import type {
ConversationNode, ConversationSnapshot, OpenState, PromptError, RunningToolCall,
ComposerPhase, ConversationNode, ConversationSnapshot, OpenState, PendingPrompt,
PromptError, RunningToolCall, SessionIntentSnapshot, SessionIntentTarget,
} from './conversation.ts'
import type { PendingInteraction } from './pending.ts'
import { PendingWait } from './pending.ts'
@@ -22,6 +23,12 @@ import { PartialAccumulator } from './partial.ts'
/** Messages requested per history page. */
export const PAGE_MESSAGES = 50
/** Optional frontend Intent and publication observer for a Session object. */
export interface SessionOptions {
intent?: { target: SessionIntentTarget; prompt: string }
onPublished?(session: Session): void
}
/**
* Owns a session's event window, derived conversation state, and observable
* snapshot. React bindings remain outside this data layer.
@@ -60,8 +67,18 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
private frozenRev = 0
private nodesCache: { folded: readonly ConversationNode[]; frozenRev: number; value: readonly ConversationNode[] } | null = null
private running = false
/**
* Sticky send marker, private input of the composerPhase derivation: set
* synchronously before prompt()'s first await, never reset — the blank →
* engaging edge of the phase machine (see ComposerPhase).
*/
private promptAttempted = false
private removed = false
private promptError: PromptError | null = null
private intent: SessionIntentSnapshot | null
private pendingPrompt: PendingPrompt | null
private intentGeneration = 0
private published: boolean
private lastAgentError: string | null = null
/** Live events buffered during open/resync and stitched by sequence once history lands. */
private liveBuffer: { event: SessionEvent; view: ToolEventView | undefined }[] = []
@@ -75,7 +92,23 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
this.snapshotCache = this.buildSnapshot()
})
constructor(readonly sessionId: SessionId, private readonly api: IApiClient) {
/**
* @param sessionId - stable identity shared by the frontend Intent and Host entity.
* @param api - shared wire client.
* @param options - optional frontend-only initial state and publication observer.
*/
constructor(
readonly sessionId: SessionId,
private readonly api: IApiClient,
private readonly options: SessionOptions = {},
) {
this.intent = options.intent === undefined
? null
: { target: options.intent.target, phase: 'ready' }
this.pendingPrompt = options.intent === undefined
? null
: { text: options.intent.prompt, phase: 'editing', retry: 'send' }
this.published = options.intent === undefined
this.snapshotCache = this.buildSnapshot()
}
@@ -90,6 +123,10 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
async prompt(content: ContentBlock[], mode: 'queue' | 'steer'): Promise<RpcResult<{ accepted: true }>> {
this.promptError = null
this.lastAgentError = null
// Synchronous, before the first await: the blank → engaging edge must be
// visible on the session area's very first frame when a caller sends
// ahead of navigation (first-send flow).
this.promptAttempted = true
this.notifier.markDirty()
let result: RpcResult<{ accepted: true }>
try {
@@ -104,6 +141,57 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
return result
}
/** @param text - exact controlled value of this Session's retained prompt. */
updatePendingPrompt(text: string): void {
const pending = this.pendingPrompt
if (pending === null || pending.phase === 'sending') return
this.pendingPrompt = { ...pending, text }
this.notifier.notifyNow()
}
/**
* Connect this frontend Session to a real Workspace and flush its retained prompt.
* @param workspaceId - real Workspace target.
*/
connect(workspaceId: WorkspaceId): void {
const intent = this.intent
const pending = this.pendingPrompt
if (intent === null || intent.phase === 'connecting' || pending === null || pending.text.trim() === '') return
const connecting: SessionIntentSnapshot = {
target: { kind: 'workspace', workspaceId },
phase: 'connecting',
}
const queued: PendingPrompt = {
...pending,
phase: 'sending',
retry: 'connect',
workspaceId,
}
delete queued.error
this.intent = connecting
this.pendingPrompt = queued
this.notifier.notifyNow()
void this.flushPendingPrompt()
}
/** Stop a superseded frontend Intent from automatically sending after publication. */
abandonIntent(): void {
if (this.intent === null) return
this.intentGeneration += 1
}
/** Retry this Session's retained prompt from its failed prerequisite. */
retryPendingPrompt(): void {
const pending = this.pendingPrompt
if (pending === null || pending.phase === 'sending' || pending.text.trim() === '') return
const sending: PendingPrompt = { ...pending, phase: 'sending' }
delete sending.error
this.pendingPrompt = sending
this.promptError = null
this.notifier.markDirty()
void this.flushPendingPrompt()
}
/**
* Stop: contract session.cancel 1:1; failures land in promptError (same error-strip display slot).
* @returns the cancel result.
@@ -271,6 +359,11 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
this.notifier.markDirty()
}
/** Mark that Host publication is known without resolving an uncertain local create response. */
handlePublished(): void {
this.markPublished()
}
/** host/session-removed relay: flag the snapshot (instance survives — resident-instance rule). */
handleRemoved(): void {
this.removed = true
@@ -304,6 +397,112 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
this.pendingRev++
}
/** Advance the retained prompt through Session attachment and submission. */
private async flushPendingPrompt(): Promise<void> {
const pending = this.pendingPrompt
if (pending?.phase === 'sending') {
const ready = pending.retry === 'connect'
? await this.attachPendingPrompt(pending)
: pending
if (ready !== null) await this.sendPendingPrompt(ready)
}
}
/** Complete the Host Session prerequisite and return the prompt's send step. */
private async attachPendingPrompt(pending: PendingPrompt): Promise<PendingPrompt | null> {
const workspaceId = pending.workspaceId
if (workspaceId === undefined) throw new Error('a Session attachment requires a Workspace id')
const originIntent = this.intent
const originGeneration = this.intentGeneration
let result: RpcResult<{ sessionId: SessionId }>
try {
result = (await this.api.sessions.create({ sessionId: this.sessionId, workspaceId })).result
} catch (error) {
result = transportError(error)
}
let ready: PendingPrompt | null = null
if (result.ok) {
ready = this.completePendingAttachment(pending, originIntent, originGeneration)
} else {
this.failPendingAttachment(pending, originIntent, originGeneration, result.error)
}
this.notifier.markDirty()
return ready
}
/** Move a published Session to the send step unless its page intent was superseded. */
private completePendingAttachment(
pending: PendingPrompt,
originIntent: SessionIntentSnapshot | null,
originGeneration: number,
): PendingPrompt | null {
this.markPublished()
this.intent = null
this.promptAttempted = true
const superseded = originIntent !== null && originGeneration !== this.intentGeneration
const next: PendingPrompt = {
...pending,
phase: superseded ? 'failed' : 'sending',
retry: 'send',
...(superseded ? { error: 'Message was not sent because you navigated away.' } : {}),
}
if (!superseded) delete next.error
this.pendingPrompt = next
return superseded ? null : next
}
/** Retain the prompt at the failed attachment step that owns the retry. */
private failPendingAttachment(
pending: PendingPrompt,
originIntent: SessionIntentSnapshot | null,
originGeneration: number,
error: RpcError,
): void {
const partiallyPublished = error.code === 'workspace-attach-failed'
if (partiallyPublished) {
this.markPublished()
this.intent = null
this.promptAttempted = true
}
const activeIntent = !partiallyPublished
&& originIntent !== null
&& originGeneration === this.intentGeneration
&& this.intent === originIntent
if (activeIntent) {
this.intent = {
target: originIntent.target,
phase: 'ready',
error: { step: 'session', message: rpcErrorMessage(error) },
}
this.pendingPrompt = { ...pending, phase: 'editing' }
}
if (!activeIntent && (partiallyPublished || originIntent === null) && this.pendingPrompt === pending) {
this.pendingPrompt = { ...pending, phase: 'failed', error: rpcErrorMessage(error) }
}
}
/** Submit the retained prompt and keep it only when Host rejects the send. */
private async sendPendingPrompt(pending: PendingPrompt): Promise<void> {
const result = await this.prompt([{ type: 'text', text: pending.text.trim() }], 'queue')
if (this.pendingPrompt === pending) {
this.pendingPrompt = result.ok
? null
: {
...pending,
retry: 'send',
phase: 'failed',
error: rpcErrorMessage(result.error),
}
this.notifier.markDirty()
}
}
private markPublished(): void {
if (this.published) return
this.published = true
this.options.onPublished?.(this)
}
/** @param generation - openGeneration at launch; every await re-checks it and a stale pass
* drops all writes (resync superseded this open — its outcome belongs to a dead connection). */
private async doOpen(generation: number): Promise<void> {
@@ -520,21 +719,47 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
if (this.pendingCache === null || this.pendingCache.rev !== this.pendingRev) {
this.pendingCache = { rev: this.pendingRev, value: [...this.pending.values()] }
}
const partial = this.partial?.toPartial() ?? null
return {
sessionId: this.sessionId,
nodes,
foldDegraded: degraded,
partial: this.partial?.toPartial() ?? null,
partial,
runningCalls: this.callsCache.value,
pending: this.pendingCache.value,
running: this.running,
composerPhase: derivePhase(
nodes.length > 0 || partial !== null || this.running || this.pendingCache.value.length > 0,
this.promptAttempted,
),
removed: this.removed,
openState: this.openState,
openError: this.openError,
hasMore: this.hasMore,
loadingOlder: this.loadingOlder,
promptError: this.promptError,
intent: this.intent,
pendingPrompt: this.pendingPrompt,
lastAgentError: this.lastAgentError,
}
}
}
function rpcErrorMessage(error: RpcError): string {
return `${error.code}: ${error.message}`
}
/**
* The composerPhase judgment — the single site that knows the predicate
* (consumers switch on the result, never re-derive). Monotone per session
* object: `hasContent` only grows within a window and `promptAttempted` is
* sticky, so blank → engaging → active never steps back; a failed first
* prompt stays engaging (retry semantics — see ComposerPhase).
* @param hasContent - any conversation material exists (nodes, partial, running turn, pending waits).
* @param promptAttempted - a prompt was initiated on this session object.
* @returns the derived phase.
*/
function derivePhase(hasContent: boolean, promptAttempted: boolean): ComposerPhase {
if (hasContent) return 'active'
return promptAttempted ? 'engaging' : 'blank'
}

View File

@@ -235,13 +235,17 @@ export class SlotsService extends Service {
}
}
/** Build (once) the host face the installed renderer reads; sessions resolve lazily at first render. */
/** Build once after both object-layer services mount; session cells still resolve lazily. */
private hostFace(): SlotRendererHost {
if (this._host !== undefined) return this._host
const sessions = this.ctx.get('sessions')
if (sessions === undefined) {
throw new Error("renderSlot('root') before the sessions service mounted — boot order puts runtime apply first")
}
const workspaces = this.ctx.get('workspaces')
if (workspaces === undefined) {
throw new Error("renderSlot('root') before the workspaces service mounted — boot order puts runtime apply first")
}
// Identity-stable view: current rides the list snapshot (arbitrated), but
// the provider consumes it as its own observable; one cached object keeps
// the renderer's per-source hook cache stable.
@@ -262,6 +266,7 @@ export class SlotsService extends Service {
current,
cell: id => sessions.cell(id),
},
workspaces: { list: workspaces.list },
}
return this._host
}

View File

@@ -0,0 +1,243 @@
/** Workspace baseline, incremental-frame, and unary-action owner. */
import type {
HostFrame, IApiClient, RpcError, RpcRequest, RpcResult, WorkspaceView,
} from '@deepseek-ai/dsh-client-connection/client'
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
import { mergeOrderedBaseline } from '../ordered-baseline.ts'
import { Notifier } from '../sessions/notifier.ts'
import {
Workspace, type WorkspaceCreateInput, type WorkspaceIntentSnapshot,
} from './workspace.ts'
export type { WorkspaceIntentSnapshot } from './workspace.ts'
/** Monotone workspace-list arrival lifecycle. */
export type WorkspaceListPhase = 'pending' | 'ready'
/** Immutable workspace-list snapshot. */
export interface WorkspaceListSnapshot {
items: readonly WorkspaceView[]
/** The sole page-local Workspace intent; never persisted or sent over the Host stream. */
intent: WorkspaceIntentSnapshot | undefined
state: 'idle' | 'loading' | 'error'
phase: WorkspaceListPhase
error: RpcError | null
}
/** Workspace object cluster driven by one list baseline and changed-frame upserts. */
export class WorkspaceManager {
private items: Workspace[] = []
private intent: Workspace | undefined
private itemViewsSource: readonly Workspace[] | null = null
private itemViewsCache: readonly WorkspaceView[] = []
private state: WorkspaceListSnapshot['state'] = 'idle'
private phase: WorkspaceListPhase = 'pending'
private error: RpcError | null = null
private inflight: Promise<void> | null = null
private refreshFrames: WorkspaceView[] | null = null
private snapshotCache: WorkspaceListSnapshot
private readonly notifier = new Notifier(() => {
this.snapshotCache = this.buildSnapshot()
})
/** @param api - shared wire client. */
constructor(private readonly api: IApiClient) {
this.snapshotCache = this.buildSnapshot()
}
/**
* Replace the current client-local Workspace intent object.
* @param name - directory/display name used if the intent is materialized.
* @returns the new intent snapshot.
*/
startIntent(name = 'workspace'): WorkspaceIntentSnapshot {
this.intent = new Workspace(this.api, { name })
this.notifier.notifyNow()
return this.intent.getSnapshot().intent as WorkspaceIntentSnapshot
}
/** Discard the current client-local Workspace intent. */
discardIntent(): void {
if (this.intent === undefined) return
this.intent = undefined
this.notifier.notifyNow()
}
/**
* Materialize the current Workspace intent through the ordinary Host create seam.
* A superseded intent is never cleared by an older completion.
* @returns the Host create result, or undefined when no intent exists.
*/
async materializeIntent(): Promise<RpcResult<{ workspace: WorkspaceView; created: boolean }> | undefined> {
const intent = this.intent
if (intent?.getSnapshot().intent?.phase !== 'ready') return undefined
const completion = intent.materialize()
if (completion === undefined) return undefined
this.notifier.notifyNow()
const result = await completion
if (result.ok) {
this.upsert(result.value.workspace, intent)
if (this.intent === intent) this.intent = undefined
}
this.notifier.markDirty()
return result
}
/**
* Refresh from workspace.list. The first successful response establishes
* Host order; later responses update membership and values without moving
* identities already visible to the client. Frames arriving during the RPC
* are replayed over its response.
* @returns the shared in-flight refresh.
*/
refresh(): Promise<void> {
if (this.inflight !== null) return this.inflight
this.state = 'loading'
this.error = null
const established = this.itemViews()
const frames: WorkspaceView[] = []
this.refreshFrames = frames
this.notifier.markDirty()
this.inflight = (async () => {
try {
const { result } = await this.api.workspace.list({})
if (result.ok) {
let items = this.phase === 'pending'
? result.value.items
: mergeOrderedBaseline(established, result.value.items, workspace => workspace.workspaceId)
for (const workspace of frames) items = upsertWorkspace(items, workspace)
this.installViews(items)
this.state = 'idle'
this.phase = 'ready'
} else {
this.state = 'error'
this.error = result.error
}
} catch (error) {
this.state = 'error'
const folded = transportError<never>(error)
/* v8 ignore next -- transportError always returns the failure branch. */
this.error = folded.ok ? null : folded.error
} finally {
this.refreshFrames = null
this.inflight = null
this.notifier.markDirty()
}
})()
return this.inflight
}
/**
* Create or resolve a real Workspace, then publish its returned snapshot
* without waiting for the changed frame.
* @param input - name under workspaceRoot or an existing absolute path.
* @returns the wire result.
*/
async create(input: WorkspaceCreateInput): Promise<RpcResult<{ workspace: WorkspaceView; created: boolean }>> {
const workspace = new Workspace(this.api, input)
const completion = workspace.materialize()
if (completion === undefined) throw new Error('a local Workspace must be materializable')
const result = await completion
if (result.ok) this.upsert(result.value.workspace, workspace)
return result
}
/**
* Host-frame entry. Non-workspace frames are ignored so the runtime can
* fan one host stream out to both object managers.
* @param envelope - host stream envelope.
*/
handleHostEnvelope(envelope: RpcRequest<HostFrame>): void {
if (envelope.payload.type === 'host/workspace-changed') this.upsert(envelope.payload.workspace)
}
/** Re-pull the baseline after each connection generation. */
handleConnected(): void {
void this.refresh()
}
/**
* Subscribe to workspace snapshot invalidation.
* @param listener - snapshot invalidation callback.
* @returns unsubscribe function.
*/
subscribe(listener: () => void): () => void {
return this.notifier.subscribe(listener)
}
/**
* Read the cached workspace snapshot after flushing pending notifications.
* @returns the cached workspace snapshot.
*/
getSnapshot(): WorkspaceListSnapshot {
this.notifier.ensureFresh()
return this.snapshotCache
}
private buildSnapshot(): WorkspaceListSnapshot {
return {
items: this.itemViews(),
intent: this.intent?.getSnapshot().intent,
state: this.state,
phase: this.phase,
error: this.error,
}
}
/** Upsert one Host view, optionally retaining the local object that materialized it. */
private upsert(view: WorkspaceView, identity?: Workspace): void {
this.refreshFrames?.push(view)
const index = this.items.findIndex(item => item.getSnapshot().view?.workspaceId === view.workspaceId)
if (identity !== undefined) {
this.items = index === -1
? [identity, ...this.items]
: this.items.map((item, position) => position === index ? identity : item)
} else if (index === -1) {
this.items = [new Workspace(this.api, view), ...this.items]
} else {
this.items[index]?.adopt(view)
this.items = [...this.items]
}
this.notifier.markDirty()
}
private installViews(views: readonly WorkspaceView[]): void {
const existing = new Map(
this.items.flatMap((workspace) => {
const view = workspace.getSnapshot().view
return view === undefined ? [] : [[view.workspaceId, workspace] as const]
}),
)
const installed = new Map<WorkspaceView['workspaceId'], Workspace>()
for (const view of views) {
const duplicate = installed.get(view.workspaceId)
if (duplicate !== undefined) {
duplicate.adopt(view)
continue
}
const workspace = existing.get(view.workspaceId) ?? new Workspace(this.api, view)
workspace.adopt(view)
installed.set(view.workspaceId, workspace)
}
this.items = [...installed.values()]
}
private itemViews(): readonly WorkspaceView[] {
if (this.itemViewsSource === this.items) return this.itemViewsCache
this.itemViewsSource = this.items
this.itemViewsCache = this.items.flatMap((workspace) => {
const view = workspace.getSnapshot().view
return view === undefined ? [] : [view]
})
return this.itemViewsCache
}
}
/** Known ids retain their position; a newly created Workspace enters first. */
function upsertWorkspace(items: readonly WorkspaceView[], workspace: WorkspaceView): WorkspaceView[] {
const index = items.findIndex(item => item.workspaceId === workspace.workspaceId)
return index === -1
? [workspace, ...items]
: items.map((item, position) => position === index ? workspace : item)
}

View File

@@ -0,0 +1,164 @@
/** WorkspacesService projects the Workspace object manager for UI consumers. */
import type { Context } from 'cordis'
import type {
IApiClient, RpcError, WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-connection/client'
import type { SnapshotStore } from '../contract/store.ts'
import { createSnapshotStore } from '../contract/store.ts'
import type { SessionsService } from '../sessions/service.ts'
import { WorkspaceManager, type WorkspaceIntentSnapshot, type WorkspaceListPhase } from './manager.ts'
/** Workspace list plus the two-baseline readiness and default-target projection. */
export interface WorkspaceListState {
items: readonly WorkspaceView[]
/** Sole client-local Workspace projection; its state remains owned by Workspace. */
intent: WorkspaceIntentSnapshot | undefined
state: 'idle' | 'loading' | 'error'
phase: WorkspaceListPhase
error: RpcError | null
/** True only after both workspace.list and session.list have succeeded. */
baselinesReady: boolean
/** Most recently active Workspace, derived without changing `items` order. */
recentWorkspaceId: WorkspaceId | undefined
}
/** Real Workspace object layer and Host actions. */
export class WorkspacesService {
/** UI-facing immutable projection; the manager remains wire truth. */
readonly list: SnapshotStore<WorkspaceListState>
/** Workspace baseline and frame owner. */
private readonly manager: WorkspaceManager
private initialSessionResolved = false
private composingIntent = false
/**
* @param ctx - client root context.
* @param api - shared wire client.
* @param sessions - lower-level Session service used for recency and cross-domain intent orchestration.
*/
constructor(ctx: Context, api: IApiClient, private readonly sessions: SessionsService) {
this.manager = new WorkspaceManager(api)
this.list = createSnapshotStore<WorkspaceListState>({
items: [], intent: undefined, state: 'idle', phase: 'pending', error: null,
baselinesReady: false, recentWorkspaceId: undefined,
})
this.manager.subscribe(() => { if (!this.composingIntent) this.project() })
this.sessions.list.subscribe(() => { if (!this.composingIntent) this.project() })
ctx.reflect.provide('workspaces', this, undefined)
}
/**
* Start the sole Session intent, resolving the default Workspace here.
* @param workspaceId - optional explicit real Workspace target.
* @param prompt - optional prompt retained while retargeting.
*/
startSession(workspaceId?: WorkspaceId, prompt = ''): void {
const snapshot = this.list.getSnapshot()
const resolved = workspaceId ?? snapshot.recentWorkspaceId ?? snapshot.items[0]?.workspaceId
this.composingIntent = true
try {
if (resolved === undefined) {
this.manager.startIntent()
this.sessions.startIntent({ kind: 'workspace-intent' }, prompt)
} else {
this.manager.discardIntent()
this.sessions.startIntent({ kind: 'workspace', workspaceId: resolved }, prompt)
}
} finally {
this.composingIntent = false
this.project()
}
}
/** Connect the current frontend Workspace and Session, then flush the Session-owned prompt. */
sendSession(): void {
const session = this.sessions.intent()
const target = session?.getSnapshot().intent?.target
if (session === undefined || target === undefined) return
if (target.kind === 'workspace') {
session.connect(target.workspaceId)
return
}
if (session.getSnapshot().pendingPrompt?.text.trim() === '') return
void this.manager.materializeIntent().then((result) => {
if (this.sessions.intent() !== session) return
if (result?.ok) {
session.connect(result.value.workspace.workspaceId)
}
})
}
/**
* Create a Workspace by name or register an existing path.
* @param input - exactly one Host create spelling.
* @returns the created or idempotently resolved Workspace.
*/
async create(input: { name: string } | { path: string }): Promise<WorkspaceView> {
const result = await this.manager.create(input)
if (!result.ok) throw new Error(`workspace create failed: ${result.error.code}: ${result.error.message}`)
return result.value.workspace
}
/**
* Refresh the workspace baseline, reusing an in-flight pull.
* @returns completion of the current or newly started workspace baseline pull.
*/
refresh(): Promise<void> {
return this.manager.refresh()
}
/**
* Route a Host stream envelope into the Workspace object layer.
* @param envelope - validated Host stream envelope.
*/
handleHostEnvelope(envelope: Parameters<WorkspaceManager['handleHostEnvelope']>[0]): void {
this.manager.handleHostEnvelope(envelope)
}
/** Rebuild the Workspace baseline after connection. */
handleConnected(): void {
this.manager.handleConnected()
}
private project(): void {
const workspace = this.manager.getSnapshot()
const sessions = this.sessions.list.getSnapshot()
if (workspace.intent !== undefined && sessions.intent?.target.kind !== 'workspace-intent') {
this.manager.discardIntent()
return
}
const baselinesReady = workspace.phase === 'ready' && sessions.phase === 'ready'
this.list.set({
...workspace,
baselinesReady,
recentWorkspaceId: baselinesReady ? recentWorkspace(workspace.items, sessions.byId) : undefined,
})
if (!this.initialSessionResolved && baselinesReady) {
this.initialSessionResolved = true
if (sessions.current === undefined && sessions.intent === undefined) this.startSession()
}
}
}
/** Stable tie-breaking follows Host Workspace order. */
function recentWorkspace(
workspaces: readonly WorkspaceView[],
sessions: ReturnType<SessionsService['list']['getSnapshot']>['byId'],
): WorkspaceId | undefined {
let selected: WorkspaceId | undefined
let selectedTime = Number.NEGATIVE_INFINITY
for (const workspace of workspaces) {
let latest = Number.NEGATIVE_INFINITY
for (const sessionId of workspace.sessionIds) {
const session = sessions[sessionId]
if (session !== undefined) latest = Math.max(latest, session.updatedAt)
}
if (latest === Number.NEGATIVE_INFINITY) latest = Date.parse(workspace.createdAt)
if (selected === undefined || latest > selectedTime) {
selected = workspace.workspaceId
selectedTime = latest
}
}
return selected
}

View File

@@ -0,0 +1,143 @@
/** React-free Workspace entity with a client-local materialization lifecycle. */
import type {
IApiClient, RpcResult, WorkspaceView,
} from '@deepseek-ai/dsh-client-connection/client'
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { ObservableSnapshot } from '../contract/store.ts'
import { Notifier } from '../sessions/notifier.ts'
/** Host input retained by a local Workspace until materialization succeeds. */
export type WorkspaceCreateInput = { name: string } | { path: string }
/** Observable state of a client-local Workspace intent. */
export interface WorkspaceIntentSnapshot {
name: string
phase: 'ready' | 'creating'
error?: string
}
/** A Workspace is either a local intent or a materialized Host view. */
export interface WorkspaceSnapshot {
view: WorkspaceView | undefined
intent: WorkspaceIntentSnapshot | undefined
}
interface WorkspaceIntent {
input: WorkspaceCreateInput
snapshot: WorkspaceIntentSnapshot
}
/**
* Observable Workspace object whose identity survives Host materialization.
* Local instances retain their create input and failure state; materialized
* instances expose the latest Host view.
*/
export class Workspace implements ObservableSnapshot<WorkspaceSnapshot> {
private view: WorkspaceView | undefined
private intent: WorkspaceIntent | undefined
private materialization: Promise<RpcResult<{ workspace: WorkspaceView; created: boolean }>> | null = null
private snapshotCache: WorkspaceSnapshot
private readonly notifier = new Notifier(() => {
this.snapshotCache = this.buildSnapshot()
})
/**
* @param api - shared wire client.
* @param source - local create input or an existing Host Workspace view.
*/
constructor(private readonly api: IApiClient, source: WorkspaceCreateInput | WorkspaceView) {
if ('workspaceId' in source) {
this.view = source
} else {
this.intent = {
input: source,
snapshot: { name: intentName(source), phase: 'ready' },
}
}
this.snapshotCache = this.buildSnapshot()
}
/**
* Materialize this local Workspace through the Host create seam.
* Re-entry shares the in-flight completion; a materialized instance returns undefined.
* @returns the Host result, or undefined when this Workspace is already materialized.
*/
materialize(): Promise<RpcResult<{ workspace: WorkspaceView; created: boolean }>> | undefined {
if (this.materialization !== null) return this.materialization
const intent = this.intent
if (intent === undefined) return undefined
intent.snapshot = { name: intent.snapshot.name, phase: 'creating' }
this.notifier.notifyNow()
const completion = this.completeMaterialization(intent).finally(() => {
if (this.materialization === completion) this.materialization = null
})
this.materialization = completion
return completion
}
/**
* Adopt a Host view without replacing this Workspace object.
* An existing materialized identity accepts updates only for the same Workspace id.
* @param view - latest Host projection.
*/
adopt(view: WorkspaceView): void {
if (this.view !== undefined && this.view.workspaceId !== view.workspaceId) {
throw new Error('cannot adopt a different Workspace id')
}
this.view = view
this.intent = undefined
this.notifier.markDirty()
}
/**
* Subscribe to Workspace snapshot invalidation.
* @param listener - snapshot invalidation callback.
* @returns unsubscribe function.
*/
subscribe(listener: () => void): () => void {
return this.notifier.subscribe(listener)
}
/**
* Read the cached Workspace snapshot after flushing pending notifications.
* @returns the cached Workspace snapshot.
*/
getSnapshot(): WorkspaceSnapshot {
this.notifier.ensureFresh()
return this.snapshotCache
}
private async completeMaterialization(
intent: WorkspaceIntent,
): Promise<RpcResult<{ workspace: WorkspaceView; created: boolean }>> {
let result: RpcResult<{ workspace: WorkspaceView; created: boolean }>
try {
result = (await this.api.workspace.create(intent.input)).result
} catch (error) {
result = transportError(error)
}
if (this.intent !== intent) return result
if (result.ok) {
this.adopt(result.value.workspace)
} else {
intent.snapshot = {
name: intent.snapshot.name,
phase: 'ready',
error: `${result.error.code}: ${result.error.message}`,
}
this.notifier.markDirty()
}
return result
}
private buildSnapshot(): WorkspaceSnapshot {
return { view: this.view, intent: this.intent?.snapshot }
}
}
function intentName(input: WorkspaceCreateInput): string {
if ('name' in input) return input.name
const trimmed = input.path.replace(/[\\/]+$/, '')
return trimmed.split(/[\\/]/).pop() ?? input.path
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,13 +2,15 @@
Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, stats line, per-tool row slot with a bash sample registrant), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares).
The no-session hero renders the frontend Session Intent from the Session list projection, including its frontend Workspace Intent when no real Workspace exists. It declares `conversation.empty.workspace`, where ui-workspace registers the same picker used by the sidebar. WorkspacesService starts and publishes the two intents. The Session object keeps its identity across publication and retains any prompt that still needs connection or delivery; ConversationRoot reads that `pendingPrompt` from `useSession` and edits or retries it through the scoped ConversationService.
The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: <active id>`), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves.
Generic tool rows classify the built-in bash, read, search, write, and edit names into dedicated visual variants. The filesystem variants render the edit icon and `Write · <path>` or `Edit · <path>` summary while retaining the shared row-to-details interaction.
Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders).
Per-session UI state (selection, composer draft, active view) lives in the declared chat store (`stores.ts` `createChatStore`): apply constructs one handle and passes it to the conversation, chat-view, and details registrations, so the session slots share one instance per session (selection written by the chat view, read by details) and the framework owns instance lifecycle and draft persistence. Components are pure — the framework standard kit (`useSession`/`sessionId`/`useSessions`) and the store faces (`useStore`/`actions`) arrive automatically from the registration declaration; the inject factories contribute plain data and callbacks only (send/stop choreography, tab read face, details/paging callbacks, startSession chain).
Per-session UI state (selection, ordinary composer draft, active view) lives in the declared chat store (`stores.ts` `createChatStore`): apply constructs one handle and passes it to the conversation, chat-view, and details registrations, so the session slots share one instance per session (selection written by the chat view, read by details) and the framework owns instance lifecycle and draft persistence. The frontend Session Intent comes from the Session list projection; after publication, any retained prompt comes from that Session's conversation snapshot. Components are pure — the framework standard kit (`useSession`/`sessionId` when session-scoped, plus global `useSessions`/`useWorkspaces`) and the store faces (`useStore`/`actions`) arrive automatically from the registration declaration; inject factories contribute plain data and callbacks for runtime Session actions, send/stop, tabs, details, and paging.
`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath).

View File

@@ -16,7 +16,7 @@ import { DetailsPanel } from './skeleton/DetailsPanel.tsx'
import { EmptyState } from './skeleton/EmptyState.tsx'
/** Services required by the conversation plugin. */
export const inject = ['slots', 'layout', 'sessions']
export const inject = ['slots', 'layout', 'sessions', 'workspaces']
/** Resolve the session-scoped conversation service (scope-addressed send/cancel), failing loud. */
function scopedConversation(sessions: SessionsService, id: SessionId): ConversationService {
@@ -32,6 +32,7 @@ function scopedConversation(sessions: SessionsService, id: SessionId): Conversat
*/
export function apply(ctx: Context): void {
const sessions = ctx.sessions
const workspaces = ctx.workspaces
const layout = ctx.layout
const slots = ctx.slots
@@ -86,7 +87,9 @@ export function apply(ctx: Context): void {
// Stop failure surfaces via snapshot.promptError; nothing to restore.
})
},
open: (target: SessionId) => { sessions.open(target) },
open: (sessionId) => { sessions.open(sessionId) },
updateSessionPrompt: (text) => { scoped.updatePendingPrompt(text) },
retrySessionPrompt: () => { scoped.retryPendingPrompt() },
}
},
}, ConversationRoot)
@@ -103,13 +106,16 @@ export function apply(ctx: Context): void {
label: 'Chat',
children: { 'conversation.chat.toolview': { kind: 'keyed', scope: 'session' } },
store: chatStore,
inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ChatViewInjected => ({
openDetails: (target) => {
actions.select(target)
layout.openDetails()
},
loadOlder: () => { void sessions.manager.get(sessionId).loadOlder() },
}),
inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ChatViewInjected => {
const scoped = scopedConversation(sessions, sessionId)
return {
openDetails: (target) => {
actions.select(target)
layout.openDetails()
},
loadOlder: () => { void scoped.loadOlder() },
}
},
}, ChatView)
// Class-plugin mount (packages/AGENTS.md service form): the service
@@ -133,20 +139,11 @@ export function apply(ctx: Context): void {
slots.register({
name: 'conversation.empty',
children: { 'conversation.empty.workspace': { kind: 'single', scope: 'root' } },
inject: (): EmptyStateInjected => ({
// ctx.get, not ctx.conversation: the service mounts on this plugin's
// own child fiber, so it is not in the inject topology the property
// proxy enforces; get reads the global store and stays loud on a torn
// boot through the optional-chain throw below.
startSession: (opts) => {
const conversation = ctx.get('conversation')
if (conversation === undefined) throw new Error('ui-conversation: conversation service unavailable')
return conversation.startSession(opts)
},
createWorkspaceSession: async (name) => {
const id = await sessions.createWorkspace(name)
sessions.open(id)
},
startSession: (workspaceId, prompt) => { workspaces.startSession(workspaceId, prompt) },
updateSessionPrompt: (text) => { sessions.updateIntent(text) },
sendSession: () => { workspaces.sendSession() },
}),
}, EmptyState)
}

View File

@@ -1,6 +1,7 @@
/** Conversation slot declarations and their composed component props. */
import type { RefObject } from 'react'
import type { PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
import type { PendingInteraction, SessionId, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
import type { PendingInteraction, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type { createChatStore } from '../stores.ts'
import type { CallId, SelectionTarget, ViewTab } from './views.ts'
@@ -30,6 +31,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
* zero owner changes.
*/
'conversation.composer': { kind: 'chain'; scope: 'session'; owner: ComposerChainProps }
/** Shared Workspace picker hole used by the page-local Session Intent hero. */
'conversation.empty.workspace': { kind: 'single'; scope: 'root'; owner: EmptyWorkspaceOwnerProps }
}
}
@@ -94,7 +97,12 @@ export interface ConversationInjected {
send(text: string, mode: 'queue' | 'steer'): void
/** Cancel the in-flight turn (failure surfaces via snapshot.promptError). */
stop(): void
open(id: SessionId): void
/** Select a real Session through the runtime navigation owner. */
open(sessionId: SessionId): void
/** Update the scoped Session's retained prompt. */
updateSessionPrompt(text: string): void
/** Retry the scoped Session's retained prompt. */
retrySessionPrompt(): void
}
/**
@@ -140,16 +148,24 @@ export interface DetailsInjected {
/** Full details-slot component props: selection arrives through the shared store, call material through useSession. */
export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore<ChatStore> & DetailsInjected
/** Injected share of the no-session empty-state slot. */
export interface EmptyStateInjected {
/** The create → navigate → first-send chain, in one service call. */
startSession(opts: { cwd?: string; text: string; mode: 'queue' | 'steer' }): Promise<void>
/**
* Create a workspace folder under the host cwd, mint a session there, and
* open it (Create-new modal success path).
*/
createWorkspaceSession(name: string): Promise<void>
/** Owner share common to the empty hero's Workspace picker. */
export interface EmptyWorkspaceOwnerProps {
open: boolean
anchorRef?: RefObject<HTMLElement>
onPick(workspaceId: WorkspaceId): void
onClose(): void
}
/** Full empty-state component props (root slot: no store; cwd options derive from useSessions in-component). */
export type EmptyStateSlotProps = PropsRuntime<'conversation.empty'> & EmptyStateInjected
/** Runtime-owned actions injected into the empty-state occupant. */
export interface EmptyStateInjected {
/** Replace the current Session intent, optionally preserving a prompt while retargeting. */
startSession(workspaceId?: WorkspaceId, prompt?: string): void
/** Update the current Session intent's controlled prompt. */
updateSessionPrompt(text: string): void
/** Materialize and send the current Session intent. */
sendSession(): void
}
/** Full empty-state component props: runtime projections, picker child slot, and injected actions. */
export type EmptyStateSlotProps =
PropsRuntime<'conversation.empty'> & PropsRenderSlots<'conversation.empty.workspace'> & EmptyStateInjected

View File

@@ -15,7 +15,7 @@ export type { ToolCallBlock } from './contract/tool-call-model.ts'
export type {
ChatStore, ChatViewInjected, ChatViewSlotProps, ComposerChainProps, ConversationInjected,
ConversationSlotProps, ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps,
EmptyStateInjected, EmptyStateSlotProps, ToolRowOwnerProps, ToolRowProps,
EmptyStateInjected, EmptyStateSlotProps, EmptyWorkspaceOwnerProps, ToolRowOwnerProps, ToolRowProps,
} from './contract/slots.ts'
// Export discipline: packages/client/AGENTS.md.

View File

@@ -1,5 +1,5 @@
/**
* Scope-addressed conversation send, cancel, and empty-state session startup.
* Scope-addressed conversation send, cancel, history, and retained-prompt orchestration.
*
* Scope addressing rides the cordis Service tracker: property access through
* `ctx.conversation` rebinds `this.ctx` to the caller's context, so methods
@@ -44,37 +44,27 @@ export class ConversationService extends Service {
if (!result.ok) throw new Error(`conversation.cancel failed: ${result.error.code}: ${result.error.message}`)
}
/**
* Empty-state first-send chain (root-context method; does not read scope):
* create the session, navigate to it, then send through the new scope.
* The create → open ordering is safe: the manager merges the new summary
* synchronously before create() resolves, so the list store is projected by
* the time open() validates against it (manager notification batching is
* microtask-based; SessionsService projects on the same flush that create
* awaited through the RPC round trip).
* @param opts - project directory, prompt text, and send mode.
*/
async startSession(opts: { cwd?: string; text: string; mode: 'queue' | 'steer' }): Promise<void> {
const sessions = this.requireSessions()
const id = await sessions.create(opts.cwd === undefined ? {} : { cwd: opts.cwd })
// The manager notifier flushes per microtask; one await guarantees the
// list-store projection landed before sessions.open validates against it.
await Promise.resolve()
sessions.open(id)
const scoped = sessions.scope(id)
if (scoped === undefined) throw new Error(`conversation.startSession: created session "${id}" resolved no scope`)
// ctx.get, not scoped.conversation: property access walks the fiber
// topology (a scope fiber never injects services), while get reads the
// global store and still binds this service to the scoped ctx.
const scopedConversation = scoped.get('conversation')
if (scopedConversation === undefined) throw new Error('conversation.startSession: conversation service unavailable through the new scope')
await scopedConversation.send(opts.text, opts.mode)
/** Pull one older history page for the scoped Session. */
async loadOlder(): Promise<void> {
await this.scopedSession('loadOlder').loadOlder()
}
/** Update the scoped Session's retained pending prompt. */
updatePendingPrompt(text: string): void {
this.scopedSession('updatePendingPrompt').updatePendingPrompt(text)
}
/** Retry the scoped Session's retained pending prompt. */
retryPendingPrompt(): void {
this.scopedSession('retryPendingPrompt').retryPendingPrompt()
}
/** Resolve the caller scope's Session or throw on root contexts. */
private scopedSession(op: string): Session {
const id = this.scopeId(op)
return this.requireSessions().manager.get(id)
const binding = this.requireSessions().binding(id)
if (binding === undefined) throw new Error(`conversation.${op}: session "${id}" resolved no binding`)
return binding.session
}
/** Read the caller's session scope tag via the sessions service; root contexts fail loud. */

View File

@@ -15,6 +15,7 @@ import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/d
import type { ConversationSlotProps } from '../contract/slots.ts'
import { InputBar } from './InputBar.tsx'
import type { InputBarError } from './InputBar.tsx'
import { EmptyHero, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx'
import css from './ConversationRoot.module.css'
/** Full props = the automatic shares & injected share — composed by reference
@@ -37,8 +38,8 @@ function deriveAncestry(list: SessionListState, id: SessionId): readonly Session
}
export function ConversationRoot({
sessionId, useSession, useSessions, useStore, actions, renderSlot, renderSlotChain,
views, send, stop, open,
sessionId, useSession, useSessions, useWorkspaces, useStore, actions, renderSlot, renderSlotChain,
views, send, stop, open, updateSessionPrompt, retrySessionPrompt,
}: ConversationRootProps) {
useSyncExternalStore(views.subscribe, views.version)
const tabs = views.list()
@@ -48,16 +49,60 @@ export function ConversationRoot({
const active = tabs.find(v => v.id === activeId) ?? tabs[0]
const ancestry = useSessions(s => deriveAncestry(s, sessionId), shallowEqual)
const draft = useStore(s => s.draft)
const running = useSession(s => s.running)
const pendingPrompt = useSession(s => s.pendingPrompt ?? undefined)
const storedDraft = useStore(s => s.draft)
const draft = pendingPrompt?.text ?? storedDraft
const sessionRunning = useSession(s => s.running)
const running = sessionRunning || pendingPrompt?.phase === 'sending'
const removed = useSession(s => s.removed)
const promptError = useSession(s => s.promptError)
const turns = useSession(s => countTurns(s))
const pending = useSession(s => s.pending)
const openState = useSession(s => s.openState)
const composerPhase = useSession(s => s.composerPhase)
const cwd = useSessions(s => s.byId[sessionId]?.cwd)
const workspaceTitle = useWorkspaces(state =>
state.items.find(workspace => workspace.sessionIds.includes(sessionId))?.title)
const error: InputBarError | null = pendingPrompt?.error !== undefined
? {
op: pendingPrompt.retry === 'connect' ? 'session' : 'send',
message: pendingPrompt.retry === 'connect'
? `Workspace attach failed: ${pendingPrompt.error}`
: `Message send failed: ${pendingPrompt.error}`,
}
: promptError === null
? null
: { op: promptError.op, message: `${promptError.error.message} (${promptError.error.code})` }
const status = pendingPrompt?.phase === 'sending'
? pendingPrompt.retry === 'connect' ? 'Attaching session to workspace…' : 'Sending message…'
: undefined
const setDraft = (text: string): void => {
if (pendingPrompt === undefined) actions.setDraft(text)
else updateSessionPrompt(text)
}
const submit = (mode: 'queue' | 'steer'): void => {
if (pendingPrompt === undefined) send(draft, mode)
else retrySessionPrompt()
}
const error: InputBarError | null = promptError === null
? null
: { op: promptError.op, message: `${promptError.error.message}${promptError.error.code}` }
// Blank-session guidance: phase-derived (the runtime snapshot owns the
// predicate — see ComposerPhase). Only `blank` renders the hero; `engaging`
// and `active` fall through to the conversation view, so an in-flight
// first send never bounces back here. Gated on the OPEN window: phase has
// no jurisdiction over loading/error frames (ChatView renders those).
if (openState === 'open' && composerPhase === 'blank') {
return (
<EmptyHero
workspaceRow={<WorkspaceChip label={workspaceTitle ?? workspaceLabel(cwd ?? '')} locked />}
draft={draft}
disabled={removed || pendingPrompt?.phase === 'sending'}
error={error}
{...(status === undefined ? {} : { status })}
onDraftChange={setDraft}
onSend={submit}
/>
)
}
// The default composer doubles as the chain's all-decline fallback: a
// pending wait with no registered takeover must still leave the input usable.
@@ -67,9 +112,10 @@ export function ConversationRoot({
running={running}
disabled={removed}
error={error}
{...(status === undefined ? {} : { status })}
variant="composer"
onDraftChange={actions.setDraft}
onSend={(mode) => { send(draft, mode) }}
onDraftChange={setDraft}
onSend={submit}
onStop={stop}
/>
)
@@ -78,7 +124,7 @@ export function ConversationRoot({
<div className={css.root}>
<header className={css.header}>
<div className={css.crumbRow}>
<nav className={css.crumbs} aria-label="会话层级">
<nav className={css.crumbs} aria-label="Session hierarchy">
{ancestry.map((s, i) => {
const last = i === ancestry.length - 1
return (

View File

@@ -0,0 +1,153 @@
// EmptyHero: the shared NEW SESSION hero (fish headline + glow + workspace
// row + hero InputBar), extracted from EmptyState so the bound guidance
// state (a current session with zero messages, ConversationRoot) renders the
// same layout without the picker wiring. Hosts own the workspace-row content
// and the send wiring; modals ride `children` after the stack.
import { useId } from 'react'
import type { ReactNode, RefObject } from 'react'
import {
FishLogo, IconChevronDownOutline14, IconFolderOpen16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { workspaceTitleOf } from '@deepseek-ai/dsh-client-runtime/client'
import { InputBar } from './InputBar.tsx'
import type { InputBarError } from './InputBar.tsx'
import css from './EmptyState.module.css'
/**
* Basename label for the workspace chip / menu rows (the shared derivation);
* empty → the design's "New Workspace" placeholder copy; separator-only
* paths echo the raw cwd.
* @param cwd - workspace directory path ('' for none).
* @returns chip label.
*/
export function workspaceLabel(cwd: string): string {
if (cwd === '') return 'New Workspace'
const base = workspaceTitleOf(cwd)
return base !== '' ? base : cwd
}
/**
* The workspace chip (folder + label + chevron). Locked form (bound guidance
* state): no chevron, no menu affordance, clicks disabled — the bound
* session's cwd is final.
* @param props.label - chip label (see {@link workspaceLabel}).
* @param props.locked - read-only echo form.
* @param props.menuOpen - menu expansion echo (interactive form only).
* @param props.onClick - menu toggle (interactive form only).
* @returns the chip button element.
*/
export function WorkspaceChip({ buttonRef, label, locked = false, menuOpen = false, onClick }: {
buttonRef?: RefObject<HTMLButtonElement>
label: string
locked?: boolean
menuOpen?: boolean
onClick?: () => void
}) {
return (
<button
ref={buttonRef}
type="button"
className={css.workspace}
aria-label={locked ? 'Current workspace' : 'Choose workspace'}
{...(locked ? {} : { 'aria-haspopup': 'menu' as const, 'aria-expanded': menuOpen })}
disabled={locked}
onClick={onClick}
>
<IconFolderOpen16 className={css.folder} size={16} />
<span className={css.workspaceLabel}>{label}</span>
{!locked && <IconChevronDownOutline14 className={css.chevron} size={12} />}
</button>
)
}
/** Hero-card props: both hosts supply the workspace row and their send wiring. */
export interface EmptyHeroProps {
/** Workspace-row content (Menu-wrapped chip in EmptyState; bare locked chip in guidance). */
workspaceRow: ReactNode
draft: string
disabled: boolean
/** Composer placeholder override (EmptyState's pick-a-workspace hint); defaults to the hero copy. */
placeholder?: string
error: InputBarError | null
status?: string
onDraftChange: (text: string) => void
onSend: (mode: 'queue' | 'steer') => void
onAdd?: () => void
/** Overlay content after the stack (EmptyState's modals). */
children?: ReactNode
}
/**
* Render the hero card.
* @param props - see {@link EmptyHeroProps}.
* @returns the centered hero element tree.
*/
export function EmptyHero({
workspaceRow,
draft,
disabled,
placeholder,
error,
status,
onDraftChange,
onSend,
onAdd,
children,
}: EmptyHeroProps) {
// Stable filter id so multiple hero mounts do not collide in the DOM.
const glowFilterId = `empty-glow-${useId().replace(/:/g, '')}`
return (
<div className={css.root}>
<div className={css.stack}>
<div className={css.headline}>
{/* figma 34:10412: fish 34×25 leading the headline, gap 10. */}
<FishLogo size={34} className={css.fish} />
Let&apos;s start building
</div>
<div className={css.body}>
{/* figma 313:14109: soft ellipse behind workspace + InputBar; width
tracks the card (glow asset 1051 vs design card 776) so blur
scales in userSpace with it. */}
<svg className={css.glow} viewBox="0 0 1051 468" fill="none" aria-hidden="true">
<defs>
<filter
id={glowFilterId}
x="0"
y="0"
width="1051"
height="468"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur stdDeviation="50" result="effect1_foregroundBlur" />
</filter>
</defs>
<g filter={`url(#${glowFilterId})`}>
<ellipse cx="525.5" cy="234" rx="425.5" ry="134" fill="#6187D8" fillOpacity="0.1" />
</g>
</svg>
<div className={css.workspaceRow}>{workspaceRow}</div>
<InputBar
draft={draft}
running={false}
disabled={disabled}
error={error}
{...(status === undefined ? {} : { status })}
variant="hero"
placeholder={placeholder ?? 'Describe what you want to build'}
onDraftChange={onDraftChange}
onSend={onSend}
{...(onAdd === undefined ? {} : { onAdd })}
addLabel="Create workspace"
/* v8 ignore next -- structural noop: hero never passes running=true, so stop is unreachable. */
onStop={() => {}}
/>
</div>
</div>
{children}
</div>
)
}

View File

@@ -100,11 +100,17 @@
cursor: pointer;
}
.workspace:hover,
.workspace:not(:disabled):hover,
.workspace[aria-expanded='true'] {
background: var(--dsw-alias-interactive-bg-hover);
}
/* Locked form (bound guidance state): a static echo — no hover feedback, no
pointer affordance; label keeps full contrast. */
.workspace:disabled {
cursor: default;
}
.folder {
flex: none;
color: var(--dsw-alias-label-primary);
@@ -121,22 +127,21 @@
color: var(--dsw-alias-label-caption);
}
/* Workspace menu width tracks the longest basename in the Figma frame. */
.workspaceMenu :global([role='menu']) {
min-width: 240px;
}
/* Dialog field (figma 451:18655 Input): h44, r22, px 14, caption placeholder. */
/* Dialog field: 44 tall on the modal's 332 content column, r22, hairline
border, pad 14/7, 14/22 wt400 primary text, caption placeholder. Focus
keeps the resting border (design shows no focus ring). */
.modalInput {
box-sizing: border-box;
width: 100%;
height: 44px;
padding: 0 14px;
padding: 7px 14px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 22px;
outline: none;
background: transparent;
font-size: 14px;
line-height: 24px;
font-weight: 400;
line-height: 22px;
color: var(--dsw-alias-label-primary);
}
@@ -144,10 +149,6 @@
color: var(--dsw-alias-label-caption);
}
.modalInput:focus {
border-color: var(--dsw-alias-state-business-primary);
}
.modalInput:disabled {
color: var(--dsw-alias-label-dimmed);
}

View File

@@ -1,305 +1,78 @@
// EmptyState (figma NEW SESSION screen): centered hero — fish + title,
// workspace picker row (MenuDropdown 122:9481 + New Workspace submenu
// 419:16920 + Dialog 451:18655), then the SAME InputBar the resident
// composer uses (empty→content is a position move, never a swap). Project
// options derive in-component from useSessions; Create new runs
// createWorkspaceSession (host mkdir + session.create + open).
import { useId, useMemo, useState } from 'react'
import {
Button,
FishLogo,
IconChevronDownOutline14,
IconFolderClose16,
IconFolderOpen16,
IconPlusOutline16,
Menu,
Modal,
type MenuEntry,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
/** Page-local Session Intent hero. */
import { useRef, useState } from 'react'
import type { EmptyStateSlotProps } from '../contract/slots.ts'
import { InputBar } from './InputBar.tsx'
import type { InputBarError } from './InputBar.tsx'
import css from './EmptyState.module.css'
import { EmptyHero, WorkspaceChip } from './EmptyHero.tsx'
/** Menu id for "New Workspace" (opens submenu; not a cwd). */
const NEW_WORKSPACE = '::new-workspace'
/** Submenu: path modal (figma 451:18655 copy). */
const USE_EXISTING = '::use-existing'
/** Submenu: create-workspace modal → mkdir + default session. */
const CREATE_NEW = '::create-new'
/** Which full-page dialog is open (null = none). */
type ModalKind = 'path' | 'create' | null
/** Full props composed by reference from the contract (runtime share & injected share; no store). */
/** Full props composed from runtime projections, injected actions, and the declared picker slot. */
export type EmptyStateProps = EmptyStateSlotProps
/** Deduped cwd set in list order (pure derivation over the sessions list). */
function deriveCwds(state: SessionListState): readonly string[] {
const seen = new Set<string>()
for (const id of state.ids) {
const cwd = state.byId[id]?.cwd
if (cwd !== undefined && cwd !== '') seen.add(cwd)
}
return [...seen]
}
export function EmptyState({
useSessions,
useWorkspaces,
startSession,
updateSessionPrompt,
sendSession,
renderSlot,
}: EmptyStateProps) {
const intent = useSessions(state => state.intent)
const workspaceSnapshot = useWorkspaces(state => state)
const workspaces = workspaceSnapshot.items
const [pickerOpen, setPickerOpen] = useState(false)
const pickerAnchor = useRef<HTMLButtonElement>(null)
if (intent === undefined) return null
const workspaceId = intent.target.kind === 'workspace' ? intent.target.workspaceId : undefined
const workspace = workspaceId === undefined
? undefined
: workspaces.find(item => item.workspaceId === workspaceId)
const workspaceLabel = intent.target.kind === 'workspace-intent'
? workspaceSnapshot.intent?.name ?? 'Workspace unavailable'
: workspace?.title ?? 'Workspace unavailable'
const workspaceIntent = workspaceSnapshot.intent
const busy = intent.phase === 'connecting' || workspaceIntent?.phase === 'creating'
const status = workspaceIntent?.phase === 'creating'
? 'Creating workspace…'
: intent.phase === 'connecting'
? 'Creating session…'
: workspaceSnapshot.phase === 'pending'
? 'Loading workspaces…'
: undefined
const error: InputBarError | null = workspaceIntent?.error !== undefined
? { op: 'workspace', message: `Workspace creation failed: ${workspaceIntent.error}` }
: intent.error === undefined
? null
: { op: 'session', message: `Session creation failed: ${intent.error.message}` }
/** Basename for the workspace chip / menu row; empty → the design's "New Workspace" label. */
function workspaceLabel(cwd: string): string {
if (cwd === '') return 'New Workspace'
const base = cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop()
return base !== undefined && base !== '' ? base : cwd
}
export function EmptyState({ useSessions, startSession, createWorkspaceSession }: EmptyStateProps) {
const list = useSessions(s => s)
const cwds = useMemo(() => deriveCwds(list), [list])
// Local viewing state: the empty state owns no session, so its draft is
// ephemeral by design (drafts are keyed by session id; there is none yet).
const [draft, setDraft] = useState('')
const [cwd, setCwd] = useState('')
const [menuOpen, setMenuOpen] = useState(false)
const [modalKind, setModalKind] = useState<ModalKind>(null)
const [pathDraft, setPathDraft] = useState('')
const [workspaceName, setWorkspaceName] = useState('New WorkSpace')
const [creating, setCreating] = useState(false)
const [modalError, setModalError] = useState<string | null>(null)
const [sending, setSending] = useState(false)
const [error, setError] = useState<InputBarError | null>(null)
// Stable filter id so multiple EmptyState mounts do not collide in the DOM.
const glowFilterId = `empty-glow-${useId().replace(/:/g, '')}`
const submit = (mode: 'queue' | 'steer'): void => {
const text = draft.trim()
/* v8 ignore next -- defensive: InputBar disables send while empty. */
if (text === '' || sending) return
setSending(true)
setError(null)
const chosen = cwd.trim()
startSession({ text, mode, ...(chosen === '' ? {} : { cwd: chosen }) })
.catch((reason: unknown) => {
// The empty state survives failure with the draft intact (no session
// exists to carry promptError; this is the only local error surface).
setError({ op: 'send', message: reason instanceof Error ? reason.message : String(reason) })
setSending(false)
})
// Success needs no cleanup: the session selection swaps this slot out for the session body.
}
const items: MenuEntry[] = [
...cwds.map(c => ({
id: c,
label: workspaceLabel(c),
icon: <IconFolderClose16 size={16} />,
})),
...(cwds.length > 0 ? [{ type: 'separator' as const, id: 'sep-new' }] : []),
{
id: NEW_WORKSPACE,
label: 'New Workspace',
icon: <IconPlusOutline16 size={16} />,
submenu: [
{ id: USE_EXISTING, label: 'Use a existing folder' },
{ id: CREATE_NEW, label: 'Create new' },
],
},
]
const closeModal = (): void => {
if (creating) return
setModalKind(null)
setModalError(null)
}
const openPathModal = (): void => {
setPathDraft(cwd)
setModalError(null)
setModalKind('path')
}
const openCreateModal = (): void => {
setWorkspaceName('New WorkSpace')
setModalError(null)
setModalKind('create')
}
const confirmPath = (): void => {
const next = pathDraft.trim()
if (next === '') return
setCwd(next)
setModalKind(null)
}
const confirmCreate = (): void => {
if (creating) return
setCreating(true)
setModalError(null)
createWorkspaceSession(workspaceName)
.catch((reason: unknown) => {
setModalError(reason instanceof Error ? reason.message : String(reason))
setCreating(false)
})
// Success swaps this slot out for the new session body — no local cleanup.
}
const modalBusy = creating
const isPath = modalKind === 'path'
const isCreate = modalKind === 'create'
const workspaceRow = (
<>
<WorkspaceChip
buttonRef={pickerAnchor}
label={workspaceLabel}
menuOpen={pickerOpen}
onClick={() => { setPickerOpen(open => !open) }}
/>
{renderSlot('conversation.empty.workspace', {
open: pickerOpen,
anchorRef: pickerAnchor,
onPick: (workspaceId) => {
setPickerOpen(false)
startSession(workspaceId, intent.prompt)
},
onClose: () => { setPickerOpen(false) },
})}
</>
)
return (
<div className={css.root}>
<div className={css.stack}>
<div className={css.headline}>
{/* figma 34:10412: fish 34×25 leading the headline, gap 10. */}
<FishLogo size={34} className={css.fish} />
Let&apos;s start building
</div>
<div className={css.body}>
{/* figma 313:14109: soft ellipse behind workspace + InputBar; width
tracks the card (glow asset 1051 vs design card 776) so blur
scales in userSpace with it. */}
<svg className={css.glow} viewBox="0 0 1051 468" fill="none" aria-hidden="true">
<defs>
<filter
id={glowFilterId}
x="0"
y="0"
width="1051"
height="468"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur stdDeviation="50" result="effect1_foregroundBlur" />
</filter>
</defs>
<g filter={`url(#${glowFilterId})`}>
<ellipse cx="525.5" cy="234" rx="425.5" ry="134" fill="#6187D8" fillOpacity="0.1" />
</g>
</svg>
<div className={css.workspaceRow}>
<Menu
open={menuOpen}
onClose={() => { setMenuOpen(false) }}
{...(cwd !== '' ? { selectedId: cwd } : {})}
items={items}
side="top"
className={css.workspaceMenu!}
onSelect={(id) => {
if (id === USE_EXISTING) {
setMenuOpen(false)
openPathModal()
return
}
if (id === CREATE_NEW) {
setMenuOpen(false)
openCreateModal()
return
}
setCwd(id)
setMenuOpen(false)
}}
anchor={(
<button
type="button"
className={css.workspace}
aria-label="项目目录"
aria-haspopup="menu"
aria-expanded={menuOpen}
onClick={() => { setMenuOpen(!menuOpen) }}
>
<IconFolderOpen16 className={css.folder} size={16} />
<span className={css.workspaceLabel}>{workspaceLabel(cwd)}</span>
<IconChevronDownOutline14 className={css.chevron} size={12} />
</button>
)}
/>
</div>
<InputBar
draft={draft}
running={false}
disabled={sending}
error={error}
variant="hero"
placeholder="Message to run task, plan and build, enter for / commands"
onDraftChange={setDraft}
onSend={submit}
/* v8 ignore next -- structural noop: hero never passes running=true, so stop is unreachable. */
onStop={() => {}}
/>
</div>
</div>
<Modal
open={isPath}
onClose={closeModal}
title="Enter an existing folder path"
footer={(
<>
<Button variant="outline" className={css.modalAction!} onClick={closeModal}>Cancel</Button>
<Button
variant="primary"
className={css.modalAction!}
disabled={pathDraft.trim() === ''}
onClick={confirmPath}
>
Open Folder
</Button>
</>
)}
>
<input
className={css.modalInput}
value={pathDraft}
aria-label="Folder path"
autoFocus
placeholder="ex. User/Documents/Harness/Space"
onChange={(e) => { setPathDraft(e.target.value) }}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
confirmPath()
}
}}
/>
</Modal>
<Modal
open={isCreate}
onClose={closeModal}
title="Create new workspace"
footer={(
<>
<Button variant="outline" className={css.modalAction!} disabled={modalBusy} onClick={closeModal}>
Cancel
</Button>
<Button
variant="primary"
className={css.modalAction!}
disabled={modalBusy || workspaceName.trim() === ''}
onClick={confirmCreate}
>
Create
</Button>
</>
)}
>
<input
className={css.modalInput}
value={workspaceName}
aria-label="Workspace name"
autoFocus
disabled={modalBusy}
onChange={(e) => { setWorkspaceName(e.target.value) }}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
confirmCreate()
}
}}
/>
{modalError !== null && <div className={css.modalError} role="alert">{modalError}</div>}
</Modal>
</div>
<EmptyHero
workspaceRow={workspaceRow}
draft={intent.prompt}
disabled={busy}
{...(status === undefined ? {} : { status })}
error={error}
onDraftChange={updateSessionPrompt}
onSend={() => { sendSession() }}
onAdd={() => { setPickerOpen(true) }}
/>
)
}

View File

@@ -19,18 +19,27 @@
padding: 0;
}
.error {
.error,
.status {
width: 100%;
max-width: 800px;
margin-bottom: 6px;
padding: 4px 8px;
border-radius: 8px;
background: var(--dsw-alias-interactive-bg-hover-danger);
color: var(--dsw-alias-state-error-primary);
font-size: 12px;
line-height: 18px;
}
.status {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-secondary);
}
.error {
background: var(--dsw-alias-interactive-bg-hover-danger);
color: var(--dsw-alias-state-error-primary);
}
.card {
display: flex;
flex-direction: column;

View File

@@ -9,7 +9,7 @@ import css from './InputBar.module.css'
/** Prompt failure surface (mirrors the session snapshot's promptError shape). */
export interface InputBarError {
op: 'send' | 'stop'
op: 'workspace' | 'session' | 'send' | 'stop'
message: string
}
@@ -18,12 +18,17 @@ export interface InputBarProps {
running: boolean
disabled: boolean
error: InputBarError | null
/** Observable async phase for browser fixtures and assistive technology. */
status?: string
/** Hero = empty-state centered card; composer = resident bottom bar. */
variant: 'hero' | 'composer'
placeholder?: string
accessory?: ReactNode
onDraftChange: (text: string) => void
onSend: (mode: 'queue' | 'steer') => void
onStop: () => void
onAdd?: () => void
addLabel?: string
}
interface SelectOption {
@@ -47,7 +52,8 @@ const MODEL_OPTIONS: readonly SelectOption[] = [
]
export function InputBar({
draft, running, disabled, error, variant, placeholder, accessory, onDraftChange, onSend, onStop,
draft, running, disabled, error, status, variant, placeholder, accessory,
onDraftChange, onSend, onStop, onAdd, addLabel = 'Add attachment',
}: InputBarProps) {
const empty = draft.trim() === ''
const inputRef = useRef<HTMLTextAreaElement | null>(null)
@@ -98,7 +104,7 @@ export function InputBar({
inputRef.current?.focus()
}
const primaryLabel = running ? '停止' : '发送'
const primaryLabel = running ? 'Stop generating' : 'Send message'
const onPrimary = (): void => {
if (running) {
onStop()
@@ -129,11 +135,8 @@ export function InputBar({
return (
<div className={clsx(css.root, variant === 'hero' && css.hero)}>
{error !== null && (
<div className={css.error}>
{error.op === 'stop' ? '停止失败' : '发送失败'}{error.message}
</div>
)}
{status !== undefined && <div className={css.status} role="status">{status}</div>}
{error !== null && <div className={css.error} role="alert">{error.message}</div>}
<div className={css.card}>
{accessory !== undefined && <div className={css.accessory}>{accessory}</div>}
{/* Mirror-div auto-grow: the hidden mirror renders draft+'\n' and stretches the wrapper
@@ -145,7 +148,7 @@ export function InputBar({
className={css.input}
value={draft}
disabled={locked}
placeholder={placeholder ?? (disabled ? '会话不可用' : running ? '回复生成中,可停止后再输入' : '输入消息Enter 发送Shift+Enter 换行')}
placeholder={placeholder ?? (disabled ? 'Session unavailable' : running ? 'Generating a response…' : 'Message the agent')}
rows={2}
onChange={(e) => onDraftChange(e.target.value)}
onKeyDown={onKeyDown}
@@ -159,10 +162,11 @@ export function InputBar({
<button
type="button"
className={css.add}
aria-label="添加"
title="添加"
aria-label={addLabel}
title={addLabel}
disabled={locked}
onMouseDown={keepFocus}
onClick={onAdd}
>
<IconPlusOutline16 size={14} />
</button>
@@ -177,7 +181,7 @@ export function InputBar({
type="button"
className={clsx(css.primary, running && css.stopping)}
aria-label={primaryLabel}
title={running ? '停止本轮' : '发送Enter'}
title={primaryLabel}
disabled={!running && (empty || disabled)}
onMouseDown={keepFocus}
onClick={onPrimary}

View File

@@ -3,8 +3,8 @@
// shape: the conversation surface (views triple, send choreography incl.
// optimistic clear + failure restore THROUGH the declared store actions,
// openDetails = select action + layout orchestration, sessions.open
// navigation), the injectless-but-closeDetails details surface, and the
// one-callback empty surface. Complements chat-apply.spec.tsx (registration)
// navigation), and the closeDetails details surface. Complements
// chat-apply.spec.tsx (registration)
// and selection-survival.spec.ts (store axis). History opening is NOT an
// inject concern anymore — the runtime sessions service opens on watch
// (sessions-service.spec.ts owns that behavior).
@@ -14,9 +14,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup } from '@testing-library/react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { SlotsService, scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import type {
SessionId, SessionListState, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { SlotRendererHost } from '@deepseek-ai/dsh-client-web-react'
import { ConversationService, apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {
ChatViewInjected, ConversationInjected, DetailsInjected, EmptyStateInjected,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
@@ -53,10 +55,14 @@ async function bench() {
ids: [ROOT],
byId: { [ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', cwd: '/proj', running: false, updatedAt: 1 } },
current: ROOT,
} as SessionListState)
intent: undefined,
phase: 'ready',
})
const sessionFake = {
open: vi.fn(() => Promise.resolve()),
loadOlder: vi.fn(() => Promise.resolve()),
updatePendingPrompt: vi.fn(),
retryPendingPrompt: vi.fn(),
prompt: vi.fn<() => Promise<{ ok: boolean; value?: object; error?: { code: string; message: string } }>>(
() => Promise.resolve({ ok: true, value: { accepted: true } })),
cancel: vi.fn<() => Promise<{ ok: boolean; value?: object; error?: { code: string; message: string } }>>(
@@ -73,15 +79,24 @@ async function bench() {
}
const sessionsFake = {
list: listStore,
manager: { get: () => sessionFake },
binding: (id: SessionId) => ({ sessionId: id, session: sessionFake, ctx: mint(id) }),
scope: (id: SessionId) => mint(id),
cell: () => undefined,
scopeOf,
create: vi.fn(() => Promise.resolve(ROOT)),
createWorkspace: vi.fn(() => Promise.resolve(ROOT)),
open: vi.fn(),
updateIntent: vi.fn(),
}
ctx.provide('sessions', sessionsFake)
const workspaceStore = createSnapshotStore<WorkspaceListState>({
items: [], intent: undefined, state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})
const workspacesFake = {
list: workspaceStore,
startSession: vi.fn(),
sendSession: vi.fn(),
}
ctx.provide('workspaces', workspacesFake)
const layoutFake = { openDetails: vi.fn(), closeDetails: vi.fn() }
ctx.provide('layout', layoutFake)
ctx.provide('i18n', { bind: () => (key: string) => key })
@@ -124,19 +139,25 @@ async function bench() {
id, instance.actions)
return { instance, injected }
}
return { ctx, slots, hostFace, entryOf, conversationSurface, chatViewSurface, sessionFake, sessionsFake, layoutFake, mint }
const emptySurface = () => {
const entry = entryOf('conversation.empty')
return (entry.inject as unknown as () => EmptyStateInjected)()
}
return {
ctx, slots, hostFace, entryOf, conversationSurface, chatViewSurface, emptySurface,
sessionFake, sessionsFake, workspacesFake, layoutFake, mint,
}
}
describe('conversation slot inject surface', () => {
it('assembles the thin surface side-effect-free, navigates via sessions.open', async () => {
it('assembles the thin surface side-effect-free', async () => {
const b = await bench()
const { injected } = b.conversationSurface(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'])
injected.open(ROOT)
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
const chatView = b.chatViewSurface(ROOT)
chatView.injected.loadOlder()
expect(b.sessionFake.loadOlder).toHaveBeenCalledTimes(1)
@@ -202,6 +223,17 @@ describe('conversation slot inject surface', () => {
expect(conv.instance).toBe(instance)
})
it('routes navigation through SessionsService and the retained prompt through the scoped Session', async () => {
const b = await bench()
const { injected } = b.conversationSurface(ROOT)
injected.open(ROOT)
injected.updateSessionPrompt('revised')
injected.retrySessionPrompt()
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
expect(b.sessionFake.updatePendingPrompt).toHaveBeenCalledWith('revised')
expect(b.sessionFake.retryPendingPrompt).toHaveBeenCalledOnce()
})
it('views read face projects the ring ledger (subscribe/version through ctx.slots)', async () => {
const b = await bench()
const { injected } = b.conversationSurface(ROOT)
@@ -225,7 +257,7 @@ describe('conversation slot inject surface', () => {
})
})
describe('details and empty inject surfaces', () => {
describe('details inject surface', () => {
it('details injects the one layout callback; selection rides the shared store instead', async () => {
const b = await bench()
const entry = b.entryOf('details')
@@ -239,29 +271,18 @@ describe('details and empty inject surfaces', () => {
expect(details).toBe(conv)
})
it('empty injects startSession and createWorkspaceSession (no store, cwds derive in-component)', async () => {
it('empty state injects the runtime intent actions and remains storeless', async () => {
const b = await bench()
const entry = b.entryOf('conversation.empty')
expect(entry.store).toBeUndefined()
const injected = (entry.inject as unknown as () => EmptyStateInjected)()
expect(Object.keys(injected).sort()).toEqual(['createWorkspaceSession', 'startSession'])
await injected.startSession({ text: 'go', mode: 'queue' })
expect(b.sessionsFake.create).toHaveBeenCalled()
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'go' }], 'queue')
b.sessionsFake.open.mockClear()
await injected.createWorkspaceSession('Fresh')
expect(b.sessionsFake.createWorkspace).toHaveBeenCalledWith('Fresh')
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
})
it('startSession fails loud on a torn boot (conversation service fiber gone)', async () => {
const b = await bench()
const injected = (b.entryOf('conversation.empty').inject as unknown as () => EmptyStateInjected)()
// Tear the service's own fiber (registry keyed by the class): the slot
// entries survive, so the gesture-time read hits the loud branch.
b.ctx.registry.delete(ConversationService)
await vi.waitFor(() => { expect(b.ctx.get('conversation')).toBeUndefined() })
expect(() => injected.startSession({ text: 'go', mode: 'queue' })).toThrow(/conversation service unavailable/)
const injected = b.emptySurface()
injected.startSession(undefined, 'fresh')
injected.startSession('workspace-1' as never, 'retargeted')
injected.updateSessionPrompt('typed')
injected.sendSession()
expect(b.workspacesFake.startSession).toHaveBeenNthCalledWith(1, undefined, 'fresh')
expect(b.workspacesFake.startSession).toHaveBeenNthCalledWith(2, 'workspace-1', 'retargeted')
expect(b.sessionsFake.updateIntent).toHaveBeenCalledWith('typed')
expect(b.workspacesFake.sendSession).toHaveBeenCalledOnce()
})
})

View File

@@ -30,16 +30,23 @@ async function bench() {
[CHILD]: { id: CHILD, title: 'C', displayTitle: 'C', parentId: ROOT, running: false, updatedAt: 2 },
},
current: undefined,
intent: undefined,
phase: 'ready',
} as SessionListState)
const sessionsFake = {
list: listStore,
manager: { get: vi.fn() },
binding: vi.fn(),
scope: () => undefined,
cell: () => undefined,
create: vi.fn(),
open: vi.fn(),
updateIntent: vi.fn(),
}
ctx.provide('sessions', sessionsFake)
ctx.provide('workspaces', {
startSession: vi.fn(),
sendSession: vi.fn(),
})
ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
ctx.provide('i18n', { bind: () => (key: string) => key })
@@ -84,7 +91,7 @@ describe('apply wiring', () => {
expect(b.slots.spec('conversation.chat.toolview')).toEqual({ kind: 'keyed', scope: 'session' })
})
it('occupies the three slots + the ring; session entries share one store handle, empty declares none', async () => {
it('occupies the three slots + the ring; session entries share one store handle, empty injects runtime actions', async () => {
const b = await bench()
await b.fiber.await()
const conversation = renderEntryOf(b.slots, 'conversation')

View File

@@ -27,8 +27,8 @@ const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessage
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
pending: [], running: false, removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
pending: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, intent: null, pendingPrompt: null, lastAgentError: null,
}
}
@@ -127,6 +127,8 @@ describe('bash sample row', () => {
[CHILD]: { id: CHILD, title: 'c', displayTitle: 'c', parentId: ROOT, running: false, updatedAt: 0 },
},
current: undefined,
intent: undefined,
phase: 'ready',
} as SessionListState)
}

View File

@@ -15,7 +15,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, render } from '@testing-library/react'
import { createSnapshotStore, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type {
ConversationSnapshot, SessionId, SessionListState, ToolResultNode,
ConversationSnapshot, SessionId, SessionListState, ToolResultNode, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
@@ -40,8 +40,8 @@ const toolResult = (seq: number, callId: string, name: string, args = '{"command
function snapshotWith(nodes: ToolResultNode[]): ConversationSnapshot {
return {
sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls: [],
pending: [], running: false, removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
pending: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, intent: null, pendingPrompt: null, lastAgentError: null,
} as ConversationSnapshot
}
@@ -65,9 +65,11 @@ async function bench(nodes: ToolResultNode[]) {
const session = createSnapshotStore<ConversationSnapshot>(snapshotWith(nodes))
const list = createSnapshotStore<SessionListState>({
ids: [SID],
byId: { [SID]: { id: SID, title: 'S', running: false, updatedAt: 1 } },
byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, updatedAt: 1 } },
current: SID,
} as SessionListState)
intent: undefined,
phase: 'ready',
})
// Identity-stable cell: the renderer caches hooks per source and inject
// results per cell, both by object identity.
const cell = { sessionId: SID, session }
@@ -75,11 +77,20 @@ async function bench(nodes: ToolResultNode[]) {
const layout = { openDetails: vi.fn(), closeDetails: vi.fn() }
ctx.provide('sessions', {
list,
manager: { get: () => ({ loadOlder: vi.fn() }) },
binding: (id: SessionId) => ({ sessionId: id, session: { loadOlder: vi.fn() } }),
scope: () => ({ get: () => scoped }),
cell: (id: string) => (id === SID ? cell : undefined),
create: vi.fn(),
open: vi.fn(),
updateIntent: vi.fn(),
})
ctx.provide('workspaces', {
list: createSnapshotStore<WorkspaceListState>({
items: [], intent: undefined, state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
}),
startSession: vi.fn(),
sendSession: vi.fn(),
})
ctx.provide('layout', layout)
ctx.provide('i18n', { bind: () => (key: string) => key })
@@ -182,12 +193,23 @@ describe('registrant load-order seam', () => {
await slotsFiber.await()
const slots = ctx.get('slots') as SlotsService
ctx.provide('sessions', {
list: createSnapshotStore<SessionListState>({ ids: [], byId: {}, current: undefined } as SessionListState),
manager: { get: vi.fn() },
list: createSnapshotStore<SessionListState>({
ids: [], byId: {}, current: undefined, intent: undefined, phase: 'ready',
}),
binding: () => undefined,
scope: () => undefined,
cell: () => undefined,
create: vi.fn(),
open: vi.fn(),
updateIntent: vi.fn(),
})
ctx.provide('workspaces', {
list: createSnapshotStore<WorkspaceListState>({
items: [], intent: undefined, state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
}),
startSession: vi.fn(),
sendSession: vi.fn(),
})
ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
ctx.provide('i18n', { bind: () => (key: string) => key })

View File

@@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Profiler } from 'react'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import type {
AssistantMessageNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, UserMessageNode,
AssistantMessageNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, UserMessageNode, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore, PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
@@ -29,8 +29,8 @@ const SID = 's1' as SessionId
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
pending: [], running: false, removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
pending: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, intent: null, pendingPrompt: null, lastAgentError: null,
}
}
@@ -72,7 +72,15 @@ const runningCall = (callId: string, name = 'bash'): RunningToolCall => ({
/** Empty sessions-list hook for the global standard-kit seat. */
function emptySessions() {
const store = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined } as SessionListState)
{ ids: [], byId: {}, current: undefined, intent: undefined, phase: 'ready' })
return bindSnapshotSelector(store)
}
function emptyWorkspaces() {
const store = createSnapshotStore<WorkspaceListState>({
items: [], intent: undefined, state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})
return bindSnapshotSelector(store)
}
@@ -95,6 +103,7 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
sessionId: SID,
useSession: bindSnapshotSelector(source),
useSessions: emptySessions(),
useWorkspaces: emptyWorkspaces(),
useStore: bindSnapshotSelector(chat),
actions: chat.actions,
renderSlot,

View File

@@ -87,8 +87,10 @@ describe('tails', () => {
const sid = 'root-1' as SessionId
const list = createSnapshotStore<SessionListState>({
ids: [sid],
byId: { [sid]: { id: sid, title: 'r', running: false, updatedAt: 0 } },
byId: { [sid]: { id: sid, title: 'r', displayTitle: 'r', running: false, updatedAt: 0 } },
current: undefined,
intent: undefined,
phase: 'ready',
} as SessionListState)
const props = {
callId: 'c1', toolName: 'bash', block: errorResult, openDetails: vi.fn(),

View File

@@ -5,7 +5,7 @@ import { cleanup, render } from '@testing-library/react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSnapshot, SessionId, SessionListState, WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client'
import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { createChatStore } from '../src/client/stores.ts'
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
@@ -19,8 +19,8 @@ const SID = 's1' as SessionId
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
pending: [], running: false, removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
pending: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, intent: null, pendingPrompt: null, lastAgentError: null,
} as ConversationSnapshot
}
@@ -65,12 +65,17 @@ describe('render branch tails', () => {
const chat = createChatStore().create()
chat.actions.select({ turnSeq: 1, callId: 'ghost' } satisfies SelectionTarget)
const emptyList = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined } as SessionListState)
{ ids: [], byId: {}, current: undefined, intent: undefined, phase: 'ready' })
const emptyWorkspaces = createSnapshotStore<WorkspaceListState>({
items: [], intent: undefined, state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})
const view = render(
<DetailsPanel
sessionId={SID}
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} }) as unknown as UseSession<ConversationSnapshot>}
useSessions={bindSnapshotSelector(emptyList)}
useWorkspaces={bindSnapshotSelector(emptyWorkspaces)}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
closeDetails={vi.fn()}

View File

@@ -1,20 +0,0 @@
/**
* Test-local selector binding through the production uSES implementation.
* Runtime remains React-free, so specs bind observable sources here.
*/
import { bindSnapshotSelector } from '../../web-react/src/bind.ts'
/** Minimal observable source (engine stores and scripted fakes both satisfy it). */
export interface HookSource<T> {
getSnapshot(): T
subscribe(fn: () => void): () => void
}
/**
* Bind a selector hook over a snapshot source.
* @param src - the source.
* @returns a SnapshotSelectorHook-shaped hook.
*/
export function hookOf<T>(src: HookSource<T>) {
return bindSnapshotSelector<T>(src)
}

View File

@@ -19,9 +19,9 @@ function setup(over?: Partial<InputBarProps>) {
}
const view = render(<InputBar {...props} />)
const textarea = view.container.querySelector('textarea')!
// aria-label (not role name): title also contains 发送/停止 and would double-match.
// aria-label (not role name): title carries the same label and would double-match.
const button = view.container.querySelector<HTMLButtonElement>(
`button[aria-label="${over?.running === true ? '停止' : '发送'}"]`,
`button[aria-label="${over?.running === true ? 'Stop generating' : 'Send message'}"]`,
)!
return { view, textarea, button, props }
}
@@ -80,7 +80,7 @@ describe('running lock and primary button', () => {
it('running locks the textarea and turns the primary into stop', () => {
const { textarea, button, props } = setup({ running: true })
expect(textarea.disabled).toBe(true)
expect(button.getAttribute('aria-label')).toBe('停止')
expect(button.getAttribute('aria-label')).toBe('Stop generating')
fireEvent.click(button)
expect(props.onStop).toHaveBeenCalledTimes(1)
expect(props.onSend).not.toHaveBeenCalled()
@@ -100,30 +100,30 @@ describe('running lock and primary button', () => {
const textarea = view.container.querySelector('textarea')!
expect(document.activeElement).toBe(textarea)
textarea.blur()
fireEvent.mouseDown(view.container.querySelector('button[aria-label="发送"]')!)
fireEvent.mouseDown(view.container.querySelector('button[aria-label="Send message"]')!)
expect(document.activeElement).toBe(textarea)
})
it('disabled state shows the unavailable placeholder; typing forwards drafts', () => {
const { textarea } = setup({ disabled: true, draft: '' })
expect(textarea.placeholder).toBe('会话不可用')
expect(textarea.placeholder).toBe('Session unavailable')
const live = setup({ draft: '' })
expect(live.textarea.placeholder).toContain('Enter 发送')
expect(live.textarea.placeholder).toBe('Message the agent')
fireEvent.change(live.textarea, { target: { value: 'typed' } })
expect(live.props.onDraftChange).toHaveBeenCalledWith('typed')
const runningPh = setup({ running: true, draft: '' })
expect(runningPh.textarea.placeholder).toContain('停止')
const custom = setup({ placeholder: '自定义' })
expect(custom.textarea.placeholder).toBe('自定义')
expect(runningPh.textarea.placeholder).toBe('Generating a response…')
const custom = setup({ placeholder: 'Custom placeholder' })
expect(custom.textarea.placeholder).toBe('Custom placeholder')
})
})
describe('error strip and variants', () => {
it('renders send and stop failure copy', () => {
const send = setup({ error: { op: 'send', message: 'boom' } })
expect(send.view.getByText(/发送失败boom/)).toBeTruthy()
expect(send.view.container.querySelector('[role="alert"]')?.textContent).toBe('boom')
const stop = setup({ error: { op: 'stop', message: 'halt' } })
expect(stop.view.getByText(/停止失败halt/)).toBeTruthy()
expect(stop.view.container.querySelector('[role="alert"]')?.textContent).toBe('halt')
})
it('hero variant adds the hero class and accessory row renders', () => {
@@ -136,7 +136,7 @@ describe('error strip and variants', () => {
describe('placeholder chrome', () => {
it('renders attach / Plan / Read-only / model controls', () => {
const { view } = setup()
expect(view.getByLabelText('添加')).toBeTruthy()
expect(view.getByLabelText('Add attachment')).toBeTruthy()
expect((view.getByLabelText('Plan mode') as HTMLSelectElement).value).toBe('plan')
expect((view.getByLabelText('Access mode') as HTMLSelectElement).value).toBe('readonly')
expect((view.getByLabelText('Model') as HTMLSelectElement).value).toBe('v4-pro-high')
@@ -162,7 +162,7 @@ describe('placeholder chrome', () => {
it('running locks the chrome selects and attach control', () => {
const { view } = setup({ running: true })
expect((view.getByLabelText('添加') as HTMLButtonElement).disabled).toBe(true)
expect((view.getByLabelText('Add attachment') as HTMLButtonElement).disabled).toBe(true)
expect((view.getByLabelText('Plan mode') as HTMLSelectElement).disabled).toBe(true)
expect((view.getByLabelText('Model') as HTMLSelectElement).disabled).toBe(true)
})

View File

@@ -5,27 +5,31 @@
*/
import { Context } from 'cordis'
import { beforeEach, describe, expect, it } from 'vitest'
import { SessionsService, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, SessionListState, WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client'
import { createChatStore } from '../src/client/stores.ts'
// Use the runtime's programmable fake to drive the real session service.
import { FakeApiClient, ok } from '../../runtime/tests/fake-api.ts'
const sid = (s: string): SessionId => s as SessionId
interface Bench {
ctx: Context
api: FakeApiClient
sessions: SessionsService
slots: SlotsService
chat: ReturnType<typeof createChatStore>
}
function bench(): Bench {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api)
ctx.provide('sessions', {
list: createSnapshotStore<SessionListState>({
ids: [], byId: {}, current: undefined, intent: undefined, phase: 'ready',
}),
cell: () => undefined,
})
ctx.provide('workspaces', {
list: createSnapshotStore<WorkspaceListState>({
items: [], intent: undefined, state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
}),
})
// Service self-registers as ctx 'slots' (cordis Service constructor).
const slots = new SlotsService(ctx)
const chat = createChatStore()
@@ -42,22 +46,7 @@ function bench(): Bench {
}, (_p: { renderSlot?: unknown }) => null)
slots.register({ name: 'conversation', store: chat }, () => null)
slots.register({ name: 'details', store: chat }, () => null)
return { ctx, api, sessions, slots, chat }
}
async function flush(): Promise<void> {
// Manager notifier + store batching are microtask-based.
await Promise.resolve()
await Promise.resolve()
}
function feed(b: Bench, rows: { id: string; cwd?: string; running?: boolean }[]): void {
b.api.onList = () => Promise.resolve(ok({
items: rows.map(r => ({
sessionId: sid(r.id), updatedAt: 1, running: r.running ?? false,
...(r.cwd !== undefined ? { cwd: r.cwd } : {}),
})),
}) as never)
return { slots, chat }
}
/** Resolve the store instance the renderer would hand a slot's component for a session. */
@@ -87,11 +76,8 @@ beforeEach(() => {
})
describe('selection survives on the store seat', () => {
it('one session, two slots: conversation writes, details reads the SAME instance', async () => {
it('one session, two slots: conversation writes, details reads the SAME instance', () => {
const b = bench()
feed(b, [{ id: 's1' }])
await b.sessions.manager.refreshList()
await flush()
const conv = storeFor(b, 'conversation', sid('s1'))
const details = storeFor(b, 'details', sid('s1'))
@@ -101,11 +87,8 @@ describe('selection survives on the store seat', () => {
expect(details).toBe(conv)
})
it('sessions are isolated: s2 selection never bleeds into s1', async () => {
it('sessions are isolated: s2 selection never bleeds into s1', () => {
const b = bench()
feed(b, [{ id: 's1' }, { id: 's2' }])
await b.sessions.manager.refreshList()
await flush()
const one = storeFor(b, 'conversation', sid('s1'))
const two = storeFor(b, 'conversation', sid('s2'))
@@ -116,25 +99,17 @@ describe('selection survives on the store seat', () => {
expect(two.store.getSnapshot().selection).toEqual({ turnSeq: 9, callId: 'z' })
})
it('a display-title-upgrading list refresh keeps instance identity and the selection value', async () => {
it('a list-projection update keeps instance identity and the selection value', () => {
const b = bench()
// First-send shape: client-side create inserts the row without cwd (title = bare id).
b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s1') }))
const id = await b.sessions.create({})
await flush()
expect(b.sessions.list.getSnapshot().byId[id]).toMatchObject({ displayTitle: 's1' })
expect(b.sessions.list.getSnapshot().byId[id]?.title).toBeUndefined()
const id = sid('s1')
const projection = createSnapshotStore({ displayTitle: 's1' })
const store = storeFor(b, 'conversation', id)
store.actions.select({ turnSeq: 3, callId: 'c1' })
store.actions.setDraft('half-typed')
// The late list refresh lands (host knows the cwd → better fallback label).
feed(b, [{ id: 's1', cwd: '/w/proj-a' }])
await b.sessions.manager.refreshList()
await flush()
expect(b.sessions.list.getSnapshot().byId[id]).toMatchObject({ displayTitle: 'proj-a' })
expect(b.sessions.list.getSnapshot().byId[id]?.title).toBeUndefined()
projection.set({ displayTitle: 'proj-a' })
expect(projection.getSnapshot().displayTitle).toBe('proj-a')
const after = storeFor(b, 'conversation', id)
expect(after).toBe(store)
@@ -142,32 +117,20 @@ describe('selection survives on the store seat', () => {
expect(after.store.getSnapshot().draft).toBe('half-typed')
})
it('session death buries the instance and its persisted draft', async () => {
it('session death buries the instance and its persisted draft', () => {
const b = bench()
feed(b, [{ id: 's1' }, { id: 's2' }])
await b.sessions.manager.refreshList()
await flush()
// Mint the scope (store prune rides the scope-teardown axis: no scope,
// no teardown — the real page always resolves the binding to render).
b.sessions.binding(sid('s1'))
const doomed = storeFor(b, 'conversation', sid('s1'))
doomed.actions.setDraft('to be buried')
doomed.actions.select({ turnSeq: 1 })
expect(localStorage.getItem('dsh.conversation.chat.s1')).not.toBeNull()
// Watch elsewhere so s1's scope teardown is not deferred, then remove it.
b.sessions.binding(sid('s2'))
feed(b, [{ id: 's2' }])
await b.sessions.manager.refreshList()
await flush()
// SessionsService calls this public slot lifecycle seam when the scope dies.
b.slots.pruneStoreScope(sid('s1'))
// Persisted residue is gone with the session...
expect(localStorage.getItem('dsh.conversation.chat.s1')).toBeNull()
// ...and a re-created same-id session starts from a FRESH instance.
feed(b, [{ id: 's1' }, { id: 's2' }])
await b.sessions.manager.refreshList()
await flush()
const reborn = storeFor(b, 'conversation', sid('s1'))
expect(reborn).not.toBe(doomed)
expect(reborn.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null })

View File

@@ -1,154 +1,70 @@
// @vitest-environment jsdom
/**
* ConversationService orchestration half after the store-seat slimming:
* scope-addressed send/cancel (result folding, root throw), the startSession
* chain (create → sessions.open → scoped send), and the service-unavailable
* loud failures. Selection/draft state left this service for the declared
* chat store (chat-store.spec.ts / selection-survival.spec.ts); the view
* registry left for the 'conversation.view' slot (views-type-chain.spec.tsx).
*/
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import { scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
const sid = (s: string): SessionId => s as SessionId
/** Recover the module-private scope tag through the public seam (same probe as apply-inject.spec). */
const sid = (id: string) => id as SessionId
const SCOPE_TAG: symbol = (() => {
const recorded: (string | symbol)[] = []
const spy = new Proxy(new Context(), {
get(target, prop, receiver): unknown {
recorded.push(prop)
return Reflect.get(target, prop, receiver)
const reads: (string | symbol)[] = []
const proxy = new Proxy(new Context(), {
get(target, property, receiver): unknown {
reads.push(property)
return Reflect.get(target, property, receiver)
},
})
void scopeOf(spy)
const symbol = recorded.find((p): p is symbol => typeof p === 'symbol')
if (symbol === undefined) throw new Error('scopeOf probe recorded no symbol read')
return symbol
void scopeOf(proxy)
return reads.find((value): value is symbol => typeof value === 'symbol')!
})()
interface SessionDouble {
prompt: ReturnType<typeof vi.fn>
cancel: ReturnType<typeof vi.fn>
}
async function bench(opts?: { sessions?: boolean }) {
async function bench(withSessions = true) {
const ctx = new Context()
const sessionDoubles = new Map<SessionId, SessionDouble>()
const scopes = new Map<SessionId, Context>()
const mint = (id: SessionId): Context => {
let scoped = scopes.get(id)
if (scoped === undefined) {
const fiber = ctx.plugin(() => {})
scoped = fiber.ctx.extend({ [SCOPE_TAG]: id })
scopes.set(id, scoped)
}
return scoped
}
const createMock = vi.fn(() => Promise.resolve(sid('new-1')))
const openMock = vi.fn()
const sessionsFake = {
manager: {
get: (id: SessionId) => {
let s = sessionDoubles.get(id)
if (s === undefined) {
s = {
prompt: vi.fn(() => Promise.resolve({ ok: true, value: { accepted: true } })),
cancel: vi.fn(() => Promise.resolve({ ok: true, value: { accepted: true } })),
}
sessionDoubles.set(id, s)
}
return s
},
},
create: createMock,
open: openMock,
scope: (id: SessionId) => (id === sid('new-1') ? mint(id) : scopes.get(id)),
const prompt = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } }))
const cancel = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } }))
const loadOlder = vi.fn(() => Promise.resolve())
const updatePendingPrompt = vi.fn()
const retryPendingPrompt = vi.fn()
const sessions = {
binding: (sessionId: SessionId) => ({
sessionId, session: { prompt, cancel, loadOlder, updatePendingPrompt, retryPendingPrompt },
}),
scopeOf,
} as unknown as SessionsService
if (opts?.sessions !== false) ctx.provide('sessions', sessionsFake)
// Class-plugin mount — the same form apply.ts uses in production.
const fiber = ctx.plugin(ConversationService)
await fiber.await()
const svc = ctx.get('conversation') as ConversationService
const scopedSvc = (id: SessionId) => mint(id).get('conversation') as ConversationService
return { ctx, svc, scopedSvc, mint, sessionDoubles, sessionsFake, createMock, openMock }
if (withSessions) ctx.provide('sessions', sessions)
await ctx.plugin(ConversationService).await()
const root = ctx.get('conversation') as ConversationService
const scoped = ctx.plugin(() => {}).ctx.extend({ [SCOPE_TAG]: sid('s1') }).get('conversation') as ConversationService
return { root, scoped, prompt, cancel, loadOlder, updatePendingPrompt, retryPendingPrompt }
}
describe('send / cancel', () => {
it('sends one text block through the scoped session with the mode', async () => {
describe('ConversationService', () => {
it('routes ordinary and retained-prompt operations through the public Session binding', async () => {
const b = await bench()
await b.scopedSvc(sid('s1')).send('hello', 'steer')
expect(b.sessionDoubles.get(sid('s1'))!.prompt).toHaveBeenCalledWith(
[{ type: 'text', text: 'hello' }], 'steer')
await b.scoped.send('hello', 'steer')
await b.scoped.cancel()
await b.scoped.loadOlder()
b.scoped.updatePendingPrompt('revised')
b.scoped.retryPendingPrompt()
expect(b.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'steer')
expect(b.cancel).toHaveBeenCalledOnce()
expect(b.loadOlder).toHaveBeenCalledOnce()
expect(b.updatePendingPrompt).toHaveBeenCalledWith('revised')
expect(b.retryPendingPrompt).toHaveBeenCalledOnce()
})
it('folds business failure into a thrown error carrying code and message', async () => {
it('folds Session business failures into callback rejections', async () => {
const b = await bench()
const s = b.scopedSvc(sid('s1'))
// Materialize the double first (manager.get is the lazy mint point).
b.sessionsFake.manager.get(sid('s1'))
const double = b.sessionDoubles.get(sid('s1'))!
double.prompt.mockResolvedValue({ ok: false, error: { code: 'agent-busy', message: 'busy' } })
await expect(s.send('x', 'queue')).rejects.toThrow(/send failed: agent-busy: busy/)
b.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'busy', details: {} } } as never)
await expect(b.scoped.send('x', 'queue')).rejects.toThrow('conversation.send failed: agent-busy: busy')
b.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'nope', details: {} } } as never)
await expect(b.scoped.cancel()).rejects.toThrow('conversation.cancel failed: internal: nope')
})
it('cancel resolves on ok and throws the folded business error', async () => {
it('fails loudly from the root scope or without SessionsService', async () => {
const b = await bench()
const s = b.scopedSvc(sid('s1'))
await s.cancel()
const double = b.sessionDoubles.get(sid('s1'))!
expect(double.cancel).toHaveBeenCalledTimes(1)
double.cancel.mockResolvedValue({ ok: false, error: { code: 'internal', message: 'nope' } })
await expect(s.cancel()).rejects.toThrow(/cancel failed: internal: nope/)
})
it('root-context send and cancel throw the addressing hint', async () => {
const b = await bench()
await expect(b.svc.send('x', 'queue')).rejects.toThrow(/requires a session scope/)
await expect(b.svc.cancel()).rejects.toThrow(/requires a session scope/)
})
})
describe('startSession chain', () => {
it('creates, navigates through sessions.open, then sends through the new scope', async () => {
const b = await bench()
await b.svc.startSession({ cwd: '/proj', text: 'first', mode: 'queue' })
expect(b.createMock).toHaveBeenCalledWith({ cwd: '/proj' })
expect(b.openMock).toHaveBeenCalledWith(sid('new-1'))
expect(b.sessionDoubles.get(sid('new-1'))!.prompt).toHaveBeenCalledWith(
[{ type: 'text', text: 'first' }], 'queue')
})
it('omits cwd from create when not chosen', async () => {
const b = await bench()
await b.svc.startSession({ text: 't', mode: 'steer' })
expect(b.createMock).toHaveBeenCalledWith({})
})
it('fails loud when the created session resolves no scope', async () => {
const b = await bench()
;(b.sessionsFake.create as ReturnType<typeof vi.fn>).mockResolvedValue(sid('ghost'))
await expect(b.svc.startSession({ text: 't', mode: 'queue' })).rejects.toThrow(/resolved no scope/)
})
})
describe('service-unavailable loud failures', () => {
it('throws when sessions is missing', async () => {
const b = await bench({ sessions: false })
await expect(b.svc.startSession({ text: 't', mode: 'queue' })).rejects.toThrow(/sessions service unavailable/)
})
it('startSession fails loud when the new scope cannot resolve conversation', async () => {
const b = await bench()
// A scope minted outside the service tree: scoped.get('conversation') finds nothing.
const foreign = new Context()
const foreignScope = foreign.plugin(() => {}).ctx.extend({})
;(b.sessionsFake.scope as unknown) = () => foreignScope
await expect(b.svc.startSession({ text: 't', mode: 'queue' }))
.rejects.toThrow(/conversation service unavailable through the new scope/)
await expect(b.root.send('x', 'queue')).rejects.toThrow(/requires a session scope/)
const missing = await bench(false)
await expect(missing.root.send('x', 'queue')).rejects.toThrow(/sessions service unavailable/)
})
})

View File

@@ -1,318 +0,0 @@
// @vitest-environment jsdom
// Skeleton branch tails for the coverage gate (complements skeleton.spec.tsx
// acceptance flows), four-share props form: breadcrumb ancestry derivation +
// error strip in ConversationRoot, DetailsPanel non-JSON args / non-text
// result blocks / error-only results over the shared store, EmptyState
// failure surface and path-modal confirm with in-component cwd derivation.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import { hookOf } from './hook.ts'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import type { SelectionTarget, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
// Export discipline: packages/client/AGENTS.md.
import { createChatStore } from '../src/client/stores.ts'
import { ConversationRoot, type ConversationRootProps } from '../src/client/skeleton/ConversationRoot.tsx'
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
import { EmptyState } from '../src/client/skeleton/EmptyState.tsx'
afterEach(cleanup)
const SID = 's1' as SessionId
/** Fallback-only chain stub (no takeover registered in these benches). */
const fallbackRenderSlotChain: ConversationRootProps['renderSlotChain'] =
(_key, _owner, opts) => opts?.fallback ?? null
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
pending: [], running: false, removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
} as ConversationSnapshot
}
function sessionSource(over?: Partial<ConversationSnapshot>) {
const snap = { ...snapshotBase(), ...over }
return {
getSnapshot: () => snap,
subscribe: () => () => {},
}
}
/** Sessions-list stub over a snapshot store (the standard useSessions hook shape). */
function listHook(rows: { id: string; title: string; cwd?: string; parentId?: string }[]) {
const store = createSnapshotStore<SessionListState>({
ids: rows.map(r => r.id as SessionId),
byId: Object.fromEntries(rows.map(r => [r.id, {
id: r.id as SessionId, title: `durable ${r.title}`, displayTitle: r.title, running: false, updatedAt: 1,
...(r.cwd !== undefined ? { cwd: r.cwd } : {}),
...(r.parentId !== undefined ? { parentId: r.parentId as SessionId } : {}),
}])),
current: undefined,
} as SessionListState)
return hookOf(store)
}
describe('ConversationRoot branches', () => {
const chatTab: ViewTab = { id: 'chat', label: 'Chat' }
/** renderSlot stub in the outlet's baked shape (ring key + only filter marker). */
const stubRenderSlot = (() => <div data-testid="view-body" />) as unknown as ConversationRootProps['renderSlot']
/** SessionProvider seat stub (render-prop pass-through; ConversationRoot never invokes it). */
const SessionProviderStub: ConversationRootProps['SessionProvider'] = ({ children }) => <>{children(SID)}</>
function rootProps(over?: {
rows?: { id: string; title: string; parentId?: string }[]
snapshot?: Partial<ConversationSnapshot>
}) {
const open = vi.fn()
const chat = createChatStore().create()
const view = render(
<ConversationRoot
sessionId={SID}
useSession={hookOf(sessionSource(over?.snapshot)) as unknown as UseSession<ConversationSnapshot>}
useSessions={listHook(over?.rows ?? [])}
useStore={hookOf(chat)}
actions={chat.actions}
renderSlot={stubRenderSlot}
renderSlotChain={fallbackRenderSlotChain}
SessionProvider={SessionProviderStub}
views={{ list: () => [chatTab], subscribe: () => () => {}, version: () => 1 }}
send={vi.fn()}
stop={vi.fn()}
open={open}
/>,
)
return { view, open, chat }
}
it('derives the ancestry breadcrumb from the sessions list and navigates on ancestor click', () => {
const { view, open } = rootProps({
rows: [{ id: 'root-1', title: 'Workspace' }, { id: 's1', title: 'Current', parentId: 'root-1' }],
})
expect(view.getByText('Workspace')).toBeTruthy()
expect(view.getByText('/')).toBeTruthy()
fireEvent.click(view.getByText('Workspace'))
expect(open).toHaveBeenCalledWith('root-1' as SessionId)
// The last crumb is the current session: disabled, no navigation.
fireEvent.click(view.getByText('Current'))
expect(open).toHaveBeenCalledTimes(1)
})
it('a broken parent link stops the ancestry walk at the known chain', () => {
const { view } = rootProps({
rows: [{ id: 's1', title: 'Orphan', parentId: 'vanished' }],
})
// The walk keeps s1 itself and stops where the parent is unknown.
expect(view.getByText('Orphan')).toBeTruthy()
})
it('falls back to the raw session id without ancestry and counts user turns', () => {
const { view } = rootProps({
snapshot: { nodes: [{ kind: 'user', seq: 1 } as never, { kind: 'assistant', seq: 2 } as never] },
})
expect(view.getByText(SID)).toBeTruthy()
expect(view.getByText(/1 turns/)).toBeTruthy()
})
it('surfaces promptError through the composer error strip', () => {
const { view } = rootProps({
snapshot: { promptError: { op: 'stop', error: { message: 'halt', code: 'internal' } } as never },
})
expect(view.getByText(/停止失败haltinternal/)).toBeTruthy()
})
it('an unknown stored view id falls back to the first registered view', () => {
const { chat } = rootProps({})
cleanup()
chat.actions.setView('gone')
const view = render(
<ConversationRoot
sessionId={SID}
useSession={hookOf(sessionSource()) as unknown as UseSession<ConversationSnapshot>}
useSessions={listHook([])}
useStore={hookOf(chat)}
actions={chat.actions}
renderSlot={stubRenderSlot}
renderSlotChain={fallbackRenderSlotChain}
SessionProvider={SessionProviderStub}
views={{ list: () => [chatTab], subscribe: () => () => {}, version: () => 1 }}
send={vi.fn()}
stop={vi.fn()}
open={vi.fn()}
/>,
)
expect(view.getByTestId('view-body')).toBeTruthy()
})
})
describe('DetailsPanel branches', () => {
function panel(selection: SelectionTarget | null, snapshot?: Partial<ConversationSnapshot>) {
const chat = createChatStore().create()
if (selection !== null) chat.actions.select(selection)
return render(
<DetailsPanel
sessionId={SID}
useSession={hookOf(sessionSource(snapshot)) as unknown as UseSession<ConversationSnapshot>}
useSessions={listHook([])}
useStore={hookOf(chat)}
actions={chat.actions}
closeDetails={vi.fn()}
/>,
)
}
it('shows non-JSON args verbatim (streaming fragment path)', () => {
const view = panel({ turnSeq: 1, callId: 'c1', toolName: 'bash' }, {
runningCalls: [{ callId: 'c1', name: 'bash', argsRaw: '{"cmd": tru', turn: 1, step: 1, time: 1_000, callView: null }],
})
expect(view.getByText('{"cmd": tru')).toBeTruthy()
})
it('a selection without callId renders the empty hint (selector null arm)', () => {
const view = panel({ turnSeq: 2 })
expect(view.getByText(/点击消息流中的工具行查看详情/)).toBeTruthy()
})
it('snapshot updates re-run the material selector through the shallow equality arm', () => {
let snap = { ...snapshotBase(), runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{"a":1}', turn: 1, step: 1, time: 1_000, callView: null }] } as ConversationSnapshot
const subs = new Set<() => void>()
const source = {
getSnapshot: () => snap,
subscribe: (fn: () => void) => {
subs.add(fn)
return () => subs.delete(fn)
},
}
const chat = createChatStore().create()
chat.actions.select({ turnSeq: 1, callId: 'c9' })
const view = render(
<DetailsPanel
sessionId={SID}
useSession={hookOf(source) as unknown as UseSession<ConversationSnapshot>}
useSessions={listHook([])}
useStore={hookOf(chat)}
actions={chat.actions}
closeDetails={vi.fn()}
/>,
)
expect(view.getByText(/"a": 1/)).toBeTruthy()
// Top-level swap with identical material members: the eq arm short-circuits.
snap = { ...snap }
for (const fn of [...subs]) fn()
expect(view.getByText(/"a": 1/)).toBeTruthy()
})
it('windowless call material: no name/args fallback to callId, mixed node walk skips non-matches', () => {
// A tool-result whose call head fell outside the window (call === null),
// preceded by non-matching nodes so the walk exercises both filter arms.
const view = panel({ turnSeq: 1, callId: 'c8' }, {
nodes: [
{ kind: 'user', seq: 1, content: [], source: null } as never,
{ kind: 'tool-result', seq: 2, callId: 'other', call: { name: 'x', argsRaw: '{}' }, content: [], isError: false, callView: null, resultView: null } as never,
{ kind: 'tool-result', seq: 3, callId: 'c8', call: null, content: [], isError: false, callView: null, resultView: null } as never,
],
})
expect(view.getByText('c8')).toBeTruthy()
})
it('stringifies non-text result blocks and renders error-only results', () => {
const withBlocks = panel({ turnSeq: 1, callId: 'c2' }, {
nodes: [{
kind: 'tool-result', seq: 3, callId: 'c2', call: { name: 'read', argsRaw: '{}' },
content: [{ type: 'image', data: 'x' } as never],
isError: false, callView: null, resultView: null,
} as never],
})
expect(withBlocks.getByText(/"type": "image"/)).toBeTruthy()
const errorOnly = panel({ turnSeq: 1, callId: 'c3' }, {
nodes: [{
kind: 'tool-result', seq: 4, callId: 'c3', call: { name: 'bash', argsRaw: '{}' },
content: [], isError: true, error: { name: 'ToolError', code: 'timeout' },
callView: null, resultView: null,
} as never],
})
expect(errorOnly.getByText(/ToolError: timeout/)).toBeTruthy()
})
})
describe('EmptyState branches', () => {
const noopCreate = () => Promise.resolve()
it('keeps the draft and surfaces a local error strip when startSession rejects', async () => {
const startSession = vi.fn(() => Promise.reject(new Error('create down')))
const view = render(
<EmptyState
useSessions={listHook([{ id: 'a', title: 'a', cwd: '/proj' }])}
startSession={startSession}
createWorkspaceSession={noopCreate}
/>,
)
const textarea = view.container.querySelector('textarea')!
fireEvent.change(textarea, { target: { value: 'first task' } })
fireEvent.keyDown(textarea, { key: 'Enter' })
await waitFor(() => expect(view.getByText(/发送失败create down/)).toBeTruthy())
expect((textarea as HTMLTextAreaElement).value).toBe('first task')
})
it('non-Error rejection reasons stringify into the error strip', async () => {
const startSession = vi.fn(() => Promise.reject('plain-string'))
const view = render(
<EmptyState
useSessions={listHook([])}
startSession={startSession}
createWorkspaceSession={noopCreate}
/>,
)
const textarea = view.container.querySelector('textarea')!
fireEvent.change(textarea, { target: { value: 'go' } })
fireEvent.keyDown(textarea, { key: 'Enter' })
await waitFor(() => expect(view.getByText(/发送失败plain-string/)).toBeTruthy())
})
it('cwd derivation skips blank cwds; menu picks, path modal confirms, submits the typed path', async () => {
const startSession = vi.fn(() => Promise.resolve())
const view = render(
<EmptyState
useSessions={listHook([
{ id: 'a', title: 'a', cwd: '/proj' },
{ id: 'b', title: 'b' }, // no cwd: filtered from the option set
])}
startSession={startSession}
createWorkspaceSession={noopCreate}
/>,
)
fireEvent.click(view.getByRole('button', { name: '项目目录' }))
expect([...view.getByRole('menu').querySelectorAll('[role="menuitem"]')].map(el => el.textContent))
.toEqual(['proj', 'New Workspace'])
fireEvent.click(view.getByRole('menuitem', { name: 'proj' }))
expect(view.getByRole('button', { name: '项目目录' }).textContent).toContain('proj')
fireEvent.click(view.getByRole('button', { name: '项目目录' }))
fireEvent.mouseEnter(view.getByRole('menuitem', { name: 'New Workspace' }).parentElement as HTMLElement)
fireEvent.click(view.getByRole('menuitem', { name: 'Use a existing folder' }))
const custom = view.getByLabelText('Folder path')
fireEvent.change(custom, { target: { value: '/typed/dir' } })
fireEvent.click(view.getByRole('button', { name: 'Open Folder' }))
const textarea = view.container.querySelector('textarea')!
fireEvent.change(textarea, { target: { value: 'task' } })
fireEvent.keyDown(textarea, { key: 'Enter' })
await waitFor(() => expect(startSession).toHaveBeenCalledWith({ text: 'task', mode: 'queue', cwd: '/typed/dir' }))
})
it('Create modal surfaces inject failures inline', async () => {
const createWorkspaceSession = vi.fn(() => Promise.reject(new Error('mkdir blocked')))
const view = render(
<EmptyState
useSessions={listHook([])}
startSession={() => Promise.resolve()}
createWorkspaceSession={createWorkspaceSession}
/>,
)
fireEvent.click(view.getByRole('button', { name: '项目目录' }))
fireEvent.mouseEnter(view.getByRole('menuitem', { name: 'New Workspace' }).parentElement as HTMLElement)
fireEvent.click(view.getByRole('menuitem', { name: 'Create new' }))
fireEvent.click(view.getByRole('button', { name: 'Create' }))
await waitFor(() => expect(view.getByRole('alert').textContent).toContain('mkdir blocked'))
})
})

View File

@@ -1,344 +1,203 @@
// @vitest-environment jsdom
/**
* Skeleton acceptance over the four-share props form: empty-state transition
* (same InputBar component in hero position, startSession submit, in-component
* cwd derivation), ConversationRoot view switching through the store's view
* field, DetailsPanel selection through the shared store. Components stay
* pure — the framework shares are stubbed (useSession/useSessions), the store
* share is a REAL createChatStore().create() instance (same construction path
* as production), injected callbacks are spies.
*/
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import type { ConversationSnapshot, PendingInteraction, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import type { SelectionTarget, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {
ConversationSnapshot, SessionId, SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { EmptyStateProps } from '../src/client/skeleton/EmptyState.tsx'
import type { ConversationRootProps } from '../src/client/skeleton/ConversationRoot.tsx'
// Export discipline: packages/client/AGENTS.md.
import { createChatStore } from '../src/client/stores.ts'
import { ConversationRoot } from '../src/client/skeleton/ConversationRoot.tsx'
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
import { EmptyState } from '../src/client/skeleton/EmptyState.tsx'
const sid = (s: string): SessionId => s as SessionId
afterEach(cleanup)
beforeEach(() => {
// jsdom normally provides localStorage; some host Node builds surface it as undefined.
globalThis.localStorage?.clear()
beforeEach(() => { localStorage.clear() })
const sid = (id: string) => id as SessionId
const wid = (id: string) => id as WorkspaceId
const SID = sid('s1')
function workspace(id = 'w1'): WorkspaceView {
return {
workspaceId: wid(id), path: `/projects/${id}`, title: id, sessionIds: [],
createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
}
}
type SessionIntent = NonNullable<SessionListState['intent']>
type WorkspaceIntent = NonNullable<WorkspaceListState['intent']>
const workspaceState = (
items: readonly WorkspaceView[], workspaceIntent?: WorkspaceIntent,
): WorkspaceListState => ({
items, intent: workspaceIntent, state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})
const hook = <T,>(snapshot: T) => <S,>(selector: (state: T) => S): S => selector(snapshot)
/** Minimal conversation snapshot slice the skeleton reads. */
interface FakeSnapshot {
nodes: readonly {
kind: string
seq?: number
time?: number
callId?: string
call?: { name: string; argsRaw: string } | null
callTime?: number | null
content?: readonly { type: string; text?: string }[]
isError?: boolean
callView?: null
resultView?: null
}[]
runningCalls: readonly {
callId: string
name: string
argsRaw: string
turn?: number
step?: number
time?: number
callView?: null
}[]
running: boolean
removed: boolean
promptError: { op: 'send' | 'stop'; error: { message: string; code: string } } | null
pending: readonly PendingInteraction[]
function mountEmpty(
intent: SessionIntent,
items: readonly WorkspaceView[] = [],
localWorkspace?: WorkspaceIntent,
) {
const updateSessionPrompt = vi.fn()
const sendSession = vi.fn()
const startSession = vi.fn()
let pickerOwner: unknown
const sessionState: SessionListState = {
ids: [], byId: {}, current: intent.sessionId, intent, phase: 'ready',
}
const workspaceIntent = intent.target.kind === 'workspace-intent'
? localWorkspace ?? { name: 'workspace', phase: 'ready' as const }
: undefined
const view = render(
<EmptyState
useSessions={hook(sessionState)}
useWorkspaces={hook(workspaceState(items, workspaceIntent))}
updateSessionPrompt={updateSessionPrompt}
sendSession={sendSession}
startSession={startSession}
renderSlot={((_key: string, owner: unknown) => { pickerOwner = owner; return null }) as EmptyStateProps['renderSlot']}
/>,
)
return { view, updateSessionPrompt, sendSession, startSession, pickerOwner: () => pickerOwner }
}
function fakeSession(init: Partial<FakeSnapshot> = {}) {
const store = createSnapshotStore<FakeSnapshot>({
nodes: [], runningCalls: [], running: false, removed: false, promptError: null, pending: [], ...init,
})
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot> }
}
/** Sessions-list stub: the standard useSessions hook over a snapshot store. */
function fakeSessions(rows: { id: string; title: string; cwd?: string; parentId?: string }[]) {
const store = createSnapshotStore<SessionListState>({
ids: rows.map(r => sid(r.id)),
byId: Object.fromEntries(rows.map(r => [r.id, {
id: sid(r.id), title: `durable ${r.title}`, displayTitle: r.title, running: false, updatedAt: 1,
...(r.cwd !== undefined ? { cwd: r.cwd } : {}),
...(r.parentId !== undefined ? { parentId: sid(r.parentId) } : {}),
}])),
current: undefined,
} as SessionListState)
return { store, useSessions: bindSnapshotSelector(store) }
}
/** SessionProvider seat stub (render-prop pass-through; ConversationRoot never invokes it). */
const SessionProviderStub: ConversationRootProps['SessionProvider'] = ({ children }) => <>{children(sid('s1'))}</>
describe('EmptyState', () => {
const noopCreate = () => Promise.resolve()
it('derives cwd options from the sessions list, submits startSession, failure surfaces locally', async () => {
const { useSessions } = fakeSessions([
{ id: 'a', title: 'a', cwd: '/w/app' },
{ id: 'b', title: 'b', cwd: '/w/lib' },
{ id: 'c', title: 'c', cwd: '/w/app' }, // duplicate cwd dedupes
])
let reject!: (e: Error) => void
const startSession = vi.fn(() => new Promise<void>((_res, rej) => { reject = rej }))
render(
<EmptyState
useSessions={useSessions}
startSession={startSession}
createWorkspaceSession={noopCreate}
/>,
)
const trigger = screen.getByRole('button', { name: '项目目录' })
fireEvent.click(trigger)
const menu = screen.getByRole('menu')
expect([...menu.querySelectorAll('[role="menuitem"]')].map(el => el.textContent))
.toEqual(['app', 'lib', 'New Workspace'])
fireEvent.click(screen.getByRole('menuitem', { name: 'app' }))
const box = screen.getByPlaceholderText('Message to run task, plan and build, enter for / commands')
fireEvent.change(box, { target: { value: '造一个轮子' } })
fireEvent.keyDown(box, { key: 'Enter' })
expect(startSession).toHaveBeenCalledWith({ text: '造一个轮子', mode: 'queue', cwd: '/w/app' })
reject(new Error('后端拒收'))
expect(await screen.findByText(/后端拒收/)).toBeTruthy()
// Draft survives the failure for retry.
expect((box as HTMLTextAreaElement).value).toBe('造一个轮子')
it('reads the Workspace and Session intents from runtime projections', () => {
const b = mountEmpty({
sessionId: sid('local-1'), target: { kind: 'workspace-intent' },
prompt: 'draft', phase: 'ready',
})
expect(b.view.getByRole('button', { name: 'Choose workspace' }).textContent).toContain('workspace')
fireEvent.change(b.view.getByPlaceholderText('Describe what you want to build'), { target: { value: 'build it' } })
expect(b.updateSessionPrompt).toHaveBeenCalledWith('build it')
fireEvent.click(b.view.getByRole('button', { name: 'Send message' }))
expect(b.sendSession).toHaveBeenCalledOnce()
})
it('Use a existing folder opens the path modal and Open Folder sets the chip', () => {
const { useSessions } = fakeSessions([])
render(
<EmptyState
useSessions={useSessions}
startSession={() => Promise.resolve()}
createWorkspaceSession={noopCreate}
/>,
)
fireEvent.click(screen.getByRole('button', { name: '项目目录' }))
const newWs = screen.getByRole('menuitem', { name: 'New Workspace' })
fireEvent.mouseEnter(newWs.parentElement as HTMLElement)
fireEvent.click(screen.getByRole('menuitem', { name: 'Use a existing folder' }))
expect(screen.getByRole('dialog', { name: 'Enter an existing folder path' })).toBeTruthy()
const path = screen.getByLabelText('Folder path') as HTMLInputElement
fireEvent.change(path, { target: { value: '/tmp/fresh' } })
fireEvent.click(screen.getByRole('button', { name: 'Open Folder' }))
expect(screen.queryByRole('dialog')).toBeNull()
expect(screen.getByRole('button', { name: '项目目录' }).textContent).toContain('fresh')
it('uses useWorkspaces for the selected label and preserves the prompt when retargeting', () => {
const first = workspace('first')
const b = mountEmpty({
sessionId: sid('local-2'), target: { kind: 'workspace', workspaceId: first.workspaceId },
prompt: 'keep me', phase: 'ready',
}, [first])
expect(b.view.getByRole('button', { name: 'Choose workspace' }).textContent).toContain('first')
fireEvent.click(b.view.getByRole('button', { name: 'Choose workspace' }))
const owner = b.pickerOwner() as { onPick(id: WorkspaceId): void }
owner.onPick(wid('second'))
expect(b.startSession).toHaveBeenCalledWith(wid('second'), 'keep me')
})
it('Create new opens the modal and createWorkspaceSession succeeds', async () => {
const { useSessions } = fakeSessions([])
const createWorkspaceSession = vi.fn(() => Promise.resolve())
render(
<EmptyState
useSessions={useSessions}
startSession={() => Promise.resolve()}
createWorkspaceSession={createWorkspaceSession}
/>,
)
fireEvent.click(screen.getByRole('button', { name: '项目目录' }))
fireEvent.mouseEnter(screen.getByRole('menuitem', { name: 'New Workspace' }).parentElement as HTMLElement)
fireEvent.click(screen.getByRole('menuitem', { name: 'Create new' }))
expect(screen.getByRole('dialog', { name: 'Create new workspace' })).toBeTruthy()
const name = screen.getByLabelText('Workspace name') as HTMLInputElement
expect(name.value).toBe('New WorkSpace')
fireEvent.change(name, { target: { value: 'My Proj' } })
fireEvent.keyDown(name, { key: 'Enter' })
await vi.waitFor(() => expect(createWorkspaceSession).toHaveBeenCalledWith('My Proj'))
})
it('Create modal Cancel dismisses without calling createWorkspaceSession', () => {
const { useSessions } = fakeSessions([])
const createWorkspaceSession = vi.fn(() => Promise.resolve())
render(
<EmptyState
useSessions={useSessions}
startSession={() => Promise.resolve()}
createWorkspaceSession={createWorkspaceSession}
/>,
)
fireEvent.click(screen.getByRole('button', { name: '项目目录' }))
fireEvent.mouseEnter(screen.getByRole('menuitem', { name: 'New Workspace' }).parentElement as HTMLElement)
fireEvent.click(screen.getByRole('menuitem', { name: 'Create new' }))
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(screen.queryByRole('dialog')).toBeNull()
expect(createWorkspaceSession).not.toHaveBeenCalled()
it('exposes materialization phase and failure text', () => {
const creating = mountEmpty({
sessionId: sid('local-3'), target: { kind: 'workspace-intent' },
prompt: 'x', phase: 'ready',
}, [], { name: 'workspace', phase: 'creating' })
expect(creating.view.getByRole('status').textContent).toBe('Creating workspace…')
cleanup()
const workspaceFailed = mountEmpty({
sessionId: sid('local-3'), target: { kind: 'workspace-intent' },
prompt: 'x', phase: 'ready',
}, [], { name: 'workspace', phase: 'ready', error: 'offline' })
expect(workspaceFailed.view.getByRole('alert').textContent).toBe('Workspace creation failed: offline')
cleanup()
const failed = mountEmpty({
sessionId: sid('local-3'), target: { kind: 'workspace', workspaceId: wid('w1') },
prompt: 'x', phase: 'ready', error: { step: 'session', message: 'offline' },
}, [workspace()])
expect(failed.view.getByRole('alert').textContent).toBe('Session creation failed: offline')
})
})
describe('ConversationRoot', () => {
function bench(
tabs: ViewTab[], activeView?: string, init: Partial<FakeSnapshot> = {},
renderSlotChain?: ConversationRootProps['renderSlotChain'],
) {
const { useSession } = fakeSession({ nodes: [{ kind: 'user' }, { kind: 'user' }], ...init })
const { useSessions } = fakeSessions([
{ id: 'root', title: 'proj' },
{ id: 's1', title: 'child', parentId: 'root' },
])
const chat = createChatStore().create()
if (activeView !== undefined) chat.actions.setView(activeView)
const send = vi.fn()
const stop = vi.fn()
const open = vi.fn()
// The renderSlot share as the outlet would bake it: renders a marker for
// the ring key carrying the active-id filter (a Mock cannot satisfy the
// generic method type directly — cast once at the prop seam).
const renderSlot = vi.fn((key: string, _owner: object, opts?: { only?: string }) => (
<div data-testid={`view-${opts?.only ?? '(all)'}`} data-slot={key} />
))
const ui = render(
<ConversationRoot
sessionId={sid('s1')}
useSession={useSession}
useSessions={useSessions}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
renderSlot={renderSlot as unknown as ConversationRootProps['renderSlot']}
renderSlotChain={renderSlotChain ?? ((_key, _owner, opts) => opts?.fallback ?? null)}
SessionProvider={SessionProviderStub}
views={{
list: () => tabs,
subscribe: () => () => {},
version: () => 1,
}}
send={send}
stop={stop}
open={open}
/>)
return { ui, chat, send, stop, open, renderSlot }
function conversationSnapshot(
composerPhase: ConversationSnapshot['composerPhase'],
pendingPrompt: ConversationSnapshot['pendingPrompt'] = null,
): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
pending: [], running: false, composerPhase, removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, intent: null, pendingPrompt, lastAgentError: null,
}
}
const tab = (id: string, label: string): ViewTab => ({ id, label })
it('renders breadcrumb chain (useSessions-derived), meta turns, and the default chat view', () => {
const { open } = bench([tab('chat', 'Chat'), tab('trajectory', 'Trajectory')])
expect(screen.getByText('proj')).toBeTruthy()
expect(screen.getByText('child')).toBeTruthy()
expect(screen.getByText(/2 turns/)).toBeTruthy()
expect(screen.getByTestId('view-chat')).toBeTruthy()
// Ancestor crumb navigates; current crumb is disabled.
fireEvent.click(screen.getByRole('button', { name: 'proj' }))
expect(open).toHaveBeenCalledWith('root')
expect((screen.getByRole('button', { name: 'child' }) as HTMLButtonElement).disabled).toBe(true)
function mountConversation(pendingPrompt: ConversationSnapshot['pendingPrompt'] = null) {
const root = sid('root')
const sessions = createSnapshotStore<SessionListState>({
ids: [root, SID],
byId: {
[root]: { id: root, displayTitle: 'Root', running: false, updatedAt: 1 },
[SID]: { id: SID, displayTitle: 'Child', parentId: root, cwd: '/projects/one', running: false, updatedAt: 2 },
},
current: SID,
intent: undefined,
phase: 'ready',
})
it('switches views through the store view field and falls back on unknown ids', () => {
const { chat } = bench([tab('chat', 'Chat'), tab('trajectory', 'Trajectory')])
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
expect(chat.store.getSnapshot().view).toBe('trajectory')
expect(screen.getByTestId('view-trajectory')).toBeTruthy()
cleanup()
// A stale persisted id (its view plugin unloaded) falls to the first view.
bench([tab('chat', 'Chat'), tab('trajectory', 'Trajectory')], 'ghost-view')
expect(screen.getByTestId('view-chat')).toBeTruthy()
})
it('renders the active view through the declared ring slot with the only filter', () => {
const { renderSlot } = bench([tab('chat', 'Chat')])
// No owner share: views take everything from the standard kit (contract).
expect(renderSlot).toHaveBeenCalledWith('conversation.view', {}, { only: 'chat' })
expect(screen.getByTestId('view-chat').getAttribute('data-slot')).toBe('conversation.view')
})
it('hides the tab strip with a single view; composer writes the store draft and sends it', () => {
const { chat, send } = bench([tab('chat', 'Chat')])
expect(screen.queryByRole('tablist')).toBeNull()
const box = screen.getByPlaceholderText(/输入消息/)
fireEvent.change(box, { target: { value: 'hi' } })
// Typing goes through actions.setDraft into the shared store.
expect(chat.store.getSnapshot().draft).toBe('hi')
fireEvent.keyDown(box, { key: 'Enter' })
expect(send).toHaveBeenCalledWith('hi', 'queue')
})
it('dispatches the pending list to the composer chain; all-decline falls back to InputBar', () => {
const wait = new PendingWait('question', RpcId('rq'), sid('s1'),
{ questions: [{ id: 'mode', question: 'Choose?', options: [{ label: 'Fast' }] }] } as PendingWait<'question'>['payload'], vi.fn())
// A matching entry takes the composer over.
const renderSlotChain = vi.fn(() => <div>question takeover</div>) as unknown as ConversationRootProps['renderSlotChain']
bench([tab('chat', 'Chat')], undefined, { pending: [wait] }, renderSlotChain)
expect(screen.getByText('question takeover')).toBeTruthy()
expect(screen.queryByPlaceholderText(/输入消息/)).toBeNull()
// The owner dispatches the raw pending list (chain currency); routing
// lives in entry selectors, not here.
expect(renderSlotChain).toHaveBeenCalledWith(
'conversation.composer',
expect.objectContaining({
interactions: expect.arrayContaining([expect.objectContaining({ key: 'q:rq' })]),
}),
expect.objectContaining({ fallback: expect.anything() }),
)
cleanup()
// Zero registered entries (default all-decline stub): the fallback IS the
// default InputBar — behavior equals the pre-chain composer.
bench([tab('chat', 'Chat')], undefined, { pending: [wait] })
expect(screen.getByPlaceholderText(/输入消息/)).toBeTruthy()
})
})
describe('DetailsPanel', () => {
function benchDetails(snapshot: Partial<FakeSnapshot>, selection: SelectionTarget | null) {
const { useSession } = fakeSession(snapshot)
const { useSessions } = fakeSessions([])
const chat = createChatStore().create()
if (selection !== null) chat.actions.select(selection)
const closeDetails = vi.fn()
render(
<DetailsPanel
sessionId={sid('s1')}
useSession={useSession}
useSessions={useSessions}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
closeDetails={closeDetails}
/>)
return { closeDetails, chat }
const workspaces = createSnapshotStore<WorkspaceListState>(workspaceState([{ ...workspace('one'), sessionIds: [SID] }]))
const session = createSnapshotStore<ConversationSnapshot>(conversationSnapshot(
pendingPrompt === null ? 'active' : 'blank', pendingPrompt,
))
const chat = createChatStore().create()
chat.actions.setDraft('ordinary draft')
const send = vi.fn()
const stop = vi.fn()
const open = vi.fn()
const updateSessionPrompt = vi.fn()
const retrySessionPrompt = vi.fn()
const renderSlot = ((_key: string, _owner: object, opts?: { only?: string }) => (
<div data-testid={`view-${opts?.only ?? 'all'}`} />
)) as ConversationRootProps['renderSlot']
const renderSlotChain = ((_key, _owner, opts) => opts?.fallback ?? null) as ConversationRootProps['renderSlotChain']
const SessionProvider: ConversationRootProps['SessionProvider'] = ({ children }) => <>{children(SID)}</>
const props: ConversationRootProps = {
sessionId: SID,
useSession: bindSnapshotSelector(session),
useSessions: bindSnapshotSelector(sessions),
useWorkspaces: bindSnapshotSelector(workspaces),
useStore: bindSnapshotSelector(chat),
actions: chat.actions,
renderSlot,
renderSlotChain,
SessionProvider,
views: { list: () => [{ id: 'chat', label: 'Chat' }], subscribe: () => () => {}, version: () => 1 },
send,
stop,
open,
updateSessionPrompt,
retrySessionPrompt,
}
const view = render(<ConversationRoot {...props} />)
return { view, chat, send, open, updateSessionPrompt, retrySessionPrompt }
}
it('renders the selected call args and result off the shared store; close fires the injected callback', () => {
const { closeDetails } = benchDetails({
nodes: [{
kind: 'tool-result', seq: 1, time: 1_000, callId: 'c1',
call: { name: 'bash', argsRaw: '{"cmd":"ls"}' },
callTime: 500,
content: [{ type: 'text', text: 'file-a\nfile-b' }],
isError: false, callView: null, resultView: null,
}],
}, { turnSeq: 1, callId: 'c1' })
expect(screen.getByText('bash')).toBeTruthy()
expect(screen.getByText(/"cmd": "ls"/)).toBeTruthy()
expect(screen.getByText(/file-a/)).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: '关闭详情' }))
expect(closeDetails).toHaveBeenCalledTimes(1)
describe('ConversationRoot draft ownership', () => {
it('keeps ordinary per-Session composer text in the chat store and selects through runtime actions', () => {
const b = mountConversation()
const box = b.view.getByRole('textbox')
expect((box as HTMLTextAreaElement).value).toBe('ordinary draft')
fireEvent.change(box, { target: { value: 'ordinary revised' } })
expect(b.chat.store.getSnapshot().draft).toBe('ordinary revised')
fireEvent.keyDown(box, { key: 'Enter' })
expect(b.send).toHaveBeenCalledWith('ordinary revised', 'queue')
fireEvent.click(b.view.getByRole('button', { name: 'Root' }))
expect(b.open).toHaveBeenCalledWith(sid('root'))
})
it('shows the empty hint without a selection and the running state for open calls', () => {
benchDetails({ runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{}', turn: 1, step: 1, time: 1_000, callView: null }] }, null)
expect(screen.getByText(/点击消息流中的工具行/)).toBeTruthy()
cleanup()
benchDetails({ runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{}', turn: 1, step: 1, time: 1_000, callView: null }] }, { turnSeq: 1, callId: 'c9' })
expect(screen.getByText('运行中…')).toBeTruthy()
})
it('reports an out-of-window call distinctly', () => {
benchDetails({}, { turnSeq: 1, callId: 'ghost' })
expect(screen.getByText(/不在当前窗口内/)).toBeTruthy()
it('reads a retained prompt from useSession and edits/retries it through the scoped Session', () => {
const b = mountConversation({
workspaceId: wid('one'), text: 'retry me', phase: 'failed',
retry: 'send', error: 'offline',
})
const box = b.view.getByRole('textbox')
expect((box as HTMLTextAreaElement).value).toBe('retry me')
expect(b.view.getByRole('alert').textContent).toBe('Message send failed: offline')
fireEvent.change(box, { target: { value: 'revised prompt' } })
expect(b.updateSessionPrompt).toHaveBeenCalledWith('revised prompt')
expect(b.chat.store.getSnapshot().draft).toBe('ordinary draft')
fireEvent.keyDown(box, { key: 'Enter' })
expect(b.retrySessionPrompt).toHaveBeenCalledOnce()
expect(b.send).not.toHaveBeenCalled()
})
})

View File

@@ -1,10 +1,10 @@
# @deepseek-ai/dsh-client-ui-layout
Shell plugin: three-column AppFrame (drag handles, concession chain) + ctx.layout viewing-state service (nav, panel widths, persist); defines the sidebar/conversation/details/conversation.empty slots. The sidebar is fixed-width (it never concedes to viewport pressure — only details shrinks, then auto-closes); a closed sidebar retains a 56px control rail while details closes to zero width; collapse/expand animates the grid tracks on the deepsuite sider curve. Contract: api-contracts v3 §5.
Shell plugin: three-column AppFrame (drag handles and concession chain) plus the `ctx.layout` panel-geometry service; it registers into the runtime-owned `root` slot and declares `sidebar`, `conversation`, `details`, and `conversation.empty`. The sidebar is fixed-width (only details shrinks, then auto-closes); a closed sidebar retains a 56px control rail while details closes to zero width.
Slot declarations use the composed-props entry form (`owner` share, no full `props`): the exported OwnerShare contracts are `SidebarOwnerProps` / `ConvOwnerProps` / `DetailsOwnerProps` / `EmptyOwnerProps` — registrants reference them via `OwnerOf<'sidebar' | ...>` and compose their own injected share locally. No entry declares `children` (declaring it requires the registered component to carry the slots face — reserved for future business slots): delegation authority is the component-side whitelist, i.e. AppFrame's `ScopedSlots<FrameSlotKey>` face over sidebar/conversation/details/conversation.empty. Since the root-slot rework the frame itself registers into 'root' and renders those child slots at its own render sites; the shell only renders 'root'.
AppFrame reads the runtime Session projection: `baselinesReady` selects loading, a page-local `SessionListState.intent` selects the empty composer, and a connected Session renders through `SessionProvider`. The conversation and empty-state owner shares are empty; each registrant obtains business data from standard hooks and actions from its own inject face. The sidebar owner share contains only `collapsed` and `width`; navigation actions belong to sidebar's own injected service face.
The export surface is the cross-package contract only: the AppFrame trio (+ `AppFrameProps`) consumed by the web shell's assembly, `LayoutService` with its store shapes (`NavState`/`PanelState`/`ViewId`), and the OwnerShare contracts. The concession-chain solver (`computeColumns`) and its geometry constants are package-internal; tests import them from `/src`.
The `/client` export surface is the plugin body (`apply`/`inject`), `LayoutService`, and the four owner-share interfaces. AppFrame, the panel store, and the concession solver remain package-internal; tests import internals through `/src`.
## Model Experience

View File

@@ -6,10 +6,10 @@
* renders HERE with live parameters from the concession solve, and the
* session pair renders under the SessionProvider standard seat (render-prop
* form, injected by the renderer because the children declaration contains
* session-scope slots; session slots get sessionId as a framework-standard
* prop, so the owner shares stay empty). Pure component: everything arrives
* through the four prop shares — zero cordis or framework imports, zero
* self-made hooks.
* session-scope slots; session data arrives through framework-standard props
* and each registrant's inject face). Pure component: everything arrives
* through the three framework shares — zero cordis or framework imports,
* zero self-made hooks.
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
@@ -18,7 +18,7 @@ import { computeColumns } from './columns.ts'
import type { createLayoutStore } from './stores.ts'
import css from './AppFrame.module.css'
/** Full composed props: runtime share + child-slot render share + store share (no business face). */
/** Full composed props: runtime share + child-slot render share + store share. */
export type AppFrameProps =
& PropsRuntime<'root'>
& PropsRenderSlots<'sidebar' | 'conversation' | 'details' | 'conversation.empty'>
@@ -82,8 +82,17 @@ function DragHandle(props: { side: 'sidebar' | 'details'; left: number; onStart:
}
/** The three-column frame (see module doc). SessionProvider arrives as a standard seat (declaring a session-scope child summons it — no framework import). */
export function AppFrame({ useStore, actions, renderSlot, SessionProvider }: AppFrameProps) {
export function AppFrame({
useStore,
actions,
renderSlot,
SessionProvider,
useSessions,
useWorkspaces,
}: AppFrameProps) {
const panels = useStore((s) => s)
const sessions = useSessions(s => s)
const baselinesReady = useWorkspaces(s => s.baselinesReady)
const frameRef = useRef<HTMLDivElement | null>(null)
const [viewport, setViewport] = useState(() => window.innerWidth)
@@ -143,24 +152,47 @@ export function AppFrame({ useStore, actions, renderSlot, SessionProvider }: App
sidebar keeps the mounted slot at the compact-rail width, and the
component sees its rendered state as owner params decided here
(collapsed follows the preference, not the resolved width). */}
{renderSlot('sidebar', { collapsed: panels.sidebar === 0, width: cols.sidebar })}
{renderSlot('sidebar', {
collapsed: panels.sidebar === 0,
width: cols.sidebar,
})}
</div>
<SessionProvider
empty={() => (
{!baselinesReady
? (
<>
<CenterColumn>{renderSlot('conversation.empty', {})}</CenterColumn>
<CenterColumn>
<div role="status">Loading workspaces and sessions</div>
</CenterColumn>
<DetailsColumn />
</>
)}
>
{() => (
<>
{/* sessionId is a framework-standard prop on session slots — the owner passes nothing. */}
<CenterColumn>{renderSlot('conversation', {})}</CenterColumn>
<DetailsColumn>{renderSlot('details', {})}</DetailsColumn>
</>
)}
</SessionProvider>
)
: sessions.intent !== undefined
? (
<>
<CenterColumn>
{renderSlot('conversation.empty', {})}
</CenterColumn>
<DetailsColumn />
</>
)
: (
<SessionProvider
empty={() => (
<>
<CenterColumn><div role="status">Opening session</div></CenterColumn>
<DetailsColumn />
</>
)}
>
{() => (
<>
{/* Session data and actions arrive from standard hooks and the registrant's inject face. */}
<CenterColumn>{renderSlot('conversation', {})}</CenterColumn>
<DetailsColumn>{renderSlot('details', {})}</DetailsColumn>
</>
)}
</SessionProvider>
)}
{/* The collapsed rail is fixed-width: no resize handle while closed. */}
{panels.sidebar > 0 && <DragHandle side="sidebar" left={cols.sidebar} onStart={onSidebarStart} onDrag={onSidebarDrag} onEnd={onDragEnd} />}
{cols.details > 0 && <DragHandle side="details" left={viewport - cols.details} onStart={onDetailsStart} onDrag={onDetailsDrag} onEnd={onDragEnd} />}

View File

@@ -29,8 +29,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
// The 'root' entry itself is the runtime's built-in slot (declared
// there); these four are the frame's children, declared by the same
// register() call that contributes AppFrame. Session slots carry no
// owner share: the framework injects sessionId as a standard prop.
// register() call that contributes AppFrame. Session owners never pass
// sessionId: the framework injects it as a standard prop.
'sidebar': { kind: 'single'; scope: 'root'; owner: SidebarOwnerProps }
'conversation': { kind: 'single'; scope: 'session'; owner: ConvOwnerProps }
'details': { kind: 'single'; scope: 'session'; owner: DetailsOwnerProps }
@@ -41,12 +41,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
// OwnerShare contracts — the render-side share the slot owner supplies at
// renderSlot. Registrants IMPORT these and compose their full component props
// through the four-share intersection (PropsRuntime & PropsRenderSlots &
// PropsStore & I). Session owner shares stay literally empty: a phantom
// `sessionId?: never` would intersect with the framework's mandatory
// SessionStandardProps.sessionId and collapse the composed props to never —
// the anti-smuggling guard is mutually exclusive with standard injection, so
// the standard member's own type is the only guard on standard keys. Phantom
// members remain fine on keys the standards never claim (EmptyOwnerProps).
// PropsStore & I). Conversation business state and actions arrive through
// framework-standard hooks and each registrant's inject face, not owner props.
/** Sidebar owner share: live column state from the frame's concession solve. */
export interface SidebarOwnerProps {
@@ -56,13 +52,13 @@ export interface SidebarOwnerProps {
width: number
}
/** Conversation owner share: empty — sessionId arrives as a framework-standard prop. */
/** Conversation owner share: business state and actions belong to the registrant. */
export interface ConvOwnerProps {}
/** Details owner share: empty — sessionId arrives as a framework-standard prop. */
export interface DetailsOwnerProps {}
/** Empty-state owner share (ui-conversation registers EmptyState here). */
/** Empty-state owner share: business state and actions belong to the registrant. */
export interface EmptyOwnerProps { children?: never }
/** Required services (cordis fiber inject — the loader passes the whole export surface as an object plugin). */
@@ -89,9 +85,8 @@ export function apply(ctx: ClientContext): void {
// Exclusive store: the factory itself — the framework instantiates per
// entry and delivers useStore/actions to AppFrame as standard props.
store: createLayoutStore,
// No business face for the frame (I = {}): the hook's job is the
// assembly side effect wiring the entry's bound actions into the
// cross-plugin service seam.
// The hook's only side effect connects the root store to ctx.layout;
// conversation business actions belong to their registrants.
inject: (actions: PanelActions) => {
layout.attachPanels(actions)
return {}

View File

@@ -13,19 +13,19 @@ import {
SIDEBAR_DEFAULT, SIDEBAR_MAX, SIDEBAR_MIN,
} from './columns.ts'
/** Panel width preferences in px (0 = closed) — the layout store's state. */
type PanelWidths = { sidebar: number; details: number }
/** Layout store state: panel width preferences in px (0 = closed). */
type LayoutState = { sidebar: number; details: number }
/**
* Annotation twin of the actions literal below (the export needs a declared
* return type); drift fails assignability at the defineStore call.
*/
type LayoutActions = {
setSidebar: (draft: PanelWidths, px: number) => void
setDetails: (draft: PanelWidths, px: number) => void
toggleSidebar: (draft: PanelWidths) => void
openDetails: (draft: PanelWidths) => void
closeDetails: (draft: PanelWidths) => void
setSidebar: (draft: LayoutState, px: number) => void
setDetails: (draft: LayoutState, px: number) => void
toggleSidebar: (draft: LayoutState) => void
openDetails: (draft: LayoutState) => void
closeDetails: (draft: LayoutState) => void
}
/**
@@ -36,9 +36,9 @@ type LayoutActions = {
* open/close transitions write 0 / the default explicitly.
* @returns the store handle (spec + type + identity + factory in one).
*/
export function createLayoutStore(): EngineStoreHandle<PanelWidths, LayoutActions> {
return defineStore({
init: () => ({ sidebar: SIDEBAR_DEFAULT, details: 0 }),
export function createLayoutStore(): EngineStoreHandle<LayoutState, LayoutActions> {
const handle = defineStore({
init: (): LayoutState => ({ sidebar: SIDEBAR_DEFAULT, details: 0 }),
persist: 'dsh.layout.panels',
actions: {
setSidebar: (d, px: number) => { d.sidebar = clampWidth(px, SIDEBAR_MIN, SIDEBAR_MAX) },
@@ -48,4 +48,5 @@ export function createLayoutStore(): EngineStoreHandle<PanelWidths, LayoutAction
closeDetails: (d) => { d.details = 0 },
},
})
return handle
}

View File

@@ -17,9 +17,13 @@ import { AppFrame } from '@deepseek-ai/dsh-client-ui-layout/src/client/AppFrame.
import type { AppFrameProps } from '@deepseek-ai/dsh-client-ui-layout/src/client/AppFrame.tsx'
import { SIDEBAR_COLLAPSED } from '@deepseek-ai/dsh-client-ui-layout/src/client/columns.ts'
import { createLayoutStore } from '@deepseek-ai/dsh-client-ui-layout/src/client/stores.ts'
import type {
SessionId, SessionListState, WorkspaceId, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
// Session-mode switch for the SessionProvider stub prop.
const sessionMode = { current: true }
const baselinesReady = { current: true }
// Render-prop contract stub fed through the standard seat prop (the renderer
// injects the real one in production): session mode runs children(id), empty
@@ -59,13 +63,31 @@ function mountFrame() {
if (key === 'details') return <div data-testid="details-content" />
return <div data-testid="empty-content" />
}) as AppFrameProps['renderSlot']
const useSessions = ((sel: (s: unknown) => unknown) => sel({ ids: [], byId: {} })) as never
const sessionId = 's-test' as SessionId
const workspaceId = 'w-test' as WorkspaceId
const sessionState = {
ids: sessionMode.current ? [sessionId] : [],
byId: sessionMode.current
? { [sessionId]: { id: sessionId, displayTitle: 'Test', running: false, updatedAt: 1 } }
: {},
current: sessionMode.current ? sessionId : undefined,
phase: 'ready',
intent: sessionMode.current
? undefined
: { sessionId: 'intent' as SessionId, target: { kind: 'workspace', workspaceId }, prompt: '', phase: 'connecting' },
} as SessionListState
const useSessions = ((sel: (s: SessionListState) => unknown) => sel(sessionState)) as never
const workspaceState: WorkspaceListState = {
items: [], intent: undefined, state: 'idle', phase: 'ready', error: null,
baselinesReady: baselinesReady.current, recentWorkspaceId: undefined,
}
const utils = render(
<AppFrame
useStore={hookOf(instance) as never}
actions={instance.actions}
renderSlot={renderSlot}
useSessions={useSessions}
useWorkspaces={((sel: (s: WorkspaceListState) => unknown) => sel(workspaceState)) as never}
SessionProvider={SessionProviderStub}
/>,
)
@@ -91,6 +113,7 @@ function drag(handle: Element, fromX: number, toX: number): void {
beforeEach(() => {
frameWidth = 1920
sessionMode.current = true
baselinesReady.current = true
localStorage.clear() // the layout store persists; instances must not bleed across tests
vi.useFakeTimers()
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
@@ -131,13 +154,22 @@ describe('AppFrame', () => {
expect(slotCalls.find((c) => c.key === 'details')!.props).toEqual({})
})
it('renders the empty branch through conversation.empty when no session is current', () => {
it('keeps a connecting page-local Session intent in conversation.empty', () => {
sessionMode.current = false
const { slotCalls, getByTestId, queryByTestId } = mountFrame()
expect(getByTestId('empty-content')).toBeTruthy()
expect(queryByTestId('center-content')).toBeNull()
expect(slotCalls.map((c) => c.key)).toContain('conversation.empty')
expect(slotCalls.map((c) => c.key)).not.toContain('conversation')
expect(slotCalls.find((c) => c.key === 'conversation.empty')!.props).toEqual({})
})
it('keeps the loading branch until both object-layer baselines are ready', () => {
baselinesReady.current = false
const { slotCalls, getByRole } = mountFrame()
expect(getByRole('status').textContent).toContain('Loading workspaces and sessions')
expect(slotCalls.map((c) => c.key)).not.toContain('conversation')
expect(slotCalls.map((c) => c.key)).not.toContain('conversation.empty')
})
it('sidebar slot receives live concession output as owner props', () => {

View File

@@ -22,12 +22,12 @@ async function bench() {
describe('ui-layout client apply', () => {
it('declares its service dependencies', () => {
expect(inject).toContain('slots')
expect(inject).toEqual(['slots'])
})
it('provides ctx.layout and registers AppFrame into root with the four child declarations', async () => {
const { ctx, slots } = await bench()
const fiber = ctx.plugin({ inject: ['slots'], apply })
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
expect(ctx.get('layout')).toBeInstanceOf(LayoutService)
// The one register() call occupied 'root'…
@@ -39,9 +39,23 @@ describe('ui-layout client apply', () => {
expect(slots.spec('conversation.empty')).toEqual({ kind: 'single', scope: 'root' })
})
it('injects no business face and attaches the layout actions', async () => {
const { ctx, slots } = await bench()
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
const actions = {
setSidebar: vi.fn(), setDetails: vi.fn(), toggleSidebar: vi.fn(), openDetails: vi.fn(), closeDetails: vi.fn(),
}
const injected = (slots.entries('root')[0]!.inject as (actions: never) => object)(actions as never)
expect(injected).toEqual({})
const layout = ctx.get('layout') as LayoutService
layout.toggleSidebar()
expect(actions.toggleSidebar).toHaveBeenCalledOnce()
})
it('teardown unwinds the service, the root registration, and the child declarations', async () => {
const { ctx, slots } = await bench()
const fiber = ctx.plugin({ inject: ['slots'], apply })
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
await fiber.dispose()
expect(ctx.get('layout')).toBeUndefined()

View File

@@ -22,12 +22,14 @@
"dependencies": {
"clsx": "^2.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.0",
"cordis": "^4.0.0-rc.7"
},
"files": [

View File

@@ -7,6 +7,8 @@
* r12, inverted hairline border, shadow-lv3, 4px inset padding. */
.list,
.submenu {
/* min-widths below are the design's outer card widths — include the pad. */
box-sizing: border-box;
padding: 4px;
display: flex;
flex-direction: column;
@@ -17,12 +19,22 @@
box-shadow: var(--dsw-shadow-lv3);
}
/* Primary card is 218 wide in the design across both hosts. */
.list {
position: absolute;
top: calc(100% + 4px);
left: 0;
z-index: 100;
min-width: 130px;
min-width: 218px;
}
/* Portal mode: fixed in the viewport, coordinates supplied inline from the
* anchor rect (side/align resolved in JS, the in-place offset rules above
* don't apply). */
.portal {
position: fixed;
top: auto;
left: auto;
}
/* Open above the anchor (empty-state workspace chip: figma 122:9481). */
@@ -116,7 +128,7 @@
bottom: -4px;
left: calc(100% + 10px);
z-index: 101;
min-width: 160px;
min-width: 163px;
}
.submenu::before {

View File

@@ -1,10 +1,13 @@
// Menu: minimal controlled dropdown (group-by pickers, project selectors).
// Pure CSS positioning relative to the anchor wrapper — no portal, no popper.
// Default: pure CSS positioning relative to the anchor wrapper — no popper.
// Opt-in `portal` renders the list into document.body, fixed-positioned from
// the anchor rect, for anchors inside overflow-clipping containers (sidebar).
// The owner controls `open`; outside-click closing uses one document listener
// active only while open. Submenus open on hover/focus inside the same root.
import { useEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import type { CSSProperties, ReactNode } from 'react'
import { createPortal } from 'react-dom'
import clsx from 'clsx'
import { IconCheckOutline16 } from './icons/index.tsx'
import css from './Menu.module.css'
@@ -43,9 +46,19 @@ function isSeparator(entry: MenuEntry): entry is MenuSeparator {
* @param props.onClose - invoked on outside click or Escape.
* @param props.align - list alignment against the anchor (default 'start').
* @param props.side - open below (`bottom`, default) or above (`top`) the anchor.
* @param props.portal - render the list into document.body, fixed-positioned
* from the anchor rect (repositions on scroll/resize while open). Use when an
* ancestor's overflow clipping would crop the in-place list; default false
* keeps the pure-CSS in-place behavior.
* @param props.getAnchorRect - portal mode only: supply the anchor rect
* directly (e.g. from a host-owned trigger button) instead of measuring the
* Menu's own wrapper span. Required when the wrapper isn't itself laid out at
* the trigger (render-prop anchors, effect-positioned proxies — measuring the
* wrapper there races the host's layout effects). Called on open and on every
* scroll/resize; return null to skip placement for that frame.
* @returns anchor wrapper with the conditional list.
*/
export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', className }: {
export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, getAnchorRect, className }: {
open: boolean
anchor: ReactNode
items: readonly MenuEntry[]
@@ -54,10 +67,44 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
onClose: () => void
align?: 'start' | 'end'
side?: 'bottom' | 'top'
portal?: boolean
getAnchorRect?: () => DOMRect | null
className?: string
}) {
const rootRef = useRef<HTMLSpanElement>(null)
const listRef = useRef<HTMLDivElement>(null)
const [openSubmenuId, setOpenSubmenuId] = useState<string | null>(null)
const [fixedPos, setFixedPos] = useState<CSSProperties | null>(null)
// Portal mode: fixed-position the list from the anchor rect before paint;
// track the anchor while open (capture-phase scroll catches nested panes).
// getAnchorRect trumps measuring the wrapper span: a child layout effect
// runs before the parent's, so a wrapper the host positions in its own
// effect measures stale here — the host callback owns the truth instead.
useLayoutEffect(() => {
if (!open || !portal) { setFixedPos(null); return }
const place = () => {
let r: DOMRect | null
if (getAnchorRect !== undefined) {
r = getAnchorRect()
} else {
/* v8 ignore next 2 -- the ref is attached before the layout effect runs and the listeners die with it. */
r = rootRef.current?.getBoundingClientRect() ?? null
}
if (r === null) return
setFixedPos({
...(align === 'start' ? { left: r.left } : { right: window.innerWidth - r.right }),
...(side === 'bottom' ? { top: r.bottom + 4 } : { bottom: window.innerHeight - r.top + 4 }),
})
}
place()
window.addEventListener('scroll', place, true)
window.addEventListener('resize', place)
return () => {
window.removeEventListener('scroll', place, true)
window.removeEventListener('resize', place)
}
}, [open, portal, align, side, getAnchorRect])
useEffect(() => {
if (!open) {
@@ -65,7 +112,11 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
return
}
const onPointerDown = (e: PointerEvent) => {
if (rootRef.current && e.target instanceof Node && !rootRef.current.contains(e.target)) onClose()
if (!(e.target instanceof Node)) return
// The portaled list is outside the anchor subtree; check both.
if (rootRef.current?.contains(e.target) === true) return
if (listRef.current?.contains(e.target) === true) return
onClose()
}
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
@@ -78,11 +129,13 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
}
}, [open, onClose])
return (
<span ref={rootRef} className={clsx(css.root, className)}>
{anchor}
{open && (
<div className={clsx(css.list, side === 'top' && css.sideTop, align === 'end' && css.alignEnd)} role="menu">
const list = open && (!portal || fixedPos !== null) && (
<div
ref={listRef}
className={clsx(css.list, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)}
style={fixedPos ?? undefined}
role="menu"
>
{items.map(entry => {
if (isSeparator(entry)) {
return <div key={entry.id} className={css.separator} role="separator" />
@@ -137,8 +190,13 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
</div>
)
})}
</div>
)}
</div>
)
return (
<span ref={rootRef} className={clsx(css.root, className)}>
{anchor}
{portal ? (list !== false && createPortal(list, document.body)) : list}
</span>
)
}

View File

@@ -40,10 +40,12 @@
width: 100%;
}
/* Header pad (figma Title row): pt 22 / pl 24 / pr 14 / pb 12. */
/* Header row (figma Title row): pad l24/t22/r14/b12, SPACE_BETWEEN —
* title left, close button right. */
.header {
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 22px 14px 12px 24px;
}
@@ -52,21 +54,43 @@
margin: 0;
font-size: 16px;
line-height: 24px;
font-weight: 500;
font-weight: 510;
color: var(--dsw-alias-label-primary);
}
.close {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 8px;
background: transparent;
cursor: pointer;
color: var(--dsw-alias-label-secondary);
}
.close:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
/* Description and body share the 332px content column (24px side pads). */
.description {
margin: 0;
padding: 0 24px;
font-size: 14px;
line-height: 22px;
color: var(--dsw-alias-label-secondary);
font-weight: 400;
color: var(--dsw-alias-label-primary);
}
.body {
display: flex;
flex-direction: column;
min-width: 0;
margin-top: 20px;
padding: 0 24px;
}

View File

@@ -5,6 +5,7 @@
import { useEffect } from 'react'
import type { ReactNode } from 'react'
import clsx from 'clsx'
import { IconCloseOutline16 } from './icons/index.tsx'
import css from './Modal.module.css'
/**
@@ -49,10 +50,13 @@ export function Modal({ open, onClose, title, description, children, footer, cla
<div className={css.content}>
<div className={css.header}>
<h2 className={css.title}>{title}</h2>
{description !== undefined && description !== '' && (
<p className={css.description}>{description}</p>
)}
<button type="button" className={css.close} aria-label="Close" onClick={onClose}>
<IconCloseOutline16 size={14} />
</button>
</div>
{description !== undefined && description !== '' && (
<p className={css.description}>{description}</p>
)}
{children !== undefined && <div className={css.body}>{children}</div>}
</div>
{footer !== undefined && <div className={css.footer}>{footer}</div>}

View File

@@ -7,8 +7,8 @@
// it escapes ancestor overflow clipping (the sidebar rail clips its column)
// without a portal.
import { cloneElement, useEffect, useRef, useState } from 'react'
import type { FocusEventHandler, MouseEventHandler, ReactElement, Ref } from 'react'
import { cloneElement, useCallback, useEffect, useRef, useState } from 'react'
import type { FocusEventHandler, MouseEventHandler, MutableRefObject, ReactElement, Ref } from 'react'
import css from './Tooltip.module.css'
/** Bubble placement relative to the anchor. */
@@ -28,11 +28,19 @@ interface AnchorProps {
* @param props.label - bubble text.
* @param props.side - placement relative to the anchor (default 'right').
* @param props.disabled - suppress the bubble while true; the anchor renders identically so toggling never remounts it (which would cut its CSS transitions).
* @param props.children - a single anchor element. Tooltip owns its ref (no current consumer passes one).
* @param props.children - a single anchor element; its own ref (callback or object) is forwarded alongside the tooltip's.
* @returns the cloned anchor plus a fixed-position bubble while hovered/focused.
*/
export function Tooltip({ label, side = 'right', disabled = false, children }: { label: string; side?: TooltipSide; disabled?: boolean; children: ReactElement<AnchorProps> }) {
const anchor = useRef<HTMLElement | null>(null)
// React 18 keeps the element's ref outside props; forward it so wrapping an
// anchor in Tooltip never silently severs the owner's ref.
const childRef = (children as ReactElement<AnchorProps> & { ref?: Ref<HTMLElement> }).ref
const mergedRef = useCallback((el: HTMLElement | null) => {
anchor.current = el
if (typeof childRef === 'function') childRef(el)
else if (childRef != null) (childRef as MutableRefObject<HTMLElement | null>).current = el
}, [childRef])
const [pos, setPos] = useState<{ x: number; y: number } | null>(null)
// Hover and focus are independent triggers: the bubble hides only after
// BOTH clear (hovering away from a focused anchor must not drop it).
@@ -61,7 +69,7 @@ export function Tooltip({ label, side = 'right', disabled = false, children }: {
return (
<>
{cloneElement(children, {
ref: anchor,
ref: mergedRef,
onMouseEnter: (e) => { children.props.onMouseEnter?.(e); triggers.current.hover = true; show() },
onMouseLeave: (e) => { children.props.onMouseLeave?.(e); triggers.current.hover = false; hide() },
onFocus: (e) => { children.props.onFocus?.(e); triggers.current.focus = true; show() },

View File

@@ -170,6 +170,71 @@ describe('Menu', () => {
fireEvent.mouseLeave(wrap)
expect(screen.queryByRole('menuitem', { name: 'Create ok' })).toBeNull()
})
it('portal mode prefers getAnchorRect over measuring its own wrapper', () => {
const rect = { left: 40, right: 72, top: 100, bottom: 128, width: 32, height: 28, x: 40, y: 100, toJSON: () => ({}) } as DOMRect
render(
<Menu
portal
open
getAnchorRect={() => rect}
anchor={null}
items={items}
onSelect={() => {}}
onClose={() => {}}
/>)
const menu = screen.getByRole('menu')
// side=bottom, align=start: below the host-supplied rect, left-aligned.
expect(menu.style.left).toBe('40px')
expect(menu.style.top).toBe('132px')
})
it('portal mode skips the frame when getAnchorRect returns null (no menu until a rect exists)', () => {
render(
<Menu
portal
open
getAnchorRect={() => null}
anchor={null}
items={items}
onSelect={() => {}}
onClose={() => {}}
/>)
expect(screen.queryByRole('menu')).toBeNull()
})
it('portal mode renders the list under body, positions it fixed, and still closes on outside pointerdown', () => {
const onSelect = vi.fn()
const onClose = vi.fn()
const { container } = render(
<Menu portal open anchor={<span>trigger</span>} items={items} onSelect={onSelect} onClose={onClose} />)
const menu = screen.getByRole('menu')
// Outside the anchor wrapper subtree — overflow-clipping ancestors can't crop it.
expect(container.contains(menu)).toBe(false)
expect(menu.parentElement).toBe(document.body)
expect(menu.style.top).not.toBe('')
fireEvent.click(screen.getByRole('menuitem', { name: 'Alpha' }))
expect(onSelect).toHaveBeenCalledWith('a')
fireEvent.pointerDown(menu)
expect(onClose).not.toHaveBeenCalled()
// Non-Node targets (e.g. window itself) are ignored, not treated as outside.
const nonNodeTarget = new Event('pointerdown', { bubbles: true })
Object.defineProperty(nonNodeTarget, 'target', { value: window })
document.dispatchEvent(nonNodeTarget)
expect(onClose).not.toHaveBeenCalled()
fireEvent.pointerDown(document.body)
expect(onClose).toHaveBeenCalledTimes(1)
})
it('portal mode positions from the opposite edges for align=end / side=top', () => {
render(
<Menu portal open align="end" side="top" anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={() => {}} />)
const menu = screen.getByRole('menu')
expect(menu.style.right).not.toBe('')
expect(menu.style.bottom).not.toBe('')
expect(menu.style.left).toBe('')
expect(menu.style.top).toBe('')
})
})
describe('Modal', () => {

View File

@@ -104,6 +104,26 @@ describe('Tooltip', () => {
expect(screen.queryByRole('tooltip')).toBeNull()
})
it('forwards the anchor element to the child ref (object and callback)', () => {
const objectRef = { current: null as HTMLButtonElement | null }
const callbackRef = vi.fn()
const { rerender } = render(
<Tooltip label="Add">
<button type="button" ref={objectRef}>anchor</button>
</Tooltip>,
)
expect(objectRef.current).toBe(screen.getByText('anchor'))
// Tooltip's own positioning still works through the merged ref.
fireEvent.mouseEnter(screen.getByText('anchor'))
expect(screen.getByRole('tooltip')).toBeTruthy()
rerender(
<Tooltip label="Add">
<button type="button" ref={callbackRef}>anchor</button>
</Tooltip>,
)
expect(callbackRef).toHaveBeenCalledWith(screen.getByText('anchor'))
})
it('drops an already-visible bubble when disabled flips mid-hover', () => {
const { rerender } = render(
<Tooltip label="Rail">

View File

@@ -1,7 +1,9 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import type {
ConversationSnapshot, SessionId, SessionListState, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
import type { RpcReceipt } from '@deepseek-ai/dsh-client-connection/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
@@ -22,6 +24,7 @@ const kit = {
sessionId: SID,
useSession: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<ConversationSnapshot>,
useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<SessionListState>,
useWorkspaces: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<WorkspaceListState>,
}
const QUESTIONS = [

View File

@@ -1,10 +1,10 @@
# @deepseek-ai/dsh-client-ui-sidebar
Sidebar plugin: session multi-level tree (cwd grouping + parentId nesting), search, by-workspace grouping, state dots, three creation entries. Top-level New Session / New Workspace clear the selection onto `conversation.empty`; per-project "+" still create-then-opens. Collapse is a slide + crossfade into the layout-owned 56px rail (open / new session / new workspace / search — search expands and focuses the search box — plus the settings foot): the expanded content freezes at its width and fades in place while the column slides over it, then the rail — whale mark resting, panel icon on hover, tooltips on every control — crossfades in at settle as the wide content unmounts. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md).
Sidebar plugin: real Host Workspaces in stable Host order, each containing its `sessionIds` in Workspace order with `parentId` nesting; Sessions outside every Workspace appear in a trailing `Ungrouped` section. Search, state dots, and collapse into the layout-owned 56px rail are presentation-local. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md).
`src/client/contract/slots.ts` is the single-domain contract file: `SidebarRootInjected` (the registrant's own injected share — plain service callbacks: onOpen/onCreate/onToggleSidebar) and `SidebarRootComponentProps = PropsRuntime<'sidebar'> & SidebarRootInjected` (owner `{collapsed,width}` plus the standard `useSessions` hook, resolved off ui-layout's SlotMap declaration, never re-stated). `apply` registers SidebarRoot cast-free against that composition; the inject factory closes over the plugin's own ctx.
New Session starts the runtime's page-local frontend Session Intent; a real Workspace's "+" starts one targeted to that Workspace. The Workspace header "+" opens ui-workspace's shared picker, whose selection also targets a frontend Session. A Workspace Intent does not appear in the sidebar.
There is no plugin store: rows derive in the component (`useMemo` over the `useSessions` snapshot + local expansion/search state) through the pure `deriveRows` in `tree.ts`.
`SidebarRootComponentProps` composes the layout owner share, the global `useSessions` and `useWorkspaces` hooks, the declared `sidebar.workspace` child slot, and injected `startSession`, `open`, and sidebar-toggle callbacks. There is no plugin store: `deriveGroups` consumes object-layer snapshots and component-local expansion/search state.
The `/client` export surface is the plugin body (`apply`/`inject`) plus the contract types only — SidebarRoot, the row components, and the tree derivation are internal (the slot registration closes over them; tests import src paths directly).

View File

@@ -104,6 +104,18 @@
line-height: 20px;
}
.renameInput {
min-width: 0;
font-size: 14px;
line-height: 20px;
padding: 0 2px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 4px;
background: var(--dsw-alias-button-elevated-fill);
color: inherit;
outline: none;
}
.sessionRow .title {
flex: 1;
}

View File

@@ -5,10 +5,10 @@
*/
import clsx from 'clsx'
import {
IconEllipsisOutline16, IconFolderClose16, IconFolderOpen16, IconPlusOutline16,
IconFolderClose16, IconFolderOpen16, IconPlusOutline16,
IconTriangleRightFill14, StateDot,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ProjectRow, SessionRow } from './tree.ts'
import type { GroupNode, SessionNode } from './tree.ts'
import { formatRelativeTime } from './tree.ts'
import css from './Rows.module.css'
@@ -16,20 +16,21 @@ import css from './Rows.module.css'
const INDENT_STEP = 16
/**
* Project (workspace) row: 54px, folder + title + session count; hover
* reveals the chevron and the more/create buttons.
* @param props.row - derived project row.
* @param props.active - group contains the selected session (blue open folder).
* Project (workspace) header row: 54px, folder + title + session count;
* hover reveals the chevron and create button. `containsCurrent` arrives on
* the node (derivation fact, no renderer scan).
* @param props.group - derived group node.
* @param props.onToggle - expand/collapse the group.
* @param props.onCreate - create a session inside this group.
* @param props.onCreate - start a frontend Session inside this Workspace.
* @returns the row element.
*/
export function ProjectRowItem({ row, active, onToggle, onCreate }: {
row: ProjectRow
active: boolean
export function ProjectRowItem({ group, onToggle, onCreate }: {
group: GroupNode
onToggle: () => void
onCreate: () => void
}) {
const row = group
const active = group.expanded && group.containsCurrent
const count = `${row.sessionCount} ${row.sessionCount === 1 ? 'session' : 'sessions'}`
return (
<div className={css.projectRow} role="treeitem" aria-expanded={row.expanded} onClick={onToggle}>
@@ -44,14 +45,10 @@ export function ProjectRowItem({ row, active, onToggle, onCreate }: {
<span className={css.meta}>{count}</span>
</span>
<span className={css.rowActions}>
{/* Row menu contents are not designed yet (figma draft notes); the button is the reserved anchor. */}
<button type="button" className={css.iconButton} aria-label="More" onClick={(e) => { e.stopPropagation() }}>
<IconEllipsisOutline16 />
</button>
<button
type="button"
className={css.iconButton}
aria-label="New session here"
aria-label={`New session in ${row.label}`}
onClick={(e) => { e.stopPropagation(); onCreate() }}
>
<IconPlusOutline16 />
@@ -62,33 +59,53 @@ export function ProjectRowItem({ row, active, onToggle, onCreate }: {
}
/**
* Session row: 34px, indent by depth, expand twist when it has children,
* running state dot, relative time swapping to the more button on hover.
* @param props.row - derived session row.
* @param props.selected - row is the current session.
* @param props.now - epoch ms for relative-time formatting.
* @param props.onOpen - open this session.
* @param props.onToggle - unfold/fold the subtree.
* @returns the row element.
* The selected "New session" row for a frontend Session Intent targeted to a
* real Workspace. The row disappears when the Intent is replaced or connects.
* @returns the placeholder row element.
*/
export function SessionRowItem({ row, selected, now, onOpen, onToggle }: {
row: SessionRow
selected: boolean
export function IntentRowItem() {
return (
<div className={clsx(css.sessionRow, css.selected)} role="treeitem" aria-selected style={{ paddingLeft: 8 }}>
<span className={css.slot} />
<span className={css.slot} />
<span className={css.title}>New session</span>
</div>
)
}
/**
* One session subtree: the node's own 34px row (indent by depth, expand
* twist when it has children, running dot, relative time) plus its visible
* children, recursively — the component tree mirrors the derived tree.
* @param props.node - derived session node.
* @param props.depth - 0 = directly under the group header.
* @param props.currentId - selected session id (row highlight).
* @param props.now - epoch ms for relative-time formatting.
* @param props.onOpen - open a session by id.
* @param props.onToggle - unfold/fold a subtree by id.
* @returns the node's row followed by its children.
*/
export function SessionNodeItem({ node, depth, currentId, now, onOpen, onToggle }: {
node: SessionNode
depth: number
currentId: string | undefined
now: number
onOpen: () => void
onToggle: () => void
onOpen: (id: SessionNode['id']) => void
onToggle: (id: SessionNode['id']) => void
}) {
const row = node
const selected = node.id === currentId
// Rail (figma session cell: pad 8, twist slot 16, status slot 16, gap 4 to
// the title): both slots are always reserved so titles align whether or not
// the twist/dot is lit. Extra depth rides the left padding.
return (
const ownRow = (
<div
className={clsx(css.sessionRow, selected && css.selected)}
role="treeitem"
aria-selected={selected}
{...(row.hasChildren ? { 'aria-expanded': row.expanded } : {})}
style={{ paddingLeft: 8 + row.depth * INDENT_STEP }}
onClick={onOpen}
style={{ paddingLeft: 8 + depth * INDENT_STEP }}
onClick={() => { onOpen(node.id) }}
>
{row.hasChildren
? (
@@ -96,7 +113,7 @@ export function SessionRowItem({ row, selected, now, onOpen, onToggle }: {
type="button"
className={css.twist}
aria-label={row.expanded ? 'Collapse' : 'Expand'}
onClick={(e) => { e.stopPropagation(); onToggle() }}
onClick={(e) => { e.stopPropagation(); onToggle(node.id) }}
>
<IconTriangleRightFill14 className={clsx(css.arrow, row.expanded && css.arrowOpen)} />
</button>
@@ -105,11 +122,22 @@ export function SessionRowItem({ row, selected, now, onOpen, onToggle }: {
<span className={css.slot}>{row.running && <StateDot state="ongoing" />}</span>
<span className={css.title}>{row.title}</span>
<span className={css.time}>{formatRelativeTime(row.updatedAt, now)}</span>
<span className={css.rowActions}>
<button type="button" className={css.iconButton} aria-label="More" onClick={(e) => { e.stopPropagation() }}>
<IconEllipsisOutline16 />
</button>
</span>
</div>
)
return (
<>
{ownRow}
{node.children.map(child => (
<SessionNodeItem
key={child.id}
node={child}
depth={depth + 1}
currentId={currentId}
now={now}
onOpen={onOpen}
onToggle={onToggle}
/>
))}
</>
)
}

View File

@@ -339,24 +339,33 @@
pointer-events: none;
}
/* Batch separator (figma 133:7661): 20px spacer after an expanded project's
session run, before the next project row. */
.batchGap {
flex: none;
height: 20px;
}
/* Tree list: the only scrolling region. */
/* Tree list: the only scrolling region. Block, not a flex column: as flex
items the 54/34 rows would shrink under content overflow (scrollHeight
collapses onto clientHeight and wheel scrolling dies); block children keep
their design heights and the 4px rhythm rides margins instead of gap. */
.list {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 4px;
padding-bottom: 12px;
}
/* One workspace section: header row + expanded session run. Rows inside
keep the former flat-list 4px gap as sibling margins; the inter-group
breathing room (figma 133:7661 batch separator, 20px after an expanded
run) rides the NEXT section's top margin so the last group adds none. */
.groupSection > * + * {
margin-top: 4px;
}
.groupSection + .groupSection {
margin-top: 4px;
}
.groupSection:has([aria-expanded='true']) + .groupSection {
margin-top: 20px;
}
.empty {
padding: 16px 12px;
color: var(--dsw-alias-label-tertiary);

View File

@@ -7,7 +7,7 @@
* (one icon each, same top-down order) fading in as the slide ends. Rail
* search expands and focuses the search box.
*/
import { Fragment, useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import clsx from 'clsx'
import {
BrandWordmark, FishLogo,
@@ -15,9 +15,10 @@ import {
IconProjectAddOutline16, IconSearchOutline16, IconSettingsOutline14,
Menu, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
import type { SidebarRootComponentProps } from './contract/slots.ts'
import { deriveRows } from './tree.ts'
import { ProjectRowItem, SessionRowItem } from './Rows.tsx'
import { deriveGroups, UNGROUPED_KEY } from './tree.ts'
import { IntentRowItem, ProjectRowItem, SessionNodeItem } from './Rows.tsx'
import css from './SidebarRoot.module.css'
/** Wide-content unmount delay; matches the 150ms wide-content fade-out. */
@@ -27,7 +28,7 @@ const COLLAPSE_SETTLE_MS = 150
const EXPAND_SLIDE_MS = 300
const GROUP_BY_ITEMS = [
{ id: 'workspace', label: 'WorkSpace' },
{ id: 'workspace', label: 'Workspace' },
// Only workspace grouping is implemented.
{ id: 'update', label: 'Update', disabled: true },
{ id: 'status', label: 'Status', disabled: true },
@@ -63,62 +64,74 @@ function GroupByMenu() {
)
}
type SessionTreeProps = Pick<SidebarRootComponentProps, 'useSessions' | 'onOpen' | 'onCreate'> & {
type SessionTreeProps = Pick<
SidebarRootComponentProps,
'useSessions' | 'startSession' | 'open'
> & {
workspaces: readonly WorkspaceView[]
/** Live search filter owned by the root (the query outlives the tree). */
query: string
}
/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */
function SessionTree({ useSessions, onOpen, onCreate, query }: SessionTreeProps) {
function SessionTree({ useSessions, startSession, open, workspaces, query }: SessionTreeProps) {
const list = useSessions((s) => s)
// Selection belongs to the sessions snapshot, not layout state.
const current = useSessions((s) => s.current)
const current = list.current
const [expandedProjects, setExpandedProjects] = useState<string[]>([])
const [expandedSessions, setExpandedSessions] = useState<string[]>([])
const rows = useMemo(
() => deriveRows(list, { expandedProjects, expandedSessions, query }),
[list, expandedProjects, expandedSessions, query],
// Re-expand when publication moves the selected intent into a real Workspace.
const intent = list.intent
const intentWorkspaceId = intent?.target.kind === 'workspace'
? intent.target.workspaceId
: undefined
const currentGroup = current === undefined
? undefined
: intent?.sessionId === current
? intentWorkspaceId
: (workspaces.find(w => w.sessionIds.includes(current))?.workspaceId as string | undefined)
?? UNGROUPED_KEY
useEffect(() => {
if (current === undefined || currentGroup === undefined) return
setExpandedProjects((l) => (l.includes(currentGroup) ? l : [...l, currentGroup]))
}, [current, currentGroup])
const groups = useMemo(
() => deriveGroups(list, workspaces, { expandedProjects, expandedSessions, query }),
[list, workspaces, expandedProjects, expandedSessions, query],
)
const now = Date.now()
// Presentational lookup (not tree derivation): the group holding the
// selected session gets the active folder; only expanded groups can show it.
let activeGroup: string | undefined
if (current !== undefined) {
for (const row of rows) {
if (row.type === 'session' && row.id === current) { activeGroup = row.groupKey; break }
}
}
return (
<div className={clsx(css.treeBody, css.wide)}>
<div className={css.list} role="tree" aria-label="Sessions">
{rows.length === 0 && (
{groups.length === 0 && (
<div className={css.empty}>{query === '' ? 'No sessions yet' : 'No matches'}</div>
)}
{rows.map((row, i) => row.type === 'project'
? (
<Fragment key={`p:${row.key}`}>
{/* Batch separator: a project row closing an expanded session run (figma 133:7661). */}
{i > 0 && rows[i - 1]!.type === 'session' && <span className={css.batchGap} />}
<ProjectRowItem
row={row}
active={row.key === activeGroup}
onToggle={() => { setExpandedProjects((l) => toggled(l, row.key)) }}
onCreate={() => { onCreate(row.cwd) }}
/>
</Fragment>
)
: (
<SessionRowItem
key={row.id}
row={row}
selected={row.id === current}
{groups.map(group => (
// Group section: header row + expanded session subtree. The
// inter-group breathing room (former flat-list batch separator)
// is the section's own margin (SidebarRoot.module.css).
<div key={group.key} className={css.groupSection}>
<ProjectRowItem
group={group}
onToggle={() => { setExpandedProjects((l) => toggled(l, group.key)) }}
onCreate={() => {
if (group.workspaceId !== undefined) startSession(group.workspaceId)
}}
/>
{group.intentHere && <IntentRowItem />}
{group.sessions.map(node => (
<SessionNodeItem
key={node.id}
node={node}
depth={0}
currentId={current}
now={now}
onOpen={() => { onOpen(row.id) }}
onToggle={() => { setExpandedSessions((l) => toggled(l, row.id)) }}
onOpen={open}
onToggle={(id) => { setExpandedSessions((l) => toggled(l, id)) }}
/>
))}
</div>
))}
</div>
<span className={css.fade} />
</div>
@@ -130,11 +143,27 @@ function SessionTree({ useSessions, onOpen, onCreate, query }: SessionTreeProps)
* @param props - composed slot props (runtime share + injected callbacks, contract/slots.ts).
* @returns the sidebar element tree.
*/
export function SidebarRoot({ collapsed, width, useSessions, onOpen, onCreate, onToggleSidebar }: SidebarRootComponentProps) {
export function SidebarRoot({
collapsed,
width,
useSessions,
useWorkspaces,
startSession,
open,
toggleSidebar,
renderSlot,
}: SidebarRootComponentProps) {
const workspaces = useWorkspaces(state => state.items)
// The query outlives the tree and the input (both wide-only) so collapsing
// does not silently drop an in-progress filter.
const [query, setQuery] = useState('')
const searchInput = useRef<HTMLInputElement | null>(null)
// Section-header opens the workspace picker (same popover in wide and
// rail states; the hole sits beside the button and opens rightward).
const [wsPickerOpen, setWsPickerOpen] = useState(false)
// Placement anchor for the picker popover: the slot span renders elsewhere
// in the DOM, so the picker positions off this button's rect.
const wsPlusRef = useRef<HTMLButtonElement>(null)
// Wide content stays mounted while the collapse animates (fading via
// .collapsed .wide), unmounts at settle, and remounts right away on expand.
@@ -188,7 +217,7 @@ export function SidebarRoot({ collapsed, width, useSessions, onOpen, onCreate, o
type="button"
className={clsx(css.iconButton, css.toggle)}
aria-label={collapsed ? 'Open sidebar' : 'Collapse sidebar'}
onClick={() => { onToggleSidebar() }}
onClick={() => { toggleSidebar() }}
>
{!wide && <FishLogo className={css.railFish} size={24} />}
{/* Rail icons render at 18 (figma rail spec); expanded keeps the glyph-native sizes. */}
@@ -202,7 +231,7 @@ export function SidebarRoot({ collapsed, width, useSessions, onOpen, onCreate, o
type="button"
className={css.newSession}
aria-label="New session"
onClick={() => { onCreate() }}
onClick={() => { startSession() }}
>
<IconNewChatOutline16 size={wide ? 14 : 18} />
{wide && <span className={clsx(css.newSessionLabel, css.wide)}>New Session</span>}
@@ -210,18 +239,29 @@ export function SidebarRoot({ collapsed, width, useSessions, onOpen, onCreate, o
</Tooltip>
<div className={css.sectionHeader}>
{wide && <span className={clsx(css.sectionLabel, css.wide)}>WorkSpace</span>}
{wide && <span className={clsx(css.sectionLabel, css.wide)}>Workspaces</span>}
{wide && <GroupByMenu />}
<Tooltip label="New Workspace" disabled={wide}>
<button
ref={wsPlusRef}
type="button"
className={css.iconButton}
aria-label="New workspace"
onClick={() => { onCreate() }}
aria-label="Create workspace"
onClick={() => { setWsPickerOpen(v => !v) }}
>
<IconProjectAddOutline16 size={wide ? 16 : 18} />
</button>
</Tooltip>
{/* Picker hole beside the (same site in wide and rail states). */}
{renderSlot('sidebar.workspace', {
open: wsPickerOpen,
anchorRef: wsPlusRef,
onPick: (workspaceId) => {
setWsPickerOpen(false)
startSession(workspaceId)
},
onClose: () => { setWsPickerOpen(false) },
})}
</div>
{/* Expanded: the row is a click-to-focus field (the leading icon is
@@ -233,7 +273,7 @@ export function SidebarRoot({ collapsed, width, useSessions, onOpen, onCreate, o
className={css.searchButton}
aria-label="Search sessions"
tabIndex={collapsed ? 0 : -1}
onClick={() => { if (collapsed) { setSearchOnExpand(true); onToggleSidebar() } }}
onClick={() => { if (collapsed) { setSearchOnExpand(true); toggleSidebar() } }}
>
<IconSearchOutline16 size={wide ? 14 : 18} />
</button>
@@ -263,7 +303,15 @@ export function SidebarRoot({ collapsed, width, useSessions, onOpen, onCreate, o
{/* Always-mounted seat: its flex slot pins the foot to the bottom in
both states while the tree itself is wide-only. */}
<div className={css.listArea}>
{wide && <SessionTree useSessions={useSessions} onOpen={onOpen} onCreate={onCreate} query={query} />}
{wide && (
<SessionTree
useSessions={useSessions}
workspaces={workspaces}
startSession={startSession}
open={open}
query={query}
/>
)}
</div>
<div className={css.foot} role="button" tabIndex={0} aria-label="Settings">

View File

@@ -1,42 +1,69 @@
/**
* Sidebar slot contract: the registrant-side props composition for the
* layout-owned `sidebar` slot. The own injected share is declared here (a
* share's type lives with whoever wires it); the runtime share — owner
* props {collapsed,width} plus the standard useSessions hook — is
* PropsRuntime<'sidebar'>, resolved off ui-layout's SlotMap declaration and
* never re-stated. Single domain — this is the package's whole contract
* surface.
* layout-owned `sidebar` slot and the Workspace picker hole declared here.
* The runtime share combines layout-owned page state and actions with the
* global useSessions and useWorkspaces hooks; the injected share adds the
* runtime navigation actions and sidebar toggle.
*/
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { RefObject } from 'react'
import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
// Type-only: pulls ui-layout's SlotMap merge (the 'sidebar' entry) into every
// program that sees this contract, so PropsRuntime<'sidebar'> resolves.
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
/**
* Registrant-private injected share (arrives via the register inject
* factory): plain cross-service callbacks only — tree data rides the
* standard useSessions hook and viewing state is component-local. A type
* alias, not an interface: the alias carries an implicit index signature,
* so the factory's return crosses the registry's `Record<string, unknown>`
* boundary uncast.
*/
export type SidebarRootInjected = {
/** Open (switch to) a session. */
onOpen: (id: SessionId) => void
/**
* New-session affordance: no cwd clears selection onto the empty-state
* launch; a cwd create-then-opens a session in that project group.
*/
onCreate: (cwd?: string) => void
/** Collapse the sidebar column (layout service action; owner share stays {collapsed,width}). */
onToggleSidebar: () => void
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
/**
* The workspace picker hole in the sidebar section header (anchored at
* the button). Declared by this package's 'sidebar' entry (declaring
* is claiming); ui-workspace registers the picker.
*/
'sidebar.workspace': { kind: 'single'; scope: 'root'; owner: SidebarWorkspaceOwnerProps }
}
}
/**
* Full component props: the framework runtime share (owner {collapsed,width}
* + standard useSessions) plus the own injected share. No children are
* declared and no store is registered, so no PropsRenderSlots/PropsStore
* term appears.
* Owner share of the sidebar workspace hole: popover geometry plus the
* sidebar's pick semantics. The picked Host Workspace is already real; the
* callback starts a frontend Session Intent targeted to it.
*/
export type SidebarRootComponentProps = PropsRuntime<'sidebar'> & SidebarRootInjected
export interface SidebarWorkspaceOwnerProps {
/** Popover visibility ( button toggle state, host-local). */
open: boolean
/**
* The button element — the popover's placement anchor. The picker's
* slot span renders elsewhere in the DOM, so without this the menu
* positions off the zero-size placement span (order-dependent). Optional
* only until the host passes it; absent falls back to in-place placement.
*/
anchorRef?: RefObject<HTMLElement>
/** Start a frontend Session in a selected or newly created real Workspace. */
onPick: (workspaceId: WorkspaceId) => void
/** Close the popover (outside click / Escape / post-pick). */
onClose: () => void
}
/**
* Registrant-private injected share (arrives via the register inject
* factory). Host Workspace and Session data use the global framework hooks;
* navigation and panel actions are plain callbacks, and viewing state remains
* component-local. A type alias supplies the implicit index signature required
* by the registry.
*/
export type SidebarRootInjected = {
/** Start or replace the current frontend Session Intent. */
startSession: (workspaceId?: WorkspaceId, prompt?: string) => void
/** Open a real Session. */
open: (sessionId: SessionId) => void
/** Toggle the sidebar column through the layout service. */
toggleSidebar: () => void
}
/**
* Full component props: layout owner state/actions plus global useSessions
* and useWorkspaces, the declared Workspace picker render share, and this
* package's injected callback. No store is registered.
*/
export type SidebarRootComponentProps =
PropsRuntime<'sidebar'> & PropsRenderSlots<'sidebar.workspace'> & SidebarRootInjected

View File

@@ -1,36 +1,30 @@
/** Registers the sidebar UI into the layout-owned slot. */
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { SidebarRootInjected } from './contract/slots.ts'
import { SidebarRoot } from './SidebarRoot.tsx'
export type { SidebarRootComponentProps, SidebarRootInjected } from './contract/slots.ts'
export type { SidebarRootComponentProps, SidebarRootInjected, SidebarWorkspaceOwnerProps } from './contract/slots.ts'
/** Services required by the sidebar plugin. */
export const inject = ['slots', 'layout', 'sessions']
export const inject = ['slots', 'layout', 'sessions', 'workspaces']
/** Registers the sidebar component and its service callbacks.
* @param ctx - Client root context.
*/
export function apply(ctx: ClientContext): void {
const injectProps = (): SidebarRootInjected => ({
// Selection belongs to the sessions service; layout owns only panel geometry.
onOpen: (id) => { ctx.sessions.open(id) },
onCreate: (cwd) => {
// Top-level New Session / New Workspace: clear selection so AppFrame
// shows conversation.empty (EmptyState + shared InputBar). Per-project
// "+" still create-then-opens into that cwd until workspace seeding
// reaches the empty-state picker.
if (cwd === undefined) {
ctx.sessions.clear()
return
}
void ctx.sessions.create({ cwd })
.then((id: SessionId) => { ctx.sessions.open(id) })
},
onToggleSidebar: () => { ctx.layout.toggleSidebar() },
startSession: (workspaceId, prompt) => { ctx.workspaces.startSession(workspaceId, prompt) },
open: (sessionId) => { ctx.sessions.open(sessionId) },
toggleSidebar: () => { ctx.layout.toggleSidebar() },
})
ctx.effect(
() => ctx.slots.register({ name: 'sidebar', inject: injectProps }, SidebarRoot),
() => ctx.slots.register({
name: 'sidebar',
// SidebarRoot owns this picker site; ui-workspace registers the shared
// picker that selects a Host Workspace for a frontend Session Intent.
children: { 'sidebar.workspace': { kind: 'single', scope: 'root' } },
inject: injectProps,
}, SidebarRoot),
'ui-sidebar: slot registration',
)
}

View File

@@ -1,41 +1,46 @@
/** Pure derivation of flat sidebar rows from sessions and local view state. */
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
/**
* Derives the sidebar tree from Host Workspace order and membership.
* Unassigned Sessions trail under Ungrouped; only Intents targeting real Workspaces render.
*/
import type { SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
/** Group key for sessions without a project directory. */
/** Group key for Sessions outside every Workspace. */
export const UNGROUPED_KEY = ''
/** Display label for the ungrouped project row. */
/** Display label for the ungrouped bucket row. */
export const UNGROUPED_LABEL = 'Ungrouped'
/** Project (workspace) row: 54px, two lines (label + session count). */
export interface ProjectRow {
type: 'project'
/** Group key: the cwd, or {@link UNGROUPED_KEY}. */
key: string
cwd: string | undefined
label: string
/** Total sessions in the group, including hidden ones. */
sessionCount: number
expanded: boolean
}
/** Session row: 34px single line; depth drives the 22px indent steps. */
export interface SessionRow {
type: 'session'
/** One session node of a group's visible tree (34px row; children render indented one step). */
export interface SessionNode {
id: SessionId
/** Owning project group key (selection -> active-folder lookup). */
groupKey: string
title: string
/** 0 = directly under the project row. */
depth: number
/** Visible children, already expansion/search-filtered (empty when folded). */
children: readonly SessionNode[]
/** The session HAS children in the data (the twist renders even while folded). */
hasChildren: boolean
expanded: boolean
running: boolean
updatedAt: number
}
/** One flat sidebar list row. */
export type SidebarRow = ProjectRow | SessionRow
/** One workspace group section: header row facts + the visible session tree. */
export interface GroupNode {
/** Group key: the workspace id or {@link UNGROUPED_KEY}. */
key: string
/** Backing Workspace id; absent only for the ungrouped bucket. */
workspaceId: WorkspaceId | undefined
cwd: string | undefined
label: string
/** Total sessions in the group, including hidden ones. */
sessionCount: number
expanded: boolean
/** The group contains the selected session (active folder tint; supplied here so the renderer never scans). */
containsCurrent: boolean
/** The frontend Session Intent points here: render one "New session" row. */
intentHere: boolean
/** Visible roots (empty while the group is folded). */
sessions: readonly SessionNode[]
}
/** Viewing state consumed by the derivation — the component's local useState arrays, taken as-is. */
export interface TreeView {
@@ -46,17 +51,18 @@ export interface TreeView {
interface Group {
key: string
workspaceId: WorkspaceId | undefined
cwd: string | undefined
label: string
summaries: Map<SessionId, SessionSummary>
roots: SessionId[]
children: Map<SessionId, SessionId[]>
latest: number
}
/**
* Project display label: basename of the group directory.
* @param cwd - project directory, or undefined for the ungrouped bucket.
* Directory display label: basename of the path (both separators accepted).
* Ungrouped-bucket fallback for surfaces without a workspace title.
* @param cwd - directory path, or undefined for the ungrouped bucket.
* @returns basename, the raw cwd when it has no basename, or the ungrouped label.
*/
export function projectLabel(cwd: string | undefined): string {
@@ -71,32 +77,32 @@ function byRecency(a: SessionSummary, b: SessionSummary): number {
return a.id < b.id ? -1 : 1
}
function groupByCwd(list: SessionListState): Group[] {
const byKey = new Map<string, SessionSummary[]>()
for (const id of list.ids) {
const s = list.byId[id]
if (s === undefined) continue
const key = s.cwd ?? UNGROUPED_KEY
const members = byKey.get(key)
if (members === undefined) byKey.set(key, [s])
else members.push(s)
}
const groups: Group[] = []
for (const [key, members] of byKey) {
const summaries = new Map(members.map(m => [m.id, m]))
const children = new Map<SessionId, SessionId[]>()
const roots: SessionSummary[] = []
for (const m of members) {
// A session is a tree child only when its parent lives in the same
// group; cross-group or unknown parents degrade to group roots.
if (m.parentId !== undefined && m.parentId !== m.id && summaries.has(m.parentId)) {
const kids = children.get(m.parentId)
if (kids === undefined) children.set(m.parentId, [m.id])
else kids.push(m.id)
} else {
roots.push(m)
}
/** Build one group's parent/child tree from an ordered member list. */
function buildGroup(
key: string,
workspaceId: WorkspaceId | undefined,
cwd: string | undefined,
label: string,
members: readonly SessionSummary[],
order: 'account' | 'recency',
): Group {
const summaries = new Map(members.map(m => [m.id, m]))
const children = new Map<SessionId, SessionId[]>()
const roots: SessionSummary[] = []
for (const m of members) {
// A session is a tree child only when its parent lives in the same
// group; cross-group or unknown parents degrade to group roots.
if (m.parentId !== undefined && m.parentId !== m.id && summaries.has(m.parentId)) {
const kids = children.get(m.parentId)
if (kids === undefined) children.set(m.parentId, [m.id])
else kids.push(m.id)
} else {
roots.push(m)
}
}
// Workspace order is the member iteration order (workspace.sessionIds), so
// attached groups keep insertion order; Ungrouped sorts by recency.
if (order === 'recency') {
roots.sort(byRecency)
for (const kids of children.values()) {
kids.sort((a, b) => {
@@ -107,48 +113,63 @@ function groupByCwd(list: SessionListState): Group[] {
return byRecency(sa, sb)
})
}
const rootIds = roots.map(r => r.id)
// parentId cycles (host bug) leave members unreachable from any root;
// surface them as extra roots — the flatten walk's visited set stops
// loops. Each node sits in at most one kids list and roots have no
// in-group parent, so the scan pushes every reachable node exactly once.
const reachable = new Set<SessionId>(rootIds)
const stack = [...rootIds]
while (stack.length > 0) {
const top = stack.pop()
/* v8 ignore next -- unreachable: the loop condition guarantees a non-empty stack. */
if (top === undefined) break
for (const kid of children.get(top) ?? []) {
reachable.add(kid)
stack.push(kid)
}
}
for (const m of [...members].sort(byRecency)) {
if (!reachable.has(m.id)) rootIds.push(m.id)
}
let latest = 0
for (const m of members) latest = Math.max(latest, m.updatedAt)
groups.push({
key,
cwd: key === UNGROUPED_KEY ? undefined : key,
label: projectLabel(key === UNGROUPED_KEY ? undefined : key),
summaries,
roots: rootIds,
children,
latest,
})
}
groups.sort((a, b) => b.latest - a.latest || (a.label < b.label ? -1 : a.label > b.label ? 1 : 0))
const rootIds = roots.map(r => r.id)
// parentId cycles (host bug) leave members unreachable from any root;
// surface them as extra roots — the flatten walk's visited set stops
// loops. Each node sits in at most one kids list and roots have no
// in-group parent, so the scan pushes every reachable node exactly once.
const reachable = new Set<SessionId>(rootIds)
const stack = [...rootIds]
while (stack.length > 0) {
const top = stack.pop()
/* v8 ignore next -- unreachable: the loop condition guarantees a non-empty stack. */
if (top === undefined) break
for (const kid of children.get(top) ?? []) {
reachable.add(kid)
stack.push(kid)
}
}
for (const m of members) {
if (!reachable.has(m.id)) rootIds.push(m.id)
}
return { key, workspaceId, cwd, label, summaries, roots: rootIds, children }
}
/**
* Group Sessions by Host Workspace: one group per entity in stable Host
* order, with members resolved from sessionIds in their stored order. Sessions
* outside every Workspace trail in the recency-ordered Ungrouped bucket.
*/
function groupByWorkspace(list: SessionListState, workspaces: readonly WorkspaceView[]): Group[] {
const groups: Group[] = []
const accounted = new Set<SessionId>()
for (const workspace of workspaces) {
const members: SessionSummary[] = []
for (const id of workspace.sessionIds) {
const summary = list.byId[id]
if (summary === undefined) continue // account may lead the list pull; the row appears when the summary lands
members.push(summary)
accounted.add(id)
}
groups.push(buildGroup(
workspace.workspaceId, workspace.workspaceId, workspace.path, workspace.title, members, 'account',
))
}
const stray = list.ids
.map(id => list.byId[id])
.filter((s): s is SessionSummary => s !== undefined && !accounted.has(s.id))
if (stray.length > 0) {
groups.push(buildGroup(UNGROUPED_KEY, undefined, undefined, UNGROUPED_LABEL, stray, 'recency'))
}
return groups
}
function sessionRow(g: Group, s: SessionSummary, depth: number, hasChildren: boolean, expanded: boolean): SessionRow {
function sessionNode(s: SessionSummary, children: readonly SessionNode[], hasChildren: boolean, expanded: boolean): SessionNode {
return {
type: 'session',
id: s.id,
groupKey: g.key,
title: s.displayTitle,
depth,
children,
hasChildren,
expanded,
running: s.running,
@@ -156,20 +177,20 @@ function sessionRow(g: Group, s: SessionSummary, depth: number, hasChildren: boo
}
}
function flattenVisible(g: Group, expandedSessions: ReadonlySet<string>, rows: SidebarRow[]): void {
function buildVisible(g: Group, expandedSessions: ReadonlySet<string>): SessionNode[] {
const visited = new Set<SessionId>()
const walk = (id: SessionId, depth: number): void => {
if (visited.has(id)) return
const walk = (id: SessionId): SessionNode | null => {
if (visited.has(id)) return null
visited.add(id)
const s = g.summaries.get(id)
/* v8 ignore next -- unreachable: walked ids come from the grouped summaries. */
if (s === undefined) return
if (s === undefined) return null
const kids = g.children.get(id) ?? []
const expanded = expandedSessions.has(id)
rows.push(sessionRow(g, s, depth, kids.length > 0, expanded))
if (expanded) for (const kid of kids) walk(kid, depth + 1)
const children = expanded ? kids.map(walk).filter((n): n is SessionNode => n !== null) : []
return sessionNode(s, children, kids.length > 0, expanded)
}
for (const root of g.roots) walk(root, 0)
return g.roots.map(walk).filter((n): n is SessionNode => n !== null)
}
/** Matched sessions plus their ancestor chains (forced visible under search). */
@@ -186,66 +207,98 @@ function searchVisible(g: Group, q: string): Set<SessionId> {
return visible
}
function flattenSearch(g: Group, visible: ReadonlySet<SessionId>, rows: SidebarRow[]): void {
function buildSearch(g: Group, visible: ReadonlySet<SessionId>): SessionNode[] {
const visited = new Set<SessionId>()
const walk = (id: SessionId, depth: number): void => {
if (visited.has(id) || !visible.has(id)) return
const walk = (id: SessionId): SessionNode | null => {
if (visited.has(id) || !visible.has(id)) return null
visited.add(id)
const s = g.summaries.get(id)
/* v8 ignore next -- unreachable: walked ids come from the grouped summaries. */
if (s === undefined) return
if (s === undefined) return null
const kids = (g.children.get(id) ?? []).filter(kid => visible.has(kid))
rows.push(sessionRow(g, s, depth, kids.length > 0, kids.length > 0))
for (const kid of kids) walk(kid, depth + 1)
const children = kids.map(walk).filter((n): n is SessionNode => n !== null)
return sessionNode(s, children, kids.length > 0, kids.length > 0)
}
for (const root of g.roots) walk(root, 0)
return g.roots.map(walk).filter((n): n is SessionNode => n !== null)
}
/**
* Derive the flat sidebar row list.
* Derive the nested sidebar group structure.
*
* Normal mode: every project row shows; sessions show under expanded
* projects, descending only into expanded sessions. Search mode (non-blank
* query, case-insensitive display-title substring): expansion state is ignored —
* Normal mode: every group shows; sessions populate under expanded groups,
* descending only into expanded sessions. A frontend Session Intent targeting
* a real Workspace marks that group `intentHere` and forces it expanded. Search mode (non-blank query,
* case-insensitive display-title substring): expansion state is ignored —
* matched sessions and their ancestor chains are forced visible, groups
* without a display-title or label hit are dropped, and a label-only hit keeps the
* bare project row.
* @param list - sessions list snapshot.
* without a display-title or label hit are dropped, a label-only hit keeps
* the bare group header, and Intent rows do not participate.
* @param list - sessions list snapshot (`current` feeds containsCurrent).
* @param workspaces - real workspaces in stable Host order.
* @param view - local expansion arrays and search query.
* @returns rows in render order.
* @returns group sections in render order.
*/
export function deriveRows(list: SessionListState, view: TreeView): SidebarRow[] {
export function deriveGroups(
list: SessionListState,
workspaces: readonly WorkspaceView[],
view: TreeView,
): GroupNode[] {
const q = view.query.trim().toLowerCase()
const expandedProjects = new Set(view.expandedProjects)
const expandedSessions = new Set(view.expandedSessions)
const rows: SidebarRow[] = []
for (const g of groupByCwd(list)) {
const intent = list.intent
const intentWorkspaceId = intent?.target.kind === 'workspace'
? intent.target.workspaceId
: undefined
const currentAccount = list.current === undefined
? undefined
: workspaces.find(w => w.sessionIds.includes(list.current as SessionId))?.workspaceId as string | undefined
const currentGroup = list.current === undefined
? undefined
: intent?.sessionId === list.current
? intentWorkspaceId
: currentAccount ?? UNGROUPED_KEY
const groups: GroupNode[] = []
for (const g of groupByWorkspace(list, workspaces)) {
const hasIntent = intentWorkspaceId !== undefined
&& g.workspaceId !== undefined && intentWorkspaceId === g.workspaceId
const intentHere = q === '' && hasIntent
if (q === '') {
const expanded = expandedProjects.has(g.key)
rows.push({ type: 'project', key: g.key, cwd: g.cwd, label: g.label, sessionCount: g.summaries.size, expanded })
if (expanded) flattenVisible(g, expandedSessions, rows)
const expanded = intentHere || expandedProjects.has(g.key)
groups.push({
key: g.key,
workspaceId: g.workspaceId,
cwd: g.cwd,
label: g.label,
sessionCount: g.summaries.size + (hasIntent ? 1 : 0),
expanded,
containsCurrent: g.key === currentGroup,
intentHere,
sessions: expanded ? buildVisible(g, expandedSessions) : [],
})
} else {
const visible = searchVisible(g, q)
if (visible.size === 0 && !g.label.toLowerCase().includes(q)) continue
rows.push({
type: 'project',
groups.push({
key: g.key,
workspaceId: g.workspaceId,
cwd: g.cwd,
label: g.label,
sessionCount: g.summaries.size,
sessionCount: g.summaries.size + (hasIntent ? 1 : 0),
expanded: visible.size > 0,
containsCurrent: g.key === currentGroup,
intentHere: false,
sessions: buildSearch(g, visible),
})
flattenSearch(g, visible, rows)
}
}
return rows
return groups
}
/**
* Relative time label for session rows (figma samples: now / 2min / 1h / 2d / 18d / 2mo).
* @param updatedAt - epoch ms of the last update.
* @param now - current epoch ms.
* @returns compact age label.
* Compact relative time for session rows ("now", "5min", "3h", "2d", "4mo", "1y").
* @param updatedAt - epoch ms of the session's last activity.
* @param now - current epoch ms (injected for pure rendering).
* @returns the row's trailing time label.
*/
export function formatRelativeTime(updatedAt: number, now: number): string {
const MIN = 60_000

View File

@@ -1,117 +1,60 @@
/**
* apply wiring on a real cordis Context + SlotsService (terminal register
* form): SidebarRoot registered into the layout-declared sidebar slot, the
* thin inject surface (three plain service callbacks closed over the plugin
* ctx — no hooks, no store lines), load-order fail-loud, and fiber-teardown
* unregistration. Component behavior is covered props-direct in
* sidebar-root.spec.tsx; no renderer machinery here.
*/
/** Sidebar slot registration and its plain runtime/layout callbacks. */
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-sidebar/client'
import type { SidebarRootInjected } from '@deepseek-ai/dsh-client-ui-sidebar/client'
// Type-only: ui-layout's SlotMap merge so the sidebar slot key typechecks.
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
const sid = (s: string) => s as SessionId
async function bench() {
async function bench(declare = true) {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
const list = createSnapshotStore<SessionListState>({
ids: [sid('a')],
byId: { [sid('a')]: { id: sid('a'), title: 'alpha', displayTitle: 'alpha', cwd: '/proj', running: false, updatedAt: 1 } },
current: undefined,
})
const sessions = {
list,
create: vi.fn(async () => sid('minted')),
open: vi.fn(),
clear: vi.fn(),
}
const layout = { toggleSidebar: vi.fn() }
ctx.provide('sessions', sessions)
const sessions = { open: vi.fn() }
const workspaces = { startSession: vi.fn() }
ctx.provide('layout', layout)
ctx.provide('sessions', sessions as never)
ctx.provide('workspaces', workspaces as never)
const slots = ctx.get('slots') as SlotsService
// The sidebar slot exists only while its declaring entry is live.
slots.register(
{ name: 'root', children: { 'sidebar': { kind: 'single', scope: 'root' } } } as never,
() => null,
)
return { ctx, slots, sessions, layout }
if (declare) {
slots.register(
{ name: 'root', children: { 'sidebar': { kind: 'single', scope: 'root' } } } as never,
() => null,
)
}
return { ctx, slots, layout, sessions, workspaces }
}
/** The sidebar entry's injected share, read off the stored entry. */
function injectedOf(slots: SlotsService): SidebarRootInjected {
const entries = slots.entries('sidebar')
expect(entries).toHaveLength(1)
// The typed StoredEntry.inject is declaration-derived ((...args: never[])
// shape); the sidebar factory is parameterless, so the call is safe here.
const inject = entries[0]!.inject as (() => SidebarRootInjected) | undefined
return inject!()
}
describe('apply', () => {
it('declares the services it binds', () => {
expect(inject).toEqual(['slots', 'layout', 'sessions'])
describe('ui-sidebar apply', () => {
it('declares only the services it uses', () => {
expect(inject).toEqual(['slots', 'layout', 'sessions', 'workspaces'])
})
it('fails loud when mounted without the inject declaration', async () => {
// ctx.slots rides the cordis property proxy: reading it from a plugin
// that never declared the dependency throws instead of yielding undefined.
const ctx = new Context()
await ctx.plugin(SlotsService).await()
await expect(ctx.plugin({ apply })).rejects.toThrow(/without inject/)
it('registers the sidebar and declares its Workspace picker hole', async () => {
const b = await bench()
await b.ctx.plugin({ inject: [...inject], apply }).await()
expect(b.slots.entries('sidebar')).toHaveLength(1)
expect(b.slots.spec('sidebar.workspace')).toEqual({ kind: 'single', scope: 'root' })
const injected = (b.slots.entries('sidebar')[0]!.inject as () => SidebarRootInjected)()
expect(Object.keys(injected)).toEqual(['startSession', 'open', 'toggleSidebar'])
injected.startSession('workspace' as never, 'prompt')
expect(b.workspaces.startSession).toHaveBeenCalledWith('workspace', 'prompt')
injected.open('session' as never)
expect(b.sessions.open).toHaveBeenCalledWith('session')
injected.toggleSidebar()
expect(b.layout.toggleSidebar).toHaveBeenCalledOnce()
})
it('fails loud when no live entry has declared the sidebar slot', async () => {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
ctx.provide('sessions', {})
ctx.provide('layout', {})
await expect(ctx.plugin({ inject: [...inject], apply })).rejects.toThrow(/slot "sidebar" is not declared/)
it('fails when no live owner declared the sidebar slot', async () => {
const b = await bench(false)
await expect(b.ctx.plugin({ inject: [...inject], apply })).rejects.toThrow(/not declared/)
})
it('registers SidebarRoot with the thin three-callback inject surface', async () => {
const { ctx, slots } = await bench()
await ctx.plugin({ inject: [...inject], apply }).await()
const injected = injectedOf(slots)
// The whole business face: three plain callbacks, no hooks, no store lines.
expect(Object.keys(injected).sort()).toEqual(['onCreate', 'onOpen', 'onToggleSidebar'])
})
it('routes the callbacks to the layout/sessions services', async () => {
const { ctx, slots, sessions, layout } = await bench()
await ctx.plugin({ inject: [...inject], apply }).await()
const injected = injectedOf(slots)
injected.onToggleSidebar()
expect(layout.toggleSidebar).toHaveBeenCalledOnce()
injected.onOpen(sid('a'))
expect(sessions.open).toHaveBeenCalledWith('a')
injected.onCreate()
expect(sessions.clear).toHaveBeenCalledOnce()
expect(sessions.create).not.toHaveBeenCalled()
injected.onCreate('/proj')
expect(sessions.create).toHaveBeenCalledWith({ cwd: '/proj' })
// create-then-open lands after the create promise resolves.
await Promise.resolve()
await Promise.resolve()
expect(sessions.open).toHaveBeenCalledWith('minted')
})
it('teardown unregisters the slot entry', async () => {
const { ctx, slots } = await bench()
const fiber = ctx.plugin({ inject: [...inject], apply })
it('removes the entry and child declaration on teardown', async () => {
const b = await bench()
const fiber = b.ctx.plugin({ inject: [...inject], apply })
await fiber.await()
expect(slots.entries('sidebar')).toHaveLength(1)
await fiber.dispose()
expect(slots.entries('sidebar')).toHaveLength(0)
expect(b.slots.entries('sidebar')).toHaveLength(0)
expect(b.slots.spec('sidebar.workspace')).toBeUndefined()
})
})

View File

@@ -1,292 +1,82 @@
// @vitest-environment jsdom
/**
* SidebarRoot interaction spec, props-direct (slot-parity test doctrine:
* components are fed composed props, no assembly machinery). The standard
* useSessions hook is stubbed with a real web-react SnapshotStore selector;
* expansion/search live inside the component, so all viewing behavior is
* driven through the DOM. Covers expand/collapse, subtree unfold, search
* filtering, row activation, and the creation entries.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { act, useSyncExternalStore } from 'react'
// Runtime is React-free, so the spec binds its selector locally.
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
import type {
SessionId, SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { SidebarRootComponentProps } from '../src/client/contract/slots.ts'
import { SidebarRoot } from '../src/client/SidebarRoot.tsx'
/** Minimal selector hook over an engine store (production binding lives in the renderer). */
function hookOf<T>(src: { getSnapshot(): T; subscribe(fn: () => void): () => void }) {
return <S,>(sel: (s: T) => S, _eq?: (a: S, b: S) => boolean): S =>
sel(useSyncExternalStore(src.subscribe.bind(src), src.getSnapshot.bind(src)))
}
const sid = (s: string) => s as SessionId
/** Bare-string init; brands ids and omits absent optional keys (exactOptionalPropertyTypes). */
interface SummaryInit {
id: string
title?: string
cwd?: string
parentId?: string
running?: boolean
updatedAt?: number
}
function summary(init: SummaryInit): SessionSummary {
const s: SessionSummary = {
id: sid(init.id),
title: init.title ?? init.id,
displayTitle: init.title ?? init.id,
running: init.running ?? false,
updatedAt: init.updatedAt ?? 0,
}
if (init.cwd !== undefined) s.cwd = init.cwd
if (init.parentId !== undefined) s.parentId = sid(init.parentId)
return s
}
function listStateOf(...summaries: SessionSummary[]): SessionListState {
const byId: Record<SessionId, SessionSummary> = {}
for (const s of summaries) byId[s.id] = s
return { ids: summaries.map((s) => s.id), byId, current: undefined }
}
afterEach(cleanup)
function mount(...summaries: SessionSummary[]) {
// Real engine store as the useSessions stub: same uSES selector shape the
// framework delivers, so list updates re-render exactly like production.
const sessions = createSnapshotStore<SessionListState>(listStateOf(...summaries))
const onOpen = vi.fn((id: SessionId) => { sessions.update((d) => { d.current = id }) })
const onCreate = vi.fn()
// The owner decides collapsed in production (AppFrame maps the preference);
// the harness mirrors that loop so the toggle drives a re-render.
let collapsed = false
const view = (width: number) => (
<SidebarRoot
collapsed={collapsed}
width={width}
useSessions={hookOf(sessions)}
onOpen={onOpen}
onCreate={onCreate}
onToggleSidebar={onToggleSidebar}
/>
)
const onToggleSidebar = vi.fn(() => {
collapsed = !collapsed
utils.rerender(view(collapsed ? 56 : 300))
})
const utils = render(view(300))
return { sessions, onOpen, onCreate, onToggleSidebar, ...utils }
const sid = (id: string) => id as SessionId
const wid = (id: string) => id as WorkspaceId
const hook = <T,>(snapshot: T) => <S,>(selector: (state: T) => S): S => selector(snapshot)
const workspace: WorkspaceView = {
workspaceId: wid('project'), path: '/projects/project', title: 'Project', sessionIds: [sid('s1')],
createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
}
const sessions: SessionListState = {
ids: [sid('s1')],
byId: { [sid('s1')]: { id: sid('s1'), displayTitle: 'First session', running: false, updatedAt: 1 } },
current: undefined, phase: 'ready',
intent: undefined,
}
const workspaces: WorkspaceListState = {
items: [workspace], state: 'idle', phase: 'ready', error: null,
intent: undefined, baselinesReady: true, recentWorkspaceId: workspace.workspaceId,
}
const projectData = () => [
summary({ id: 'root', title: 'root work', cwd: '/proj', updatedAt: 5 }),
summary({ id: 'kid', title: 'forked child', cwd: '/proj', parentId: sid('root'), updatedAt: 4 }),
summary({ id: 'lone', title: 'elsewhere', cwd: '/other', updatedAt: 3 }),
]
/** Flush the store's microtask-batched notification into React. */
const flush = async () => { await act(async () => { await Promise.resolve() }) }
/** The brand wordmark is decorative svg (aria-hidden, no text); locate it by its native viewBox. */
const wordmark = () => document.querySelector('svg[viewBox="0 0 182 24"]')
function mount(sessionState: SessionListState = sessions) {
const startSession = vi.fn()
const open = vi.fn()
let pickerOwner: unknown
const view = render(
<SidebarRoot
collapsed={false} width={300}
useSessions={hook(sessionState)} useWorkspaces={hook(workspaces)}
startSession={startSession} open={open} toggleSidebar={vi.fn()}
renderSlot={((_key: string, owner: unknown) => { pickerOwner = owner; return null }) as SidebarRootComponentProps['renderSlot']}
/>,
)
return { view, startSession, open, pickerOwner: () => pickerOwner }
}
describe('SidebarRoot', () => {
it('renders chrome and collapsed project rows', () => {
mount(...projectData())
expect(wordmark()).not.toBeNull()
expect(screen.getByText('New Session')).toBeTruthy()
expect(screen.getByText('proj')).toBeTruthy()
expect(screen.getByText('2 sessions')).toBeTruthy()
expect(screen.getByText('1 session')).toBeTruthy()
expect(screen.queryByText('root work')).toBeNull()
it('renders real Workspaces from useWorkspaces and routes New Session', () => {
const b = mount()
expect(screen.getByText('Project')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'New session' }))
expect(b.startSession).toHaveBeenCalledWith()
})
it('expands a project on click and unfolds a subtree via the twist', () => {
mount(...projectData())
act(() => { fireEvent.click(screen.getByText('proj')) })
expect(screen.getByText('root work')).toBeTruthy()
expect(screen.queryByText('forked child')).toBeNull()
act(() => { fireEvent.click(screen.getByLabelText('Expand')) })
expect(screen.getByText('forked child')).toBeTruthy()
act(() => { fireEvent.click(screen.getByLabelText('Collapse')) })
expect(screen.queryByText('forked child')).toBeNull()
})
it('opens a session on row click and marks it selected', async () => {
const { onOpen } = mount(...projectData())
act(() => { fireEvent.click(screen.getByText('proj')) })
act(() => { fireEvent.click(screen.getByText('root work')) })
expect(onOpen).toHaveBeenCalledWith('root')
// The mock routed the open into sessions.current — highlight follows.
await flush()
expect(screen.getByText('root work').closest('[role="treeitem"]')!.getAttribute('aria-selected')).toBe('true')
})
it('search filters across groups and forces ancestor chains visible', () => {
mount(...projectData())
const input = screen.getByPlaceholderText('Search name, keywords...')
act(() => { fireEvent.change(input, { target: { value: 'forked' } }) })
expect(screen.getByText('forked child')).toBeTruthy()
expect(screen.getByText('root work')).toBeTruthy()
expect(screen.queryByText('elsewhere')).toBeNull()
expect(screen.queryByText(/^other$/)).toBeNull()
act(() => { fireEvent.click(screen.getByLabelText('Clear search')) })
expect(screen.queryByText('root work')).toBeNull()
expect(screen.getByText('proj')).toBeTruthy()
})
it('shows the blank-list empty state without a query', () => {
mount()
expect(screen.getByText('No sessions yet')).toBeTruthy()
})
it('shows the no-match empty state', () => {
mount(...projectData())
const input = screen.getByPlaceholderText('Search name, keywords...')
act(() => { fireEvent.change(input, { target: { value: 'zzz-none' } }) })
expect(screen.getByText('No matches')).toBeTruthy()
})
it('routes the three creation entries with the right cwd', () => {
const { onCreate } = mount(...projectData())
act(() => { fireEvent.click(screen.getByText('New Session')) })
expect(onCreate).toHaveBeenLastCalledWith()
act(() => { fireEvent.click(screen.getByLabelText('New workspace')) })
expect(onCreate).toHaveBeenLastCalledWith()
// Per-project "+" is hover-revealed by CSS; still clickable in jsdom.
act(() => { fireEvent.click(screen.getAllByLabelText('New session here')[0]!) })
expect(onCreate).toHaveBeenLastCalledWith('/proj')
})
it('collapse fades the wide content out, then the rail keeps the four controls', () => {
vi.useFakeTimers()
try {
const { onToggleSidebar, onCreate } = mount(...projectData())
act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) })
expect(onToggleSidebar).toHaveBeenCalledOnce()
// Fade window: the wide chrome is still mounted while it fades.
expect(wordmark()).not.toBeNull()
expect(screen.getByRole('tree')).toBeTruthy()
// Settle: wide content unmounts, the rail controls remain.
act(() => { vi.advanceTimersByTime(300) })
expect(wordmark()).toBeNull()
expect(screen.queryByText('New Session')).toBeNull()
expect(screen.queryByRole('tree')).toBeNull()
// Rail order mirrors the expanded rows: open, new session, new workspace, search.
const rail = ['Open sidebar', 'New session', 'New workspace', 'Search sessions', 'Settings']
.map((label) => screen.getByLabelText(label))
for (let i = 1; i < rail.length; i++) {
expect(rail[i - 1]!.compareDocumentPosition(rail[i]!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
}
// Rail creation entries route like their expanded counterparts.
act(() => { fireEvent.click(screen.getByLabelText('New session')) })
expect(onCreate).toHaveBeenLastCalledWith()
act(() => { fireEvent.click(screen.getByLabelText('Open sidebar')) })
expect(onToggleSidebar).toHaveBeenCalledTimes(2)
expect(screen.getByLabelText('Collapse sidebar')).toBeTruthy()
expect(screen.getByText('New Session')).toBeTruthy()
} finally {
vi.useRealTimers()
}
})
it('rail search expands the sidebar and focuses the search box', () => {
vi.useFakeTimers()
try {
const { onToggleSidebar } = mount(...projectData())
// While expanded the search control is inert (the row click focuses instead).
act(() => { fireEvent.click(screen.getByLabelText('Search sessions')) })
expect(onToggleSidebar).not.toHaveBeenCalled()
act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) })
act(() => { vi.advanceTimersByTime(300) })
act(() => { fireEvent.click(screen.getByLabelText('Search sessions')) })
expect(onToggleSidebar).toHaveBeenCalledTimes(2)
// Focus waits out the 300ms column slide (EXPAND_SLIDE_MS).
act(() => { vi.advanceTimersByTime(300) })
const input = screen.getByPlaceholderText('Search name, keywords...')
expect(document.activeElement).toBe(input)
} finally {
vi.useRealTimers()
}
})
it('expanded search focuses without toggling the sidebar', () => {
const { onToggleSidebar } = mount(...projectData())
const input = screen.getByPlaceholderText('Search name, keywords...')
act(() => { fireEvent.click(screen.getByLabelText('Search sessions')) })
expect(document.activeElement).toBe(input)
expect(onToggleSidebar).not.toHaveBeenCalled()
})
it('the search query survives a collapse/expand round trip', () => {
vi.useFakeTimers()
try {
mount(...projectData())
const input = screen.getByPlaceholderText('Search name, keywords...')
act(() => { fireEvent.change(input, { target: { value: 'forked' } }) })
act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) })
act(() => { vi.advanceTimersByTime(300) })
act(() => { fireEvent.click(screen.getByLabelText('Open sidebar')) })
const restored = screen.getByPlaceholderText('Search name, keywords...') as HTMLInputElement
expect(restored.value).toBe('forked')
expect(screen.getByText('forked child')).toBeTruthy()
expect(screen.queryByText('elsewhere')).toBeNull()
} finally {
vi.useRealTimers()
}
})
it('group-by menu behaves', () => {
mount(...projectData())
expect(screen.queryByText('Update')).toBeNull()
act(() => { fireEvent.click(screen.getByLabelText('Group by')) })
expect(screen.getByText('Update')).toBeTruthy()
expect(screen.getByText('Status')).toBeTruthy()
// Selecting the active strategy closes the list (only workspace is enabled).
act(() => { fireEvent.click(screen.getByText('WorkSpace', { selector: 'button *' })) })
expect(screen.queryByText('Update')).toBeNull()
// Reopen and dismiss via Escape (Menu onClose channel).
act(() => { fireEvent.click(screen.getByLabelText('Group by')) })
act(() => { fireEvent.keyDown(document, { key: 'Escape' }) })
expect(screen.queryByText('Update')).toBeNull()
})
it('re-renders when the sessions list gains a session', async () => {
const { sessions } = mount(...projectData())
act(() => {
sessions.update((draft) => {
draft.ids.push(sid('fresh'))
draft.byId[sid('fresh')] = summary({ id: 'fresh', title: 'brand new', cwd: '/fresh', updatedAt: 99 })
})
it('shows a frontend Session under its real Workspace and routes its row plus', () => {
const intent = { sessionId: sid('intent'), target: { kind: 'workspace' as const, workspaceId: workspace.workspaceId }, prompt: '', phase: 'connecting' as const }
const b = mount({
...sessions,
current: intent.sessionId,
intent,
})
// Store notifications are microtask-batched.
await flush()
expect(screen.getByText('fresh')).toBeTruthy()
expect(screen.getByText('New session')).toBeTruthy()
expect(screen.getByText('2 sessions')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'New session in Project' }))
expect(b.startSession).toHaveBeenCalledWith(workspace.workspaceId)
})
it('row "More" anchors swallow the click without opening or toggling', () => {
const { onOpen } = mount(...projectData())
act(() => { fireEvent.click(screen.getByText('proj')) })
// Project-row anchor: must not collapse the project (rows stay visible).
act(() => { fireEvent.click(screen.getAllByLabelText('More')[0]!) })
expect(screen.getByText('root work')).toBeTruthy()
// Session-row anchor: must not open the session.
act(() => { fireEvent.click(screen.getAllByLabelText('More')[1]!) })
expect(onOpen).not.toHaveBeenCalled()
it('forwards Workspace picker selection and closes the picker', () => {
const b = mount()
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
const owner = b.pickerOwner() as { open: boolean; onPick(id: WorkspaceId): void }
expect(owner.open).toBe(true)
owner.onPick(workspace.workspaceId)
expect(b.startSession).toHaveBeenCalledWith(workspace.workspaceId)
})
it('shows the running state dot only for running sessions', () => {
mount(
summary({ id: 'busy', title: 'busy one', cwd: '/p', running: true, updatedAt: 2 }),
summary({ id: 'idle', title: 'idle one', cwd: '/p', updatedAt: 1 }),
)
act(() => { fireEvent.click(screen.getByText('p')) })
const busyRow = screen.getByText('busy one').closest('[role="treeitem"]')!
const idleRow = screen.getByText('idle one').closest('[role="treeitem"]')!
expect(busyRow.querySelector('[data-state="ongoing"]')).toBeTruthy()
expect(idleRow.querySelector('[data-state="ongoing"]')).toBeNull()
it('opens a real Session through the owner action', () => {
const b = mount({ ...sessions, current: sid('intent'), intent: {
sessionId: sid('intent'), target: { kind: 'workspace', workspaceId: workspace.workspaceId }, prompt: '', phase: 'ready',
} })
fireEvent.click(screen.getByText('Project'))
fireEvent.click(screen.getByText('First session'))
expect(b.open).toHaveBeenCalledWith(sid('s1'))
})
})

View File

@@ -1,245 +1,74 @@
import { describe, expect, it } from 'vitest'
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
import {
deriveRows, formatRelativeTime, projectLabel, UNGROUPED_KEY, UNGROUPED_LABEL,
type SessionRow, type TreeView,
} from '../src/client/tree.ts'
import type {
SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
import { deriveGroups, formatRelativeTime, UNGROUPED_KEY } from '../src/client/tree.ts'
const sid = (s: string) => s as SessionId
/** Bare-string init; brands ids and omits absent optional keys (exactOptionalPropertyTypes). */
interface SummaryInit {
id: string
title?: string
displayTitle?: string
cwd?: string
parentId?: string
running?: boolean
updatedAt?: number
}
function summary(init: SummaryInit): SessionSummary {
const s: SessionSummary = {
id: sid(init.id),
displayTitle: init.displayTitle ?? init.title ?? init.id,
running: init.running ?? false,
updatedAt: init.updatedAt ?? 0,
}
if (init.title !== undefined) s.title = init.title
if (init.cwd !== undefined) s.cwd = init.cwd
if (init.parentId !== undefined) s.parentId = sid(init.parentId)
return s
}
function listOf(...summaries: SessionSummary[]): SessionListState {
const byId: Record<SessionId, SessionSummary> = {}
for (const s of summaries) byId[s.id] = s
return { ids: summaries.map(s => s.id), byId, current: undefined }
}
const view = (partial: Partial<TreeView> = {}): TreeView => ({
expandedProjects: partial.expandedProjects ?? [],
expandedSessions: partial.expandedSessions ?? [],
query: partial.query ?? '',
const sid = (id: string) => id as SessionId
const wid = (id: string) => id as WorkspaceId
const summary = (id: string, updatedAt: number, cwd?: string): SessionSummary => ({
id: sid(id), displayTitle: id, running: false, updatedAt, ...(cwd === undefined ? {} : { cwd }),
})
const list = (...items: SessionSummary[]): SessionListState => ({
ids: items.map(item => item.id),
byId: Object.fromEntries(items.map(item => [item.id, item])),
current: undefined,
phase: 'ready',
intent: undefined,
})
const workspace = (id: string, sessionIds: string[]): WorkspaceView => ({
workspaceId: wid(id), path: `/projects/${id}`, title: id,
sessionIds: sessionIds.map(sid), createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
})
const view = (expandedProjects: readonly string[] = [], query = '') => ({
expandedProjects, expandedSessions: [] as string[], query,
})
describe('projectLabel', () => {
it('takes the basename and survives trailing separators', () => {
expect(projectLabel('/home/me/proj')).toBe('proj')
expect(projectLabel('/home/me/proj/')).toBe('proj')
expect(projectLabel('C:\\work\\thing')).toBe('thing')
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, view(['first']))
expect(groups.map(group => group.key)).toEqual(['first', 'empty'])
expect(groups[0]!.sessions.map(session => session.id)).toEqual([sid('older'), sid('newer')])
})
it('falls back for empty and root-only paths', () => {
expect(projectLabel(undefined)).toBe(UNGROUPED_LABEL)
expect(projectLabel('')).toBe(UNGROUPED_LABEL)
expect(projectLabel('///')).toBe('///')
})
})
describe('deriveRows grouping', () => {
it('groups by cwd into project rows with counts, newest group first', () => {
const rows = deriveRows(listOf(
summary({ id: 'a', cwd: '/x/alpha', updatedAt: 10 }),
summary({ id: 'b', cwd: '/x/beta', updatedAt: 30 }),
summary({ id: 'c', cwd: '/x/alpha', updatedAt: 20 }),
), view())
expect(rows).toEqual([
expect.objectContaining({ type: 'project', key: '/x/beta', label: 'beta', sessionCount: 1, expanded: false }),
expect.objectContaining({ type: 'project', key: '/x/alpha', label: 'alpha', sessionCount: 2 }),
])
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'])], view([UNGROUPED_KEY]))
expect(groups.map(group => group.key)).toEqual(['first', UNGROUPED_KEY])
expect(groups[1]!.sessions.map(session => session.id)).toEqual([sid('loose')])
})
it('orders equally-recent groups by label and skips ids missing from byId', () => {
const list = listOf(
summary({ id: 'b1', cwd: '/x/beta', updatedAt: 5 }),
summary({ id: 'a1', cwd: '/x/alpha', updatedAt: 5 }),
// Same basename and same recency as beta: label comparator returns 0,
// insertion order breaks the tie.
summary({ id: 'b2', cwd: '/y/beta', updatedAt: 5 }),
)
list.ids.push(sid('ghost'))
const rows = deriveRows(list, view())
expect(rows.map(r => r.type === 'project' && r.key)).toEqual(['/x/alpha', '/x/beta', '/y/beta'])
})
it('buckets cwd-less sessions under the ungrouped project row', () => {
const rows = deriveRows(listOf(summary({ id: 'a' })), view())
expect(rows).toEqual([
expect.objectContaining({ type: 'project', key: UNGROUPED_KEY, cwd: undefined, label: UNGROUPED_LABEL }),
])
})
it('hides sessions under collapsed projects and shows them when expanded', () => {
const list = listOf(
summary({ id: 'a', cwd: '/p', updatedAt: 1 }),
summary({ id: 'b', cwd: '/p', updatedAt: 2 }),
)
expect(deriveRows(list, view()).filter(r => r.type === 'session')).toHaveLength(0)
const rows = deriveRows(list, view({ expandedProjects: ['/p'] }))
expect(rows.slice(1)).toEqual([
expect.objectContaining({ type: 'session', id: 'b', depth: 0 }),
expect.objectContaining({ type: 'session', id: 'a', depth: 0 }),
])
})
})
describe('deriveRows session tree', () => {
const treeList = listOf(
summary({ id: 'root', cwd: '/p', updatedAt: 5 }),
summary({ id: 'kid', cwd: '/p', parentId: sid('root'), updatedAt: 4 }),
summary({ id: 'grandkid', cwd: '/p', parentId: sid('kid'), updatedAt: 3 }),
summary({ id: 'other', cwd: '/p', updatedAt: 9 }),
)
it('nests children under expanded parents with increasing depth', () => {
const rows = deriveRows(treeList, view({
expandedProjects: ['/p'],
expandedSessions: ['root', 'kid'],
it('shows one frontend Session row only under a real target Workspace', () => {
const intent = { sessionId: sid('intent'), target: { kind: 'workspace' as const, workspaceId: wid('first') }, prompt: '', phase: 'connecting' as const }
const target = workspace('first', [])
expect(deriveGroups({ ...list(), current: intent.sessionId, intent }, [target], view())[0]).toEqual(expect.objectContaining({
intentHere: true,
sessionCount: 1,
containsCurrent: true,
}))
expect(rows.slice(1)).toEqual([
expect.objectContaining({ id: 'other', depth: 0, hasChildren: false }),
expect.objectContaining({ id: 'root', depth: 0, hasChildren: true, expanded: true }),
expect.objectContaining({ id: 'kid', depth: 1, hasChildren: true, expanded: true }),
expect.objectContaining({ id: 'grandkid', depth: 2, hasChildren: false }),
])
const hiddenIntent = { sessionId: sid('zero'), target: { kind: 'workspace-intent' as const }, prompt: '', phase: 'ready' as const }
expect(deriveGroups({ ...list(), intent: hiddenIntent }, [target], view())[0]!.intentHere).toBe(false)
})
it('collapses subtrees at unexpanded sessions', () => {
const rows = deriveRows(treeList, view({ expandedProjects: ['/p'] }))
const ids = rows.filter((r): r is SessionRow => r.type === 'session').map(r => r.id)
expect(ids).toEqual(['other', 'root'])
})
it('degrades a cross-group parent link to a group root', () => {
const rows = deriveRows(listOf(
summary({ id: 'p1', cwd: '/a', updatedAt: 2 }),
summary({ id: 'stray', cwd: '/b', parentId: sid('p1'), updatedAt: 1 }),
), view({ expandedProjects: ['/a', '/b'] }))
expect(rows).toEqual([
expect.objectContaining({ type: 'project', key: '/a' }),
expect.objectContaining({ id: 'p1', depth: 0 }),
expect.objectContaining({ type: 'project', key: '/b' }),
expect.objectContaining({ id: 'stray', depth: 0 }),
])
})
it('keeps cycle members visible as extra roots without looping', () => {
const rows = deriveRows(listOf(
summary({ id: 'x', cwd: '/p', parentId: sid('y'), updatedAt: 2 }),
summary({ id: 'y', cwd: '/p', parentId: sid('x'), updatedAt: 1 }),
summary({ id: 'self', cwd: '/p', parentId: sid('self'), updatedAt: 3 }),
), view({ expandedProjects: ['/p'], expandedSessions: ['x', 'y', 'self'] }))
const ids = rows.filter((r): r is SessionRow => r.type === 'session').map(r => r.id)
expect(ids).toContain('self')
expect(ids).toContain('x')
expect(ids).toContain('y')
expect(ids).toHaveLength(3)
})
it('breaks updatedAt ties deterministically by id', () => {
const rows = deriveRows(listOf(
summary({ id: 'b', cwd: '/p', updatedAt: 7 }),
summary({ id: 'a', cwd: '/p', updatedAt: 7 }),
summary({ id: 'c', cwd: '/p', updatedAt: 7 }),
), view({ expandedProjects: ['/p'] }))
const ids = rows.filter((r): r is SessionRow => r.type === 'session').map(r => r.id)
expect(ids).toEqual(['a', 'b', 'c'])
})
it('collects multiple children under one parent in recency order', () => {
const rows = deriveRows(listOf(
summary({ id: 'p', cwd: '/p', updatedAt: 9 }),
summary({ id: 'old', cwd: '/p', parentId: sid('p'), updatedAt: 1 }),
summary({ id: 'new', cwd: '/p', parentId: sid('p'), updatedAt: 5 }),
), view({ expandedProjects: ['/p'], expandedSessions: ['p'] }))
const ids = rows.filter((r): r is SessionRow => r.type === 'session').map(r => r.id)
expect(ids).toEqual(['p', 'new', 'old'])
})
it('carries the running flag onto rows', () => {
const rows = deriveRows(
listOf(summary({ id: 'a', cwd: '/p', running: true })),
view({ expandedProjects: ['/p'] }))
expect(rows[1]).toEqual(expect.objectContaining({ id: 'a', running: true }))
})
})
describe('deriveRows search', () => {
const list = listOf(
summary({ id: 'root', title: 'alpha work', cwd: '/p', updatedAt: 5 }),
summary({ id: 'kid', title: 'deep needle here', cwd: '/p', parentId: sid('root'), updatedAt: 4 }),
summary({ id: 'noise', title: 'unrelated', cwd: '/p', updatedAt: 3 }),
summary({ id: 'q', title: 'quiet', cwd: '/other', updatedAt: 2 }),
)
it('forces matched sessions and their ancestor chains visible, ignoring expansion', () => {
const rows = deriveRows(list, view({ query: 'NEEDLE' }))
expect(rows).toEqual([
expect.objectContaining({ type: 'project', key: '/p', expanded: true }),
expect.objectContaining({ id: 'root', depth: 0, expanded: true }),
expect.objectContaining({ id: 'kid', depth: 1 }),
])
})
it('drops groups without a hit and keeps a bare project row on label-only hits', () => {
const rows = deriveRows(list, view({ query: 'other' }))
expect(rows).toEqual([
expect.objectContaining({ type: 'project', key: '/other', expanded: false }),
])
})
it('blank query means normal mode', () => {
const rows = deriveRows(list, view({ query: ' ' }))
expect(rows.every(r => r.type === 'project')).toBe(true)
})
it('matches the effective display title when no durable title is available', () => {
const fallback = listOf(summary({ id: 'raw-id', displayTitle: 'project fallback', cwd: '/elsewhere' }))
const rows = deriveRows(fallback, view({ query: 'fallback' }))
expect(rows).toEqual([
expect.objectContaining({ type: 'project', key: '/elsewhere' }),
expect.objectContaining({ type: 'session', id: 'raw-id', title: 'project fallback' }),
])
it('search filters real Sessions and omits the Intent placeholder', () => {
const intent = { sessionId: sid('intent'), target: { kind: 'workspace' as const, workspaceId: wid('first') }, prompt: '', phase: 'ready' as const }
const groups = deriveGroups({ ...list(summary('match', 1)), intent }, [workspace('first', ['match'])], view([], 'match'))
expect(groups[0]!.sessions.map(session => session.id)).toEqual([sid('match')])
expect(groups[0]!.intentHere).toBe(false)
expect(groups[0]!.sessionCount).toBe(2)
})
})
describe('formatRelativeTime', () => {
const now = 1_000_000_000_000
it.each([
[now, 'now'],
[now - 30_000, 'now'],
[now - 2 * 60_000, '2min'],
[now - 3_600_000, '1h'],
[now - 2 * 86_400_000, '2d'],
[now - 18 * 86_400_000, '18d'],
[now - 65 * 86_400_000, '2mo'],
[now - 400 * 86_400_000, '1y'],
])('%d -> %s', (at, label) => {
expect(formatRelativeTime(at, now)).toBe(label)
})
it('clamps future timestamps to now', () => {
expect(formatRelativeTime(now + 5_000, now)).toBe('now')
it('formats current, minute, hour, day, month, and year buckets', () => {
const now = 400 * 24 * 60 * 60 * 1_000
expect(formatRelativeTime(now, now)).toBe('now')
expect(formatRelativeTime(now - 5 * 60_000, now)).toBe('5min')
expect(formatRelativeTime(now - 3 * 3_600_000, now)).toBe('3h')
expect(formatRelativeTime(now - 2 * 86_400_000, now)).toBe('2d')
expect(formatRelativeTime(now - 60 * 86_400_000, now)).toBe('2mo')
expect(formatRelativeTime(0, now)).toBe('1y')
})
})

View File

@@ -13,7 +13,7 @@ One `register({ name, children?, store?, inject?, ...kind }, Component)` call co
Chain-kind slots invert keyed routing — entries self-nominate instead of the dispatch site picking an `entryKey`: each registration carries a pure `ChainSelect` selector (plus optional ascending `priority`, ties in registration order), the first non-null return elects its entry and becomes the component's `matched` prop, and all-null falls to the owner's `renderSlotChain` fallback (`ChainRenderOpts`).
The standard-kit interfaces (`SessionStandardProps`, `GlobalStandardProps`) are declared empty here and merged by the runtime package (same declare-merge pattern as SlotMap keys). Inject factory parameters derive from the declaration (`InjectParams`): session slots get `sessionId`, a declared store appends baked `actions`, nothing else — data access lives in the apply closure's ctx.
The standard-kit interfaces (`SessionStandardProps`, `GlobalStandardProps`) are declared empty here and merged by the runtime package (same declare-merge pattern as SlotMap keys). The renderer binds the runtime's session and workspace observable sources into selector hooks. Inject factory parameters derive from the declaration (`InjectParams`): session slots get `sessionId`, a declared store appends baked `actions`, nothing else — data access lives in the apply closure's ctx.
The store family (`defineStore` spec in / `StoreHandle<T, A>` out) types the store seat: `init` infers the state schema, `actions` is the complete draft-transform write set, `BakedActions` strips the draft parameter into the callbacks components and inject factories receive. The `defineStore` value implementation lives in the runtime package (the engine's home) and satisfies the `DefineStore` contract exported here. Engine products and the renderer host contract carry bare snapshot sources (`getSnapshot`/`subscribe`), never React hooks — hook binding is the render machinery's side of the seam; only the props-contract hook type (`SnapshotSelectorHook`) lives here.

View File

@@ -74,8 +74,8 @@ export interface SessionStandardProps {}
/**
* Framework standard kit delivered to EVERY slot component (the global seat).
* Declared empty here; the runtime package merges `useSessions` (the session
* list selector hook the sidebar tree's single derivation source).
* Declared empty here; the runtime package merges the global object-layer
* selector hooks that shared page composition consumes.
*/
export interface GlobalStandardProps {}

View File

@@ -98,6 +98,11 @@ export interface SlotRendererHost {
*/
cell(id: string): SessionCell | undefined
}
/** Workspace-side standard-kit sources. */
workspaces: {
/** Workspace list source backing the useWorkspaces standard hook. */
list: HostObservable<unknown>
}
}
/** The install seam: runtime owns install()/renderSlot(); web-react implements rendering. */

View File

@@ -15,7 +15,7 @@ import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSnapshot, SessionId, SessionListState, WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConvViewProps, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
// Export discipline: packages/client/AGENTS.md.
import { ConversationRoot, type ConversationRootProps } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationRoot.tsx'
@@ -62,7 +62,15 @@ function fakeSession(nodes: ConversationSnapshot['nodes']) {
/** Empty sessions-list hook; breadcrumbs therefore fall back to the raw id. */
function emptySessions() {
const store = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined } as SessionListState)
{ ids: [], byId: {}, current: undefined, intent: undefined, phase: 'ready' })
return bindSnapshotSelector(store)
}
function emptyWorkspaces() {
const store = createSnapshotStore<WorkspaceListState>({
items: [], intent: undefined, state: 'idle', phase: 'ready', error: null, baselinesReady: true,
recentWorkspaceId: undefined,
})
return bindSnapshotSelector(store)
}
@@ -75,6 +83,7 @@ function standaloneProps(nodes: ConversationSnapshot['nodes']): ConvViewProps {
sessionId: SID,
useSession: fakeSession(nodes).useSession,
useSessions: emptySessions(),
useWorkspaces: emptyWorkspaces(),
} as unknown as ConvViewProps
}
@@ -121,7 +130,7 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
const View = entry.component as FC<ConvViewProps>
return (
<View
{...({ sessionId: SID, useSession, useSessions: emptySessions() } as unknown as ConvViewProps)}
{...({ sessionId: SID, useSession, useSessions: emptySessions(), useWorkspaces: emptyWorkspaces() } as unknown as ConvViewProps)}
key={key}
/>
)
@@ -131,6 +140,7 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
sessionId={SID}
useSession={useSession}
useSessions={emptySessions()}
useWorkspaces={emptyWorkspaces()}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
renderSlot={renderSlot}
@@ -144,6 +154,8 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
send={vi.fn()}
stop={vi.fn()}
open={vi.fn()}
updateSessionPrompt={vi.fn()}
retrySessionPrompt={vi.fn()}
/>,
)
}

View File

@@ -0,0 +1,20 @@
# @deepseek-ai/dsh-client-ui-workspace
Shared Workspace picker plugin. `WorkspacePicker` is registered into the sidebar's `sidebar.workspace` slot and the page-local Session Intent hero's `conversation.empty.workspace` slot, so both surfaces use the same menu and creation modals.
The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object; the existing-folder and create-new actions first create a real Workspace through the object layer, then select it. Create-new disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization.
Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored.
## Model Experience
None, as the picker is browser chrome; nothing here reaches a model request.
#### KV Cache effect
None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **No Workspace rename/delete controls** — the picker supports selection and creation only.
- **Existing-folder entry is manual path input only** — Host creation failures are shown in the modal.

View File

@@ -0,0 +1,65 @@
{
"name": "@deepseek-ai/dsh-client-ui-workspace",
"description": "Workspace picker plugin: one WorkspacePicker registered into the sidebar and empty-state workspace slots",
"version": "0.0.1",
"private": true,
"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"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-conversation",
"@deepseek-ai/dsh-client-ui-sidebar"
],
"platform": "web"
},
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
},
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
]
}

View File

@@ -0,0 +1,46 @@
/* Modal form styles mirror the empty state's path/create modals (same figma
* dialog family: field h44, r22, hairline border, pad 14/7) so the two
* entries stay visually identical. */
.modalInput {
box-sizing: border-box;
width: 100%;
height: 44px;
padding: 7px 14px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 22px;
outline: none;
background: transparent;
font-size: 14px;
font-weight: 400;
line-height: 22px;
color: var(--dsw-alias-label-primary);
}
.modalInput::placeholder {
color: var(--dsw-alias-label-caption);
}
.modalInput:disabled {
color: var(--dsw-alias-label-dimmed);
}
.modalAction {
min-width: 72px;
}
.modalError,
.modalStatus,
.menuStatus {
margin-top: 8px;
font-size: 12px;
line-height: 18px;
}
.modalError {
color: var(--dsw-alias-state-error-primary);
}
.modalStatus,
.menuStatus {
color: var(--dsw-alias-label-secondary);
}

View File

@@ -0,0 +1,196 @@
/** Shared Workspace picker for the sidebar and New Session hero. */
import { useCallback, useState } from 'react'
import {
Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type { WorkspacePickerProps } from './contract/slots.ts'
import css from './WorkspacePicker.module.css'
const CREATE_WORKSPACE = '::create-workspace'
const USE_EXISTING = '::use-existing'
const CREATE_NEW = '::create-new'
type ModalKind = 'path' | 'create' | null
export function WorkspacePicker({
open,
anchorRef,
useWorkspaces,
onPick,
onClose,
createWorkspace,
}: WorkspacePickerProps) {
const workspaceSnapshot = useWorkspaces(state => state)
const workspaces = workspaceSnapshot.items
const getAnchorRect = useCallback(
() => anchorRef?.current?.getBoundingClientRect() ?? null,
[anchorRef],
)
const [modalKind, setModalKind] = useState<ModalKind>(null)
const [pathDraft, setPathDraft] = useState('')
const [workspaceName, setWorkspaceName] = useState('')
const [creating, setCreating] = useState(false)
const [modalError, setModalError] = useState<string | null>(null)
const normalizedWorkspaceName = workspaceName.trim()
const duplicateWorkspaceName = normalizedWorkspaceName !== ''
&& workspaces.some(workspace => workspace.title === normalizedWorkspaceName)
const items: MenuEntry[] = [
...workspaces.map(workspace => ({
id: workspace.workspaceId as string,
label: workspace.title,
icon: <IconFolderClose16 size={16} />,
})),
...(workspaces.length > 0 ? [{ type: 'separator' as const, id: 'sep-create' }] : []),
{
id: CREATE_WORKSPACE,
label: 'Create workspace',
icon: <IconPlusOutline16 size={16} />,
submenu: [
{ id: USE_EXISTING, label: 'Use an existing folder' },
{ id: CREATE_NEW, label: 'Create a new workspace' },
],
},
]
const closeModal = (): void => {
if (creating) return
setModalKind(null)
setModalError(null)
}
const handleSelect = (id: string): void => {
if (id === USE_EXISTING) {
onClose()
setPathDraft('')
setModalError(null)
setModalKind('path')
return
}
if (id === CREATE_NEW) {
onClose()
setWorkspaceName('workspace')
setModalError(null)
setModalKind('create')
return
}
onPick(id as WorkspaceId)
}
const create = (input: { name: string } | { path: string }): void => {
if (creating) return
setCreating(true)
setModalError(null)
void createWorkspace(input).then((workspace) => {
setCreating(false)
setModalKind(null)
onPick(workspace.workspaceId)
}).catch((reason: unknown) => {
const message = reason instanceof Error ? reason.message : String(reason)
setModalError(`Workspace creation failed: ${message}`)
setCreating(false)
})
}
const confirmPath = (): void => {
const path = pathDraft.trim()
if (path !== '') create({ path })
}
const confirmCreate = (): void => {
if (normalizedWorkspaceName !== '' && !duplicateWorkspaceName) {
create({ name: normalizedWorkspaceName })
}
}
return (
<>
<Menu
open={open}
anchor={null}
items={items}
onSelect={handleSelect}
onClose={onClose}
portal
getAnchorRect={getAnchorRect}
/>
{open && workspaceSnapshot.phase === 'pending' && <div className={css.menuStatus} role="status">Loading workspaces</div>}
<Modal
open={modalKind === 'path'}
onClose={closeModal}
title="Use an existing folder"
footer={(
<>
<Button variant="outline" className={css.modalAction!} disabled={creating} onClick={closeModal}>Cancel</Button>
<Button
variant="primary"
className={css.modalAction!}
disabled={creating || pathDraft.trim() === ''}
onClick={confirmPath}
>
Use folder
</Button>
</>
)}
>
<input
className={css.modalInput}
value={pathDraft}
aria-label="Existing folder path"
autoFocus
disabled={creating}
placeholder="/path/to/project"
onChange={(event) => { setPathDraft(event.target.value) }}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
confirmPath()
}
}}
/>
{creating && <div className={css.modalStatus} role="status">Creating workspace</div>}
{modalError !== null && <div className={css.modalError} role="alert">{modalError}</div>}
</Modal>
<Modal
open={modalKind === 'create'}
onClose={closeModal}
title="Create a new workspace"
description="The name is used for both the workspace and its new folder."
footer={(
<>
<Button variant="outline" className={css.modalAction!} disabled={creating} onClick={closeModal}>Cancel</Button>
<Button
variant="primary"
className={css.modalAction!}
disabled={creating || normalizedWorkspaceName === '' || duplicateWorkspaceName}
onClick={confirmCreate}
>
Create workspace
</Button>
</>
)}
>
<input
className={css.modalInput}
value={workspaceName}
aria-label="New workspace name"
autoFocus
disabled={creating}
onChange={(event) => { setWorkspaceName(event.target.value); setModalError(null) }}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
confirmCreate()
}
}}
/>
{creating && <div className={css.modalStatus} role="status">Creating workspace</div>}
{duplicateWorkspaceName && (
<div className={css.modalError} role="alert">A workspace named {normalizedWorkspaceName} already exists.</div>
)}
{modalError !== null && <div className={css.modalError} role="alert">{modalError}</div>}
</Modal>
</>
)
}

View File

@@ -0,0 +1,30 @@
/**
* Shared Workspace picker contract for the sidebar and page-local Session Intent hero
* slots. Each runtime share provides its owner's popover controls plus the
* global useWorkspaces hook; this package adds the injected Host Workspace
* creation callback.
*/
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
// Type-only: pull both owner SlotMap merges into programs that resolve the
// picker runtime union below.
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
/**
* Registrant-private injected share. Pick semantics remain in each owner's
* onPick callback; this callback creates only the real Host Workspace. A type
* alias supplies the implicit index signature required by the registry.
*/
export type WorkspacePickerInjected = {
/** Explicitly create or adopt a real Workspace before targeting a Session. */
createWorkspace(input: { name: string } | { path: string }): Promise<WorkspaceView>
}
/**
* Full picker props: either owner's runtime share, including useWorkspaces,
* plus this package's injected creation callback.
*/
export type WorkspacePickerProps =
(PropsRuntime<'sidebar.workspace'> | PropsRuntime<'conversation.empty.workspace'>)
& WorkspacePickerInjected

View File

@@ -0,0 +1,54 @@
/**
* Shared Workspace picker plugin, browser half. WorkspacePicker registers in
* the sidebar and page-local Session Intent hero slots, reads real Host Workspaces
* through the global useWorkspaces hook, and delegates selection semantics to
* each owner. Its injected share creates a Workspace without creating a
* Session. Export discipline: packages/client/AGENTS.md.
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { WorkspacePickerInjected } from './contract/slots.ts'
import { WorkspacePicker } from './WorkspacePicker.tsx'
export type { WorkspacePickerInjected, WorkspacePickerProps } from './contract/slots.ts'
/**
* Required services (cordis fiber inject). The target slot is declared by
* the ui-sidebar apply, whose activation order relative to this one is NOT
* constrained: dshClient.inject edges are informational (loading/prefetch
* metadata, never apply sequencing) and the sidebar provides no waitable
* service. apply therefore registers via declaration-aware deferral instead
* of assuming order.
*/
export const inject = ['slots', 'workspaces']
/**
* Register WorkspacePicker in both owner slots once their declarations are on
* the ledger. The inject factory returns a plain Workspace creation callback;
* data reads use the framework's global useWorkspaces hook.
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
const injected = (): WorkspacePickerInjected => ({
createWorkspace: input => ctx.workspaces.create(input),
})
// Declaration-aware registration: the sidebar's declaring apply may
// activate after this one (entry activation order is unconstrained), and a
// register into an undeclared slot throws. Register once the declaration
// is on the ledger; the subscription also re-registers after an HMR
// collapse re-declares the slot (the cascade disposed our entry with it).
ctx.effect(() => {
const slotNames = ['sidebar.workspace', 'conversation.empty.workspace'] as const
const disposers = new Map<(typeof slotNames)[number], () => void>()
const tryRegister = (name: (typeof slotNames)[number]): void => {
if (ctx.slots.spec(name) === undefined) return
if (ctx.slots.entries(name).some(e => e.component === WorkspacePicker)) return
disposers.set(name, ctx.slots.register({ name, inject: injected }, WorkspacePicker))
}
const unsubscribers = slotNames.map(name => ctx.slots.subscribe(name, () => { tryRegister(name) }))
for (const name of slotNames) tryRegister(name)
return () => {
for (const unsubscribe of unsubscribers) unsubscribe()
for (const dispose of disposers.values()) dispose()
}
}, 'ui-workspace: picker registrations')
}

View File

@@ -0,0 +1,6 @@
declare module '*.module.css' {
const classes: Record<string, string>
export default classes
}
declare module '*.css'

View File

@@ -0,0 +1,9 @@
/**
* Workspace picker plugin, node half. Pure UI plugin: the empty apply exists
* so the plugin appears in the host cordis.yml / Loader (load and lifecycle
* follow the host; the browser half ships via exports["./client"], discovered
* through the package.json dshClient declaration).
*/
/** Host plugin body — no host-side behavior for the workspace picker plugin. */
export function apply(): void {}

View File

@@ -0,0 +1,32 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-workspace`.
* @module @deepseek-ai/dsh-client-ui-workspace/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-workspace'
/** Cordis companion plugin name. */
export const name = 'client-ui-workspace-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: a pure-consumer plugin registering one presentational
* component into two host-declared slots — its inject face is two stateless
* RPC wrappers plus a create-and-open call; it emits no cordis events and
* owns no cross-plugin mutable state.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,69 @@
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client'
import type { WorkspacePickerInjected } from '@deepseek-ai/dsh-client-ui-workspace/client'
import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx'
async function bench() {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
const create = vi.fn(async (input: { name: string } | { path: string }) => ({
workspaceId: 'ws-new' as never,
path: 'name' in input ? `/projects/${input.name}` : input.path,
title: 'new', sessionIds: [], createdAt: '0', updatedAt: '0',
}))
ctx.provide('workspaces', { create })
return { ctx, slots: ctx.get('slots') as SlotsService, create }
}
function declare(slots: SlotsService, name: 'sidebar.workspace' | 'conversation.empty.workspace'): () => void {
return slots.register(
{ name: 'root', children: { [name]: { kind: 'single', scope: 'root' } } } as never,
() => null,
)
}
function injectedOf(slots: SlotsService, name: 'sidebar.workspace' | 'conversation.empty.workspace'): WorkspacePickerInjected {
const entry = slots.entries(name)[0]!
return (entry.inject as () => WorkspacePickerInjected)()
}
describe('ui-workspace apply', () => {
it('declares the independent Workspace service', () => {
expect(inject).toEqual(['slots', 'workspaces'])
})
it('registers the shared picker for declarations that arrive before or after apply', async () => {
const before = await bench()
declare(before.slots, 'sidebar.workspace')
await before.ctx.plugin({ inject: [...inject], apply }).await()
expect(before.slots.entries('sidebar.workspace')[0]!.component).toBe(WorkspacePicker)
const after = await bench()
await after.ctx.plugin({ inject: [...inject], apply }).await()
declare(after.slots, 'conversation.empty.workspace')
await Promise.resolve()
expect(after.slots.entries('conversation.empty.workspace')[0]!.component).toBe(WorkspacePicker)
})
it('routes name and path creation to WorkspacesService', async () => {
const b = await bench()
declare(b.slots, 'sidebar.workspace')
await b.ctx.plugin({ inject: [...inject], apply }).await()
const injected = injectedOf(b.slots, 'sidebar.workspace')
await injected.createWorkspace({ name: 'project' })
await injected.createWorkspace({ path: '/tmp/project' })
expect(b.create).toHaveBeenNthCalledWith(1, { name: 'project' })
expect(b.create).toHaveBeenNthCalledWith(2, { path: '/tmp/project' })
})
it('unregisters picker entries on teardown', async () => {
const b = await bench()
declare(b.slots, 'sidebar.workspace')
const fiber = b.ctx.plugin({ inject: [...inject], apply })
await fiber.await()
await fiber.dispose()
expect(b.slots.entries('sidebar.workspace')).toHaveLength(0)
})
})

View File

@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import * as WorkspaceInvariant from '@deepseek-ai/dsh-client-ui-workspace/invariant'
import InvariantService from '@deepseek-ai/dsh-invariants'
describe('invariant companion', () => {
it('registers under the package name with an empty installer', async () => {
const ctx = new Context()
await ctx.plugin(InvariantService, { enabled: true })
await expect(ctx.plugin(WorkspaceInvariant).await()).resolves.toBeDefined()
})
it('node-half apply is a no-op host placeholder', async () => {
const { apply } = await import('@deepseek-ai/dsh-client-ui-workspace')
apply()
expect(true).toBe(true) // reaching here without throw is the contract
})
})

View File

@@ -0,0 +1,123 @@
// @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, WorkspaceId, WorkspaceListState, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx'
afterEach(cleanup)
const wid = (id: string) => id as WorkspaceId
function workspace(id: string, title = id): WorkspaceView {
return {
workspaceId: wid(id), path: `/projects/${id}`, title, sessionIds: [],
createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
}
}
const hook = <T,>(snapshot: T) => <S,>(selector: (state: T) => S): S => selector(snapshot)
const sessions: SessionListState = {
ids: [], byId: {}, current: undefined, intent: undefined, phase: 'ready',
}
const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => ({
items, intent: undefined, state: 'idle', phase: 'ready', error: null, baselinesReady: true,
recentWorkspaceId: items[0]?.workspaceId,
})
function anchor(): { current: HTMLElement } {
const element = document.createElement('button')
element.getBoundingClientRect = () => ({
top: 10, left: 20, width: 30, height: 40, right: 50, bottom: 50,
x: 20, y: 10, toJSON: () => ({}),
})
return { current: element }
}
function mount(items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')], createWorkspace = vi.fn()) {
const onPick = vi.fn()
const onClose = vi.fn()
const view = render(
<WorkspacePicker
open
anchorRef={anchor()}
useSessions={hook(sessions)}
useWorkspaces={hook(workspaceState(items))}
onPick={onPick}
onClose={onClose}
createWorkspace={createWorkspace}
/>,
)
return { view, onPick, onClose, createWorkspace }
}
function chooseCreateItem(name: 'Use an existing folder' | 'Create a new workspace'): void {
const parent = screen.getByRole('menuitem', { name: 'Create workspace' })
fireEvent.mouseEnter(parent.parentElement as HTMLElement)
fireEvent.click(screen.getByRole('menuitem', { name }))
}
describe('WorkspacePicker', () => {
it('lists real Workspaces from useWorkspaces and forwards a selected id', () => {
const b = mount()
fireEvent.click(screen.getByRole('menuitem', { name: 'Alpha' }))
expect(b.onPick).toHaveBeenCalledWith(wid('alpha'))
})
it('creates a real Workspace from a name and focuses its frontend Session target', async () => {
const created = workspace('new', 'New')
const createWorkspace = vi.fn(async () => created)
const b = mount([], createWorkspace)
chooseCreateItem('Create a new workspace')
const input = screen.getByLabelText('New workspace name')
fireEvent.change(input, { target: { value: 'project-one' } })
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
expect(createWorkspace).toHaveBeenCalledWith({ name: 'project-one' })
await waitFor(() => { expect(b.onPick).toHaveBeenCalledWith(created.workspaceId) })
})
it('adopts an existing path through the same immediate create action', async () => {
const created = workspace('adopted')
const createWorkspace = vi.fn(async () => created)
const b = mount([], createWorkspace)
chooseCreateItem('Use an existing folder')
fireEvent.change(screen.getByLabelText('Existing folder path'), { target: { value: ' /tmp/project ' } })
fireEvent.click(screen.getByRole('button', { name: 'Use folder' }))
expect(createWorkspace).toHaveBeenCalledWith({ path: '/tmp/project' })
await waitFor(() => { expect(b.onPick).toHaveBeenCalledWith(created.workspaceId) })
})
it('blocks a create-new name already present in the Workspace list', () => {
const b = mount([workspace('alpha', 'Alpha')])
chooseCreateItem('Create a new workspace')
fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: ' Alpha ' } })
expect(screen.getByRole('alert').textContent).toBe('A workspace named “Alpha” already exists.')
expect((screen.getByRole('button', { name: 'Create workspace' }) as HTMLButtonElement).disabled).toBe(true)
fireEvent.keyDown(screen.getByLabelText('New workspace name'), { key: 'Enter' })
expect(b.createWorkspace).not.toHaveBeenCalled()
})
it('exposes creation phase and error text while retaining the modal for retry', async () => {
let reject!: (reason: unknown) => void
const pending = new Promise<WorkspaceView>((_resolve, rejectPromise) => { reject = rejectPromise })
const b = mount([], vi.fn(() => pending))
chooseCreateItem('Create a new workspace')
fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: 'broken' } })
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
expect(screen.getByRole('status').textContent).toBe('Creating workspace…')
await act(async () => { reject(new Error('disk unavailable')); await pending.catch(() => {}) })
expect(screen.getByRole('alert').textContent).toBe('Workspace creation failed: disk unavailable')
expect(b.view.getByRole('dialog')).toBeTruthy()
})
it('shows list loading through a stable status surface', () => {
const state: WorkspaceListState = {
...workspaceState([]), phase: 'pending', state: 'loading', baselinesReady: false,
}
render(
<WorkspacePicker
open anchorRef={anchor()} useSessions={hook(sessions)} useWorkspaces={hook(state)}
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()}
/>,
)
expect(screen.getByRole('status').textContent).toBe('Loading workspaces…')
})
})

View File

@@ -0,0 +1,33 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../ui-slots"
},
{
"path": "../ui-primitives"
},
{
"path": "../runtime"
},
{
"path": "../ui-sidebar"
},
{
"path": "../ui-conversation"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -0,0 +1,3 @@
import { clientBundle } from '../tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-client-ui-workspace', ['lib/types/index.js', 'lib/types/invariant.js'])

View File

@@ -164,7 +164,7 @@ class SlotErrorBoundary extends Component<
/**
* Standard-kit synthesis shared by both scope branches: the global
* useSessions hook, the session pair, the store pair when declared, the
* useSessions/useWorkspaces hooks, the session pair, the store pair when declared, the
* renderSlot binding when children are declared, and the SessionProvider
* seat when the children declare a session-scope slot. Hosts hand out BARE
* observable sources (hooks never cross the host contract); every hook is
@@ -174,7 +174,10 @@ class SlotErrorBoundary extends Component<
function standardKit(host: SlotRendererHost, entry: StoredEntry, cell: SessionCell | undefined): {
kit: InjectedProps; actions: object | undefined
} {
const kit: InjectedProps = { useSessions: observableHook(host.sessions.list) }
const kit: InjectedProps = {
useSessions: observableHook(host.sessions.list),
useWorkspaces: observableHook(host.workspaces.list),
}
if (cell !== undefined) {
kit['useSession'] = observableHook(cell.session)
kit['sessionId'] = cell.sessionId

View File

@@ -38,6 +38,9 @@ function hostOver(core: SlotCore): SlotRendererHost {
current: { getSnapshot: () => undefined, subscribe: () => () => {} },
cell: () => undefined,
},
workspaces: {
list: { getSnapshot: () => ({}), subscribe: () => () => {} },
},
}
}

View File

@@ -81,6 +81,7 @@ function makeHost() {
const live = new Set<StoredEntry>()
const storeCache = new Map<StoredEntry, Map<string, StoreInstanceLike>>()
const list = observable<{ ids: string[] }>({ ids: [] })
const workspaces = observable<{ ids: string[] }>({ ids: [] })
const current = observable<string | undefined>(undefined)
const cells = new Map<string, SessionCell>()
@@ -122,10 +123,12 @@ function makeHost() {
current,
cell: (id) => cells.get(id),
},
workspaces: { list: workspaces },
}
return {
host,
list,
workspaces,
current,
declare: (key: string, spec: DeclaredSpec) => { specs.set(key, spec); bump(key) },
add: (key: string, partial: Omit<StoredEntry, 'options'> & { options?: StoredEntry['options'] }) => {
@@ -483,6 +486,19 @@ describe('standard-kit synthesis', () => {
expect(view.container.textContent).toBe('2')
})
it('delivers a live useWorkspaces hook to every slot component', () => {
const h = makeHost()
h.declare('k.single', SINGLE_ROOT)
h.add('k.single', {
component: ({ useWorkspaces }: { useWorkspaces: <S>(sel: (s: { ids: string[] }) => S) => S }) =>
<b>{useWorkspaces((s) => s.ids.length)}</b>,
})
const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT }, (renderSlot) => renderSlot('k.single', {}))
expect(view.container.textContent).toBe('0')
act(() => { h.workspaces.set({ ids: ['w1'] }) })
expect(view.container.textContent).toBe('1')
})
it('delivers the session pair (bound useSession + sessionId) under SessionProvider', () => {
const h = makeHost()
h.declare('k.session', SINGLE_SESSION)
@@ -710,6 +726,7 @@ describe('inject: execution point, parameter derivation, cache granularity', ()
(renderSlot) => renderSlot('k.single', { owner: 'owner', shared: 'owner' }))
const props = seen.at(-1)!
expect(typeof props['useSessions']).toBe('function') // kit always present
expect(typeof props['useWorkspaces']).toBe('function')
expect(props['fromInject']).toBe('inject')
expect(props['owner']).toBe('owner')
expect(props['shared']).toBe('owner') // owner overrides inject

View File

@@ -52,6 +52,7 @@ function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.Rea
current,
cell: (id) => cells.get(id),
},
workspaces: { list: observable<unknown>({ items: [] }) },
}
return {
host,

Some files were not shown because too many files have changed in this diff Show More