Merge remote-tracking branch 'origin/master' into web-e2e-lane
# Conflicts: # vitest.web.config.ts
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
/**
|
||||
* Browser half of the wire consumer layer (contract: api-contracts v3
|
||||
* section 3; export inventory = v3 §3.2). The wire is this package's client
|
||||
* half in its entirety — apply mounts ctx.connection: the shared api client
|
||||
* plus the connection controller handle. Mode selection (?fixture) happens
|
||||
* here so the rest of the client tree is mode-blind; the controller's sinks
|
||||
* are wired by the runtime plugin (object layer), which injects this service.
|
||||
* Browser wire client. The plugin selects fixture or HTTP transport, provides
|
||||
* the shared API client, and lets the runtime object layer start the stream
|
||||
* controller with its sinks.
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import type { IApiClient } from './api.ts'
|
||||
@@ -16,16 +13,15 @@ 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,
|
||||
} from './api.ts'
|
||||
export { RpcId, AbstractApiClient, transportError } from './api.ts'
|
||||
|
||||
// ---- Connection loop types (part of the ConnectionHandle.start contract;
|
||||
// the controller class itself stays package-internal — apply owns the loop,
|
||||
// tests reach it via src) ----
|
||||
// Connection loop types are public through ConnectionHandle.start; the
|
||||
// controller remains package-internal.
|
||||
export type { ConnectionConfig, ConnectionSinks, ConnectionState }
|
||||
|
||||
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
/**
|
||||
* Connection plugin, node half: the host end of the web transport. Registers
|
||||
* the /api prefix route on the web server and bridges node:http requests to
|
||||
* the transport-agnostic fetch-shaped api handler. The wire consumer layer
|
||||
* lives in the client half (src/client/ — contract: api-contracts v3
|
||||
* section 3); consumers import the /client subpath.
|
||||
*/
|
||||
/** Host HTTP bridge for browser-client RPC. */
|
||||
import type { Context } from 'cordis'
|
||||
// Type-only route import; it also carries the httpServer Context merge.
|
||||
// Activates the httpServer Context merge used below.
|
||||
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
import { API_PATH } from './api-path.ts'
|
||||
@@ -14,16 +8,15 @@ import { bridge } from './http-bridge.ts'
|
||||
|
||||
export { API_PATH } from './api-path.ts'
|
||||
|
||||
/** Cordis plugin name. */
|
||||
/** Stable Cordis plugin name. */
|
||||
export const name = 'client-connection'
|
||||
|
||||
/** Required services: the route registry and the api gateway. */
|
||||
/** Services required before mounting the route. */
|
||||
export const inject = ['httpServer', 'apiProxy']
|
||||
|
||||
/**
|
||||
* Mount the /api transport: wrap the api gateway into a fetch handler and
|
||||
* serve it under the /api prefix.
|
||||
* @param ctx - host plugin context carrying httpServer and apiProxy.
|
||||
* Mounts the API gateway under the browser transport prefix.
|
||||
* @param ctx - Host plugin context.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
const apiHandler = toFetchHandler(ctx.apiProxy)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,213 @@ 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('attaches an existing ungrouped Session to a matching Workspace', async () => {
|
||||
const api = createFixtureApi()
|
||||
const sessionId = sid('fx-existing-ungrouped')
|
||||
await expect(api.sessions.create(req({ sessionId, cwd: '/tmp/fixture' }))).resolves.toMatchObject({
|
||||
result: { ok: true, value: { sessionId } },
|
||||
})
|
||||
|
||||
await expect(api.sessions.create(req({
|
||||
sessionId,
|
||||
workspaceId: 'fx-ws-fixture' as WorkspaceId,
|
||||
}))).resolves.toMatchObject({ result: { ok: true, value: { sessionId } } })
|
||||
|
||||
const workspaces = await api.workspace.list(req({}))
|
||||
if (!workspaces.result.ok) throw new Error('workspace list failed')
|
||||
expect(workspaces.result.value.items[0]?.sessionIds).toContain(sessionId)
|
||||
})
|
||||
|
||||
it('reports a conflict without an existing cwd detail for an unrecorded cwd', async () => {
|
||||
const api = createFixtureApi()
|
||||
const listed = await api.sessions.list(req({}))
|
||||
if (!listed.result.ok) throw new Error('session list failed')
|
||||
const existing = listed.result.value.items.find(item => item.sessionId === sid('fx-alpha'))
|
||||
if (existing === undefined) throw new Error('fixture Session missing')
|
||||
delete existing.cwd
|
||||
|
||||
const conflict = await api.sessions.create(req({ sessionId: existing.sessionId }))
|
||||
expect(conflict.result).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'session-conflict',
|
||||
message: `session ${existing.sessionId} already uses no cwd`,
|
||||
details: { sessionId: existing.sessionId, requestedCwd: '/tmp/fixture' },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
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 +518,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 +554,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 () => {
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
/**
|
||||
* i18n plugin, browser half: namespace x locale dictionary registry with a
|
||||
* bound translate function whose reference is stable (safe for inject
|
||||
* surfaces). Mounts ctx.i18n and seeds the zh/en base dictionaries.
|
||||
* Contract: api-contracts v3 section 8.
|
||||
* Browser-side locale registry. Bound translation functions retain stable
|
||||
* identity for injected consumers.
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
// The snapshot-store engine lives in runtime (store relocation): framework
|
||||
// data stores like this locale cell use it directly. The store carries no
|
||||
// hook — a React consumer binds a selector hook via web-react's
|
||||
// bindSnapshotSelector at its own seam (none exists today; the current
|
||||
// consumers are translate() reads and test-side subscribe/set).
|
||||
// Snapshot stores are framework-neutral; React consumers bind hooks at their
|
||||
// rendering boundary.
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { en } from '../locales/en.ts'
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
/**
|
||||
* i18n 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). Everything else —
|
||||
* I18nService, Translate, LocaleDict — lives in the client half; consumers
|
||||
* import the /client subpath. Contract: api-contracts v3 section 8.
|
||||
*/
|
||||
/** Host loader entry for the browser implementation exported from `./client`. */
|
||||
|
||||
/** Host plugin body — no host-side behavior for the i18n plugin. */
|
||||
export function apply(): void {}
|
||||
|
||||
@@ -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 depends on SessionsService and owns Workspace objects, list/actions, page-local Workspace Intent state, default-target derivation, and the cross-object New Session flow. 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
|
||||
|
||||
|
||||
@@ -161,11 +161,7 @@ function deepFreeze(value: unknown): void {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- defineStore shell (slot terminal design §4) ----
|
||||
// The type authority is ui-slots' store family (create(scopeKey?) and
|
||||
// clearPersisted() included); this module houses only the engine-backed
|
||||
// implementation. The one engine-side widening left: instances expose the
|
||||
// raw engine store for framework/test surfaces.
|
||||
// ui-slots owns the contract; this module supplies the engine implementation.
|
||||
|
||||
/** A live engine instance: the contract instance plus the raw engine store. */
|
||||
export interface EngineStoreInstance<T, A extends ActionsDecl<T>> extends StoreInstance<T, A> {
|
||||
|
||||
@@ -1,55 +1,38 @@
|
||||
/**
|
||||
* Browser half: the whole runtime contract surface (api-contracts v3 §4) —
|
||||
* SlotsService (declaration ledger + renderer seam + store axis, built-in
|
||||
* 'root'), SessionsService (list store + current selection + scope tree +
|
||||
* object layer), and the cordis Context/Events merges. apply mounts
|
||||
* ctx.slots + ctx.sessions and wires the connection stream loop into the
|
||||
* object layer. A static-arrival entry: the web shell bundles this module
|
||||
* and mounts it through the host graph (module loading lives in
|
||||
* @deepseek-ai/dsh-client-modules, entry governance in the vendored Loader).
|
||||
*/
|
||||
/** 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'
|
||||
// RootOwnerProps rides the 'root' SlotMap row (both migrated here from
|
||||
// ui-layout: the framework slot is declared by the framework package).
|
||||
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'
|
||||
// The snapshot-store engine lives here since the store migration (the data
|
||||
// layer owns its substrate; web-react is React glue only). The './client'
|
||||
// main export is the single serving door — no store subpath.
|
||||
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'
|
||||
// PendingWait is a value export: tests construct fixture waits directly.
|
||||
export { PendingWait } from './sessions/pending.ts'
|
||||
export type { PendingInteraction, PendingKind, PendingPayloads } from './sessions/pending.ts'
|
||||
export type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
|
||||
// ---- Narrowed aliases (the single narrowing point of the slot type chain:
|
||||
// ui-slots/web-react stay generic and dependency-inverted; the client-tree
|
||||
// concrete types live here, where their subjects live) ----
|
||||
|
||||
/**
|
||||
* The client cordis context face: the base Context plus the service keys
|
||||
* this package's declaration merge contributes (slots/sessions/loader) and
|
||||
* every later plugin's merge. A plain alias — the merges land on Context
|
||||
* itself inside the client program; the name marks intent at consumer seams.
|
||||
*/
|
||||
/** Client-side Cordis context after declaration merging. */
|
||||
export type ClientContext = Context
|
||||
|
||||
/** The conversation-snapshot selector hook (ConvViewProps/ToolRowProps take this). */
|
||||
@@ -69,15 +52,15 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
* every session-scope slot component receives these from the framework.
|
||||
*/
|
||||
interface SessionStandardProps {
|
||||
/** Selector hook over this session's conversation snapshot. */
|
||||
useSession: SnapshotSelectorHook<ConversationSnapshot>
|
||||
/** The framework-resolved session id (owners never pass it). */
|
||||
sessionId: SessionId
|
||||
}
|
||||
/** Global standard kit, real members: the session-list hook every slot component receives. */
|
||||
/** Props injected into every global slot component. */
|
||||
interface GlobalStandardProps {
|
||||
/** Selector hook over the session list snapshot (`current` included — the arbitrated selection seat). */
|
||||
useSessions: SnapshotSelectorHook<SessionListState>
|
||||
/** Selector hook over real Workspaces and their independent baseline lifecycle. */
|
||||
useWorkspaces: SnapshotSelectorHook<import('./workspaces/service.ts').WorkspaceListState>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,24 +76,31 @@ declare module 'cordis' {
|
||||
interface Context {
|
||||
slots: import('./slots.ts').SlotsService
|
||||
sessions: import('./sessions/service.ts').SessionsService
|
||||
workspaces: import('./workspaces/service.ts').WorkspacesService
|
||||
}
|
||||
}
|
||||
|
||||
/** Required services: the wire handle mounted by the connection plugin. */
|
||||
export const inject = ['connection']
|
||||
|
||||
/**
|
||||
* Client plugin body: mount slots + sessions, start the stream loop.
|
||||
* @param ctx - client cordis context.
|
||||
/** Mounts the browser runtime services and connection stream.
|
||||
* @param ctx - Client Cordis context.
|
||||
*/
|
||||
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')
|
||||
}
|
||||
|
||||
43
packages/client/runtime/src/client/ordered-baseline.ts
Normal file
43
packages/client/runtime/src/client/ordered-baseline.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -24,10 +24,10 @@ export interface CallIndexEntry {
|
||||
callView: ToolCallView | null
|
||||
}
|
||||
|
||||
/** Non-surface-eligible sentinel event (safely skipped by surfaceOpOf's undefined branch).
|
||||
* 'noop/padding' is not a real event type on purpose: a genuine type with fake data would
|
||||
* surface as garbage the day anyone adds handling for it (design §D.1; the cast is the one
|
||||
* place a synthetic event enters the window). */
|
||||
/** Non-surface sentinel used to preserve paged-window sequence offsets.
|
||||
* `noop/padding` is deliberately not a real event type, so it cannot acquire
|
||||
* surface behavior; this cast is the only synthetic event entry point.
|
||||
*/
|
||||
function paddingEvent(seq: number): SessionEvent {
|
||||
return { type: 'noop/padding', seq, time: 0, data: {} } as unknown as SessionEvent
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,90 @@ 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the active frontend Session Intent.
|
||||
* @returns the active frontend Session, if one remains selected.
|
||||
*/
|
||||
getIntent(): Session | undefined {
|
||||
return this.intentSessionId === undefined ? undefined : this.sessions.get(this.intentSessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the retained prompt of the active frontend Session.
|
||||
* @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 +184,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 +199,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 +222,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 +249,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 +260,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 +298,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 +394,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 +423,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 +452,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
|
||||
}
|
||||
|
||||
@@ -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,88 @@ 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.
|
||||
* @returns the frontend Session object that owns the Intent.
|
||||
*/
|
||||
startIntent(target: SessionIntentTarget, prompt = ''): Session {
|
||||
return this.manager.startIntent(target, prompt)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the active frontend Session Intent.
|
||||
* @returns the active frontend Session object, if one exists.
|
||||
*/
|
||||
intent(): Session | undefined {
|
||||
return this.manager.getIntent()
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the retained prompt of the active frontend Session.
|
||||
* @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 +325,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)
|
||||
@@ -291,8 +373,7 @@ export class SessionsService {
|
||||
fiber,
|
||||
ctx,
|
||||
binding: { sessionId: id, session, ctx },
|
||||
// Bare source form (store migration): the Session object IS the
|
||||
// observable; the React side binds the useSession hook per cell.
|
||||
// Session is the observable; React binds a selector hook at its own seam.
|
||||
cell: { sessionId: id, session },
|
||||
}
|
||||
this.scopes.set(id, record)
|
||||
@@ -301,7 +382,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) {
|
||||
@@ -316,11 +397,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)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
// Session: wraps every contract call that needs a sessionId + all conversation state for this
|
||||
// session (design §A.2/§A.9/§D.2/§D.3). Instances are resident (ruling 2): never destroyed once
|
||||
// created, they keep consuming mux frames in the background; React connects directly via
|
||||
// subscribe/getSnapshot.
|
||||
// Sessions remain resident after creation so they continue consuming mux frames off-screen.
|
||||
|
||||
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,14 +20,18 @@ import { FoldAdapter } from './fold-adapter.ts'
|
||||
import { Notifier } from './notifier.ts'
|
||||
import { PartialAccumulator } from './partial.ts'
|
||||
|
||||
/** Messages per page (F.4 ledger: promote to Config at graduation; every call site references this constant). */
|
||||
/** 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-session state owner: event window + fold + partial, snapshot out via
|
||||
* subscribe/getSnapshot (see the web client architecture RFC). Bare source
|
||||
* only (store migration): the React machinery binds the per-cell useSession
|
||||
* hook at its own seam — no selector hook member lives on the data layer.
|
||||
* Owns a session's event window, derived conversation state, and observable
|
||||
* snapshot. React bindings remain outside this data layer.
|
||||
*/
|
||||
export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
// ---- Window and derived state (all private; the snapshot is the only read surface) ----
|
||||
@@ -54,8 +56,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
* Derived from window events (turn/end sweep) — rebuilt by rebuildDerivedFromWindow like partial/openCalls. */
|
||||
private frozenNodes: ConversationNode[] = []
|
||||
private pending = new Map<string, PendingInteraction>()
|
||||
// Revision counters + caches backing the snapshot's reference-stability contract (§A.9.4/§C.2,
|
||||
// audit S5): buildSnapshot reuses the previous array when the revision is unchanged, so
|
||||
// Revision counters preserve array identity when derived content is unchanged, so
|
||||
// React.memo children survive unrelated snapshot swaps (chunk storms must not re-render every
|
||||
// tool card and pending card). Mutation sites bump the matching revision. partial needs no
|
||||
// counter — PartialAccumulator.toPartial already returns a cached reference when unchanged.
|
||||
@@ -66,12 +67,22 @@ 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
|
||||
/** Buffer for live events arriving while open/resync is in flight (stitched by seq once history lands, §D.3). */
|
||||
/** Live events buffered during open/resync and stitched by sequence once history lands. */
|
||||
private liveBuffer: { event: SessionEvent; view: ToolEventView | undefined }[] = []
|
||||
/** Gap-repair (resync-lite) in flight: acceptLiveEvent detours to liveBuffer until the tail page lands (audit S3). */
|
||||
/** Gap repair in flight; live events detour to the buffer until the tail page lands. */
|
||||
private stitching = false
|
||||
/** subscribed.lastSeq baseline (gap detection; null when no subscribed frame arrived — degrade to the liveBuffer dedup path). */
|
||||
private subscribedLastSeq: number | null = null
|
||||
@@ -81,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()
|
||||
}
|
||||
|
||||
@@ -96,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 {
|
||||
@@ -110,6 +141,60 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Update this Session's retained prompt while it remains editable.
|
||||
* @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.
|
||||
@@ -277,6 +362,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
|
||||
@@ -292,8 +382,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
/** Instance-eviction hook, reserved no-op (design §F.6): resident instances are never destroyed
|
||||
* in v1; an eviction policy lands here (unsubscribe, drop buffers) without touching call sites. */
|
||||
/** No-op because session instances remain resident. */
|
||||
dispose(): void {}
|
||||
|
||||
// ---- 私有 ----
|
||||
@@ -311,6 +400,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> {
|
||||
@@ -527,21 +722,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'
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
243
packages/client/runtime/src/client/workspaces/manager.ts
Normal file
243
packages/client/runtime/src/client/workspaces/manager.ts
Normal 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)
|
||||
}
|
||||
164
packages/client/runtime/src/client/workspaces/service.ts
Normal file
164
packages/client/runtime/src/client/workspaces/service.ts
Normal 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
|
||||
}
|
||||
143
packages/client/runtime/src/client/workspaces/workspace.ts
Normal file
143
packages/client/runtime/src/client/workspaces/workspace.ts
Normal 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
|
||||
}
|
||||
@@ -1,11 +1,4 @@
|
||||
/**
|
||||
* Runtime plugin, node half. The implementation lives entirely in the client
|
||||
* half (src/client/ — SlotsService, SessionsService + object layer, and the
|
||||
* shell-held ClientLoader under ./loader); consumers import the /client or
|
||||
* /loader subpaths. The empty apply exists so the plugin appears in the host
|
||||
* Loader (lifecycle governance + dshClient discovery). Contract:
|
||||
* api-contracts v3 section 4.
|
||||
*/
|
||||
/** Host loader entry for the browser runtime exported from `./client` and `./loader`. */
|
||||
|
||||
/** Host plugin body — no host-side behavior for the runtime plugin. */
|
||||
export function apply(_ctx: unknown): void {}
|
||||
|
||||
@@ -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?.()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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],
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
191
packages/client/runtime/tests/session-intents.spec.ts
Normal file
191
packages/client/runtime/tests/session-intents.spec.ts
Normal 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])
|
||||
})
|
||||
})
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,9 +187,8 @@ describe('cell (render-layer session kit)', () => {
|
||||
const cell = b.svc.cell('s1')
|
||||
expect(cell).toBeDefined()
|
||||
expect(cell?.sessionId).toBe('s1')
|
||||
// Bare-source form (store migration): the cell carries the Session
|
||||
// observable itself; hook binding happens in the React machinery.
|
||||
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()
|
||||
})
|
||||
@@ -285,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' })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
157
packages/client/runtime/tests/workspaces-service.spec.ts
Normal file
157
packages/client/runtime/tests/workspaces-service.spec.ts
Normal 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/)
|
||||
})
|
||||
})
|
||||
@@ -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 the cross-object flow; each Workspace or Session object owns its own materialization. The Session 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).
|
||||
|
||||
|
||||
@@ -1,14 +1,4 @@
|
||||
/**
|
||||
* Client plugin body: register the conversation/details slot occupants and
|
||||
* the no-session empty state, contribute the chat entry into the
|
||||
* 'conversation.view' ring that the conversation registration declares, then
|
||||
* mount the conversation service (class plugin) and the bash toolview sample.
|
||||
* Assembly only — components receive everything through props: the framework
|
||||
* standard kit and store faces arrive automatically from the declarations
|
||||
* below; the inject factories contribute the plain-data-and-callbacks
|
||||
* business face (design §5). Tool rows are ordinary keyed-slot registrations
|
||||
* into 'conversation.chat.toolview' — no dedicated registry exists.
|
||||
*/
|
||||
/** Registers the conversation components, shared store, and service callbacks. */
|
||||
import type { Context } from 'cordis'
|
||||
import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -25,8 +15,8 @@ import { ConversationRoot } from './skeleton/ConversationRoot.tsx'
|
||||
import { DetailsPanel } from './skeleton/DetailsPanel.tsx'
|
||||
import { EmptyState } from './skeleton/EmptyState.tsx'
|
||||
|
||||
/** Required services (cordis fiber inject — the loader passes the whole export surface as an object plugin). */
|
||||
export const inject = ['slots', 'layout', 'sessions']
|
||||
/** Services required by the conversation plugin. */
|
||||
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 {
|
||||
@@ -37,24 +27,18 @@ function scopedConversation(sessions: SessionsService, id: SessionId): Conversat
|
||||
return conversation
|
||||
}
|
||||
|
||||
/**
|
||||
* Client plugin body.
|
||||
* @param ctx - client root context.
|
||||
/** Mounts the conversation plugin.
|
||||
* @param ctx - Client root context.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
const sessions = ctx.sessions
|
||||
const workspaces = ctx.workspaces
|
||||
const layout = ctx.layout
|
||||
const slots = ctx.slots
|
||||
|
||||
// Shared store handle, constructed here so its identity lives and dies with
|
||||
// this fiber (a module-level handle would be a de-facto singleton). The
|
||||
// conversation, chat-view, and details registrations all declare it; same
|
||||
// scope key = same instance, so chat-view selection writes and details
|
||||
// reads meet in one store.
|
||||
// Apply-time construction keeps store identity bound to this fiber.
|
||||
const chatStore = createChatStore()
|
||||
|
||||
// Tab projection over the view ring's ledger (list entries carry id/order/
|
||||
// label as registration options; the ledger keeps them order-sorted).
|
||||
const viewTabs = (): ViewTab[] => {
|
||||
const tabs: ViewTab[] = []
|
||||
for (const entry of slots.entries('conversation.view')) {
|
||||
@@ -103,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)
|
||||
@@ -120,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
|
||||
@@ -150,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)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
// StatsLine: the session stats row (figma 122:11212 "cache hit 92% · 1,284
|
||||
// tokens · 45.2s · 5 turns · 32 steps"), rendered by ChatView under the flow
|
||||
// (part of the chat view body — the chrome attachment mechanism retired with
|
||||
// the view ring). Duration has no data source in P-I (ledger). Subscribes to
|
||||
// `nodes` only: chunk batches never swap that reference, so the row renders
|
||||
// zero times during streaming (the RFC performance model's acceptance row).
|
||||
// Settled-node identity prevents stream-delta updates from rerendering this row.
|
||||
|
||||
import { memo, useMemo } from 'react'
|
||||
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
@@ -1,17 +1,7 @@
|
||||
/**
|
||||
* Slot-ring contract for the conversation package: the 'conversation.view'
|
||||
* slot this package declares (the view ring — one list entry per conversation
|
||||
* view tab), the chat view's per-tool row hole ('conversation.chat.toolview',
|
||||
* keyed on the wire tool name), and the composed props shapes its registrants
|
||||
* mount into the layout-owned slots (conversation / details /
|
||||
* conversation.empty) plus its own slots. Terminal slot design (§3): full
|
||||
* component props are the automatic shares — PropsRuntime<K> (framework
|
||||
* standard kit) & PropsRenderSlots<S> (declared children) & PropsStore<H>
|
||||
* (declared store's read/write faces) & the injected business face declared
|
||||
* here.
|
||||
*/
|
||||
/** 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'
|
||||
|
||||
@@ -41,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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,15 +85,9 @@ export type ConvViewProps = PropsRuntime<'conversation.view'>
|
||||
/** The shared chat store handle type (apply constructs one; the conversation, details, and chat-view registrations all declare it). */
|
||||
export type ChatStore = ReturnType<typeof createChatStore>
|
||||
|
||||
/**
|
||||
* Injected share of the conversation slot: plain data and callbacks only
|
||||
* (design §5 — hooks are framework-made). The store lines that used to ride
|
||||
* here live in the declared {@link ChatStore}; ancestry derives from the
|
||||
* standard useSessions hook in-component; views render through the declared
|
||||
* 'conversation.view' child slot, with this face projecting the tab strip.
|
||||
*/
|
||||
/** Business callbacks injected into the conversation slot. */
|
||||
export interface ConversationInjected {
|
||||
/** View tab read face (uSES triple over the 'conversation.view' slot ledger). */
|
||||
/** Views projected from the `conversation.view` slot ledger. */
|
||||
views: {
|
||||
list(): readonly ViewTab[]
|
||||
subscribe(fn: () => void): () => void
|
||||
@@ -111,8 +97,12 @@ export interface ConversationInjected {
|
||||
send(text: string, mode: 'queue' | 'steer'): void
|
||||
/** Cancel the in-flight turn (failure surfaces via snapshot.promptError). */
|
||||
stop(): void
|
||||
/** Navigate to another session (breadcrumb ancestors). */
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,7 +113,6 @@ export interface ConversationInjected {
|
||||
* with zero owner changes.
|
||||
*/
|
||||
export interface ComposerChainProps {
|
||||
/** The session's live pending waits, in arrival order (snapshot reference). */
|
||||
interactions: readonly PendingInteraction[]
|
||||
}
|
||||
|
||||
@@ -139,7 +128,6 @@ export type ConversationSlotProps =
|
||||
export interface ChatViewInjected {
|
||||
/** Selection write + details panel opening in one gesture (store action + layout orchestration). */
|
||||
openDetails(target: SelectionTarget): void
|
||||
/** Pull one older history page. */
|
||||
loadOlder(): void
|
||||
}
|
||||
|
||||
@@ -160,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
|
||||
|
||||
@@ -1,14 +1,4 @@
|
||||
/**
|
||||
* Shared conversation contract primitives: the view tab projection (slot
|
||||
* entries in 'conversation.view' surface as tabs), the chat store state
|
||||
* shared through the declared store, and the selection primitives every
|
||||
* domain consumes. Shared face between the skeleton domain (tab strip +
|
||||
* view outlet) and the chat domain; domain implementation files import this,
|
||||
* never each other. The view ring itself IS the 'conversation.view' slot
|
||||
* (contract in slots.ts) — the package-local view registry is retired, and
|
||||
* so is the hand-threaded translate channel (framework-level per-slot i18n
|
||||
* injection is the planned replacement).
|
||||
*/
|
||||
/** Shared conversation view, selection, and store-state contracts. */
|
||||
|
||||
/** Tool call identity as carried on the wire (branded upstream in connection). */
|
||||
export type CallId = string
|
||||
@@ -23,11 +13,8 @@ export interface SelectionTarget { turnSeq: number; stepSeq?: number; callId?: C
|
||||
export interface ViewTab { id: string; label: string }
|
||||
|
||||
/**
|
||||
* Chat store state (slot terminal design §4): the per-session store shared by
|
||||
* the conversation, chat-view, and details registrations. `createChatStore`
|
||||
* implements this shape. `view` may carry a stale persisted id after a view
|
||||
* plugin unloads — the slot ledger is the runtime validator (unknown ids fall
|
||||
* back to the first registered view).
|
||||
* Per-session state shared by conversation, chat-view, and details slots.
|
||||
* Unknown persisted view ids fall back to the first registered view.
|
||||
*/
|
||||
export interface ChatStoreState {
|
||||
/** Details-linkage channel (conversation writes, details reads). */
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
/**
|
||||
* Conversation domain plugin, browser half: skeleton (header/tabs/composer),
|
||||
* the 'conversation.view' slot ring (chat entry here; other plugins
|
||||
* contribute view tabs through ctx.slots), the chat view's keyed
|
||||
* 'conversation.chat.toolview' row hole, scope-addressed ConversationService,
|
||||
* minimal details panel. Contract: api-contracts v3 section 7. Thin shell:
|
||||
* type surfaces live in contract/, assembly in apply.ts; the implementation
|
||||
* domains (skeleton/chat) never import each other — contract/ is their only
|
||||
* shared face.
|
||||
* Browser conversation plugin. `contract/` is the shared type boundary
|
||||
* between the independently implemented skeleton and chat domains; `apply.ts`
|
||||
* owns their slot assembly.
|
||||
*/
|
||||
import type { ConversationService } from './service.ts'
|
||||
|
||||
@@ -20,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.
|
||||
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
/**
|
||||
* ConversationService implementation: scope-addressed send/cancel and the
|
||||
* empty-state startSession chain. Contract: api-contracts v3 section 7.
|
||||
* Selection/draft state moved to the declared chat store (slot terminal
|
||||
* design §4); the view registry moved to the 'conversation.view' slot (slot
|
||||
* ledger owns registration, ordering, and disposal) — what remains is the
|
||||
* send/stop orchestration face.
|
||||
* 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
|
||||
* read the session tag with scopeOf (same mechanism as the host tool
|
||||
* registry). Mutable state lives in plain objects reached by one property
|
||||
* read — field assignment through the tracker's shadow proxy is off-limits,
|
||||
* as are `#` hard-private fields.
|
||||
* read the session tag with `scopeOf`. Mutable state must remain reachable
|
||||
* through one property read; assignment through the tracker proxy and `#`
|
||||
* private fields bypass that rebinding.
|
||||
*/
|
||||
import { Service } from 'cordis'
|
||||
import type { Context } from 'cordis'
|
||||
@@ -50,37 +44,30 @@ export class ConversationService extends Service {
|
||||
if (!result.ok) throw new Error(`conversation.cancel failed: ${result.error.code}: ${result.error.message}`)
|
||||
}
|
||||
|
||||
/** Pull one older history page for the scoped Session. */
|
||||
async loadOlder(): Promise<void> {
|
||||
await this.scopedSession('loadOlder').loadOlder()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Update the scoped Session's retained pending prompt.
|
||||
* @param text - exact controlled-input value to retain.
|
||||
*/
|
||||
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)
|
||||
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. */
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
// 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
|
||||
/** 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,
|
||||
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'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}
|
||||
/* v8 ignore next -- structural noop: hero never passes running=true, so stop is unreachable. */
|
||||
onStop={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,305 +1,77 @@
|
||||
// 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'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() }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
// InputBar: the one composer input (figma Input_Bottom). The same component
|
||||
// serves the empty state (variant='hero': centered launch card) and the
|
||||
// resident composer (variant='composer') — the empty→content transition is a
|
||||
// position move of this component, never a swap (layout ruling). Running
|
||||
// LOCKS the input: textarea disabled with the draft visible, stop is the only
|
||||
// action; the turn ending re-enables and refocuses.
|
||||
//
|
||||
// Bottom chrome (attach / Plan / Read-only / model) is visual-only for now —
|
||||
// local native <select> state, no host wiring.
|
||||
// Shared empty-state and resident composer. Running retains the draft, locks
|
||||
// the textarea, and exposes only Stop. Bottom controls are local visual state.
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
|
||||
@@ -16,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
|
||||
}
|
||||
|
||||
@@ -25,14 +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
|
||||
/** Optional leading accessory row above the textarea (kept for callers; empty state no longer uses it). */
|
||||
accessory?: ReactNode
|
||||
onDraftChange: (text: string) => void
|
||||
onSend: (mode: 'queue' | 'steer') => void
|
||||
onStop: () => void
|
||||
onAdd?: () => void
|
||||
addLabel?: string
|
||||
}
|
||||
|
||||
interface SelectOption {
|
||||
@@ -56,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)
|
||||
@@ -107,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()
|
||||
@@ -138,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
|
||||
@@ -154,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}
|
||||
@@ -168,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>
|
||||
@@ -186,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}
|
||||
|
||||
@@ -1,21 +1,11 @@
|
||||
/**
|
||||
* Chat store factory (slot terminal design §4): selection + draft + active
|
||||
* view for one session, shared by the conversation and details registrations
|
||||
* (apply constructs one handle and passes it to both). Session-scope
|
||||
* derivation: both mount slots are scope=session, so the framework creates
|
||||
* one instance per session; the persist key is scope-suffixed by the
|
||||
* framework, aligning with the previous per-session draft persistence.
|
||||
*
|
||||
* Module exports the factory only — a module-level handle would pin identity
|
||||
* in the module cache (a de-facto singleton surviving plugin reloads).
|
||||
* Per-session chat store shared by conversation and details registrations.
|
||||
* The plugin creates its handle at apply time so identity follows the fiber.
|
||||
*/
|
||||
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ChatStoreState, SelectionTarget } from './contract/views.ts'
|
||||
|
||||
/**
|
||||
* Annotation twin of the actions literal below (the export needs a declared
|
||||
* return type); drift fails assignability at the defineStore call.
|
||||
*/
|
||||
/** Declared action shape used to give the exported factory a stable return type. */
|
||||
type ChatActions = {
|
||||
select: (draft: ChatStoreState, target: SelectionTarget | null) => void
|
||||
setDraft: (draft: ChatStoreState, text: string) => void
|
||||
@@ -25,18 +15,11 @@ type ChatActions = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Declare the per-session chat store. `selection` is the details-linkage
|
||||
* channel (conversation writes, details reads); `draft` is the composer text
|
||||
* (persisted so it survives session switches and reloads); `view` is the
|
||||
* active conversation view id (a 'conversation.view' entry id — store seat is
|
||||
* the cross-remount survival channel, null falls back to the first view).
|
||||
* @returns the store handle (spec + identity + factory in one value).
|
||||
* Declares the per-session chat state and write surface.
|
||||
* @returns the store handle.
|
||||
*/
|
||||
export function createChatStore(): EngineStoreHandle<ChatStoreState, ChatActions> {
|
||||
return defineStore({
|
||||
// Anchored to the contract shape: consumers read the store through
|
||||
// PropsStore<ChatStore>'s SnapshotSelectorHook<ChatStoreState>, so init
|
||||
// and the contract cannot drift.
|
||||
init: (): ChatStoreState => ({ selection: null, draft: '', view: null }),
|
||||
persist: 'dsh.conversation.chat',
|
||||
actions: {
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
/**
|
||||
* Conversation 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). Contract: api-contracts
|
||||
* v3 sections 0.3 and 7.
|
||||
*/
|
||||
/** Host loader entry for the browser-only conversation plugin. */
|
||||
|
||||
/** Host plugin body — no host-side behavior for the conversation plugin. */
|
||||
/** Provides no host-side behavior. */
|
||||
export function apply(): void {}
|
||||
|
||||
@@ -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,20 +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)
|
||||
// loadOlder moved to the chat view entry's face (the ring rider).
|
||||
|
||||
const chatView = b.chatViewSurface(ROOT)
|
||||
chatView.injected.loadOlder()
|
||||
expect(b.sessionFake.loadOlder).toHaveBeenCalledTimes(1)
|
||||
@@ -203,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)
|
||||
@@ -226,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')
|
||||
@@ -240,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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* createChatStore unit account (slot terminal design §4): the declared
|
||||
* actions write set, persist round-trip through the scope-suffixed key, and
|
||||
* factory purity (every create() is an independent instance; the factory
|
||||
* itself holds no singleton state).
|
||||
*/
|
||||
/** Chat-store actions, scoped persistence, and instance isolation. */
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createChatStore } from '../src/client/stores.ts'
|
||||
|
||||
|
||||
@@ -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 })
|
||||
@@ -146,8 +157,6 @@ describe('keyed toolview hole through the real machinery', () => {
|
||||
|
||||
it('a duplicate key registration fails loud at load', async () => {
|
||||
const b = await bench([])
|
||||
// The bash sample already holds the 'bash' key (later-wins retired with
|
||||
// the ring — the keyed ledger throws instead).
|
||||
expect(() => b.slots.register(
|
||||
{ name: 'conversation.chat.toolview', key: 'bash' },
|
||||
() => null,
|
||||
@@ -184,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 })
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,10 +69,18 @@ const runningCall = (callId: string, name = 'bash'): RunningToolCall => ({
|
||||
callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, time: 1_000, callView: null,
|
||||
})
|
||||
|
||||
/** Empty sessions-list hook stub (the global standard-kit seat; engines carry no hook since the store migration — bind here). */
|
||||
/** 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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
// @vitest-environment jsdom
|
||||
// Final branch tails for the coverage gate, terminal slot form:
|
||||
// AssistantMarkdown non-final reasoning, StatsLine usage-less node,
|
||||
// DetailsPanel titleless selection. (The old cwd WeakMap-cache account
|
||||
// retired with the mechanism — derivation lives in EmptyState now, covered
|
||||
// by the skeleton specs.)
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
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'
|
||||
@@ -24,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
|
||||
}
|
||||
|
||||
@@ -70,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()}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
/**
|
||||
* Test-local selector-hook binder: the engine carries no hook since the store
|
||||
* migration (runtime is React-free); the renderer binds in production, specs
|
||||
* bind here. Delegates to web-react's bindSnapshotSelector SOURCE (same
|
||||
* with-selector uSES shim as production, so selector-level render economics —
|
||||
* a top-level snapshot swap with an unchanged slice does NOT re-render — hold
|
||||
* in Profiler-count specs). Source-relative import: the package dependency
|
||||
* edge to web-react is gone (store migration §7); tests reach the sibling
|
||||
* package the same way they reach their own src internals.
|
||||
*/
|
||||
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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -1,38 +1,35 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Selection survival across the store seat (terminal design §4): the chat
|
||||
* store now carries what the per-scope selection account used to — this pins
|
||||
* the same behavior contract in the new mechanism. Drives the REAL
|
||||
* SlotsService store axis with the shared createChatStore handle (the exact
|
||||
* apply.ts shape: one handle, two session-slot registrations): same session's
|
||||
* two slots resolve one instance (conversation writes, details reads);
|
||||
* sessions are isolated; a session's death buries its instance AND its
|
||||
* persisted draft; a list refresh does not touch instance identity.
|
||||
* Exercises selection persistence through the real SlotsService store axis;
|
||||
* component stubs cannot prove per-session identity or disposal.
|
||||
*/
|
||||
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'
|
||||
|
||||
// The runtime package's programmable fake lives in its tests; import through
|
||||
// the src path (same pattern the runtime specs use — test-support material).
|
||||
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()
|
||||
@@ -49,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. */
|
||||
@@ -94,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'))
|
||||
@@ -108,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'))
|
||||
@@ -123,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)
|
||||
@@ -149,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 })
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(/停止失败:halt(internal)/)).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'))
|
||||
})
|
||||
})
|
||||
@@ -1,344 +1,205 @@
|
||||
// @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.click(b.view.getByRole('button', { name: 'Add attachment' }))
|
||||
expect((b.pickerOwner() as { open: boolean }).open).toBe(false)
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
/**
|
||||
* Layout 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). Contract: api-contracts
|
||||
* v3 sections 0.3 and 5.
|
||||
*/
|
||||
/** Host loader entry for the browser-only layout plugin. */
|
||||
|
||||
/** Host plugin body — no host-side behavior for the layout plugin. */
|
||||
/** Provides no host-side behavior. */
|
||||
export function apply(): void {}
|
||||
|
||||
@@ -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
|
||||
@@ -42,7 +46,7 @@ class ResizeObserverStub {
|
||||
|
||||
let frameWidth = 1920
|
||||
|
||||
/** Minimal selector hook over an engine instance (the engine carries no hook since the store migration; the renderer binds in production, the spec binds here). */
|
||||
/** Test-local selector hook over a framework-neutral store instance. */
|
||||
function hookOf<T>(inst: { subscribe: (fn: () => void) => () => void; getSnapshot: () => T }) {
|
||||
return <S,>(sel: (s: T) => S): S => sel(useSyncExternalStore(inst.subscribe, inst.getSnapshot))
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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() },
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
/**
|
||||
* Pure React atoms (zero cordis): StateDot, icons, Button/Pill/Menu/Modal/Input,
|
||||
* markdown family, ConnectionBanner. Everything consumes props plus --dsw-*
|
||||
* token vars only. Contract: api-contracts v3 section 8.
|
||||
* Cordis-free React primitives styled only through `--dsw-*` tokens.
|
||||
*/
|
||||
|
||||
export { StateDot } from './StateDot.tsx'
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -16,9 +16,7 @@ async function bench() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
const slots = ctx.get('slots') as SlotsService
|
||||
// Stand-in for ui-conversation's conversation entry: the composer slot only
|
||||
// exists while a live entry declares it in children (declaration account:
|
||||
// design §2.2).
|
||||
// The composer slot exists only while its declaring entry is live.
|
||||
slots.register(
|
||||
{ name: 'root', children: { 'conversation.composer': { kind: 'chain', scope: 'session' } } } as never,
|
||||
() => null,
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
/**
|
||||
* SidebarRoot (figma 133:7629): logo row + collapse, New Session, WorkSpace
|
||||
* section header with the group-by menu, search, session tree list, Settings
|
||||
* foot. Pure presentational — the session list arrives through the standard
|
||||
* useSessions hook, viewing state (expansion, search) is local component
|
||||
* state, and rows are derived in render via useMemo (slot design section 6:
|
||||
* derived data is a pure function, no materializing store).
|
||||
*
|
||||
* Collapse is a slide + crossfade: the content freezes at its expanded
|
||||
* Collapse is a slide plus crossfade: content freezes at its expanded
|
||||
* width (inline style) and fades out in place while the sliding column
|
||||
* (AppFrame grid tracks) clips it — nothing reflows mid-slide. At settle
|
||||
* the wide-only content (brand, labels, input, tree) unmounts, dropping
|
||||
@@ -14,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,
|
||||
@@ -22,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. */
|
||||
@@ -34,8 +28,8 @@ const COLLAPSE_SETTLE_MS = 150
|
||||
const EXPAND_SLIDE_MS = 300
|
||||
|
||||
const GROUP_BY_ITEMS = [
|
||||
{ id: 'workspace', label: 'WorkSpace' },
|
||||
// Update/Status grouping has no design yet (figma §3) — visible, disabled.
|
||||
{ id: 'workspace', label: 'Workspace' },
|
||||
// Only workspace grouping is implemented.
|
||||
{ id: 'update', label: 'Update', disabled: true },
|
||||
{ id: 'status', label: 'Status', disabled: true },
|
||||
]
|
||||
@@ -70,63 +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)
|
||||
// Wave-2 seam: row highlight expects `current` on the sessions list
|
||||
// snapshot (sessions.current lives with the runtime sessions service).
|
||||
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>
|
||||
@@ -138,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.
|
||||
@@ -196,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. */}
|
||||
@@ -210,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>}
|
||||
@@ -218,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
|
||||
@@ -241,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>
|
||||
@@ -271,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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,47 +1,30 @@
|
||||
/**
|
||||
* Sidebar plugin, browser half: SidebarRoot registered into the layout-owned
|
||||
* sidebar slot. Pure consumer — the session list arrives through the
|
||||
* standard useSessions prop, tree rows derive in the component, and the
|
||||
* inject surface is plain cross-service callbacks closed over the plugin's
|
||||
* own ctx (slot design sections 5 and 6); props composition in
|
||||
* contract/slots.ts. Export discipline: packages/client/AGENTS.md.
|
||||
*/
|
||||
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
/** Registers the sidebar UI into the layout-owned slot. */
|
||||
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'
|
||||
|
||||
/** Required services (cordis fiber inject — the loader passes the whole export surface as an object plugin). */
|
||||
export const inject = ['slots', 'layout', 'sessions']
|
||||
/** Services required by the sidebar plugin. */
|
||||
export const inject = ['slots', 'layout', 'sessions', 'workspaces']
|
||||
|
||||
/**
|
||||
* Client plugin body: register SidebarRoot into the sidebar slot. The inject
|
||||
* factory returns service callbacks only (no hooks, no store lines) — all
|
||||
* data reads ride the framework's standard useSessions delivery.
|
||||
* @param ctx - client root context.
|
||||
/** Registers the sidebar component and its service callbacks.
|
||||
* @param ctx - Client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const injectProps = (): SidebarRootInjected => ({
|
||||
// Selection lives with the runtime sessions service (current rides the
|
||||
// list snapshot); layout keeps 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',
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,48 +1,46 @@
|
||||
/**
|
||||
* Pure sidebar tree derivation: session list snapshot -> flat render rows.
|
||||
* Groups sessions by project directory (cwd), builds the per-group session
|
||||
* tree from parentId links, sorts by recency, and applies search filtering
|
||||
* with forced ancestor visibility. Derived data is a pure function (slot
|
||||
* design section 6): the component feeds the useSessions snapshot plus its
|
||||
* local viewing state through useMemo — no materializing store.
|
||||
* 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 } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
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 {
|
||||
@@ -53,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 {
|
||||
@@ -78,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) => {
|
||||
@@ -114,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,
|
||||
@@ -163,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). */
|
||||
@@ -193,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
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
/**
|
||||
* Sidebar 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). Contract: api-contracts
|
||||
* v3 sections 0.3 and 6.
|
||||
*/
|
||||
/** Host loader entry for the browser-only sidebar plugin. */
|
||||
|
||||
/** Host plugin body — no host-side behavior for the sidebar plugin. */
|
||||
/** Provides no host-side behavior. */
|
||||
export function apply(): void {}
|
||||
|
||||
@@ -1,118 +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
|
||||
// Stand-in for ui-layout's root entry: the sidebar slot only exists while
|
||||
// a live entry declares it in children (declaration account: design §2.2).
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
76
packages/client/ui-sidebar/tests/rows.spec.tsx
Normal file
76
packages/client/ui-sidebar/tests/rows.spec.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { IntentRowItem, ProjectRowItem, SessionNodeItem } from '../src/client/Rows.tsx'
|
||||
import type { GroupNode, SessionNode } from '../src/client/tree.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const sid = (id: string) => id as SessionId
|
||||
const wid = (id: string) => id as WorkspaceId
|
||||
|
||||
describe('sidebar rows', () => {
|
||||
it('renders an active Workspace and keeps its create action separate from toggling', () => {
|
||||
const onToggle = vi.fn()
|
||||
const onCreate = vi.fn()
|
||||
const group: GroupNode = {
|
||||
key: 'project', workspaceId: wid('project'), cwd: '/projects/project', label: 'Project',
|
||||
sessionCount: 1, expanded: true, containsCurrent: true, intentHere: false, sessions: [],
|
||||
}
|
||||
render(<ProjectRowItem group={group} onToggle={onToggle} onCreate={onCreate} />)
|
||||
|
||||
expect(screen.getByText('1 session')).toBeTruthy()
|
||||
expect(screen.getByRole('treeitem').getAttribute('aria-expanded')).toBe('true')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New session in Project' }))
|
||||
expect(onCreate).toHaveBeenCalledOnce()
|
||||
expect(onToggle).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByText('Project'))
|
||||
expect(onToggle).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('renders the frontend Intent placeholder as selected', () => {
|
||||
render(<IntentRowItem />)
|
||||
expect(screen.getByRole('treeitem').getAttribute('aria-selected')).toBe('true')
|
||||
})
|
||||
|
||||
it('renders and operates selected, running, recursive Session nodes', () => {
|
||||
const child: SessionNode = {
|
||||
id: sid('child'), title: 'Child', children: [], hasChildren: false,
|
||||
expanded: false, running: false, updatedAt: 0,
|
||||
}
|
||||
const parent: SessionNode = {
|
||||
id: sid('parent'), title: 'Parent', children: [child], hasChildren: true,
|
||||
expanded: true, running: true, updatedAt: 0,
|
||||
}
|
||||
const onOpen = vi.fn()
|
||||
const onToggle = vi.fn()
|
||||
const view = render(
|
||||
<SessionNodeItem node={parent} depth={0} currentId={parent.id} now={0} onOpen={onOpen} onToggle={onToggle} />,
|
||||
)
|
||||
|
||||
const parentRow = screen.getByText('Parent').closest('[role="treeitem"]')!
|
||||
const childRow = screen.getByText('Child').closest('[role="treeitem"]')!
|
||||
expect(parentRow.getAttribute('aria-selected')).toBe('true')
|
||||
expect(parentRow.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(childRow.getAttribute('aria-selected')).toBe('false')
|
||||
expect(childRow.hasAttribute('aria-expanded')).toBe(false)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Collapse' }))
|
||||
expect(onToggle).toHaveBeenCalledWith(parent.id)
|
||||
expect(onOpen).not.toHaveBeenCalled()
|
||||
fireEvent.click(parentRow)
|
||||
fireEvent.click(childRow)
|
||||
expect(onOpen.mock.calls).toEqual([[parent.id], [child.id]])
|
||||
|
||||
view.rerender(
|
||||
<SessionNodeItem
|
||||
node={{ ...parent, children: [], expanded: false, running: false }}
|
||||
depth={1} currentId={undefined} now={0} onOpen={onOpen} onToggle={onToggle}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Expand' })).toBeTruthy()
|
||||
expect(screen.getByRole('treeitem').getAttribute('aria-selected')).toBe('false')
|
||||
expect(screen.getByRole('treeitem').style.paddingLeft).toBe('24px')
|
||||
})
|
||||
})
|
||||
@@ -1,293 +1,261 @@
|
||||
// @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'
|
||||
// Engine home: runtime/client since the store migration; the engine carries
|
||||
// no hook (runtime is React-free), so the spec binds the selector locally.
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
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)))
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
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 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) => (
|
||||
function mount(sessionState: SessionListState = sessions) {
|
||||
const startSession = vi.fn()
|
||||
const open = vi.fn()
|
||||
let pickerOwner: unknown
|
||||
const view = render(
|
||||
<SidebarRoot
|
||||
collapsed={collapsed}
|
||||
width={width}
|
||||
useSessions={hookOf(sessions)}
|
||||
onOpen={onOpen}
|
||||
onCreate={onCreate}
|
||||
onToggleSidebar={onToggleSidebar}
|
||||
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 }
|
||||
}
|
||||
|
||||
function mountSidebar({
|
||||
sessionState = sessions,
|
||||
workspaceState = workspaces,
|
||||
collapsed = false,
|
||||
width = 300,
|
||||
}: {
|
||||
sessionState?: SessionListState
|
||||
workspaceState?: WorkspaceListState
|
||||
collapsed?: boolean
|
||||
width?: number
|
||||
} = {}) {
|
||||
const startSession = vi.fn()
|
||||
const open = vi.fn()
|
||||
const toggleSidebar = vi.fn()
|
||||
let pickerOwner: unknown
|
||||
let current = { sessionState, workspaceState, collapsed, width }
|
||||
const root = () => (
|
||||
<SidebarRoot
|
||||
collapsed={current.collapsed} width={current.width}
|
||||
useSessions={hook(current.sessionState)} useWorkspaces={hook(current.workspaceState)}
|
||||
startSession={startSession} open={open} toggleSidebar={toggleSidebar}
|
||||
renderSlot={((_key: string, owner: unknown) => { pickerOwner = owner; return null }) as SidebarRootComponentProps['renderSlot']}
|
||||
/>
|
||||
)
|
||||
const onToggleSidebar = vi.fn(() => {
|
||||
collapsed = !collapsed
|
||||
utils.rerender(view(collapsed ? 56 : 300))
|
||||
})
|
||||
const utils = render(view(300))
|
||||
return { sessions, onOpen, onCreate, onToggleSidebar, ...utils }
|
||||
const view = render(root())
|
||||
return {
|
||||
startSession,
|
||||
open,
|
||||
toggleSidebar,
|
||||
pickerOwner: () => pickerOwner,
|
||||
rerender(next: Partial<typeof current>) {
|
||||
current = { ...current, ...next }
|
||||
view.rerender(root())
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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"]')
|
||||
|
||||
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()
|
||||
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('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,
|
||||
})
|
||||
expect(screen.getByText('New session')).toBeTruthy()
|
||||
expect(screen.getByText('2 sessions')).toBeTruthy()
|
||||
expect(screen.getByText('1 session')).toBeTruthy()
|
||||
expect(screen.queryByText('root work')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New session in Project' }))
|
||||
expect(b.startSession).toHaveBeenCalledWith(workspace.workspaceId)
|
||||
})
|
||||
|
||||
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('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('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('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'))
|
||||
})
|
||||
|
||||
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', () => {
|
||||
it('opens, selects, dismisses, and toggles the group-by menu', () => {
|
||||
mount()
|
||||
const button = screen.getByRole('button', { name: 'Group by' })
|
||||
|
||||
fireEvent.click(button)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Workspace' }))
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
|
||||
fireEvent.click(button)
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
|
||||
fireEvent.click(button)
|
||||
fireEvent.click(button)
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
})
|
||||
|
||||
it('routes every Workspace picker close path', () => {
|
||||
const b = mount()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
|
||||
const owner = b.pickerOwner() as { open: boolean; onClose(): void }
|
||||
expect(owner.open).toBe(true)
|
||||
act(() => { owner.onClose() })
|
||||
expect((b.pickerOwner() as { open: boolean }).open).toBe(false)
|
||||
})
|
||||
|
||||
it('focuses, filters, and clears search while distinguishing both empty states', () => {
|
||||
mount()
|
||||
const input = screen.getByPlaceholderText('Search name, keywords...')
|
||||
fireEvent.click(input.parentElement!)
|
||||
expect(document.activeElement).toBe(input)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Search sessions' }))
|
||||
|
||||
fireEvent.change(input, { target: { value: 'missing' } })
|
||||
expect(screen.getByText('No matches')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Clear search' }))
|
||||
expect(screen.queryByText('No matches')).toBeNull()
|
||||
|
||||
cleanup()
|
||||
const emptySessions = listState()
|
||||
const emptyWorkspaces: WorkspaceListState = { ...workspaces, items: [], recentWorkspaceId: undefined }
|
||||
mountSidebar({ sessionState: emptySessions, workspaceState: emptyWorkspaces })
|
||||
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('toggles Workspace and nested Session expansion in both directions', () => {
|
||||
const parent = sid('parent')
|
||||
const child = sid('child')
|
||||
const nestedSessions: SessionListState = {
|
||||
...sessions,
|
||||
ids: [parent, child],
|
||||
byId: {
|
||||
[parent]: { id: parent, displayTitle: 'Parent', running: false, updatedAt: 2 },
|
||||
[child]: { id: child, displayTitle: 'Child', running: false, updatedAt: 1, parentId: parent },
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
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()
|
||||
const nestedWorkspace: WorkspaceListState = {
|
||||
...workspaces,
|
||||
items: [{ ...workspace, sessionIds: [parent, child] }],
|
||||
}
|
||||
mountSidebar({ sessionState: nestedSessions, workspaceState: nestedWorkspace })
|
||||
|
||||
fireEvent.click(screen.getByText('Project'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Expand' }))
|
||||
expect(screen.getByText('Child')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Collapse' }))
|
||||
expect(screen.queryByText('Child')).toBeNull()
|
||||
fireEvent.click(screen.getByText('Project'))
|
||||
expect(screen.queryByText('Parent')).toBeNull()
|
||||
})
|
||||
|
||||
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('does not start a Session from an Ungrouped row create action', () => {
|
||||
const loose = sid('loose')
|
||||
const looseSessions: SessionListState = {
|
||||
...listState(),
|
||||
ids: [loose],
|
||||
byId: { [loose]: { id: loose, displayTitle: 'Loose', running: false, updatedAt: 1 } },
|
||||
current: loose,
|
||||
}
|
||||
})
|
||||
|
||||
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 })
|
||||
})
|
||||
const b = mountSidebar({
|
||||
sessionState: looseSessions,
|
||||
workspaceState: { ...workspaces, items: [], recentWorkspaceId: undefined },
|
||||
})
|
||||
// Store notifications are microtask-batched.
|
||||
await flush()
|
||||
expect(screen.getByText('fresh')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New session in Ungrouped' }))
|
||||
expect(b.startSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
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('keeps an already expanded selected Workspace open and resolves later Workspace matches', () => {
|
||||
const b = mountSidebar()
|
||||
fireEvent.click(screen.getByText('Project'))
|
||||
const other = { ...workspace, workspaceId: wid('other'), title: 'Other', sessionIds: [] }
|
||||
b.rerender({
|
||||
sessionState: { ...sessions, current: sid('s1') },
|
||||
workspaceState: { ...workspaces, items: [other, workspace] },
|
||||
})
|
||||
expect(screen.getByText('First session')).toBeTruthy()
|
||||
|
||||
b.rerender({
|
||||
sessionState: {
|
||||
...sessions,
|
||||
current: sid('draft'),
|
||||
intent: { sessionId: sid('draft'), target: { kind: 'workspace-intent' }, prompt: '', phase: 'ready' },
|
||||
},
|
||||
})
|
||||
expect(screen.getByText('Project')).toBeTruthy()
|
||||
})
|
||||
|
||||
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('renders the static collapsed rail and expands rail search into focused input', () => {
|
||||
vi.useFakeTimers()
|
||||
const b = mountSidebar({ collapsed: true })
|
||||
expect(screen.getByRole('button', { name: 'Open sidebar' })).toBeTruthy()
|
||||
expect(screen.queryByPlaceholderText('Search name, keywords...')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open sidebar' }))
|
||||
expect(b.toggleSidebar).toHaveBeenCalledOnce()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Search sessions' }))
|
||||
expect(b.toggleSidebar).toHaveBeenCalledTimes(2)
|
||||
b.rerender({ collapsed: false })
|
||||
const input = screen.getByPlaceholderText('Search name, keywords...')
|
||||
act(() => { vi.advanceTimersByTime(300) })
|
||||
expect(document.activeElement).toBe(input)
|
||||
})
|
||||
|
||||
it('keeps wide content during live collapse, then settles to the rail', () => {
|
||||
vi.useFakeTimers()
|
||||
const b = mountSidebar({ width: 320 })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Collapse sidebar' }))
|
||||
expect(b.toggleSidebar).toHaveBeenCalledOnce()
|
||||
b.rerender({ collapsed: true, width: 56 })
|
||||
expect(screen.getByPlaceholderText('Search name, keywords...')).toBeTruthy()
|
||||
act(() => { vi.advanceTimersByTime(150) })
|
||||
expect(screen.queryByPlaceholderText('Search name, keywords...')).toBeNull()
|
||||
expect(screen.getByRole('button', { name: 'Open sidebar' })).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
function listState(): SessionListState {
|
||||
return { ids: [], byId: {}, current: undefined, phase: 'ready', intent: undefined }
|
||||
}
|
||||
|
||||
@@ -1,245 +1,158 @@
|
||||
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, projectLabel, UNGROUPED_KEY, UNGROUPED_LABEL } from '../src/client/tree.ts'
|
||||
|
||||
const sid = (s: string) => s as SessionId
|
||||
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,
|
||||
})
|
||||
|
||||
/** 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
|
||||
}
|
||||
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')])
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
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')])
|
||||
})
|
||||
|
||||
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 }
|
||||
}
|
||||
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,
|
||||
}))
|
||||
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)
|
||||
})
|
||||
|
||||
const view = (partial: Partial<TreeView> = {}): TreeView => ({
|
||||
expandedProjects: partial.expandedProjects ?? [],
|
||||
expandedSessions: partial.expandedSessions ?? [],
|
||||
query: partial.query ?? '',
|
||||
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)
|
||||
})
|
||||
|
||||
it('builds, sorts, expands, and cycle-guards an ungrouped session tree', () => {
|
||||
const parent = summary('parent', 1)
|
||||
const oldChild = { ...summary('old-child', 10), parentId: parent.id }
|
||||
const newChild = { ...summary('new-child', 20), parentId: parent.id }
|
||||
const tieB = { ...summary('tie-b', 20), parentId: parent.id }
|
||||
const tieA = { ...summary('tie-a', 20), parentId: parent.id }
|
||||
const self = { ...summary('self', 2), parentId: sid('self') }
|
||||
const orphan = { ...summary('orphan', 3), parentId: sid('missing') }
|
||||
const cycleA = { ...summary('cycle-a', 4), parentId: sid('cycle-b') }
|
||||
const cycleB = { ...summary('cycle-b', 5), parentId: sid('cycle-a') }
|
||||
const groups = deriveGroups(
|
||||
list(parent, oldChild, newChild, tieB, tieA, self, orphan, cycleA, cycleB),
|
||||
[],
|
||||
{ expandedProjects: [UNGROUPED_KEY], expandedSessions: [parent.id, cycleA.id, cycleB.id], query: '' },
|
||||
)
|
||||
|
||||
expect(groups).toHaveLength(1)
|
||||
expect(groups[0]!.sessions.map(node => node.id)).toEqual([
|
||||
sid('orphan'), sid('self'), parent.id, sid('cycle-a'),
|
||||
])
|
||||
expect(groups[0]!.sessions[2]!.children.map(node => node.id)).toEqual([
|
||||
newChild.id, tieA.id, tieB.id, oldChild.id,
|
||||
])
|
||||
|
||||
// Equal timestamps use ids as a deterministic tiebreak in either input order.
|
||||
expect(deriveGroups(list(summary('tie-a', 1), summary('tie-b', 1)), [], view([UNGROUPED_KEY]))[0]!
|
||||
.sessions.map(node => node.id)).toEqual([sid('tie-a'), sid('tie-b')])
|
||||
})
|
||||
|
||||
it('tolerates Workspace membership arriving before its Session summary', () => {
|
||||
const partial: SessionListState = {
|
||||
...list(),
|
||||
ids: [sid('present')],
|
||||
byId: { [sid('present')]: summary('present', 1) },
|
||||
}
|
||||
const groups = deriveGroups(partial, [workspace('project', ['missing', 'present'])], view(['project']))
|
||||
expect(groups[0]!.sessions.map(node => node.id)).toEqual([sid('present')])
|
||||
})
|
||||
|
||||
it('searches descendants with ancestors and handles cycles, self parents, and label-only hits', () => {
|
||||
const root = { ...summary('root', 1), displayTitle: 'Ancestor' }
|
||||
const match = { ...summary('match', 2), displayTitle: 'Needle child', parentId: root.id }
|
||||
const sibling = { ...summary('sibling', 3), displayTitle: 'Other child', parentId: root.id }
|
||||
const self = { ...summary('self', 4), displayTitle: 'Needle self', parentId: sid('self') }
|
||||
const orphan = { ...summary('orphan', 5), displayTitle: 'Needle orphan', parentId: sid('absent') }
|
||||
const cycleA = { ...summary('cycle-a', 6), displayTitle: 'Needle cycle A', parentId: sid('cycle-b') }
|
||||
const cycleB = { ...summary('cycle-b', 7), displayTitle: 'Needle cycle B', parentId: sid('cycle-a') }
|
||||
const sessions = list(root, match, sibling, self, orphan, cycleA, cycleB)
|
||||
const groups = deriveGroups(sessions, [workspace('project', sessions.ids)], view([], 'needle'))
|
||||
|
||||
expect(groups[0]!.sessions.flatMap(node => [node.id, ...node.children.map(child => child.id)])).toEqual([
|
||||
root.id, match.id, self.id, orphan.id, cycleA.id, cycleB.id,
|
||||
])
|
||||
|
||||
const labelOnly = deriveGroups(
|
||||
list(summary('hidden', 1)),
|
||||
[workspace('label-hit', ['hidden']), workspace('other', [])],
|
||||
view([], 'label'),
|
||||
)
|
||||
expect(labelOnly).toEqual([
|
||||
expect.objectContaining({ key: 'label-hit', expanded: false, sessions: [], sessionCount: 1 }),
|
||||
])
|
||||
})
|
||||
|
||||
it('marks selected Workspace and Ungrouped sessions without relying on an Intent', () => {
|
||||
const owned = summary('owned', 1)
|
||||
const loose = summary('loose', 2)
|
||||
const ws = workspace('project', ['owned'])
|
||||
const ownedGroups = deriveGroups({ ...list(owned, loose), current: owned.id }, [ws], view())
|
||||
expect(ownedGroups.find(group => group.key === 'project')!.containsCurrent).toBe(true)
|
||||
const looseGroups = deriveGroups({ ...list(owned, loose), current: loose.id }, [ws], view())
|
||||
expect(looseGroups.find(group => group.key === UNGROUPED_KEY)!.containsCurrent).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
it('falls back for empty and root-only paths', () => {
|
||||
it('uses the Ungrouped fallback and extracts POSIX and Windows basenames', () => {
|
||||
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('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'],
|
||||
}))
|
||||
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 }),
|
||||
])
|
||||
})
|
||||
|
||||
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' }),
|
||||
])
|
||||
expect(projectLabel('/projects/demo/')).toBe('demo')
|
||||
expect(projectLabel('C:\\projects\\demo\\')).toBe('demo')
|
||||
expect(projectLabel('/')).toBe('/')
|
||||
})
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -142,13 +142,9 @@ export interface SessionAreaProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* The framework-wired session area component (slot terminal design §7):
|
||||
* subscribes to the current-session selection internally (design fiat ① —
|
||||
* selection authority lives with runtime sessions) and switches between the
|
||||
* session body and the empty branch. Delivered as a standard seat to every
|
||||
* entry whose children declaration contains a session-scope slot (the
|
||||
* derivation rides {@link PropsRenderSlots}); the value is injected by the
|
||||
* installed renderer — business code never imports it.
|
||||
* Framework-wired session area component. It subscribes to runtime-owned
|
||||
* session selection and is injected into entries that declare session-scoped
|
||||
* children; business code does not import it directly.
|
||||
*/
|
||||
export type SessionProviderComponent = (props: SessionAreaProps) => ReactNode
|
||||
|
||||
@@ -352,8 +348,7 @@ export class SlotCore {
|
||||
|
||||
/**
|
||||
* Contribute a component to a declared slot and (optionally) declare child
|
||||
* slots, a store seat, and the registrant's business face — the single
|
||||
* composition API (the separate define API is retired).
|
||||
* slots, a store seat, and the registrant's business face.
|
||||
*
|
||||
* Load-time validation (misconfiguration fails loud; the render hot path
|
||||
* re-checks nothing): registering into an undeclared slot throws; declaring
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
/**
|
||||
* Renderer install seam (slot terminal design §8): the SlotRenderer interface
|
||||
* web-react's machinery implements, the host surface the runtime SlotsService
|
||||
* presents to the installed renderer, and the render-path authorization
|
||||
* errors. Pure types plus two error classes — this package stays React-free
|
||||
* at runtime (React types only).
|
||||
*/
|
||||
/** React-free contracts between the slot host and an installed renderer. */
|
||||
import type { ReactNode } from 'react'
|
||||
import type { SlotEntryDef, SlotSpec, StoredEntry } from './index.ts'
|
||||
|
||||
@@ -22,7 +16,6 @@ export interface HostObservable<T> {
|
||||
* typing lands at the component seam via {@link PropsStore}.
|
||||
*/
|
||||
export interface StoreInstanceLike {
|
||||
/** Current state snapshot (uSES getSnapshot side). */
|
||||
getSnapshot(): unknown
|
||||
/**
|
||||
* Subscribe to state changes (uSES subscribe side).
|
||||
@@ -30,7 +23,6 @@ export interface StoreInstanceLike {
|
||||
* @returns unsubscribe.
|
||||
*/
|
||||
subscribe(fn: () => void): () => void
|
||||
/** Baked write callbacks (delivered to components as `actions`). */
|
||||
readonly actions: Record<string, (...params: never[]) => void>
|
||||
}
|
||||
|
||||
@@ -97,7 +89,7 @@ export interface SlotRendererHost {
|
||||
sessions: {
|
||||
/** Session list source backing the useSessions standard hook. */
|
||||
list: HostObservable<unknown>
|
||||
/** Current-session source backing SessionProvider's self-wiring (design fiat ①). */
|
||||
/** Current-session source used by SessionProvider. */
|
||||
current: HostObservable<string | undefined>
|
||||
/**
|
||||
* Resolve the session standard kit.
|
||||
@@ -106,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. */
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
/**
|
||||
* Store-seat type family (slot terminal design §4): a registrant declares its
|
||||
* shared/exclusive business store as data — schema (`init`), optional
|
||||
* persistence key, and the complete write set (`actions`) — and the framework
|
||||
* owns instance lifecycle (scope derives from the mounting entry's slot).
|
||||
* ui-slots ships the contract types only; the engine-backed `defineStore`
|
||||
* value lives in web-react (the snapshot-store engine's home) and must
|
||||
* satisfy {@link DefineStore}.
|
||||
*/
|
||||
/** Framework-neutral store contracts for slot registrations and the runtime engine. */
|
||||
|
||||
/**
|
||||
* Typed selector hook over a snapshot source. Canonical shape for the whole
|
||||
@@ -41,11 +33,8 @@ export type BakedActions<T, A extends ActionsDecl<T>> = {
|
||||
* and the actions write set.
|
||||
*/
|
||||
export interface StoreSpec<T, A extends ActionsDecl<T>> {
|
||||
/** Initial-state factory; called once per framework-created instance. */
|
||||
init: () => T
|
||||
/** Opt-in persistence key (storage mechanics belong to the engine). */
|
||||
persist?: string
|
||||
/** Complete write set: pure draft transforms. */
|
||||
actions: A
|
||||
}
|
||||
|
||||
@@ -58,9 +47,7 @@ export interface StoreSpec<T, A extends ActionsDecl<T>> {
|
||||
* call create() themselves — instance lifecycle is the framework's.
|
||||
*/
|
||||
export interface StoreInstance<T, A extends ActionsDecl<T>> {
|
||||
/** Baked write callbacks (delivered to components as `actions`). */
|
||||
readonly actions: BakedActions<T, A>
|
||||
/** Current state snapshot (uSES getSnapshot side; test assertions). */
|
||||
getSnapshot(): T
|
||||
/**
|
||||
* Subscribe to state changes (uSES subscribe side).
|
||||
@@ -84,7 +71,6 @@ export interface StoreInstance<T, A extends ActionsDecl<T>> {
|
||||
* identity is a disguised singleton across plugin reloads.
|
||||
*/
|
||||
export interface StoreHandle<T, A extends ActionsDecl<T>> {
|
||||
/** The inert declaration this handle was defined from. */
|
||||
readonly spec: StoreSpec<T, A>
|
||||
/**
|
||||
* Create a live engine instance (framework machinery and tests only).
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
/**
|
||||
* Theme plugin, browser half: ThemeService over the --dsw-* token base
|
||||
* stylesheets in src/styles/ (the sole token source; components must not
|
||||
* hardcode colors). apply(id) toggles body[data-ds-dark-theme] — theming is
|
||||
* CSS cascade, zero React renders. Contract: api-contracts v3 section 8.
|
||||
* The base stylesheets ship separately (the web shell imports them as base
|
||||
* CSS); this plugin only owns the registry and the body-attribute switch.
|
||||
* Browser theme registry over the `--dsw-*` token stylesheets. Theme changes
|
||||
* update CSS variables and `body[data-ds-dark-theme]` without React renders.
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
/**
|
||||
* Theme 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). ThemeService and its
|
||||
* types live in the client half; consumers import the /client subpath.
|
||||
* Contract: api-contracts v3 section 8.
|
||||
*/
|
||||
/** Host loader entry for the browser implementation exported from `./client`. */
|
||||
|
||||
/** Host plugin body — no host-side behavior for the theme plugin. */
|
||||
export function apply(): void {}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user