feat(workspace): support persistent workspace ordering

This commit is contained in:
_Kerman
2026-08-11 13:43:47 +08:00
parent 1d4ab4492e
commit b3e843056e
14 changed files with 230 additions and 13 deletions

View File

@@ -2408,6 +2408,38 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
emitHost({ type: 'host/workspace-removed', workspaceId }) emitHost({ type: 'host/workspace-removed', workspaceId })
return ok(request, { deleted: true as const }) return ok(request, { deleted: true as const })
}, },
insertBefore: (request) => {
const { workspaceId, beforeWorkspaceId } = request.payload
const source = workspaces.findIndex(workspace => workspace.workspaceId === workspaceId)
const anchor = beforeWorkspaceId === undefined
? workspaces.length
: workspaces.findIndex(workspace => workspace.workspaceId === beforeWorkspaceId)
const missing = source === -1 ? workspaceId : anchor === -1 ? beforeWorkspaceId : undefined
if (missing !== undefined) {
return err(request, {
code: 'workspace-not-found',
message: `no workspace ${missing}`,
details: { workspaceId: missing },
})
}
if (beforeWorkspaceId !== workspaceId) {
const previousOrder = workspaces.map(candidate => candidate.workspaceId)
const [workspace] = workspaces.splice(source, 1)
/* v8 ignore next -- source was resolved from the same array immediately above. */
if (workspace === undefined) throw new Error(`fixture lost workspace ${workspaceId}`)
const at = beforeWorkspaceId === undefined
? workspaces.length
: workspaces.findIndex(candidate => candidate.workspaceId === beforeWorkspaceId)
workspaces.splice(at, 0, workspace)
if (workspaces.some((candidate, index) => candidate.workspaceId !== previousOrder[index])) {
emitHost({
type: 'host/workspace-order-changed',
workspaceIds: workspaces.map(candidate => candidate.workspaceId),
})
}
}
return ok(request, { workspaceIds: workspaces.map(candidate => candidate.workspaceId) })
},
insertSessionBefore: (request) => { insertSessionBefore: (request) => {
const { workspaceId, sessionId, beforeSessionId } = request.payload const { workspaceId, sessionId, beforeSessionId } = request.payload
const workspace = workspaces.find(w => w.workspaceId === workspaceId) const workspace = workspaces.find(w => w.workspaceId === workspaceId)
@@ -2949,6 +2981,7 @@ export class FixtureApiClient extends AbstractApiClient {
case 'workspace.create': return this.api.workspace.create(request) case 'workspace.create': return this.api.workspace.create(request)
case 'workspace.rename': return this.api.workspace.rename(request) case 'workspace.rename': return this.api.workspace.rename(request)
case 'workspace.delete': return this.api.workspace.delete(request) case 'workspace.delete': return this.api.workspace.delete(request)
case 'workspace.insertBefore': return this.api.workspace.insertBefore(request)
case 'workspace.insertSessionBefore': return this.api.workspace.insertSessionBefore(request) case 'workspace.insertSessionBefore': return this.api.workspace.insertSessionBefore(request)
case 'workspace.archiveSession': return this.api.workspace.archiveSession(request) case 'workspace.archiveSession': return this.api.workspace.archiveSession(request)
case 'command.list': return this.api.commands.list(request) case 'command.list': return this.api.commands.list(request)

View File

@@ -68,6 +68,12 @@ export interface IWorkspaces {
* @param workspaceId - target workspace. * @param workspaceId - target workspace.
*/ */
delete(workspaceId: WorkspaceId): Promise<void> 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. * Move an accounted session within/into a Workspace's ordered list.
* @param workspaceId - target workspace. * @param workspaceId - target workspace.

View File

@@ -4,7 +4,6 @@ import type {
HostFrame, IApiClient, RpcError, RpcRequest, RpcResult, SessionId, WorkspaceId, WorkspaceView, HostFrame, IApiClient, RpcError, RpcRequest, RpcResult, SessionId, WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-connection/client' } from '@deepseek-ai/dsh-client-connection/client'
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
import { mergeOrderedBaseline } from '../ordered-baseline.ts'
import { Notifier } from '../sessions/notifier.ts' import { Notifier } from '../sessions/notifier.ts'
import { Workspace, type WorkspaceCreateInput } from './workspace.ts' import { Workspace, type WorkspaceCreateInput } from './workspace.ts'
@@ -30,6 +29,7 @@ export interface WorkspaceListSnapshot {
type WorkspaceDelta = type WorkspaceDelta =
| { type: 'upsert'; workspace: WorkspaceView } | { type: 'upsert'; workspace: WorkspaceView }
| { type: 'remove'; workspaceId: WorkspaceId } | { type: 'remove'; workspaceId: WorkspaceId }
| { type: 'order'; workspaceIds: readonly WorkspaceId[] }
/** Workspace object cluster driven by one list baseline and changed-frame upserts. */ /** Workspace object cluster driven by one list baseline and changed-frame upserts. */
export class WorkspaceManager { export class WorkspaceManager {
@@ -51,6 +51,10 @@ export class WorkspaceManager {
* mirror of replaying refreshFrames over the item baseline. * mirror of replaying refreshFrames over the item baseline.
*/ */
private archivedSupersedesRefresh = false 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
/** /**
* Ids this process has seen removed, kept for the connection's lifetime so * 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 * a late changed frame or a stale baseline row cannot resurrect a deleted
@@ -72,16 +76,15 @@ export class WorkspaceManager {
/** /**
* Refresh from workspace.list. The first successful response establishes * Refresh from workspace.list. The first successful response establishes
* Host order; later responses update membership and values without moving * Host order; later responses re-establish the durable order so reconnects
* identities already visible to the client. Frames arriving during the RPC * adopt reorders committed while this client was offline. Frames arriving
* are replayed over its response. * during the RPC are replayed over its response.
* @returns the shared in-flight refresh. * @returns the shared in-flight refresh.
*/ */
refresh(): Promise<void> { refresh(): Promise<void> {
if (this.inflight !== null) return this.inflight if (this.inflight !== null) return this.inflight
this.state = 'loading' this.state = 'loading'
this.error = null this.error = null
const established = this.itemViews()
const frames: WorkspaceDelta[] = [] const frames: WorkspaceDelta[] = []
this.refreshFrames = frames this.refreshFrames = frames
this.notifier.markDirty() this.notifier.markDirty()
@@ -89,9 +92,7 @@ export class WorkspaceManager {
try { try {
const { result } = await this.api.workspace.list({}) const { result } = await this.api.workspace.list({})
if (result.ok) { if (result.ok) {
let items = this.phase === 'pending' let items = result.value.items
? result.value.items
: mergeOrderedBaseline(established, result.value.items, workspace => workspace.workspaceId)
items = items.filter(workspace => !this.removedIds.has(workspace.workspaceId)) items = items.filter(workspace => !this.removedIds.has(workspace.workspaceId))
for (const delta of frames) items = applyWorkspaceDelta(items, delta) for (const delta of frames) items = applyWorkspaceDelta(items, delta)
this.installViews(items) this.installViews(items)
@@ -157,6 +158,35 @@ export class WorkspaceManager {
return result 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 previousOrder = this.itemViews().map(workspace => workspace.workspaceId)
this.installOrder(insertIdBefore(previousOrder, workspaceId, beforeWorkspaceId))
const { result } = await this.api.workspace.insertBefore({
workspaceId,
...beforeWorkspaceId === undefined ? {} : { beforeWorkspaceId },
})
if (result.ok && requestGeneration === this.orderRequestGeneration
&& frameGeneration === this.orderFrameGeneration) {
this.installOrder(result.value.workspaceIds)
} else if (!result.ok && requestGeneration === this.orderRequestGeneration
&& frameGeneration === this.orderFrameGeneration) {
this.installOrder(previousOrder)
}
return result
}
/** /**
* Move a session within its Workspace's manual order, then publish the * Move a session within its Workspace's manual order, then publish the
* returned snapshot without waiting for the changed frame. * returned snapshot without waiting for the changed frame.
@@ -198,6 +228,10 @@ export class WorkspaceManager {
handleHostEnvelope(envelope: RpcRequest<HostFrame>): void { handleHostEnvelope(envelope: RpcRequest<HostFrame>): void {
if (envelope.payload.type === 'host/workspace-changed') this.upsert(envelope.payload.workspace) 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-removed') this.remove(envelope.payload.workspaceId)
else if (envelope.payload.type === 'host/workspace-order-changed') {
this.orderFrameGeneration++
this.installOrder(envelope.payload.workspaceIds)
}
else if (envelope.payload.type === 'host/archived-sessions-changed') { else if (envelope.payload.type === 'host/archived-sessions-changed') {
this.installArchived(envelope.payload.archivedSessionIds) this.installArchived(envelope.payload.archivedSessionIds)
} }
@@ -249,6 +283,21 @@ export class WorkspaceManager {
this.notifier.markDirty() this.notifier.markDirty()
} }
/** Reorder known Workspace objects by a complete Host id sequence. */
private installOrder(workspaceIds: readonly WorkspaceId[]): void {
this.refreshFrames?.push({ type: 'order', 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. */ /** Upsert one Host view, optionally retaining the local object that materialized it. */
private upsert(view: WorkspaceView, identity?: Workspace): void { private upsert(view: WorkspaceView, identity?: Workspace): void {
if (this.removedIds.has(view.workspaceId)) return if (this.removedIds.has(view.workspaceId)) return
@@ -332,7 +381,26 @@ function upsertWorkspace(items: readonly WorkspaceView[], workspace: WorkspaceVi
/** Replay one ordered delta over a baseline: upsert in place, or drop the removed id. */ /** Replay one ordered delta over a baseline: upsert in place, or drop the removed id. */
function applyWorkspaceDelta(items: readonly WorkspaceView[], delta: WorkspaceDelta): WorkspaceView[] { function applyWorkspaceDelta(items: readonly WorkspaceView[], delta: WorkspaceDelta): WorkspaceView[] {
return delta.type === 'upsert' if (delta.type === 'upsert') return upsertWorkspace(items, delta.workspace)
? upsertWorkspace(items, delta.workspace) if (delta.type === 'remove') {
: items.filter(workspace => workspace.workspaceId !== delta.workspaceId) 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

@@ -265,6 +265,16 @@ export class WorkspacesService implements IWorkspaces {
if (!result.ok) throw new Error(`workspace delete failed: ${result.error.code}: ${result.error.message}`) 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 * Archive a session into the registry-global set. Clearing an archived
* current selection is the projection sweep's job (one rule for the local * current selection is the projection sweep's job (one rule for the local

View File

@@ -172,6 +172,16 @@ export class TestWorkspaces implements IWorkspaces {
await (this.stubs.get('delete')?.(workspaceId) as Promise<void> | undefined) await (this.stubs.get('delete')?.(workspaceId) as Promise<void> | undefined)
} }
/**
* Move a Workspace in display order (recorded; default no-op).
* @param workspaceId - Workspace to move.
* @param beforeWorkspaceId - Anchor; omitted appends.
*/
async insertBefore(workspaceId: WorkspaceId, beforeWorkspaceId?: WorkspaceId): Promise<void> {
this.calls.push({ method: 'insertBefore', args: [workspaceId, beforeWorkspaceId] })
await (this.stubs.get('insertBefore')?.(workspaceId, beforeWorkspaceId) as Promise<void> | undefined)
}
/** /**
* Move an accounted session (recorded). The default echoes a minimal view. * Move an accounted session (recorded). The default echoes a minimal view.
* @param workspaceId - target workspace. * @param workspaceId - target workspace.

View File

@@ -25,7 +25,7 @@ import { isUserInvocable } from '@deepseek-ai/dsh-skill'
import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace' import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
import { import {
workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId, workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId,
WorkspaceMoveInvalidError, WorkspaceUnknownSessionError, WorkspaceMoveInvalidError, WorkspaceOrderInvalidError, WorkspaceUnknownSessionError,
} from '@deepseek-ai/dsh-workspace' } from '@deepseek-ai/dsh-workspace'
// Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters). // Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters).
import { import {
@@ -2671,6 +2671,20 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
return ok(request, { deleted: true as const }) return ok(request, { deleted: true as const })
}, },
async insertBefore(request) {
const { workspaceId, beforeWorkspaceId } = request.payload
try {
const workspaceIds = await ctx.workspace.insertBefore(
brandWorkspaceId(workspaceId),
beforeWorkspaceId === undefined ? undefined : brandWorkspaceId(beforeWorkspaceId),
)
return ok(request, { workspaceIds: [...workspaceIds] })
} catch (error: unknown) {
if (!(error instanceof WorkspaceOrderInvalidError)) throw error
return workspaceNotFound(request, error.workspaceId)
}
},
async insertSessionBefore(request) { async insertSessionBefore(request) {
const { payload } = request const { payload } = request
const workspace = ctx.workspace.get(brandWorkspaceId(payload.workspaceId)) const workspace = ctx.workspace.get(brandWorkspaceId(payload.workspaceId))
@@ -3370,6 +3384,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const committedWorkspaceIds = new Set( const committedWorkspaceIds = new Set(
ctx.workspace.list().map(workspace => String(workspace.id)), ctx.workspace.list().map(workspace => String(workspace.id)),
) )
let committedWorkspaceOrder = ctx.workspace.list().map(workspace => workspaceView(workspace).workspaceId)
// Frame-dedup baseline, same posture as committedWorkspaceIds: the // Frame-dedup baseline, same posture as committedWorkspaceIds: the
// stream opens against the current set; workspace.list re-baselines // stream opens against the current set; workspace.list re-baselines
// reconnecting clients, so only later changes need frames. // reconnecting clients, so only later changes need frames.
@@ -3401,6 +3416,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
if (change.table === '') { if (change.table === '') {
if (change.operation !== 'put') return if (change.operation !== 'put') return
const state = workspaceDomainState.parse(change.value) const state = workspaceDomainState.parse(change.value)
const orderChanged = state.workspaceIds.length === committedWorkspaceOrder.length
&& state.workspaceIds.every(workspaceId => committedWorkspaceIds.has(String(workspaceId)))
&& state.workspaceIds.some((workspaceId, index) => workspaceId !== committedWorkspaceOrder[index])
for (const workspaceId of state.workspaceIds) { for (const workspaceId of state.workspaceIds) {
if (committedWorkspaceIds.has(workspaceId)) continue if (committedWorkspaceIds.has(workspaceId)) continue
const workspace = ctx.workspace.get(workspaceId) const workspace = ctx.workspace.get(workspaceId)
@@ -3410,6 +3428,13 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
committedWorkspaceIds.add(workspaceId) committedWorkspaceIds.add(workspaceId)
queue.push(frame({ type: 'host/workspace-changed', workspace: workspaceView(workspace) })) queue.push(frame({ type: 'host/workspace-changed', workspace: workspaceView(workspace) }))
} }
committedWorkspaceOrder = [...state.workspaceIds]
if (orderChanged) {
queue.push(frame({
type: 'host/workspace-order-changed',
workspaceIds: [...state.workspaceIds],
}))
}
if (state.archivedSessionIds.length !== archivedSessionIds.length if (state.archivedSessionIds.length !== archivedSessionIds.length
|| state.archivedSessionIds.some((id, index) => id !== archivedSessionIds[index])) { || state.archivedSessionIds.some((id, index) => id !== archivedSessionIds[index])) {
archivedSessionIds = state.archivedSessionIds archivedSessionIds = state.archivedSessionIds

View File

@@ -83,6 +83,7 @@ export const hostFrameSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('host/agent-error'), sessionId: sessionIdSchema, message: z.string() }), z.object({ type: z.literal('host/agent-error'), sessionId: sessionIdSchema, message: z.string() }),
z.object({ type: z.literal('host/workspace-changed'), workspace: workspaceViewSchema }), z.object({ type: z.literal('host/workspace-changed'), workspace: workspaceViewSchema }),
z.object({ type: z.literal('host/workspace-removed'), workspaceId: workspaceIdSchema }), z.object({ type: z.literal('host/workspace-removed'), workspaceId: workspaceIdSchema }),
z.object({ type: z.literal('host/workspace-order-changed'), workspaceIds: z.array(workspaceIdSchema) }),
z.object({ type: z.literal('host/archived-sessions-changed'), archivedSessionIds: z.array(sessionIdSchema) }), z.object({ type: z.literal('host/archived-sessions-changed'), archivedSessionIds: z.array(sessionIdSchema) }),
z.object({ type: z.literal('host/commands-changed') }), z.object({ type: z.literal('host/commands-changed') }),
z.object({ type: z.literal('host/session-preset-changed'), sessionId: sessionIdSchema, agentPreset: z.string() }), z.object({ type: z.literal('host/session-preset-changed'), sessionId: sessionIdSchema, agentPreset: z.string() }),

View File

@@ -119,7 +119,8 @@ export type MuxFrame =
* workspace mutation (create/attach/order change — the client upserts, while * workspace mutation (create/attach/order change — the client upserts, while
* `workspace.list` provides the reconnect baseline); workspace-removed is the * `workspace.list` provides the reconnect baseline); workspace-removed is the
* committed registration-deletion increment and never implies directory or * committed registration-deletion increment and never implies directory or
* session-log deletion; archived-sessions-changed pushes the full registry * session-log deletion; workspace-order-changed pushes the complete durable
* registry order after a reorder; archived-sessions-changed pushes the full registry
* archive set after every durable change (same full-snapshot posture as * archive set after every durable change (same full-snapshot posture as
* workspace-changed — `workspace.list` re-baselines it on reconnect). * workspace-changed — `workspace.list` re-baselines it on reconnect).
*/ */
@@ -139,6 +140,7 @@ export type HostFrame =
| { type: 'host/agent-error'; sessionId: SessionId; message: string } | { type: 'host/agent-error'; sessionId: SessionId; message: string }
| { type: 'host/workspace-changed'; workspace: WorkspaceView } | { type: 'host/workspace-changed'; workspace: WorkspaceView }
| { type: 'host/workspace-removed'; workspaceId: WorkspaceView['workspaceId'] } | { type: 'host/workspace-removed'; workspaceId: WorkspaceView['workspaceId'] }
| { type: 'host/workspace-order-changed'; workspaceIds: WorkspaceView['workspaceId'][] }
| { type: 'host/archived-sessions-changed'; archivedSessionIds: SessionId[] } | { type: 'host/archived-sessions-changed'; archivedSessionIds: SessionId[] }
/** /**
* The command registry changed (`commands/change` passthrough). Pure * The command registry changed (`commands/change` passthrough). Pure

View File

@@ -48,6 +48,7 @@ export interface RpcMethodMap {
'workspace.create': WorkspaceApi['create'] 'workspace.create': WorkspaceApi['create']
'workspace.rename': WorkspaceApi['rename'] 'workspace.rename': WorkspaceApi['rename']
'workspace.delete': WorkspaceApi['delete'] 'workspace.delete': WorkspaceApi['delete']
'workspace.insertBefore': WorkspaceApi['insertBefore']
'workspace.insertSessionBefore': WorkspaceApi['insertSessionBefore'] 'workspace.insertSessionBefore': WorkspaceApi['insertSessionBefore']
'workspace.archiveSession': WorkspaceApi['archiveSession'] 'workspace.archiveSession': WorkspaceApi['archiveSession']
'command.list': CommandsApi['list'] 'command.list': CommandsApi['list']

View File

@@ -66,6 +66,17 @@ export const workspaceDeleteValueSchema = z.object({
deleted: z.literal(true), deleted: z.literal(true),
}) satisfies z.ZodType<Wire<ResponseValue<'workspace.delete'>>> }) satisfies z.ZodType<Wire<ResponseValue<'workspace.delete'>>>
/** workspace.insertBefore request payload (anchor omitted = append to end). */
export const workspaceInsertBeforeRequestSchema = z.object({
workspaceId: workspaceIdSchema,
beforeWorkspaceId: workspaceIdSchema.optional(),
}) satisfies z.ZodType<Wire<RequestPayload<'workspace.insertBefore'>>>
/** workspace.insertBefore response value: the complete durable display order. */
export const workspaceInsertBeforeValueSchema = z.object({
workspaceIds: z.array(workspaceIdSchema),
}) satisfies z.ZodType<Wire<ResponseValue<'workspace.insertBefore'>>>
/** workspace.insertSessionBefore request payload (anchor omitted = append to end). */ /** workspace.insertSessionBefore request payload (anchor omitted = append to end). */
export const workspaceInsertSessionBeforeRequestSchema = z.object({ export const workspaceInsertSessionBeforeRequestSchema = z.object({
workspaceId: workspaceIdSchema, workspaceId: workspaceIdSchema,

View File

@@ -73,6 +73,15 @@ export interface WorkspaceApi {
delete(request: RpcRequest<{ workspaceId: WorkspaceId }>): delete(request: RpcRequest<{ workspaceId: WorkspaceId }>):
Promise<RpcResponse<{ deleted: true }>> Promise<RpcResponse<{ deleted: true }>>
/**
* Moves one Workspace within the registry display order,
* DOM-insertBefore-like. An omitted anchor appends to the end.
*/
insertBefore(request: RpcRequest<{
workspaceId: WorkspaceId
beforeWorkspaceId?: WorkspaceId
}>): Promise<RpcResponse<{ workspaceIds: WorkspaceId[] }>>
/** /**
* Moves an accounted session within its workspace's manual order, * Moves an accounted session within its workspace's manual order,
* DOM-insertBefore-like: with `beforeSessionId` the session is inserted * DOM-insertBefore-like: with `beforeSessionId` the session is inserted

View File

@@ -35,6 +35,7 @@ import {
workspaceArchiveSessionValueSchema, workspaceArchiveSessionValueSchema,
workspaceCreateValueSchema, workspaceCreateValueSchema,
workspaceDeleteValueSchema, workspaceDeleteValueSchema,
workspaceInsertBeforeValueSchema,
workspaceInsertSessionBeforeValueSchema, workspaceInsertSessionBeforeValueSchema,
workspaceListValueSchema, workspaceListValueSchema,
workspaceRenameValueSchema, workspaceRenameValueSchema,
@@ -117,6 +118,7 @@ export interface IApiClient {
create(payload: RequestPayload<'workspace.create'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.create'>>> create(payload: RequestPayload<'workspace.create'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.create'>>>
rename(payload: RequestPayload<'workspace.rename'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.rename'>>> rename(payload: RequestPayload<'workspace.rename'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.rename'>>>
delete(payload: RequestPayload<'workspace.delete'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.delete'>>> delete(payload: RequestPayload<'workspace.delete'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.delete'>>>
insertBefore(payload: RequestPayload<'workspace.insertBefore'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.insertBefore'>>>
insertSessionBefore(payload: RequestPayload<'workspace.insertSessionBefore'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.insertSessionBefore'>>> insertSessionBefore(payload: RequestPayload<'workspace.insertSessionBefore'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.insertSessionBefore'>>>
archiveSession(payload: RequestPayload<'workspace.archiveSession'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.archiveSession'>>> archiveSession(payload: RequestPayload<'workspace.archiveSession'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.archiveSession'>>>
} }
@@ -198,6 +200,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
'workspace.create': workspaceCreateValueSchema, 'workspace.create': workspaceCreateValueSchema,
'workspace.rename': workspaceRenameValueSchema, 'workspace.rename': workspaceRenameValueSchema,
'workspace.delete': workspaceDeleteValueSchema, 'workspace.delete': workspaceDeleteValueSchema,
'workspace.insertBefore': workspaceInsertBeforeValueSchema,
'workspace.insertSessionBefore': workspaceInsertSessionBeforeValueSchema, 'workspace.insertSessionBefore': workspaceInsertSessionBeforeValueSchema,
'workspace.archiveSession': workspaceArchiveSessionValueSchema, 'workspace.archiveSession': workspaceArchiveSessionValueSchema,
'command.list': commandListValueSchema, 'command.list': commandListValueSchema,
@@ -452,6 +455,7 @@ export abstract class AbstractApiClient implements IApiClient {
create: (payload, signal) => this.callUnary('workspace.create', payload, signal), create: (payload, signal) => this.callUnary('workspace.create', payload, signal),
rename: (payload, signal) => this.callUnary('workspace.rename', payload, signal), rename: (payload, signal) => this.callUnary('workspace.rename', payload, signal),
delete: (payload, signal) => this.callUnary('workspace.delete', payload, signal), delete: (payload, signal) => this.callUnary('workspace.delete', payload, signal),
insertBefore: (payload, signal) => this.callUnary('workspace.insertBefore', payload, signal),
insertSessionBefore: (payload, signal) => this.callUnary('workspace.insertSessionBefore', payload, signal), insertSessionBefore: (payload, signal) => this.callUnary('workspace.insertSessionBefore', payload, signal),
archiveSession: (payload, signal) => this.callUnary('workspace.archiveSession', payload, signal), archiveSession: (payload, signal) => this.callUnary('workspace.archiveSession', payload, signal),
} }

View File

@@ -38,6 +38,7 @@ import {
workspaceArchiveSessionRequestSchema, workspaceArchiveSessionRequestSchema,
workspaceCreateRequestSchema, workspaceCreateRequestSchema,
workspaceDeleteRequestSchema, workspaceDeleteRequestSchema,
workspaceInsertBeforeRequestSchema,
workspaceInsertSessionBeforeRequestSchema, workspaceInsertSessionBeforeRequestSchema,
workspaceListRequestSchema, workspaceListRequestSchema,
workspaceRenameRequestSchema, workspaceRenameRequestSchema,
@@ -113,6 +114,7 @@ const UNARY_ROUTES: UnaryRoutes = {
'workspace.create': { schema: workspaceCreateRequestSchema, invoke: (api, r) => api.workspace.create(r) }, 'workspace.create': { schema: workspaceCreateRequestSchema, invoke: (api, r) => api.workspace.create(r) },
'workspace.rename': { schema: workspaceRenameRequestSchema, invoke: (api, r) => api.workspace.rename(r) }, 'workspace.rename': { schema: workspaceRenameRequestSchema, invoke: (api, r) => api.workspace.rename(r) },
'workspace.delete': { schema: workspaceDeleteRequestSchema, invoke: (api, r) => api.workspace.delete(r) }, 'workspace.delete': { schema: workspaceDeleteRequestSchema, invoke: (api, r) => api.workspace.delete(r) },
'workspace.insertBefore': { schema: workspaceInsertBeforeRequestSchema, invoke: (api, r) => api.workspace.insertBefore(r) },
'workspace.insertSessionBefore': { schema: workspaceInsertSessionBeforeRequestSchema, invoke: (api, r) => api.workspace.insertSessionBefore(r) }, 'workspace.insertSessionBefore': { schema: workspaceInsertSessionBeforeRequestSchema, invoke: (api, r) => api.workspace.insertSessionBefore(r) },
'workspace.archiveSession': { schema: workspaceArchiveSessionRequestSchema, invoke: (api, r) => api.workspace.archiveSession(r) }, 'workspace.archiveSession': { schema: workspaceArchiveSessionRequestSchema, invoke: (api, r) => api.workspace.archiveSession(r) },
'command.list': { schema: commandListRequestSchema, invoke: (api, r) => api.commands.list(r) }, 'command.list': { schema: commandListRequestSchema, invoke: (api, r) => api.commands.list(r) },

View File

@@ -52,6 +52,17 @@ export class WorkspaceUnknownSessionError extends Error {
} }
} }
/** A workspace reorder named a source or anchor absent from the durable registry order. */
export class WorkspaceOrderInvalidError extends Error {
/**
* @param workspaceId - Missing source or anchor id.
*/
constructor(readonly workspaceId: WorkspaceId) {
super(`cannot reorder unknown workspace '${workspaceId}'`)
this.name = 'WorkspaceOrderInvalidError'
}
}
declare module '@deepseek-ai/cordis' { declare module '@deepseek-ai/cordis' {
interface Context { interface Context {
@@ -189,6 +200,30 @@ export class WorkspaceRegistry extends Service {
return this.enqueueOperation(() => this.deleteKnown(id)) return this.enqueueOperation(() => this.deleteKnown(id))
} }
/**
* Move one workspace within the durable display order, DOM-insertBefore-like.
* With an anchor it lands before that workspace; without one it appends.
* @param id - Workspace to move.
* @param beforeId - Workspace anchor; omitted appends.
* @returns the complete committed workspace order.
*/
insertBefore(id: WorkspaceId, beforeId?: WorkspaceId): Promise<readonly WorkspaceId[]> {
return this.enqueueOperation(async () => {
const state = this.requireState()
if (!state.workspaceIds.includes(id)) throw new WorkspaceOrderInvalidError(id)
if (beforeId !== undefined && !state.workspaceIds.includes(beforeId)) {
throw new WorkspaceOrderInvalidError(beforeId)
}
if (beforeId === id) return state.workspaceIds
const without = state.workspaceIds.filter(workspaceId => workspaceId !== id)
const at = beforeId === undefined ? without.length : without.indexOf(beforeId)
const workspaceIds = [...without.slice(0, at), id, ...without.slice(at)]
if (sameIds(workspaceIds, state.workspaceIds)) return state.workspaceIds
await this.setState({ ...state, workspaceIds })
return workspaceIds
})
}
/** /**
* The registry-global archive set: sessions hidden from every grouping * The registry-global archive set: sessions hidden from every grouping
* surface. Archiving never touches workspace accounting — an archived * surface. Archiving never touches workspace accounting — an archived