feat(web): add workspace-aware session flow
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',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { WebApiClient } from './web-api-client.ts'
|
||||
export type {
|
||||
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
|
||||
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
|
||||
ToolCallView, ToolResultView,
|
||||
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
|
||||
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
|
||||
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
|
||||
IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,
|
||||
|
||||
@@ -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,177 @@ describe('createFixtureApi', () => {
|
||||
const api = createFixtureApi()
|
||||
const response = await api.host.describe(req({}))
|
||||
expect(response.result).toMatchObject({ ok: true, value: { version: '0.0.0-fixture', attachedSessions: 1 } })
|
||||
const empty = await createFixtureApi({ empty: true }).host.describe(req({}))
|
||||
expect(empty.result).toMatchObject({ ok: true, value: { attachedSessions: 0 } })
|
||||
})
|
||||
|
||||
it('workspace.list serves the resident account and create reuses on path collision', async () => {
|
||||
const api = createFixtureApi()
|
||||
const listed = await api.workspace.list(req({}))
|
||||
if (!listed.result.ok) throw new Error('list failed')
|
||||
expect(listed.result.value.items).toEqual([expect.objectContaining({
|
||||
workspaceId: 'fx-ws-fixture', path: '/tmp/fixture', title: 'fixture',
|
||||
sessionIds: ['fx-alpha', 'fx-beta', 'fx-gamma'],
|
||||
})])
|
||||
// path collision → the existing entity comes back, created:false, no frame.
|
||||
const reused = await api.workspace.create(req({ path: '/tmp/fixture' }))
|
||||
if (!reused.result.ok) throw new Error('reuse failed')
|
||||
expect(reused.result.value).toMatchObject({ created: false, workspace: { workspaceId: 'fx-ws-fixture' } })
|
||||
})
|
||||
|
||||
it('workspace.create by name mints a new entity and pushes host/workspace-changed', async () => {
|
||||
const api = createFixtureApi()
|
||||
const abort = new AbortController()
|
||||
const seen: HostFrame[] = []
|
||||
const consuming = (async () => {
|
||||
for await (const envelope of api.events.host(req({}), abort.signal)) {
|
||||
seen.push(envelope.payload)
|
||||
abort.abort()
|
||||
}
|
||||
})()
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
const created = await api.workspace.create(req({ name: 'nova' }))
|
||||
if (!created.result.ok) throw new Error('create failed')
|
||||
expect(created.result.value.created).toBe(true)
|
||||
expect(created.result.value.workspace).toMatchObject({
|
||||
path: '/tmp/fixture-workspaces/nova', title: 'nova', sessionIds: [],
|
||||
})
|
||||
await consuming
|
||||
expect(seen).toEqual([{ type: 'host/workspace-changed', workspace: created.result.value.workspace }])
|
||||
// path spelling falls back to the basename when no title/name rides along.
|
||||
const pathOnly = await api.workspace.create(req({ path: '/tmp/fixture-elsewhere/base' }))
|
||||
if (!pathOnly.result.ok) throw new Error('pathOnly failed')
|
||||
expect(pathOnly.result.value.workspace.title).toBe('base')
|
||||
// Degenerate spellings reach the impl unfiltered (the fixture carrier has
|
||||
// no schema gate): both-absent falls back to the bucket dir, and a
|
||||
// basename-less path serves as its own title.
|
||||
const bare = await api.workspace.create(req({}))
|
||||
if (!bare.result.ok) throw new Error('bare failed')
|
||||
expect(bare.result.value.workspace).toMatchObject({ path: '/tmp/fixture-workspaces/', title: 'fixture-workspaces' })
|
||||
const rootPath = await api.workspace.create(req({ path: '/' }))
|
||||
if (!rootPath.result.ok) throw new Error('rootPath failed')
|
||||
expect(rootPath.result.value.workspace.title).toBe('/')
|
||||
})
|
||||
|
||||
it('session.create({workspaceId}) lands on the account and unknown ids error', async () => {
|
||||
const api = createFixtureApi()
|
||||
const abort = new AbortController()
|
||||
const seen: HostFrame[] = []
|
||||
const consuming = (async () => {
|
||||
for await (const envelope of api.events.host(req({}), abort.signal)) {
|
||||
seen.push(envelope.payload)
|
||||
if (seen.length >= 2) abort.abort()
|
||||
}
|
||||
})()
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
const missing = await api.sessions.create(req({ workspaceId: 'fx-ws-void' as WorkspaceId }))
|
||||
expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found', details: { workspaceId: 'fx-ws-void' } } })
|
||||
const created = await api.sessions.create(req({ workspaceId: 'fx-ws-fixture' as WorkspaceId }))
|
||||
if (!created.result.ok) throw new Error('create failed')
|
||||
const id = created.result.value.sessionId
|
||||
await consuming
|
||||
// The session lands with the workspace's path as cwd, and the account
|
||||
// write pushes the fresh workspace snapshot after session-added.
|
||||
expect(seen[0]).toEqual({ type: 'host/session-added', sessionId: id, cwd: '/tmp/fixture' })
|
||||
expect(seen[1]).toMatchObject({
|
||||
type: 'host/workspace-changed',
|
||||
workspace: { workspaceId: 'fx-ws-fixture', sessionIds: [id, 'fx-alpha', 'fx-beta', 'fx-gamma'] },
|
||||
})
|
||||
})
|
||||
|
||||
it('supports an empty baseline, preallocated ids, workspace-first frames, and idempotent retry', async () => {
|
||||
const api = createFixtureApi({ empty: true, createFrameOrder: 'workspace-first' })
|
||||
const initialSessions = await api.sessions.list(req({}))
|
||||
const initialWorkspaces = await api.workspace.list(req({}))
|
||||
expect(initialSessions.result).toMatchObject({ ok: true, value: { items: [] } })
|
||||
expect(initialWorkspaces.result).toMatchObject({ ok: true, value: { items: [] } })
|
||||
|
||||
const made = await api.workspace.create(req({ name: 'nova' }))
|
||||
if (!made.result.ok) throw new Error('workspace create failed')
|
||||
const abort = new AbortController()
|
||||
const framesPromise = collect(api.events.host(req({}), abort.signal), abort, frames => frames.length === 2)
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
const preallocated = sid('fx-preallocated')
|
||||
const created = await api.sessions.create(req({
|
||||
workspaceId: made.result.value.workspace.workspaceId,
|
||||
sessionId: preallocated,
|
||||
}))
|
||||
expect(created.result).toEqual({ ok: true, value: { sessionId: preallocated } })
|
||||
const frames = await framesPromise
|
||||
expect(frames[0]).toMatchObject({
|
||||
type: 'host/workspace-changed', workspace: { sessionIds: [preallocated] },
|
||||
})
|
||||
expect(frames[1]).toEqual({ type: 'host/session-added', sessionId: preallocated, cwd: made.result.value.workspace.path })
|
||||
|
||||
const retried = await api.sessions.create(req({
|
||||
workspaceId: made.result.value.workspace.workspaceId,
|
||||
sessionId: preallocated,
|
||||
}))
|
||||
expect(retried.result).toEqual({ ok: true, value: { sessionId: preallocated } })
|
||||
const listed = await api.sessions.list(req({}))
|
||||
if (!listed.result.ok) throw new Error('session list failed')
|
||||
expect(listed.result.value.items.filter(item => item.sessionId === preallocated)).toHaveLength(1)
|
||||
|
||||
const conflict = await api.sessions.create(req({ sessionId: preallocated, cwd: '/elsewhere' }))
|
||||
expect(conflict.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'session-conflict', details: { sessionId: preallocated, requestedCwd: '/elsewhere' } },
|
||||
})
|
||||
})
|
||||
|
||||
it('publishes an ungrouped Session when Workspace attachment fails', async () => {
|
||||
const api = createFixtureApi({ failWorkspaceAttach: true })
|
||||
const sessionId = sid('fx-partial')
|
||||
const created = await api.sessions.create(req({
|
||||
workspaceId: 'fx-ws-fixture' as WorkspaceId,
|
||||
sessionId,
|
||||
}))
|
||||
expect(created.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'workspace-attach-failed', details: { sessionId, workspaceId: 'fx-ws-fixture' } },
|
||||
})
|
||||
const listed = await api.sessions.list(req({}))
|
||||
const workspaces = await api.workspace.list(req({}))
|
||||
if (!listed.result.ok || !workspaces.result.ok) throw new Error('list failed')
|
||||
expect(listed.result.value.items.filter(item => item.sessionId === sessionId)).toHaveLength(1)
|
||||
expect(workspaces.result.value.items[0]?.sessionIds).not.toContain(sessionId)
|
||||
|
||||
const retried = await api.sessions.create(req({
|
||||
workspaceId: 'fx-ws-fixture' as WorkspaceId,
|
||||
sessionId,
|
||||
}))
|
||||
expect(retried.result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } })
|
||||
const afterRetry = await api.sessions.list(req({}))
|
||||
if (!afterRetry.result.ok) throw new Error('list failed')
|
||||
expect(afterRetry.result.value.items.filter(item => item.sessionId === sessionId)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('reconciles a dropped create response and can reject a prompt before acceptance', async () => {
|
||||
const sessionId = sid('fx-lost-response')
|
||||
const dropped = createFixtureApi({ dropSessionCreateResponse: true })
|
||||
await expect(Promise.resolve().then(() => dropped.sessions.create(req({
|
||||
workspaceId: 'fx-ws-fixture' as WorkspaceId,
|
||||
sessionId,
|
||||
})))).rejects.toThrow(/dropped session\.create response/)
|
||||
const listed = await dropped.sessions.list(req({}))
|
||||
const workspaces = await dropped.workspace.list(req({}))
|
||||
if (!listed.result.ok || !workspaces.result.ok) throw new Error('list failed')
|
||||
expect(listed.result.value.items.some(item => item.sessionId === sessionId)).toBe(true)
|
||||
expect(workspaces.result.value.items[0]?.sessionIds).toContain(sessionId)
|
||||
await expect(dropped.sessions.create(req({
|
||||
workspaceId: 'fx-ws-fixture' as WorkspaceId,
|
||||
sessionId,
|
||||
}))).resolves.toMatchObject({ result: { ok: true, value: { sessionId } } })
|
||||
|
||||
const rejecting = createFixtureApi({ empty: true, rejectPrompt: true })
|
||||
const real = await rejecting.sessions.create(req({ sessionId: sid('fx-rejected') }))
|
||||
if (!real.result.ok) throw new Error('session create failed')
|
||||
const prompt = await rejecting.sessions.prompt(req({
|
||||
sessionId: real.result.value.sessionId,
|
||||
mode: 'queue' as const,
|
||||
content: [{ type: 'text' as const, text: 'keep me' }],
|
||||
}))
|
||||
expect(prompt.result).toMatchObject({ ok: false, error: { code: 'agent-busy' } })
|
||||
})
|
||||
|
||||
it('timing hooks: history delay + one-shot failure, silent append, and breakStreams end open generators', async () => {
|
||||
@@ -311,6 +482,7 @@ describe('createFixtureApi', () => {
|
||||
describe('FixtureApiClient (protocol-level fake carrier)', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('doFetch is an unreachable tripwire (all protocol paths overridden)', () => {
|
||||
@@ -346,6 +518,57 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => {
|
||||
expect((await client.sessions.prompt({ sessionId: id, mode: 'queue', content: [{ type: 'text', text: '嗨' }] })).result.ok).toBe(true)
|
||||
expect((await client.sessions.cancel({ sessionId: id })).result.ok).toBe(true)
|
||||
expect((await client.host.describe({})).result.ok).toBe(true)
|
||||
expect((await client.workspace.list({})).result.ok).toBe(true)
|
||||
const workspace = await client.workspace.create({ name: 'via-client' })
|
||||
if (!workspace.result.ok) throw new Error('workspace create failed')
|
||||
expect(workspace.result.value.workspace.title).toBe('via-client')
|
||||
})
|
||||
|
||||
it('maps empty, prompt-reject, and workspace-first query scenarios', async () => {
|
||||
vi.stubGlobal('location', {
|
||||
search: '?fixture=empty&fixturePrompt=reject&fixtureFrames=workspace-first',
|
||||
})
|
||||
const client = new FixtureApiClient()
|
||||
await expect(client.sessions.list({})).resolves.toMatchObject({ result: { ok: true, value: { items: [] } } })
|
||||
const made = await client.workspace.create({ name: 'query-workspace' })
|
||||
if (!made.result.ok) throw new Error('workspace create failed')
|
||||
const abort = new AbortController()
|
||||
const framesPromise = collect(client.events.host({}, abort.signal), abort, frames => frames.length === 2)
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
const sessionId = sid('fx-query-session')
|
||||
const created = await client.sessions.create({
|
||||
workspaceId: made.result.value.workspace.workspaceId,
|
||||
sessionId,
|
||||
})
|
||||
expect(created.result).toMatchObject({ ok: true, value: { sessionId } })
|
||||
const frames = await framesPromise
|
||||
expect(frames.map(frame => frame.type)).toEqual(['host/workspace-changed', 'host/session-added'])
|
||||
const rejected = await client.sessions.prompt({
|
||||
sessionId,
|
||||
mode: 'queue',
|
||||
content: [{ type: 'text', text: 'retain' }],
|
||||
})
|
||||
expect(rejected.result).toMatchObject({ ok: false, error: { code: 'agent-busy' } })
|
||||
})
|
||||
|
||||
it('maps attach-failure and dropped-response query scenarios', async () => {
|
||||
vi.stubGlobal('location', { search: '?fixture&fixtureAttach=fail' })
|
||||
const partial = new FixtureApiClient()
|
||||
const partialResult = await partial.sessions.create({
|
||||
workspaceId: 'fx-ws-fixture' as WorkspaceId,
|
||||
sessionId: sid('fx-query-partial'),
|
||||
})
|
||||
expect(partialResult.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'workspace-attach-failed', details: { sessionId: 'fx-query-partial' } },
|
||||
})
|
||||
|
||||
vi.stubGlobal('location', { search: '?fixture&fixtureSessionCreate=drop-response' })
|
||||
const dropped = new FixtureApiClient()
|
||||
await expect(dropped.sessions.create({
|
||||
workspaceId: 'fx-ws-fixture' as WorkspaceId,
|
||||
sessionId: sid('fx-query-dropped'),
|
||||
})).rejects.toThrow(/dropped session\.create response/)
|
||||
})
|
||||
|
||||
it('fires onOpen at stream-iteration start and taps server-request full forms', async () => {
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
# @deepseek-ai/dsh-client-runtime
|
||||
|
||||
Client cordis boot + core services: SlotsService (Service wrapper over SlotCore + 'slots/changed' bridge), SessionsService (list store projection, scope tree, bindings, ancestry), the Session object layer (exported as a type; instances are owned and handed out by SessionsService — the manager/paging internals stay package-internal, tests reach them via src), ClientLoader (`./loader` subpath, statically held by the shell). Contract: api-contracts v3 §4.
|
||||
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list/scope/history state, and page-local Session Intent state; WorkspacesService owns Workspace objects, list/actions, page-local Workspace Intent state, and default-target derivation. The runtime fans the shared Host stream into both managers. Contract: api-contracts v3 §4.
|
||||
|
||||
## Workspace and Session lists
|
||||
|
||||
Workspace and Session lists have independent monotone `pending` → `ready` baseline phases and separate refresh activity/error state. Incremental frames arriving during a list request replay over its response. The first successful baseline establishes Host order; later refreshes update rows and membership without changing the relative order of identities already shown. Workspace recency is derived only after both baselines are ready and never changes Workspace list order.
|
||||
|
||||
SlotsService gives the renderer separate bare observables for `useSessions` and `useWorkspaces`; web-react creates the hooks. Workspace business state does not enter `SessionListState` or an entry store.
|
||||
|
||||
## Session creation failures
|
||||
|
||||
`SessionsService.create` accepts an optional caller-preallocated SessionId. It throws `SessionCreateError` on failure: `requestedSessionId` remains available after transport uncertainty, while `publishedSessionId` is set when `workspace-attach-failed` proves the Host published a real Session before attachment failed. For the New Session flow, the frontend Session object owns its retained prompt and advances it through attachment and send; a partially published Session keeps the same object and prompt while it appears as Ungrouped.
|
||||
|
||||
## Session title projection
|
||||
|
||||
|
||||
@@ -1,30 +1,32 @@
|
||||
/**
|
||||
* Browser runtime services for slots, sessions, and connection-stream
|
||||
* delivery. The web shell mounts this static client entry through the host
|
||||
* plugin graph.
|
||||
*/
|
||||
/** Browser runtime services for slots, sessions, workspaces, and connection-stream delivery. */
|
||||
import type { Context } from 'cordis'
|
||||
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotsService } from './slots.ts'
|
||||
import { SessionsService } from './sessions/service.ts'
|
||||
import type { SessionListState } from './sessions/service.ts'
|
||||
import { WorkspacesService } from './workspaces/service.ts'
|
||||
import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from './sessions/conversation.ts'
|
||||
|
||||
export { SlotsService } from './slots.ts'
|
||||
export type { RootOwnerProps } from './slots.ts'
|
||||
export { SessionsService, scopeOf } from './sessions/service.ts'
|
||||
export { SessionCreateError, SessionsService, scopeOf, workspaceTitleOf } from './sessions/service.ts'
|
||||
export { WorkspacesService } from './workspaces/service.ts'
|
||||
export type { Session } from './sessions/session.ts'
|
||||
export type { SessionBinding, SessionListState, SessionSummary } from './sessions/service.ts'
|
||||
export type { SessionIntentListSnapshot, SessionListPhase } from './sessions/manager.ts'
|
||||
export type { WorkspaceListPhase } from './workspaces/manager.ts'
|
||||
export type { WorkspaceListState } from './workspaces/service.ts'
|
||||
export type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
// Runtime owns the snapshot store; web-react only binds it to React.
|
||||
export { createSnapshotStore, defineStore, shallowEqual } from './contract/store.ts'
|
||||
export type {
|
||||
EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore,
|
||||
} from './contract/store.ts'
|
||||
export type {
|
||||
AssistantBlock, AssistantMessageNode, ContextMessageNode, ConversationNode, ConversationSnapshot,
|
||||
RunningToolCall, SteeringMessageNode,
|
||||
ToolResultNode, UnknownSurfaceNode, UserMessageNode,
|
||||
AssistantBlock, AssistantMessageNode, ComposerPhase, ContextMessageNode, ConversationNode,
|
||||
ConversationSnapshot, PendingPrompt, RunningToolCall, SessionIntentSnapshot, SessionIntentTarget,
|
||||
SteeringMessageNode, ToolResultNode, UnknownSurfaceNode, UserMessageNode,
|
||||
} from './sessions/conversation.ts'
|
||||
export { PendingWait } from './sessions/pending.ts'
|
||||
export type { PendingInteraction, PendingKind, PendingPayloads } from './sessions/pending.ts'
|
||||
@@ -57,6 +59,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
/** Props injected into every global slot component. */
|
||||
interface GlobalStandardProps {
|
||||
useSessions: SnapshotSelectorHook<SessionListState>
|
||||
/** Selector hook over real Workspaces and their independent baseline lifecycle. */
|
||||
useWorkspaces: SnapshotSelectorHook<import('./workspaces/service.ts').WorkspaceListState>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +76,7 @@ declare module 'cordis' {
|
||||
interface Context {
|
||||
slots: import('./slots.ts').SlotsService
|
||||
sessions: import('./sessions/service.ts').SessionsService
|
||||
workspaces: import('./workspaces/service.ts').WorkspacesService
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,10 +90,17 @@ export function apply(ctx: Context): void {
|
||||
ctx.plugin(SlotsService)
|
||||
const connection = ctx.get('connection') as ConnectionHandle
|
||||
const sessions = new SessionsService(ctx, connection.api)
|
||||
const workspaces = new WorkspacesService(ctx, connection.api, sessions)
|
||||
const loop = connection.start({
|
||||
onMuxEnvelope: (envelope) => { sessions.manager.handleMuxEnvelope(envelope) },
|
||||
onHostEnvelope: (envelope) => { sessions.manager.handleHostEnvelope(envelope) },
|
||||
onConnected: () => { sessions.manager.handleConnected() },
|
||||
onMuxEnvelope: (envelope) => { sessions.handleMuxEnvelope(envelope) },
|
||||
onHostEnvelope: (envelope) => {
|
||||
sessions.handleHostEnvelope(envelope)
|
||||
workspaces.handleHostEnvelope(envelope)
|
||||
},
|
||||
onConnected: () => {
|
||||
sessions.handleConnected()
|
||||
workspaces.handleConnected()
|
||||
},
|
||||
})
|
||||
ctx.effect(() => () => { loop.stop() }, 'runtime: connection stream loop')
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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,84 @@ export class SessionManager {
|
||||
this.listSnapshotCache = this.buildListSnapshot()
|
||||
})
|
||||
|
||||
constructor(private readonly api: IApiClient) {
|
||||
/**
|
||||
* @param api - shared wire client.
|
||||
* @param restoredSelection - persisted real-Session selection candidate.
|
||||
*/
|
||||
constructor(
|
||||
private readonly api: IApiClient,
|
||||
restoredSelection?: SessionId,
|
||||
) {
|
||||
this.selected = restoredSelection
|
||||
this.listSnapshotCache = this.buildListSnapshot()
|
||||
}
|
||||
|
||||
// ---- Selection and client-local intents ----
|
||||
|
||||
/**
|
||||
* Select a real Session and discard the unmaterialized intent.
|
||||
* @param sessionId - listed real Session id.
|
||||
*/
|
||||
select(sessionId: SessionId): void {
|
||||
if (!this.summaries.some(summary => summary.sessionId === sessionId)) {
|
||||
throw new Error(`sessions.select: unknown session ${sessionId}`)
|
||||
}
|
||||
this.discardIntent()
|
||||
this.selected = sessionId
|
||||
this.notifier.notifyNow()
|
||||
}
|
||||
|
||||
/** Clear selection and abandon any frontend-only Session. */
|
||||
clearSelection(): void {
|
||||
this.discardIntent()
|
||||
this.selected = undefined
|
||||
this.notifier.notifyNow()
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a frontend Session against a real or still-local Workspace target.
|
||||
* @param target - real Workspace or the WorkspacesService-owned local target.
|
||||
* @param prompt - optional prompt retained when retargeting from a picker.
|
||||
* @returns the frontend Session object that owns the Intent.
|
||||
*/
|
||||
startIntent(target: SessionIntentTarget, prompt = ''): Session {
|
||||
this.discardIntent()
|
||||
const sessionId = `client-session-${crypto.randomUUID()}` as SessionId
|
||||
const session = this.createSession(sessionId, { target, prompt })
|
||||
this.sessions.set(sessionId, session)
|
||||
this.intentSessionId = sessionId
|
||||
this.selected = sessionId
|
||||
this.stopIntentWatch = session.subscribe(() => {
|
||||
if (this.intentSessionId !== sessionId) return
|
||||
if (session.getSnapshot().intent === null) {
|
||||
this.intentSessionId = undefined
|
||||
this.stopIntentWatch?.()
|
||||
this.stopIntentWatch = undefined
|
||||
}
|
||||
this.notifier.markDirty()
|
||||
})
|
||||
this.notifier.notifyNow()
|
||||
return session
|
||||
}
|
||||
|
||||
/** @returns the active frontend Session, if one remains selected. */
|
||||
getIntent(): Session | undefined {
|
||||
return this.intentSessionId === undefined ? undefined : this.sessions.get(this.intentSessionId)
|
||||
}
|
||||
|
||||
/** @param text - exact controlled-input value for the active frontend Session. */
|
||||
updateIntent(text: string): void {
|
||||
this.getIntent()?.updatePendingPrompt(text)
|
||||
}
|
||||
|
||||
private discardIntent(): void {
|
||||
const session = this.getIntent()
|
||||
this.intentSessionId = undefined
|
||||
this.stopIntentWatch?.()
|
||||
this.stopIntentWatch = undefined
|
||||
session?.abandonIntent()
|
||||
}
|
||||
|
||||
// ---- Instance management ----
|
||||
|
||||
/**
|
||||
@@ -67,7 +178,7 @@ export class SessionManager {
|
||||
get(sessionId: SessionId): Session {
|
||||
let session = this.sessions.get(sessionId)
|
||||
if (session === undefined) {
|
||||
session = new Session(sessionId, this.api)
|
||||
session = this.createSession(sessionId)
|
||||
this.sessions.set(sessionId, session)
|
||||
// Sync the running bit from the list snapshot into the new instance (consistency when the list precedes open).
|
||||
const summary = this.summaries.find(s => s.sessionId === sessionId)
|
||||
@@ -82,6 +193,22 @@ export class SessionManager {
|
||||
return session
|
||||
}
|
||||
|
||||
private createSession(
|
||||
sessionId: SessionId,
|
||||
intent?: { target: SessionIntentTarget; prompt: string },
|
||||
): Session {
|
||||
return new Session(sessionId, this.api, {
|
||||
...(intent === undefined ? {} : { intent }),
|
||||
onPublished: (published) => {
|
||||
this.sessions.set(published.sessionId, published)
|
||||
this.recordMutation({
|
||||
kind: 'upsert',
|
||||
summary: { sessionId: published.sessionId, updatedAt: Date.now(), running: false },
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ---- List surface ----
|
||||
|
||||
/** Full refresh via session.list (single-flight: an in-flight call is reused). */
|
||||
@@ -89,13 +216,21 @@ export class SessionManager {
|
||||
if (this.listInflight !== null) return this.listInflight
|
||||
this.listState = 'loading'
|
||||
this.listError = null
|
||||
const established = this.summaries
|
||||
const mutations: SessionListMutation[] = []
|
||||
this.listMutations = mutations
|
||||
this.notifier.markDirty()
|
||||
this.listInflight = (async () => {
|
||||
try {
|
||||
const { result } = await this.api.sessions.list({})
|
||||
if (result.ok) {
|
||||
this.summaries = result.value.items
|
||||
let summaries = this.listPhase === 'pending'
|
||||
? result.value.items
|
||||
: mergeOrderedBaseline(established, result.value.items, summary => summary.sessionId)
|
||||
for (const mutation of mutations) summaries = applyMutation(summaries, mutation)
|
||||
this.summaries = summaries
|
||||
this.listState = 'idle'
|
||||
this.listPhase = 'ready'
|
||||
// Push running bits down to instantiated Sessions (the list is the authoritative summary source).
|
||||
for (const s of this.summaries) this.sessions.get(s.sessionId)?.handleRunning(s.running)
|
||||
} else {
|
||||
@@ -108,6 +243,7 @@ export class SessionManager {
|
||||
/* v8 ignore next -- the `? null` arm is unreachable: transportError always returns ok:false. */
|
||||
this.listError = folded.ok ? null : folded.error
|
||||
} finally {
|
||||
this.listMutations = null
|
||||
this.listInflight = null
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
@@ -118,18 +254,37 @@ export class SessionManager {
|
||||
/**
|
||||
* Contract session.create; on success merge into summaries immediately (no
|
||||
* wait for the next refresh).
|
||||
* @param cwd - optional working directory for the new session.
|
||||
* @param opts - target workspace or working directory, plus an optional caller-owned id.
|
||||
* @returns the create result.
|
||||
*/
|
||||
async create(cwd?: string): Promise<RpcResult<{ sessionId: SessionId }>> {
|
||||
async create(
|
||||
opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId } = {},
|
||||
): Promise<RpcResult<{ sessionId: SessionId }>> {
|
||||
try {
|
||||
const { result } = await this.api.sessions.create(cwd === undefined ? {} : { cwd })
|
||||
if (result.ok && !this.summaries.some(s => s.sessionId === result.value.sessionId)) {
|
||||
this.summaries = [
|
||||
{ sessionId: result.value.sessionId, updatedAt: Date.now(), running: false, ...(cwd !== undefined ? { cwd } : {}) },
|
||||
...this.summaries,
|
||||
]
|
||||
this.notifier.markDirty()
|
||||
const payload = opts.workspaceId !== undefined
|
||||
? { workspaceId: opts.workspaceId, ...(opts.sessionId === undefined ? {} : { sessionId: opts.sessionId }) }
|
||||
: {
|
||||
...(opts.cwd === undefined ? {} : { cwd: opts.cwd }),
|
||||
...(opts.sessionId === undefined ? {} : { sessionId: opts.sessionId }),
|
||||
}
|
||||
const { result } = await this.api.sessions.create(payload)
|
||||
if (result.ok) {
|
||||
this.recordMutation({ kind: 'upsert', summary: {
|
||||
sessionId: result.value.sessionId, updatedAt: Date.now(), running: false,
|
||||
...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
|
||||
} })
|
||||
} else {
|
||||
const publishedSessionId = workspaceAttachSessionId(result.error)
|
||||
// Publication precedes attachment. The error's id is a real Session,
|
||||
// so expose it immediately as Ungrouped while the caller keeps the
|
||||
// prompt buffer and decides whether to retry attachment.
|
||||
if (publishedSessionId !== undefined) {
|
||||
this.recordMutation({ kind: 'upsert', summary: {
|
||||
sessionId: publishedSessionId,
|
||||
updatedAt: Date.now(),
|
||||
running: false,
|
||||
} })
|
||||
}
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
@@ -137,6 +292,23 @@ export class SessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert-or-enrich a locally synthesized summary: a new id prepends; an
|
||||
* existing entry only gains fields it lacks (the session-added frame and the
|
||||
* create() echo race — whichever lands second must fill the placeholder's
|
||||
* missing cwd/parentSessionId, never overwrite list-refresh data).
|
||||
*/
|
||||
private mergeSummary(summary: SessionSummary): void {
|
||||
this.recordMutation({ kind: 'upsert', summary })
|
||||
}
|
||||
|
||||
/** Apply immediately and retain for replay when a list response is in flight. */
|
||||
private recordMutation(mutation: SessionListMutation): void {
|
||||
this.listMutations?.push(mutation)
|
||||
this.summaries = applyMutation(this.summaries, mutation)
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
// ---- Subscription surface (for useSessionList) ----
|
||||
|
||||
/**
|
||||
@@ -216,31 +388,24 @@ export class SessionManager {
|
||||
const frame = envelope.payload
|
||||
switch (frame.type) {
|
||||
case 'host/session-added': {
|
||||
if (!this.summaries.some(s => s.sessionId === frame.sessionId)) {
|
||||
this.summaries = [
|
||||
{
|
||||
sessionId: frame.sessionId, updatedAt: Date.now(), running: false,
|
||||
...(frame.parentSessionId !== undefined ? { parentSessionId: frame.parentSessionId } : {}),
|
||||
},
|
||||
...this.summaries,
|
||||
]
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
this.mergeSummary({
|
||||
sessionId: frame.sessionId, updatedAt: Date.now(), running: false,
|
||||
...(frame.parentSessionId !== undefined ? { parentSessionId: frame.parentSessionId } : {}),
|
||||
...(frame.cwd !== undefined ? { cwd: frame.cwd } : {}),
|
||||
})
|
||||
this.sessions.get(frame.sessionId)?.handlePublished()
|
||||
return
|
||||
}
|
||||
case 'host/session-removed': {
|
||||
this.summaries = this.summaries.filter(s => s.sessionId !== frame.sessionId)
|
||||
this.recordMutation({ kind: 'remove', sessionId: frame.sessionId })
|
||||
this.sessions.get(frame.sessionId)?.handleRemoved() // instance survives (resident-instance rule), only flagged in the snapshot
|
||||
this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation
|
||||
this.titleSnapshots.delete(frame.sessionId)
|
||||
this.notifier.markDirty()
|
||||
return
|
||||
}
|
||||
case 'host/session-status': {
|
||||
this.summaries = this.summaries.map(s =>
|
||||
s.sessionId === frame.sessionId && s.running !== frame.running ? { ...s, running: frame.running } : s)
|
||||
this.recordMutation({ kind: 'status', sessionId: frame.sessionId, running: frame.running })
|
||||
this.sessions.get(frame.sessionId)?.handleRunning(frame.running)
|
||||
this.notifier.markDirty()
|
||||
return
|
||||
}
|
||||
case 'host/agent-error': {
|
||||
@@ -252,7 +417,7 @@ export class SessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** After each connection generation (first connect included): refresh the list + resync opened instances (reconnect = rebuild). */
|
||||
/** After each connection generation: refresh the session baseline and rebuild opened windows. */
|
||||
handleConnected(): void {
|
||||
void this.refreshList()
|
||||
for (const session of this.sessions.values()) void session.resync()
|
||||
@@ -281,6 +446,57 @@ export class SessionManager {
|
||||
}
|
||||
const sameOrder = items.length === this.itemsCache.length && items.every((e, i) => e === this.itemsCache[i])
|
||||
if (!sameOrder) this.itemsCache = items
|
||||
return { items: this.itemsCache, state: this.listState, error: this.listError }
|
||||
const intentSession = this.getIntent()
|
||||
const intentState = intentSession?.getSnapshot()
|
||||
const intent = intentSession !== undefined
|
||||
&& intentState !== undefined && intentState.intent !== null && intentState.pendingPrompt !== null
|
||||
? {
|
||||
sessionId: intentSession.sessionId,
|
||||
...intentState.intent,
|
||||
prompt: intentState.pendingPrompt.text,
|
||||
}
|
||||
: undefined
|
||||
const selected = this.selected
|
||||
const current = selected !== undefined && (
|
||||
intent?.sessionId === selected || items.some(item => item.sessionId === selected)
|
||||
) ? selected : undefined
|
||||
return {
|
||||
items: this.itemsCache,
|
||||
current,
|
||||
intent,
|
||||
state: this.listState,
|
||||
phase: this.listPhase,
|
||||
error: this.listError,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply one list mutation without deriving display order. */
|
||||
function applyMutation(summaries: readonly SessionSummary[], mutation: SessionListMutation): SessionSummary[] {
|
||||
switch (mutation.kind) {
|
||||
case 'upsert': {
|
||||
const existing = summaries.find(summary => summary.sessionId === mutation.summary.sessionId)
|
||||
if (existing === undefined) return [mutation.summary, ...summaries]
|
||||
const filled: SessionSummary = {
|
||||
...existing,
|
||||
...(existing.cwd === undefined && mutation.summary.cwd !== undefined ? { cwd: mutation.summary.cwd } : {}),
|
||||
...(existing.parentSessionId === undefined && mutation.summary.parentSessionId !== undefined
|
||||
? { parentSessionId: mutation.summary.parentSessionId } : {}),
|
||||
}
|
||||
if (filled.cwd === existing.cwd && filled.parentSessionId === existing.parentSessionId) return [...summaries]
|
||||
return summaries.map(summary => summary.sessionId === mutation.summary.sessionId ? filled : summary)
|
||||
}
|
||||
case 'remove':
|
||||
return summaries.filter(summary => summary.sessionId !== mutation.sessionId)
|
||||
case 'status':
|
||||
return summaries.map(summary => summary.sessionId === mutation.sessionId && summary.running !== mutation.running
|
||||
? { ...summary, running: mutation.running }
|
||||
: summary)
|
||||
}
|
||||
}
|
||||
|
||||
/** Temporary source-plane bridge while the Host contract and client project build independently. */
|
||||
function workspaceAttachSessionId(error: RpcError): SessionId | undefined {
|
||||
const candidate = error as unknown as { code: string; details: { sessionId?: SessionId } }
|
||||
return candidate.code === 'workspace-attach-failed' ? candidate.details.sessionId : undefined
|
||||
}
|
||||
|
||||
@@ -15,12 +15,16 @@
|
||||
* survives frozen (read-only view) until the stage moves on.
|
||||
*/
|
||||
import type { Context, Fiber } from 'cordis'
|
||||
import type { IApiClient, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { IApiClient, RpcError, SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionCell } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SnapshotStore } from '../contract/store.ts'
|
||||
import { createSnapshotStore } from '../contract/store.ts'
|
||||
import { SessionManager } from './manager.ts'
|
||||
import type {
|
||||
SessionIntentListSnapshot, SessionListPhase,
|
||||
} from './manager.ts'
|
||||
import type { Session } from './session.ts'
|
||||
import type { SessionIntentTarget } from './conversation.ts'
|
||||
|
||||
/** Session list row projected from the host list RPC plus live stream increments. */
|
||||
export interface SessionSummary {
|
||||
@@ -40,7 +44,36 @@ export interface SessionSummary {
|
||||
* the single useSessions standard hook reads list and selection together —
|
||||
* sidebar highlighting and SessionProvider share one fact source).
|
||||
*/
|
||||
export interface SessionListState { ids: SessionId[]; byId: Record<SessionId, SessionSummary>; current: SessionId | undefined }
|
||||
export interface SessionListState {
|
||||
ids: SessionId[]
|
||||
byId: Record<SessionId, SessionSummary>
|
||||
current: SessionId | undefined
|
||||
/** Frontend Session Intent projected from its owning Session object. */
|
||||
intent: SessionIntentListSnapshot | undefined
|
||||
/** Arrival lifecycle projected 1:1 from the manager snapshot (see SessionListPhase): empty-with-ready means "truly no sessions". */
|
||||
phase: SessionListPhase
|
||||
}
|
||||
|
||||
/** Structured session-create failure preserving partial publication identity. */
|
||||
export class SessionCreateError extends Error {
|
||||
override readonly name = 'SessionCreateError'
|
||||
/** Definitely published by Host before Workspace attachment failed. */
|
||||
readonly publishedSessionId: SessionId | undefined
|
||||
|
||||
/**
|
||||
* @param rpcError - Host business or folded transport error.
|
||||
* @param requestedSessionId - caller-preallocated id used for later stream/list reconciliation.
|
||||
*/
|
||||
constructor(
|
||||
readonly rpcError: RpcError,
|
||||
readonly requestedSessionId: SessionId | undefined,
|
||||
) {
|
||||
super(`session create failed: ${rpcError.code}: ${rpcError.message}`)
|
||||
this.publishedSessionId = rpcError.code === 'workspace-attach-failed'
|
||||
? rpcError.details.sessionId
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Session assembly handle for SessionProvider/inject factories (identity-stable per session). */
|
||||
export interface SessionBinding {
|
||||
@@ -64,6 +97,20 @@ export function scopeOf(ctx: Context): SessionId | undefined {
|
||||
/** Shared no-op plugin backing each session scope fiber. */
|
||||
function sessionScope(): void {}
|
||||
|
||||
/**
|
||||
* Workspace display title of a session cwd: the path's last non-empty
|
||||
* segment (both separators accepted; trailing separators ignored), or ''
|
||||
* for separator-only paths — callers own their fallback (session id, raw
|
||||
* cwd, default-directory copy). The repo-wide single basename derivation —
|
||||
* every surface naming a workspace (picker rows, toggle labels, list titles)
|
||||
* calls this instead of re-splitting paths.
|
||||
* @param cwd - workspace directory path.
|
||||
* @returns basename title, or '' when no non-empty segment exists.
|
||||
*/
|
||||
export function workspaceTitleOf(cwd: string): string {
|
||||
return cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop() ?? ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Display title projection: durable title, project directory basename, then
|
||||
* the raw id.
|
||||
@@ -71,8 +118,8 @@ function sessionScope(): void {}
|
||||
function displayTitleOf(title: string | undefined, cwd: string | undefined, id: SessionId): string {
|
||||
if (title !== undefined) return title
|
||||
if (cwd !== undefined && cwd !== '') {
|
||||
const base = cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop()
|
||||
if (base !== undefined && base !== '') return base
|
||||
const base = workspaceTitleOf(cwd)
|
||||
if (base !== '') return base
|
||||
}
|
||||
return id
|
||||
}
|
||||
@@ -89,8 +136,8 @@ interface ScopeRecord {
|
||||
export class SessionsService {
|
||||
/** List snapshot store (list RPC + host stream increments; re-pulled on reconnect) — the useSessions standard feed, current included. */
|
||||
readonly list: SnapshotStore<SessionListState>
|
||||
/** The object-layer instance cluster and frame dispatch entry (wired to the connection by the runtime apply). */
|
||||
readonly manager: SessionManager
|
||||
/** The object-layer instance cluster and frame dispatch entry. */
|
||||
private readonly manager: SessionManager
|
||||
|
||||
/**
|
||||
* Persisted selection cell (the durable half of `list.current`). Private on
|
||||
@@ -117,12 +164,14 @@ export class SessionsService {
|
||||
* @param ctx - client root context (scope fibers mount under it).
|
||||
* @param api - wire client shared with every Session.
|
||||
*/
|
||||
constructor(private readonly rootCtx: Context, private readonly api: IApiClient) {
|
||||
this.manager = new SessionManager(api)
|
||||
constructor(private readonly rootCtx: Context, api: IApiClient) {
|
||||
this.selection = createSnapshotStore<{ sessionId?: SessionId }>(
|
||||
{},
|
||||
{ persist: { name: 'dsh.sessions.current' } })
|
||||
this.list = createSnapshotStore<SessionListState>({ ids: [], byId: {}, current: undefined })
|
||||
this.manager = new SessionManager(api, this.selection.getSnapshot().sessionId)
|
||||
this.list = createSnapshotStore<SessionListState>({
|
||||
ids: [], byId: {}, current: undefined, intent: undefined, phase: 'pending',
|
||||
})
|
||||
// The manager owns wire truth; the store is its projection. Manager
|
||||
// notifications are already microtask-batched.
|
||||
this.manager.subscribe(() => { this.projectList() })
|
||||
@@ -142,56 +191,81 @@ export class SessionsService {
|
||||
* @param id - session id (must exist in the list store).
|
||||
*/
|
||||
open(id: SessionId): void {
|
||||
if (this.list.getSnapshot().byId[id] === undefined) {
|
||||
throw new Error(`sessions.open: unknown session ${id}`)
|
||||
}
|
||||
this.selection.update((draft) => { draft.sessionId = id })
|
||||
this.list.update((draft) => { draft.current = id })
|
||||
this.manager.select(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the current selection so the layout shows the no-session empty
|
||||
* state. Wipes the persisted selection too — a reload stays on empty until
|
||||
* the user opens or starts a session. Staging holds the previous occupant
|
||||
* across the blank (same masked-gap rule as a transient list miss).
|
||||
* state (new-session affordance and the workspace preselection flow).
|
||||
* Wipes the persisted selection too — a reload stays on empty until the
|
||||
* user opens or starts a session. The staged scope keeps its frozen view
|
||||
* per the masked-gap contract until the next open() moves the stage.
|
||||
*/
|
||||
clear(): void {
|
||||
this.selection.set({})
|
||||
this.list.update((draft) => { draft.current = undefined })
|
||||
this.manager.clearSelection()
|
||||
}
|
||||
|
||||
/**
|
||||
* Start or retarget the sole client-local Session intent.
|
||||
* @param target - resolved real or frontend-only Workspace target.
|
||||
* @param prompt - optional prompt retained across retargeting.
|
||||
*/
|
||||
startIntent(target: SessionIntentTarget, prompt = ''): Session {
|
||||
return this.manager.startIntent(target, prompt)
|
||||
}
|
||||
|
||||
/** @returns the active frontend Session object, if one exists. */
|
||||
intent(): Session | undefined {
|
||||
return this.manager.getIntent()
|
||||
}
|
||||
|
||||
/** @param text - exact controlled-input value for the current Session Intent. */
|
||||
updateIntent(text: string): void {
|
||||
this.manager.updateIntent(text)
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the real Session baseline, reusing an in-flight pull.
|
||||
* @returns completion of the current or newly started baseline pull.
|
||||
*/
|
||||
refresh(): Promise<void> {
|
||||
return this.manager.refreshList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a mux stream envelope into the Session object layer.
|
||||
* @param envelope - validated mux stream envelope.
|
||||
*/
|
||||
handleMuxEnvelope(envelope: Parameters<SessionManager['handleMuxEnvelope']>[0]): void {
|
||||
this.manager.handleMuxEnvelope(envelope)
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a Host stream envelope into the Session object layer.
|
||||
* @param envelope - validated Host stream envelope.
|
||||
*/
|
||||
handleHostEnvelope(envelope: Parameters<SessionManager['handleHostEnvelope']>[0]): void {
|
||||
this.manager.handleHostEnvelope(envelope)
|
||||
}
|
||||
|
||||
/** Rebuild the Session baseline and every opened window after connection. */
|
||||
handleConnected(): void {
|
||||
this.manager.handleConnected()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a session on the host.
|
||||
* @param opts - creation options (project directory).
|
||||
* @param opts - target workspace or directory and an optional preallocated id.
|
||||
* @returns the new session id.
|
||||
* @throws {SessionCreateError} with the requested id and, after an attach
|
||||
* failure, the definitely published id.
|
||||
*/
|
||||
async create(opts: { cwd?: string } = {}): Promise<SessionId> {
|
||||
const result = await this.manager.create(opts.cwd)
|
||||
if (!result.ok) throw new Error(`session create failed: ${result.error.code}: ${result.error.message}`)
|
||||
async create(opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId } = {}): Promise<SessionId> {
|
||||
const result = await this.manager.create(opts)
|
||||
if (!result.ok) throw new SessionCreateError(result.error, opts.sessionId)
|
||||
return result.value.sessionId
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a workspace folder under the host process cwd and a session in it.
|
||||
* Name is a single path segment (no separators); the host mkdir runs inside
|
||||
* session.create. Caller opens the returned id when it wants the session staged.
|
||||
* @param name - workspace folder basename.
|
||||
* @returns the new session id.
|
||||
*/
|
||||
async createWorkspace(name: string): Promise<SessionId> {
|
||||
const trimmed = name.trim()
|
||||
if (trimmed === '') throw new Error('sessions.createWorkspace: name is required')
|
||||
if (/[/\\]/.test(trimmed)) {
|
||||
throw new Error('sessions.createWorkspace: name must not contain path separators')
|
||||
}
|
||||
const { result } = await this.api.host.describe({})
|
||||
if (!result.ok) {
|
||||
throw new Error(`host.describe failed: ${result.error.code}: ${result.error.message}`)
|
||||
}
|
||||
const hostCwd = result.value.cwd.replace(/[/\\]+$/, '')
|
||||
return this.create({ cwd: `${hostCwd}/${trimmed}` })
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a session-scoped context view (use-and-discard).
|
||||
* @param id - session id.
|
||||
@@ -244,11 +318,12 @@ export class SessionsService {
|
||||
* failed one retries the next time current is touched).
|
||||
*/
|
||||
private followCurrent(): void {
|
||||
const current = this.list.getSnapshot().current
|
||||
const snapshot = this.list.getSnapshot()
|
||||
const current = snapshot.current
|
||||
// A masked gap (current blanked while the selection's session is
|
||||
// transiently absent) holds the stage: tearing down on the gap would
|
||||
// destroy exactly the frozen scope the mask exists to preserve.
|
||||
if (current === undefined || current === this.watched) return
|
||||
if (current === undefined || snapshot.byId[current] === undefined || current === this.watched) return
|
||||
this.watched = current
|
||||
this.sweepDeferred()
|
||||
const record = this.resolve(current)
|
||||
@@ -300,7 +375,7 @@ export class SessionsService {
|
||||
|
||||
/** Project the manager's list snapshot into the store (title derivation is display-only). */
|
||||
private projectList(): void {
|
||||
const items = this.manager.getListSnapshot().items
|
||||
const { items, current, intent, phase } = this.manager.getListSnapshot()
|
||||
const ids: SessionId[] = []
|
||||
const byId: Record<SessionId, SessionSummary> = {}
|
||||
for (const entry of items) {
|
||||
@@ -315,11 +390,13 @@ export class SessionsService {
|
||||
...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}),
|
||||
}
|
||||
}
|
||||
// current = the persisted selection, masked while its session is absent
|
||||
// (falls to the empty state; resurfaces if the session returns).
|
||||
const selected = this.selection.getSnapshot().sessionId
|
||||
const current = selected !== undefined && byId[selected] !== undefined ? selected : undefined
|
||||
this.list.set({ ids, byId, current })
|
||||
const persisted = this.selection.getSnapshot().sessionId
|
||||
if (intent?.sessionId === current) {
|
||||
if (persisted !== undefined) this.selection.set({})
|
||||
} else if (current !== undefined && byId[current] !== undefined && persisted !== current) {
|
||||
this.selection.set({ sessionId: current })
|
||||
}
|
||||
this.list.set({ ids, byId, current, intent, phase })
|
||||
this.pruneScopes(byId)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,14 +4,15 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult,
|
||||
SessionId, ToolEventView,
|
||||
SessionId, ToolEventView, WorkspaceId,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
// Value import from the inline-safe wire layer (not the connection plugin):
|
||||
// plugin-to-plugin value imports are a bundle purity error.
|
||||
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { ObservableSnapshot } from '../contract/store.ts'
|
||||
import type {
|
||||
ConversationNode, ConversationSnapshot, OpenState, PromptError, RunningToolCall,
|
||||
ComposerPhase, ConversationNode, ConversationSnapshot, OpenState, PendingPrompt,
|
||||
PromptError, RunningToolCall, SessionIntentSnapshot, SessionIntentTarget,
|
||||
} from './conversation.ts'
|
||||
import type { PendingInteraction } from './pending.ts'
|
||||
import { PendingWait } from './pending.ts'
|
||||
@@ -22,6 +23,12 @@ import { PartialAccumulator } from './partial.ts'
|
||||
/** Messages requested per history page. */
|
||||
export const PAGE_MESSAGES = 50
|
||||
|
||||
/** Optional frontend Intent and publication observer for a Session object. */
|
||||
export interface SessionOptions {
|
||||
intent?: { target: SessionIntentTarget; prompt: string }
|
||||
onPublished?(session: Session): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns a session's event window, derived conversation state, and observable
|
||||
* snapshot. React bindings remain outside this data layer.
|
||||
@@ -60,8 +67,18 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
private frozenRev = 0
|
||||
private nodesCache: { folded: readonly ConversationNode[]; frozenRev: number; value: readonly ConversationNode[] } | null = null
|
||||
private running = false
|
||||
/**
|
||||
* Sticky send marker, private input of the composerPhase derivation: set
|
||||
* synchronously before prompt()'s first await, never reset — the blank →
|
||||
* engaging edge of the phase machine (see ComposerPhase).
|
||||
*/
|
||||
private promptAttempted = false
|
||||
private removed = false
|
||||
private promptError: PromptError | null = null
|
||||
private intent: SessionIntentSnapshot | null
|
||||
private pendingPrompt: PendingPrompt | null
|
||||
private intentGeneration = 0
|
||||
private published: boolean
|
||||
private lastAgentError: string | null = null
|
||||
/** Live events buffered during open/resync and stitched by sequence once history lands. */
|
||||
private liveBuffer: { event: SessionEvent; view: ToolEventView | undefined }[] = []
|
||||
@@ -75,7 +92,23 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
this.snapshotCache = this.buildSnapshot()
|
||||
})
|
||||
|
||||
constructor(readonly sessionId: SessionId, private readonly api: IApiClient) {
|
||||
/**
|
||||
* @param sessionId - stable identity shared by the frontend Intent and Host entity.
|
||||
* @param api - shared wire client.
|
||||
* @param options - optional frontend-only initial state and publication observer.
|
||||
*/
|
||||
constructor(
|
||||
readonly sessionId: SessionId,
|
||||
private readonly api: IApiClient,
|
||||
private readonly options: SessionOptions = {},
|
||||
) {
|
||||
this.intent = options.intent === undefined
|
||||
? null
|
||||
: { target: options.intent.target, phase: 'ready' }
|
||||
this.pendingPrompt = options.intent === undefined
|
||||
? null
|
||||
: { text: options.intent.prompt, phase: 'editing', retry: 'send' }
|
||||
this.published = options.intent === undefined
|
||||
this.snapshotCache = this.buildSnapshot()
|
||||
}
|
||||
|
||||
@@ -90,6 +123,10 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
async prompt(content: ContentBlock[], mode: 'queue' | 'steer'): Promise<RpcResult<{ accepted: true }>> {
|
||||
this.promptError = null
|
||||
this.lastAgentError = null
|
||||
// Synchronous, before the first await: the blank → engaging edge must be
|
||||
// visible on the session area's very first frame when a caller sends
|
||||
// ahead of navigation (first-send flow).
|
||||
this.promptAttempted = true
|
||||
this.notifier.markDirty()
|
||||
let result: RpcResult<{ accepted: true }>
|
||||
try {
|
||||
@@ -104,6 +141,57 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
return result
|
||||
}
|
||||
|
||||
/** @param text - exact controlled value of this Session's retained prompt. */
|
||||
updatePendingPrompt(text: string): void {
|
||||
const pending = this.pendingPrompt
|
||||
if (pending === null || pending.phase === 'sending') return
|
||||
this.pendingPrompt = { ...pending, text }
|
||||
this.notifier.notifyNow()
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect this frontend Session to a real Workspace and flush its retained prompt.
|
||||
* @param workspaceId - real Workspace target.
|
||||
*/
|
||||
connect(workspaceId: WorkspaceId): void {
|
||||
const intent = this.intent
|
||||
const pending = this.pendingPrompt
|
||||
if (intent === null || intent.phase === 'connecting' || pending === null || pending.text.trim() === '') return
|
||||
const connecting: SessionIntentSnapshot = {
|
||||
target: { kind: 'workspace', workspaceId },
|
||||
phase: 'connecting',
|
||||
}
|
||||
const queued: PendingPrompt = {
|
||||
...pending,
|
||||
phase: 'sending',
|
||||
retry: 'connect',
|
||||
workspaceId,
|
||||
}
|
||||
delete queued.error
|
||||
this.intent = connecting
|
||||
this.pendingPrompt = queued
|
||||
this.notifier.notifyNow()
|
||||
void this.flushPendingPrompt()
|
||||
}
|
||||
|
||||
/** Stop a superseded frontend Intent from automatically sending after publication. */
|
||||
abandonIntent(): void {
|
||||
if (this.intent === null) return
|
||||
this.intentGeneration += 1
|
||||
}
|
||||
|
||||
/** Retry this Session's retained prompt from its failed prerequisite. */
|
||||
retryPendingPrompt(): void {
|
||||
const pending = this.pendingPrompt
|
||||
if (pending === null || pending.phase === 'sending' || pending.text.trim() === '') return
|
||||
const sending: PendingPrompt = { ...pending, phase: 'sending' }
|
||||
delete sending.error
|
||||
this.pendingPrompt = sending
|
||||
this.promptError = null
|
||||
this.notifier.markDirty()
|
||||
void this.flushPendingPrompt()
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop: contract session.cancel 1:1; failures land in promptError (same error-strip display slot).
|
||||
* @returns the cancel result.
|
||||
@@ -271,6 +359,11 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
/** Mark that Host publication is known without resolving an uncertain local create response. */
|
||||
handlePublished(): void {
|
||||
this.markPublished()
|
||||
}
|
||||
|
||||
/** host/session-removed relay: flag the snapshot (instance survives — resident-instance rule). */
|
||||
handleRemoved(): void {
|
||||
this.removed = true
|
||||
@@ -304,6 +397,112 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
this.pendingRev++
|
||||
}
|
||||
|
||||
/** Advance the retained prompt through Session attachment and submission. */
|
||||
private async flushPendingPrompt(): Promise<void> {
|
||||
const pending = this.pendingPrompt
|
||||
if (pending?.phase === 'sending') {
|
||||
const ready = pending.retry === 'connect'
|
||||
? await this.attachPendingPrompt(pending)
|
||||
: pending
|
||||
if (ready !== null) await this.sendPendingPrompt(ready)
|
||||
}
|
||||
}
|
||||
|
||||
/** Complete the Host Session prerequisite and return the prompt's send step. */
|
||||
private async attachPendingPrompt(pending: PendingPrompt): Promise<PendingPrompt | null> {
|
||||
const workspaceId = pending.workspaceId
|
||||
if (workspaceId === undefined) throw new Error('a Session attachment requires a Workspace id')
|
||||
const originIntent = this.intent
|
||||
const originGeneration = this.intentGeneration
|
||||
let result: RpcResult<{ sessionId: SessionId }>
|
||||
try {
|
||||
result = (await this.api.sessions.create({ sessionId: this.sessionId, workspaceId })).result
|
||||
} catch (error) {
|
||||
result = transportError(error)
|
||||
}
|
||||
let ready: PendingPrompt | null = null
|
||||
if (result.ok) {
|
||||
ready = this.completePendingAttachment(pending, originIntent, originGeneration)
|
||||
} else {
|
||||
this.failPendingAttachment(pending, originIntent, originGeneration, result.error)
|
||||
}
|
||||
this.notifier.markDirty()
|
||||
return ready
|
||||
}
|
||||
|
||||
/** Move a published Session to the send step unless its page intent was superseded. */
|
||||
private completePendingAttachment(
|
||||
pending: PendingPrompt,
|
||||
originIntent: SessionIntentSnapshot | null,
|
||||
originGeneration: number,
|
||||
): PendingPrompt | null {
|
||||
this.markPublished()
|
||||
this.intent = null
|
||||
this.promptAttempted = true
|
||||
const superseded = originIntent !== null && originGeneration !== this.intentGeneration
|
||||
const next: PendingPrompt = {
|
||||
...pending,
|
||||
phase: superseded ? 'failed' : 'sending',
|
||||
retry: 'send',
|
||||
...(superseded ? { error: 'Message was not sent because you navigated away.' } : {}),
|
||||
}
|
||||
if (!superseded) delete next.error
|
||||
this.pendingPrompt = next
|
||||
return superseded ? null : next
|
||||
}
|
||||
|
||||
/** Retain the prompt at the failed attachment step that owns the retry. */
|
||||
private failPendingAttachment(
|
||||
pending: PendingPrompt,
|
||||
originIntent: SessionIntentSnapshot | null,
|
||||
originGeneration: number,
|
||||
error: RpcError,
|
||||
): void {
|
||||
const partiallyPublished = error.code === 'workspace-attach-failed'
|
||||
if (partiallyPublished) {
|
||||
this.markPublished()
|
||||
this.intent = null
|
||||
this.promptAttempted = true
|
||||
}
|
||||
const activeIntent = !partiallyPublished
|
||||
&& originIntent !== null
|
||||
&& originGeneration === this.intentGeneration
|
||||
&& this.intent === originIntent
|
||||
if (activeIntent) {
|
||||
this.intent = {
|
||||
target: originIntent.target,
|
||||
phase: 'ready',
|
||||
error: { step: 'session', message: rpcErrorMessage(error) },
|
||||
}
|
||||
this.pendingPrompt = { ...pending, phase: 'editing' }
|
||||
}
|
||||
if (!activeIntent && (partiallyPublished || originIntent === null) && this.pendingPrompt === pending) {
|
||||
this.pendingPrompt = { ...pending, phase: 'failed', error: rpcErrorMessage(error) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Submit the retained prompt and keep it only when Host rejects the send. */
|
||||
private async sendPendingPrompt(pending: PendingPrompt): Promise<void> {
|
||||
const result = await this.prompt([{ type: 'text', text: pending.text.trim() }], 'queue')
|
||||
if (this.pendingPrompt === pending) {
|
||||
this.pendingPrompt = result.ok
|
||||
? null
|
||||
: {
|
||||
...pending,
|
||||
retry: 'send',
|
||||
phase: 'failed',
|
||||
error: rpcErrorMessage(result.error),
|
||||
}
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
}
|
||||
|
||||
private markPublished(): void {
|
||||
if (this.published) return
|
||||
this.published = true
|
||||
this.options.onPublished?.(this)
|
||||
}
|
||||
|
||||
/** @param generation - openGeneration at launch; every await re-checks it and a stale pass
|
||||
* drops all writes (resync superseded this open — its outcome belongs to a dead connection). */
|
||||
private async doOpen(generation: number): Promise<void> {
|
||||
@@ -520,21 +719,47 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
if (this.pendingCache === null || this.pendingCache.rev !== this.pendingRev) {
|
||||
this.pendingCache = { rev: this.pendingRev, value: [...this.pending.values()] }
|
||||
}
|
||||
const partial = this.partial?.toPartial() ?? null
|
||||
return {
|
||||
sessionId: this.sessionId,
|
||||
nodes,
|
||||
foldDegraded: degraded,
|
||||
partial: this.partial?.toPartial() ?? null,
|
||||
partial,
|
||||
runningCalls: this.callsCache.value,
|
||||
pending: this.pendingCache.value,
|
||||
running: this.running,
|
||||
composerPhase: derivePhase(
|
||||
nodes.length > 0 || partial !== null || this.running || this.pendingCache.value.length > 0,
|
||||
this.promptAttempted,
|
||||
),
|
||||
removed: this.removed,
|
||||
openState: this.openState,
|
||||
openError: this.openError,
|
||||
hasMore: this.hasMore,
|
||||
loadingOlder: this.loadingOlder,
|
||||
promptError: this.promptError,
|
||||
intent: this.intent,
|
||||
pendingPrompt: this.pendingPrompt,
|
||||
lastAgentError: this.lastAgentError,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function rpcErrorMessage(error: RpcError): string {
|
||||
return `${error.code}: ${error.message}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The composerPhase judgment — the single site that knows the predicate
|
||||
* (consumers switch on the result, never re-derive). Monotone per session
|
||||
* object: `hasContent` only grows within a window and `promptAttempted` is
|
||||
* sticky, so blank → engaging → active never steps back; a failed first
|
||||
* prompt stays engaging (retry semantics — see ComposerPhase).
|
||||
* @param hasContent - any conversation material exists (nodes, partial, running turn, pending waits).
|
||||
* @param promptAttempted - a prompt was initiated on this session object.
|
||||
* @returns the derived phase.
|
||||
*/
|
||||
function derivePhase(hasContent: boolean, promptAttempted: boolean): ComposerPhase {
|
||||
if (hasContent) return 'active'
|
||||
return promptAttempted ? 'engaging' : 'blank'
|
||||
}
|
||||
|
||||
@@ -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,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-drafts.spec.ts
Normal file
191
packages/client/runtime/tests/session-drafts.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,8 +187,8 @@ describe('cell (render-layer session kit)', () => {
|
||||
const cell = b.svc.cell('s1')
|
||||
expect(cell).toBeDefined()
|
||||
expect(cell?.sessionId).toBe('s1')
|
||||
// Hook binding happens in React; the cell carries the observable itself.
|
||||
expect(cell?.session).toBe(b.svc.manager.get(sid('s1')))
|
||||
// The cell carries the observable; hook binding happens in React.
|
||||
expect(cell?.session).toBe(b.svc.binding(sid('s1'))?.session)
|
||||
expect(b.svc.cell('s1')).toBe(cell)
|
||||
expect(b.svc.cell('ghost')).toBeUndefined()
|
||||
})
|
||||
@@ -284,36 +284,45 @@ describe('ancestry', () => {
|
||||
})
|
||||
|
||||
describe('create', () => {
|
||||
it('returns the new id on ok and throws a coded error on failure', async () => {
|
||||
it('passes a preallocated id and preserves it on ordinary failure', async () => {
|
||||
const b = bench()
|
||||
b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('fresh') }))
|
||||
await expect(b.svc.create({ cwd: '/w' })).resolves.toBe('fresh')
|
||||
await expect(b.svc.create({ cwd: '/w', sessionId: sid('fresh') })).resolves.toBe('fresh')
|
||||
expect(b.api.callsOf('session.create')).toEqual([{ cwd: '/w', sessionId: 'fresh' }])
|
||||
b.api.onCreate = () => Promise.resolve({
|
||||
rpcId: 'e' as never,
|
||||
result: { ok: false as const, error: { code: 'internal' as const, message: '爆了', details: {} } },
|
||||
} as never)
|
||||
await expect(b.svc.create()).rejects.toThrow(/internal: 爆了/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createWorkspace', () => {
|
||||
it('joins host.describe cwd with the name and creates there', async () => {
|
||||
const b = bench()
|
||||
b.api.onDescribe = () => Promise.resolve(ok({ version: '0', cwd: '/host/root', attachedSessions: 0 }))
|
||||
b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('ws') }))
|
||||
await expect(b.svc.createWorkspace('My Proj')).resolves.toBe('ws')
|
||||
expect(b.api.callsOf('session.create')).toEqual([{ cwd: '/host/root/My Proj' }])
|
||||
const failure = await b.svc.create({ sessionId: sid('candidate') }).catch((error: unknown) => error)
|
||||
expect(failure).toBeInstanceOf(SessionCreateError)
|
||||
expect(failure).toMatchObject({
|
||||
requestedSessionId: 'candidate', publishedSessionId: undefined,
|
||||
rpcError: { code: 'internal', message: '爆了' },
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects empty names and path separators; surfaces describe failures', async () => {
|
||||
it('surfaces the definitely published id after Workspace attachment fails', async () => {
|
||||
const b = bench()
|
||||
await expect(b.svc.createWorkspace(' ')).rejects.toThrow(/name is required/)
|
||||
await expect(b.svc.createWorkspace('a/b')).rejects.toThrow(/path separators/)
|
||||
b.api.onDescribe = () => Promise.resolve({
|
||||
rpcId: 'e' as never,
|
||||
result: { ok: false as const, error: { code: 'internal' as const, message: 'down', details: {} } },
|
||||
b.api.onCreate = () => Promise.resolve({
|
||||
rpcId: 'attach' as never,
|
||||
result: {
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'workspace-attach-failed', message: 'ledger unavailable',
|
||||
details: { sessionId: sid('published'), workspaceId: 'ws' },
|
||||
},
|
||||
},
|
||||
} as never)
|
||||
await expect(b.svc.createWorkspace('ok')).rejects.toThrow(/host.describe failed/)
|
||||
const failure = await b.svc.create({
|
||||
workspaceId: 'ws' as never,
|
||||
sessionId: sid('published'),
|
||||
}).catch((error: unknown) => error)
|
||||
await Promise.resolve()
|
||||
expect(failure).toMatchObject({
|
||||
publishedSessionId: 'published', requestedSessionId: 'published',
|
||||
rpcError: { code: 'workspace-attach-failed' },
|
||||
})
|
||||
expect(b.svc.list.getSnapshot().byId[sid('published')]).toMatchObject({ id: 'published' })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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 and publishes the two intents. The Session object keeps its identity across publication and retains any prompt that still needs connection or delivery; ConversationRoot reads that `pendingPrompt` from `useSession` and edits or retries it through the scoped ConversationService.
|
||||
|
||||
The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: <active id>`), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves.
|
||||
|
||||
Generic tool rows classify the built-in bash, read, search, write, and edit names into dedicated visual variants. The filesystem variants render the edit icon and `Write · <path>` or `Edit · <path>` summary while retaining the shared row-to-details interaction.
|
||||
|
||||
Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders).
|
||||
|
||||
Per-session UI state (selection, composer draft, active view) lives in the declared chat store (`stores.ts` `createChatStore`): apply constructs one handle and passes it to the conversation, chat-view, and details registrations, so the session slots share one instance per session (selection written by the chat view, read by details) and the framework owns instance lifecycle and draft persistence. Components are pure — the framework standard kit (`useSession`/`sessionId`/`useSessions`) and the store faces (`useStore`/`actions`) arrive automatically from the registration declaration; the inject factories contribute plain data and callbacks only (send/stop choreography, tab read face, details/paging callbacks, startSession chain).
|
||||
Per-session UI state (selection, ordinary composer draft, active view) lives in the declared chat store (`stores.ts` `createChatStore`): apply constructs one handle and passes it to the conversation, chat-view, and details registrations, so the session slots share one instance per session (selection written by the chat view, read by details) and the framework owns instance lifecycle and draft persistence. The frontend Session Intent comes from the Session list projection; after publication, any retained prompt comes from that Session's conversation snapshot. Components are pure — the framework standard kit (`useSession`/`sessionId` when session-scoped, plus global `useSessions`/`useWorkspaces`) and the store faces (`useStore`/`actions`) arrive automatically from the registration declaration; inject factories contribute plain data and callbacks for runtime Session actions, send/stop, tabs, details, and paging.
|
||||
|
||||
`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath).
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import { DetailsPanel } from './skeleton/DetailsPanel.tsx'
|
||||
import { EmptyState } from './skeleton/EmptyState.tsx'
|
||||
|
||||
/** Services required by the conversation plugin. */
|
||||
export const inject = ['slots', 'layout', 'sessions']
|
||||
export const inject = ['slots', 'layout', 'sessions', 'workspaces']
|
||||
|
||||
/** Resolve the session-scoped conversation service (scope-addressed send/cancel), failing loud. */
|
||||
function scopedConversation(sessions: SessionsService, id: SessionId): ConversationService {
|
||||
@@ -32,6 +32,7 @@ function scopedConversation(sessions: SessionsService, id: SessionId): Conversat
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
const sessions = ctx.sessions
|
||||
const workspaces = ctx.workspaces
|
||||
const layout = ctx.layout
|
||||
const slots = ctx.slots
|
||||
|
||||
@@ -86,7 +87,9 @@ export function apply(ctx: Context): void {
|
||||
// Stop failure surfaces via snapshot.promptError; nothing to restore.
|
||||
})
|
||||
},
|
||||
open: (target: SessionId) => { sessions.open(target) },
|
||||
open: (sessionId) => { sessions.open(sessionId) },
|
||||
updateSessionPrompt: (text) => { scoped.updatePendingPrompt(text) },
|
||||
retrySessionPrompt: () => { scoped.retryPendingPrompt() },
|
||||
}
|
||||
},
|
||||
}, ConversationRoot)
|
||||
@@ -103,13 +106,16 @@ export function apply(ctx: Context): void {
|
||||
label: 'Chat',
|
||||
children: { 'conversation.chat.toolview': { kind: 'keyed', scope: 'session' } },
|
||||
store: chatStore,
|
||||
inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ChatViewInjected => ({
|
||||
openDetails: (target) => {
|
||||
actions.select(target)
|
||||
layout.openDetails()
|
||||
},
|
||||
loadOlder: () => { void sessions.manager.get(sessionId).loadOlder() },
|
||||
}),
|
||||
inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ChatViewInjected => {
|
||||
const scoped = scopedConversation(sessions, sessionId)
|
||||
return {
|
||||
openDetails: (target) => {
|
||||
actions.select(target)
|
||||
layout.openDetails()
|
||||
},
|
||||
loadOlder: () => { void scoped.loadOlder() },
|
||||
}
|
||||
},
|
||||
}, ChatView)
|
||||
|
||||
// Class-plugin mount (packages/AGENTS.md service form): the service
|
||||
@@ -133,20 +139,11 @@ export function apply(ctx: Context): void {
|
||||
|
||||
slots.register({
|
||||
name: 'conversation.empty',
|
||||
children: { 'conversation.empty.workspace': { kind: 'single', scope: 'root' } },
|
||||
inject: (): EmptyStateInjected => ({
|
||||
// ctx.get, not ctx.conversation: the service mounts on this plugin's
|
||||
// own child fiber, so it is not in the inject topology the property
|
||||
// proxy enforces; get reads the global store and stays loud on a torn
|
||||
// boot through the optional-chain throw below.
|
||||
startSession: (opts) => {
|
||||
const conversation = ctx.get('conversation')
|
||||
if (conversation === undefined) throw new Error('ui-conversation: conversation service unavailable')
|
||||
return conversation.startSession(opts)
|
||||
},
|
||||
createWorkspaceSession: async (name) => {
|
||||
const id = await sessions.createWorkspace(name)
|
||||
sessions.open(id)
|
||||
},
|
||||
startSession: (workspaceId, prompt) => { workspaces.startSession(workspaceId, prompt) },
|
||||
updateSessionPrompt: (text) => { sessions.updateIntent(text) },
|
||||
sendSession: () => { workspaces.sendSession() },
|
||||
}),
|
||||
}, EmptyState)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** Conversation slot declarations and their composed component props. */
|
||||
import type { RefObject } from 'react'
|
||||
import type { PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { PendingInteraction, SessionId, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { PendingInteraction, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { createChatStore } from '../stores.ts'
|
||||
import type { CallId, SelectionTarget, ViewTab } from './views.ts'
|
||||
|
||||
@@ -30,6 +31,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
* zero owner changes.
|
||||
*/
|
||||
'conversation.composer': { kind: 'chain'; scope: 'session'; owner: ComposerChainProps }
|
||||
/** Shared Workspace picker hole used by the page-local Session Intent hero. */
|
||||
'conversation.empty.workspace': { kind: 'single'; scope: 'root'; owner: EmptyWorkspaceOwnerProps }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +97,12 @@ export interface ConversationInjected {
|
||||
send(text: string, mode: 'queue' | 'steer'): void
|
||||
/** Cancel the in-flight turn (failure surfaces via snapshot.promptError). */
|
||||
stop(): void
|
||||
open(id: SessionId): void
|
||||
/** Select a real Session through the runtime navigation owner. */
|
||||
open(sessionId: SessionId): void
|
||||
/** Update the scoped Session's retained prompt. */
|
||||
updateSessionPrompt(text: string): void
|
||||
/** Retry the scoped Session's retained prompt. */
|
||||
retrySessionPrompt(): void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -140,16 +148,24 @@ export interface DetailsInjected {
|
||||
/** Full details-slot component props: selection arrives through the shared store, call material through useSession. */
|
||||
export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore<ChatStore> & DetailsInjected
|
||||
|
||||
/** Injected share of the no-session empty-state slot. */
|
||||
export interface EmptyStateInjected {
|
||||
/** The create → navigate → first-send chain, in one service call. */
|
||||
startSession(opts: { cwd?: string; text: string; mode: 'queue' | 'steer' }): Promise<void>
|
||||
/**
|
||||
* Create a workspace folder under the host cwd, mint a session there, and
|
||||
* open it (Create-new modal success path).
|
||||
*/
|
||||
createWorkspaceSession(name: string): Promise<void>
|
||||
/** Owner share common to the empty hero's Workspace picker. */
|
||||
export interface EmptyWorkspaceOwnerProps {
|
||||
open: boolean
|
||||
anchorRef?: RefObject<HTMLElement>
|
||||
onPick(workspaceId: WorkspaceId): void
|
||||
onClose(): void
|
||||
}
|
||||
|
||||
/** Full empty-state component props (root slot: no store; cwd options derive from useSessions in-component). */
|
||||
export type EmptyStateSlotProps = PropsRuntime<'conversation.empty'> & EmptyStateInjected
|
||||
/** Runtime-owned actions injected into the empty-state occupant. */
|
||||
export interface EmptyStateInjected {
|
||||
/** Replace the current Session intent, optionally preserving a prompt while retargeting. */
|
||||
startSession(workspaceId?: WorkspaceId, prompt?: string): void
|
||||
/** Update the current Session intent's controlled prompt. */
|
||||
updateSessionPrompt(text: string): void
|
||||
/** Materialize and send the current Session intent. */
|
||||
sendSession(): void
|
||||
}
|
||||
|
||||
/** Full empty-state component props: runtime projections, picker child slot, and injected actions. */
|
||||
export type EmptyStateSlotProps =
|
||||
PropsRuntime<'conversation.empty'> & PropsRenderSlots<'conversation.empty.workspace'> & EmptyStateInjected
|
||||
|
||||
@@ -15,7 +15,7 @@ export type { ToolCallBlock } from './contract/tool-call-model.ts'
|
||||
export type {
|
||||
ChatStore, ChatViewInjected, ChatViewSlotProps, ComposerChainProps, ConversationInjected,
|
||||
ConversationSlotProps, ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps,
|
||||
EmptyStateInjected, EmptyStateSlotProps, ToolRowOwnerProps, ToolRowProps,
|
||||
EmptyStateInjected, EmptyStateSlotProps, EmptyWorkspaceOwnerProps, ToolRowOwnerProps, ToolRowProps,
|
||||
} from './contract/slots.ts'
|
||||
// Export discipline: packages/client/AGENTS.md.
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Scope-addressed conversation send, cancel, and empty-state session startup.
|
||||
* Scope-addressed conversation send, cancel, history, and retained-prompt orchestration.
|
||||
*
|
||||
* Scope addressing rides the cordis Service tracker: property access through
|
||||
* `ctx.conversation` rebinds `this.ctx` to the caller's context, so methods
|
||||
@@ -44,37 +44,27 @@ export class ConversationService extends Service {
|
||||
if (!result.ok) throw new Error(`conversation.cancel failed: ${result.error.code}: ${result.error.message}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty-state first-send chain (root-context method; does not read scope):
|
||||
* create the session, navigate to it, then send through the new scope.
|
||||
* The create → open ordering is safe: the manager merges the new summary
|
||||
* synchronously before create() resolves, so the list store is projected by
|
||||
* the time open() validates against it (manager notification batching is
|
||||
* microtask-based; SessionsService projects on the same flush that create
|
||||
* awaited through the RPC round trip).
|
||||
* @param opts - project directory, prompt text, and send mode.
|
||||
*/
|
||||
async startSession(opts: { cwd?: string; text: string; mode: 'queue' | 'steer' }): Promise<void> {
|
||||
const sessions = this.requireSessions()
|
||||
const id = await sessions.create(opts.cwd === undefined ? {} : { cwd: opts.cwd })
|
||||
// The manager notifier flushes per microtask; one await guarantees the
|
||||
// list-store projection landed before sessions.open validates against it.
|
||||
await Promise.resolve()
|
||||
sessions.open(id)
|
||||
const scoped = sessions.scope(id)
|
||||
if (scoped === undefined) throw new Error(`conversation.startSession: created session "${id}" resolved no scope`)
|
||||
// ctx.get, not scoped.conversation: property access walks the fiber
|
||||
// topology (a scope fiber never injects services), while get reads the
|
||||
// global store and still binds this service to the scoped ctx.
|
||||
const scopedConversation = scoped.get('conversation')
|
||||
if (scopedConversation === undefined) throw new Error('conversation.startSession: conversation service unavailable through the new scope')
|
||||
await scopedConversation.send(opts.text, opts.mode)
|
||||
/** Pull one older history page for the scoped Session. */
|
||||
async loadOlder(): Promise<void> {
|
||||
await this.scopedSession('loadOlder').loadOlder()
|
||||
}
|
||||
|
||||
/** Update the scoped Session's retained pending prompt. */
|
||||
updatePendingPrompt(text: string): void {
|
||||
this.scopedSession('updatePendingPrompt').updatePendingPrompt(text)
|
||||
}
|
||||
|
||||
/** Retry the scoped Session's retained pending prompt. */
|
||||
retryPendingPrompt(): void {
|
||||
this.scopedSession('retryPendingPrompt').retryPendingPrompt()
|
||||
}
|
||||
|
||||
/** Resolve the caller scope's Session or throw on root contexts. */
|
||||
private scopedSession(op: string): Session {
|
||||
const id = this.scopeId(op)
|
||||
return this.requireSessions().manager.get(id)
|
||||
const binding = this.requireSessions().binding(id)
|
||||
if (binding === undefined) throw new Error(`conversation.${op}: session "${id}" resolved no binding`)
|
||||
return binding.session
|
||||
}
|
||||
|
||||
/** Read the caller's session scope tag via the sessions service; root contexts fail loud. */
|
||||
|
||||
@@ -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,153 @@
|
||||
// EmptyHero: the shared NEW SESSION hero (fish headline + glow + workspace
|
||||
// row + hero InputBar), extracted from EmptyState so the bound guidance
|
||||
// state (a current session with zero messages, ConversationRoot) renders the
|
||||
// same layout without the picker wiring. Hosts own the workspace-row content
|
||||
// and the send wiring; modals ride `children` after the stack.
|
||||
|
||||
import { useId } from 'react'
|
||||
import type { ReactNode, RefObject } from 'react'
|
||||
import {
|
||||
FishLogo, IconChevronDownOutline14, IconFolderOpen16,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { workspaceTitleOf } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { InputBar } from './InputBar.tsx'
|
||||
import type { InputBarError } from './InputBar.tsx'
|
||||
import css from './EmptyState.module.css'
|
||||
|
||||
/**
|
||||
* Basename label for the workspace chip / menu rows (the shared derivation);
|
||||
* empty → the design's "New Workspace" placeholder copy; separator-only
|
||||
* paths echo the raw cwd.
|
||||
* @param cwd - workspace directory path ('' for none).
|
||||
* @returns chip label.
|
||||
*/
|
||||
export function workspaceLabel(cwd: string): string {
|
||||
if (cwd === '') return 'New Workspace'
|
||||
const base = workspaceTitleOf(cwd)
|
||||
return base !== '' ? base : cwd
|
||||
}
|
||||
|
||||
/**
|
||||
* The workspace chip (folder + label + chevron). Locked form (bound guidance
|
||||
* state): no chevron, no menu affordance, clicks disabled — the bound
|
||||
* session's cwd is final.
|
||||
* @param props.label - chip label (see {@link workspaceLabel}).
|
||||
* @param props.locked - read-only echo form.
|
||||
* @param props.menuOpen - menu expansion echo (interactive form only).
|
||||
* @param props.onClick - menu toggle (interactive form only).
|
||||
* @returns the chip button element.
|
||||
*/
|
||||
export function WorkspaceChip({ buttonRef, label, locked = false, menuOpen = false, onClick }: {
|
||||
buttonRef?: RefObject<HTMLButtonElement>
|
||||
label: string
|
||||
locked?: boolean
|
||||
menuOpen?: boolean
|
||||
onClick?: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
className={css.workspace}
|
||||
aria-label={locked ? 'Current workspace' : 'Choose workspace'}
|
||||
{...(locked ? {} : { 'aria-haspopup': 'menu' as const, 'aria-expanded': menuOpen })}
|
||||
disabled={locked}
|
||||
onClick={onClick}
|
||||
>
|
||||
<IconFolderOpen16 className={css.folder} size={16} />
|
||||
<span className={css.workspaceLabel}>{label}</span>
|
||||
{!locked && <IconChevronDownOutline14 className={css.chevron} size={12} />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/** Hero-card props: both hosts supply the workspace row and their send wiring. */
|
||||
export interface EmptyHeroProps {
|
||||
/** Workspace-row content (Menu-wrapped chip in EmptyState; bare locked chip in guidance). */
|
||||
workspaceRow: ReactNode
|
||||
draft: string
|
||||
disabled: boolean
|
||||
/** Composer placeholder override (EmptyState's pick-a-workspace hint); defaults to the hero copy. */
|
||||
placeholder?: string
|
||||
error: InputBarError | null
|
||||
status?: string
|
||||
onDraftChange: (text: string) => void
|
||||
onSend: (mode: 'queue' | 'steer') => void
|
||||
onAdd?: () => void
|
||||
/** Overlay content after the stack (EmptyState's modals). */
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the hero card.
|
||||
* @param props - see {@link EmptyHeroProps}.
|
||||
* @returns the centered hero element tree.
|
||||
*/
|
||||
export function EmptyHero({
|
||||
workspaceRow,
|
||||
draft,
|
||||
disabled,
|
||||
placeholder,
|
||||
error,
|
||||
status,
|
||||
onDraftChange,
|
||||
onSend,
|
||||
onAdd,
|
||||
children,
|
||||
}: EmptyHeroProps) {
|
||||
// Stable filter id so multiple hero mounts do not collide in the DOM.
|
||||
const glowFilterId = `empty-glow-${useId().replace(/:/g, '')}`
|
||||
return (
|
||||
<div className={css.root}>
|
||||
<div className={css.stack}>
|
||||
<div className={css.headline}>
|
||||
{/* figma 34:10412: fish 34×25 leading the headline, gap 10. */}
|
||||
<FishLogo size={34} className={css.fish} />
|
||||
Let's start building
|
||||
</div>
|
||||
<div className={css.body}>
|
||||
{/* figma 313:14109: soft ellipse behind workspace + InputBar; width
|
||||
tracks the card (glow asset 1051 vs design card 776) so blur
|
||||
scales in userSpace with it. */}
|
||||
<svg className={css.glow} viewBox="0 0 1051 468" fill="none" aria-hidden="true">
|
||||
<defs>
|
||||
<filter
|
||||
id={glowFilterId}
|
||||
x="0"
|
||||
y="0"
|
||||
width="1051"
|
||||
height="468"
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||
<feGaussianBlur stdDeviation="50" result="effect1_foregroundBlur" />
|
||||
</filter>
|
||||
</defs>
|
||||
<g filter={`url(#${glowFilterId})`}>
|
||||
<ellipse cx="525.5" cy="234" rx="425.5" ry="134" fill="#6187D8" fillOpacity="0.1" />
|
||||
</g>
|
||||
</svg>
|
||||
<div className={css.workspaceRow}>{workspaceRow}</div>
|
||||
<InputBar
|
||||
draft={draft}
|
||||
running={false}
|
||||
disabled={disabled}
|
||||
error={error}
|
||||
{...(status === undefined ? {} : { status })}
|
||||
variant="hero"
|
||||
placeholder={placeholder ?? 'Describe what you want to build'}
|
||||
onDraftChange={onDraftChange}
|
||||
onSend={onSend}
|
||||
{...(onAdd === undefined ? {} : { onAdd })}
|
||||
addLabel="Create workspace"
|
||||
/* v8 ignore next -- structural noop: hero never passes running=true, so stop is unreachable. */
|
||||
onStop={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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,78 @@
|
||||
// EmptyState (figma NEW SESSION screen): centered hero — fish + title,
|
||||
// workspace picker row (MenuDropdown 122:9481 + New Workspace submenu
|
||||
// 419:16920 + Dialog 451:18655), then the SAME InputBar the resident
|
||||
// composer uses (empty→content is a position move, never a swap). Project
|
||||
// options derive in-component from useSessions; Create new runs
|
||||
// createWorkspaceSession (host mkdir + session.create + open).
|
||||
|
||||
import { useId, useMemo, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
FishLogo,
|
||||
IconChevronDownOutline14,
|
||||
IconFolderClose16,
|
||||
IconFolderOpen16,
|
||||
IconPlusOutline16,
|
||||
Menu,
|
||||
Modal,
|
||||
type MenuEntry,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
/** Page-local Session Intent hero. */
|
||||
import { useRef, useState } from 'react'
|
||||
import type { EmptyStateSlotProps } from '../contract/slots.ts'
|
||||
import { InputBar } from './InputBar.tsx'
|
||||
import type { InputBarError } from './InputBar.tsx'
|
||||
import css from './EmptyState.module.css'
|
||||
import { EmptyHero, WorkspaceChip } from './EmptyHero.tsx'
|
||||
|
||||
/** Menu id for "New Workspace" (opens submenu; not a cwd). */
|
||||
const NEW_WORKSPACE = '::new-workspace'
|
||||
/** Submenu: path modal (figma 451:18655 copy). */
|
||||
const USE_EXISTING = '::use-existing'
|
||||
/** Submenu: create-workspace modal → mkdir + default session. */
|
||||
const CREATE_NEW = '::create-new'
|
||||
|
||||
/** Which full-page dialog is open (null = none). */
|
||||
type ModalKind = 'path' | 'create' | null
|
||||
|
||||
/** Full props composed by reference from the contract (runtime share & injected share; no store). */
|
||||
/** Full props composed from runtime projections, injected actions, and the declared picker slot. */
|
||||
export type EmptyStateProps = EmptyStateSlotProps
|
||||
|
||||
/** Deduped cwd set in list order (pure derivation over the sessions list). */
|
||||
function deriveCwds(state: SessionListState): readonly string[] {
|
||||
const seen = new Set<string>()
|
||||
for (const id of state.ids) {
|
||||
const cwd = state.byId[id]?.cwd
|
||||
if (cwd !== undefined && cwd !== '') seen.add(cwd)
|
||||
}
|
||||
return [...seen]
|
||||
}
|
||||
export function EmptyState({
|
||||
useSessions,
|
||||
useWorkspaces,
|
||||
startSession,
|
||||
updateSessionPrompt,
|
||||
sendSession,
|
||||
renderSlot,
|
||||
}: EmptyStateProps) {
|
||||
const intent = useSessions(state => state.intent)
|
||||
const workspaceSnapshot = useWorkspaces(state => state)
|
||||
const workspaces = workspaceSnapshot.items
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
const pickerAnchor = useRef<HTMLButtonElement>(null)
|
||||
if (intent === undefined) return null
|
||||
const workspaceId = intent.target.kind === 'workspace' ? intent.target.workspaceId : undefined
|
||||
const workspace = workspaceId === undefined
|
||||
? undefined
|
||||
: workspaces.find(item => item.workspaceId === workspaceId)
|
||||
const workspaceLabel = intent.target.kind === 'workspace-intent'
|
||||
? workspaceSnapshot.intent?.name ?? 'Workspace unavailable'
|
||||
: workspace?.title ?? 'Workspace unavailable'
|
||||
const workspaceIntent = workspaceSnapshot.intent
|
||||
const busy = intent.phase === 'connecting' || workspaceIntent?.phase === 'creating'
|
||||
const status = workspaceIntent?.phase === 'creating'
|
||||
? 'Creating workspace…'
|
||||
: intent.phase === 'connecting'
|
||||
? 'Creating session…'
|
||||
: workspaceSnapshot.phase === 'pending'
|
||||
? 'Loading workspaces…'
|
||||
: undefined
|
||||
const error: InputBarError | null = workspaceIntent?.error !== undefined
|
||||
? { op: 'workspace', message: `Workspace creation failed: ${workspaceIntent.error}` }
|
||||
: intent.error === undefined
|
||||
? null
|
||||
: { op: 'session', message: `Session creation failed: ${intent.error.message}` }
|
||||
|
||||
/** Basename for the workspace chip / menu row; empty → the design's "New Workspace" label. */
|
||||
function workspaceLabel(cwd: string): string {
|
||||
if (cwd === '') return 'New Workspace'
|
||||
const base = cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop()
|
||||
return base !== undefined && base !== '' ? base : cwd
|
||||
}
|
||||
|
||||
export function EmptyState({ useSessions, startSession, createWorkspaceSession }: EmptyStateProps) {
|
||||
const list = useSessions(s => s)
|
||||
const cwds = useMemo(() => deriveCwds(list), [list])
|
||||
// Local viewing state: the empty state owns no session, so its draft is
|
||||
// ephemeral by design (drafts are keyed by session id; there is none yet).
|
||||
const [draft, setDraft] = useState('')
|
||||
const [cwd, setCwd] = useState('')
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [modalKind, setModalKind] = useState<ModalKind>(null)
|
||||
const [pathDraft, setPathDraft] = useState('')
|
||||
const [workspaceName, setWorkspaceName] = useState('New WorkSpace')
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [modalError, setModalError] = useState<string | null>(null)
|
||||
const [sending, setSending] = useState(false)
|
||||
const [error, setError] = useState<InputBarError | null>(null)
|
||||
// Stable filter id so multiple EmptyState mounts do not collide in the DOM.
|
||||
const glowFilterId = `empty-glow-${useId().replace(/:/g, '')}`
|
||||
|
||||
const submit = (mode: 'queue' | 'steer'): void => {
|
||||
const text = draft.trim()
|
||||
/* v8 ignore next -- defensive: InputBar disables send while empty. */
|
||||
if (text === '' || sending) return
|
||||
setSending(true)
|
||||
setError(null)
|
||||
const chosen = cwd.trim()
|
||||
startSession({ text, mode, ...(chosen === '' ? {} : { cwd: chosen }) })
|
||||
.catch((reason: unknown) => {
|
||||
// The empty state survives failure with the draft intact (no session
|
||||
// exists to carry promptError; this is the only local error surface).
|
||||
setError({ op: 'send', message: reason instanceof Error ? reason.message : String(reason) })
|
||||
setSending(false)
|
||||
})
|
||||
// Success needs no cleanup: the session selection swaps this slot out for the session body.
|
||||
}
|
||||
|
||||
const items: MenuEntry[] = [
|
||||
...cwds.map(c => ({
|
||||
id: c,
|
||||
label: workspaceLabel(c),
|
||||
icon: <IconFolderClose16 size={16} />,
|
||||
})),
|
||||
...(cwds.length > 0 ? [{ type: 'separator' as const, id: 'sep-new' }] : []),
|
||||
{
|
||||
id: NEW_WORKSPACE,
|
||||
label: 'New Workspace',
|
||||
icon: <IconPlusOutline16 size={16} />,
|
||||
submenu: [
|
||||
{ id: USE_EXISTING, label: 'Use a existing folder' },
|
||||
{ id: CREATE_NEW, label: 'Create new' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const closeModal = (): void => {
|
||||
if (creating) return
|
||||
setModalKind(null)
|
||||
setModalError(null)
|
||||
}
|
||||
|
||||
const openPathModal = (): void => {
|
||||
setPathDraft(cwd)
|
||||
setModalError(null)
|
||||
setModalKind('path')
|
||||
}
|
||||
|
||||
const openCreateModal = (): void => {
|
||||
setWorkspaceName('New WorkSpace')
|
||||
setModalError(null)
|
||||
setModalKind('create')
|
||||
}
|
||||
|
||||
const confirmPath = (): void => {
|
||||
const next = pathDraft.trim()
|
||||
if (next === '') return
|
||||
setCwd(next)
|
||||
setModalKind(null)
|
||||
}
|
||||
|
||||
const confirmCreate = (): void => {
|
||||
if (creating) return
|
||||
setCreating(true)
|
||||
setModalError(null)
|
||||
createWorkspaceSession(workspaceName)
|
||||
.catch((reason: unknown) => {
|
||||
setModalError(reason instanceof Error ? reason.message : String(reason))
|
||||
setCreating(false)
|
||||
})
|
||||
// Success swaps this slot out for the new session body — no local cleanup.
|
||||
}
|
||||
|
||||
const modalBusy = creating
|
||||
const isPath = modalKind === 'path'
|
||||
const isCreate = modalKind === 'create'
|
||||
const workspaceRow = (
|
||||
<>
|
||||
<WorkspaceChip
|
||||
buttonRef={pickerAnchor}
|
||||
label={workspaceLabel}
|
||||
menuOpen={pickerOpen}
|
||||
onClick={() => { setPickerOpen(open => !open) }}
|
||||
/>
|
||||
{renderSlot('conversation.empty.workspace', {
|
||||
open: pickerOpen,
|
||||
anchorRef: pickerAnchor,
|
||||
onPick: (workspaceId) => {
|
||||
setPickerOpen(false)
|
||||
startSession(workspaceId, intent.prompt)
|
||||
},
|
||||
onClose: () => { setPickerOpen(false) },
|
||||
})}
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={css.root}>
|
||||
<div className={css.stack}>
|
||||
<div className={css.headline}>
|
||||
{/* figma 34:10412: fish 34×25 leading the headline, gap 10. */}
|
||||
<FishLogo size={34} className={css.fish} />
|
||||
Let's start building
|
||||
</div>
|
||||
<div className={css.body}>
|
||||
{/* figma 313:14109: soft ellipse behind workspace + InputBar; width
|
||||
tracks the card (glow asset 1051 vs design card 776) so blur
|
||||
scales in userSpace with it. */}
|
||||
<svg className={css.glow} viewBox="0 0 1051 468" fill="none" aria-hidden="true">
|
||||
<defs>
|
||||
<filter
|
||||
id={glowFilterId}
|
||||
x="0"
|
||||
y="0"
|
||||
width="1051"
|
||||
height="468"
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||
<feGaussianBlur stdDeviation="50" result="effect1_foregroundBlur" />
|
||||
</filter>
|
||||
</defs>
|
||||
<g filter={`url(#${glowFilterId})`}>
|
||||
<ellipse cx="525.5" cy="234" rx="425.5" ry="134" fill="#6187D8" fillOpacity="0.1" />
|
||||
</g>
|
||||
</svg>
|
||||
<div className={css.workspaceRow}>
|
||||
<Menu
|
||||
open={menuOpen}
|
||||
onClose={() => { setMenuOpen(false) }}
|
||||
{...(cwd !== '' ? { selectedId: cwd } : {})}
|
||||
items={items}
|
||||
side="top"
|
||||
className={css.workspaceMenu!}
|
||||
onSelect={(id) => {
|
||||
if (id === USE_EXISTING) {
|
||||
setMenuOpen(false)
|
||||
openPathModal()
|
||||
return
|
||||
}
|
||||
if (id === CREATE_NEW) {
|
||||
setMenuOpen(false)
|
||||
openCreateModal()
|
||||
return
|
||||
}
|
||||
setCwd(id)
|
||||
setMenuOpen(false)
|
||||
}}
|
||||
anchor={(
|
||||
<button
|
||||
type="button"
|
||||
className={css.workspace}
|
||||
aria-label="项目目录"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menuOpen}
|
||||
onClick={() => { setMenuOpen(!menuOpen) }}
|
||||
>
|
||||
<IconFolderOpen16 className={css.folder} size={16} />
|
||||
<span className={css.workspaceLabel}>{workspaceLabel(cwd)}</span>
|
||||
<IconChevronDownOutline14 className={css.chevron} size={12} />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<InputBar
|
||||
draft={draft}
|
||||
running={false}
|
||||
disabled={sending}
|
||||
error={error}
|
||||
variant="hero"
|
||||
placeholder="Message to run task, plan and build, enter for / commands"
|
||||
onDraftChange={setDraft}
|
||||
onSend={submit}
|
||||
/* v8 ignore next -- structural noop: hero never passes running=true, so stop is unreachable. */
|
||||
onStop={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Modal
|
||||
open={isPath}
|
||||
onClose={closeModal}
|
||||
title="Enter an existing folder path"
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="outline" className={css.modalAction!} onClick={closeModal}>Cancel</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
className={css.modalAction!}
|
||||
disabled={pathDraft.trim() === ''}
|
||||
onClick={confirmPath}
|
||||
>
|
||||
Open Folder
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<input
|
||||
className={css.modalInput}
|
||||
value={pathDraft}
|
||||
aria-label="Folder path"
|
||||
autoFocus
|
||||
placeholder="ex. User/Documents/Harness/Space"
|
||||
onChange={(e) => { setPathDraft(e.target.value) }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
confirmPath()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
open={isCreate}
|
||||
onClose={closeModal}
|
||||
title="Create new workspace"
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="outline" className={css.modalAction!} disabled={modalBusy} onClick={closeModal}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
className={css.modalAction!}
|
||||
disabled={modalBusy || workspaceName.trim() === ''}
|
||||
onClick={confirmCreate}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<input
|
||||
className={css.modalInput}
|
||||
value={workspaceName}
|
||||
aria-label="Workspace name"
|
||||
autoFocus
|
||||
disabled={modalBusy}
|
||||
onChange={(e) => { setWorkspaceName(e.target.value) }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
confirmCreate()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{modalError !== null && <div className={css.modalError} role="alert">{modalError}</div>}
|
||||
</Modal>
|
||||
</div>
|
||||
<EmptyHero
|
||||
workspaceRow={workspaceRow}
|
||||
draft={intent.prompt}
|
||||
disabled={busy}
|
||||
{...(status === undefined ? {} : { status })}
|
||||
error={error}
|
||||
onDraftChange={updateSessionPrompt}
|
||||
onSend={() => { sendSession() }}
|
||||
onAdd={() => { setPickerOpen(true) }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -9,7 +9,7 @@ import css from './InputBar.module.css'
|
||||
|
||||
/** Prompt failure surface (mirrors the session snapshot's promptError shape). */
|
||||
export interface InputBarError {
|
||||
op: 'send' | 'stop'
|
||||
op: 'workspace' | 'session' | 'send' | 'stop'
|
||||
message: string
|
||||
}
|
||||
|
||||
@@ -18,12 +18,17 @@ export interface InputBarProps {
|
||||
running: boolean
|
||||
disabled: boolean
|
||||
error: InputBarError | null
|
||||
/** Observable async phase for browser fixtures and assistive technology. */
|
||||
status?: string
|
||||
/** Hero = empty-state centered card; composer = resident bottom bar. */
|
||||
variant: 'hero' | 'composer'
|
||||
placeholder?: string
|
||||
accessory?: ReactNode
|
||||
onDraftChange: (text: string) => void
|
||||
onSend: (mode: 'queue' | 'steer') => void
|
||||
onStop: () => void
|
||||
onAdd?: () => void
|
||||
addLabel?: string
|
||||
}
|
||||
|
||||
interface SelectOption {
|
||||
@@ -47,7 +52,8 @@ const MODEL_OPTIONS: readonly SelectOption[] = [
|
||||
]
|
||||
|
||||
export function InputBar({
|
||||
draft, running, disabled, error, variant, placeholder, accessory, onDraftChange, onSend, onStop,
|
||||
draft, running, disabled, error, status, variant, placeholder, accessory,
|
||||
onDraftChange, onSend, onStop, onAdd, addLabel = 'Add attachment',
|
||||
}: InputBarProps) {
|
||||
const empty = draft.trim() === ''
|
||||
const inputRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
@@ -98,7 +104,7 @@ export function InputBar({
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
const primaryLabel = running ? '停止' : '发送'
|
||||
const primaryLabel = running ? 'Stop generating' : 'Send message'
|
||||
const onPrimary = (): void => {
|
||||
if (running) {
|
||||
onStop()
|
||||
@@ -129,11 +135,8 @@ export function InputBar({
|
||||
|
||||
return (
|
||||
<div className={clsx(css.root, variant === 'hero' && css.hero)}>
|
||||
{error !== null && (
|
||||
<div className={css.error}>
|
||||
{error.op === 'stop' ? '停止失败' : '发送失败'}:{error.message}
|
||||
</div>
|
||||
)}
|
||||
{status !== undefined && <div className={css.status} role="status">{status}</div>}
|
||||
{error !== null && <div className={css.error} role="alert">{error.message}</div>}
|
||||
<div className={css.card}>
|
||||
{accessory !== undefined && <div className={css.accessory}>{accessory}</div>}
|
||||
{/* Mirror-div auto-grow: the hidden mirror renders draft+'\n' and stretches the wrapper
|
||||
@@ -145,7 +148,7 @@ export function InputBar({
|
||||
className={css.input}
|
||||
value={draft}
|
||||
disabled={locked}
|
||||
placeholder={placeholder ?? (disabled ? '会话不可用' : running ? '回复生成中,可停止后再输入' : '输入消息,Enter 发送,Shift+Enter 换行')}
|
||||
placeholder={placeholder ?? (disabled ? 'Session unavailable' : running ? 'Generating a response…' : 'Message the agent')}
|
||||
rows={2}
|
||||
onChange={(e) => onDraftChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
@@ -159,10 +162,11 @@ export function InputBar({
|
||||
<button
|
||||
type="button"
|
||||
className={css.add}
|
||||
aria-label="添加"
|
||||
title="添加"
|
||||
aria-label={addLabel}
|
||||
title={addLabel}
|
||||
disabled={locked}
|
||||
onMouseDown={keepFocus}
|
||||
onClick={onAdd}
|
||||
>
|
||||
<IconPlusOutline16 size={14} />
|
||||
</button>
|
||||
@@ -177,7 +181,7 @@ export function InputBar({
|
||||
type="button"
|
||||
className={clsx(css.primary, running && css.stopping)}
|
||||
aria-label={primaryLabel}
|
||||
title={running ? '停止本轮' : '发送(Enter)'}
|
||||
title={primaryLabel}
|
||||
disabled={!running && (empty || disabled)}
|
||||
onMouseDown={keepFocus}
|
||||
onClick={onPrimary}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
// shape: the conversation surface (views triple, send choreography incl.
|
||||
// optimistic clear + failure restore THROUGH the declared store actions,
|
||||
// openDetails = select action + layout orchestration, sessions.open
|
||||
// navigation), the injectless-but-closeDetails details surface, and the
|
||||
// one-callback empty surface. Complements chat-apply.spec.tsx (registration)
|
||||
// navigation), and the closeDetails details surface. Complements
|
||||
// chat-apply.spec.tsx (registration)
|
||||
// and selection-survival.spec.ts (store axis). History opening is NOT an
|
||||
// inject concern anymore — the runtime sessions service opens on watch
|
||||
// (sessions-service.spec.ts owns that behavior).
|
||||
@@ -14,9 +14,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup } from '@testing-library/react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { SlotsService, scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
SessionId, SessionListState, WorkspaceListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SlotRendererHost } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { ConversationService, apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type {
|
||||
ChatViewInjected, ConversationInjected, DetailsInjected, EmptyStateInjected,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
@@ -53,10 +55,14 @@ async function bench() {
|
||||
ids: [ROOT],
|
||||
byId: { [ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', cwd: '/proj', running: false, updatedAt: 1 } },
|
||||
current: ROOT,
|
||||
} as SessionListState)
|
||||
intent: undefined,
|
||||
phase: 'ready',
|
||||
})
|
||||
const sessionFake = {
|
||||
open: vi.fn(() => Promise.resolve()),
|
||||
loadOlder: vi.fn(() => Promise.resolve()),
|
||||
updatePendingPrompt: vi.fn(),
|
||||
retryPendingPrompt: vi.fn(),
|
||||
prompt: vi.fn<() => Promise<{ ok: boolean; value?: object; error?: { code: string; message: string } }>>(
|
||||
() => Promise.resolve({ ok: true, value: { accepted: true } })),
|
||||
cancel: vi.fn<() => Promise<{ ok: boolean; value?: object; error?: { code: string; message: string } }>>(
|
||||
@@ -73,15 +79,24 @@ async function bench() {
|
||||
}
|
||||
const sessionsFake = {
|
||||
list: listStore,
|
||||
manager: { get: () => sessionFake },
|
||||
binding: (id: SessionId) => ({ sessionId: id, session: sessionFake, ctx: mint(id) }),
|
||||
scope: (id: SessionId) => mint(id),
|
||||
cell: () => undefined,
|
||||
scopeOf,
|
||||
create: vi.fn(() => Promise.resolve(ROOT)),
|
||||
createWorkspace: vi.fn(() => Promise.resolve(ROOT)),
|
||||
open: vi.fn(),
|
||||
updateIntent: vi.fn(),
|
||||
}
|
||||
ctx.provide('sessions', sessionsFake)
|
||||
const workspaceStore = createSnapshotStore<WorkspaceListState>({
|
||||
items: [], intent: undefined, state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})
|
||||
const workspacesFake = {
|
||||
list: workspaceStore,
|
||||
startSession: vi.fn(),
|
||||
sendSession: vi.fn(),
|
||||
}
|
||||
ctx.provide('workspaces', workspacesFake)
|
||||
const layoutFake = { openDetails: vi.fn(), closeDetails: vi.fn() }
|
||||
ctx.provide('layout', layoutFake)
|
||||
ctx.provide('i18n', { bind: () => (key: string) => key })
|
||||
@@ -124,19 +139,25 @@ async function bench() {
|
||||
id, instance.actions)
|
||||
return { instance, injected }
|
||||
}
|
||||
return { ctx, slots, hostFace, entryOf, conversationSurface, chatViewSurface, sessionFake, sessionsFake, layoutFake, mint }
|
||||
const emptySurface = () => {
|
||||
const entry = entryOf('conversation.empty')
|
||||
return (entry.inject as unknown as () => EmptyStateInjected)()
|
||||
}
|
||||
return {
|
||||
ctx, slots, hostFace, entryOf, conversationSurface, chatViewSurface, emptySurface,
|
||||
sessionFake, sessionsFake, workspacesFake, layoutFake, mint,
|
||||
}
|
||||
}
|
||||
|
||||
describe('conversation slot inject surface', () => {
|
||||
it('assembles the thin surface side-effect-free, navigates via sessions.open', async () => {
|
||||
it('assembles the thin surface side-effect-free', async () => {
|
||||
const b = await bench()
|
||||
const { injected } = b.conversationSurface(ROOT)
|
||||
// Assembly has no session side effects: opening the event window belongs
|
||||
// to the runtime watch path, not the inject factory.
|
||||
expect(b.sessionFake.open).not.toHaveBeenCalled()
|
||||
expect(injected.views.list().map(v => v.id)).toEqual(['chat'])
|
||||
injected.open(ROOT)
|
||||
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
|
||||
|
||||
const chatView = b.chatViewSurface(ROOT)
|
||||
chatView.injected.loadOlder()
|
||||
expect(b.sessionFake.loadOlder).toHaveBeenCalledTimes(1)
|
||||
@@ -202,6 +223,17 @@ describe('conversation slot inject surface', () => {
|
||||
expect(conv.instance).toBe(instance)
|
||||
})
|
||||
|
||||
it('routes navigation through SessionsService and the retained prompt through the scoped Session', async () => {
|
||||
const b = await bench()
|
||||
const { injected } = b.conversationSurface(ROOT)
|
||||
injected.open(ROOT)
|
||||
injected.updateSessionPrompt('revised')
|
||||
injected.retrySessionPrompt()
|
||||
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
|
||||
expect(b.sessionFake.updatePendingPrompt).toHaveBeenCalledWith('revised')
|
||||
expect(b.sessionFake.retryPendingPrompt).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('views read face projects the ring ledger (subscribe/version through ctx.slots)', async () => {
|
||||
const b = await bench()
|
||||
const { injected } = b.conversationSurface(ROOT)
|
||||
@@ -225,7 +257,7 @@ describe('conversation slot inject surface', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('details and empty inject surfaces', () => {
|
||||
describe('details inject surface', () => {
|
||||
it('details injects the one layout callback; selection rides the shared store instead', async () => {
|
||||
const b = await bench()
|
||||
const entry = b.entryOf('details')
|
||||
@@ -239,29 +271,18 @@ describe('details and empty inject surfaces', () => {
|
||||
expect(details).toBe(conv)
|
||||
})
|
||||
|
||||
it('empty injects startSession and createWorkspaceSession (no store, cwds derive in-component)', async () => {
|
||||
it('empty state injects the runtime intent actions and remains storeless', async () => {
|
||||
const b = await bench()
|
||||
const entry = b.entryOf('conversation.empty')
|
||||
expect(entry.store).toBeUndefined()
|
||||
const injected = (entry.inject as unknown as () => EmptyStateInjected)()
|
||||
expect(Object.keys(injected).sort()).toEqual(['createWorkspaceSession', 'startSession'])
|
||||
await injected.startSession({ text: 'go', mode: 'queue' })
|
||||
expect(b.sessionsFake.create).toHaveBeenCalled()
|
||||
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
|
||||
expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'go' }], 'queue')
|
||||
b.sessionsFake.open.mockClear()
|
||||
await injected.createWorkspaceSession('Fresh')
|
||||
expect(b.sessionsFake.createWorkspace).toHaveBeenCalledWith('Fresh')
|
||||
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
|
||||
})
|
||||
|
||||
it('startSession fails loud on a torn boot (conversation service fiber gone)', async () => {
|
||||
const b = await bench()
|
||||
const injected = (b.entryOf('conversation.empty').inject as unknown as () => EmptyStateInjected)()
|
||||
// Tear the service's own fiber (registry keyed by the class): the slot
|
||||
// entries survive, so the gesture-time read hits the loud branch.
|
||||
b.ctx.registry.delete(ConversationService)
|
||||
await vi.waitFor(() => { expect(b.ctx.get('conversation')).toBeUndefined() })
|
||||
expect(() => injected.startSession({ text: 'go', mode: 'queue' })).toThrow(/conversation service unavailable/)
|
||||
const injected = b.emptySurface()
|
||||
injected.startSession(undefined, 'fresh')
|
||||
injected.startSession('workspace-1' as never, 'retargeted')
|
||||
injected.updateSessionPrompt('typed')
|
||||
injected.sendSession()
|
||||
expect(b.workspacesFake.startSession).toHaveBeenNthCalledWith(1, undefined, 'fresh')
|
||||
expect(b.workspacesFake.startSession).toHaveBeenNthCalledWith(2, 'workspace-1', 'retargeted')
|
||||
expect(b.sessionsFake.updateIntent).toHaveBeenCalledWith('typed')
|
||||
expect(b.workspacesFake.sendSession).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import { createSnapshotStore, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
ConversationSnapshot, SessionId, SessionListState, ToolResultNode,
|
||||
ConversationSnapshot, SessionId, SessionListState, ToolResultNode, WorkspaceListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
@@ -40,8 +40,8 @@ const toolResult = (seq: number, callId: string, name: string, args = '{"command
|
||||
function snapshotWith(nodes: ToolResultNode[]): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls: [],
|
||||
pending: [], running: false, removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
|
||||
pending: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, intent: null, pendingPrompt: null, lastAgentError: null,
|
||||
} as ConversationSnapshot
|
||||
}
|
||||
|
||||
@@ -65,9 +65,11 @@ async function bench(nodes: ToolResultNode[]) {
|
||||
const session = createSnapshotStore<ConversationSnapshot>(snapshotWith(nodes))
|
||||
const list = createSnapshotStore<SessionListState>({
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, title: 'S', running: false, updatedAt: 1 } },
|
||||
byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, updatedAt: 1 } },
|
||||
current: SID,
|
||||
} as SessionListState)
|
||||
intent: undefined,
|
||||
phase: 'ready',
|
||||
})
|
||||
// Identity-stable cell: the renderer caches hooks per source and inject
|
||||
// results per cell, both by object identity.
|
||||
const cell = { sessionId: SID, session }
|
||||
@@ -75,11 +77,20 @@ async function bench(nodes: ToolResultNode[]) {
|
||||
const layout = { openDetails: vi.fn(), closeDetails: vi.fn() }
|
||||
ctx.provide('sessions', {
|
||||
list,
|
||||
manager: { get: () => ({ loadOlder: vi.fn() }) },
|
||||
binding: (id: SessionId) => ({ sessionId: id, session: { loadOlder: vi.fn() } }),
|
||||
scope: () => ({ get: () => scoped }),
|
||||
cell: (id: string) => (id === SID ? cell : undefined),
|
||||
create: vi.fn(),
|
||||
open: vi.fn(),
|
||||
updateIntent: vi.fn(),
|
||||
})
|
||||
ctx.provide('workspaces', {
|
||||
list: createSnapshotStore<WorkspaceListState>({
|
||||
items: [], intent: undefined, state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
}),
|
||||
startSession: vi.fn(),
|
||||
sendSession: vi.fn(),
|
||||
})
|
||||
ctx.provide('layout', layout)
|
||||
ctx.provide('i18n', { bind: () => (key: string) => key })
|
||||
@@ -182,12 +193,23 @@ describe('registrant load-order seam', () => {
|
||||
await slotsFiber.await()
|
||||
const slots = ctx.get('slots') as SlotsService
|
||||
ctx.provide('sessions', {
|
||||
list: createSnapshotStore<SessionListState>({ ids: [], byId: {}, current: undefined } as SessionListState),
|
||||
manager: { get: vi.fn() },
|
||||
list: createSnapshotStore<SessionListState>({
|
||||
ids: [], byId: {}, current: undefined, intent: undefined, phase: 'ready',
|
||||
}),
|
||||
binding: () => undefined,
|
||||
scope: () => undefined,
|
||||
cell: () => undefined,
|
||||
create: vi.fn(),
|
||||
open: vi.fn(),
|
||||
updateIntent: vi.fn(),
|
||||
})
|
||||
ctx.provide('workspaces', {
|
||||
list: createSnapshotStore<WorkspaceListState>({
|
||||
items: [], intent: undefined, state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
}),
|
||||
startSession: vi.fn(),
|
||||
sendSession: vi.fn(),
|
||||
})
|
||||
ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
ctx.provide('i18n', { bind: () => (key: string) => key })
|
||||
|
||||
@@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Profiler } from 'react'
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import type {
|
||||
AssistantMessageNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, UserMessageNode,
|
||||
AssistantMessageNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, UserMessageNode, WorkspaceListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore, PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -29,8 +29,8 @@ const SID = 's1' as SessionId
|
||||
function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
|
||||
pending: [], running: false, removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
|
||||
pending: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, intent: null, pendingPrompt: null, lastAgentError: null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,15 @@ const runningCall = (callId: string, name = 'bash'): RunningToolCall => ({
|
||||
/** Empty sessions-list hook for the global standard-kit seat. */
|
||||
function emptySessions() {
|
||||
const store = createSnapshotStore<SessionListState>(
|
||||
{ ids: [], byId: {}, current: undefined } as SessionListState)
|
||||
{ ids: [], byId: {}, current: undefined, intent: undefined, phase: 'ready' })
|
||||
return bindSnapshotSelector(store)
|
||||
}
|
||||
|
||||
function emptyWorkspaces() {
|
||||
const store = createSnapshotStore<WorkspaceListState>({
|
||||
items: [], intent: undefined, state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})
|
||||
return bindSnapshotSelector(store)
|
||||
}
|
||||
|
||||
@@ -95,6 +103,7 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
sessionId: SID,
|
||||
useSession: bindSnapshotSelector(source),
|
||||
useSessions: emptySessions(),
|
||||
useWorkspaces: emptyWorkspaces(),
|
||||
useStore: bindSnapshotSelector(chat),
|
||||
actions: chat.actions,
|
||||
renderSlot,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -5,7 +5,7 @@ import { cleanup, render } from '@testing-library/react'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSnapshot, SessionId, SessionListState, WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { createChatStore } from '../src/client/stores.ts'
|
||||
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
|
||||
@@ -19,8 +19,8 @@ const SID = 's1' as SessionId
|
||||
function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
|
||||
pending: [], running: false, removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
|
||||
pending: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, intent: null, pendingPrompt: null, lastAgentError: null,
|
||||
} as ConversationSnapshot
|
||||
}
|
||||
|
||||
@@ -65,12 +65,17 @@ describe('render branch tails', () => {
|
||||
const chat = createChatStore().create()
|
||||
chat.actions.select({ turnSeq: 1, callId: 'ghost' } satisfies SelectionTarget)
|
||||
const emptyList = createSnapshotStore<SessionListState>(
|
||||
{ ids: [], byId: {}, current: undefined } as SessionListState)
|
||||
{ ids: [], byId: {}, current: undefined, intent: undefined, phase: 'ready' })
|
||||
const emptyWorkspaces = createSnapshotStore<WorkspaceListState>({
|
||||
items: [], intent: undefined, state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})
|
||||
const view = render(
|
||||
<DetailsPanel
|
||||
sessionId={SID}
|
||||
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} }) as unknown as UseSession<ConversationSnapshot>}
|
||||
useSessions={bindSnapshotSelector(emptyList)}
|
||||
useWorkspaces={bindSnapshotSelector(emptyWorkspaces)}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
closeDetails={vi.fn()}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
/**
|
||||
* Test-local selector binding through the production uSES implementation.
|
||||
* Runtime remains React-free, so specs bind observable sources here.
|
||||
*/
|
||||
import { bindSnapshotSelector } from '../../web-react/src/bind.ts'
|
||||
|
||||
/** Minimal observable source (engine stores and scripted fakes both satisfy it). */
|
||||
export interface HookSource<T> {
|
||||
getSnapshot(): T
|
||||
subscribe(fn: () => void): () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind a selector hook over a snapshot source.
|
||||
* @param src - the source.
|
||||
* @returns a SnapshotSelectorHook-shaped hook.
|
||||
*/
|
||||
export function hookOf<T>(src: HookSource<T>) {
|
||||
return bindSnapshotSelector<T>(src)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -5,27 +5,31 @@
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { SessionsService, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSnapshotStore, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId, SessionListState, WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createChatStore } from '../src/client/stores.ts'
|
||||
|
||||
// Use the runtime's programmable fake to drive the real session service.
|
||||
import { FakeApiClient, ok } from '../../runtime/tests/fake-api.ts'
|
||||
|
||||
const sid = (s: string): SessionId => s as SessionId
|
||||
|
||||
interface Bench {
|
||||
ctx: Context
|
||||
api: FakeApiClient
|
||||
sessions: SessionsService
|
||||
slots: SlotsService
|
||||
chat: ReturnType<typeof createChatStore>
|
||||
}
|
||||
|
||||
function bench(): Bench {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
ctx.provide('sessions', {
|
||||
list: createSnapshotStore<SessionListState>({
|
||||
ids: [], byId: {}, current: undefined, intent: undefined, phase: 'ready',
|
||||
}),
|
||||
cell: () => undefined,
|
||||
})
|
||||
ctx.provide('workspaces', {
|
||||
list: createSnapshotStore<WorkspaceListState>({
|
||||
items: [], intent: undefined, state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
}),
|
||||
})
|
||||
// Service self-registers as ctx 'slots' (cordis Service constructor).
|
||||
const slots = new SlotsService(ctx)
|
||||
const chat = createChatStore()
|
||||
@@ -42,22 +46,7 @@ function bench(): Bench {
|
||||
}, (_p: { renderSlot?: unknown }) => null)
|
||||
slots.register({ name: 'conversation', store: chat }, () => null)
|
||||
slots.register({ name: 'details', store: chat }, () => null)
|
||||
return { ctx, api, sessions, slots, chat }
|
||||
}
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
// Manager notifier + store batching are microtask-based.
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
function feed(b: Bench, rows: { id: string; cwd?: string; running?: boolean }[]): void {
|
||||
b.api.onList = () => Promise.resolve(ok({
|
||||
items: rows.map(r => ({
|
||||
sessionId: sid(r.id), updatedAt: 1, running: r.running ?? false,
|
||||
...(r.cwd !== undefined ? { cwd: r.cwd } : {}),
|
||||
})),
|
||||
}) as never)
|
||||
return { slots, chat }
|
||||
}
|
||||
|
||||
/** Resolve the store instance the renderer would hand a slot's component for a session. */
|
||||
@@ -87,11 +76,8 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('selection survives on the store seat', () => {
|
||||
it('one session, two slots: conversation writes, details reads the SAME instance', async () => {
|
||||
it('one session, two slots: conversation writes, details reads the SAME instance', () => {
|
||||
const b = bench()
|
||||
feed(b, [{ id: 's1' }])
|
||||
await b.sessions.manager.refreshList()
|
||||
await flush()
|
||||
|
||||
const conv = storeFor(b, 'conversation', sid('s1'))
|
||||
const details = storeFor(b, 'details', sid('s1'))
|
||||
@@ -101,11 +87,8 @@ describe('selection survives on the store seat', () => {
|
||||
expect(details).toBe(conv)
|
||||
})
|
||||
|
||||
it('sessions are isolated: s2 selection never bleeds into s1', async () => {
|
||||
it('sessions are isolated: s2 selection never bleeds into s1', () => {
|
||||
const b = bench()
|
||||
feed(b, [{ id: 's1' }, { id: 's2' }])
|
||||
await b.sessions.manager.refreshList()
|
||||
await flush()
|
||||
|
||||
const one = storeFor(b, 'conversation', sid('s1'))
|
||||
const two = storeFor(b, 'conversation', sid('s2'))
|
||||
@@ -116,25 +99,17 @@ describe('selection survives on the store seat', () => {
|
||||
expect(two.store.getSnapshot().selection).toEqual({ turnSeq: 9, callId: 'z' })
|
||||
})
|
||||
|
||||
it('a display-title-upgrading list refresh keeps instance identity and the selection value', async () => {
|
||||
it('a list-projection update keeps instance identity and the selection value', () => {
|
||||
const b = bench()
|
||||
// First-send shape: client-side create inserts the row without cwd (title = bare id).
|
||||
b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s1') }))
|
||||
const id = await b.sessions.create({})
|
||||
await flush()
|
||||
expect(b.sessions.list.getSnapshot().byId[id]).toMatchObject({ displayTitle: 's1' })
|
||||
expect(b.sessions.list.getSnapshot().byId[id]?.title).toBeUndefined()
|
||||
const id = sid('s1')
|
||||
const projection = createSnapshotStore({ displayTitle: 's1' })
|
||||
|
||||
const store = storeFor(b, 'conversation', id)
|
||||
store.actions.select({ turnSeq: 3, callId: 'c1' })
|
||||
store.actions.setDraft('half-typed')
|
||||
|
||||
// The late list refresh lands (host knows the cwd → better fallback label).
|
||||
feed(b, [{ id: 's1', cwd: '/w/proj-a' }])
|
||||
await b.sessions.manager.refreshList()
|
||||
await flush()
|
||||
expect(b.sessions.list.getSnapshot().byId[id]).toMatchObject({ displayTitle: 'proj-a' })
|
||||
expect(b.sessions.list.getSnapshot().byId[id]?.title).toBeUndefined()
|
||||
projection.set({ displayTitle: 'proj-a' })
|
||||
expect(projection.getSnapshot().displayTitle).toBe('proj-a')
|
||||
|
||||
const after = storeFor(b, 'conversation', id)
|
||||
expect(after).toBe(store)
|
||||
@@ -142,32 +117,20 @@ describe('selection survives on the store seat', () => {
|
||||
expect(after.store.getSnapshot().draft).toBe('half-typed')
|
||||
})
|
||||
|
||||
it('session death buries the instance and its persisted draft', async () => {
|
||||
it('session death buries the instance and its persisted draft', () => {
|
||||
const b = bench()
|
||||
feed(b, [{ id: 's1' }, { id: 's2' }])
|
||||
await b.sessions.manager.refreshList()
|
||||
await flush()
|
||||
|
||||
// Mint the scope (store prune rides the scope-teardown axis: no scope,
|
||||
// no teardown — the real page always resolves the binding to render).
|
||||
b.sessions.binding(sid('s1'))
|
||||
const doomed = storeFor(b, 'conversation', sid('s1'))
|
||||
doomed.actions.setDraft('to be buried')
|
||||
doomed.actions.select({ turnSeq: 1 })
|
||||
expect(localStorage.getItem('dsh.conversation.chat.s1')).not.toBeNull()
|
||||
|
||||
// Watch elsewhere so s1's scope teardown is not deferred, then remove it.
|
||||
b.sessions.binding(sid('s2'))
|
||||
feed(b, [{ id: 's2' }])
|
||||
await b.sessions.manager.refreshList()
|
||||
await flush()
|
||||
// SessionsService calls this public slot lifecycle seam when the scope dies.
|
||||
b.slots.pruneStoreScope(sid('s1'))
|
||||
|
||||
// Persisted residue is gone with the session...
|
||||
expect(localStorage.getItem('dsh.conversation.chat.s1')).toBeNull()
|
||||
// ...and a re-created same-id session starts from a FRESH instance.
|
||||
feed(b, [{ id: 's1' }, { id: 's2' }])
|
||||
await b.sessions.manager.refreshList()
|
||||
await flush()
|
||||
const reborn = storeFor(b, 'conversation', sid('s1'))
|
||||
expect(reborn).not.toBe(doomed)
|
||||
expect(reborn.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null })
|
||||
|
||||
@@ -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,203 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Skeleton acceptance over the four-share props form: empty-state transition
|
||||
* (same InputBar component in hero position, startSession submit, in-component
|
||||
* cwd derivation), ConversationRoot view switching through the store's view
|
||||
* field, DetailsPanel selection through the shared store. Components stay
|
||||
* pure — the framework shares are stubbed (useSession/useSessions), the store
|
||||
* share is a REAL createChatStore().create() instance (same construction path
|
||||
* as production), injected callbacks are spies.
|
||||
*/
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { ConversationSnapshot, PendingInteraction, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SelectionTarget, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type {
|
||||
ConversationSnapshot, SessionId, SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { EmptyStateProps } from '../src/client/skeleton/EmptyState.tsx'
|
||||
import type { ConversationRootProps } from '../src/client/skeleton/ConversationRoot.tsx'
|
||||
// Export discipline: packages/client/AGENTS.md.
|
||||
import { createChatStore } from '../src/client/stores.ts'
|
||||
import { ConversationRoot } from '../src/client/skeleton/ConversationRoot.tsx'
|
||||
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
|
||||
import { EmptyState } from '../src/client/skeleton/EmptyState.tsx'
|
||||
|
||||
const sid = (s: string): SessionId => s as SessionId
|
||||
|
||||
afterEach(cleanup)
|
||||
beforeEach(() => {
|
||||
// jsdom normally provides localStorage; some host Node builds surface it as undefined.
|
||||
globalThis.localStorage?.clear()
|
||||
beforeEach(() => { localStorage.clear() })
|
||||
|
||||
const sid = (id: string) => id as SessionId
|
||||
const wid = (id: string) => id as WorkspaceId
|
||||
const SID = sid('s1')
|
||||
|
||||
function workspace(id = 'w1'): WorkspaceView {
|
||||
return {
|
||||
workspaceId: wid(id), path: `/projects/${id}`, title: id, sessionIds: [],
|
||||
createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
}
|
||||
}
|
||||
|
||||
type SessionIntent = NonNullable<SessionListState['intent']>
|
||||
type WorkspaceIntent = NonNullable<WorkspaceListState['intent']>
|
||||
|
||||
const workspaceState = (
|
||||
items: readonly WorkspaceView[], workspaceIntent?: WorkspaceIntent,
|
||||
): WorkspaceListState => ({
|
||||
items, intent: workspaceIntent, state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})
|
||||
const hook = <T,>(snapshot: T) => <S,>(selector: (state: T) => S): S => selector(snapshot)
|
||||
|
||||
/** Minimal conversation snapshot slice the skeleton reads. */
|
||||
interface FakeSnapshot {
|
||||
nodes: readonly {
|
||||
kind: string
|
||||
seq?: number
|
||||
time?: number
|
||||
callId?: string
|
||||
call?: { name: string; argsRaw: string } | null
|
||||
callTime?: number | null
|
||||
content?: readonly { type: string; text?: string }[]
|
||||
isError?: boolean
|
||||
callView?: null
|
||||
resultView?: null
|
||||
}[]
|
||||
runningCalls: readonly {
|
||||
callId: string
|
||||
name: string
|
||||
argsRaw: string
|
||||
turn?: number
|
||||
step?: number
|
||||
time?: number
|
||||
callView?: null
|
||||
}[]
|
||||
running: boolean
|
||||
removed: boolean
|
||||
promptError: { op: 'send' | 'stop'; error: { message: string; code: string } } | null
|
||||
pending: readonly PendingInteraction[]
|
||||
function mountEmpty(
|
||||
intent: SessionIntent,
|
||||
items: readonly WorkspaceView[] = [],
|
||||
localWorkspace?: WorkspaceIntent,
|
||||
) {
|
||||
const updateSessionPrompt = vi.fn()
|
||||
const sendSession = vi.fn()
|
||||
const startSession = vi.fn()
|
||||
let pickerOwner: unknown
|
||||
const sessionState: SessionListState = {
|
||||
ids: [], byId: {}, current: intent.sessionId, intent, phase: 'ready',
|
||||
}
|
||||
const workspaceIntent = intent.target.kind === 'workspace-intent'
|
||||
? localWorkspace ?? { name: 'workspace', phase: 'ready' as const }
|
||||
: undefined
|
||||
const view = render(
|
||||
<EmptyState
|
||||
useSessions={hook(sessionState)}
|
||||
useWorkspaces={hook(workspaceState(items, workspaceIntent))}
|
||||
updateSessionPrompt={updateSessionPrompt}
|
||||
sendSession={sendSession}
|
||||
startSession={startSession}
|
||||
renderSlot={((_key: string, owner: unknown) => { pickerOwner = owner; return null }) as EmptyStateProps['renderSlot']}
|
||||
/>,
|
||||
)
|
||||
return { view, updateSessionPrompt, sendSession, startSession, pickerOwner: () => pickerOwner }
|
||||
}
|
||||
|
||||
function fakeSession(init: Partial<FakeSnapshot> = {}) {
|
||||
const store = createSnapshotStore<FakeSnapshot>({
|
||||
nodes: [], runningCalls: [], running: false, removed: false, promptError: null, pending: [], ...init,
|
||||
})
|
||||
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot> }
|
||||
}
|
||||
|
||||
/** Sessions-list stub: the standard useSessions hook over a snapshot store. */
|
||||
function fakeSessions(rows: { id: string; title: string; cwd?: string; parentId?: string }[]) {
|
||||
const store = createSnapshotStore<SessionListState>({
|
||||
ids: rows.map(r => sid(r.id)),
|
||||
byId: Object.fromEntries(rows.map(r => [r.id, {
|
||||
id: sid(r.id), title: `durable ${r.title}`, displayTitle: r.title, running: false, updatedAt: 1,
|
||||
...(r.cwd !== undefined ? { cwd: r.cwd } : {}),
|
||||
...(r.parentId !== undefined ? { parentId: sid(r.parentId) } : {}),
|
||||
}])),
|
||||
current: undefined,
|
||||
} as SessionListState)
|
||||
return { store, useSessions: bindSnapshotSelector(store) }
|
||||
}
|
||||
|
||||
/** SessionProvider seat stub (render-prop pass-through; ConversationRoot never invokes it). */
|
||||
const SessionProviderStub: ConversationRootProps['SessionProvider'] = ({ children }) => <>{children(sid('s1'))}</>
|
||||
|
||||
describe('EmptyState', () => {
|
||||
const noopCreate = () => Promise.resolve()
|
||||
|
||||
it('derives cwd options from the sessions list, submits startSession, failure surfaces locally', async () => {
|
||||
const { useSessions } = fakeSessions([
|
||||
{ id: 'a', title: 'a', cwd: '/w/app' },
|
||||
{ id: 'b', title: 'b', cwd: '/w/lib' },
|
||||
{ id: 'c', title: 'c', cwd: '/w/app' }, // duplicate cwd dedupes
|
||||
])
|
||||
let reject!: (e: Error) => void
|
||||
const startSession = vi.fn(() => new Promise<void>((_res, rej) => { reject = rej }))
|
||||
render(
|
||||
<EmptyState
|
||||
useSessions={useSessions}
|
||||
startSession={startSession}
|
||||
createWorkspaceSession={noopCreate}
|
||||
/>,
|
||||
)
|
||||
|
||||
const trigger = screen.getByRole('button', { name: '项目目录' })
|
||||
fireEvent.click(trigger)
|
||||
const menu = screen.getByRole('menu')
|
||||
expect([...menu.querySelectorAll('[role="menuitem"]')].map(el => el.textContent))
|
||||
.toEqual(['app', 'lib', 'New Workspace'])
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'app' }))
|
||||
const box = screen.getByPlaceholderText('Message to run task, plan and build, enter for / commands')
|
||||
fireEvent.change(box, { target: { value: '造一个轮子' } })
|
||||
fireEvent.keyDown(box, { key: 'Enter' })
|
||||
expect(startSession).toHaveBeenCalledWith({ text: '造一个轮子', mode: 'queue', cwd: '/w/app' })
|
||||
|
||||
reject(new Error('后端拒收'))
|
||||
expect(await screen.findByText(/后端拒收/)).toBeTruthy()
|
||||
// Draft survives the failure for retry.
|
||||
expect((box as HTMLTextAreaElement).value).toBe('造一个轮子')
|
||||
it('reads the Workspace and Session intents from runtime projections', () => {
|
||||
const b = mountEmpty({
|
||||
sessionId: sid('local-1'), target: { kind: 'workspace-intent' },
|
||||
prompt: 'draft', phase: 'ready',
|
||||
})
|
||||
expect(b.view.getByRole('button', { name: 'Choose workspace' }).textContent).toContain('workspace')
|
||||
fireEvent.change(b.view.getByPlaceholderText('Describe what you want to build'), { target: { value: 'build it' } })
|
||||
expect(b.updateSessionPrompt).toHaveBeenCalledWith('build it')
|
||||
fireEvent.click(b.view.getByRole('button', { name: 'Send message' }))
|
||||
expect(b.sendSession).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('Use a existing folder opens the path modal and Open Folder sets the chip', () => {
|
||||
const { useSessions } = fakeSessions([])
|
||||
render(
|
||||
<EmptyState
|
||||
useSessions={useSessions}
|
||||
startSession={() => Promise.resolve()}
|
||||
createWorkspaceSession={noopCreate}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: '项目目录' }))
|
||||
const newWs = screen.getByRole('menuitem', { name: 'New Workspace' })
|
||||
fireEvent.mouseEnter(newWs.parentElement as HTMLElement)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Use a existing folder' }))
|
||||
expect(screen.getByRole('dialog', { name: 'Enter an existing folder path' })).toBeTruthy()
|
||||
const path = screen.getByLabelText('Folder path') as HTMLInputElement
|
||||
fireEvent.change(path, { target: { value: '/tmp/fresh' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open Folder' }))
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
expect(screen.getByRole('button', { name: '项目目录' }).textContent).toContain('fresh')
|
||||
it('uses useWorkspaces for the selected label and preserves the prompt when retargeting', () => {
|
||||
const first = workspace('first')
|
||||
const b = mountEmpty({
|
||||
sessionId: sid('local-2'), target: { kind: 'workspace', workspaceId: first.workspaceId },
|
||||
prompt: 'keep me', phase: 'ready',
|
||||
}, [first])
|
||||
expect(b.view.getByRole('button', { name: 'Choose workspace' }).textContent).toContain('first')
|
||||
fireEvent.click(b.view.getByRole('button', { name: 'Choose workspace' }))
|
||||
const owner = b.pickerOwner() as { onPick(id: WorkspaceId): void }
|
||||
owner.onPick(wid('second'))
|
||||
expect(b.startSession).toHaveBeenCalledWith(wid('second'), 'keep me')
|
||||
})
|
||||
|
||||
it('Create new opens the modal and createWorkspaceSession succeeds', async () => {
|
||||
const { useSessions } = fakeSessions([])
|
||||
const createWorkspaceSession = vi.fn(() => Promise.resolve())
|
||||
render(
|
||||
<EmptyState
|
||||
useSessions={useSessions}
|
||||
startSession={() => Promise.resolve()}
|
||||
createWorkspaceSession={createWorkspaceSession}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: '项目目录' }))
|
||||
fireEvent.mouseEnter(screen.getByRole('menuitem', { name: 'New Workspace' }).parentElement as HTMLElement)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Create new' }))
|
||||
expect(screen.getByRole('dialog', { name: 'Create new workspace' })).toBeTruthy()
|
||||
const name = screen.getByLabelText('Workspace name') as HTMLInputElement
|
||||
expect(name.value).toBe('New WorkSpace')
|
||||
fireEvent.change(name, { target: { value: 'My Proj' } })
|
||||
fireEvent.keyDown(name, { key: 'Enter' })
|
||||
await vi.waitFor(() => expect(createWorkspaceSession).toHaveBeenCalledWith('My Proj'))
|
||||
})
|
||||
|
||||
it('Create modal Cancel dismisses without calling createWorkspaceSession', () => {
|
||||
const { useSessions } = fakeSessions([])
|
||||
const createWorkspaceSession = vi.fn(() => Promise.resolve())
|
||||
render(
|
||||
<EmptyState
|
||||
useSessions={useSessions}
|
||||
startSession={() => Promise.resolve()}
|
||||
createWorkspaceSession={createWorkspaceSession}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: '项目目录' }))
|
||||
fireEvent.mouseEnter(screen.getByRole('menuitem', { name: 'New Workspace' }).parentElement as HTMLElement)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Create new' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
expect(createWorkspaceSession).not.toHaveBeenCalled()
|
||||
it('exposes materialization phase and failure text', () => {
|
||||
const creating = mountEmpty({
|
||||
sessionId: sid('local-3'), target: { kind: 'workspace-intent' },
|
||||
prompt: 'x', phase: 'ready',
|
||||
}, [], { name: 'workspace', phase: 'creating' })
|
||||
expect(creating.view.getByRole('status').textContent).toBe('Creating workspace…')
|
||||
cleanup()
|
||||
const workspaceFailed = mountEmpty({
|
||||
sessionId: sid('local-3'), target: { kind: 'workspace-intent' },
|
||||
prompt: 'x', phase: 'ready',
|
||||
}, [], { name: 'workspace', phase: 'ready', error: 'offline' })
|
||||
expect(workspaceFailed.view.getByRole('alert').textContent).toBe('Workspace creation failed: offline')
|
||||
cleanup()
|
||||
const failed = mountEmpty({
|
||||
sessionId: sid('local-3'), target: { kind: 'workspace', workspaceId: wid('w1') },
|
||||
prompt: 'x', phase: 'ready', error: { step: 'session', message: 'offline' },
|
||||
}, [workspace()])
|
||||
expect(failed.view.getByRole('alert').textContent).toBe('Session creation failed: offline')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ConversationRoot', () => {
|
||||
function bench(
|
||||
tabs: ViewTab[], activeView?: string, init: Partial<FakeSnapshot> = {},
|
||||
renderSlotChain?: ConversationRootProps['renderSlotChain'],
|
||||
) {
|
||||
const { useSession } = fakeSession({ nodes: [{ kind: 'user' }, { kind: 'user' }], ...init })
|
||||
const { useSessions } = fakeSessions([
|
||||
{ id: 'root', title: 'proj' },
|
||||
{ id: 's1', title: 'child', parentId: 'root' },
|
||||
])
|
||||
const chat = createChatStore().create()
|
||||
if (activeView !== undefined) chat.actions.setView(activeView)
|
||||
const send = vi.fn()
|
||||
const stop = vi.fn()
|
||||
const open = vi.fn()
|
||||
// The renderSlot share as the outlet would bake it: renders a marker for
|
||||
// the ring key carrying the active-id filter (a Mock cannot satisfy the
|
||||
// generic method type directly — cast once at the prop seam).
|
||||
const renderSlot = vi.fn((key: string, _owner: object, opts?: { only?: string }) => (
|
||||
<div data-testid={`view-${opts?.only ?? '(all)'}`} data-slot={key} />
|
||||
))
|
||||
const ui = render(
|
||||
<ConversationRoot
|
||||
sessionId={sid('s1')}
|
||||
useSession={useSession}
|
||||
useSessions={useSessions}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
renderSlot={renderSlot as unknown as ConversationRootProps['renderSlot']}
|
||||
renderSlotChain={renderSlotChain ?? ((_key, _owner, opts) => opts?.fallback ?? null)}
|
||||
SessionProvider={SessionProviderStub}
|
||||
views={{
|
||||
list: () => tabs,
|
||||
subscribe: () => () => {},
|
||||
version: () => 1,
|
||||
}}
|
||||
send={send}
|
||||
stop={stop}
|
||||
open={open}
|
||||
/>)
|
||||
return { ui, chat, send, stop, open, renderSlot }
|
||||
function conversationSnapshot(
|
||||
composerPhase: ConversationSnapshot['composerPhase'],
|
||||
pendingPrompt: ConversationSnapshot['pendingPrompt'] = null,
|
||||
): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
|
||||
pending: [], running: false, composerPhase, removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, intent: null, pendingPrompt, lastAgentError: null,
|
||||
}
|
||||
}
|
||||
|
||||
const tab = (id: string, label: string): ViewTab => ({ id, label })
|
||||
|
||||
it('renders breadcrumb chain (useSessions-derived), meta turns, and the default chat view', () => {
|
||||
const { open } = bench([tab('chat', 'Chat'), tab('trajectory', 'Trajectory')])
|
||||
expect(screen.getByText('proj')).toBeTruthy()
|
||||
expect(screen.getByText('child')).toBeTruthy()
|
||||
expect(screen.getByText(/2 turns/)).toBeTruthy()
|
||||
expect(screen.getByTestId('view-chat')).toBeTruthy()
|
||||
// Ancestor crumb navigates; current crumb is disabled.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'proj' }))
|
||||
expect(open).toHaveBeenCalledWith('root')
|
||||
expect((screen.getByRole('button', { name: 'child' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
function mountConversation(pendingPrompt: ConversationSnapshot['pendingPrompt'] = null) {
|
||||
const root = sid('root')
|
||||
const sessions = createSnapshotStore<SessionListState>({
|
||||
ids: [root, SID],
|
||||
byId: {
|
||||
[root]: { id: root, displayTitle: 'Root', running: false, updatedAt: 1 },
|
||||
[SID]: { id: SID, displayTitle: 'Child', parentId: root, cwd: '/projects/one', running: false, updatedAt: 2 },
|
||||
},
|
||||
current: SID,
|
||||
intent: undefined,
|
||||
phase: 'ready',
|
||||
})
|
||||
|
||||
it('switches views through the store view field and falls back on unknown ids', () => {
|
||||
const { chat } = bench([tab('chat', 'Chat'), tab('trajectory', 'Trajectory')])
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
|
||||
expect(chat.store.getSnapshot().view).toBe('trajectory')
|
||||
expect(screen.getByTestId('view-trajectory')).toBeTruthy()
|
||||
cleanup()
|
||||
// A stale persisted id (its view plugin unloaded) falls to the first view.
|
||||
bench([tab('chat', 'Chat'), tab('trajectory', 'Trajectory')], 'ghost-view')
|
||||
expect(screen.getByTestId('view-chat')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders the active view through the declared ring slot with the only filter', () => {
|
||||
const { renderSlot } = bench([tab('chat', 'Chat')])
|
||||
// No owner share: views take everything from the standard kit (contract).
|
||||
expect(renderSlot).toHaveBeenCalledWith('conversation.view', {}, { only: 'chat' })
|
||||
expect(screen.getByTestId('view-chat').getAttribute('data-slot')).toBe('conversation.view')
|
||||
})
|
||||
|
||||
it('hides the tab strip with a single view; composer writes the store draft and sends it', () => {
|
||||
const { chat, send } = bench([tab('chat', 'Chat')])
|
||||
expect(screen.queryByRole('tablist')).toBeNull()
|
||||
const box = screen.getByPlaceholderText(/输入消息/)
|
||||
fireEvent.change(box, { target: { value: 'hi' } })
|
||||
// Typing goes through actions.setDraft into the shared store.
|
||||
expect(chat.store.getSnapshot().draft).toBe('hi')
|
||||
fireEvent.keyDown(box, { key: 'Enter' })
|
||||
expect(send).toHaveBeenCalledWith('hi', 'queue')
|
||||
})
|
||||
|
||||
it('dispatches the pending list to the composer chain; all-decline falls back to InputBar', () => {
|
||||
const wait = new PendingWait('question', RpcId('rq'), sid('s1'),
|
||||
{ questions: [{ id: 'mode', question: 'Choose?', options: [{ label: 'Fast' }] }] } as PendingWait<'question'>['payload'], vi.fn())
|
||||
// A matching entry takes the composer over.
|
||||
const renderSlotChain = vi.fn(() => <div>question takeover</div>) as unknown as ConversationRootProps['renderSlotChain']
|
||||
bench([tab('chat', 'Chat')], undefined, { pending: [wait] }, renderSlotChain)
|
||||
expect(screen.getByText('question takeover')).toBeTruthy()
|
||||
expect(screen.queryByPlaceholderText(/输入消息/)).toBeNull()
|
||||
// The owner dispatches the raw pending list (chain currency); routing
|
||||
// lives in entry selectors, not here.
|
||||
expect(renderSlotChain).toHaveBeenCalledWith(
|
||||
'conversation.composer',
|
||||
expect.objectContaining({
|
||||
interactions: expect.arrayContaining([expect.objectContaining({ key: 'q:rq' })]),
|
||||
}),
|
||||
expect.objectContaining({ fallback: expect.anything() }),
|
||||
)
|
||||
cleanup()
|
||||
// Zero registered entries (default all-decline stub): the fallback IS the
|
||||
// default InputBar — behavior equals the pre-chain composer.
|
||||
bench([tab('chat', 'Chat')], undefined, { pending: [wait] })
|
||||
expect(screen.getByPlaceholderText(/输入消息/)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DetailsPanel', () => {
|
||||
function benchDetails(snapshot: Partial<FakeSnapshot>, selection: SelectionTarget | null) {
|
||||
const { useSession } = fakeSession(snapshot)
|
||||
const { useSessions } = fakeSessions([])
|
||||
const chat = createChatStore().create()
|
||||
if (selection !== null) chat.actions.select(selection)
|
||||
const closeDetails = vi.fn()
|
||||
render(
|
||||
<DetailsPanel
|
||||
sessionId={sid('s1')}
|
||||
useSession={useSession}
|
||||
useSessions={useSessions}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
closeDetails={closeDetails}
|
||||
/>)
|
||||
return { closeDetails, chat }
|
||||
const workspaces = createSnapshotStore<WorkspaceListState>(workspaceState([{ ...workspace('one'), sessionIds: [SID] }]))
|
||||
const session = createSnapshotStore<ConversationSnapshot>(conversationSnapshot(
|
||||
pendingPrompt === null ? 'active' : 'blank', pendingPrompt,
|
||||
))
|
||||
const chat = createChatStore().create()
|
||||
chat.actions.setDraft('ordinary draft')
|
||||
const send = vi.fn()
|
||||
const stop = vi.fn()
|
||||
const open = vi.fn()
|
||||
const updateSessionPrompt = vi.fn()
|
||||
const retrySessionPrompt = vi.fn()
|
||||
const renderSlot = ((_key: string, _owner: object, opts?: { only?: string }) => (
|
||||
<div data-testid={`view-${opts?.only ?? 'all'}`} />
|
||||
)) as ConversationRootProps['renderSlot']
|
||||
const renderSlotChain = ((_key, _owner, opts) => opts?.fallback ?? null) as ConversationRootProps['renderSlotChain']
|
||||
const SessionProvider: ConversationRootProps['SessionProvider'] = ({ children }) => <>{children(SID)}</>
|
||||
const props: ConversationRootProps = {
|
||||
sessionId: SID,
|
||||
useSession: bindSnapshotSelector(session),
|
||||
useSessions: bindSnapshotSelector(sessions),
|
||||
useWorkspaces: bindSnapshotSelector(workspaces),
|
||||
useStore: bindSnapshotSelector(chat),
|
||||
actions: chat.actions,
|
||||
renderSlot,
|
||||
renderSlotChain,
|
||||
SessionProvider,
|
||||
views: { list: () => [{ id: 'chat', label: 'Chat' }], subscribe: () => () => {}, version: () => 1 },
|
||||
send,
|
||||
stop,
|
||||
open,
|
||||
updateSessionPrompt,
|
||||
retrySessionPrompt,
|
||||
}
|
||||
const view = render(<ConversationRoot {...props} />)
|
||||
return { view, chat, send, open, updateSessionPrompt, retrySessionPrompt }
|
||||
}
|
||||
|
||||
it('renders the selected call args and result off the shared store; close fires the injected callback', () => {
|
||||
const { closeDetails } = benchDetails({
|
||||
nodes: [{
|
||||
kind: 'tool-result', seq: 1, time: 1_000, callId: 'c1',
|
||||
call: { name: 'bash', argsRaw: '{"cmd":"ls"}' },
|
||||
callTime: 500,
|
||||
content: [{ type: 'text', text: 'file-a\nfile-b' }],
|
||||
isError: false, callView: null, resultView: null,
|
||||
}],
|
||||
}, { turnSeq: 1, callId: 'c1' })
|
||||
expect(screen.getByText('bash')).toBeTruthy()
|
||||
expect(screen.getByText(/"cmd": "ls"/)).toBeTruthy()
|
||||
expect(screen.getByText(/file-a/)).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭详情' }))
|
||||
expect(closeDetails).toHaveBeenCalledTimes(1)
|
||||
describe('ConversationRoot draft ownership', () => {
|
||||
it('keeps ordinary per-Session composer text in the chat store and selects through runtime actions', () => {
|
||||
const b = mountConversation()
|
||||
const box = b.view.getByRole('textbox')
|
||||
expect((box as HTMLTextAreaElement).value).toBe('ordinary draft')
|
||||
fireEvent.change(box, { target: { value: 'ordinary revised' } })
|
||||
expect(b.chat.store.getSnapshot().draft).toBe('ordinary revised')
|
||||
fireEvent.keyDown(box, { key: 'Enter' })
|
||||
expect(b.send).toHaveBeenCalledWith('ordinary revised', 'queue')
|
||||
fireEvent.click(b.view.getByRole('button', { name: 'Root' }))
|
||||
expect(b.open).toHaveBeenCalledWith(sid('root'))
|
||||
})
|
||||
|
||||
it('shows the empty hint without a selection and the running state for open calls', () => {
|
||||
benchDetails({ runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{}', turn: 1, step: 1, time: 1_000, callView: null }] }, null)
|
||||
expect(screen.getByText(/点击消息流中的工具行/)).toBeTruthy()
|
||||
cleanup()
|
||||
benchDetails({ runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{}', turn: 1, step: 1, time: 1_000, callView: null }] }, { turnSeq: 1, callId: 'c9' })
|
||||
expect(screen.getByText('运行中…')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('reports an out-of-window call distinctly', () => {
|
||||
benchDetails({}, { turnSeq: 1, callId: 'ghost' })
|
||||
expect(screen.getByText(/不在当前窗口内/)).toBeTruthy()
|
||||
it('reads a retained prompt from useSession and edits/retries it through the scoped Session', () => {
|
||||
const b = mountConversation({
|
||||
workspaceId: wid('one'), text: 'retry me', phase: 'failed',
|
||||
retry: 'send', error: 'offline',
|
||||
})
|
||||
const box = b.view.getByRole('textbox')
|
||||
expect((box as HTMLTextAreaElement).value).toBe('retry me')
|
||||
expect(b.view.getByRole('alert').textContent).toBe('Message send failed: offline')
|
||||
fireEvent.change(box, { target: { value: 'revised prompt' } })
|
||||
expect(b.updateSessionPrompt).toHaveBeenCalledWith('revised prompt')
|
||||
expect(b.chat.store.getSnapshot().draft).toBe('ordinary draft')
|
||||
fireEvent.keyDown(box, { key: 'Enter' })
|
||||
expect(b.retrySessionPrompt).toHaveBeenCalledOnce()
|
||||
expect(b.send).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -17,9 +17,13 @@ import { AppFrame } from '@deepseek-ai/dsh-client-ui-layout/src/client/AppFrame.
|
||||
import type { AppFrameProps } from '@deepseek-ai/dsh-client-ui-layout/src/client/AppFrame.tsx'
|
||||
import { SIDEBAR_COLLAPSED } from '@deepseek-ai/dsh-client-ui-layout/src/client/columns.ts'
|
||||
import { createLayoutStore } from '@deepseek-ai/dsh-client-ui-layout/src/client/stores.ts'
|
||||
import type {
|
||||
SessionId, SessionListState, WorkspaceId, WorkspaceListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
// Session-mode switch for the SessionProvider stub prop.
|
||||
const sessionMode = { current: true }
|
||||
const baselinesReady = { current: true }
|
||||
|
||||
// Render-prop contract stub fed through the standard seat prop (the renderer
|
||||
// injects the real one in production): session mode runs children(id), empty
|
||||
@@ -59,13 +63,31 @@ function mountFrame() {
|
||||
if (key === 'details') return <div data-testid="details-content" />
|
||||
return <div data-testid="empty-content" />
|
||||
}) as AppFrameProps['renderSlot']
|
||||
const useSessions = ((sel: (s: unknown) => unknown) => sel({ ids: [], byId: {} })) as never
|
||||
const sessionId = 's-test' as SessionId
|
||||
const workspaceId = 'w-test' as WorkspaceId
|
||||
const sessionState = {
|
||||
ids: sessionMode.current ? [sessionId] : [],
|
||||
byId: sessionMode.current
|
||||
? { [sessionId]: { id: sessionId, displayTitle: 'Test', running: false, updatedAt: 1 } }
|
||||
: {},
|
||||
current: sessionMode.current ? sessionId : undefined,
|
||||
phase: 'ready',
|
||||
intent: sessionMode.current
|
||||
? undefined
|
||||
: { sessionId: 'intent' as SessionId, target: { kind: 'workspace', workspaceId }, prompt: '', phase: 'connecting' },
|
||||
} as SessionListState
|
||||
const useSessions = ((sel: (s: SessionListState) => unknown) => sel(sessionState)) as never
|
||||
const workspaceState: WorkspaceListState = {
|
||||
items: [], intent: undefined, state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: baselinesReady.current, recentWorkspaceId: undefined,
|
||||
}
|
||||
const utils = render(
|
||||
<AppFrame
|
||||
useStore={hookOf(instance) as never}
|
||||
actions={instance.actions}
|
||||
renderSlot={renderSlot}
|
||||
useSessions={useSessions}
|
||||
useWorkspaces={((sel: (s: WorkspaceListState) => unknown) => sel(workspaceState)) as never}
|
||||
SessionProvider={SessionProviderStub}
|
||||
/>,
|
||||
)
|
||||
@@ -91,6 +113,7 @@ function drag(handle: Element, fromX: number, toX: number): void {
|
||||
beforeEach(() => {
|
||||
frameWidth = 1920
|
||||
sessionMode.current = true
|
||||
baselinesReady.current = true
|
||||
localStorage.clear() // the layout store persists; instances must not bleed across tests
|
||||
vi.useFakeTimers()
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
@@ -131,13 +154,22 @@ describe('AppFrame', () => {
|
||||
expect(slotCalls.find((c) => c.key === 'details')!.props).toEqual({})
|
||||
})
|
||||
|
||||
it('renders the empty branch through conversation.empty when no session is current', () => {
|
||||
it('keeps a connecting page-local Session intent in conversation.empty', () => {
|
||||
sessionMode.current = false
|
||||
const { slotCalls, getByTestId, queryByTestId } = mountFrame()
|
||||
expect(getByTestId('empty-content')).toBeTruthy()
|
||||
expect(queryByTestId('center-content')).toBeNull()
|
||||
expect(slotCalls.map((c) => c.key)).toContain('conversation.empty')
|
||||
expect(slotCalls.map((c) => c.key)).not.toContain('conversation')
|
||||
expect(slotCalls.find((c) => c.key === 'conversation.empty')!.props).toEqual({})
|
||||
})
|
||||
|
||||
it('keeps the loading branch until both object-layer baselines are ready', () => {
|
||||
baselinesReady.current = false
|
||||
const { slotCalls, getByRole } = mountFrame()
|
||||
expect(getByRole('status').textContent).toContain('Loading workspaces and sessions')
|
||||
expect(slotCalls.map((c) => c.key)).not.toContain('conversation')
|
||||
expect(slotCalls.map((c) => c.key)).not.toContain('conversation.empty')
|
||||
})
|
||||
|
||||
it('sidebar slot receives live concession output as owner props', () => {
|
||||
|
||||
@@ -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() },
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* (one icon each, same top-down order) fading in as the slide ends. Rail
|
||||
* search expands and focuses the search box.
|
||||
*/
|
||||
import { Fragment, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
BrandWordmark, FishLogo,
|
||||
@@ -15,9 +15,10 @@ import {
|
||||
IconProjectAddOutline16, IconSearchOutline16, IconSettingsOutline14,
|
||||
Menu, Tooltip,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SidebarRootComponentProps } from './contract/slots.ts'
|
||||
import { deriveRows } from './tree.ts'
|
||||
import { ProjectRowItem, SessionRowItem } from './Rows.tsx'
|
||||
import { deriveGroups, UNGROUPED_KEY } from './tree.ts'
|
||||
import { IntentRowItem, ProjectRowItem, SessionNodeItem } from './Rows.tsx'
|
||||
import css from './SidebarRoot.module.css'
|
||||
|
||||
/** Wide-content unmount delay; matches the 150ms wide-content fade-out. */
|
||||
@@ -27,7 +28,7 @@ const COLLAPSE_SETTLE_MS = 150
|
||||
const EXPAND_SLIDE_MS = 300
|
||||
|
||||
const GROUP_BY_ITEMS = [
|
||||
{ id: 'workspace', label: 'WorkSpace' },
|
||||
{ id: 'workspace', label: 'Workspace' },
|
||||
// Only workspace grouping is implemented.
|
||||
{ id: 'update', label: 'Update', disabled: true },
|
||||
{ id: 'status', label: 'Status', disabled: true },
|
||||
@@ -63,62 +64,74 @@ function GroupByMenu() {
|
||||
)
|
||||
}
|
||||
|
||||
type SessionTreeProps = Pick<SidebarRootComponentProps, 'useSessions' | 'onOpen' | 'onCreate'> & {
|
||||
type SessionTreeProps = Pick<
|
||||
SidebarRootComponentProps,
|
||||
'useSessions' | 'startSession' | 'open'
|
||||
> & {
|
||||
workspaces: readonly WorkspaceView[]
|
||||
/** Live search filter owned by the root (the query outlives the tree). */
|
||||
query: string
|
||||
}
|
||||
|
||||
/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */
|
||||
function SessionTree({ useSessions, onOpen, onCreate, query }: SessionTreeProps) {
|
||||
function SessionTree({ useSessions, startSession, open, workspaces, query }: SessionTreeProps) {
|
||||
const list = useSessions((s) => s)
|
||||
// Selection belongs to the sessions snapshot, not layout state.
|
||||
const current = useSessions((s) => s.current)
|
||||
const current = list.current
|
||||
const [expandedProjects, setExpandedProjects] = useState<string[]>([])
|
||||
const [expandedSessions, setExpandedSessions] = useState<string[]>([])
|
||||
const rows = useMemo(
|
||||
() => deriveRows(list, { expandedProjects, expandedSessions, query }),
|
||||
[list, expandedProjects, expandedSessions, query],
|
||||
// Re-expand when publication moves the selected intent into a real Workspace.
|
||||
const intent = list.intent
|
||||
const intentWorkspaceId = intent?.target.kind === 'workspace'
|
||||
? intent.target.workspaceId
|
||||
: undefined
|
||||
const currentGroup = current === undefined
|
||||
? undefined
|
||||
: intent?.sessionId === current
|
||||
? intentWorkspaceId
|
||||
: (workspaces.find(w => w.sessionIds.includes(current))?.workspaceId as string | undefined)
|
||||
?? UNGROUPED_KEY
|
||||
useEffect(() => {
|
||||
if (current === undefined || currentGroup === undefined) return
|
||||
setExpandedProjects((l) => (l.includes(currentGroup) ? l : [...l, currentGroup]))
|
||||
}, [current, currentGroup])
|
||||
const groups = useMemo(
|
||||
() => deriveGroups(list, workspaces, { expandedProjects, expandedSessions, query }),
|
||||
[list, workspaces, expandedProjects, expandedSessions, query],
|
||||
)
|
||||
const now = Date.now()
|
||||
|
||||
// Presentational lookup (not tree derivation): the group holding the
|
||||
// selected session gets the active folder; only expanded groups can show it.
|
||||
let activeGroup: string | undefined
|
||||
if (current !== undefined) {
|
||||
for (const row of rows) {
|
||||
if (row.type === 'session' && row.id === current) { activeGroup = row.groupKey; break }
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx(css.treeBody, css.wide)}>
|
||||
<div className={css.list} role="tree" aria-label="Sessions">
|
||||
{rows.length === 0 && (
|
||||
{groups.length === 0 && (
|
||||
<div className={css.empty}>{query === '' ? 'No sessions yet' : 'No matches'}</div>
|
||||
)}
|
||||
{rows.map((row, i) => row.type === 'project'
|
||||
? (
|
||||
<Fragment key={`p:${row.key}`}>
|
||||
{/* Batch separator: a project row closing an expanded session run (figma 133:7661). */}
|
||||
{i > 0 && rows[i - 1]!.type === 'session' && <span className={css.batchGap} />}
|
||||
<ProjectRowItem
|
||||
row={row}
|
||||
active={row.key === activeGroup}
|
||||
onToggle={() => { setExpandedProjects((l) => toggled(l, row.key)) }}
|
||||
onCreate={() => { onCreate(row.cwd) }}
|
||||
/>
|
||||
</Fragment>
|
||||
)
|
||||
: (
|
||||
<SessionRowItem
|
||||
key={row.id}
|
||||
row={row}
|
||||
selected={row.id === current}
|
||||
{groups.map(group => (
|
||||
// Group section: header row + expanded session subtree. The
|
||||
// inter-group breathing room (former flat-list batch separator)
|
||||
// is the section's own margin (SidebarRoot.module.css).
|
||||
<div key={group.key} className={css.groupSection}>
|
||||
<ProjectRowItem
|
||||
group={group}
|
||||
onToggle={() => { setExpandedProjects((l) => toggled(l, group.key)) }}
|
||||
onCreate={() => {
|
||||
if (group.workspaceId !== undefined) startSession(group.workspaceId)
|
||||
}}
|
||||
/>
|
||||
{group.intentHere && <IntentRowItem />}
|
||||
{group.sessions.map(node => (
|
||||
<SessionNodeItem
|
||||
key={node.id}
|
||||
node={node}
|
||||
depth={0}
|
||||
currentId={current}
|
||||
now={now}
|
||||
onOpen={() => { onOpen(row.id) }}
|
||||
onToggle={() => { setExpandedSessions((l) => toggled(l, row.id)) }}
|
||||
onOpen={open}
|
||||
onToggle={(id) => { setExpandedSessions((l) => toggled(l, id)) }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<span className={css.fade} />
|
||||
</div>
|
||||
@@ -130,11 +143,27 @@ function SessionTree({ useSessions, onOpen, onCreate, query }: SessionTreeProps)
|
||||
* @param props - composed slot props (runtime share + injected callbacks, contract/slots.ts).
|
||||
* @returns the sidebar element tree.
|
||||
*/
|
||||
export function SidebarRoot({ collapsed, width, useSessions, onOpen, onCreate, onToggleSidebar }: SidebarRootComponentProps) {
|
||||
export function SidebarRoot({
|
||||
collapsed,
|
||||
width,
|
||||
useSessions,
|
||||
useWorkspaces,
|
||||
startSession,
|
||||
open,
|
||||
toggleSidebar,
|
||||
renderSlot,
|
||||
}: SidebarRootComponentProps) {
|
||||
const workspaces = useWorkspaces(state => state.items)
|
||||
// The query outlives the tree and the input (both wide-only) so collapsing
|
||||
// does not silently drop an in-progress filter.
|
||||
const [query, setQuery] = useState('')
|
||||
const searchInput = useRef<HTMLInputElement | null>(null)
|
||||
// Section-header + opens the workspace picker (same popover in wide and
|
||||
// rail states; the hole sits beside the button and opens rightward).
|
||||
const [wsPickerOpen, setWsPickerOpen] = useState(false)
|
||||
// Placement anchor for the picker popover: the slot span renders elsewhere
|
||||
// in the DOM, so the picker positions off this button's rect.
|
||||
const wsPlusRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
// Wide content stays mounted while the collapse animates (fading via
|
||||
// .collapsed .wide), unmounts at settle, and remounts right away on expand.
|
||||
@@ -188,7 +217,7 @@ export function SidebarRoot({ collapsed, width, useSessions, onOpen, onCreate, o
|
||||
type="button"
|
||||
className={clsx(css.iconButton, css.toggle)}
|
||||
aria-label={collapsed ? 'Open sidebar' : 'Collapse sidebar'}
|
||||
onClick={() => { onToggleSidebar() }}
|
||||
onClick={() => { toggleSidebar() }}
|
||||
>
|
||||
{!wide && <FishLogo className={css.railFish} size={24} />}
|
||||
{/* Rail icons render at 18 (figma rail spec); expanded keeps the glyph-native sizes. */}
|
||||
@@ -202,7 +231,7 @@ export function SidebarRoot({ collapsed, width, useSessions, onOpen, onCreate, o
|
||||
type="button"
|
||||
className={css.newSession}
|
||||
aria-label="New session"
|
||||
onClick={() => { onCreate() }}
|
||||
onClick={() => { startSession() }}
|
||||
>
|
||||
<IconNewChatOutline16 size={wide ? 14 : 18} />
|
||||
{wide && <span className={clsx(css.newSessionLabel, css.wide)}>New Session</span>}
|
||||
@@ -210,18 +239,29 @@ export function SidebarRoot({ collapsed, width, useSessions, onOpen, onCreate, o
|
||||
</Tooltip>
|
||||
|
||||
<div className={css.sectionHeader}>
|
||||
{wide && <span className={clsx(css.sectionLabel, css.wide)}>WorkSpace</span>}
|
||||
{wide && <span className={clsx(css.sectionLabel, css.wide)}>Workspaces</span>}
|
||||
{wide && <GroupByMenu />}
|
||||
<Tooltip label="New Workspace" disabled={wide}>
|
||||
<button
|
||||
ref={wsPlusRef}
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label="New workspace"
|
||||
onClick={() => { onCreate() }}
|
||||
aria-label="Create workspace"
|
||||
onClick={() => { setWsPickerOpen(v => !v) }}
|
||||
>
|
||||
<IconProjectAddOutline16 size={wide ? 16 : 18} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{/* Picker hole beside the + (same site in wide and rail states). */}
|
||||
{renderSlot('sidebar.workspace', {
|
||||
open: wsPickerOpen,
|
||||
anchorRef: wsPlusRef,
|
||||
onPick: (workspaceId) => {
|
||||
setWsPickerOpen(false)
|
||||
startSession(workspaceId)
|
||||
},
|
||||
onClose: () => { setWsPickerOpen(false) },
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Expanded: the row is a click-to-focus field (the leading icon is
|
||||
@@ -233,7 +273,7 @@ export function SidebarRoot({ collapsed, width, useSessions, onOpen, onCreate, o
|
||||
className={css.searchButton}
|
||||
aria-label="Search sessions"
|
||||
tabIndex={collapsed ? 0 : -1}
|
||||
onClick={() => { if (collapsed) { setSearchOnExpand(true); onToggleSidebar() } }}
|
||||
onClick={() => { if (collapsed) { setSearchOnExpand(true); toggleSidebar() } }}
|
||||
>
|
||||
<IconSearchOutline16 size={wide ? 14 : 18} />
|
||||
</button>
|
||||
@@ -263,7 +303,15 @@ export function SidebarRoot({ collapsed, width, useSessions, onOpen, onCreate, o
|
||||
{/* Always-mounted seat: its flex slot pins the foot to the bottom in
|
||||
both states while the tree itself is wide-only. */}
|
||||
<div className={css.listArea}>
|
||||
{wide && <SessionTree useSessions={useSessions} onOpen={onOpen} onCreate={onCreate} query={query} />}
|
||||
{wide && (
|
||||
<SessionTree
|
||||
useSessions={useSessions}
|
||||
workspaces={workspaces}
|
||||
startSession={startSession}
|
||||
open={open}
|
||||
query={query}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={css.foot} role="button" tabIndex={0} aria-label="Settings">
|
||||
|
||||
@@ -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,36 +1,30 @@
|
||||
/** Registers the sidebar UI into the layout-owned slot. */
|
||||
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SidebarRootInjected } from './contract/slots.ts'
|
||||
import { SidebarRoot } from './SidebarRoot.tsx'
|
||||
|
||||
export type { SidebarRootComponentProps, SidebarRootInjected } from './contract/slots.ts'
|
||||
export type { SidebarRootComponentProps, SidebarRootInjected, SidebarWorkspaceOwnerProps } from './contract/slots.ts'
|
||||
|
||||
/** Services required by the sidebar plugin. */
|
||||
export const inject = ['slots', 'layout', 'sessions']
|
||||
export const inject = ['slots', 'layout', 'sessions', 'workspaces']
|
||||
|
||||
/** Registers the sidebar component and its service callbacks.
|
||||
* @param ctx - Client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const injectProps = (): SidebarRootInjected => ({
|
||||
// Selection belongs to the sessions service; layout owns only panel geometry.
|
||||
onOpen: (id) => { ctx.sessions.open(id) },
|
||||
onCreate: (cwd) => {
|
||||
// Top-level New Session / New Workspace: clear selection so AppFrame
|
||||
// shows conversation.empty (EmptyState + shared InputBar). Per-project
|
||||
// "+" still create-then-opens into that cwd until workspace seeding
|
||||
// reaches the empty-state picker.
|
||||
if (cwd === undefined) {
|
||||
ctx.sessions.clear()
|
||||
return
|
||||
}
|
||||
void ctx.sessions.create({ cwd })
|
||||
.then((id: SessionId) => { ctx.sessions.open(id) })
|
||||
},
|
||||
onToggleSidebar: () => { ctx.layout.toggleSidebar() },
|
||||
startSession: (workspaceId, prompt) => { ctx.workspaces.startSession(workspaceId, prompt) },
|
||||
open: (sessionId) => { ctx.sessions.open(sessionId) },
|
||||
toggleSidebar: () => { ctx.layout.toggleSidebar() },
|
||||
})
|
||||
ctx.effect(
|
||||
() => ctx.slots.register({ name: 'sidebar', inject: injectProps }, SidebarRoot),
|
||||
() => ctx.slots.register({
|
||||
name: 'sidebar',
|
||||
// SidebarRoot owns this picker site; ui-workspace registers the shared
|
||||
// picker that selects a Host Workspace for a frontend Session Intent.
|
||||
children: { 'sidebar.workspace': { kind: 'single', scope: 'root' } },
|
||||
inject: injectProps,
|
||||
}, SidebarRoot),
|
||||
'ui-sidebar: slot registration',
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,41 +1,46 @@
|
||||
/** Pure derivation of flat sidebar rows from sessions and local view state. */
|
||||
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
/**
|
||||
* Derives the sidebar tree from Host Workspace order and membership.
|
||||
* Unassigned Sessions trail under Ungrouped; only Intents targeting real Workspaces render.
|
||||
*/
|
||||
import type { SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** Group key for sessions without a project directory. */
|
||||
/** Group key for Sessions outside every Workspace. */
|
||||
export const UNGROUPED_KEY = ''
|
||||
|
||||
/** Display label for the ungrouped project row. */
|
||||
/** Display label for the ungrouped bucket row. */
|
||||
export const UNGROUPED_LABEL = 'Ungrouped'
|
||||
|
||||
/** Project (workspace) row: 54px, two lines (label + session count). */
|
||||
export interface ProjectRow {
|
||||
type: 'project'
|
||||
/** Group key: the cwd, or {@link UNGROUPED_KEY}. */
|
||||
key: string
|
||||
cwd: string | undefined
|
||||
label: string
|
||||
/** Total sessions in the group, including hidden ones. */
|
||||
sessionCount: number
|
||||
expanded: boolean
|
||||
}
|
||||
|
||||
/** Session row: 34px single line; depth drives the 22px indent steps. */
|
||||
export interface SessionRow {
|
||||
type: 'session'
|
||||
/** One session node of a group's visible tree (34px row; children render indented one step). */
|
||||
export interface SessionNode {
|
||||
id: SessionId
|
||||
/** Owning project group key (selection -> active-folder lookup). */
|
||||
groupKey: string
|
||||
title: string
|
||||
/** 0 = directly under the project row. */
|
||||
depth: number
|
||||
/** Visible children, already expansion/search-filtered (empty when folded). */
|
||||
children: readonly SessionNode[]
|
||||
/** The session HAS children in the data (the twist renders even while folded). */
|
||||
hasChildren: boolean
|
||||
expanded: boolean
|
||||
running: boolean
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
/** One flat sidebar list row. */
|
||||
export type SidebarRow = ProjectRow | SessionRow
|
||||
/** One workspace group section: header row facts + the visible session tree. */
|
||||
export interface GroupNode {
|
||||
/** Group key: the workspace id or {@link UNGROUPED_KEY}. */
|
||||
key: string
|
||||
/** Backing Workspace id; absent only for the ungrouped bucket. */
|
||||
workspaceId: WorkspaceId | undefined
|
||||
cwd: string | undefined
|
||||
label: string
|
||||
/** Total sessions in the group, including hidden ones. */
|
||||
sessionCount: number
|
||||
expanded: boolean
|
||||
/** The group contains the selected session (active folder tint; supplied here so the renderer never scans). */
|
||||
containsCurrent: boolean
|
||||
/** The frontend Session Intent points here: render one "New session" row. */
|
||||
intentHere: boolean
|
||||
/** Visible roots (empty while the group is folded). */
|
||||
sessions: readonly SessionNode[]
|
||||
}
|
||||
|
||||
/** Viewing state consumed by the derivation — the component's local useState arrays, taken as-is. */
|
||||
export interface TreeView {
|
||||
@@ -46,17 +51,18 @@ export interface TreeView {
|
||||
|
||||
interface Group {
|
||||
key: string
|
||||
workspaceId: WorkspaceId | undefined
|
||||
cwd: string | undefined
|
||||
label: string
|
||||
summaries: Map<SessionId, SessionSummary>
|
||||
roots: SessionId[]
|
||||
children: Map<SessionId, SessionId[]>
|
||||
latest: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Project display label: basename of the group directory.
|
||||
* @param cwd - project directory, or undefined for the ungrouped bucket.
|
||||
* Directory display label: basename of the path (both separators accepted).
|
||||
* Ungrouped-bucket fallback for surfaces without a workspace title.
|
||||
* @param cwd - directory path, or undefined for the ungrouped bucket.
|
||||
* @returns basename, the raw cwd when it has no basename, or the ungrouped label.
|
||||
*/
|
||||
export function projectLabel(cwd: string | undefined): string {
|
||||
@@ -71,32 +77,32 @@ function byRecency(a: SessionSummary, b: SessionSummary): number {
|
||||
return a.id < b.id ? -1 : 1
|
||||
}
|
||||
|
||||
function groupByCwd(list: SessionListState): Group[] {
|
||||
const byKey = new Map<string, SessionSummary[]>()
|
||||
for (const id of list.ids) {
|
||||
const s = list.byId[id]
|
||||
if (s === undefined) continue
|
||||
const key = s.cwd ?? UNGROUPED_KEY
|
||||
const members = byKey.get(key)
|
||||
if (members === undefined) byKey.set(key, [s])
|
||||
else members.push(s)
|
||||
}
|
||||
const groups: Group[] = []
|
||||
for (const [key, members] of byKey) {
|
||||
const summaries = new Map(members.map(m => [m.id, m]))
|
||||
const children = new Map<SessionId, SessionId[]>()
|
||||
const roots: SessionSummary[] = []
|
||||
for (const m of members) {
|
||||
// A session is a tree child only when its parent lives in the same
|
||||
// group; cross-group or unknown parents degrade to group roots.
|
||||
if (m.parentId !== undefined && m.parentId !== m.id && summaries.has(m.parentId)) {
|
||||
const kids = children.get(m.parentId)
|
||||
if (kids === undefined) children.set(m.parentId, [m.id])
|
||||
else kids.push(m.id)
|
||||
} else {
|
||||
roots.push(m)
|
||||
}
|
||||
/** Build one group's parent/child tree from an ordered member list. */
|
||||
function buildGroup(
|
||||
key: string,
|
||||
workspaceId: WorkspaceId | undefined,
|
||||
cwd: string | undefined,
|
||||
label: string,
|
||||
members: readonly SessionSummary[],
|
||||
order: 'account' | 'recency',
|
||||
): Group {
|
||||
const summaries = new Map(members.map(m => [m.id, m]))
|
||||
const children = new Map<SessionId, SessionId[]>()
|
||||
const roots: SessionSummary[] = []
|
||||
for (const m of members) {
|
||||
// A session is a tree child only when its parent lives in the same
|
||||
// group; cross-group or unknown parents degrade to group roots.
|
||||
if (m.parentId !== undefined && m.parentId !== m.id && summaries.has(m.parentId)) {
|
||||
const kids = children.get(m.parentId)
|
||||
if (kids === undefined) children.set(m.parentId, [m.id])
|
||||
else kids.push(m.id)
|
||||
} else {
|
||||
roots.push(m)
|
||||
}
|
||||
}
|
||||
// Workspace order is the member iteration order (workspace.sessionIds), so
|
||||
// attached groups keep insertion order; Ungrouped sorts by recency.
|
||||
if (order === 'recency') {
|
||||
roots.sort(byRecency)
|
||||
for (const kids of children.values()) {
|
||||
kids.sort((a, b) => {
|
||||
@@ -107,48 +113,63 @@ function groupByCwd(list: SessionListState): Group[] {
|
||||
return byRecency(sa, sb)
|
||||
})
|
||||
}
|
||||
const rootIds = roots.map(r => r.id)
|
||||
// parentId cycles (host bug) leave members unreachable from any root;
|
||||
// surface them as extra roots — the flatten walk's visited set stops
|
||||
// loops. Each node sits in at most one kids list and roots have no
|
||||
// in-group parent, so the scan pushes every reachable node exactly once.
|
||||
const reachable = new Set<SessionId>(rootIds)
|
||||
const stack = [...rootIds]
|
||||
while (stack.length > 0) {
|
||||
const top = stack.pop()
|
||||
/* v8 ignore next -- unreachable: the loop condition guarantees a non-empty stack. */
|
||||
if (top === undefined) break
|
||||
for (const kid of children.get(top) ?? []) {
|
||||
reachable.add(kid)
|
||||
stack.push(kid)
|
||||
}
|
||||
}
|
||||
for (const m of [...members].sort(byRecency)) {
|
||||
if (!reachable.has(m.id)) rootIds.push(m.id)
|
||||
}
|
||||
let latest = 0
|
||||
for (const m of members) latest = Math.max(latest, m.updatedAt)
|
||||
groups.push({
|
||||
key,
|
||||
cwd: key === UNGROUPED_KEY ? undefined : key,
|
||||
label: projectLabel(key === UNGROUPED_KEY ? undefined : key),
|
||||
summaries,
|
||||
roots: rootIds,
|
||||
children,
|
||||
latest,
|
||||
})
|
||||
}
|
||||
groups.sort((a, b) => b.latest - a.latest || (a.label < b.label ? -1 : a.label > b.label ? 1 : 0))
|
||||
const rootIds = roots.map(r => r.id)
|
||||
// parentId cycles (host bug) leave members unreachable from any root;
|
||||
// surface them as extra roots — the flatten walk's visited set stops
|
||||
// loops. Each node sits in at most one kids list and roots have no
|
||||
// in-group parent, so the scan pushes every reachable node exactly once.
|
||||
const reachable = new Set<SessionId>(rootIds)
|
||||
const stack = [...rootIds]
|
||||
while (stack.length > 0) {
|
||||
const top = stack.pop()
|
||||
/* v8 ignore next -- unreachable: the loop condition guarantees a non-empty stack. */
|
||||
if (top === undefined) break
|
||||
for (const kid of children.get(top) ?? []) {
|
||||
reachable.add(kid)
|
||||
stack.push(kid)
|
||||
}
|
||||
}
|
||||
for (const m of members) {
|
||||
if (!reachable.has(m.id)) rootIds.push(m.id)
|
||||
}
|
||||
return { key, workspaceId, cwd, label, summaries, roots: rootIds, children }
|
||||
}
|
||||
|
||||
/**
|
||||
* Group Sessions by Host Workspace: one group per entity in stable Host
|
||||
* order, with members resolved from sessionIds in their stored order. Sessions
|
||||
* outside every Workspace trail in the recency-ordered Ungrouped bucket.
|
||||
*/
|
||||
function groupByWorkspace(list: SessionListState, workspaces: readonly WorkspaceView[]): Group[] {
|
||||
const groups: Group[] = []
|
||||
const accounted = new Set<SessionId>()
|
||||
for (const workspace of workspaces) {
|
||||
const members: SessionSummary[] = []
|
||||
for (const id of workspace.sessionIds) {
|
||||
const summary = list.byId[id]
|
||||
if (summary === undefined) continue // account may lead the list pull; the row appears when the summary lands
|
||||
members.push(summary)
|
||||
accounted.add(id)
|
||||
}
|
||||
groups.push(buildGroup(
|
||||
workspace.workspaceId, workspace.workspaceId, workspace.path, workspace.title, members, 'account',
|
||||
))
|
||||
}
|
||||
const stray = list.ids
|
||||
.map(id => list.byId[id])
|
||||
.filter((s): s is SessionSummary => s !== undefined && !accounted.has(s.id))
|
||||
if (stray.length > 0) {
|
||||
groups.push(buildGroup(UNGROUPED_KEY, undefined, undefined, UNGROUPED_LABEL, stray, 'recency'))
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
function sessionRow(g: Group, s: SessionSummary, depth: number, hasChildren: boolean, expanded: boolean): SessionRow {
|
||||
function sessionNode(s: SessionSummary, children: readonly SessionNode[], hasChildren: boolean, expanded: boolean): SessionNode {
|
||||
return {
|
||||
type: 'session',
|
||||
id: s.id,
|
||||
groupKey: g.key,
|
||||
title: s.displayTitle,
|
||||
depth,
|
||||
children,
|
||||
hasChildren,
|
||||
expanded,
|
||||
running: s.running,
|
||||
@@ -156,20 +177,20 @@ function sessionRow(g: Group, s: SessionSummary, depth: number, hasChildren: boo
|
||||
}
|
||||
}
|
||||
|
||||
function flattenVisible(g: Group, expandedSessions: ReadonlySet<string>, rows: SidebarRow[]): void {
|
||||
function buildVisible(g: Group, expandedSessions: ReadonlySet<string>): SessionNode[] {
|
||||
const visited = new Set<SessionId>()
|
||||
const walk = (id: SessionId, depth: number): void => {
|
||||
if (visited.has(id)) return
|
||||
const walk = (id: SessionId): SessionNode | null => {
|
||||
if (visited.has(id)) return null
|
||||
visited.add(id)
|
||||
const s = g.summaries.get(id)
|
||||
/* v8 ignore next -- unreachable: walked ids come from the grouped summaries. */
|
||||
if (s === undefined) return
|
||||
if (s === undefined) return null
|
||||
const kids = g.children.get(id) ?? []
|
||||
const expanded = expandedSessions.has(id)
|
||||
rows.push(sessionRow(g, s, depth, kids.length > 0, expanded))
|
||||
if (expanded) for (const kid of kids) walk(kid, depth + 1)
|
||||
const children = expanded ? kids.map(walk).filter((n): n is SessionNode => n !== null) : []
|
||||
return sessionNode(s, children, kids.length > 0, expanded)
|
||||
}
|
||||
for (const root of g.roots) walk(root, 0)
|
||||
return g.roots.map(walk).filter((n): n is SessionNode => n !== null)
|
||||
}
|
||||
|
||||
/** Matched sessions plus their ancestor chains (forced visible under search). */
|
||||
@@ -186,66 +207,98 @@ function searchVisible(g: Group, q: string): Set<SessionId> {
|
||||
return visible
|
||||
}
|
||||
|
||||
function flattenSearch(g: Group, visible: ReadonlySet<SessionId>, rows: SidebarRow[]): void {
|
||||
function buildSearch(g: Group, visible: ReadonlySet<SessionId>): SessionNode[] {
|
||||
const visited = new Set<SessionId>()
|
||||
const walk = (id: SessionId, depth: number): void => {
|
||||
if (visited.has(id) || !visible.has(id)) return
|
||||
const walk = (id: SessionId): SessionNode | null => {
|
||||
if (visited.has(id) || !visible.has(id)) return null
|
||||
visited.add(id)
|
||||
const s = g.summaries.get(id)
|
||||
/* v8 ignore next -- unreachable: walked ids come from the grouped summaries. */
|
||||
if (s === undefined) return
|
||||
if (s === undefined) return null
|
||||
const kids = (g.children.get(id) ?? []).filter(kid => visible.has(kid))
|
||||
rows.push(sessionRow(g, s, depth, kids.length > 0, kids.length > 0))
|
||||
for (const kid of kids) walk(kid, depth + 1)
|
||||
const children = kids.map(walk).filter((n): n is SessionNode => n !== null)
|
||||
return sessionNode(s, children, kids.length > 0, kids.length > 0)
|
||||
}
|
||||
for (const root of g.roots) walk(root, 0)
|
||||
return g.roots.map(walk).filter((n): n is SessionNode => n !== null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the flat sidebar row list.
|
||||
* Derive the nested sidebar group structure.
|
||||
*
|
||||
* Normal mode: every project row shows; sessions show under expanded
|
||||
* projects, descending only into expanded sessions. Search mode (non-blank
|
||||
* query, case-insensitive display-title substring): expansion state is ignored —
|
||||
* Normal mode: every group shows; sessions populate under expanded groups,
|
||||
* descending only into expanded sessions. A frontend Session Intent targeting
|
||||
* a real Workspace marks that group `intentHere` and forces it expanded. Search mode (non-blank query,
|
||||
* case-insensitive display-title substring): expansion state is ignored —
|
||||
* matched sessions and their ancestor chains are forced visible, groups
|
||||
* without a display-title or label hit are dropped, and a label-only hit keeps the
|
||||
* bare project row.
|
||||
* @param list - sessions list snapshot.
|
||||
* without a display-title or label hit are dropped, a label-only hit keeps
|
||||
* the bare group header, and Intent rows do not participate.
|
||||
* @param list - sessions list snapshot (`current` feeds containsCurrent).
|
||||
* @param workspaces - real workspaces in stable Host order.
|
||||
* @param view - local expansion arrays and search query.
|
||||
* @returns rows in render order.
|
||||
* @returns group sections in render order.
|
||||
*/
|
||||
export function deriveRows(list: SessionListState, view: TreeView): SidebarRow[] {
|
||||
export function deriveGroups(
|
||||
list: SessionListState,
|
||||
workspaces: readonly WorkspaceView[],
|
||||
view: TreeView,
|
||||
): GroupNode[] {
|
||||
const q = view.query.trim().toLowerCase()
|
||||
const expandedProjects = new Set(view.expandedProjects)
|
||||
const expandedSessions = new Set(view.expandedSessions)
|
||||
const rows: SidebarRow[] = []
|
||||
for (const g of groupByCwd(list)) {
|
||||
const intent = list.intent
|
||||
const intentWorkspaceId = intent?.target.kind === 'workspace'
|
||||
? intent.target.workspaceId
|
||||
: undefined
|
||||
const currentAccount = list.current === undefined
|
||||
? undefined
|
||||
: workspaces.find(w => w.sessionIds.includes(list.current as SessionId))?.workspaceId as string | undefined
|
||||
const currentGroup = list.current === undefined
|
||||
? undefined
|
||||
: intent?.sessionId === list.current
|
||||
? intentWorkspaceId
|
||||
: currentAccount ?? UNGROUPED_KEY
|
||||
const groups: GroupNode[] = []
|
||||
for (const g of groupByWorkspace(list, workspaces)) {
|
||||
const hasIntent = intentWorkspaceId !== undefined
|
||||
&& g.workspaceId !== undefined && intentWorkspaceId === g.workspaceId
|
||||
const intentHere = q === '' && hasIntent
|
||||
if (q === '') {
|
||||
const expanded = expandedProjects.has(g.key)
|
||||
rows.push({ type: 'project', key: g.key, cwd: g.cwd, label: g.label, sessionCount: g.summaries.size, expanded })
|
||||
if (expanded) flattenVisible(g, expandedSessions, rows)
|
||||
const expanded = intentHere || expandedProjects.has(g.key)
|
||||
groups.push({
|
||||
key: g.key,
|
||||
workspaceId: g.workspaceId,
|
||||
cwd: g.cwd,
|
||||
label: g.label,
|
||||
sessionCount: g.summaries.size + (hasIntent ? 1 : 0),
|
||||
expanded,
|
||||
containsCurrent: g.key === currentGroup,
|
||||
intentHere,
|
||||
sessions: expanded ? buildVisible(g, expandedSessions) : [],
|
||||
})
|
||||
} else {
|
||||
const visible = searchVisible(g, q)
|
||||
if (visible.size === 0 && !g.label.toLowerCase().includes(q)) continue
|
||||
rows.push({
|
||||
type: 'project',
|
||||
groups.push({
|
||||
key: g.key,
|
||||
workspaceId: g.workspaceId,
|
||||
cwd: g.cwd,
|
||||
label: g.label,
|
||||
sessionCount: g.summaries.size,
|
||||
sessionCount: g.summaries.size + (hasIntent ? 1 : 0),
|
||||
expanded: visible.size > 0,
|
||||
containsCurrent: g.key === currentGroup,
|
||||
intentHere: false,
|
||||
sessions: buildSearch(g, visible),
|
||||
})
|
||||
flattenSearch(g, visible, rows)
|
||||
}
|
||||
}
|
||||
return rows
|
||||
return groups
|
||||
}
|
||||
|
||||
/**
|
||||
* Relative time label for session rows (figma samples: now / 2min / 1h / 2d / 18d / 2mo).
|
||||
* @param updatedAt - epoch ms of the last update.
|
||||
* @param now - current epoch ms.
|
||||
* @returns compact age label.
|
||||
* Compact relative time for session rows ("now", "5min", "3h", "2d", "4mo", "1y").
|
||||
* @param updatedAt - epoch ms of the session's last activity.
|
||||
* @param now - current epoch ms (injected for pure rendering).
|
||||
* @returns the row's trailing time label.
|
||||
*/
|
||||
export function formatRelativeTime(updatedAt: number, now: number): string {
|
||||
const MIN = 60_000
|
||||
|
||||
@@ -1,117 +1,60 @@
|
||||
/**
|
||||
* apply wiring on a real cordis Context + SlotsService (terminal register
|
||||
* form): SidebarRoot registered into the layout-declared sidebar slot, the
|
||||
* thin inject surface (three plain service callbacks closed over the plugin
|
||||
* ctx — no hooks, no store lines), load-order fail-loud, and fiber-teardown
|
||||
* unregistration. Component behavior is covered props-direct in
|
||||
* sidebar-root.spec.tsx; no renderer machinery here.
|
||||
*/
|
||||
/** Sidebar slot registration and its plain runtime/layout callbacks. */
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-sidebar/client'
|
||||
import type { SidebarRootInjected } from '@deepseek-ai/dsh-client-ui-sidebar/client'
|
||||
// Type-only: ui-layout's SlotMap merge so the sidebar slot key typechecks.
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
|
||||
const sid = (s: string) => s as SessionId
|
||||
|
||||
async function bench() {
|
||||
async function bench(declare = true) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
const list = createSnapshotStore<SessionListState>({
|
||||
ids: [sid('a')],
|
||||
byId: { [sid('a')]: { id: sid('a'), title: 'alpha', displayTitle: 'alpha', cwd: '/proj', running: false, updatedAt: 1 } },
|
||||
current: undefined,
|
||||
})
|
||||
const sessions = {
|
||||
list,
|
||||
create: vi.fn(async () => sid('minted')),
|
||||
open: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
}
|
||||
const layout = { toggleSidebar: vi.fn() }
|
||||
ctx.provide('sessions', sessions)
|
||||
const sessions = { open: vi.fn() }
|
||||
const workspaces = { startSession: vi.fn() }
|
||||
ctx.provide('layout', layout)
|
||||
ctx.provide('sessions', sessions as never)
|
||||
ctx.provide('workspaces', workspaces as never)
|
||||
const slots = ctx.get('slots') as SlotsService
|
||||
// The sidebar slot exists only while its declaring entry is live.
|
||||
slots.register(
|
||||
{ name: 'root', children: { 'sidebar': { kind: 'single', scope: 'root' } } } as never,
|
||||
() => null,
|
||||
)
|
||||
return { ctx, slots, sessions, layout }
|
||||
if (declare) {
|
||||
slots.register(
|
||||
{ name: 'root', children: { 'sidebar': { kind: 'single', scope: 'root' } } } as never,
|
||||
() => null,
|
||||
)
|
||||
}
|
||||
return { ctx, slots, layout, sessions, workspaces }
|
||||
}
|
||||
|
||||
/** The sidebar entry's injected share, read off the stored entry. */
|
||||
function injectedOf(slots: SlotsService): SidebarRootInjected {
|
||||
const entries = slots.entries('sidebar')
|
||||
expect(entries).toHaveLength(1)
|
||||
// The typed StoredEntry.inject is declaration-derived ((...args: never[])
|
||||
// shape); the sidebar factory is parameterless, so the call is safe here.
|
||||
const inject = entries[0]!.inject as (() => SidebarRootInjected) | undefined
|
||||
return inject!()
|
||||
}
|
||||
|
||||
describe('apply', () => {
|
||||
it('declares the services it binds', () => {
|
||||
expect(inject).toEqual(['slots', 'layout', 'sessions'])
|
||||
describe('ui-sidebar apply', () => {
|
||||
it('declares only the services it uses', () => {
|
||||
expect(inject).toEqual(['slots', 'layout', 'sessions', 'workspaces'])
|
||||
})
|
||||
|
||||
it('fails loud when mounted without the inject declaration', async () => {
|
||||
// ctx.slots rides the cordis property proxy: reading it from a plugin
|
||||
// that never declared the dependency throws instead of yielding undefined.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
await expect(ctx.plugin({ apply })).rejects.toThrow(/without inject/)
|
||||
it('registers the sidebar and declares its Workspace picker hole', async () => {
|
||||
const b = await bench()
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
expect(b.slots.entries('sidebar')).toHaveLength(1)
|
||||
expect(b.slots.spec('sidebar.workspace')).toEqual({ kind: 'single', scope: 'root' })
|
||||
const injected = (b.slots.entries('sidebar')[0]!.inject as () => SidebarRootInjected)()
|
||||
expect(Object.keys(injected)).toEqual(['startSession', 'open', 'toggleSidebar'])
|
||||
injected.startSession('workspace' as never, 'prompt')
|
||||
expect(b.workspaces.startSession).toHaveBeenCalledWith('workspace', 'prompt')
|
||||
injected.open('session' as never)
|
||||
expect(b.sessions.open).toHaveBeenCalledWith('session')
|
||||
injected.toggleSidebar()
|
||||
expect(b.layout.toggleSidebar).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('fails loud when no live entry has declared the sidebar slot', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
ctx.provide('sessions', {})
|
||||
ctx.provide('layout', {})
|
||||
await expect(ctx.plugin({ inject: [...inject], apply })).rejects.toThrow(/slot "sidebar" is not declared/)
|
||||
it('fails when no live owner declared the sidebar slot', async () => {
|
||||
const b = await bench(false)
|
||||
await expect(b.ctx.plugin({ inject: [...inject], apply })).rejects.toThrow(/not declared/)
|
||||
})
|
||||
|
||||
it('registers SidebarRoot with the thin three-callback inject surface', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const injected = injectedOf(slots)
|
||||
// The whole business face: three plain callbacks, no hooks, no store lines.
|
||||
expect(Object.keys(injected).sort()).toEqual(['onCreate', 'onOpen', 'onToggleSidebar'])
|
||||
})
|
||||
|
||||
it('routes the callbacks to the layout/sessions services', async () => {
|
||||
const { ctx, slots, sessions, layout } = await bench()
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const injected = injectedOf(slots)
|
||||
|
||||
injected.onToggleSidebar()
|
||||
expect(layout.toggleSidebar).toHaveBeenCalledOnce()
|
||||
|
||||
injected.onOpen(sid('a'))
|
||||
expect(sessions.open).toHaveBeenCalledWith('a')
|
||||
|
||||
injected.onCreate()
|
||||
expect(sessions.clear).toHaveBeenCalledOnce()
|
||||
expect(sessions.create).not.toHaveBeenCalled()
|
||||
|
||||
injected.onCreate('/proj')
|
||||
expect(sessions.create).toHaveBeenCalledWith({ cwd: '/proj' })
|
||||
// create-then-open lands after the create promise resolves.
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(sessions.open).toHaveBeenCalledWith('minted')
|
||||
})
|
||||
|
||||
it('teardown unregisters the slot entry', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
it('removes the entry and child declaration on teardown', async () => {
|
||||
const b = await bench()
|
||||
const fiber = b.ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
expect(slots.entries('sidebar')).toHaveLength(1)
|
||||
await fiber.dispose()
|
||||
expect(slots.entries('sidebar')).toHaveLength(0)
|
||||
expect(b.slots.entries('sidebar')).toHaveLength(0)
|
||||
expect(b.slots.spec('sidebar.workspace')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,292 +1,82 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* SidebarRoot interaction spec, props-direct (slot-parity test doctrine:
|
||||
* components are fed composed props, no assembly machinery). The standard
|
||||
* useSessions hook is stubbed with a real web-react SnapshotStore selector;
|
||||
* expansion/search live inside the component, so all viewing behavior is
|
||||
* driven through the DOM. Covers expand/collapse, subtree unfold, search
|
||||
* filtering, row activation, and the creation entries.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { act, useSyncExternalStore } from 'react'
|
||||
// Runtime is React-free, so the spec binds its selector locally.
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
SessionId, SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SidebarRootComponentProps } from '../src/client/contract/slots.ts'
|
||||
import { SidebarRoot } from '../src/client/SidebarRoot.tsx'
|
||||
|
||||
/** Minimal selector hook over an engine store (production binding lives in the renderer). */
|
||||
function hookOf<T>(src: { getSnapshot(): T; subscribe(fn: () => void): () => void }) {
|
||||
return <S,>(sel: (s: T) => S, _eq?: (a: S, b: S) => boolean): S =>
|
||||
sel(useSyncExternalStore(src.subscribe.bind(src), src.getSnapshot.bind(src)))
|
||||
}
|
||||
|
||||
const sid = (s: string) => s as SessionId
|
||||
|
||||
/** Bare-string init; brands ids and omits absent optional keys (exactOptionalPropertyTypes). */
|
||||
interface SummaryInit {
|
||||
id: string
|
||||
title?: string
|
||||
cwd?: string
|
||||
parentId?: string
|
||||
running?: boolean
|
||||
updatedAt?: number
|
||||
}
|
||||
|
||||
function summary(init: SummaryInit): SessionSummary {
|
||||
const s: SessionSummary = {
|
||||
id: sid(init.id),
|
||||
title: init.title ?? init.id,
|
||||
displayTitle: init.title ?? init.id,
|
||||
running: init.running ?? false,
|
||||
updatedAt: init.updatedAt ?? 0,
|
||||
}
|
||||
if (init.cwd !== undefined) s.cwd = init.cwd
|
||||
if (init.parentId !== undefined) s.parentId = sid(init.parentId)
|
||||
return s
|
||||
}
|
||||
|
||||
function listStateOf(...summaries: SessionSummary[]): SessionListState {
|
||||
const byId: Record<SessionId, SessionSummary> = {}
|
||||
for (const s of summaries) byId[s.id] = s
|
||||
return { ids: summaries.map((s) => s.id), byId, current: undefined }
|
||||
}
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
function mount(...summaries: SessionSummary[]) {
|
||||
// Real engine store as the useSessions stub: same uSES selector shape the
|
||||
// framework delivers, so list updates re-render exactly like production.
|
||||
const sessions = createSnapshotStore<SessionListState>(listStateOf(...summaries))
|
||||
const onOpen = vi.fn((id: SessionId) => { sessions.update((d) => { d.current = id }) })
|
||||
const onCreate = vi.fn()
|
||||
// The owner decides collapsed in production (AppFrame maps the preference);
|
||||
// the harness mirrors that loop so the toggle drives a re-render.
|
||||
let collapsed = false
|
||||
const view = (width: number) => (
|
||||
<SidebarRoot
|
||||
collapsed={collapsed}
|
||||
width={width}
|
||||
useSessions={hookOf(sessions)}
|
||||
onOpen={onOpen}
|
||||
onCreate={onCreate}
|
||||
onToggleSidebar={onToggleSidebar}
|
||||
/>
|
||||
)
|
||||
const onToggleSidebar = vi.fn(() => {
|
||||
collapsed = !collapsed
|
||||
utils.rerender(view(collapsed ? 56 : 300))
|
||||
})
|
||||
const utils = render(view(300))
|
||||
return { sessions, onOpen, onCreate, onToggleSidebar, ...utils }
|
||||
const sid = (id: string) => id as SessionId
|
||||
const wid = (id: string) => id as WorkspaceId
|
||||
const hook = <T,>(snapshot: T) => <S,>(selector: (state: T) => S): S => selector(snapshot)
|
||||
const workspace: WorkspaceView = {
|
||||
workspaceId: wid('project'), path: '/projects/project', title: 'Project', sessionIds: [sid('s1')],
|
||||
createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
}
|
||||
const sessions: SessionListState = {
|
||||
ids: [sid('s1')],
|
||||
byId: { [sid('s1')]: { id: sid('s1'), displayTitle: 'First session', running: false, updatedAt: 1 } },
|
||||
current: undefined, phase: 'ready',
|
||||
intent: undefined,
|
||||
}
|
||||
const workspaces: WorkspaceListState = {
|
||||
items: [workspace], state: 'idle', phase: 'ready', error: null,
|
||||
intent: undefined, baselinesReady: true, recentWorkspaceId: workspace.workspaceId,
|
||||
}
|
||||
|
||||
const projectData = () => [
|
||||
summary({ id: 'root', title: 'root work', cwd: '/proj', updatedAt: 5 }),
|
||||
summary({ id: 'kid', title: 'forked child', cwd: '/proj', parentId: sid('root'), updatedAt: 4 }),
|
||||
summary({ id: 'lone', title: 'elsewhere', cwd: '/other', updatedAt: 3 }),
|
||||
]
|
||||
|
||||
/** Flush the store's microtask-batched notification into React. */
|
||||
const flush = async () => { await act(async () => { await Promise.resolve() }) }
|
||||
|
||||
/** The brand wordmark is decorative svg (aria-hidden, no text); locate it by its native viewBox. */
|
||||
const wordmark = () => document.querySelector('svg[viewBox="0 0 182 24"]')
|
||||
function mount(sessionState: SessionListState = sessions) {
|
||||
const startSession = vi.fn()
|
||||
const open = vi.fn()
|
||||
let pickerOwner: unknown
|
||||
const view = render(
|
||||
<SidebarRoot
|
||||
collapsed={false} width={300}
|
||||
useSessions={hook(sessionState)} useWorkspaces={hook(workspaces)}
|
||||
startSession={startSession} open={open} toggleSidebar={vi.fn()}
|
||||
renderSlot={((_key: string, owner: unknown) => { pickerOwner = owner; return null }) as SidebarRootComponentProps['renderSlot']}
|
||||
/>,
|
||||
)
|
||||
return { view, startSession, open, pickerOwner: () => pickerOwner }
|
||||
}
|
||||
|
||||
describe('SidebarRoot', () => {
|
||||
it('renders chrome and collapsed project rows', () => {
|
||||
mount(...projectData())
|
||||
expect(wordmark()).not.toBeNull()
|
||||
expect(screen.getByText('New Session')).toBeTruthy()
|
||||
expect(screen.getByText('proj')).toBeTruthy()
|
||||
expect(screen.getByText('2 sessions')).toBeTruthy()
|
||||
expect(screen.getByText('1 session')).toBeTruthy()
|
||||
expect(screen.queryByText('root work')).toBeNull()
|
||||
it('renders real Workspaces from useWorkspaces and routes New Session', () => {
|
||||
const b = mount()
|
||||
expect(screen.getByText('Project')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New session' }))
|
||||
expect(b.startSession).toHaveBeenCalledWith()
|
||||
})
|
||||
|
||||
it('expands a project on click and unfolds a subtree via the twist', () => {
|
||||
mount(...projectData())
|
||||
act(() => { fireEvent.click(screen.getByText('proj')) })
|
||||
expect(screen.getByText('root work')).toBeTruthy()
|
||||
expect(screen.queryByText('forked child')).toBeNull()
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Expand')) })
|
||||
expect(screen.getByText('forked child')).toBeTruthy()
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Collapse')) })
|
||||
expect(screen.queryByText('forked child')).toBeNull()
|
||||
})
|
||||
|
||||
it('opens a session on row click and marks it selected', async () => {
|
||||
const { onOpen } = mount(...projectData())
|
||||
act(() => { fireEvent.click(screen.getByText('proj')) })
|
||||
act(() => { fireEvent.click(screen.getByText('root work')) })
|
||||
expect(onOpen).toHaveBeenCalledWith('root')
|
||||
// The mock routed the open into sessions.current — highlight follows.
|
||||
await flush()
|
||||
expect(screen.getByText('root work').closest('[role="treeitem"]')!.getAttribute('aria-selected')).toBe('true')
|
||||
})
|
||||
|
||||
it('search filters across groups and forces ancestor chains visible', () => {
|
||||
mount(...projectData())
|
||||
const input = screen.getByPlaceholderText('Search name, keywords...')
|
||||
act(() => { fireEvent.change(input, { target: { value: 'forked' } }) })
|
||||
expect(screen.getByText('forked child')).toBeTruthy()
|
||||
expect(screen.getByText('root work')).toBeTruthy()
|
||||
expect(screen.queryByText('elsewhere')).toBeNull()
|
||||
expect(screen.queryByText(/^other$/)).toBeNull()
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Clear search')) })
|
||||
expect(screen.queryByText('root work')).toBeNull()
|
||||
expect(screen.getByText('proj')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows the blank-list empty state without a query', () => {
|
||||
mount()
|
||||
expect(screen.getByText('No sessions yet')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows the no-match empty state', () => {
|
||||
mount(...projectData())
|
||||
const input = screen.getByPlaceholderText('Search name, keywords...')
|
||||
act(() => { fireEvent.change(input, { target: { value: 'zzz-none' } }) })
|
||||
expect(screen.getByText('No matches')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('routes the three creation entries with the right cwd', () => {
|
||||
const { onCreate } = mount(...projectData())
|
||||
act(() => { fireEvent.click(screen.getByText('New Session')) })
|
||||
expect(onCreate).toHaveBeenLastCalledWith()
|
||||
act(() => { fireEvent.click(screen.getByLabelText('New workspace')) })
|
||||
expect(onCreate).toHaveBeenLastCalledWith()
|
||||
// Per-project "+" is hover-revealed by CSS; still clickable in jsdom.
|
||||
act(() => { fireEvent.click(screen.getAllByLabelText('New session here')[0]!) })
|
||||
expect(onCreate).toHaveBeenLastCalledWith('/proj')
|
||||
})
|
||||
|
||||
it('collapse fades the wide content out, then the rail keeps the four controls', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { onToggleSidebar, onCreate } = mount(...projectData())
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) })
|
||||
expect(onToggleSidebar).toHaveBeenCalledOnce()
|
||||
// Fade window: the wide chrome is still mounted while it fades.
|
||||
expect(wordmark()).not.toBeNull()
|
||||
expect(screen.getByRole('tree')).toBeTruthy()
|
||||
// Settle: wide content unmounts, the rail controls remain.
|
||||
act(() => { vi.advanceTimersByTime(300) })
|
||||
expect(wordmark()).toBeNull()
|
||||
expect(screen.queryByText('New Session')).toBeNull()
|
||||
expect(screen.queryByRole('tree')).toBeNull()
|
||||
// Rail order mirrors the expanded rows: open, new session, new workspace, search.
|
||||
const rail = ['Open sidebar', 'New session', 'New workspace', 'Search sessions', 'Settings']
|
||||
.map((label) => screen.getByLabelText(label))
|
||||
for (let i = 1; i < rail.length; i++) {
|
||||
expect(rail[i - 1]!.compareDocumentPosition(rail[i]!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
}
|
||||
// Rail creation entries route like their expanded counterparts.
|
||||
act(() => { fireEvent.click(screen.getByLabelText('New session')) })
|
||||
expect(onCreate).toHaveBeenLastCalledWith()
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Open sidebar')) })
|
||||
expect(onToggleSidebar).toHaveBeenCalledTimes(2)
|
||||
expect(screen.getByLabelText('Collapse sidebar')).toBeTruthy()
|
||||
expect(screen.getByText('New Session')).toBeTruthy()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('rail search expands the sidebar and focuses the search box', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { onToggleSidebar } = mount(...projectData())
|
||||
// While expanded the search control is inert (the row click focuses instead).
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Search sessions')) })
|
||||
expect(onToggleSidebar).not.toHaveBeenCalled()
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) })
|
||||
act(() => { vi.advanceTimersByTime(300) })
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Search sessions')) })
|
||||
expect(onToggleSidebar).toHaveBeenCalledTimes(2)
|
||||
// Focus waits out the 300ms column slide (EXPAND_SLIDE_MS).
|
||||
act(() => { vi.advanceTimersByTime(300) })
|
||||
const input = screen.getByPlaceholderText('Search name, keywords...')
|
||||
expect(document.activeElement).toBe(input)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('expanded search focuses without toggling the sidebar', () => {
|
||||
const { onToggleSidebar } = mount(...projectData())
|
||||
const input = screen.getByPlaceholderText('Search name, keywords...')
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Search sessions')) })
|
||||
expect(document.activeElement).toBe(input)
|
||||
expect(onToggleSidebar).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('the search query survives a collapse/expand round trip', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
mount(...projectData())
|
||||
const input = screen.getByPlaceholderText('Search name, keywords...')
|
||||
act(() => { fireEvent.change(input, { target: { value: 'forked' } }) })
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) })
|
||||
act(() => { vi.advanceTimersByTime(300) })
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Open sidebar')) })
|
||||
const restored = screen.getByPlaceholderText('Search name, keywords...') as HTMLInputElement
|
||||
expect(restored.value).toBe('forked')
|
||||
expect(screen.getByText('forked child')).toBeTruthy()
|
||||
expect(screen.queryByText('elsewhere')).toBeNull()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('group-by menu behaves', () => {
|
||||
mount(...projectData())
|
||||
expect(screen.queryByText('Update')).toBeNull()
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Group by')) })
|
||||
expect(screen.getByText('Update')).toBeTruthy()
|
||||
expect(screen.getByText('Status')).toBeTruthy()
|
||||
// Selecting the active strategy closes the list (only workspace is enabled).
|
||||
act(() => { fireEvent.click(screen.getByText('WorkSpace', { selector: 'button *' })) })
|
||||
expect(screen.queryByText('Update')).toBeNull()
|
||||
// Reopen and dismiss via Escape (Menu onClose channel).
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Group by')) })
|
||||
act(() => { fireEvent.keyDown(document, { key: 'Escape' }) })
|
||||
expect(screen.queryByText('Update')).toBeNull()
|
||||
})
|
||||
|
||||
it('re-renders when the sessions list gains a session', async () => {
|
||||
const { sessions } = mount(...projectData())
|
||||
act(() => {
|
||||
sessions.update((draft) => {
|
||||
draft.ids.push(sid('fresh'))
|
||||
draft.byId[sid('fresh')] = summary({ id: 'fresh', title: 'brand new', cwd: '/fresh', updatedAt: 99 })
|
||||
})
|
||||
it('shows a frontend Session under its real Workspace and routes its row plus', () => {
|
||||
const intent = { sessionId: sid('intent'), target: { kind: 'workspace' as const, workspaceId: workspace.workspaceId }, prompt: '', phase: 'connecting' as const }
|
||||
const b = mount({
|
||||
...sessions,
|
||||
current: intent.sessionId,
|
||||
intent,
|
||||
})
|
||||
// Store notifications are microtask-batched.
|
||||
await flush()
|
||||
expect(screen.getByText('fresh')).toBeTruthy()
|
||||
expect(screen.getByText('New session')).toBeTruthy()
|
||||
expect(screen.getByText('2 sessions')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New session in Project' }))
|
||||
expect(b.startSession).toHaveBeenCalledWith(workspace.workspaceId)
|
||||
})
|
||||
|
||||
it('row "More" anchors swallow the click without opening or toggling', () => {
|
||||
const { onOpen } = mount(...projectData())
|
||||
act(() => { fireEvent.click(screen.getByText('proj')) })
|
||||
// Project-row anchor: must not collapse the project (rows stay visible).
|
||||
act(() => { fireEvent.click(screen.getAllByLabelText('More')[0]!) })
|
||||
expect(screen.getByText('root work')).toBeTruthy()
|
||||
// Session-row anchor: must not open the session.
|
||||
act(() => { fireEvent.click(screen.getAllByLabelText('More')[1]!) })
|
||||
expect(onOpen).not.toHaveBeenCalled()
|
||||
it('forwards Workspace picker selection and closes the picker', () => {
|
||||
const b = mount()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
|
||||
const owner = b.pickerOwner() as { open: boolean; onPick(id: WorkspaceId): void }
|
||||
expect(owner.open).toBe(true)
|
||||
owner.onPick(workspace.workspaceId)
|
||||
expect(b.startSession).toHaveBeenCalledWith(workspace.workspaceId)
|
||||
})
|
||||
|
||||
it('shows the running state dot only for running sessions', () => {
|
||||
mount(
|
||||
summary({ id: 'busy', title: 'busy one', cwd: '/p', running: true, updatedAt: 2 }),
|
||||
summary({ id: 'idle', title: 'idle one', cwd: '/p', updatedAt: 1 }),
|
||||
)
|
||||
act(() => { fireEvent.click(screen.getByText('p')) })
|
||||
const busyRow = screen.getByText('busy one').closest('[role="treeitem"]')!
|
||||
const idleRow = screen.getByText('idle one').closest('[role="treeitem"]')!
|
||||
expect(busyRow.querySelector('[data-state="ongoing"]')).toBeTruthy()
|
||||
expect(idleRow.querySelector('[data-state="ongoing"]')).toBeNull()
|
||||
it('opens a real Session through the owner action', () => {
|
||||
const b = mount({ ...sessions, current: sid('intent'), intent: {
|
||||
sessionId: sid('intent'), target: { kind: 'workspace', workspaceId: workspace.workspaceId }, prompt: '', phase: 'ready',
|
||||
} })
|
||||
fireEvent.click(screen.getByText('Project'))
|
||||
fireEvent.click(screen.getByText('First session'))
|
||||
expect(b.open).toHaveBeenCalledWith(sid('s1'))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,245 +1,74 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
deriveRows, formatRelativeTime, projectLabel, UNGROUPED_KEY, UNGROUPED_LABEL,
|
||||
type SessionRow, type TreeView,
|
||||
} from '../src/client/tree.ts'
|
||||
import type {
|
||||
SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { deriveGroups, formatRelativeTime, UNGROUPED_KEY } from '../src/client/tree.ts'
|
||||
|
||||
const sid = (s: string) => s as SessionId
|
||||
|
||||
/** Bare-string init; brands ids and omits absent optional keys (exactOptionalPropertyTypes). */
|
||||
interface SummaryInit {
|
||||
id: string
|
||||
title?: string
|
||||
displayTitle?: string
|
||||
cwd?: string
|
||||
parentId?: string
|
||||
running?: boolean
|
||||
updatedAt?: number
|
||||
}
|
||||
|
||||
function summary(init: SummaryInit): SessionSummary {
|
||||
const s: SessionSummary = {
|
||||
id: sid(init.id),
|
||||
displayTitle: init.displayTitle ?? init.title ?? init.id,
|
||||
running: init.running ?? false,
|
||||
updatedAt: init.updatedAt ?? 0,
|
||||
}
|
||||
if (init.title !== undefined) s.title = init.title
|
||||
if (init.cwd !== undefined) s.cwd = init.cwd
|
||||
if (init.parentId !== undefined) s.parentId = sid(init.parentId)
|
||||
return s
|
||||
}
|
||||
|
||||
function listOf(...summaries: SessionSummary[]): SessionListState {
|
||||
const byId: Record<SessionId, SessionSummary> = {}
|
||||
for (const s of summaries) byId[s.id] = s
|
||||
return { ids: summaries.map(s => s.id), byId, current: undefined }
|
||||
}
|
||||
|
||||
const view = (partial: Partial<TreeView> = {}): TreeView => ({
|
||||
expandedProjects: partial.expandedProjects ?? [],
|
||||
expandedSessions: partial.expandedSessions ?? [],
|
||||
query: partial.query ?? '',
|
||||
const sid = (id: string) => id as SessionId
|
||||
const wid = (id: string) => id as WorkspaceId
|
||||
const summary = (id: string, updatedAt: number, cwd?: string): SessionSummary => ({
|
||||
id: sid(id), displayTitle: id, running: false, updatedAt, ...(cwd === undefined ? {} : { cwd }),
|
||||
})
|
||||
const list = (...items: SessionSummary[]): SessionListState => ({
|
||||
ids: items.map(item => item.id),
|
||||
byId: Object.fromEntries(items.map(item => [item.id, item])),
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
intent: undefined,
|
||||
})
|
||||
const workspace = (id: string, sessionIds: string[]): WorkspaceView => ({
|
||||
workspaceId: wid(id), path: `/projects/${id}`, title: id,
|
||||
sessionIds: sessionIds.map(sid), createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
})
|
||||
const view = (expandedProjects: readonly string[] = [], query = '') => ({
|
||||
expandedProjects, expandedSessions: [] as string[], query,
|
||||
})
|
||||
|
||||
describe('projectLabel', () => {
|
||||
it('takes the basename and survives trailing separators', () => {
|
||||
expect(projectLabel('/home/me/proj')).toBe('proj')
|
||||
expect(projectLabel('/home/me/proj/')).toBe('proj')
|
||||
expect(projectLabel('C:\\work\\thing')).toBe('thing')
|
||||
describe('deriveGroups', () => {
|
||||
it('keeps Host Workspace and sessionIds order without Client recency sorting', () => {
|
||||
const sessions = list(summary('newer', 20), summary('older', 10))
|
||||
const workspaces = [workspace('first', ['older', 'newer']), workspace('empty', [])]
|
||||
const groups = deriveGroups(sessions, workspaces, view(['first']))
|
||||
expect(groups.map(group => group.key)).toEqual(['first', 'empty'])
|
||||
expect(groups[0]!.sessions.map(session => session.id)).toEqual([sid('older'), sid('newer')])
|
||||
})
|
||||
|
||||
it('falls back for empty and root-only paths', () => {
|
||||
expect(projectLabel(undefined)).toBe(UNGROUPED_LABEL)
|
||||
expect(projectLabel('')).toBe(UNGROUPED_LABEL)
|
||||
expect(projectLabel('///')).toBe('///')
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveRows grouping', () => {
|
||||
it('groups by cwd into project rows with counts, newest group first', () => {
|
||||
const rows = deriveRows(listOf(
|
||||
summary({ id: 'a', cwd: '/x/alpha', updatedAt: 10 }),
|
||||
summary({ id: 'b', cwd: '/x/beta', updatedAt: 30 }),
|
||||
summary({ id: 'c', cwd: '/x/alpha', updatedAt: 20 }),
|
||||
), view())
|
||||
expect(rows).toEqual([
|
||||
expect.objectContaining({ type: 'project', key: '/x/beta', label: 'beta', sessionCount: 1, expanded: false }),
|
||||
expect.objectContaining({ type: 'project', key: '/x/alpha', label: 'alpha', sessionCount: 2 }),
|
||||
])
|
||||
it('puts only real unaccounted Sessions in the trailing Ungrouped group', () => {
|
||||
const sessions = list(summary('owned', 1, '/projects/first'), summary('loose', 9, '/other'))
|
||||
const groups = deriveGroups(sessions, [workspace('first', ['owned'])], view([UNGROUPED_KEY]))
|
||||
expect(groups.map(group => group.key)).toEqual(['first', UNGROUPED_KEY])
|
||||
expect(groups[1]!.sessions.map(session => session.id)).toEqual([sid('loose')])
|
||||
})
|
||||
|
||||
it('orders equally-recent groups by label and skips ids missing from byId', () => {
|
||||
const list = listOf(
|
||||
summary({ id: 'b1', cwd: '/x/beta', updatedAt: 5 }),
|
||||
summary({ id: 'a1', cwd: '/x/alpha', updatedAt: 5 }),
|
||||
// Same basename and same recency as beta: label comparator returns 0,
|
||||
// insertion order breaks the tie.
|
||||
summary({ id: 'b2', cwd: '/y/beta', updatedAt: 5 }),
|
||||
)
|
||||
list.ids.push(sid('ghost'))
|
||||
const rows = deriveRows(list, view())
|
||||
expect(rows.map(r => r.type === 'project' && r.key)).toEqual(['/x/alpha', '/x/beta', '/y/beta'])
|
||||
})
|
||||
|
||||
it('buckets cwd-less sessions under the ungrouped project row', () => {
|
||||
const rows = deriveRows(listOf(summary({ id: 'a' })), view())
|
||||
expect(rows).toEqual([
|
||||
expect.objectContaining({ type: 'project', key: UNGROUPED_KEY, cwd: undefined, label: UNGROUPED_LABEL }),
|
||||
])
|
||||
})
|
||||
|
||||
it('hides sessions under collapsed projects and shows them when expanded', () => {
|
||||
const list = listOf(
|
||||
summary({ id: 'a', cwd: '/p', updatedAt: 1 }),
|
||||
summary({ id: 'b', cwd: '/p', updatedAt: 2 }),
|
||||
)
|
||||
expect(deriveRows(list, view()).filter(r => r.type === 'session')).toHaveLength(0)
|
||||
const rows = deriveRows(list, view({ expandedProjects: ['/p'] }))
|
||||
expect(rows.slice(1)).toEqual([
|
||||
expect.objectContaining({ type: 'session', id: 'b', depth: 0 }),
|
||||
expect.objectContaining({ type: 'session', id: 'a', depth: 0 }),
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveRows session tree', () => {
|
||||
const treeList = listOf(
|
||||
summary({ id: 'root', cwd: '/p', updatedAt: 5 }),
|
||||
summary({ id: 'kid', cwd: '/p', parentId: sid('root'), updatedAt: 4 }),
|
||||
summary({ id: 'grandkid', cwd: '/p', parentId: sid('kid'), updatedAt: 3 }),
|
||||
summary({ id: 'other', cwd: '/p', updatedAt: 9 }),
|
||||
)
|
||||
|
||||
it('nests children under expanded parents with increasing depth', () => {
|
||||
const rows = deriveRows(treeList, view({
|
||||
expandedProjects: ['/p'],
|
||||
expandedSessions: ['root', 'kid'],
|
||||
it('shows one frontend Session row only under a real target Workspace', () => {
|
||||
const intent = { sessionId: sid('intent'), target: { kind: 'workspace' as const, workspaceId: wid('first') }, prompt: '', phase: 'connecting' as const }
|
||||
const target = workspace('first', [])
|
||||
expect(deriveGroups({ ...list(), current: intent.sessionId, intent }, [target], view())[0]).toEqual(expect.objectContaining({
|
||||
intentHere: true,
|
||||
sessionCount: 1,
|
||||
containsCurrent: true,
|
||||
}))
|
||||
expect(rows.slice(1)).toEqual([
|
||||
expect.objectContaining({ id: 'other', depth: 0, hasChildren: false }),
|
||||
expect.objectContaining({ id: 'root', depth: 0, hasChildren: true, expanded: true }),
|
||||
expect.objectContaining({ id: 'kid', depth: 1, hasChildren: true, expanded: true }),
|
||||
expect.objectContaining({ id: 'grandkid', depth: 2, hasChildren: false }),
|
||||
])
|
||||
const hiddenIntent = { sessionId: sid('zero'), target: { kind: 'workspace-intent' as const }, prompt: '', phase: 'ready' as const }
|
||||
expect(deriveGroups({ ...list(), intent: hiddenIntent }, [target], view())[0]!.intentHere).toBe(false)
|
||||
})
|
||||
|
||||
it('collapses subtrees at unexpanded sessions', () => {
|
||||
const rows = deriveRows(treeList, view({ expandedProjects: ['/p'] }))
|
||||
const ids = rows.filter((r): r is SessionRow => r.type === 'session').map(r => r.id)
|
||||
expect(ids).toEqual(['other', 'root'])
|
||||
})
|
||||
|
||||
it('degrades a cross-group parent link to a group root', () => {
|
||||
const rows = deriveRows(listOf(
|
||||
summary({ id: 'p1', cwd: '/a', updatedAt: 2 }),
|
||||
summary({ id: 'stray', cwd: '/b', parentId: sid('p1'), updatedAt: 1 }),
|
||||
), view({ expandedProjects: ['/a', '/b'] }))
|
||||
expect(rows).toEqual([
|
||||
expect.objectContaining({ type: 'project', key: '/a' }),
|
||||
expect.objectContaining({ id: 'p1', depth: 0 }),
|
||||
expect.objectContaining({ type: 'project', key: '/b' }),
|
||||
expect.objectContaining({ id: 'stray', depth: 0 }),
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps cycle members visible as extra roots without looping', () => {
|
||||
const rows = deriveRows(listOf(
|
||||
summary({ id: 'x', cwd: '/p', parentId: sid('y'), updatedAt: 2 }),
|
||||
summary({ id: 'y', cwd: '/p', parentId: sid('x'), updatedAt: 1 }),
|
||||
summary({ id: 'self', cwd: '/p', parentId: sid('self'), updatedAt: 3 }),
|
||||
), view({ expandedProjects: ['/p'], expandedSessions: ['x', 'y', 'self'] }))
|
||||
const ids = rows.filter((r): r is SessionRow => r.type === 'session').map(r => r.id)
|
||||
expect(ids).toContain('self')
|
||||
expect(ids).toContain('x')
|
||||
expect(ids).toContain('y')
|
||||
expect(ids).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('breaks updatedAt ties deterministically by id', () => {
|
||||
const rows = deriveRows(listOf(
|
||||
summary({ id: 'b', cwd: '/p', updatedAt: 7 }),
|
||||
summary({ id: 'a', cwd: '/p', updatedAt: 7 }),
|
||||
summary({ id: 'c', cwd: '/p', updatedAt: 7 }),
|
||||
), view({ expandedProjects: ['/p'] }))
|
||||
const ids = rows.filter((r): r is SessionRow => r.type === 'session').map(r => r.id)
|
||||
expect(ids).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
it('collects multiple children under one parent in recency order', () => {
|
||||
const rows = deriveRows(listOf(
|
||||
summary({ id: 'p', cwd: '/p', updatedAt: 9 }),
|
||||
summary({ id: 'old', cwd: '/p', parentId: sid('p'), updatedAt: 1 }),
|
||||
summary({ id: 'new', cwd: '/p', parentId: sid('p'), updatedAt: 5 }),
|
||||
), view({ expandedProjects: ['/p'], expandedSessions: ['p'] }))
|
||||
const ids = rows.filter((r): r is SessionRow => r.type === 'session').map(r => r.id)
|
||||
expect(ids).toEqual(['p', 'new', 'old'])
|
||||
})
|
||||
|
||||
it('carries the running flag onto rows', () => {
|
||||
const rows = deriveRows(
|
||||
listOf(summary({ id: 'a', cwd: '/p', running: true })),
|
||||
view({ expandedProjects: ['/p'] }))
|
||||
expect(rows[1]).toEqual(expect.objectContaining({ id: 'a', running: true }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveRows search', () => {
|
||||
const list = listOf(
|
||||
summary({ id: 'root', title: 'alpha work', cwd: '/p', updatedAt: 5 }),
|
||||
summary({ id: 'kid', title: 'deep needle here', cwd: '/p', parentId: sid('root'), updatedAt: 4 }),
|
||||
summary({ id: 'noise', title: 'unrelated', cwd: '/p', updatedAt: 3 }),
|
||||
summary({ id: 'q', title: 'quiet', cwd: '/other', updatedAt: 2 }),
|
||||
)
|
||||
|
||||
it('forces matched sessions and their ancestor chains visible, ignoring expansion', () => {
|
||||
const rows = deriveRows(list, view({ query: 'NEEDLE' }))
|
||||
expect(rows).toEqual([
|
||||
expect.objectContaining({ type: 'project', key: '/p', expanded: true }),
|
||||
expect.objectContaining({ id: 'root', depth: 0, expanded: true }),
|
||||
expect.objectContaining({ id: 'kid', depth: 1 }),
|
||||
])
|
||||
})
|
||||
|
||||
it('drops groups without a hit and keeps a bare project row on label-only hits', () => {
|
||||
const rows = deriveRows(list, view({ query: 'other' }))
|
||||
expect(rows).toEqual([
|
||||
expect.objectContaining({ type: 'project', key: '/other', expanded: false }),
|
||||
])
|
||||
})
|
||||
|
||||
it('blank query means normal mode', () => {
|
||||
const rows = deriveRows(list, view({ query: ' ' }))
|
||||
expect(rows.every(r => r.type === 'project')).toBe(true)
|
||||
})
|
||||
|
||||
it('matches the effective display title when no durable title is available', () => {
|
||||
const fallback = listOf(summary({ id: 'raw-id', displayTitle: 'project fallback', cwd: '/elsewhere' }))
|
||||
const rows = deriveRows(fallback, view({ query: 'fallback' }))
|
||||
expect(rows).toEqual([
|
||||
expect.objectContaining({ type: 'project', key: '/elsewhere' }),
|
||||
expect.objectContaining({ type: 'session', id: 'raw-id', title: 'project fallback' }),
|
||||
])
|
||||
it('search filters real Sessions and omits the Intent placeholder', () => {
|
||||
const intent = { sessionId: sid('intent'), target: { kind: 'workspace' as const, workspaceId: wid('first') }, prompt: '', phase: 'ready' as const }
|
||||
const groups = deriveGroups({ ...list(summary('match', 1)), intent }, [workspace('first', ['match'])], view([], 'match'))
|
||||
expect(groups[0]!.sessions.map(session => session.id)).toEqual([sid('match')])
|
||||
expect(groups[0]!.intentHere).toBe(false)
|
||||
expect(groups[0]!.sessionCount).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatRelativeTime', () => {
|
||||
const now = 1_000_000_000_000
|
||||
it.each([
|
||||
[now, 'now'],
|
||||
[now - 30_000, 'now'],
|
||||
[now - 2 * 60_000, '2min'],
|
||||
[now - 3_600_000, '1h'],
|
||||
[now - 2 * 86_400_000, '2d'],
|
||||
[now - 18 * 86_400_000, '18d'],
|
||||
[now - 65 * 86_400_000, '2mo'],
|
||||
[now - 400 * 86_400_000, '1y'],
|
||||
])('%d -> %s', (at, label) => {
|
||||
expect(formatRelativeTime(at, now)).toBe(label)
|
||||
})
|
||||
|
||||
it('clamps future timestamps to now', () => {
|
||||
expect(formatRelativeTime(now + 5_000, now)).toBe('now')
|
||||
it('formats current, minute, hour, day, month, and year buckets', () => {
|
||||
const now = 400 * 24 * 60 * 60 * 1_000
|
||||
expect(formatRelativeTime(now, now)).toBe('now')
|
||||
expect(formatRelativeTime(now - 5 * 60_000, now)).toBe('5min')
|
||||
expect(formatRelativeTime(now - 3 * 3_600_000, now)).toBe('3h')
|
||||
expect(formatRelativeTime(now - 2 * 86_400_000, now)).toBe('2d')
|
||||
expect(formatRelativeTime(now - 60 * 86_400_000, now)).toBe('2mo')
|
||||
expect(formatRelativeTime(0, now)).toBe('1y')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
|
||||
@@ -98,6 +98,11 @@ export interface SlotRendererHost {
|
||||
*/
|
||||
cell(id: string): SessionCell | undefined
|
||||
}
|
||||
/** Workspace-side standard-kit sources. */
|
||||
workspaces: {
|
||||
/** Workspace list source backing the useWorkspaces standard hook. */
|
||||
list: HostObservable<unknown>
|
||||
}
|
||||
}
|
||||
|
||||
/** The install seam: runtime owns install()/renderSlot(); web-react implements rendering. */
|
||||
|
||||
@@ -15,7 +15,7 @@ import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSnapshot, SessionId, SessionListState, WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConvViewProps, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
// Export discipline: packages/client/AGENTS.md.
|
||||
import { ConversationRoot, type ConversationRootProps } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationRoot.tsx'
|
||||
@@ -62,7 +62,15 @@ function fakeSession(nodes: ConversationSnapshot['nodes']) {
|
||||
/** Empty sessions-list hook; breadcrumbs therefore fall back to the raw id. */
|
||||
function emptySessions() {
|
||||
const store = createSnapshotStore<SessionListState>(
|
||||
{ ids: [], byId: {}, current: undefined } as SessionListState)
|
||||
{ ids: [], byId: {}, current: undefined, intent: undefined, phase: 'ready' })
|
||||
return bindSnapshotSelector(store)
|
||||
}
|
||||
|
||||
function emptyWorkspaces() {
|
||||
const store = createSnapshotStore<WorkspaceListState>({
|
||||
items: [], intent: undefined, state: 'idle', phase: 'ready', error: null, baselinesReady: true,
|
||||
recentWorkspaceId: undefined,
|
||||
})
|
||||
return bindSnapshotSelector(store)
|
||||
}
|
||||
|
||||
@@ -75,6 +83,7 @@ function standaloneProps(nodes: ConversationSnapshot['nodes']): ConvViewProps {
|
||||
sessionId: SID,
|
||||
useSession: fakeSession(nodes).useSession,
|
||||
useSessions: emptySessions(),
|
||||
useWorkspaces: emptyWorkspaces(),
|
||||
} as unknown as ConvViewProps
|
||||
}
|
||||
|
||||
@@ -121,7 +130,7 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
|
||||
const View = entry.component as FC<ConvViewProps>
|
||||
return (
|
||||
<View
|
||||
{...({ sessionId: SID, useSession, useSessions: emptySessions() } as unknown as ConvViewProps)}
|
||||
{...({ sessionId: SID, useSession, useSessions: emptySessions(), useWorkspaces: emptyWorkspaces() } as unknown as ConvViewProps)}
|
||||
key={key}
|
||||
/>
|
||||
)
|
||||
@@ -131,6 +140,7 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
|
||||
sessionId={SID}
|
||||
useSession={useSession}
|
||||
useSessions={emptySessions()}
|
||||
useWorkspaces={emptyWorkspaces()}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
renderSlot={renderSlot}
|
||||
@@ -144,6 +154,8 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
|
||||
send={vi.fn()}
|
||||
stop={vi.fn()}
|
||||
open={vi.fn()}
|
||||
updateSessionPrompt={vi.fn()}
|
||||
retrySessionPrompt={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
20
packages/client/ui-workspace/README.md
Normal file
20
packages/client/ui-workspace/README.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# @deepseek-ai/dsh-client-ui-workspace
|
||||
|
||||
Shared Workspace picker plugin. `WorkspacePicker` is registered into the sidebar's `sidebar.workspace` slot and the page-local Session Intent hero's `conversation.empty.workspace` slot, so both surfaces use the same menu and creation modals.
|
||||
|
||||
The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object; the existing-folder and create-new actions first create a real Workspace through the object layer, then select it. Create-new disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization.
|
||||
|
||||
Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as the picker is browser chrome; nothing here reaches a model request.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **No Workspace rename/delete controls** — the picker supports selection and creation only.
|
||||
- **Existing-folder entry is manual path input only** — Host creation failures are shown in the modal.
|
||||
65
packages/client/ui-workspace/package.json
Normal file
65
packages/client/ui-workspace/package.json
Normal file
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-workspace",
|
||||
"description": "Workspace picker plugin: one WorkspacePicker registered into the sidebar and empty-state workspace slots",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-conversation",
|
||||
"@deepseek-ai/dsh-client-ui-sidebar"
|
||||
],
|
||||
"platform": "web"
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/* Modal form styles mirror the empty state's path/create modals (same figma
|
||||
* dialog family: field h44, r22, hairline border, pad 14/7) so the two
|
||||
* entries stay visually identical. */
|
||||
.modalInput {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
padding: 7px 14px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 22px;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.modalInput::placeholder {
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.modalInput:disabled {
|
||||
color: var(--dsw-alias-label-dimmed);
|
||||
}
|
||||
|
||||
.modalAction {
|
||||
min-width: 72px;
|
||||
}
|
||||
|
||||
.modalError,
|
||||
.modalStatus,
|
||||
.menuStatus {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.modalError {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.modalStatus,
|
||||
.menuStatus {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
196
packages/client/ui-workspace/src/client/WorkspacePicker.tsx
Normal file
196
packages/client/ui-workspace/src/client/WorkspacePicker.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
/** Shared Workspace picker for the sidebar and New Session hero. */
|
||||
import { useCallback, useState } from 'react'
|
||||
import {
|
||||
Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { WorkspacePickerProps } from './contract/slots.ts'
|
||||
import css from './WorkspacePicker.module.css'
|
||||
|
||||
const CREATE_WORKSPACE = '::create-workspace'
|
||||
const USE_EXISTING = '::use-existing'
|
||||
const CREATE_NEW = '::create-new'
|
||||
|
||||
type ModalKind = 'path' | 'create' | null
|
||||
|
||||
export function WorkspacePicker({
|
||||
open,
|
||||
anchorRef,
|
||||
useWorkspaces,
|
||||
onPick,
|
||||
onClose,
|
||||
createWorkspace,
|
||||
}: WorkspacePickerProps) {
|
||||
const workspaceSnapshot = useWorkspaces(state => state)
|
||||
const workspaces = workspaceSnapshot.items
|
||||
const getAnchorRect = useCallback(
|
||||
() => anchorRef?.current?.getBoundingClientRect() ?? null,
|
||||
[anchorRef],
|
||||
)
|
||||
const [modalKind, setModalKind] = useState<ModalKind>(null)
|
||||
const [pathDraft, setPathDraft] = useState('')
|
||||
const [workspaceName, setWorkspaceName] = useState('')
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [modalError, setModalError] = useState<string | null>(null)
|
||||
const normalizedWorkspaceName = workspaceName.trim()
|
||||
const duplicateWorkspaceName = normalizedWorkspaceName !== ''
|
||||
&& workspaces.some(workspace => workspace.title === normalizedWorkspaceName)
|
||||
|
||||
const items: MenuEntry[] = [
|
||||
...workspaces.map(workspace => ({
|
||||
id: workspace.workspaceId as string,
|
||||
label: workspace.title,
|
||||
icon: <IconFolderClose16 size={16} />,
|
||||
})),
|
||||
...(workspaces.length > 0 ? [{ type: 'separator' as const, id: 'sep-create' }] : []),
|
||||
{
|
||||
id: CREATE_WORKSPACE,
|
||||
label: 'Create workspace',
|
||||
icon: <IconPlusOutline16 size={16} />,
|
||||
submenu: [
|
||||
{ id: USE_EXISTING, label: 'Use an existing folder' },
|
||||
{ id: CREATE_NEW, label: 'Create a new workspace' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const closeModal = (): void => {
|
||||
if (creating) return
|
||||
setModalKind(null)
|
||||
setModalError(null)
|
||||
}
|
||||
|
||||
const handleSelect = (id: string): void => {
|
||||
if (id === USE_EXISTING) {
|
||||
onClose()
|
||||
setPathDraft('')
|
||||
setModalError(null)
|
||||
setModalKind('path')
|
||||
return
|
||||
}
|
||||
if (id === CREATE_NEW) {
|
||||
onClose()
|
||||
setWorkspaceName('workspace')
|
||||
setModalError(null)
|
||||
setModalKind('create')
|
||||
return
|
||||
}
|
||||
onPick(id as WorkspaceId)
|
||||
}
|
||||
|
||||
const create = (input: { name: string } | { path: string }): void => {
|
||||
if (creating) return
|
||||
setCreating(true)
|
||||
setModalError(null)
|
||||
void createWorkspace(input).then((workspace) => {
|
||||
setCreating(false)
|
||||
setModalKind(null)
|
||||
onPick(workspace.workspaceId)
|
||||
}).catch((reason: unknown) => {
|
||||
const message = reason instanceof Error ? reason.message : String(reason)
|
||||
setModalError(`Workspace creation failed: ${message}`)
|
||||
setCreating(false)
|
||||
})
|
||||
}
|
||||
|
||||
const confirmPath = (): void => {
|
||||
const path = pathDraft.trim()
|
||||
if (path !== '') create({ path })
|
||||
}
|
||||
|
||||
const confirmCreate = (): void => {
|
||||
if (normalizedWorkspaceName !== '' && !duplicateWorkspaceName) {
|
||||
create({ name: normalizedWorkspaceName })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu
|
||||
open={open}
|
||||
anchor={null}
|
||||
items={items}
|
||||
onSelect={handleSelect}
|
||||
onClose={onClose}
|
||||
portal
|
||||
getAnchorRect={getAnchorRect}
|
||||
/>
|
||||
{open && workspaceSnapshot.phase === 'pending' && <div className={css.menuStatus} role="status">Loading workspaces…</div>}
|
||||
<Modal
|
||||
open={modalKind === 'path'}
|
||||
onClose={closeModal}
|
||||
title="Use an existing folder"
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="outline" className={css.modalAction!} disabled={creating} onClick={closeModal}>Cancel</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
className={css.modalAction!}
|
||||
disabled={creating || pathDraft.trim() === ''}
|
||||
onClick={confirmPath}
|
||||
>
|
||||
Use folder
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<input
|
||||
className={css.modalInput}
|
||||
value={pathDraft}
|
||||
aria-label="Existing folder path"
|
||||
autoFocus
|
||||
disabled={creating}
|
||||
placeholder="/path/to/project"
|
||||
onChange={(event) => { setPathDraft(event.target.value) }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
confirmPath()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{creating && <div className={css.modalStatus} role="status">Creating workspace…</div>}
|
||||
{modalError !== null && <div className={css.modalError} role="alert">{modalError}</div>}
|
||||
</Modal>
|
||||
<Modal
|
||||
open={modalKind === 'create'}
|
||||
onClose={closeModal}
|
||||
title="Create a new workspace"
|
||||
description="The name is used for both the workspace and its new folder."
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="outline" className={css.modalAction!} disabled={creating} onClick={closeModal}>Cancel</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
className={css.modalAction!}
|
||||
disabled={creating || normalizedWorkspaceName === '' || duplicateWorkspaceName}
|
||||
onClick={confirmCreate}
|
||||
>
|
||||
Create workspace
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<input
|
||||
className={css.modalInput}
|
||||
value={workspaceName}
|
||||
aria-label="New workspace name"
|
||||
autoFocus
|
||||
disabled={creating}
|
||||
onChange={(event) => { setWorkspaceName(event.target.value); setModalError(null) }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
confirmCreate()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{creating && <div className={css.modalStatus} role="status">Creating workspace…</div>}
|
||||
{duplicateWorkspaceName && (
|
||||
<div className={css.modalError} role="alert">A workspace named “{normalizedWorkspaceName}” already exists.</div>
|
||||
)}
|
||||
{modalError !== null && <div className={css.modalError} role="alert">{modalError}</div>}
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
30
packages/client/ui-workspace/src/client/contract/slots.ts
Normal file
30
packages/client/ui-workspace/src/client/contract/slots.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Shared Workspace picker contract for the sidebar and page-local Session Intent hero
|
||||
* slots. Each runtime share provides its owner's popover controls plus the
|
||||
* global useWorkspaces hook; this package adds the injected Host Workspace
|
||||
* creation callback.
|
||||
*/
|
||||
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
// Type-only: pull both owner SlotMap merges into programs that resolve the
|
||||
// picker runtime union below.
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/**
|
||||
* Registrant-private injected share. Pick semantics remain in each owner's
|
||||
* onPick callback; this callback creates only the real Host Workspace. A type
|
||||
* alias supplies the implicit index signature required by the registry.
|
||||
*/
|
||||
export type WorkspacePickerInjected = {
|
||||
/** Explicitly create or adopt a real Workspace before targeting a Session. */
|
||||
createWorkspace(input: { name: string } | { path: string }): Promise<WorkspaceView>
|
||||
}
|
||||
|
||||
/**
|
||||
* Full picker props: either owner's runtime share, including useWorkspaces,
|
||||
* plus this package's injected creation callback.
|
||||
*/
|
||||
export type WorkspacePickerProps =
|
||||
(PropsRuntime<'sidebar.workspace'> | PropsRuntime<'conversation.empty.workspace'>)
|
||||
& WorkspacePickerInjected
|
||||
54
packages/client/ui-workspace/src/client/index.ts
Normal file
54
packages/client/ui-workspace/src/client/index.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Shared Workspace picker plugin, browser half. WorkspacePicker registers in
|
||||
* the sidebar and page-local Session Intent hero slots, reads real Host Workspaces
|
||||
* through the global useWorkspaces hook, and delegates selection semantics to
|
||||
* each owner. Its injected share creates a Workspace without creating a
|
||||
* Session. Export discipline: packages/client/AGENTS.md.
|
||||
*/
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { WorkspacePickerInjected } from './contract/slots.ts'
|
||||
import { WorkspacePicker } from './WorkspacePicker.tsx'
|
||||
|
||||
export type { WorkspacePickerInjected, WorkspacePickerProps } from './contract/slots.ts'
|
||||
|
||||
/**
|
||||
* Required services (cordis fiber inject). The target slot is declared by
|
||||
* the ui-sidebar apply, whose activation order relative to this one is NOT
|
||||
* constrained: dshClient.inject edges are informational (loading/prefetch
|
||||
* metadata, never apply sequencing) and the sidebar provides no waitable
|
||||
* service. apply therefore registers via declaration-aware deferral instead
|
||||
* of assuming order.
|
||||
*/
|
||||
export const inject = ['slots', 'workspaces']
|
||||
|
||||
/**
|
||||
* Register WorkspacePicker in both owner slots once their declarations are on
|
||||
* the ledger. The inject factory returns a plain Workspace creation callback;
|
||||
* data reads use the framework's global useWorkspaces hook.
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const injected = (): WorkspacePickerInjected => ({
|
||||
createWorkspace: input => ctx.workspaces.create(input),
|
||||
})
|
||||
// Declaration-aware registration: the sidebar's declaring apply may
|
||||
// activate after this one (entry activation order is unconstrained), and a
|
||||
// register into an undeclared slot throws. Register once the declaration
|
||||
// is on the ledger; the subscription also re-registers after an HMR
|
||||
// collapse re-declares the slot (the cascade disposed our entry with it).
|
||||
ctx.effect(() => {
|
||||
const slotNames = ['sidebar.workspace', 'conversation.empty.workspace'] as const
|
||||
const disposers = new Map<(typeof slotNames)[number], () => void>()
|
||||
const tryRegister = (name: (typeof slotNames)[number]): void => {
|
||||
if (ctx.slots.spec(name) === undefined) return
|
||||
if (ctx.slots.entries(name).some(e => e.component === WorkspacePicker)) return
|
||||
disposers.set(name, ctx.slots.register({ name, inject: injected }, WorkspacePicker))
|
||||
}
|
||||
const unsubscribers = slotNames.map(name => ctx.slots.subscribe(name, () => { tryRegister(name) }))
|
||||
for (const name of slotNames) tryRegister(name)
|
||||
return () => {
|
||||
for (const unsubscribe of unsubscribers) unsubscribe()
|
||||
for (const dispose of disposers.values()) dispose()
|
||||
}
|
||||
}, 'ui-workspace: picker registrations')
|
||||
}
|
||||
6
packages/client/ui-workspace/src/css-modules.d.ts
vendored
Normal file
6
packages/client/ui-workspace/src/css-modules.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
|
||||
declare module '*.css'
|
||||
9
packages/client/ui-workspace/src/index.ts
Normal file
9
packages/client/ui-workspace/src/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Workspace picker plugin, node half. Pure UI plugin: the empty apply exists
|
||||
* so the plugin appears in the host cordis.yml / Loader (load and lifecycle
|
||||
* follow the host; the browser half ships via exports["./client"], discovered
|
||||
* through the package.json dshClient declaration).
|
||||
*/
|
||||
|
||||
/** Host plugin body — no host-side behavior for the workspace picker plugin. */
|
||||
export function apply(): void {}
|
||||
32
packages/client/ui-workspace/src/invariant.ts
Normal file
32
packages/client/ui-workspace/src/invariant.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-workspace`.
|
||||
* @module @deepseek-ai/dsh-client-ui-workspace/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-workspace'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'client-ui-workspace-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: a pure-consumer plugin registering one presentational
|
||||
* component into two host-declared slots — its inject face is two stateless
|
||||
* RPC wrappers plus a create-and-open call; it emits no cordis events and
|
||||
* owns no cross-plugin mutable state.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
69
packages/client/ui-workspace/tests/apply.spec.ts
Normal file
69
packages/client/ui-workspace/tests/apply.spec.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client'
|
||||
import type { WorkspacePickerInjected } from '@deepseek-ai/dsh-client-ui-workspace/client'
|
||||
import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx'
|
||||
|
||||
async function bench() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
const create = vi.fn(async (input: { name: string } | { path: string }) => ({
|
||||
workspaceId: 'ws-new' as never,
|
||||
path: 'name' in input ? `/projects/${input.name}` : input.path,
|
||||
title: 'new', sessionIds: [], createdAt: '0', updatedAt: '0',
|
||||
}))
|
||||
ctx.provide('workspaces', { create })
|
||||
return { ctx, slots: ctx.get('slots') as SlotsService, create }
|
||||
}
|
||||
|
||||
function declare(slots: SlotsService, name: 'sidebar.workspace' | 'conversation.empty.workspace'): () => void {
|
||||
return slots.register(
|
||||
{ name: 'root', children: { [name]: { kind: 'single', scope: 'root' } } } as never,
|
||||
() => null,
|
||||
)
|
||||
}
|
||||
|
||||
function injectedOf(slots: SlotsService, name: 'sidebar.workspace' | 'conversation.empty.workspace'): WorkspacePickerInjected {
|
||||
const entry = slots.entries(name)[0]!
|
||||
return (entry.inject as () => WorkspacePickerInjected)()
|
||||
}
|
||||
|
||||
describe('ui-workspace apply', () => {
|
||||
it('declares the independent Workspace service', () => {
|
||||
expect(inject).toEqual(['slots', 'workspaces'])
|
||||
})
|
||||
|
||||
it('registers the shared picker for declarations that arrive before or after apply', async () => {
|
||||
const before = await bench()
|
||||
declare(before.slots, 'sidebar.workspace')
|
||||
await before.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
expect(before.slots.entries('sidebar.workspace')[0]!.component).toBe(WorkspacePicker)
|
||||
|
||||
const after = await bench()
|
||||
await after.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
declare(after.slots, 'conversation.empty.workspace')
|
||||
await Promise.resolve()
|
||||
expect(after.slots.entries('conversation.empty.workspace')[0]!.component).toBe(WorkspacePicker)
|
||||
})
|
||||
|
||||
it('routes name and path creation to WorkspacesService', async () => {
|
||||
const b = await bench()
|
||||
declare(b.slots, 'sidebar.workspace')
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const injected = injectedOf(b.slots, 'sidebar.workspace')
|
||||
await injected.createWorkspace({ name: 'project' })
|
||||
await injected.createWorkspace({ path: '/tmp/project' })
|
||||
expect(b.create).toHaveBeenNthCalledWith(1, { name: 'project' })
|
||||
expect(b.create).toHaveBeenNthCalledWith(2, { path: '/tmp/project' })
|
||||
})
|
||||
|
||||
it('unregisters picker entries on teardown', async () => {
|
||||
const b = await bench()
|
||||
declare(b.slots, 'sidebar.workspace')
|
||||
const fiber = b.ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
await fiber.dispose()
|
||||
expect(b.slots.entries('sidebar.workspace')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
18
packages/client/ui-workspace/tests/invariant.spec.ts
Normal file
18
packages/client/ui-workspace/tests/invariant.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import * as WorkspaceInvariant from '@deepseek-ai/dsh-client-ui-workspace/invariant'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
describe('invariant companion', () => {
|
||||
it('registers under the package name with an empty installer', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
await expect(ctx.plugin(WorkspaceInvariant).await()).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('node-half apply is a no-op host placeholder', async () => {
|
||||
const { apply } = await import('@deepseek-ai/dsh-client-ui-workspace')
|
||||
apply()
|
||||
expect(true).toBe(true) // reaching here without throw is the contract
|
||||
})
|
||||
})
|
||||
123
packages/client/ui-workspace/tests/workspace-picker.spec.tsx
Normal file
123
packages/client/ui-workspace/tests/workspace-picker.spec.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import type {
|
||||
SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const wid = (id: string) => id as WorkspaceId
|
||||
function workspace(id: string, title = id): WorkspaceView {
|
||||
return {
|
||||
workspaceId: wid(id), path: `/projects/${id}`, title, sessionIds: [],
|
||||
createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
}
|
||||
}
|
||||
const hook = <T,>(snapshot: T) => <S,>(selector: (state: T) => S): S => selector(snapshot)
|
||||
const sessions: SessionListState = {
|
||||
ids: [], byId: {}, current: undefined, intent: undefined, phase: 'ready',
|
||||
}
|
||||
const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => ({
|
||||
items, intent: undefined, state: 'idle', phase: 'ready', error: null, baselinesReady: true,
|
||||
recentWorkspaceId: items[0]?.workspaceId,
|
||||
})
|
||||
function anchor(): { current: HTMLElement } {
|
||||
const element = document.createElement('button')
|
||||
element.getBoundingClientRect = () => ({
|
||||
top: 10, left: 20, width: 30, height: 40, right: 50, bottom: 50,
|
||||
x: 20, y: 10, toJSON: () => ({}),
|
||||
})
|
||||
return { current: element }
|
||||
}
|
||||
|
||||
function mount(items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')], createWorkspace = vi.fn()) {
|
||||
const onPick = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
const view = render(
|
||||
<WorkspacePicker
|
||||
open
|
||||
anchorRef={anchor()}
|
||||
useSessions={hook(sessions)}
|
||||
useWorkspaces={hook(workspaceState(items))}
|
||||
onPick={onPick}
|
||||
onClose={onClose}
|
||||
createWorkspace={createWorkspace}
|
||||
/>,
|
||||
)
|
||||
return { view, onPick, onClose, createWorkspace }
|
||||
}
|
||||
|
||||
function chooseCreateItem(name: 'Use an existing folder' | 'Create a new workspace'): void {
|
||||
const parent = screen.getByRole('menuitem', { name: 'Create workspace' })
|
||||
fireEvent.mouseEnter(parent.parentElement as HTMLElement)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name }))
|
||||
}
|
||||
|
||||
describe('WorkspacePicker', () => {
|
||||
it('lists real Workspaces from useWorkspaces and forwards a selected id', () => {
|
||||
const b = mount()
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Alpha' }))
|
||||
expect(b.onPick).toHaveBeenCalledWith(wid('alpha'))
|
||||
})
|
||||
|
||||
it('creates a real Workspace from a name and focuses its frontend Session target', async () => {
|
||||
const created = workspace('new', 'New')
|
||||
const createWorkspace = vi.fn(async () => created)
|
||||
const b = mount([], createWorkspace)
|
||||
chooseCreateItem('Create a new workspace')
|
||||
const input = screen.getByLabelText('New workspace name')
|
||||
fireEvent.change(input, { target: { value: 'project-one' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
|
||||
expect(createWorkspace).toHaveBeenCalledWith({ name: 'project-one' })
|
||||
await waitFor(() => { expect(b.onPick).toHaveBeenCalledWith(created.workspaceId) })
|
||||
})
|
||||
|
||||
it('adopts an existing path through the same immediate create action', async () => {
|
||||
const created = workspace('adopted')
|
||||
const createWorkspace = vi.fn(async () => created)
|
||||
const b = mount([], createWorkspace)
|
||||
chooseCreateItem('Use an existing folder')
|
||||
fireEvent.change(screen.getByLabelText('Existing folder path'), { target: { value: ' /tmp/project ' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Use folder' }))
|
||||
expect(createWorkspace).toHaveBeenCalledWith({ path: '/tmp/project' })
|
||||
await waitFor(() => { expect(b.onPick).toHaveBeenCalledWith(created.workspaceId) })
|
||||
})
|
||||
|
||||
it('blocks a create-new name already present in the Workspace list', () => {
|
||||
const b = mount([workspace('alpha', 'Alpha')])
|
||||
chooseCreateItem('Create a new workspace')
|
||||
fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: ' Alpha ' } })
|
||||
expect(screen.getByRole('alert').textContent).toBe('A workspace named “Alpha” already exists.')
|
||||
expect((screen.getByRole('button', { name: 'Create workspace' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
fireEvent.keyDown(screen.getByLabelText('New workspace name'), { key: 'Enter' })
|
||||
expect(b.createWorkspace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('exposes creation phase and error text while retaining the modal for retry', async () => {
|
||||
let reject!: (reason: unknown) => void
|
||||
const pending = new Promise<WorkspaceView>((_resolve, rejectPromise) => { reject = rejectPromise })
|
||||
const b = mount([], vi.fn(() => pending))
|
||||
chooseCreateItem('Create a new workspace')
|
||||
fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: 'broken' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
|
||||
expect(screen.getByRole('status').textContent).toBe('Creating workspace…')
|
||||
await act(async () => { reject(new Error('disk unavailable')); await pending.catch(() => {}) })
|
||||
expect(screen.getByRole('alert').textContent).toBe('Workspace creation failed: disk unavailable')
|
||||
expect(b.view.getByRole('dialog')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows list loading through a stable status surface', () => {
|
||||
const state: WorkspaceListState = {
|
||||
...workspaceState([]), phase: 'pending', state: 'loading', baselinesReady: false,
|
||||
}
|
||||
render(
|
||||
<WorkspacePicker
|
||||
open anchorRef={anchor()} useSessions={hook(sessions)} useWorkspaces={hook(state)}
|
||||
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByRole('status').textContent).toBe('Loading workspaces…')
|
||||
})
|
||||
})
|
||||
33
packages/client/ui-workspace/tsconfig.json
Normal file
33
packages/client/ui-workspace/tsconfig.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.client.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../ui-primitives"
|
||||
},
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
{
|
||||
"path": "../ui-sidebar"
|
||||
},
|
||||
{
|
||||
"path": "../ui-conversation"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
packages/client/ui-workspace/tsdown.config.ts
Normal file
3
packages/client/ui-workspace/tsdown.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { clientBundle } from '../tsdown.client.ts'
|
||||
|
||||
export default clientBundle('@deepseek-ai/dsh-client-ui-workspace', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
@@ -164,7 +164,7 @@ class SlotErrorBoundary extends Component<
|
||||
|
||||
/**
|
||||
* Standard-kit synthesis shared by both scope branches: the global
|
||||
* useSessions hook, the session pair, the store pair when declared, the
|
||||
* useSessions/useWorkspaces hooks, the session pair, the store pair when declared, the
|
||||
* renderSlot binding when children are declared, and the SessionProvider
|
||||
* seat when the children declare a session-scope slot. Hosts hand out BARE
|
||||
* observable sources (hooks never cross the host contract); every hook is
|
||||
@@ -174,7 +174,10 @@ class SlotErrorBoundary extends Component<
|
||||
function standardKit(host: SlotRendererHost, entry: StoredEntry, cell: SessionCell | undefined): {
|
||||
kit: InjectedProps; actions: object | undefined
|
||||
} {
|
||||
const kit: InjectedProps = { useSessions: observableHook(host.sessions.list) }
|
||||
const kit: InjectedProps = {
|
||||
useSessions: observableHook(host.sessions.list),
|
||||
useWorkspaces: observableHook(host.workspaces.list),
|
||||
}
|
||||
if (cell !== undefined) {
|
||||
kit['useSession'] = observableHook(cell.session)
|
||||
kit['sessionId'] = cell.sessionId
|
||||
|
||||
@@ -38,6 +38,9 @@ function hostOver(core: SlotCore): SlotRendererHost {
|
||||
current: { getSnapshot: () => undefined, subscribe: () => () => {} },
|
||||
cell: () => undefined,
|
||||
},
|
||||
workspaces: {
|
||||
list: { getSnapshot: () => ({}), subscribe: () => () => {} },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -81,6 +81,7 @@ function makeHost() {
|
||||
const live = new Set<StoredEntry>()
|
||||
const storeCache = new Map<StoredEntry, Map<string, StoreInstanceLike>>()
|
||||
const list = observable<{ ids: string[] }>({ ids: [] })
|
||||
const workspaces = observable<{ ids: string[] }>({ ids: [] })
|
||||
const current = observable<string | undefined>(undefined)
|
||||
const cells = new Map<string, SessionCell>()
|
||||
|
||||
@@ -122,10 +123,12 @@ function makeHost() {
|
||||
current,
|
||||
cell: (id) => cells.get(id),
|
||||
},
|
||||
workspaces: { list: workspaces },
|
||||
}
|
||||
return {
|
||||
host,
|
||||
list,
|
||||
workspaces,
|
||||
current,
|
||||
declare: (key: string, spec: DeclaredSpec) => { specs.set(key, spec); bump(key) },
|
||||
add: (key: string, partial: Omit<StoredEntry, 'options'> & { options?: StoredEntry['options'] }) => {
|
||||
@@ -483,6 +486,19 @@ describe('standard-kit synthesis', () => {
|
||||
expect(view.container.textContent).toBe('2')
|
||||
})
|
||||
|
||||
it('delivers a live useWorkspaces hook to every slot component', () => {
|
||||
const h = makeHost()
|
||||
h.declare('k.single', SINGLE_ROOT)
|
||||
h.add('k.single', {
|
||||
component: ({ useWorkspaces }: { useWorkspaces: <S>(sel: (s: { ids: string[] }) => S) => S }) =>
|
||||
<b>{useWorkspaces((s) => s.ids.length)}</b>,
|
||||
})
|
||||
const { view } = mountRoot(h, { 'k.single': SINGLE_ROOT }, (renderSlot) => renderSlot('k.single', {}))
|
||||
expect(view.container.textContent).toBe('0')
|
||||
act(() => { h.workspaces.set({ ids: ['w1'] }) })
|
||||
expect(view.container.textContent).toBe('1')
|
||||
})
|
||||
|
||||
it('delivers the session pair (bound useSession + sessionId) under SessionProvider', () => {
|
||||
const h = makeHost()
|
||||
h.declare('k.session', SINGLE_SESSION)
|
||||
@@ -710,6 +726,7 @@ describe('inject: execution point, parameter derivation, cache granularity', ()
|
||||
(renderSlot) => renderSlot('k.single', { owner: 'owner', shared: 'owner' }))
|
||||
const props = seen.at(-1)!
|
||||
expect(typeof props['useSessions']).toBe('function') // kit always present
|
||||
expect(typeof props['useWorkspaces']).toBe('function')
|
||||
expect(props['fromInject']).toBe('inject')
|
||||
expect(props['owner']).toBe('owner')
|
||||
expect(props['shared']).toBe('owner') // owner overrides inject
|
||||
|
||||
@@ -52,6 +52,7 @@ function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.Rea
|
||||
current,
|
||||
cell: (id) => cells.get(id),
|
||||
},
|
||||
workspaces: { list: observable<unknown>({ items: [] }) },
|
||||
}
|
||||
return {
|
||||
host,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user