Files
deepseek-harness/packages/host/apiproxy/src/api/rpc.ts
imccyu ea8b1178cd feat(web): session list one-list, hover card, row menus, rename, manual ordering
Sidebar session list grows the figma 239-10458 feature set and the
workspace/session browsing region moves wholesale into ui-workspace:

- Group-by menu (WorkSpace / In one list): flat mode lists every session
  top-level, strictly newest-first; the choice persists across reloads.
- Session rows get a 500ms hover detail card (title / relative time /
  status line) and a ... menu (Rename / Fork session / Delete session,
  visual-only for now); workspace headers get ... with Rename (wired) and
  Delete workspace (visual-only).
- workspace.rename RPC: trims, rejects duplicate titles on the create
  chain (workspace-name-conflict), no-op on same title; modal dialog with
  client-side duplicate pre-check.
- workspace.insertSessionBefore RPC (DOM-insertBefore semantics, omitted
  anchor appends): HTML5 drag reorder of root sessions inside a workspace
  group; order truth stays host-side, the view refreshes from the
  response/changed frame.
- Activity pinning removed: the session/event touchSession chain is gone;
  workspace accounts are manually owned (new sessions prepend, explicit
  reordering only). Contracts and tests updated, api catalog regenerated.
- ui-sidebar reduced to the column shell (brand, fold state machine, New
  Session, Settings) exposing one sidebar.workspaces hole with a two-fact
  owner share {wide, expandSidebar}; ui-workspace owns the whole region
  (header, search, grouped/flat lists, dialogs, drag) plus the picker via
  a shared WorkspaceCreateFlow. The old sidebar.workspace picker slot and
  its deferral indirection are gone.
- ui-primitives: Menu gains label entries, danger rows, and
  closeOnPointerLeave; new HoverCard (portaled, open-delay, disabled
  guard). Hover card and row menu never coexist.
2026-07-26 00:55:17 +08:00

135 lines
5.2 KiB
TypeScript

/**
* Four-quadrant RPC message model. Channels and messages are
* decoupled: HTTP is the client→server physical channel, SSE the server→client one; logical
* messages are channel-independent, and the wire full form is a four-member discriminated union.
* api/ contract layer: zero Node dependencies, importable from the browser.
*/
import type { z as zCore } from 'zod'
type ZodIssue = zCore.core.$ZodIssue
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
/**
* Message correlation id: the initiator mints it on a request; a response
* echoes the matching request's rpcId and never mints a new one.
*/
export type RpcId = Branded<'rpc-id'>
/**
* Brands a string as RpcId (same precedent as core `SessionId()`). Minted by the initiator:
* client-request → client mints; server-request → host mints (answerable frames get a stable
* logical id, pure pushes mint a fresh one each time).
* @param id - Raw id string (implementations mint UUIDs; tests may pass fixtures).
* @returns The same string, branded (compile-time cast, zero runtime cost).
*/
export function RpcId(id: string): RpcId {
return id as RpcId
}
/** Error code → details type map (a second table isomorphic to RpcMethodMap). New code = one row here + one branch in the error schema. */
export interface RpcErrorDetailsMap {
'bad-request': { issues: ZodIssue[] }
'cancelled': {}
'session-not-found': { sessionId: SessionId }
'session-conflict': { sessionId: SessionId; requestedCwd: string; existingCwd?: string }
'workspace-attach-failed': { sessionId: SessionId; workspaceId: string }
'workspace-not-found': { workspaceId: string }
'workspace-invalid-path': { path: string }
'workspace-name-conflict': { name: string }
'workspace-move-invalid': { workspaceId: string; sessionId: SessionId; beforeSessionId?: SessionId }
'agent-busy': { reason: string }
'internal': {}
}
/** Closed error-code union (the keys of RpcErrorDetailsMap). */
export type RpcErrorCode = keyof RpcErrorDetailsMap
/**
* Distributive union expanded from the map: code is the discriminant, so
* `switch (error.code)` narrows details. details is required (internal uses an explicit {}).
*/
export type RpcError = {
[C in RpcErrorCode]: { code: C; message: string; details: RpcErrorDetailsMap[C] }
}[RpcErrorCode]
/** Business success/failure result: the result slot of a unary response; methods never throw business errors. */
export type RpcResult<T> = { ok: true; value: T } | { ok: false; error: RpcError }
/**
* Fold a transport exception into the RpcResult error branch (unified error
* surface; 'internal' as the catch-all code). Lives with RpcResult so every
* carrier consumer folds the same way.
* @param error - the thrown value from the carrier.
* @returns the error branch of an RpcResult.
*/
export function transportError<T>(error: unknown): RpcResult<T> {
return {
ok: false,
error: { code: 'internal', message: error instanceof Error ? error.message : String(error), details: {} },
}
}
/**
* Signature-layer narrow form, request side (domain-interface view, shared by
* both directions): rpcId is explicit in the signature, never mixed into the
* business payload; the type tag and method are filled in by the carrier layer.
*/
export interface RpcRequest<P> {
rpcId: RpcId
payload: P
}
/** Signature-layer narrow form, response side: rpcId always echoes the matching request. */
export interface RpcResponse<T> {
rpcId: RpcId
result: RpcResult<T>
}
// ---- Wire full forms: four named members of a discriminated union (discriminant = the four `type` literals) ----
/** Call initiated by the client (wire carrier: POST /api/<method> body). */
export interface ClientRequest {
type: 'client-request'
rpcId: RpcId
method: string
payload: unknown
}
/** Response to a ClientRequest (wire carrier: the HTTP response body of that POST); rpcId echoed. */
export interface ServerResponse {
type: 'server-response'
rpcId: RpcId
result: RpcResult<unknown>
}
/**
* Message initiated by the server (wire carrier: SSE frame). Answerable interactions
* (approval/question requested — stable rpcId, reused on replay) and pure pushes
* (session/event etc. — rpcId identifies that one push) share this shape; whether a
* response is expected is determined statically by method (a strict dichotomy, no third kind).
*/
export interface ServerRequest {
type: 'server-request'
rpcId: RpcId
method: string
payload: unknown
}
/** Response to a ServerRequest (wire carrier: POST /api/respond body); rpcId echoed, never minted anew. */
export interface ClientResponse {
type: 'client-response'
rpcId: RpcId
result: RpcResult<unknown>
}
/** Authoritative wire full-form union; narrow via `switch (message.type)`. */
export type RpcMessage = ClientRequest | ServerResponse | ServerRequest | ClientResponse
/**
* Carrier receipt (not an RpcMessage — it belongs to the carrier layer, same
* discipline as "HTTP status describes only the carrier"): the HTTP response
* body of the POST carrying a client-response. Late/duplicate responses yield not-pending.
*/
export type RpcReceipt = { accepted: true } | { accepted: false; reason: 'not-pending' | 'bad-response' }