Merge remote-tracking branch 'origin/master' into docs/post-v3-release-proofreading

# Conflicts:
#	.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md
#	.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md
#	.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md
#	.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.zh.md
#	docs/user/develop/basic/index.i18n.yaml
#	docs/user/develop/basic/index.zh.md
#	docs/user/guide/config.i18n.yaml
#	docs/user/guide/config.zh.md
#	docs/user/guide/providers.i18n.yaml
#	docs/user/guide/providers.zh.md
#	packages/bundle/base/README.i18n.yaml
#	packages/bundle/base/README.zh.md
#	packages/host/apiproxy/README.i18n.yaml
#	packages/host/apiproxy/README.zh.md
This commit is contained in:
xjt
2026-08-12 12:59:38 +08:00
200 changed files with 3714 additions and 1718 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
README.md: f4823f58ec79df0cbccfff0a08d9bb59b9a3ac8d
README.zh.md: c60f0be261bae6123e2202e4a0e05e556723e5e9
README.md: 44fd9b84e45c0a4d7f5846ce9ba040ef41b8b446
README.zh.md: 46120665e2720df7c0331b6bdea957316476a4a6

View File

@@ -16,7 +16,7 @@ The callback returns one synchronous disposer or an iterable of disposers. A gen
## Workspace and Session lists
Workspace and Session lists have independent monotone `pending``ready` baseline phases and separate refresh activity/error state. Incremental upsert/removal frames and unary mutation echoes 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. Removed Workspace ids retain process-local tombstones so late changed frames cannot resurrect them; reconnect still takes `workspace.list` as the baseline. Workspace recency is derived only after both baselines are ready and never changes Workspace list order.
Workspace and Session lists have independent monotone `pending``ready` baseline phases and separate refresh activity/error state. Incremental upsert/removal/order frames and unary mutation echoes arriving during a list request replay over its response. Every successful Workspace baseline re-establishes Host-durable Workspace order so reconnects adopt changes committed while this client was offline. `WorkspacesService.insertBefore` installs an optimistic order immediately; only the latest unary echo may replace it, a newer Host order frame outranks an older echo, and a latest rejected request restores the last Host-confirmed order rather than an earlier uncommitted drag. Removed Workspace ids retain process-local tombstones so late changed frames cannot resurrect them. Workspace recency is derived only after both baselines are ready and never changes Workspace list order.
`SessionSummary.pendingInteraction` classifies the live user action blocking a Session as `approval`, `plan-review`, or `question`. `SessionManager` tracks answerable requested/resolved mux frames by their stable request identities even before a Session object is instantiated; pre-instantiation buffering retains every live request, replaces replay duplicates, and removes resolved requests so the list status always has a matching answerable `PendingWait` when the Session is opened. The first pending question takes presentation priority over concurrent approvals to match composer routing, while only a request that satisfies the plan-review composer's binary rendering constraints keeps the distinct `plan-review` status. The state is connection-generation scoped: disconnect clears it, and mux-open replay restores only requests that remain pending.
@@ -34,7 +34,7 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
## New Session and the blank mirror
`WorkspacesService.connectWorkspace(workspaceId)` resolves the session a New Session flow lands in: it reuses the workspace's existing blank session from the list mirror (`blank && cwd == workspace.path && sessionIds.includes(id)` — the host's own membership rule, never cwd alone, so a cwd-matching unaccounted blank session is never hijacked) or calls `session.create({workspaceId})`, returning the session id for the caller to open. `SessionSummary.blank` mirrors the host's derived empty-log bit and only ever lowers on the client: seeded by `session.list` / the `host/session-added` frame, flipped false by the first ACCEPTED local `prompt()` (on the RPC success response — acceptance proves the user message is in the host log; a rejected first prompt keeps the session blank and reusable) and by any `running: true` status frame, re-aligned by every list re-pull. List surfaces hide blank rows; the store carries every row. `SessionsService.create` accepts an optional caller-preallocated SessionId and throws `SessionCreateError` (carrying `requestedSessionId`) on failure.
`WorkspacesService.connectWorkspace(workspaceId)` resolves the session a New Session flow lands in: it reuses the workspace's existing blank session from the list mirror (`blank && cwd == workspace.path && sessionIds.includes(id)` — the host's own membership rule, never cwd alone, so a cwd-matching unaccounted blank session is never hijacked) or calls `session.create({workspaceId})`, returning the session id for the caller to open. The shared `startSession` action targets an explicit Workspace first, then the current Session's Workspace, then the derived recent Workspace; with no Workspace it clears into the blank New Session page. `SessionSummary.blank` mirrors the host's derived empty-log bit and only ever lowers on the client: seeded by `session.list` / the `host/session-added` frame, flipped false by the first ACCEPTED local `prompt()` (on the RPC success response — acceptance proves the user message is in the host log; a rejected first prompt keeps the session blank and reusable) and by any `running: true` status frame, re-aligned by every list re-pull. List surfaces hide blank rows; the store carries every row. `SessionsService.create` accepts an optional caller-preallocated SessionId and throws `SessionCreateError` (carrying `requestedSessionId`) on failure.
`Session.composerPhase` treats any visible non-command Chat Node as conversation content, so a client plugin can project durable human input without opening a turn while a window containing only generic command rows retains the Host blank posture. List hiding and blank-session reuse still follow the Host blank bit. A history window that lacks the plugin-owned input Node returns to that blank posture until an older page restores it.

View File

@@ -16,7 +16,7 @@
## Workspace 与 Session 列表
Workspace 和 Session 列表各自具有单调的 `pending``ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量插入或更新/移除帧与一元变更回显会在其响应之上回放。第一次成功的基线建立 Host 顺序;后续刷新更新行和成员关系,但不改变已经显示的标识之间的相对顺序。已移除的 Workspace id 会保留进程本地删除标记,避免延迟到达的 changed 帧将其复活;重连仍以 `workspace.list` 作为基线。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。
Workspace 和 Session 列表各自具有单调的 `pending``ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量插入或更新/移除/顺序帧与一元变更回显会在其响应之上回放。次成功的 Workspace 基线都会重新建立 Host 持久 Workspace 顺序,因此重连会接纳该客户端离线期间提交的变更。`WorkspacesService.insertBefore` 会立即安装乐观顺序;只有最新一元回声可以替换它,更新的 Host 顺序帧优先于旧回声,而最新请求被拒时会恢复最近一次由 Host 确认的顺序,不会恢复更早且尚未提交的拖拽。已移除的 Workspace id 会保留进程本地删除标记,避免延迟到达的 changed 帧将其复活。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。
`SessionSummary.pendingInteraction` 将阻塞 Session 的实时用户操作分类为 `approval``plan-review``question``SessionManager` 依据稳定的请求标识跟踪可应答请求的 requested/resolved mux 帧,即使 `Session` 对象尚未实例化也不例外;实例化前的缓冲会保留每个仍有效的请求,替换回放产生的重复项,并移除已解决的请求,因此打开 Session 时,列表状态始终有一个对应的可应答 `PendingWait`。审批与问题并发时,第一个 pending 问题具有更高的呈现优先级,以匹配 composer 路由;只有满足 plan-review composer 二元呈现约束的请求才会保留独立的 `plan-review` 状态。该状态的作用域限定在连接代次内断连时清除mux 打开时的回放只恢复仍处于 pending 的请求。
@@ -34,7 +34,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
## New Session 与 blank 镜像
`WorkspacesService.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 workspace 的既有空会话(`blank && cwd == workspace.path && sessionIds.includes(id)`——host 自己的成员规则,绝不只按 cwd避免劫持 cwd 匹配但未入账的空白会话),未命中则调用 `session.create({workspaceId})`,返回会话 id 由调用方 open。`SessionSummary.blank` 镜像主机派生的空日志位,在客户端只降不升:由 `session.list``host/session-added` 帧播种,本地首次获 Host 接受的 `prompt()`RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用与任何 `running: true` 状态帧翻为 false每次列表重拉重新对齐。列表界面隐藏 blank 行store 保留全部行。`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。
`WorkspacesService.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 workspace 的既有空会话(`blank && cwd == workspace.path && sessionIds.includes(id)`——host 自己的成员规则,绝不只按 cwd避免劫持 cwd 匹配但未入账的空白会话),未命中则调用 `session.create({workspaceId})`,返回会话 id 由调用方 open。共享的 `startSession` 操作优先使用明确指定的 Workspace其次使用当前 Session 所属 Workspace再其次使用派生的最近活跃 Workspace一个 Workspace 都没有时则清空选择,进入空白 New Session 页面。`SessionSummary.blank` 镜像主机派生的空日志位,在客户端只降不升:由 `session.list``host/session-added` 帧播种,本地首次获 Host 接受的 `prompt()`RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用与任何 `running: true` 状态帧翻为 false每次列表重拉重新对齐。列表界面隐藏 blank 行store 保留全部行。`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。
`Session.composerPhase` 把任何可见的非命令 Chat Node 视为对话内容,因此客户端插件可以在不打开轮次的情况下投影持久用户输入,而仅包含通用命令行的窗口仍保持 Host blank 状态。列表隐藏和空白会话复用仍遵循 Host blank 位。缺少插件输入 Node 的历史窗口会恢复该空白状态,直到加载更早页面后该 Node 恢复。

View File

@@ -21,9 +21,11 @@ export interface IWorkspaces {
*/
connectWorkspace(workspaceId: WorkspaceId): Promise<SessionId>
/**
* The New Session flow: connect the target (or recent) Workspace and open
* the resulting session; failures surface on the session list state.
* @param workspaceId - explicit target; omitted uses the recency projection.
* The New Session flow: connect the explicit, current-Session, or recent
* Workspace and open the resulting session; failures surface on the session
* list state.
* @param workspaceId - explicit target; omitted inherits the current
* Session's Workspace before falling back to the recency projection.
*/
startSession(workspaceId?: WorkspaceId): void
/**
@@ -68,6 +70,12 @@ export interface IWorkspaces {
* @param workspaceId - target workspace.
*/
delete(workspaceId: WorkspaceId): Promise<void>
/**
* Move a Workspace within the registry display order.
* @param workspaceId - Workspace to move.
* @param beforeWorkspaceId - Anchor workspace; omitted appends.
*/
insertBefore(workspaceId: WorkspaceId, beforeWorkspaceId?: WorkspaceId): Promise<void>
/**
* Move an accounted session within/into a Workspace's ordered list.
* @param workspaceId - target workspace.

View File

@@ -72,6 +72,7 @@ type SessionListMutation =
| { kind: 'upsert'; summary: SessionSummary }
| { kind: 'remove'; sessionId: SessionId }
| { kind: 'status'; sessionId: SessionId; running: boolean }
| { kind: 'activity'; sessionId: SessionId; updatedAt: number }
/** Local first-send flip: the sender clears blank without waiting for a host frame. */
| { kind: 'engaged'; sessionId: SessionId }
@@ -682,6 +683,16 @@ export class SessionManager {
handleMuxEnvelope(envelope: RpcRequest<MuxFrame>): void {
const frame = envelope.payload
if (frame.type === 'stream/error') return // Controller already treats this as stream failure
if (
frame.type === 'session/event'
&& frame.event.type === 'user/message'
&& frame.event.data.source.kind === 'user'
) {
// session.list supplies the cold baseline, while a direct prompt or an
// admitted steer advances it between pulls. Max keeps replayed or
// repaired older user messages from moving the row backwards.
this.recordMutation({ kind: 'activity', sessionId: frame.sessionId, updatedAt: frame.event.time })
}
if (frame.type === 'session/projection') {
// Finished host-computed value: land it in the resident store whether or
// not the Session is instantiated (list rows read the 'title' key). The
@@ -1101,6 +1112,11 @@ function applyMutation(summaries: readonly SessionSummary[], mutation: SessionLi
&& (summary.running !== mutation.running || (mutation.running && summary.blank))
? { ...summary, running: mutation.running, blank: summary.blank && !mutation.running }
: summary)
case 'activity':
return summaries.map(summary => summary.sessionId === mutation.sessionId
&& mutation.updatedAt > summary.updatedAt
? { ...summary, updatedAt: mutation.updatedAt }
: summary)
case 'engaged':
return summaries.map(summary => summary.sessionId === mutation.sessionId && summary.blank
? { ...summary, blank: false }

View File

@@ -4,7 +4,6 @@ import type {
HostFrame, IApiClient, RpcError, RpcRequest, RpcResult, SessionId, WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-api-remotes/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 } from './workspace.ts'
@@ -30,6 +29,7 @@ export interface WorkspaceListSnapshot {
type WorkspaceDelta =
| { type: 'upsert'; workspace: WorkspaceView }
| { type: 'remove'; workspaceId: WorkspaceId }
| { type: 'order'; workspaceIds: readonly WorkspaceId[] }
/** Workspace object cluster driven by one list baseline and changed-frame upserts. */
export class WorkspaceManager {
@@ -51,6 +51,12 @@ export class WorkspaceManager {
* mirror of replaying refreshFrames over the item baseline.
*/
private archivedSupersedesRefresh = false
/** Latest local reorder request; only its unary echo may install order. */
private orderRequestGeneration = 0
/** Increments on order frames so a later remote commit outranks an older unary echo. */
private orderFrameGeneration = 0
/** Last complete order accepted from a Host baseline, frame, or current unary echo. */
private committedOrder: WorkspaceId[] = []
/**
* Ids this process has seen removed, kept for the connection's lifetime so
* a late changed frame or a stale baseline row cannot resurrect a deleted
@@ -72,16 +78,15 @@ export class WorkspaceManager {
/**
* 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.
* Host order; later responses re-establish the durable order so reconnects
* adopt reorders committed while this client was offline. 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: WorkspaceDelta[] = []
this.refreshFrames = frames
this.notifier.markDirty()
@@ -89,9 +94,7 @@ export class WorkspaceManager {
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)
let items = result.value.items
items = items.filter(workspace => !this.removedIds.has(workspace.workspaceId))
for (const delta of frames) items = applyWorkspaceDelta(items, delta)
this.installViews(items)
@@ -157,6 +160,44 @@ export class WorkspaceManager {
return result
}
/**
* Move a Workspace within the registry display order and install the full
* returned order without waiting for the Host frame.
* @param workspaceId - Workspace to move.
* @param beforeWorkspaceId - Anchor workspace; omitted appends.
* @returns the wire result.
*/
async insertBefore(
workspaceId: WorkspaceId,
beforeWorkspaceId?: WorkspaceId,
): Promise<RpcResult<{ workspaceIds: WorkspaceId[] }>> {
const requestGeneration = ++this.orderRequestGeneration
const frameGeneration = this.orderFrameGeneration
const localOrder = this.itemViews().map(workspace => workspace.workspaceId)
this.installOrder(insertIdBefore(localOrder, workspaceId, beforeWorkspaceId))
let result: RpcResult<{ workspaceIds: WorkspaceId[] }>
try {
;({ result } = await this.api.workspace.insertBefore({
workspaceId,
...beforeWorkspaceId === undefined ? {} : { beforeWorkspaceId },
}))
} catch (error) {
if (requestGeneration === this.orderRequestGeneration
&& frameGeneration === this.orderFrameGeneration) {
this.installOrder(this.committedOrder)
}
throw error
}
if (result.ok && requestGeneration === this.orderRequestGeneration
&& frameGeneration === this.orderFrameGeneration) {
this.installOrder(result.value.workspaceIds, true)
} else if (!result.ok && requestGeneration === this.orderRequestGeneration
&& frameGeneration === this.orderFrameGeneration) {
this.installOrder(this.committedOrder)
}
return result
}
/**
* Move a session within its Workspace's manual order, then publish the
* returned snapshot without waiting for the changed frame.
@@ -198,6 +239,10 @@ export class WorkspaceManager {
handleHostEnvelope(envelope: RpcRequest<HostFrame>): void {
if (envelope.payload.type === 'host/workspace-changed') this.upsert(envelope.payload.workspace)
else if (envelope.payload.type === 'host/workspace-removed') this.remove(envelope.payload.workspaceId)
else if (envelope.payload.type === 'host/workspace-order-changed') {
this.orderFrameGeneration++
this.installOrder(envelope.payload.workspaceIds, true)
}
else if (envelope.payload.type === 'host/archived-sessions-changed') {
this.installArchived(envelope.payload.archivedSessionIds)
}
@@ -249,6 +294,24 @@ export class WorkspaceManager {
this.notifier.markDirty()
}
/** Reorder known Workspace objects, optionally recording a Host-committed sequence. */
private installOrder(workspaceIds: readonly WorkspaceId[], committed = false): void {
if (committed) {
this.refreshFrames?.push({ type: 'order', workspaceIds })
this.committedOrder = [...workspaceIds]
}
const rank = new Map(workspaceIds.map((id, index) => [id, index]))
const items = [...this.items].sort((left, right) => {
const leftId = left.getSnapshot().view?.workspaceId
const rightId = right.getSnapshot().view?.workspaceId
return (leftId === undefined ? Number.MAX_SAFE_INTEGER : rank.get(leftId) ?? Number.MAX_SAFE_INTEGER)
- (rightId === undefined ? Number.MAX_SAFE_INTEGER : rank.get(rightId) ?? Number.MAX_SAFE_INTEGER)
})
if (items.every((item, index) => item === this.items[index])) return
this.items = items
this.notifier.markDirty()
}
/** Upsert one Host view, optionally retaining the local object that materialized it. */
private upsert(view: WorkspaceView, identity?: Workspace): void {
if (this.removedIds.has(view.workspaceId)) return
@@ -259,6 +322,9 @@ export class WorkspaceManager {
// late unary response cannot roll back a newer frame.
const installed = index === -1 ? undefined : this.items[index]?.getSnapshot().view
if (installed !== undefined && Date.parse(view.updatedAt) < Date.parse(installed.updatedAt)) return
if (!this.committedOrder.includes(view.workspaceId)) {
this.committedOrder = [view.workspaceId, ...this.committedOrder]
}
if (identity !== undefined) {
this.items = index === -1
? [identity, ...this.items]
@@ -276,6 +342,7 @@ export class WorkspaceManager {
private remove(workspaceId: WorkspaceId, direct = false): void {
this.refreshFrames?.push({ type: 'remove', workspaceId })
this.removedIds.add(workspaceId)
this.committedOrder = this.committedOrder.filter(id => id !== workspaceId)
const items = this.items.filter(item =>
item.getSnapshot().view?.workspaceId !== workspaceId)
if (items.length === this.items.length) {
@@ -309,6 +376,7 @@ export class WorkspaceManager {
installed.set(view.workspaceId, workspace)
}
this.items = [...installed.values()]
this.committedOrder = views.map(view => view.workspaceId)
}
private itemViews(): readonly WorkspaceView[] {
@@ -332,7 +400,26 @@ function upsertWorkspace(items: readonly WorkspaceView[], workspace: WorkspaceVi
/** Replay one ordered delta over a baseline: upsert in place, or drop the removed id. */
function applyWorkspaceDelta(items: readonly WorkspaceView[], delta: WorkspaceDelta): WorkspaceView[] {
return delta.type === 'upsert'
? upsertWorkspace(items, delta.workspace)
: items.filter(workspace => workspace.workspaceId !== delta.workspaceId)
if (delta.type === 'upsert') return upsertWorkspace(items, delta.workspace)
if (delta.type === 'remove') {
return items.filter(workspace => workspace.workspaceId !== delta.workspaceId)
}
const rank = new Map(delta.workspaceIds.map((id, index) => [id, index]))
return [...items].sort((left, right) =>
(rank.get(left.workspaceId) ?? Number.MAX_SAFE_INTEGER)
- (rank.get(right.workspaceId) ?? Number.MAX_SAFE_INTEGER))
}
/** Move one known id before an optional anchor; unknown ids leave the order unchanged. */
function insertIdBefore(
ids: readonly WorkspaceId[],
id: WorkspaceId,
beforeId?: WorkspaceId,
): WorkspaceId[] {
if (!ids.includes(id) || (beforeId !== undefined && !ids.includes(beforeId)) || beforeId === id) {
return [...ids]
}
const without = ids.filter(candidate => candidate !== id)
const at = beforeId === undefined ? without.length : without.indexOf(beforeId)
return [...without.slice(0, at), id, ...without.slice(at)]
}

View File

@@ -167,14 +167,20 @@ export class WorkspacesService implements IWorkspaces {
/**
* The shared New Session action behind the shell entry points (sidebar
* button, workspace browser): resolve the target Workspace — explicit wins,
* else the recent-Workspace projection — connect its blank session and
* navigate there; with no Workspace at all, clear the selection into the
* New Session view state. Connect failures are non-fatal (console
* diagnostics; the current view stays usable).
* then the current Session's Workspace, then the recent-Workspace
* projection — connect its blank session and navigate there; with no
* Workspace at all, clear the selection into the New Session view state.
* Connect failures are non-fatal (console diagnostics; the current view
* stays usable).
* @param workspaceId - explicit target Workspace for scoped actions.
*/
startSession(workspaceId?: WorkspaceId): void {
const target = workspaceId ?? this.list.getSnapshot().recentWorkspaceId
const workspace = this.list.getSnapshot()
const current = this.sessions.list.getSnapshot().current
const currentWorkspaceId = current === undefined
? undefined
: workspace.items.find(item => item.sessionIds.includes(current))?.workspaceId
const target = workspaceId ?? currentWorkspaceId ?? workspace.recentWorkspaceId
if (target === undefined) {
this.sessions.clear()
return
@@ -265,6 +271,16 @@ export class WorkspacesService implements IWorkspaces {
if (!result.ok) throw new Error(`workspace delete failed: ${result.error.code}: ${result.error.message}`)
}
/**
* Move a Workspace within the durable registry display order.
* @param workspaceId - Workspace to move.
* @param beforeWorkspaceId - Anchor workspace; omitted appends.
*/
async insertBefore(workspaceId: WorkspaceId, beforeWorkspaceId?: WorkspaceId): Promise<void> {
const result = await this.manager.insertBefore(workspaceId, beforeWorkspaceId)
if (!result.ok) throw new Error(`workspace reorder failed: ${result.error.code}: ${result.error.message}`)
}
/**
* Archive a session into the registry-global set. Clearing an archived
* current selection is the projection sweep's job (one rule for the local

View File

@@ -195,6 +195,9 @@ export class FakeApiClient implements IApiClient {
onWorkspaceDelete: (payload: unknown) => Promise<RpcResponse<{ deleted: true }>> =
() => Promise.resolve(ok({ deleted: true }))
onWorkspaceInsertBefore: (payload: unknown) => Promise<RpcResponse<{ workspaceIds: WorkspaceId[] }>> =
() => Promise.resolve(ok({ workspaceIds: [] }))
onWorkspaceInsertSessionBefore: (payload: unknown) => Promise<RpcResponse<{ workspace: WorkspaceView }>> =
() => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') }))
@@ -210,6 +213,8 @@ export class FakeApiClient implements IApiClient {
create: (payload: unknown) => this.record('workspace.create', payload, this.onWorkspaceCreate(payload)),
rename: (payload: unknown) => this.record('workspace.rename', payload, this.onWorkspaceRename(payload)),
delete: (payload: unknown) => this.record('workspace.delete', payload, this.onWorkspaceDelete(payload)),
insertBefore: (payload: unknown) =>
this.record('workspace.insertBefore', payload, this.onWorkspaceInsertBefore(payload)),
insertSessionBefore: (payload: unknown) =>
this.record('workspace.insertSessionBefore', payload, this.onWorkspaceInsertSessionBefore(payload)),
archiveSession: (payload: unknown) =>

View File

@@ -7,7 +7,7 @@ import { describe, expect, it, vi } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
import { SessionManager } from '../src/client/sessions/manager.ts'
import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts'
import { entries, plainTurn } from './event-script.client.ts'
import { entries, ev, plainTurn } from './event-script.client.ts'
const S1 = 'fk-m1' as SessionId
const S2 = 'fk-m2' as SessionId
@@ -113,6 +113,46 @@ describe('list lifecycle', () => {
expect(manager.getListSnapshot().items.map(item => item.sessionId)).toEqual([S2, S1])
})
it('advances list activity only for direct user messages', async () => {
const api = new FakeApiClient()
api.onList = () => Promise.resolve(ok({ items: [summary(S1)] as never[] }))
const manager = new SessionManager(api, fakeRemote())
await manager.refreshList()
// Both a new prompt and an admitted steer land as a user-sourced message.
const activity = { ...ev.user(10, 'new'), time: 500 }
manager.handleMuxEnvelope({
rpcId: 'activity' as never,
payload: { type: 'session/event', sessionId: S1, event: activity },
})
expect(manager.getListSnapshot().items[0]?.updatedAt).toBe(500)
manager.handleMuxEnvelope({
rpcId: 'older' as never,
payload: { type: 'session/event', sessionId: S1, event: { ...activity, time: 400 } },
})
manager.handleMuxEnvelope({
rpcId: 'assistant' as never,
payload: { type: 'session/event', sessionId: S1, event: { ...ev.assistant(11, 0, 'reply'), time: 600 } },
})
const injected = ev.user(12, 'context')
if (injected.type !== 'user/message') throw new Error('user builder returned another event type')
manager.handleMuxEnvelope({
rpcId: 'injected' as never,
payload: {
type: 'session/event',
sessionId: S1,
event: {
...injected,
time: 700,
data: { ...injected.data, source: { kind: 'plugin', plugin: 'test' } },
},
},
})
expect(manager.getListSnapshot().items[0]?.updatedAt).toBe(500)
})
it('keeps the error in the list snapshot on failure', async () => {
const api = new FakeApiClient()
api.onList = () => Promise.resolve(err({ code: 'internal', message: 'boom', details: {} }))

View File

@@ -1,5 +1,5 @@
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-remotes/client'
import { SessionsService } from '../src/client/sessions/service.ts'
import { WorkspaceManager } from '../src/client/workspaces/manager.ts'
@@ -17,7 +17,7 @@ function workspace(id: string, sessionIds: SessionId[] = [], createdAt = '2026-0
}
describe('WorkspaceManager', () => {
it('replays changed frames over hydration and keeps established order on refresh', async () => {
it('replays changed frames over hydration and adopts the durable order on refresh', async () => {
const api = new FakeApiClient()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceList']>>>()
api.onWorkspaceList = () => gate.promise
@@ -36,7 +36,7 @@ describe('WorkspaceManager', () => {
items: [workspace('old'), workspace('new')] as never[],
}))
await manager.refresh()
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['new', 'old'])
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['old', 'new'])
})
it('single-flights refreshes and exposes result and transport failures independently of readiness', async () => {
@@ -77,6 +77,73 @@ describe('WorkspaceManager', () => {
})
})
it('reorders optimistically while newer Host frames outrank unary echoes and failures roll back', async () => {
const api = new FakeApiClient()
api.onWorkspaceList = () => Promise.resolve(ok({
items: [workspace('one'), workspace('two'), workspace('three')] as never[],
}))
const manager = new WorkspaceManager(api)
await manager.refresh()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceInsertBefore']>>>()
api.onWorkspaceInsertBefore = () => gate.promise
const pending = manager.insertBefore(wid('three'), wid('one'))
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['three', 'one', 'two'])
manager.handleHostEnvelope({
rpcId: 'newer-order' as never,
payload: {
type: 'host/workspace-order-changed',
workspaceIds: [wid('one'), wid('three'), wid('two')],
},
})
gate.resolve(ok({ workspaceIds: [wid('three'), wid('one'), wid('two')] }))
await pending
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'three', 'two'])
api.onWorkspaceInsertBefore = () => Promise.resolve(err({
code: 'workspace-not-found', message: 'gone', details: { workspaceId: 'three' },
}))
const rejected = manager.insertBefore(wid('three'))
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'two', 'three'])
await expect(rejected).resolves.toMatchObject({ ok: false })
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'three', 'two'])
api.onWorkspaceInsertBefore = () => Promise.reject(new Error('transport down'))
const disconnected = manager.insertBefore(wid('three'), wid('one'))
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['three', 'one', 'two'])
await expect(disconnected).rejects.toThrow('transport down')
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'three', 'two'])
})
it('rolls overlapping rejected reorders back to the last Host-confirmed order', async () => {
const api = new FakeApiClient()
api.onWorkspaceList = () => Promise.resolve(ok({
items: [workspace('one'), workspace('two'), workspace('three')] as never[],
}))
const manager = new WorkspaceManager(api)
await manager.refresh()
const firstGate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceInsertBefore']>>>()
const secondGate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceInsertBefore']>>>()
let request = 0
api.onWorkspaceInsertBefore = () => request++ === 0 ? firstGate.promise : secondGate.promise
const first = manager.insertBefore(wid('three'), wid('one'))
const second = manager.insertBefore(wid('two'), wid('three'))
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['two', 'three', 'one'])
firstGate.resolve(err({
code: 'workspace-not-found', message: 'first rejected', details: { workspaceId: 'three' },
}))
await expect(first).resolves.toMatchObject({ ok: false })
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['two', 'three', 'one'])
secondGate.resolve(err({
code: 'workspace-not-found', message: 'second rejected', details: { workspaceId: 'two' },
}))
await expect(second).resolves.toMatchObject({ ok: false })
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'two', 'three'])
})
it('replays removal over an in-flight baseline and ignores duplicate or late updates', async () => {
const api = new FakeApiClient()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceList']>>>()
@@ -309,6 +376,72 @@ describe('WorkspacesService', () => {
await expect(workspaces.delete(wid('ghost'))).rejects.toThrow(/workspace-not-found: gone/)
})
it('moves a Workspace through the durable order RPC and surfaces Host rejection', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const workspaces = new WorkspacesService(ctx, api, new SessionsService(ctx, api, fakeRemote()))
api.onWorkspaceList = () => Promise.resolve(ok({
items: [workspace('one'), workspace('two')] as never[],
}))
await workspaces.refresh()
api.onWorkspaceInsertBefore = () => Promise.resolve(ok({
workspaceIds: [wid('two'), wid('one')],
}))
await expect(workspaces.insertBefore(wid('two'), wid('one'))).resolves.toBeUndefined()
expect(api.callsOf('workspace.insertBefore')).toEqual([{
workspaceId: 'two', beforeWorkspaceId: 'one',
}])
expect(workspaces.list.getSnapshot().items.map(item => item.workspaceId)).toEqual(['two', 'one'])
api.onWorkspaceInsertBefore = () => Promise.resolve(err({
code: 'workspace-not-found', message: 'gone', details: { workspaceId: 'ghost' },
}))
await expect(workspaces.insertBefore(wid('ghost'))).rejects.toThrow(/workspace-not-found: gone/)
})
it('targets New Session at explicit, current-session, then recent Workspaces and clears with none', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api, fakeRemote())
const workspaces = new WorkspacesService(ctx, api, sessions)
api.onWorkspaceList = () => Promise.resolve(ok({
items: [
workspace('current-home', [sid('current')]),
workspace('recent-home', [sid('recent')]),
] as never[],
}))
api.onList = () => Promise.resolve(ok({ items: [
{ sessionId: sid('current'), updatedAt: 1, running: false, blank: false },
{ sessionId: sid('recent'), updatedAt: 2, running: false, blank: false },
] as never[] }))
await Promise.all([workspaces.refresh(), sessions.refresh()])
await Promise.resolve()
sessions.open(sid('current'))
const unresolved = new Promise<SessionId>(() => {})
const connect = vi.spyOn(workspaces, 'connectWorkspace').mockReturnValue(unresolved)
workspaces.startSession(wid('recent-home'))
await Promise.resolve()
expect(connect).toHaveBeenLastCalledWith(wid('recent-home'))
workspaces.startSession()
await Promise.resolve()
expect(connect).toHaveBeenLastCalledWith(wid('current-home'))
sessions.clear()
workspaces.startSession()
await Promise.resolve()
expect(connect).toHaveBeenLastCalledWith(wid('recent-home'))
const emptyCtx = new Context()
const emptyApi = new FakeApiClient()
const emptySessions = new SessionsService(emptyCtx, emptyApi, fakeRemote())
const emptyWorkspaces = new WorkspacesService(emptyCtx, emptyApi, emptySessions)
const clear = vi.spyOn(emptySessions, 'clear')
emptyWorkspaces.startSession()
expect(clear).toHaveBeenCalledOnce()
})
it('archives a session, projects the set from the response, list, and frame, and clears only the current one', async () => {
const ctx = new Context()
const api = new FakeApiClient()