Merge remote-tracking branch 'origin/master' into fix/pty-raw-ready-wait

master already stabilized the raw-mode SIGINT scenario in 65e0ffe03 and
3be090f1d with wider scenario-owned bounds and deliberately delayed child
readiness, so the merge takes master's test implementation. What this branch
keeps is the contract that explains why those bounds are required: the note
paragraph on the settle boundary in PtySendOperation.append, and the
waitForOutput comment recording that raising its deadline alone cannot
recover output the send never collected.
This commit is contained in:
Chinesezjc
2026-07-27 18:15:47 +08:00
353 changed files with 7981 additions and 2728 deletions

View File

@@ -6,7 +6,7 @@
// approval/question requests exercise replay and composer takeover with stable rpcIds.
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
import type { SessionEvent, SessionId, TodoItem } from '@deepseek-ai/dsh-session/types'
import type {
ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt,
RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary,
@@ -167,6 +167,22 @@ function buildAlphaLog(): SessionEvent[] {
push({ type: 'step/end', data: { turn, step: 0 } })
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
}
// Turn 65: todo_write sample — the TodoRow toolview in the flow plus the
// todo/write snapshot event feeding the TodoPanel plan strip.
const fixtureTodos = [
{ content: '梳理需求', status: 'completed' },
{ content: '实现 fixture 样本', status: 'in_progress' },
{ content: '浏览器验收', status: 'pending' },
]
const todoArgs = JSON.stringify({ todos: fixtureTodos })
toolTurn(65, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.')
// The real tool appends the snapshot mid-execution — between tool/call and
// tool/result — so the fixture reproduces that exact ordering (the last
// toolTurn events run ... tool/call, tool/result, step/end, turn/end).
const callIndex = events.length - 4
const callTime = events[callIndex]?.time as number
events.splice(callIndex + 1, 0, { type: 'todo/write', time: callTime + 400, data: { todos: fixtureTodos } })
events.forEach((e, i) => { e.seq = i })
return events as unknown as SessionEvent[]
}
@@ -281,6 +297,15 @@ function pageOf(
return { events, hasMore: start > 0 }
}
/** Current todo projection over the full log (host parallel: latest todo/write, last write wins). */
function backscanTodos(log: readonly SessionEvent[]): TodoItem[] | undefined {
for (let i = log.length - 1; i >= 0; i--) {
const event = log[i]
if (event !== undefined && event.type === 'todo/write') return event.data.todos
}
return undefined
}
interface StreamConn<F> {
push(envelope: RpcRequest<F>): void
}
@@ -619,12 +644,14 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
const log = logs.get(request.payload.sessionId) ?? []
// Snapshot at request time, deliver after the transit delay (mirrors a real host under latency).
const page = pageOf(log, request.payload.beforeSeq, request.payload.maxMessages ?? 50)
// Tail page carries the session-level todo projection (host parallel: full-log backscan).
const todos = request.payload.beforeSeq === undefined ? backscanTodos(log) : undefined
const doomed = failNextHistory
failNextHistory = false
const delay = historyDelayMs
if (delay > 0) await new Promise(resolve => setTimeout(resolve, delay))
if (doomed) throw new Error('fixture: simulated history transport failure')
return ok(request, page)
return ok(request, { ...page, ...todos === undefined ? {} : { todos } })
},
prompt: (request) => {
const { sessionId: id, mode, content } = request.payload
@@ -723,6 +750,20 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
}
return ok(request, { workspace: { ...workspace } })
},
delete: (request) => {
const { workspaceId } = request.payload
const index = workspaces.findIndex(workspace => workspace.workspaceId === workspaceId)
if (index === -1) {
return err(request, {
code: 'workspace-not-found',
message: `no workspace ${workspaceId}`,
details: { workspaceId },
})
}
workspaces.splice(index, 1)
emitHost({ type: 'host/workspace-removed', workspaceId })
return ok(request, { deleted: true as const })
},
insertSessionBefore: (request) => {
const { workspaceId, sessionId, beforeSessionId } = request.payload
const workspace = workspaces.find(w => w.workspaceId === workspaceId)
@@ -914,6 +955,7 @@ export class FixtureApiClient extends AbstractApiClient {
case 'workspace.list': return this.api.workspace.list(request)
case 'workspace.create': return this.api.workspace.create(request)
case 'workspace.rename': return this.api.workspace.rename(request)
case 'workspace.delete': return this.api.workspace.delete(request)
case 'workspace.insertSessionBefore': return this.api.workspace.insertSessionBefore(request)
case 'command.list': return this.api.commands.list(request)
// The in-memory execute never blocks, so a never-aborting signal is faithful here.

View File

@@ -81,6 +81,7 @@ export class FakeApiClient implements IApiClient {
rename: (payload: unknown) => this.record('workspace.rename', payload, Promise.resolve(ok({
workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' },
}))),
delete: (payload: unknown) => this.record('workspace.delete', payload, Promise.resolve(ok({ deleted: true as const }))),
insertSessionBefore: (payload: unknown) => this.record('workspace.insertSessionBefore', payload, Promise.resolve(ok({
workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' },
}))),

View File

@@ -71,6 +71,21 @@ describe('createFixtureApi', () => {
expect(empty.result.value).toEqual({ events: [], hasMore: false })
})
it('emits the todo/write snapshot at the real tool boundary: between tool/call and tool/result, timestamps monotonic', async () => {
const api = createFixtureApi()
const tail = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 10 }))
if (!tail.result.ok) throw new Error('history failed')
const events = tail.result.value.events.map(e => e.event)
const todoAt = events.findIndex(e => e.type === 'todo/write')
expect(todoAt).toBeGreaterThan(0)
// Production ordering (the tool appends mid-execution): call → snapshot → result.
expect(events[todoAt - 1]?.type).toBe('tool/call')
expect(events[todoAt + 1]?.type).toBe('tool/result')
const times = events.slice(todoAt - 1, todoAt + 2).map(e => e.time)
expect(times[0]).toBeLessThanOrEqual(times[1] ?? 0)
expect(times[1]).toBeLessThanOrEqual(times[2] ?? 0)
})
it('create adds a session and pushes host/session-added to open host streams', async () => {
const api = createFixtureApi()
const abort = new AbortController()
@@ -365,6 +380,31 @@ describe('createFixtureApi', () => {
expect(noop.result.value.workspace.updatedAt).toBe(before)
})
it('workspace.delete removes only the Workspace row and emits the removal frame', 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 missing = await api.workspace.delete(req({ workspaceId: 'fx-ws-void' as WorkspaceId }))
expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found' } })
const deleted = await api.workspace.delete(req({ workspaceId: 'fx-ws-fixture' as WorkspaceId }))
expect(deleted.result).toEqual({ ok: true, value: { deleted: true } })
await consuming
expect(seen).toEqual([{ type: 'host/workspace-removed', workspaceId: 'fx-ws-fixture' }])
const list = await api.workspace.list(req({}))
if (!list.result.ok) throw new Error('workspace list failed')
expect(list.result.value.items.some(workspace => workspace.workspaceId === 'fx-ws-fixture')).toBe(false)
const sessions = await api.sessions.list(req({}))
if (!sessions.result.ok) throw new Error('session list failed')
expect(sessions.result.value.items.map(session => session.sessionId)).toContain('fx-alpha')
})
it('session.create({workspaceId}) lands on the account and unknown ids error', async () => {
const api = createFixtureApi()
const abort = new AbortController()

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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
README.md: 4724ebc75d441252245a0e811a4ae34f8b529a98
README.zh.md: 6a0076742efccaf946910c77c77a9b74194b9dc5
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
README.md: a434b2d5719de2f30a883ee6e0d26264b3c62f4e
README.zh.md: a79fa99578e6bce4f0ff8e7c1f7538df8a436778

View File

@@ -2,11 +2,13 @@
English | [中文](README.zh.md)
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list/scope/history state; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into both managers. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. 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; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into both managers. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. `ConversationSnapshot` carries `todos` — the session's current todo projection: taken from the tail history page's full-log value (host-computed, independent of the page window), preserved across an older-page prepend, and overwritten by each live `todo/write` (last write wins). A tail response that omits the field means the log holds no `todo/write`, so the list resets to empty — a plan the log never kept (a write lost to a host crash) disappears on the next open or resync.
## 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.
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.
`WorkspacesService.delete(workspaceId)` removes the registration from the client projection after the successful unary response; the matching `host/workspace-removed` frame is idempotent and synchronizes other tabs. Session state and the current Session selection are independent, so accounted Sessions immediately project under Ungrouped after their Workspace disappears.
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.

View File

@@ -2,11 +2,13 @@
[English](README.md) | 中文
客户端 cordis 启动与不依赖 React 的对象服务SlotsService 包装 SlotCore 并提供 renderer 数据源SessionsService 拥有 Session 对象、列表scopehistory 状态WorkspacesService 依赖 SessionsService拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给两个 manager。客户端 Session 一律由 Host 出生(一次 `session.create` 同瞬产出 Session+Agent+cwd客户端不持有任何实体化之前的会话状态——Agent scopehost dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时出生,随 prune 死亡。契约api-contracts v3 §4。
客户端 cordis 启动与不依赖 React 的对象服务SlotsService 包装 SlotCore 并提供 renderer 数据源SessionsService 拥有 Session 对象、列表scopehistory 状态WorkspacesService 依赖 SessionsService拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给两个 manager。客户端 Session 一律由 Host 出生(一次 `session.create` 同瞬产出 Session+Agent+cwd客户端不持有任何实体化之前的会话状态——Agent scopehost dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时出生,随 prune 死亡。契约api-contracts v3 §4。`ConversationSnapshot` 携带 `todos`——会话当前的 todo 投影:取自尾页 history 携带的全量 log 值host 计算,独立于分页窗口),跨往前翻页保留,并被每次实时 `todo/write` 覆盖(后写胜出)。尾页响应省略该字段即表示 log 中没有任何 `todo/write`因此列表复位为空——log 从未留下的计划(写入因 host 崩溃丢失)会在下一次打开或 resync 时消失。
## Workspace 与 Session 列表
Workspace 和 Session 列表各自具有单调的 `pending``ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量会在其响应之上回放。第一次成功的基线建立 Host 顺序后续刷新更新行和成员关系但不改变已经显示的标识之间的相对顺序。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。
Workspace 和 Session 列表各自具有单调的 `pending``ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量更新/移除帧与一元变更回显会在其响应之上回放。第一次成功的基线建立 Host 顺序;后续刷新更新行和成员关系,但不改变已经显示的标识之间的相对顺序。已移除的 Workspace id 会保留进程本地删除标记,避免延迟到达的 changed 帧将其复活;重连仍以 `workspace.list` 作为基线。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。
`WorkspacesService.delete(workspaceId)` 在一元响应成功后从客户端投影中移除注册记录;对应的 `host/workspace-removed` 帧具有幂等性并负责同步其他标签页。Session 状态与当前 Session selection 相互独立,因此 Workspace 消失后,其已记账的 Session 会立即投影到 Ungrouped 下。
SlotsService 分别为 renderer 提供 `useSessions``useWorkspaces` 的裸 observableweb-react 创建 hook。Workspace 业务状态不会进入 `SessionListState` 或配置项 store。

View File

@@ -30,7 +30,7 @@ export type {
export type {
AssistantBlock, AssistantMessageNode, CodeSubCall, ComposerPhase, ContextMessageNode, ConversationNode,
ConversationSnapshot, QueuedMessage, RunningToolCall,
SteeringMessageNode, ToolResultNode, UnknownSurfaceNode, UserMessageNode,
SteeringMessageNode, TodoItem, ToolResultNode, UnknownSurfaceNode, UserMessageNode,
} from './sessions/conversation.ts'
export { PendingWait } from './sessions/pending.ts'
export type { PendingInteraction, PendingKind, PendingPayloads } from './sessions/pending.ts'

View File

@@ -4,11 +4,14 @@
// string here (narrow to real brands when convenient).
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { TodoItem } from '@deepseek-ai/dsh-session/types'
import type {
RpcError, SessionId, ToolCallView, ToolResultView,
} from '@deepseek-ai/dsh-client-connection/client'
import type { PendingInteraction } from './pending.ts'
export type { TodoItem }
/** Assistant content blocks sorted by what the UI cares about
* (text body / collapsible reasoning / tool-call card head / other fallback). */
export type AssistantBlock =
@@ -241,4 +244,7 @@ export interface ConversationSnapshot {
*/
blank: boolean
lastAgentError: string | null
/** Current whole-list `todo/write` projection — the tail page's full-log value, then each live
* write (last write wins); empty = the log holds no plan. */
todos: readonly TodoItem[]
}

View File

@@ -1,590 +0,0 @@
/**
* SessionsService: root sessions service — list snapshot store (manager
* projection; carries `current`, the persisted selection every
* session-scoped surface keys off — migrated here from ui-layout per the
* slot-parity design), Agent scope tree (mintScope pattern: no-op plugin
* Fiber + ctx.extend scope tag; one scope per session, agent id === session
* id), stable SessionBinding cache, ancestry walk.
*
* Scope lifecycle is stage-driven: a scope is minted lazily on first
* resolution (pure — resolution has no side effects and is render-safe);
* the event window and deferred teardown key off the STAGED session, which
* follows `list.current` exactly. Staging is the open signal: the window
* opens ⟺ the session is on stage (today the stage is `current`; the staged
* state can widen to a multi-pane list later). A session leaving the list
* tears its scope down immediately unless it is the staged one, whose scope
* survives frozen (read-only view) until the stage moves on.
*/
import type { Context, Fiber } from 'cordis'
import type { IApiClient, RpcError, SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client'
import type {
HostObservable, SessionMaybeProvideInfo, SessionProvideInfo,
} from '@deepseek-ai/dsh-client-ui-slots'
import type { SnapshotStore } from '../contract/store.ts'
import { createSnapshotStore } from '../contract/store.ts'
import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts'
import { SessionManager } from './manager.ts'
import type { SessionListPhase } from './manager.ts'
import type { Session } from './session.ts'
/** Session list row projected from the host list RPC plus live stream increments. */
export interface SessionSummary {
id: SessionId
/** Latest durable log-backed title, absent until the host projects one. */
title?: string
/** Human-facing label: durable title, project basename, then session id. */
displayTitle: string
cwd?: string
parentId?: SessionId
running: boolean
/**
* Empty-log bit (host summary derivation mirror). List surfaces hide blank
* sessions; New Session reuses a blank one targeting the same workspace.
* Filtering stays with the consumer — the store carries every row.
*/
blank: boolean
updatedAt: number
}
/**
* Session list store shape. `current` rides the same snapshot (arbitrated:
* 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
/** Arrival lifecycle projected 1:1 from the manager snapshot (see SessionListPhase): empty-with-ready means "truly no sessions". */
phase: SessionListPhase
}
/** Structured session-create failure. */
export class SessionCreateError extends Error {
override readonly name = 'SessionCreateError'
/**
* @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}`)
}
}
/** Session assembly handle for SessionProvider/inject factories (identity-stable per session). */
export interface SessionBinding {
readonly sessionId: SessionId
readonly session: Session
readonly ctx: Context
}
// Scope primitives live in ../agents/scope.ts (the client mirror of host
// dsh-scope, keyed by Agent identity); re-exported here so existing
// consumers keep their import site.
export { scopeOf } from '../agents/scope.ts'
/**
* 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.
*/
function displayTitleOf(title: string | undefined, cwd: string | undefined, id: SessionId): string {
if (title !== undefined) return title
if (cwd !== undefined && cwd !== '') {
const base = workspaceTitleOf(cwd)
if (base !== '') return base
}
return id
}
interface ScopeRecord {
fiber: Fiber
ctx: Context
binding: SessionBinding
/** Render-layer standard-props bundle (identity-stable per scope; the renderer's per-info caches key off it). */
provideInfo: SessionProvideInfo
}
/** One plugin's per-session standard-props contribution (see {@link SessionsService.provide}). */
export interface SessionProvideContribution {
/** Bare observable sources, keyed by hook base name ('input' → useInput). */
hooks?: Record<string, HostObservable<unknown>>
/** Stable plain members (action callbacks etc.), spread into standard props verbatim. */
props?: Record<string, unknown>
}
/**
* Static declaration plus per-session resolver for one standard-kit
* contribution. The declared names let the renderer construct the same hook
* and prop surface while no session is current.
*/
export interface SessionProvideDescriptor {
/** Hook base names (`input` becomes `useInput`). */
hooks?: readonly string[]
/** Plain standard-prop names. */
props?: readonly string[]
/** Resolve every declared member for one definite session. */
resolve(binding: SessionBinding): SessionProvideContribution
}
/** Root sessions service: list store, current selection, object-layer manager, scope tree, bindings, ancestry. */
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. */
private readonly manager: SessionManager
/**
* Persisted selection cell (the durable half of `list.current`). Private on
* purpose: reads go through the list snapshot; writes through {@link
* SessionsService.open} / {@link SessionsService.clear}. Projection
* validates it against the live list instead of destructively pruning, so a
* selection survives transient list states (reconnect re-pull) and
* resurfaces when its session returns.
*/
private readonly selection: SnapshotStore<{ sessionId?: SessionId }>
private readonly scopes = new Map<SessionId, ScopeRecord>()
/** Registered per-session standard-props providers, in registration order. */
private readonly providers: SessionProvideDescriptor[] = []
/** Static no-session projection, rebuilt only when the provider roster changes. */
private maybeInfo: SessionMaybeProvideInfo
/**
* The staged session id — follows `list.current` exactly, holding its last
* defined value across masked gaps (a transiently absent selection blanks
* `current` without moving the stage, so reconnect re-pulls and removals
* keep the staged scope's frozen view alive until the stage moves on).
*/
private watched: SessionId | undefined
/** Removed-while-staged sessions whose teardown waits for the stage to move away. */
private readonly deferredRemovals = new Set<SessionId>()
/**
* @param ctx - client root context (scope fibers mount under it).
* @param api - wire client shared with every Session.
*/
constructor(private readonly rootCtx: Context, api: IApiClient) {
this.selection = createSnapshotStore<{ sessionId?: SessionId }>(
{},
{ persist: { name: 'dsh.sessions.current' } })
this.manager = new SessionManager(api, this.selection.getSnapshot().sessionId)
this.list = createSnapshotStore<SessionListState>({
ids: [], byId: {}, current: undefined, phase: 'pending',
})
// The manager owns wire truth; the store is its projection. Manager
// notifications are already microtask-batched.
this.manager.subscribe(() => { this.projectList() })
// Stage follower: every current write (open() and projection alike)
// re-evaluates staging, so startup restore (persisted selection validated
// by the projection) and reconnect resurfacing open their window with no
// dedicated code path. Safe to run synchronously inside the store notify:
// the follower writes no list state — session.open()'s synchronous prefix
// touches only session-side state and its own microtask-batched notifier.
this.list.subscribe(() => { this.followCurrent() })
// The runtime's own contribution comes first: useSession rides the same
// provide channel every plugin uses (no renderer special case).
this.providers.push({
hooks: ['session'],
resolve: binding => ({ hooks: { session: binding.session } }),
})
this.maybeInfo = this.materializeMaybeProvideInfo()
rootCtx.reflect.provide('sessions', this, undefined)
}
/**
* Register a per-session standard-props provider: every session-scope slot
* component receives the contributed members as standard props (`hooks`
* sources become `use<Name>` selector hooks on the render side; `props`
* spread verbatim). Contributions materialize lazily with the session's
* scope record and die with it. Registration order is resolution order;
* duplicate member names fail loud at materialization.
* @param descriptor - static member roster plus per-session resolver.
* @returns disposer removing the provider (already-materialized bundles keep their members until their scope drops).
*/
provide(descriptor: SessionProvideDescriptor): () => void {
this.providers.push(descriptor)
// Scopes may already exist (boot order: the list lands and resolves
// scopes before later plugins register) — their bundles must include
// every provider by first render, so re-materialize on roster change.
this.rematerializeProvideBundles()
return () => {
const at = this.providers.indexOf(descriptor)
if (at >= 0) this.providers.splice(at, 1)
this.rematerializeProvideBundles()
}
}
/** Rebuild every live scope's standard-props bundle after a provider roster change. */
private rematerializeProvideBundles(): void {
this.maybeInfo = this.materializeMaybeProvideInfo()
for (const record of this.scopes.values()) {
record.provideInfo = this.materializeProvideInfo(record.binding)
}
}
/** Build the static no-session kit and reject duplicate declared names. */
private materializeMaybeProvideInfo(): SessionMaybeProvideInfo {
const hooks: Record<string, undefined> = {}
const props: Record<string, undefined> = {}
for (const descriptor of this.providers) {
for (const name of descriptor.hooks ?? []) {
if (Object.hasOwn(hooks, name)) throw new Error(`sessions.provide: duplicate hook "${name}"`)
hooks[name] = undefined
}
for (const name of descriptor.props ?? []) {
if (Object.hasOwn(props, name)) throw new Error(`sessions.provide: duplicate prop "${name}"`)
props[name] = undefined
}
}
return { sessionId: undefined, hooks, props }
}
/** Materialize the standard-props bundle for one session (fails loud on duplicate member names). */
private materializeProvideInfo(binding: SessionBinding): SessionProvideInfo {
const hooks: Record<string, HostObservable<unknown>> = {}
const props: Record<string, unknown> = {}
for (const descriptor of this.providers) {
const contribution = descriptor.resolve(binding)
const contributedHooks = contribution.hooks ?? {}
const contributedProps = contribution.props ?? {}
for (const name of Object.keys(contributedHooks)) {
if (!(descriptor.hooks ?? []).includes(name)) {
throw new Error(`sessions.provide: undeclared hook "${name}"`)
}
}
for (const name of Object.keys(contributedProps)) {
if (!(descriptor.props ?? []).includes(name)) {
throw new Error(`sessions.provide: undeclared prop "${name}"`)
}
}
for (const name of descriptor.hooks ?? []) {
const source = contributedHooks[name]
if (source === undefined) throw new Error(`sessions.provide: missing hook "${name}"`)
if (Object.hasOwn(hooks, name)) throw new Error(`sessions.provide: duplicate hook "${name}"`)
hooks[name] = source
}
for (const name of descriptor.props ?? []) {
if (!Object.hasOwn(contributedProps, name)) throw new Error(`sessions.provide: missing prop "${name}"`)
if (Object.hasOwn(props, name)) throw new Error(`sessions.provide: duplicate prop "${name}"`)
props[name] = contributedProps[name]
}
}
return { sessionId: binding.sessionId, hooks, props }
}
/**
* Select a session as current. Unknown ids fail loud instead of navigating
* nowhere.
* @param id - session id (must exist in the list store).
*/
open(id: SessionId): void {
this.manager.select(id)
}
/**
* Clear the current selection so the layout shows the no-session empty
* 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.manager.clearSelection()
}
/**
* 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. Resolution guarantee: by the time the
* promise resolves, the created session is in the list store and
* {@link SessionsService.binding} resolves it — callers (New Session
* draft hand-off) may address the scope synchronously, without waiting a
* notifier flush. The synchronous projection below makes this structural
* rather than an accident of microtask ordering.
* @param opts - target workspace or directory and an optional preallocated id.
* @returns the new session id.
* @throws {SessionCreateError} with the requested id.
*/
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)
this.projectList()
return result.value.sessionId
}
/**
* Resolve an Agent-scoped context view (use-and-discard).
* @param id - session id (the agent identity — 1:1 same axis).
* @returns scoped ctx, or undefined for a session neither listed nor already scoped.
*/
scope(id: SessionId): Context | undefined {
return this.resolve(id)?.ctx
}
/**
* Read the Agent scope tag off a context. Service-method seam: fetch
* bundles must reach scope resolution through ctx.sessions — a cross-bundle
* value import of the standalone helper would inline a second module
* instance whose private tag Symbol never matches.
* @param ctx - any client context.
* @returns the session id, or undefined on root contexts.
*/
scopeOf(ctx: Context): SessionId | undefined {
return scopeTagOf(ctx)
}
/**
* Resolve the business Session behind an Agent-scoped context — the one
* hop every scoped consumer (event listeners, per-session controllers)
* takes from ctx-space into object-space (the client mirror of host
* `agent.session`). Same service-method seam as
* {@link SessionsService.scopeOf}.
* @param ctx - an Agent-scoped context.
* @returns the Session, or undefined when the ctx is untagged or its scope was pruned.
*/
sessionOf(ctx: Context): Session | undefined {
const id = scopeTagOf(ctx)
if (id === undefined) return undefined
return this.scopes.get(id)?.binding.session
}
/**
* Resolve the stable session binding (scope-addressed assembly feed). Pure
* resolution — no staging, no window side effects.
* @param id - session id.
* @returns binding, or undefined for a session neither listed nor already scoped.
*/
binding(id: SessionId): SessionBinding | undefined {
return this.resolve(id)?.binding
}
/**
* Resolve the render-layer standard-props bundle (SessionProvider's feed
* through the renderer host; ctx never enters the render layer). Pure
* resolution — render-safe: SessionProvider calls this during render, so no
* staging, no window side effects (StrictMode double-invokes and concurrent
* discarded passes must stay free).
* @param id - session id.
* @returns the provide info, or undefined for a session neither listed nor already scoped.
*/
provideInfo(id: string): SessionProvideInfo | undefined {
return this.resolve(id as SessionId)?.provideInfo
}
/**
* Resolve the current-session-optional standard kit. Unknown or absent ids
* return the static no-session projection rather than removing hook props.
* @param id - current session id, when selected.
* @returns a definite or no-session provide bundle.
*/
maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo {
return (id === undefined ? undefined : this.provideInfo(id)) ?? this.maybeInfo
}
/**
* Move the stage to the list's current session: sweep teardowns deferred
* behind the previous occupant and pull the new occupant's history window.
* Staging IS the open signal — the window opens ⟺ the session is on stage
* — and open() is idempotent (an in-flight or completed open no-ops; a
* failed one retries the next time current is touched).
*/
private followCurrent(): void {
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 || snapshot.byId[current] === undefined || current === this.watched) return
this.watched = current
this.sweepDeferred()
const record = this.resolve(current)
/* v8 ignore next 3 -- defensive: current is always a listed id (open()
* validates and the projection masks absent selections), so resolve
* cannot miss; kept so a future current writer cannot crash the notify. */
if (record !== undefined) {
void record.binding.session.open()
}
}
/**
* Breadcrumb feed: walk parentId links inside the list store.
* @param id - session id.
* @returns summaries from root ancestor to the session itself (empty when unknown; a broken link stops the walk).
*/
ancestry(id: SessionId): SessionSummary[] {
const { byId } = this.list.getSnapshot()
const chain: SessionSummary[] = []
let cursor: SessionId | undefined = id
while (cursor !== undefined) {
const summary: SessionSummary | undefined = byId[cursor]
if (summary === undefined || chain.includes(summary)) break
chain.unshift(summary)
cursor = summary.parentId
}
return chain
}
/**
* Lazily mint the scope + binding for an eligible session. Eligibility and
* prune share one predicate (decision 12): listed on the host — a scope is
* born when its session enters the client's view (list mirror row from the
* baseline pull, a create() echo, or the session-added frame) and dies with
* the prune when the row leaves.
*/
private resolve(id: SessionId): ScopeRecord | undefined {
const existing = this.scopes.get(id)
if (existing !== undefined) return existing
if (!this.eligible(id)) return undefined
const { fiber, ctx } = createScope(this.rootCtx, id)
const session = this.manager.get(id)
// The Session owns its scoped dispatch point (host Agent.loopCtx mirror);
// mint and bind are one step so a live scope record implies a bound actx.
session.bindScope(ctx)
const binding: SessionBinding = { sessionId: id, session, ctx }
const record: ScopeRecord = {
fiber,
ctx,
binding,
// Sources are bare observables; React binds selector hooks at its own seam.
provideInfo: this.materializeProvideInfo(binding),
}
this.scopes.set(id, record)
return record
}
/** The one aliveness predicate shared by scope mint and prune: host-listed. */
private eligible(id: SessionId): boolean {
return this.list.getSnapshot().byId[id] !== undefined
}
/** Project the manager's list snapshot into the store (title derivation is display-only). */
private projectList(): void {
const { items, current, phase } = this.manager.getListSnapshot()
const ids: SessionId[] = []
const byId: Record<SessionId, SessionSummary> = {}
for (const entry of items) {
ids.push(entry.sessionId)
byId[entry.sessionId] = {
id: entry.sessionId,
displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId),
running: entry.running,
blank: entry.blank,
updatedAt: entry.updatedAt,
...(entry.title !== undefined ? { title: entry.title } : {}),
...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}),
...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}),
}
}
const persisted = this.selection.getSnapshot().sessionId
// No current (cleared, or masked gap) wipes the persisted cell — a reload
// stays on empty; the in-memory selection still resurfaces a masked id.
if (current === undefined) {
if (persisted !== undefined) this.selection.set({})
} else if (byId[current] !== undefined && persisted !== current) {
this.selection.set({ sessionId: current })
}
this.list.set({ ids, byId, current, phase })
this.pruneScopes(byId)
}
/** Tear down scope + instance for no-longer-eligible sessions off stage; the staged one defers until the stage moves. */
private pruneScopes(byId: Record<SessionId, SessionSummary>): void {
void byId
for (const [id, record] of this.scopes) {
if (this.eligible(id)) continue
if (id === this.watched) {
this.deferredRemovals.add(id)
continue
}
this.scopes.delete(id)
this.deferredRemovals.delete(id)
this.dropScope(id, record)
}
}
/**
* One teardown for the whole per-session axis (decision 12): the scope
* fiber (cascading every actx-registered effect: input shell, slash
* controller, popup, plugin stores, listeners), the session-keyed slot
* stores, and the Session instance itself — the host session log is the
* durable truth, a reopen lazily rebuilds and backfills via open().
*/
private dropScope(id: SessionId, record: ScopeRecord): void {
void record.fiber.dispose()
// Release the Session's dispatch point with the scope it belongs to (a
// surviving instance — the live Intent — rebinds when resolve re-mints).
record.binding.session.unbindScope()
// Optional lookup: slots and sessions are sibling services with no
// declared dependency; a slots-less boot (object-layer tests) skips.
this.rootCtx.get('slots')?.pruneStoreScope(id)
this.manager.drop(id)
}
/** Run deferred teardowns whose session is no longer staged (called when the stage moves). */
private sweepDeferred(): void {
for (const id of [...this.deferredRemovals]) {
/* v8 ignore next -- defensive: only the staged id ever defers, and every
* stage move sweeps first, so the set cannot contain the id the stage just
* moved to; kept as a guard against future extra sweep call sites. */
if (id === this.watched) continue
// Eligible again? (A re-added id cancels the deferred teardown.)
if (this.eligible(id)) {
this.deferredRemovals.delete(id)
continue
}
const record = this.scopes.get(id)
this.deferredRemovals.delete(id)
/* v8 ignore next -- defensive: prune deletes a scope and its deferral
* together, so a deferred id always still owns its record; kept so a
* future teardown path cannot double-dispose. */
if (record !== undefined) {
this.scopes.delete(id)
this.dropScope(id, record)
}
}
}
}

View File

@@ -2,7 +2,7 @@
import type { Context } from 'cordis'
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type { SessionEvent, TodoItem } from '@deepseek-ai/dsh-session/types'
import type {
HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult,
SessionId, ToolEventView,
@@ -99,6 +99,9 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
private queueCache: { rev: number; value: QueuedMessage[] } | null = null
private frozenRev = 0
private nodesCache: { folded: readonly ConversationNode[]; frozenRev: number; value: readonly ConversationNode[] } | null = null
/** Current whole-list todo/write projection: each tail history response replaces it (an omitted
* field is the authoritative empty list) and every live write overwrites it. */
private todos: readonly TodoItem[] = []
/** `run_code` sub-dispatches by parent callId (window-derived, like openCalls). Appends
* copy-on-write the per-parent array so published snapshot references never mutate. */
private codeDispatches = new Map<string, readonly CodeSubCall[]>()
@@ -479,13 +482,13 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
this.openError = result.error
return
}
this.installWindow(result.value.events, result.value.hasMore)
this.installWindow(result.value.events, result.value.hasMore, result.value.todos)
// Gap detection (§D.3-4): baseline past the window tail and liveBuffer did not cover it -> pull the tail page once more.
const tailSeq = this.windowTailSeq()
if (this.subscribedLastSeq !== null && tailSeq !== null && this.subscribedLastSeq > tailSeq) {
result = (await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })).result
if (generation !== this.openGeneration) return
if (result.ok) this.installWindow(result.value.events, result.value.hasMore)
if (result.ok) this.installWindow(result.value.events, result.value.hasMore, result.value.todos)
}
this.openState = 'open'
} catch (error) {
@@ -503,11 +506,19 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
* Stitching MUST NOT route through acceptLiveEvent: openState is still 'loading' here
* (doOpen flips it after install), so recursing would push every buffered event straight
* back into liveBuffer where nothing ever drains it — a silent drop loop (audit S1). */
private installWindow(entries: HistoryEntry[], hasMore: boolean): void {
private installWindow(entries: HistoryEntry[], hasMore: boolean, todos: readonly TodoItem[] | undefined): void {
this.events = entries.map(e => e.event)
this.views = entries.map(e => e.view)
this.baseSeq = this.events[0]?.seq ?? 0
this.hasMore = hasMore
// Session-level projection from the tail page (full-log latest todo/write,
// independent of the window); an in-window write below re-derives the same
// value, and later live events keep overwriting it. Every caller here is a
// tail request (no beforeSeq), which the host answers with the projection
// or omits it only when the full log holds no todo/write — so an absent
// field is the authoritative empty list, not a missing carrier. Assigning
// it clears a plan the log never kept (a write lost to a host crash).
this.todos = todos ?? []
this.foldAdapter.reset(this.events, this.baseSeq, this.views)
this.rebuildDerivedFromWindow()
const buffered = this.liveBuffer
@@ -558,7 +569,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
const { result } = await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })
// Failure or superseded by a full resync: drop — the resync path rebuilds and clears the buffer itself.
if (result.ok && generation === this.openGeneration && this.openState === 'open') {
this.installWindow(result.value.events, result.value.hasMore)
this.installWindow(result.value.events, result.value.hasMore, result.value.todos)
}
} catch (error) {
console.error('[web-runtime] gap repair failed:', error)
@@ -678,6 +689,10 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
if (this.openCalls.delete(String(event.data.callId))) this.callsRev++
return
}
case 'todo/write': {
this.todos = event.data.todos
return
}
case 'turn/end': {
// Aborted turns never finalize. The accumulated partial is VALUE, not residue: freeze it
// into an interrupted terminal node (pulse stops, text survives) instead of deleting it.
@@ -722,7 +737,10 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
/** Re-derive state (partial/openCalls/frozenNodes) from raw window events after a rebuild — keeps
* paging/stitching consistent, and makes the live freeze and the history replay converge on the
* same interrupted nodes (chunks are logged, so the replayed sweep re-freezes identical text). */
* same interrupted nodes (chunks are logged, so the replayed sweep re-freezes identical text).
* todos is deliberately NOT reset: it is session-level (seeded by the tail page's full-log
* projection, not derivable from an arbitrary window). The window always extends to the log
* tail, so an in-window todo/write can only overwrite it with the same latest value. */
private rebuildDerivedFromWindow(): void {
this.partial = null
this.openCalls.clear()
@@ -792,6 +810,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
promptError: this.promptError,
blank: this.blankBit,
lastAgentError: this.lastAgentError,
todos: this.todos,
}
}
}

View File

@@ -19,6 +19,10 @@ export interface WorkspaceListSnapshot {
error: RpcError | null
}
type WorkspaceDelta =
| { type: 'upsert'; workspace: WorkspaceView }
| { type: 'remove'; workspaceId: WorkspaceId }
/** Workspace object cluster driven by one list baseline and changed-frame upserts. */
export class WorkspaceManager {
private items: Workspace[] = []
@@ -28,7 +32,16 @@ export class WorkspaceManager {
private phase: WorkspaceListPhase = 'pending'
private error: RpcError | null = null
private inflight: Promise<void> | null = null
private refreshFrames: WorkspaceView[] | null = null
private refreshFrames: WorkspaceDelta[] | null = null
/**
* 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
* row. Correctness rests on Host ids never being reused (the registry mints
* a fresh `randomUUID` per record, including when the same directory is
* registered again) — a path-derived id scheme would turn these entries
* into permanent blindfolds and must clear them instead.
*/
private readonly removedIds = new Set<WorkspaceId>()
private snapshotCache: WorkspaceListSnapshot
private readonly notifier = new Notifier(() => {
this.snapshotCache = this.buildSnapshot()
@@ -51,7 +64,7 @@ export class WorkspaceManager {
this.state = 'loading'
this.error = null
const established = this.itemViews()
const frames: WorkspaceView[] = []
const frames: WorkspaceDelta[] = []
this.refreshFrames = frames
this.notifier.markDirty()
this.inflight = (async () => {
@@ -61,7 +74,8 @@ export class WorkspaceManager {
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)
items = items.filter(workspace => !this.removedIds.has(workspace.workspaceId))
for (const delta of frames) items = applyWorkspaceDelta(items, delta)
this.installViews(items)
this.state = 'idle'
this.phase = 'ready'
@@ -111,6 +125,18 @@ export class WorkspaceManager {
return result
}
/**
* Delete a Workspace registration and remove its local projection from the
* unary response without waiting for the Host frame.
* @param workspaceId - target workspace.
* @returns the wire result.
*/
async delete(workspaceId: WorkspaceId): Promise<RpcResult<{ deleted: true }>> {
const { result } = await this.api.workspace.delete({ workspaceId })
if (result.ok) this.remove(workspaceId, true)
return result
}
/**
* Move a session within its Workspace's manual order, then publish the
* returned snapshot without waiting for the changed frame.
@@ -139,6 +165,7 @@ 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)
}
/** Re-pull the baseline after each connection generation. */
@@ -175,7 +202,8 @@ export class WorkspaceManager {
/** Upsert one Host view, optionally retaining the local object that materialized it. */
private upsert(view: WorkspaceView, identity?: Workspace): void {
this.refreshFrames?.push(view)
if (this.removedIds.has(view.workspaceId)) return
this.refreshFrames?.push({ type: 'upsert', workspace: view })
const index = this.items.findIndex(item => item.getSnapshot().view?.workspaceId === view.workspaceId)
// Mutation responses and changed frames race (two carriers, no ordering):
// reject a snapshot strictly older than the installed projection so a
@@ -195,6 +223,24 @@ export class WorkspaceManager {
this.notifier.markDirty()
}
/** Remove one id idempotently and retain a tombstone against late echoes. */
private remove(workspaceId: WorkspaceId, direct = false): void {
this.refreshFrames?.push({ type: 'remove', workspaceId })
this.removedIds.add(workspaceId)
const items = this.items.filter(item =>
item.getSnapshot().view?.workspaceId !== workspaceId)
if (items.length === this.items.length) {
// The Host frame may have removed the row first but left its batched
// notification pending. A successful unary echo still flushes that
// committed state before the user action resolves.
if (direct) this.notifier.notifyNow()
return
}
this.items = items
if (direct) this.notifier.notifyNow()
else this.notifier.markDirty()
}
private installViews(views: readonly WorkspaceView[]): void {
const existing = new Map(
this.items.flatMap((workspace) => {
@@ -234,3 +280,10 @@ function upsertWorkspace(items: readonly WorkspaceView[], workspace: WorkspaceVi
? [workspace, ...items]
: items.map((item, position) => position === index ? workspace : item)
}
/** 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)
}

View File

@@ -174,6 +174,16 @@ export class WorkspacesService {
return result.value.workspace
}
/**
* Delete one Workspace registration. Sessions, session logs, and the
* directory remain Host-owned outside this operation.
* @param workspaceId - target workspace.
*/
async delete(workspaceId: WorkspaceId): Promise<void> {
const result = await this.manager.delete(workspaceId)
if (!result.ok) throw new Error(`workspace delete failed: ${result.error.code}: ${result.error.message}`)
}
/**
* Move a session within its Workspace's manual order (DOM-insertBefore-like).
* @param workspaceId - owning workspace.

View File

@@ -40,6 +40,8 @@ export const ev = {
at(seq, { type: 'step/end', data: { turn, step } }),
turnEnd: (seq: number, turn: number, reason: 'completed' | 'cancelled' = 'completed'): SessionEvent =>
at(seq, { type: 'turn/end', data: { turn, reason: { kind: reason } } }),
todoWrite: (seq: number, todos: { content: string; status: 'pending' | 'in_progress' | 'completed' }[]): SessionEvent =>
at(seq, { type: 'todo/write', data: { todos } }),
}
/** One complete plain turn (turn/start → user → step → assistant → turn/end), 6 events from startSeq. */

View File

@@ -62,7 +62,7 @@ export class FakeApiClient implements IApiClient {
onList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean }>> =
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean; todos?: { content: string; status: 'pending' | 'in_progress' | 'completed' }[] }>> =
() => Promise.resolve(ok({ events: [], hasMore: false }))
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
@@ -96,6 +96,9 @@ export class FakeApiClient implements IApiClient {
onWorkspaceRename: (payload: unknown) => Promise<RpcResponse<{ workspace: WorkspaceView }>> =
() => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') }))
onWorkspaceDelete: (payload: unknown) => Promise<RpcResponse<{ deleted: true }>> =
() => Promise.resolve(ok({ deleted: true }))
onWorkspaceInsertSessionBefore: (payload: unknown) => Promise<RpcResponse<{ workspace: WorkspaceView }>> =
() => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') }))
@@ -103,6 +106,7 @@ export class FakeApiClient implements IApiClient {
list: (payload: unknown) => this.record('workspace.list', payload, this.onWorkspaceList(payload)),
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)),
insertSessionBefore: (payload: unknown) =>
this.record('workspace.insertSessionBefore', payload, this.onWorkspaceInsertSessionBefore(payload)),
}

View File

@@ -22,9 +22,9 @@ function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session:
return { api, session: new Session(SID, api) }
}
function histResponse(events: SessionEvent[], hasMore = false) {
function histResponse(events: SessionEvent[], hasMore = false, todos?: { content: string; status: 'pending' | 'in_progress' | 'completed' }[]) {
// history now returns HistoryEntry[] ({event, view?}); these tests are view-less.
return Promise.resolve(ok({ events: entries(events) as never[], hasMore }))
return Promise.resolve(ok({ events: entries(events) as never[], hasMore, ...todos === undefined ? {} : { todos } }))
}
describe('open', () => {
@@ -153,6 +153,42 @@ describe('live event path', () => {
})
})
it('folds todo/write into snapshot.todos last-write-wins, live and on window replay', async () => {
const listA = [{ content: '搭骨架', status: 'completed' as const }, { content: '写组件', status: 'in_progress' as const }]
const listB = [{ content: '搭骨架', status: 'completed' as const }, { content: '写组件', status: 'completed' as const }]
const { session } = await opened()
expect(session.getSnapshot().todos).toEqual([])
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
feed(ev.todoWrite(6, listA))
expect(session.getSnapshot().todos).toEqual(listA)
feed(ev.todoWrite(7, listB))
expect(session.getSnapshot().todos).toEqual(listB)
// Window replay converges on the same last snapshot (history contains both writes).
const replayed = makeSession()
replayed.api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ev.todoWrite(6, listA), ev.todoWrite(7, listB)])
await replayed.session.open()
expect(replayed.session.getSnapshot().todos).toEqual(listB)
})
it('seeds todos from the tail page projection when the last write precedes the window', async () => {
const list = [{ content: '窗口外的计划', status: 'in_progress' as const }]
// Cold open: the page window carries NO todo/write; the projection rides the response.
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(100, 9, '问', '答'), true, list)
await session.open()
expect(session.getSnapshot().todos).toEqual(list)
// Paging an older window in must not clear the session-level projection.
api.onHistory = () => histResponse(plainTurn(94, 8, '旧问', '旧答'), false)
await session.loadOlder()
expect(session.getSnapshot().todos).toEqual(list)
// A later live write still overrides the seeded projection.
session.handleMuxEnvelope('r' as never, {
type: 'session/event', sessionId: SID,
event: ev.todoWrite(106, [{ content: '新计划', status: 'pending' as const }]),
})
expect(session.getSnapshot().todos).toEqual([{ content: '新计划', status: 'pending' }])
})
it('repairs a seq gap by repulling the tail page instead of appending a hole', async () => {
const { api, session } = await opened(plainTurn(0, 0, 'a', 'b')) // tail seq = 5
const repaired = [...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')]
@@ -166,6 +202,37 @@ describe('live event path', () => {
const seqs = session.getSnapshot().nodes.map(n => n.seq)
expect(seqs).toEqual([1, 3, 7, 9]) // both turns' user/assistant, no hole, no duplicate 9
})
it('gap repair adopts the repull response projection (a missed todo/write outside the new tail page)', async () => {
const { api, session } = await opened(plainTurn(0, 0, 'a', 'b')) // tail seq = 5
expect(session.getSnapshot().todos).toEqual([])
// The missed range contained a todo/write that the repulled page no longer
// covers; the response's session-level projection is the only carrier.
const current = [{ content: '断线期间写的', status: 'in_progress' as const }]
api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...plainTurn(8, 1, 'c', 'd')], false, current)
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.assistant(11, 1, 'd') })
await vi.waitFor(() => {
expect(api.callsOf('session.history').length).toBe(2)
})
await Promise.resolve()
expect(session.getSnapshot().todos).toEqual(current)
})
it('clears the plan when a tail response omits the projection (a write the log never kept)', async () => {
// Live write lands, then the host crashes before persisting it: the
// authoritative log holds no todo/write, so the resync tail response
// carries no projection — an omitted field on a tail request is the empty
// list, not a missing carrier, and the rolled-back plan must disappear.
const { api, session } = await opened(plainTurn(0, 0, 'a', 'b'))
session.handleMuxEnvelope('r' as never, {
type: 'session/event', sessionId: SID,
event: ev.todoWrite(6, [{ content: '丢失的计划', status: 'in_progress' as const }]),
})
expect(session.getSnapshot().todos).toEqual([{ content: '丢失的计划', status: 'in_progress' }])
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
await session.resync()
expect(session.getSnapshot().todos).toEqual([])
})
})
describe('paging', () => {

View File

@@ -76,6 +76,48 @@ describe('WorkspaceManager', () => {
ok: false, error: { code: 'internal', message: 'create transport' },
})
})
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']>>>()
api.onWorkspaceList = () => gate.promise
const manager = new WorkspaceManager(api)
const hydration = manager.refresh()
manager.handleHostEnvelope({
rpcId: 'removed' as never,
payload: { type: 'host/workspace-removed', workspaceId: wid('gone') },
})
gate.resolve(ok({ items: [workspace('gone'), workspace('kept')] as never[] }))
await hydration
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['kept'])
manager.handleHostEnvelope({
rpcId: 'late-change' as never,
payload: { type: 'host/workspace-changed', workspace: workspace('gone') },
})
manager.handleHostEnvelope({
rpcId: 'duplicate-remove' as never,
payload: { type: 'host/workspace-removed', workspaceId: wid('gone') },
})
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['kept'])
})
it('removes from the unary delete echo while a refresh is in flight', async () => {
const api = new FakeApiClient()
api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('gone')] as never[] }))
const manager = new WorkspaceManager(api)
await manager.refresh()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceList']>>>()
api.onWorkspaceList = () => gate.promise
const refresh = manager.refresh()
await expect(manager.delete(wid('gone'))).resolves.toMatchObject({ ok: true })
expect(api.callsOf('workspace.delete')).toEqual([{ workspaceId: 'gone' }])
expect(manager.getSnapshot().items).toEqual([])
gate.resolve(ok({ items: [workspace('gone')] as never[] }))
await refresh
expect(manager.getSnapshot().items).toEqual([])
})
})
describe('WorkspacesService', () => {
@@ -175,4 +217,20 @@ describe('WorkspacesService', () => {
}))
await expect(workspaces.create({ path: '/missing' })).rejects.toThrow(/workspace-invalid-path: missing/)
})
it('deletes a Workspace or preserves it when the Host rejects deletion', 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('alpha')] as never[] }))
await workspaces.refresh()
await expect(workspaces.delete(wid('alpha'))).resolves.toBeUndefined()
expect(workspaces.list.getSnapshot().items).toEqual([])
api.onWorkspaceDelete = () => Promise.resolve(err({
code: 'workspace-not-found', message: 'gone', details: { workspaceId: 'ghost' },
}))
await expect(workspaces.delete(wid('ghost'))).rejects.toThrow(/workspace-not-found: gone/)
})
})

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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
README.md: b9ec555f158722ea1f41e01c4b3f7131d3fe3467
README.zh.md: b1e3c1f4331148ebf1c58b4bcd4869270bb44311
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
README.md: 453922dafd1eb7a617cb2d1c93ac1daa2e7273c6
README.zh.md: 88992176165ab11050a30c7df381479796908ba2

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
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).
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 and the todo row), input dock (queue rows plus the todo plan strip), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares).
The no-session hero renders the frontend Session Intent from the Session list projection, including its frontend Workspace Intent when no real Workspace exists. It declares `conversation.empty.workspace`, where ui-workspace registers the same picker used by the sidebar. WorkspacesService starts the cross-object flow; each Workspace or Session object owns its own materialization. The Session keeps its identity across publication and retains any prompt that still needs connection or delivery; ConversationRoot reads that `pendingPrompt` from `useSession` and edits or retries it through the scoped ConversationService.
@@ -12,6 +12,8 @@ Generic tool rows classify the built-in bash, read, search, write, edit, and run
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).
The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> 已完成 · <active item>` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the durable plan strip: it selects `todos` off the session snapshot and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and collapses to a header of title plus `"<done>/<total> tasks · <n> in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the persistent list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.
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).
@@ -31,3 +33,4 @@ None; this package neither assembles nor sends a provider request.
- **Assistant footer extensions (IconActions row, per-message paging) are reserved slots** — drawn in the design, not implemented.
- **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export.
- **Approval cards are display-only placeholders** — question requests answer through the composer chain (ui-question), while web-side approval answering is the P-II approvals project.
- **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、统计行、逐工具行 slot 及一个 bash 示例注册方)、最小详情面板、按 scope 寻址的 ConversationService。契约api-contracts v3 §7 加 slot 终端设计store seatprops share
会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、统计行、逐工具行 slot 及一个 bash 示例注册方与 todo 行)、输入区 dock队列行加 todo 计划条)、最小详情面板、按 scope 寻址的 ConversationService。契约api-contracts v3 §7 加 slot 终端设计store seatprops share
无会话主视觉区会渲染来自 Session 列表投影的前端 Session Intent没有真实 Workspace 时,还会包含其前端 Workspace Intent。它声明 `conversation.empty.workspace`ui-workspace 会在此注册侧边栏所用的同一选择器。WorkspacesService 启动跨对象流程;每个 Workspace 或 Session 对象拥有自身的物化。Session 在发布期间保持身份并保留任何仍需连接或交付的提示词ConversationRoot 读取该 `pendingPrompt`,其来源是 `useSession`,再通过 scope 内的 ConversationService 编辑或重试。
@@ -12,6 +12,8 @@
工具行同样是 slot独立工具环`ToolViewRegistry``ctx.toolviews`outlet已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位Session scopekey 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps``callId``toolName``block``openDetails``ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seamapply 在聊天注册后挂载 ConversationService因此服务存在即可保证 slot 已声明Session 区分在组件内部完成(`useSessions` 读取 `parentId`bash 示例是第三方姿态的范例。Trajectory/waterfall 工具视图 slot 共享此形状并随各自的渲染点落地RendersCheck 会拒绝没有任何渲染方的声明)。
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']``TodoRow` 占用 `'conversation.chat.toolview'``todo_write` key摘要该次调用「试图写入」的内容从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock``order: -1` 占用 `'conversation.input.dock'` 列表 slot位于队列行之上是常驻的计划条它从会话快照中选取 `todos` 并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏,折叠时收成标题加 `"<已完成>/<总数> tasks · <n> in progress"` 的表头(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;常驻列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock包括这条计划条。
逐 Session UI 状态(选择、普通编辑器草稿、活跃视图)位于已声明的聊天 store`stores.ts` `createChatStore`apply 构造一个 handle并将其传给会话、聊天视图和详情注册因此 Session slot 每个 Session 共享一个实例(选择由聊天视图写入、详情读取),框架拥有实例生命周期与草稿持久化。前端 Session Intent 来自 Session 列表投影;发布后,任何保留的提示词都来自该 Session 的会话快照。组件保持纯粹框架标准工具包Session scope 下的 `useSession``sessionId`,以及全局 `useSessions``useWorkspaces`)和 store 表层(`useStore``actions`会从注册声明自动到达inject factory 为运行时 Session 操作、发送/停止、标签页、详情和分页贡献普通数据与回调。
`src/client/` 按未来的包拆分组织:`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明 + 组合后的 slot props包括工具行契约、`views.ts` 共享原语、`tool-call-model.ts``skeleton/``chat/``toolviews/`(示例注册方)领域目录只导入 contract 文件,彼此绝不导入;`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply``inject`、两个服务类和 `contract/` 类型家族;实现组件(骨架、聊天行)与 store factory 保持内部状态,只能通过 apply 的 slot 注册到达页面(测试通过 `./src/*` 子路径获取它们)。
@@ -31,3 +33,4 @@
- **assistant footer 扩展IconActions 行、逐消息分页)是预留 slot**:设计中已有图稿,尚未实现。
- **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。
- **审批卡片只是只读占位符**问题请求通过编辑器链回答ui-questionWeb 侧审批回答属于 P-II 审批项目。
- **TodoPanel 将过长条目截成单行省略号**figma 条没有换行或展开入口,完整文本无法在行内读完。

View File

@@ -13,6 +13,8 @@ import { InputHub } from './input/hub.ts'
import { InputBar } from './skeleton/InputBar.tsx'
import { ChatView } from './chat/ChatView.tsx'
import { bashToolviewSample } from './toolviews/bash-sample.tsx'
import { todoToolview } from './toolviews/todo-row.tsx'
import { todoDockEntry } from './skeleton/TodoPanel.tsx'
import { queueDockEntry } from './queue/QueueDock.tsx'
import { ConversationRoot } from './skeleton/ConversationRoot.tsx'
import { ConversationSession } from './skeleton/ConversationSession.tsx'
@@ -179,9 +181,16 @@ export function apply(ctx: Context): void {
// 'conversation.chat.toolview' declaration) is on the ledger.
ctx.plugin(ConversationService, { input: inputHub })
// The bash sample rides that exact seam, in third-party posture.
// The bash sample rides that exact seam, in third-party posture
// (ToolRow-matching Bash · {description} chrome; scoped badge in child sessions).
ctx.plugin(bashToolviewSample)
// The todo_write row rides the same seam (a product registration, not a sample).
ctx.plugin(todoToolview)
// The plan strip rides the input dock above the queue rows (same posture).
ctx.plugin(todoDockEntry)
// The read-only queue dock entry (T9 file territory) rides the same
// registration seam into the input dock declared above.
ctx.plugin(queueDockEntry)

View File

@@ -37,17 +37,11 @@
border-radius: 6px;
}
/* Selection linkage: the selected call row wears the blue outline.
button-info-fill flips 500→400 with the theme, hitting the darker-blue
dark-mode spec exactly (business-primary stays 500 on both). */
.callRow[data-selected] {
outline: 1.5px solid var(--dsw-alias-button-info-fill);
outline-offset: 1px;
}
/* Selection still sets data-selected for details linkage; no outline
tool rows match Think chrome (no selected ring). */
/* run_code sub-dispatch rows: indented under the parent row, left-edged so
the code turn reads as one unit; each nested row is itself a .callRow
(same components, same selection outline as top-level rows). */
the code turn reads as one unit; each nested row is itself a .callRow. */
.subCalls {
display: flex;
flex-direction: column;

View File

@@ -1,10 +1,11 @@
/* User bubble: right-aligned, figma r22 fill = the bubble specific token
(#EDF3FE light / dark pair rides the token sheet). */
/* User bubble: right-aligned column (bubble + IconActions). Figma
User_Bubble/message_container 659:38813 — r22 fill, actions gap 6 below. */
/* Block spacing is the flow column's gap alone — no extra padding here. */
.userRow {
display: flex;
justify-content: flex-end;
flex-direction: column;
align-items: flex-end;
gap: 6px;
}
.bubble {
@@ -19,6 +20,46 @@
color: var(--dsw-alias-label-primary);
}
.actions {
display: flex;
align-items: center;
gap: 10px;
height: 28px;
}
/* Hover-capable pointers: hide until the row is hovered/focused. Touch /
hover:none keeps actions visible (opacity:0 still hit-tests). */
@media (hover: hover) {
.actions {
opacity: 0;
transition: opacity var(--ds-transition-duration) var(--ds-ease-in-out);
}
.userRow:hover .actions,
.userRow:focus-within .actions {
opacity: 1;
}
}
.action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 6px;
border: none;
border-radius: 28px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
.action:hover {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-secondary);
}
.badge {
display: inline-block;
margin-bottom: 4px;

View File

@@ -1,14 +1,18 @@
// MessageItem: the four simple node kinds — user bubble (right-aligned),
// steering (badged bubble), context injection and unknown-surface JSON rows.
// Props are frozen node slices off the snapshot cache; memo holds across
// streaming because unchanged nodes keep their references.
// MessageItem: the four simple node kinds — user bubble (right-aligned, with
// copy / branch / edit IconActions), steering (badged bubble), context
// injection and unknown-surface JSON rows. Props are frozen node slices off
// the snapshot cache; memo holds across streaming because unchanged nodes
// keep their references.
import { memo } from 'react'
import { memo, useCallback } from 'react'
import type { ReactNode } from 'react'
import type {
ContextMessageNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
import {
IconBranchOutline16, IconCopyOutline16, IconEditOutline16,
JsonBlock, MessageText, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import css from './MessageItem.module.css'
export interface MessageItemProps {
@@ -26,6 +30,35 @@ function contentText(content: readonly unknown[]): { text: string; rest: unknown
return { text: texts.join(''), rest }
}
/** Best-effort clipboard write; rejections stay swallowed (no success chrome). */
async function writeClipboard(text: string): Promise<void> {
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text)
} catch {
// Denied permissions / iframe policy.
}
return
}
const exec = typeof document.execCommand === 'function'
? document.execCommand.bind(document)
: undefined
if (exec === undefined) return
const el = document.createElement('textarea')
el.value = text
el.setAttribute('readonly', '')
el.style.position = 'fixed'
el.style.left = '-9999px'
document.body.appendChild(el)
el.select()
try {
exec('copy')
} catch {
// Clipboard unavailable; the button stays idle.
}
el.remove()
}
/**
* Display projection of reference forms in a user bubble (free geometry — no
* textarea alignment constraint here); everything else stays plain text. The
@@ -58,15 +91,52 @@ function projectUserText(text: string): ReactNode {
return <>{parts}</>
}
/** User-bubble IconActions (figma 659:38820): copy is live; branch/edit are chrome stubs. */
function UserActions({ text }: { text: string }) {
const onCopy = useCallback(() => {
void writeClipboard(text)
}, [text])
return (
<div className={css.actions}>
<Tooltip label="复制" side="bottom">
<button type="button" className={css.action} aria-label="复制" onClick={onCopy}>
<IconCopyOutline16 />
</button>
</Tooltip>
<Tooltip label="在新对话中分支" side="bottom">
<button type="button" className={css.action} aria-label="在新对话中分支">
<IconBranchOutline16 />
</button>
</Tooltip>
<Tooltip label="编辑" side="bottom">
<button type="button" className={css.action} aria-label="编辑">
<IconEditOutline16 />
</button>
</Tooltip>
</div>
)
}
export const MessageItem = memo(function MessageItem({ node }: MessageItemProps) {
switch (node.kind) {
case 'user':
case 'user': {
const { text, rest } = contentText(node.content)
return (
<div className={css.userRow}>
<div className={css.bubble}>
{projectUserText(text)}
{rest.map((block, i) => <JsonBlock key={i} label="附加内容块" payload={block} />)}
</div>
<UserActions text={text} />
</div>
)
}
case 'steering': {
const { text, rest } = contentText(node.content)
return (
<div className={css.userRow}>
<div className={css.bubble}>
{node.kind === 'steering' && <span className={css.badge}></span>}
<span className={css.badge}></span>
{projectUserText(text)}
{rest.map((block, i) => <JsonBlock key={i} label="附加内容块" payload={block} />)}
</div>

View File

@@ -30,7 +30,7 @@ export function QueueDock({ useSession }: QueueDockProps) {
}
/**
* The dock entry as a plain registrant plugin (bash-sample posture).
* The dock entry as a plain registrant plugin (bash posture).
* `inject: ['conversation']` is the ordering seam: the conversation service
* mounts after ui-conversation's slot registrations, so the
* 'conversation.input.dock' declaration is on the ledger by then.

View File

@@ -21,8 +21,9 @@
flex-direction: column;
align-items: center;
/* figma Input_Bottom: pad L32/R32/B12; the bottom gradient mask is owned by
the chat scroller. Top 8 hosts the error strip's breathing room. */
padding: 8px 32px 12px;
the chat scroller. Top 6 is the gap under the dock todo strip (12px todo
margin + 6px here); error/status strips still carry their own margin. */
padding: 6px 32px 12px;
}
.hero {
@@ -323,7 +324,7 @@
.stopping,
.stopping:hover {
background: var(--dsw-alias-button-primary-dimmed);
color: var(--dsw-alias-brand-text);
color: var(--dsw-alias-label-primary);
}
.retry {

View File

@@ -0,0 +1,116 @@
/* Todo strip above the composer (figma 772:51905 / 772:52972 / 772:53419):
tip surface, 14px radius, status icons + secondary item labels. Column is
calc(100% - 88px) / max 776, centered; InputBar top pad supplies the gap. */
.root {
flex: none;
overflow: hidden;
margin: 0 auto;
width: calc(100% - 88px);
max-width: 776px;
border: 1px solid var(--dsw-alias-border-l1);
border-radius: 14px;
background: var(--dsw-specific-tip);
}
.body {
display: flex;
flex-direction: column;
gap: 10px;
padding: 10px 16px;
}
.header {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 0;
border: none;
background: transparent;
text-align: left;
cursor: pointer;
}
.title {
flex: none;
font-size: 14px;
line-height: 24px;
font-weight: 500;
color: var(--dsw-alias-label-primary);
}
.progress {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
font-size: 13px;
line-height: 20px;
font-weight: 400;
color: var(--dsw-alias-label-tertiary);
text-overflow: ellipsis;
white-space: nowrap;
}
.chevron {
display: grid;
flex: none;
place-items: center;
color: var(--dsw-alias-label-tertiary);
}
.list {
display: flex;
flex-direction: column;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
max-height: 180px;
overflow-y: auto;
}
.item {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
font-size: 13px;
line-height: 20px;
color: var(--dsw-alias-label-secondary);
}
.glyph {
display: grid;
flex: none;
place-items: center;
width: 16px;
height: 16px;
}
.glyphCompleted {
color: var(--dsw-alias-state-success-primary);
}
.glyphProgress {
color: var(--dsw-alias-state-business-primary);
animation: todo-progress-spin 1s linear infinite;
}
.glyphPending {
color: var(--dsw-alias-label-caption);
}
@keyframes todo-progress-spin {
to {
transform: rotate(360deg);
}
}
/* Figma strip is single-line; long items ellipsize with no inline expand. */
.content {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -0,0 +1,140 @@
// TodoPanel: persistent plan strip above the composer (the web counterpart
// of the TUI plan panel). Renders the latest todo/write whole-list snapshot —
// no data of its own, hidden while the list is empty. Mounted through the
// 'conversation.input.dock' slot (QueueDock posture): the dock adapter does
// the selecting, so the panel takes the plain list and stays framework-free.
// Visual: figma 772:51905 (states) / 772:52972 (collapsed) / 772:53419 (expanded).
import { useId, useState } from 'react'
import type { Context } from 'cordis'
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { TodoItem } from '@deepseek-ai/dsh-client-runtime/client'
import { IconChevronDownOutline14, IconChevronUpOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import css from './TodoPanel.module.css'
export interface TodoPanelProps {
/** The session's current plan (empty renders nothing) — selected by the dock adapter. */
todos: readonly TodoItem[]
}
/** Local exhaustiveness helper — client packages do not depend on `dsh-llm`. */
/* v8 ignore next 3 -- closed-union backstop; only reached if status is forged */
function assertNever(value: never): never {
throw new Error(`unreachable todo status: ${String(value)}`)
}
/** Status glyphs share the figma 14×14 artboard; the 16×16 `.glyph` cell centers them. */
function CompletedGlyph() {
return (
<svg width={14} height={14} viewBox="0 0 14 14" fill="none" aria-hidden="true" className={css.glyphCompleted}>
<circle cx="7" cy="7" r="6.4" stroke="currentColor" strokeWidth="1.2" />
<path
d="M10.9631 5.71411L7.70154 8.97571C7.48011 9.19714 7.27736 9.40099 7.09229 9.54993C6.89742 9.70669 6.66314 9.85279 6.3634 9.90027C6.2049 9.92534 6.04339 9.92534 5.88489 9.90027C5.58515 9.85279 5.35087 9.70669 5.15601 9.54993C4.97093 9.40099 4.76818 9.19714 4.54675 8.97571L3.03516 7.46411L3.96313 6.53613L5.47473 8.04773C5.7169 8.28989 5.86196 8.43389 5.97888 8.52795C6.08597 8.61409 6.10875 8.60701 6.08997 8.604C6.11259 8.60758 6.13571 8.60758 6.15833 8.604C6.13954 8.60701 6.16232 8.61409 6.26941 8.52795C6.38633 8.43389 6.53139 8.28989 6.77356 8.04773L10.0352 4.78613L10.9631 5.71411Z"
fill="currentColor"
/>
</svg>
)
}
/** In-progress: business-blue ring fading out; CSS spins the svg. */
function ProgressGlyph() {
const gradientId = useId()
return (
<svg width={14} height={14} viewBox="0 0 14 14" fill="none" aria-hidden="true" className={css.glyphProgress}>
<defs>
<linearGradient id={gradientId} x1="2.5" y1="12" x2="10.5" y2="3.5" gradientUnits="userSpaceOnUse">
<stop stopColor="currentColor" />
<stop offset="1" stopColor="currentColor" stopOpacity="0" />
</linearGradient>
</defs>
<circle cx="7" cy="7" r="6.4" stroke={`url(#${gradientId})`} strokeWidth="1.2" />
</svg>
)
}
/** Pending: dashed unstarted ring (figma dash 2.4 2.4). */
function PendingGlyph() {
return (
<svg width={14} height={14} viewBox="0 0 14 14" fill="none" aria-hidden="true" className={css.glyphPending}>
<circle cx="7" cy="7" r="6.4" stroke="currentColor" strokeWidth="1.2" strokeDasharray="2.4 2.4" />
</svg>
)
}
function StatusGlyph({ status }: { status: TodoItem['status'] }) {
switch (status) {
case 'completed': return <CompletedGlyph />
case 'in_progress': return <ProgressGlyph />
case 'pending': return <PendingGlyph />
/* v8 ignore next -- closed TodoItem status union */
default: return assertNever(status)
}
}
/** Header summary: "<done>/<total> tasks · <n> in progress". */
function progressLabel(todos: readonly TodoItem[]): string {
const done = todos.filter(t => t.status === 'completed').length
const active = todos.filter(t => t.status === 'in_progress').length
return `${done}/${todos.length} tasks · ${active} in progress`
}
export function TodoPanel({ todos }: TodoPanelProps) {
const [collapsed, setCollapsed] = useState(false)
if (todos.length === 0) return null
return (
<section className={css.root} data-testid="todo-panel" aria-label="To-dos">
<div className={css.body}>
<button
type="button"
className={css.header}
aria-expanded={!collapsed}
onClick={() => { setCollapsed(v => !v) }}
>
<span className={css.title}>To-dos</span>
<span className={css.progress}>{progressLabel(todos)}</span>
<span className={css.chevron} aria-hidden>
{collapsed ? <IconChevronUpOutline14 /> : <IconChevronDownOutline14 />}
</span>
</button>
{!collapsed && (
<ul className={css.list}>
{todos.map(item => (
<li key={item.content} className={css.item} data-status={item.status}>
<span className={css.glyph} aria-hidden><StatusGlyph status={item.status} /></span>
<span className={css.content}>{item.content}</span>
</li>
))}
</ul>
)}
</div>
</section>
)
}
/** Full props of a dock entry: InputZone owner share + session standard kit + global seat. */
export type TodoDockProps = PropsRuntime<'conversation.input.dock'>
/** Dock adapter: selects the plan off the session snapshot and hands the strip a plain list. */
export function TodoDock({ useSession }: TodoDockProps) {
const todos = useSession(s => s.todos)
return <TodoPanel todos={todos} />
}
/**
* The plan strip as a plain registrant plugin (QueueDock posture).
* `inject: ['conversation']` is the ordering seam: the conversation service
* mounts after ui-conversation's slot registrations, so the
* 'conversation.input.dock' declaration is on the ledger by then.
*/
export const todoDockEntry = {
name: 'conversation-todo-dock',
inject: ['slots', 'conversation'],
/**
* Register the plan strip into the input dock (list entry, above the queue rows).
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
*/
apply(ctx: Context): void {
ctx.slots.register({ name: 'conversation.input.dock', id: 'todo', order: -1 }, TodoDock)
},
}

View File

@@ -1,29 +1,32 @@
/* Sample bash rows: deliberately distinct from ToolRow so the differential
registry hit is visible at a glance. */
/* Bash toolview: same geometry/tokens as ToolRow (figma Bash · description). */
.row {
.root {
display: flex;
align-items: center;
gap: 8px;
height: 24px;
min-width: 0;
cursor: pointer;
border-radius: 6px;
font-family: var(--ds-font-family-code);
font-size: 13px;
}
.row:hover {
.root:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.prompt {
.leading {
flex: none;
color: var(--dsw-alias-state-success-primary);
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-right: 6px;
color: var(--dsw-alias-label-tertiary);
}
.scopeBadge {
flex: none;
margin-right: 8px;
padding: 0 6px;
border-radius: 6px;
font-size: 11px;
@@ -32,17 +35,38 @@
background: var(--dsw-alias-state-business-primary);
}
.command {
.title {
flex: none;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-primary-dimmed);
}
.sep {
flex: none;
width: 2px;
height: 2px;
border-radius: 1px;
margin: 0 8px;
background: var(--dsw-alias-label-caption);
}
.summary {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--dsw-alias-label-secondary);
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-tertiary);
}
.err {
flex: none;
color: var(--dsw-alias-state-error-primary);
font-size: 11px;
.visuallyHidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}

View File

@@ -1,35 +1,54 @@
// Bash toolview sample, written in third-party posture: everything below uses
// only the public slot surface (ctx.slots.register into the keyed
// 'conversation.chat.toolview' hole + ToolRowProps) — the acceptance proof
// that a plain plugin can take over a tool row with zero dedicated machinery.
// Session-dimension differentiation happens INSIDE the component (the
// canonical sub-agent scenario): rows in child sessions render the scoped
// variant, derived from the standard useSessions kit — no registry predicates.
// Bash toolview registrant: third-party posture over the keyed toolview hole
// (ctx.slots.register + ToolRowProps only — never imports the chat domain).
// Product chrome matches ToolRow / Think (figma: Bash · {description}).
// Child sessions keep a scoped badge so session-dimension differentiation stays
// observable inside the component (no parallel registry).
import type { Context } from 'cordis'
import { IconApiOutline14, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolRowProps } from '../contract/slots.ts'
import { toolRowModel } from '../contract/tool-call-model.ts'
import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts'
import css from './bash-sample.module.css'
/** Bash row: command-first monospace summary replacing the generic card.
* Sub-session rows (parentId present) swap the prompt for a scoped badge —
* the differential stays observable per session from one registration. */
function leadingFor(state: ToolRowState) {
switch (state) {
case 'running': return <StateDot state="ongoing" />
case 'error': return <StateDot state="error" />
case 'stopped': return <StateDot state="warning" />
default: return <IconApiOutline14 size={16} />
}
}
/** Visually hidden status — StateDot is aria-hidden; AT needs a text label. */
function stateStatus(state: ToolRowState): string | null {
switch (state) {
case 'running': return '运行中'
case 'error': return '失败'
case 'stopped': return '已停止'
default: return null
}
}
/** Bash row: icon + Bash · {description}, matching the shared ToolRow chrome. */
export function BashRow({ toolName, block, openDetails, sessionId, useSessions }: ToolRowProps) {
const model = toolRowModel(toolName, block)
const isChild = useSessions(list => list.byId[sessionId]?.parentId !== undefined)
if (isChild) {
return (
<div className={css.row} data-sample="bash-scoped" onClick={openDetails}>
<span className={css.scopeBadge}>scoped</span>
<span className={css.command}>{model.summary}</span>
</div>
)
}
const status = stateStatus(model.state)
return (
<div className={css.row} data-sample="bash-global" onClick={openDetails}>
<span className={css.prompt} aria-hidden>$</span>
<span className={css.command}>{model.summary}</span>
{model.state === 'error' && <span className={css.err}>failed</span>}
<div
className={css.root}
data-sample={isChild ? 'bash-scoped' : 'bash-global'}
data-variant="bash"
data-state={model.state}
data-clickable
onClick={openDetails}
>
<span className={css.leading}>{leadingFor(model.state)}</span>
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
{isChild && <span className={css.scopeBadge}>scoped</span>}
<span className={css.title}>{model.title}</span>
<span className={css.sep} aria-hidden />
<span className={css.summary}>{model.summary}</span>
</div>
)
}

View File

@@ -0,0 +1,42 @@
/* todo_write plan-update row: title + progress summary on one line. */
.row {
display: flex;
align-items: center;
gap: 8px;
height: 24px;
min-width: 0;
cursor: pointer;
border-radius: 6px;
font-size: 13px;
}
.row:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.badge {
flex: none;
color: var(--dsw-alias-state-business-primary);
}
.title {
flex: none;
font-weight: 510;
color: var(--dsw-alias-label-primary);
}
.summary {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--dsw-alias-label-secondary);
}
.err {
flex: none;
color: var(--dsw-alias-state-error-primary);
font-size: 11px;
}

View File

@@ -0,0 +1,93 @@
// todo_write toolview: plan-flavored summary row replacing the generic
// "Tool call" card, registered into the keyed 'conversation.chat.toolview'
// hole like the bash sample (a product registration, not a sample). The row
// summarizes the written list (counts + active item) from the call args; the
// durable list itself renders in the TodoPanel above the composer, so the
// row stays one line.
import type { KeyboardEvent } from 'react'
import type { Context } from 'cordis'
import { StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolRowProps } from '../contract/slots.ts'
import { toolRowModel } from '../contract/tool-call-model.ts'
import css from './todo-row.module.css'
/** One parsed args item, shape-checked (model JSON: any field may be missing or mistyped). */
interface TodoWriteItem { content?: unknown; status?: unknown }
function isItem(value: unknown): value is TodoWriteItem {
return typeof value === 'object' && value !== null
}
function summarize(argsRaw: string): string | null {
let parsed: unknown
try {
parsed = JSON.parse(argsRaw)
} catch {
// Mid-stream truncation or malformed model JSON: fall back to the generic summary.
return null
}
// Valid JSON with an invalid shape (null root, non-array todos, null items —
// a rejected tool/call retains such args verbatim): same generic fallback.
if (typeof parsed !== 'object' || parsed === null) return null
const todos = (parsed as { todos?: unknown }).todos
if (!Array.isArray(todos) || !todos.every(isItem)) return null
const done = todos.filter(t => t.status === 'completed').length
const active = todos.find(t => t.status === 'in_progress')
const head = `${done}/${todos.length} 已完成`
return typeof active?.content === 'string' && active.content !== ''
? `${head} · ${active.content}`
: head
}
/** One-line plan update row (click opens the raw args in details). Non-ok
* execution states keep the generic row's dot semantics — a cancelled call
* wrote no todo/write, so it must not read as a completed update. */
export function TodoRow({ toolName, block, openDetails }: ToolRowProps) {
const model = toolRowModel(toolName, block)
const argsRaw = ('kind' in block ? block.call?.argsRaw : block.argsRaw) ?? ''
const summary = summarize(argsRaw) ?? model.summary
// Button semantics, not a <button>: the row carries inline spans a button
// would flatten, and ToolRow takes the same role/tabIndex/Enter-Space route.
const openFromKeyboard = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
openDetails()
}
return (
<div
className={css.row}
data-sample="todo-row"
data-state={model.state}
role="button"
tabIndex={0}
onClick={openDetails}
onKeyDown={openFromKeyboard}
>
{model.state === 'ok'
? <span className={css.badge} aria-hidden></span>
: <StateDot state={model.state === 'running' ? 'ongoing' : model.state === 'stopped' ? 'warning' : 'error'} />}
<span className={css.title}></span>
<span className={css.summary}>{summary}</span>
{model.state === 'error' && <span className={css.err}>failed</span>}
{model.state === 'stopped' && <span className={css.err}></span>}
</div>
)
}
/**
* The todo row as a plain registrant plugin, riding the same load-order seam
* as the bash sample: `inject: ['conversation']` guarantees the chat entry
* (and with it the 'conversation.chat.toolview' declaration) is on the ledger.
*/
export const todoToolview = {
name: 'todo-toolview',
inject: ['slots', 'conversation'],
/**
* Register the todo row into the chat view's keyed toolview hole.
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
*/
apply(ctx: Context): void {
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'todo_write' }, TodoRow)
},
}

View File

@@ -111,13 +111,13 @@ describe('apply wiring', () => {
expect(b.slots.spec('conversation.hero.workspace')).toEqual({ kind: 'single', scope: 'root' })
})
it('mounts the bash sample as a keyed entry through the load-order seam', async () => {
it('mounts the bash sample and the todo row as keyed entries through the load-order seam', async () => {
const b = await bench()
await b.fiber.await()
// The sample plugin's inject: ['slots', 'conversation'] resolved — the
// Both registrant plugins' inject: ['slots', 'conversation'] resolved — the
// service being present implies the chat entry declared the hole first.
const entries = b.slots.entries('conversation.chat.toolview')
expect(entries.map((e) => e.options.key)).toEqual(['bash'])
expect(entries.map((e) => e.options.key)).toEqual(['bash', 'todo_write'])
})
it('plugin fiber disposal collects every registration (unload cascade, ring and hole included)', async () => {

View File

@@ -1,11 +1,12 @@
// @vitest-environment jsdom
// Remaining chat branch tails: MessageItem context/unknown/steering arms,
// StatsLine no-cache join, PendingCard reason strip, AssistantMarkdown
// single-line reasoning. (Tool-row dispatch tails live with the keyed-slot
// machinery specs since the tool ring dissolved into renderSlot.)
// user IconActions, StatsLine no-cache join, PendingCard reason strip,
// AssistantMarkdown single-line reasoning. (Tool-row dispatch tails live
// with the keyed-slot machinery specs since the tool ring dissolved into
// renderSlot.)
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
@@ -18,7 +19,75 @@ import { StatsLine, type StatsLineProps } from '../src/client/chat/StatsLine.tsx
afterEach(cleanup)
describe('MessageItem arms', () => {
it('steering bubbles carry the interjection badge and non-text rest blocks', () => {
it('user bubbles expose copy / branch / edit actions; copy writes the text', () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
})
render(
<MessageItem node={{
kind: 'user', seq: 1,
content: [{ type: 'text', text: 'hello bubble' }] as never,
} as never}
/>,
)
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
expect(screen.getByRole('button', { name: '在新对话中分支' })).toBeTruthy()
expect(screen.getByRole('button', { name: '编辑' })).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: '复制' }))
expect(writeText).toHaveBeenCalledWith('hello bubble')
})
it('user copy falls back to execCommand when clipboard.writeText is unavailable', () => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: undefined,
})
const exec = vi.fn().mockReturnValue(true)
Object.defineProperty(document, 'execCommand', {
configurable: true,
value: exec,
})
render(
<MessageItem node={{
kind: 'user', seq: 1,
content: [{ type: 'text', text: 'fallback body' }] as never,
} as never}
/>,
)
fireEvent.click(screen.getByRole('button', { name: '复制' }))
expect(exec).toHaveBeenCalledWith('copy')
})
it('user copy stays quiet when execCommand throws or is absent', () => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: undefined,
})
Object.defineProperty(document, 'execCommand', {
configurable: true,
value: () => {
throw new Error('denied')
},
})
render(
<MessageItem node={{
kind: 'user', seq: 1,
content: [{ type: 'text', text: 'quiet' }] as never,
} as never}
/>,
)
fireEvent.click(screen.getByRole('button', { name: '复制' }))
Object.defineProperty(document, 'execCommand', {
configurable: true,
value: undefined,
})
fireEvent.click(screen.getByRole('button', { name: '复制' }))
})
it('steering bubbles carry the interjection badge and non-text rest blocks, without user actions', () => {
const view = render(
<MessageItem node={{
kind: 'steering', seq: 2, turn: 1, source: null,
@@ -29,6 +98,7 @@ describe('MessageItem arms', () => {
expect(view.getByText('插话')).toBeTruthy()
expect(view.getByText('steer!')).toBeTruthy()
expect(view.getByText(/附加内容块/)).toBeTruthy()
expect(view.queryByRole('button', { name: '复制' })).toBeNull()
})
it('context and unknown nodes render their JSON rows', () => {

View File

@@ -56,7 +56,7 @@ function snapshotWith(
): ConversationSnapshot {
return {
sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls, codeDispatches,
pending: [], queue: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false,
pending: [], queue: [], todos: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false,
openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
} as ConversationSnapshot
@@ -156,12 +156,13 @@ describe('run_code sub-calls through the real chat machinery', () => {
expect(view.getByText('List the notes directory')).toBeTruthy()
// Nested rows are ALWAYS visible (no parent expand needed): the bash
// sub-call landed in the bash sample plugin's keyed registration — the
// exact component a native top-level bash row uses — and the unregistered
// sub-call landed in the bash sample plugin's keyed registration — Bash ·
// description chrome, same as a top-level bash row — and the unregistered
// sub-tool fell back to GenericToolCard at the same render site.
const nest = view.container.querySelector('[data-subcalls]')
expect(nest).not.toBeNull()
expect(nest!.querySelector('[data-sample="bash-global"]')).not.toBeNull()
expect(view.getByText('Bash')).toBeTruthy()
expect(view.getByText('List notes')).toBeTruthy()
expect(view.getByText('Tool call')).toBeTruthy()
})

View File

@@ -27,7 +27,7 @@ const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessage
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
}
}
@@ -169,17 +169,19 @@ describe('bash sample row', () => {
expect(view.container.querySelector('[data-sample="bash-scoped"]')).not.toBeNull()
})
it('summarizes the command and hands clicks to openDetails on both arms', () => {
it('summarizes as Bash · description and hands clicks to openDetails on both arms', () => {
const openGlobal = vi.fn()
const global = render(<BashRow {...rowProps(ROOT, { openDetails: openGlobal })} />)
// Two renders share document.body: query inside each container.
const globalRow = global.container.querySelector('[data-sample="bash-global"]')!
expect(globalRow.textContent).toContain('Bash')
expect(globalRow.textContent).toContain('Build')
fireEvent.click(globalRow)
expect(openGlobal).toHaveBeenCalledTimes(1)
const openScoped = vi.fn()
const scoped = render(<BashRow {...rowProps(CHILD, { openDetails: openScoped })} />)
const scopedRow = scoped.container.querySelector('[data-sample="bash-scoped"]')!
expect(scopedRow.textContent).toContain('Bash')
expect(scopedRow.textContent).toContain('Build')
fireEvent.click(scopedRow)
expect(openScoped).toHaveBeenCalledTimes(1)

View File

@@ -40,7 +40,7 @@ const toolResult = (seq: number, callId: string, name: string, args = '{"command
function snapshotWith(nodes: ToolResultNode[]): ConversationSnapshot {
return {
sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
} as ConversationSnapshot
}
@@ -156,6 +156,7 @@ describe('keyed toolview hole through the real machinery', () => {
// bash: the sample plugin's keyed registration took the row (root
// session → global arm, decided inside the component off useSessions).
expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
expect(view.getByText('Bash')).toBeTruthy()
expect(view.getByText('Build')).toBeTruthy()
// mystery: no registration under that key → render-site fallback.
expect(view.getByText('Tool call')).toBeTruthy()

View File

@@ -29,7 +29,7 @@ const SID = 's1' as SessionId
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
}
}
@@ -264,7 +264,7 @@ describe('ChatView', () => {
expect(view.getByText(/"command": "cmd-a"/)).toBeTruthy()
})
it('clicking a tool row opens details with callId and toolName; selection paints the outline', () => {
it('clicking a tool row opens details with callId and toolName; selection marks data-selected', () => {
const h = makeHarness({ nodes: [toolResult(3, 'a')] })
const view = render(<h.ChatView {...h.props} />)
fireEvent.click(view.getByText('run a'))

View File

@@ -1,13 +1,13 @@
// @vitest-environment jsdom
// Branch tails the acceptance specs do not reach: ToolRow stopped-state dot,
// PendingCard question arm, bash sample error pill, the node-half empty
// PendingCard question arm, bash sample state dots, the node-half empty
// apply, and AssistantMarkdown reasoning/unknown block arms.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { SessionId, SessionListState, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { RunningToolCall, SessionId, SessionListState, ToolResultNode } 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 { ToolRowOwnerProps, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
@@ -76,14 +76,7 @@ describe('tails', () => {
expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
})
it('BashRow shows the failed pill on error results (root session arm)', () => {
const errorResult: ToolResultNode = {
kind: 'tool-result', seq: 1, time: 1_000, callId: 'c1',
call: { name: 'bash', argsRaw: '{"command":"boom"}' },
callTime: 500,
content: [], isError: true, callView: null, resultView: null,
}
// Root session (no parentId): the global arm renders, error pill visible.
it('BashRow shows StateDot chrome for running/error/stopped (root session arm)', () => {
const sid = 'root-1' as SessionId
const list = createSnapshotStore<SessionListState>({
ids: [sid],
@@ -91,12 +84,40 @@ describe('tails', () => {
current: undefined,
phase: 'ready',
} as SessionListState)
const props = {
callId: 'c1', toolName: 'bash', block: errorResult, openDetails: vi.fn(),
const props = (block: RunningToolCall | ToolResultNode) => ({
callId: 'c1', toolName: 'bash', block, openDetails: vi.fn(),
sessionId: sid, useSessions: bindSnapshotSelector(list),
} as unknown as ToolRowProps
const view = render(<BashRow {...props} />)
expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
expect(view.getByText('failed')).toBeTruthy()
} as unknown as ToolRowProps)
const running: RunningToolCall = {
callId: 'c1', name: 'bash', argsRaw: '{"command":"ls","description":"List"}',
turn: 1, step: 1, time: 1_000, callView: null,
}
const errorResult: ToolResultNode = {
kind: 'tool-result', seq: 1, time: 1_000, callId: 'c1',
call: { name: 'bash', argsRaw: '{"command":"boom"}' },
callTime: 500,
content: [], isError: true, callView: null, resultView: null,
}
const stoppedResult: ToolResultNode = {
...errorResult,
error: { name: 'E', code: 'interrupted' },
}
const runningView = render(<BashRow {...props(running)} />)
expect(runningView.container.querySelector('[data-state="running"]')).not.toBeNull()
expect(runningView.getByText('Bash')).toBeTruthy()
expect(runningView.getByText('List')).toBeTruthy()
runningView.unmount()
const errorView = render(<BashRow {...props(errorResult)} />)
expect(errorView.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
expect(errorView.container.querySelector('[data-state="error"]')).not.toBeNull()
expect(errorView.getByText('失败')).toBeTruthy()
errorView.unmount()
const stoppedView = render(<BashRow {...props(stoppedResult)} />)
expect(stoppedView.container.querySelector('[data-state="stopped"]')).not.toBeNull()
expect(stoppedView.getByText('已停止')).toBeTruthy()
})
})

View File

@@ -19,7 +19,7 @@ const SID = 's1' as SessionId
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
} as ConversationSnapshot
}

View File

@@ -21,7 +21,7 @@ const SID = 's1' as SessionId
function snapshotOf(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, lastAgentError: null,
...overrides,

View File

@@ -24,7 +24,7 @@ const SID = 's1' as SessionId
function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled?: boolean }) {
const session = createSnapshotStore<ConversationSnapshot>({
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: over?.running ?? false, composerPhase: 'active',
pending: [], queue: [], todos: [], running: over?.running ?? false, composerPhase: 'active',
removed: over?.disabled ?? false, openState: 'open', openError: null, hasMore: false,
loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
})

View File

@@ -111,7 +111,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
const wiring = shell
const sessionStore = createSnapshotStore<ConversationSnapshot>({
sessionId, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, lastAgentError: null,
})

View File

@@ -19,7 +19,7 @@ const SID = 's1' as SessionId
function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue, running: true, composerPhase: 'active', removed: false, openState: 'open', openError: null,
pending: [], queue, todos: [], running: true, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
}
}

View File

@@ -48,7 +48,7 @@ const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState =>
function conversationSnapshot(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, lastAgentError: null,
...overrides,

View File

@@ -0,0 +1,188 @@
// @vitest-environment jsdom
/**
* Todo display acceptance: the TodoPanel plan strip (empty-hidden, status
* rows, collapse), its TodoDock adapter (selects the plan off the session
* snapshot and follows changes), and the todo_write toolview row (progress
* summary from args, generic fallback on malformed JSON, error badge,
* keyboard activation).
*/
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { TodoItem, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
// Export discipline: packages/client/AGENTS.md.
import { TodoRow, todoToolview } from '../src/client/toolviews/todo-row.tsx'
import type { TodoDockProps } from '../src/client/skeleton/TodoPanel.tsx'
import { TodoDock, TodoPanel, todoDockEntry } from '../src/client/skeleton/TodoPanel.tsx'
afterEach(cleanup)
const LIST: TodoItem[] = [
{ content: '搭骨架', status: 'completed' },
{ content: '写组件', status: 'in_progress' },
{ content: '补测试', status: 'pending' },
]
describe('TodoPanel', () => {
it('renders nothing while the list is empty', () => {
const { container } = render(<TodoPanel todos={[]} />)
expect(container.innerHTML).toBe('')
})
it('shows progress, one row per item with its status glyph', () => {
render(<TodoPanel todos={LIST} />)
expect(screen.getByTestId('todo-panel')).toBeTruthy()
expect(screen.getByText('To-dos')).toBeTruthy()
expect(screen.getByText('1/3 tasks · 1 in progress')).toBeTruthy()
const items = screen.getAllByRole('listitem')
expect(items.map(li => li.getAttribute('data-status'))).toEqual(['completed', 'in_progress', 'pending'])
expect(screen.getByText('搭骨架')).toBeTruthy()
expect(screen.getByText('写组件')).toBeTruthy()
// Each status row carries an SVG glyph (not a text bullet).
expect(items.every(li => li.querySelector('svg') !== null)).toBe(true)
})
it('collapse hides the list; expand restores; header keeps the count summary', () => {
render(<TodoPanel todos={LIST} />)
const header = screen.getByRole('button', { expanded: true })
fireEvent.click(header)
expect(screen.queryByRole('list')).toBeNull()
// Collapsed header is title + progress only (no in-progress content hint).
expect(screen.getByText('1/3 tasks · 1 in progress')).toBeTruthy()
expect(screen.queryByText('写组件')).toBeNull()
fireEvent.click(screen.getByRole('button', { expanded: false }))
expect(screen.getAllByRole('listitem')).toHaveLength(3)
})
it('collapsed header still shows zero in-progress when nothing is active', () => {
render(<TodoPanel todos={[{ content: '都完了', status: 'completed' }]} />)
fireEvent.click(screen.getByRole('button', { expanded: true }))
expect(screen.queryByText('都完了')).toBeNull()
expect(screen.getByText('1/1 tasks · 0 in progress')).toBeTruthy()
})
})
/** Dock props stub: the adapter reads useSession only; the rest of the owner share is unused. */
function dockProps(store: ReturnType<typeof createSnapshotStore<{ todos: readonly TodoItem[] }>>): TodoDockProps {
return { useSession: bindSnapshotSelector(store) } as unknown as TodoDockProps
}
describe('TodoDock', () => {
it('selects the plan off the session snapshot and follows later writes', () => {
const store = createSnapshotStore<{ todos: readonly TodoItem[] }>({ todos: [] })
render(<TodoDock {...dockProps(store)} />)
expect(screen.queryByTestId('todo-panel')).toBeNull()
act(() => { store.set({ todos: LIST }) })
expect(screen.getByText('1/3 tasks · 1 in progress')).toBeTruthy()
// A rollback to the empty list retires the strip (the panel owns no data).
act(() => { store.set({ todos: [] }) })
expect(screen.queryByTestId('todo-panel')).toBeNull()
})
it('ships the registrant plugin shape (list entry above the queue rows)', () => {
expect(todoDockEntry.name).toBe('conversation-todo-dock')
expect(todoDockEntry.inject).toEqual(['slots', 'conversation'])
const register = vi.fn()
todoDockEntry.apply({ slots: { register } } as never)
expect(register).toHaveBeenCalledWith({ name: 'conversation.input.dock', id: 'todo', order: -1 }, TodoDock)
})
})
const resultNode = (argsRaw: string, over?: Partial<ToolResultNode>): ToolResultNode => ({
kind: 'tool-result', seq: 10, time: 2_000, callTime: 1_000, callId: 'c1',
call: { name: 'todo_write', argsRaw },
content: [], isError: false, callView: null, resultView: null, ...over,
})
function rowProps(block: unknown, openDetails = vi.fn()): ToolRowProps {
return {
callId: 'c1', toolName: 'todo_write', block,
openDetails,
sessionId: 's1',
useSessions: () => undefined,
} as unknown as ToolRowProps
}
describe('TodoRow', () => {
const ARGS = JSON.stringify({ todos: LIST })
it('summarizes counts and the active item from the call args', () => {
render(<TodoRow {...rowProps(resultNode(ARGS))} />)
expect(screen.getByText('更新任务清单')).toBeTruthy()
expect(screen.getByText('1/3 已完成 · 写组件')).toBeTruthy()
})
it('omits the active clause when no item is in progress and reads running-call args', () => {
const args = JSON.stringify({ todos: [{ content: 'x', status: 'completed' }] })
render(<TodoRow {...rowProps({ callId: 'c1', name: 'todo_write', argsRaw: args, turn: 1, step: 1, time: 1_000, callView: null })} />)
expect(screen.getByText('1/1 已完成')).toBeTruthy()
})
it('keeps the non-ok execution states visible: running dot, interrupted marker', () => {
// A running call (no result yet) shows the ongoing dot, never the ok badge.
const args = JSON.stringify({ todos: LIST })
const running = render(<TodoRow {...rowProps({ callId: 'c1', name: 'todo_write', argsRaw: args, turn: 1, step: 1, time: 1_000, callView: null })} />)
expect(running.container.querySelector('[data-state="running"]')).not.toBeNull()
expect(running.container.querySelector('[data-state="running"] svg')).not.toBeNull()
running.unmount()
// A cancelled call wrote no todo/write: the row must not read as a completed update.
const stopped = render(<TodoRow {...rowProps(resultNode(args, { isError: true, error: { name: 'Interrupted', code: 'interrupted' } }))} />)
expect(stopped.container.querySelector('[data-state="stopped"]')).not.toBeNull()
expect(stopped.getByText('已中断')).toBeTruthy()
})
it('falls back to the generic summary on malformed args and flags errors', () => {
render(<TodoRow {...rowProps(resultNode('not json', { isError: true }))} />)
expect(screen.getByText('failed')).toBeTruthy()
// Generic others summary: "<tool> · <raw>".
expect(screen.getByText('todo_write · not json')).toBeTruthy()
})
it('falls back when parsed args carry no todos array, and click opens details', () => {
const openDetails = vi.fn()
render(<TodoRow {...rowProps(resultNode('{"other":1}'), openDetails)} />)
expect(screen.getByText('todo_write · {"other":1}')).toBeTruthy()
fireEvent.click(screen.getByText('更新任务清单'))
expect(openDetails).toHaveBeenCalledTimes(1)
})
it('opens details from the keyboard on Enter and Space, ignoring other keys', () => {
const openDetails = vi.fn()
render(<TodoRow {...rowProps(resultNode(ARGS), openDetails)} />)
const row = screen.getByRole('button')
expect(row.getAttribute('tabindex')).toBe('0')
fireEvent.keyDown(row, { key: 'Enter' })
fireEvent.keyDown(row, { key: ' ' })
expect(openDetails).toHaveBeenCalledTimes(2)
// Space must not also scroll the flow: the handler claims the event.
expect(fireEvent.keyDown(row, { key: ' ' })).toBe(false)
fireEvent.keyDown(row, { key: 'a' })
fireEvent.keyDown(row, { key: 'ArrowDown' })
expect(openDetails).toHaveBeenCalledTimes(3)
})
it.each([
{ label: 'null root', argsRaw: 'null' },
{ label: 'non-object root', argsRaw: '42' },
{ label: 'null items', argsRaw: '{"todos":[null]}' },
])('falls back to the generic summary on valid JSON with an invalid shape ($label)', ({ argsRaw }) => {
render(<TodoRow {...rowProps(resultNode(argsRaw))} />)
// No throw, and the generic others summary carries the raw args verbatim.
expect(screen.getByText(`todo_write · ${argsRaw}`)).toBeTruthy()
})
it('window-truncated result (call head lost) falls back to the callId summary', () => {
render(<TodoRow {...rowProps(resultNode('', { call: null }))} />)
expect(screen.getByText('todo_write · c1')).toBeTruthy()
})
it('todoToolview is a plain registrant riding the conversation load-order seam', () => {
expect(todoToolview.name).toBe('todo-toolview')
expect(todoToolview.inject).toEqual(['slots', 'conversation'])
const register = vi.fn()
todoToolview.apply({ slots: { register } } as never)
expect(register).toHaveBeenCalledWith({ name: 'conversation.chat.toolview', key: 'todo_write' }, TodoRow)
})
})

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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
README.md: 6aeda0e04bf690f5cc8a6d52eea95b1b2782a143
README.zh.md: 9fbc2ed8392ae3ceba453517d9af5ef41f039d2d
# pnpm run verify-translation-pairing --write packages/client/ui-layout/README.md
README.md: 26e909b96412985792eeae72d51a2ab2a315c943
README.zh.md: 2e5799fd32c41328f8ca8b9e1a439fbccb3cdba2

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
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. The package also seats the theme presenter: it consumes resolved `ctx.theme` snapshots and projects them onto `document.body` (`data-ds-dark-theme` from the active color scheme plus the theme's alias tokens as inline variables).
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. The package also seats the theme presenter: it consumes resolved `ctx.theme` snapshots and projects them onto the document (`html { color-scheme }` for native UA chrome, `body[data-ds-dark-theme]` from the active color scheme, plus the theme's alias tokens as inline variables on body).
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.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
外壳插件:三栏 AppFrame拖动手柄与让步链`ctx.layout` 面板几何服务;它注册到运行时拥有的 `root` slot并声明 `sidebar``conversation``details``conversation.empty`。侧边栏宽度固定(只会收缩详情栏,然后将其自动关闭);关闭的侧边栏仍保留 56px 控制轨道,详情栏则关闭到零宽度。该包还提供主题呈现器:它消费解析后的 `ctx.theme` 快照,并将其投影到 `document.body`依据当前配色方案设置 `data-ds-dark-theme`,并将主题的别名 token 设为内联变量)。
外壳插件:三栏 AppFrame拖动手柄与让步链`ctx.layout` 面板几何服务;它注册到运行时拥有的 `root` slot并声明 `sidebar``conversation``details``conversation.empty`。侧边栏宽度固定(只会收缩详情栏,然后将其自动关闭);关闭的侧边栏仍保留 56px 控制轨道,详情栏则关闭到零宽度。该包还提供主题呈现器:它消费解析后的 `ctx.theme` 快照,并将其投影到 document(用 `html { color-scheme }` 驱动原生 UA 控件,依据当前配色方案设置 `body[data-ds-dark-theme]`,并将主题的别名 token 设为 body 上的内联变量)。
AppFrame 读取运行时 Session 投影:`baselinesReady` 选择加载状态,页面局部的 `SessionListState.intent` 选择空白编辑器,已连接 Session 则通过 `SessionProvider` 渲染。会话及空状态的 owner share 为空;每个注册方通过标准 hook 获取业务数据,并从自身的 inject 表层获取操作。侧边栏 owner share 只包含 `collapsed``width`;导航操作属于侧边栏自身注入的服务表层。

View File

@@ -1,29 +1,33 @@
/**
* Global theme DOM applier: projects the resolved ThemeSnapshot onto
* document.body — the `data-ds-dark-theme` palette switch plus the active
* theme's alias-token overrides as inline CSS variables. Pure DOM writes, no
* React involvement; the presenter only ever retracts what it wrote itself,
* so foreign body attributes and inline styles survive apply/dispose.
* Global theme DOM applier: projects the resolved ThemeSnapshot onto the
* document — `html { color-scheme }` for native UA chrome (scrollbars, form
* controls), `body[data-ds-dark-theme]` for the token palette, and the active
* theme's alias-token overrides as inline CSS variables on body. Pure DOM
* writes, no React involvement; the presenter only ever retracts what it wrote
* itself, so foreign attributes and inline styles survive apply/dispose.
*/
import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client'
/** Body attribute selecting the dark base palette in the token stylesheets. */
export const DARK_ATTRIBUTE = 'data-ds-dark-theme'
/** Applies theme snapshots to document.body; one instance per plugin fiber. */
/** Applies theme snapshots to the document; one instance per plugin fiber. */
export class ThemePresenter {
/** Token names this presenter wrote in the last apply (its retraction set). */
private appliedTokens: string[] = []
/**
* Project a snapshot onto the body: switch the palette attribute from
* `active.colorScheme` (never the id — `system` is resolved upstream) and
* replace the previously applied token variables with `active.tokens`.
* Project a snapshot onto the document: set root `color-scheme` and the body
* palette attribute from `active.colorScheme` (never the id — `system` is
* resolved upstream), then replace the previously applied token variables
* with `active.tokens`.
* @param snapshot - resolved theme snapshot from ctx.theme.
*/
apply(snapshot: ThemeSnapshot): void {
const scheme = snapshot.active.colorScheme
document.documentElement.style.colorScheme = scheme
const body = document.body
if (snapshot.active.colorScheme === 'dark') body.setAttribute(DARK_ATTRIBUTE, '')
if (scheme === 'dark') body.setAttribute(DARK_ATTRIBUTE, '')
else body.removeAttribute(DARK_ATTRIBUTE)
for (const name of this.appliedTokens) body.style.removeProperty(name)
this.appliedTokens = []
@@ -33,8 +37,9 @@ export class ThemePresenter {
}
}
/** Retract everything this presenter wrote: the palette attribute and all applied token variables. */
/** Retract everything this presenter wrote: root color-scheme, the palette attribute, and all applied token variables. */
dispose(): void {
document.documentElement.style.removeProperty('color-scheme')
const body = document.body
body.removeAttribute(DARK_ATTRIBUTE)
for (const name of this.appliedTokens) body.style.removeProperty(name)

View File

@@ -63,15 +63,19 @@ describe('ui-layout client apply', () => {
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
// Initial getter application: jsdom has no matchMedia, system resolves light.
expect(document.documentElement.style.colorScheme).toBe('light')
expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false)
const theme = ctx.get('theme') as ThemeService
theme.setTheme('dark')
expect(document.documentElement.style.colorScheme).toBe('dark')
expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(true)
await fiber.dispose()
expect(document.documentElement.style.colorScheme).toBe('')
expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false)
// Listener is off: further theme changes no longer reach the body.
// Listener is off: further theme changes no longer reach the document.
theme.setTheme('light')
theme.setTheme('dark')
expect(document.documentElement.style.colorScheme).toBe('')
expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false)
})

View File

@@ -1,7 +1,7 @@
// @vitest-environment jsdom
// ThemePresenter behavior account: the palette attribute follows
// active.colorScheme only, token variables replace the previous apply's set,
// and dispose retracts everything the presenter wrote.
// ThemePresenter behavior account: root color-scheme and the palette attribute
// follow active.colorScheme only, token variables replace the previous apply's
// set, and dispose retracts everything the presenter wrote.
import { beforeEach, describe, expect, it } from 'vitest'
import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client'
@@ -14,22 +14,26 @@ function snapshot(colorScheme: 'light' | 'dark', tokens: Record<string, string>
}
beforeEach(() => {
document.documentElement.style.removeProperty('color-scheme')
document.body.removeAttribute(DARK_ATTRIBUTE)
document.body.removeAttribute('style')
})
describe('ThemePresenter', () => {
it('light scheme leaves the dark attribute absent', () => {
it('light scheme sets root color-scheme and leaves the dark attribute absent', () => {
const presenter = new ThemePresenter()
presenter.apply(snapshot('light'))
expect(document.documentElement.style.colorScheme).toBe('light')
expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false)
})
it('dark scheme sets the attribute; switching back to light removes it', () => {
it('dark scheme sets root color-scheme and the attribute; switching to light clears both', () => {
const presenter = new ThemePresenter()
presenter.apply(snapshot('dark'))
expect(document.documentElement.style.colorScheme).toBe('dark')
expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(true)
presenter.apply(snapshot('light'))
expect(document.documentElement.style.colorScheme).toBe('light')
expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false)
})
@@ -44,11 +48,12 @@ describe('ThemePresenter', () => {
expect(document.body.style.getPropertyValue('--dsw-alias-fg')).toBe('')
})
it('dispose removes the attribute and every applied variable, sparing foreign inline styles', () => {
it('dispose removes color-scheme, the attribute, and every applied variable, sparing foreign inline styles', () => {
document.body.style.setProperty('--foreign', 'kept')
const presenter = new ThemePresenter()
presenter.apply(snapshot('dark', { '--dsw-alias-bg': '#111' }))
presenter.dispose()
expect(document.documentElement.style.colorScheme).toBe('')
expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false)
expect(document.body.style.getPropertyValue('--dsw-alias-bg')).toBe('')
expect(document.body.style.getPropertyValue('--foreign')).toBe('kept')

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
README.md: 4e2a22e77dc1611728477ea0a9d8c50dfc9f7f5d
README.zh.md: 36253971281fd346f9b0ec4648c4b8824ed918a7
README.md: 58e450451ab64f69762817dfb277b8a888e2177f
README.zh.md: 6824f3efe4981adf9549941afa7e2f5db2ac005d

View File

@@ -6,7 +6,7 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/
## Markdown rendering
`MarkdownText` renders GFM from untrusted assistant output through React elements. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders image alt text without loading remote resources; `MessageText` remains the literal-text primitive for user-authored content.
`MarkdownText` renders GFM from untrusted assistant output through React elements. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders image alt text without loading remote resources; `MessageText` remains the literal-text primitive for user-authored content. Element spacing, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars).
## Model Experience

View File

@@ -6,7 +6,7 @@
## Markdown 渲染
`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM。它会省略原始 HTML使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并只渲染图片 alt 文本而不加载远程资源;`MessageText` 仍是用户创作内容使用的字面文本原语。
`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM。它会省略原始 HTML使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并只渲染图片 alt 文本而不加载远程资源;`MessageText` 仍是用户创作内容使用的字面文本原语。元素间距、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki
## 模型体验

View File

@@ -1,13 +1,79 @@
/* One code-block geometry for highlighted and plain arms: the shiki <pre>
and the fallback <pre> draw identically except for token colors. */
/* Visual baseline: deepsuite `@deepseek/md` code-block.css. Highlight colors
stay on the existing shiki `--shiki-*` sheet (not Prism highlight.css). */
.block {
--dsl-code-block-banner-background-color: var(--dsw-alias-markdown-code-block-banner);
--dsl-code-block-border-radius: 12px;
--dsl-code-block-banner-font: var(--dsw-font-xs-13);
--dsl-code-block-content-font: var(--dsw-font-markdown-code-block);
position: relative;
margin: 16px 0;
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-markdown-code-block);
border-radius: var(--dsl-code-block-border-radius);
}
.block:not(:last-child) {
margin-bottom: 11px;
}
.bannerWrap {
position: sticky;
top: 0;
z-index: 6;
background-color: var(--dsw-alias-bg-base);
border-top-left-radius: var(--dsl-code-block-border-radius);
border-top-right-radius: var(--dsl-code-block-border-radius);
}
.banner {
background: var(--dsl-code-block-banner-background-color);
padding: 9px 14px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
font: var(--dsl-code-block-banner-font);
border-top-left-radius: var(--dsl-code-block-border-radius);
border-top-right-radius: var(--dsl-code-block-border-radius);
}
.infostring {
color: var(--dsw-alias-label-primary);
font-family: var(--ds-font-family-code);
font-size: 12px;
line-height: 18px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.action {
display: flex;
align-items: center;
flex-shrink: 0;
}
.copyButton {
background-color: rgb(255 255 255 / 0);
border: none;
padding: 0;
margin: 0;
color: inherit;
cursor: pointer;
font: inherit;
}
.block :where(pre) {
margin: 0;
padding: 8px 10px;
border-radius: 8px;
font: var(--dsl-code-block-content-font);
padding: 16px;
margin: 0 !important;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
background: var(--dsw-alias-markdown-code-block);
font: var(--dsw-font-markdown-code-block);
}
/* Shiki inlines its theme background var; route it to the repo token. */
@@ -23,5 +89,4 @@
.plain {
color: var(--dsw-alias-label-primary);
white-space: pre;
}

View File

@@ -1,12 +1,10 @@
// CodeBlock: one code surface for every consumer — markdown fences, the
// run_code program body, and the details panel's raw args/output — with
// shiki highlighting for the registered grammars and an identical-geometry
// plain fallback for everything else. Shiki emits a single <pre class="shiki">
// tree of nested spans whose colors are --shiki-* custom properties
// (token sheets own the values); it produces no scripts or event handlers,
// so injecting its output is safe by construction.
// plain fallback for everything else. Chrome (language banner + copy) matches
// deepsuite `@deepseek/md` code blocks; token colors stay on `--shiki-*`.
import { useMemo } from 'react'
import { useCallback, useMemo, useRef, useState } from 'react'
import clsx from 'clsx'
import { highlightToHtml } from './highlight.ts'
import css from './CodeBlock.module.css'
@@ -20,18 +18,80 @@ export interface CodeBlockProps {
className?: string | undefined
}
/** @returns true only when the host accepted the write. */
async function writeClipboard(text: string): Promise<boolean> {
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
// Denied permissions / iframe policy — do not claim success.
return false
}
}
// jsdom and older hosts: best-effort execCommand path when present.
const exec = typeof document.execCommand === 'function'
? document.execCommand.bind(document)
: undefined
if (exec === undefined) return false
const el = document.createElement('textarea')
el.value = text
el.setAttribute('readonly', '')
el.style.position = 'fixed'
el.style.left = '-9999px'
document.body.appendChild(el)
el.select()
try {
return exec('copy')
} catch {
return false
} finally {
el.remove()
}
}
export function CodeBlock({ code, lang, className }: CodeBlockProps) {
const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
const html = useMemo(() => highlightToHtml(trimmed, lang), [trimmed, lang])
if (html === undefined) {
return (
<div className={clsx(css.block, className)}>
const rootRef = useRef<HTMLDivElement>(null)
const [copied, setCopied] = useState(false)
const onCopy = useCallback(() => {
if (copied) return
/* v8 ignore next -- both arms always mount a <pre>; trimmed is the
typed fallback if the DOM shape ever diverges. */
const text = rootRef.current?.querySelector('pre')?.textContent ?? trimmed
void writeClipboard(text).then((ok) => {
if (!ok) return
setCopied(true)
window.setTimeout(() => setCopied(false), 1000)
})
}, [copied, trimmed])
const body = html === undefined
? (
<pre className={css.plain}><code>{trimmed}</code></pre>
)
: (
// eslint-disable-next-line react/no-danger -- shiki's output is a static
// span tree it generated from `code` (no user HTML passes through), the
// sanctioned innerHTML consumption path per shiki's own docs.
<div dangerouslySetInnerHTML={{ __html: html }} />
)
return (
<div ref={rootRef} className={clsx(css.block, 'md-code-block', className)}>
<div className={css.bannerWrap}>
<div className={css.banner}>
<div className={css.infostring}>{lang ?? ''}</div>
<div className={css.action}>
<button type="button" className={css.copyButton} onClick={onCopy}>
{copied ? '复制成功' : '复制'}
</button>
</div>
</div>
</div>
)
}
// eslint-disable-next-line react/no-danger -- shiki's output is a static
// span tree it generated from `code` (no user HTML passes through), the
// sanctioned innerHTML consumption path per shiki's own docs.
return <div className={clsx(css.block, className)} dangerouslySetInnerHTML={{ __html: html }} />
{body}
</div>
)
}

View File

@@ -1,95 +1,168 @@
/* Visual baseline: deepsuite `@deepseek/md` markdown.css, adapted to CSS
Modules. Cite pills, KaTeX, header anchors, and thinking-small variants are
intentionally absent (no matching DOM). Token names match that sheet. */
.markdown {
display: flex;
min-width: 0;
flex-direction: column;
gap: 12px;
overflow-wrap: anywhere;
font: var(--dsw-font-markdown-base);
color: var(--dsw-alias-label-primary);
}
.markdown :where(h1, h2, h3, h4, h5, h6, p, ul, ol, blockquote, pre, hr) {
margin: 0;
.markdown strong {
font-weight: 600;
}
.markdown h1 {
font: var(--dsw-font-markdown-h1);
margin: 32px 0 16px;
}
.markdown h2 {
font: var(--dsw-font-markdown-h2);
margin: 32px 0 16px;
}
.markdown h3 {
font: var(--dsw-font-markdown-h3);
margin: 32px 0 16px;
}
.markdown :where(h4, h5, h6) {
.markdown h4 {
font: var(--dsw-font-markdown-h4);
margin: 16px 0;
}
.markdown :where(strong, th) {
font-weight: var(--dsw-font-markdown-base-strong-font-weight);
.markdown :where(h5, h6) {
font: var(--dsw-font-markdown-base-strong);
margin: 16px 0;
}
.markdown :where(ul, ol) {
padding-inline-start: 24px;
.markdown :where(h1, h2, h3, h4, h5, h6) strong {
font-weight: inherit;
}
.markdown li + li {
margin-block-start: 4px;
.markdown p {
margin: 16px 0;
}
.markdown li > :where(ul, ol) {
margin-block-start: 4px;
/* Tighten h4h6 against a following list (design: 8px gap). */
.markdown :where(h4, h5, h6) + :where(ul, ol) {
margin-top: 8px;
}
.markdown blockquote {
padding-inline-start: 12px;
border-inline-start: 3px solid var(--dsw-alias-markdown-citation);
color: var(--dsw-alias-label-secondary);
.markdown :where(h4, h5, h6):has(+ :where(ul, ol)) {
margin-bottom: 8px;
}
.markdown a {
/* deepsuite markdown.css uses brand-text (blue in newDesign); this sheet
keeps design-platform brand-text as near-black, so links use the blue
business-primary alias instead. */
color: var(--dsw-alias-state-business-primary);
text-decoration: underline;
text-underline-offset: 2px;
transition: box-shadow var(--ds-transition-duration) var(--ds-ease-in-out);
position: relative;
text-decoration: none;
/* Transparent hit-area padding; literal zero-alpha only (no painted color). */
border-left: 3px solid rgb(255 255 255 / 0);
border-right: 3px solid rgb(255 255 255 / 0);
border-top: 2px solid rgb(255 255 255 / 0);
border-bottom: 2px solid rgb(255 255 255 / 0);
margin-left: -3px;
margin-right: -3px;
}
.markdown :not(pre) > code {
padding: 2px 4px;
border-radius: 4px;
background: var(--dsw-alias-markdown-inline-code);
font: var(--dsw-font-markdown-code);
.markdown a:hover,
.markdown a:focus {
outline: none;
text-decoration: underline var(--dsw-alias-state-business-primary);
}
.markdown pre {
max-width: 100%;
overflow-x: auto;
overscroll-behavior-x: contain;
padding: 12px 16px;
border-radius: 8px;
background: var(--dsw-alias-markdown-code-block);
font: var(--dsw-font-markdown-code-block);
.markdown a:focus-visible {
box-shadow: 0 0 0 2px var(--dsw-alias-state-business-primary);
}
.markdown pre code {
padding: 0;
background: transparent;
font: inherit;
overflow-wrap: normal;
word-break: normal;
white-space: pre;
.markdown :where(ul, ol) {
margin: 16px 0;
padding-left: 18px;
}
.markdown li:not(:first-child) {
margin-top: 6px;
}
.markdown li > :where(ul, ol) {
margin-top: 4px;
}
.markdown li::marker {
line-height: 28px;
color: var(--dsw-alias-label-secondary);
}
/* Nested ol under ul/ol: markers inside (models sometimes emit this shape). */
.markdown :where(ul, ol) ol {
list-style-position: inside;
padding-left: 0;
}
.markdown :where(ul, ol) ol li p {
display: inline;
}
.markdown li > p {
margin: 8px 0;
}
.markdown li > *:first-child {
margin-top: 0;
}
/* Keep list-nested code-block vertical margins (design: +4px vs other last children). */
.markdown li > *:last-child:not(:global(.md-code-block)) {
margin-bottom: 0;
}
.markdown hr {
width: 100%;
border: 0;
border-block-start: 1px solid var(--dsw-alias-markdown-citation);
display: block;
border: none;
height: 1px;
margin: 32px 0;
background: var(--dsw-alias-border-l2);
}
.markdown blockquote {
border-left: 2px solid var(--dsw-alias-label-caption);
margin: 16px 0 0;
padding-left: 14px;
}
.markdown pre {
margin: 16px 0;
font-family: var(--ds-font-family-code);
overflow: auto;
}
.markdown :not(pre) > code {
display: inline-flex;
align-items: center;
box-sizing: border-box;
font: var(--dsw-font-markdown-code);
font-family: var(--ds-font-family-code);
font-size: 0.875em !important;
background-color: var(--dsw-alias-markdown-inline-code);
border-radius: 6px;
padding: 0 5px;
}
.markdown :where(h1, h2, h3, h4, h5, h6) code {
font: inherit;
font-family: var(--ds-font-family-code);
}
.markdown input[type='checkbox'] {
margin: 0 8px 0 0;
accent-color: var(--dsw-alias-state-business-primary);
accent-color: var(--dsw-alias-label-secondary);
}
.tableScroll {
@@ -99,22 +172,52 @@
}
.tableScroll table {
width: max-content;
min-width: 100%;
border-collapse: collapse;
font: var(--dsw-font-markdown-table);
}
.tableScroll :where(th, td) {
padding: 6px 12px;
border: 1px solid var(--dsw-alias-markdown-citation);
text-align: start;
white-space: nowrap;
width: max-content;
max-width: max-content;
}
.tableScroll th {
background: var(--dsw-alias-markdown-code-block-banner);
text-align: start;
padding: 10px 16px;
border-bottom: 1px solid var(--dsw-alias-border-l3);
border-top: none;
font: var(--dsw-font-markdown-table-head);
max-width: 320px;
max-width: min(30vw, 320px);
min-width: 100px;
}
.tableScroll td {
padding: 10px 16px;
border-bottom: 1px solid var(--dsw-alias-border-l2);
font: var(--dsw-font-markdown-table);
max-width: 320px;
max-width: min(30vw, 320px);
min-width: 100px;
}
.tableScroll th:first-child,
.tableScroll td:first-child {
padding-left: 0;
}
.tableScroll td:last-child {
padding-right: 0;
}
.tableScroll table code {
font-size: 13px;
}
.markdown > *:first-child,
.markdown p:first-child {
margin-top: 0 !important;
}
.markdown > *:last-child,
.markdown p:last-child {
margin-bottom: 0 !important;
}
.imageAlt {

View File

@@ -5,14 +5,17 @@
// display-trimmed. MarkdownText's fence route is pinned in markdown.spec.tsx
// alongside the rest of the markdown family.
import { describe, expect, it } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import { afterEach } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { CodeBlock } from '../src/markdown/CodeBlock.tsx'
import { highlightToHtml } from '../src/markdown/highlight.ts'
afterEach(cleanup)
beforeEach(() => {
vi.useRealTimers()
})
describe('highlightToHtml', () => {
it('highlights a registered grammar into css-variables token spans', () => {
const html = highlightToHtml('const x: number = 1', 'typescript')
@@ -50,4 +53,86 @@ describe('CodeBlock', () => {
expect(view.container.querySelector('pre.shiki')).toBeNull()
expect(view.getByText('plain text')).toBeTruthy()
})
it('shows the language banner and copies the pre textContent', async () => {
vi.useFakeTimers()
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
})
render(<CodeBlock code={'const a = 1\n'} lang="ts" />)
expect(screen.getByText('ts')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: '复制' }))
expect(writeText).toHaveBeenCalledWith('const a = 1')
// Flush the clipboard promise under fake timers before asserting the label.
await act(async () => {
await Promise.resolve()
})
expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy()
// While the ok label is showing, further clicks are no-ops.
fireEvent.click(screen.getByRole('button', { name: '复制成功' }))
expect(writeText).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1000)
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
})
it('does not claim success when clipboard.writeText rejects', async () => {
const writeText = vi.fn().mockRejectedValue(new Error('denied'))
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
})
render(<CodeBlock code="plain body" />)
fireEvent.click(screen.getByRole('button', { name: '复制' }))
await act(async () => {
await Promise.resolve()
})
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
expect(screen.queryByRole('button', { name: '复制成功' })).toBeNull()
})
it('falls back to execCommand when clipboard.writeText is unavailable', async () => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: undefined,
})
const exec = vi.fn().mockReturnValue(true)
Object.defineProperty(document, 'execCommand', {
configurable: true,
value: exec,
})
render(<CodeBlock code="plain body" />)
fireEvent.click(screen.getByRole('button', { name: '复制' }))
expect(exec).toHaveBeenCalledWith('copy')
expect(await screen.findByRole('button', { name: '复制成功' })).toBeTruthy()
})
it('does not claim success when execCommand throws or is absent', async () => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: undefined,
})
Object.defineProperty(document, 'execCommand', {
configurable: true,
value: () => {
throw new Error('denied')
},
})
const denied = render(<CodeBlock code="plain body" />)
fireEvent.click(denied.getByRole('button', { name: '复制' }))
await Promise.resolve()
expect(denied.getByRole('button', { name: '复制' })).toBeTruthy()
denied.unmount()
Object.defineProperty(document, 'execCommand', {
configurable: true,
value: undefined,
})
const absent = render(<CodeBlock code="plain body" />)
fireEvent.click(absent.getByRole('button', { name: '复制' }))
await Promise.resolve()
expect(absent.getByRole('button', { name: '复制' })).toBeTruthy()
expect(absent.queryByRole('button', { name: '复制成功' })).toBeNull()
})
})

View File

@@ -57,8 +57,10 @@ describe('MarkdownText', () => {
expect(container.querySelector('table')?.textContent).toContain('alphabeta')
expect(container.querySelector('hr')).not.toBeNull()
expect(container.querySelector('pre code')?.textContent).toContain('const answer = 42')
// The ts fence routed through the shared CodeBlock: shiki token spans present.
// The ts fence routed through the shared CodeBlock: shiki token spans + banner.
expect(container.querySelector('pre.shiki')).not.toBeNull()
expect(screen.getByText('ts')).toBeTruthy()
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
expect(container.querySelector('br')).not.toBeNull()
expect(screen.getByRole('link', { name: 'safe' }).getAttribute('target')).toBe('_blank')
expect(screen.getByRole('link', { name: 'https://deepseek.com' })).toBeTruthy()

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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
README.md: 5c9794d4f5faea42861f47423d4e8980cbf89216
README.zh.md: 2e0f76133ee38ba2fdc385e7fbb2a4a03b733cfa
# pnpm run verify-translation-pairing --write packages/client/ui-theme/README.md
README.md: 1227df357cb93241fcf28da9b74d7ba15207e9c5
README.zh.md: cd87ede7264c8d47dd780acaa11128e83d7862f9

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Theme plugin: ThemeService over the --dsw-* token base stylesheets (static scale + alias semantic layers). The service owns the theme preference (`light`/`dark`/`system`, persisted under `dsh.theme`), resolves `system` through `prefers-color-scheme`, and publishes immutable `ThemeSnapshot`s on the `theme/change` event; it never touches the DOM — ui-layout's presenter applies the resolved snapshot (`body[data-ds-dark-theme]` + inline alias tokens). Contract: api-contracts v3 §8.
Theme plugin: ThemeService over the --dsw-* token base stylesheets (static scale + alias semantic layers). The service owns the theme preference (`light`/`dark`/`system`, persisted under `dsh.theme`), resolves `system` through `prefers-color-scheme`, and publishes immutable `ThemeSnapshot`s on the `theme/change` event; it never touches the DOM — ui-layout's presenter applies the resolved snapshot (`html { color-scheme }`, `body[data-ds-dark-theme]`, and inline alias tokens). Contract: api-contracts v3 §8.
## Model Experience

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
主题插件:基于 --dsw-* token 基础样式表(静态尺度 + 别名语义层)的 ThemeService。该服务拥有主题偏好`light``dark``system`,以 `dsh.theme` 为键持久化),将 `system` 通过 `prefers-color-scheme` 解析为实际主题,并发布不可变的 `ThemeSnapshot`,通过 `theme/change` 事件通知变化;它绝不接触 DOMui-layout 的呈现器会应用解析后的快照(依据当前配色方案设置 `body[data-ds-dark-theme]`并将主题的别名 token 设为内联变量。契约api-contracts v3 §8。
主题插件:基于 --dsw-* token 基础样式表(静态尺度 + 别名语义层)的 ThemeService。该服务拥有主题偏好`light``dark``system`,以 `dsh.theme` 为键持久化),将 `system` 通过 `prefers-color-scheme` 解析为实际主题,并发布不可变的 `ThemeSnapshot`,通过 `theme/change` 事件通知变化;它绝不接触 DOMui-layout 的呈现器会应用解析后的快照(`html { color-scheme }``body[data-ds-dark-theme]`以及主题的别名 token 内联变量。契约api-contracts v3 §8。
## 模型体验

View File

@@ -9,5 +9,7 @@
--ds-font-family-code: 'SF Mono', 'JetBrains Mono', 'Fira Code', Consolas,
'Liberation Mono', Menlo, Courier, 'PingFang SC', 'Microsoft YaHei';
--ds-ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--ds-transition-duration: 0.2s;
--ds-transition-duration-fast: 0.1s;
--ds-transition-duration-slow: 0.3s;
}

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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
README.md: e0247b3e26f617f86e9c0094afa1cbc920f02d33
README.zh.md: 92ef463faab4b1ccda85d7f3cec1678a338d4010
# pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md
README.md: b5a78c30ddae5e12612bb8cced65b5fe95f7e259
README.zh.md: 904543a48f1609e23ba80cf240be965d0654a951

View File

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
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.
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. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped.
Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored.
@@ -18,5 +18,5 @@ 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.
- **No Session deletion control** — the existing Session menu row remains visual-only; Workspace registration deletion does not delete Sessions.
- **Existing-folder entry is manual path input only** — Host creation failures are shown in the modal.

View File

@@ -4,7 +4,7 @@
共享 Workspace 选择器插件。`WorkspacePicker` 注册到侧边栏的 `sidebar.workspace` slot以及页面局部 Session Intent 主视觉区的 `conversation.empty.workspace` slot因此两个表层使用同一菜单和创建模态框。
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象;使用现有文件夹和新建操作时,系统会先通过对象层创建真实 Workspace再将其选中。新建操作会禁用列表中已有的名称而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象;使用现有文件夹和新建操作时,系统会先通过对象层创建真实 Workspace再将其选中。新建操作会禁用列表中已有的名称而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。
两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。
@@ -18,5 +18,5 @@
## 已知限制与暂缓事项
- **没有 Workspace 重命名/删除控件**:选择器仅支持选择和创建
- **没有 Session 删除控件**:现有 Session 菜单行仍仅提供视觉效果;删除 Workspace 注册记录不会删除 Session
- **现有文件夹入口仅支持手动输入路径**Host 创建失败会显示在模态框中。

View File

@@ -258,6 +258,16 @@
color: var(--dsw-alias-state-error-primary);
}
.deleteAction:not(:disabled) {
color: var(--dsw-alias-state-error-primary);
}
.deleteStatus {
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-label-secondary);
}
@media (prefers-reduced-motion: reduce) {
.wide {
animation: none;

View File

@@ -87,10 +87,15 @@ type SessionTreeProps = Pick<
query: string
/** Open the browser-owned rename dialog for a real Workspace group. */
onRenameRequest: (workspaceId: WorkspaceId, currentTitle: string) => void
/** Open the browser-owned delete-confirmation dialog for a real Workspace group. */
onDeleteRequest: (workspaceId: WorkspaceId, currentTitle: string) => void
}
/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */
function SessionTree({ useSessions, startSession, open, workspaces, query, onRenameRequest, insertSessionBefore }: SessionTreeProps) {
function SessionTree({
useSessions, startSession, open, workspaces, query,
onRenameRequest, onDeleteRequest, insertSessionBefore,
}: SessionTreeProps) {
const list = useSessions((s) => s)
const current = list.current
const [expandedProjects, setExpandedProjects] = useState<string[]>([])
@@ -128,11 +133,17 @@ function SessionTree({ useSessions, startSession, open, workspaces, query, onRen
onCreate={() => {
if (group.workspaceId !== undefined) startSession(group.workspaceId)
}}
onRename={group.workspaceId === undefined
actions={group.workspaceId === undefined
? undefined
: () => {
/* v8 ignore next -- narrowing guard: the closure is only created for real-workspace groups. */
if (group.workspaceId !== undefined) onRenameRequest(group.workspaceId, group.label)
: {
rename: () => {
/* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */
if (group.workspaceId !== undefined) onRenameRequest(group.workspaceId, group.label)
},
delete: () => {
/* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */
if (group.workspaceId !== undefined) onDeleteRequest(group.workspaceId, group.label)
},
}}
/>
{group.sessions.map((node, index) => {
@@ -236,6 +247,7 @@ export function WorkspaceBrowser({
startSession,
open,
renameWorkspace,
deleteWorkspace,
insertSessionBefore,
createWorkspace,
}: WorkspaceBrowserProps) {
@@ -291,6 +303,41 @@ export function WorkspaceBrowser({
})
}
// Delete dialog is separate from the row so a successful removal can
// unmount that row without tearing down the in-flight confirmation state.
const [deleteTarget, setDeleteTarget] = useState<{ workspaceId: WorkspaceId; title: string } | null>(null)
const [deleting, setDeleting] = useState(false)
const [deleteCommittedId, setDeleteCommittedId] = useState<WorkspaceId | null>(null)
const [deleteError, setDeleteError] = useState<string | null>(null)
useEffect(() => {
if (deleteCommittedId === null
|| workspaces.some(workspace => workspace.workspaceId === deleteCommittedId)) return
setDeleting(false)
setDeleteCommittedId(null)
setDeleteTarget(null)
}, [deleteCommittedId, workspaces])
const closeDelete = () => {
if (deleting) return
setDeleteTarget(null)
setDeleteError(null)
}
const confirmDelete = () => {
/* v8 ignore next -- the Modal is absent without a target and its button is disabled while deleting. */
if (deleting || deleteTarget === null) return
setDeleting(true)
setDeleteCommittedId(null)
setDeleteError(null)
deleteWorkspace(deleteTarget.workspaceId).then(() => {
// Keep the confirmation pending until this component has rendered the
// committed list projection without the deleted id. Closing earlier
// exposes one stale React frame to the next Create Workspace gesture.
setDeleteCommittedId(deleteTarget.workspaceId)
}).catch((reason: unknown) => {
setDeleting(false)
setDeleteError(reason instanceof Error ? reason.message : String(reason))
})
}
return (
<div className={clsx(css.root, !wide && css.rail)}>
<div className={css.sectionHeader}>
@@ -382,6 +429,10 @@ export function WorkspaceBrowser({
setRenameDraft(currentTitle)
setRenameError(null)
}}
onDeleteRequest={(workspaceId, title) => {
setDeleteTarget({ workspaceId, title })
setDeleteError(null)
}}
/>
))}
</div>
@@ -416,6 +467,30 @@ export function WorkspaceBrowser({
)}
{renameError !== null && <div className={css.renameError} role="alert">{renameError}</div>}
</Modal>
<Modal
open={deleteTarget !== null}
onClose={closeDelete}
title="Delete workspace"
{...deleteTarget === null
? {}
: { description: `This removes “${deleteTarget.title}” from the workspace list. The folder and session logs will be kept. Its sessions will appear under Ungrouped.` }}
footer={(
<>
<Button variant="outline" disabled={deleting} onClick={closeDelete}>Cancel</Button>
<Button
variant="outline"
className={css.deleteAction!}
disabled={deleting}
onClick={confirmDelete}
>
Delete workspace
</Button>
</>
)}
>
{deleting && <div className={css.deleteStatus} role="status">Deleting workspace</div>}
{deleteError !== null && <div className={css.renameError} role="alert">{deleteError}</div>}
</Modal>
</div>
)
}

View File

@@ -60,7 +60,7 @@ export function WorkspaceCreateFlow({
const [creating, setCreating] = useState(false)
const [modalError, setModalError] = useState<string | null>(null)
const normalizedWorkspaceName = workspaceName.trim()
const duplicateWorkspaceName = normalizedWorkspaceName !== ''
const duplicateWorkspaceName = !creating && normalizedWorkspaceName !== ''
&& workspaces.some(workspace => workspace.title === normalizedWorkspaceName)
const items: MenuEntry[] = [

View File

@@ -32,6 +32,8 @@ export type WorkspaceBrowserInjected = {
open: (sessionId: SessionId) => void
/** Rename a Host Workspace (rejects on name conflict; resolves on durability). */
renameWorkspace: (workspaceId: WorkspaceId, title: string) => Promise<void>
/** Delete only a Host Workspace registration; directory and Session logs remain. */
deleteWorkspace: (workspaceId: WorkspaceId) => Promise<void>
/**
* Reorder a session inside its Workspace account (DOM-insertBefore
* semantics: omitted anchor appends to the end). The view refreshes from

View File

@@ -39,6 +39,7 @@ export function apply(ctx: ClientContext): void {
startSession: (workspaceId) => { ctx.workspaces.startSession(workspaceId) },
open: (sessionId) => { ctx.sessions.open(sessionId) },
renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) },
deleteWorkspace: async (workspaceId) => { await ctx.workspaces.delete(workspaceId) },
insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => {
await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId)
},

View File

@@ -1,98 +0,0 @@
/**
* Workspace plugin, browser half. Two registrations: WorkspaceBrowser fills
* the sidebar shell's `sidebar.workspaces` hole (the whole browsing region),
* and WorkspacePicker fills the conversation hero's picker hole
* (`conversation.hero.workspace` — both hero forms). Both read real Host
* Workspaces through the global useWorkspaces hook. Export discipline:
* packages/client/AGENTS.md.
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from './contract/slots.ts'
import { createWorkspaceViewStore } from './stores.ts'
import { WorkspaceBrowser } from './WorkspaceBrowser.tsx'
import { WorkspacePicker } from './WorkspacePicker.tsx'
export type {
WorkspaceBrowserInjected, WorkspaceBrowserProps, WorkspacePickerInjected, WorkspacePickerProps,
} from './contract/slots.ts'
/**
* Required services (cordis fiber inject). The target slots are declared by
* the ui-sidebar / ui-conversation applies, whose activation order relative
* to this one is NOT constrained: dshClient.inject edges are informational
* (loading/prefetch metadata, never apply sequencing) and neither owner
* provides a waitable service. apply therefore registers via
* declaration-aware deferral instead of assuming order.
*/
export const inject = ['slots', 'sessions', 'workspaces']
/**
* Register the browser and picker once their slot declarations are on the
* ledger. Inject factories return plain callbacks; data reads use the
* framework's global hooks.
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
const browserInjected = (): WorkspaceBrowserInjected => ({
// With a workspace: materialize (reuse-or-create the blank session) and
// navigate. Without one: clear the selection — the layout's empty seat
// shows the New Session pure view state and the user picks there.
startSession: (workspaceId) => {
if (workspaceId === undefined) {
ctx.sessions.clear()
return
}
void ctx.workspaces.connectWorkspace(workspaceId).then(
(sessionId) => { ctx.sessions.open(sessionId) },
(reason: unknown) => { console.warn('new session failed:', reason) },
)
},
open: (sessionId) => { ctx.sessions.open(sessionId) },
renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) },
insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => {
await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId)
},
createWorkspace: input => ctx.workspaces.create(input),
})
const pickerInjected = (): WorkspacePickerInjected => ({
createWorkspace: input => ctx.workspaces.create(input),
})
// Declaration-aware registration: each owner'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 registrations = [
{
name: 'sidebar.workspaces' as const,
component: WorkspaceBrowser,
register: () => ctx.slots.register(
{ name: 'sidebar.workspaces', store: createWorkspaceViewStore(), inject: browserInjected },
WorkspaceBrowser,
),
},
{
name: 'conversation.hero.workspace' as const,
component: WorkspacePicker,
register: () => ctx.slots.register(
{ name: 'conversation.hero.workspace', inject: pickerInjected },
WorkspacePicker,
),
},
]
const disposers = new Map<string, () => void>()
const tryRegister = (entry: (typeof registrations)[number]): void => {
if (ctx.slots.spec(entry.name) === undefined) return
if (ctx.slots.entries(entry.name).some(e => e.component === entry.component)) return
disposers.set(entry.name, entry.register())
}
const unsubscribers = registrations.map(entry =>
ctx.slots.subscribe(entry.name, () => { tryRegister(entry) }))
for (const entry of registrations) tryRegister(entry)
return () => {
for (const unsubscribe of unsubscribers) unsubscribe()
for (const dispose of disposers.values()) dispose()
}
}, 'ui-workspace: browser + picker registrations')
}

View File

@@ -3,7 +3,7 @@
* all data and callbacks arrive via props. Hover swaps (folder->chevron,
* time->ellipsis, action buttons) are CSS-only. Row ... menus are visual-only
* except workspace Rename; the session hover card is suppressed while a menu
* is open.
* is open. Workspace Rename/Delete are wired; session actions remain visual-only.
*/
import { useState } from 'react'
import clsx from 'clsx'
@@ -39,12 +39,12 @@ const WORKSPACE_MENU_ITEMS = [
* @param props.onCreate - start a frontend Session inside this Workspace.
* @returns the row element.
*/
export function ProjectRowItem({ group, onToggle, onCreate, onRename }: {
export function ProjectRowItem({ group, onToggle, onCreate, actions }: {
group: GroupNode
onToggle: () => void
onCreate: () => void
/** Open the rename dialog; absent for the ungrouped bucket (no menu shown). */
onRename?: (() => void) | undefined
/** Real-Workspace actions; absent for the ungrouped bucket (no menu shown). */
actions?: { rename: () => void; delete: () => void } | undefined
}) {
const row = group
const active = group.expanded && group.containsCurrent
@@ -68,15 +68,19 @@ export function ProjectRowItem({ group, onToggle, onCreate, onRename }: {
<span className={css.meta}>{count}</span>
</span>
<span className={css.rowActions}>
{onRename !== undefined && (
{actions !== undefined && (
<Menu
open={menuOpen}
onClose={() => { setMenuOpen(false) }}
items={WORKSPACE_MENU_ITEMS}
onSelect={(id) => {
setMenuOpen(false)
if (id === 'rename') onRename()
// Delete is visual-only for now.
// Unknown ids leave before the dispatch: a future menu row must
// not inherit the destructive branch as an else fallback.
/* v8 ignore next -- WORKSPACE_MENU_ITEMS carries exactly these two rows today. */
if (id !== 'rename' && id !== 'delete') return
if (id === 'rename') actions.rename()
else actions.delete()
}}
portal
closeOnPointerLeave

View File

@@ -1,321 +0,0 @@
/**
* Derives the workspace browser tree from Host Workspace order and membership.
* Unassigned Sessions trail under Ungrouped; blank Sessions remain visible.
*/
import type { SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
/** Group key for Sessions outside every Workspace. */
export const UNGROUPED_KEY = ''
/** Display label for the ungrouped bucket row. */
export const UNGROUPED_LABEL = 'Ungrouped'
/** One session node of a group's visible tree (34px row; children render indented one step). */
export interface SessionNode {
id: SessionId
title: string
/** 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 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 visible sessions in the group. */
sessionCount: number
expanded: boolean
/** The group contains the selected session (active folder tint; supplied here so the renderer never scans). */
containsCurrent: 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 {
expandedProjects: readonly string[]
expandedSessions: readonly string[]
query: string
}
interface Group {
key: string
workspaceId: WorkspaceId | undefined
cwd: string | undefined
label: string
summaries: Map<SessionId, SessionSummary>
roots: SessionId[]
children: Map<SessionId, SessionId[]>
}
/**
* 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 {
if (cwd === undefined || cwd === '') return UNGROUPED_LABEL
const base = cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop()
return base !== undefined && base !== '' ? base : cwd
}
/** Recency comparator: newest first, id as the deterministic tiebreak (ids are unique per group). */
function byRecency(a: SessionSummary, b: SessionSummary): number {
if (b.updatedAt !== a.updatedAt) return b.updatedAt - a.updatedAt
return a.id < b.id ? -1 : 1
}
/** 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) => {
const sa = summaries.get(a)
const sb = summaries.get(b)
/* v8 ignore next -- unreachable: kid ids are inserted alongside their summaries. */
if (sa === undefined || sb === undefined) return 0
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) {
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
accounted.add(id)
members.push(summary)
}
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 sessionNode(s: SessionSummary, children: readonly SessionNode[], hasChildren: boolean, expanded: boolean): SessionNode {
return {
id: s.id,
title: s.displayTitle,
children,
hasChildren,
expanded,
running: s.running,
updatedAt: s.updatedAt,
}
}
function buildVisible(g: Group, expandedSessions: ReadonlySet<string>): SessionNode[] {
const visited = new Set<SessionId>()
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 null
const kids = g.children.get(id) ?? []
const expanded = expandedSessions.has(id)
const children = expanded ? kids.map(walk).filter((n): n is SessionNode => n !== null) : []
return sessionNode(s, children, kids.length > 0, expanded)
}
return g.roots.map(walk).filter((n): n is SessionNode => n !== null)
}
/** Matched sessions plus their ancestor chains (forced visible under search). */
function searchVisible(g: Group, q: string): Set<SessionId> {
const visible = new Set<SessionId>()
for (const m of g.summaries.values()) {
if (!m.displayTitle.toLowerCase().includes(q)) continue
let cur: SessionSummary | undefined = m
while (cur !== undefined && !visible.has(cur.id)) {
visible.add(cur.id)
cur = cur.parentId !== undefined && cur.parentId !== cur.id ? g.summaries.get(cur.parentId) : undefined
}
}
return visible
}
function buildSearch(g: Group, visible: ReadonlySet<SessionId>): SessionNode[] {
const visited = new Set<SessionId>()
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 null
const kids = (g.children.get(id) ?? []).filter(kid => visible.has(kid))
const children = kids.map(walk).filter((n): n is SessionNode => n !== null)
return sessionNode(s, children, kids.length > 0, kids.length > 0)
}
return g.roots.map(walk).filter((n): n is SessionNode => n !== null)
}
/**
* Derive the nested workspace browser group structure.
*
* Normal mode: every group shows; sessions populate under expanded groups,
* descending only into expanded sessions. 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 group header. Blank sessions are excluded everywhere.
* @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 group sections in render order.
*/
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 currentGroup = list.current === undefined
? undefined
: (workspaces.find(w => w.sessionIds.includes(list.current as SessionId))?.workspaceId as string | undefined)
?? UNGROUPED_KEY
const groups: GroupNode[] = []
for (const g of groupByWorkspace(list, workspaces)) {
if (q === '') {
const expanded = expandedProjects.has(g.key)
groups.push({
key: g.key,
workspaceId: g.workspaceId,
cwd: g.cwd,
label: g.label,
sessionCount: g.summaries.size,
expanded,
containsCurrent: g.key === currentGroup,
sessions: expanded ? buildVisible(g, expandedSessions) : [],
})
} else {
const visible = searchVisible(g, q)
if (visible.size === 0 && !g.label.toLowerCase().includes(q)) continue
groups.push({
key: g.key,
workspaceId: g.workspaceId,
cwd: g.cwd,
label: g.label,
sessionCount: g.summaries.size,
expanded: visible.size > 0,
containsCurrent: g.key === currentGroup,
sessions: buildSearch(g, visible),
})
}
}
return groups
}
/**
* Derive the flat session list ("In one list" mode): every session — fork
* children included — as a top-level row, strictly newest-first. No grouping,
* no parent/child adjacency; rows reuse SessionNode with children always
* empty so the renderer stays branch-free. Search mode filters by
* case-insensitive display-title substring.
* @param list - sessions list snapshot.
* @param view - the search query (expansion state does not apply).
* @returns flat rows in render order.
*/
export function deriveFlat(list: SessionListState, view: Pick<TreeView, 'query'>): SessionNode[] {
const q = view.query.trim().toLowerCase()
const rows: SessionSummary[] = []
for (const id of list.ids) {
const s = list.byId[id]
if (s === undefined) continue
if (q !== '' && !s.displayTitle.toLowerCase().includes(q)) continue
rows.push(s)
}
rows.sort(byRecency)
return rows.map(s => sessionNode(s, [], false, false))
}
/**
* 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
const HOUR = 3_600_000
const DAY = 86_400_000
const diff = Math.max(0, now - updatedAt)
if (diff < MIN) return 'now'
if (diff < HOUR) return `${Math.floor(diff / MIN)}min`
if (diff < DAY) return `${Math.floor(diff / HOUR)}h`
if (diff < 30 * DAY) return `${Math.floor(diff / DAY)}d`
if (diff < 365 * DAY) return `${Math.floor(diff / (30 * DAY))}mo`
return `${Math.floor(diff / (365 * DAY))}y`
}

View File

@@ -98,12 +98,16 @@ describe('workspace browser rows', () => {
it('workspace row menu opens on the ellipsis, renames, and shows the danger delete row', () => {
const onRename = vi.fn()
const onDelete = vi.fn()
const onToggle = vi.fn()
const group: GroupNode = {
key: 'project', workspaceId: wid('project'), cwd: '/projects/project', label: 'Project',
sessionCount: 0, expanded: false, containsCurrent: false, sessions: [],
}
render(<ProjectRowItem group={group} onToggle={onToggle} onCreate={vi.fn()} onRename={onRename} />)
render(<ProjectRowItem
group={group} onToggle={onToggle} onCreate={vi.fn()}
actions={{ rename: onRename, delete: onDelete }}
/>)
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Project' }))
// Opening the menu neither toggles the group nor renames yet.
expect(onToggle).not.toHaveBeenCalled()
@@ -111,11 +115,11 @@ describe('workspace browser rows', () => {
fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' }))
expect(onRename).toHaveBeenCalledOnce()
expect(screen.queryByRole('menu')).toBeNull()
// Delete stays visual-only: selecting it just closes the menu.
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Project' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Delete workspace' }))
expect(screen.queryByRole('menu')).toBeNull()
expect(onRename).toHaveBeenCalledOnce()
expect(onDelete).toHaveBeenCalledOnce()
// Escape closes without selecting (Menu onClose path).
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Project' }))
fireEvent.keyDown(document, { key: 'Escape' })

View File

@@ -54,6 +54,7 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
startSession: vi.fn(),
open: vi.fn(),
renameWorkspace: vi.fn(async () => {}),
deleteWorkspace: vi.fn(async () => {}),
insertSessionBefore: vi.fn(async () => {}),
createWorkspace: vi.fn(async () => workspace('created', [])),
...overrides,
@@ -457,6 +458,79 @@ describe('WorkspaceBrowser', () => {
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('denied') })
})
it('confirms Workspace deletion, explains retention, and blocks duplicate submission', async () => {
let resolveDelete!: () => void
const deleteWorkspace = vi.fn(() => new Promise<void>((resolve) => { resolveDelete = resolve }))
const browser = mount({
useWorkspaces: hook(workspaceState([workspace('alpha', ['session'], 'Alpha')])),
deleteWorkspace,
})
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Delete workspace' }))
const dialog = screen.getByRole('dialog', { name: 'Delete workspace' })
expect(dialog.textContent).toContain('removes “Alpha” from the workspace list')
expect(dialog.textContent).toContain('folder and session logs will be kept')
expect(dialog.textContent).toContain('sessions will appear under Ungrouped')
const confirm = screen.getByRole('button', { name: 'Delete workspace' }) as HTMLButtonElement
fireEvent.click(confirm)
fireEvent.click(confirm)
expect(deleteWorkspace).toHaveBeenCalledOnce()
expect(deleteWorkspace).toHaveBeenCalledWith(wid('alpha'))
expect(confirm.disabled).toBe(true)
expect((screen.getByRole('button', { name: 'Cancel' }) as HTMLButtonElement).disabled).toBe(true)
expect(screen.getByRole('status').textContent).toBe('Deleting workspace…')
fireEvent.keyDown(document, { key: 'Escape' })
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
expect(screen.getByRole('dialog', { name: 'Delete workspace' })).toBeTruthy()
await act(async () => { resolveDelete() })
// RPC success alone does not close: the component waits until its
// useWorkspaces projection has committed the removal, preventing a stale
// duplicate-name frame from leaking into the next create gesture.
expect(screen.getByRole('dialog', { name: 'Delete workspace' })).toBeTruthy()
rerender(browser, { useWorkspaces: hook(workspaceState([])) })
expect(screen.queryByRole('dialog', { name: 'Delete workspace' })).toBeNull()
})
it('keeps the delete dialog open on failure and allows retry or cancellation', async () => {
const deleteWorkspace = vi.fn()
.mockRejectedValueOnce(new Error('storage unavailable'))
.mockRejectedValueOnce('denied')
mount({
useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha')])),
deleteWorkspace,
})
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Delete workspace' }))
fireEvent.click(screen.getByRole('button', { name: 'Delete workspace' }))
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('storage unavailable') })
expect(screen.getByRole('dialog', { name: 'Delete workspace' })).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Delete workspace' }))
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('denied') })
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(screen.queryByRole('dialog', { name: 'Delete workspace' })).toBeNull()
})
it('Cancel, Escape, and Close dismiss deletion without calling the action', () => {
const deleteWorkspace = vi.fn(async () => {})
mount({
useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha')])),
deleteWorkspace,
})
const open = () => {
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Delete workspace' }))
}
open()
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
open()
fireEvent.keyDown(document, { key: 'Escape' })
open()
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
expect(deleteWorkspace).not.toHaveBeenCalled()
expect(screen.queryByRole('dialog', { name: 'Delete workspace' })).toBeNull()
})
it('search hides drag affordances (rows are not draggable during search)', () => {
const sessions = sessionState([summary('needle-a', 2, { displayTitle: 'Needle A' })])
mount({

View File

@@ -35,18 +35,25 @@ function anchor(): { current: HTMLElement } {
function mount(items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')], createWorkspace = vi.fn()) {
const onPick = vi.fn()
const onClose = vi.fn()
const view = render(
const anchorRef = anchor()
const renderPicker = (nextItems: readonly WorkspaceView[]) => (
<WorkspacePicker
open
anchorRef={anchor()}
anchorRef={anchorRef}
useSessions={hook(sessions)}
useWorkspaces={hook(workspaceState(items))}
useWorkspaces={hook(workspaceState(nextItems))}
onPick={onPick}
onClose={onClose}
createWorkspace={createWorkspace}
/>,
/>
)
return { view, onPick, onClose, createWorkspace }
const view = render(
renderPicker(items),
)
return {
view, onPick, onClose, createWorkspace,
rerenderItems: (nextItems: readonly WorkspaceView[]) => { view.rerender(renderPicker(nextItems)) },
}
}
function chooseCreateItem(name: 'Use an existing folder' | 'Create a new workspace'): void {
@@ -106,6 +113,22 @@ describe('WorkspacePicker', () => {
expect(b.createWorkspace).not.toHaveBeenCalled()
})
it('does not flash a duplicate alert when the successful create frame arrives before its unary response', async () => {
let resolve!: (workspace: WorkspaceView) => void
const pending = new Promise<WorkspaceView>((settle) => { resolve = settle })
const created = workspace('fresh', 'same-name')
const b = mount([], vi.fn(() => pending))
chooseCreateItem('Create a new workspace')
fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: 'same-name' } })
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
b.rerenderItems([created])
expect(screen.getByRole('status').textContent).toBe('Creating workspace…')
expect(screen.queryByRole('alert')).toBeNull()
await act(async () => { resolve(created); await pending })
expect(b.onPick).toHaveBeenCalledWith(created.workspaceId)
})
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 })

View File

@@ -272,7 +272,7 @@ export class BasicCompactService extends CompactService {
return this.compactRegion(range.start, range.end, agent, signal)
}
const context = await this.ctx.llm.resolveModelContext(target.provider, target.model)
const context = (await this.ctx.llm.resolveModelInfo(target.provider, target.model, signal)).context
const targetKey = `${target.provider}/${target.model}`
if (context === undefined) {
throw new TargetPressureConfigError(

View File

@@ -16,7 +16,7 @@ import type {
ContentBlock,
GenerateOptions,
LlmFailure,
LlmModelContext,
LlmResolvedModelInfo,
Message,
StreamChunk,
} from '@deepseek-ai/dsh-llm'
@@ -33,8 +33,13 @@ class ContextAdapter extends LlmAdapter {
super()
}
override resolveModelContext(): Promise<LlmModelContext> {
return Promise.resolve({ contextWindow: this.contextWindow })
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
return Promise.resolve({
provider,
id: model,
name: model,
context: { contextWindow: this.contextWindow },
})
}
override async * stream(): AsyncIterable<StreamChunk> {
@@ -47,9 +52,14 @@ class RoutedContextAdapter extends LlmAdapter {
super()
}
override resolveModelContext(provider: string): Promise<LlmModelContext | undefined> {
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
const contextWindow = this.windows[provider]
return Promise.resolve(contextWindow === undefined ? undefined : { contextWindow })
return Promise.resolve({
provider,
id: model,
name: model,
...contextWindow === undefined ? {} : { context: { contextWindow } },
})
}
override async * stream(): AsyncIterable<StreamChunk> {
@@ -453,6 +463,18 @@ describe('pressure measurement and retention', () => {
.resolves.not.toBeNull()
})
it('forwards turn cancellation to proactive model metadata resolution', async () => {
const ctx = createContext()
const resolveModelInfo = vi.spyOn(ctx.llm, 'resolveModelInfo')
const compact = service(compactConfig, ctx)
const session = conversation()
const signal = new AbortController().signal
await expect(compact.compactIfNeeded(agent(session, MODEL), 'pressure', signal))
.resolves.not.toBeNull()
expect(resolveModelInfo).toHaveBeenCalledWith(MODEL, MODEL, signal)
})
it('re-resolves capacity after a same-model-id provider switch in one session', async () => {
const ctx = new Context()
void new LlmService(ctx)
@@ -485,7 +507,11 @@ describe('pressure measurement and retention', () => {
void new LlmService(ctx)
void new TokenMeterService(ctx)
ctx.llm.registerAdapter(['unknown-context'], new ContextAdapter(1_000))
vi.spyOn(ctx.llm, 'resolveModelContext').mockResolvedValue(undefined)
vi.spyOn(ctx.llm, 'resolveModelInfo').mockImplementation((provider, model) => Promise.resolve({
provider,
id: model,
name: model,
}))
const compact = service(compactConfig, ctx)
const session = conversation(4)
session.append('request/header', {
@@ -1337,7 +1363,11 @@ describe('automatic listener and loader composition', () => {
const ctx = createContext()
const warnings: string[] = []
ctx.logger.warn = ((message: string) => void warnings.push(message)) as typeof ctx.logger.warn
vi.spyOn(ctx.llm, 'resolveModelContext').mockResolvedValue(undefined)
vi.spyOn(ctx.llm, 'resolveModelInfo').mockImplementation((provider, model) => Promise.resolve({
provider,
id: model,
name: model,
}))
void new TestCompactService(ctx, {
thresholdRatio: 0.5,
retainTokens: 180,

View File

@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact'
import { CONTEXT_WINDOW_EXCEEDED_CODE, LlmError } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, GenerateOptions, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import type { Agent } from '@deepseek-ai/dsh-agent'
@@ -41,8 +41,13 @@ class StepwiseToolAdapter extends LlmAdapter {
super()
}
override resolveModelContext(): Promise<{ contextWindow: number }> {
return Promise.resolve({ contextWindow: 400 })
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
return Promise.resolve({
provider,
id: model,
name: model,
context: { contextWindow: 400 },
})
}
async * stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
@@ -76,8 +81,13 @@ class OverflowRecoveryAdapter extends LlmAdapter {
super()
}
override resolveModelContext(): Promise<{ contextWindow: number }> {
return Promise.resolve({ contextWindow: 128 })
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
return Promise.resolve({
provider,
id: model,
name: model,
context: { contextWindow: 128 },
})
}
override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {

View File

@@ -381,12 +381,20 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
jsDoc: '/**\n * Discover models advertised by one registered provider. Catalog membership\n * is advisory and never changes routing or request validation.\n * @param provider - registered provider route to inspect.\n * @returns detached model metadata in adapter-preferred order.\n */',
},
{
signature: 'async resolveModelContext( provider: string, model: string, ): Promise<LlmModelContext | undefined>',
jsDoc: '/**\n * Resolve context capacity from the adapter that owns one exact route.\n * This query is independent of the advisory model catalog: an unlisted model\n * may return metadata, while `undefined` never rejects later routing.\n * @param provider - registered provider route to inspect.\n * @param model - exact model id passed to the adapter.\n * @returns detached context metadata, or `undefined` when the adapter has none.\n */',
signature: 'async resolveModelInfo( provider: string, model: string, signal?: AbortSignal, ): Promise<LlmResolvedModelInfo>',
jsDoc: '/**\n * Resolve and validate all metadata from the adapter that owns one exact\n * route. The result is detached from adapter-owned objects; catalog\n * membership remains advisory and does not control request routing.\n * @param provider - registered provider route to inspect.\n * @param model - exact model id passed to the adapter.\n * @param signal - optional cancellation for adapter-owned asynchronous lookup.\n * @returns exact model identity plus available context and reasoning metadata.\n */',
},
{
signature: 'async resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise<LlmCallConfig>',
jsDoc: '/**\n * Validate a conversation call config against its exact model capability and\n * materialize an adapter-configured default. Unsupported explicit efforts\n * reject before provider I/O; no clamping or aliasing is performed. This\n * standalone query does not bind a later dispatch; use {@link prepareCall}\n * when logging and streaming must share one adapter registration.\n * @param config - provider/model route and optional request controls.\n * @param signal - optional cancellation for adapter-owned capability lookup.\n * @returns a detached config only when a default must be materialized.\n */',
},
{
signature: 'async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<PreparedLlmCall>',
jsDoc: '/**\n * Resolve one call under its current adapter registration. The returned\n * one-shot handle keeps that registration across header logging and dispatch,\n * so HMR cannot combine one adapter\'s capability result with another adapter.\n * @param config - provider/model route and optional request controls.\n * @param signal - optional cancellation for adapter-owned capability lookup.\n * @returns a prepared config and its registration-bound stream entry point.\n */',
},
{
signature: 'stream(options: GenerateOptions): AsyncIterable<StreamChunk>',
jsDoc: '/**\n * Stream one model call as raw chunks (token-level deltas). Throws\n * `LlmError` with code `NO_ADAPTER` if no adapter is registered for\n * `options.provider`. Replay state is retained only when the same adapter\n * instance owns its historical provider and the target provider. Final\n * adapter selection, dispatch, and iteration failures retain their original\n * Error identity and are tagged in a call-local scope for narrow agent-loop\n * request recovery; middleware and nested-call failures remain untagged for\n * the outer call.\n * @param options - the full request; `options.provider` selects the adapter.\n * @returns the chunk stream, possibly wrapped by `llm/stream` listeners.\n */',
jsDoc: '/**\n * Stream one model call as raw chunks (token-level deltas). Throws\n * `LlmError` with code `NO_ADAPTER` if no adapter is registered for\n * `options.provider`. Replay state is retained only when the same adapter\n * instance owns its historical provider and the target provider. Final\n * adapter selection remains fixed through asynchronous exact-model resolution\n * and dispatch. Selection, dispatch, and iteration failures retain their\n * original Error identity and are tagged in a call-local scope for narrow\n * agent-loop request recovery; middleware and nested-call failures remain\n * untagged for the outer call.\n * @param options - the full request; `options.provider` selects the adapter.\n * @returns the chunk stream, possibly wrapped by `llm/stream` listeners.\n */',
},
],
},
@@ -942,6 +950,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
signature: 'list(): Workspace[]',
jsDoc: '/**\n * Synchronous workspace projection in durable registry order. Every\n * entity\'s `sessionIds` getter is already filtered by the startup/live\n * canonical-cwd header index; this method performs no persistence reads.\n * @returns a fresh ordered array of workspace entities.\n */',
},
{
signature: 'delete(id: WorkspaceId): Promise<boolean>',
jsDoc: '/**\n * Delete one workspace registration while retaining its directory and every\n * session log. The durable order is updated before the table deletion; a\n * failed table write restores the prior order and keeps the entity\n * published. Unknown ids are an idempotent no-op for domain callers.\n * @param id - Workspace registration to remove.\n * @returns `true` when a record was deleted, `false` when it was unknown.\n */',
},
{
signature: 'async resolveByPath(path: string): Promise<Workspace | undefined>',
jsDoc: '/**\n * Resolve by canonical directory path without creating or mutating a\n * workspace. A missing path rejects during `realpath`; an existing unowned\n * directory returns `undefined`.\n * @param path - Existing directory path in any spelling.\n * @returns the workspace owning the canonical path, when one exists.\n */',
@@ -1653,7 +1665,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'GenerateOptions',
declaration: 'export interface GenerateOptions {\n provider: string;\n model: string;\n messages: Message[];\n system?: string;\n tools?: ToolSchema[];\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n signal?: AbortSignal;\n sessionId?: Branded<\'SessionId\'>;\n purpose?: \'compaction\' | \'session-title\';\n}',
declaration: 'export interface GenerateOptions {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n messages: Message[];\n system?: string;\n tools?: ToolSchema[];\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n signal?: AbortSignal;\n sessionId?: Branded<\'SessionId\'>;\n purpose?: \'compaction\' | \'session-title\';\n}',
},
{
name: 'GenericCallView',
@@ -1733,11 +1745,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'LlmAdapter',
declaration: 'export abstract class LlmAdapter {\n providerInfo(provider: string): LlmProviderInfo;\n listModels(_provider: string): Promise<readonly LlmModelInfo[]>;\n resolveModelContext(_provider: string, _model: string): Promise<LlmModelContext | undefined>;\n abstract stream(options: GenerateOptions): AsyncIterable<StreamChunk>;\n}',
declaration: 'export abstract class LlmAdapter {\n providerInfo(provider: string): LlmProviderInfo;\n listModels(_provider: string): Promise<readonly LlmModelInfo[]>;\n resolveModel(provider: string, model: string, _signal?: AbortSignal): Promise<LlmResolvedModelInfo>;\n abstract stream(options: GenerateOptions): AsyncIterable<StreamChunk>;\n}',
},
{
name: 'LlmCallConfig',
declaration: 'export interface LlmCallConfig {\n provider: string;\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n}',
declaration: 'export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n}',
},
{
name: 'LlmFailure',
@@ -1751,10 +1763,22 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'LlmModelInfo',
declaration: 'export interface LlmModelInfo {\n provider: string;\n id: string;\n name: string;\n description?: string;\n}',
},
{
name: 'LlmModelReasoningInfo',
declaration: 'export interface LlmModelReasoningInfo {\n efforts: readonly LlmReasoningEffortInfo[];\n defaultEffort?: ReasoningEffortId;\n}',
},
{
name: 'LlmProviderInfo',
declaration: 'export interface LlmProviderInfo {\n id: string;\n name: string;\n}',
},
{
name: 'LlmReasoningEffortInfo',
declaration: 'export interface LlmReasoningEffortInfo {\n id: ReasoningEffortId;\n name: string;\n description?: string;\n}',
},
{
name: 'LlmResolvedModelInfo',
declaration: 'export interface LlmResolvedModelInfo extends LlmModelInfo {\n context?: LlmModelContext;\n reasoning?: LlmModelReasoningInfo;\n}',
},
{
name: 'Message',
declaration: 'export interface Message {\n role: \'system\' | \'user\' | \'assistant\';\n content: ContentBlock[];\n provenance?: AssistantProvenance;\n}',
@@ -1779,6 +1803,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'OutOfBandSessionEventType',
declaration: 'export type OutOfBandSessionEventType = Exclude<Extract<SessionEventType, keyof OutOfBandSessionEventMap>, SurfaceEventType>;',
},
{
name: 'PreparedLlmCall',
declaration: 'export interface PreparedLlmCall {\n readonly config: LlmCallConfig;\n stream(options: GenerateOptions): AsyncIterable<StreamChunk>;\n}',
},
{
name: 'PreparedReferencedMessage',
declaration: 'export interface PreparedReferencedMessage {\n content: ContentBlock[];\n contexts: HookContext[];\n}',
@@ -1899,6 +1927,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'ReasoningBlock',
declaration: 'export interface ReasoningBlock {\n type: \'reasoning\';\n text: string;\n}',
},
{
name: 'ReasoningEffortId',
declaration: 'export type ReasoningEffortId = Branded<\'ReasoningEffortId\'>;',
},
{
name: 'RequestHeaderReason',
declaration: 'export type RequestHeaderReason = \'initial\' | \'resume\' | \'change\';',

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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
README.md: 3acf4d3828291d5f318306f2652e0d920c695675
README.zh.md: 11ff8318813b1abd096e1a3549d389cbba88f12b
# pnpm run verify-translation-pairing --write packages/core/agent-loop/README.md
README.md: 6ad982ffff7b73e16ee39f9c29da787e37547de4
README.zh.md: c72df198774f0f8009cc5ab43932e69187757745

View File

@@ -62,6 +62,8 @@ The driver owns one agent for its lifetime and runs inside `ctx.agents.withIniti
Every provider call that reaches a successful finish appends exactly one `assistant/message` completion anchor, including content-less calls and `max-tokens` finishes. A successful `agent/step-result` stores its transformed content; a rejected result records empty content before the original failure continues. The anchor retains exact chunk provenance (`[]` for a stream with no chunks) and usage when available, while empty content stays out of derived message history.
After `agent/request` returns a provider/model call config, the loop asks `ctx.llm.prepareCall()` to validate any adapter-owned reasoning effort and materialize its configured default under the active turn signal. The prepared call retains the exact adapter registration across this asynchronous resolution, `request/header` logging, and terminal dispatch, so HMR cannot mix one adapter's capability result with another adapter's request. The effective config is logged before dispatch, so a listener can change effort between steps without hidden request drift. A route with no registered adapter preserves the proposed config so an `llm/stream` listener can own and short-circuit it; unhandled terminal dispatch still fails with `NO_ADAPTER`. A new loop instance restores the last effort only when its initial provider/model route exactly matches the logged route; a route change discards that opaque model-owned ID and resolves the new model independently.
Plugin failure ends the current turn, not the loop. Only final adapter dispatch/iteration failures and terminal in-band error or aborted finishes enter `agent/request-error`; middleware, result processing, tools, and `agent/post-step` remain ordinary turn failures. Recovery receives the exact live error, immutable provider facts, and immutable prior failures after the failed step closes. A retry rebuilds from the durable log in a new numbered step, success clears the consecutive history, and exhaustion records the structured failure once on `turn/end`. AgentLoop privately owns one cancellation holder whose explicit signal spans prompt policy, assembly, every step, model and tool work, recovery, continuation, and terminal stop; it retires the holder immediately before publishing `turn/end`, while the driver may remain `running` through the durability flush. An effective `cancel()` emits the typed runtime-only `user | parent` cause before clearing pending work and cooperatively aborting the holder; notification failures cannot veto cancellation, work queued by a notification observer is cleared, work queued by a later abort observer belongs to the next turn, and idle cancellation emits nothing. Durable `turn/end` remains coarse `aborted`; undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs. Disposal wins terminal classification, and work that ignores the signal must settle before quiescence. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns the lifecycle and race contract. Terminal continuation stops remain authoritative through turn close and durability flush.
Within a step, exclusive calls form barriers; parallel-safe calls use a bounded rolling pool and are reclassified before start. Only dispatch/body overlaps. Policy, durable results, and result context remain model-ordered. Abort stops new calls, drains started results, then drains accepted batch context before the turn closes through the normal abort path.

View File

@@ -62,6 +62,8 @@ interface Config {
每次提供方调用成功结束时,都会恰好追加一个 `assistant/message` 完成锚点,包括无内容调用和以 `max-tokens` 结束的调用。成功的 `agent/step-result` 存储其转换后内容;被拒绝的结果会先记录空内容,再继续抛出原始失败。该锚点保留确切的 chunk 溯源(流没有 chunk 时为 `[]`),并在用量可用时保留用量;空内容不会进入派生消息历史。
`agent/request` 返回提供方/模型调用配置后,循环会调用 `ctx.llm.prepareCall()`在活跃轮次信号的控制下校验由适配器持有的推理reasoning强度并填入其配置默认值。准备完成的调用会在这次异步解析、`request/header` 日志记录和最终分派期间保留同一项确切的适配器注册,因此 HMR热模块替换不会把某个适配器的能力解析结果与另一适配器的请求混用。生效配置会在分派前写入日志因此监听器可以在步骤之间更改推理强度而不会产生未记录的请求变化。没有已注册适配器的路由会保留原定配置使 `llm/stream` 监听器可以接管并短路该请求;最终分派仍会以 `NO_ADAPTER` 拒绝未得到处理的路由。新循环实例仅在初始提供方/模型路由与日志路由完全一致时恢复上次的推理强度;路由变化会丢弃由前一模型持有的不透明 ID并单独解析新模型。
插件失败会结束当前轮次,而不是结束循环。只有最终适配器分发/迭代失败,以及带内的终止错误或中止结束原因,才进入 `agent/request-error`;中间件、结果处理、工具和 `agent/post-step` 仍属于普通轮次失败。失败步骤关闭后,恢复逻辑会接收确切的实时错误、不可变的提供方事实和不可变的先前失败。重试会在新的编号步骤中根据持久日志重建;成功会清除连续失败历史;耗尽后只在 `turn/end` 上记录一次结构化失败。AgentLoop 私下拥有一个取消持有者其显式信号覆盖提示词策略、组装、每个步骤、模型与工具工作、恢复、continuation 和终止停止;它会在发布 `turn/end` 前立即退役该持有者,而驱动器可以在持久性 flush 期间继续保持 `running`。有效的 `cancel()` 会先发出仅存在于运行时的类型化 `user | parent` 原因,再清除待处理工作,并以协作方式中止该持有者;通知失败无法 veto 取消,通知观察方排队的工作会被清除,之后由中止观察方排队的工作属于下一轮次,空闲取消则不发出任何内容。持久 `turn/end` 仍使用粗粒度的 `aborted`;未分发的模型工具调用会收到合成的 `tool/call``ABORTED_BEFORE_DISPATCH` 结果对。释放会在终止分类中胜出;忽略信号的工作必须先结算,系统才能完全停稳。[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)规定生命周期与竞态契约。终止 continuation 的停止决定在轮次关闭和持久性 flush 期间始终具有权威性。
在步骤内独占调用形成屏障并行安全调用使用有界滚动池并在启动前重新分类。只有分发主体会重叠。策略、持久结果和结果上下文仍保持模型顺序。中止会停止新调用、drain 已启动的结果然后在轮次通过普通中止路径关闭前drain 已接纳的批次上下文。

View File

@@ -7,7 +7,7 @@
import { randomUUID } from 'node:crypto'
import type { Context } from 'cordis'
import type { ContentBlock, FinishReason, GenerateOptions, LlmCallConfig, LlmFailure, Message } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, FinishReason, GenerateOptions, LlmCallConfig, LlmFailure, Message, PreparedLlmCall } from '@deepseek-ai/dsh-llm'
import { isDeepStrictEqual } from 'node:util'
import { BlockAssembler, HarnessError, LlmError, assertNever, deepFreeze, errorChain, llmFailureOf, markAgentLoopRequest } from '@deepseek-ai/dsh-llm'
import { agentEvents, agentInterruptReasonOf, assembleContextFor, AgentMessageId } from '@deepseek-ai/dsh-agent'
@@ -661,19 +661,45 @@ async function runStep(
// Seed the first request from agent options and later requests from the logged header;
// detach and freeze so listeners must return an attributable replacement.
const seedConfig: LlmCallConfig = deepFreeze(structuredClone(transmission.loggedHeader
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- loggedHeader ⟹ a snapshot is in the log
? session.requestHeader()!.config
: { provider: options.provider ?? '', model: options.model ?? '' }))
const loggedConfig = session.requestHeader()?.config
const initialProvider = options.provider ?? ''
const initialModel = options.model ?? ''
const initialConfig: LlmCallConfig = {
provider: initialProvider,
model: initialModel,
...loggedConfig?.provider === initialProvider
&& loggedConfig.model === initialModel
&& loggedConfig.reasoningEffort !== undefined
? { reasoningEffort: loggedConfig.reasoningEffort }
: {},
}
const seedConfig: LlmCallConfig = deepFreeze(structuredClone(
transmission.loggedHeader
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- loggedHeader ⟹ a snapshot is in the log
? session.requestHeader()!.config
: initialConfig,
))
// Listener replacements are recorded in the request header before dispatch.
const config = await events.waterfall(
const proposedConfig = await events.waterfall(
'agent/request', turn, step, seedConfig, signal, () => Promise.resolve(seedConfig),
)
interruptionCheckpoint(signal)
if (!config.provider || !config.model) {
if (!proposedConfig.provider || !proposedConfig.model) {
throw new Error(`agent "${agent.id}" has no provider/model: set AgentOptions.provider and AgentOptions.model or supply both via the agent/request waterfall`)
}
let config: LlmCallConfig
let preparedCall: PreparedLlmCall | undefined
try {
preparedCall = await ctx.llm.prepareCall(proposedConfig, signal)
config = preparedCall.config
} catch (error: unknown) {
// A waterfall listener may own and short-circuit a route with no adapter.
// Terminal dispatch still raises NO_ADAPTER when no listener handles it.
if (!(error instanceof LlmError) || error.code !== 'NO_ADAPTER') throw error
config = proposedConfig
}
interruptionCheckpoint(signal)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- runTurn composes the prefix before every runStep call
const sessionPrefix = transmission.sessionPrefix!
@@ -691,6 +717,9 @@ async function runStep(
const request: GenerateOptions = markAgentLoopRequest(deepFreeze({
provider: header.config.provider,
model: header.config.model,
...header.config.reasoningEffort !== undefined
? { reasoningEffort: header.config.reasoningEffort }
: {},
messages: [...header.messagePrefix ?? [], ...boundaryMessages],
...header.system !== undefined ? { system: header.system } : {},
...header.tools !== undefined ? { tools: header.tools } : {},
@@ -704,7 +733,7 @@ async function runStep(
// --- Model call (streaming-first; raw chunks are the replay record) ---
const assembler = new BlockAssembler()
const chunkSeqs: number[] = []
const stream = ctx.llm.stream(request)
const stream = preparedCall?.stream(request) ?? ctx.llm.stream(request)
try {
for await (const chunk of stream) {
interruptionCheckpoint(signal)

View File

@@ -1,4 +1,4 @@
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, LlmModelReasoningInfo, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
/** Helpers to write scripted responses tersely. */
@@ -64,10 +64,25 @@ export function toolCallResponse(rawCallId: string, name: string, args: object,
export class MockAdapter extends LlmAdapter {
requests: GenerateOptions[] = []
constructor(private script: (StreamChunk[] | ((options: GenerateOptions) => StreamChunk[]) | 'hang')[]) {
constructor(
private script: (StreamChunk[] | ((options: GenerateOptions) => StreamChunk[]) | 'hang')[],
private readonly reasoning?: LlmModelReasoningInfo,
) {
super()
}
override resolveModel(
provider: string,
model: string,
): Promise<LlmResolvedModelInfo> {
return Promise.resolve({
provider,
id: model,
name: model,
...this.reasoning === undefined ? {} : { reasoning: this.reasoning },
})
}
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
this.requests.push(options)
const entry = this.script.shift()

View File

@@ -7,8 +7,8 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
import LlmService, { LlmError, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, LlmModelReasoningInfo, LlmResolvedModelInfo } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
@@ -104,6 +104,168 @@ describe('request stability across the loop', () => {
expectPrefixExtension(adapter.requests[0]!, adapter.requests[1]!)
})
it('logs adapter defaults, supports per-turn effort changes, and restores the effective value', async () => {
const reasoning = {
efforts: [
{ id: ReasoningEffortId('high'), name: 'High' },
{ id: ReasoningEffortId('max'), name: 'Max' },
],
defaultEffort: ReasoningEffortId('high'),
}
const adapter = new MockAdapter([textResponse('one'), textResponse('two')], reasoning)
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('effort'), { provider: 'mock', model: 'mock' })
ctx.on('agent/request', async (_agent, turn, _step, _config, _signal, next) => {
const config = await next()
return turn === 2 ? { ...config, reasoningEffort: ReasoningEffortId('max') } : config
})
send(agent, 'first')
await waitForIdle(ctx, agent)
send(agent, 'second')
await waitForIdle(ctx, agent)
expect(adapter.requests.map(request => request.reasoningEffort)).toEqual([
ReasoningEffortId('high'),
ReasoningEffortId('max'),
])
const headers = agent.session.events.filter(event => event.type === 'request/header')
expect(headers.map(event => event.data.header.config.reasoningEffort)).toEqual([
ReasoningEffortId('high'),
ReasoningEffortId('max'),
])
expect(headers.map(event => event.data.reason)).toEqual(['initial', 'change'])
const resumedAdapter = new MockAdapter([textResponse('three')], reasoning)
const resumedCtx = await harness(resumedAdapter)
const resumedHandle = await resumedCtx.agents.create({
sessionId: SessionId('effort-resumed'),
seed: structuredClone(agent.session.events),
agentOptions: { provider: 'mock', model: 'mock' },
})
send(resumedHandle.agent, 'third')
await waitForIdle(resumedCtx, resumedHandle.agent)
expect(resumedAdapter.requests[0]?.reasoningEffort).toBe(ReasoningEffortId('max'))
const resumedHeaders = resumedHandle.agent.session.events.filter(event => event.type === 'request/header')
expect(resumedHeaders.at(-1)?.data.header.config.reasoningEffort).toBe(ReasoningEffortId('max'))
expect(resumedHeaders.at(-1)?.data.reason).toBe('resume')
})
it('keeps exact-model resolution, request logging, and dispatch on one adapter registration', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { persona: 'stable base' })
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
const started = Promise.withResolvers<undefined>()
const reasoning = Promise.withResolvers<LlmModelReasoningInfo>()
const first = new class extends MockAdapter {
override async resolveModel(
provider: string,
model: string,
_signal?: AbortSignal,
): Promise<LlmResolvedModelInfo> {
started.resolve(undefined)
return {
provider,
id: model,
name: model,
reasoning: await reasoning.promise,
}
}
}([textResponse('first')])
const second = new MockAdapter([textResponse('second')], {
efforts: [{ id: ReasoningEffortId('max'), name: 'Max' }],
defaultEffort: ReasoningEffortId('max'),
})
const disposeFirst = ctx.llm.registerAdapter(['mock'], first)
const agent = ctx.agentLoop.create(SessionId('effort-hmr'), { provider: 'mock', model: 'mock' })
send(agent, 'go')
await started.promise
disposeFirst()
ctx.llm.registerAdapter(['mock'], second)
reasoning.resolve({
efforts: [{ id: ReasoningEffortId('high'), name: 'High' }],
defaultEffort: ReasoningEffortId('high'),
})
await waitForIdle(ctx, agent)
expect(first.requests.map(request => request.reasoningEffort)).toEqual([
ReasoningEffortId('high'),
])
expect(second.requests).toHaveLength(0)
const headers = agent.session.events.filter(event => event.type === 'request/header')
expect(headers.at(-1)?.data.header.config.reasoningEffort).toBe(ReasoningEffortId('high'))
})
it('aborts a blocked reasoning lookup before quiescent disposal completes', async () => {
const started = Promise.withResolvers<AbortSignal>()
const adapter = new class extends MockAdapter {
override resolveModel(
_provider: string,
_model: string,
signal?: AbortSignal,
): Promise<never> {
if (signal === undefined) return Promise.reject(new Error('missing reasoning signal'))
started.resolve(signal)
return new Promise((_resolve, reject) => {
if (signal.aborted) {
reject(signal.reason instanceof Error ? signal.reason : new Error('reasoning aborted'))
return
}
signal.addEventListener('abort', () => {
reject(signal.reason instanceof Error ? signal.reason : new Error('reasoning aborted'))
}, { once: true })
})
}
}([])
const ctx = await harness(adapter)
const handle = await ctx.agents.create({
sessionId: SessionId('reasoning-dispose'),
agentOptions: { provider: 'mock', model: 'mock' },
})
send(handle.agent, 'go')
const signal = await started.promise
await handle.dispose()
expect(signal.aborted).toBe(true)
expect(handle.agent.status).toBe('disposed')
expect(adapter.requests).toHaveLength(0)
expect(handle.agent.session.events.some(event => event.type === 'request/header')).toBe(false)
})
it.each(['plain error', 'LLM error'] as const)(
'does not swallow a %s from exact-model resolution',
async (kind) => {
const failure = kind === 'plain error'
? new Error('reasoning metadata failed')
: new LlmError('unsupported effort', 'UNSUPPORTED_REASONING_EFFORT')
const adapter = new class extends MockAdapter {
override resolveModel(): Promise<never> {
return Promise.reject(failure)
}
}([])
const ctx = await harness(adapter)
const errors: Error[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
const agent = ctx.agentLoop.create(SessionId(`reasoning-${kind}`), {
provider: 'mock',
model: 'mock',
})
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(errors).toContain(failure)
expect(adapter.requests).toHaveLength(0)
},
)
it('a compaction replace rewrites the resend, and the log explains it', async () => {
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
const ctx = await harness(adapter)
@@ -301,6 +463,7 @@ describe('request stability across the loop', () => {
const firstChunk = events.find(e => e.type === 'assistant/chunk' && e.seq > stepStart.seq)!
const header = foldRequestHeader(events.slice(0, firstChunk.seq))!
expect(request.model).toBe(header.config.model)
expect(request.reasoningEffort).toBe(header.config.reasoningEffort)
expect(request.system).toEqual(header.system)
expect(structuredClone(request.tools ?? [])).toEqual(structuredClone(header.tools ?? []))
expect(request.temperature).toBe(header.config.temperature)

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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
README.md: bbae91ff2f497208f1ce620e162c27968d666ac3
README.zh.md: 95367b35a546c68491b9623daf45a54cd63f2731
# pnpm run verify-translation-pairing --write packages/core/agent/README.md
README.md: a65c53b3e4edf2031f286d7d172e73357c66ff1e
README.zh.md: 05da8a0a0d3ad2ed879b72e20eae1c4efaf711c8

View File

@@ -12,7 +12,7 @@ Tracks live agents and carries the initiating Agent through asynchronous driver
### Public API
The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `installAgentLlmTarget(agentCtx, target)` snapshots a mutable provider/model selection during prompt assembly and applies that pair to both prompt variables and request routing for one step. `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup is trusted, composition-only same-process code: drive the agent only after creation resolves.
The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `installAgentLlmTarget(agentCtx, target)` snapshots a mutable provider/model/reasoning-effort selection during prompt assembly, applies the route to prompt variables, and applies the complete target to request routing for one step; an absent selected effort clears an inherited effort so the target uses adapter/provider defaults. `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup is trusted, composition-only same-process code: drive the agent only after creation resolves.
- `ctx.agents.register(agent: Agent): () => void` — record an **already-constructed** agent. Disposed with the calling fiber.
- Advanced ordered lifecycle: `enter(agent, owner): () => void` enforces `agent.id === agent.session.id`, performs the authoritative ID collision check, and inserts without announcing; `owner` explicitly records the live creator-agent relation (or `undefined` for a root), independently of durable session lineage. `announce(agent)` emits `agent/created` exactly once. A detach requested synchronously by a creation listener is deferred until that dispatch unwinds, and every detach checks the captured entry object, so a stale capability cannot delete a later same-ID replacement. The async factory uses this split; ordinary plugins use `register()`.

View File

@@ -12,7 +12,7 @@ Agent 接口、注册表、进程本地发起方作用域,以及 `agent/*` 事
### 公开 API
带作用域的注册表层:`Agent.ctx` 是 agent 的作用域上下文(`dsh-scope`,键 = 该 agent。通过它注册工具变量监听器只对该 agent 生效,并在释放时全部撤销。`agentEvents(ctx, agent)` 是普通 agent 主体操作的融合分发器(一次完成载体 + 注入主体);其通知 mode 会调用每个监听器,并同时收容同步抛出和返回 Promise 的拒绝。注册表生命周期对复用一个稳定路由载体。`assembleContextFor(agent)` 构建按 agent 的组装上下文(同时包含 `agent` + `scope`)。`installAgentLlmTarget(agentCtx, target)` 在提示词组装期间快照可变的提供方/模型选择,并将该对同时应用到一个步骤的提示词变量与请求路由`CreateAgentOptions.setup(agentCtx)``ResumeAgentOptions.setup(agentCtx)` 在新建或恢复的 agent 尚未发布时组合其带作用域的世界。Setup 是受信任、仅用于组合的同进程代码:只有创建完成后才能驱动 agent。
带作用域的注册表层:`Agent.ctx` 是 agent 的作用域上下文(`dsh-scope`,键 = 该 agent。通过它注册工具变量监听器只对该 agent 生效,并在释放时全部撤销。`agentEvents(ctx, agent)` 是普通 agent 主体操作的融合分发器(一次完成载体 + 注入主体);其通知 mode 会调用每个监听器,并同时收容同步抛出和返回 Promise 的拒绝。注册表生命周期对复用一个稳定路由载体。`assembleContextFor(agent)` 构建按 agent 的组装上下文(同时包含 `agent` + `scope`)。`installAgentLlmTarget(agentCtx, target)` 在提示词组装期间快照可变的提供方/模型推理reasoning强度选择将路由应用到提示词变量并将完整目标应用到一个步骤的请求路由如果没有选定推理强度则会清除继承的推理强度使该目标使用适配器提供方默认值`CreateAgentOptions.setup(agentCtx)``ResumeAgentOptions.setup(agentCtx)` 在新建或恢复的 agent 尚未发布时组合其带作用域的世界。Setup 是受信任、仅用于组合的同进程代码:只有创建完成后才能驱动 agent。
- `ctx.agents.register(agent: Agent): () => void`:记录一个 **已经构造完成** 的 agent。随调用 fiber 释放。
- 高级有序生命周期:`enter(agent, owner): () => void` 强制 `agent.id === agent.session.id`,执行权威 ID 冲突检查,并在不通知的情况下插入;`owner` 显式记录实时创建方 agent 关系(根 agent 为 `undefined`),与持久会话谱系无关。`announce(agent)` 恰好发出一次 `agent/created`。创建监听器同步请求的 detach 会延后到该次分发结束;每次 detach 都会检查捕获的条目对象,因此陈旧能力无法删除后续使用同一 ID 的替代项。异步工厂使用这一拆分;普通插件使用 `register()`

View File

@@ -1,17 +1,19 @@
/**
* Agent-scoped provider/model target snapshot shared by interactive front doors.
* Agent-scoped LLM target snapshot shared by interactive front doors.
* @module @deepseek-ai/dsh-agent/llm-target
*/
import type { Context } from 'cordis'
import type { LlmCallConfig } from '@deepseek-ai/dsh-llm'
import type { LlmCallConfig, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
/** Complete provider/model route selected for one live agent. */
/** Complete provider/model route and optional reasoning effort selected for one live agent. */
export interface AgentLlmTarget {
/** Registered provider route. */
provider: string
/** Provider-owned model id. */
model: string
/** Adapter-owned reasoning effort, or provider/default behavior when absent. */
reasoningEffort?: ReasoningEffortId
}
/** Mutable selection plus the target captured for the current step. */
@@ -24,9 +26,11 @@ export interface AgentLlmTargetRef {
/**
* Couple one mutable target to agent-scoped prompt assembly and request routing.
* Prompt assembly snapshots the selected pair before delegating, then applies
* both prompt variables and request config to that snapshot so a concurrent
* switch takes effect on a later step instead of splitting the two surfaces.
* Prompt assembly snapshots the selected target before delegating, then applies
* its route to prompt variables and its route/effort to request config so a
* concurrent switch takes effect on a later step instead of splitting the two
* surfaces. An absent selected effort clears any inherited effort so a model
* switch can restore that target's provider/default behavior.
*
* @param agentCtx - The target agent's scoped context.
* @param target - Mutable selection owned by the calling front door.
@@ -52,10 +56,15 @@ export function installAgentLlmTarget(agentCtx: Context, target: AgentLlmTargetR
async (_agent, _turn, _step, _config, _signal, next): Promise<LlmCallConfig> => {
const resolved = await next()
const selected = target.assembled
return selected === undefined ? resolved : {
...resolved,
if (selected === undefined) return resolved
const { reasoningEffort: _inheritedEffort, ...withoutInheritedEffort } = resolved
return {
...withoutInheritedEffort,
provider: selected.provider,
model: selected.model,
...selected.reasoningEffort === undefined
? {}
: { reasoningEffort: selected.reasoningEffort },
}
},
)

View File

@@ -7,7 +7,7 @@ import {
type Agent,
type AgentLlmTargetRef,
} from '../src/index.ts'
import type { LlmCallConfig } from '@deepseek-ai/dsh-llm'
import { ReasoningEffortId, type LlmCallConfig } from '@deepseek-ai/dsh-llm'
describe('installAgentLlmTarget()', () => {
it('snapshots prompt variables and request routing together, then disposes both listeners', async () => {
@@ -24,16 +24,31 @@ describe('installAgentLlmTarget()', () => {
'agent/request', 1, 0, seed, signal, () => Promise.resolve(seed),
)).resolves.toBe(seed)
target.current = { provider: 'alpha', model: 'a1' }
target.current = {
provider: 'alpha',
model: 'a1',
reasoningEffort: ReasoningEffortId('high'),
}
expect((await ctx.systemPrompt.assemble()).variables).toMatchObject({ provider: 'alpha', model: 'a1' })
target.current = { provider: 'beta', model: 'b1' }
await expect(agentEvents(ctx, agent).waterfall(
'agent/request', 1, 0, seed, signal, () => Promise.resolve(seed),
)).resolves.toEqual({ provider: 'alpha', model: 'a1', temperature: 0.2 })
)).resolves.toEqual({
provider: 'alpha',
model: 'a1',
reasoningEffort: ReasoningEffortId('high'),
temperature: 0.2,
})
expect((await ctx.systemPrompt.assemble()).variables).toMatchObject({ provider: 'beta', model: 'b1' })
const inherited: LlmCallConfig = {
provider: 'alpha',
model: 'a1',
reasoningEffort: ReasoningEffortId('max'),
temperature: 0.2,
}
await expect(agentEvents(ctx, agent).waterfall(
'agent/request', 1, 1, seed, signal, () => Promise.resolve(seed),
'agent/request', 1, 1, inherited, signal, () => Promise.resolve(inherited),
)).resolves.toEqual({ provider: 'beta', model: 'b1', temperature: 0.2 })
dispose()

View File

@@ -183,6 +183,11 @@ function assertCurrentLlmShape(event: Record<string, unknown>, index: number): v
const header = record['header']
const config = typeof header === 'object' && header !== null ? (header as Record<string, unknown>)['config'] : undefined
if (!hasProviderModel(config)) throw new Error(`seed request/header at index ${index} lacks provider/model`)
const reasoningEffort = (config as Record<string, unknown>)['reasoningEffort']
if (reasoningEffort !== undefined
&& (typeof reasoningEffort !== 'string' || reasoningEffort.length === 0)) {
throw new Error(`seed request/header at index ${index} has an invalid reasoningEffort`)
}
}
if (event['type'] === 'assistant/message' && !hasProviderModel(record['provenance'])) {
throw new Error(`seed assistant/message at index ${index} lacks provider/model provenance`)

View File

@@ -156,7 +156,7 @@ export interface TodoItem {
* canonical empty optional fields are absent.
*/
export interface EpochHeader {
/** The conversation's call configuration (provider, model, and sampling scalars). */
/** The conversation's call configuration (provider, model, reasoning effort, and sampling scalars). */
config: LlmCallConfig
/** Rendered system prompt text; absent for a system-less request. */
system?: string

View File

@@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest'
import { Session, SessionId, canonicalHeader, foldRequestHeader, headerEquals } from '@deepseek-ai/dsh-session'
import type { EpochHeader, SessionEvent } from '@deepseek-ai/dsh-session'
import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm'
import { ReasoningEffortId } from '@deepseek-ai/dsh-llm'
const CONFIG = { provider: 'mock', model: 'm' }
@@ -29,6 +30,10 @@ describe('headerEquals', () => {
it('compares every canonical field and preserves tool order', () => {
expect(headerEquals(base, structuredClone(base))).toBe(true)
expect(headerEquals(base, { ...base, config: { provider: 'mock', model: 'other' } })).toBe(false)
expect(headerEquals(base, {
...base,
config: { ...base.config, reasoningEffort: ReasoningEffortId('high') },
})).toBe(false)
expect(headerEquals(base, { ...base, system: 'other' })).toBe(false)
expect(headerEquals(base, { ...base, messagePrefix: [msg('other')] })).toBe(false)
expect(headerEquals(base, { ...base, tools: [] })).toBe(false)

View File

@@ -1,6 +1,6 @@
import { describe, expect, expectTypeOf, it, vi } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import { CallId, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import SessionStore, {
displayPromptContent,
findLastMessageTurnEnd,
@@ -118,6 +118,11 @@ describe('Session', () => {
})
it('renders injected-context and steering messages as plain user content', () => {
expect(displayPromptContent({
content: [{ type: 'text', text: 'plain prompt' }],
source: { kind: 'user' },
})).toEqual([{ type: 'text', text: 'plain prompt' }])
const session = new Session(SessionId('s2'))
session.append('user/message', {
content: [{ type: 'text', text: 'file changed: a.ts' }],
@@ -228,6 +233,35 @@ describe('Session', () => {
.toEqual([unrelatedPrimitiveData])
})
it('round-trips a non-empty reasoning effort and rejects invalid durable values', () => {
const valid = {
type: 'request/header',
seq: 0,
time: 1,
data: {
header: {
config: {
provider: 'mock',
model: 'model',
reasoningEffort: ReasoningEffortId('adapter-owned'),
},
},
reason: 'initial',
},
} as const
expect(new Session(SessionId('reasoning-effort'), [valid]).events[0])
.toEqual(valid)
for (const reasoningEffort of ['', 1]) {
const invalid = structuredClone(valid) as unknown as SessionEvent
if (invalid.type !== 'request/header') throw new Error('test fixture must be a request header')
const config = invalid.data.header.config as unknown as Record<string, unknown>
config.reasoningEffort = reasoningEffort
expect(() => new Session(SessionId('invalid-reasoning-effort'), [invalid]))
.toThrow('seed request/header at index 0 has an invalid reasoningEffort')
}
})
it('isolates the log from mutation through a derived message (append-only contract)', () => {
const session = new Session(SessionId('s4'))
session.append('user/message', { content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } }, { surfaceOp: 'append' })

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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
README.md: 43ad70fa8b865b0b80496bbb67013f24e9e3a33f
README.zh.md: cc95a7512fb872add816bf0456a93dfcf7b84c10
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
README.md: adcffaf553bb62f77e38cf8f19f5b48e8d7881e1
README.zh.md: 957b7967c95131f3da6cd25ce395fba8db57b020

View File

@@ -12,7 +12,9 @@ The layering/protocol decisions are recorded in the [GUI layering and RPC protoc
The mux stream projects the latest log-backed title as a validated `session/title` control frame after each attached-session subscription baseline and immediately after the corresponding live raw title event. This projection does not add titles to `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs.
Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed` plus `host/session-added` carry committed increments in either arrival order. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`.
Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`.
`session.history` pages on message boundaries, and its tail page (no `beforeSeq`) carries two session-level extras the page window cannot supply: the in-flight partial's chunk events, and `todos` — the latest `todo/write` whole-list projection over the full log. Older pages omit `todos` because the projection is session-level, not per-page; a tail response that omits it means the whole log holds no `todo/write`, so clients read the absent field as the empty plan rather than as unchanged state.
The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `command.execute` runs a slash-command line host-side and returns a detached result; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing.

View File

@@ -12,7 +12,9 @@
mux 流会在每个已附加会话的订阅基线之后,以及对应的实时原始标题事件之后,立即把基于日志的最新标题投影为经过校验的 `session/title` 控制帧。该投影不会把标题加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`session.create` 接受可选的预分配 Session id`host/workspace-changed``host/session-added` 则以任意到达顺序携带已提交的增量。`SessionSummary.blank``host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank并以 `session.list` 作为重连权威;冷会话摘要永远不是空白——惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id`host/workspace-changed``host/workspace-removed``host/session-added` 则以任意到达顺序携带已提交的增量。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank``host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank并以 `session.list` 作为重连权威;冷会话摘要永远不是空白惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。
`session.history` 按消息边界分页,其尾页(不带 `beforeSeq`)额外携带两项页窗口本身无法提供的会话级数据:进行中局部消息的 chunk 事件,以及 `todos`——整份日志上最后一次 `todo/write` 的整表投影。较早的页面不带 `todos`,因为该投影是会话级而非分页级的;尾页响应缺少该字段意味着整份日志中没有任何 `todo/write`,因此客户端要把缺失字段读作空计划,而不是读作「状态未变」。
`command.*``skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent被服务的会话必有 Agent`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`command.execute` 在宿主侧运行一条斜杠命令行并返回脱耦结果;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。

View File

@@ -9,7 +9,7 @@ import { join } from 'node:path'
import type { Context } from 'cordis'
import type { Agent, AgentMessage, AgentMessageId, AgentStatus } from '@deepseek-ai/dsh-agent'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import type { JsonValue, Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type { JsonValue, Session, SessionEvent, SessionHeader, SessionId, TodoItem } from '@deepseek-ai/dsh-session'
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
@@ -283,6 +283,15 @@ function backscanArgs(events: readonly SessionEvent[], callId: string): { name:
return undefined
}
/** Current todo projection: the latest `todo/write` over the full log (whole-list replace ⇒ last write wins); undefined when none. */
function backscanTodos(events: readonly SessionEvent[]): TodoItem[] | undefined {
for (let i = events.length - 1; i >= 0; i--) {
const event = events[i]
if (event !== undefined && event.type === 'todo/write') return event.data.todos
}
return undefined
}
/**
* Thrown by the cold-resume path when the id names no servable session
* (absent from the store, or a pre-project legacy log without a cwd).
@@ -642,7 +651,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId))
return { event, ...view === undefined ? {} : { view } }
})
return ok(request, { events: entries, hasMore: page.hasMore })
// Tail page carries the session-level todo projection over the FULL
// log (the page window may not contain the last todo/write; a paged
// client cannot reconstruct session-level state from it).
const todos = beforeSeq === undefined ? backscanTodos(found.agent.session.events) : undefined
return ok(request, { events: entries, hasMore: page.hasMore, ...todos === undefined ? {} : { todos } })
},
async prompt(request) {
@@ -764,6 +777,15 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
return ok(request, { workspace: workspaceView(workspace) })
},
async delete(request) {
const { workspaceId } = request.payload
const operation = workspaceCreationChain.then(() =>
ctx.workspace.delete(brandWorkspaceId(workspaceId)))
workspaceCreationChain = operation.then(() => undefined, () => undefined)
if (!await operation) return workspaceNotFound(request, workspaceId)
return ok(request, { deleted: true as const })
},
async insertSessionBefore(request) {
const { payload } = request
const workspace = ctx.workspace.get(brandWorkspaceId(payload.workspaceId))
@@ -977,8 +999,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
queue.push(frame({ type: 'host/agent-error', sessionId: agent.id, message: String(error) }))
}),
ctx.on('domain/changed', (change) => {
if (change.domain !== 'workspace' || change.operation !== 'put') return
if (change.domain !== 'workspace') return
if (change.table === '') {
if (change.operation !== 'put') return
const state = workspaceDomainState.parse(change.value)
for (const workspaceId of state.workspaceIds) {
if (committedWorkspaceIds.has(workspaceId)) continue
@@ -991,7 +1014,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
}
return
}
if (change.table !== 'workspaces' || !committedWorkspaceIds.has(change.key)) return
if (change.table !== 'workspaces') return
if (change.operation === 'deleted') {
if (!committedWorkspaceIds.delete(change.key)) return
queue.push(frame({
type: 'host/workspace-removed',
workspaceId: change.key as WorkspaceId,
}))
return
}
if (!committedWorkspaceIds.has(change.key)) return
// Existing-entity table writes are complete attach/touch commits.
// A new entity's first put waits for the global registry write above.
queue.push(frame({

Some files were not shown because too many files have changed in this diff Show More